s-app-scoder-agent 0.9.8 → 0.9.9
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/.scoderrc.example.json +12 -1
- package/dist/agent/guardrails.js +19 -5
- package/dist/agent/mcp-bridge.js +101 -5
- package/dist/agent/run-loop.js +69 -10
- package/dist/agent/tool-executor.js +11 -5
- package/dist/agent/verification.js +28 -2
- package/dist/agent.js +224 -21
- package/dist/config/config.js +102 -4
- package/dist/context/compaction.js +39 -5
- package/dist/context/context.js +131 -28
- package/dist/index.js +12 -1
- package/dist/llm/context-probe.js +151 -0
- package/dist/llm/llm.js +21 -10
- package/dist/llm/prompt-metrics.js +78 -0
- package/dist/mcp/manager.js +3 -19
- package/dist/mcp/tool-filter.js +34 -0
- package/dist/tools/path-safety.js +22 -2
- package/dist/tools/read-file.js +24 -4
- package/dist/tui/hooks/use-submit.js +3 -3
- package/dist/tui/state.js +3 -2
- package/dist/workspace/build.js +5 -2
- package/dist/workspace/file-index.js +8 -3
- package/dist/workspace/fingerprint.js +44 -0
- package/package.json +1 -1
package/dist/context/context.js
CHANGED
|
@@ -15,12 +15,38 @@ export const MAX_HISTORY_TOKENS = 90_000;
|
|
|
15
15
|
* 0.8) so semantic compaction gets a chance to run first.
|
|
16
16
|
*/
|
|
17
17
|
export const HISTORY_TRIM_FRACTION = 0.95;
|
|
18
|
-
/**
|
|
19
|
-
export
|
|
20
|
-
|
|
18
|
+
/** Sàn dưới của ngân sách history — window quá nhỏ thì history cũng phải còn đủ sống. */
|
|
19
|
+
export const MIN_HISTORY_TOKENS = 2_000;
|
|
20
|
+
/**
|
|
21
|
+
* Mechanical trim budget for a given context window (floor MIN_HISTORY_TOKENS).
|
|
22
|
+
*
|
|
23
|
+
* `fixedTokens` (P1-1/RC-4): phần prompt cố định KHÔNG nằm trong messages —
|
|
24
|
+
* quan trọng nhất là tool schema (~16k token với 46 tool MCP). `outputReserve`:
|
|
25
|
+
* chỗ trống cho reply (config.maxTokens). Với 1 tham số, hành vi giữ nguyên
|
|
26
|
+
* như trước (chỉ scale theo window) để tương thích test và rollback.
|
|
27
|
+
*
|
|
28
|
+
* Reply bị trần ở 25% phần còn lại: với window nhỏ (llama.cpp -c 16384) mà để
|
|
29
|
+
* maxTokens 8192 ăn hết thì ngân sách history rớt xuống sàn 2k → trim gần như
|
|
30
|
+
* mỗi bước, đúng cái mà P0-1 sinh ra để tránh.
|
|
31
|
+
*/
|
|
32
|
+
export function historyTokenBudget(contextWindow, fixedTokens = 0, outputReserve = 0) {
|
|
33
|
+
const usable = Math.floor(contextWindow * HISTORY_TRIM_FRACTION) - fixedTokens;
|
|
34
|
+
if (usable <= 0)
|
|
35
|
+
return MIN_HISTORY_TOKENS;
|
|
36
|
+
const reserve = Math.min(outputReserve, Math.floor(usable * 0.25));
|
|
37
|
+
return Math.max(MIN_HISTORY_TOKENS, Math.floor(usable - reserve));
|
|
21
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* P0-1 (hysteresis): khi trim đã cần thiết, cắt xuống còn fraction này của
|
|
41
|
+
* ngân sách thay vì đúng ngưỡng. Mỗi lần trim đều vô hiệu hoá KV-cache của
|
|
42
|
+
* toàn bộ prompt phía sau điểm cắt (llama.cpp/Ollama chỉ reuse prefix giống
|
|
43
|
+
* hệt từng token), nên mục tiêu là THỨA NHẤT trim, không phải trim "vừa đủ".
|
|
44
|
+
*/
|
|
45
|
+
export const TRIM_RECOVERY_FRACTION = 0.7;
|
|
22
46
|
/** Tool results older than this many recent tool messages get pruned. */
|
|
23
47
|
const KEEP_RECENT_TOOL_RESULTS = 20;
|
|
48
|
+
/** Placeholder do prune thay vào — test và UI nhận diện chuỗi này. */
|
|
49
|
+
export const PRUNED_TOOL_RESULT = "[pruned — old tool result]";
|
|
24
50
|
/** Token count for one message (tool_calls arguments included). Never throws. */
|
|
25
51
|
function messageTokens(m) {
|
|
26
52
|
let text = m.content ?? "";
|
|
@@ -35,20 +61,71 @@ function messageTokens(m) {
|
|
|
35
61
|
}
|
|
36
62
|
}
|
|
37
63
|
const historyTokens = (msgs) => msgs.reduce((n, m) => n + messageTokens(m), 0);
|
|
64
|
+
/** Char count (content + tool_call args) — không tokenize. */
|
|
65
|
+
function historyChars(msgs) {
|
|
66
|
+
let chars = 0;
|
|
67
|
+
for (const m of msgs) {
|
|
68
|
+
chars += m.content?.length ?? 0;
|
|
69
|
+
// ChatMessage dùng shape {name, arguments}; history dựng từ JSONL resume có
|
|
70
|
+
// thể mang shape OpenAI {function:{name,arguments}} — đọc cả hai, không crash.
|
|
71
|
+
for (const tc of m.tool_calls ?? []) {
|
|
72
|
+
const call = tc;
|
|
73
|
+
chars +=
|
|
74
|
+
(call.name?.length ?? 0) +
|
|
75
|
+
(call.arguments?.length ?? 0) +
|
|
76
|
+
(call.function?.name?.length ?? 0) +
|
|
77
|
+
(call.function?.arguments?.length ?? 0);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return chars;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* P0-1: áp lực history so với ngân sách token.
|
|
84
|
+
*
|
|
85
|
+
* Một token luôn dài ≥ 1 ký tự, nên `chars` là cận trên tuyệt đối của số
|
|
86
|
+
* token: nếu chars còn chưa vượt ngân sách thì chắc chắn UNDER — bỏ qua
|
|
87
|
+
* bước encode (O(n) trên toàn bộ history, tránh trả giá mỗi bước).
|
|
88
|
+
* Khi chars vượt ngân sách mới tokenize chính xác.
|
|
89
|
+
*/
|
|
90
|
+
export function historyPressure(messages, budget = MAX_HISTORY_TOKENS) {
|
|
91
|
+
const chars = historyChars(messages);
|
|
92
|
+
if (chars <= budget)
|
|
93
|
+
return { tokens: chars, budget, over: false };
|
|
94
|
+
const tokens = historyTokens(messages);
|
|
95
|
+
return { tokens, budget, over: tokens > budget };
|
|
96
|
+
}
|
|
38
97
|
/**
|
|
39
98
|
* Trim message history to stay within a token budget. Layered strategy
|
|
40
99
|
* (context engineering — inspired by compaction patterns, from scratch):
|
|
100
|
+
* 0. P0-1 fast path: dưới ngân sách thì TRẢ NGUYÊN MẢNG (identity).
|
|
41
101
|
* 1. Message-count cap: keep system + newest MAX_MESSAGES-1.
|
|
42
|
-
* 2.
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
102
|
+
* 2. Old tool results (outside the recent window) are pruned in ONE bulk
|
|
103
|
+
* pass to a short placeholder — cheapest information loss.
|
|
104
|
+
* 3. Still over target: drop the OLDEST BLOCKS from the head (system được
|
|
105
|
+
* bảo vệ; điểm cắt không bao giờ nằm sau một assistant có tool_calls)
|
|
106
|
+
* until under the recovery target.
|
|
107
|
+
*
|
|
108
|
+
* Không bao giờ sửa nội dung lác đác ở giữa prompt: mỗi lần trim là một lần
|
|
109
|
+
* cắt gọn nhất có thể, để THỨA lần trim xảy ra.
|
|
47
110
|
*
|
|
48
111
|
* `maxTokens` defaults to the legacy 90k cap; callers with compaction enabled
|
|
49
112
|
* pass `historyTokenBudget(contextWindow)` so trim runs *after* compaction.
|
|
113
|
+
*
|
|
114
|
+
* PERF CONTRACT (plan/performance-issue.md RC-1): hàm này KHÔNG được gọi mỗi
|
|
115
|
+
* bước. Trước đây nó chạy sau mỗi step và luôn luôn sửa in-place tool result
|
|
116
|
+
* ở giữa prompt (_boundary_ prune list dịch theo từng step) → mọi lượt gọi LLM
|
|
117
|
+
* đều re-prefill toàn bộ history. Session `20261106-023204-670b`: 1.58M input
|
|
118
|
+
* token, `cachedTokens: 0`, latency nhảy từ 11.9s → 141.3s/lượt. Chỉ gọi khi
|
|
119
|
+
* `historyPressure().over` (guardrail `history-trim`) hoặc khi compaction fail.
|
|
50
120
|
*/
|
|
51
121
|
export function trimHistory(messages, maxTokens = MAX_HISTORY_TOKENS) {
|
|
122
|
+
// 0) Nothing over budget → trả về đúng mảng được truyền vào. Prefix của
|
|
123
|
+
// prompt vì thế giữ nguyên từng byte giữa các lượt gọi LLM.
|
|
124
|
+
const overCount = messages.length > MAX_MESSAGES;
|
|
125
|
+
const overTokens = historyPressure(messages, maxTokens).over;
|
|
126
|
+
if (!overCount && !overTokens)
|
|
127
|
+
return messages;
|
|
128
|
+
const target = Math.max(1, Math.floor(maxTokens * TRIM_RECOVERY_FRACTION));
|
|
52
129
|
let out = messages;
|
|
53
130
|
// 1) Message-count cap
|
|
54
131
|
if (out.length > MAX_MESSAGES) {
|
|
@@ -56,27 +133,53 @@ export function trimHistory(messages, maxTokens = MAX_HISTORY_TOKENS) {
|
|
|
56
133
|
const rest = system ? out.slice(1) : out;
|
|
57
134
|
out = system ? [system, ...rest.slice(-(MAX_MESSAGES - 1))] : rest.slice(-(MAX_MESSAGES - 1));
|
|
58
135
|
}
|
|
59
|
-
// 2)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
let
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
136
|
+
// 2) PRUNE tool result cũ TRƯỚC, một lô duy nhất: mất ít thông tin nhất
|
|
137
|
+
// (khung hội thoại assistant↔tool vẫn nguyên, model vẫn biết đã gọi gì),
|
|
138
|
+
// chỉ bỏ nội dung ngoài cửa sổ KEEP_RECENT_TOOL_RESULTS.
|
|
139
|
+
if (historyTokens(out) > target) {
|
|
140
|
+
const oldIdx = [];
|
|
141
|
+
for (let i = 0; i < out.length; i++)
|
|
142
|
+
if (out[i].role === "tool")
|
|
143
|
+
oldIdx.push(i);
|
|
144
|
+
const pruneBefore = oldIdx.length - KEEP_RECENT_TOOL_RESULTS;
|
|
145
|
+
if (pruneBefore > 0) {
|
|
146
|
+
const pruneSet = new Set(oldIdx.slice(0, pruneBefore));
|
|
147
|
+
out = out.map((m, i) => pruneSet.has(i) && (m.content?.length ?? 0) > 200 ? { ...m, content: PRUNED_TOOL_RESULT } : m);
|
|
148
|
+
}
|
|
68
149
|
}
|
|
69
|
-
// 3)
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
150
|
+
// 3) Vẫn vượt target → BỎ block nguyên khối từ đầu, dừng ở `target`
|
|
151
|
+
// (hysteresis) chứ không dừng ngay ngưỡng: số lần trim giảm ~3 lần, mỗi
|
|
152
|
+
// lần vẫn phá KV-cache nhưng tổng thời gian re-prefill thấp hơn hẳn so với
|
|
153
|
+
// phá cache mỗi bước. Điểm cắt PHẢI thoả cả hai điều kiện để không mồ côi:
|
|
154
|
+
// (a) message ngay trước điểm cắt không phải assistant có tool_calls;
|
|
155
|
+
// (b) message tại điểm cắt không phải role "tool" (cha nó đã bị bỏ).
|
|
156
|
+
// System prompt + nhiệm vụ gốc (message user đầu) luôn được bảo vệ, nên
|
|
157
|
+
// run MỘT task dài vẫn còn mục tiêu sau trim — logic "nguyên turn" cũ
|
|
158
|
+
// (cắt từ user này tới user kế tiếp) không bỏ được gì trong case đó.
|
|
159
|
+
const keepsSystem = out[0]?.role === "system";
|
|
160
|
+
const keepsTask = out[keepsSystem ? 1 : 0]?.role === "user";
|
|
161
|
+
const head = (keepsSystem ? 1 : 0) + (keepsTask ? 1 : 0);
|
|
162
|
+
const perMessage = out.map(messageTokens);
|
|
163
|
+
let total = 0;
|
|
164
|
+
for (let i = 0; i < perMessage.length; i++)
|
|
165
|
+
total += perMessage[i];
|
|
166
|
+
if (total > target) {
|
|
167
|
+
let cut = head;
|
|
168
|
+
let dropped = 0;
|
|
169
|
+
for (let i = head; i < out.length; i++) {
|
|
170
|
+
if (total - dropped <= target)
|
|
171
|
+
break;
|
|
172
|
+
dropped += perMessage[i];
|
|
173
|
+
const prev = out[i];
|
|
174
|
+
const next = out[i + 1];
|
|
175
|
+
const safeCut = i + 1 <= out.length - 2 &&
|
|
176
|
+
!(prev.role === "assistant" && prev.tool_calls?.length) &&
|
|
177
|
+
next?.role !== "tool";
|
|
178
|
+
if (safeCut)
|
|
179
|
+
cut = i + 1;
|
|
180
|
+
}
|
|
181
|
+
if (cut > head)
|
|
182
|
+
out = [...out.slice(0, head), ...out.slice(cut)];
|
|
80
183
|
}
|
|
81
184
|
return out;
|
|
82
185
|
}
|
package/dist/index.js
CHANGED
|
@@ -2,12 +2,23 @@
|
|
|
2
2
|
/** LEGACY PUBLIC PATH — CLI implementation lives in ./cli/main.js. */
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { realpathSync } from "node:fs";
|
|
5
6
|
import * as ui from "./io/terminal.js";
|
|
6
7
|
import { main } from "./cli/main.js";
|
|
7
8
|
export { parseArgs } from "./cli/args.js";
|
|
8
9
|
export { runInit } from "./cli/init.js";
|
|
9
10
|
// Guard: only run when executed as entry point (not imported in tests).
|
|
10
|
-
|
|
11
|
+
const _argv1 = process.argv[1]
|
|
12
|
+
? (() => {
|
|
13
|
+
try {
|
|
14
|
+
return realpathSync(process.argv[1]);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return process.argv[1];
|
|
18
|
+
}
|
|
19
|
+
})()
|
|
20
|
+
: null;
|
|
21
|
+
if (_argv1 && import.meta.url === pathToFileURL(_argv1).href) {
|
|
11
22
|
main().catch((err) => {
|
|
12
23
|
ui.error(`fatal: ${err.stack ?? err.message}`);
|
|
13
24
|
process.exit(2);
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P1-1 (RC-4): lấy context window THẬT của engine đang phục vụ model.
|
|
3
|
+
*
|
|
4
|
+
* Vì sao cần: `contextWindowFor()` fallback 128k cho model lạ. Session
|
|
5
|
+
* `20261106-023204-670b` chạy `qwen3.6:35b` trên Ollama/llama.cpp — server
|
|
6
|
+
* thật chạy `num_ctx` nhỏ hơn nhiều. Agent tưởng còn 102k token để nén
|
|
7
|
+
* (0.8 × 128k) trong khi server chỉ còn vài nghìn → server tự cắt history,
|
|
8
|
+
* và mỗi lần server cắt là prefix đổi → mất KV-cache → re-prefill.
|
|
9
|
+
*
|
|
10
|
+
* Chiến lược (chỉ đọc, không gửi token nào):
|
|
11
|
+
* 1. llama.cpp server: GET {origin}/props → n_ctx.
|
|
12
|
+
* 2. Ollama: POST {origin}/api/show {model} → parameters "num_ctx N"
|
|
13
|
+
* (runtime), nếu không có thì model_info "*ctx_length" (window khai
|
|
14
|
+
* báo của model, floor theo catalog).
|
|
15
|
+
* 3. OpenAI-compatible có `max_model_len` (vLLM): GET {origin}/v1/models.
|
|
16
|
+
* Không chắc chắn → trả null, gọi giữ nguyên catalog fallback.
|
|
17
|
+
*/
|
|
18
|
+
/** Cache theo (origin, model) — một engine một window, không probe lại mỗi bước. */
|
|
19
|
+
const cache = new Map();
|
|
20
|
+
function originOf(baseUrl) {
|
|
21
|
+
try {
|
|
22
|
+
// baseUrl có thể có space thừa (" http://0.0.0.0:8080/v1" trong rc thật) —
|
|
23
|
+
// URL() tự trim, nên parse được cả khi user gõ dư khoảng trắng.
|
|
24
|
+
const u = new URL(baseUrl.trim());
|
|
25
|
+
return `${u.protocol}//${u.host}`;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function authHeaders(auth) {
|
|
32
|
+
const h = { ...(auth?.headers ?? {}) };
|
|
33
|
+
if (auth?.apiKey && !h.authorization && !h["x-api-key"])
|
|
34
|
+
h.authorization = `Bearer ${auth.apiKey}`;
|
|
35
|
+
return h;
|
|
36
|
+
}
|
|
37
|
+
async function jsonFetch(url, init, timeoutMs, auth) {
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetch(url, {
|
|
40
|
+
...init,
|
|
41
|
+
headers: { ...authHeaders(auth), ...(init.headers ?? {}) },
|
|
42
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
43
|
+
});
|
|
44
|
+
if (!res.ok)
|
|
45
|
+
return null; // 401 khi server bật --api-key: probe im lặng, giữ fallback
|
|
46
|
+
return await res.json();
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null; // offline / không có endpoint này
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function asNumber(v) {
|
|
53
|
+
const n = typeof v === "string" ? Number(v) : v;
|
|
54
|
+
return typeof n === "number" && Number.isFinite(n) && n >= 1 ? Math.floor(n) : null;
|
|
55
|
+
}
|
|
56
|
+
/** "num_ctx 4096\nrope_freqscale 1" — chuỗi parameters của Ollama. */
|
|
57
|
+
function numCtxFromParameters(parameters) {
|
|
58
|
+
if (typeof parameters === "string") {
|
|
59
|
+
const m = /(?:^|\s)num_ctx\s+(\d+)/i.exec(parameters);
|
|
60
|
+
if (m)
|
|
61
|
+
return Number(m[1]);
|
|
62
|
+
}
|
|
63
|
+
if (parameters && typeof parameters === "object") {
|
|
64
|
+
return asNumber(parameters.num_ctx);
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
function ctxLengthFromModelInfo(info) {
|
|
69
|
+
if (!info || typeof info !== "object")
|
|
70
|
+
return null;
|
|
71
|
+
let best = null;
|
|
72
|
+
for (const [k, v] of Object.entries(info)) {
|
|
73
|
+
if (!/ctx_length|context_length/i.test(k))
|
|
74
|
+
continue;
|
|
75
|
+
const n = asNumber(v);
|
|
76
|
+
if (n && (!best || n < best))
|
|
77
|
+
best = n; // window KHAI BÁO: lấy số nhỏ nhất (an toàn)
|
|
78
|
+
}
|
|
79
|
+
return best;
|
|
80
|
+
}
|
|
81
|
+
async function probeLlamaCpp(origin, timeoutMs, auth) {
|
|
82
|
+
const props = (await jsonFetch(`${origin}/props`, { method: "GET" }, timeoutMs, auth));
|
|
83
|
+
if (!props)
|
|
84
|
+
return null;
|
|
85
|
+
// llama.cpp hiện để window ở default_generation_settings.n_ctx (per-slot),
|
|
86
|
+
// KHÔNG ở top level; các build cũ hơn dùng n_ctx / n_ctx_slot / total_n_ctx.
|
|
87
|
+
const perSlot = asNumber(props.default_generation_settings?.n_ctx) ??
|
|
88
|
+
asNumber(props.default_generation_settings?.params?.n_ctx) ??
|
|
89
|
+
asNumber(props.n_ctx_slot) ??
|
|
90
|
+
asNumber(props.n_ctx);
|
|
91
|
+
// total_n_ctx = perSlot * total_slots: dùng perSlot vì MỘT hội thoại sống
|
|
92
|
+
// trong MỘT slot; lấy total sẽ phóng to window lên số lần sai.
|
|
93
|
+
const n = perSlot ?? asNumber(props.total_n_ctx);
|
|
94
|
+
if (!n)
|
|
95
|
+
return null;
|
|
96
|
+
const slots = asNumber(props.total_slots);
|
|
97
|
+
return {
|
|
98
|
+
tokens: n,
|
|
99
|
+
source: "llama.cpp",
|
|
100
|
+
detail: `GET /props n_ctx=${n}${slots && slots > 1 ? ` (server chia ${slots} slot — request rải nhau nên KV-cache ít được dùng lại hơn)` : ""}`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
async function probeOllama(origin, model, timeoutMs, auth) {
|
|
104
|
+
const show = (await jsonFetch(`${origin}/api/show`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model }) }, timeoutMs, auth));
|
|
105
|
+
if (!show)
|
|
106
|
+
return null;
|
|
107
|
+
const runtime = numCtxFromParameters(show.parameters) ?? asNumber(show.context_window);
|
|
108
|
+
if (runtime)
|
|
109
|
+
return { tokens: runtime, source: "ollama", detail: `POST /api/show num_ctx=${runtime}` };
|
|
110
|
+
const declared = ctxLengthFromModelInfo(show.model_info);
|
|
111
|
+
if (declared)
|
|
112
|
+
return { tokens: declared, source: "ollama", detail: `model_info ctx_length=${declared} (server không đặt num_ctx)` };
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
async function probeVllm(baseUrl, origin, model, timeoutMs, auth) {
|
|
116
|
+
// baseUrl OpenAI-compatible thường đã gồm /v1 → GET {base}/models; thử thêm
|
|
117
|
+
// {origin}/v1/models cho server đặt root khác.
|
|
118
|
+
const trimmed = baseUrl.trim().replace(/\/+$/, "");
|
|
119
|
+
for (const url of [`${trimmed}/models`, `${origin}/v1/models`]) {
|
|
120
|
+
const list = (await jsonFetch(url, { method: "GET" }, timeoutMs, auth));
|
|
121
|
+
if (!list?.data?.length)
|
|
122
|
+
continue;
|
|
123
|
+
const entry = list.data.find((m) => m.id === model) ?? list.data[0];
|
|
124
|
+
const n = asNumber(entry?.max_model_len);
|
|
125
|
+
if (n)
|
|
126
|
+
return { tokens: n, source: "vllm", detail: `GET ${url} max_model_len=${n}` };
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Probe window thật của (baseUrl, model). null = không xác định được (giữ
|
|
132
|
+
* cách xử lý cũ). Không bao giờ throw — agent vẫn chạy bình thường với server
|
|
133
|
+
* không expose metadata.
|
|
134
|
+
*/
|
|
135
|
+
export async function probeContextWindow(baseUrl, model, timeoutMs = 1_500, auth) {
|
|
136
|
+
const origin = originOf(baseUrl);
|
|
137
|
+
if (!origin)
|
|
138
|
+
return null;
|
|
139
|
+
const key = `${origin}|${model}`;
|
|
140
|
+
if (cache.has(key))
|
|
141
|
+
return cache.get(key) ?? null;
|
|
142
|
+
const found = (await probeOllama(origin, model, timeoutMs, auth)) ??
|
|
143
|
+
(await probeLlamaCpp(origin, timeoutMs, auth)) ??
|
|
144
|
+
(await probeVllm(baseUrl, origin, model, timeoutMs, auth));
|
|
145
|
+
cache.set(key, found);
|
|
146
|
+
return found;
|
|
147
|
+
}
|
|
148
|
+
/** Test hook: xoá cache trong tiến trình (không dùng trong code production). */
|
|
149
|
+
export function resetContextProbeCache() {
|
|
150
|
+
cache.clear();
|
|
151
|
+
}
|
package/dist/llm/llm.js
CHANGED
|
@@ -88,6 +88,24 @@ export function combinedSignal(external, timeoutMs) {
|
|
|
88
88
|
// ---------------------------------------------------------------------------
|
|
89
89
|
const SSE_PREFIX = "data: ";
|
|
90
90
|
const SSE_DONE = "[DONE]";
|
|
91
|
+
/**
|
|
92
|
+
* P1-3: chuẩn hoá usage từ OpenAI-compatible server.
|
|
93
|
+
*
|
|
94
|
+
* llama.cpp/Ollama báo số token lấy từ KV-cache ở `usage.prompt_tokens_details
|
|
95
|
+
* .cached_tokens` (kiểu OpenAI mới), không phải `cached_tokens` — nếu không
|
|
96
|
+
* map thì `scoder cost` hiện cached=0 dù server thật có reuse cache, tức là
|
|
97
|
+
* đúng cái metric cần để phát hiện "prefix bị đổi".
|
|
98
|
+
*/
|
|
99
|
+
export function normalizeUsage(usage) {
|
|
100
|
+
if (!usage)
|
|
101
|
+
return undefined;
|
|
102
|
+
const out = { ...usage };
|
|
103
|
+
const details = out.prompt_tokens_details;
|
|
104
|
+
if (typeof out.cached_tokens !== "number" && typeof details?.cached_tokens === "number") {
|
|
105
|
+
out.cached_tokens = details.cached_tokens;
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
91
109
|
/** Parse SSE chunks from an OpenAI-compatible stream. */
|
|
92
110
|
function parseSSELine(line) {
|
|
93
111
|
if (!line.startsWith(SSE_PREFIX))
|
|
@@ -188,14 +206,7 @@ export class LlmClient {
|
|
|
188
206
|
throw new LlmError("LLM API returned no message in response");
|
|
189
207
|
// Extract cached prompt tokens when the provider reports them
|
|
190
208
|
// (OpenAI-style: usage.prompt_tokens_details.cached_tokens).
|
|
191
|
-
|
|
192
|
-
if (data.usage) {
|
|
193
|
-
usage = { ...data.usage };
|
|
194
|
-
const details = usage.prompt_tokens_details;
|
|
195
|
-
if (usage.cached_tokens === undefined && typeof details?.cached_tokens === "number") {
|
|
196
|
-
usage.cached_tokens = details.cached_tokens;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
209
|
+
const usage = normalizeUsage(data.usage);
|
|
199
210
|
return {
|
|
200
211
|
message: {
|
|
201
212
|
role: "assistant",
|
|
@@ -312,7 +323,7 @@ export class LlmClient {
|
|
|
312
323
|
// Usage-only final chunk: many providers send usage in a chunk with
|
|
313
324
|
// an empty choices array — must not be skipped.
|
|
314
325
|
if (chunkData.usage)
|
|
315
|
-
finalUsage =
|
|
326
|
+
finalUsage = normalizeUsage(chunkData.usage);
|
|
316
327
|
const choice = chunkData.choices?.[0];
|
|
317
328
|
if (!choice)
|
|
318
329
|
continue;
|
|
@@ -369,7 +380,7 @@ export class LlmClient {
|
|
|
369
380
|
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
370
381
|
reasoning: reasoningAccum || undefined,
|
|
371
382
|
},
|
|
372
|
-
usage: chunkData.usage,
|
|
383
|
+
usage: normalizeUsage(chunkData.usage),
|
|
373
384
|
};
|
|
374
385
|
}
|
|
375
386
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P1-3 (RC-1): đo "prefix" của prompt để phát hiện mất KV-cache.
|
|
3
|
+
*
|
|
4
|
+
* Engine cục bộ (llama.cpp / Ollama) chỉ reuse KV cache cho phần prefix GIỐNG
|
|
5
|
+
* HẸT từng token; một token ở giữa đổi ⇒ re-prefill toàn bộ phía sau. Session
|
|
6
|
+
* `20261106-023204-670b` đổi 11.9s → 141.3s/lượt đúng tại điểm prune đầu
|
|
7
|
+
* tiên. Câu hỏi cần trả lời được từ JSONL: "lượt này history có phải là append
|
|
8
|
+
* lên history của lượt trước không?"
|
|
9
|
+
*
|
|
10
|
+
* Cách đo: băm TỪNG message theo thứ tự, so mảng hash của lượt trước với lượt
|
|
11
|
+
* này. `prefixReused = true` khi mảng cũ là tiền tố của mảng mới (append-only)
|
|
12
|
+
* ⇒ toàn bộ prompt đã gửi trước đó vẫn nằm nguyên trong cache. Một message ở
|
|
13
|
+
* giữa bị sửa (prune/trim/compaction) ⇒ false. Đổi tool list cũng làm hỏng
|
|
14
|
+
* prefix (tool defs nằm ở vùng system) nên `promptHash` (gồm cả tools) được log
|
|
15
|
+
* kèm để phân biệt hai nguyên nhân.
|
|
16
|
+
*/
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
const short = (s) => createHash("sha1").update(s).digest("hex").slice(0, 12);
|
|
19
|
+
/** Chuỗi đặc trưng cho một message (role + content + tool_calls). */
|
|
20
|
+
function messageKey(m) {
|
|
21
|
+
let s = m.role + "\u0000" + (m.content ?? "");
|
|
22
|
+
for (const tc of m.tool_calls ?? []) {
|
|
23
|
+
const call = tc;
|
|
24
|
+
s += "\u0001" + (call.name ?? call.function?.name ?? "") + (call.arguments ?? call.function?.arguments ?? "");
|
|
25
|
+
}
|
|
26
|
+
if (m.tool_call_id)
|
|
27
|
+
s += "\u0002" + m.tool_call_id;
|
|
28
|
+
return s;
|
|
29
|
+
}
|
|
30
|
+
/** Serialize phần tools — đặc trưng và ổn định theo thứ tự khai báo. */
|
|
31
|
+
export function serializeTools(tools) {
|
|
32
|
+
return (tools ?? [])
|
|
33
|
+
.map((t) => t.function.name + "\u0000" + (t.function.description ?? "") + "\u0000" + JSON.stringify(t.function.parameters ?? {}))
|
|
34
|
+
.join("\u0003");
|
|
35
|
+
}
|
|
36
|
+
export function messageHashes(messages) {
|
|
37
|
+
return (messages ?? []).map((m) => short(messageKey(m)));
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* So sánh: mảng hash của lượt trước có phải tiền tố của lượt này không.
|
|
41
|
+
* `commonPrefix` = số message đầu giống nhau.
|
|
42
|
+
*/
|
|
43
|
+
export function commonPrefixLength(prev, current) {
|
|
44
|
+
const previous = prev ?? [];
|
|
45
|
+
let i = 0;
|
|
46
|
+
while (i < previous.length && i < current.length && previous[i] === current[i])
|
|
47
|
+
i++;
|
|
48
|
+
return { reused: i === previous.length, commonPrefix: i };
|
|
49
|
+
}
|
|
50
|
+
/** Tính metrics cho một lượt gọi — O(kích thước prompt), chỉ chạy một lần/lượt. */
|
|
51
|
+
export function promptMetrics(messages, tools, previous) {
|
|
52
|
+
const hashes = messageHashes(messages);
|
|
53
|
+
const historyText = hashes.join("");
|
|
54
|
+
const toolsText = serializeTools(tools);
|
|
55
|
+
const cmp = previous ? commonPrefixLength(previous.messageHashes, hashes) : null;
|
|
56
|
+
// Char thật của prompt (nội dung message + tool defs) — để trả lời "12k token
|
|
57
|
+
// này đến từ đâu". KHÔNG dùng historyText (chuỗi hash) ở đây.
|
|
58
|
+
let messageChars = 0;
|
|
59
|
+
for (const m of messages ?? []) {
|
|
60
|
+
messageChars += (m.content ?? "").length + (m.tool_call_id ?? "").length;
|
|
61
|
+
for (const tc of m.tool_calls ?? []) {
|
|
62
|
+
const call = tc;
|
|
63
|
+
messageChars += (call.name ?? call.function?.name ?? "").length + (call.arguments ?? call.function?.arguments ?? "").length;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
messages: hashes.length,
|
|
68
|
+
tools: (tools ?? []).length,
|
|
69
|
+
promptChars: messageChars + toolsText.length,
|
|
70
|
+
messageChars,
|
|
71
|
+
schemaChars: toolsText.length,
|
|
72
|
+
promptHash: short(toolsText + "\u0004" + historyText),
|
|
73
|
+
historyHash: short(historyText),
|
|
74
|
+
messageHashes: hashes,
|
|
75
|
+
prefixReused: cmp ? cmp.reused : null,
|
|
76
|
+
commonPrefixMessages: cmp ? cmp.commonPrefix : null,
|
|
77
|
+
};
|
|
78
|
+
}
|
package/dist/mcp/manager.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { redactSecrets } from "../llm/llm.js";
|
|
2
2
|
import { MCP_RESOURCE_TOOL_NAMES, mcpToolKey } from "./types.js";
|
|
3
|
+
import { filterToolNames } from "./tool-filter.js";
|
|
3
4
|
import { McpConnection } from "./connection.js";
|
|
4
5
|
const schemaObj = (properties, required) => ({
|
|
5
6
|
type: "object",
|
|
@@ -174,25 +175,8 @@ export class McpManager {
|
|
|
174
175
|
}
|
|
175
176
|
/** MCP A: apply per-server tool include/exclude filters to a tool list. */
|
|
176
177
|
filterToolDefs(name, defs) {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
return defs;
|
|
180
|
-
const include = filter.include?.filter(Boolean) ?? [];
|
|
181
|
-
const exclude = new Set((filter.exclude ?? []).map((s) => s.toLowerCase()));
|
|
182
|
-
const match = (pat, n) => {
|
|
183
|
-
if (!pat.includes("*"))
|
|
184
|
-
return n === pat.toLowerCase();
|
|
185
|
-
const rx = new RegExp("^" + pat.split("*").map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$", "i");
|
|
186
|
-
return rx.test(n);
|
|
187
|
-
};
|
|
188
|
-
return defs.filter((t) => {
|
|
189
|
-
const n = t.name.toLowerCase();
|
|
190
|
-
if (include.length > 0 && !include.some((pat) => match(pat, n)))
|
|
191
|
-
return false;
|
|
192
|
-
if ([...exclude].some((e) => match(e, n)))
|
|
193
|
-
return false;
|
|
194
|
-
return true;
|
|
195
|
-
});
|
|
178
|
+
// P0-3: matcher dùng chung với bộ lọc toàn cục rc "mcpTools" (tool-filter.ts).
|
|
179
|
+
return filterToolNames(defs, (t) => t.name, this.configs[name]?.tools);
|
|
196
180
|
}
|
|
197
181
|
/** Replace một server's tool registrations (reconnect refresh) — honor config filters. */
|
|
198
182
|
reregisterTools(name, conn, defs) {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P0-3 (RC-2): glob filter cho tên tool MCP.
|
|
3
|
+
*
|
|
4
|
+
* Tách khỏi McpManager để `agent/mcp-bridge.ts` lọc được DANH SÁCH TOOL ĐÃ
|
|
5
|
+
* QUẢNG CÁO (tên đầy đủ `mcp__<server>__<tool>`) bằng rc "mcpTools" — cùng cú
|
|
6
|
+
* pháp với `mcpServers.<name>.tools.include/exclude` (lọc theo server).
|
|
7
|
+
*/
|
|
8
|
+
/** Pattern không có `*` so khớp đúng tên (case-insensitive); có `*` → glob. */
|
|
9
|
+
export function matchToolPattern(pattern, name) {
|
|
10
|
+
const p = pattern.toLowerCase();
|
|
11
|
+
const n = name.toLowerCase();
|
|
12
|
+
if (!p.includes("*"))
|
|
13
|
+
return n === p;
|
|
14
|
+
const rx = new RegExp("^" + p.split("*").map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$", "i");
|
|
15
|
+
return rx.test(n);
|
|
16
|
+
}
|
|
17
|
+
/** True khi `name` được phép theo filter (include rỗng = tất cả; exclude thắng). */
|
|
18
|
+
export function toolAllowed(name, filter) {
|
|
19
|
+
if (!filter)
|
|
20
|
+
return true;
|
|
21
|
+
const include = (filter.include ?? []).filter(Boolean);
|
|
22
|
+
const exclude = (filter.exclude ?? []).filter(Boolean);
|
|
23
|
+
if (include.length > 0 && !include.some((pat) => matchToolPattern(pat, name)))
|
|
24
|
+
return false;
|
|
25
|
+
if (exclude.some((pat) => matchToolPattern(pat, name)))
|
|
26
|
+
return false;
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
/** Lọc một danh sách theo nhiều filter (giữ thứ tự khai báo). */
|
|
30
|
+
export function filterToolNames(items, nameOf, filter) {
|
|
31
|
+
if (!filter || (!filter.include?.length && !filter.exclude?.length))
|
|
32
|
+
return items;
|
|
33
|
+
return items.filter((item) => toolAllowed(nameOf(item), filter));
|
|
34
|
+
}
|
|
@@ -5,11 +5,31 @@ export function setMaxOutputChars(n) {
|
|
|
5
5
|
if (Number.isFinite(n) && n > 0)
|
|
6
6
|
MAX_OUTPUT_CHARS = Math.floor(n);
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* P0-2 (RC-3): ngân sách ký tự cho MỘT tool result khi đẩy vào history.
|
|
10
|
+
*
|
|
11
|
+
* Khác MAX_OUTPUT_CHARS (chặn blob từ tool): con số này quyết định kích thước
|
|
12
|
+
* nội dung model nhìn thấy mỗi bước. Với local model, mỗi 6k ký tự đọc thêm
|
|
13
|
+
* ≈ 1.5k token và TOÀN BỘ số đó được re-prefill ở mọi lượt sau. read_file dùng
|
|
14
|
+
* giá trị này để cắt theo DÒNG kèm hướng dẫn đọc tiếp (xem read-file.ts).
|
|
15
|
+
*/
|
|
16
|
+
export let MAX_TOOL_OUTPUT_CHARS = 6_000;
|
|
17
|
+
export function setMaxToolOutputChars(n) {
|
|
18
|
+
if (Number.isFinite(n) && n > 0)
|
|
19
|
+
MAX_TOOL_OUTPUT_CHARS = Math.floor(n);
|
|
20
|
+
}
|
|
8
21
|
export const MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024; // refuse to read files > 2MB
|
|
9
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Cắt chuỗi theo ký tự, thêm marker số lượng còn lại. `hint` (P0-2) được gắn
|
|
24
|
+
* vào marker để model biết CÁCH lấy phần còn lại (offset/limit) thay vì đoán —
|
|
25
|
+
* đây chính là điểm commit aec95ef đã sửa (trunc mù → agent đọc lại/đổi chiến
|
|
26
|
+
* lược, đốt thêm nhiều lượt).
|
|
27
|
+
*/
|
|
28
|
+
export function clamp(s, max = MAX_OUTPUT_CHARS, hint) {
|
|
10
29
|
if (s.length <= max)
|
|
11
30
|
return s;
|
|
12
|
-
|
|
31
|
+
const tail = hint ? ` — ${hint}` : "";
|
|
32
|
+
return s.slice(0, max) + `\n... [truncated, ${s.length - max} more chars${tail}]`;
|
|
13
33
|
}
|
|
14
34
|
/** Resolve a user-supplied path inside workdir; reject escapes and symlink traversal. */
|
|
15
35
|
export function safePath(workdir, p) {
|
package/dist/tools/read-file.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync, statSync, realpathSync } from "node:fs";
|
|
2
2
|
import { relative } from "node:path";
|
|
3
3
|
import { detectImageBytes } from "../media/image.js";
|
|
4
|
-
import { clamp, MAX_FILE_SIZE_BYTES, safePath, toPosInt, toResult } from "./path-safety.js";
|
|
4
|
+
import { clamp, MAX_FILE_SIZE_BYTES, MAX_OUTPUT_CHARS, MAX_TOOL_OUTPUT_CHARS, safePath, toPosInt, toResult } from "./path-safety.js";
|
|
5
5
|
import { isProtectedEnvFile } from "./path-safety.js";
|
|
6
6
|
export function toolReadFile(workdir, args) {
|
|
7
7
|
return toResult(() => {
|
|
@@ -56,9 +56,29 @@ export function toolReadFile(workdir, args) {
|
|
|
56
56
|
const offset = Math.min(toPosInt(args.offset, 1), total);
|
|
57
57
|
const limit = Math.min(toPosInt(args.limit, total - offset + 1), 2000);
|
|
58
58
|
const slice = lines.slice(offset - 1, offset - 1 + limit);
|
|
59
|
-
const
|
|
60
|
-
|
|
59
|
+
const numberedAll = slice.map((l, i) => `${offset + i}: ${l}`);
|
|
60
|
+
// P0-2 (RC-3): cắt theo DÒNG ngay tại ngân sách history (limits.toolOutputChars)
|
|
61
|
+
// và nói rõ phần còn lại nằm ở dòng nào + lấy tiếp bằng tham số nào. Trunc mù
|
|
62
|
+
// kiểu "... more chars" từng khiến agent đọc lại cả file bằng "cách khác".
|
|
63
|
+
const budget = Math.max(200, Math.min(MAX_TOOL_OUTPUT_CHARS, MAX_OUTPUT_CHARS));
|
|
64
|
+
const kept = [];
|
|
65
|
+
let used = 0;
|
|
66
|
+
for (const line of numberedAll) {
|
|
67
|
+
if (used + line.length + 1 > budget && kept.length > 0)
|
|
68
|
+
break;
|
|
69
|
+
kept.push(line);
|
|
70
|
+
used += line.length + 1;
|
|
71
|
+
}
|
|
72
|
+
const dropped = numberedAll.length - kept.length;
|
|
73
|
+
const end = offset + kept.length - 1;
|
|
61
74
|
const header = offset > 1 || end < total ? `[lines ${offset}-${end} of ${total} — ${args.path}]\n` : "";
|
|
62
|
-
|
|
75
|
+
const footer = dropped > 0
|
|
76
|
+
? `\n... [${dropped} more line(s): ${end + 1}-${offset + slice.length - 1} of ${total}. ` +
|
|
77
|
+
`Read the next chunk with read_file({"path":"${args.path}","offset":${end + 1}}) — ` +
|
|
78
|
+
`do NOT re-read from the start.]`
|
|
79
|
+
: "";
|
|
80
|
+
// clamp cho dòng kếch xù (một dòng 40k ký tự vẫn phải nằm trong ngân sách).
|
|
81
|
+
const numbered = clamp(kept.join("\n"), budget);
|
|
82
|
+
return { ok: true, output: header + numbered + footer };
|
|
63
83
|
});
|
|
64
84
|
}
|