buildwithnexus 0.10.7 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -4
- package/SECURITY.md +42 -30
- package/harness/Cargo.lock +1 -1
- package/harness/Cargo.toml +1 -1
- package/harness/src/agent.rs +1052 -385
- package/harness/src/checkpoint.rs +98 -4
- package/harness/src/config.rs +47 -7
- package/harness/src/hooks.rs +294 -40
- package/harness/src/knowledge.rs +116 -3
- package/harness/src/lib.rs +93 -30
- package/harness/src/onboarding.rs +5 -1
- package/harness/src/provider.rs +974 -54
- package/harness/src/report.rs +300 -3
- package/harness/src/session.rs +74 -5
- package/harness/src/tools.rs +1926 -229
- package/harness/src/tui.rs +506 -117
- package/harness/src/verifier.rs +90 -6
- package/package.json +1 -1
package/harness/src/agent.rs
CHANGED
|
@@ -21,9 +21,29 @@ use crate::tui;
|
|
|
21
21
|
const MAX_ITERS: usize = 30;
|
|
22
22
|
const MAX_DEPTH: usize = 3;
|
|
23
23
|
const KEEP_RECENT: usize = 6;
|
|
24
|
-
const MAX_IDENTICAL_TOOL_RESULTS: usize =
|
|
24
|
+
const MAX_IDENTICAL_TOOL_RESULTS: usize = 3;
|
|
25
25
|
const MAX_PLAN_TOOL_ROUNDS: usize = 10;
|
|
26
26
|
const MAX_CHAT_TOOL_ROUNDS: usize = 12;
|
|
27
|
+
// How many times a turn truncated at the output-token limit is asked to continue.
|
|
28
|
+
const MAX_TOKEN_LIMIT_CONTINUATIONS: usize = 2;
|
|
29
|
+
// How many verifier Blocked/Failed verdicts the model is asked to address.
|
|
30
|
+
const MAX_VERIFIER_FIX_ROUNDS: usize = 2;
|
|
31
|
+
// How many act-don't-explain nudges an imperative BUILD task gets when the
|
|
32
|
+
// model answers with instructions instead of doing the work.
|
|
33
|
+
const MAX_ACT_NUDGES: usize = 2;
|
|
34
|
+
|
|
35
|
+
/// Truncate `s` to at most `max` bytes without splitting a UTF-8 character —
|
|
36
|
+
/// byte-offset slicing (`&s[..max]`) panics on emoji/CJK at the boundary.
|
|
37
|
+
fn truncate_at_char_boundary(s: &str, max: usize) -> &str {
|
|
38
|
+
if s.len() <= max {
|
|
39
|
+
return s;
|
|
40
|
+
}
|
|
41
|
+
let mut end = max;
|
|
42
|
+
while end > 0 && !s.is_char_boundary(end) {
|
|
43
|
+
end -= 1;
|
|
44
|
+
}
|
|
45
|
+
&s[..end]
|
|
46
|
+
}
|
|
27
47
|
|
|
28
48
|
// ── context compaction ────────────────────────────────────────────────────────
|
|
29
49
|
fn estimate_tokens(msgs: &[Msg]) -> usize {
|
|
@@ -52,10 +72,38 @@ fn compaction_split(msgs: &[Msg]) -> (usize, usize) {
|
|
|
52
72
|
.iter()
|
|
53
73
|
.take_while(|m| matches!(m, Msg::System(_)))
|
|
54
74
|
.count();
|
|
55
|
-
let tail_start = msgs.len().saturating_sub(KEEP_RECENT).max(sys_end);
|
|
75
|
+
let mut tail_start = msgs.len().saturating_sub(KEEP_RECENT).max(sys_end);
|
|
76
|
+
// Never sever a tool_use/tool_result pair: a tail that starts with
|
|
77
|
+
// Msg::Tool has orphaned results (API 400). Advance until the tail begins
|
|
78
|
+
// at a safe boundary — a user turn or an assistant turn (an assistant turn
|
|
79
|
+
// with calls keeps its Msg::Tool inside the tail).
|
|
80
|
+
while tail_start < msgs.len()
|
|
81
|
+
&& !matches!(
|
|
82
|
+
msgs[tail_start],
|
|
83
|
+
Msg::User(_) | Msg::UserImages { .. } | Msg::Assistant { .. }
|
|
84
|
+
)
|
|
85
|
+
{
|
|
86
|
+
tail_start += 1;
|
|
87
|
+
}
|
|
56
88
|
(sys_end, tail_start)
|
|
57
89
|
}
|
|
58
90
|
|
|
91
|
+
// The first real user message is the task; a prior compaction pins it after an
|
|
92
|
+
// "[Original task]" marker, so re-compactions keep the verbatim text.
|
|
93
|
+
const ORIGINAL_TASK_MARKER: &str = "[Original task]\n";
|
|
94
|
+
|
|
95
|
+
fn original_task_text(msgs: &[Msg]) -> Option<String> {
|
|
96
|
+
for m in msgs {
|
|
97
|
+
if let Msg::User(t) | Msg::UserImages { text: t, .. } = m {
|
|
98
|
+
if let Some(idx) = t.find(ORIGINAL_TASK_MARKER) {
|
|
99
|
+
return Some(t[idx + ORIGINAL_TASK_MARKER.len()..].to_string());
|
|
100
|
+
}
|
|
101
|
+
return Some(t.clone());
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
None
|
|
105
|
+
}
|
|
106
|
+
|
|
59
107
|
fn structural_summary(middle: &[Msg]) -> String {
|
|
60
108
|
let mut actions: Vec<String> = Vec::new();
|
|
61
109
|
for m in middle {
|
|
@@ -121,20 +169,27 @@ fn render_msgs(msgs: &[Msg]) -> String {
|
|
|
121
169
|
|
|
122
170
|
fn compact_with(msgs: Vec<Msg>, summarize: impl FnOnce(&[Msg]) -> String) -> Vec<Msg> {
|
|
123
171
|
let (sys_end, tail_start) = compaction_split(&msgs);
|
|
124
|
-
// Even if there's nothing to summarize, truncate bloated tool results
|
|
125
|
-
let msgs = truncate_tool_results(msgs);
|
|
126
172
|
if tail_start <= sys_end {
|
|
127
173
|
return msgs;
|
|
128
174
|
}
|
|
175
|
+
let task = original_task_text(&msgs);
|
|
129
176
|
let mut it = msgs.into_iter();
|
|
130
177
|
let system: Vec<Msg> = it.by_ref().take(sys_end).collect();
|
|
131
|
-
|
|
178
|
+
// Clip bloated tool results only in the middle being summarized — the
|
|
179
|
+
// recent tail keeps its full content so the model retains recent context.
|
|
180
|
+
let middle: Vec<Msg> = truncate_tool_results(it.by_ref().take(tail_start - sys_end).collect());
|
|
132
181
|
let tail: Vec<Msg> = it.collect();
|
|
133
182
|
let summary = summarize(&middle);
|
|
134
183
|
let mut v = system;
|
|
135
|
-
|
|
136
|
-
"[Summary of earlier conversation, compacted to save context]\n{summary}"
|
|
137
|
-
|
|
184
|
+
let mut body =
|
|
185
|
+
format!("[Summary of earlier conversation, compacted to save context]\n{summary}");
|
|
186
|
+
// Pin the original task verbatim so it survives any number of compactions.
|
|
187
|
+
if let Some(task) = task {
|
|
188
|
+
body.push_str("\n\n");
|
|
189
|
+
body.push_str(ORIGINAL_TASK_MARKER);
|
|
190
|
+
body.push_str(&task);
|
|
191
|
+
}
|
|
192
|
+
v.push(Msg::User(body));
|
|
138
193
|
v.extend(tail);
|
|
139
194
|
v
|
|
140
195
|
}
|
|
@@ -208,8 +263,16 @@ struct ToolLoopGuard {
|
|
|
208
263
|
|
|
209
264
|
struct ToolLoopRecord {
|
|
210
265
|
content: String,
|
|
211
|
-
is_error: bool,
|
|
212
266
|
count: usize,
|
|
267
|
+
nudged: bool,
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// What the guard wants the caller to do about a detected loop.
|
|
271
|
+
enum LoopSignal {
|
|
272
|
+
/// First trigger: steer the model with a corrective user turn and continue.
|
|
273
|
+
Nudge(String),
|
|
274
|
+
/// The loop repeated even after a nudge: stop the run and say so honestly.
|
|
275
|
+
Stop(String),
|
|
213
276
|
}
|
|
214
277
|
|
|
215
278
|
impl ToolLoopGuard {
|
|
@@ -219,28 +282,45 @@ impl ToolLoopGuard {
|
|
|
219
282
|
input: &serde_json::Value,
|
|
220
283
|
content: &str,
|
|
221
284
|
is_error: bool,
|
|
222
|
-
) -> Option<
|
|
285
|
+
) -> Option<LoopSignal> {
|
|
223
286
|
let key = format!(
|
|
224
287
|
"{name}:{}",
|
|
225
288
|
serde_json::to_string(input).unwrap_or_default()
|
|
226
289
|
);
|
|
290
|
+
// Identical successful, non-empty results are legitimate (e.g. re-reading
|
|
291
|
+
// a file after edits elsewhere) — only repeated errors and repeated
|
|
292
|
+
// no-op/empty results count as loop evidence.
|
|
293
|
+
if !is_error && !content.trim().is_empty() {
|
|
294
|
+
self.seen.remove(&key);
|
|
295
|
+
return None;
|
|
296
|
+
}
|
|
227
297
|
let rec = self.seen.entry(key).or_insert_with(|| ToolLoopRecord {
|
|
228
298
|
content: String::new(),
|
|
229
|
-
is_error,
|
|
230
299
|
count: 0,
|
|
300
|
+
nudged: false,
|
|
231
301
|
});
|
|
232
|
-
if rec.content == content
|
|
302
|
+
if rec.content == content {
|
|
233
303
|
rec.count += 1;
|
|
234
304
|
} else {
|
|
235
305
|
rec.content = content.to_string();
|
|
236
|
-
rec.is_error = is_error;
|
|
237
306
|
rec.count = 1;
|
|
307
|
+
rec.nudged = false;
|
|
238
308
|
}
|
|
239
|
-
if rec.count
|
|
240
|
-
|
|
241
|
-
} else {
|
|
242
|
-
None
|
|
309
|
+
if rec.count < MAX_IDENTICAL_TOOL_RESULTS {
|
|
310
|
+
return None;
|
|
243
311
|
}
|
|
312
|
+
if !rec.nudged {
|
|
313
|
+
rec.nudged = true;
|
|
314
|
+
let preview = tools::preview(name, input);
|
|
315
|
+
return Some(LoopSignal::Nudge(format!(
|
|
316
|
+
"You already ran `{preview}` {} times and got the same result — \
|
|
317
|
+
take a different action instead of repeating the same call.",
|
|
318
|
+
rec.count
|
|
319
|
+
)));
|
|
320
|
+
}
|
|
321
|
+
Some(LoopSignal::Stop(repeated_tool_summary(
|
|
322
|
+
name, input, content, is_error,
|
|
323
|
+
)))
|
|
244
324
|
}
|
|
245
325
|
}
|
|
246
326
|
|
|
@@ -276,14 +356,22 @@ fn note_loop_result(
|
|
|
276
356
|
input: &serde_json::Value,
|
|
277
357
|
content: &str,
|
|
278
358
|
is_error: bool,
|
|
279
|
-
|
|
359
|
+
nudge: &mut Option<String>,
|
|
360
|
+
stop: &mut Option<String>,
|
|
280
361
|
) {
|
|
281
|
-
if
|
|
362
|
+
if stop.is_some() {
|
|
282
363
|
return;
|
|
283
364
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
365
|
+
match guard.note(name, input, content, is_error) {
|
|
366
|
+
Some(LoopSignal::Nudge(msg)) => {
|
|
367
|
+
report::notice(" ⟳ repeated tool result — nudging the model to change course");
|
|
368
|
+
*nudge = Some(msg);
|
|
369
|
+
}
|
|
370
|
+
Some(LoopSignal::Stop(msg)) => {
|
|
371
|
+
report::assistant(&msg);
|
|
372
|
+
*stop = Some(msg);
|
|
373
|
+
}
|
|
374
|
+
None => {}
|
|
287
375
|
}
|
|
288
376
|
}
|
|
289
377
|
|
|
@@ -355,7 +443,6 @@ fn repair_placeholder_tool_input(
|
|
|
355
443
|
fn repair_write_file_input(input: &serde_json::Value, task: &str) -> Option<serde_json::Value> {
|
|
356
444
|
let requested = auto_create_file_input(task)?;
|
|
357
445
|
let requested_path = requested["path"].as_str().unwrap_or("");
|
|
358
|
-
let requested_content = requested["content"].as_str().unwrap_or("");
|
|
359
446
|
let path = input
|
|
360
447
|
.get("path")
|
|
361
448
|
.or_else(|| input.get("filePath"))
|
|
@@ -366,11 +453,41 @@ fn repair_write_file_input(input: &serde_json::Value, task: &str) -> Option<serd
|
|
|
366
453
|
.or_else(|| input.get("contents"))
|
|
367
454
|
.and_then(|v| v.as_str())
|
|
368
455
|
.unwrap_or("");
|
|
369
|
-
let
|
|
456
|
+
let path_broken = path.trim().is_empty()
|
|
370
457
|
|| matches!(path.trim(), "~" | "." | "/" | "./")
|
|
371
|
-
|| is_placeholder_path(path)
|
|
372
|
-
|
|
373
|
-
|
|
458
|
+
|| is_placeholder_path(path);
|
|
459
|
+
// NEVER override non-empty plausible model content — only repair when the
|
|
460
|
+
// model wrote nothing usable (empty or an obvious placeholder).
|
|
461
|
+
if is_placeholder_content(content) {
|
|
462
|
+
return Some(requested);
|
|
463
|
+
}
|
|
464
|
+
if path_broken {
|
|
465
|
+
// The model's content is fine; only the destination path is broken.
|
|
466
|
+
let mut repaired = input.clone();
|
|
467
|
+
let key = if input.get("path").is_none() && input.get("filePath").is_some() {
|
|
468
|
+
"filePath"
|
|
469
|
+
} else {
|
|
470
|
+
"path"
|
|
471
|
+
};
|
|
472
|
+
repaired[key] = serde_json::Value::String(requested_path.to_string());
|
|
473
|
+
return Some(repaired);
|
|
474
|
+
}
|
|
475
|
+
None
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/// True when write_file content is empty or an obvious stand-in the model left
|
|
479
|
+
/// for "fill this in later" — real content, however different from the task
|
|
480
|
+
/// text, must never be overwritten.
|
|
481
|
+
fn is_placeholder_content(content: &str) -> bool {
|
|
482
|
+
let t = content.trim();
|
|
483
|
+
if t.is_empty() {
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
486
|
+
matches!(t, "..." | "…" | "TODO" | "todo" | "TBD" | "tbd")
|
|
487
|
+
|| matches!(t, "<content>" | "{content}" | "(content)" | "<contents>")
|
|
488
|
+
|| t.eq_ignore_ascii_case("your content here")
|
|
489
|
+
|| t.eq_ignore_ascii_case("placeholder")
|
|
490
|
+
|| t.eq_ignore_ascii_case("file content goes here")
|
|
374
491
|
}
|
|
375
492
|
|
|
376
493
|
fn should_repair_filesystem_root(path: &str, task: &str) -> bool {
|
|
@@ -530,6 +647,12 @@ fn request_reply(
|
|
|
530
647
|
Ok(r)
|
|
531
648
|
}
|
|
532
649
|
|
|
650
|
+
// Normalized stop reason from the provider: "max_tokens" means the output was
|
|
651
|
+
// cut off mid-generation, so both the text and any tool call may be incomplete.
|
|
652
|
+
fn reply_truncated_at_token_limit(reply: &Reply) -> bool {
|
|
653
|
+
reply.stop_reason.as_deref() == Some("max_tokens")
|
|
654
|
+
}
|
|
655
|
+
|
|
533
656
|
static TEXT_TOOL_SEQ: AtomicUsize = AtomicUsize::new(0);
|
|
534
657
|
|
|
535
658
|
fn normalize_text_tool_calls(mut reply: Reply, defs: &[tools::ToolDef], user_text: &str) -> Reply {
|
|
@@ -582,19 +705,9 @@ fn is_casual_turn(text: &str) -> bool {
|
|
|
582
705
|
.trim()
|
|
583
706
|
.trim_matches(|c: char| c.is_ascii_punctuation() || c.is_whitespace())
|
|
584
707
|
.to_ascii_lowercase();
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
| "hey"
|
|
589
|
-
| "yo"
|
|
590
|
-
| "sup"
|
|
591
|
-
| "thanks"
|
|
592
|
-
| "thank you"
|
|
593
|
-
| "ok"
|
|
594
|
-
| "okay"
|
|
595
|
-
| "cool"
|
|
596
|
-
| "nice"
|
|
597
|
-
)
|
|
708
|
+
// Only pure greetings: acknowledgements like "ok"/"thanks" are often valid
|
|
709
|
+
// "yes, proceed" turns and must not have their tool calls discarded.
|
|
710
|
+
matches!(normalized.as_str(), "hi" | "hello" | "hey")
|
|
598
711
|
}
|
|
599
712
|
|
|
600
713
|
fn parse_text_tool_calls(text: &str, defs: &[tools::ToolDef]) -> Option<Vec<provider::ToolCall>> {
|
|
@@ -608,22 +721,73 @@ fn parse_text_tool_calls(text: &str, defs: &[tools::ToolDef]) -> Option<Vec<prov
|
|
|
608
721
|
|
|
609
722
|
fn extract_json_tool_candidate(text: &str) -> Option<&str> {
|
|
610
723
|
let trimmed = text.trim();
|
|
724
|
+
// Whole-text JSON (the strict, original case).
|
|
611
725
|
if trimmed.starts_with('{') || trimmed.starts_with('[') {
|
|
612
726
|
return Some(trimmed);
|
|
613
727
|
}
|
|
614
|
-
|
|
615
|
-
let
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
728
|
+
// A fenced ```json block that is the entire message.
|
|
729
|
+
if let Some(rest) = trimmed.strip_prefix("```") {
|
|
730
|
+
if let Some(fence_end) = rest.find('\n') {
|
|
731
|
+
let lang = rest[..fence_end].trim().to_ascii_lowercase();
|
|
732
|
+
if lang.is_empty() || lang == "json" {
|
|
733
|
+
let body = &rest[fence_end + 1..];
|
|
734
|
+
if let Some(close) = body.rfind("```") {
|
|
735
|
+
if body[close + 3..].trim().is_empty() {
|
|
736
|
+
return Some(body[..close].trim());
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
619
741
|
}
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
742
|
+
// XML-tagged tool calls. Small local coders (notably Qwen2.5-Coder on
|
|
743
|
+
// llama.cpp/Ollama) emit calls as `<tools>{…}</tools>` or
|
|
744
|
+
// `<tool_call>{…}</tool_call>` text in `content` instead of native
|
|
745
|
+
// tool_calls. Pull the first balanced JSON object out of the tagged region.
|
|
746
|
+
for tag in ["<tools>", "<tool_call>", "<function_call>", "<function>"] {
|
|
747
|
+
if let Some(pos) = text.find(tag) {
|
|
748
|
+
if let Some(json) = balanced_json_object(&text[pos + tag.len()..]) {
|
|
749
|
+
return Some(json);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
625
752
|
}
|
|
626
|
-
|
|
753
|
+
// A bare object embedded in prose (a leading sentence, then the JSON).
|
|
754
|
+
balanced_json_object(text)
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Return the first balanced `{…}` slice, tracking string state so braces inside
|
|
758
|
+
// JSON string values (e.g. CSS `{ }` inside an HTML `content` field) don't end
|
|
759
|
+
// the object early. None if there's no `{` or it never closes (truncated output).
|
|
760
|
+
fn balanced_json_object(text: &str) -> Option<&str> {
|
|
761
|
+
let bytes = text.as_bytes();
|
|
762
|
+
let start = text.find('{')?;
|
|
763
|
+
let mut depth = 0usize;
|
|
764
|
+
let mut in_string = false;
|
|
765
|
+
let mut escaped = false;
|
|
766
|
+
for i in start..bytes.len() {
|
|
767
|
+
let c = bytes[i];
|
|
768
|
+
if in_string {
|
|
769
|
+
if escaped {
|
|
770
|
+
escaped = false;
|
|
771
|
+
} else if c == b'\\' {
|
|
772
|
+
escaped = true;
|
|
773
|
+
} else if c == b'"' {
|
|
774
|
+
in_string = false;
|
|
775
|
+
}
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
match c {
|
|
779
|
+
b'"' => in_string = true,
|
|
780
|
+
b'{' => depth += 1,
|
|
781
|
+
b'}' => {
|
|
782
|
+
depth -= 1;
|
|
783
|
+
if depth == 0 {
|
|
784
|
+
return Some(&text[start..=i]);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
_ => {}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
None
|
|
627
791
|
}
|
|
628
792
|
|
|
629
793
|
fn collect_text_tool_calls(
|
|
@@ -804,7 +968,7 @@ Use the tools to inspect and modify the project directly. \
|
|
|
804
968
|
Prefer small, verifiable edits. Read before you write. \
|
|
805
969
|
When writing or editing files, provide the complete, fully working code. NEVER use placeholders (e.g. `// ... rest of code`). \
|
|
806
970
|
BUILD mode means execute: for any concrete build/fix/create/change request, inspect the project and make the needed edits instead of replying with a capability statement. \
|
|
807
|
-
|
|
971
|
+
When asked to build or create something, call tools to create the files on disk (write_file, edit_file, run_command) instead of pasting instructions or code blocks in chat — you are the builder. \
|
|
808
972
|
For local web apps that require a dev server, use start_server, wait_for_url, inspect read_server_log if readiness fails, then open_browser when useful. \
|
|
809
973
|
If a path or file is missing, use discovery tools before asking the user. \
|
|
810
974
|
DO NOT ask the user for permission, themes, or choices unless absolutely necessary. If the user leaves something open-ended (e.g. 'pick a theme' or 'make it cool'), MAKE A REASONABLE DECISION and proceed immediately. \
|
|
@@ -822,6 +986,21 @@ over installing an alternative.",
|
|
|
822
986
|
Role { system }
|
|
823
987
|
}
|
|
824
988
|
|
|
989
|
+
// Web-artifact guidance is only relevant when the task actually asks to build
|
|
990
|
+
// one — injecting it into every prompt biased the model toward canvas games.
|
|
991
|
+
fn artifact_guidance(task: &str) -> Option<String> {
|
|
992
|
+
(static_artifact_requested(task) || canvas_game_requested(task)).then(|| {
|
|
993
|
+
"[Web artifact task]\n\
|
|
994
|
+
Build an actual runnable artifact: one complete self-contained HTML file via \
|
|
995
|
+
Artifact/publish_artifact with all CSS and JavaScript inline, unless the user \
|
|
996
|
+
explicitly asks for a framework. Include controls, restart/error states, responsive \
|
|
997
|
+
sizing, and touch/mobile support when useful; then open_browser if possible. \
|
|
998
|
+
Write the code yourself from scratch — never search GitHub or clone external \
|
|
999
|
+
repositories for the game/app. Never paste the HTML as plain markdown; call the tool."
|
|
1000
|
+
.to_string()
|
|
1001
|
+
})
|
|
1002
|
+
}
|
|
1003
|
+
|
|
825
1004
|
// Build the system prompt prefix from memory and skills/agents files.
|
|
826
1005
|
// When context_tokens is small (≤16K), skip expensive sections to leave
|
|
827
1006
|
// room for tool definitions + actual conversation.
|
|
@@ -869,7 +1048,7 @@ fn context_prefix(cwd: &Path, context_tokens: usize) -> String {
|
|
|
869
1048
|
if let Some(mem) = config::load_memory() {
|
|
870
1049
|
// Memory is user-important; always include but truncate for small ctx
|
|
871
1050
|
let mem_text = if compact && mem.len() > 300 {
|
|
872
|
-
format!("{}…", &mem
|
|
1051
|
+
format!("{}…", truncate_at_char_boundary(&mem, 300))
|
|
873
1052
|
} else {
|
|
874
1053
|
mem
|
|
875
1054
|
};
|
|
@@ -883,7 +1062,7 @@ fn context_prefix(cwd: &Path, context_tokens: usize) -> String {
|
|
|
883
1062
|
);
|
|
884
1063
|
// For small contexts, truncate Agents.md to avoid blowing the budget
|
|
885
1064
|
let agents_text = if compact && agents.len() > 500 {
|
|
886
|
-
format!("{}…", &agents
|
|
1065
|
+
format!("{}…", truncate_at_char_boundary(&agents, 500))
|
|
887
1066
|
} else {
|
|
888
1067
|
agents
|
|
889
1068
|
};
|
|
@@ -892,33 +1071,13 @@ fn context_prefix(cwd: &Path, context_tokens: usize) -> String {
|
|
|
892
1071
|
|
|
893
1072
|
// Skip rules, knowledge, hooks, and skill descriptions for small contexts
|
|
894
1073
|
if !compact {
|
|
895
|
-
//
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
{
|
|
903
|
-
for r in loaded.rules {
|
|
904
|
-
engine.add_rule(r);
|
|
905
|
-
}
|
|
906
|
-
}
|
|
907
|
-
}
|
|
908
|
-
}
|
|
909
|
-
let mut rules_summary = String::from("The following engineering constraints and business logic rules MUST be strictly enforced for operational judgment:\n");
|
|
910
|
-
for r in &engine.rules {
|
|
911
|
-
if r.enabled {
|
|
912
|
-
rules_summary.push_str(&format!(
|
|
913
|
-
"• [{}] {} — {}\n",
|
|
914
|
-
r.severity, r.id, r.description
|
|
915
|
-
));
|
|
916
|
-
}
|
|
917
|
-
}
|
|
918
|
-
parts.push(format!(
|
|
919
|
-
"[Operational Judgment — Engineering Constraints & Rules]\n{}",
|
|
920
|
-
rules_summary.trim()
|
|
921
|
-
));
|
|
1074
|
+
// A separate verifier pass enforces the engineering rules — a 2-line
|
|
1075
|
+
// pointer is enough; injecting the full rule list bloated every prompt.
|
|
1076
|
+
parts.push(
|
|
1077
|
+
"[Operational rules]\nProject engineering rules are enforced by a separate \
|
|
1078
|
+
verifier pass after you finish.\nViolations are reported back to you to address."
|
|
1079
|
+
.to_string(),
|
|
1080
|
+
);
|
|
922
1081
|
|
|
923
1082
|
// Inject structured knowledge base if present
|
|
924
1083
|
let kb = crate::knowledge::KnowledgeBase::new(".");
|
|
@@ -953,15 +1112,13 @@ fn context_prefix(cwd: &Path, context_tokens: usize) -> String {
|
|
|
953
1112
|
}
|
|
954
1113
|
|
|
955
1114
|
fn tool_manifest() -> String {
|
|
1115
|
+
// Role guidance (no-placeholders, build-immediately, artifact rules) lives
|
|
1116
|
+
// in role()/artifact_guidance() — this section only lists what's built in.
|
|
956
1117
|
"[Built-in tools — always available, no install needed]\n\
|
|
957
1118
|
Aliases are supported: bash/read/write/edit/patch/glob/grep/list/task/todowrite/todoread/webfetch/websearch/skill/question. \
|
|
958
1119
|
Use built-ins before installing anything. Use list_tree/find_paths/grep_files before guessing paths; use find_paths kind=`dir` for folders. \
|
|
959
|
-
Use start_server/list_servers/wait_for_url/read_server_log/stop_server for long-running local dev servers, and open_browser for local URLs or generated HTML
|
|
960
|
-
|
|
961
|
-
• For generated/edited code, HTML, or file contents, call write_file/edit_file/Artifact; never paste code as plain markdown.\n\
|
|
962
|
-
• For canvas games, browser games, standalone demos, landing pages, and small web apps: publish a complete runnable static HTML artifact with embedded CSS/JS unless a framework is explicitly requested. Include controls, restart/error states, responsive sizing, and touch/mobile support when useful; then open_browser if possible.\n\
|
|
963
|
-
• When tasked to build, create, or write code/applications/games, build them locally from scratch. NEVER search the web or attempt to fetch non-existent repositories or URLs from GitHub or the internet unless the user explicitly provides a URL or asks to download from external sources.\n\
|
|
964
|
-
• No placeholders. No asking for theme/layout/permission when a reasonable default works. Build immediately."
|
|
1120
|
+
Use start_server/list_servers/wait_for_url/read_server_log/stop_server for long-running local dev servers, and open_browser for local URLs or generated HTML. \
|
|
1121
|
+
For generated/edited code, HTML, or file contents, call write_file/edit_file/Artifact; never paste code as plain markdown."
|
|
965
1122
|
.to_string()
|
|
966
1123
|
}
|
|
967
1124
|
|
|
@@ -1258,13 +1415,16 @@ pub fn run_build_session(
|
|
|
1258
1415
|
sid: &str,
|
|
1259
1416
|
) -> Result<(), String> {
|
|
1260
1417
|
hooks::notify("SessionStart", cwd);
|
|
1261
|
-
let r = build_inner(p, perm, role_id, task, cwd, 0, transcript).map(|_| ());
|
|
1418
|
+
let r = build_inner(p, perm, role_id, task, cwd, 0, transcript, Some(sid)).map(|_| ());
|
|
1262
1419
|
hooks::notify("SessionEnd", cwd);
|
|
1263
1420
|
hooks::notify("Stop", cwd);
|
|
1264
1421
|
crate::session::save(sid, cwd, &p.model, transcript);
|
|
1265
1422
|
r
|
|
1266
1423
|
}
|
|
1267
1424
|
|
|
1425
|
+
// `sid` is Some for the top-level session (per-round transcript saves) and
|
|
1426
|
+
// None for subagents, whose transcripts live inside the parent's results.
|
|
1427
|
+
#[allow(clippy::too_many_arguments)]
|
|
1268
1428
|
fn build_inner(
|
|
1269
1429
|
p: &Provider,
|
|
1270
1430
|
perm: Permission,
|
|
@@ -1273,6 +1433,7 @@ fn build_inner(
|
|
|
1273
1433
|
cwd: &Path,
|
|
1274
1434
|
depth: usize,
|
|
1275
1435
|
msgs: &mut Vec<Msg>,
|
|
1436
|
+
sid: Option<&str>,
|
|
1276
1437
|
) -> Result<String, String> {
|
|
1277
1438
|
let task = match hooks::user_prompt_submit(task, cwd) {
|
|
1278
1439
|
Err(reason) => {
|
|
@@ -1289,8 +1450,15 @@ fn build_inner(
|
|
|
1289
1450
|
tools::defs_for_context(depth < MAX_DEPTH, p.context_tokens)
|
|
1290
1451
|
};
|
|
1291
1452
|
if msgs.is_empty() {
|
|
1292
|
-
|
|
1293
|
-
|
|
1453
|
+
// Role identity and the current-mode contract come FIRST; the
|
|
1454
|
+
// environment/tool-manifest/skills/memory sections follow.
|
|
1455
|
+
let mut sys = String::from(role(role_id).system);
|
|
1456
|
+
if let Some(guidance) = artifact_guidance(&task_for_recovery) {
|
|
1457
|
+
sys.push_str("\n\n");
|
|
1458
|
+
sys.push_str(&guidance);
|
|
1459
|
+
}
|
|
1460
|
+
sys.push_str("\n\n");
|
|
1461
|
+
sys.push_str(&context_prefix(cwd, p.context_tokens));
|
|
1294
1462
|
msgs.push(Msg::System(sys));
|
|
1295
1463
|
}
|
|
1296
1464
|
// If the caller already pushed a UserImages message (multimodal input), use its
|
|
@@ -1305,6 +1473,18 @@ fn build_inner(
|
|
|
1305
1473
|
let mut loop_guard = ToolLoopGuard::default();
|
|
1306
1474
|
let mut artifact_error_count = 0usize;
|
|
1307
1475
|
let mut static_artifact_recovery_count = 0usize;
|
|
1476
|
+
let mut empty_reply_retried = false;
|
|
1477
|
+
let mut token_limit_continuations = 0usize;
|
|
1478
|
+
let mut forced_compact_retry = false;
|
|
1479
|
+
let mut verifier_fix_rounds = 0usize;
|
|
1480
|
+
// Act-don't-explain tracking: whether any call reached dispatch, whether a
|
|
1481
|
+
// mutating tool actually executed, and how many nudges were spent.
|
|
1482
|
+
let mut act_nudges = 0usize;
|
|
1483
|
+
let mut any_tool_ran = false;
|
|
1484
|
+
let mut mutating_tool_ran = false;
|
|
1485
|
+
// Executed calls and touched files, collected for the verifier pass.
|
|
1486
|
+
let mut tool_records: Vec<crate::verifier::ToolCallRecord> = Vec::new();
|
|
1487
|
+
let mut changed_files: Vec<String> = Vec::new();
|
|
1308
1488
|
|
|
1309
1489
|
for step in 1..=MAX_ITERS {
|
|
1310
1490
|
if tui::interrupted() {
|
|
@@ -1319,6 +1499,18 @@ fn build_inner(
|
|
|
1319
1499
|
let reply = match request_reply(p, msgs.as_slice(), &defs, "thinking") {
|
|
1320
1500
|
Ok(r) => r,
|
|
1321
1501
|
Err(e) => {
|
|
1502
|
+
// A context-overflow rejection is recoverable: force-compact the
|
|
1503
|
+
// transcript and retry once instead of dying.
|
|
1504
|
+
let lower = e.to_ascii_lowercase();
|
|
1505
|
+
if !forced_compact_retry
|
|
1506
|
+
&& (lower.contains("prompt is too long") || lower.contains("context length"))
|
|
1507
|
+
{
|
|
1508
|
+
forced_compact_retry = true;
|
|
1509
|
+
report::notice(" ⟳ context overflow — force-compacting and retrying…");
|
|
1510
|
+
let taken = std::mem::take(msgs);
|
|
1511
|
+
*msgs = compact_with(taken, |middle| model_summary(p, middle));
|
|
1512
|
+
continue;
|
|
1513
|
+
}
|
|
1322
1514
|
hooks::notify("OnError", cwd);
|
|
1323
1515
|
return Err(e);
|
|
1324
1516
|
}
|
|
@@ -1330,10 +1522,89 @@ fn build_inner(
|
|
|
1330
1522
|
tui::inference_telemetry(gen_toks.max(10), elapsed);
|
|
1331
1523
|
}
|
|
1332
1524
|
hooks::notify("PostResponse", cwd);
|
|
1525
|
+
// Output truncated at the token limit: the reply (and any tool call in
|
|
1526
|
+
// it) may be incomplete — ask the model to continue instead of acting
|
|
1527
|
+
// on or returning a cut-off result. Bounded to avoid loops.
|
|
1528
|
+
if reply_truncated_at_token_limit(&reply)
|
|
1529
|
+
&& token_limit_continuations < MAX_TOKEN_LIMIT_CONTINUATIONS
|
|
1530
|
+
{
|
|
1531
|
+
token_limit_continuations += 1;
|
|
1532
|
+
report::notice(
|
|
1533
|
+
" ⚠ output truncated at the token limit — asking the model to continue",
|
|
1534
|
+
);
|
|
1535
|
+
let follow_up = if reply.calls.is_empty() {
|
|
1536
|
+
"Your answer was cut off at the token limit — continue from where you stopped."
|
|
1537
|
+
} else {
|
|
1538
|
+
"Your response hit the output token limit mid tool call, so the call was \
|
|
1539
|
+
discarded. Re-issue the complete tool call with smaller content — e.g. split \
|
|
1540
|
+
large writes into multiple write_file/edit_file calls — and continue."
|
|
1541
|
+
};
|
|
1542
|
+
// Drop the possibly-truncated calls so no dangling tool_use block
|
|
1543
|
+
// reaches the API; keep the text (when present) for continuity —
|
|
1544
|
+
// an empty assistant turn would serialize to an empty content array.
|
|
1545
|
+
if !reply.text.trim().is_empty() {
|
|
1546
|
+
msgs.push(Msg::Assistant {
|
|
1547
|
+
text: reply.text.clone(),
|
|
1548
|
+
calls: vec![],
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
msgs.push(Msg::User(follow_up.to_string()));
|
|
1552
|
+
continue;
|
|
1553
|
+
}
|
|
1333
1554
|
if reply.calls.is_empty() {
|
|
1555
|
+
// One empty response is retried before ending the run; a second
|
|
1556
|
+
// empty response ends it.
|
|
1557
|
+
if reply.text.trim().is_empty() {
|
|
1558
|
+
// No assistant turn is recorded here: an empty assistant
|
|
1559
|
+
// message would serialize to an empty content array (API 400).
|
|
1560
|
+
if !empty_reply_retried {
|
|
1561
|
+
empty_reply_retried = true;
|
|
1562
|
+
report::notice("model returned no output — asking it to continue");
|
|
1563
|
+
msgs.push(Msg::User(
|
|
1564
|
+
"You returned no output. Continue the task: call a tool or state your final answer."
|
|
1565
|
+
.to_string(),
|
|
1566
|
+
));
|
|
1567
|
+
continue;
|
|
1568
|
+
}
|
|
1569
|
+
report::notice("model returned no output");
|
|
1570
|
+
return Ok(reply.text);
|
|
1571
|
+
}
|
|
1572
|
+
// Imperative task answered with how-to prose instead of tool
|
|
1573
|
+
// calls: tell the model to act, bounded per task. Never fires
|
|
1574
|
+
// once a mutating tool has run (that prose is a real summary).
|
|
1575
|
+
if should_nudge_to_act(
|
|
1576
|
+
&task_for_recovery,
|
|
1577
|
+
&reply.text,
|
|
1578
|
+
any_tool_ran,
|
|
1579
|
+
mutating_tool_ran,
|
|
1580
|
+
act_nudges,
|
|
1581
|
+
) {
|
|
1582
|
+
act_nudges += 1;
|
|
1583
|
+
report::notice(" ⟳ model explained instead of acting — nudging it to use tools");
|
|
1584
|
+
msgs.push(Msg::Assistant {
|
|
1585
|
+
text: reply.text.clone(),
|
|
1586
|
+
calls: vec![],
|
|
1587
|
+
});
|
|
1588
|
+
msgs.push(Msg::User(
|
|
1589
|
+
"Do not explain how to do it — actually do it now. Use your tools \
|
|
1590
|
+
(write_file/edit_file/run_command) to make the changes. Begin immediately \
|
|
1591
|
+
with your first tool call."
|
|
1592
|
+
.to_string(),
|
|
1593
|
+
));
|
|
1594
|
+
continue;
|
|
1595
|
+
}
|
|
1334
1596
|
if let Some(input) = auto_create_file_input(&task_for_recovery) {
|
|
1597
|
+
// Record this harness recovery in the transcript so saved and
|
|
1598
|
+
// resumed sessions aren't missing turns.
|
|
1599
|
+
msgs.push(Msg::Assistant {
|
|
1600
|
+
text: reply.text.clone(),
|
|
1601
|
+
calls: vec![],
|
|
1602
|
+
});
|
|
1335
1603
|
if let Some(reason) = gate(perm, "write_file", &input, cwd) {
|
|
1336
1604
|
report::tool_denied(&reason);
|
|
1605
|
+
msgs.push(Msg::User(format!(
|
|
1606
|
+
"[harness] write_file recovery blocked: {reason}"
|
|
1607
|
+
)));
|
|
1337
1608
|
return Err(reason);
|
|
1338
1609
|
}
|
|
1339
1610
|
report::tool_call("write_file", &tools::preview("write_file", &input), &input);
|
|
@@ -1343,65 +1614,50 @@ fn build_inner(
|
|
|
1343
1614
|
report::tool_result("write_file", &out.content, out.is_error);
|
|
1344
1615
|
trace_tool_result("write_file", &out.content, out.is_error, "build", depth);
|
|
1345
1616
|
if out.is_error {
|
|
1617
|
+
msgs.push(Msg::User(format!(
|
|
1618
|
+
"[harness] write_file recovery failed: {}",
|
|
1619
|
+
out.content
|
|
1620
|
+
)));
|
|
1346
1621
|
return Err(out.content);
|
|
1347
1622
|
}
|
|
1623
|
+
msgs.push(Msg::User(format!(
|
|
1624
|
+
"[harness] completed the explicit file creation with write_file: {}",
|
|
1625
|
+
out.content
|
|
1626
|
+
)));
|
|
1348
1627
|
return Ok(format!(
|
|
1349
1628
|
"{}\n\nCompleted the explicit file creation task after the model returned prose without tool calls.",
|
|
1350
1629
|
out.content
|
|
1351
1630
|
));
|
|
1352
1631
|
}
|
|
1353
1632
|
if let Some(input) = auto_static_artifact_input(&task_for_recovery, &reply.text) {
|
|
1633
|
+
msgs.push(Msg::Assistant {
|
|
1634
|
+
text: reply.text.clone(),
|
|
1635
|
+
calls: vec![],
|
|
1636
|
+
});
|
|
1354
1637
|
report::tool_call("Artifact", &tools::preview("Artifact", &input), &input);
|
|
1355
1638
|
trace_tool_call("Artifact", &input, "build", depth);
|
|
1356
1639
|
let out = tools::run("Artifact", &input, cwd);
|
|
1357
1640
|
report::tool_result("Artifact", &out.content, out.is_error);
|
|
1358
1641
|
trace_tool_result("Artifact", &out.content, out.is_error, "build", depth);
|
|
1359
1642
|
if out.is_error {
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
fallback_out.is_error,
|
|
1373
|
-
);
|
|
1374
|
-
trace_tool_result(
|
|
1375
|
-
"Artifact",
|
|
1376
|
-
&fallback_out.content,
|
|
1377
|
-
fallback_out.is_error,
|
|
1378
|
-
"build",
|
|
1379
|
-
depth,
|
|
1380
|
-
);
|
|
1381
|
-
if !fallback_out.is_error {
|
|
1382
|
-
return Ok(format!(
|
|
1383
|
-
"{}\n\nThe model produced incomplete canvas HTML, so buildwithnexus published a complete self-contained fallback canvas game.",
|
|
1384
|
-
fallback_out.content
|
|
1385
|
-
));
|
|
1386
|
-
}
|
|
1387
|
-
return Err(fallback_out.content);
|
|
1643
|
+
// Tell the model exactly why the artifact was rejected and
|
|
1644
|
+
// let it fix and re-publish; fail honestly once bounded
|
|
1645
|
+
// recovery attempts run out.
|
|
1646
|
+
if artifact_error_count < 2 {
|
|
1647
|
+
artifact_error_count += 1;
|
|
1648
|
+
msgs.push(Msg::User(format!(
|
|
1649
|
+
"The HTML artifact you wrote in plain text was rejected: {}. \
|
|
1650
|
+
Fix that problem and re-publish: call Artifact/publish_artifact with one \
|
|
1651
|
+
complete self-contained HTML file. Do not answer with markdown code.",
|
|
1652
|
+
out.content
|
|
1653
|
+
)));
|
|
1654
|
+
continue;
|
|
1388
1655
|
}
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
calls: vec![],
|
|
1392
|
-
});
|
|
1393
|
-
msgs.push(Msg::User(format!(
|
|
1394
|
-
"The HTML artifact you wrote in plain text was rejected: {}. \
|
|
1395
|
-
Rewrite it as one complete self-contained HTML artifact and call Artifact/publish_artifact. \
|
|
1396
|
-
Do not answer with markdown code.",
|
|
1656
|
+
return Err(format!(
|
|
1657
|
+
"artifact publishing failed after {artifact_error_count} recovery attempts; last rejection: {}",
|
|
1397
1658
|
out.content
|
|
1398
|
-
))
|
|
1399
|
-
continue;
|
|
1659
|
+
));
|
|
1400
1660
|
}
|
|
1401
|
-
msgs.push(Msg::Assistant {
|
|
1402
|
-
text: reply.text.clone(),
|
|
1403
|
-
calls: vec![],
|
|
1404
|
-
});
|
|
1405
1661
|
return Ok(out.content);
|
|
1406
1662
|
}
|
|
1407
1663
|
if static_artifact_requested(&task_for_recovery) {
|
|
@@ -1409,42 +1665,19 @@ fn build_inner(
|
|
|
1409
1665
|
text: reply.text.clone(),
|
|
1410
1666
|
calls: vec![],
|
|
1411
1667
|
});
|
|
1412
|
-
if static_artifact_recovery_count
|
|
1668
|
+
if static_artifact_recovery_count < 2 {
|
|
1413
1669
|
static_artifact_recovery_count += 1;
|
|
1414
1670
|
msgs.push(Msg::User(static_artifact_recovery_prompt(
|
|
1415
1671
|
&task_for_recovery,
|
|
1416
1672
|
)));
|
|
1417
1673
|
continue;
|
|
1418
1674
|
}
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
);
|
|
1426
|
-
trace_tool_call("Artifact", &fallback, "build", depth);
|
|
1427
|
-
let fallback_out = tools::run("Artifact", &fallback, cwd);
|
|
1428
|
-
report::tool_result("Artifact", &fallback_out.content, fallback_out.is_error);
|
|
1429
|
-
trace_tool_result(
|
|
1430
|
-
"Artifact",
|
|
1431
|
-
&fallback_out.content,
|
|
1432
|
-
fallback_out.is_error,
|
|
1433
|
-
"build",
|
|
1434
|
-
depth,
|
|
1435
|
-
);
|
|
1436
|
-
if !fallback_out.is_error {
|
|
1437
|
-
return Ok(format!(
|
|
1438
|
-
"{}\n\nThe model did not produce a runnable canvas artifact after being asked to proceed with reasonable defaults, so buildwithnexus published a complete self-contained fallback canvas game.",
|
|
1439
|
-
fallback_out.content
|
|
1440
|
-
));
|
|
1441
|
-
}
|
|
1442
|
-
return Err(fallback_out.content);
|
|
1443
|
-
}
|
|
1444
|
-
return Ok(reply.text);
|
|
1445
|
-
}
|
|
1446
|
-
if reply.text.trim().is_empty() {
|
|
1447
|
-
report::notice("model returned no output");
|
|
1675
|
+
// No fabricated fallback artifact: report the failure honestly.
|
|
1676
|
+
return Err(format!(
|
|
1677
|
+
"the model did not produce a runnable web artifact after \
|
|
1678
|
+
{static_artifact_recovery_count} recovery prompts; its last response was:\n{}",
|
|
1679
|
+
reply.text
|
|
1680
|
+
));
|
|
1448
1681
|
}
|
|
1449
1682
|
msgs.push(Msg::Assistant {
|
|
1450
1683
|
text: reply.text.clone(),
|
|
@@ -1455,12 +1688,12 @@ fn build_inner(
|
|
|
1455
1688
|
|
|
1456
1689
|
let mut results = Vec::new();
|
|
1457
1690
|
let mut summary: Option<String> = None;
|
|
1691
|
+
let mut loop_nudge: Option<String> = None;
|
|
1692
|
+
let mut loop_stop: Option<String> = None;
|
|
1693
|
+
let mut artifact_recovery: Option<String> = None;
|
|
1458
1694
|
for call in &reply.calls {
|
|
1459
1695
|
if let Some(raw) = call.input.get(tools::INVALID_ARGS).and_then(|v| v.as_str()) {
|
|
1460
|
-
let msg =
|
|
1461
|
-
"tool arguments were not valid JSON: {}",
|
|
1462
|
-
raw.chars().take(200).collect::<String>()
|
|
1463
|
-
);
|
|
1696
|
+
let msg = invalid_args_feedback(&call.name, raw, &defs);
|
|
1464
1697
|
report::tool_denied(&msg);
|
|
1465
1698
|
note_loop_result(
|
|
1466
1699
|
&mut loop_guard,
|
|
@@ -1468,7 +1701,8 @@ fn build_inner(
|
|
|
1468
1701
|
&call.input,
|
|
1469
1702
|
&msg,
|
|
1470
1703
|
true,
|
|
1471
|
-
&mut
|
|
1704
|
+
&mut loop_nudge,
|
|
1705
|
+
&mut loop_stop,
|
|
1472
1706
|
);
|
|
1473
1707
|
results.push(ToolResult {
|
|
1474
1708
|
id: call.id.clone(),
|
|
@@ -1485,56 +1719,7 @@ fn build_inner(
|
|
|
1485
1719
|
depth,
|
|
1486
1720
|
&task_for_recovery,
|
|
1487
1721
|
);
|
|
1488
|
-
|
|
1489
|
-
if let Some(fallback) = auto_create_file_input(&task_for_recovery) {
|
|
1490
|
-
report::notice(
|
|
1491
|
-
" recovery: using write_file for explicit file creation instead of shell",
|
|
1492
|
-
);
|
|
1493
|
-
if let Some(reason) = gate(perm, "write_file", &fallback, cwd) {
|
|
1494
|
-
report::tool_denied(&reason);
|
|
1495
|
-
results.push(ToolResult {
|
|
1496
|
-
id: call.id.clone(),
|
|
1497
|
-
content: reason,
|
|
1498
|
-
is_error: true,
|
|
1499
|
-
});
|
|
1500
|
-
continue;
|
|
1501
|
-
}
|
|
1502
|
-
report::tool_call(
|
|
1503
|
-
"write_file",
|
|
1504
|
-
&tools::preview("write_file", &fallback),
|
|
1505
|
-
&fallback,
|
|
1506
|
-
);
|
|
1507
|
-
trace_tool_call("write_file", &fallback, "build", depth);
|
|
1508
|
-
let fallback_out = tools::run("write_file", &fallback, cwd);
|
|
1509
|
-
hooks::post_tool_use(
|
|
1510
|
-
"write_file",
|
|
1511
|
-
&fallback,
|
|
1512
|
-
&fallback_out.content,
|
|
1513
|
-
fallback_out.is_error,
|
|
1514
|
-
cwd,
|
|
1515
|
-
);
|
|
1516
|
-
report::tool_result("write_file", &fallback_out.content, fallback_out.is_error);
|
|
1517
|
-
trace_tool_result(
|
|
1518
|
-
"write_file",
|
|
1519
|
-
&fallback_out.content,
|
|
1520
|
-
fallback_out.is_error,
|
|
1521
|
-
"build",
|
|
1522
|
-
depth,
|
|
1523
|
-
);
|
|
1524
|
-
results.push(ToolResult {
|
|
1525
|
-
id: call.id.clone(),
|
|
1526
|
-
content: fallback_out.content.clone(),
|
|
1527
|
-
is_error: fallback_out.is_error,
|
|
1528
|
-
});
|
|
1529
|
-
if !fallback_out.is_error {
|
|
1530
|
-
summary = Some(format!(
|
|
1531
|
-
"{}\n\nCompleted the explicit file creation task with write_file.",
|
|
1532
|
-
fallback_out.content
|
|
1533
|
-
));
|
|
1534
|
-
}
|
|
1535
|
-
continue;
|
|
1536
|
-
}
|
|
1537
|
-
}
|
|
1722
|
+
any_tool_ran = true;
|
|
1538
1723
|
report::tool_call(
|
|
1539
1724
|
&call.name,
|
|
1540
1725
|
&tools::preview(&call.name, &call_input),
|
|
@@ -1552,7 +1737,8 @@ fn build_inner(
|
|
|
1552
1737
|
&call_input,
|
|
1553
1738
|
&answer,
|
|
1554
1739
|
is_error,
|
|
1555
|
-
&mut
|
|
1740
|
+
&mut loop_nudge,
|
|
1741
|
+
&mut loop_stop,
|
|
1556
1742
|
);
|
|
1557
1743
|
results.push(ToolResult {
|
|
1558
1744
|
id: call.id.clone(),
|
|
@@ -1580,7 +1766,8 @@ fn build_inner(
|
|
|
1580
1766
|
&call_input,
|
|
1581
1767
|
&reason,
|
|
1582
1768
|
true,
|
|
1583
|
-
&mut
|
|
1769
|
+
&mut loop_nudge,
|
|
1770
|
+
&mut loop_stop,
|
|
1584
1771
|
);
|
|
1585
1772
|
results.push(ToolResult {
|
|
1586
1773
|
id: call.id.clone(),
|
|
@@ -1609,7 +1796,8 @@ fn build_inner(
|
|
|
1609
1796
|
&call_input,
|
|
1610
1797
|
&msg,
|
|
1611
1798
|
true,
|
|
1612
|
-
&mut
|
|
1799
|
+
&mut loop_nudge,
|
|
1800
|
+
&mut loop_stop,
|
|
1613
1801
|
);
|
|
1614
1802
|
results.push(ToolResult {
|
|
1615
1803
|
id: call.id.clone(),
|
|
@@ -1621,13 +1809,15 @@ fn build_inner(
|
|
|
1621
1809
|
}
|
|
1622
1810
|
|
|
1623
1811
|
if matches!(call.name.as_str(), "task" | "spawn_subagent") {
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1812
|
+
// A subagent can mutate the workspace — count it as action.
|
|
1813
|
+
mutating_tool_ran = true;
|
|
1814
|
+
let (out, is_error) = spawn_subagent(p, perm, &call_input, cwd, depth);
|
|
1815
|
+
report::tool_result(&call.name, &out, is_error);
|
|
1816
|
+
trace_tool_result(&call.name, &out, is_error, "build", depth);
|
|
1627
1817
|
results.push(ToolResult {
|
|
1628
1818
|
id: call.id.clone(),
|
|
1629
1819
|
content: out,
|
|
1630
|
-
is_error
|
|
1820
|
+
is_error,
|
|
1631
1821
|
});
|
|
1632
1822
|
continue;
|
|
1633
1823
|
}
|
|
@@ -1647,6 +1837,9 @@ fn build_inner(
|
|
|
1647
1837
|
}
|
|
1648
1838
|
}
|
|
1649
1839
|
|
|
1840
|
+
if tools::is_mutating_call(&call.name, &call_input) {
|
|
1841
|
+
mutating_tool_ran = true;
|
|
1842
|
+
}
|
|
1650
1843
|
let out = tools::run(&call.name, &call_input, cwd);
|
|
1651
1844
|
hooks::post_tool_use(&call.name, &call_input, &out.content, out.is_error, cwd);
|
|
1652
1845
|
if out.is_error {
|
|
@@ -1654,62 +1847,44 @@ fn build_inner(
|
|
|
1654
1847
|
}
|
|
1655
1848
|
report::tool_result(&call.name, &out.content, out.is_error);
|
|
1656
1849
|
trace_tool_result(&call.name, &out.content, out.is_error, "build", depth);
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
trace_tool_result(
|
|
1671
|
-
"write_file",
|
|
1672
|
-
&fallback_out.content,
|
|
1673
|
-
fallback_out.is_error,
|
|
1674
|
-
"build",
|
|
1675
|
-
depth,
|
|
1676
|
-
);
|
|
1677
|
-
if !fallback_out.is_error {
|
|
1678
|
-
summary = Some(format!(
|
|
1679
|
-
"{}\n\nRecovered from a failed shell redirect by writing the requested file directly.",
|
|
1680
|
-
fallback_out.content
|
|
1681
|
-
));
|
|
1850
|
+
// Feed the verifier real data: every executed call, and the files
|
|
1851
|
+
// touched by successful write/edit tools.
|
|
1852
|
+
tool_records.push(crate::verifier::ToolCallRecord {
|
|
1853
|
+
tool_name: call.name.clone(),
|
|
1854
|
+
args_summary: tools::preview(&call.name, &call_input),
|
|
1855
|
+
result_preview: out.content.chars().take(200).collect(),
|
|
1856
|
+
timestamp: String::new(),
|
|
1857
|
+
});
|
|
1858
|
+
if !out.is_error {
|
|
1859
|
+
if let Some(pb) = tools::edit_tracking_path(&call.name, &call_input, cwd) {
|
|
1860
|
+
let path = pb.display().to_string();
|
|
1861
|
+
if !changed_files.contains(&path) {
|
|
1862
|
+
changed_files.push(path);
|
|
1682
1863
|
}
|
|
1683
1864
|
}
|
|
1684
1865
|
}
|
|
1685
1866
|
if matches!(call.name.as_str(), "Artifact" | "publish_artifact")
|
|
1686
1867
|
&& out.is_error
|
|
1687
|
-
&&
|
|
1868
|
+
&& (static_artifact_requested(&task_for_recovery)
|
|
1869
|
+
|| canvas_game_requested(&task_for_recovery))
|
|
1688
1870
|
{
|
|
1871
|
+
// No fabricated fallback artifact: tell the model exactly why
|
|
1872
|
+
// publishing was rejected and ask it to fix and re-publish;
|
|
1873
|
+
// fail honestly after bounded recovery attempts.
|
|
1689
1874
|
artifact_error_count += 1;
|
|
1690
|
-
if artifact_error_count
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
);
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
fallback_out.is_error,
|
|
1704
|
-
"build",
|
|
1705
|
-
depth,
|
|
1706
|
-
);
|
|
1707
|
-
if !fallback_out.is_error {
|
|
1708
|
-
summary = Some(format!(
|
|
1709
|
-
"{}\n\nThe model repeatedly produced incomplete canvas HTML, so buildwithnexus published a complete self-contained fallback canvas game.",
|
|
1710
|
-
fallback_out.content
|
|
1711
|
-
));
|
|
1712
|
-
}
|
|
1875
|
+
if artifact_error_count <= 2 {
|
|
1876
|
+
artifact_recovery = Some(format!(
|
|
1877
|
+
"Your artifact was rejected: {}. Fix that exact problem and call \
|
|
1878
|
+
Artifact/publish_artifact again with one complete self-contained HTML file \
|
|
1879
|
+
(all CSS/JS inline). Do not answer with markdown code.",
|
|
1880
|
+
out.content
|
|
1881
|
+
));
|
|
1882
|
+
} else {
|
|
1883
|
+
loop_stop = Some(format!(
|
|
1884
|
+
"artifact publishing failed after {artifact_error_count} attempts; \
|
|
1885
|
+
last rejection: {}",
|
|
1886
|
+
out.content
|
|
1887
|
+
));
|
|
1713
1888
|
}
|
|
1714
1889
|
}
|
|
1715
1890
|
if out.finished {
|
|
@@ -1722,7 +1897,8 @@ fn build_inner(
|
|
|
1722
1897
|
&call_input,
|
|
1723
1898
|
&out.content,
|
|
1724
1899
|
out.is_error,
|
|
1725
|
-
&mut
|
|
1900
|
+
&mut loop_nudge,
|
|
1901
|
+
&mut loop_stop,
|
|
1726
1902
|
);
|
|
1727
1903
|
}
|
|
1728
1904
|
results.push(ToolResult {
|
|
@@ -1737,6 +1913,11 @@ fn build_inner(
|
|
|
1737
1913
|
calls: reply.calls,
|
|
1738
1914
|
});
|
|
1739
1915
|
msgs.push(Msg::Tool(results));
|
|
1916
|
+
// Persist the transcript after every tool round so a crash or kill
|
|
1917
|
+
// doesn't lose the session (save is cheap and swallows I/O errors).
|
|
1918
|
+
if let Some(sid) = sid {
|
|
1919
|
+
crate::session::save(sid, cwd, &p.model, msgs);
|
|
1920
|
+
}
|
|
1740
1921
|
if !report::is_json() {
|
|
1741
1922
|
tui::context_meter(estimate_tokens(msgs), p.context_tokens);
|
|
1742
1923
|
tui::poll_typeahead();
|
|
@@ -1746,48 +1927,114 @@ fn build_inner(
|
|
|
1746
1927
|
let verifier = crate::verifier::Verifier::new(&cwd.to_string_lossy());
|
|
1747
1928
|
let ctx = crate::verifier::VerificationContext {
|
|
1748
1929
|
task_description: task.to_string(),
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
evidence_gathered: vec![],
|
|
1753
|
-
tests_added: vec![],
|
|
1754
|
-
dependencies_changed: vec![],
|
|
1755
|
-
git_diff: None,
|
|
1930
|
+
changed_files: changed_files.clone(),
|
|
1931
|
+
tool_calls: tool_records.clone(),
|
|
1932
|
+
..Default::default()
|
|
1756
1933
|
};
|
|
1757
1934
|
let rep = verifier.verify(&ctx);
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
crate::verifier::VerificationStatus::
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
&rep.rule_violations
|
|
1935
|
+
match rep.status {
|
|
1936
|
+
crate::verifier::VerificationStatus::Blocked
|
|
1937
|
+
| crate::verifier::VerificationStatus::Failed
|
|
1938
|
+
if verifier_fix_rounds < MAX_VERIFIER_FIX_ROUNDS =>
|
|
1939
|
+
{
|
|
1940
|
+
// Give the model a chance to address the violations
|
|
1941
|
+
// before finishing.
|
|
1942
|
+
verifier_fix_rounds += 1;
|
|
1943
|
+
let violations =
|
|
1944
|
+
crate::rules::RuleEngine::format_violations(&rep.rule_violations);
|
|
1945
|
+
report::notice(&format!(
|
|
1946
|
+
" [Verification: {} — asking the model to address violations]",
|
|
1947
|
+
rep.status
|
|
1948
|
+
));
|
|
1949
|
+
report::notice(&violations);
|
|
1950
|
+
msgs.push(Msg::User(format!(
|
|
1951
|
+
"Verification of your finished work returned `{}` with these rule \
|
|
1952
|
+
violations:\n{violations}\nAddress them, then call finish again.",
|
|
1953
|
+
rep.status
|
|
1954
|
+
)));
|
|
1955
|
+
continue;
|
|
1956
|
+
}
|
|
1957
|
+
crate::verifier::VerificationStatus::Blocked
|
|
1958
|
+
| crate::verifier::VerificationStatus::Failed => {
|
|
1959
|
+
// Fix rounds exhausted: finish anyway with an honest note.
|
|
1960
|
+
let violations =
|
|
1961
|
+
crate::rules::RuleEngine::format_violations(&rep.rule_violations);
|
|
1962
|
+
report::notice(&format!(" [Verification: {}]", rep.status));
|
|
1963
|
+
return Ok(format!(
|
|
1964
|
+
"{s}\n\n[verification note] The verifier still reports `{}` after \
|
|
1965
|
+
{verifier_fix_rounds} fix attempts:\n{violations}",
|
|
1966
|
+
rep.status
|
|
1768
1967
|
));
|
|
1769
1968
|
}
|
|
1969
|
+
crate::verifier::VerificationStatus::PassedWithWarnings => {
|
|
1970
|
+
report::notice(&format!(" [Verification: {}]", rep.status));
|
|
1971
|
+
if !rep.rule_violations.is_empty() {
|
|
1972
|
+
report::notice(&crate::rules::RuleEngine::format_violations(
|
|
1973
|
+
&rep.rule_violations,
|
|
1974
|
+
));
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
crate::verifier::VerificationStatus::Passed => {}
|
|
1770
1978
|
}
|
|
1771
1979
|
}
|
|
1772
1980
|
return Ok(s);
|
|
1773
1981
|
}
|
|
1982
|
+
if let Some(stop_msg) = loop_stop {
|
|
1983
|
+
// A stopped loop (or exhausted artifact recovery) is not success —
|
|
1984
|
+
// surface it as a failure with the honest summary.
|
|
1985
|
+
return Err(stop_msg);
|
|
1986
|
+
}
|
|
1987
|
+
if let Some(recovery) = artifact_recovery {
|
|
1988
|
+
msgs.push(Msg::User(recovery));
|
|
1989
|
+
} else if let Some(nudge) = loop_nudge {
|
|
1990
|
+
msgs.push(Msg::User(nudge));
|
|
1991
|
+
}
|
|
1774
1992
|
}
|
|
1775
1993
|
Err(format!(
|
|
1776
1994
|
"reached the {MAX_ITERS}-step limit without finishing"
|
|
1777
1995
|
))
|
|
1778
1996
|
}
|
|
1779
1997
|
|
|
1998
|
+
// Feedback for a tool call whose arguments failed to parse: name the tool,
|
|
1999
|
+
// show the parse error and the schema's required params, and demand a re-send.
|
|
2000
|
+
fn invalid_args_feedback(name: &str, raw: &str, defs: &[tools::ToolDef]) -> String {
|
|
2001
|
+
let parse_err = match serde_json::from_str::<serde_json::Value>(raw) {
|
|
2002
|
+
Err(e) => e.to_string(),
|
|
2003
|
+
Ok(_) => "arguments did not match the expected object shape".to_string(),
|
|
2004
|
+
};
|
|
2005
|
+
let required = defs
|
|
2006
|
+
.iter()
|
|
2007
|
+
.find(|d| d.name == name)
|
|
2008
|
+
.and_then(|d| d.schema.get("required"))
|
|
2009
|
+
.and_then(|r| r.as_array())
|
|
2010
|
+
.map(|a| {
|
|
2011
|
+
a.iter()
|
|
2012
|
+
.filter_map(|v| v.as_str())
|
|
2013
|
+
.collect::<Vec<_>>()
|
|
2014
|
+
.join(", ")
|
|
2015
|
+
})
|
|
2016
|
+
.filter(|s| !s.is_empty())
|
|
2017
|
+
.unwrap_or_else(|| "(none)".to_string());
|
|
2018
|
+
format!(
|
|
2019
|
+
"Tool `{name}` arguments were not valid JSON ({parse_err}): {}. \
|
|
2020
|
+
Required params: {required}. Re-send the complete corrected call.",
|
|
2021
|
+
raw.chars().take(200).collect::<String>()
|
|
2022
|
+
)
|
|
2023
|
+
}
|
|
2024
|
+
|
|
1780
2025
|
static SUB_SEQ: AtomicUsize = AtomicUsize::new(0);
|
|
1781
2026
|
|
|
2027
|
+
// Returns the subagent's result and whether it failed — failures must reach
|
|
2028
|
+
// the parent model as is_error so it can react instead of trusting bad output.
|
|
1782
2029
|
fn spawn_subagent(
|
|
1783
2030
|
p: &Provider,
|
|
1784
2031
|
perm: Permission,
|
|
1785
2032
|
input: &serde_json::Value,
|
|
1786
2033
|
cwd: &Path,
|
|
1787
2034
|
depth: usize,
|
|
1788
|
-
) -> String {
|
|
2035
|
+
) -> (String, bool) {
|
|
1789
2036
|
if depth + 1 >= MAX_DEPTH {
|
|
1790
|
-
return "subagent depth limit reached".into();
|
|
2037
|
+
return ("subagent depth limit reached".into(), true);
|
|
1791
2038
|
}
|
|
1792
2039
|
let task = input["task"]
|
|
1793
2040
|
.as_str()
|
|
@@ -1795,7 +2042,7 @@ fn spawn_subagent(
|
|
|
1795
2042
|
.unwrap_or("")
|
|
1796
2043
|
.trim();
|
|
1797
2044
|
if task.is_empty() {
|
|
1798
|
-
return "spawn_subagent requires a task".into();
|
|
2045
|
+
return ("spawn_subagent requires a task".into(), true);
|
|
1799
2046
|
}
|
|
1800
2047
|
let role = input["role"].as_str().unwrap_or("engineer");
|
|
1801
2048
|
let isolate = input["isolate"].as_bool().unwrap_or(false);
|
|
@@ -1828,8 +2075,11 @@ fn spawn_subagent(
|
|
|
1828
2075
|
}),
|
|
1829
2076
|
);
|
|
1830
2077
|
let mut child: Vec<Msg> = Vec::new();
|
|
1831
|
-
let result
|
|
1832
|
-
|
|
2078
|
+
let (result, is_error) =
|
|
2079
|
+
match build_inner(p, perm, role, task, &run_cwd, depth + 1, &mut child, None) {
|
|
2080
|
+
Ok(r) => (r, false),
|
|
2081
|
+
Err(e) => (format!("subagent error: {e}"), true),
|
|
2082
|
+
};
|
|
1833
2083
|
trace::record_visible(
|
|
1834
2084
|
"subagent_done",
|
|
1835
2085
|
format!("{role}: {}", trace::preview(task, 80)),
|
|
@@ -1839,10 +2089,11 @@ fn spawn_subagent(
|
|
|
1839
2089
|
"isolate": isolate,
|
|
1840
2090
|
"cwd": run_cwd.to_string_lossy(),
|
|
1841
2091
|
"result": result,
|
|
2092
|
+
"is_error": is_error,
|
|
1842
2093
|
"depth": depth + 1,
|
|
1843
2094
|
}),
|
|
1844
2095
|
);
|
|
1845
|
-
format!("{note}{result}")
|
|
2096
|
+
(format!("{note}{result}"), is_error)
|
|
1846
2097
|
}
|
|
1847
2098
|
|
|
1848
2099
|
fn make_worktree(cwd: &Path) -> Option<PathBuf> {
|
|
@@ -2080,7 +2331,31 @@ fn recovery_task_text(task: &str) -> String {
|
|
|
2080
2331
|
.to_string()
|
|
2081
2332
|
}
|
|
2082
2333
|
|
|
2334
|
+
// A build/create verb must co-occur with the artifact noun so that "fix the
|
|
2335
|
+
// collision bug in my game" or "explain how this website works" don't trigger
|
|
2336
|
+
// artifact-recovery flows.
|
|
2337
|
+
fn task_has_build_verb(task: &str) -> bool {
|
|
2338
|
+
let lower = task.to_lowercase();
|
|
2339
|
+
lower.split(|c: char| !c.is_ascii_alphabetic()).any(|word| {
|
|
2340
|
+
matches!(
|
|
2341
|
+
word,
|
|
2342
|
+
"build"
|
|
2343
|
+
| "create"
|
|
2344
|
+
| "make"
|
|
2345
|
+
| "write"
|
|
2346
|
+
| "generate"
|
|
2347
|
+
| "develop"
|
|
2348
|
+
| "implement"
|
|
2349
|
+
| "code"
|
|
2350
|
+
| "publish"
|
|
2351
|
+
)
|
|
2352
|
+
})
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2083
2355
|
fn static_artifact_requested(task: &str) -> bool {
|
|
2356
|
+
if !task_has_build_verb(task) {
|
|
2357
|
+
return false;
|
|
2358
|
+
}
|
|
2084
2359
|
let lower = task.to_lowercase();
|
|
2085
2360
|
[
|
|
2086
2361
|
"canvas game",
|
|
@@ -2159,79 +2434,102 @@ fn extract_html_title(html: &str) -> Option<String> {
|
|
|
2159
2434
|
}
|
|
2160
2435
|
|
|
2161
2436
|
fn canvas_game_requested(task: &str) -> bool {
|
|
2437
|
+
if !task_has_build_verb(task) {
|
|
2438
|
+
return false;
|
|
2439
|
+
}
|
|
2162
2440
|
let lower = task.to_lowercase();
|
|
2163
2441
|
lower.contains("canvas game") || lower.contains("browser game") || lower.contains("game")
|
|
2164
2442
|
}
|
|
2165
2443
|
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2444
|
+
// An imperative task tells the agent to do work, not answer a question —
|
|
2445
|
+
// prose-only replies to these get the act-don't-explain nudge. Reuses the
|
|
2446
|
+
// build-verb classifier plus change-verbs like add/fix; question-style tasks
|
|
2447
|
+
// ("how do I build…?") want an answer and are excluded.
|
|
2448
|
+
fn task_is_imperative(task: &str) -> bool {
|
|
2449
|
+
let lower = task.trim().to_lowercase();
|
|
2450
|
+
if lower.ends_with('?')
|
|
2451
|
+
|| [
|
|
2452
|
+
"how ", "what ", "why ", "when ", "where ", "who ", "which ", "should ", "could ",
|
|
2453
|
+
"can ", "is ", "are ", "does ", "do ", "explain ",
|
|
2454
|
+
]
|
|
2455
|
+
.iter()
|
|
2456
|
+
.any(|q| lower.starts_with(q))
|
|
2457
|
+
{
|
|
2458
|
+
return false;
|
|
2459
|
+
}
|
|
2460
|
+
task_has_build_verb(task)
|
|
2461
|
+
|| lower.split(|c: char| !c.is_ascii_alphabetic()).any(|word| {
|
|
2462
|
+
matches!(
|
|
2463
|
+
word,
|
|
2464
|
+
"add"
|
|
2465
|
+
| "fix"
|
|
2466
|
+
| "refactor"
|
|
2467
|
+
| "update"
|
|
2468
|
+
| "install"
|
|
2469
|
+
| "convert"
|
|
2470
|
+
| "remove"
|
|
2471
|
+
| "rename"
|
|
2472
|
+
| "delete"
|
|
2473
|
+
| "change"
|
|
2474
|
+
| "setup"
|
|
2475
|
+
)
|
|
2476
|
+
})
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
// Fix 14 decision: nudge the model to act instead of explaining. Fires only
|
|
2480
|
+
// for imperative tasks, only before any mutating tool has run this session,
|
|
2481
|
+
// and only while the per-task cap has not been reached. A prose reply with no
|
|
2482
|
+
// tool activity at all, or one that reads as how-to instructions, is treated
|
|
2483
|
+
// as explaining rather than doing.
|
|
2484
|
+
fn should_nudge_to_act(
|
|
2485
|
+
task: &str,
|
|
2486
|
+
reply_text: &str,
|
|
2487
|
+
any_tool_ran: bool,
|
|
2488
|
+
mutating_tool_ran: bool,
|
|
2489
|
+
nudges_so_far: usize,
|
|
2490
|
+
) -> bool {
|
|
2491
|
+
if nudges_so_far >= MAX_ACT_NUDGES || mutating_tool_ran || !task_is_imperative(task) {
|
|
2492
|
+
return false;
|
|
2493
|
+
}
|
|
2494
|
+
!any_tool_ran || reads_as_instructions(reply_text)
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
// True when a reply reads as instructions/explanation for the USER to follow
|
|
2498
|
+
// ("here's how", "you should…") rather than a summary of completed work.
|
|
2499
|
+
// Kept conservative: callers must also check that no mutating tool ran.
|
|
2500
|
+
fn reads_as_instructions(text: &str) -> bool {
|
|
2501
|
+
let lower = text.to_lowercase();
|
|
2502
|
+
[
|
|
2503
|
+
"you can",
|
|
2504
|
+
"you should",
|
|
2505
|
+
"you could",
|
|
2506
|
+
"you'll need to",
|
|
2507
|
+
"you will need to",
|
|
2508
|
+
"here's how",
|
|
2509
|
+
"here is how",
|
|
2510
|
+
"steps:",
|
|
2511
|
+
"step 1",
|
|
2512
|
+
"first,",
|
|
2513
|
+
"follow these steps",
|
|
2514
|
+
"to do this",
|
|
2515
|
+
]
|
|
2516
|
+
.iter()
|
|
2517
|
+
.any(|marker| lower.contains(marker))
|
|
2221
2518
|
}
|
|
2222
2519
|
|
|
2223
2520
|
// ── PLAN mode ─────────────────────────────────────────────────────────────────
|
|
2224
2521
|
// The planning phase now has tools available so the model can inspect the
|
|
2225
2522
|
// codebase while breaking down the task. Execution still runs through BUILD.
|
|
2226
2523
|
pub fn run_plan(p: &Provider, perm: Permission, task: &str, cwd: &Path) -> Result<(), String> {
|
|
2524
|
+
// Role identity + mode contract come first; environment sections follow.
|
|
2227
2525
|
let prefix = context_prefix(cwd, p.context_tokens);
|
|
2228
2526
|
let sys = format!(
|
|
2229
|
-
"
|
|
2527
|
+
"You are a planning engineer with full access to the codebase. \
|
|
2230
2528
|
Use read_file/list_dir/list_tree/find_paths/grep_files/fetch_url and read-only bash/run_command calls to inspect the project as needed. \
|
|
2231
2529
|
Do not write files, edit files, apply patches, spawn subagents, or run mutating shell commands while planning. \
|
|
2232
2530
|
When ready, call exit_plan or ExitPlanMode with a concise numbered implementation plan. \
|
|
2233
2531
|
The plan must be concrete, actionable, and at most 8 steps. \
|
|
2234
|
-
Do not include code fences, shell snippets, intro text, or outro text
|
|
2532
|
+
Do not include code fences, shell snippets, intro text, or outro text.\n\n{prefix}"
|
|
2235
2533
|
);
|
|
2236
2534
|
|
|
2237
2535
|
let defs = tools::defs_readonly(); // planning inspects context but never writes
|
|
@@ -2296,12 +2594,10 @@ pub fn run_plan(p: &Provider, perm: Permission, task: &str, cwd: &Path) -> Resul
|
|
|
2296
2594
|
// Execute tool calls during the planning phase.
|
|
2297
2595
|
let mut results = Vec::new();
|
|
2298
2596
|
let mut loop_summary: Option<String> = None;
|
|
2597
|
+
let mut loop_nudge: Option<String> = None;
|
|
2299
2598
|
for call in &reply.calls {
|
|
2300
2599
|
if let Some(raw) = call.input.get(tools::INVALID_ARGS).and_then(|v| v.as_str()) {
|
|
2301
|
-
let msg =
|
|
2302
|
-
"tool arguments were not valid JSON: {}",
|
|
2303
|
-
raw.chars().take(200).collect::<String>()
|
|
2304
|
-
);
|
|
2600
|
+
let msg = invalid_args_feedback(&call.name, raw, &defs);
|
|
2305
2601
|
report::tool_denied(&msg);
|
|
2306
2602
|
note_loop_result(
|
|
2307
2603
|
&mut loop_guard,
|
|
@@ -2309,6 +2605,7 @@ pub fn run_plan(p: &Provider, perm: Permission, task: &str, cwd: &Path) -> Resul
|
|
|
2309
2605
|
&call.input,
|
|
2310
2606
|
&msg,
|
|
2311
2607
|
true,
|
|
2608
|
+
&mut loop_nudge,
|
|
2312
2609
|
&mut loop_summary,
|
|
2313
2610
|
);
|
|
2314
2611
|
results.push(ToolResult {
|
|
@@ -2380,6 +2677,7 @@ pub fn run_plan(p: &Provider, perm: Permission, task: &str, cwd: &Path) -> Resul
|
|
|
2380
2677
|
&call_input,
|
|
2381
2678
|
&answer,
|
|
2382
2679
|
is_error,
|
|
2680
|
+
&mut loop_nudge,
|
|
2383
2681
|
&mut loop_summary,
|
|
2384
2682
|
);
|
|
2385
2683
|
results.push(ToolResult {
|
|
@@ -2406,6 +2704,7 @@ pub fn run_plan(p: &Provider, perm: Permission, task: &str, cwd: &Path) -> Resul
|
|
|
2406
2704
|
&call_input,
|
|
2407
2705
|
&reason,
|
|
2408
2706
|
true,
|
|
2707
|
+
&mut loop_nudge,
|
|
2409
2708
|
&mut loop_summary,
|
|
2410
2709
|
);
|
|
2411
2710
|
results.push(ToolResult {
|
|
@@ -2438,6 +2737,7 @@ pub fn run_plan(p: &Provider, perm: Permission, task: &str, cwd: &Path) -> Resul
|
|
|
2438
2737
|
&call_input,
|
|
2439
2738
|
&out.content,
|
|
2440
2739
|
out.is_error,
|
|
2740
|
+
&mut loop_nudge,
|
|
2441
2741
|
&mut loop_summary,
|
|
2442
2742
|
);
|
|
2443
2743
|
results.push(ToolResult {
|
|
@@ -2463,6 +2763,9 @@ pub fn run_plan(p: &Provider, perm: Permission, task: &str, cwd: &Path) -> Resul
|
|
|
2463
2763
|
}
|
|
2464
2764
|
return Err(loop_msg);
|
|
2465
2765
|
}
|
|
2766
|
+
if let Some(nudge) = loop_nudge {
|
|
2767
|
+
msgs.push(Msg::User(nudge));
|
|
2768
|
+
}
|
|
2466
2769
|
};
|
|
2467
2770
|
|
|
2468
2771
|
let mut steps = parse_plan_steps(&plan_text);
|
|
@@ -2542,13 +2845,14 @@ pub fn run_brainstorm(
|
|
|
2542
2845
|
cwd: &Path,
|
|
2543
2846
|
first: &str,
|
|
2544
2847
|
) -> Result<Option<ModeHint>, String> {
|
|
2848
|
+
// Role identity + mode contract come first; environment sections follow.
|
|
2545
2849
|
let prefix = context_prefix(cwd, p.context_tokens);
|
|
2546
|
-
let sys = format!("
|
|
2850
|
+
let sys = format!("You are a sharp, concise thought partner with full access to the codebase and the internet. \
|
|
2547
2851
|
Use tools freely to look things up, read files, grep for patterns, or run commands — \
|
|
2548
2852
|
whatever helps the conversation. \
|
|
2549
2853
|
When you think the user is ready to stop discussing and start building or planning, \
|
|
2550
2854
|
end your response with the exact token [SUGGEST:BUILD] or [SUGGEST:PLAN] on its own line. \
|
|
2551
|
-
Otherwise just respond naturally. No fluff
|
|
2855
|
+
Otherwise just respond naturally. No fluff.\n\n{prefix}");
|
|
2552
2856
|
|
|
2553
2857
|
let defs = tools::defs_for_context(false, p.context_tokens);
|
|
2554
2858
|
let mut msgs: Vec<Msg> = vec![Msg::System(sys)];
|
|
@@ -2592,12 +2896,10 @@ pub fn run_brainstorm(
|
|
|
2592
2896
|
// Execute tool calls inline.
|
|
2593
2897
|
let mut results = Vec::new();
|
|
2594
2898
|
let mut loop_summary: Option<String> = None;
|
|
2899
|
+
let mut loop_nudge: Option<String> = None;
|
|
2595
2900
|
for call in &reply.calls {
|
|
2596
2901
|
if let Some(raw) = call.input.get(tools::INVALID_ARGS).and_then(|v| v.as_str()) {
|
|
2597
|
-
let msg =
|
|
2598
|
-
"tool arguments were not valid JSON: {}",
|
|
2599
|
-
raw.chars().take(200).collect::<String>()
|
|
2600
|
-
);
|
|
2902
|
+
let msg = invalid_args_feedback(&call.name, raw, &defs);
|
|
2601
2903
|
report::tool_denied(&msg);
|
|
2602
2904
|
note_loop_result(
|
|
2603
2905
|
&mut loop_guard,
|
|
@@ -2605,6 +2907,7 @@ pub fn run_brainstorm(
|
|
|
2605
2907
|
&call.input,
|
|
2606
2908
|
&msg,
|
|
2607
2909
|
true,
|
|
2910
|
+
&mut loop_nudge,
|
|
2608
2911
|
&mut loop_summary,
|
|
2609
2912
|
);
|
|
2610
2913
|
results.push(ToolResult {
|
|
@@ -2638,6 +2941,7 @@ pub fn run_brainstorm(
|
|
|
2638
2941
|
&call_input,
|
|
2639
2942
|
&answer,
|
|
2640
2943
|
is_error,
|
|
2944
|
+
&mut loop_nudge,
|
|
2641
2945
|
&mut loop_summary,
|
|
2642
2946
|
);
|
|
2643
2947
|
results.push(ToolResult {
|
|
@@ -2664,6 +2968,7 @@ pub fn run_brainstorm(
|
|
|
2664
2968
|
&call_input,
|
|
2665
2969
|
&reason,
|
|
2666
2970
|
true,
|
|
2971
|
+
&mut loop_nudge,
|
|
2667
2972
|
&mut loop_summary,
|
|
2668
2973
|
);
|
|
2669
2974
|
results.push(ToolResult {
|
|
@@ -2697,6 +3002,7 @@ pub fn run_brainstorm(
|
|
|
2697
3002
|
&call_input,
|
|
2698
3003
|
&out.content,
|
|
2699
3004
|
out.is_error,
|
|
3005
|
+
&mut loop_nudge,
|
|
2700
3006
|
&mut loop_summary,
|
|
2701
3007
|
);
|
|
2702
3008
|
results.push(ToolResult {
|
|
@@ -2717,6 +3023,9 @@ pub fn run_brainstorm(
|
|
|
2717
3023
|
if let Some(loop_msg) = loop_summary {
|
|
2718
3024
|
break loop_msg;
|
|
2719
3025
|
}
|
|
3026
|
+
if let Some(nudge) = loop_nudge {
|
|
3027
|
+
msgs.push(Msg::User(nudge));
|
|
3028
|
+
}
|
|
2720
3029
|
};
|
|
2721
3030
|
|
|
2722
3031
|
// Check for mode-transition suggestion embedded in the reply.
|
|
@@ -2764,12 +3073,13 @@ pub fn run_chat_turn(
|
|
|
2764
3073
|
cwd: &Path,
|
|
2765
3074
|
question: &str,
|
|
2766
3075
|
) -> Result<(), String> {
|
|
3076
|
+
// Role identity + mode contract come first; environment sections follow.
|
|
2767
3077
|
let prefix = context_prefix(cwd, p.context_tokens);
|
|
2768
3078
|
let sys = format!(
|
|
2769
|
-
"
|
|
3079
|
+
"You are buildwithnexus in a coding terminal. Answer the user's current message naturally and concisely. \
|
|
2770
3080
|
If the user asks a normal conversational question or greeting, answer in plain text and do not call tools. \
|
|
2771
3081
|
If answering well requires inspecting the workspace or environment, use tools, then summarize the result. \
|
|
2772
|
-
Do not emit JSON unless a tool call is actually required by the tool protocol
|
|
3082
|
+
Do not emit JSON unless a tool call is actually required by the tool protocol.\n\n{prefix}"
|
|
2773
3083
|
);
|
|
2774
3084
|
|
|
2775
3085
|
let defs = tools::defs_for_context(false, p.context_tokens);
|
|
@@ -2796,12 +3106,10 @@ pub fn run_chat_turn(
|
|
|
2796
3106
|
|
|
2797
3107
|
let mut results = Vec::new();
|
|
2798
3108
|
let mut loop_summary: Option<String> = None;
|
|
3109
|
+
let mut loop_nudge: Option<String> = None;
|
|
2799
3110
|
for call in &reply.calls {
|
|
2800
3111
|
if let Some(raw) = call.input.get(tools::INVALID_ARGS).and_then(|v| v.as_str()) {
|
|
2801
|
-
let msg =
|
|
2802
|
-
"tool arguments were not valid JSON: {}",
|
|
2803
|
-
raw.chars().take(200).collect::<String>()
|
|
2804
|
-
);
|
|
3112
|
+
let msg = invalid_args_feedback(&call.name, raw, &defs);
|
|
2805
3113
|
report::tool_denied(&msg);
|
|
2806
3114
|
note_loop_result(
|
|
2807
3115
|
&mut loop_guard,
|
|
@@ -2809,6 +3117,7 @@ pub fn run_chat_turn(
|
|
|
2809
3117
|
&call.input,
|
|
2810
3118
|
&msg,
|
|
2811
3119
|
true,
|
|
3120
|
+
&mut loop_nudge,
|
|
2812
3121
|
&mut loop_summary,
|
|
2813
3122
|
);
|
|
2814
3123
|
results.push(ToolResult {
|
|
@@ -2852,6 +3161,7 @@ pub fn run_chat_turn(
|
|
|
2852
3161
|
&call_input,
|
|
2853
3162
|
&reason,
|
|
2854
3163
|
true,
|
|
3164
|
+
&mut loop_nudge,
|
|
2855
3165
|
&mut loop_summary,
|
|
2856
3166
|
);
|
|
2857
3167
|
results.push(ToolResult {
|
|
@@ -2872,6 +3182,7 @@ pub fn run_chat_turn(
|
|
|
2872
3182
|
&call_input,
|
|
2873
3183
|
&out.content,
|
|
2874
3184
|
out.is_error,
|
|
3185
|
+
&mut loop_nudge,
|
|
2875
3186
|
&mut loop_summary,
|
|
2876
3187
|
);
|
|
2877
3188
|
results.push(ToolResult {
|
|
@@ -2894,6 +3205,9 @@ pub fn run_chat_turn(
|
|
|
2894
3205
|
report::assistant(&loop_msg);
|
|
2895
3206
|
return Ok(());
|
|
2896
3207
|
}
|
|
3208
|
+
if let Some(nudge) = loop_nudge {
|
|
3209
|
+
msgs.push(Msg::User(nudge));
|
|
3210
|
+
}
|
|
2897
3211
|
}
|
|
2898
3212
|
|
|
2899
3213
|
report::assistant(&format!(
|
|
@@ -2955,7 +3269,7 @@ mod tests {
|
|
|
2955
3269
|
let defs = tools::defs_for_context(true, 128_000);
|
|
2956
3270
|
let reply = Reply {
|
|
2957
3271
|
text: "```json\n{\"name\":\"start_server\",\"arguments\":{\"command\":\"npm start\",\"cwd\":\"/tmp/app\",\"port\":3000}}\n```".to_string(),
|
|
2958
|
-
|
|
3272
|
+
..Default::default()
|
|
2959
3273
|
};
|
|
2960
3274
|
let normalized = normalize_text_tool_calls(reply, &defs, "build the app");
|
|
2961
3275
|
assert_eq!(normalized.text, "");
|
|
@@ -2964,12 +3278,73 @@ mod tests {
|
|
|
2964
3278
|
assert_eq!(normalized.calls[0].input["command"], "npm start");
|
|
2965
3279
|
}
|
|
2966
3280
|
|
|
3281
|
+
#[test]
|
|
3282
|
+
fn qwen_tools_tagged_call_with_css_braces_is_parsed() {
|
|
3283
|
+
// The real failure from an end-to-end run against qwen2.5-coder-1.5b on
|
|
3284
|
+
// llama.cpp: the model emits its call as `<tools>{…}</tools>` text with
|
|
3285
|
+
// an HTML `content` field whose CSS contains `{ }`. The balanced-object
|
|
3286
|
+
// scanner must not stop at the first CSS brace.
|
|
3287
|
+
let defs = tools::defs_for_context(true, 128_000);
|
|
3288
|
+
let reply = Reply {
|
|
3289
|
+
text: "<tools>{\"name\":\"write_file\",\"arguments\":{\"path\":\"index.html\",\"content\":\"<style>body { color: #ff6faa; } .hero { padding: 2rem; }</style>\"}}</tools>".to_string(),
|
|
3290
|
+
..Default::default()
|
|
3291
|
+
};
|
|
3292
|
+
let normalized = normalize_text_tool_calls(reply, &defs, "build a donut shop site");
|
|
3293
|
+
assert_eq!(normalized.calls.len(), 1, "text: unparsed");
|
|
3294
|
+
assert_eq!(normalized.calls[0].name, "write_file");
|
|
3295
|
+
assert_eq!(normalized.calls[0].input["path"], "index.html");
|
|
3296
|
+
assert!(normalized.calls[0].input["content"]
|
|
3297
|
+
.as_str()
|
|
3298
|
+
.unwrap()
|
|
3299
|
+
.contains("#ff6faa"));
|
|
3300
|
+
}
|
|
3301
|
+
|
|
3302
|
+
#[test]
|
|
3303
|
+
fn tool_call_tagged_and_embedded_json_variants_parse() {
|
|
3304
|
+
let defs = tools::defs_for_context(true, 128_000);
|
|
3305
|
+
// Hermes/Qwen `<tool_call>` wrapper.
|
|
3306
|
+
let a = normalize_text_tool_calls(
|
|
3307
|
+
Reply {
|
|
3308
|
+
text: "<tool_call>{\"name\":\"read_file\",\"arguments\":{\"path\":\"a.txt\"}}</tool_call>"
|
|
3309
|
+
.to_string(),
|
|
3310
|
+
..Default::default()
|
|
3311
|
+
},
|
|
3312
|
+
&defs,
|
|
3313
|
+
"read it",
|
|
3314
|
+
);
|
|
3315
|
+
assert_eq!(a.calls.len(), 1);
|
|
3316
|
+
assert_eq!(a.calls[0].name, "read_file");
|
|
3317
|
+
// A leading sentence, then a bare JSON object.
|
|
3318
|
+
let b = normalize_text_tool_calls(
|
|
3319
|
+
Reply {
|
|
3320
|
+
text: "Sure, I'll do that now. {\"name\":\"read_file\",\"arguments\":{\"path\":\"b.txt\"}}"
|
|
3321
|
+
.to_string(),
|
|
3322
|
+
..Default::default()
|
|
3323
|
+
},
|
|
3324
|
+
&defs,
|
|
3325
|
+
"read it",
|
|
3326
|
+
);
|
|
3327
|
+
assert_eq!(b.calls.len(), 1);
|
|
3328
|
+
assert_eq!(b.calls[0].input["path"], "b.txt");
|
|
3329
|
+
}
|
|
3330
|
+
|
|
3331
|
+
#[test]
|
|
3332
|
+
fn balanced_json_object_respects_strings_and_truncation() {
|
|
3333
|
+
assert_eq!(
|
|
3334
|
+
balanced_json_object("x {\"a\":\"}{\"} y"),
|
|
3335
|
+
Some("{\"a\":\"}{\"}")
|
|
3336
|
+
);
|
|
3337
|
+
// Unterminated object (truncated at the token limit) yields nothing.
|
|
3338
|
+
assert_eq!(balanced_json_object("{\"a\": {\"b\": 1"), None);
|
|
3339
|
+
assert_eq!(balanced_json_object("no braces here"), None);
|
|
3340
|
+
}
|
|
3341
|
+
|
|
2967
3342
|
#[test]
|
|
2968
3343
|
fn casual_text_json_mutating_tool_call_is_ignored() {
|
|
2969
3344
|
let defs = tools::defs_for_context(true, 128_000);
|
|
2970
3345
|
let reply = Reply {
|
|
2971
3346
|
text: "```json\n{\"name\":\"start_server\",\"arguments\":{\"command\":\"npm start\",\"port\":3000}}\n```".to_string(),
|
|
2972
|
-
|
|
3347
|
+
..Default::default()
|
|
2973
3348
|
};
|
|
2974
3349
|
let normalized = normalize_text_tool_calls(reply, &defs, "hello");
|
|
2975
3350
|
assert!(normalized.calls.is_empty());
|
|
@@ -3097,7 +3472,9 @@ mod tests {
|
|
|
3097
3472
|
}
|
|
3098
3473
|
|
|
3099
3474
|
#[test]
|
|
3100
|
-
fn
|
|
3475
|
+
fn write_file_placeholder_path_is_repaired_but_content_is_kept() {
|
|
3476
|
+
// A broken destination path is repaired from the task, but the model's
|
|
3477
|
+
// non-empty content is preserved — never replaced with the task text.
|
|
3101
3478
|
let repaired = repair_placeholder_tool_input(
|
|
3102
3479
|
"write_file",
|
|
3103
3480
|
&json!({"path": "~", "content": "exit plan smoke\n- Create scratch/exitplan-smoke.txt"}),
|
|
@@ -3106,20 +3483,46 @@ mod tests {
|
|
|
3106
3483
|
)
|
|
3107
3484
|
.unwrap();
|
|
3108
3485
|
assert_eq!(repaired["path"], "scratch/exitplan-smoke.txt");
|
|
3109
|
-
assert_eq!(
|
|
3486
|
+
assert_eq!(
|
|
3487
|
+
repaired["content"],
|
|
3488
|
+
"exit plan smoke\n- Create scratch/exitplan-smoke.txt"
|
|
3489
|
+
);
|
|
3110
3490
|
}
|
|
3111
3491
|
|
|
3112
3492
|
#[test]
|
|
3113
|
-
fn
|
|
3114
|
-
|
|
3493
|
+
fn write_file_nonempty_content_is_never_overridden() {
|
|
3494
|
+
// Path matches the task but content differs: real model output must
|
|
3495
|
+
// pass through untouched (no repair at all).
|
|
3496
|
+
assert!(repair_placeholder_tool_input(
|
|
3115
3497
|
"write",
|
|
3116
3498
|
&json!({"path": "scratch/exitplan-smoke.txt", "content": "exit plan smoke\n\n1. Create scratch/exitplan-smoke.txt"}),
|
|
3117
3499
|
Path::new("/tmp/example-project"),
|
|
3118
3500
|
"create scratch/exitplan-smoke.txt containing exit plan smoke",
|
|
3119
3501
|
)
|
|
3502
|
+
.is_none());
|
|
3503
|
+
}
|
|
3504
|
+
|
|
3505
|
+
#[test]
|
|
3506
|
+
fn write_file_empty_or_placeholder_content_is_repaired() {
|
|
3507
|
+
let task = "create scratch/exitplan-smoke.txt containing exit plan smoke";
|
|
3508
|
+
let cwd = Path::new("/tmp/example-project");
|
|
3509
|
+
let empty = repair_placeholder_tool_input(
|
|
3510
|
+
"write_file",
|
|
3511
|
+
&json!({"path": "scratch/exitplan-smoke.txt", "content": ""}),
|
|
3512
|
+
cwd,
|
|
3513
|
+
task,
|
|
3514
|
+
)
|
|
3120
3515
|
.unwrap();
|
|
3121
|
-
assert_eq!(
|
|
3122
|
-
|
|
3516
|
+
assert_eq!(empty["content"], "exit plan smoke");
|
|
3517
|
+
let todo = repair_placeholder_tool_input(
|
|
3518
|
+
"write_file",
|
|
3519
|
+
&json!({"path": "scratch/exitplan-smoke.txt", "content": "TODO"}),
|
|
3520
|
+
cwd,
|
|
3521
|
+
task,
|
|
3522
|
+
)
|
|
3523
|
+
.unwrap();
|
|
3524
|
+
assert_eq!(todo["content"], "exit plan smoke");
|
|
3525
|
+
assert_eq!(todo["path"], "scratch/exitplan-smoke.txt");
|
|
3123
3526
|
}
|
|
3124
3527
|
|
|
3125
3528
|
#[test]
|
|
@@ -3297,13 +3700,8 @@ mod tests {
|
|
|
3297
3700
|
#[test]
|
|
3298
3701
|
fn gate_ask_allows_default_allowed_commands() {
|
|
3299
3702
|
let cwd = Path::new("/proj");
|
|
3300
|
-
//
|
|
3301
|
-
for cmd in &[
|
|
3302
|
-
"git status",
|
|
3303
|
-
"cargo test",
|
|
3304
|
-
"npm install",
|
|
3305
|
-
"git commit -m 'x'",
|
|
3306
|
-
] {
|
|
3703
|
+
// Only read-only binaries remain in the default allowed_commands list.
|
|
3704
|
+
for cmd in &["ls -la", "cat foo.txt", "grep -r pattern ."] {
|
|
3307
3705
|
let r = gate(
|
|
3308
3706
|
Permission::Ask,
|
|
3309
3707
|
"run_command",
|
|
@@ -3312,6 +3710,19 @@ mod tests {
|
|
|
3312
3710
|
);
|
|
3313
3711
|
assert!(r.is_none(), "expected {cmd} to be auto-allowed in Ask mode");
|
|
3314
3712
|
}
|
|
3713
|
+
// Mutating binaries now require confirmation (denied in a non-terminal).
|
|
3714
|
+
for cmd in &["npm install", "git commit -m 'x'"] {
|
|
3715
|
+
let r = gate(
|
|
3716
|
+
Permission::Ask,
|
|
3717
|
+
"run_command",
|
|
3718
|
+
&json!({"command": cmd}),
|
|
3719
|
+
cwd,
|
|
3720
|
+
);
|
|
3721
|
+
assert!(
|
|
3722
|
+
r.is_some(),
|
|
3723
|
+
"expected {cmd} to require confirmation in Ask mode"
|
|
3724
|
+
);
|
|
3725
|
+
}
|
|
3315
3726
|
}
|
|
3316
3727
|
|
|
3317
3728
|
#[test]
|
|
@@ -3381,4 +3792,260 @@ mod tests {
|
|
|
3381
3792
|
super::add_session_allowed_tool("custom_test_tool");
|
|
3382
3793
|
assert!(super::is_session_allowed_tool("custom_test_tool"));
|
|
3383
3794
|
}
|
|
3795
|
+
|
|
3796
|
+
#[test]
|
|
3797
|
+
fn truncate_at_char_boundary_never_splits_multibyte() {
|
|
3798
|
+
let s = "ab🚀cd"; // the emoji occupies bytes 2..6
|
|
3799
|
+
assert_eq!(truncate_at_char_boundary(s, 3), "ab");
|
|
3800
|
+
assert_eq!(truncate_at_char_boundary(s, 6), "ab🚀");
|
|
3801
|
+
assert_eq!(truncate_at_char_boundary(s, 100), s);
|
|
3802
|
+
let cjk = "日本語テキスト"; // 3 bytes per char
|
|
3803
|
+
let t = truncate_at_char_boundary(cjk, 7);
|
|
3804
|
+
assert_eq!(t, "日本");
|
|
3805
|
+
assert!(cjk.starts_with(t));
|
|
3806
|
+
}
|
|
3807
|
+
|
|
3808
|
+
#[test]
|
|
3809
|
+
fn compaction_split_never_starts_tail_on_tool_result() {
|
|
3810
|
+
// Layout puts the naive tail boundary on a Msg::Tool; the split must
|
|
3811
|
+
// advance past it so no tool_result is orphaned from its tool_use.
|
|
3812
|
+
let mut msgs = vec![Msg::System("s".into()), Msg::User("task".into())];
|
|
3813
|
+
for i in 0..3 {
|
|
3814
|
+
msgs.push(Msg::Assistant {
|
|
3815
|
+
text: format!("a{i}"),
|
|
3816
|
+
calls: vec![],
|
|
3817
|
+
});
|
|
3818
|
+
msgs.push(Msg::Tool(vec![crate::provider::ToolResult {
|
|
3819
|
+
id: format!("{i}"),
|
|
3820
|
+
content: "r".into(),
|
|
3821
|
+
is_error: false,
|
|
3822
|
+
}]));
|
|
3823
|
+
}
|
|
3824
|
+
msgs.push(Msg::User("follow-up".into()));
|
|
3825
|
+
// len = 9 → naive tail_start = 3, which is a Msg::Tool.
|
|
3826
|
+
assert!(matches!(msgs[3], Msg::Tool(_)));
|
|
3827
|
+
let (sys_end, tail_start) = compaction_split(&msgs);
|
|
3828
|
+
assert_eq!(sys_end, 1);
|
|
3829
|
+
assert_eq!(tail_start, 4);
|
|
3830
|
+
assert!(matches!(msgs[tail_start], Msg::Assistant { .. }));
|
|
3831
|
+
}
|
|
3832
|
+
|
|
3833
|
+
#[test]
|
|
3834
|
+
fn compaction_pins_original_task_verbatim() {
|
|
3835
|
+
let task = "build the 🚀 thing";
|
|
3836
|
+
let mut msgs = vec![Msg::System("sys".into()), Msg::User(task.into())];
|
|
3837
|
+
for i in 0..10 {
|
|
3838
|
+
msgs.push(Msg::User(format!("u{i}")));
|
|
3839
|
+
}
|
|
3840
|
+
let out = compact_with(msgs, |_| "SUMMARY".into());
|
|
3841
|
+
let Msg::User(summary) = &out[1] else {
|
|
3842
|
+
panic!("expected summary user message");
|
|
3843
|
+
};
|
|
3844
|
+
assert!(summary.contains(&format!("[Original task]\n{task}")));
|
|
3845
|
+
// A second compaction re-extracts the same verbatim task text.
|
|
3846
|
+
let mut again = out;
|
|
3847
|
+
for i in 0..10 {
|
|
3848
|
+
again.push(Msg::User(format!("v{i}")));
|
|
3849
|
+
}
|
|
3850
|
+
let out2 = compact_with(again, |_| "SUMMARY2".into());
|
|
3851
|
+
let Msg::User(summary2) = &out2[1] else {
|
|
3852
|
+
panic!("expected summary user message");
|
|
3853
|
+
};
|
|
3854
|
+
assert!(summary2.contains(&format!("[Original task]\n{task}")));
|
|
3855
|
+
}
|
|
3856
|
+
|
|
3857
|
+
#[test]
|
|
3858
|
+
fn compaction_keeps_recent_tail_tool_results_intact() {
|
|
3859
|
+
let long = "x".repeat(2000);
|
|
3860
|
+
let mut msgs = vec![Msg::System("sys".into()), Msg::User("task".into())];
|
|
3861
|
+
for i in 0..8 {
|
|
3862
|
+
msgs.push(Msg::User(format!("u{i}")));
|
|
3863
|
+
}
|
|
3864
|
+
msgs.push(Msg::Assistant {
|
|
3865
|
+
text: "a".into(),
|
|
3866
|
+
calls: vec![],
|
|
3867
|
+
});
|
|
3868
|
+
msgs.push(Msg::Tool(vec![crate::provider::ToolResult {
|
|
3869
|
+
id: "1".into(),
|
|
3870
|
+
content: long.clone(),
|
|
3871
|
+
is_error: false,
|
|
3872
|
+
}]));
|
|
3873
|
+
let out = compact_with(msgs, |_| "S".into());
|
|
3874
|
+
let Some(Msg::Tool(results)) = out.last() else {
|
|
3875
|
+
panic!("expected tool results in the tail");
|
|
3876
|
+
};
|
|
3877
|
+
assert_eq!(results[0].content, long, "tail results must not be clipped");
|
|
3878
|
+
}
|
|
3879
|
+
|
|
3880
|
+
#[test]
|
|
3881
|
+
fn loop_guard_ignores_repeated_successful_results() {
|
|
3882
|
+
let mut guard = ToolLoopGuard::default();
|
|
3883
|
+
let input = json!({"path": "src/main.rs"});
|
|
3884
|
+
for _ in 0..10 {
|
|
3885
|
+
assert!(guard
|
|
3886
|
+
.note("read_file", &input, "fn main() {}", false)
|
|
3887
|
+
.is_none());
|
|
3888
|
+
}
|
|
3889
|
+
}
|
|
3890
|
+
|
|
3891
|
+
#[test]
|
|
3892
|
+
fn loop_guard_nudges_then_stops_on_repeated_errors() {
|
|
3893
|
+
let mut guard = ToolLoopGuard::default();
|
|
3894
|
+
let input = json!({"path": "missing.txt"});
|
|
3895
|
+
assert!(guard
|
|
3896
|
+
.note("read_file", &input, "no such file", true)
|
|
3897
|
+
.is_none());
|
|
3898
|
+
assert!(guard
|
|
3899
|
+
.note("read_file", &input, "no such file", true)
|
|
3900
|
+
.is_none());
|
|
3901
|
+
let third = guard.note("read_file", &input, "no such file", true);
|
|
3902
|
+
assert!(matches!(third, Some(LoopSignal::Nudge(_))));
|
|
3903
|
+
// The same error repeating after the nudge stops the run.
|
|
3904
|
+
let fourth = guard.note("read_file", &input, "no such file", true);
|
|
3905
|
+
assert!(matches!(fourth, Some(LoopSignal::Stop(_))));
|
|
3906
|
+
}
|
|
3907
|
+
|
|
3908
|
+
#[test]
|
|
3909
|
+
fn loop_guard_resets_when_result_changes() {
|
|
3910
|
+
let mut guard = ToolLoopGuard::default();
|
|
3911
|
+
let input = json!({"path": "f"});
|
|
3912
|
+
assert!(guard.note("read_file", &input, "err A", true).is_none());
|
|
3913
|
+
assert!(guard.note("read_file", &input, "err A", true).is_none());
|
|
3914
|
+
// A different result resets the counter — no trigger on the next call.
|
|
3915
|
+
assert!(guard.note("read_file", &input, "err B", true).is_none());
|
|
3916
|
+
assert!(guard.note("read_file", &input, "err B", true).is_none());
|
|
3917
|
+
}
|
|
3918
|
+
|
|
3919
|
+
#[test]
|
|
3920
|
+
fn max_tokens_stop_reason_is_detected() {
|
|
3921
|
+
let truncated = Reply {
|
|
3922
|
+
stop_reason: Some("max_tokens".into()),
|
|
3923
|
+
..Default::default()
|
|
3924
|
+
};
|
|
3925
|
+
assert!(reply_truncated_at_token_limit(&truncated));
|
|
3926
|
+
let done = Reply {
|
|
3927
|
+
stop_reason: Some("end_turn".into()),
|
|
3928
|
+
..Default::default()
|
|
3929
|
+
};
|
|
3930
|
+
assert!(!reply_truncated_at_token_limit(&done));
|
|
3931
|
+
assert!(!reply_truncated_at_token_limit(&Reply::default()));
|
|
3932
|
+
}
|
|
3933
|
+
|
|
3934
|
+
#[test]
|
|
3935
|
+
fn artifact_classifiers_require_build_verb() {
|
|
3936
|
+
assert!(canvas_game_requested("build me a canvas game"));
|
|
3937
|
+
assert!(canvas_game_requested("make a browser game about space"));
|
|
3938
|
+
assert!(!canvas_game_requested("fix the collision bug in my game"));
|
|
3939
|
+
assert!(static_artifact_requested(
|
|
3940
|
+
"create a landing page for my startup"
|
|
3941
|
+
));
|
|
3942
|
+
assert!(!static_artifact_requested("explain how this website works"));
|
|
3943
|
+
assert!(!static_artifact_requested("why is the landing page slow?"));
|
|
3944
|
+
}
|
|
3945
|
+
|
|
3946
|
+
#[test]
|
|
3947
|
+
fn casual_turn_detection_only_matches_pure_greetings() {
|
|
3948
|
+
assert!(is_casual_turn("hi"));
|
|
3949
|
+
assert!(is_casual_turn("Hello!"));
|
|
3950
|
+
assert!(is_casual_turn("hey"));
|
|
3951
|
+
// Acknowledgements can be valid "yes, proceed" turns.
|
|
3952
|
+
assert!(!is_casual_turn("ok"));
|
|
3953
|
+
assert!(!is_casual_turn("okay"));
|
|
3954
|
+
assert!(!is_casual_turn("thanks"));
|
|
3955
|
+
assert!(!is_casual_turn("yes, proceed"));
|
|
3956
|
+
}
|
|
3957
|
+
|
|
3958
|
+
#[test]
|
|
3959
|
+
fn placeholder_content_detection_is_conservative() {
|
|
3960
|
+
assert!(is_placeholder_content(""));
|
|
3961
|
+
assert!(is_placeholder_content(" \n"));
|
|
3962
|
+
assert!(is_placeholder_content("TODO"));
|
|
3963
|
+
assert!(is_placeholder_content("<content>"));
|
|
3964
|
+
assert!(!is_placeholder_content("hello world"));
|
|
3965
|
+
assert!(!is_placeholder_content("TODO: fix the parser later"));
|
|
3966
|
+
}
|
|
3967
|
+
|
|
3968
|
+
#[test]
|
|
3969
|
+
fn invalid_args_feedback_names_tool_error_and_required_params() {
|
|
3970
|
+
let defs = tools::defs_for_context(true, 128_000);
|
|
3971
|
+
let msg = invalid_args_feedback("write_file", "{not json", &defs);
|
|
3972
|
+
assert!(msg.contains("write_file"));
|
|
3973
|
+
assert!(msg.contains("Required params:"));
|
|
3974
|
+
assert!(msg.ends_with("Re-send the complete corrected call."));
|
|
3975
|
+
}
|
|
3976
|
+
|
|
3977
|
+
#[test]
|
|
3978
|
+
fn instructional_prose_classifier_is_conservative() {
|
|
3979
|
+
assert!(reads_as_instructions("First, you should create the file."));
|
|
3980
|
+
assert!(reads_as_instructions(
|
|
3981
|
+
"Here's how to build it. Steps:\n1. Create index.html\n2. Add a canvas"
|
|
3982
|
+
));
|
|
3983
|
+
assert!(reads_as_instructions("To do this, you can run npm init."));
|
|
3984
|
+
// Completed-work summaries must not read as instructions.
|
|
3985
|
+
assert!(!reads_as_instructions(
|
|
3986
|
+
"I created index.html with the game and published the artifact."
|
|
3987
|
+
));
|
|
3988
|
+
assert!(!reads_as_instructions(
|
|
3989
|
+
"Done — the collision bug is fixed and verified with the test suite."
|
|
3990
|
+
));
|
|
3991
|
+
}
|
|
3992
|
+
|
|
3993
|
+
#[test]
|
|
3994
|
+
fn act_nudge_fires_on_instructional_prose_for_imperative_task() {
|
|
3995
|
+
let prose = "Here's how you can build it:\nSteps:\n1. Create index.html\n2. Add a canvas";
|
|
3996
|
+
// Imperative task + how-to prose + no prior mutation → nudge.
|
|
3997
|
+
assert!(should_nudge_to_act(
|
|
3998
|
+
"build me a todo app",
|
|
3999
|
+
prose,
|
|
4000
|
+
false,
|
|
4001
|
+
false,
|
|
4002
|
+
0
|
|
4003
|
+
));
|
|
4004
|
+
assert!(should_nudge_to_act(
|
|
4005
|
+
"fix the collision bug in my game",
|
|
4006
|
+
prose,
|
|
4007
|
+
false,
|
|
4008
|
+
false,
|
|
4009
|
+
0
|
|
4010
|
+
));
|
|
4011
|
+
// The same prose after a mutating tool ran is a real summary → no nudge.
|
|
4012
|
+
assert!(!should_nudge_to_act(
|
|
4013
|
+
"build me a todo app",
|
|
4014
|
+
prose,
|
|
4015
|
+
true,
|
|
4016
|
+
true,
|
|
4017
|
+
0
|
|
4018
|
+
));
|
|
4019
|
+
// Question-style tasks want an answer, not action → no nudge.
|
|
4020
|
+
assert!(!should_nudge_to_act(
|
|
4021
|
+
"how do I build a todo app?",
|
|
4022
|
+
prose,
|
|
4023
|
+
false,
|
|
4024
|
+
false,
|
|
4025
|
+
0
|
|
4026
|
+
));
|
|
4027
|
+
assert!(!should_nudge_to_act(
|
|
4028
|
+
"what does this repo do",
|
|
4029
|
+
prose,
|
|
4030
|
+
false,
|
|
4031
|
+
false,
|
|
4032
|
+
0
|
|
4033
|
+
));
|
|
4034
|
+
// Read-only exploration followed by a plain answer (no markers) → no nudge.
|
|
4035
|
+
assert!(!should_nudge_to_act(
|
|
4036
|
+
"build me a todo app",
|
|
4037
|
+
"The repo already contains a complete todo app in src/.",
|
|
4038
|
+
true,
|
|
4039
|
+
false,
|
|
4040
|
+
0
|
|
4041
|
+
));
|
|
4042
|
+
// The per-task cap is respected.
|
|
4043
|
+
assert!(!should_nudge_to_act(
|
|
4044
|
+
"build me a todo app",
|
|
4045
|
+
prose,
|
|
4046
|
+
false,
|
|
4047
|
+
false,
|
|
4048
|
+
MAX_ACT_NUDGES
|
|
4049
|
+
));
|
|
4050
|
+
}
|
|
3384
4051
|
}
|