@noobdemon/noob-cli 1.7.7 → 1.7.10

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.10",
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,25 +277,24 @@ 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) {
265
- const msgs = compact(history, MAX_PROMPT_CHARS);
266
- // 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.
280
+ function buildSystem(history, extraToolsDoc, goal) {
269
281
  const parts = [SYSTEM, "", memoryBlock()];
282
+ if (goal && goal.trim()) parts.push("", goalBlock(goal));
270
283
  if (extraToolsDoc) parts.push("", extraToolsDoc);
271
- parts.push("", runtimeContext(), "", filesLedger(history), "", "=".repeat(60), "# CONVERSATION", "");
284
+ parts.push("", runtimeContext());
285
+ return parts.join("\n");
286
+ }
287
+
288
+ function buildUserMessage(history) {
289
+ const msgs = compact(history, MAX_PROMPT_CHARS);
290
+ const parts = [filesLedger(history), "", "=".repeat(60), "# CONVERSATION", ""];
272
291
  for (const m of msgs) {
273
292
  if (m.role === "user") parts.push(`## USER\n${m.content}`);
274
293
  else if (m.role === "assistant") parts.push(`## ASSISTANT\n${m.content}`);
275
294
  else if (m.role === "tool") parts.push(`## TOOL RESULT (${m.name})\n${m.content}`);
276
295
  parts.push("");
277
296
  }
278
- parts.push("=".repeat(60));
279
- // Recency bias: câu chốt cuối prompt nằm ở vị trí attention mạnh nhất. Nhắc
280
- // model đối chiếu FILES CHANGED trước khi claim đã sửa file — chống ảo giác
281
- // "đã tạo file" khi chưa gọi write_file/edit_file.
282
- parts.push("Continue. Emit a tool block to act, or reply in Markdown if done. Before claiming any file was created/edited, verify it appears in the FILES CHANGED list above — if not, emit the tool call now.");
297
+ parts.push("=".repeat(60), "Continue. Emit a tool block to act, or reply in Markdown if done. Before claiming any file was created/edited, verify it appears in the FILES CHANGED list above — if not, emit the tool call now.");
283
298
  return parts.join("\n");
284
299
  }
285
300
 
@@ -339,7 +354,10 @@ function extractJsonObject(s, from) {
339
354
  * @param {(msg:string)=>void} opts.onStatus thinking/streaming status
340
355
  * @returns {Promise<string>} the final assistant answer (no tool block)
341
356
  */
342
- export async function runAgent({ history, model, signal, onTool, onStatus, onDelta, onSteer, tokenMeter, extraToolsDoc }) {
357
+ export async function runAgent({ history, model, signal, onTool, onStatus, onDelta, onSteer, tokenMeter, extraToolsDoc, goal }) {
358
+ // [GỠ BUDGET 2026-06-06] Không còn token budget enforcement. Agent/loop/sub-agent
359
+ // chạy không giới hạn token. Dừng theo: GOAL đạt, <<LOOP_DONE>>, <<ULTRA_DONE>>,
360
+ // model tự kết thúc reply không có tool block, hoặc user Ctrl+C.
343
361
  for (let step = 0; step < MAX_STEPS; step++) {
344
362
  // 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
363
  if (step > 0 && step % 100 === 0) onStatus?.(`đã chạy ${step} bước…`);
@@ -352,26 +370,39 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
352
370
  // 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
371
  try { await maybeSummarize(history, { model, signal }); } catch {}
354
372
 
355
- const prompt = buildPrompt(history, extraToolsDoc);
356
- tokenMeter?.addInput(countTokens(prompt));
373
+ const system = buildSystem(history, extraToolsDoc, goal);
374
+ const message = buildUserMessage(history);
375
+ tokenMeter?.addInput(countTokens(message));
357
376
  onStatus?.("thinking");
358
377
  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
- },
378
+ // Stream + auto-retry: bao lớp resilience cho lỗi stream cut / empty / network.
379
+ // api.js đã tự nối tiếp khi truncated (maxContinues=Infinity); agent.js xử lý các
380
+ // trường hợp api.js trả về với finishReason bất thường (tool_unclosed/empty) hoặc
381
+ // throw ApiError retryable (network drop, 5xx, timeout).
382
+ const { text, finishReason } = await streamWithRetry({
383
+ model, message, system, signal, tokenMeter, onDelta, onStatus,
368
384
  });
369
385
  tokenMeter?.endOutput();
370
386
  onDelta?.({ type: "step-end" });
371
387
  history.push({ role: "assistant", content: text });
372
388
 
373
389
  const call = parseToolCall(text);
374
- if (!call) return text; // final answer
390
+ if (!call) {
391
+ // Không có tool call. Nếu finishReason bất thường (empty/tool_unclosed) →
392
+ // model bị cắt ngay trước khi kịp gọi tool → nudge tiếp 1 lượt nữa thay vì
393
+ // return text rỗng/dở dang.
394
+ if (finishReason === "empty" || finishReason === "tool_unclosed") {
395
+ history.push({
396
+ role: "tool",
397
+ name: "stream_recovery",
398
+ content: finishReason === "tool_unclosed"
399
+ ? "[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."
400
+ : "[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.",
401
+ });
402
+ continue;
403
+ }
404
+ return text; // final answer
405
+ }
375
406
 
376
407
  const { allow, result } = await onTool(call.name, call.input);
377
408
  history.push({
@@ -382,3 +413,49 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
382
413
  }
383
414
  return t.maxSteps;
384
415
  }
416
+
417
+ /**
418
+ * Bao lớp resilience quanh stream():
419
+ * - api.js đã tự nối tiếp khi gateway báo truncated (maxContinues=Infinity).
420
+ * - Tại đây chỉ xử lý ApiError retryable (network drop / 5xx / timeout): exponential
421
+ * backoff (1s, 2s, 4s, 8s, max 30s), tối đa 8 lần thử trước khi bỏ cuộc.
422
+ * - Throw lại nếu signal abort hoặc lỗi không retryable.
423
+ */
424
+ async function streamWithRetry({ model, message, system, signal, tokenMeter, onDelta, onStatus }) {
425
+ const MAX_RETRIES = 8;
426
+ let lastErr = null;
427
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
428
+ try {
429
+ const result = await stream({
430
+ mode: "chat",
431
+ model,
432
+ message,
433
+ system,
434
+ signal,
435
+ onDelta: (d) => {
436
+ tokenMeter?.pushOutputDelta(d);
437
+ onDelta?.({ type: "delta", text: d });
438
+ },
439
+ });
440
+ return result; // { text, reasoning, finishReason }
441
+ } catch (err) {
442
+ if (signal?.aborted) throw err; // user Ctrl+C — không retry
443
+ if (err?.name !== "ApiError" || !err.retryable) throw err; // lỗi cứng — bail
444
+ lastErr = err;
445
+ if (attempt >= MAX_RETRIES) break;
446
+ const backoff = Math.min(30000, 1000 * Math.pow(2, attempt));
447
+ onStatus?.(`mạng lỗi (${err.message}) — thử lại sau ${(backoff/1000)|0}s [${attempt+1}/${MAX_RETRIES}]`);
448
+ await sleep(backoff, signal);
449
+ }
450
+ }
451
+ throw lastErr || new Error("streamWithRetry: exhausted retries");
452
+ }
453
+
454
+ function sleep(ms, signal) {
455
+ return new Promise((resolve, reject) => {
456
+ const id = setTimeout(() => { cleanup(); resolve(); }, ms);
457
+ const onAbort = () => { cleanup(); reject(new Error("aborted")); };
458
+ const cleanup = () => { clearTimeout(id); signal?.removeEventListener("abort", onAbort); };
459
+ signal?.addEventListener("abort", onAbort, { once: true });
460
+ });
461
+ }
package/src/api.js CHANGED
@@ -3,6 +3,33 @@
3
3
  // upstream. The CLI only ever sees the gateway URL + the user's key.
4
4
  import { config } from "./config.js";
5
5
 
6
+ // ── memoryToken: per-session random token for upstream conversation state.
7
+ // Browser sends something like "uuid_uuid" (two v4 UUIDs joined by _).
8
+ // Upstream requires both `remember: true` and a valid `memoryToken`.
9
+ // ────────────────────────────────────────────────────────────────────────────
10
+ let _sessionMemoryToken = null;
11
+
12
+ function makeUUID() {
13
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
14
+ const r = (Math.random() * 16) | 0;
15
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
16
+ return v.toString(16);
17
+ });
18
+ }
19
+
20
+ function makeMemoryToken() {
21
+ return `${makeUUID()}_${makeUUID()}`;
22
+ }
23
+
24
+ function getMemoryToken() {
25
+ if (!_sessionMemoryToken) _sessionMemoryToken = makeMemoryToken();
26
+ return _sessionMemoryToken;
27
+ }
28
+
29
+ export function resetMemoryToken() {
30
+ _sessionMemoryToken = null;
31
+ }
32
+
6
33
  // Opt-in TLS escape hatch for machines behind a TLS-intercepting / broken-
7
34
  // revocation proxy. Off by default. Prefer fixing the trust store.
8
35
  // LƯU Ý: gọi từ bin/noob.js SAU khi parse argv — vì ESM import được hoist nên
@@ -26,16 +53,32 @@ function authHeaders() {
26
53
  }
27
54
 
28
55
  class ApiError extends Error {
29
- constructor(message, { status, code, reset_at, plan } = {}) {
56
+ constructor(message, { status, code, reset_at, plan, retryable, partial } = {}) {
30
57
  super(message);
31
58
  this.name = "ApiError";
32
59
  this.status = status;
33
60
  this.code = code;
34
61
  this.reset_at = reset_at;
35
62
  this.plan = plan;
63
+ // retryable: true nếu lỗi network/5xx/timeout (caller có thể tự retry); false
64
+ // nếu lỗi 4xx auth/bad-request (retry vô nghĩa). Tự suy ra khi không truyền.
65
+ this.retryable = retryable ?? deriveRetryable({ status, code });
66
+ // partial: phần text đã nhận được trước khi lỗi (cho phép caller continue).
67
+ this.partial = partial || "";
36
68
  }
37
69
  }
38
70
 
71
+ // Phân loại lỗi gateway: 5xx + 408/429 (không phải plan limit) + timeout/network
72
+ // → retryable. 4xx khác (401 auth, 400 bad request, 429 plan_limit) → KHÔNG retry.
73
+ function deriveRetryable({ status, code }) {
74
+ if (code === "timeout") return true;
75
+ if (code === "plan_limit") return false;
76
+ if (!status) return true; // không có status = network drop / fetch throw → retry
77
+ if (status >= 500) return true;
78
+ if (status === 408 || status === 429) return true;
79
+ return false;
80
+ }
81
+
39
82
  async function parseError(resp) {
40
83
  let j = null;
41
84
  try {
@@ -49,6 +92,17 @@ async function parseError(resp) {
49
92
  });
50
93
  }
51
94
 
95
+ // Tool block detection: kiểm tra text có chứa fenced ```tool đang dở (mở mà chưa
96
+ // đóng) — nếu có, đó là tín hiệu rõ ràng stream bị cắt giữa lúc emit tool call.
97
+ function hasUnclosedToolBlock(text) {
98
+ if (!text) return false;
99
+ const opens = (text.match(/```tool\b/g) || []).length;
100
+ if (opens === 0) return false;
101
+ // Đếm closing fence ``` sau mỗi ```tool. Heuristic: nếu số ``` lẻ → mở dở.
102
+ const fences = (text.match(/```/g) || []).length;
103
+ return fences % 2 === 1;
104
+ }
105
+
52
106
  /**
53
107
  * Stream a chat/merge/search request from the gateway.
54
108
  *
@@ -62,25 +116,43 @@ async function parseError(resp) {
62
116
  *
63
117
  * @returns {Promise<{text:string, reasoning:string}>}
64
118
  */
65
- export async function stream({ mode = "chat", message, model, signal, onDelta, onReasoning, onStatus, idleMs = 120000, maxContinues = 6 }) {
119
+ export async function stream({ mode = "chat", message, model, system, conversation, effort, signal, onDelta, onReasoning, onStatus, idleMs = 25000, maxContinues = Infinity }) {
66
120
  const endpoint = mode === "search" ? "/api/search" : mode === "merge" ? "/api/merge" : "/api/chat";
67
121
 
68
122
  let fullText = "";
69
123
  let reasoning = "";
70
124
  let prompt = message; // prompt gửi đi: lần đầu = nguyên bản, các lần sau = nối tiếp
125
+ let lastFinishReason = "stop"; // stop | truncated | tool_unclosed | empty | network_drop
126
+ 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
127
 
72
128
  for (let attempt = 0; ; attempt++) {
73
- const r = await streamOnce({ endpoint, mode, message: prompt, model, signal, idleMs, onStatus, onDelta, onReasoning });
129
+ const r = await streamOnce({ endpoint, mode, message: prompt, model, system, conversation, effort, signal, idleMs, onStatus, onDelta, onReasoning });
74
130
  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
131
  if (r.reasoning) reasoning = r.reasoning;
76
132
 
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;
133
+ // Phát hiện thêm: tool block mở chưa đóng → coi như bị cắt gateway báo done.
134
+ const toolUnclosed = mode === "chat" && hasUnclosedToolBlock(fullText);
135
+ const truncated = r.truncated || toolUnclosed;
136
+
137
+ if (!truncated) { lastFinishReason = "stop"; break; }
138
+ if (mode !== "chat") { lastFinishReason = "truncated"; break; }
139
+
140
+ // Đế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.
141
+ if (!r.text.trim()) {
142
+ emptyStreak++;
143
+ if (emptyStreak >= 3) { lastFinishReason = "empty"; break; }
144
+ } else {
145
+ emptyStreak = 0;
146
+ }
147
+
148
+ if (attempt >= maxContinues) {
149
+ lastFinishReason = toolUnclosed ? "tool_unclosed" : "truncated";
150
+ break;
151
+ }
80
152
  prompt = continuationPrompt(message, fullText);
81
153
  }
82
154
 
83
- return { text: fullText.trim(), reasoning: reasoning.trim() };
155
+ return { text: fullText.trim(), reasoning: reasoning.trim(), finishReason: lastFinishReason };
84
156
  }
85
157
 
86
158
  // 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ữ
@@ -103,8 +175,18 @@ function continuationPrompt(message, partial) {
103
175
  * One network attempt of the stream. Returns this attempt's accumulated text +
104
176
  * a `truncated` flag telling the caller whether the reply was cut short.
105
177
  */
106
- async function streamOnce({ endpoint, mode, message, model, signal, idleMs, onStatus, onDelta, onReasoning }) {
107
- const body = mode === "search" ? { query: message } : mode === "merge" ? { message } : { message, model };
178
+ async function streamOnce({ endpoint, mode, message, model, system, conversation, effort, signal, idleMs, onStatus, onDelta, onReasoning }) {
179
+ // chat body: gửi system + conversation riêng để gateway forward đúng tới upstream.
180
+ // Worker gateway (handleChat) + upstream đều nhận shape này.
181
+ let body;
182
+ if (mode === "search") body = { query: message };
183
+ else if (mode === "merge") body = { message };
184
+ else {
185
+ body = { message, model, remember: true, memoryToken: getMemoryToken() };
186
+ if (system) body.customInstructions = system;
187
+ if (Array.isArray(conversation) && conversation.length) body.conversation = conversation;
188
+ if (effort) body.effort = effort;
189
+ }
108
190
 
109
191
  // Idle-timeout: nếu KHÔNG nhận được byte nào trong idleMs (kết nối treo), tự
110
192
  // huỷ và báo lỗi rõ ràng — thay vì spinner quay vô tận. Vẫn tôn trọng signal
@@ -113,14 +195,65 @@ async function streamOnce({ endpoint, mode, message, model, signal, idleMs, onSt
113
195
  let timedOut = false;
114
196
  const onUserAbort = () => ctrl.abort();
115
197
  signal?.addEventListener("abort", onUserAbort, { once: true });
116
- let idle;
117
- const arm = () => {
118
- clearTimeout(idle);
119
- idle = setTimeout(() => {
198
+ // Hai loại timer tách rời (xem comment dài bên dưới):
199
+ // - WIRE: kết nối TCP còn thở không? Reset mỗi khi reader.read() trả bytes
200
+ // (kể cả comment SSE `: keepalive` từ worker). Ngưỡng cao (idleMs*2) — chỉ
201
+ // để bắt trường hợp socket chết cứng không còn cả heartbeat.
202
+ // - CONTENT: upstream có thực sự đang nghĩ/nói không? Reset chỉ khi parser
203
+ // thấy delta/status/reasoning/done/truncated thật. WARN/PROBE/idleMs gắn
204
+ // vào timer này. Đây là fix cho bug: worker phát `: keepalive\n\n` mỗi 15s
205
+ // bất kể upstream Vercel còn sống hay đã treo → nếu reset idle theo wire,
206
+ // status đếm thời gian sẽ chạy mãi không bao giờ trigger probe/abort.
207
+ let wireIdle = null;
208
+ let contentIdle = null;
209
+ let warnTimer = null;
210
+ let probeTimer = null;
211
+ let probeInFlight = false;
212
+ const WARN_MS = Math.min(8000, idleMs / 3);
213
+ const PROBE_MS = Math.min(12000, (idleMs * 2) / 3);
214
+ const WIRE_MS = idleMs * 2; // socket dead-cứng (mất cả heartbeat) — ngưỡng rộng
215
+ const clearContentTimers = () => {
216
+ clearTimeout(warnTimer); warnTimer = null;
217
+ clearTimeout(probeTimer); probeTimer = null;
218
+ clearTimeout(contentIdle); contentIdle = null;
219
+ };
220
+ const armContent = () => {
221
+ clearContentTimers();
222
+ warnTimer = setTimeout(() => {
223
+ if (onStatus) onStatus("Đang chờ proxy phản hồi…");
224
+ }, WARN_MS);
225
+ probeTimer = setTimeout(async () => {
226
+ if (probeInFlight || ctrl.signal.aborted) return;
227
+ probeInFlight = true;
228
+ try {
229
+ const probeCtl = new AbortController();
230
+ const probeT = setTimeout(() => probeCtl.abort(), 3000);
231
+ const r = await fetch(config.gatewayUrl + "/api/usage", { headers: authHeaders(), signal: probeCtl.signal });
232
+ clearTimeout(probeT);
233
+ if (!r.ok && r.status >= 500) throw new Error("proxy 5xx");
234
+ } catch {
235
+ if (!ctrl.signal.aborted) {
236
+ timedOut = true;
237
+ if (onStatus) onStatus("Proxy không phản hồi — đang gọi lại model…");
238
+ ctrl.abort();
239
+ }
240
+ } finally {
241
+ probeInFlight = false;
242
+ }
243
+ }, PROBE_MS);
244
+ contentIdle = setTimeout(() => {
120
245
  timedOut = true;
121
246
  ctrl.abort();
122
247
  }, idleMs);
123
248
  };
249
+ const armWire = () => {
250
+ clearTimeout(wireIdle);
251
+ wireIdle = setTimeout(() => {
252
+ // Mất cả heartbeat → socket chết cứng. Abort, lớp trên sẽ retry.
253
+ timedOut = true;
254
+ ctrl.abort();
255
+ }, WIRE_MS);
256
+ };
124
257
 
125
258
  let text = "";
126
259
  let reasoning = "";
@@ -139,6 +272,12 @@ async function streamOnce({ endpoint, mode, message, model, signal, idleMs, onSt
139
272
  } catch {
140
273
  return;
141
274
  }
275
+ // Có ít nhất 1 field nội dung thực → upstream đang nghĩ/nói. Reset content
276
+ // idle/warn/probe. JSON rỗng `{}` (ping) hoặc comment `: keepalive` của
277
+ // worker KHÔNG khớp điều kiện này → không che mất idle detection.
278
+ if (p.status || p.delta || p.reasoning || p.done || p.truncated || p.error) {
279
+ armContent();
280
+ }
142
281
  if (p.status && onStatus) onStatus(p.status);
143
282
  if (p.delta) {
144
283
  text += p.delta;
@@ -155,7 +294,8 @@ async function streamOnce({ endpoint, mode, message, model, signal, idleMs, onSt
155
294
  };
156
295
 
157
296
  try {
158
- arm();
297
+ armWire();
298
+ armContent();
159
299
  const resp = await fetch(config.gatewayUrl + endpoint, {
160
300
  method: "POST",
161
301
  headers: authHeaders(),
@@ -169,7 +309,7 @@ async function streamOnce({ endpoint, mode, message, model, signal, idleMs, onSt
169
309
  let buf = "";
170
310
  while (true) {
171
311
  const { done, value } = await reader.read();
172
- arm(); // hoạt động → gia hạn idle
312
+ armWire(); // bất kỳ byte nào tới socket còn thở, gia hạn wire idle
173
313
  if (done) break;
174
314
  buf += decoder.decode(value, { stream: true });
175
315
  let nl;
@@ -194,7 +334,8 @@ async function streamOnce({ endpoint, mode, message, model, signal, idleMs, onSt
194
334
  if (mode === "chat" && text) return { text, reasoning, truncated: true };
195
335
  throw err;
196
336
  } finally {
197
- clearTimeout(idle);
337
+ clearTimeout(wireIdle);
338
+ clearContentTimers();
198
339
  signal?.removeEventListener("abort", onUserAbort);
199
340
  }
200
341
  }