buildwithnexus 0.10.6 → 0.11.0
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 +938 -375
- 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 +649 -96
- package/harness/src/verifier.rs +90 -6
- package/package.json +1 -1
- package/scripts/postinstall.js +4 -1
- package/scripts/resolve-binary.js +2 -0
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
|
-
}
|
|
242
|
-
|
|
309
|
+
if rec.count < MAX_IDENTICAL_TOOL_RESULTS {
|
|
310
|
+
return None;
|
|
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
|
+
)));
|
|
243
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>> {
|
|
@@ -681,7 +794,15 @@ fn collect_text_tool_calls(
|
|
|
681
794
|
.get("arguments")
|
|
682
795
|
.or_else(|| obj.get("input"))
|
|
683
796
|
.cloned()
|
|
684
|
-
.unwrap_or_else(||
|
|
797
|
+
.unwrap_or_else(|| {
|
|
798
|
+
let mut map = serde_json::Map::new();
|
|
799
|
+
for (k, v) in obj {
|
|
800
|
+
if k != "name" && k != "tool_name" && k != "type" && k != "id" {
|
|
801
|
+
map.insert(k.clone(), v.clone());
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
serde_json::Value::Object(map)
|
|
805
|
+
});
|
|
685
806
|
out.push(text_tool_call(name, input));
|
|
686
807
|
}
|
|
687
808
|
|
|
@@ -796,7 +917,7 @@ Use the tools to inspect and modify the project directly. \
|
|
|
796
917
|
Prefer small, verifiable edits. Read before you write. \
|
|
797
918
|
When writing or editing files, provide the complete, fully working code. NEVER use placeholders (e.g. `// ... rest of code`). \
|
|
798
919
|
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. \
|
|
799
|
-
|
|
920
|
+
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. \
|
|
800
921
|
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. \
|
|
801
922
|
If a path or file is missing, use discovery tools before asking the user. \
|
|
802
923
|
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. \
|
|
@@ -814,6 +935,21 @@ over installing an alternative.",
|
|
|
814
935
|
Role { system }
|
|
815
936
|
}
|
|
816
937
|
|
|
938
|
+
// Web-artifact guidance is only relevant when the task actually asks to build
|
|
939
|
+
// one — injecting it into every prompt biased the model toward canvas games.
|
|
940
|
+
fn artifact_guidance(task: &str) -> Option<String> {
|
|
941
|
+
(static_artifact_requested(task) || canvas_game_requested(task)).then(|| {
|
|
942
|
+
"[Web artifact task]\n\
|
|
943
|
+
Build an actual runnable artifact: one complete self-contained HTML file via \
|
|
944
|
+
Artifact/publish_artifact with all CSS and JavaScript inline, unless the user \
|
|
945
|
+
explicitly asks for a framework. Include controls, restart/error states, responsive \
|
|
946
|
+
sizing, and touch/mobile support when useful; then open_browser if possible. \
|
|
947
|
+
Write the code yourself from scratch — never search GitHub or clone external \
|
|
948
|
+
repositories for the game/app. Never paste the HTML as plain markdown; call the tool."
|
|
949
|
+
.to_string()
|
|
950
|
+
})
|
|
951
|
+
}
|
|
952
|
+
|
|
817
953
|
// Build the system prompt prefix from memory and skills/agents files.
|
|
818
954
|
// When context_tokens is small (≤16K), skip expensive sections to leave
|
|
819
955
|
// room for tool definitions + actual conversation.
|
|
@@ -861,7 +997,7 @@ fn context_prefix(cwd: &Path, context_tokens: usize) -> String {
|
|
|
861
997
|
if let Some(mem) = config::load_memory() {
|
|
862
998
|
// Memory is user-important; always include but truncate for small ctx
|
|
863
999
|
let mem_text = if compact && mem.len() > 300 {
|
|
864
|
-
format!("{}…", &mem
|
|
1000
|
+
format!("{}…", truncate_at_char_boundary(&mem, 300))
|
|
865
1001
|
} else {
|
|
866
1002
|
mem
|
|
867
1003
|
};
|
|
@@ -875,7 +1011,7 @@ fn context_prefix(cwd: &Path, context_tokens: usize) -> String {
|
|
|
875
1011
|
);
|
|
876
1012
|
// For small contexts, truncate Agents.md to avoid blowing the budget
|
|
877
1013
|
let agents_text = if compact && agents.len() > 500 {
|
|
878
|
-
format!("{}…", &agents
|
|
1014
|
+
format!("{}…", truncate_at_char_boundary(&agents, 500))
|
|
879
1015
|
} else {
|
|
880
1016
|
agents
|
|
881
1017
|
};
|
|
@@ -884,33 +1020,13 @@ fn context_prefix(cwd: &Path, context_tokens: usize) -> String {
|
|
|
884
1020
|
|
|
885
1021
|
// Skip rules, knowledge, hooks, and skill descriptions for small contexts
|
|
886
1022
|
if !compact {
|
|
887
|
-
//
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
{
|
|
895
|
-
for r in loaded.rules {
|
|
896
|
-
engine.add_rule(r);
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
}
|
|
900
|
-
}
|
|
901
|
-
let mut rules_summary = String::from("The following engineering constraints and business logic rules MUST be strictly enforced for operational judgment:\n");
|
|
902
|
-
for r in &engine.rules {
|
|
903
|
-
if r.enabled {
|
|
904
|
-
rules_summary.push_str(&format!(
|
|
905
|
-
"• [{}] {} — {}\n",
|
|
906
|
-
r.severity, r.id, r.description
|
|
907
|
-
));
|
|
908
|
-
}
|
|
909
|
-
}
|
|
910
|
-
parts.push(format!(
|
|
911
|
-
"[Operational Judgment — Engineering Constraints & Rules]\n{}",
|
|
912
|
-
rules_summary.trim()
|
|
913
|
-
));
|
|
1023
|
+
// A separate verifier pass enforces the engineering rules — a 2-line
|
|
1024
|
+
// pointer is enough; injecting the full rule list bloated every prompt.
|
|
1025
|
+
parts.push(
|
|
1026
|
+
"[Operational rules]\nProject engineering rules are enforced by a separate \
|
|
1027
|
+
verifier pass after you finish.\nViolations are reported back to you to address."
|
|
1028
|
+
.to_string(),
|
|
1029
|
+
);
|
|
914
1030
|
|
|
915
1031
|
// Inject structured knowledge base if present
|
|
916
1032
|
let kb = crate::knowledge::KnowledgeBase::new(".");
|
|
@@ -945,15 +1061,13 @@ fn context_prefix(cwd: &Path, context_tokens: usize) -> String {
|
|
|
945
1061
|
}
|
|
946
1062
|
|
|
947
1063
|
fn tool_manifest() -> String {
|
|
1064
|
+
// Role guidance (no-placeholders, build-immediately, artifact rules) lives
|
|
1065
|
+
// in role()/artifact_guidance() — this section only lists what's built in.
|
|
948
1066
|
"[Built-in tools — always available, no install needed]\n\
|
|
949
1067
|
Aliases are supported: bash/read/write/edit/patch/glob/grep/list/task/todowrite/todoread/webfetch/websearch/skill/question. \
|
|
950
1068
|
Use built-ins before installing anything. Use list_tree/find_paths/grep_files before guessing paths; use find_paths kind=`dir` for folders. \
|
|
951
|
-
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
|
|
952
|
-
|
|
953
|
-
• For generated/edited code, HTML, or file contents, call write_file/edit_file/Artifact; never paste code as plain markdown.\n\
|
|
954
|
-
• 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\
|
|
955
|
-
• 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\
|
|
956
|
-
• No placeholders. No asking for theme/layout/permission when a reasonable default works. Build immediately."
|
|
1069
|
+
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. \
|
|
1070
|
+
For generated/edited code, HTML, or file contents, call write_file/edit_file/Artifact; never paste code as plain markdown."
|
|
957
1071
|
.to_string()
|
|
958
1072
|
}
|
|
959
1073
|
|
|
@@ -1250,13 +1364,16 @@ pub fn run_build_session(
|
|
|
1250
1364
|
sid: &str,
|
|
1251
1365
|
) -> Result<(), String> {
|
|
1252
1366
|
hooks::notify("SessionStart", cwd);
|
|
1253
|
-
let r = build_inner(p, perm, role_id, task, cwd, 0, transcript).map(|_| ());
|
|
1367
|
+
let r = build_inner(p, perm, role_id, task, cwd, 0, transcript, Some(sid)).map(|_| ());
|
|
1254
1368
|
hooks::notify("SessionEnd", cwd);
|
|
1255
1369
|
hooks::notify("Stop", cwd);
|
|
1256
1370
|
crate::session::save(sid, cwd, &p.model, transcript);
|
|
1257
1371
|
r
|
|
1258
1372
|
}
|
|
1259
1373
|
|
|
1374
|
+
// `sid` is Some for the top-level session (per-round transcript saves) and
|
|
1375
|
+
// None for subagents, whose transcripts live inside the parent's results.
|
|
1376
|
+
#[allow(clippy::too_many_arguments)]
|
|
1260
1377
|
fn build_inner(
|
|
1261
1378
|
p: &Provider,
|
|
1262
1379
|
perm: Permission,
|
|
@@ -1265,6 +1382,7 @@ fn build_inner(
|
|
|
1265
1382
|
cwd: &Path,
|
|
1266
1383
|
depth: usize,
|
|
1267
1384
|
msgs: &mut Vec<Msg>,
|
|
1385
|
+
sid: Option<&str>,
|
|
1268
1386
|
) -> Result<String, String> {
|
|
1269
1387
|
let task = match hooks::user_prompt_submit(task, cwd) {
|
|
1270
1388
|
Err(reason) => {
|
|
@@ -1281,8 +1399,15 @@ fn build_inner(
|
|
|
1281
1399
|
tools::defs_for_context(depth < MAX_DEPTH, p.context_tokens)
|
|
1282
1400
|
};
|
|
1283
1401
|
if msgs.is_empty() {
|
|
1284
|
-
|
|
1285
|
-
|
|
1402
|
+
// Role identity and the current-mode contract come FIRST; the
|
|
1403
|
+
// environment/tool-manifest/skills/memory sections follow.
|
|
1404
|
+
let mut sys = String::from(role(role_id).system);
|
|
1405
|
+
if let Some(guidance) = artifact_guidance(&task_for_recovery) {
|
|
1406
|
+
sys.push_str("\n\n");
|
|
1407
|
+
sys.push_str(&guidance);
|
|
1408
|
+
}
|
|
1409
|
+
sys.push_str("\n\n");
|
|
1410
|
+
sys.push_str(&context_prefix(cwd, p.context_tokens));
|
|
1286
1411
|
msgs.push(Msg::System(sys));
|
|
1287
1412
|
}
|
|
1288
1413
|
// If the caller already pushed a UserImages message (multimodal input), use its
|
|
@@ -1297,6 +1422,18 @@ fn build_inner(
|
|
|
1297
1422
|
let mut loop_guard = ToolLoopGuard::default();
|
|
1298
1423
|
let mut artifact_error_count = 0usize;
|
|
1299
1424
|
let mut static_artifact_recovery_count = 0usize;
|
|
1425
|
+
let mut empty_reply_retried = false;
|
|
1426
|
+
let mut token_limit_continuations = 0usize;
|
|
1427
|
+
let mut forced_compact_retry = false;
|
|
1428
|
+
let mut verifier_fix_rounds = 0usize;
|
|
1429
|
+
// Act-don't-explain tracking: whether any call reached dispatch, whether a
|
|
1430
|
+
// mutating tool actually executed, and how many nudges were spent.
|
|
1431
|
+
let mut act_nudges = 0usize;
|
|
1432
|
+
let mut any_tool_ran = false;
|
|
1433
|
+
let mut mutating_tool_ran = false;
|
|
1434
|
+
// Executed calls and touched files, collected for the verifier pass.
|
|
1435
|
+
let mut tool_records: Vec<crate::verifier::ToolCallRecord> = Vec::new();
|
|
1436
|
+
let mut changed_files: Vec<String> = Vec::new();
|
|
1300
1437
|
|
|
1301
1438
|
for step in 1..=MAX_ITERS {
|
|
1302
1439
|
if tui::interrupted() {
|
|
@@ -1311,6 +1448,18 @@ fn build_inner(
|
|
|
1311
1448
|
let reply = match request_reply(p, msgs.as_slice(), &defs, "thinking") {
|
|
1312
1449
|
Ok(r) => r,
|
|
1313
1450
|
Err(e) => {
|
|
1451
|
+
// A context-overflow rejection is recoverable: force-compact the
|
|
1452
|
+
// transcript and retry once instead of dying.
|
|
1453
|
+
let lower = e.to_ascii_lowercase();
|
|
1454
|
+
if !forced_compact_retry
|
|
1455
|
+
&& (lower.contains("prompt is too long") || lower.contains("context length"))
|
|
1456
|
+
{
|
|
1457
|
+
forced_compact_retry = true;
|
|
1458
|
+
report::notice(" ⟳ context overflow — force-compacting and retrying…");
|
|
1459
|
+
let taken = std::mem::take(msgs);
|
|
1460
|
+
*msgs = compact_with(taken, |middle| model_summary(p, middle));
|
|
1461
|
+
continue;
|
|
1462
|
+
}
|
|
1314
1463
|
hooks::notify("OnError", cwd);
|
|
1315
1464
|
return Err(e);
|
|
1316
1465
|
}
|
|
@@ -1322,10 +1471,89 @@ fn build_inner(
|
|
|
1322
1471
|
tui::inference_telemetry(gen_toks.max(10), elapsed);
|
|
1323
1472
|
}
|
|
1324
1473
|
hooks::notify("PostResponse", cwd);
|
|
1474
|
+
// Output truncated at the token limit: the reply (and any tool call in
|
|
1475
|
+
// it) may be incomplete — ask the model to continue instead of acting
|
|
1476
|
+
// on or returning a cut-off result. Bounded to avoid loops.
|
|
1477
|
+
if reply_truncated_at_token_limit(&reply)
|
|
1478
|
+
&& token_limit_continuations < MAX_TOKEN_LIMIT_CONTINUATIONS
|
|
1479
|
+
{
|
|
1480
|
+
token_limit_continuations += 1;
|
|
1481
|
+
report::notice(
|
|
1482
|
+
" ⚠ output truncated at the token limit — asking the model to continue",
|
|
1483
|
+
);
|
|
1484
|
+
let follow_up = if reply.calls.is_empty() {
|
|
1485
|
+
"Your answer was cut off at the token limit — continue from where you stopped."
|
|
1486
|
+
} else {
|
|
1487
|
+
"Your response hit the output token limit mid tool call, so the call was \
|
|
1488
|
+
discarded. Re-issue the complete tool call with smaller content — e.g. split \
|
|
1489
|
+
large writes into multiple write_file/edit_file calls — and continue."
|
|
1490
|
+
};
|
|
1491
|
+
// Drop the possibly-truncated calls so no dangling tool_use block
|
|
1492
|
+
// reaches the API; keep the text (when present) for continuity —
|
|
1493
|
+
// an empty assistant turn would serialize to an empty content array.
|
|
1494
|
+
if !reply.text.trim().is_empty() {
|
|
1495
|
+
msgs.push(Msg::Assistant {
|
|
1496
|
+
text: reply.text.clone(),
|
|
1497
|
+
calls: vec![],
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
msgs.push(Msg::User(follow_up.to_string()));
|
|
1501
|
+
continue;
|
|
1502
|
+
}
|
|
1325
1503
|
if reply.calls.is_empty() {
|
|
1504
|
+
// One empty response is retried before ending the run; a second
|
|
1505
|
+
// empty response ends it.
|
|
1506
|
+
if reply.text.trim().is_empty() {
|
|
1507
|
+
// No assistant turn is recorded here: an empty assistant
|
|
1508
|
+
// message would serialize to an empty content array (API 400).
|
|
1509
|
+
if !empty_reply_retried {
|
|
1510
|
+
empty_reply_retried = true;
|
|
1511
|
+
report::notice("model returned no output — asking it to continue");
|
|
1512
|
+
msgs.push(Msg::User(
|
|
1513
|
+
"You returned no output. Continue the task: call a tool or state your final answer."
|
|
1514
|
+
.to_string(),
|
|
1515
|
+
));
|
|
1516
|
+
continue;
|
|
1517
|
+
}
|
|
1518
|
+
report::notice("model returned no output");
|
|
1519
|
+
return Ok(reply.text);
|
|
1520
|
+
}
|
|
1521
|
+
// Imperative task answered with how-to prose instead of tool
|
|
1522
|
+
// calls: tell the model to act, bounded per task. Never fires
|
|
1523
|
+
// once a mutating tool has run (that prose is a real summary).
|
|
1524
|
+
if should_nudge_to_act(
|
|
1525
|
+
&task_for_recovery,
|
|
1526
|
+
&reply.text,
|
|
1527
|
+
any_tool_ran,
|
|
1528
|
+
mutating_tool_ran,
|
|
1529
|
+
act_nudges,
|
|
1530
|
+
) {
|
|
1531
|
+
act_nudges += 1;
|
|
1532
|
+
report::notice(" ⟳ model explained instead of acting — nudging it to use tools");
|
|
1533
|
+
msgs.push(Msg::Assistant {
|
|
1534
|
+
text: reply.text.clone(),
|
|
1535
|
+
calls: vec![],
|
|
1536
|
+
});
|
|
1537
|
+
msgs.push(Msg::User(
|
|
1538
|
+
"Do not explain how to do it — actually do it now. Use your tools \
|
|
1539
|
+
(write_file/edit_file/run_command) to make the changes. Begin immediately \
|
|
1540
|
+
with your first tool call."
|
|
1541
|
+
.to_string(),
|
|
1542
|
+
));
|
|
1543
|
+
continue;
|
|
1544
|
+
}
|
|
1326
1545
|
if let Some(input) = auto_create_file_input(&task_for_recovery) {
|
|
1546
|
+
// Record this harness recovery in the transcript so saved and
|
|
1547
|
+
// resumed sessions aren't missing turns.
|
|
1548
|
+
msgs.push(Msg::Assistant {
|
|
1549
|
+
text: reply.text.clone(),
|
|
1550
|
+
calls: vec![],
|
|
1551
|
+
});
|
|
1327
1552
|
if let Some(reason) = gate(perm, "write_file", &input, cwd) {
|
|
1328
1553
|
report::tool_denied(&reason);
|
|
1554
|
+
msgs.push(Msg::User(format!(
|
|
1555
|
+
"[harness] write_file recovery blocked: {reason}"
|
|
1556
|
+
)));
|
|
1329
1557
|
return Err(reason);
|
|
1330
1558
|
}
|
|
1331
1559
|
report::tool_call("write_file", &tools::preview("write_file", &input), &input);
|
|
@@ -1335,65 +1563,50 @@ fn build_inner(
|
|
|
1335
1563
|
report::tool_result("write_file", &out.content, out.is_error);
|
|
1336
1564
|
trace_tool_result("write_file", &out.content, out.is_error, "build", depth);
|
|
1337
1565
|
if out.is_error {
|
|
1566
|
+
msgs.push(Msg::User(format!(
|
|
1567
|
+
"[harness] write_file recovery failed: {}",
|
|
1568
|
+
out.content
|
|
1569
|
+
)));
|
|
1338
1570
|
return Err(out.content);
|
|
1339
1571
|
}
|
|
1572
|
+
msgs.push(Msg::User(format!(
|
|
1573
|
+
"[harness] completed the explicit file creation with write_file: {}",
|
|
1574
|
+
out.content
|
|
1575
|
+
)));
|
|
1340
1576
|
return Ok(format!(
|
|
1341
1577
|
"{}\n\nCompleted the explicit file creation task after the model returned prose without tool calls.",
|
|
1342
1578
|
out.content
|
|
1343
1579
|
));
|
|
1344
1580
|
}
|
|
1345
1581
|
if let Some(input) = auto_static_artifact_input(&task_for_recovery, &reply.text) {
|
|
1582
|
+
msgs.push(Msg::Assistant {
|
|
1583
|
+
text: reply.text.clone(),
|
|
1584
|
+
calls: vec![],
|
|
1585
|
+
});
|
|
1346
1586
|
report::tool_call("Artifact", &tools::preview("Artifact", &input), &input);
|
|
1347
1587
|
trace_tool_call("Artifact", &input, "build", depth);
|
|
1348
1588
|
let out = tools::run("Artifact", &input, cwd);
|
|
1349
1589
|
report::tool_result("Artifact", &out.content, out.is_error);
|
|
1350
1590
|
trace_tool_result("Artifact", &out.content, out.is_error, "build", depth);
|
|
1351
1591
|
if out.is_error {
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
fallback_out.is_error,
|
|
1365
|
-
);
|
|
1366
|
-
trace_tool_result(
|
|
1367
|
-
"Artifact",
|
|
1368
|
-
&fallback_out.content,
|
|
1369
|
-
fallback_out.is_error,
|
|
1370
|
-
"build",
|
|
1371
|
-
depth,
|
|
1372
|
-
);
|
|
1373
|
-
if !fallback_out.is_error {
|
|
1374
|
-
return Ok(format!(
|
|
1375
|
-
"{}\n\nThe model produced incomplete canvas HTML, so buildwithnexus published a complete self-contained fallback canvas game.",
|
|
1376
|
-
fallback_out.content
|
|
1377
|
-
));
|
|
1378
|
-
}
|
|
1379
|
-
return Err(fallback_out.content);
|
|
1592
|
+
// Tell the model exactly why the artifact was rejected and
|
|
1593
|
+
// let it fix and re-publish; fail honestly once bounded
|
|
1594
|
+
// recovery attempts run out.
|
|
1595
|
+
if artifact_error_count < 2 {
|
|
1596
|
+
artifact_error_count += 1;
|
|
1597
|
+
msgs.push(Msg::User(format!(
|
|
1598
|
+
"The HTML artifact you wrote in plain text was rejected: {}. \
|
|
1599
|
+
Fix that problem and re-publish: call Artifact/publish_artifact with one \
|
|
1600
|
+
complete self-contained HTML file. Do not answer with markdown code.",
|
|
1601
|
+
out.content
|
|
1602
|
+
)));
|
|
1603
|
+
continue;
|
|
1380
1604
|
}
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
calls: vec![],
|
|
1384
|
-
});
|
|
1385
|
-
msgs.push(Msg::User(format!(
|
|
1386
|
-
"The HTML artifact you wrote in plain text was rejected: {}. \
|
|
1387
|
-
Rewrite it as one complete self-contained HTML artifact and call Artifact/publish_artifact. \
|
|
1388
|
-
Do not answer with markdown code.",
|
|
1605
|
+
return Err(format!(
|
|
1606
|
+
"artifact publishing failed after {artifact_error_count} recovery attempts; last rejection: {}",
|
|
1389
1607
|
out.content
|
|
1390
|
-
))
|
|
1391
|
-
continue;
|
|
1608
|
+
));
|
|
1392
1609
|
}
|
|
1393
|
-
msgs.push(Msg::Assistant {
|
|
1394
|
-
text: reply.text.clone(),
|
|
1395
|
-
calls: vec![],
|
|
1396
|
-
});
|
|
1397
1610
|
return Ok(out.content);
|
|
1398
1611
|
}
|
|
1399
1612
|
if static_artifact_requested(&task_for_recovery) {
|
|
@@ -1401,42 +1614,19 @@ fn build_inner(
|
|
|
1401
1614
|
text: reply.text.clone(),
|
|
1402
1615
|
calls: vec![],
|
|
1403
1616
|
});
|
|
1404
|
-
if static_artifact_recovery_count
|
|
1617
|
+
if static_artifact_recovery_count < 2 {
|
|
1405
1618
|
static_artifact_recovery_count += 1;
|
|
1406
1619
|
msgs.push(Msg::User(static_artifact_recovery_prompt(
|
|
1407
1620
|
&task_for_recovery,
|
|
1408
1621
|
)));
|
|
1409
1622
|
continue;
|
|
1410
1623
|
}
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
);
|
|
1418
|
-
trace_tool_call("Artifact", &fallback, "build", depth);
|
|
1419
|
-
let fallback_out = tools::run("Artifact", &fallback, cwd);
|
|
1420
|
-
report::tool_result("Artifact", &fallback_out.content, fallback_out.is_error);
|
|
1421
|
-
trace_tool_result(
|
|
1422
|
-
"Artifact",
|
|
1423
|
-
&fallback_out.content,
|
|
1424
|
-
fallback_out.is_error,
|
|
1425
|
-
"build",
|
|
1426
|
-
depth,
|
|
1427
|
-
);
|
|
1428
|
-
if !fallback_out.is_error {
|
|
1429
|
-
return Ok(format!(
|
|
1430
|
-
"{}\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.",
|
|
1431
|
-
fallback_out.content
|
|
1432
|
-
));
|
|
1433
|
-
}
|
|
1434
|
-
return Err(fallback_out.content);
|
|
1435
|
-
}
|
|
1436
|
-
return Ok(reply.text);
|
|
1437
|
-
}
|
|
1438
|
-
if reply.text.trim().is_empty() {
|
|
1439
|
-
report::notice("model returned no output");
|
|
1624
|
+
// No fabricated fallback artifact: report the failure honestly.
|
|
1625
|
+
return Err(format!(
|
|
1626
|
+
"the model did not produce a runnable web artifact after \
|
|
1627
|
+
{static_artifact_recovery_count} recovery prompts; its last response was:\n{}",
|
|
1628
|
+
reply.text
|
|
1629
|
+
));
|
|
1440
1630
|
}
|
|
1441
1631
|
msgs.push(Msg::Assistant {
|
|
1442
1632
|
text: reply.text.clone(),
|
|
@@ -1447,12 +1637,12 @@ fn build_inner(
|
|
|
1447
1637
|
|
|
1448
1638
|
let mut results = Vec::new();
|
|
1449
1639
|
let mut summary: Option<String> = None;
|
|
1640
|
+
let mut loop_nudge: Option<String> = None;
|
|
1641
|
+
let mut loop_stop: Option<String> = None;
|
|
1642
|
+
let mut artifact_recovery: Option<String> = None;
|
|
1450
1643
|
for call in &reply.calls {
|
|
1451
1644
|
if let Some(raw) = call.input.get(tools::INVALID_ARGS).and_then(|v| v.as_str()) {
|
|
1452
|
-
let msg =
|
|
1453
|
-
"tool arguments were not valid JSON: {}",
|
|
1454
|
-
raw.chars().take(200).collect::<String>()
|
|
1455
|
-
);
|
|
1645
|
+
let msg = invalid_args_feedback(&call.name, raw, &defs);
|
|
1456
1646
|
report::tool_denied(&msg);
|
|
1457
1647
|
note_loop_result(
|
|
1458
1648
|
&mut loop_guard,
|
|
@@ -1460,7 +1650,8 @@ fn build_inner(
|
|
|
1460
1650
|
&call.input,
|
|
1461
1651
|
&msg,
|
|
1462
1652
|
true,
|
|
1463
|
-
&mut
|
|
1653
|
+
&mut loop_nudge,
|
|
1654
|
+
&mut loop_stop,
|
|
1464
1655
|
);
|
|
1465
1656
|
results.push(ToolResult {
|
|
1466
1657
|
id: call.id.clone(),
|
|
@@ -1477,56 +1668,7 @@ fn build_inner(
|
|
|
1477
1668
|
depth,
|
|
1478
1669
|
&task_for_recovery,
|
|
1479
1670
|
);
|
|
1480
|
-
|
|
1481
|
-
if let Some(fallback) = auto_create_file_input(&task_for_recovery) {
|
|
1482
|
-
report::notice(
|
|
1483
|
-
" recovery: using write_file for explicit file creation instead of shell",
|
|
1484
|
-
);
|
|
1485
|
-
if let Some(reason) = gate(perm, "write_file", &fallback, cwd) {
|
|
1486
|
-
report::tool_denied(&reason);
|
|
1487
|
-
results.push(ToolResult {
|
|
1488
|
-
id: call.id.clone(),
|
|
1489
|
-
content: reason,
|
|
1490
|
-
is_error: true,
|
|
1491
|
-
});
|
|
1492
|
-
continue;
|
|
1493
|
-
}
|
|
1494
|
-
report::tool_call(
|
|
1495
|
-
"write_file",
|
|
1496
|
-
&tools::preview("write_file", &fallback),
|
|
1497
|
-
&fallback,
|
|
1498
|
-
);
|
|
1499
|
-
trace_tool_call("write_file", &fallback, "build", depth);
|
|
1500
|
-
let fallback_out = tools::run("write_file", &fallback, cwd);
|
|
1501
|
-
hooks::post_tool_use(
|
|
1502
|
-
"write_file",
|
|
1503
|
-
&fallback,
|
|
1504
|
-
&fallback_out.content,
|
|
1505
|
-
fallback_out.is_error,
|
|
1506
|
-
cwd,
|
|
1507
|
-
);
|
|
1508
|
-
report::tool_result("write_file", &fallback_out.content, fallback_out.is_error);
|
|
1509
|
-
trace_tool_result(
|
|
1510
|
-
"write_file",
|
|
1511
|
-
&fallback_out.content,
|
|
1512
|
-
fallback_out.is_error,
|
|
1513
|
-
"build",
|
|
1514
|
-
depth,
|
|
1515
|
-
);
|
|
1516
|
-
results.push(ToolResult {
|
|
1517
|
-
id: call.id.clone(),
|
|
1518
|
-
content: fallback_out.content.clone(),
|
|
1519
|
-
is_error: fallback_out.is_error,
|
|
1520
|
-
});
|
|
1521
|
-
if !fallback_out.is_error {
|
|
1522
|
-
summary = Some(format!(
|
|
1523
|
-
"{}\n\nCompleted the explicit file creation task with write_file.",
|
|
1524
|
-
fallback_out.content
|
|
1525
|
-
));
|
|
1526
|
-
}
|
|
1527
|
-
continue;
|
|
1528
|
-
}
|
|
1529
|
-
}
|
|
1671
|
+
any_tool_ran = true;
|
|
1530
1672
|
report::tool_call(
|
|
1531
1673
|
&call.name,
|
|
1532
1674
|
&tools::preview(&call.name, &call_input),
|
|
@@ -1544,7 +1686,8 @@ fn build_inner(
|
|
|
1544
1686
|
&call_input,
|
|
1545
1687
|
&answer,
|
|
1546
1688
|
is_error,
|
|
1547
|
-
&mut
|
|
1689
|
+
&mut loop_nudge,
|
|
1690
|
+
&mut loop_stop,
|
|
1548
1691
|
);
|
|
1549
1692
|
results.push(ToolResult {
|
|
1550
1693
|
id: call.id.clone(),
|
|
@@ -1572,7 +1715,8 @@ fn build_inner(
|
|
|
1572
1715
|
&call_input,
|
|
1573
1716
|
&reason,
|
|
1574
1717
|
true,
|
|
1575
|
-
&mut
|
|
1718
|
+
&mut loop_nudge,
|
|
1719
|
+
&mut loop_stop,
|
|
1576
1720
|
);
|
|
1577
1721
|
results.push(ToolResult {
|
|
1578
1722
|
id: call.id.clone(),
|
|
@@ -1601,7 +1745,8 @@ fn build_inner(
|
|
|
1601
1745
|
&call_input,
|
|
1602
1746
|
&msg,
|
|
1603
1747
|
true,
|
|
1604
|
-
&mut
|
|
1748
|
+
&mut loop_nudge,
|
|
1749
|
+
&mut loop_stop,
|
|
1605
1750
|
);
|
|
1606
1751
|
results.push(ToolResult {
|
|
1607
1752
|
id: call.id.clone(),
|
|
@@ -1613,13 +1758,15 @@ fn build_inner(
|
|
|
1613
1758
|
}
|
|
1614
1759
|
|
|
1615
1760
|
if matches!(call.name.as_str(), "task" | "spawn_subagent") {
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1761
|
+
// A subagent can mutate the workspace — count it as action.
|
|
1762
|
+
mutating_tool_ran = true;
|
|
1763
|
+
let (out, is_error) = spawn_subagent(p, perm, &call_input, cwd, depth);
|
|
1764
|
+
report::tool_result(&call.name, &out, is_error);
|
|
1765
|
+
trace_tool_result(&call.name, &out, is_error, "build", depth);
|
|
1619
1766
|
results.push(ToolResult {
|
|
1620
1767
|
id: call.id.clone(),
|
|
1621
1768
|
content: out,
|
|
1622
|
-
is_error
|
|
1769
|
+
is_error,
|
|
1623
1770
|
});
|
|
1624
1771
|
continue;
|
|
1625
1772
|
}
|
|
@@ -1639,6 +1786,9 @@ fn build_inner(
|
|
|
1639
1786
|
}
|
|
1640
1787
|
}
|
|
1641
1788
|
|
|
1789
|
+
if tools::is_mutating_call(&call.name, &call_input) {
|
|
1790
|
+
mutating_tool_ran = true;
|
|
1791
|
+
}
|
|
1642
1792
|
let out = tools::run(&call.name, &call_input, cwd);
|
|
1643
1793
|
hooks::post_tool_use(&call.name, &call_input, &out.content, out.is_error, cwd);
|
|
1644
1794
|
if out.is_error {
|
|
@@ -1646,62 +1796,44 @@ fn build_inner(
|
|
|
1646
1796
|
}
|
|
1647
1797
|
report::tool_result(&call.name, &out.content, out.is_error);
|
|
1648
1798
|
trace_tool_result(&call.name, &out.content, out.is_error, "build", depth);
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
trace_tool_result(
|
|
1663
|
-
"write_file",
|
|
1664
|
-
&fallback_out.content,
|
|
1665
|
-
fallback_out.is_error,
|
|
1666
|
-
"build",
|
|
1667
|
-
depth,
|
|
1668
|
-
);
|
|
1669
|
-
if !fallback_out.is_error {
|
|
1670
|
-
summary = Some(format!(
|
|
1671
|
-
"{}\n\nRecovered from a failed shell redirect by writing the requested file directly.",
|
|
1672
|
-
fallback_out.content
|
|
1673
|
-
));
|
|
1799
|
+
// Feed the verifier real data: every executed call, and the files
|
|
1800
|
+
// touched by successful write/edit tools.
|
|
1801
|
+
tool_records.push(crate::verifier::ToolCallRecord {
|
|
1802
|
+
tool_name: call.name.clone(),
|
|
1803
|
+
args_summary: tools::preview(&call.name, &call_input),
|
|
1804
|
+
result_preview: out.content.chars().take(200).collect(),
|
|
1805
|
+
timestamp: String::new(),
|
|
1806
|
+
});
|
|
1807
|
+
if !out.is_error {
|
|
1808
|
+
if let Some(pb) = tools::edit_tracking_path(&call.name, &call_input, cwd) {
|
|
1809
|
+
let path = pb.display().to_string();
|
|
1810
|
+
if !changed_files.contains(&path) {
|
|
1811
|
+
changed_files.push(path);
|
|
1674
1812
|
}
|
|
1675
1813
|
}
|
|
1676
1814
|
}
|
|
1677
1815
|
if matches!(call.name.as_str(), "Artifact" | "publish_artifact")
|
|
1678
1816
|
&& out.is_error
|
|
1679
|
-
&&
|
|
1817
|
+
&& (static_artifact_requested(&task_for_recovery)
|
|
1818
|
+
|| canvas_game_requested(&task_for_recovery))
|
|
1680
1819
|
{
|
|
1820
|
+
// No fabricated fallback artifact: tell the model exactly why
|
|
1821
|
+
// publishing was rejected and ask it to fix and re-publish;
|
|
1822
|
+
// fail honestly after bounded recovery attempts.
|
|
1681
1823
|
artifact_error_count += 1;
|
|
1682
|
-
if artifact_error_count
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
);
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
fallback_out.is_error,
|
|
1696
|
-
"build",
|
|
1697
|
-
depth,
|
|
1698
|
-
);
|
|
1699
|
-
if !fallback_out.is_error {
|
|
1700
|
-
summary = Some(format!(
|
|
1701
|
-
"{}\n\nThe model repeatedly produced incomplete canvas HTML, so buildwithnexus published a complete self-contained fallback canvas game.",
|
|
1702
|
-
fallback_out.content
|
|
1703
|
-
));
|
|
1704
|
-
}
|
|
1824
|
+
if artifact_error_count <= 2 {
|
|
1825
|
+
artifact_recovery = Some(format!(
|
|
1826
|
+
"Your artifact was rejected: {}. Fix that exact problem and call \
|
|
1827
|
+
Artifact/publish_artifact again with one complete self-contained HTML file \
|
|
1828
|
+
(all CSS/JS inline). Do not answer with markdown code.",
|
|
1829
|
+
out.content
|
|
1830
|
+
));
|
|
1831
|
+
} else {
|
|
1832
|
+
loop_stop = Some(format!(
|
|
1833
|
+
"artifact publishing failed after {artifact_error_count} attempts; \
|
|
1834
|
+
last rejection: {}",
|
|
1835
|
+
out.content
|
|
1836
|
+
));
|
|
1705
1837
|
}
|
|
1706
1838
|
}
|
|
1707
1839
|
if out.finished {
|
|
@@ -1714,7 +1846,8 @@ fn build_inner(
|
|
|
1714
1846
|
&call_input,
|
|
1715
1847
|
&out.content,
|
|
1716
1848
|
out.is_error,
|
|
1717
|
-
&mut
|
|
1849
|
+
&mut loop_nudge,
|
|
1850
|
+
&mut loop_stop,
|
|
1718
1851
|
);
|
|
1719
1852
|
}
|
|
1720
1853
|
results.push(ToolResult {
|
|
@@ -1729,6 +1862,11 @@ fn build_inner(
|
|
|
1729
1862
|
calls: reply.calls,
|
|
1730
1863
|
});
|
|
1731
1864
|
msgs.push(Msg::Tool(results));
|
|
1865
|
+
// Persist the transcript after every tool round so a crash or kill
|
|
1866
|
+
// doesn't lose the session (save is cheap and swallows I/O errors).
|
|
1867
|
+
if let Some(sid) = sid {
|
|
1868
|
+
crate::session::save(sid, cwd, &p.model, msgs);
|
|
1869
|
+
}
|
|
1732
1870
|
if !report::is_json() {
|
|
1733
1871
|
tui::context_meter(estimate_tokens(msgs), p.context_tokens);
|
|
1734
1872
|
tui::poll_typeahead();
|
|
@@ -1738,48 +1876,114 @@ fn build_inner(
|
|
|
1738
1876
|
let verifier = crate::verifier::Verifier::new(&cwd.to_string_lossy());
|
|
1739
1877
|
let ctx = crate::verifier::VerificationContext {
|
|
1740
1878
|
task_description: task.to_string(),
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
evidence_gathered: vec![],
|
|
1745
|
-
tests_added: vec![],
|
|
1746
|
-
dependencies_changed: vec![],
|
|
1747
|
-
git_diff: None,
|
|
1879
|
+
changed_files: changed_files.clone(),
|
|
1880
|
+
tool_calls: tool_records.clone(),
|
|
1881
|
+
..Default::default()
|
|
1748
1882
|
};
|
|
1749
1883
|
let rep = verifier.verify(&ctx);
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
crate::verifier::VerificationStatus::
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
&rep.rule_violations
|
|
1884
|
+
match rep.status {
|
|
1885
|
+
crate::verifier::VerificationStatus::Blocked
|
|
1886
|
+
| crate::verifier::VerificationStatus::Failed
|
|
1887
|
+
if verifier_fix_rounds < MAX_VERIFIER_FIX_ROUNDS =>
|
|
1888
|
+
{
|
|
1889
|
+
// Give the model a chance to address the violations
|
|
1890
|
+
// before finishing.
|
|
1891
|
+
verifier_fix_rounds += 1;
|
|
1892
|
+
let violations =
|
|
1893
|
+
crate::rules::RuleEngine::format_violations(&rep.rule_violations);
|
|
1894
|
+
report::notice(&format!(
|
|
1895
|
+
" [Verification: {} — asking the model to address violations]",
|
|
1896
|
+
rep.status
|
|
1760
1897
|
));
|
|
1898
|
+
report::notice(&violations);
|
|
1899
|
+
msgs.push(Msg::User(format!(
|
|
1900
|
+
"Verification of your finished work returned `{}` with these rule \
|
|
1901
|
+
violations:\n{violations}\nAddress them, then call finish again.",
|
|
1902
|
+
rep.status
|
|
1903
|
+
)));
|
|
1904
|
+
continue;
|
|
1905
|
+
}
|
|
1906
|
+
crate::verifier::VerificationStatus::Blocked
|
|
1907
|
+
| crate::verifier::VerificationStatus::Failed => {
|
|
1908
|
+
// Fix rounds exhausted: finish anyway with an honest note.
|
|
1909
|
+
let violations =
|
|
1910
|
+
crate::rules::RuleEngine::format_violations(&rep.rule_violations);
|
|
1911
|
+
report::notice(&format!(" [Verification: {}]", rep.status));
|
|
1912
|
+
return Ok(format!(
|
|
1913
|
+
"{s}\n\n[verification note] The verifier still reports `{}` after \
|
|
1914
|
+
{verifier_fix_rounds} fix attempts:\n{violations}",
|
|
1915
|
+
rep.status
|
|
1916
|
+
));
|
|
1917
|
+
}
|
|
1918
|
+
crate::verifier::VerificationStatus::PassedWithWarnings => {
|
|
1919
|
+
report::notice(&format!(" [Verification: {}]", rep.status));
|
|
1920
|
+
if !rep.rule_violations.is_empty() {
|
|
1921
|
+
report::notice(&crate::rules::RuleEngine::format_violations(
|
|
1922
|
+
&rep.rule_violations,
|
|
1923
|
+
));
|
|
1924
|
+
}
|
|
1761
1925
|
}
|
|
1926
|
+
crate::verifier::VerificationStatus::Passed => {}
|
|
1762
1927
|
}
|
|
1763
1928
|
}
|
|
1764
1929
|
return Ok(s);
|
|
1765
1930
|
}
|
|
1931
|
+
if let Some(stop_msg) = loop_stop {
|
|
1932
|
+
// A stopped loop (or exhausted artifact recovery) is not success —
|
|
1933
|
+
// surface it as a failure with the honest summary.
|
|
1934
|
+
return Err(stop_msg);
|
|
1935
|
+
}
|
|
1936
|
+
if let Some(recovery) = artifact_recovery {
|
|
1937
|
+
msgs.push(Msg::User(recovery));
|
|
1938
|
+
} else if let Some(nudge) = loop_nudge {
|
|
1939
|
+
msgs.push(Msg::User(nudge));
|
|
1940
|
+
}
|
|
1766
1941
|
}
|
|
1767
1942
|
Err(format!(
|
|
1768
1943
|
"reached the {MAX_ITERS}-step limit without finishing"
|
|
1769
1944
|
))
|
|
1770
1945
|
}
|
|
1771
1946
|
|
|
1947
|
+
// Feedback for a tool call whose arguments failed to parse: name the tool,
|
|
1948
|
+
// show the parse error and the schema's required params, and demand a re-send.
|
|
1949
|
+
fn invalid_args_feedback(name: &str, raw: &str, defs: &[tools::ToolDef]) -> String {
|
|
1950
|
+
let parse_err = match serde_json::from_str::<serde_json::Value>(raw) {
|
|
1951
|
+
Err(e) => e.to_string(),
|
|
1952
|
+
Ok(_) => "arguments did not match the expected object shape".to_string(),
|
|
1953
|
+
};
|
|
1954
|
+
let required = defs
|
|
1955
|
+
.iter()
|
|
1956
|
+
.find(|d| d.name == name)
|
|
1957
|
+
.and_then(|d| d.schema.get("required"))
|
|
1958
|
+
.and_then(|r| r.as_array())
|
|
1959
|
+
.map(|a| {
|
|
1960
|
+
a.iter()
|
|
1961
|
+
.filter_map(|v| v.as_str())
|
|
1962
|
+
.collect::<Vec<_>>()
|
|
1963
|
+
.join(", ")
|
|
1964
|
+
})
|
|
1965
|
+
.filter(|s| !s.is_empty())
|
|
1966
|
+
.unwrap_or_else(|| "(none)".to_string());
|
|
1967
|
+
format!(
|
|
1968
|
+
"Tool `{name}` arguments were not valid JSON ({parse_err}): {}. \
|
|
1969
|
+
Required params: {required}. Re-send the complete corrected call.",
|
|
1970
|
+
raw.chars().take(200).collect::<String>()
|
|
1971
|
+
)
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1772
1974
|
static SUB_SEQ: AtomicUsize = AtomicUsize::new(0);
|
|
1773
1975
|
|
|
1976
|
+
// Returns the subagent's result and whether it failed — failures must reach
|
|
1977
|
+
// the parent model as is_error so it can react instead of trusting bad output.
|
|
1774
1978
|
fn spawn_subagent(
|
|
1775
1979
|
p: &Provider,
|
|
1776
1980
|
perm: Permission,
|
|
1777
1981
|
input: &serde_json::Value,
|
|
1778
1982
|
cwd: &Path,
|
|
1779
1983
|
depth: usize,
|
|
1780
|
-
) -> String {
|
|
1984
|
+
) -> (String, bool) {
|
|
1781
1985
|
if depth + 1 >= MAX_DEPTH {
|
|
1782
|
-
return "subagent depth limit reached".into();
|
|
1986
|
+
return ("subagent depth limit reached".into(), true);
|
|
1783
1987
|
}
|
|
1784
1988
|
let task = input["task"]
|
|
1785
1989
|
.as_str()
|
|
@@ -1787,7 +1991,7 @@ fn spawn_subagent(
|
|
|
1787
1991
|
.unwrap_or("")
|
|
1788
1992
|
.trim();
|
|
1789
1993
|
if task.is_empty() {
|
|
1790
|
-
return "spawn_subagent requires a task".into();
|
|
1994
|
+
return ("spawn_subagent requires a task".into(), true);
|
|
1791
1995
|
}
|
|
1792
1996
|
let role = input["role"].as_str().unwrap_or("engineer");
|
|
1793
1997
|
let isolate = input["isolate"].as_bool().unwrap_or(false);
|
|
@@ -1820,8 +2024,11 @@ fn spawn_subagent(
|
|
|
1820
2024
|
}),
|
|
1821
2025
|
);
|
|
1822
2026
|
let mut child: Vec<Msg> = Vec::new();
|
|
1823
|
-
let result
|
|
1824
|
-
|
|
2027
|
+
let (result, is_error) =
|
|
2028
|
+
match build_inner(p, perm, role, task, &run_cwd, depth + 1, &mut child, None) {
|
|
2029
|
+
Ok(r) => (r, false),
|
|
2030
|
+
Err(e) => (format!("subagent error: {e}"), true),
|
|
2031
|
+
};
|
|
1825
2032
|
trace::record_visible(
|
|
1826
2033
|
"subagent_done",
|
|
1827
2034
|
format!("{role}: {}", trace::preview(task, 80)),
|
|
@@ -1831,10 +2038,11 @@ fn spawn_subagent(
|
|
|
1831
2038
|
"isolate": isolate,
|
|
1832
2039
|
"cwd": run_cwd.to_string_lossy(),
|
|
1833
2040
|
"result": result,
|
|
2041
|
+
"is_error": is_error,
|
|
1834
2042
|
"depth": depth + 1,
|
|
1835
2043
|
}),
|
|
1836
2044
|
);
|
|
1837
|
-
format!("{note}{result}")
|
|
2045
|
+
(format!("{note}{result}"), is_error)
|
|
1838
2046
|
}
|
|
1839
2047
|
|
|
1840
2048
|
fn make_worktree(cwd: &Path) -> Option<PathBuf> {
|
|
@@ -2072,7 +2280,31 @@ fn recovery_task_text(task: &str) -> String {
|
|
|
2072
2280
|
.to_string()
|
|
2073
2281
|
}
|
|
2074
2282
|
|
|
2283
|
+
// A build/create verb must co-occur with the artifact noun so that "fix the
|
|
2284
|
+
// collision bug in my game" or "explain how this website works" don't trigger
|
|
2285
|
+
// artifact-recovery flows.
|
|
2286
|
+
fn task_has_build_verb(task: &str) -> bool {
|
|
2287
|
+
let lower = task.to_lowercase();
|
|
2288
|
+
lower.split(|c: char| !c.is_ascii_alphabetic()).any(|word| {
|
|
2289
|
+
matches!(
|
|
2290
|
+
word,
|
|
2291
|
+
"build"
|
|
2292
|
+
| "create"
|
|
2293
|
+
| "make"
|
|
2294
|
+
| "write"
|
|
2295
|
+
| "generate"
|
|
2296
|
+
| "develop"
|
|
2297
|
+
| "implement"
|
|
2298
|
+
| "code"
|
|
2299
|
+
| "publish"
|
|
2300
|
+
)
|
|
2301
|
+
})
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2075
2304
|
fn static_artifact_requested(task: &str) -> bool {
|
|
2305
|
+
if !task_has_build_verb(task) {
|
|
2306
|
+
return false;
|
|
2307
|
+
}
|
|
2076
2308
|
let lower = task.to_lowercase();
|
|
2077
2309
|
[
|
|
2078
2310
|
"canvas game",
|
|
@@ -2151,79 +2383,102 @@ fn extract_html_title(html: &str) -> Option<String> {
|
|
|
2151
2383
|
}
|
|
2152
2384
|
|
|
2153
2385
|
fn canvas_game_requested(task: &str) -> bool {
|
|
2386
|
+
if !task_has_build_verb(task) {
|
|
2387
|
+
return false;
|
|
2388
|
+
}
|
|
2154
2389
|
let lower = task.to_lowercase();
|
|
2155
2390
|
lower.contains("canvas game") || lower.contains("browser game") || lower.contains("game")
|
|
2156
2391
|
}
|
|
2157
2392
|
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
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
|
-
|
|
2393
|
+
// An imperative task tells the agent to do work, not answer a question —
|
|
2394
|
+
// prose-only replies to these get the act-don't-explain nudge. Reuses the
|
|
2395
|
+
// build-verb classifier plus change-verbs like add/fix; question-style tasks
|
|
2396
|
+
// ("how do I build…?") want an answer and are excluded.
|
|
2397
|
+
fn task_is_imperative(task: &str) -> bool {
|
|
2398
|
+
let lower = task.trim().to_lowercase();
|
|
2399
|
+
if lower.ends_with('?')
|
|
2400
|
+
|| [
|
|
2401
|
+
"how ", "what ", "why ", "when ", "where ", "who ", "which ", "should ", "could ",
|
|
2402
|
+
"can ", "is ", "are ", "does ", "do ", "explain ",
|
|
2403
|
+
]
|
|
2404
|
+
.iter()
|
|
2405
|
+
.any(|q| lower.starts_with(q))
|
|
2406
|
+
{
|
|
2407
|
+
return false;
|
|
2408
|
+
}
|
|
2409
|
+
task_has_build_verb(task)
|
|
2410
|
+
|| lower.split(|c: char| !c.is_ascii_alphabetic()).any(|word| {
|
|
2411
|
+
matches!(
|
|
2412
|
+
word,
|
|
2413
|
+
"add"
|
|
2414
|
+
| "fix"
|
|
2415
|
+
| "refactor"
|
|
2416
|
+
| "update"
|
|
2417
|
+
| "install"
|
|
2418
|
+
| "convert"
|
|
2419
|
+
| "remove"
|
|
2420
|
+
| "rename"
|
|
2421
|
+
| "delete"
|
|
2422
|
+
| "change"
|
|
2423
|
+
| "setup"
|
|
2424
|
+
)
|
|
2425
|
+
})
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
// Fix 14 decision: nudge the model to act instead of explaining. Fires only
|
|
2429
|
+
// for imperative tasks, only before any mutating tool has run this session,
|
|
2430
|
+
// and only while the per-task cap has not been reached. A prose reply with no
|
|
2431
|
+
// tool activity at all, or one that reads as how-to instructions, is treated
|
|
2432
|
+
// as explaining rather than doing.
|
|
2433
|
+
fn should_nudge_to_act(
|
|
2434
|
+
task: &str,
|
|
2435
|
+
reply_text: &str,
|
|
2436
|
+
any_tool_ran: bool,
|
|
2437
|
+
mutating_tool_ran: bool,
|
|
2438
|
+
nudges_so_far: usize,
|
|
2439
|
+
) -> bool {
|
|
2440
|
+
if nudges_so_far >= MAX_ACT_NUDGES || mutating_tool_ran || !task_is_imperative(task) {
|
|
2441
|
+
return false;
|
|
2442
|
+
}
|
|
2443
|
+
!any_tool_ran || reads_as_instructions(reply_text)
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
// True when a reply reads as instructions/explanation for the USER to follow
|
|
2447
|
+
// ("here's how", "you should…") rather than a summary of completed work.
|
|
2448
|
+
// Kept conservative: callers must also check that no mutating tool ran.
|
|
2449
|
+
fn reads_as_instructions(text: &str) -> bool {
|
|
2450
|
+
let lower = text.to_lowercase();
|
|
2451
|
+
[
|
|
2452
|
+
"you can",
|
|
2453
|
+
"you should",
|
|
2454
|
+
"you could",
|
|
2455
|
+
"you'll need to",
|
|
2456
|
+
"you will need to",
|
|
2457
|
+
"here's how",
|
|
2458
|
+
"here is how",
|
|
2459
|
+
"steps:",
|
|
2460
|
+
"step 1",
|
|
2461
|
+
"first,",
|
|
2462
|
+
"follow these steps",
|
|
2463
|
+
"to do this",
|
|
2464
|
+
]
|
|
2465
|
+
.iter()
|
|
2466
|
+
.any(|marker| lower.contains(marker))
|
|
2213
2467
|
}
|
|
2214
2468
|
|
|
2215
2469
|
// ── PLAN mode ─────────────────────────────────────────────────────────────────
|
|
2216
2470
|
// The planning phase now has tools available so the model can inspect the
|
|
2217
2471
|
// codebase while breaking down the task. Execution still runs through BUILD.
|
|
2218
2472
|
pub fn run_plan(p: &Provider, perm: Permission, task: &str, cwd: &Path) -> Result<(), String> {
|
|
2473
|
+
// Role identity + mode contract come first; environment sections follow.
|
|
2219
2474
|
let prefix = context_prefix(cwd, p.context_tokens);
|
|
2220
2475
|
let sys = format!(
|
|
2221
|
-
"
|
|
2476
|
+
"You are a planning engineer with full access to the codebase. \
|
|
2222
2477
|
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. \
|
|
2223
2478
|
Do not write files, edit files, apply patches, spawn subagents, or run mutating shell commands while planning. \
|
|
2224
2479
|
When ready, call exit_plan or ExitPlanMode with a concise numbered implementation plan. \
|
|
2225
2480
|
The plan must be concrete, actionable, and at most 8 steps. \
|
|
2226
|
-
Do not include code fences, shell snippets, intro text, or outro text
|
|
2481
|
+
Do not include code fences, shell snippets, intro text, or outro text.\n\n{prefix}"
|
|
2227
2482
|
);
|
|
2228
2483
|
|
|
2229
2484
|
let defs = tools::defs_readonly(); // planning inspects context but never writes
|
|
@@ -2288,12 +2543,10 @@ pub fn run_plan(p: &Provider, perm: Permission, task: &str, cwd: &Path) -> Resul
|
|
|
2288
2543
|
// Execute tool calls during the planning phase.
|
|
2289
2544
|
let mut results = Vec::new();
|
|
2290
2545
|
let mut loop_summary: Option<String> = None;
|
|
2546
|
+
let mut loop_nudge: Option<String> = None;
|
|
2291
2547
|
for call in &reply.calls {
|
|
2292
2548
|
if let Some(raw) = call.input.get(tools::INVALID_ARGS).and_then(|v| v.as_str()) {
|
|
2293
|
-
let msg =
|
|
2294
|
-
"tool arguments were not valid JSON: {}",
|
|
2295
|
-
raw.chars().take(200).collect::<String>()
|
|
2296
|
-
);
|
|
2549
|
+
let msg = invalid_args_feedback(&call.name, raw, &defs);
|
|
2297
2550
|
report::tool_denied(&msg);
|
|
2298
2551
|
note_loop_result(
|
|
2299
2552
|
&mut loop_guard,
|
|
@@ -2301,6 +2554,7 @@ pub fn run_plan(p: &Provider, perm: Permission, task: &str, cwd: &Path) -> Resul
|
|
|
2301
2554
|
&call.input,
|
|
2302
2555
|
&msg,
|
|
2303
2556
|
true,
|
|
2557
|
+
&mut loop_nudge,
|
|
2304
2558
|
&mut loop_summary,
|
|
2305
2559
|
);
|
|
2306
2560
|
results.push(ToolResult {
|
|
@@ -2372,6 +2626,7 @@ pub fn run_plan(p: &Provider, perm: Permission, task: &str, cwd: &Path) -> Resul
|
|
|
2372
2626
|
&call_input,
|
|
2373
2627
|
&answer,
|
|
2374
2628
|
is_error,
|
|
2629
|
+
&mut loop_nudge,
|
|
2375
2630
|
&mut loop_summary,
|
|
2376
2631
|
);
|
|
2377
2632
|
results.push(ToolResult {
|
|
@@ -2398,6 +2653,7 @@ pub fn run_plan(p: &Provider, perm: Permission, task: &str, cwd: &Path) -> Resul
|
|
|
2398
2653
|
&call_input,
|
|
2399
2654
|
&reason,
|
|
2400
2655
|
true,
|
|
2656
|
+
&mut loop_nudge,
|
|
2401
2657
|
&mut loop_summary,
|
|
2402
2658
|
);
|
|
2403
2659
|
results.push(ToolResult {
|
|
@@ -2430,6 +2686,7 @@ pub fn run_plan(p: &Provider, perm: Permission, task: &str, cwd: &Path) -> Resul
|
|
|
2430
2686
|
&call_input,
|
|
2431
2687
|
&out.content,
|
|
2432
2688
|
out.is_error,
|
|
2689
|
+
&mut loop_nudge,
|
|
2433
2690
|
&mut loop_summary,
|
|
2434
2691
|
);
|
|
2435
2692
|
results.push(ToolResult {
|
|
@@ -2455,6 +2712,9 @@ pub fn run_plan(p: &Provider, perm: Permission, task: &str, cwd: &Path) -> Resul
|
|
|
2455
2712
|
}
|
|
2456
2713
|
return Err(loop_msg);
|
|
2457
2714
|
}
|
|
2715
|
+
if let Some(nudge) = loop_nudge {
|
|
2716
|
+
msgs.push(Msg::User(nudge));
|
|
2717
|
+
}
|
|
2458
2718
|
};
|
|
2459
2719
|
|
|
2460
2720
|
let mut steps = parse_plan_steps(&plan_text);
|
|
@@ -2534,13 +2794,14 @@ pub fn run_brainstorm(
|
|
|
2534
2794
|
cwd: &Path,
|
|
2535
2795
|
first: &str,
|
|
2536
2796
|
) -> Result<Option<ModeHint>, String> {
|
|
2797
|
+
// Role identity + mode contract come first; environment sections follow.
|
|
2537
2798
|
let prefix = context_prefix(cwd, p.context_tokens);
|
|
2538
|
-
let sys = format!("
|
|
2799
|
+
let sys = format!("You are a sharp, concise thought partner with full access to the codebase and the internet. \
|
|
2539
2800
|
Use tools freely to look things up, read files, grep for patterns, or run commands — \
|
|
2540
2801
|
whatever helps the conversation. \
|
|
2541
2802
|
When you think the user is ready to stop discussing and start building or planning, \
|
|
2542
2803
|
end your response with the exact token [SUGGEST:BUILD] or [SUGGEST:PLAN] on its own line. \
|
|
2543
|
-
Otherwise just respond naturally. No fluff
|
|
2804
|
+
Otherwise just respond naturally. No fluff.\n\n{prefix}");
|
|
2544
2805
|
|
|
2545
2806
|
let defs = tools::defs_for_context(false, p.context_tokens);
|
|
2546
2807
|
let mut msgs: Vec<Msg> = vec![Msg::System(sys)];
|
|
@@ -2584,12 +2845,10 @@ pub fn run_brainstorm(
|
|
|
2584
2845
|
// Execute tool calls inline.
|
|
2585
2846
|
let mut results = Vec::new();
|
|
2586
2847
|
let mut loop_summary: Option<String> = None;
|
|
2848
|
+
let mut loop_nudge: Option<String> = None;
|
|
2587
2849
|
for call in &reply.calls {
|
|
2588
2850
|
if let Some(raw) = call.input.get(tools::INVALID_ARGS).and_then(|v| v.as_str()) {
|
|
2589
|
-
let msg =
|
|
2590
|
-
"tool arguments were not valid JSON: {}",
|
|
2591
|
-
raw.chars().take(200).collect::<String>()
|
|
2592
|
-
);
|
|
2851
|
+
let msg = invalid_args_feedback(&call.name, raw, &defs);
|
|
2593
2852
|
report::tool_denied(&msg);
|
|
2594
2853
|
note_loop_result(
|
|
2595
2854
|
&mut loop_guard,
|
|
@@ -2597,6 +2856,7 @@ pub fn run_brainstorm(
|
|
|
2597
2856
|
&call.input,
|
|
2598
2857
|
&msg,
|
|
2599
2858
|
true,
|
|
2859
|
+
&mut loop_nudge,
|
|
2600
2860
|
&mut loop_summary,
|
|
2601
2861
|
);
|
|
2602
2862
|
results.push(ToolResult {
|
|
@@ -2630,6 +2890,7 @@ pub fn run_brainstorm(
|
|
|
2630
2890
|
&call_input,
|
|
2631
2891
|
&answer,
|
|
2632
2892
|
is_error,
|
|
2893
|
+
&mut loop_nudge,
|
|
2633
2894
|
&mut loop_summary,
|
|
2634
2895
|
);
|
|
2635
2896
|
results.push(ToolResult {
|
|
@@ -2656,6 +2917,7 @@ pub fn run_brainstorm(
|
|
|
2656
2917
|
&call_input,
|
|
2657
2918
|
&reason,
|
|
2658
2919
|
true,
|
|
2920
|
+
&mut loop_nudge,
|
|
2659
2921
|
&mut loop_summary,
|
|
2660
2922
|
);
|
|
2661
2923
|
results.push(ToolResult {
|
|
@@ -2689,6 +2951,7 @@ pub fn run_brainstorm(
|
|
|
2689
2951
|
&call_input,
|
|
2690
2952
|
&out.content,
|
|
2691
2953
|
out.is_error,
|
|
2954
|
+
&mut loop_nudge,
|
|
2692
2955
|
&mut loop_summary,
|
|
2693
2956
|
);
|
|
2694
2957
|
results.push(ToolResult {
|
|
@@ -2709,6 +2972,9 @@ pub fn run_brainstorm(
|
|
|
2709
2972
|
if let Some(loop_msg) = loop_summary {
|
|
2710
2973
|
break loop_msg;
|
|
2711
2974
|
}
|
|
2975
|
+
if let Some(nudge) = loop_nudge {
|
|
2976
|
+
msgs.push(Msg::User(nudge));
|
|
2977
|
+
}
|
|
2712
2978
|
};
|
|
2713
2979
|
|
|
2714
2980
|
// Check for mode-transition suggestion embedded in the reply.
|
|
@@ -2756,12 +3022,13 @@ pub fn run_chat_turn(
|
|
|
2756
3022
|
cwd: &Path,
|
|
2757
3023
|
question: &str,
|
|
2758
3024
|
) -> Result<(), String> {
|
|
3025
|
+
// Role identity + mode contract come first; environment sections follow.
|
|
2759
3026
|
let prefix = context_prefix(cwd, p.context_tokens);
|
|
2760
3027
|
let sys = format!(
|
|
2761
|
-
"
|
|
3028
|
+
"You are buildwithnexus in a coding terminal. Answer the user's current message naturally and concisely. \
|
|
2762
3029
|
If the user asks a normal conversational question or greeting, answer in plain text and do not call tools. \
|
|
2763
3030
|
If answering well requires inspecting the workspace or environment, use tools, then summarize the result. \
|
|
2764
|
-
Do not emit JSON unless a tool call is actually required by the tool protocol
|
|
3031
|
+
Do not emit JSON unless a tool call is actually required by the tool protocol.\n\n{prefix}"
|
|
2765
3032
|
);
|
|
2766
3033
|
|
|
2767
3034
|
let defs = tools::defs_for_context(false, p.context_tokens);
|
|
@@ -2788,12 +3055,10 @@ pub fn run_chat_turn(
|
|
|
2788
3055
|
|
|
2789
3056
|
let mut results = Vec::new();
|
|
2790
3057
|
let mut loop_summary: Option<String> = None;
|
|
3058
|
+
let mut loop_nudge: Option<String> = None;
|
|
2791
3059
|
for call in &reply.calls {
|
|
2792
3060
|
if let Some(raw) = call.input.get(tools::INVALID_ARGS).and_then(|v| v.as_str()) {
|
|
2793
|
-
let msg =
|
|
2794
|
-
"tool arguments were not valid JSON: {}",
|
|
2795
|
-
raw.chars().take(200).collect::<String>()
|
|
2796
|
-
);
|
|
3061
|
+
let msg = invalid_args_feedback(&call.name, raw, &defs);
|
|
2797
3062
|
report::tool_denied(&msg);
|
|
2798
3063
|
note_loop_result(
|
|
2799
3064
|
&mut loop_guard,
|
|
@@ -2801,6 +3066,7 @@ pub fn run_chat_turn(
|
|
|
2801
3066
|
&call.input,
|
|
2802
3067
|
&msg,
|
|
2803
3068
|
true,
|
|
3069
|
+
&mut loop_nudge,
|
|
2804
3070
|
&mut loop_summary,
|
|
2805
3071
|
);
|
|
2806
3072
|
results.push(ToolResult {
|
|
@@ -2844,6 +3110,7 @@ pub fn run_chat_turn(
|
|
|
2844
3110
|
&call_input,
|
|
2845
3111
|
&reason,
|
|
2846
3112
|
true,
|
|
3113
|
+
&mut loop_nudge,
|
|
2847
3114
|
&mut loop_summary,
|
|
2848
3115
|
);
|
|
2849
3116
|
results.push(ToolResult {
|
|
@@ -2864,6 +3131,7 @@ pub fn run_chat_turn(
|
|
|
2864
3131
|
&call_input,
|
|
2865
3132
|
&out.content,
|
|
2866
3133
|
out.is_error,
|
|
3134
|
+
&mut loop_nudge,
|
|
2867
3135
|
&mut loop_summary,
|
|
2868
3136
|
);
|
|
2869
3137
|
results.push(ToolResult {
|
|
@@ -2886,6 +3154,9 @@ pub fn run_chat_turn(
|
|
|
2886
3154
|
report::assistant(&loop_msg);
|
|
2887
3155
|
return Ok(());
|
|
2888
3156
|
}
|
|
3157
|
+
if let Some(nudge) = loop_nudge {
|
|
3158
|
+
msgs.push(Msg::User(nudge));
|
|
3159
|
+
}
|
|
2889
3160
|
}
|
|
2890
3161
|
|
|
2891
3162
|
report::assistant(&format!(
|
|
@@ -2947,7 +3218,7 @@ mod tests {
|
|
|
2947
3218
|
let defs = tools::defs_for_context(true, 128_000);
|
|
2948
3219
|
let reply = Reply {
|
|
2949
3220
|
text: "```json\n{\"name\":\"start_server\",\"arguments\":{\"command\":\"npm start\",\"cwd\":\"/tmp/app\",\"port\":3000}}\n```".to_string(),
|
|
2950
|
-
|
|
3221
|
+
..Default::default()
|
|
2951
3222
|
};
|
|
2952
3223
|
let normalized = normalize_text_tool_calls(reply, &defs, "build the app");
|
|
2953
3224
|
assert_eq!(normalized.text, "");
|
|
@@ -2961,7 +3232,7 @@ mod tests {
|
|
|
2961
3232
|
let defs = tools::defs_for_context(true, 128_000);
|
|
2962
3233
|
let reply = Reply {
|
|
2963
3234
|
text: "```json\n{\"name\":\"start_server\",\"arguments\":{\"command\":\"npm start\",\"port\":3000}}\n```".to_string(),
|
|
2964
|
-
|
|
3235
|
+
..Default::default()
|
|
2965
3236
|
};
|
|
2966
3237
|
let normalized = normalize_text_tool_calls(reply, &defs, "hello");
|
|
2967
3238
|
assert!(normalized.calls.is_empty());
|
|
@@ -3089,7 +3360,9 @@ mod tests {
|
|
|
3089
3360
|
}
|
|
3090
3361
|
|
|
3091
3362
|
#[test]
|
|
3092
|
-
fn
|
|
3363
|
+
fn write_file_placeholder_path_is_repaired_but_content_is_kept() {
|
|
3364
|
+
// A broken destination path is repaired from the task, but the model's
|
|
3365
|
+
// non-empty content is preserved — never replaced with the task text.
|
|
3093
3366
|
let repaired = repair_placeholder_tool_input(
|
|
3094
3367
|
"write_file",
|
|
3095
3368
|
&json!({"path": "~", "content": "exit plan smoke\n- Create scratch/exitplan-smoke.txt"}),
|
|
@@ -3098,20 +3371,46 @@ mod tests {
|
|
|
3098
3371
|
)
|
|
3099
3372
|
.unwrap();
|
|
3100
3373
|
assert_eq!(repaired["path"], "scratch/exitplan-smoke.txt");
|
|
3101
|
-
assert_eq!(
|
|
3374
|
+
assert_eq!(
|
|
3375
|
+
repaired["content"],
|
|
3376
|
+
"exit plan smoke\n- Create scratch/exitplan-smoke.txt"
|
|
3377
|
+
);
|
|
3102
3378
|
}
|
|
3103
3379
|
|
|
3104
3380
|
#[test]
|
|
3105
|
-
fn
|
|
3106
|
-
|
|
3381
|
+
fn write_file_nonempty_content_is_never_overridden() {
|
|
3382
|
+
// Path matches the task but content differs: real model output must
|
|
3383
|
+
// pass through untouched (no repair at all).
|
|
3384
|
+
assert!(repair_placeholder_tool_input(
|
|
3107
3385
|
"write",
|
|
3108
3386
|
&json!({"path": "scratch/exitplan-smoke.txt", "content": "exit plan smoke\n\n1. Create scratch/exitplan-smoke.txt"}),
|
|
3109
3387
|
Path::new("/tmp/example-project"),
|
|
3110
3388
|
"create scratch/exitplan-smoke.txt containing exit plan smoke",
|
|
3111
3389
|
)
|
|
3390
|
+
.is_none());
|
|
3391
|
+
}
|
|
3392
|
+
|
|
3393
|
+
#[test]
|
|
3394
|
+
fn write_file_empty_or_placeholder_content_is_repaired() {
|
|
3395
|
+
let task = "create scratch/exitplan-smoke.txt containing exit plan smoke";
|
|
3396
|
+
let cwd = Path::new("/tmp/example-project");
|
|
3397
|
+
let empty = repair_placeholder_tool_input(
|
|
3398
|
+
"write_file",
|
|
3399
|
+
&json!({"path": "scratch/exitplan-smoke.txt", "content": ""}),
|
|
3400
|
+
cwd,
|
|
3401
|
+
task,
|
|
3402
|
+
)
|
|
3112
3403
|
.unwrap();
|
|
3113
|
-
assert_eq!(
|
|
3114
|
-
|
|
3404
|
+
assert_eq!(empty["content"], "exit plan smoke");
|
|
3405
|
+
let todo = repair_placeholder_tool_input(
|
|
3406
|
+
"write_file",
|
|
3407
|
+
&json!({"path": "scratch/exitplan-smoke.txt", "content": "TODO"}),
|
|
3408
|
+
cwd,
|
|
3409
|
+
task,
|
|
3410
|
+
)
|
|
3411
|
+
.unwrap();
|
|
3412
|
+
assert_eq!(todo["content"], "exit plan smoke");
|
|
3413
|
+
assert_eq!(todo["path"], "scratch/exitplan-smoke.txt");
|
|
3115
3414
|
}
|
|
3116
3415
|
|
|
3117
3416
|
#[test]
|
|
@@ -3289,13 +3588,8 @@ mod tests {
|
|
|
3289
3588
|
#[test]
|
|
3290
3589
|
fn gate_ask_allows_default_allowed_commands() {
|
|
3291
3590
|
let cwd = Path::new("/proj");
|
|
3292
|
-
//
|
|
3293
|
-
for cmd in &[
|
|
3294
|
-
"git status",
|
|
3295
|
-
"cargo test",
|
|
3296
|
-
"npm install",
|
|
3297
|
-
"git commit -m 'x'",
|
|
3298
|
-
] {
|
|
3591
|
+
// Only read-only binaries remain in the default allowed_commands list.
|
|
3592
|
+
for cmd in &["ls -la", "cat foo.txt", "grep -r pattern ."] {
|
|
3299
3593
|
let r = gate(
|
|
3300
3594
|
Permission::Ask,
|
|
3301
3595
|
"run_command",
|
|
@@ -3304,6 +3598,19 @@ mod tests {
|
|
|
3304
3598
|
);
|
|
3305
3599
|
assert!(r.is_none(), "expected {cmd} to be auto-allowed in Ask mode");
|
|
3306
3600
|
}
|
|
3601
|
+
// Mutating binaries now require confirmation (denied in a non-terminal).
|
|
3602
|
+
for cmd in &["npm install", "git commit -m 'x'"] {
|
|
3603
|
+
let r = gate(
|
|
3604
|
+
Permission::Ask,
|
|
3605
|
+
"run_command",
|
|
3606
|
+
&json!({"command": cmd}),
|
|
3607
|
+
cwd,
|
|
3608
|
+
);
|
|
3609
|
+
assert!(
|
|
3610
|
+
r.is_some(),
|
|
3611
|
+
"expected {cmd} to require confirmation in Ask mode"
|
|
3612
|
+
);
|
|
3613
|
+
}
|
|
3307
3614
|
}
|
|
3308
3615
|
|
|
3309
3616
|
#[test]
|
|
@@ -3373,4 +3680,260 @@ mod tests {
|
|
|
3373
3680
|
super::add_session_allowed_tool("custom_test_tool");
|
|
3374
3681
|
assert!(super::is_session_allowed_tool("custom_test_tool"));
|
|
3375
3682
|
}
|
|
3683
|
+
|
|
3684
|
+
#[test]
|
|
3685
|
+
fn truncate_at_char_boundary_never_splits_multibyte() {
|
|
3686
|
+
let s = "ab🚀cd"; // the emoji occupies bytes 2..6
|
|
3687
|
+
assert_eq!(truncate_at_char_boundary(s, 3), "ab");
|
|
3688
|
+
assert_eq!(truncate_at_char_boundary(s, 6), "ab🚀");
|
|
3689
|
+
assert_eq!(truncate_at_char_boundary(s, 100), s);
|
|
3690
|
+
let cjk = "日本語テキスト"; // 3 bytes per char
|
|
3691
|
+
let t = truncate_at_char_boundary(cjk, 7);
|
|
3692
|
+
assert_eq!(t, "日本");
|
|
3693
|
+
assert!(cjk.starts_with(t));
|
|
3694
|
+
}
|
|
3695
|
+
|
|
3696
|
+
#[test]
|
|
3697
|
+
fn compaction_split_never_starts_tail_on_tool_result() {
|
|
3698
|
+
// Layout puts the naive tail boundary on a Msg::Tool; the split must
|
|
3699
|
+
// advance past it so no tool_result is orphaned from its tool_use.
|
|
3700
|
+
let mut msgs = vec![Msg::System("s".into()), Msg::User("task".into())];
|
|
3701
|
+
for i in 0..3 {
|
|
3702
|
+
msgs.push(Msg::Assistant {
|
|
3703
|
+
text: format!("a{i}"),
|
|
3704
|
+
calls: vec![],
|
|
3705
|
+
});
|
|
3706
|
+
msgs.push(Msg::Tool(vec![crate::provider::ToolResult {
|
|
3707
|
+
id: format!("{i}"),
|
|
3708
|
+
content: "r".into(),
|
|
3709
|
+
is_error: false,
|
|
3710
|
+
}]));
|
|
3711
|
+
}
|
|
3712
|
+
msgs.push(Msg::User("follow-up".into()));
|
|
3713
|
+
// len = 9 → naive tail_start = 3, which is a Msg::Tool.
|
|
3714
|
+
assert!(matches!(msgs[3], Msg::Tool(_)));
|
|
3715
|
+
let (sys_end, tail_start) = compaction_split(&msgs);
|
|
3716
|
+
assert_eq!(sys_end, 1);
|
|
3717
|
+
assert_eq!(tail_start, 4);
|
|
3718
|
+
assert!(matches!(msgs[tail_start], Msg::Assistant { .. }));
|
|
3719
|
+
}
|
|
3720
|
+
|
|
3721
|
+
#[test]
|
|
3722
|
+
fn compaction_pins_original_task_verbatim() {
|
|
3723
|
+
let task = "build the 🚀 thing";
|
|
3724
|
+
let mut msgs = vec![Msg::System("sys".into()), Msg::User(task.into())];
|
|
3725
|
+
for i in 0..10 {
|
|
3726
|
+
msgs.push(Msg::User(format!("u{i}")));
|
|
3727
|
+
}
|
|
3728
|
+
let out = compact_with(msgs, |_| "SUMMARY".into());
|
|
3729
|
+
let Msg::User(summary) = &out[1] else {
|
|
3730
|
+
panic!("expected summary user message");
|
|
3731
|
+
};
|
|
3732
|
+
assert!(summary.contains(&format!("[Original task]\n{task}")));
|
|
3733
|
+
// A second compaction re-extracts the same verbatim task text.
|
|
3734
|
+
let mut again = out;
|
|
3735
|
+
for i in 0..10 {
|
|
3736
|
+
again.push(Msg::User(format!("v{i}")));
|
|
3737
|
+
}
|
|
3738
|
+
let out2 = compact_with(again, |_| "SUMMARY2".into());
|
|
3739
|
+
let Msg::User(summary2) = &out2[1] else {
|
|
3740
|
+
panic!("expected summary user message");
|
|
3741
|
+
};
|
|
3742
|
+
assert!(summary2.contains(&format!("[Original task]\n{task}")));
|
|
3743
|
+
}
|
|
3744
|
+
|
|
3745
|
+
#[test]
|
|
3746
|
+
fn compaction_keeps_recent_tail_tool_results_intact() {
|
|
3747
|
+
let long = "x".repeat(2000);
|
|
3748
|
+
let mut msgs = vec![Msg::System("sys".into()), Msg::User("task".into())];
|
|
3749
|
+
for i in 0..8 {
|
|
3750
|
+
msgs.push(Msg::User(format!("u{i}")));
|
|
3751
|
+
}
|
|
3752
|
+
msgs.push(Msg::Assistant {
|
|
3753
|
+
text: "a".into(),
|
|
3754
|
+
calls: vec![],
|
|
3755
|
+
});
|
|
3756
|
+
msgs.push(Msg::Tool(vec![crate::provider::ToolResult {
|
|
3757
|
+
id: "1".into(),
|
|
3758
|
+
content: long.clone(),
|
|
3759
|
+
is_error: false,
|
|
3760
|
+
}]));
|
|
3761
|
+
let out = compact_with(msgs, |_| "S".into());
|
|
3762
|
+
let Some(Msg::Tool(results)) = out.last() else {
|
|
3763
|
+
panic!("expected tool results in the tail");
|
|
3764
|
+
};
|
|
3765
|
+
assert_eq!(results[0].content, long, "tail results must not be clipped");
|
|
3766
|
+
}
|
|
3767
|
+
|
|
3768
|
+
#[test]
|
|
3769
|
+
fn loop_guard_ignores_repeated_successful_results() {
|
|
3770
|
+
let mut guard = ToolLoopGuard::default();
|
|
3771
|
+
let input = json!({"path": "src/main.rs"});
|
|
3772
|
+
for _ in 0..10 {
|
|
3773
|
+
assert!(guard
|
|
3774
|
+
.note("read_file", &input, "fn main() {}", false)
|
|
3775
|
+
.is_none());
|
|
3776
|
+
}
|
|
3777
|
+
}
|
|
3778
|
+
|
|
3779
|
+
#[test]
|
|
3780
|
+
fn loop_guard_nudges_then_stops_on_repeated_errors() {
|
|
3781
|
+
let mut guard = ToolLoopGuard::default();
|
|
3782
|
+
let input = json!({"path": "missing.txt"});
|
|
3783
|
+
assert!(guard
|
|
3784
|
+
.note("read_file", &input, "no such file", true)
|
|
3785
|
+
.is_none());
|
|
3786
|
+
assert!(guard
|
|
3787
|
+
.note("read_file", &input, "no such file", true)
|
|
3788
|
+
.is_none());
|
|
3789
|
+
let third = guard.note("read_file", &input, "no such file", true);
|
|
3790
|
+
assert!(matches!(third, Some(LoopSignal::Nudge(_))));
|
|
3791
|
+
// The same error repeating after the nudge stops the run.
|
|
3792
|
+
let fourth = guard.note("read_file", &input, "no such file", true);
|
|
3793
|
+
assert!(matches!(fourth, Some(LoopSignal::Stop(_))));
|
|
3794
|
+
}
|
|
3795
|
+
|
|
3796
|
+
#[test]
|
|
3797
|
+
fn loop_guard_resets_when_result_changes() {
|
|
3798
|
+
let mut guard = ToolLoopGuard::default();
|
|
3799
|
+
let input = json!({"path": "f"});
|
|
3800
|
+
assert!(guard.note("read_file", &input, "err A", true).is_none());
|
|
3801
|
+
assert!(guard.note("read_file", &input, "err A", true).is_none());
|
|
3802
|
+
// A different result resets the counter — no trigger on the next call.
|
|
3803
|
+
assert!(guard.note("read_file", &input, "err B", true).is_none());
|
|
3804
|
+
assert!(guard.note("read_file", &input, "err B", true).is_none());
|
|
3805
|
+
}
|
|
3806
|
+
|
|
3807
|
+
#[test]
|
|
3808
|
+
fn max_tokens_stop_reason_is_detected() {
|
|
3809
|
+
let truncated = Reply {
|
|
3810
|
+
stop_reason: Some("max_tokens".into()),
|
|
3811
|
+
..Default::default()
|
|
3812
|
+
};
|
|
3813
|
+
assert!(reply_truncated_at_token_limit(&truncated));
|
|
3814
|
+
let done = Reply {
|
|
3815
|
+
stop_reason: Some("end_turn".into()),
|
|
3816
|
+
..Default::default()
|
|
3817
|
+
};
|
|
3818
|
+
assert!(!reply_truncated_at_token_limit(&done));
|
|
3819
|
+
assert!(!reply_truncated_at_token_limit(&Reply::default()));
|
|
3820
|
+
}
|
|
3821
|
+
|
|
3822
|
+
#[test]
|
|
3823
|
+
fn artifact_classifiers_require_build_verb() {
|
|
3824
|
+
assert!(canvas_game_requested("build me a canvas game"));
|
|
3825
|
+
assert!(canvas_game_requested("make a browser game about space"));
|
|
3826
|
+
assert!(!canvas_game_requested("fix the collision bug in my game"));
|
|
3827
|
+
assert!(static_artifact_requested(
|
|
3828
|
+
"create a landing page for my startup"
|
|
3829
|
+
));
|
|
3830
|
+
assert!(!static_artifact_requested("explain how this website works"));
|
|
3831
|
+
assert!(!static_artifact_requested("why is the landing page slow?"));
|
|
3832
|
+
}
|
|
3833
|
+
|
|
3834
|
+
#[test]
|
|
3835
|
+
fn casual_turn_detection_only_matches_pure_greetings() {
|
|
3836
|
+
assert!(is_casual_turn("hi"));
|
|
3837
|
+
assert!(is_casual_turn("Hello!"));
|
|
3838
|
+
assert!(is_casual_turn("hey"));
|
|
3839
|
+
// Acknowledgements can be valid "yes, proceed" turns.
|
|
3840
|
+
assert!(!is_casual_turn("ok"));
|
|
3841
|
+
assert!(!is_casual_turn("okay"));
|
|
3842
|
+
assert!(!is_casual_turn("thanks"));
|
|
3843
|
+
assert!(!is_casual_turn("yes, proceed"));
|
|
3844
|
+
}
|
|
3845
|
+
|
|
3846
|
+
#[test]
|
|
3847
|
+
fn placeholder_content_detection_is_conservative() {
|
|
3848
|
+
assert!(is_placeholder_content(""));
|
|
3849
|
+
assert!(is_placeholder_content(" \n"));
|
|
3850
|
+
assert!(is_placeholder_content("TODO"));
|
|
3851
|
+
assert!(is_placeholder_content("<content>"));
|
|
3852
|
+
assert!(!is_placeholder_content("hello world"));
|
|
3853
|
+
assert!(!is_placeholder_content("TODO: fix the parser later"));
|
|
3854
|
+
}
|
|
3855
|
+
|
|
3856
|
+
#[test]
|
|
3857
|
+
fn invalid_args_feedback_names_tool_error_and_required_params() {
|
|
3858
|
+
let defs = tools::defs_for_context(true, 128_000);
|
|
3859
|
+
let msg = invalid_args_feedback("write_file", "{not json", &defs);
|
|
3860
|
+
assert!(msg.contains("write_file"));
|
|
3861
|
+
assert!(msg.contains("Required params:"));
|
|
3862
|
+
assert!(msg.ends_with("Re-send the complete corrected call."));
|
|
3863
|
+
}
|
|
3864
|
+
|
|
3865
|
+
#[test]
|
|
3866
|
+
fn instructional_prose_classifier_is_conservative() {
|
|
3867
|
+
assert!(reads_as_instructions("First, you should create the file."));
|
|
3868
|
+
assert!(reads_as_instructions(
|
|
3869
|
+
"Here's how to build it. Steps:\n1. Create index.html\n2. Add a canvas"
|
|
3870
|
+
));
|
|
3871
|
+
assert!(reads_as_instructions("To do this, you can run npm init."));
|
|
3872
|
+
// Completed-work summaries must not read as instructions.
|
|
3873
|
+
assert!(!reads_as_instructions(
|
|
3874
|
+
"I created index.html with the game and published the artifact."
|
|
3875
|
+
));
|
|
3876
|
+
assert!(!reads_as_instructions(
|
|
3877
|
+
"Done — the collision bug is fixed and verified with the test suite."
|
|
3878
|
+
));
|
|
3879
|
+
}
|
|
3880
|
+
|
|
3881
|
+
#[test]
|
|
3882
|
+
fn act_nudge_fires_on_instructional_prose_for_imperative_task() {
|
|
3883
|
+
let prose = "Here's how you can build it:\nSteps:\n1. Create index.html\n2. Add a canvas";
|
|
3884
|
+
// Imperative task + how-to prose + no prior mutation → nudge.
|
|
3885
|
+
assert!(should_nudge_to_act(
|
|
3886
|
+
"build me a todo app",
|
|
3887
|
+
prose,
|
|
3888
|
+
false,
|
|
3889
|
+
false,
|
|
3890
|
+
0
|
|
3891
|
+
));
|
|
3892
|
+
assert!(should_nudge_to_act(
|
|
3893
|
+
"fix the collision bug in my game",
|
|
3894
|
+
prose,
|
|
3895
|
+
false,
|
|
3896
|
+
false,
|
|
3897
|
+
0
|
|
3898
|
+
));
|
|
3899
|
+
// The same prose after a mutating tool ran is a real summary → no nudge.
|
|
3900
|
+
assert!(!should_nudge_to_act(
|
|
3901
|
+
"build me a todo app",
|
|
3902
|
+
prose,
|
|
3903
|
+
true,
|
|
3904
|
+
true,
|
|
3905
|
+
0
|
|
3906
|
+
));
|
|
3907
|
+
// Question-style tasks want an answer, not action → no nudge.
|
|
3908
|
+
assert!(!should_nudge_to_act(
|
|
3909
|
+
"how do I build a todo app?",
|
|
3910
|
+
prose,
|
|
3911
|
+
false,
|
|
3912
|
+
false,
|
|
3913
|
+
0
|
|
3914
|
+
));
|
|
3915
|
+
assert!(!should_nudge_to_act(
|
|
3916
|
+
"what does this repo do",
|
|
3917
|
+
prose,
|
|
3918
|
+
false,
|
|
3919
|
+
false,
|
|
3920
|
+
0
|
|
3921
|
+
));
|
|
3922
|
+
// Read-only exploration followed by a plain answer (no markers) → no nudge.
|
|
3923
|
+
assert!(!should_nudge_to_act(
|
|
3924
|
+
"build me a todo app",
|
|
3925
|
+
"The repo already contains a complete todo app in src/.",
|
|
3926
|
+
true,
|
|
3927
|
+
false,
|
|
3928
|
+
0
|
|
3929
|
+
));
|
|
3930
|
+
// The per-task cap is respected.
|
|
3931
|
+
assert!(!should_nudge_to_act(
|
|
3932
|
+
"build me a todo app",
|
|
3933
|
+
prose,
|
|
3934
|
+
false,
|
|
3935
|
+
false,
|
|
3936
|
+
MAX_ACT_NUDGES
|
|
3937
|
+
));
|
|
3938
|
+
}
|
|
3376
3939
|
}
|