@noobdemon/noob-cli 1.13.0 → 1.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  Tất cả thay đổi đáng kể của `@noobdemon/noob-cli` được ghi vào file này.
4
4
 
5
+ ## [1.13.1] - 2026-06-28
6
+
7
+ ### Fixed
8
+ - **Text-loop detection** (`src/agent.js`): model lặp cùng 1 câu liên tiếp mà không gọi tool (vd "Tôi sẽ bắt đầu bằng việc khảo sát workspace" 40 lần) — phát hiện ≥2 lần trùng → nudge `[TEXT LOOP]`, ≥3 lần → force stop `[LOOP STOPPED]`. Reset khi model gọi tool thành công.
9
+ - **Duplicate tool call block trước execution** (`src/agent.js`): kiểm tra `duplicateToolGuidance` chạy TRƯỚC `onTool()` (chỉ post-execution trước đó).
10
+ - **Edit-file retry guidance** (`src/tools.js` + `src/agent.js`): lỗi `old_string not found` giờ inject `NEXT REQUIRED TOOL: read_file {path,offset,limit}` + `toolErrorGuidance` system nudge ép model đọc lại file trước khi retry.
11
+ - **Todo nudge phân biệt success/failure** (`src/agent.js`): `todoContinuationMessage` nói "vừa LỖI" khi tool trả `ERROR:`, không đẩy model lặp lại thao tác thất bại.
12
+ - **nearbyContext mở rộng** (`src/tools.js`): context lines khi edit_file lỗi scales với độ dài `old_string` (`Math.max(8, oldLineCount + 5)`).
13
+
14
+ ### Verified
15
+ - `npm test` 109/109 pass.
16
+
5
17
  ## [1.13.0] - 2026-06-27
6
18
 
7
19
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noobdemon/noob-cli",
3
- "version": "1.13.0",
3
+ "version": "1.13.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/agent.js CHANGED
@@ -475,6 +475,24 @@ export function buildUserMessage(history) {
475
475
  return parts.join('\n');
476
476
  }
477
477
 
478
+ export function todoContinuationMessage(toolName, ok, tasks) {
479
+ if (!tasks?.length) return null;
480
+ const next = tasks[0];
481
+ const status = ok ? `đã hoàn thành` : `vừa LỖI`;
482
+ return `[SYSTEM] Việc "${toolName}" ${status}. Còn ${tasks.length} việc: ${tasks.map((t) => `"${t}"`).join(', ')}. Việc tiếp theo BẮT BUỘC phải làm ngay: "${next}". Gọi tool (write_file/edit_file/run_command) để làm việc này. KHÔNG dừng, KHÔNG tóm tắt.`;
483
+ }
484
+
485
+ export function toolErrorGuidance(name, { result } = {}) {
486
+ if (name !== 'edit_file' || !String(result || '').includes('old_string not found')) return null;
487
+ const m = String(result).match(/NEXT REQUIRED TOOL:\s*(read_file \{[^\n]+\})/);
488
+ if (!m) return null;
489
+ return `[SYSTEM] edit_file thất bại vì old_string sai. Bạn PHẢI gọi đúng tool này ngay: ${m[1]}. KHÔNG gọi edit_file lại trước khi đọc file. Sau đó copy đúng text từ read_file mới để retry.`;
490
+ }
491
+
492
+ export function duplicateToolGuidance(name, input) {
493
+ return `[SYSTEM] Tool call vừa rồi TRÙNG HỆT lần trước: ${name} ${JSON.stringify(input || {})}. KHÔNG chạy tool này lại. Dùng kết quả đã có trong history; nếu cần tiến thêm thì gọi tool khác (grep/read_file/edit_file/run_command) hoặc trả lời Markdown nếu xong.`;
494
+ }
495
+
478
496
  // Detect câu trả lời bị cắt giữa chừng — KHÔNG phải câu hoàn chỉnh.
479
497
  // Trả true nếu text kết thúc đột ngột (thiếu dấu câu, list chưa đóng, v.v.).
