buildwithnexus 0.10.7 → 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.
@@ -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 = 2;
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
- let middle: Vec<Msg> = it.by_ref().take(tail_start - sys_end).collect();
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
- v.push(Msg::User(format!(
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<String> {
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 && rec.is_error == is_error {
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 >= MAX_IDENTICAL_TOOL_RESULTS {
240
- Some(repeated_tool_summary(name, input, content, is_error))
241
- } else {
242
- None
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
- summary: &mut Option<String>,
359
+ nudge: &mut Option<String>,
360
+ stop: &mut Option<String>,
280
361
  ) {
281
- if summary.is_some() {
362
+ if stop.is_some() {
282
363
  return;
283
364
  }
284
- if let Some(loop_msg) = guard.note(name, input, content, is_error) {
285
- report::assistant(&loop_msg);
286
- *summary = Some(loop_msg);
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 should_repair = path.trim().is_empty()
456
+ let path_broken = path.trim().is_empty()
370
457
  || matches!(path.trim(), "~" | "." | "/" | "./")
371
- || is_placeholder_path(path)
372
- || (path == requested_path && content != requested_content);
373
- should_repair.then_some(requested)
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
- matches!(
586
- normalized.as_str(),
587
- "hi" | "hello"
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>> {
@@ -804,7 +917,7 @@ Use the tools to inspect and modify the project directly. \
804
917
  Prefer small, verifiable edits. Read before you write. \
805
918
  When writing or editing files, provide the complete, fully working code. NEVER use placeholders (e.g. `// ... rest of code`). \
806
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. \
807
- For simple browser games, canvas demos, landing pages, prototypes, and static visual apps, build an actual runnable artifact. Prefer a single self-contained HTML file via Artifact/publish_artifact unless the user explicitly asks for a framework. NEVER search GitHub, git clone external repositories, or look for existing games online when asked to build a game, app, or prototype from scratch. You MUST write the code yourself from scratch. When asked to build or create something, YOU MUST IMMEDIATELY CALL TOOLS to create the files on disk (e.g. write_file, edit_file, run_command). DO NOT output markdown instructions, step-by-step tutorials, or code blocks in chat telling the user how to build it! YOU are the builder — execute the tool calls to build it yourself directly. \
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. \
808
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. \
809
922
  If a path or file is missing, use discovery tools before asking the user. \
810
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. \
@@ -822,6 +935,21 @@ over installing an alternative.",
822
935
  Role { system }
823
936
  }
824
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
+
825
953
  // Build the system prompt prefix from memory and skills/agents files.
826
954
  // When context_tokens is small (≤16K), skip expensive sections to leave
827
955
  // room for tool definitions + actual conversation.
@@ -869,7 +997,7 @@ fn context_prefix(cwd: &Path, context_tokens: usize) -> String {
869
997
  if let Some(mem) = config::load_memory() {
870
998
  // Memory is user-important; always include but truncate for small ctx
871
999
  let mem_text = if compact && mem.len() > 300 {
872
- format!("{}…", &mem[..300])
1000
+ format!("{}…", truncate_at_char_boundary(&mem, 300))
873
1001
  } else {
874
1002
  mem
875
1003
  };
@@ -883,7 +1011,7 @@ fn context_prefix(cwd: &Path, context_tokens: usize) -> String {
883
1011
  );
884
1012
  // For small contexts, truncate Agents.md to avoid blowing the budget
885
1013
  let agents_text = if compact && agents.len() > 500 {
886
- format!("{}…", &agents[..500])
1014
+ format!("{}…", truncate_at_char_boundary(&agents, 500))
887
1015
  } else {
888
1016
  agents
889
1017
  };
@@ -892,33 +1020,13 @@ fn context_prefix(cwd: &Path, context_tokens: usize) -> String {
892
1020
 
893
1021
  // Skip rules, knowledge, hooks, and skill descriptions for small contexts
894
1022
  if !compact {
895
- // Inject active rules for operational judgment
896
- let mut engine = crate::rules::RuleEngine::load_defaults();
897
- let rules_dir = config::home().join("rules");
898
- if let Ok(rd) = std::fs::read_dir(&rules_dir) {
899
- for e in rd.flatten() {
900
- if let Ok(loaded) =
901
- crate::rules::RuleEngine::load_from_file(&e.path().to_string_lossy())
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
- ));
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
+ );
922
1030
 
923
1031
  // Inject structured knowledge base if present
924
1032
  let kb = crate::knowledge::KnowledgeBase::new(".");
@@ -953,15 +1061,13 @@ fn context_prefix(cwd: &Path, context_tokens: usize) -> String {
953
1061
  }
954
1062
 
955
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.
956
1066
  "[Built-in tools — always available, no install needed]\n\
957
1067
  Aliases are supported: bash/read/write/edit/patch/glob/grep/list/task/todowrite/todoread/webfetch/websearch/skill/question. \
958
1068
  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.\n\n\
960
- [CRITICAL TOOL DISCIPLINE]\n\
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."
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."
965
1071
  .to_string()
966
1072
  }
967
1073
 
@@ -1258,13 +1364,16 @@ pub fn run_build_session(
1258
1364
  sid: &str,
1259
1365
  ) -> Result<(), String> {
1260
1366
  hooks::notify("SessionStart", cwd);
1261
- 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(|_| ());
1262
1368
  hooks::notify("SessionEnd", cwd);
1263
1369
  hooks::notify("Stop", cwd);
1264
1370
  crate::session::save(sid, cwd, &p.model, transcript);
1265
1371
  r
1266
1372
  }
1267
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)]
1268
1377
  fn build_inner(
1269
1378
  p: &Provider,
1270
1379
  perm: Permission,
@@ -1273,6 +1382,7 @@ fn build_inner(
1273
1382
  cwd: &Path,
1274
1383
  depth: usize,
1275
1384
  msgs: &mut Vec<Msg>,
1385
+ sid: Option<&str>,
1276
1386
  ) -> Result<String, String> {
1277
1387
  let task = match hooks::user_prompt_submit(task, cwd) {
1278
1388
  Err(reason) => {
@@ -1289,8 +1399,15 @@ fn build_inner(
1289
1399
  tools::defs_for_context(depth < MAX_DEPTH, p.context_tokens)
1290
1400
  };
1291
1401
  if msgs.is_empty() {
1292
- let prefix = context_prefix(cwd, p.context_tokens);
1293
- let sys = format!("{prefix}{}", role(role_id).system);
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));
1294
1411
  msgs.push(Msg::System(sys));
1295
1412
  }
1296
1413
  // If the caller already pushed a UserImages message (multimodal input), use its
@@ -1305,6 +1422,18 @@ fn build_inner(
1305
1422
  let mut loop_guard = ToolLoopGuard::default();
1306
1423
  let mut artifact_error_count = 0usize;
1307
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();
1308
1437
 
1309
1438
  for step in 1..=MAX_ITERS {
1310
1439
  if tui::interrupted() {
@@ -1319,6 +1448,18 @@ fn build_inner(
1319
1448
  let reply = match request_reply(p, msgs.as_slice(), &defs, "thinking") {
1320
1449
  Ok(r) => r,
1321
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
+ }
1322
1463
  hooks::notify("OnError", cwd);
1323
1464
  return Err(e);
1324
1465
  }
@@ -1330,10 +1471,89 @@ fn build_inner(
1330
1471
  tui::inference_telemetry(gen_toks.max(10), elapsed);
1331
1472
  }
1332
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
+ }
1333
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
+ }
1334
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
+ });
1335
1552
  if let Some(reason) = gate(perm, "write_file", &input, cwd) {
1336
1553
  report::tool_denied(&reason);
1554
+ msgs.push(Msg::User(format!(
1555
+ "[harness] write_file recovery blocked: {reason}"
1556
+ )));
1337
1557
  return Err(reason);
1338
1558
  }
1339
1559
  report::tool_call("write_file", &tools::preview("write_file", &input), &input);
@@ -1343,65 +1563,50 @@ fn build_inner(
1343
1563
  report::tool_result("write_file", &out.content, out.is_error);
1344
1564
  trace_tool_result("write_file", &out.content, out.is_error, "build", depth);
1345
1565
  if out.is_error {
1566
+ msgs.push(Msg::User(format!(
1567
+ "[harness] write_file recovery failed: {}",
1568
+ out.content
1569
+ )));
1346
1570
  return Err(out.content);
1347
1571
  }
1572
+ msgs.push(Msg::User(format!(
1573
+ "[harness] completed the explicit file creation with write_file: {}",
1574
+ out.content
1575
+ )));
1348
1576
  return Ok(format!(
1349
1577
  "{}\n\nCompleted the explicit file creation task after the model returned prose without tool calls.",
1350
1578
  out.content
1351
1579
  ));
1352
1580
  }
1353
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
+ });
1354
1586
  report::tool_call("Artifact", &tools::preview("Artifact", &input), &input);
1355
1587
  trace_tool_call("Artifact", &input, "build", depth);
1356
1588
  let out = tools::run("Artifact", &input, cwd);
1357
1589
  report::tool_result("Artifact", &out.content, out.is_error);
1358
1590
  trace_tool_result("Artifact", &out.content, out.is_error, "build", depth);
1359
1591
  if out.is_error {
1360
- if canvas_game_requested(&task_for_recovery) {
1361
- let fallback = fallback_canvas_game_input(&task_for_recovery);
1362
- report::tool_call(
1363
- "Artifact",
1364
- &tools::preview("Artifact", &fallback),
1365
- &fallback,
1366
- );
1367
- trace_tool_call("Artifact", &fallback, "build", depth);
1368
- let fallback_out = tools::run("Artifact", &fallback, cwd);
1369
- report::tool_result(
1370
- "Artifact",
1371
- &fallback_out.content,
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);
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;
1388
1604
  }
1389
- msgs.push(Msg::Assistant {
1390
- text: reply.text.clone(),
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.",
1605
+ return Err(format!(
1606
+ "artifact publishing failed after {artifact_error_count} recovery attempts; last rejection: {}",
1397
1607
  out.content
1398
- )));
1399
- continue;
1608
+ ));
1400
1609
  }
1401
- msgs.push(Msg::Assistant {
1402
- text: reply.text.clone(),
1403
- calls: vec![],
1404
- });
1405
1610
  return Ok(out.content);
1406
1611
  }
1407
1612
  if static_artifact_requested(&task_for_recovery) {
@@ -1409,42 +1614,19 @@ fn build_inner(
1409
1614
  text: reply.text.clone(),
1410
1615
  calls: vec![],
1411
1616
  });
1412
- if static_artifact_recovery_count == 0 {
1617
+ if static_artifact_recovery_count < 2 {
1413
1618
  static_artifact_recovery_count += 1;
1414
1619
  msgs.push(Msg::User(static_artifact_recovery_prompt(
1415
1620
  &task_for_recovery,
1416
1621
  )));
1417
1622
  continue;
1418
1623
  }
1419
- if canvas_game_requested(&task_for_recovery) {
1420
- let fallback = fallback_canvas_game_input(&task_for_recovery);
1421
- report::tool_call(
1422
- "Artifact",
1423
- &tools::preview("Artifact", &fallback),
1424
- &fallback,
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");
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
+ ));
1448
1630
  }
1449
1631
  msgs.push(Msg::Assistant {
1450
1632
  text: reply.text.clone(),
@@ -1455,12 +1637,12 @@ fn build_inner(
1455
1637
 
1456
1638
  let mut results = Vec::new();
1457
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;
1458
1643
  for call in &reply.calls {
1459
1644
  if let Some(raw) = call.input.get(tools::INVALID_ARGS).and_then(|v| v.as_str()) {
1460
- let msg = format!(
1461
- "tool arguments were not valid JSON: {}",
1462
- raw.chars().take(200).collect::<String>()
1463
- );
1645
+ let msg = invalid_args_feedback(&call.name, raw, &defs);
1464
1646
  report::tool_denied(&msg);
1465
1647
  note_loop_result(
1466
1648
  &mut loop_guard,
@@ -1468,7 +1650,8 @@ fn build_inner(
1468
1650
  &call.input,
1469
1651
  &msg,
1470
1652
  true,
1471
- &mut summary,
1653
+ &mut loop_nudge,
1654
+ &mut loop_stop,
1472
1655
  );
1473
1656
  results.push(ToolResult {
1474
1657
  id: call.id.clone(),
@@ -1485,56 +1668,7 @@ fn build_inner(
1485
1668
  depth,
1486
1669
  &task_for_recovery,
1487
1670
  );
1488
- if matches!(call.name.as_str(), "bash" | "run_command") {
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
- }
1671
+ any_tool_ran = true;
1538
1672
  report::tool_call(
1539
1673
  &call.name,
1540
1674
  &tools::preview(&call.name, &call_input),
@@ -1552,7 +1686,8 @@ fn build_inner(
1552
1686
  &call_input,
1553
1687
  &answer,
1554
1688
  is_error,
1555
- &mut summary,
1689
+ &mut loop_nudge,
1690
+ &mut loop_stop,
1556
1691
  );
1557
1692
  results.push(ToolResult {
1558
1693
  id: call.id.clone(),
@@ -1580,7 +1715,8 @@ fn build_inner(
1580
1715
  &call_input,
1581
1716
  &reason,
1582
1717
  true,
1583
- &mut summary,
1718
+ &mut loop_nudge,
1719
+ &mut loop_stop,
1584
1720
  );
1585
1721
  results.push(ToolResult {
1586
1722
  id: call.id.clone(),
@@ -1609,7 +1745,8 @@ fn build_inner(
1609
1745
  &call_input,
1610
1746
  &msg,
1611
1747
  true,
1612
- &mut summary,
1748
+ &mut loop_nudge,
1749
+ &mut loop_stop,
1613
1750
  );
1614
1751
  results.push(ToolResult {
1615
1752
  id: call.id.clone(),
@@ -1621,13 +1758,15 @@ fn build_inner(
1621
1758
  }
1622
1759
 
1623
1760
  if matches!(call.name.as_str(), "task" | "spawn_subagent") {
1624
- let out = spawn_subagent(p, perm, &call_input, cwd, depth);
1625
- report::tool_result(&call.name, &out, false);
1626
- trace_tool_result(&call.name, &out, false, "build", depth);
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);
1627
1766
  results.push(ToolResult {
1628
1767
  id: call.id.clone(),
1629
1768
  content: out,
1630
- is_error: false,
1769
+ is_error,
1631
1770
  });
1632
1771
  continue;
1633
1772
  }
@@ -1647,6 +1786,9 @@ fn build_inner(
1647
1786
  }
1648
1787
  }
1649
1788
 
1789
+ if tools::is_mutating_call(&call.name, &call_input) {
1790
+ mutating_tool_ran = true;
1791
+ }
1650
1792
  let out = tools::run(&call.name, &call_input, cwd);
1651
1793
  hooks::post_tool_use(&call.name, &call_input, &out.content, out.is_error, cwd);
1652
1794
  if out.is_error {
@@ -1654,62 +1796,44 @@ fn build_inner(
1654
1796
  }
1655
1797
  report::tool_result(&call.name, &out.content, out.is_error);
1656
1798
  trace_tool_result(&call.name, &out.content, out.is_error, "build", depth);
1657
- if matches!(call.name.as_str(), "bash" | "run_command")
1658
- && out.is_error
1659
- && out.content.contains("No such file or directory")
1660
- {
1661
- if let Some(fallback) = auto_create_file_input(&task_for_recovery) {
1662
- report::tool_call(
1663
- "write_file",
1664
- &tools::preview("write_file", &fallback),
1665
- &fallback,
1666
- );
1667
- trace_tool_call("write_file", &fallback, "build", depth);
1668
- let fallback_out = tools::run("write_file", &fallback, cwd);
1669
- report::tool_result("write_file", &fallback_out.content, fallback_out.is_error);
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
- ));
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);
1682
1812
  }
1683
1813
  }
1684
1814
  }
1685
1815
  if matches!(call.name.as_str(), "Artifact" | "publish_artifact")
1686
1816
  && out.is_error
1687
- && canvas_game_requested(&task_for_recovery)
1817
+ && (static_artifact_requested(&task_for_recovery)
1818
+ || canvas_game_requested(&task_for_recovery))
1688
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.
1689
1823
  artifact_error_count += 1;
1690
- if artifact_error_count >= 1 {
1691
- let fallback = fallback_canvas_game_input(&task_for_recovery);
1692
- report::tool_call(
1693
- "Artifact",
1694
- &tools::preview("Artifact", &fallback),
1695
- &fallback,
1696
- );
1697
- trace_tool_call("Artifact", &fallback, "build", depth);
1698
- let fallback_out = tools::run("Artifact", &fallback, cwd);
1699
- report::tool_result("Artifact", &fallback_out.content, fallback_out.is_error);
1700
- trace_tool_result(
1701
- "Artifact",
1702
- &fallback_out.content,
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
- }
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
+ ));
1713
1837
  }
1714
1838
  }
1715
1839
  if out.finished {
@@ -1722,7 +1846,8 @@ fn build_inner(
1722
1846
  &call_input,
1723
1847
  &out.content,
1724
1848
  out.is_error,
1725
- &mut summary,
1849
+ &mut loop_nudge,
1850
+ &mut loop_stop,
1726
1851
  );
1727
1852
  }
1728
1853
  results.push(ToolResult {
@@ -1737,6 +1862,11 @@ fn build_inner(
1737
1862
  calls: reply.calls,
1738
1863
  });
1739
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
+ }
1740
1870
  if !report::is_json() {
1741
1871
  tui::context_meter(estimate_tokens(msgs), p.context_tokens);
1742
1872
  tui::poll_typeahead();
@@ -1746,48 +1876,114 @@ fn build_inner(
1746
1876
  let verifier = crate::verifier::Verifier::new(&cwd.to_string_lossy());
1747
1877
  let ctx = crate::verifier::VerificationContext {
1748
1878
  task_description: task.to_string(),
1749
- task_type: None,
1750
- changed_files: vec![],
1751
- tool_calls: vec![],
1752
- evidence_gathered: vec![],
1753
- tests_added: vec![],
1754
- dependencies_changed: vec![],
1755
- git_diff: None,
1879
+ changed_files: changed_files.clone(),
1880
+ tool_calls: tool_records.clone(),
1881
+ ..Default::default()
1756
1882
  };
1757
1883
  let rep = verifier.verify(&ctx);
1758
- if matches!(
1759
- rep.status,
1760
- crate::verifier::VerificationStatus::PassedWithWarnings
1761
- | crate::verifier::VerificationStatus::Blocked
1762
- | crate::verifier::VerificationStatus::Failed
1763
- ) {
1764
- report::notice(&format!(" [Verification: {}]", rep.status));
1765
- if !rep.rule_violations.is_empty() {
1766
- report::notice(&crate::rules::RuleEngine::format_violations(
1767
- &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
1768
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
+ }
1769
1925
  }
1926
+ crate::verifier::VerificationStatus::Passed => {}
1770
1927
  }
1771
1928
  }
1772
1929
  return Ok(s);
1773
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
+ }
1774
1941
  }
1775
1942
  Err(format!(
1776
1943
  "reached the {MAX_ITERS}-step limit without finishing"
1777
1944
  ))
1778
1945
  }
1779
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
+
1780
1974
  static SUB_SEQ: AtomicUsize = AtomicUsize::new(0);
1781
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.
1782
1978
  fn spawn_subagent(
1783
1979
  p: &Provider,
1784
1980
  perm: Permission,
1785
1981
  input: &serde_json::Value,
1786
1982
  cwd: &Path,
1787
1983
  depth: usize,
1788
- ) -> String {
1984
+ ) -> (String, bool) {
1789
1985
  if depth + 1 >= MAX_DEPTH {
1790
- return "subagent depth limit reached".into();
1986
+ return ("subagent depth limit reached".into(), true);
1791
1987
  }
1792
1988
  let task = input["task"]
1793
1989
  .as_str()
@@ -1795,7 +1991,7 @@ fn spawn_subagent(
1795
1991
  .unwrap_or("")
1796
1992
  .trim();
1797
1993
  if task.is_empty() {
1798
- return "spawn_subagent requires a task".into();
1994
+ return ("spawn_subagent requires a task".into(), true);
1799
1995
  }
1800
1996
  let role = input["role"].as_str().unwrap_or("engineer");
1801
1997
  let isolate = input["isolate"].as_bool().unwrap_or(false);
@@ -1828,8 +2024,11 @@ fn spawn_subagent(
1828
2024
  }),
1829
2025
  );
1830
2026
  let mut child: Vec<Msg> = Vec::new();
1831
- let result = build_inner(p, perm, role, task, &run_cwd, depth + 1, &mut child)
1832
- .unwrap_or_else(|e| format!("subagent error: {e}"));
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
+ };
1833
2032
  trace::record_visible(
1834
2033
  "subagent_done",
1835
2034
  format!("{role}: {}", trace::preview(task, 80)),
@@ -1839,10 +2038,11 @@ fn spawn_subagent(
1839
2038
  "isolate": isolate,
1840
2039
  "cwd": run_cwd.to_string_lossy(),
1841
2040
  "result": result,
2041
+ "is_error": is_error,
1842
2042
  "depth": depth + 1,
1843
2043
  }),
1844
2044
  );
1845
- format!("{note}{result}")
2045
+ (format!("{note}{result}"), is_error)
1846
2046
  }
1847
2047
 
1848
2048
  fn make_worktree(cwd: &Path) -> Option<PathBuf> {
@@ -2080,7 +2280,31 @@ fn recovery_task_text(task: &str) -> String {
2080
2280
  .to_string()
2081
2281
  }
2082
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
+
2083
2304
  fn static_artifact_requested(task: &str) -> bool {
2305
+ if !task_has_build_verb(task) {
2306
+ return false;
2307
+ }
2084
2308
  let lower = task.to_lowercase();
2085
2309
  [
2086
2310
  "canvas game",
@@ -2159,79 +2383,102 @@ fn extract_html_title(html: &str) -> Option<String> {
2159
2383
  }
2160
2384
 
2161
2385
  fn canvas_game_requested(task: &str) -> bool {
2386
+ if !task_has_build_verb(task) {
2387
+ return false;
2388
+ }
2162
2389
  let lower = task.to_lowercase();
2163
2390
  lower.contains("canvas game") || lower.contains("browser game") || lower.contains("game")
2164
2391
  }
2165
2392
 
2166
- fn fallback_canvas_game_input(task: &str) -> serde_json::Value {
2167
- let title = if task.to_lowercase().contains("orbit") {
2168
- "Orbit Dodge"
2169
- } else {
2170
- "Canvas Dodge"
2171
- };
2172
- let html = format!(
2173
- r#"<!doctype html>
2174
- <html lang="en">
2175
- <head>
2176
- <meta charset="utf-8">
2177
- <meta name="viewport" content="width=device-width,initial-scale=1">
2178
- <title>{title}</title>
2179
- <style>
2180
- html,body{{margin:0;height:100%;overflow:hidden;background:#111827;color:#e5e7eb;font-family:system-ui,-apple-system,Segoe UI,sans-serif}}
2181
- #wrap{{position:fixed;inset:0;display:grid;place-items:center;background:radial-gradient(circle at 50% 35%,#1f2937,#030712 70%)}}
2182
- canvas{{width:min(94vw,860px);height:min(72vh,560px);background:#050816;border:1px solid #334155;box-shadow:0 24px 80px #0008}}
2183
- #hud{{position:fixed;left:18px;right:18px;top:14px;display:flex;justify-content:space-between;gap:16px;font-weight:700;text-shadow:0 2px 8px #000}}
2184
- #help{{position:fixed;left:18px;right:18px;bottom:14px;text-align:center;color:#9ca3af;font-size:14px}}
2185
- #pad{{position:fixed;right:18px;bottom:56px;display:grid;grid-template-columns:48px 48px 48px;gap:8px}}
2186
- button{{background:#1f2937cc;color:#e5e7eb;border:1px solid #475569;border-radius:8px;min-height:44px;font:inherit}}
2187
- @media (pointer:fine){{#pad{{display:none}}}}
2188
- </style>
2189
- </head>
2190
- <body>
2191
- <div id="wrap"><canvas id="game" width="860" height="560"></canvas></div>
2192
- <div id="hud"><span id="score">Score 0</span><span id="state">Move to start</span></div>
2193
- <div id="help">WASD/Arrows move · Space/P pause · R restart · Dodge the orbiting sparks</div>
2194
- <div id="pad"><span></span><button data-k="ArrowUp">↑</button><span></span><button data-k="ArrowLeft">←</button><button data-k=" ">Ⅱ</button><button data-k="ArrowRight">→</button><span></span><button data-k="ArrowDown">↓</button><span></span></div>
2195
- <script>
2196
- const c=document.getElementById('game'),x=c.getContext('2d'),scoreEl=document.getElementById('score'),stateEl=document.getElementById('state');
2197
- const keys=new Set();let player,orbs,score,best=0,over=false,paused=false,last=0;
2198
- function reset(){{player={{x:c.width/2,y:c.height/2,r:11,vx:0,vy:0}};orbs=[];score=0;over=false;paused=false;last=0;stateEl.textContent='Survive';for(let i=0;i<7;i++)orbs.push({{a:i*.9,d:70+i*34,s:.7+i*.08,r:8+i%3}});}}
2199
- function key(k,on){{if(on)keys.add(k);else keys.delete(k);if(k==='r'||k==='R')reset();if(k===' '||k==='p'||k==='P')paused=!paused;}}
2200
- addEventListener('keydown',e=>{{key(e.key,true);if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(e.key))e.preventDefault();}});
2201
- addEventListener('keyup',e=>key(e.key,false));
2202
- document.querySelectorAll('button').forEach(b=>{{b.onpointerdown=e=>{{b.setPointerCapture(e.pointerId);key(b.dataset.k,true)}};b.onpointerup=e=>key(b.dataset.k,false);}});
2203
- function step(t){{requestAnimationFrame(step);let dt=Math.min(.033,(t-last)/1000||0);last=t;if(paused){{draw();stateEl.textContent='Paused';return}}if(over){{draw();stateEl.textContent='Game over · R to restart';return}}
2204
- let ax=(keys.has('ArrowRight')||keys.has('d')||keys.has('D'))-(keys.has('ArrowLeft')||keys.has('a')||keys.has('A'));
2205
- let ay=(keys.has('ArrowDown')||keys.has('s')||keys.has('S'))-(keys.has('ArrowUp')||keys.has('w')||keys.has('W'));
2206
- player.vx=(player.vx+ax*900*dt)*.86;player.vy=(player.vy+ay*900*dt)*.86;player.x=Math.max(player.r,Math.min(c.width-player.r,player.x+player.vx*dt));player.y=Math.max(player.r,Math.min(c.height-player.r,player.y+player.vy*dt));
2207
- score+=dt*10;best=Math.max(best,score);scoreEl.textContent=`Score ${{score|0}} · Best ${{best|0}}`;
2208
- for(const o of orbs){{o.a+=o.s*dt;let ox=c.width/2+Math.cos(o.a)*o.d,oy=c.height/2+Math.sin(o.a*1.35)*o.d;if(Math.hypot(player.x-ox,player.y-oy)<player.r+o.r)over=true;}}
2209
- draw();}}
2210
- function draw(){{x.clearRect(0,0,c.width,c.height);x.fillStyle='#111827';x.fillRect(0,0,c.width,c.height);x.strokeStyle='#1f9cf0';x.globalAlpha=.18;for(let r=70;r<330;r+=34){{x.beginPath();x.ellipse(c.width/2,c.height/2,r,r*.72,0,0,7);x.stroke();}}x.globalAlpha=1;for(const o of orbs){{let ox=c.width/2+Math.cos(o.a)*o.d,oy=c.height/2+Math.sin(o.a*1.35)*o.d;x.fillStyle='#f97316';x.beginPath();x.arc(ox,oy,o.r,0,7);x.fill();}}x.fillStyle=over?'#ef4444':'#22c55e';x.beginPath();x.arc(player.x,player.y,player.r,0,7);x.fill();}}
2211
- reset();requestAnimationFrame(step);
2212
- </script>
2213
- </body>
2214
- </html>"#
2215
- );
2216
- serde_json::json!({
2217
- "title": title,
2218
- "contents": html,
2219
- "type": "html",
2220
- })
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))
2221
2467
  }
2222
2468
 
2223
2469
  // ── PLAN mode ─────────────────────────────────────────────────────────────────
2224
2470
  // The planning phase now has tools available so the model can inspect the
2225
2471
  // codebase while breaking down the task. Execution still runs through BUILD.
2226
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.
2227
2474
  let prefix = context_prefix(cwd, p.context_tokens);
2228
2475
  let sys = format!(
2229
- "{prefix}You are a planning engineer with full access to the codebase. \
2476
+ "You are a planning engineer with full access to the codebase. \
2230
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. \
2231
2478
  Do not write files, edit files, apply patches, spawn subagents, or run mutating shell commands while planning. \
2232
2479
  When ready, call exit_plan or ExitPlanMode with a concise numbered implementation plan. \
2233
2480
  The plan must be concrete, actionable, and at most 8 steps. \
2234
- 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}"
2235
2482
  );
2236
2483
 
2237
2484
  let defs = tools::defs_readonly(); // planning inspects context but never writes
@@ -2296,12 +2543,10 @@ pub fn run_plan(p: &Provider, perm: Permission, task: &str, cwd: &Path) -> Resul
2296
2543
  // Execute tool calls during the planning phase.
2297
2544
  let mut results = Vec::new();
2298
2545
  let mut loop_summary: Option<String> = None;
2546
+ let mut loop_nudge: Option<String> = None;
2299
2547
  for call in &reply.calls {
2300
2548
  if let Some(raw) = call.input.get(tools::INVALID_ARGS).and_then(|v| v.as_str()) {
2301
- let msg = format!(
2302
- "tool arguments were not valid JSON: {}",
2303
- raw.chars().take(200).collect::<String>()
2304
- );
2549
+ let msg = invalid_args_feedback(&call.name, raw, &defs);
2305
2550
  report::tool_denied(&msg);
2306
2551
  note_loop_result(
2307
2552
  &mut loop_guard,
@@ -2309,6 +2554,7 @@ pub fn run_plan(p: &Provider, perm: Permission, task: &str, cwd: &Path) -> Resul
2309
2554
  &call.input,
2310
2555
  &msg,
2311
2556
  true,
2557
+ &mut loop_nudge,
2312
2558
  &mut loop_summary,
2313
2559
  );
2314
2560
  results.push(ToolResult {
@@ -2380,6 +2626,7 @@ pub fn run_plan(p: &Provider, perm: Permission, task: &str, cwd: &Path) -> Resul
2380
2626
  &call_input,
2381
2627
  &answer,
2382
2628
  is_error,
2629
+ &mut loop_nudge,
2383
2630
  &mut loop_summary,
2384
2631
  );
2385
2632
  results.push(ToolResult {
@@ -2406,6 +2653,7 @@ pub fn run_plan(p: &Provider, perm: Permission, task: &str, cwd: &Path) -> Resul
2406
2653
  &call_input,
2407
2654
  &reason,
2408
2655
  true,
2656
+ &mut loop_nudge,
2409
2657
  &mut loop_summary,
2410
2658
  );
2411
2659
  results.push(ToolResult {
@@ -2438,6 +2686,7 @@ pub fn run_plan(p: &Provider, perm: Permission, task: &str, cwd: &Path) -> Resul
2438
2686
  &call_input,
2439
2687
  &out.content,
2440
2688
  out.is_error,
2689
+ &mut loop_nudge,
2441
2690
  &mut loop_summary,
2442
2691
  );
2443
2692
  results.push(ToolResult {
@@ -2463,6 +2712,9 @@ pub fn run_plan(p: &Provider, perm: Permission, task: &str, cwd: &Path) -> Resul
2463
2712
  }
2464
2713
  return Err(loop_msg);
2465
2714
  }
2715
+ if let Some(nudge) = loop_nudge {
2716
+ msgs.push(Msg::User(nudge));
2717
+ }
2466
2718
  };
2467
2719
 
2468
2720
  let mut steps = parse_plan_steps(&plan_text);
@@ -2542,13 +2794,14 @@ pub fn run_brainstorm(
2542
2794
  cwd: &Path,
2543
2795
  first: &str,
2544
2796
  ) -> Result<Option<ModeHint>, String> {
2797
+ // Role identity + mode contract come first; environment sections follow.
2545
2798
  let prefix = context_prefix(cwd, p.context_tokens);
2546
- let sys = format!("{prefix}You are a sharp, concise thought partner with full access to the codebase and the internet. \
2799
+ let sys = format!("You are a sharp, concise thought partner with full access to the codebase and the internet. \
2547
2800
  Use tools freely to look things up, read files, grep for patterns, or run commands — \
2548
2801
  whatever helps the conversation. \
2549
2802
  When you think the user is ready to stop discussing and start building or planning, \
2550
2803
  end your response with the exact token [SUGGEST:BUILD] or [SUGGEST:PLAN] on its own line. \
2551
- Otherwise just respond naturally. No fluff.");
2804
+ Otherwise just respond naturally. No fluff.\n\n{prefix}");
2552
2805
 
2553
2806
  let defs = tools::defs_for_context(false, p.context_tokens);
2554
2807
  let mut msgs: Vec<Msg> = vec![Msg::System(sys)];
@@ -2592,12 +2845,10 @@ pub fn run_brainstorm(
2592
2845
  // Execute tool calls inline.
2593
2846
  let mut results = Vec::new();
2594
2847
  let mut loop_summary: Option<String> = None;
2848
+ let mut loop_nudge: Option<String> = None;
2595
2849
  for call in &reply.calls {
2596
2850
  if let Some(raw) = call.input.get(tools::INVALID_ARGS).and_then(|v| v.as_str()) {
2597
- let msg = format!(
2598
- "tool arguments were not valid JSON: {}",
2599
- raw.chars().take(200).collect::<String>()
2600
- );
2851
+ let msg = invalid_args_feedback(&call.name, raw, &defs);
2601
2852
  report::tool_denied(&msg);
2602
2853
  note_loop_result(
2603
2854
  &mut loop_guard,
@@ -2605,6 +2856,7 @@ pub fn run_brainstorm(
2605
2856
  &call.input,
2606
2857
  &msg,
2607
2858
  true,
2859
+ &mut loop_nudge,
2608
2860
  &mut loop_summary,
2609
2861
  );
2610
2862
  results.push(ToolResult {
@@ -2638,6 +2890,7 @@ pub fn run_brainstorm(
2638
2890
  &call_input,
2639
2891
  &answer,
2640
2892
  is_error,
2893
+ &mut loop_nudge,
2641
2894
  &mut loop_summary,
2642
2895
  );
2643
2896
  results.push(ToolResult {
@@ -2664,6 +2917,7 @@ pub fn run_brainstorm(
2664
2917
  &call_input,
2665
2918
  &reason,
2666
2919
  true,
2920
+ &mut loop_nudge,
2667
2921
  &mut loop_summary,
2668
2922
  );
2669
2923
  results.push(ToolResult {
@@ -2697,6 +2951,7 @@ pub fn run_brainstorm(
2697
2951
  &call_input,
2698
2952
  &out.content,
2699
2953
  out.is_error,
2954
+ &mut loop_nudge,
2700
2955
  &mut loop_summary,
2701
2956
  );
2702
2957
  results.push(ToolResult {
@@ -2717,6 +2972,9 @@ pub fn run_brainstorm(
2717
2972
  if let Some(loop_msg) = loop_summary {
2718
2973
  break loop_msg;
2719
2974
  }
2975
+ if let Some(nudge) = loop_nudge {
2976
+ msgs.push(Msg::User(nudge));
2977
+ }
2720
2978
  };
2721
2979
 
2722
2980
  // Check for mode-transition suggestion embedded in the reply.
@@ -2764,12 +3022,13 @@ pub fn run_chat_turn(
2764
3022
  cwd: &Path,
2765
3023
  question: &str,
2766
3024
  ) -> Result<(), String> {
3025
+ // Role identity + mode contract come first; environment sections follow.
2767
3026
  let prefix = context_prefix(cwd, p.context_tokens);
2768
3027
  let sys = format!(
2769
- "{prefix}You are buildwithnexus in a coding terminal. Answer the user's current message naturally and concisely. \
3028
+ "You are buildwithnexus in a coding terminal. Answer the user's current message naturally and concisely. \
2770
3029
  If the user asks a normal conversational question or greeting, answer in plain text and do not call tools. \
2771
3030
  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."
3031
+ Do not emit JSON unless a tool call is actually required by the tool protocol.\n\n{prefix}"
2773
3032
  );
2774
3033
 
2775
3034
  let defs = tools::defs_for_context(false, p.context_tokens);
@@ -2796,12 +3055,10 @@ pub fn run_chat_turn(
2796
3055
 
2797
3056
  let mut results = Vec::new();
2798
3057
  let mut loop_summary: Option<String> = None;
3058
+ let mut loop_nudge: Option<String> = None;
2799
3059
  for call in &reply.calls {
2800
3060
  if let Some(raw) = call.input.get(tools::INVALID_ARGS).and_then(|v| v.as_str()) {
2801
- let msg = format!(
2802
- "tool arguments were not valid JSON: {}",
2803
- raw.chars().take(200).collect::<String>()
2804
- );
3061
+ let msg = invalid_args_feedback(&call.name, raw, &defs);
2805
3062
  report::tool_denied(&msg);
2806
3063
  note_loop_result(
2807
3064
  &mut loop_guard,
@@ -2809,6 +3066,7 @@ pub fn run_chat_turn(
2809
3066
  &call.input,
2810
3067
  &msg,
2811
3068
  true,
3069
+ &mut loop_nudge,
2812
3070
  &mut loop_summary,
2813
3071
  );
2814
3072
  results.push(ToolResult {
@@ -2852,6 +3110,7 @@ pub fn run_chat_turn(
2852
3110
  &call_input,
2853
3111
  &reason,
2854
3112
  true,
3113
+ &mut loop_nudge,
2855
3114
  &mut loop_summary,
2856
3115
  );
2857
3116
  results.push(ToolResult {
@@ -2872,6 +3131,7 @@ pub fn run_chat_turn(
2872
3131
  &call_input,
2873
3132
  &out.content,
2874
3133
  out.is_error,
3134
+ &mut loop_nudge,
2875
3135
  &mut loop_summary,
2876
3136
  );
2877
3137
  results.push(ToolResult {
@@ -2894,6 +3154,9 @@ pub fn run_chat_turn(
2894
3154
  report::assistant(&loop_msg);
2895
3155
  return Ok(());
2896
3156
  }
3157
+ if let Some(nudge) = loop_nudge {
3158
+ msgs.push(Msg::User(nudge));
3159
+ }
2897
3160
  }
2898
3161
 
2899
3162
  report::assistant(&format!(
@@ -2955,7 +3218,7 @@ mod tests {
2955
3218
  let defs = tools::defs_for_context(true, 128_000);
2956
3219
  let reply = Reply {
2957
3220
  text: "```json\n{\"name\":\"start_server\",\"arguments\":{\"command\":\"npm start\",\"cwd\":\"/tmp/app\",\"port\":3000}}\n```".to_string(),
2958
- calls: vec![],
3221
+ ..Default::default()
2959
3222
  };
2960
3223
  let normalized = normalize_text_tool_calls(reply, &defs, "build the app");
2961
3224
  assert_eq!(normalized.text, "");
@@ -2969,7 +3232,7 @@ mod tests {
2969
3232
  let defs = tools::defs_for_context(true, 128_000);
2970
3233
  let reply = Reply {
2971
3234
  text: "```json\n{\"name\":\"start_server\",\"arguments\":{\"command\":\"npm start\",\"port\":3000}}\n```".to_string(),
2972
- calls: vec![],
3235
+ ..Default::default()
2973
3236
  };
2974
3237
  let normalized = normalize_text_tool_calls(reply, &defs, "hello");
2975
3238
  assert!(normalized.calls.is_empty());
@@ -3097,7 +3360,9 @@ mod tests {
3097
3360
  }
3098
3361
 
3099
3362
  #[test]
3100
- fn write_file_placeholder_path_is_repaired_from_create_task() {
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.
3101
3366
  let repaired = repair_placeholder_tool_input(
3102
3367
  "write_file",
3103
3368
  &json!({"path": "~", "content": "exit plan smoke\n- Create scratch/exitplan-smoke.txt"}),
@@ -3106,20 +3371,46 @@ mod tests {
3106
3371
  )
3107
3372
  .unwrap();
3108
3373
  assert_eq!(repaired["path"], "scratch/exitplan-smoke.txt");
3109
- assert_eq!(repaired["content"], "exit plan smoke");
3374
+ assert_eq!(
3375
+ repaired["content"],
3376
+ "exit plan smoke\n- Create scratch/exitplan-smoke.txt"
3377
+ );
3110
3378
  }
3111
3379
 
3112
3380
  #[test]
3113
- fn write_file_plan_contaminated_content_is_repaired_from_create_task() {
3114
- let repaired = repair_placeholder_tool_input(
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(
3115
3385
  "write",
3116
3386
  &json!({"path": "scratch/exitplan-smoke.txt", "content": "exit plan smoke\n\n1. Create scratch/exitplan-smoke.txt"}),
3117
3387
  Path::new("/tmp/example-project"),
3118
3388
  "create scratch/exitplan-smoke.txt containing exit plan smoke",
3119
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
+ )
3120
3403
  .unwrap();
3121
- assert_eq!(repaired["path"], "scratch/exitplan-smoke.txt");
3122
- assert_eq!(repaired["content"], "exit plan smoke");
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");
3123
3414
  }
3124
3415
 
3125
3416
  #[test]
@@ -3297,13 +3588,8 @@ mod tests {
3297
3588
  #[test]
3298
3589
  fn gate_ask_allows_default_allowed_commands() {
3299
3590
  let cwd = Path::new("/proj");
3300
- // git, cargo, npm are in the default allowed_commands list
3301
- for cmd in &[
3302
- "git status",
3303
- "cargo test",
3304
- "npm install",
3305
- "git commit -m 'x'",
3306
- ] {
3591
+ // Only read-only binaries remain in the default allowed_commands list.
3592
+ for cmd in &["ls -la", "cat foo.txt", "grep -r pattern ."] {
3307
3593
  let r = gate(
3308
3594
  Permission::Ask,
3309
3595
  "run_command",
@@ -3312,6 +3598,19 @@ mod tests {
3312
3598
  );
3313
3599
  assert!(r.is_none(), "expected {cmd} to be auto-allowed in Ask mode");
3314
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
+ }
3315
3614
  }
3316
3615
 
3317
3616
  #[test]
@@ -3381,4 +3680,260 @@ mod tests {
3381
3680
  super::add_session_allowed_tool("custom_test_tool");
3382
3681
  assert!(super::is_session_allowed_tool("custom_test_tool"));
3383
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
+ }
3384
3939
  }