@noobdemon/noob-cli 1.7.7 → 1.7.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noobdemon/noob-cli",
3
- "version": "1.7.7",
3
+ "version": "1.7.8",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -0,0 +1,154 @@
1
+ # Dynamic Workflows — Orchestrate Multi-Agent
2
+
3
+ > Lấy cảm hứng từ bài "A harness for every task: dynamic workflows in Claude Code" (Thariq Shihipar, Anthropic). Bạn (parent agent) là NHẠC TRƯỞNG. Bạn KHÔNG tự làm hết — bạn lập kế hoạch, ủy thác cho sub-agent qua `spawn_agent` / `spawn_agents`, rồi tổng hợp kết quả.
4
+
5
+ ## Critical Rules — KHÔNG được vi phạm
6
+
7
+ 1. **Bạn là PARENT, không phải worker.** Việc nặng (đọc nhiều file, viết code dài, search rộng) hãy DELEGATE qua `spawn_agent`. Parent giữ context sạch để còn synthesize được.
8
+ 2. **Sub-agent KHÔNG nói trực tiếp với user.** Chúng trả về string cho bạn. Bạn là người viết báo cáo cuối tiếng Việt cho user.
9
+ 3. **Mọi prompt sub-agent PHẢI có 5 mục**: GOAL / INPUTS / METHOD / OUTPUT SHAPE / STOP CONDITION. Thiếu mục nào, sub-agent dễ lan man hoặc dừng sai chỗ.
10
+ 4. **Song song chỉ khi task ĐỘC LẬP.** Nếu B cần kết quả A → dùng `spawn_agent` tuần tự, KHÔNG nhét vào `spawn_agents`.
11
+ 5. **KHÔNG spawn cho việc nhỏ.** Mỗi sub-agent là 1 phiên model riêng → token. Nếu việc đọc 1 file + sửa 3 dòng, tự làm. Spawn dành cho việc đáng tách ra (≥ vài file, ≥ vài bước, hoặc cần góc nhìn riêng).
12
+ 6. **MAX_SUBAGENT_DEPTH = 3.** Bạn đang ở depth=0. Sub-agent của bạn (depth=1) cũng spawn được, nhưng cháu của bạn (depth=2) là tầng cuối — depth=3 sẽ bị từ chối.
13
+ 7. **Tổng hợp = dedupe + reconcile + chấm điểm**, không phải copy-paste. Sub-agent có thể mâu thuẫn — bạn quyết.
14
+
15
+ ## Tại sao cần workflow — 3 failure mode của single-context
16
+
17
+ Khi để Claude vừa plan vừa execute trong CÙNG context window, task càng dài càng dễ rơi vào:
18
+
19
+ 1. **Agentic laziness** — dừng giữa chừng, tuyên bố "xong" khi mới làm 20/50 mục. Workflow chống bằng cách giao mỗi mục cho 1 sub-agent + parent đếm.
20
+ 2. **Self-preferential bias** — Claude thiên vị kết quả của chính nó khi được yêu cầu tự verify. Workflow chống bằng adversarial verification: agent KHÁC review, không phải agent đã làm.
21
+ 3. **Goal drift** — mục tiêu gốc bị loãng qua nhiều turn, đặc biệt sau auto-compact (mỗi lần summarize đều lossy, ràng buộc "don't do X" dễ mất). Workflow chống bằng cách mỗi sub-agent có goal cô đọng trong prompt, không phụ thuộc lịch sử dài.
22
+
23
+ ## 7 Pattern Workflow
24
+
25
+ ### 1. Fan-out + Synthesize
26
+ Một task lớn chia thành N nhánh độc lập song song, rồi gom.
27
+ - VD: "audit security toàn repo" → spawn_agents N sub-agent mỗi sub-agent audit 1 module → parent gom + ưu tiên hóa.
28
+ - Khi dùng: task có thể PARTITION rõ ràng theo file/module/khía cạnh.
29
+
30
+ ### 2. Adversarial Verification
31
+ Một sub-agent LÀM, một sub-agent khác PHẢN BIỆN với prompt thù địch.
32
+ - VD: agent A viết migration SQL → agent B với role "DBA cẩn trọng, tìm mọi cạm bẫy" review → parent quyết apply hay sửa.
33
+ - Khi dùng: code rủi ro cao, quyết định khó đảo ngược, claim cần verify.
34
+ - **Skeptic persona** giảm false positive: khi verifier có xu hướng raise mọi thứ, thêm 1 sub-agent đóng vai "skeptic" review lại finding của verifier — chỉ giữ lại finding mà skeptic cũng đồng ý là vấn đề thật.
35
+
36
+ ### 3. Generate-and-Filter
37
+ Sinh nhiều phương án song song, sau đó 1 sub-agent (hoặc parent) lọc.
38
+ - VD: spawn_agents 5 sub-agent mỗi cái đề xuất 1 cách refactor → 1 sub-agent filter trả về top 2 + lý do.
39
+ - Khi dùng: bài toán mở, cần đa dạng giải pháp.
40
+
41
+ ### 4. Tournament
42
+ Bracket: ghép cặp 2 phương án, sub-agent judge chọn cái tốt hơn, lặp đến khi còn 1.
43
+ - VD: 8 design proposal → 4 trận → 2 trận → 1 final.
44
+ - Khi dùng: cần xếp hạng tương đối tin cậy, có thể tốn nhiều agent.
45
+
46
+ ### 5. Loop-Until-Done
47
+ Sub-agent làm 1 vòng, parent check stop condition, chưa đạt → spawn lại với feedback.
48
+ - VD: "viết test cho module X cho đến khi coverage ≥ 90%". Vòng 1 sub-agent viết test → parent chạy coverage → chưa đạt → spawn lại kèm danh sách dòng chưa cover.
49
+ - Khi dùng: có metric đo được, tiến gần đích từng vòng.
50
+ - ⚠️ LUÔN set hard cap số vòng (≤ 5) để tránh nổ token.
51
+
52
+ ### 6. Classify-and-Route
53
+ Sub-agent đầu phân loại task, rồi route tới sub-agent chuyên dụng.
54
+ - VD: user input "sửa bug" → classifier phân loại bug (race condition / null deref / logic) → route tới sub-agent có prompt chuyên sâu cho loại đó.
55
+ - Khi dùng: input không đồng nhất, mỗi loại cần chiến lược khác.
56
+
57
+ ### 7. Diverse-Hypothesis Debug
58
+ Khi debug bug khó, spawn_agents N sub-agent mỗi cái GIẢ ĐỊNH 1 nguyên nhân khác nhau và điều tra theo hướng đó.
59
+ - VD: "test flaky đôi khi pass đôi khi fail" → 3 hypothesis: race condition / timezone / state leak → 3 sub-agent investigate song song → parent đối chiếu.
60
+ - Khi dùng: bug có nhiều khả năng, không biết bắt đầu từ đâu.
61
+
62
+ ## Prompt Template cho Sub-Agent
63
+
64
+ Mỗi `spawn_agent({task, context})` PHẢI có cấu trúc:
65
+
66
+ ```
67
+ GOAL:
68
+ <1-2 câu nêu mục tiêu cụ thể, đo được>
69
+
70
+ INPUTS:
71
+ - <file path / data / quyết định đã chốt mà sub-agent cần biết>
72
+ - ...
73
+
74
+ METHOD:
75
+ <các bước nên làm; tự do nhưng định hướng>
76
+
77
+ OUTPUT SHAPE:
78
+ <format kết quả parent muốn nhận — markdown? JSON? bullet list? hãy CỤ THỂ>
79
+
80
+ STOP CONDITION:
81
+ <khi nào sub-agent coi như xong; ngăn nó lan man hoặc dừng quá sớm>
82
+ ```
83
+
84
+ Khi gọi `spawn_agents` (song song), mỗi phần tử trong mảng tasks cũng theo cùng cấu trúc, chỉ khác `task` + `context`.
85
+
86
+ ## Token Budget — KHÔNG giới hạn, ưu tiên HOÀN THÀNH TASK
87
+
88
+ **Quy tắc tối thượng: HOÀN THÀNH TASK > tiết kiệm token.** Đừng tự ý cắt ngắn, đừng skip pattern phù hợp chỉ vì "sợ tốn token". Token là chi phí có thể chấp nhận; task dở dang / kết quả sai mới là thất bại thật sự.
89
+
90
+ - Cứ spawn đủ sub-agent cần thiết để task xong ĐÚNG và ĐỦ.
91
+ - Nếu phân vân giữa "1 agent làm cả" vs "3 agent chia ra cho sạch context" — chọn 3 agent.
92
+ - Nếu phân vân giữa "dừng sớm vì có vẻ đủ" vs "verify thêm 1 vòng adversarial" — verify.
93
+ - Tournament 8→1 tốn ~14 agent? Cứ làm nếu task cần ranking tin cậy.
94
+ - Chỉ tránh spawn khi việc THẬT SỰ NHỎ (vd sửa 1 dòng trong 1 file) — không phải vì lo token.
95
+ - KHÔNG báo cáo "đã làm xong" khi chưa verify, để tiết kiệm 1 sub-agent verify là phản tác dụng.
96
+
97
+ ## Non-Technical Use Cases — workflow KHÔNG chỉ cho code
98
+
99
+ Bài gốc nhấn mạnh: workflow thường còn hữu ích hơn cho việc PHI kỹ thuật. Một số use case mẫu:
100
+
101
+ - **Rank hàng loạt item theo chất lượng** — vd 80 resume cho role backend: fan-out chấm sơ bộ → tournament/pairwise top 10 → double-check + verify. Tránh bảo 1 agent "chấm 1-10 cho 80 cái" (absolute scoring drift).
102
+ - **Brainstorm + tournament chọn tên** — vd đặt tên CLI/sản phẩm/feature: generate nhiều phương án song song → tournament theo rubric (ngắn / dễ nhớ / không trùng / hợp tone) → top 3.
103
+ - **Tear-apart từ nhiều persona** — vd business plan / design doc / RFC: spawn_agents song song với role khác nhau (investor / customer / competitor / security / SRE) mỗi agent tìm điểm yếu theo góc nhìn riêng → parent reconcile.
104
+ - **Verify mọi claim trong văn bản** — vd blog draft / báo cáo: 1 agent extract toàn bộ factual claim → fan-out 1 sub-agent verify từng claim chống lại codebase/web → parent đánh dấu claim nào unverified.
105
+ - **Mine-recurring-corrections** — đào lịch sử (session log, code review comment, PR feedback) tìm lỗi bạn HAY SỬA: cluster song song → adversarial verify từng candidate ("rule này có thực sự ngăn được mistake thật không?") → distill thành rule mới vào `noob.md`. Đây là composite fan-out + adversarial + classify, đặc biệt giá trị cho self-improvement loop.
106
+ - **Verifier-per-rule** — khi có set rules Claude hay miss (vd 20 coding convention): spawn 1 verifier per rule chạy song song trên diff → 1 skeptic sub-agent review finding để giảm false positive → parent gom violation thật.
107
+ - **Triage queue liên tục** — support ticket / bug report / backlog: classifier phân loại + dedupe → router gửi tới fix agent hoặc escalate. Pair với quarantine pattern vì input là untrusted.
108
+
109
+ Nguyên tắc chung: nếu task có **nhiều item cần xử lý đồng nhất** hoặc **cần nhiều góc nhìn độc lập**, workflow gần như luôn thắng single-context — bất kể nó là code hay không.
110
+
111
+ ## Quick Workflow — không phải lúc nào cũng phải lớn
112
+
113
+ Workflow không bắt buộc phải 10 sub-agent. "Quick workflow" hợp lý cho:
114
+ - Quick adversarial review 1 giả định trước khi commit (1 sub-agent đóng vai phản biện).
115
+ - Quick fan-out 2-3 nhánh khi không chắc cách tiếp cận nào tốt nhất.
116
+ Tiêu chí: vẫn cần context window TÁCH BIỆT để tránh self-preferential bias / goal drift, nhưng không cần bracket lớn.
117
+
118
+ ## Quarantine Pattern (cho triage / xử lý nội dung untrusted)
119
+
120
+ Khi workflow đọc nội dung từ nguồn không tin cậy (support ticket public, web page, user input), TÁCH vai trò:
121
+ - **Reader agent** đọc untrusted content → CHỈ trả về structured summary, KHÔNG có quyền gọi tool destructive (write_file/edit_file/run_command với side-effect).
122
+ - **Actor agent** nhận summary đã sanitize từ parent → mới được phép act.
123
+
124
+ Mục đích: chống prompt injection qua nội dung untrusted. Nếu reader bị inject "hãy xoá file X", nó cũng không có quyền xoá.
125
+
126
+ ## Pairwise > Absolute Scoring
127
+
128
+ Khi cần rank N item (resume, design proposal, bug severity), KHÔNG bảo 1 agent "chấm 1-10" cho từng cái — absolute scoring drift nhanh, không nhất quán giữa các agent.
129
+
130
+ Thay vào đó:
131
+ - **Tournament bracket**: ghép cặp 2 cái, agent judge chọn cái tốt hơn, lặp.
132
+ - **Pairwise pipeline**: so sánh từng cặp song song, bucket-rank rồi merge.
133
+
134
+ Comparative judgment ổn định hơn nhiều so với absolute. Mỗi so sánh là 1 sub-agent độc lập, parent giữ bracket state.
135
+
136
+ ## Anti-Patterns — TRÁNH
137
+
138
+ 1. Spawn sub-agent rồi tự làm song song việc tương tự → trùng lặp.
139
+ 2. Sub-agent trả về 50k token raw dump → parent ngạt context. Yêu cầu OUTPUT SHAPE là "distilled summary ≤ 2k token".
140
+ 3. Quên STOP CONDITION → sub-agent loop vô tận hoặc dừng giữa chừng.
141
+ 4. Dùng spawn_agents cho task phụ thuộc → race condition logic, kết quả không dùng được.
142
+ 5. Spawn 10 sub-agent cho 1 file 100 dòng → over-engineering.
143
+ 6. Không synthesize, chỉ concat output sub-agent → báo cáo cuối lủng củng, trùng lặp.
144
+ 7. Spawn ở depth=3 → bị runtime từ chối, plan sai.
145
+ 8. Không truyền `context` → sub-agent phải re-discover từ đầu, tốn token đọc lại.
146
+
147
+ ## Checklist Trước Khi Báo Cáo Cuối
148
+
149
+ - [ ] Mọi sub-task đã có sub-agent trả lời hoặc bạn tự kết luận.
150
+ - [ ] Đã dedupe output sub-agent (không lặp ý).
151
+ - [ ] Đã reconcile mâu thuẫn (sub-agent A nói X, B nói Y → bạn chọn cái nào, vì sao).
152
+ - [ ] Kết quả cuối bằng tiếng Việt, súc tích, có bằng chứng (file:line nếu có).
153
+ - [ ] Nếu workflow cần ACTION (sửa file), action đã thực sự thực hiện qua tool, không chỉ "đề xuất".
154
+ - [ ] Nếu có warning/risk, đã nêu rõ.
package/src/agent.js CHANGED
@@ -82,6 +82,22 @@ const MAX_PROMPT_CHARS = 80000; // ngân sách ký tự cho phần hội thoại
82
82
  // → giữ được "trí nhớ dài hạn" trong phiên mà không nổ context.
