s-app-scoder-agent 0.6.0 → 0.8.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/.scoderrc.example.json +97 -0
- package/LICENSE +21 -21
- package/README.md +600 -468
- package/dist/agent.js +841 -197
- package/dist/agents.js +70 -0
- package/dist/compaction.js +114 -0
- package/dist/config.js +178 -12
- package/dist/context.js +119 -14
- package/dist/cost.js +56 -6
- package/dist/guardrails.js +423 -0
- package/dist/index.js +129 -76
- package/dist/json-schema.js +58 -0
- package/dist/llm.js +123 -9
- package/dist/mcp.js +397 -70
- package/dist/model-catalog.js +175 -0
- package/dist/permissions.js +328 -35
- package/dist/prompt-builder.js +47 -0
- package/dist/prompt.js +103 -0
- package/dist/provider-anthropic.js +113 -35
- package/dist/provider-registry.js +116 -0
- package/dist/retry.js +3 -2
- package/dist/sandbox.js +96 -0
- package/dist/server.js +32 -2
- package/dist/session.js +282 -11
- package/dist/shell-scanner.js +112 -0
- package/dist/skills.js +122 -0
- package/dist/snapshot.js +170 -0
- package/dist/steering.js +35 -0
- package/dist/tools.js +513 -19
- package/dist/tui/app.js +505 -198
- package/dist/tui/at-complete.js +93 -0
- package/dist/tui/chat-views.js +146 -0
- package/dist/tui/chrome.js +96 -0
- package/dist/tui/command-registry.js +189 -0
- package/dist/tui/commands.js +200 -2
- package/dist/tui/image.js +149 -0
- package/dist/tui/input.js +214 -0
- package/dist/tui/markdown.js +229 -0
- package/dist/tui/mouse.js +20 -0
- package/dist/tui/permission-modal.js +34 -0
- package/dist/tui/review.js +64 -0
- package/dist/tui/session-picker.js +68 -0
- package/dist/tui/sidebar.js +99 -0
- package/dist/tui/state.js +192 -6
- package/dist/tui/theme.js +90 -0
- package/dist/tui/vim.js +60 -0
- package/dist/watch.js +52 -0
- package/package.json +16 -8
- package/dist/tui-direct.mjs +0 -4
package/dist/agent.js
CHANGED
|
@@ -1,48 +1,43 @@
|
|
|
1
|
-
import { loadPermissionLists } from "./config.js";
|
|
2
|
-
import { LlmClient, LlmError } from "./llm.js";
|
|
1
|
+
import { loadPermissionLists, loadPolicyRules, loadSavedApprovals, saveApproval } from "./config.js";
|
|
2
|
+
import { LlmClient, LlmError, redactSecrets } from "./llm.js";
|
|
3
3
|
import { AnthropicClient } from "./provider-anthropic.js";
|
|
4
|
-
import {
|
|
4
|
+
import { getTool, allToolSpecs, setMaxOutputChars } from "./tools.js";
|
|
5
|
+
import { invalidateFileIndex } from "./tui/at-complete.js";
|
|
5
6
|
import { detectBuildCommand, runBuild } from "./build.js";
|
|
6
|
-
import { trimHistory, summariseOutput,
|
|
7
|
+
import { trimHistory, summariseOutput, expandMentions } from "./context.js";
|
|
8
|
+
import { createGuardrailEngine, CHAT_DONE_MARKER } from "./guardrails.js";
|
|
9
|
+
import { PROMPT_VERSION } from "./prompt.js";
|
|
10
|
+
import { buildSystemPrompt, buildEnvInfo, buildSkillsBlock, buildAgentsBlock, buildMcpBlock } from "./prompt-builder.js";
|
|
7
11
|
import { PermissionChecker } from "./permissions.js";
|
|
8
|
-
import { SessionStore } from "./session.js";
|
|
12
|
+
import { SessionStore, EventSessionStore } from "./session.js";
|
|
13
|
+
import { DEFAULT_COMPACTION, shouldCompact, extractSummaryInput, COMPACTION_INSTRUCTION, applyCompaction } from "./compaction.js";
|
|
14
|
+
import { SnapshotManager } from "./snapshot.js";
|
|
15
|
+
import { contextWindowFor } from "./model-catalog.js";
|
|
9
16
|
import { JsonlLogger } from "./logger.js";
|
|
17
|
+
import { createRuntime, parseCustomProviders, resolveProvider, splitProviderModel } from "./provider-registry.js";
|
|
18
|
+
import { loadRcFile } from "./config.js";
|
|
10
19
|
import { McpManager } from "./mcp.js";
|
|
20
|
+
import { validateSchema } from "./json-schema.js";
|
|
21
|
+
import { watchWorkdir } from "./watch.js";
|
|
22
|
+
import { discoverSkills, findSkill } from "./skills.js";
|
|
23
|
+
import { discoverAgents, findAgent } from "./agents.js";
|
|
24
|
+
import { SteeringInbox } from "./steering.js";
|
|
11
25
|
import * as ui from "./ui.js";
|
|
12
|
-
const SYSTEM_PROMPT = `You are Scoder, an autonomous coding agent running in a terminal.
|
|
13
|
-
You complete coding tasks by using tools: reading/writing/editing files, listing
|
|
14
|
-
files, and running shell commands.
|
|
15
|
-
|
|
16
|
-
RULES:
|
|
17
|
-
1. Work autonomously. Do NOT ask the user questions mid-task; make reasonable
|
|
18
|
-
decisions and proceed.
|
|
19
|
-
2. When you believe the task is done, call run_command with the project's build
|
|
20
|
-
command (or the one the user specified). The loop ends only when the build
|
|
21
|
-
succeeds.
|
|
22
|
-
3. If the build fails, read the error output carefully, fix the code, and build
|
|
23
|
-
again. Iterate until it passes.
|
|
24
|
-
4. Prefer small, focused edits. Read a file before editing it.
|
|
25
|
-
5. If an external/environmental problem (missing dependency, wrong environment)
|
|
26
|
-
makes the task impossible, stop editing and report it clearly in your final
|
|
27
|
-
message.
|
|
28
|
-
6. Never invent requirements beyond the given task.
|
|
29
|
-
7. IMPORTANT: if a run_command is denied by permission policy, you will receive
|
|
30
|
-
an error message. Use this as a signal to find a safer alternative or to
|
|
31
|
-
communicate the limitation to the user.
|
|
32
|
-
8. You have web access ONLY through web_fetch (it fetches exact URLs, it cannot
|
|
33
|
-
search). Use it when the task depends on external or fast-changing knowledge:
|
|
34
|
-
library/API documentation, a URL found in an error message or code comment,
|
|
35
|
-
current package versions, changelogs, release notes, standards. Skip it when
|
|
36
|
-
the answer is in the working directory or is stable general knowledge. After
|
|
37
|
-
fetching, ground your edits in what the page actually says.`;
|
|
38
26
|
export class Agent {
|
|
39
27
|
config;
|
|
40
28
|
client;
|
|
41
29
|
messages = [];
|
|
42
|
-
tools =
|
|
30
|
+
tools = allToolSpecs();
|
|
43
31
|
ctx;
|
|
44
32
|
permissions;
|
|
33
|
+
/** T-03: pending permission prompts — id → resolve (TUI/serve answer them). */
|
|
34
|
+
pendingPermissions = new Map();
|
|
35
|
+
permSeq = 0;
|
|
45
36
|
session;
|
|
37
|
+
/** v2 durable event log (session/ + events.jsonl) — crash recovery + steering. */
|
|
38
|
+
events;
|
|
39
|
+
/** Git shadow-repo checkpoints before write batches (H-11). */
|
|
40
|
+
snapshots;
|
|
46
41
|
logger;
|
|
47
42
|
aborted = false;
|
|
48
43
|
streamFallbackWarned = false;
|
|
@@ -53,39 +48,161 @@ export class Agent {
|
|
|
53
48
|
usageTotals = { calls: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0 };
|
|
54
49
|
mcp = null;
|
|
55
50
|
/** Aborted when abort() is called — cancels in-flight LLM requests (SIGINT). */
|
|
51
|
+
/** Steering inbox — user input delivered at safe step boundaries (H-08, I5 module). */
|
|
52
|
+
steerInbox = new SteeringInbox();
|
|
56
53
|
runSignal = new AbortController();
|
|
57
54
|
/** Optional structured-event sink (used by the HTTP/SSE server frontend). */
|
|
58
55
|
onEvent = null;
|
|
59
56
|
/** Set while a tool call is executing, for tool_output event correlation. */
|
|
60
57
|
currentToolCall = null;
|
|
58
|
+
/** True once any file-writing tool succeeded during the current run. */
|
|
59
|
+
filesModified = false;
|
|
60
|
+
/** Discovered skills (project + global). */
|
|
61
|
+
skills = [];
|
|
62
|
+
/** M4: hot-reload watcher (rc + skills dir). */
|
|
63
|
+
watcher = null;
|
|
64
|
+
/** Content staged via /skill — injected before the next run/turn. */
|
|
65
|
+
pendingContext = [];
|
|
66
|
+
/** Session plan (write_todos tool). */
|
|
67
|
+
plan = [];
|
|
68
|
+
/** Guardrail Engine — khởi tạo trong constructor (cần this.config). */
|
|
69
|
+
guardrails;
|
|
70
|
+
/** Log một quyết định guardrail ra JSONL + event (quan sát được, không ồn ào UI). */
|
|
71
|
+
logGuardrailDecision(d) {
|
|
72
|
+
const kind = d.action.kind;
|
|
73
|
+
this.logger.log({ type: "guardrail", rule: d.rule, action: kind, reason: d.reason });
|
|
74
|
+
this.emit({ type: "guardrail", rule: d.rule, action: kind, reason: d.reason });
|
|
75
|
+
}
|
|
61
76
|
constructor(config) {
|
|
62
77
|
this.config = config;
|
|
78
|
+
this.guardrails = createGuardrailEngine(config.guardrails ?? {}, (d) => this.logGuardrailDecision(d));
|
|
63
79
|
const commonOpts = {
|
|
64
80
|
baseUrl: config.baseUrl,
|
|
65
81
|
apiKey: config.apiKey,
|
|
66
82
|
model: config.model,
|
|
67
83
|
maxTokens: config.maxTokens,
|
|
68
84
|
temperature: config.temperature,
|
|
85
|
+
requestTimeoutMs: config.limits?.requestTimeoutMs, // G7
|
|
69
86
|
};
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
87
|
+
// I-01: registry-driven runtime — providerDef (custom/builtin) wins; else legacy ternary.
|
|
88
|
+
// I-05: thinking budget = 10% maxTokens khi reasoning on (Anthropic only).
|
|
89
|
+
// I-12: eval harness — scripted client override thay vì construct.
|
|
90
|
+
const thinkingBudget = config.reasoning === "on" ? Math.max(1024, Math.floor(config.maxTokens * 0.25)) : 0;
|
|
91
|
+
this.client = config.clientFactory?.() ??
|
|
92
|
+
(config.providerDef?.kind === "anthropic" || (!config.providerDef && config.provider === "anthropic")
|
|
93
|
+
? new AnthropicClient({ ...commonOpts, enableCache: config.providerDef?.supportCache ?? true, thinkingBudgetTokens: thinkingBudget })
|
|
94
|
+
: new LlmClient({ ...commonOpts, stream: config.stream }));
|
|
74
95
|
const permLists = loadPermissionLists(config.workdir);
|
|
75
96
|
this.permissions = new PermissionChecker({
|
|
76
97
|
mode: config.permissionMode,
|
|
77
98
|
allowCommands: permLists.allowCommands,
|
|
78
99
|
denyCommands: permLists.denyCommands,
|
|
79
100
|
nonInteractive: config.yes,
|
|
101
|
+
rules: config.permissionRules,
|
|
102
|
+
savedRules: loadSavedApprovals(config.workdir),
|
|
103
|
+
policyRules: loadPolicyRules(config.workdir),
|
|
80
104
|
});
|
|
105
|
+
// T-03: route permission prompts through the event stream when an event
|
|
106
|
+
// sink is attached (TUI/serve) — readline on stdin corrupts alt-screen.
|
|
107
|
+
this.permissions.promptHandler = (req) => this.programmaticPermissionPrompt(req);
|
|
81
108
|
this.ctx = {
|
|
82
109
|
workdir: config.workdir,
|
|
83
110
|
permissions: this.permissions,
|
|
84
111
|
permissionMode: config.permissionMode,
|
|
85
112
|
onToolOutput: this.handleToolOutput,
|
|
113
|
+
plan: {
|
|
114
|
+
get: () => this.plan,
|
|
115
|
+
set: (items) => {
|
|
116
|
+
this.plan = items;
|
|
117
|
+
const done = items.filter((t) => t.status === "completed").length;
|
|
118
|
+
ui.info(`plan: ${done}/${items.length} completed`);
|
|
119
|
+
this.emit({ type: "todo", items: [...items] });
|
|
120
|
+
this.logger.log({ type: "todo", items });
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
formatter: config.formatter,
|
|
124
|
+
sandbox: config.sandbox,
|
|
125
|
+
allowSubagents: config.allowSubagents,
|
|
86
126
|
};
|
|
127
|
+
// G7: thresholds từ rc limits.
|
|
128
|
+
if (config.limits?.maxOutputChars)
|
|
129
|
+
setMaxOutputChars(config.limits.maxOutputChars);
|
|
87
130
|
this.session = new SessionStore(config.workdir, config.noSave !== true);
|
|
131
|
+
this.events = new EventSessionStore(config.workdir, config.noSave !== true, this.session.id);
|
|
132
|
+
this.snapshots = new SnapshotManager(config.workdir, config.snapshots !== false);
|
|
133
|
+
// I-06: task tool → child Agent run (same process, own session, event
|
|
134
|
+
// relay, abort cascade via parent's runSignal). Depth-1 only: the runner
|
|
135
|
+
// refuses when the calling agent was constructed with allowSubagents:false
|
|
136
|
+
// (children set this; the runner checks ctx, not a global flag — global
|
|
137
|
+
// registration alone allowed grandchildren through). Runner lives on the
|
|
138
|
+
// per-instance ctx — concurrent Agents (server multi-run) stay isolated.
|
|
139
|
+
if (config.allowSubagents !== false) {
|
|
140
|
+
const runner = async (ctx, args) => {
|
|
141
|
+
if (ctx.allowSubagents === false) {
|
|
142
|
+
return { ok: false, output: "ERROR: task tool not available — subagents cannot spawn further subagents (depth limit)." };
|
|
143
|
+
}
|
|
144
|
+
const def = args.agent ? findAgent(config.workdir, args.agent) : findAgent(config.workdir, "explore");
|
|
145
|
+
if (args.agent && !def) {
|
|
146
|
+
return { ok: false, output: `ERROR: agent "${args.agent}" not found — available: ${discoverAgents(config.workdir).filter((a) => a.mode !== "subagent" || a.name === "explore").map((a) => a.name).join(", ")}` };
|
|
147
|
+
}
|
|
148
|
+
const child = new Agent({
|
|
149
|
+
...config,
|
|
150
|
+
allowSubagents: false,
|
|
151
|
+
model: def?.model ?? config.model,
|
|
152
|
+
maxSteps: def?.steps ?? Math.min(config.maxSteps, 20),
|
|
153
|
+
noSave: true, // subagent runs are ephemeral — no session pollution
|
|
154
|
+
// plan/explore agents stay read-only regardless of parent mode.
|
|
155
|
+
permissionMode: def?.name === "plan" || def?.name === "explore" ? "readonly" : config.permissionMode,
|
|
156
|
+
});
|
|
157
|
+
// Abort cascade: parent abort kills child's in-flight LLM calls.
|
|
158
|
+
this.runSignal.signal.addEventListener("abort", () => child.abort(), { once: true });
|
|
159
|
+
// Event relay: child activity appears as subagent-tagged tool output.
|
|
160
|
+
const tag = `subagent:${def?.name ?? "task"}`;
|
|
161
|
+
child.onEvent = (ev) => {
|
|
162
|
+
if (ev.type === "tool_call")
|
|
163
|
+
this.emit({ type: "tool_output", name: tag, stream: "stdout", delta: `→ ${ev.name}\n` });
|
|
164
|
+
else if (ev.type === "llm_delta")
|
|
165
|
+
this.emit({ type: "tool_output", name: tag, stream: "stdout", delta: ev.delta });
|
|
166
|
+
};
|
|
167
|
+
try {
|
|
168
|
+
const res = await child.run(args.prompt);
|
|
169
|
+
return {
|
|
170
|
+
ok: res.success,
|
|
171
|
+
output: res.success
|
|
172
|
+
? `[subagent ${def?.name ?? "task"} done — ${res.steps} steps]\n${res.summary}`
|
|
173
|
+
: `[subagent ${def?.name ?? "task"} FAILED — ${res.steps} steps]\n${res.summary}`,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
return { ok: false, output: `ERROR: subagent failed: ${err.message}` };
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
// Child agents are ephemeral: close their MCP connections (if any).
|
|
181
|
+
child.dispose();
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
this.ctx.taskRunner = runner;
|
|
185
|
+
}
|
|
88
186
|
this.logger = JsonlLogger.forSession(config.workdir, this.session.id, config.logFile);
|
|
187
|
+
this.skills = discoverSkills(config.workdir);
|
|
188
|
+
// M4: hot-reload — .scoderrc (MCP servers) + .scoder/skills/ đổi giữa
|
|
189
|
+
// session → reload ở turn sau (debounced, không watcher trong test).
|
|
190
|
+
if (config.noSave !== true && process.env.SCODER_NO_WATCH !== "1") {
|
|
191
|
+
this.watcher = watchWorkdir(config.workdir, (kind) => {
|
|
192
|
+
if (kind === "skills") {
|
|
193
|
+
this.skills = discoverSkills(config.workdir);
|
|
194
|
+
this.logger.log({ type: "hot_reload", kind, skills: this.skills.length });
|
|
195
|
+
}
|
|
196
|
+
else if (kind === "config") {
|
|
197
|
+
// MCP servers mới: merge vào manager hiện tại (turn sau advertise).
|
|
198
|
+
const rc = loadRcFile(config.workdir);
|
|
199
|
+
const servers = rc.mcpServers ?? {};
|
|
200
|
+
if (this.mcp && Object.keys(servers).length > 0) {
|
|
201
|
+
void this.mcp.applyConfig(servers);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}
|
|
89
206
|
}
|
|
90
207
|
/**
|
|
91
208
|
* Handle SIGINT: abort gracefully after current tool finishes.
|
|
@@ -95,6 +212,32 @@ export class Agent {
|
|
|
95
212
|
this.aborted = true;
|
|
96
213
|
this.runSignal.abort(new DOMException("interrupted by user", "AbortError"));
|
|
97
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* Steer: user input delivered at the NEXT safe step boundary (pattern from
|
|
217
|
+
* opencode "steer by default"). Injected as a user message before the next
|
|
218
|
+
* LLM call; resets the doom-loop text-allowance once for the whole batch.
|
|
219
|
+
*/
|
|
220
|
+
steer(text) {
|
|
221
|
+
this.steerInbox.steer(text);
|
|
222
|
+
const t = text.trim();
|
|
223
|
+
if (!t)
|
|
224
|
+
return;
|
|
225
|
+
this.events.append({ type: "steer.enqueued", mode: "steer", text: t });
|
|
226
|
+
this.emit({ type: "steer_queued", mode: "steer", text: t });
|
|
227
|
+
}
|
|
228
|
+
/** Queue: same inbox, but only relevant when the agent is idle (queued run). */
|
|
229
|
+
queue(text) {
|
|
230
|
+
this.steerInbox.queue(text);
|
|
231
|
+
const t = text.trim();
|
|
232
|
+
if (!t)
|
|
233
|
+
return;
|
|
234
|
+
this.events.append({ type: "steer.enqueued", mode: "queue", text: t });
|
|
235
|
+
this.emit({ type: "steer_queued", mode: "queue", text: t });
|
|
236
|
+
}
|
|
237
|
+
/** Drain the steering inbox; delivered at the top of the next step. */
|
|
238
|
+
drainSteerInbox() {
|
|
239
|
+
return this.steerInbox.drain().map((x) => x.text);
|
|
240
|
+
}
|
|
98
241
|
/** Reset the run-signal so a new run() can use a fresh controller. */
|
|
99
242
|
resetRunSignal() {
|
|
100
243
|
this.runSignal = new AbortController();
|
|
@@ -107,6 +250,49 @@ export class Agent {
|
|
|
107
250
|
emit(ev) {
|
|
108
251
|
this.onEvent?.(ev);
|
|
109
252
|
}
|
|
253
|
+
/**
|
|
254
|
+
* T-03: permission ask via the event stream. Emits permission_request and
|
|
255
|
+
* waits for resolvePermission(); when no sink is attached, falls back to
|
|
256
|
+
* deny (the TUI always attaches one; programmatic callers answer directly).
|
|
257
|
+
* Timeout ⇒ deny + auto-resolve (no leaked promise).
|
|
258
|
+
*/
|
|
259
|
+
programmaticPermissionPrompt(req) {
|
|
260
|
+
if (!this.onEvent)
|
|
261
|
+
return Promise.resolve("no");
|
|
262
|
+
const id = `perm-${++this.permSeq}`;
|
|
263
|
+
return new Promise((resolve) => {
|
|
264
|
+
const timer = setTimeout(() => {
|
|
265
|
+
if (this.pendingPermissions.delete(id))
|
|
266
|
+
resolve("no");
|
|
267
|
+
}, req.timeoutSecs * 1000);
|
|
268
|
+
this.pendingPermissions.set(id, (a) => {
|
|
269
|
+
clearTimeout(timer);
|
|
270
|
+
resolve(a);
|
|
271
|
+
});
|
|
272
|
+
this.emit({
|
|
273
|
+
type: "permission_request",
|
|
274
|
+
id,
|
|
275
|
+
kind: req.kind,
|
|
276
|
+
label: req.label,
|
|
277
|
+
...(req.command !== undefined ? { command: req.command } : {}),
|
|
278
|
+
...(req.diff !== undefined ? { diff: req.diff } : {}),
|
|
279
|
+
...(req.alwaysAllowPrefix !== undefined ? { alwaysAllowPrefix: req.alwaysAllowPrefix } : {}),
|
|
280
|
+
timeoutSecs: req.timeoutSecs,
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* T-03: answer a pending permission_request (called from the TUI modal /
|
|
286
|
+
* HTTP API). Unknown id → silently ignored (already timed out).
|
|
287
|
+
*/
|
|
288
|
+
resolvePermission(id, answer) {
|
|
289
|
+
const res = this.pendingPermissions.get(id);
|
|
290
|
+
if (res) {
|
|
291
|
+
this.pendingPermissions.delete(id);
|
|
292
|
+
res(answer);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/** Aborted when abort() is called — cancels in-flight LLM requests (SIGINT). */
|
|
110
296
|
/**
|
|
111
297
|
* Live tool-output handler: renders chunks in the terminal (dimmed block) and
|
|
112
298
|
* forwards them as `tool_output` events. Opens the stream block lazily on the
|
|
@@ -126,12 +312,14 @@ export class Agent {
|
|
|
126
312
|
}
|
|
127
313
|
/** Push one tool-result message back into the conversation. */
|
|
128
314
|
pushToolResult(tc, content) {
|
|
129
|
-
|
|
315
|
+
const msg = {
|
|
130
316
|
role: "tool",
|
|
131
317
|
tool_call_id: tc.id,
|
|
132
318
|
name: tc.name,
|
|
133
319
|
content,
|
|
134
|
-
}
|
|
320
|
+
};
|
|
321
|
+
this.messages.push(msg);
|
|
322
|
+
this.events.append({ type: "message.added", message: msg });
|
|
135
323
|
}
|
|
136
324
|
/**
|
|
137
325
|
* Handle a rejected tool call (unknown tool, policy denial, user denial):
|
|
@@ -140,10 +328,151 @@ export class Agent {
|
|
|
140
328
|
*/
|
|
141
329
|
rejectToolCall(step, tc, output, logExtra) {
|
|
142
330
|
ui.toolResult(tc.name, false, output);
|
|
143
|
-
this.emit({ type: "tool_result", step, name: tc.name, ok: false, output });
|
|
331
|
+
this.emit({ type: "tool_result", step, id: tc.id, name: tc.name, ok: false, output });
|
|
144
332
|
this.logger.log({ type: "tool", step, tool: tc.name, ok: false, ...logExtra });
|
|
145
333
|
this.pushToolResult(tc, output);
|
|
146
334
|
}
|
|
335
|
+
/** I-09: tools safe to run concurrently (pure reads, no permission ask). */
|
|
336
|
+
static READONLY_TOOLS = new Set(["read_file", "list_files", "grep", "glob", "load_skill"]);
|
|
337
|
+
/**
|
|
338
|
+
* I-09: bounded-concurrency map preserving input order.
|
|
339
|
+
*/
|
|
340
|
+
async parallelMap(items, fn, limit) {
|
|
341
|
+
const results = new Array(items.length);
|
|
342
|
+
let next = 0;
|
|
343
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
344
|
+
while (next < items.length) {
|
|
345
|
+
const i = next++;
|
|
346
|
+
results[i] = await fn(items[i], i);
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
await Promise.all(workers);
|
|
350
|
+
return results;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Execute one tool call end-to-end: validate args, render, permission gate,
|
|
354
|
+
* run (builtin or MCP), log, and emit result. Returns `post` side-effects to
|
|
355
|
+
* run by the caller AFTER the whole batch completes — in parallel mode these
|
|
356
|
+
* must not interleave with other calls' work (messages order, abort checks).
|
|
357
|
+
*/
|
|
358
|
+
async executeToolCall(tc, steps, stepToolResults) {
|
|
359
|
+
const tool = getTool(tc.name);
|
|
360
|
+
let args;
|
|
361
|
+
try {
|
|
362
|
+
args = tc.arguments ? JSON.parse(tc.arguments) : {};
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
this.pushToolResult(tc, `ERROR: invalid JSON arguments: ${tc.arguments.slice(0, 200)}`);
|
|
366
|
+
return {};
|
|
367
|
+
}
|
|
368
|
+
ui.toolCall(tc.name, args);
|
|
369
|
+
this.currentToolCall = { step: steps, id: tc.id, name: tc.name };
|
|
370
|
+
this.emit({ type: "tool_call", step: steps, id: tc.id, name: tc.name, args });
|
|
371
|
+
if (!tool && !this.mcp?.hasTool(tc.name)) {
|
|
372
|
+
this.rejectToolCall(steps, tc, `ERROR: unknown tool "${tc.name}". Available: ${allToolSpecs().map((t) => t.function.name).join(", ")}${this.mcp ? " + MCP tools (mcp__<server>__<tool>)" : ""}`, { error: "unknown tool" });
|
|
373
|
+
return {};
|
|
374
|
+
}
|
|
375
|
+
let result;
|
|
376
|
+
try {
|
|
377
|
+
// Permission gate for shell commands
|
|
378
|
+
if (tc.name === "run_command" && typeof args.command === "string") {
|
|
379
|
+
const decision = this.permissions.classifyShell(args.command);
|
|
380
|
+
if (decision.decision === "deny") {
|
|
381
|
+
const output = `ERROR: command denied by permission policy (${decision.reason}): ${String(args.command).slice(0, 200)}`;
|
|
382
|
+
this.rejectToolCall(steps, tc, output, { args, denied: decision.reason });
|
|
383
|
+
return {};
|
|
384
|
+
}
|
|
385
|
+
if (decision.decision === "ask") {
|
|
386
|
+
const allowed = await this.permissions.confirm(args.command);
|
|
387
|
+
if (!allowed) {
|
|
388
|
+
const output = `ERROR: user denied permission to run: ${String(args.command).slice(0, 200)}`;
|
|
389
|
+
this.rejectToolCall(steps, tc, output, { args, denied: "user" });
|
|
390
|
+
return {};
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
const t0 = Date.now();
|
|
395
|
+
// Route MCP tools first (they carry the mcp__ prefix, not in AGENT_TOOLS)
|
|
396
|
+
if (tool) {
|
|
397
|
+
result = await tool.run(this.ctx, args);
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
// MCP tools — G5: đi qua permission engine như tool thường
|
|
401
|
+
// (policy floor → rules → mode fallback). Không có nhánh bypass.
|
|
402
|
+
const mcpDecision = this.permissions.classifyMcpTool(tc.name);
|
|
403
|
+
if (mcpDecision?.decision === "deny") {
|
|
404
|
+
const output = `ERROR: MCP tool "${tc.name}" denied by permission policy (${mcpDecision.reason}).`;
|
|
405
|
+
this.rejectToolCall(steps, tc, output, { args, denied: mcpDecision.reason });
|
|
406
|
+
return {};
|
|
407
|
+
}
|
|
408
|
+
if (this.config.permissionMode === "readonly" && mcpDecision?.decision !== "allow") {
|
|
409
|
+
result = {
|
|
410
|
+
ok: false,
|
|
411
|
+
output: `ERROR: readonly mode — MCP tool "${tc.name}" is disabled. Allow it explicitly with a permission rule (mcp__${tc.name.replace(/^mcp__/, "").replace(/__/, "__")} or a wildcard).`,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
else if (mcpDecision?.decision === "ask" && !this.config.yes) {
|
|
415
|
+
const allowed = await this.confirmMcpTool(tc.name);
|
|
416
|
+
if (!allowed) {
|
|
417
|
+
const output = `ERROR: user denied permission to call MCP tool: ${tc.name}`;
|
|
418
|
+
this.rejectToolCall(steps, tc, output, { args, denied: "user" });
|
|
419
|
+
return {};
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
result = await this.invokeMcpTool(tc.name, args);
|
|
423
|
+
}
|
|
424
|
+
this.logger.log({ type: "tool", step: steps, tool: tc.name, args, ok: result.ok, elapsedMs: Date.now() - t0 });
|
|
425
|
+
}
|
|
426
|
+
catch (err) {
|
|
427
|
+
result = { ok: false, output: `ERROR: ${err.message}` };
|
|
428
|
+
this.logger.log({ type: "tool", step: steps, tool: tc.name, args, ok: false, error: err.message });
|
|
429
|
+
}
|
|
430
|
+
// User pressed [q]uit in an edit-confirmation prompt — stop the whole run.
|
|
431
|
+
if (this.permissions.abortRequested) {
|
|
432
|
+
this.aborted = true;
|
|
433
|
+
ui.warn("edit confirmation aborted by user ([q]uit) — stopping the run");
|
|
434
|
+
}
|
|
435
|
+
const filesModified = result.ok && (tc.name === "write_file" || tc.name === "edit_file");
|
|
436
|
+
return {
|
|
437
|
+
post: () => {
|
|
438
|
+
// Track file modifications — a completion claim with no edits needs no
|
|
439
|
+
// build verification, and conversational turns never touch files.
|
|
440
|
+
if (filesModified)
|
|
441
|
+
this.filesModified = true;
|
|
442
|
+
// T-01: @-autocomplete index invalidate on tool writes.
|
|
443
|
+
if (filesModified)
|
|
444
|
+
invalidateFileIndex();
|
|
445
|
+
this.endToolStream();
|
|
446
|
+
this.currentToolCall = null;
|
|
447
|
+
ui.toolResult(tc.name, result.ok, result.output);
|
|
448
|
+
this.emit({
|
|
449
|
+
type: "tool_result",
|
|
450
|
+
step: steps,
|
|
451
|
+
id: tc.id,
|
|
452
|
+
name: tc.name,
|
|
453
|
+
ok: result.ok,
|
|
454
|
+
output: result.output,
|
|
455
|
+
...(result.diff ? { diff: result.diff } : {}),
|
|
456
|
+
...(result.image ? { image: result.image } : {}),
|
|
457
|
+
});
|
|
458
|
+
stepToolResults.set(tc.id, { ok: result.ok, output: result.output });
|
|
459
|
+
this.pushToolResult(tc, summariseOutput(result.output));
|
|
460
|
+
},
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
/** MCP tool call with output clamp (extracted for I-09 readability). */
|
|
464
|
+
async invokeMcpTool(name, args) {
|
|
465
|
+
const reg = this.mcp?.getTool(name);
|
|
466
|
+
if (!reg)
|
|
467
|
+
return { ok: false, output: `ERROR: unknown tool "${name}"` };
|
|
468
|
+
// M2: validate args theo inputSchema — lỗi local, không gọi server.
|
|
469
|
+
const schemaErrors = validateSchema(args, reg.def.inputSchema);
|
|
470
|
+
if (schemaErrors.length > 0) {
|
|
471
|
+
return { ok: false, output: `ERROR: invalid arguments for ${name}:\n${schemaErrors.join("\n")}` };
|
|
472
|
+
}
|
|
473
|
+
const r = await reg.conn.callTool(reg.toolName, args);
|
|
474
|
+
return { ok: r.ok, output: r.output.length > 20_000 ? r.output.slice(0, 20_000) + "\n... [truncated]" : r.output };
|
|
475
|
+
}
|
|
147
476
|
/**
|
|
148
477
|
* Record usage of one LLM call: render token counts, log an `llm_usage`
|
|
149
478
|
* event, and accumulate totals into session state.
|
|
@@ -185,17 +514,27 @@ export class Agent {
|
|
|
185
514
|
/**
|
|
186
515
|
* One LLM turn: streaming if enabled (renders deltas live), with automatic
|
|
187
516
|
* fallback to non-streaming when the provider/stream fails.
|
|
517
|
+
*
|
|
518
|
+
* H-09 incomplete-stream continuation: when a stream dies mid-flight but a
|
|
519
|
+
* COMPLETE tool-call delta was already accumulated, surface the partial
|
|
520
|
+
* result (flagged `incomplete`) instead of discarding it — the run loop
|
|
521
|
+
* executes the tool calls and continues with a synthetic instruction.
|
|
188
522
|
*/
|
|
189
523
|
async chatWithStream() {
|
|
190
524
|
const signal = this.runSignal.signal;
|
|
191
525
|
if (!this.config.stream) {
|
|
192
526
|
return this.client.chat(this.messages, this.tools, { signal });
|
|
193
527
|
}
|
|
528
|
+
let streamedText = "";
|
|
529
|
+
let final;
|
|
530
|
+
let started = false;
|
|
531
|
+
let sawToolCallDelta = false;
|
|
194
532
|
try {
|
|
195
|
-
let streamedText = "";
|
|
196
|
-
let final;
|
|
197
|
-
let started = false;
|
|
198
533
|
for await (const chunk of this.client.streamChat(this.messages, this.tools, { signal })) {
|
|
534
|
+
if (chunk.reasoningDelta) {
|
|
535
|
+
// I-05: reasoning hiển thị dim qua UI stream; không vào llm_delta.
|
|
536
|
+
this.emit({ type: "llm_reasoning", delta: chunk.reasoningDelta });
|
|
537
|
+
}
|
|
199
538
|
if (chunk.contentDelta) {
|
|
200
539
|
if (!started) {
|
|
201
540
|
ui.llmStreamStart("assistant");
|
|
@@ -205,6 +544,8 @@ export class Agent {
|
|
|
205
544
|
this.emit({ type: "llm_delta", delta: chunk.contentDelta });
|
|
206
545
|
streamedText += chunk.contentDelta;
|
|
207
546
|
}
|
|
547
|
+
if (chunk.toolCallDelta)
|
|
548
|
+
sawToolCallDelta = true;
|
|
208
549
|
if (chunk.final)
|
|
209
550
|
final = chunk.final;
|
|
210
551
|
}
|
|
@@ -219,7 +560,35 @@ export class Agent {
|
|
|
219
560
|
// No finish_reason seen — fall through to non-streaming
|
|
220
561
|
return this.client.chat(this.messages, this.tools);
|
|
221
562
|
}
|
|
222
|
-
catch (
|
|
563
|
+
catch (caught) {
|
|
564
|
+
if (started)
|
|
565
|
+
ui.llmStreamEnd();
|
|
566
|
+
// A timeout is a real failure, not a streaming quirk — falling back to
|
|
567
|
+
// non-streaming would just wait the full timeout a second time.
|
|
568
|
+
const e = caught;
|
|
569
|
+
const isTimeout = e?.name === "TimeoutError" || /timed out|TimeoutError/i.test(e?.message ?? "");
|
|
570
|
+
const err = isTimeout
|
|
571
|
+
? (caught instanceof LlmError ? caught : new LlmError(`LLM request timed out: ${e?.message ?? caught}`))
|
|
572
|
+
: caught;
|
|
573
|
+
// Incomplete-stream continuation: the stream died (timeout/disconnect)
|
|
574
|
+
// after a complete tool-call delta → execute what we have rather than
|
|
575
|
+
// failing the whole step (pattern from opencode runner).
|
|
576
|
+
if (sawToolCallDelta &&
|
|
577
|
+
(isTimeout || /ECONNRESET|socket hang up|network|terminated/i.test(e?.message ?? ""))) {
|
|
578
|
+
const partial = this.client.lastPartialToolCalls ?? [];
|
|
579
|
+
if (partial.length > 0) {
|
|
580
|
+
ui.warn("stream interrupted after complete tool-call(s) — continuing with what was received");
|
|
581
|
+
return {
|
|
582
|
+
message: {
|
|
583
|
+
role: "assistant",
|
|
584
|
+
content: streamedText || null,
|
|
585
|
+
tool_calls: partial,
|
|
586
|
+
},
|
|
587
|
+
usage: undefined,
|
|
588
|
+
incomplete: true,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
}
|
|
223
592
|
if (err instanceof LlmError && !this.streamFallbackWarned) {
|
|
224
593
|
this.streamFallbackWarned = true;
|
|
225
594
|
ui.warn(`streaming unavailable (${err.message.slice(0, 120)}) — falling back to non-streaming`);
|
|
@@ -235,21 +604,48 @@ export class Agent {
|
|
|
235
604
|
const servers = this.config.mcpServers ?? {};
|
|
236
605
|
if (Object.keys(servers).length === 0)
|
|
237
606
|
return;
|
|
607
|
+
// Idempotent: /mcp may connect while idle — never spawn a second set of
|
|
608
|
+
// server processes (the first manager's children would leak).
|
|
609
|
+
if (this.mcp)
|
|
610
|
+
return;
|
|
238
611
|
if (this.config.permissionMode === "readonly") {
|
|
239
612
|
ui.warn("readonly mode: MCP tools are disabled (they bypass the permission system)");
|
|
240
613
|
}
|
|
241
614
|
this.mcp = new McpManager(servers);
|
|
615
|
+
this.mcp.onStatus = (line) => {
|
|
616
|
+
this.logger.log({ type: "mcp_status", line });
|
|
617
|
+
this.emit({ type: "mcp_status", line });
|
|
618
|
+
ui.info(line); // setQuiet suppresses this in the TUI — alt-screen stays clean
|
|
619
|
+
};
|
|
620
|
+
// Sidebar TUI: trạng thái máy trạng thái per-server (M1) — event structured
|
|
621
|
+
// (server/state) để reducer giữ map, không append thêm notice vào chat.
|
|
622
|
+
this.mcp.onServerState = (name, st, detail) => {
|
|
623
|
+
const line = `MCP "${name}" ${st}${detail ? ` — ${detail}` : ""}`;
|
|
624
|
+
this.logger.log({ type: "mcp_status", line, server: name, state: st });
|
|
625
|
+
this.emit({ type: "mcp_status", line, server: name, state: st });
|
|
626
|
+
};
|
|
242
627
|
await this.mcp.connectAll();
|
|
243
628
|
const mcpSpecs = this.mcp.toToolSpecs();
|
|
244
629
|
if (mcpSpecs.length > 0) {
|
|
245
|
-
this.tools = [...
|
|
630
|
+
this.tools = [...allToolSpecs(), ...mcpSpecs];
|
|
246
631
|
}
|
|
247
632
|
}
|
|
248
|
-
/**
|
|
633
|
+
/**
|
|
634
|
+
* Disconnect MCP servers when the agent is disposed — connections are kept
|
|
635
|
+
* alive for the whole agent/session lifetime (one "connected" line, no
|
|
636
|
+
* re-spawn per message). Ephemeral owners (server runs, one-shot CLI) call
|
|
637
|
+
* dispose() when their agent is done.
|
|
638
|
+
*/
|
|
249
639
|
shutdownMcp() {
|
|
250
640
|
this.mcp?.killAll();
|
|
251
641
|
this.mcp = null;
|
|
252
642
|
}
|
|
643
|
+
/** M4: đóng hot-reload watcher (khi agent bị hủy vĩnh viễn). */
|
|
644
|
+
dispose() {
|
|
645
|
+
this.watcher?.close();
|
|
646
|
+
this.watcher = null;
|
|
647
|
+
this.shutdownMcp();
|
|
648
|
+
}
|
|
253
649
|
/**
|
|
254
650
|
* Autonomous closed loop:
|
|
255
651
|
* task -> LLM plans -> tool calls -> build -> on failure: read error, patch, retry.
|
|
@@ -259,6 +655,8 @@ export class Agent {
|
|
|
259
655
|
throw new Error("No API key configured. Set SCODER_API_KEY or OPENAI_API_KEY (or put apiKey in .scoderrc.json).");
|
|
260
656
|
}
|
|
261
657
|
const buildCmd = this.buildCmd;
|
|
658
|
+
// @file mention expansion — task trước khi build messages.
|
|
659
|
+
task = expandMentions(task, this.config.workdir);
|
|
262
660
|
ui.banner("scoder", [
|
|
263
661
|
["task", task],
|
|
264
662
|
["model", `${this.client.model} (${this.config.provider}${this.config.stream ? ", stream" : ""})`],
|
|
@@ -267,16 +665,30 @@ export class Agent {
|
|
|
267
665
|
["steps", `max ${this.config.maxSteps} · permission ${this.config.permissionMode}`],
|
|
268
666
|
]);
|
|
269
667
|
this.currentTask = task;
|
|
668
|
+
this.events.append({ type: "run.started", task });
|
|
270
669
|
this.resetRunSignal();
|
|
271
670
|
this.resetUsageTotals();
|
|
671
|
+
this.filesModified = false;
|
|
672
|
+
this.plan = [];
|
|
673
|
+
const skillsBlock = buildSkillsBlock(this.skills); // I3
|
|
272
674
|
this.messages = [
|
|
273
675
|
{
|
|
274
676
|
role: "system",
|
|
275
|
-
content:
|
|
276
|
-
|
|
677
|
+
content: buildSystemPrompt({
|
|
678
|
+
workdir: this.config.workdir,
|
|
679
|
+
buildCmd,
|
|
680
|
+
envInfo: buildEnvInfo(this.config.workdir),
|
|
681
|
+
skillsBlock,
|
|
682
|
+
mcpBlock: buildMcpBlock(this.config),
|
|
683
|
+
}) + buildAgentsBlock(discoverAgents(this.config.workdir), this.config.allowSubagents !== false),
|
|
277
684
|
},
|
|
685
|
+
...this.pendingContext.map((content) => ({ role: "user", content })),
|
|
278
686
|
{ role: "user", content: task },
|
|
279
687
|
];
|
|
688
|
+
this.pendingContext = [];
|
|
689
|
+
// Phiên bản system prompt được ghi vào log — giúp so sánh chất lượng
|
|
690
|
+
// giữa các phiên bản prompt khi phân tích JSONL.
|
|
691
|
+
this.logger.log({ type: "prompt_version", version: PROMPT_VERSION });
|
|
280
692
|
this.emit({
|
|
281
693
|
type: "run_start",
|
|
282
694
|
task,
|
|
@@ -284,36 +696,58 @@ export class Agent {
|
|
|
284
696
|
provider: this.config.provider,
|
|
285
697
|
workdir: this.config.workdir,
|
|
286
698
|
buildCmd,
|
|
699
|
+
permission: this.config.permissionMode,
|
|
287
700
|
});
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
return await this.runLoop(0, buildCmd, null);
|
|
291
|
-
}
|
|
292
|
-
finally {
|
|
293
|
-
this.shutdownMcp();
|
|
294
|
-
}
|
|
701
|
+
await this.connectMcp();
|
|
702
|
+
return this.runLoop(0, buildCmd, null);
|
|
295
703
|
}
|
|
296
704
|
/**
|
|
297
705
|
* One conversational turn in an ongoing session (used by the REPL):
|
|
298
706
|
* appends the task to the existing message history instead of resetting it,
|
|
299
707
|
* then runs the autonomous loop. First call behaves like run().
|
|
300
708
|
*/
|
|
709
|
+
/**
|
|
710
|
+
* T-07: trim the conversation back one exchange (last user message +
|
|
711
|
+
* everything after) — fork/edit-resend. The caller re-sends an edited
|
|
712
|
+
* task via runTurn().
|
|
713
|
+
*/
|
|
714
|
+
trimLastTurn() {
|
|
715
|
+
// Cut = last REAL user message (skip guardrail reminder tails injected at
|
|
716
|
+
// finish); drop it and everything after. Reminder tails BEFORE the cut
|
|
717
|
+
// point belong to earlier turns and stay (history intact).
|
|
718
|
+
for (let i = this.messages.length - 1; i >= 0; i--) {
|
|
719
|
+
const m = this.messages[i];
|
|
720
|
+
if (m.role !== "user")
|
|
721
|
+
continue;
|
|
722
|
+
if (this.isReminderTail(String(m.content ?? "")))
|
|
723
|
+
continue;
|
|
724
|
+
this.messages = this.messages.slice(0, i);
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
this.messages = [];
|
|
728
|
+
}
|
|
729
|
+
/** True for guardrail-injected user messages (not real user tasks). */
|
|
730
|
+
isReminderTail(content) {
|
|
731
|
+
return (content.includes("<system-reminder>") ||
|
|
732
|
+
/^Continue working on (the task|the original task)\. Build must pass before you finish\.$/.test(content.trim()));
|
|
733
|
+
}
|
|
301
734
|
async runTurn(task) {
|
|
302
735
|
if (this.messages.length === 0) {
|
|
303
736
|
return this.run(task);
|
|
304
737
|
}
|
|
305
738
|
const buildCmd = this.buildCmd;
|
|
739
|
+
task = expandMentions(task, this.config.workdir);
|
|
306
740
|
this.currentTask = `${this.currentTask}\n${task}`.trim();
|
|
307
741
|
this.resetRunSignal();
|
|
308
742
|
this.resetUsageTotals();
|
|
743
|
+
this.filesModified = false;
|
|
744
|
+
this.plan = [];
|
|
745
|
+
for (const content of this.pendingContext)
|
|
746
|
+
this.messages.push({ role: "user", content });
|
|
747
|
+
this.pendingContext = [];
|
|
309
748
|
this.messages.push({ role: "user", content: task });
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
return await this.runLoop(0, buildCmd, null);
|
|
313
|
-
}
|
|
314
|
-
finally {
|
|
315
|
-
this.shutdownMcp();
|
|
316
|
-
}
|
|
749
|
+
await this.connectMcp();
|
|
750
|
+
return this.runLoop(0, buildCmd, null);
|
|
317
751
|
}
|
|
318
752
|
/**
|
|
319
753
|
* Resume a previous session: restore messages/step count and continue the loop.
|
|
@@ -325,11 +759,16 @@ export class Agent {
|
|
|
325
759
|
this.currentTask = session.task;
|
|
326
760
|
this.resetRunSignal();
|
|
327
761
|
this.resetUsageTotals();
|
|
762
|
+
this.filesModified = false;
|
|
763
|
+
this.skills = discoverSkills(this.config.workdir);
|
|
328
764
|
this.messages = session.messages ?? [];
|
|
329
765
|
ui.header(`RESUME ${session.id}`);
|
|
330
766
|
ui.info(`previous task: ${session.task} (${session.steps} step(s) done)`);
|
|
331
767
|
if (followUp) {
|
|
332
|
-
|
|
768
|
+
for (const content of this.pendingContext)
|
|
769
|
+
this.messages.push({ role: "user", content });
|
|
770
|
+
this.pendingContext = [];
|
|
771
|
+
this.messages.push({ role: "user", content: expandMentions(followUp, this.config.workdir) });
|
|
333
772
|
}
|
|
334
773
|
else {
|
|
335
774
|
this.messages.push({
|
|
@@ -340,13 +779,10 @@ export class Agent {
|
|
|
340
779
|
const lastBuild = session.lastBuild
|
|
341
780
|
? { success: session.lastBuild.success, command: session.lastBuild.command, output: session.lastBuild.output }
|
|
342
781
|
: null;
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
finally {
|
|
348
|
-
this.shutdownMcp();
|
|
349
|
-
}
|
|
782
|
+
// v2: opening a resumed run stamps a fresh run.started (event log continuity).
|
|
783
|
+
this.events.append({ type: "run.started", task: this.currentTask });
|
|
784
|
+
await this.connectMcp();
|
|
785
|
+
return this.runLoop(session.steps ?? 0, buildCmd, lastBuild);
|
|
350
786
|
}
|
|
351
787
|
/**
|
|
352
788
|
* The shared autonomous loop:
|
|
@@ -355,16 +791,48 @@ export class Agent {
|
|
|
355
791
|
async runLoop(startSteps, buildCmd, initialBuild = null) {
|
|
356
792
|
let steps = startSteps;
|
|
357
793
|
let lastBuild = initialBuild;
|
|
794
|
+
// G1: guardrail tracker state (textOnlyStreak, sigRepeat, toolError*,
|
|
795
|
+
// lowTokenStreak, wrapUpInjected) sống trong GuardrailEngine — reset mỗi run.
|
|
796
|
+
this.guardrails.reset();
|
|
797
|
+
// H-10 compaction config (false = disabled → mechanical trim only).
|
|
798
|
+
// I-02: contextWindow từ model catalog (lookup sync, cache/fallback) thay
|
|
799
|
+
// vì default 128k cứng — compaction trigger chính xác theo model.
|
|
800
|
+
const catalogWindow = contextWindowFor(this.config.provider, this.config.model);
|
|
801
|
+
const compactionCfg = this.config.compaction === false
|
|
802
|
+
? null
|
|
803
|
+
: {
|
|
804
|
+
...DEFAULT_COMPACTION,
|
|
805
|
+
contextWindow: this.config.contextWindow ?? catalogWindow,
|
|
806
|
+
...(this.config.compaction ?? {}),
|
|
807
|
+
};
|
|
358
808
|
while (steps < this.config.maxSteps && !this.aborted) {
|
|
809
|
+
// ── Safe step boundary: deliver steered user input (H-08) ────────────
|
|
810
|
+
const steered = this.drainSteerInbox();
|
|
811
|
+
if (steered.length > 0) {
|
|
812
|
+
for (const s of steered) {
|
|
813
|
+
this.messages.push({ role: "user", content: s });
|
|
814
|
+
this.events.append({ type: "message.added", message: { role: "user", content: s } });
|
|
815
|
+
}
|
|
816
|
+
// One allowance reset for the whole steering batch (opencode behavior)
|
|
817
|
+
// — engine exposes a hook so the runLoop doesn't touch tracker state.
|
|
818
|
+
this.guardrails.notifySteered();
|
|
819
|
+
this.emit({ type: "steer_delivered", count: steered.length });
|
|
820
|
+
ui.info(`steer: +${steered.length} user message(s) delivered`);
|
|
821
|
+
}
|
|
359
822
|
steps++;
|
|
360
823
|
ui.step(steps, this.config.maxSteps, "thinking...");
|
|
824
|
+
this.events.claimExecution(steps);
|
|
361
825
|
this.emit({ type: "step", step: steps, maxSteps: this.config.maxSteps });
|
|
362
|
-
|
|
826
|
+
// Metrics: thời gian phần LLM của bước này (điều chỉnh chất lượng +
|
|
827
|
+
// so sánh provider; hiển thị trong `scoder cost`).
|
|
828
|
+
const stepStart = Date.now();
|
|
829
|
+
const { message, usage, incomplete } = await this.chatWithStream().catch((err) => {
|
|
363
830
|
// SIGINT during the LLM call — stop gracefully instead of crashing
|
|
364
831
|
if (this.aborted) {
|
|
365
832
|
return {
|
|
366
833
|
message: { role: "assistant", content: null, tool_calls: undefined },
|
|
367
834
|
usage: undefined,
|
|
835
|
+
incomplete: undefined,
|
|
368
836
|
};
|
|
369
837
|
}
|
|
370
838
|
throw err;
|
|
@@ -372,163 +840,214 @@ export class Agent {
|
|
|
372
840
|
if (this.aborted)
|
|
373
841
|
break;
|
|
374
842
|
this.recordLlmUsage(usage);
|
|
375
|
-
this.logger.log({ type: "step", step: steps });
|
|
376
|
-
//
|
|
377
|
-
|
|
378
|
-
|
|
843
|
+
this.logger.log({ type: "step", step: steps, durationMs: Date.now() - stepStart, toolCalls: message.tool_calls?.length ?? 0 });
|
|
844
|
+
// H-09: after an interrupted stream, nudge the model to continue rather
|
|
845
|
+
// than restart (synthetic continuation instruction, opencode pattern).
|
|
846
|
+
if (incomplete) {
|
|
847
|
+
this.messages.push({
|
|
848
|
+
role: "user",
|
|
849
|
+
content: "[system] The previous response was cut off mid-stream. The tool call(s) above were received in full — continue exactly where you left off; do not repeat completed work.",
|
|
850
|
+
});
|
|
851
|
+
this.events.append({ type: "retry.scheduled", reason: "incomplete-stream continuation" });
|
|
852
|
+
}
|
|
853
|
+
// ── Chuẩn bị context cho Guardrail Engine ─────────────────────────────
|
|
854
|
+
// Chat marker được làm sạch ở đây để rule nhận text đã chuẩn hoá.
|
|
855
|
+
const hasToolCalls = !!message.tool_calls && message.tool_calls.length > 0;
|
|
856
|
+
let text = message.content ?? "";
|
|
857
|
+
let chatDone = false;
|
|
858
|
+
if (!hasToolCalls && text.includes(CHAT_DONE_MARKER)) {
|
|
859
|
+
chatDone = true;
|
|
860
|
+
text = text.split(CHAT_DONE_MARKER)[0].trim();
|
|
861
|
+
}
|
|
862
|
+
if (!hasToolCalls) {
|
|
379
863
|
this.messages.push({ role: "assistant", content: text });
|
|
380
|
-
|
|
381
|
-
if (claimsCompletion(text) && steps < this.config.maxSteps) {
|
|
382
|
-
ui.info("agent claims completion -> verifying with build");
|
|
383
|
-
lastBuild = await this.checkBuild(buildCmd);
|
|
384
|
-
if (lastBuild.success) {
|
|
385
|
-
return this.finish(true, steps, buildCmd, text);
|
|
386
|
-
}
|
|
387
|
-
this.messages.push({
|
|
388
|
-
role: "user",
|
|
389
|
-
content: `Build still FAILED after your claimed completion. Output:\n${summariseOutput(lastBuild.output)}\nFix and continue.`,
|
|
390
|
-
});
|
|
391
|
-
continue;
|
|
392
|
-
}
|
|
864
|
+
this.events.append({ type: "message.added", message: { role: "assistant", content: text } });
|
|
393
865
|
ui.llm("agent", text);
|
|
394
|
-
|
|
866
|
+
}
|
|
867
|
+
else {
|
|
395
868
|
this.messages.push({
|
|
396
|
-
role: "
|
|
397
|
-
content:
|
|
869
|
+
role: "assistant",
|
|
870
|
+
content: message.content ?? null,
|
|
871
|
+
tool_calls: message.tool_calls,
|
|
398
872
|
});
|
|
399
|
-
|
|
873
|
+
this.events.append({ type: "message.added", message: this.messages[this.messages.length - 1] });
|
|
400
874
|
}
|
|
401
|
-
this.messages.push({
|
|
402
|
-
role: "assistant",
|
|
403
|
-
content: message.content ?? null,
|
|
404
|
-
tool_calls: message.tool_calls,
|
|
405
|
-
});
|
|
406
875
|
// Execute each tool call (validate args before running)
|
|
407
876
|
const stepToolResults = new Map();
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
877
|
+
// H-11: checkpoint before any batch that may write files.
|
|
878
|
+
const batchMayWrite = (message.tool_calls ?? []).some((tc) => tc.name === "write_file" || tc.name === "edit_file");
|
|
879
|
+
if (batchMayWrite)
|
|
880
|
+
this.snapshots.capture(`step ${steps}: ${(message.tool_calls ?? []).map((t) => t.name).join(",")}`);
|
|
881
|
+
// I-09: parallelize read-only tool calls (concurrency cap 4); mutating
|
|
882
|
+
// calls (and any batch containing an ask-gated call) run sequentially.
|
|
883
|
+
const calls = message.tool_calls ?? [];
|
|
884
|
+
const needsAsk = calls.some((tc) => {
|
|
885
|
+
if (tc.name !== "run_command")
|
|
886
|
+
return false;
|
|
413
887
|
try {
|
|
414
|
-
|
|
888
|
+
const a = JSON.parse(tc.arguments || "{}");
|
|
889
|
+
if (typeof a.command !== "string")
|
|
890
|
+
return false;
|
|
891
|
+
const d = this.permissions.classifyShell(a.command);
|
|
892
|
+
return d.decision === "ask" || d.decision === "deny";
|
|
415
893
|
}
|
|
416
894
|
catch {
|
|
417
|
-
|
|
418
|
-
continue;
|
|
419
|
-
}
|
|
420
|
-
ui.toolCall(tc.name, args);
|
|
421
|
-
this.currentToolCall = { step: steps, id: tc.id, name: tc.name };
|
|
422
|
-
this.emit({ type: "tool_call", step: steps, id: tc.id, name: tc.name, args });
|
|
423
|
-
if (!tool && !(this.mcp?.hasTool(tc.name))) {
|
|
424
|
-
this.rejectToolCall(steps, tc, `ERROR: unknown tool "${tc.name}". Available: ${AGENT_TOOLS.map((t) => t.spec.function.name).join(", ")}${this.mcp ? " + MCP tools (mcp__<server>__<tool>)" : ""}`, { error: "unknown tool" });
|
|
425
|
-
continue;
|
|
895
|
+
return true;
|
|
426
896
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
output: `ERROR: command denied by permission policy (${decision.reason}): ${String(args.command).slice(0, 200)}`,
|
|
436
|
-
};
|
|
437
|
-
this.rejectToolCall(steps, tc, result.output, { args, denied: decision.reason });
|
|
438
|
-
continue;
|
|
439
|
-
}
|
|
440
|
-
if (decision.decision === "ask") {
|
|
441
|
-
const allowed = await this.permissions.confirm(args.command);
|
|
442
|
-
if (!allowed) {
|
|
443
|
-
result = {
|
|
444
|
-
ok: false,
|
|
445
|
-
output: `ERROR: user denied permission to run: ${String(args.command).slice(0, 200)}`,
|
|
446
|
-
};
|
|
447
|
-
this.rejectToolCall(steps, tc, result.output, { args, denied: "user" });
|
|
448
|
-
continue;
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
const t0 = Date.now();
|
|
453
|
-
// Route MCP tools first (they carry the mcp__ prefix, not in AGENT_TOOLS)
|
|
454
|
-
if (tool) {
|
|
455
|
-
result = await tool.run(this.ctx, args);
|
|
897
|
+
});
|
|
898
|
+
if (calls.length > 1 && !needsAsk) {
|
|
899
|
+
const readOnly = calls.every((tc) => Agent.READONLY_TOOLS.has(tc.name));
|
|
900
|
+
if (readOnly) {
|
|
901
|
+
const results = await this.parallelMap(calls, (tc) => this.executeToolCall(tc, steps, stepToolResults), this.config.limits?.toolConcurrency ?? 4);
|
|
902
|
+
for (const r of results) {
|
|
903
|
+
if (r.post)
|
|
904
|
+
r.post();
|
|
456
905
|
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
906
|
+
}
|
|
907
|
+
else {
|
|
908
|
+
for (const tc of calls) {
|
|
909
|
+
if (this.aborted)
|
|
910
|
+
break;
|
|
911
|
+
const r = await this.executeToolCall(tc, steps, stepToolResults);
|
|
912
|
+
if (r.post)
|
|
913
|
+
r.post();
|
|
464
914
|
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
else {
|
|
918
|
+
for (const tc of calls) {
|
|
919
|
+
if (this.aborted)
|
|
920
|
+
break;
|
|
921
|
+
const r = await this.executeToolCall(tc, steps, stepToolResults);
|
|
922
|
+
if (r.post)
|
|
923
|
+
r.post();
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
// ── Guardrail Engine — đánh giá toàn bộ rule sau khi tools chạy xong ──
|
|
927
|
+
// Thông tin build stop condition: có tool call nào chạy ĐÚNG buildCmd?
|
|
928
|
+
// Match theo arguments đã parse (đúng hơn substring: "npm run build && rm -rf /"
|
|
929
|
+
// vẫn tính là build call, nhưng lệnh echo chứa chuỗi buildCmd thì không).
|
|
930
|
+
const buildCall = hasToolCalls
|
|
931
|
+
? (message.tool_calls ?? []).find((tc) => tc.name === "run_command" &&
|
|
932
|
+
(() => {
|
|
933
|
+
try {
|
|
934
|
+
const a = JSON.parse(tc.arguments || "{}");
|
|
935
|
+
return typeof a.command === "string" && a.command.includes(buildCmd);
|
|
469
936
|
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
result = { ok: r.ok, output: r.output.length > 20_000 ? r.output.slice(0, 20_000) + "\n... [truncated]" : r.output };
|
|
937
|
+
catch {
|
|
938
|
+
return false;
|
|
473
939
|
}
|
|
474
|
-
}
|
|
475
|
-
|
|
940
|
+
})())
|
|
941
|
+
: undefined;
|
|
942
|
+
let buildPassedThisStep = false;
|
|
943
|
+
if (buildCall) {
|
|
944
|
+
const r = stepToolResults.get(buildCall.id);
|
|
945
|
+
if (r && r.ok)
|
|
946
|
+
buildPassedThisStep = true;
|
|
947
|
+
}
|
|
948
|
+
const decisions = await this.guardrails.evaluate({
|
|
949
|
+
step: steps,
|
|
950
|
+
maxSteps: this.config.maxSteps,
|
|
951
|
+
text,
|
|
952
|
+
message,
|
|
953
|
+
hasToolCalls,
|
|
954
|
+
toolResults: stepToolResults,
|
|
955
|
+
buildPassedThisStep,
|
|
956
|
+
buildCallPresent: !!buildCall,
|
|
957
|
+
filesModified: this.filesModified,
|
|
958
|
+
chatDone,
|
|
959
|
+
outputTokensThisStep: usage?.completion_tokens,
|
|
960
|
+
totalTokensIn: this.usageTotals.inputTokens, // G6 budget rule
|
|
961
|
+
totalTokensOut: this.usageTotals.outputTokens,
|
|
962
|
+
});
|
|
963
|
+
// Áp dụng decision: finish đầu tiên thắng; inject gom lại đẩy một lần.
|
|
964
|
+
const reminders = [];
|
|
965
|
+
let doTrim = false;
|
|
966
|
+
for (const d of decisions) {
|
|
967
|
+
const a = d.action;
|
|
968
|
+
if (a.kind === "inject") {
|
|
969
|
+
if (d.rule === "wrap-up")
|
|
970
|
+
this.guardrails.markWrapUpInjected(); // chỉ inject một lần / run
|
|
971
|
+
reminders.push(a.reminder);
|
|
972
|
+
continue;
|
|
476
973
|
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
974
|
+
if (a.kind === "trim") {
|
|
975
|
+
doTrim = true;
|
|
976
|
+
continue;
|
|
480
977
|
}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
978
|
+
if (a.kind === "verify-build") {
|
|
979
|
+
// Xác minh độc lập với lời của model (async — loop tự chạy build).
|
|
980
|
+
lastBuild = await this.checkBuild(buildCmd);
|
|
981
|
+
if (lastBuild.success) {
|
|
982
|
+
if (doTrim)
|
|
983
|
+
this.messages = trimHistory(this.messages);
|
|
984
|
+
return this.finish(true, steps, buildCmd, a.successSummary);
|
|
985
|
+
}
|
|
986
|
+
reminders.push(`${a.failReminder}\n${summariseOutput(lastBuild.output)}\nFix and continue.`);
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
if (a.kind === "finish") {
|
|
990
|
+
// Giữ nguyên thứ tự tin nhắn như bản inline cũ: reminder (wrap-up)
|
|
991
|
+
// đã đẩy trước khi kết thúc.
|
|
992
|
+
for (const r of reminders)
|
|
993
|
+
this.messages.push({ role: "user", content: r });
|
|
994
|
+
if (doTrim)
|
|
995
|
+
this.messages = trimHistory(this.messages);
|
|
996
|
+
return this.finish(a.success, steps, buildCmd, a.summary, a.chatOnly ?? false);
|
|
485
997
|
}
|
|
486
|
-
this.endToolStream();
|
|
487
|
-
this.currentToolCall = null;
|
|
488
|
-
ui.toolResult(tc.name, result.ok, result.output);
|
|
489
|
-
this.emit({
|
|
490
|
-
type: "tool_result",
|
|
491
|
-
step: steps,
|
|
492
|
-
name: tc.name,
|
|
493
|
-
ok: result.ok,
|
|
494
|
-
output: result.output,
|
|
495
|
-
...(result.diff ? { diff: result.diff } : {}),
|
|
496
|
-
});
|
|
497
|
-
stepToolResults.set(tc.id, { ok: result.ok, output: result.output });
|
|
498
|
-
this.pushToolResult(tc, summariseOutput(result.output));
|
|
499
998
|
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
999
|
+
for (const r of reminders)
|
|
1000
|
+
this.messages.push({ role: "user", content: r });
|
|
1001
|
+
if (doTrim)
|
|
1002
|
+
this.messages = trimHistory(this.messages);
|
|
1003
|
+
// H-10: LLM compaction when history approaches the context window.
|
|
1004
|
+
if (compactionCfg && shouldCompact(this.messages, compactionCfg)) {
|
|
1005
|
+
try {
|
|
1006
|
+
ui.info("compaction: summarizing older history…");
|
|
1007
|
+
const summaryInput = extractSummaryInput(this.currentTask, this.messages);
|
|
1008
|
+
const summaryRes = await this.client.chat([
|
|
1009
|
+
...this.messages,
|
|
1010
|
+
{ role: "user", content: `${summaryInput}\n\n${COMPACTION_INSTRUCTION}` },
|
|
1011
|
+
], [], // no tools for the summarizer call
|
|
1012
|
+
{ signal: this.runSignal.signal });
|
|
1013
|
+
this.messages = applyCompaction(this.messages, redactSecrets(summaryRes.message.content ?? "History summarized."), compactionCfg);
|
|
1014
|
+
this.events.append({ type: "compaction.ran", tokensBefore: 0, summary: (summaryRes.message.content ?? "").slice(0, 2000) });
|
|
1015
|
+
ui.info(`compaction: done (history now ${this.messages.length} messages)`);
|
|
510
1016
|
}
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
1017
|
+
catch (err) {
|
|
1018
|
+
// Compaction is best-effort — mechanical trim stays as fallback.
|
|
1019
|
+
ui.warn(`compaction failed (${err.message.slice(0, 100)}) — using mechanical trim`);
|
|
1020
|
+
this.messages = trimHistory(this.messages);
|
|
514
1021
|
}
|
|
515
|
-
this.messages.push({
|
|
516
|
-
role: "user",
|
|
517
|
-
content: `Build FAILED (stop condition not met). Error output:\n${summariseOutput(lastBuild.output)}\nAnalyze the errors, fix the code, and rebuild.`,
|
|
518
|
-
});
|
|
519
1022
|
}
|
|
520
|
-
this.messages = trimHistory(this.messages);
|
|
521
1023
|
// Persist session state after every step (enables resume after crash/SIGINT)
|
|
522
1024
|
this.saveSession(steps, buildCmd, lastBuild);
|
|
1025
|
+
this.events.releaseExecution(steps);
|
|
523
1026
|
}
|
|
524
1027
|
// Max steps reached or aborted
|
|
525
1028
|
const reason = this.aborted ? "interrupted by user (SIGINT)" : `reached max steps (${this.config.maxSteps})`;
|
|
526
1029
|
this.saveSession(steps, buildCmd, lastBuild);
|
|
527
1030
|
return this.finish(false, steps, buildCmd, `Agent stopped: ${reason}`);
|
|
528
1031
|
}
|
|
529
|
-
/** Persist current agent state
|
|
1032
|
+
/** Persist current agent state (v2 event log + manifest; crash-resumable). */
|
|
530
1033
|
saveSession(steps, buildCmd, lastBuild) {
|
|
531
1034
|
this.lastBuildResult = lastBuild;
|
|
1035
|
+
// v2: durable event log is authoritative; JSON manifest is a cheap summary.
|
|
1036
|
+
this.events.writeManifest({
|
|
1037
|
+
task: this.currentTask,
|
|
1038
|
+
model: this.client.model,
|
|
1039
|
+
provider: this.config.provider,
|
|
1040
|
+
buildCmd,
|
|
1041
|
+
steps,
|
|
1042
|
+
maxSteps: this.config.maxSteps,
|
|
1043
|
+
usage: {
|
|
1044
|
+
llmCalls: this.usageTotals.calls,
|
|
1045
|
+
inputTokens: this.usageTotals.inputTokens,
|
|
1046
|
+
outputTokens: this.usageTotals.outputTokens,
|
|
1047
|
+
cachedTokens: this.usageTotals.cachedTokens,
|
|
1048
|
+
},
|
|
1049
|
+
});
|
|
1050
|
+
// v1 snapshot kept in sync (back-compat for /sessions readers, cost, resume).
|
|
532
1051
|
this.session.save({
|
|
533
1052
|
task: this.currentTask,
|
|
534
1053
|
model: this.client.model,
|
|
@@ -557,9 +1076,134 @@ export class Agent {
|
|
|
557
1076
|
this.logger.log({ type: "build", success: res.success, command: buildCmd, output: res.output.slice(0, 10_000) });
|
|
558
1077
|
return res;
|
|
559
1078
|
}
|
|
560
|
-
|
|
1079
|
+
// ── Frontend-facing APIs (REPL/TUI slash commands) ─────────────────────────
|
|
1080
|
+
/** Skills visible to this project (for /skills). */
|
|
1081
|
+
getSkills() {
|
|
1082
|
+
return this.skills;
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Stage a skill's content for injection before the next run/turn (/skill).
|
|
1086
|
+
* Non-destructive: nothing is sent to the LLM until the user submits a task.
|
|
1087
|
+
*/
|
|
1088
|
+
stageSkill(name) {
|
|
1089
|
+
const skill = findSkill(this.config.workdir, name);
|
|
1090
|
+
if (!skill) {
|
|
1091
|
+
const names = this.skills.map((s) => s.name).join(", ");
|
|
1092
|
+
return { ok: false, message: `skill "${name}" not found${names ? ` — available: ${names}` : " — no skills discovered (try /skills)"}` };
|
|
1093
|
+
}
|
|
1094
|
+
this.pendingContext.push(`[User loaded skill "${skill.name}" — follow these instructions for the upcoming task]\n\n${skill.body}`);
|
|
1095
|
+
return { ok: true, message: `skill "${skill.name}" staged — it will apply to your next task` };
|
|
1096
|
+
}
|
|
1097
|
+
/** MCP server overview (for /mcp): config always; tools once connected. */
|
|
1098
|
+
async mcpStatus() {
|
|
1099
|
+
const configs = (this.config.mcpServers ?? {});
|
|
1100
|
+
// M3: http server hiển thị url thay vì command.
|
|
1101
|
+
const target = (cfg) => cfg.url ?? cfg.command ?? "(unknown)";
|
|
1102
|
+
if (!this.mcp) {
|
|
1103
|
+
return Object.entries(configs).map(([name, cfg]) => ({ name, command: target(cfg), connected: false, tools: [] }));
|
|
1104
|
+
}
|
|
1105
|
+
await this.connectMcp();
|
|
1106
|
+
const byServer = new Map(this.mcp.listToolsByServer().map((s) => [s.server, s.tools]));
|
|
1107
|
+
return Object.entries(configs).map(([name, cfg]) => ({
|
|
1108
|
+
name,
|
|
1109
|
+
command: target(cfg),
|
|
1110
|
+
connected: byServer.has(name),
|
|
1111
|
+
tools: byServer.get(name) ?? [],
|
|
1112
|
+
}));
|
|
1113
|
+
}
|
|
1114
|
+
/** Call one MCP tool directly by its full name (mcp__server__tool) (/mcp exec). */
|
|
1115
|
+
async callMcpTool(fullName, args) {
|
|
1116
|
+
if (this.config.permissionMode === "readonly") {
|
|
1117
|
+
return { ok: false, output: "ERROR: readonly mode — MCP tools are disabled (they bypass the permission policy)." };
|
|
1118
|
+
}
|
|
1119
|
+
await this.connectMcp();
|
|
1120
|
+
const reg = this.mcp?.getTool(fullName);
|
|
1121
|
+
if (!reg) {
|
|
1122
|
+
return { ok: false, output: `ERROR: unknown MCP tool "${fullName}" — run /mcp to list servers and tools.` };
|
|
1123
|
+
}
|
|
1124
|
+
// Text shorthand (`/mcp exec <tool> free text`): bind to the tool's first
|
|
1125
|
+
// required string property from its inputSchema, so users don't have to
|
|
1126
|
+
// hand-write JSON for the common one-argument case.
|
|
1127
|
+
if ("__text" in args && typeof args.__text === "string") {
|
|
1128
|
+
const props = (reg.def.inputSchema?.properties ?? {});
|
|
1129
|
+
const required = (reg.def.inputSchema?.required ?? []);
|
|
1130
|
+
const target = required.find((k) => props[k]?.type === "string") ??
|
|
1131
|
+
Object.keys(props).find((k) => props[k]?.type === "string");
|
|
1132
|
+
if (!target) {
|
|
1133
|
+
return { ok: false, output: `ERROR: "${fullName}" has no string parameter — pass JSON args instead.` };
|
|
1134
|
+
}
|
|
1135
|
+
args = { [target]: args.__text };
|
|
1136
|
+
}
|
|
1137
|
+
try {
|
|
1138
|
+
return await reg.conn.callTool(reg.toolName, args);
|
|
1139
|
+
}
|
|
1140
|
+
catch (err) {
|
|
1141
|
+
return { ok: false, output: `ERROR: ${err.message}` };
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
/** Ask user to approve an MCP tool call; "always" persists a project-scoped rule. */
|
|
1145
|
+
async confirmMcpTool(fullName) {
|
|
1146
|
+
return this.permissions.confirmMcpTool(fullName, () => {
|
|
1147
|
+
try {
|
|
1148
|
+
saveApproval(this.config.workdir, { action: fullName, resource: "*", effect: "allow" });
|
|
1149
|
+
}
|
|
1150
|
+
catch { /* persistence failure must not block the run */ }
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
/** Switch permission mode at runtime (/permission) — rebuilds the checker. */ setPermissionMode(mode) {
|
|
1154
|
+
if (mode === this.config.permissionMode)
|
|
1155
|
+
return;
|
|
1156
|
+
this.config = { ...this.config, permissionMode: mode };
|
|
1157
|
+
const permLists = loadPermissionLists(this.config.workdir);
|
|
1158
|
+
this.permissions = new PermissionChecker({
|
|
1159
|
+
mode,
|
|
1160
|
+
allowCommands: permLists.allowCommands,
|
|
1161
|
+
denyCommands: permLists.denyCommands,
|
|
1162
|
+
nonInteractive: this.config.yes,
|
|
1163
|
+
rules: this.config.permissionRules,
|
|
1164
|
+
savedRules: loadSavedApprovals(this.config.workdir),
|
|
1165
|
+
policyRules: loadPolicyRules(this.config.workdir),
|
|
1166
|
+
});
|
|
1167
|
+
this.ctx.permissionMode = mode;
|
|
1168
|
+
ui.info(`permission mode → ${mode}`);
|
|
1169
|
+
}
|
|
1170
|
+
/**
|
|
1171
|
+
* T-04: runtime model hot-swap. Same provider → setModel on the live
|
|
1172
|
+
* client (zero rebuild, mid-session). Different provider (provider/model
|
|
1173
|
+
* syntax) → rebuild the runtime from the registry. Returns a notice line.
|
|
1174
|
+
*/
|
|
1175
|
+
setModel(model) {
|
|
1176
|
+
const [prov, id] = splitProviderModel(model);
|
|
1177
|
+
if (!prov || prov === this.config.provider) {
|
|
1178
|
+
const m = id ?? model;
|
|
1179
|
+
this.client.setModel(m);
|
|
1180
|
+
this.config = { ...this.config, model: m };
|
|
1181
|
+
return `model → ${m} (${this.config.provider})`;
|
|
1182
|
+
}
|
|
1183
|
+
// Cross-provider: rebuild the client runtime.
|
|
1184
|
+
const resolved = resolveProvider(prov, parseCustomProviders(loadRcFile(this.config.workdir).providers));
|
|
1185
|
+
if ("error" in resolved)
|
|
1186
|
+
return `ERROR: ${resolved.error}`;
|
|
1187
|
+
this.client = createRuntime(resolved.def, {
|
|
1188
|
+
baseUrl: this.config.baseUrl,
|
|
1189
|
+
apiKey: this.config.apiKey,
|
|
1190
|
+
model: id,
|
|
1191
|
+
maxTokens: this.config.maxTokens,
|
|
1192
|
+
temperature: this.config.temperature,
|
|
1193
|
+
enableCache: resolved.def.supportCache,
|
|
1194
|
+
});
|
|
1195
|
+
this.config = { ...this.config, provider: prov, model: id, providerDef: resolved.def };
|
|
1196
|
+
return `model → ${id} (provider ${prov})`;
|
|
1197
|
+
}
|
|
1198
|
+
finish(success, steps, buildCmd, summary, chatOnly = false) {
|
|
1199
|
+
this.events.append({ type: "finish", success, summary: summary.slice(0, 2000) });
|
|
561
1200
|
if (success) {
|
|
562
|
-
|
|
1201
|
+
if (chatOnly) {
|
|
1202
|
+
ui.success(`Done in ${steps} step(s).`);
|
|
1203
|
+
}
|
|
1204
|
+
else {
|
|
1205
|
+
ui.success(`Task complete in ${steps} step(s). Build passed: ${buildCmd}`);
|
|
1206
|
+
}
|
|
563
1207
|
}
|
|
564
1208
|
else {
|
|
565
1209
|
ui.error(`Task NOT complete. ${summary}`);
|