s-app-scoder-agent 0.9.10 → 0.9.15
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 +2 -0
- package/README.md +1 -1
- package/dist/agent/interaction.js +15 -8
- package/dist/agent/prompt-context.js +5 -0
- package/dist/agent.js +232 -3
- package/dist/cli/repl.js +6 -0
- package/dist/config/config.js +23 -6
- package/dist/context/prompt.js +10 -4
- package/dist/llm/llm.js +3 -1
- package/dist/llm/provider-anthropic.js +5 -3
- package/dist/llm/retry.js +4 -0
- package/dist/mcp/manager.js +2 -2
- package/dist/security/permissions.js +32 -12
- package/dist/tools/read-file.js +4 -2
- package/dist/tools/run-command.js +42 -24
- package/dist/tools/search.js +7 -0
- package/dist/tui/app.js +16 -3
- package/dist/tui/charset.js +26 -0
- package/dist/tui/chrome.js +9 -6
- package/dist/tui/hooks/use-keybinds.js +24 -1
- package/dist/tui/hooks/use-scroll.js +12 -3
- package/dist/tui/markdown/diagram.js +85 -6
- package/dist/tui/markdown/graph.js +1021 -0
- package/dist/tui/selectors.js +9 -3
- package/dist/tui/state.js +13 -0
- package/dist/workspace/file-index.js +6 -5
- package/package.json +1 -1
package/.scoderrc.example.json
CHANGED
package/README.md
CHANGED
|
@@ -395,7 +395,7 @@ Features:
|
|
|
395
395
|
- **Slash commands** — `/help`, `/clear`, `/compact`, `/cost`, `/model`, `/skill`, `/mcp`, `/permission` (identical to the REPL; `/theme` is TUI-only)
|
|
396
396
|
- **Multi-turn** — keep typing follow-ups after a task finishes; the conversation context carries over (same as the REPL)
|
|
397
397
|
- **Scrollback** — `PgUp`/`PgDn` page in 0.8-viewport steps, `Home`/`End` jump to top/bottom, `Esc` returns to the live tail (press again to abort); rendering is capped at the last 200 items for smooth scrolling on long runs
|
|
398
|
-
- **Keybinds** — `Enter` send · `PgUp`/`PgDn` scroll · `Esc` reset scroll / abort · `Ctrl+C` quit
|
|
398
|
+
- **Keybinds** — `Enter` send · `PgUp`/`PgDn` scroll · `Esc` reset scroll / abort · `Ctrl+O` toggle select mode (releases mouse tracking so you can drag-highlight chat text and copy it with `⌘C`/`Ctrl+C`) · `Ctrl+C` quit
|
|
399
399
|
- Non-TTY environments (CI, pipes) automatically fall back to the classic REPL
|
|
400
400
|
- `permission: "ask"` is not supported in the TUI (no readline prompts on the alt screen) — it falls back to `auto`
|
|
401
401
|
|
|
@@ -21,15 +21,22 @@ export class InteractionBroker {
|
|
|
21
21
|
async awaitQuestion(runtime, request, id, handler) {
|
|
22
22
|
try {
|
|
23
23
|
const timeoutMs = (request.timeoutSecs ?? 120) * 1000;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
24
|
+
let timer;
|
|
25
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
26
|
+
timer = setTimeout(() => resolve(null), timeoutMs);
|
|
27
|
+
});
|
|
28
|
+
try {
|
|
29
|
+
const answer = await Promise.race([handler({ ...request, id }), timeoutPromise]);
|
|
30
|
+
if (answer == null || (typeof answer === "string" && !answer.trim())) {
|
|
31
|
+
return { ok: false, output: "ERROR: ask_user timed out or was cancelled." };
|
|
32
|
+
}
|
|
33
|
+
runtime.logger.log({ type: "ask_user", question: request.question, answer: String(answer).slice(0, 500) });
|
|
34
|
+
return { ok: true, output: `User answered: ${answer}` };
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
if (timer)
|
|
38
|
+
clearTimeout(timer);
|
|
30
39
|
}
|
|
31
|
-
runtime.logger.log({ type: "ask_user", question: request.question, answer: String(answer).slice(0, 500) });
|
|
32
|
-
return { ok: true, output: `User answered: ${answer}` };
|
|
33
40
|
}
|
|
34
41
|
catch (err) {
|
|
35
42
|
return { ok: false, output: `ERROR: ask_user failed: ${err.message.slice(0, 200)}` };
|
|
@@ -88,6 +88,11 @@ export class PromptContext {
|
|
|
88
88
|
clearPendingTools() {
|
|
89
89
|
this.pendingAllowedTools = null;
|
|
90
90
|
}
|
|
91
|
+
/** Non-destructive read — warm-up dựng đúng messages như run() nhưng KHÔNG
|
|
92
|
+
* tiêu thụ pending context (nó thuộc về run thật, take là mất). */
|
|
93
|
+
peekPendingContext() {
|
|
94
|
+
return [...this.pendingContext];
|
|
95
|
+
}
|
|
91
96
|
takePendingContext() {
|
|
92
97
|
const pending = this.pendingContext;
|
|
93
98
|
this.pendingContext = [];
|
package/dist/agent.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { loadPermissionLists, loadPolicyRules, loadSavedApprovals } from "./config/config.js";
|
|
1
|
+
import { loadPermissionLists, loadPolicyRules, loadSavedApprovals, adaptiveToolOutputChars } from "./config/config.js";
|
|
2
2
|
import { LlmClient, LlmError, redactSecrets } from "./llm/llm.js";
|
|
3
3
|
import { AnthropicClient } from "./llm/provider-anthropic.js";
|
|
4
4
|
import { allToolSpecs } from "./tools/registry.js";
|
|
@@ -33,6 +33,12 @@ import { runAgentLoop } from "./agent/run-loop.js";
|
|
|
33
33
|
// CHAT_DONE_MARKER import từ guardrails.js (marker chat thuần, rule 9).
|
|
34
34
|
import { EVENT_SCHEMA_VERSION, applyToolFilter, resolvePrimaryConfig, } from "./agent/types.js";
|
|
35
35
|
export { EVENT_SCHEMA_VERSION, resolvePrimaryConfig } from "./agent/types.js";
|
|
36
|
+
/**
|
|
37
|
+
* FILE-READ-PERF P3: nhịp heartbeat re-warm khi idle — 4' < TTL cache ~5'
|
|
38
|
+
* (Anthropic ephemeral / Ollama keep_alive mặc định), để prefix không bao giờ
|
|
39
|
+
* nguội trong lúc session còn mở.
|
|
40
|
+
*/
|
|
41
|
+
export const WARMUP_IDLE_REFRESH_MS = 240_000;
|
|
36
42
|
export class Agent {
|
|
37
43
|
/** v2 event schema: stable id for the run — links agent_start/agent_end. */
|
|
38
44
|
id = randomUUID();
|
|
@@ -89,6 +95,25 @@ export class Agent {
|
|
|
89
95
|
/** v2: build no-progress tracking — same failure signature twice → escalate. */
|
|
90
96
|
lastVerifySignature = "";
|
|
91
97
|
verifyStrikes = 0;
|
|
98
|
+
/**
|
|
99
|
+
* WARMUP (plan/WARMUP-PLAN.md): request prefill nền — dựng đúng prefix
|
|
100
|
+
* system+tools mà lượt thật sẽ gửi, max_tokens=1, vứt response. Null khi
|
|
101
|
+
* chưa trigger; `settled` sau khi LLM request kết thúc (thành công/abort/lỗi).
|
|
102
|
+
*/
|
|
103
|
+
warmupRun = null;
|
|
104
|
+
/** True after dispose() — warm-up không được để MCP server con mọc lại. */
|
|
105
|
+
disposed = false;
|
|
106
|
+
/**
|
|
107
|
+
* FILE-READ-PERF P3 (plan/FILE-READ-PERF-PLAN.md): heartbeat re-warm khi idle —
|
|
108
|
+
* engine local có keep_alive ~5', model unload thì lượt kế trả 35–56s cold
|
|
109
|
+
* prefill (đo thật). Timer chạy CHỈ lúc nhàn rỗi (settleWarmup tắt, finish()
|
|
110
|
+
* bật lại), unref để không giữ process. CHỈ cho baseUrl loopback.
|
|
111
|
+
*/
|
|
112
|
+
warmupIdleTimer = null;
|
|
113
|
+
/** True nếu frontend này THỰC sự dùng warm-up (REPL/TUI) — serve/run-once không heartbeat. */
|
|
114
|
+
warmupUsed = false;
|
|
115
|
+
/** Test knob: thu ngắn nhịp heartbeat (default WARMUP_IDLE_REFRESH_MS). */
|
|
116
|
+
warmupIdleRefreshMs = WARMUP_IDLE_REFRESH_MS;
|
|
92
117
|
/** v2: ask_user plumbing (frontends register handlers; serve → not-interactive). */
|
|
93
118
|
/** Guardrail Engine — khởi tạo trong constructor (cần this.config). */
|
|
94
119
|
guardrails;
|
|
@@ -411,7 +436,7 @@ export class Agent {
|
|
|
411
436
|
const servers = rc.mcpServers ?? {};
|
|
412
437
|
this.config.mcpServers = servers;
|
|
413
438
|
if (this.mcp) {
|
|
414
|
-
void this.mcp.applyConfig(servers);
|
|
439
|
+
void this.mcp.applyConfig(servers).catch(() => { });
|
|
415
440
|
}
|
|
416
441
|
}
|
|
417
442
|
});
|
|
@@ -424,6 +449,8 @@ export class Agent {
|
|
|
424
449
|
abort() {
|
|
425
450
|
this.aborted = true;
|
|
426
451
|
this.runSignal.abort(new DOMException("interrupted by user", "AbortError"));
|
|
452
|
+
// WARMUP: SIGINT giết luôn request prefill nếu còn chạy nền.
|
|
453
|
+
this.warmupRun?.abort.abort(new DOMException("interrupted by user", "AbortError"));
|
|
427
454
|
}
|
|
428
455
|
/**
|
|
429
456
|
* Steer: user input delivered at the NEXT safe step boundary (pattern from
|
|
@@ -848,8 +875,10 @@ export class Agent {
|
|
|
848
875
|
* server tự cắt history, và mỗi lần nó cắt là một lần mất KV-cache.
|
|
849
876
|
*/
|
|
850
877
|
async applyContextProbe() {
|
|
851
|
-
if (this.config.contextWindow)
|
|
878
|
+
if (this.config.contextWindow) {
|
|
879
|
+
this.applyAdaptiveReadBudget();
|
|
852
880
|
return;
|
|
881
|
+
}
|
|
853
882
|
let probe = null;
|
|
854
883
|
try {
|
|
855
884
|
// Server local vẫn có thể bật --api-key: /props trả 401 nếu không gửi
|
|
@@ -875,11 +904,190 @@ export class Agent {
|
|
|
875
904
|
if (probe.tokens !== catalog) {
|
|
876
905
|
this.reporter.info(`context window: ${probe.tokens} token (${probe.detail}) — thay fallback ${catalog} token, compaction/trim tính theo số này`);
|
|
877
906
|
}
|
|
907
|
+
this.applyAdaptiveReadBudget();
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* FILE-READ-PERF (plan/FILE-READ-PERF-PLAN.md) P1: nới ngân sách một lần đọc
|
|
911
|
+
* theo window THẬT khi local + user không đặt tường minh. Chạy một lần; giá
|
|
912
|
+
* trị ghi cả vào config.limits (safety-net ở tool-executor) lẫn module budget
|
|
913
|
+
* mà read-file.ts cắt theo. Probe thất bại (không có window) → giữ floor cũ.
|
|
914
|
+
*/
|
|
915
|
+
readBudgetAdapted = false;
|
|
916
|
+
applyAdaptiveReadBudget() {
|
|
917
|
+
if (this.readBudgetAdapted)
|
|
918
|
+
return;
|
|
919
|
+
const window = this.config.contextWindow;
|
|
920
|
+
if (!window || !isLocalBaseUrl(this.config.baseUrl) || this.config.localProfile === false)
|
|
921
|
+
return;
|
|
922
|
+
if (this.config.limits?.toolOutputChars !== undefined)
|
|
923
|
+
return; // user thắng, không đụng
|
|
924
|
+
const budget = adaptiveToolOutputChars(window);
|
|
925
|
+
this.config.limits = { ...this.config.limits, toolOutputChars: budget };
|
|
926
|
+
setMaxToolOutputChars(budget);
|
|
927
|
+
this.readBudgetAdapted = true;
|
|
928
|
+
this.logger.log({ type: "read_budget", chars: budget, window });
|
|
878
929
|
}
|
|
879
930
|
/** Rebuild built-in and connected MCP tool specs for the current skill scope. */
|
|
880
931
|
rebuildToolList() {
|
|
881
932
|
rebuildMcpToolList(this.mcpBridgeRuntime());
|
|
882
933
|
}
|
|
934
|
+
/**
|
|
935
|
+
* WARMUP (plan/WARMUP-PLAN.md): prefill prefix system+tools NỀN ngay khi mở
|
|
936
|
+
* session, để lượt chat đầu không phải trả cold-load + 20–60k token prefill
|
|
937
|
+
* trên đường tới token đầu (đo: 30–120s trên máy cá nhân, xem P2-3). One-shot,
|
|
938
|
+
* idempotent, fire-and-forget — lỗi im lặng (best-effort).
|
|
939
|
+
* `rewarm` (FILE-READ-PERF P3): bản heartbeat — bỏ guard "chưa có messages",
|
|
940
|
+
* tiền đề là entry trước đã settle.
|
|
941
|
+
*/
|
|
942
|
+
warmup(opts = {}) {
|
|
943
|
+
const w = this.warmupRun;
|
|
944
|
+
if (w && (!w.settled || !opts.rewarm))
|
|
945
|
+
return w.promise;
|
|
946
|
+
if (this.config.warmup === false)
|
|
947
|
+
return Promise.resolve();
|
|
948
|
+
// aborted chỉ chặn warm KHỞI ĐẦU; heartbeat sống lại sau lượt kế (resetRunSignal).
|
|
949
|
+
if (this.disposed || (this.aborted && !opts.rewarm))
|
|
950
|
+
return Promise.resolve();
|
|
951
|
+
if (!opts.rewarm && this.messages.length > 0)
|
|
952
|
+
return Promise.resolve();
|
|
953
|
+
if (!this.config.apiKey)
|
|
954
|
+
return Promise.resolve();
|
|
955
|
+
// I-12: harness eval thay client bằng scripted transcript — warm-up sẽ ăn
|
|
956
|
+
// mất một lượt kịch bản, làm hỏng mọi assertion đếm request.
|
|
957
|
+
if (this.config.clientFactory)
|
|
958
|
+
return Promise.resolve();
|
|
959
|
+
const abort = new AbortController();
|
|
960
|
+
const entry = { abort, settled: false, promise: Promise.resolve() };
|
|
961
|
+
this.warmupUsed = true;
|
|
962
|
+
entry.promise = this.runWarmup(abort.signal, () => {
|
|
963
|
+
entry.settled = true;
|
|
964
|
+
}, opts.rewarm === true);
|
|
965
|
+
this.warmupRun = entry;
|
|
966
|
+
return entry.promise;
|
|
967
|
+
}
|
|
968
|
+
async runWarmup(signal, settle, rewarm = false) {
|
|
969
|
+
const t0 = Date.now();
|
|
970
|
+
try {
|
|
971
|
+
// Báo UI biết warm-up đang chạy (giữ màn hình welcome + progress bar).
|
|
972
|
+
this.emit({ type: "warmup", active: true, stage: "init", percent: 5, message: "đang chuẩn bị warm-up…" });
|
|
973
|
+
// Dời hai việc network ra khỏi đường tới token đầu. run() vẫn gọi lại
|
|
974
|
+
// nguyên như cũ — cả hai đều return sớm khi đã hoàn tất ở đây.
|
|
975
|
+
await Promise.allSettled([this.applyContextProbe(), this.connectMcp()]);
|
|
976
|
+
if (signal.aborted || this.disposed)
|
|
977
|
+
return;
|
|
978
|
+
this.emit({ type: "warmup", active: true, stage: "context+mcp", percent: 60, message: "context window + MCP sẵn sàng" });
|
|
979
|
+
// FILE-READ-PERF P3: heartbeat sau khi đã chạy ≥1 lượt thật gửi lại
|
|
980
|
+
// HISTORY THẬT — đó mới là prefix của lượt kế tiếp (placeholder sẽ phân
|
|
981
|
+
// nhánh ngay sau system). Trước lượt đầu: dựng đúng messages như run().
|
|
982
|
+
const hasHistory = this.messages.length > 0;
|
|
983
|
+
// Tool list + messages dựng ĐÚNG như run() — cache-key (Anthropic
|
|
984
|
+
// breakpoint, prefix KV của llama.cpp) là từng byte JSON của schema.
|
|
985
|
+
const pendingAllowedTools = this.promptContext.pendingTools();
|
|
986
|
+
const allow = pendingAllowedTools?.length
|
|
987
|
+
? new Set(pendingAllowedTools.map((t) => t.toLowerCase()))
|
|
988
|
+
: null;
|
|
989
|
+
let baseTools = applyToolFilter(allToolSpecs(), this.primaryDef, this.reporter);
|
|
990
|
+
if (allow)
|
|
991
|
+
baseTools = baseTools.filter((s) => allow.has(s.function.name.toLowerCase()));
|
|
992
|
+
const mcpSpecs = governedMcpSpecs(this.mcpBridgeRuntime());
|
|
993
|
+
const tools = allow
|
|
994
|
+
? [...baseTools, ...mcpSpecs.filter((s) => allow.has(s.function.name.toLowerCase()))]
|
|
995
|
+
: [...baseTools, ...mcpSpecs];
|
|
996
|
+
const messages = hasHistory
|
|
997
|
+
? this.messages
|
|
998
|
+
: buildRunMessages({
|
|
999
|
+
config: this.config,
|
|
1000
|
+
primaryDef: this.primaryDef,
|
|
1001
|
+
skills: this.promptContext.getSkills(),
|
|
1002
|
+
buildCmd: this.buildCmd,
|
|
1003
|
+
// Placeholder KHÔNG mention file nào: system prompt trùng byte với
|
|
1004
|
+
// request thật ở mọi task không có focus-file guide (xem plan §7).
|
|
1005
|
+
task: "[warmup]",
|
|
1006
|
+
pendingContext: this.promptContext.peekPendingContext(),
|
|
1007
|
+
});
|
|
1008
|
+
this.emit({ type: "warmup", active: true, stage: "prompt", percent: 80, message: "đang prefill system prompt…" });
|
|
1009
|
+
const res = await this.client.chat(messages, tools, { signal, maxTokens: 1 });
|
|
1010
|
+
this.emit({ type: "warmup", active: true, stage: "done", percent: 100, message: "warm-up xong" });
|
|
1011
|
+
this.logger.log({
|
|
1012
|
+
type: "warmup",
|
|
1013
|
+
ok: true,
|
|
1014
|
+
rewarm,
|
|
1015
|
+
ms: Date.now() - t0,
|
|
1016
|
+
model: this.client.model,
|
|
1017
|
+
promptTokens: res.usage?.prompt_tokens,
|
|
1018
|
+
cachedTokens: res.usage?.cached_tokens,
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
catch (err) {
|
|
1022
|
+
// Warm-up không bao giờ được làm hỏng run thật — kể cả SIGINT abort.
|
|
1023
|
+
this.logger.log({
|
|
1024
|
+
type: "warmup",
|
|
1025
|
+
ok: false,
|
|
1026
|
+
ms: Date.now() - t0,
|
|
1027
|
+
error: (err?.message ?? String(err)).slice(0, 200),
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
finally {
|
|
1031
|
+
this.emit({ type: "warmup", active: false, stage: "done", percent: 100, message: "warm-up kết thúc" });
|
|
1032
|
+
settle();
|
|
1033
|
+
// dispose() chạy giữa chừng: kill lại MCP servers mà connectMcp của
|
|
1034
|
+
// warm-up vừa spawn (killAll của dispose đã chạy TRƯỚC khi chúng mọc).
|
|
1035
|
+
if (this.disposed)
|
|
1036
|
+
this.shutdownMcp();
|
|
1037
|
+
// FILE-READ-PERF P3: cycle warm kết thúc TỰ NHIÊN (không bị supersede/
|
|
1038
|
+
// abort) → arm nhịp heartbeat tiếp theo. Bị abort nghĩa là run thật đang
|
|
1039
|
+
// chạy — finish() sẽ arm lại khi xong.
|
|
1040
|
+
else if (!signal.aborted)
|
|
1041
|
+
this.scheduleWarmupRefresh();
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Đầu mọi run thật (run/runTurn/resume): lượt thật thắng warm-up — abort
|
|
1046
|
+
* request prefill nếu còn treo, rồi await cho settle để connectMcp/probe
|
|
1047
|
+
* không double-spawn server con song song với chính nó.
|
|
1048
|
+
*/
|
|
1049
|
+
async settleWarmup() {
|
|
1050
|
+
// Run thật bắt đầu → heartbeat phải im lặng (timer re-arm ở finish()).
|
|
1051
|
+
this.clearWarmupTimer();
|
|
1052
|
+
const w = this.warmupRun;
|
|
1053
|
+
if (!w)
|
|
1054
|
+
return;
|
|
1055
|
+
if (!w.settled)
|
|
1056
|
+
w.abort.abort(new DOMException("real run supersedes warmup", "AbortError"));
|
|
1057
|
+
await w.promise.catch(() => { });
|
|
1058
|
+
}
|
|
1059
|
+
/** Tắt heartbeat idle (run thật vào / dispose). */
|
|
1060
|
+
clearWarmupTimer() {
|
|
1061
|
+
if (this.warmupIdleTimer) {
|
|
1062
|
+
clearTimeout(this.warmupIdleTimer);
|
|
1063
|
+
this.warmupIdleTimer = null;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* FILE-READ-PERF P3: arm lại nhịp re-warm — chỉ khi engine local và bộ máy
|
|
1068
|
+
* warmup còn bật (đúng các guard của warmup()). unref(): timer không được
|
|
1069
|
+
* giữ process sống (run-once/test thoát sạch).
|
|
1070
|
+
*/
|
|
1071
|
+
scheduleWarmupRefresh() {
|
|
1072
|
+
if (this.disposed || !this.warmupUsed)
|
|
1073
|
+
return;
|
|
1074
|
+
if (this.config.warmup === false || !this.config.apiKey || this.config.clientFactory) {
|
|
1075
|
+
this.clearWarmupTimer();
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
// KHÔNG phải local (kể cả /model đổi từ local → cloud giữa session): tắt
|
|
1079
|
+
// heartbeat đang arm — không đốt tiền API để giữ cache.
|
|
1080
|
+
if (!isLocalBaseUrl(this.config.baseUrl)) {
|
|
1081
|
+
this.clearWarmupTimer();
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
this.clearWarmupTimer();
|
|
1085
|
+
this.warmupIdleTimer = setTimeout(() => {
|
|
1086
|
+
this.warmupIdleTimer = null;
|
|
1087
|
+
void this.warmup({ rewarm: true }).catch(() => { });
|
|
1088
|
+
}, this.warmupIdleRefreshMs);
|
|
1089
|
+
this.warmupIdleTimer.unref();
|
|
1090
|
+
}
|
|
883
1091
|
/**
|
|
884
1092
|
* Disconnect MCP servers when the agent is disposed — connections are kept
|
|
885
1093
|
* alive for the whole agent/session lifetime (one "connected" line, no
|
|
@@ -891,8 +1099,13 @@ export class Agent {
|
|
|
891
1099
|
}
|
|
892
1100
|
/** M4: đóng hot-reload watcher (khi agent bị hủy vĩnh viễn). */
|
|
893
1101
|
dispose() {
|
|
1102
|
+
this.disposed = true;
|
|
1103
|
+
this.clearWarmupTimer();
|
|
894
1104
|
this.watcher?.close();
|
|
895
1105
|
this.watcher = null;
|
|
1106
|
+
// WARMUP: hủy request prefill đang chạy (chặn MCP servers connect thêm
|
|
1107
|
+
// sau khi kill — runWarmup tự shutdownMcp lần cuối trong finally).
|
|
1108
|
+
this.warmupRun?.abort.abort(new DOMException("agent disposed", "AbortError"));
|
|
896
1109
|
this.shutdownMcp();
|
|
897
1110
|
}
|
|
898
1111
|
/**
|
|
@@ -934,6 +1147,8 @@ export class Agent {
|
|
|
934
1147
|
if (!this.config.apiKey) {
|
|
935
1148
|
throw new Error("No API key configured. Set SCODER_API_KEY or OPENAI_API_KEY (or put apiKey in .scoderrc.json).");
|
|
936
1149
|
}
|
|
1150
|
+
// WARMUP: lượt thật thắng request prefill đang chạy nền (xem warmup()).
|
|
1151
|
+
await this.settleWarmup();
|
|
937
1152
|
const buildCmd = this.buildCmd;
|
|
938
1153
|
const rawTask = task;
|
|
939
1154
|
// @file mention expansion — task trước khi build messages.
|
|
@@ -974,6 +1189,8 @@ export class Agent {
|
|
|
974
1189
|
else {
|
|
975
1190
|
this.tools = [...baseTools, ...mcpSpecs];
|
|
976
1191
|
}
|
|
1192
|
+
// Tool list (re)built for this run — previous calibration no longer valid.
|
|
1193
|
+
this.measuredFixedTokens = null;
|
|
977
1194
|
this.messages = buildRunMessages({
|
|
978
1195
|
config: this.config,
|
|
979
1196
|
primaryDef: this.primaryDef,
|
|
@@ -1025,6 +1242,9 @@ export class Agent {
|
|
|
1025
1242
|
if (this.messages.length === 0) {
|
|
1026
1243
|
return this.run(task);
|
|
1027
1244
|
}
|
|
1245
|
+
// WARMUP: thường đã settle ở run() đầu; await lại cho chắc (race /clear+
|
|
1246
|
+
// warm-up + runTurn ngay lập tức).
|
|
1247
|
+
await this.settleWarmup();
|
|
1028
1248
|
const buildCmd = this.buildCmd;
|
|
1029
1249
|
const rawTask = task;
|
|
1030
1250
|
task = expandMentions(task, this.config.workdir);
|
|
@@ -1033,6 +1253,10 @@ export class Agent {
|
|
|
1033
1253
|
this.resetUsageTotals();
|
|
1034
1254
|
this.filesModified = false;
|
|
1035
1255
|
this.plan = [];
|
|
1256
|
+
this.runDiffs = [];
|
|
1257
|
+
this.selfReviewDone = false;
|
|
1258
|
+
this.verifyStrikes = 0;
|
|
1259
|
+
this.lastVerifySignature = "";
|
|
1036
1260
|
for (const content of this.promptContext.takePendingContext())
|
|
1037
1261
|
this.messages.push({ role: "user", content });
|
|
1038
1262
|
this.messages.push({ role: "user", content: task });
|
|
@@ -1049,6 +1273,9 @@ export class Agent {
|
|
|
1049
1273
|
* An optional follow-up task is appended as a new user message.
|
|
1050
1274
|
*/
|
|
1051
1275
|
async resume(session, followUp) {
|
|
1276
|
+
// WARMUP: session restore mang system prompt RIÊNG (từ session cũ) — hủy
|
|
1277
|
+
// request prefill của prompt mới để không nhân đôi prefill nền.
|
|
1278
|
+
await this.settleWarmup();
|
|
1052
1279
|
const buildCmd = this.buildCmd;
|
|
1053
1280
|
this.sessionCreatedAt = session.createdAt;
|
|
1054
1281
|
this.currentTask = session.task;
|
|
@@ -1325,6 +1552,8 @@ export class Agent {
|
|
|
1325
1552
|
: { ok: null, detail: "no verification command ran" };
|
|
1326
1553
|
this.emit({ type: "finish", success, steps, summary: summary.slice(0, 2000), usage: this.getUsage(), verification });
|
|
1327
1554
|
this.logger.log({ type: "finish", success, steps });
|
|
1555
|
+
// FILE-READ-PERF P3: run kết thúc → session quay về nhàn rỗi, arm heartbeat.
|
|
1556
|
+
this.scheduleWarmupRefresh();
|
|
1328
1557
|
return { success, steps, buildCmd, summary: summary.slice(0, 2000) };
|
|
1329
1558
|
}
|
|
1330
1559
|
}
|
package/dist/cli/repl.js
CHANGED
|
@@ -12,6 +12,9 @@ export async function repl(cfg) {
|
|
|
12
12
|
// every input is a follow-up turn, context carries over, saved continuously.
|
|
13
13
|
// /clear swaps in a fresh Agent (the /clear branch reassigns this).
|
|
14
14
|
let agent = new Agent(cfg);
|
|
15
|
+
// WARMUP (plan/WARMUP-PLAN.md): dựng MCP+probe+prefill system+tools nền
|
|
16
|
+
// trong lúc user còn đọc prompt — lệnh đầu không trả 30–120s cold prefill.
|
|
17
|
+
void agent.warmup();
|
|
15
18
|
process.on("SIGINT", () => {
|
|
16
19
|
ui.warn("\nSIGINT — aborting current task...");
|
|
17
20
|
agent.abort();
|
|
@@ -37,6 +40,9 @@ export async function repl(cfg) {
|
|
|
37
40
|
agent.dispose();
|
|
38
41
|
agent = next ?? new Agent(cfg);
|
|
39
42
|
first = true;
|
|
43
|
+
// Agent mới = prefix mới (session id, repo state) — warm-up lại từ đầu.
|
|
44
|
+
// Với `next` đã có messages (resume) warmup() tự no-op.
|
|
45
|
+
void agent.warmup();
|
|
40
46
|
},
|
|
41
47
|
};
|
|
42
48
|
const { handled, outcome } = await dispatchCommand(input, host, (text, level) => {
|
package/dist/config/config.js
CHANGED
|
@@ -68,11 +68,29 @@ export const LOCAL_PROFILE_DEFAULTS = {
|
|
|
68
68
|
maxSteps: 24,
|
|
69
69
|
/** P2-3: prefill 20–60k token trên máy cá nhân có thể vượt 120s → timeout + retry. */
|
|
70
70
|
requestTimeoutMs: 600_000,
|
|
71
|
-
/**
|
|
72
|
-
|
|
71
|
+
/**
|
|
72
|
+
* FILE-READ-PERF P1: profile KHÔNG pin toolOutputChars nữa (trước là 6000) —
|
|
73
|
+
* ngân sách đọc giờ suy từ context window THẬT qua adaptiveToolOutputChars,
|
|
74
|
+
* apply ở cuối applyContextProbe (agent.ts). Window nhỏ vẫn ra 6000 như cũ.
|
|
75
|
+
*/
|
|
73
76
|
/** P0-3: trần token cho MCP schema; 46 tool GitHub MCP ≈ 12k token mỗi lượt. */
|
|
74
77
|
mcpSchemaTokens: 6_000,
|
|
75
78
|
};
|
|
79
|
+
/**
|
|
80
|
+
* FILE-READ-PERF (plan/FILE-READ-PERF-PLAN.md) N1: một lần đọc file bị cắt
|
|
81
|
+
* thành 5–6 MẢNH vì budget 6000 ký tự, mà mỗi mảnh = một lượt LLM tròn
|
|
82
|
+
* (đo: 8–11s/mảnh với README 28KB). Nới budget theo window đã probe:
|
|
83
|
+
* 0.17 ký tự mỗi token window ≈ 4% history cho một tool result
|
|
84
|
+
* (4 chars ≈ 1 token) — window 16k giữ nguyên 6000, 96k lên trần 16000.
|
|
85
|
+
*/
|
|
86
|
+
export const TOOL_OUTPUT_WINDOW_SHARE = 0.17;
|
|
87
|
+
export const TOOL_OUTPUT_CHARS_FLOOR = 6_000;
|
|
88
|
+
export const TOOL_OUTPUT_CHARS_CEIL = 16_000;
|
|
89
|
+
export function adaptiveToolOutputChars(windowTokens) {
|
|
90
|
+
if (!Number.isFinite(windowTokens) || windowTokens <= 0)
|
|
91
|
+
return TOOL_OUTPUT_CHARS_FLOOR;
|
|
92
|
+
return Math.min(TOOL_OUTPUT_CHARS_CEIL, Math.max(TOOL_OUTPUT_CHARS_FLOOR, Math.round(windowTokens * TOOL_OUTPUT_WINDOW_SHARE)));
|
|
93
|
+
}
|
|
76
94
|
/**
|
|
77
95
|
* Áp mặc định phù hợp cho local engine. Trả về danh sách thay đổi đã áp (để
|
|
78
96
|
* log/kiểm thử); rỗng khi không áp dụng (cloud, hoặc localProfile:false).
|
|
@@ -98,10 +116,8 @@ export function applyLocalProfile(cfg, explicit = {}) {
|
|
|
98
116
|
limits.requestTimeoutMs = LOCAL_PROFILE_DEFAULTS.requestTimeoutMs;
|
|
99
117
|
applied.push(`requestTimeoutMs=${LOCAL_PROFILE_DEFAULTS.requestTimeoutMs}`);
|
|
100
118
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
applied.push(`toolOutputChars=${LOCAL_PROFILE_DEFAULTS.toolOutputChars}`);
|
|
104
|
-
}
|
|
119
|
+
// FILE-READ-PERF P1: toolOutputChars KHÔNG pin ở đây nữa — để undefined để
|
|
120
|
+
// applyAdaptiveReadBudget nhận diện "user không đặt" và suy từ window.
|
|
105
121
|
if (limits.mcpSchemaTokens === undefined) {
|
|
106
122
|
limits.mcpSchemaTokens = LOCAL_PROFILE_DEFAULTS.mcpSchemaTokens;
|
|
107
123
|
applied.push(`mcpSchemaTokens=${LOCAL_PROFILE_DEFAULTS.mcpSchemaTokens}`);
|
|
@@ -304,6 +320,7 @@ export function loadConfig(overrides = {}) {
|
|
|
304
320
|
agent: overrides.agent ?? process.env.SCODER_AGENT ?? rc.agent,
|
|
305
321
|
routing: rc.routing,
|
|
306
322
|
repoMap: rc.repoMap,
|
|
323
|
+
warmup: rc.warmup,
|
|
307
324
|
promptVersion: rc.promptVersion,
|
|
308
325
|
verifyCmds: rc.verifyCmds,
|
|
309
326
|
selfReview: rc.selfReview,
|
package/dist/context/prompt.js
CHANGED
|
@@ -203,8 +203,9 @@ verifying your work with the build command. You act; you don't just advise.`,
|
|
|
203
203
|
Your answers render in a terminal (Ink). Markdown displays best when you:
|
|
204
204
|
- Tables: at most 6 columns with short cell text; no line breaks inside cells;
|
|
205
205
|
escape literal pipes as \\| and keep pipes inside \`code spans\` intact.
|
|
206
|
-
- Diagrams:
|
|
207
|
-
|
|
206
|
+
- Diagrams: \`\`\`mermaid flowchart/graph renders as a real box-drawing chart
|
|
207
|
+
in the terminal — keep it ≤12 nodes, plain node ids, short labels (line
|
|
208
|
+
breaks via <br/>). sequence/state/other types show a structured summary.
|
|
208
209
|
- Numeric comparisons/trends: prefer a \`\`\`chart fence (JSON array of
|
|
209
210
|
{"label","value"}) — it renders as a native terminal bar/pie chart.
|
|
210
211
|
- Code fences: always tag the language; keep lines reasonably short (they wrap
|
|
@@ -259,6 +260,10 @@ export const PROMPT_SECTIONS = {
|
|
|
259
260
|
tools: `## Tool policy
|
|
260
261
|
- Orient with list_files / grep / glob / repo_map — prefer them over shell
|
|
261
262
|
greps. read_file before every edit_file.
|
|
263
|
+
- read_file: when a result ends with "N more line(s)", read the REST in ONE
|
|
264
|
+
step — issue several read_file calls with successive offset/limit covering
|
|
265
|
+
every remaining chunk at once (they run in parallel). Never re-read lines
|
|
266
|
+
you already have.
|
|
262
267
|
- edit_file: old_string MUST appear exactly once — otherwise re-read the file
|
|
263
268
|
and add more context.
|
|
264
269
|
- run_command IS the build command: run it only when you changed files; a
|
|
@@ -294,8 +299,9 @@ export const PROMPT_SECTIONS = {
|
|
|
294
299
|
- Reply in the user's language; keep final reports concise. Never print
|
|
295
300
|
secrets (API keys, tokens, .env contents).
|
|
296
301
|
- Terminal markdown: tag code fences with the language; tables ≤6 columns,
|
|
297
|
-
short cells;
|
|
298
|
-
|
|
302
|
+
short cells; \`\`\`mermaid flowcharts render as real terminal graphs (keep
|
|
303
|
+
≤12 nodes, plain ids, short labels), sequence as a summary; numeric trends
|
|
304
|
+
as a \`\`\`chart fence (JSON array of {"label","value"}).`,
|
|
299
305
|
};
|
|
300
306
|
/** V6 — assembled from the sections above (order matters). */
|
|
301
307
|
function assembleV6() {
|
package/dist/llm/llm.js
CHANGED
|
@@ -169,7 +169,9 @@ export class LlmClient {
|
|
|
169
169
|
messages: toWireMessages(messages),
|
|
170
170
|
tools: tools.length > 0 ? tools : undefined,
|
|
171
171
|
temperature: this.opts.temperature,
|
|
172
|
-
|
|
172
|
+
// WARMUP: override max_tokens cho request prefill (1 token là đủ để
|
|
173
|
+
// engine tính xong KV-cache của prefix) — lượt thật không truyền key này.
|
|
174
|
+
max_tokens: opts.maxTokens ?? this.opts.maxTokens,
|
|
173
175
|
stream: false,
|
|
174
176
|
});
|
|
175
177
|
let res;
|
|
@@ -153,12 +153,12 @@ export class AnthropicClient {
|
|
|
153
153
|
this.opts = { ...this.opts, model };
|
|
154
154
|
}
|
|
155
155
|
/** I-05: request body builder — shared by chat/streamChat. */
|
|
156
|
-
buildBody(system, messages, tools, stream) {
|
|
156
|
+
buildBody(system, messages, tools, stream, maxTokensOverride) {
|
|
157
157
|
// Extended thinking: temperature must be 1 và thinking block format.
|
|
158
158
|
const thinkingOn = (this.opts.thinkingBudgetTokens ?? 0) > 0;
|
|
159
159
|
return JSON.stringify({
|
|
160
160
|
model: this.opts.model,
|
|
161
|
-
max_tokens: this.opts.maxTokens,
|
|
161
|
+
max_tokens: maxTokensOverride ?? this.opts.maxTokens,
|
|
162
162
|
temperature: thinkingOn ? 1 : this.opts.temperature,
|
|
163
163
|
...(thinkingOn ? { thinking: { type: "enabled", budget_tokens: this.opts.thinkingBudgetTokens } } : {}),
|
|
164
164
|
system,
|
|
@@ -172,7 +172,9 @@ export class AnthropicClient {
|
|
|
172
172
|
const { system, messages: translated } = toAnthropicMessages(messages);
|
|
173
173
|
const { signal, cleanup } = combinedSignal(opts.signal, this.timeoutMs);
|
|
174
174
|
const cached = this.applyCacheControl(system, translated, tools);
|
|
175
|
-
|
|
175
|
+
// WARMUP: override max_tokens cho request prefill — các breakpoint
|
|
176
|
+
// cache_control (tools + system) đã áp dụng y hệt lượt thật.
|
|
177
|
+
const body = this.buildBody(cached.system, cached.messages, cached.tools, false, opts.maxTokens);
|
|
176
178
|
let res;
|
|
177
179
|
try {
|
|
178
180
|
res = await withRetry(async () => {
|
package/dist/llm/retry.js
CHANGED
|
@@ -32,6 +32,10 @@ export async function withRetry(fn, opts = {}, onRetry) {
|
|
|
32
32
|
catch (err) {
|
|
33
33
|
lastErr = err;
|
|
34
34
|
const status = err.status;
|
|
35
|
+
// WARMUP: abort của CHÍNH caller (warm-up bị lượt thật supersede, SIGINT)
|
|
36
|
+
// không bao giờ được retry — message có thể là bất kỳ chuỗi nào.
|
|
37
|
+
if (err?.name === "AbortError")
|
|
38
|
+
throw err;
|
|
35
39
|
const retryable = (typeof status === "number" && isRetryableStatus(status)) ||
|
|
36
40
|
(!(err instanceof NonRetryableError) && isRetryableNetworkError(err));
|
|
37
41
|
// Client errors (except 429) — fail immediately
|
package/dist/mcp/manager.js
CHANGED
|
@@ -89,8 +89,8 @@ export class McpManager {
|
|
|
89
89
|
.listTools()
|
|
90
90
|
.then((defs) => this.reregisterTools(name, conn, defs))
|
|
91
91
|
.catch(() => { });
|
|
92
|
-
void this.refreshResources(name, conn);
|
|
93
|
-
void this.refreshPrompts(name, conn);
|
|
92
|
+
void this.refreshResources(name, conn).catch(() => { });
|
|
93
|
+
void this.refreshPrompts(name, conn).catch(() => { });
|
|
94
94
|
for (const uri of this.subscriptions.get(name) ?? []) {
|
|
95
95
|
void conn.subscribeResource(uri).catch(() => { });
|
|
96
96
|
}
|
|
@@ -272,22 +272,29 @@ export class PermissionChecker {
|
|
|
272
272
|
// T3: timeout enforced HERE (checker-level) — a hung frontend handler
|
|
273
273
|
// (TUI modal never answered, server client disconnected) must not leak
|
|
274
274
|
// the pending permission promise forever.
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
275
|
+
let timer;
|
|
276
|
+
const timeoutPromise = new Promise((_, rej) => {
|
|
277
|
+
timer = setTimeout(() => {
|
|
278
278
|
const e = new Error("permission handler timed out");
|
|
279
279
|
e.name = "AbortError";
|
|
280
280
|
rej(e);
|
|
281
|
-
}, req.timeoutSecs * 1000)
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
this.
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
281
|
+
}, req.timeoutSecs * 1000);
|
|
282
|
+
});
|
|
283
|
+
try {
|
|
284
|
+
const answer = await Promise.race([this.promptHandler(req), timeoutPromise]);
|
|
285
|
+
if (answer === "quit")
|
|
286
|
+
this.abortRequested = true;
|
|
287
|
+
if (answer === "always" && req.kind === "edit")
|
|
288
|
+
this.allowAllEdits = true;
|
|
289
|
+
if (answer === "always" && req.kind === "shell" && req.alwaysAllowPrefix) {
|
|
290
|
+
this.sessionAllow.addPrefix(req.alwaysAllowPrefix);
|
|
291
|
+
}
|
|
292
|
+
return answer === "yes" || answer === "always";
|
|
293
|
+
}
|
|
294
|
+
finally {
|
|
295
|
+
if (timer)
|
|
296
|
+
clearTimeout(timer);
|
|
289
297
|
}
|
|
290
|
-
return answer === "yes" || answer === "always";
|
|
291
298
|
}
|
|
292
299
|
catch {
|
|
293
300
|
return false; // handler error/timeout — deny
|
|
@@ -461,6 +468,19 @@ export class PermissionChecker {
|
|
|
461
468
|
};
|
|
462
469
|
}
|
|
463
470
|
}
|
|
471
|
+
// Command substitution $(...) / backticks can hide arbitrary commands inside
|
|
472
|
+
// an otherwise-allowed prefix (e.g. `echo $(rm -rf /)`). Treat as ask
|
|
473
|
+
// unless an explicit rule allows the whole command line.
|
|
474
|
+
if (scan?.ok && scan.hasCommandSubstitution) {
|
|
475
|
+
const e = matchRule(this.activeRules(), "shell", command);
|
|
476
|
+
if (e !== "allow") {
|
|
477
|
+
return {
|
|
478
|
+
decision: "ask",
|
|
479
|
+
reason: "command substitution ($() / backticks) — approval required",
|
|
480
|
+
alwaysAllowPrefix: extractFirstWord(command) + " ",
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
}
|
|
464
484
|
return this.classify(command);
|
|
465
485
|
}
|
|
466
486
|
/**
|
package/dist/tools/read-file.js
CHANGED
|
@@ -74,8 +74,10 @@ export function toolReadFile(workdir, args) {
|
|
|
74
74
|
const header = offset > 1 || end < total ? `[lines ${offset}-${end} of ${total} — ${args.path}]\n` : "";
|
|
75
75
|
const footer = dropped > 0
|
|
76
76
|
? `\n... [${dropped} more line(s): ${end + 1}-${offset + slice.length - 1} of ${total}. ` +
|
|
77
|
-
`Read the
|
|
78
|
-
`
|
|
77
|
+
`Read the REST in ONE step — parallel read_file calls at ` +
|
|
78
|
+
`{"path":"${args.path}","offset":${end + 1}}, ` +
|
|
79
|
+
`{"path":"${args.path}","offset":${end + 1 + kept.length}}, ... ` +
|
|
80
|
+
`until the file is done; do NOT re-read from the start.]`
|
|
79
81
|
: "";
|
|
80
82
|
// 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
83
|
const numbered = clamp(kept.join("\n"), budget);
|