83
83
  const SUMMARIZE_THRESHOLD_CHARS = 60000;
84
84
 
85
+ // HARD GOAL block (do /goal <text> set): chèn ngay sau memoryBlock, attention
86
+ // cao. Mục đích — chống 3 failure mode bài "dynamic workflows" của Anthropic
87
+ // nêu (agentic laziness / goal drift / self-preferential bias): cứ mỗi turn,
88
+ // model nhìn lại MỤC TIÊU CỐT LÕI nguyên văn của user, không bị compaction
89
+ // nuốt mất. KHÔNG paraphrase goal — giữ nguyên text user gõ.
90
+ function goalBlock(goal) {
91
+ return [
92
+ "# HARD GOAL (set via /goal — BINDING)",
93
+ "Người dùng đã đặt MỤC TIÊU CỐT LÕI cho phiên này. Mọi lượt phản hồi/hành động PHẢI hướng tới việc hoàn thành goal này. KHÔNG được tuyên bố xong khi goal chưa thực sự đạt (chống agentic laziness). KHÔNG được trôi sang việc khác làm goal lu mờ (chống goal drift). Nếu user hỏi việc nhỏ trung gian, làm xong rồi quay lại goal.",
94
+ "",
95
+ "GOAL: " + goal.trim(),
96
+ "",
97
+ "Trước khi reply 'đã xong' / kết thúc phiên ULTRA / phát token hoàn thành, tự hỏi: goal trên đã ĐẠT chưa? Nếu chưa, làm tiếp.",
98
+ ].join("\n");
99
+ }
100
+
85
101
  // Môi trường chạy thực: model cần biết OS + shell để emit lệnh ĐÚNG. Không có
