@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 +1 -1
- package/skills/dynamic-workflows/SKILL.md +154 -0
- package/src/agent.js +101 -24
- package/src/api.js +157 -16
- package/src/i18n.js +35 -0
- package/src/models.js +14 -1
- package/src/repl.js +338 -9
- package/src/subagent.js +112 -53
- package/src/tokens.js +16 -0
- package/src/tools.js +46 -11
- package/src/workflows.js +142 -0
package/package.json
CHANGED
|
@@ -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
|
|
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()
|
|
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
|
|
356
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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)
|
|
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 =
|
|
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
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
133
|
+
// Phát hiện thêm: tool block mở mà chưa đóng → coi như bị cắt dù 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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
337
|
+
clearTimeout(wireIdle);
|
|
338
|
+
clearContentTimers();
|
|
198
339
|
signal?.removeEventListener("abort", onUserAbort);
|
|
199
340
|
}
|
|
200
341
|
}
|