@noobdemon/noob-cli 1.10.20 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +465 -0
- package/README.md +113 -27
- package/bin/noob.js +40 -27
- package/package.json +30 -2
- package/src/agent.js +213 -124
- package/src/api.js +105 -48
- package/src/config.js +11 -11
- package/src/i18n.js +171 -148
- package/src/memory.js +24 -13
- package/src/models.js +96 -46
- package/src/prompts/system.md +85 -0
- package/src/repl/complete.js +120 -0
- package/src/repl/todos.js +38 -0
- package/src/repl/ultra.js +62 -0
- package/src/repl/workflow-commands.js +238 -0
- package/src/repl.js +794 -769
- package/src/sessions.js +20 -20
- package/src/skills.js +13 -9
- package/src/subagent.js +3 -3
- package/src/tokens.js +37 -12
- package/src/tools.js +202 -121
- package/src/tui.js +240 -124
- package/src/ui.js +44 -44
- package/src/update.js +21 -21
- package/src/workflows-builtin.js +16 -14
- package/src/workflows.js +29 -27
package/src/agent.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import os from
|
|
2
|
-
import { stream } from
|
|
3
|
-
import { loadMemory, memoryStats } from
|
|
4
|
-
import { listRoots } from
|
|
5
|
-
import { t } from
|
|
6
|
-
import { countTokens } from
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import { stream } from './api.js';
|
|
3
|
+
import { loadMemory, memoryStats } from './memory.js';
|
|
4
|
+
import { listRoots } from './tools.js';
|
|
5
|
+
import { t } from './i18n.js';
|
|
6
|
+
import { countTokens } from './tokens.js';
|
|
7
7
|
|
|
8
8
|
const SYSTEM = `You are noob, an agentic coding assistant in the spirit of Claude Code. You help with software engineering tasks by reading and editing files and running commands in the user's current working directory.
|
|
9
9
|
|
|
@@ -130,7 +130,7 @@ const HIGH_PATTERNS = [
|
|
|
130
130
|
];
|
|
131
131
|
|
|
132
132
|
export function classifyEffort(userMessage) {
|
|
133
|
-
return
|
|
133
|
+
return 'high';
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
// Số bước tool tối đa cho một lượt. Đặt rất cao theo yêu cầu người dùng: task
|
|
@@ -155,24 +155,24 @@ const SUMMARIZE_THRESHOLD_CHARS = 1000000; // ~250k tokens (83% window) — summ
|
|
|
155
155
|
// nuốt mất. KHÔNG paraphrase goal — giữ nguyên text user gõ.
|
|
156
156
|
function goalBlock(goal) {
|
|
157
157
|
return [
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
158
|
+
'# HARD GOAL (set via /goal — BINDING)',
|
|
159
|
+
'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.',
|
|
160
|
+
'',
|
|
161
|
+
'GOAL: ' + goal.trim(),
|
|
162
|
+
'',
|
|
163
163
|
"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.",
|
|
164
|
-
].join(
|
|
164
|
+
].join('\n');
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
// Môi trường chạy thực: model cần biết OS + shell để emit lệnh ĐÚNG. Không có
|
|
168
168
|
// khối này, trên Windows model hay emit lệnh Unix (wc/ls/cat/grep) → run_command
|
|
169
169
|
// (PowerShell) báo lỗi.
|
|
170
170
|
function runtimeContext() {
|
|
171
|
-
const isWin = process.platform ===
|
|
171
|
+
const isWin = process.platform === 'win32';
|
|
172
172
|
const lines = [
|
|
173
|
-
|
|
173
|
+
'# ENVIRONMENT',
|
|
174
174
|
`- OS: ${process.platform} (${os.release()})`,
|
|
175
|
-
`- Shell for run_command: ${isWin ?
|
|
175
|
+
`- Shell for run_command: ${isWin ? 'Windows PowerShell (powershell.exe)' : 'bash'}`,
|
|
176
176
|
`- Workspace (cwd): ${process.cwd()}`,
|
|
177
177
|
];
|
|
178
178
|
// Phạm vi truy cập filesystem. Model mặc định CHỈ được chạm cwd + các folder
|
|
@@ -183,26 +183,30 @@ function runtimeContext() {
|
|
|
183
183
|
try {
|
|
184
184
|
const roots = listRoots();
|
|
185
185
|
const extras = roots.slice(1); // [0] là cwd
|
|
186
|
-
lines.push(
|
|
186
|
+
lines.push(
|
|
187
|
+
`- Filesystem scope: workspace + ${extras.length} extra root(s)${extras.length ? ':' : ' (chỉ workspace).'}`
|
|
188
|
+
);
|
|
187
189
|
for (const r of extras) lines.push(` • ${r}`);
|
|
188
190
|
lines.push(
|
|
189
|
-
`- Nếu cần folder NGOÀI scope: dùng path tuyệt đối trong tool call — repl sẽ hỏi user, nếu duyệt folder tự được thêm + persist theo workspace
|
|
191
|
+
`- Nếu cần folder NGOÀI scope: dùng path tuyệt đối trong tool call — repl sẽ hỏi user, nếu duyệt folder tự được thêm + persist theo workspace.`
|
|
190
192
|
);
|
|
191
193
|
} catch {}
|
|
192
194
|
if (isWin) {
|
|
193
195
|
lines.push(
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
196
|
+
'- IMPORTANT: run_command runs in PowerShell on Windows — do NOT use Unix tools.',
|
|
197
|
+
' Use: Get-Content (not cat), Get-ChildItem (not ls), Select-String (not grep),',
|
|
198
|
+
' (Get-Content f | Measure-Object -Line) (not wc -l). Paths use backslashes.',
|
|
199
|
+
'- Trên Windows, script chạy bằng `script.bat` hoặc `script.cmd`, KHÔNG dùng `./script` (Unix).',
|
|
200
|
+
' VD: gradlew → gradlew.bat, mvnw → mvnw.cmd.',
|
|
201
|
+
'- Prefer the dedicated tools (read_file / list_dir / grep / glob) over shell commands;',
|
|
202
|
+
' they are cross-platform. Use run_command mainly for builds/tests/installs.'
|
|
201
203
|
);
|
|
202
204
|
} else {
|
|
203
|
-
lines.push(
|
|
205
|
+
lines.push(
|
|
206
|
+
'- Prefer the dedicated tools (read_file/list_dir/grep/glob) over shell when possible.'
|
|
207
|
+
);
|
|
204
208
|
}
|
|
205
|
-
return lines.join(
|
|
209
|
+
return lines.join('\n');
|
|
206
210
|
}
|
|
207
211
|
|
|
208
212
|
// Lược ngữ cảnh để không vượt context khi phiên dài. KHÔNG đụng vào history thật
|
|
@@ -210,7 +214,7 @@ function runtimeContext() {
|
|
|
210
214
|
// Nếu history đã có summary (do summarizeHistory ghi vào _summary), dùng làm head.
|
|
211
215
|
// Exported cho test (scripts/test-compact.js) — không dùng ngoài file này.
|
|
212
216
|
export function compact(history, budget) {
|
|
213
|
-
const len = (m) => (m.content ||
|
|
217
|
+
const len = (m) => (m.content || '').length + 24;
|
|
214
218
|
let total = history.reduce((s, m) => s + len(m), 0);
|
|
215
219
|
if (total <= budget) return history;
|
|
216
220
|
const out = history.map((m) => ({ ...m }));
|
|
@@ -218,12 +222,12 @@ export function compact(history, budget) {
|
|
|
218
222
|
// nhất (đọc file lớn) và model đã xử lý xong rồi.
|
|
219
223
|
// SKIP session_summary — đó là bộ nhớ dài hạn, không phải tool result cồng kềnh.
|
|
220
224
|
const toolIdx = out
|
|
221
|
-
.map((m, i) => (m.role ===
|
|
225
|
+
.map((m, i) => (m.role === 'tool' && m.name !== 'session_summary' ? i : -1))
|
|
222
226
|
.filter((i) => i >= 0);
|
|
223
227
|
for (const i of toolIdx.slice(0, Math.max(0, toolIdx.length - 5))) {
|
|
224
228
|
if (total <= budget) break;
|
|
225
229
|
const before = len(out[i]);
|
|
226
|
-
out[i].content =
|
|
230
|
+
out[i].content = '[kết quả công cụ cũ đã được lược bớt để tiết kiệm ngữ cảnh]';
|
|
227
231
|
total -= before - len(out[i]);
|
|
228
232
|
}
|
|
229
233
|
if (total <= budget) return out;
|
|
@@ -231,14 +235,18 @@ export function compact(history, budget) {
|
|
|
231
235
|
// do maybeSummarize() chèn) + USER đầu tiên (nhiệm vụ gốc) + 12 message gần
|
|
232
236
|
// nhất. KHÔNG BỎ session_summary — đó là bộ nhớ phiên, mất nó là mất hết.
|
|
233
237
|
const summaryIdx = out
|
|
234
|
-
.map((m, i) => (m.role ===
|
|
238
|
+
.map((m, i) => (m.role === 'tool' && m.name === 'session_summary' ? i : -1))
|
|
235
239
|
.filter((i) => i >= 0);
|
|
236
|
-
const firstUser = out.findIndex((m) => m.role ===
|
|
240
|
+
const firstUser = out.findIndex((m) => m.role === 'user');
|
|
237
241
|
const headIndices = new Set([...summaryIdx, ...(firstUser >= 0 ? [firstUser] : [])]);
|
|
238
242
|
const head = [...headIndices].sort((a, b) => a - b).map((i) => out[i]);
|
|
239
243
|
const maxHeadIdx = head.length ? Math.max(...headIndices) : -1;
|
|
240
244
|
const tailStart = Math.max(maxHeadIdx + 1, out.length - 12);
|
|
241
|
-
const elided = {
|
|
245
|
+
const elided = {
|
|
246
|
+
role: 'tool',
|
|
247
|
+
name: 'context',
|
|
248
|
+
content: '[… các lượt trước đã được lược bớt …]',
|
|
249
|
+
};
|
|
242
250
|
return [...head, elided, ...out.slice(tailStart)];
|
|
243
251
|
}
|
|
244
252
|
|
|
@@ -257,10 +265,12 @@ export async function maybeSummarize(history, { model, signal, force = false } =
|
|
|
257
265
|
// Nếu lượt đầu đã là summary (role=system, name=summary) → tóm tắt thêm.
|
|
258
266
|
const head = history.slice(0, history.length - keepTail);
|
|
259
267
|
const tail = history.slice(history.length - keepTail);
|
|
260
|
-
const transcript = head
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
268
|
+
const transcript = head
|
|
269
|
+
.map((m) => {
|
|
270
|
+
const role = m.role === 'tool' ? `TOOL(${m.name || '?'})` : m.role.toUpperCase();
|
|
271
|
+
return `## ${role}\n${(m.content || '').slice(0, 2000)}`;
|
|
272
|
+
})
|
|
273
|
+
.join('\n\n');
|
|
264
274
|
const ask = `Tóm tắt phần hội thoại sau thành BẢN GHI NGẮN GỌN (~25-40 dòng, tiếng Việt) để bạn — chính bạn — đọc lại sau và tiếp tục công việc mà không quên ngữ cảnh quan trọng.
|
|
265
275
|
|
|
266
276
|
BẮT BUỘC giữ lại:
|
|
@@ -289,13 +299,13 @@ LOẠI BỎ: chi tiết nội dung file đọc được, output dài của tool,
|
|
|
289
299
|
--- HỘI THOẠI CẦN TÓM TẮT ---
|
|
290
300
|
${transcript}`;
|
|
291
301
|
try {
|
|
292
|
-
const { text } = await stream({ mode:
|
|
293
|
-
const summary = (text ||
|
|
302
|
+
const { text } = await stream({ mode: 'chat', model, message: ask, signal });
|
|
303
|
+
const summary = (text || '').trim();
|
|
294
304
|
if (!summary || summary.length < 50) return false;
|
|
295
305
|
// Thay head bằng MỘT message tool tên "session_summary".
|
|
296
306
|
const summaryMsg = {
|
|
297
|
-
role:
|
|
298
|
-
name:
|
|
307
|
+
role: 'tool',
|
|
308
|
+
name: 'session_summary',
|
|
299
309
|
content: `[BỘ NHỚ DÀI HẠN — tóm tắt ${head.length} lượt đầu để không quên ngữ cảnh]\n\n${summary}`,
|
|
300
310
|
};
|
|
301
311
|
history.splice(0, history.length, summaryMsg, ...tail);
|
|
@@ -313,16 +323,19 @@ ${transcript}`;
|
|
|
313
323
|
export function filesLedger(history) {
|
|
314
324
|
const touched = new Map(); // path -> "đã ghi" | "đã sửa"
|
|
315
325
|
for (const m of history) {
|
|
316
|
-
if (m.role !==
|
|
326
|
+
if (m.role !== 'tool' || typeof m.content !== 'string' || m.content.startsWith('ERROR'))
|
|
327
|
+
continue;
|
|
317
328
|
let mm;
|
|
318
|
-
if (m.name ===
|
|
319
|
-
|
|
329
|
+
if (m.name === 'write_file' && (mm = m.content.match(/ to (.+)$/m)))
|
|
330
|
+
touched.set(mm[1].trim(), 'đã ghi');
|
|
331
|
+
else if (m.name === 'edit_file' && (mm = m.content.match(/^Edited (.+?) \(/m)))
|
|
332
|
+
touched.set(mm[1].trim(), 'đã sửa');
|
|
320
333
|
}
|
|
321
334
|
if (!touched.size)
|
|
322
|
-
return
|
|
335
|
+
return '# FILES CHANGED THIS SESSION: none.\nYou have NOT created or modified any file yet. Do not claim otherwise — to change a file you MUST emit write_file/edit_file. Describing a change in prose does nothing.';
|
|
323
336
|
return (
|
|
324
|
-
|
|
325
|
-
[...touched].map(([p, a]) => `- ${p} (${a})`).join(
|
|
337
|
+
'# FILES ACTUALLY CHANGED THIS SESSION (runtime ground truth — trust THIS over your memory)\n' +
|
|
338
|
+
[...touched].map(([p, a]) => `- ${p} (${a})`).join('\n') +
|
|
326
339
|
"\nIf you think you changed a file that is NOT in this list, you did NOT — emit the tool call now. Never say a file 'was reverted'/'should exist' from memory; verify with read_file or list_dir first."
|
|
327
340
|
);
|
|
328
341
|
}
|
|
@@ -338,14 +351,17 @@ function memoryBlock() {
|
|
|
338
351
|
const stats = memoryStats();
|
|
339
352
|
const statLine = stats
|
|
340
353
|
? `\n\n📊 noob.md hiện tại: ${stats.lines} dòng (${stats.rules} rules, ${stats.notes} notes) · cập nhật ${relTime(stats.mtime)}. Nếu cũ/quá ít, cân nhắc /learn để chưng cất bài học mới.`
|
|
341
|
-
:
|
|
354
|
+
: '';
|
|
342
355
|
if (!mem)
|
|
343
|
-
return
|
|
356
|
+
return (
|
|
357
|
+
'# PROJECT RULES & MEMORY (noob.md)\n(Chưa có noob.md trong thư mục này. Khi rút ra điều đáng nhớ lâu dài — lệnh build/test/run, quy ước, kiến trúc, sở thích người dùng, tiến độ — hãy TẠO noob.md bằng write_file và ghi vào đó.)' +
|
|
358
|
+
statLine
|
|
359
|
+
);
|
|
344
360
|
return (
|
|
345
|
-
|
|
346
|
-
|
|
361
|
+
'# PROJECT RULES & MEMORY (noob.md) — BINDING\n' +
|
|
362
|
+
'Phần `## Rules` dưới đây là LUẬT DỰ ÁN bạn PHẢI tuân theo trong mọi hành động ở lượt này — coi như mở rộng của SYSTEM, không phải gợi ý. Phần `## Notes` là quan sát tham khảo, có thể xác minh lại với filesystem nếu nghi ngờ.\n\n' +
|
|
347
363
|
mem +
|
|
348
|
-
|
|
364
|
+
'\n\nTrước khi emit hành động, đối chiếu với `## Rules` ở trên. Học thêm điều đáng nhớ → cập nhật noob.md bằng edit_file/write_file (Notes mới, promote lên Rules khi đã chứng minh).' +
|
|
349
365
|
statLine
|
|
350
366
|
);
|
|
351
367
|
}
|
|
@@ -355,25 +371,25 @@ function memoryBlock() {
|
|
|
355
371
|
// ý /resume nếu user muốn tiếp tục. Bỏ qua phiên hiện tại (repl.js lọc).
|
|
356
372
|
// recentSessions: [{ id, title, turns, updatedAt }] — đã sort mới → cũ.
|
|
357
373
|
function recentSessionsBlock(recentSessions) {
|
|
358
|
-
if (!recentSessions || !recentSessions.length) return
|
|
374
|
+
if (!recentSessions || !recentSessions.length) return '';
|
|
359
375
|
const lines = [
|
|
360
|
-
|
|
376
|
+
'# RECENT SESSIONS IN THIS WORKSPACE (newest first)',
|
|
361
377
|
"Đây là các phiên TRƯỚC của cùng dự án. User có thể `/resume <id>` để tiếp tục 1 phiên cụ thể (xem lịch sử đầy đủ). Nếu user nói chung chung ('tiếp tục hôm qua', 'làm lại cái kia') mà KHÔNG chỉ rõ — hỏi lại hoặc dùng breadcrumbs dưới để đoán (xem title + số lượt + thời gian).",
|
|
362
|
-
|
|
378
|
+
'',
|
|
363
379
|
];
|
|
364
380
|
for (const s of recentSessions) {
|
|
365
381
|
const ago = relTime(s.updatedAt);
|
|
366
|
-
const title = s.title ||
|
|
382
|
+
const title = s.title || '(chưa đặt tiêu đề)';
|
|
367
383
|
lines.push(`- \`${s.id}\` — "${title}" · ${s.turns} lượt · ${ago}`);
|
|
368
384
|
}
|
|
369
|
-
return lines.join(
|
|
385
|
+
return lines.join('\n');
|
|
370
386
|
}
|
|
371
387
|
|
|
372
388
|
// "X ago" ngắn gọn, tiếng Việt. Dùng cho noob.md mtime + recent sessions.
|
|
373
389
|
function relTime(ts) {
|
|
374
|
-
if (!ts) return
|
|
390
|
+
if (!ts) return '—';
|
|
375
391
|
const ms = Date.now() - ts;
|
|
376
|
-
if (ms < 5000) return
|
|
392
|
+
if (ms < 5000) return 'vừa xong'; // < 5s hoặc tương lai → "vừa xong" (tránh "0s trước" xấu)
|
|
377
393
|
const s = Math.floor(ms / 1000);
|
|
378
394
|
if (s < 60) return `${s}s trước`;
|
|
379
395
|
const m = Math.floor(s / 60);
|
|
@@ -393,27 +409,30 @@ function relTime(ts) {
|
|
|
393
409
|
// recentSessions: breadcrumbs các phiên trước cùng workspace (repl.js cung cấp)
|
|
394
410
|
// → chèn ngay sau memoryBlock() để model "thấy" lịch sử dù chưa /resume.
|
|
395
411
|
export function buildSystem(history, extraToolsDoc, goal, recentSessions) {
|
|
396
|
-
const parts = [SYSTEM,
|
|
412
|
+
const parts = [SYSTEM, '', memoryBlock()];
|
|
397
413
|
if (recentSessions && recentSessions.length) {
|
|
398
|
-
parts.push(
|
|
414
|
+
parts.push('', recentSessionsBlock(recentSessions));
|
|
399
415
|
}
|
|
400
|
-
if (goal && goal.trim()) parts.push(
|
|
401
|
-
if (extraToolsDoc) parts.push(
|
|
402
|
-
parts.push(
|
|
403
|
-
return parts.join(
|
|
416
|
+
if (goal && goal.trim()) parts.push('', goalBlock(goal));
|
|
417
|
+
if (extraToolsDoc) parts.push('', extraToolsDoc);
|
|
418
|
+
parts.push('', runtimeContext());
|
|
419
|
+
return parts.join('\n');
|
|
404
420
|
}
|
|
405
421
|
|
|
406
422
|
export function buildUserMessage(history) {
|
|
407
423
|
const msgs = compact(history, MAX_PROMPT_CHARS);
|
|
408
|
-
const parts = [filesLedger(history),
|
|
424
|
+
const parts = [filesLedger(history), '', '='.repeat(60), '# CONVERSATION', ''];
|
|
409
425
|
for (const m of msgs) {
|
|
410
|
-
if (m.role ===
|
|
411
|
-
else if (m.role ===
|
|
412
|
-
else if (m.role ===
|
|
413
|
-
parts.push(
|
|
426
|
+
if (m.role === 'user') parts.push(`## USER\n${m.content}`);
|
|
427
|
+
else if (m.role === 'assistant') parts.push(`## ASSISTANT\n${m.content}`);
|
|
428
|
+
else if (m.role === 'tool') parts.push(`## TOOL RESULT (${m.name})\n${m.content}`);
|
|
429
|
+
parts.push('');
|
|
414
430
|
}
|
|
415
|
-
parts.push(
|
|
416
|
-
|
|
431
|
+
parts.push(
|
|
432
|
+
'='.repeat(60),
|
|
433
|
+
'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.'
|
|
434
|
+
);
|
|
435
|
+
return parts.join('\n');
|
|
417
436
|
}
|
|
418
437
|
|
|
419
438
|
// Detect câu trả lời bị cắt giữa chừng — KHÔNG phải câu hoàn chỉnh.
|
|
@@ -429,10 +448,15 @@ function isIncompleteResponse(text) {
|
|
|
429
448
|
const lastChar = t.slice(-1);
|
|
430
449
|
if (lastChar && !/[.!?:;)\]"'`#>\n]/.test(lastChar) && t.length > 50) {
|
|
431
450
|
// Kiểm thêm: dòng cuối chứa từ khóa bị cắt (ví dụ, vd, hay, hoặc, và, nhưng, mà)
|
|
432
|
-
const lastLine = t.split(
|
|
451
|
+
const lastLine = t.split('\n').pop().trim();
|
|
433
452
|
if (/\s(ví|vd|hay|hoặc|và|nhưng|mà|hoac|or|and|but|e\.g|i\.e)\s*$/i.test(lastLine)) return true;
|
|
434
453
|
// Dòng cuối là 1 câu bắt đầu nhưng chưa xong (có chủ ngữ nhưng không có vị ngữ hoàn chỉnh)
|
|
435
|
-
if (
|
|
454
|
+
if (
|
|
455
|
+
/\s(Bạn|Tôi|Mình|Anh|Em|Chị|Ông|Bà|Họ)\s+(muốn|có|thấy|nên|cần|đã|đang|sẽ|chọn|định|sắp|vừa)\s*$/i.test(
|
|
456
|
+
lastLine
|
|
457
|
+
)
|
|
458
|
+
)
|
|
459
|
+
return true;
|
|
436
460
|
}
|
|
437
461
|
return false;
|
|
438
462
|
}
|
|
@@ -443,11 +467,11 @@ function isIncompleteResponse(text) {
|
|
|
443
467
|
// would close the block early and break the JSON. Instead, find the ```tool (or
|
|
444
468
|
// ```json) opener and brace-match the first balanced JSON object after it.
|
|
445
469
|
function parseToolCall(text) {
|
|
446
|
-
for (const fence of [
|
|
447
|
-
const open = text.match(new RegExp(
|
|
470
|
+
for (const fence of ['tool', 'json']) {
|
|
471
|
+
const open = text.match(new RegExp('```' + fence + '[ \\t]*\\n'));
|
|
448
472
|
if (!open) continue;
|
|
449
473
|
const obj = extractJsonObject(text, open.index + open[0].length);
|
|
450
|
-
if (obj && typeof obj.name ===
|
|
474
|
+
if (obj && typeof obj.name === 'string') return { name: obj.name, input: obj.input || {} };
|
|
451
475
|
}
|
|
452
476
|
return null;
|
|
453
477
|
}
|
|
@@ -456,20 +480,22 @@ function parseToolCall(text) {
|
|
|
456
480
|
// escapes so braces or backticks INSIDE string values don't throw off the depth
|
|
457
481
|
// count. Returns the parsed object, or null if malformed/truncated (unbalanced).
|
|
458
482
|
function extractJsonObject(s, from) {
|
|
459
|
-
const start = s.indexOf(
|
|
483
|
+
const start = s.indexOf('{', from);
|
|
460
484
|
if (start === -1) return null;
|
|
461
|
-
let depth = 0,
|
|
485
|
+
let depth = 0,
|
|
486
|
+
inStr = false,
|
|
487
|
+
esc = false;
|
|
462
488
|
for (let i = start; i < s.length; i++) {
|
|
463
489
|
const ch = s[i];
|
|
464
490
|
if (inStr) {
|
|
465
491
|
if (esc) esc = false;
|
|
466
|
-
else if (ch ===
|
|
492
|
+
else if (ch === '\\') esc = true;
|
|
467
493
|
else if (ch === '"') inStr = false;
|
|
468
494
|
continue;
|
|
469
495
|
}
|
|
470
496
|
if (ch === '"') inStr = true;
|
|
471
|
-
else if (ch ===
|
|
472
|
-
else if (ch ===
|
|
497
|
+
else if (ch === '{') depth++;
|
|
498
|
+
else if (ch === '}' && --depth === 0) {
|
|
473
499
|
try {
|
|
474
500
|
return JSON.parse(s.slice(start, i + 1));
|
|
475
501
|
} catch {
|
|
@@ -493,7 +519,20 @@ function extractJsonObject(s, from) {
|
|
|
493
519
|
* @param {(msg:string)=>void} opts.onStatus thinking/streaming status
|
|
494
520
|
* @returns {Promise<string>} the final assistant answer (no tool block)
|
|
495
521
|
*/
|
|
496
|
-
export async function runAgent({
|
|
522
|
+
export async function runAgent({
|
|
523
|
+
history,
|
|
524
|
+
model,
|
|
525
|
+
signal,
|
|
526
|
+
onTool,
|
|
527
|
+
onStatus,
|
|
528
|
+
onDelta,
|
|
529
|
+
onSteer,
|
|
530
|
+
tokenMeter,
|
|
531
|
+
extraToolsDoc,
|
|
532
|
+
goal,
|
|
533
|
+
recentSessions,
|
|
534
|
+
pendingTasks,
|
|
535
|
+
}) {
|
|
497
536
|
// [GỠ BUDGET 2026-06-06] Không còn token budget enforcement. Agent/loop/sub-agent
|
|
498
537
|
// chạy không giới hạn token. Dừng theo: GOAL đạt, <<LOOP_DONE>>, <<ULTRA_DONE>>,
|
|
499
538
|
// model tự kết thúc reply không có tool block, hoặc user Ctrl+C.
|
|
@@ -502,11 +541,11 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
|
|
|
502
541
|
const MAX_LOOP_DETECTIONS = 3; // sau 3 lần loop detection liên tiếp → force stop
|
|
503
542
|
// Effort classifier: phân loại task từ user message gốc → set effort level.
|
|
504
543
|
// Chỉ classify 1 lần ở bước đầu, giữ nguyên suốt task (thay đổi giữa chừng gây bất ổn).
|
|
505
|
-
const effort = classifyEffort(history.find((m) => m.role ===
|
|
544
|
+
const effort = classifyEffort(history.find((m) => m.role === 'user')?.content || '');
|
|
506
545
|
for (let step = 0; step < MAX_STEPS; step++) {
|
|
507
546
|
// Check abort signal ngay đầu mỗi iteration để Ctrl+C dừng ngay lập tức
|
|
508
547
|
if (signal?.aborted) {
|
|
509
|
-
throw new Error(
|
|
548
|
+
throw new Error('aborted');
|
|
510
549
|
}
|
|
511
550
|
// 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).
|
|
512
551
|
if (step > 0 && step % 100 === 0) onStatus?.(`đã chạy ${step} bước…`);
|
|
@@ -514,40 +553,50 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
|
|
|
514
553
|
// Steering: tin nhắn người dùng gõ GIỮA CHỪNG được chèn vào hội thoại TRƯỚC
|
|
515
554
|
// lần gọi model kế tiếp → model thấy và điều chỉnh ngay trong cùng task.
|
|
516
555
|
const steer = onSteer?.() || [];
|
|
517
|
-
for (const msg of steer) history.push({ role:
|
|
556
|
+
for (const msg of steer) history.push({ role: 'user', content: msg });
|
|
518
557
|
|
|
519
558
|
// 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.
|
|
520
|
-
try {
|
|
559
|
+
try {
|
|
560
|
+
await maybeSummarize(history, { model, signal });
|
|
561
|
+
} catch {}
|
|
521
562
|
|
|
522
563
|
const system = buildSystem(history, extraToolsDoc, goal, recentSessions);
|
|
523
564
|
const message = buildUserMessage(history);
|
|
524
565
|
tokenMeter?.addInput(countTokens(system) + countTokens(message));
|
|
525
566
|
tokenMeter?.setContext(tokenMeter.total);
|
|
526
|
-
onStatus?.(
|
|
527
|
-
onDelta?.({ type:
|
|
567
|
+
onStatus?.('thinking');
|
|
568
|
+
onDelta?.({ type: 'step-start' });
|
|
528
569
|
// Stream + auto-retry: bao lớp resilience cho lỗi stream cut / empty / network.
|
|
529
570
|
// api.js đã tự nối tiếp khi truncated (maxContinues=Infinity); agent.js xử lý các
|
|
530
571
|
// trường hợp api.js trả về với finishReason bất thường (tool_unclosed/empty) hoặc
|
|
531
572
|
// throw ApiError retryable (network drop, 5xx, timeout).
|
|
532
573
|
const { text, finishReason } = await streamWithRetry({
|
|
533
|
-
model,
|
|
574
|
+
model,
|
|
575
|
+
message,
|
|
576
|
+
system,
|
|
577
|
+
signal,
|
|
578
|
+
tokenMeter,
|
|
579
|
+
onDelta,
|
|
580
|
+
onStatus,
|
|
581
|
+
effort,
|
|
534
582
|
});
|
|
535
583
|
tokenMeter?.endOutput();
|
|
536
|
-
onDelta?.({ type:
|
|
537
|
-
history.push({ role:
|
|
584
|
+
onDelta?.({ type: 'step-end' });
|
|
585
|
+
history.push({ role: 'assistant', content: text });
|
|
538
586
|
|
|
539
587
|
const call = parseToolCall(text);
|
|
540
588
|
if (!call) {
|
|
541
589
|
// Không có tool call. Nếu finishReason bất thường (empty/tool_unclosed) →
|
|
542
590
|
// model bị cắt ngay trước khi kịp gọi tool → nudge tiếp 1 lượt nữa thay vì
|
|
543
591
|
// return text rỗng/dở dang.
|
|
544
|
-
if (finishReason ===
|
|
592
|
+
if (finishReason === 'empty' || finishReason === 'tool_unclosed') {
|
|
545
593
|
history.push({
|
|
546
|
-
role:
|
|
547
|
-
name:
|
|
548
|
-
content:
|
|
549
|
-
|
|
550
|
-
|
|
594
|
+
role: 'tool',
|
|
595
|
+
name: 'stream_recovery',
|
|
596
|
+
content:
|
|
597
|
+
finishReason === 'tool_unclosed'
|
|
598
|
+
? '[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.'
|
|
599
|
+
: '[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.',
|
|
551
600
|
});
|
|
552
601
|
continue;
|
|
553
602
|
}
|
|
@@ -555,9 +604,10 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
|
|
|
555
604
|
// đột ngột không có dấu câu/đóng danh sách/tool block → nudge model viết tiếp.
|
|
556
605
|
if (isIncompleteResponse(text)) {
|
|
557
606
|
history.push({
|
|
558
|
-
role:
|
|
559
|
-
name:
|
|
560
|
-
content:
|
|
607
|
+
role: 'tool',
|
|
608
|
+
name: 'stream_recovery',
|
|
609
|
+
content:
|
|
610
|
+
'[STREAM CUT] Lượt vừa bị cắt giữa chừng. Hãy viết TIẾP liền mạch — nếu đang hỏi user thì hoàn tất câu hỏi, nếu đang list thì đóng danh sách, nếu đang viết tool block thì đóng đúng JSON.',
|
|
561
611
|
});
|
|
562
612
|
continue;
|
|
563
613
|
}
|
|
@@ -567,7 +617,7 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
|
|
|
567
617
|
|
|
568
618
|
const { allow, result } = await onTool(call.name, call.input);
|
|
569
619
|
history.push({
|
|
570
|
-
role:
|
|
620
|
+
role: 'tool',
|
|
571
621
|
name: call.name,
|
|
572
622
|
content: allow ? result : t.toolDenied,
|
|
573
623
|
});
|
|
@@ -580,8 +630,8 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
|
|
|
580
630
|
if (tasks.length > 0) {
|
|
581
631
|
const next = tasks[0];
|
|
582
632
|
history.push({
|
|
583
|
-
role:
|
|
584
|
-
content: `[SYSTEM] Việc "${call.name}" đã hoàn thành. Còn ${tasks.length} việc: ${tasks.map((t) => `"${t}"`).join(
|
|
633
|
+
role: 'user',
|
|
634
|
+
content: `[SYSTEM] Việc "${call.name}" đã hoàn thành. Còn ${tasks.length} việc: ${tasks.map((t) => `"${t}"`).join(', ')}. Việc tiếp theo BẮT BUỘC phải làm ngay: "${next}". Gọi tool (write_file/edit_file/run_command) để làm việc này. KHÔNG dừng, KHÔNG tóm tắt.`,
|
|
585
635
|
});
|
|
586
636
|
}
|
|
587
637
|
}
|
|
@@ -596,43 +646,62 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
|
|
|
596
646
|
recentCalls.push({ name: call.name, inputStr });
|
|
597
647
|
if (recentCalls.length > LOOP_DETECT_WINDOW) recentCalls.shift();
|
|
598
648
|
let loopType = null; // 'consecutive' | 'pattern' | null
|
|
649
|
+
let lastN = [];
|
|
599
650
|
if (recentCalls.length >= LOOP_DETECT_THRESHOLD + 1) {
|
|
600
651
|
// (A) Same consecutive check
|
|
601
|
-
|
|
602
|
-
const allSame = lastN.every(
|
|
652
|
+
lastN = recentCalls.slice(-LOOP_DETECT_THRESHOLD);
|
|
653
|
+
const allSame = lastN.every(
|
|
654
|
+
(c) => c.name === lastN[0].name && c.inputStr === lastN[0].inputStr
|
|
655
|
+
);
|
|
603
656
|
if (allSame) loopType = 'consecutive';
|
|
604
657
|
}
|
|
605
658
|
// (B) Pattern cycle check: tìm chu kỳ lặp trong window (độ dài 2-3)
|
|
606
659
|
if (!loopType && recentCalls.length >= 4) {
|
|
607
|
-
for (
|
|
660
|
+
for (
|
|
661
|
+
let cycleLen = 2;
|
|
662
|
+
cycleLen <= Math.min(3, Math.floor(recentCalls.length / 2));
|
|
663
|
+
cycleLen++
|
|
664
|
+
) {
|
|
608
665
|
const half = Math.floor(recentCalls.length / cycleLen) * cycleLen;
|
|
609
666
|
const first = recentCalls.slice(-half, -half + cycleLen);
|
|
610
667
|
const rest = recentCalls.slice(-half + cycleLen);
|
|
611
668
|
let matched = true;
|
|
612
669
|
for (let i = 0; i < rest.length; i++) {
|
|
613
|
-
if (
|
|
670
|
+
if (
|
|
671
|
+
rest[i].name !== first[i % cycleLen].name ||
|
|
672
|
+
rest[i].inputStr !== first[i % cycleLen].inputStr
|
|
673
|
+
) {
|
|
614
674
|
matched = false;
|
|
615
675
|
break;
|
|
616
676
|
}
|
|
617
677
|
}
|
|
618
|
-
if (matched) {
|
|
678
|
+
if (matched) {
|
|
679
|
+
loopType = 'pattern';
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
619
682
|
}
|
|
620
683
|
}
|
|
621
684
|
if (loopType) {
|
|
622
|
-
const label =
|
|
685
|
+
const label =
|
|
686
|
+
loopType === 'consecutive'
|
|
687
|
+
? lastN[0].name
|
|
688
|
+
: `pattern [${recentCalls
|
|
689
|
+
.slice(-4)
|
|
690
|
+
.map((c) => c.name)
|
|
691
|
+
.join(', ')}]`;
|
|
623
692
|
loopDetectedCount++;
|
|
624
693
|
if (loopDetectedCount >= MAX_LOOP_DETECTIONS) {
|
|
625
694
|
history.push({
|
|
626
|
-
role:
|
|
627
|
-
name:
|
|
695
|
+
role: 'tool',
|
|
696
|
+
name: 'loop_detection',
|
|
628
697
|
content: `[LOOP DETECTED × ${loopDetectedCount}] Bạn lặp lại ${label} nhiều lần — KHÔNG THỂ TIẾP TỤC. Dừng ngay.`,
|
|
629
698
|
});
|
|
630
699
|
return `[LOOP STOPPED] Đã dừng vì model bị kẹt trong vòng lặp.`;
|
|
631
700
|
}
|
|
632
701
|
recentCalls.length = 0;
|
|
633
702
|
history.push({
|
|
634
|
-
role:
|
|
635
|
-
name:
|
|
703
|
+
role: 'tool',
|
|
704
|
+
name: 'loop_detection',
|
|
636
705
|
content: `[LOOP DETECTED × ${loopDetectedCount}] Bạn lặp lại ${label} — có vẻ đang kẹt. HÃY CHUYỂN BƯỚC: gọi tool khác hoặc trả lời Markdown nếu xong. KHÔNG lặp lại. Nếu tiếp tục, task sẽ dừng.`,
|
|
637
706
|
});
|
|
638
707
|
} else {
|
|
@@ -649,13 +718,22 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
|
|
|
649
718
|
* backoff (1s, 2s, 4s, 8s, max 30s), tối đa 8 lần thử trước khi bỏ cuộc.
|
|
650
719
|
* - Throw lại nếu signal abort hoặc lỗi không retryable.
|
|
651
720
|
*/
|
|
652
|
-
async function streamWithRetry({
|
|
721
|
+
async function streamWithRetry({
|
|
722
|
+
model,
|
|
723
|
+
message,
|
|
724
|
+
system,
|
|
725
|
+
signal,
|
|
726
|
+
tokenMeter,
|
|
727
|
+
onDelta,
|
|
728
|
+
onStatus,
|
|
729
|
+
effort,
|
|
730
|
+
}) {
|
|
653
731
|
const MAX_RETRIES = 8;
|
|
654
732
|
let lastErr = null;
|
|
655
733
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
656
734
|
try {
|
|
657
735
|
const result = await stream({
|
|
658
|
-
mode:
|
|
736
|
+
mode: 'chat',
|
|
659
737
|
model,
|
|
660
738
|
message,
|
|
661
739
|
system,
|
|
@@ -663,28 +741,39 @@ async function streamWithRetry({ model, message, system, signal, tokenMeter, onD
|
|
|
663
741
|
effort,
|
|
664
742
|
onDelta: (d) => {
|
|
665
743
|
tokenMeter?.pushOutputDelta(d);
|
|
666
|
-
onDelta?.({ type:
|
|
744
|
+
onDelta?.({ type: 'delta', text: d });
|
|
667
745
|
},
|
|
668
746
|
});
|
|
669
747
|
return result; // { text, reasoning, finishReason }
|
|
670
748
|
} catch (err) {
|
|
671
749
|
if (signal?.aborted) throw err; // user Ctrl+C — không retry
|
|
672
|
-
if (err?.name !==
|
|
750
|
+
if (err?.name !== 'ApiError' || !err.retryable) throw err; // lỗi cứng — bail
|
|
673
751
|
lastErr = err;
|
|
674
752
|
if (attempt >= MAX_RETRIES) break;
|
|
675
753
|
const backoff = Math.min(30000, 1000 * Math.pow(2, attempt));
|
|
676
|
-
onStatus?.(
|
|
754
|
+
onStatus?.(
|
|
755
|
+
`Lỗi kết nối — thử lại sau ${(backoff / 1000) | 0}s [${attempt + 1}/${MAX_RETRIES}]…`
|
|
756
|
+
);
|
|
677
757
|
await sleep(backoff, signal);
|
|
678
758
|
}
|
|
679
759
|
}
|
|
680
|
-
throw lastErr || new Error(
|
|
760
|
+
throw lastErr || new Error('streamWithRetry: exhausted retries');
|
|
681
761
|
}
|
|
682
762
|
|
|
683
763
|
function sleep(ms, signal) {
|
|
684
764
|
return new Promise((resolve, reject) => {
|
|
685
|
-
const id = setTimeout(() => {
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
765
|
+
const id = setTimeout(() => {
|
|
766
|
+
cleanup();
|
|
767
|
+
resolve();
|
|
768
|
+
}, ms);
|
|
769
|
+
const onAbort = () => {
|
|
770
|
+
cleanup();
|
|
771
|
+
reject(new Error('aborted'));
|
|
772
|
+
};
|
|
773
|
+
const cleanup = () => {
|
|
774
|
+
clearTimeout(id);
|
|
775
|
+
signal?.removeEventListener('abort', onAbort);
|
|
776
|
+
};
|
|
777
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
689
778
|
});
|
|
690
779
|
}
|