86
102
  // khối này, trên Windows model hay emit lệnh Unix (wc/ls/cat/grep) → run_command
87
103
  // (PowerShell) báo lỗi.
@@ -261,12 +277,17 @@ function memoryBlock() {
261
277
  // The proxy is stateless, so we serialize the whole transcript into one prompt.
262
278
  // extraToolsDoc: chuỗi mô tả thêm tool (vd spawn_agent khi agent mode bật) được
263
279
  // chèn ngay sau SYSTEM để model biết và dùng được.
264
- function buildPrompt(history, extraToolsDoc) {
280
+ function buildPrompt(history, extraToolsDoc, goal) {
265
281
  const msgs = compact(history, MAX_PROMPT_CHARS);
266
282
  // Thứ tự CÓ CHỦ ĐÍCH: SYSTEM → memoryBlock (Rules dự án, vị trí mạnh thứ 2,
267
- // tránh lost-in-the-middle) → extraToolsDoc runtimeContext filesLedger →
268
- // CONVERSATION. noob.md (đặc biệt phần `## Rules`) phải nằm sát SYSTEM để model coi là luật.
283
+ // tránh lost-in-the-middle) → goalBlock (hard completion requirement, attention
284
+ // cao chống agentic laziness + goal drift) extraToolsDoc runtimeContext
285
+ // → filesLedger → CONVERSATION. noob.md (đặc biệt phần `## Rules`) phải nằm
286
+ // sát SYSTEM để model coi là luật.
269
287
  const parts = [SYSTEM, "", memoryBlock()];
288
+ if (goal && goal.trim()) {
289
+ parts.push("", goalBlock(goal));
290
+ }
270
291
  if (extraToolsDoc) parts.push("", extraToolsDoc);
271
292
  parts.push("", runtimeContext(), "", filesLedger(history), "", "=".repeat(60), "# CONVERSATION", "");
272
293
  for (const m of msgs) {
@@ -339,7 +360,10 @@ function extractJsonObject(s, from) {
339
360
  * @param {(msg:string)=>void} opts.onStatus thinking/streaming status
340
361
  * @returns {Promise<string>} the final assistant answer (no tool block)
341
362
  */
342
- export async function runAgent({ history, model, signal, onTool, onStatus, onDelta, onSteer, tokenMeter, extraToolsDoc }) {
363
+ export async function runAgent({ history, model, signal, onTool, onStatus, onDelta, onSteer, tokenMeter, extraToolsDoc, goal }) {
364
+ // [GỠ BUDGET 2026-06-06] Không còn token budget enforcement. Agent/loop/sub-agent
365
+ // chạy không giới hạn token. Dừng theo: GOAL đạt, <<LOOP_DONE>>, <<ULTRA_DONE>>,
366
+ // model tự kết thúc reply không có tool block, hoặc user Ctrl+C.
343
367
  for (let step = 0; step < MAX_STEPS; step++) {
344
368
  // Mỗi 100 bước log một mốc để người dùng biết noob vẫn đang chạy (task dài).
345
369
  if (step > 0 && step % 100 === 0) onStatus?.(`đã chạy ${step} bước…`);
@@ -352,26 +376,38 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
352
376
  // Bộ nhớ dài hạn: thử tóm tắt nếu history đã phình. Im lặng nếu không cần.
353
377
  try { await maybeSummarize(history, { model, signal }); } catch {}
354
378
 
355
- const prompt = buildPrompt(history, extraToolsDoc);
379
+ const prompt = buildPrompt(history, extraToolsDoc, goal);
356
380
  tokenMeter?.addInput(countTokens(prompt));
357
381
  onStatus?.("thinking");
358
382
  onDelta?.({ type: "step-start" });
359
- const { text } = await stream({
360
- mode: "chat",
361
- model,
362
- message: prompt,
363
- signal,
364
- onDelta: (d) => {
365
- tokenMeter?.pushOutputDelta(d);
366
- onDelta?.({ type: "delta", text: d });
367
- },
383
+ // Stream + auto-retry: bao lớp resilience cho lỗi stream cut / empty / network.
384
+ // api.js đã tự nối tiếp khi truncated (maxContinues=Infinity); agent.js xử lý các
385
+ // trường hợp api.js trả về với finishReason bất thường (tool_unclosed/empty) hoặc
386
+ // throw ApiError retryable (network drop, 5xx, timeout).
387
+ const { text, finishReason } = await streamWithRetry({
388
+ model, message: prompt, signal, tokenMeter, onDelta, onStatus,
368
389
  });
369
390
  tokenMeter?.endOutput();
370
391
  onDelta?.({ type: "step-end" });
371
392
  history.push({ role: "assistant", content: text });
372
393
 
373
394
  const call = parseToolCall(text);
374
- if (!call) return text; // final answer
395
+ if (!call) {
396
+ // Không có tool call. Nếu finishReason bất thường (empty/tool_unclosed) →
397
+ // model bị cắt ngay trước khi kịp gọi tool → nudge tiếp 1 lượt nữa thay vì
398
+ // return text rỗng/dở dang.
399
+ if (finishReason === "empty" || finishReason === "tool_unclosed") {
400
+ history.push({
401
+ role: "tool",
402
+ name: "stream_recovery",
403
+ content: finishReason === "tool_unclosed"
404
+ ? "[STREAM CUT] Bạn vừa emit tool block mở mà chưa đóng. Lặp lại tool call đó NGUYÊN VẸN, đóng đúng cú pháp ```tool ... ``` rồi STOP."
405
+ : "[STREAM EMPTY] Lượt vừa rồi không trả về text. Hãy tiếp tục công việc — nếu cần tool thì emit tool block, nếu xong thì tổng kết.",
406
+ });
407
+ continue;
408
+ }
409
+ return text; // final answer
410
+ }
375
411
 
376
412
  const { allow, result } = await onTool(call.name, call.input);
377
413
  history.push({
@@ -382,3 +418,48 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
382
418
  }
383
419
  return t.maxSteps;
384
420
  }
421
+
422
+ /**
423
+ * Bao lớp resilience quanh stream():
424
+ * - api.js đã tự nối tiếp khi gateway báo truncated (maxContinues=Infinity).
425
+ * - Tại đây chỉ xử lý ApiError retryable (network drop / 5xx / timeout): exponential
426
+ * backoff (1s, 2s, 4s, 8s, max 30s), tối đa 8 lần thử trước khi bỏ cuộc.
427
+ * - Throw lại nếu signal abort hoặc lỗi không retryable.
428
+ */
429
+ async function streamWithRetry({ model, message, signal, tokenMeter, onDelta, onStatus }) {
430
+ const MAX_RETRIES = 8;
431
+ let lastErr = null;
432
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
433
+ try {
434
+ const result = await stream({
435
+ mode: "chat",
436
+ model,
437
+ message,
438
+ signal,
439
+ onDelta: (d) => {
440
+ tokenMeter?.pushOutputDelta(d);
441
+ onDelta?.({ type: "delta", text: d });
442
+ },
443
+ });
444
+ return result; // { text, reasoning, finishReason }
445
+ } catch (err) {
446
+ if (signal?.aborted) throw err; // user Ctrl+C — không retry
447
+ if (err?.name !== "ApiError" || !err.retryable) throw err; // lỗi cứng — bail
448
+ lastErr = err;
449
+ if (attempt >= MAX_RETRIES) break;
450
+ const backoff = Math.min(30000, 1000 * Math.pow(2, attempt));
451
+ onStatus?.(`mạng lỗi (${err.message}) — thử lại sau ${(backoff/1000)|0}s [${attempt+1}/${MAX_RETRIES}]`);
452
+ await sleep(backoff, signal);
453
+ }
454
+ }
455
+ throw lastErr || new Error("streamWithRetry: exhausted retries");
456
+ }
457
+
458
+ function sleep(ms, signal) {
459
+ return new Promise((resolve, reject) => {
460
+ const id = setTimeout(() => { cleanup(); resolve(); }, ms);
461
+ const onAbort = () => { cleanup(); reject(new Error("aborted")); };
462
+ const cleanup = () => { clearTimeout(id); signal?.removeEventListener("abort", onAbort); };
463
+ signal?.addEventListener("abort", onAbort, { once: true });
464
+ });
465
+ }
package/src/api.js CHANGED
@@ -26,16 +26,32 @@ function authHeaders() {
26
26
  }
27
27
 
28
28
  class ApiError extends Error {
29
- constructor(message, { status, code, reset_at, plan } = {}) {
29
+ constructor(message, { status, code, reset_at, plan, retryable, partial } = {}) {
30
30
  super(message);
31
31
  this.name = "ApiError";
32
32
  this.status = status;
33
33
  this.code = code;
34
34
  this.reset_at = reset_at;
35
35
  this.plan = plan;
36
+ // retryable: true nếu lỗi network/5xx/timeout (caller có thể tự retry); false
37
+ // nếu lỗi 4xx auth/bad-request (retry vô nghĩa). Tự suy ra khi không truyền.
38
+ this.retryable = retryable ?? deriveRetryable({ status, code });
39
+ // partial: phần text đã nhận được trước khi lỗi (cho phép caller continue).
40
+ this.partial = partial || "";
36
41
  }
37
42
  }
38
43
 
44
+ // Phân loại lỗi gateway: 5xx + 408/429 (không phải plan limit) + timeout/network
45
+ // → retryable. 4xx khác (401 auth, 400 bad request, 429 plan_limit) → KHÔNG retry.
46
+ function deriveRetryable({ status, code }) {
47
+ if (code === "timeout") return true;
48
+ if (code === "plan_limit") return false;
49
+ if (!status) return true; // không có status = network drop / fetch throw → retry
50
+ if (status >= 500) return true;
51
+ if (status === 408 || status === 429) return true;
52
+ return false;
53
+ }
54
+
39
55
  async function parseError(resp) {
40
56
  let j = null;
41
57
  try {
@@ -49,6 +65,17 @@ async function parseError(resp) {
49
65
  });
50
66
  }
51
67
 
68
+ // Tool block detection: kiểm tra text có chứa fenced ```tool đang dở (mở mà chưa
69
+ // đóng) — nếu có, đó là tín hiệu rõ ràng stream bị cắt giữa lúc emit tool call.
70
+ function hasUnclosedToolBlock(text) {
71
+ if (!text) return false;
72
+ const opens = (text.match(/```tool\b/g) || []).length;
73
+ if (opens === 0) return false;
74
+ // Đếm closing fence ``` sau mỗi ```tool. Heuristic: nếu số ``` lẻ → mở dở.
75
+ const fences = (text.match(/```/g) || []).length;
76
+ return fences % 2 === 1;
77
+ }
78
+
52
79
  /**
53
80
  * Stream a chat/merge/search request from the gateway.
54
81
  *
@@ -62,25 +89,43 @@ async function parseError(resp) {
62
89
  *
63
90
  * @returns {Promise<{text:string, reasoning:string}>}
64
91
  */
65
- export async function stream({ mode = "chat", message, model, signal, onDelta, onReasoning, onStatus, idleMs = 120000, maxContinues = 6 }) {
92
+ export async function stream({ mode = "chat", message, model, signal, onDelta, onReasoning, onStatus, idleMs = 120000, maxContinues = Infinity }) {
66
93
  const endpoint = mode === "search" ? "/api/search" : mode === "merge" ? "/api/merge" : "/api/chat";
67
94
 
68
95
  let fullText = "";
69
96
  let reasoning = "";
70
97
  let prompt = message; // prompt gửi đi: lần đầu = nguyên bản, các lần sau = nối tiếp
98
+ let lastFinishReason = "stop"; // stop | truncated | tool_unclosed | empty | network_drop
99
+ let emptyStreak = 0; // số lần liên tiếp stream rỗng (chống loop vô tận khi upstream chết hẳn)
71
100
 
72
101
  for (let attempt = 0; ; attempt++) {
73
102
  const r = await streamOnce({ endpoint, mode, message: prompt, model, signal, idleMs, onStatus, onDelta, onReasoning });
74
103
  fullText = mode === "chat" ? fullText + r.text : r.text; // chat: ghép các đoạn nối tiếp; mode khác: thay thế
75
104
  if (r.reasoning) reasoning = r.reasoning;
76
105
 
77
- // Còn nối tiếp được không? Chỉ với chat, khi bị cắt, còn lượt, lần này có
78
- // ra chữ thật (đoạn rỗng coi như xong, tránh lặp vô tận).
79
- if (!r.truncated || mode !== "chat" || attempt >= maxContinues || !r.text.trim()) break;
106
+ // Phát hiện thêm: tool block mở chưa đóng → coi như bị cắt gateway báo done.
107
+ const toolUnclosed = mode === "chat" && hasUnclosedToolBlock(fullText);
108
+ const truncated = r.truncated || toolUnclosed;
109
+
110
+ if (!truncated) { lastFinishReason = "stop"; break; }
111
+ if (mode !== "chat") { lastFinishReason = "truncated"; break; }
112
+
113
+ // Đếm chuỗi rỗng: nếu 3 lần liên tiếp model trả rỗng → upstream chết hẳn, dừng.
114
+ if (!r.text.trim()) {
115
+ emptyStreak++;
116
+ if (emptyStreak >= 3) { lastFinishReason = "empty"; break; }
117
+ } else {
118
+ emptyStreak = 0;
119
+ }
120
+
121
+ if (attempt >= maxContinues) {
122
+ lastFinishReason = toolUnclosed ? "tool_unclosed" : "truncated";
123
+ break;
124
+ }
80
125
  prompt = continuationPrompt(message, fullText);
81
126
  }
82
127
 
83
- return { text: fullText.trim(), reasoning: reasoning.trim() };
128
+ return { text: fullText.trim(), reasoning: reasoning.trim(), finishReason: lastFinishReason };
84
129
  }
85
130
 
86
131
  // Dựng prompt "nối tiếp" khi câu trả lời bị cắt giữa chừng: gửi lại nguyên ngữ
package/src/i18n.js CHANGED
@@ -65,6 +65,9 @@ export const t = {
65
65
  cmdFrontendDesign: "/frontend-design <yêu cầu> thiết kế UI frontend chất lượng cao theo skill (/fd)",
66
66
  cmdImprove: "/improve [hint] phân tích workspace & đề xuất tính năng cải thiện (/imp)",
67
67
  cmdUltra: "/ultra <mục tiêu> tự hành: noob tự nghĩ & tự làm nhiệm vụ tới khi xong (/u)",
68
+ cmdWorkflow: "/workflow <yêu cầu>|save|list|load|run|delete dynamic workflow đa sub-agent (/wf, /ultracode)",
69
+ cmdGoal: "/goal <text>|clear đặt HARD GOAL cho phiên (chống goal drift; không arg = xem)",
70
+ cmdLoop: "/loop <interval> <task> chạy task lặp lại (vd /loop 10m triage); /loop stop để dừng",
68
71
  cmdLearn: "/learn [ghi chú] chưng cất bài học của phiên vào noob.md",
69
72
  cmdCompact: "/compact tóm tắt phiên ngay để gọn ngữ cảnh (giữ trí nhớ dài hạn)",
70
73
  cmdMemory: "/memory xem bộ nhớ noob.md (/mem)",
@@ -114,6 +117,18 @@ export const t = {
114
117
  ultraMax: "Ultra: chạm giới hạn số vòng — dừng để bạn kiểm tra & ra lệnh tiếp.",
115
118
  ultraNeedGoal: "Cần mục tiêu. Dùng: /ultra <mô tả mục tiêu>",
116
119
  ultraQuest: (n) => `tự nghĩ nhiệm vụ kế tiếp (vòng ${n})…`,
120
+ loopNeedArgs: "Cần task. Dùng: /loop <interval> <task> (vd: /loop 5m kiểm tra log lỗi mới) · /loop stop để dừng · /loop để xem trạng thái",
121
+ loopBadInterval: (s) => `interval không hợp lệ: "${s}". Dùng dạng 30s / 5m / 1h / 2h30m.`,
122
+ loopStarted: (interval, task) => `🔁 Loop BẬT — chạy mỗi ${interval} (không giới hạn token): ${task}`,
123
+ // [GỠ BUDGET 2026-06-06] loopBadBudget + loopBudgetExceeded giữ lại để tương thích ngược nếu có code cũ gọi, không dùng nữa.
124
+ loopBadBudget: (s) => `(deprecated) ngân sách không còn được hỗ trợ: "${s}".`,
125
+ loopBudgetExceeded: (used, budget, ticks) => `(deprecated) loop không còn cap token.`,
126
+ loopStopped: "🔁 Loop đã dừng.",
127
+ loopNotRunning: "Không có loop nào đang chạy.",
128
+ loopStatus: (interval, task, ticks, nextIn) => `🔁 Loop: chạy mỗi ${interval} · đã ${ticks} lần · lần kế trong ~${nextIn}\n task: ${task}`,
129
+ loopTick: (n) => `🔁 loop tick #${n}…`,
130
+ loopAutoStop: (n) => `Loop tự dừng sau tick #${n} — model phát <<LOOP_DONE>> (task hoàn tất).`,
131
+ loopAlreadyRunning: "Đã có loop đang chạy. /loop stop trước khi đặt loop mới.",
117
132
  learning: "đang chưng cất bài học vào noob.md…",
118
133
  compactRunning: "đang tóm tắt phiên để gọn ngữ cảnh…",
119
134
  compactEmpty: "Phiên còn trống — không có gì để tóm tắt.",
@@ -129,6 +144,26 @@ export const t = {
129
144
  improveRunning: "đang khảo sát workspace & soạn đề xuất cải thiện…",
130
145
  frontendDesignNoSkill: "không tìm thấy skills/frontend-design/SKILL.md — skill chưa được cài.",
131
146
  frontendDesignNeedReq: "cần mô tả yêu cầu. Ví dụ: /frontend-design landing page cho app nghe nhạc lo-fi",
147
+ workflowRunning: "đang chạy dynamic workflow đa sub-agent…",
148
+ workflowNoSkill: "không tìm thấy skills/dynamic-workflows/SKILL.md — skill chưa được cài.",
149
+ workflowNeedArg: "cần mô tả task. Ví dụ: /workflow audit toàn bộ src/ tìm lỗ hổng SQL injection",
150
+ workflowAgentAutoOn: "agent mode tự bật cho /workflow (cần spawn_agent)",
151
+ // saved workflows (CRUD)
152
+ workflowListEmpty: (dir) => `Chưa có workflow đã lưu. Tạo bằng /workflow save <name> <yêu cầu>. Thư mục: ${dir}`,
153
+ workflowListHeader: (dir) => `Workflow đã lưu (${dir}):`,
154
+ workflowSaveNeedArgs: "Cách dùng: /workflow save <name> <yêu cầu workflow>",
155
+ workflowSaveBadName: (n) => `Tên workflow không hợp lệ: '${n}'. Chỉ chấp nhận [a-z0-9_-], bắt đầu bằng chữ/số, tối đa 64 ký tự.`,
156
+ workflowSaveError: (n, e) => `Không lưu được workflow '${n}': ${e}`,
157
+ workflowSaveOk: (n, p) => `Đã lưu workflow '${n}' → ${p}`,
158
+ workflowRunNeedName: "Cách dùng: /workflow run <name> [thêm ngữ cảnh]",
159
+ workflowRunError: (n, e) => `Không nạp được workflow '${n}': ${e}`,
160
+ workflowRunOk: (n) => `Chạy workflow đã lưu '${n}'…`,
161
+ workflowLoadNeedName: "Cách dùng: /workflow load <name>",
162
+ workflowLoadError: (n, e) => `Không nạp được workflow '${n}': ${e}`,
163
+ workflowLoadOk: (n, p) => `Workflow '${n}' (${p}):`,
164
+ workflowDeleteNeedName: "Cách dùng: /workflow delete <name>",
165
+ workflowDeleteError: (n, e) => `Không xoá được workflow '${n}': ${e}`,
166
+ workflowDeleteOk: (n) => `Đã xoá workflow '${n}'.`,
132
167
  initOverwriteWarn: (p) => `⚠ Đã có noob.md tại ${p}. /init sẽ ghi đè nội dung hiện tại.`,
133
168
  initOverwriteConfirm: "Ghi đè? gõ 'y' để xác nhận, phím khác để huỷ › ",
134
169
  initCancel: "Huỷ /init — giữ nguyên noob.md.",
package/src/models.js CHANGED
@@ -50,7 +50,20 @@ export const PROVIDERS = {
50
50
  export const DEFAULT_MODEL = "gateway-claude-opus-4-7";
51
51
 
52
52
  export function findModel(id) {
53
- return MODELS.find((m) => m.id === id);
53
+ if (!id || typeof id !== "string") return undefined;
54
+ // Tier 1: exact id match (đường nhanh, dùng cho config + state nội bộ).
55
+ let hit = MODELS.find((m) => m.id === id);
56
+ if (hit) return hit;
57
+ // Tier 2: match mở rộng cho input từ model (sub-agent routing) hoặc user gõ tay.
58
+ // Bỏ prefix "gateway-", chuẩn hoá dấu (- _ . space → -), so id/name không phân biệt hoa thường.
59
+ const norm = (s) => String(s).toLowerCase().replace(/^gateway-/, "").replace(/[\s_.]+/g, "-").replace(/-+/g, "-");
60
+ const q = norm(id);
61
+ // Exact normalized match trước (tránh "opus" lỡ tay match nhầm "opus-4-1" khi muốn "opus-4-7").
62
+ hit = MODELS.find((m) => norm(m.id) === q || norm(m.name) === q);
63
+ if (hit) return hit;
64
+ // Tier 3: contains — chấp nhận khi CHỈ có 1 match duy nhất (tránh ambiguous).
65
+ const subs = MODELS.filter((m) => norm(m.id).includes(q) || norm(m.name).includes(q));
66
+ return subs.length === 1 ? subs[0] : undefined;
54
67
  }
55
68
 
56
69
  export function providerColor(providerKey) {