480
498
  function isIncompleteResponse(text) {
@@ -578,9 +596,13 @@ export async function runAgent({
578
596
  // [GỠ BUDGET 2026-06-06] Không còn token budget enforcement. Agent/loop/sub-agent
579
597
  // chạy không giới hạn token. Dừng theo: GOAL đạt, <<LOOP_DONE>>, <<ULTRA_DONE>>,
580
598
  // model tự kết thúc reply không có tool block, hoặc user Ctrl+C.
581
- const recentCalls = []; // {name, inputStr} — theo dõi vòng lặp
599
+ const recentCalls = []; // {name, inputStr} — theo dõi vòng lặp tool call
582
600
  let loopDetectedCount = 0; // số lần loop detection liên tiếp — reset khi model gọi tool khác
583
601
  const MAX_LOOP_DETECTIONS = 3; // sau 3 lần loop detection liên tiếp → force stop
602
+ // ponytail: text-loop detection — model lặp cùng 1 câu mà không gọi tool
603
+ let lastTextHash = '';
604
+ let textRepeatCount = 0;
605
+ const MAX_TEXT_REPEATS = 3; // cùng text 3 lần liên tiếp → force stop
584
606
  // Effort classifier: phân loại task từ user message gốc → set effort level.
585
607
  // Chỉ classify 1 lần ở bước đầu, giữ nguyên suốt task (thay đổi giữa chừng gây bất ổn).
586
608
  const effort = classifyEffort(history.find((m) => m.role === 'user')?.content || '');
@@ -654,29 +676,59 @@ export async function runAgent({
654
676
  });
655
677
  continue;
656
678
  }
679
+ // ponytail: text-loop detection — model lặp cùng 1 câu không tool
680
+ const textHash = text.trim().slice(0, 200);
681
+ if (textHash === lastTextHash) {
682
+ textRepeatCount++;
683
+ if (textRepeatCount >= MAX_TEXT_REPEATS) {
684
+ history.push({
685
+ role: 'tool',
686
+ name: 'loop_detection',
687
+ content: `[TEXT LOOP × ${textRepeatCount}] Bạn vừa nói cùng 1 câu ${textRepeatCount} lần liên tiếp mà KHÔNG gọi tool. DỪNG NGAY. Gọi read_file/list_dir/grep để thực sự bắt đầu, hoặc trả lời Markdown nếu đã xong.`,
688
+ });
689
+ return `[LOOP STOPPED] Model kẹt trong vòng lặp text: "${text.slice(0, 80)}…"`;
690
+ }
691
+ history.push({
692
+ role: 'tool',
693
+ name: 'loop_detection',
694
+ content: `[TEXT LOOP × ${textRepeatCount}] Bạn vừa lặp lại câu trả lời giống hệt. Hãy gọi tool (read_file/grep/list_dir) hoặc trả lời Markdown khác đi. KHÔNG nói lại câu cũ.`,
695
+ });
696
+ continue; // quay lại loop, ép model gọi tool
697
+ }
698
+ lastTextHash = textHash;
699
+ textRepeatCount = 0;
657
700
  // Model dừng (không tool call, không incomplete) → return để repl quyết định tiếp tục hay không
658
701
  return text; // final answer
659
702
  }
660
703
 
704
+ const inputStr = JSON.stringify(call.input || {});
705
+ const prev = recentCalls[recentCalls.length - 1];
706
+ if (prev && prev.name === call.name && prev.inputStr === inputStr) {
707
+ history.push({
708
+ role: 'tool',
709
+ name: 'loop_detection',
710
+ content: duplicateToolGuidance(call.name, call.input),
711
+ });
712
+ continue;
713
+ }
714
+
661
715
  const { allow, result } = await onTool(call.name, call.input);
662
716
  history.push({
663
717
  role: 'tool',
664
718
  name: call.name,
665
719
  content: allow ? result : t.toolDenied,
666
720
  });
721
+ const toolOk = allow && !String(result || '').startsWith('ERROR:');
722
+ const errorNudge = allow ? toolErrorGuidance(call.name, { result }) : null;
723
+ if (errorNudge) history.push({ role: 'user', content: errorNudge });
667
724
 
668
725
  // ── Todo continuation nudge ──────────────────────────────────────────
669
726
  // Sau mỗi tool result, inject nudge nếu còn task chưa xong.
670
727
  // Dùng pendingTasks (caller gửi vào) thay vì parse output của model.
671
728
  {
672
729
  const tasks = pendingTasks || [];
673
- if (tasks.length > 0) {
674
- const next = tasks[0];
675
- history.push({
676
- role: 'user',
677
- content: `[SYSTEM] Việc "${call.name}" đã hoàn thành. Còn ${tasks.length} việc: ${tasks.map((t) => `"${t}"`).join(', ')}. Việc tiếp theo BẮT BUỘC phải làm ngay: "${next}". Gọi tool (write_file/edit_file/run_command) để làm việc này. KHÔNG dừng, KHÔNG tóm tắt.`,
678
- });
679
- }
730
+ const msg = todoContinuationMessage(call.name, toolOk, tasks);
731
+ if (msg) history.push({ role: 'user', content: msg });
680
732
  }
681
733
 
682
734
  // ── Loop detection ──────────────────────────────────────────────────
@@ -685,7 +737,6 @@ export async function runAgent({
685
737
  // (B) Pattern vòng (A-B-A-B, A-B-C-A-B-C) — model xen kẽ 2-3 tool
686
738
  // khác nhau để tránh phát hiện cũ. So nửa đầu vs nửa cuối window.
687
739
  // Nếu phát hiện → inject cảnh báo. Nếu tái diễn → force stop.
688
- const inputStr = JSON.stringify(call.input || {});
689
740
  recentCalls.push({ name: call.name, inputStr });
690
741
  if (recentCalls.length > LOOP_DETECT_WINDOW) recentCalls.shift();
691
742
  let loopType = null; // 'consecutive' | 'pattern' | null
@@ -749,6 +800,8 @@ export async function runAgent({
749
800
  });
750
801
  } else {
751
802
  loopDetectedCount = 0; // tool khác → reset
803
+ lastTextHash = ''; // text-loop: reset sau tool call thành công
804
+ textRepeatCount = 0;
752
805
  }
753
806
  }
754
807
  return t.maxSteps;
package/src/tools.js CHANGED
@@ -408,7 +408,7 @@ export const TOOLS = {
408
408
  // Không thấy → lỗi GIÀU THÔNG TIN: cho model thấy đúng byte trong file để sửa.
409
409
  throw new Error(
410
410
  `old_string not found in ${rel(file)}. Copy the target text EXACTLY (indentation/whitespace included, NO line-number prefix).` +
411
- nearbyContext(data, old_string)
411
+ nearbyContext(data, old_string, rel(file))
412
412
  );
413
413
  },
414
414
 
@@ -730,7 +730,8 @@ function matchByLines(data, oldStr) {
730
730
 
731
731
  // Khi không khớp: in vùng file gần dòng giống nhất, dạng JSON-escaped để model
732
732
  // thấy rõ tab/space → sửa old_string cho khớp ngay lần sau.
733
- function nearbyContext(data, oldStr) {
733
+ function nearbyContext(data, oldStr, p) {
734
+ const oldLineCount = oldStr.replace(/\r\n/g, '\n').split('\n').length;
734
735
  const want = (
735
736
  oldStr
736
737
  .replace(/\r\n/g, '\n')
@@ -756,14 +757,19 @@ function nearbyContext(data, oldStr) {
756
757
  }
757
758
  }
758
759
  if (hit < 0 || best < 6)
759
- return ` (no similar line found; the file has ${lines.length} lines — re-read it.)`;
760
+ return ` (no similar line found; the file has ${lines.length} lines — re-read it.)${readFileRetryHint(p, 1, Math.min(lines.length, 80))}`;
760
761
  const a = Math.max(0, hit - 2);
761
- const b = Math.min(lines.length, hit + 3);
762
+ const b = Math.min(lines.length, hit + Math.max(8, oldLineCount + 5));
762
763
  const snippet = lines
763
764
  .slice(a, b)
764
765
  .map((l, k) => ` ${a + k + 1}: ${JSON.stringify(l)}`)
765
766
  .join('\n');
766
- return `\nActual file lines near the closest match (JSON-escaped — match these bytes EXACTLY):\n${snippet}`;
767
+ return `\nActual file lines near the closest match (JSON-escaped — match these bytes EXACTLY):\n${snippet}${readFileRetryHint(p, a + 1, b - a)}`;
768
+ }
769
+
770
+ function readFileRetryHint(pathHint, offset, limit) {
771
+ const pathPart = `"path":${JSON.stringify(pathHint || '<same path>')},`;
772
+ return `\nNEXT REQUIRED TOOL: read_file {${pathPart}"offset":${offset},"limit":${limit}}. Then retry edit_file using text copied from that fresh read_file result.`;
767
773
  }
768
774
 
769
775
  function globToRegExp(glob) {