@kavienw/deepseek-cli 0.1.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/README.md +470 -0
- package/dist/agent.js +864 -0
- package/dist/agent.js.map +1 -0
- package/dist/attachments.js +54 -0
- package/dist/attachments.js.map +1 -0
- package/dist/btw.js +52 -0
- package/dist/btw.js.map +1 -0
- package/dist/client.js +88 -0
- package/dist/client.js.map +1 -0
- package/dist/commands.js +922 -0
- package/dist/commands.js.map +1 -0
- package/dist/config.js +98 -0
- package/dist/config.js.map +1 -0
- package/dist/hooks.js +90 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.js +149 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp.js +408 -0
- package/dist/mcp.js.map +1 -0
- package/dist/permissions.js +30 -0
- package/dist/permissions.js.map +1 -0
- package/dist/plugins.js +341 -0
- package/dist/plugins.js.map +1 -0
- package/dist/project.js +114 -0
- package/dist/project.js.map +1 -0
- package/dist/session.js +57 -0
- package/dist/session.js.map +1 -0
- package/dist/skills.js +147 -0
- package/dist/skills.js.map +1 -0
- package/dist/todo.js +67 -0
- package/dist/todo.js.map +1 -0
- package/dist/tools/bash.js +61 -0
- package/dist/tools/bash.js.map +1 -0
- package/dist/tools/diff.js +26 -0
- package/dist/tools/diff.js.map +1 -0
- package/dist/tools/edit.js +73 -0
- package/dist/tools/edit.js.map +1 -0
- package/dist/tools/glob.js +47 -0
- package/dist/tools/glob.js.map +1 -0
- package/dist/tools/grep.js +133 -0
- package/dist/tools/grep.js.map +1 -0
- package/dist/tools/index.js +60 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/read.js +62 -0
- package/dist/tools/read.js.map +1 -0
- package/dist/tools/setThinking.js +46 -0
- package/dist/tools/setThinking.js.map +1 -0
- package/dist/tools/task.js +40 -0
- package/dist/tools/task.js.map +1 -0
- package/dist/tools/todo.js +73 -0
- package/dist/tools/todo.js.map +1 -0
- package/dist/tools/types.js +2 -0
- package/dist/tools/types.js.map +1 -0
- package/dist/tools/webFetch.js +64 -0
- package/dist/tools/webFetch.js.map +1 -0
- package/dist/tools/webSearch.js +200 -0
- package/dist/tools/webSearch.js.map +1 -0
- package/dist/tools/write.js +46 -0
- package/dist/tools/write.js.map +1 -0
- package/dist/ui/render.js +248 -0
- package/dist/ui/render.js.map +1 -0
- package/dist/ui/repl.js +429 -0
- package/dist/ui/repl.js.map +1 -0
- package/package.json +48 -0
package/dist/agent.js
ADDED
|
@@ -0,0 +1,864 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import readline from "node:readline";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { DeepSeekClient } from "./client.js";
|
|
6
|
+
import { getTool, toOpenAITools } from "./tools/index.js";
|
|
7
|
+
import { findModel, saveConfig } from "./config.js";
|
|
8
|
+
import { formatProjectMemoryForPrompt } from "./project.js";
|
|
9
|
+
import { ui, chalk, Spinner } from "./ui/render.js";
|
|
10
|
+
import { TodoStore } from "./todo.js";
|
|
11
|
+
import { runHooks } from "./hooks.js";
|
|
12
|
+
const MAX_TOOL_ITERATIONS = 25;
|
|
13
|
+
const FILE_MENTION_RE = /(^|\s)@([^\s@]+)/g;
|
|
14
|
+
const MAX_MENTION_CHARS = 50_000;
|
|
15
|
+
const IMAGE_EXT = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"]);
|
|
16
|
+
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
17
|
+
const MAX_CHECKPOINTS = 50;
|
|
18
|
+
/** Expand `@path` mentions in user input by appending the referenced file contents. */
|
|
19
|
+
function expandFileMentions(input, cwd) {
|
|
20
|
+
const seen = new Set();
|
|
21
|
+
const blocks = [];
|
|
22
|
+
let match;
|
|
23
|
+
FILE_MENTION_RE.lastIndex = 0;
|
|
24
|
+
while ((match = FILE_MENTION_RE.exec(input)) !== null) {
|
|
25
|
+
const rel = match[2];
|
|
26
|
+
if (seen.has(rel))
|
|
27
|
+
continue;
|
|
28
|
+
const abs = path.isAbsolute(rel) ? rel : path.join(cwd, rel);
|
|
29
|
+
try {
|
|
30
|
+
if (!fs.statSync(abs).isFile())
|
|
31
|
+
continue;
|
|
32
|
+
let content = fs.readFileSync(abs, "utf8");
|
|
33
|
+
if (content.length > MAX_MENTION_CHARS)
|
|
34
|
+
content = content.slice(0, MAX_MENTION_CHARS) + "\n…[truncated]";
|
|
35
|
+
seen.add(rel);
|
|
36
|
+
blocks.push(`### @${rel}\n\`\`\`\n${content}\n\`\`\``);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Not a readable file — leave the @token in place as plain text.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (blocks.length === 0)
|
|
43
|
+
return input;
|
|
44
|
+
return `${input}\n\n--- Referenced files ---\n${blocks.join("\n\n")}`;
|
|
45
|
+
}
|
|
46
|
+
const COLLAPSED_REASONING_LINES = 6;
|
|
47
|
+
const AUTO_COMPRESS_ESTIMATED_TOKEN_LIMIT = 48_000;
|
|
48
|
+
const AUTO_COMPRESS_MESSAGE_LIMIT = 80;
|
|
49
|
+
const COMPRESSION_INPUT_CHAR_LIMIT = 160_000;
|
|
50
|
+
const COMPRESSION_SYSTEM_PROMPT = [
|
|
51
|
+
"You are compressing the context of an agentic coding CLI conversation.",
|
|
52
|
+
"Create a concise but complete continuity summary for the next assistant turn.",
|
|
53
|
+
"Focus on user goals, decisions, constraints, important files, commands/tests run, changes made, unresolved issues, and next steps.",
|
|
54
|
+
"Preserve exact file paths, command names, model names, and behavioral requirements when they matter.",
|
|
55
|
+
"Do not include secrets, API keys, tokens, or credentials. If a secret was discussed, only say that it is configured or must remain private.",
|
|
56
|
+
"Use the conversation's main language. If the user primarily writes Chinese, write the summary in Chinese.",
|
|
57
|
+
].join("\n");
|
|
58
|
+
// DeepSeek pricing per 1M tokens (USD).
|
|
59
|
+
const PRICING = {
|
|
60
|
+
"deepseek-v4-pro": { input: 0.435, output: 0.87 },
|
|
61
|
+
"deepseek-v4-flash": { input: 0.14, output: 0.28 },
|
|
62
|
+
};
|
|
63
|
+
export function estimateCost(model, usage) {
|
|
64
|
+
const price = PRICING[model] ?? PRICING["deepseek-v4-pro"];
|
|
65
|
+
// We can't separate input/output from streaming easily, so use blended average.
|
|
66
|
+
const blended = (price.input + price.output) / 2;
|
|
67
|
+
const cost = (usage.totalTokens / 1_000_000) * blended;
|
|
68
|
+
return `$${cost.toFixed(4)}`;
|
|
69
|
+
}
|
|
70
|
+
function roleOf(message) {
|
|
71
|
+
return String(message.role ?? "unknown");
|
|
72
|
+
}
|
|
73
|
+
function valueToText(value) {
|
|
74
|
+
if (value === null || value === undefined)
|
|
75
|
+
return "";
|
|
76
|
+
if (typeof value === "string")
|
|
77
|
+
return value;
|
|
78
|
+
try {
|
|
79
|
+
return JSON.stringify(value, null, 2);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return String(value);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function messageContentToText(message) {
|
|
86
|
+
const content = message.content;
|
|
87
|
+
if (typeof content === "string")
|
|
88
|
+
return content;
|
|
89
|
+
if (Array.isArray(content)) {
|
|
90
|
+
return content
|
|
91
|
+
.map((part) => {
|
|
92
|
+
const p = part;
|
|
93
|
+
if (p.type === "text")
|
|
94
|
+
return p.text ?? "";
|
|
95
|
+
if (p.type === "image_url")
|
|
96
|
+
return "[image]";
|
|
97
|
+
return "";
|
|
98
|
+
})
|
|
99
|
+
.join(" ");
|
|
100
|
+
}
|
|
101
|
+
return valueToText(content);
|
|
102
|
+
}
|
|
103
|
+
function truncateText(text, maxChars) {
|
|
104
|
+
if (text.length <= maxChars)
|
|
105
|
+
return text;
|
|
106
|
+
const headLength = Math.floor(maxChars * 0.65);
|
|
107
|
+
const tailLength = Math.floor(maxChars * 0.25);
|
|
108
|
+
const omitted = text.length - headLength - tailLength;
|
|
109
|
+
return [
|
|
110
|
+
text.slice(0, headLength),
|
|
111
|
+
`\n...[truncated ${omitted.toLocaleString()} chars]...\n`,
|
|
112
|
+
text.slice(-tailLength),
|
|
113
|
+
].join("");
|
|
114
|
+
}
|
|
115
|
+
function formatToolCalls(message) {
|
|
116
|
+
const toolCalls = message.tool_calls ?? [];
|
|
117
|
+
if (toolCalls.length === 0)
|
|
118
|
+
return "";
|
|
119
|
+
return toolCalls
|
|
120
|
+
.map((call, index) => {
|
|
121
|
+
const args = truncateText(call.function.arguments || "{}", 2_000);
|
|
122
|
+
return `tool_call[${index}]: ${call.function.name}(${args})`;
|
|
123
|
+
})
|
|
124
|
+
.join("\n");
|
|
125
|
+
}
|
|
126
|
+
function formatMessageForCompression(message, index) {
|
|
127
|
+
const role = roleOf(message);
|
|
128
|
+
const toolCallId = valueToText(message.tool_call_id);
|
|
129
|
+
const content = messageContentToText(message) || "(no text content)";
|
|
130
|
+
const toolCalls = formatToolCalls(message);
|
|
131
|
+
const parts = [
|
|
132
|
+
`### ${index + 1}. ${role}`,
|
|
133
|
+
toolCallId ? `tool_call_id: ${toolCallId}` : "",
|
|
134
|
+
truncateText(content, 8_000),
|
|
135
|
+
toolCalls ? `Tool calls:\n${toolCalls}` : "",
|
|
136
|
+
];
|
|
137
|
+
return parts.filter(Boolean).join("\n");
|
|
138
|
+
}
|
|
139
|
+
function buildCompressionInput(contextSummary, messages) {
|
|
140
|
+
const header = contextSummary
|
|
141
|
+
? ["Existing compressed summary:", truncateText(contextSummary, 24_000)].join("\n")
|
|
142
|
+
: "";
|
|
143
|
+
const formatted = messages.map(formatMessageForCompression);
|
|
144
|
+
const selected = [];
|
|
145
|
+
let usedChars = header.length;
|
|
146
|
+
let omittedMessages = 0;
|
|
147
|
+
for (let index = formatted.length - 1; index >= 0; index--) {
|
|
148
|
+
const next = formatted[index];
|
|
149
|
+
if (usedChars + next.length + 2 > COMPRESSION_INPUT_CHAR_LIMIT) {
|
|
150
|
+
omittedMessages = index + 1;
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
selected.unshift(next);
|
|
154
|
+
usedChars += next.length + 2;
|
|
155
|
+
}
|
|
156
|
+
const omittedNote = omittedMessages > 0
|
|
157
|
+
? `[${omittedMessages} older messages omitted because the transcript was too large. Preserve continuity from the existing compressed summary when available.]`
|
|
158
|
+
: "";
|
|
159
|
+
return [
|
|
160
|
+
header,
|
|
161
|
+
omittedNote,
|
|
162
|
+
"Conversation transcript to compress:",
|
|
163
|
+
...selected,
|
|
164
|
+
]
|
|
165
|
+
.filter(Boolean)
|
|
166
|
+
.join("\n\n");
|
|
167
|
+
}
|
|
168
|
+
function estimateTokensFromText(text) {
|
|
169
|
+
return Math.ceil(text.length / 4);
|
|
170
|
+
}
|
|
171
|
+
function renderHookRuns(results) {
|
|
172
|
+
return results
|
|
173
|
+
.map((result) => {
|
|
174
|
+
const state = result.ok ? "ok" : result.blocking ? "blocked" : "failed";
|
|
175
|
+
const output = result.output ? `\n${truncateText(result.output, 4_000)}` : "";
|
|
176
|
+
return `hook ${state}: ${result.command}${output}`;
|
|
177
|
+
})
|
|
178
|
+
.join("\n");
|
|
179
|
+
}
|
|
180
|
+
function getGitContext(cwd) {
|
|
181
|
+
try {
|
|
182
|
+
const root = execSync("git rev-parse --show-toplevel", {
|
|
183
|
+
cwd,
|
|
184
|
+
encoding: "utf8",
|
|
185
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
186
|
+
}).trim();
|
|
187
|
+
const branch = execSync("git branch --show-current", {
|
|
188
|
+
cwd,
|
|
189
|
+
encoding: "utf8",
|
|
190
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
191
|
+
}).trim();
|
|
192
|
+
const status = execSync("git status --short", {
|
|
193
|
+
cwd,
|
|
194
|
+
encoding: "utf8",
|
|
195
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
196
|
+
}).trim();
|
|
197
|
+
const lines = [`Git repository: ${root}`, `Current branch: ${branch}`];
|
|
198
|
+
if (status) {
|
|
199
|
+
const statusLines = status.split("\n").slice(0, 30);
|
|
200
|
+
lines.push(`Git status (first 30 lines):\n${statusLines.join("\n")}`);
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
lines.push("Working tree is clean.");
|
|
204
|
+
}
|
|
205
|
+
return lines.join("\n");
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function systemPrompt(cwd, contextSummary = null) {
|
|
212
|
+
const projectMemory = formatProjectMemoryForPrompt(cwd);
|
|
213
|
+
const gitContext = getGitContext(cwd);
|
|
214
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
215
|
+
const platform = `${process.platform} (${process.arch})`;
|
|
216
|
+
const shell = process.env.SHELL || "unknown";
|
|
217
|
+
return [
|
|
218
|
+
"You are DeepSeek Code Agent, a local coding assistant running in the user's current project directory.",
|
|
219
|
+
"",
|
|
220
|
+
"Use the provided tools to inspect files, search code, edit files, and run shell commands.",
|
|
221
|
+
"Before editing, inspect the relevant files. Keep changes focused and explain what changed.",
|
|
222
|
+
"Prefer search_text over broad file reads. Never assume file contents you have not read.",
|
|
223
|
+
"Use todo_write to track multi-step tasks: create a short list before substantial work, keep exactly one item in_progress, and mark items completed as you finish them.",
|
|
224
|
+
"",
|
|
225
|
+
"The local CLI will ask the user before running shell commands or writing files unless they start it with --yes.",
|
|
226
|
+
"If the user asks to show or hide your reasoning/thinking (e.g. \"关闭思考\"/\"显示思考\"/\"hide your thinking\"), call the set_thinking tool.",
|
|
227
|
+
"",
|
|
228
|
+
`Working directory: ${cwd}`,
|
|
229
|
+
`Today's date: ${today}`,
|
|
230
|
+
`Platform: ${platform}`,
|
|
231
|
+
`Shell: ${shell}`,
|
|
232
|
+
gitContext ? `\n${gitContext}` : "",
|
|
233
|
+
projectMemory ? `\n${projectMemory}` : "",
|
|
234
|
+
contextSummary
|
|
235
|
+
? `\nCompressed conversation context (authoritative summary of earlier turns):\n${contextSummary}`
|
|
236
|
+
: "",
|
|
237
|
+
]
|
|
238
|
+
.filter((line) => typeof line === "string" && line.length > 0)
|
|
239
|
+
.join("\n");
|
|
240
|
+
}
|
|
241
|
+
export class Agent {
|
|
242
|
+
config;
|
|
243
|
+
permissions;
|
|
244
|
+
ctx;
|
|
245
|
+
messages;
|
|
246
|
+
client;
|
|
247
|
+
contextSummary = null;
|
|
248
|
+
compressedAt = null;
|
|
249
|
+
todoStore = new TodoStore();
|
|
250
|
+
subagentDepth = 0;
|
|
251
|
+
checkpoints = [];
|
|
252
|
+
checkpointSeq = 0;
|
|
253
|
+
suppressCheckpoint = false;
|
|
254
|
+
totalUsage;
|
|
255
|
+
constructor(config, permissions, ctx) {
|
|
256
|
+
this.config = config;
|
|
257
|
+
this.permissions = permissions;
|
|
258
|
+
this.ctx = ctx;
|
|
259
|
+
this.client = new DeepSeekClient(config);
|
|
260
|
+
// Let tools (e.g. set_thinking) change session settings and persist them.
|
|
261
|
+
const toolCtx = { ...ctx };
|
|
262
|
+
toolCtx.setThinkingMode = (mode) => {
|
|
263
|
+
this.config.thinkingMode = mode;
|
|
264
|
+
try {
|
|
265
|
+
saveConfig(this.config);
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
// Persisting is best-effort; the in-session change still applies.
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
toolCtx.getThinkingMode = () => this.config.thinkingMode ?? "off";
|
|
272
|
+
toolCtx.todoStore = this.todoStore;
|
|
273
|
+
toolCtx.runSubagent = (prompt, opts) => this.runSubagent(prompt, opts ?? {});
|
|
274
|
+
toolCtx.recordFileBackup = (absPath, previous) => this.recordFileBackup(absPath, previous);
|
|
275
|
+
this.ctx = toolCtx;
|
|
276
|
+
this.messages = [];
|
|
277
|
+
this.refreshSystemPrompt();
|
|
278
|
+
this.totalUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
|
279
|
+
}
|
|
280
|
+
setModel(model) {
|
|
281
|
+
this.config.model = model;
|
|
282
|
+
}
|
|
283
|
+
getModel() {
|
|
284
|
+
return this.config.model;
|
|
285
|
+
}
|
|
286
|
+
setThinkingMode(mode) {
|
|
287
|
+
this.config.thinkingMode = mode;
|
|
288
|
+
}
|
|
289
|
+
getThinkingMode() {
|
|
290
|
+
return this.config.thinkingMode ?? "off";
|
|
291
|
+
}
|
|
292
|
+
getCwd() {
|
|
293
|
+
return this.ctx.cwd;
|
|
294
|
+
}
|
|
295
|
+
async listModels() {
|
|
296
|
+
return this.client.listModels();
|
|
297
|
+
}
|
|
298
|
+
refreshSystemPrompt() {
|
|
299
|
+
const nonSystemMessages = this.messages.filter((message) => roleOf(message) !== "system");
|
|
300
|
+
this.messages = [
|
|
301
|
+
{ role: "system", content: systemPrompt(this.ctx.cwd, this.contextSummary) },
|
|
302
|
+
...nonSystemMessages,
|
|
303
|
+
];
|
|
304
|
+
}
|
|
305
|
+
/** Rebuild the system prompt in place (e.g. after DEEPSEEK.md changes), keeping history. */
|
|
306
|
+
reloadProjectContext() {
|
|
307
|
+
this.refreshSystemPrompt();
|
|
308
|
+
}
|
|
309
|
+
/** Drop conversation history and compressed context, but keep the system prompt. */
|
|
310
|
+
reset() {
|
|
311
|
+
this.contextSummary = null;
|
|
312
|
+
this.compressedAt = null;
|
|
313
|
+
this.messages = [];
|
|
314
|
+
this.checkpoints = [];
|
|
315
|
+
this.refreshSystemPrompt();
|
|
316
|
+
this.totalUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
|
317
|
+
}
|
|
318
|
+
messageCount() {
|
|
319
|
+
return this.messages.filter((m) => m.role !== "system").length;
|
|
320
|
+
}
|
|
321
|
+
/** Export current session for persistence. */
|
|
322
|
+
getSession() {
|
|
323
|
+
return {
|
|
324
|
+
messages: [...this.messages],
|
|
325
|
+
totalUsage: { ...this.totalUsage },
|
|
326
|
+
contextSummary: this.contextSummary,
|
|
327
|
+
compressedAt: this.compressedAt,
|
|
328
|
+
todos: this.todoStore.list(),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
/** Restore a previously saved session. */
|
|
332
|
+
restoreSession(data) {
|
|
333
|
+
// Keep the system prompt fresh but restore the rest.
|
|
334
|
+
this.contextSummary = data.contextSummary ?? null;
|
|
335
|
+
this.compressedAt = data.compressedAt ?? null;
|
|
336
|
+
this.todoStore.replace(Array.isArray(data.todos) ? data.todos : []);
|
|
337
|
+
this.messages = [
|
|
338
|
+
{ role: "system", content: systemPrompt(this.ctx.cwd, this.contextSummary) },
|
|
339
|
+
...data.messages.filter((m) => roleOf(m) !== "system"),
|
|
340
|
+
];
|
|
341
|
+
this.checkpoints = [];
|
|
342
|
+
this.totalUsage = { ...data.totalUsage };
|
|
343
|
+
}
|
|
344
|
+
getContextSummary() {
|
|
345
|
+
return this.contextSummary;
|
|
346
|
+
}
|
|
347
|
+
getTodos() {
|
|
348
|
+
return this.todoStore.formatForModel();
|
|
349
|
+
}
|
|
350
|
+
estimatedContextTokens(extraUserInput = "") {
|
|
351
|
+
const serialized = this.messages
|
|
352
|
+
.map((message, index) => formatMessageForCompression(message, index))
|
|
353
|
+
.join("\n\n");
|
|
354
|
+
return estimateTokensFromText(serialized + extraUserInput);
|
|
355
|
+
}
|
|
356
|
+
shouldAutoCompress(extraUserInput) {
|
|
357
|
+
if (this.messageCount() === 0)
|
|
358
|
+
return false;
|
|
359
|
+
return (this.messageCount() >= AUTO_COMPRESS_MESSAGE_LIMIT ||
|
|
360
|
+
this.estimatedContextTokens(extraUserInput) >= AUTO_COMPRESS_ESTIMATED_TOKEN_LIMIT);
|
|
361
|
+
}
|
|
362
|
+
async compressContext(reason = "manual") {
|
|
363
|
+
const sourceMessages = this.messages.filter((message) => roleOf(message) !== "system");
|
|
364
|
+
const messagesBefore = sourceMessages.length;
|
|
365
|
+
const estimatedTokensBefore = this.estimatedContextTokens();
|
|
366
|
+
if (sourceMessages.length === 0) {
|
|
367
|
+
return {
|
|
368
|
+
compressed: false,
|
|
369
|
+
reason,
|
|
370
|
+
messagesBefore,
|
|
371
|
+
messagesAfter: 0,
|
|
372
|
+
estimatedTokensBefore,
|
|
373
|
+
estimatedTokensAfter: estimatedTokensBefore,
|
|
374
|
+
summary: this.contextSummary ?? "",
|
|
375
|
+
summaryChars: this.contextSummary?.length ?? 0,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
const compressionInput = buildCompressionInput(this.contextSummary, sourceMessages);
|
|
379
|
+
const promptMessages = [
|
|
380
|
+
{ role: "system", content: COMPRESSION_SYSTEM_PROMPT },
|
|
381
|
+
{
|
|
382
|
+
role: "user",
|
|
383
|
+
content: [
|
|
384
|
+
"Compress this DeepSeek CLI session into a durable continuation summary.",
|
|
385
|
+
"The next assistant turn will rely on this summary instead of the full transcript.",
|
|
386
|
+
"Return only the summary, with clear bullets or short sections.",
|
|
387
|
+
"",
|
|
388
|
+
compressionInput,
|
|
389
|
+
].join("\n"),
|
|
390
|
+
},
|
|
391
|
+
];
|
|
392
|
+
const spinner = new Spinner(reason === "auto" ? "auto-compressing context…" : "compressing context…");
|
|
393
|
+
spinner.start();
|
|
394
|
+
try {
|
|
395
|
+
const turn = await this.client.stream(promptMessages, [], this.config.model);
|
|
396
|
+
if (turn.usage) {
|
|
397
|
+
this.totalUsage.promptTokens += turn.usage.promptTokens;
|
|
398
|
+
this.totalUsage.completionTokens += turn.usage.completionTokens;
|
|
399
|
+
this.totalUsage.totalTokens += turn.usage.totalTokens;
|
|
400
|
+
}
|
|
401
|
+
const summary = turn.content.trim();
|
|
402
|
+
if (!summary)
|
|
403
|
+
throw new Error("model returned an empty compression summary");
|
|
404
|
+
this.contextSummary = summary;
|
|
405
|
+
this.compressedAt = new Date().toISOString();
|
|
406
|
+
this.messages = [];
|
|
407
|
+
this.refreshSystemPrompt();
|
|
408
|
+
return {
|
|
409
|
+
compressed: true,
|
|
410
|
+
reason,
|
|
411
|
+
messagesBefore,
|
|
412
|
+
messagesAfter: this.messageCount(),
|
|
413
|
+
estimatedTokensBefore,
|
|
414
|
+
estimatedTokensAfter: this.estimatedContextTokens(),
|
|
415
|
+
summary,
|
|
416
|
+
summaryChars: summary.length,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
finally {
|
|
420
|
+
spinner.stop();
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
listenForGenerationAbort(controller) {
|
|
424
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
425
|
+
return () => undefined;
|
|
426
|
+
const input = process.stdin;
|
|
427
|
+
const wasRaw = Boolean(input.isRaw);
|
|
428
|
+
const onKeypress = (_str, key = {}) => {
|
|
429
|
+
if (key.name === "escape" || (key.ctrl && key.name === "c")) {
|
|
430
|
+
controller.abort();
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
readline.emitKeypressEvents(input);
|
|
434
|
+
input.setRawMode?.(true);
|
|
435
|
+
input.on("keypress", onKeypress);
|
|
436
|
+
input.resume();
|
|
437
|
+
return () => {
|
|
438
|
+
input.off("keypress", onKeypress);
|
|
439
|
+
if (!wasRaw)
|
|
440
|
+
input.setRawMode?.(false);
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
async runIsolated(userInput) {
|
|
444
|
+
const savedMessages = this.messages;
|
|
445
|
+
this.messages = [];
|
|
446
|
+
this.suppressCheckpoint = true;
|
|
447
|
+
this.refreshSystemPrompt();
|
|
448
|
+
try {
|
|
449
|
+
await this.run(userInput);
|
|
450
|
+
}
|
|
451
|
+
finally {
|
|
452
|
+
this.suppressCheckpoint = false;
|
|
453
|
+
this.messages = savedMessages;
|
|
454
|
+
this.refreshSystemPrompt();
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
/** Build a user message content, attaching files (text inline, images as image_url). */
|
|
458
|
+
buildUserContent(text, attachments) {
|
|
459
|
+
let textContent = text;
|
|
460
|
+
const imageParts = [];
|
|
461
|
+
const visionSupported = findModel(this.config.model)?.supportsVision === true ||
|
|
462
|
+
["1", "true", "on", "yes"].includes((process.env.DEEPSEEK_VISION ?? "").toLowerCase());
|
|
463
|
+
for (const file of attachments) {
|
|
464
|
+
const ext = path.extname(file).toLowerCase();
|
|
465
|
+
try {
|
|
466
|
+
if (IMAGE_EXT.has(ext)) {
|
|
467
|
+
// DeepSeek chat models are text-only; only send images to vision models.
|
|
468
|
+
if (!visionSupported) {
|
|
469
|
+
const kb = (() => { try {
|
|
470
|
+
return Math.round(fs.statSync(file).size / 1024);
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
return 0;
|
|
474
|
+
} })();
|
|
475
|
+
ui.warn(`Image ${path.basename(file)} not sent: model '${this.config.model}' has no vision support.`);
|
|
476
|
+
textContent += `\n\n[Attached image ${path.basename(file)} (${kb} KB) was not sent because the current model cannot view images.]`;
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
const size = fs.statSync(file).size;
|
|
480
|
+
if (size > MAX_IMAGE_BYTES) {
|
|
481
|
+
textContent += `\n\n[image ${path.basename(file)} skipped: ${(size / 1e6).toFixed(1)}MB exceeds 5MB]`;
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
const b64 = fs.readFileSync(file).toString("base64");
|
|
485
|
+
const mime = ext === ".jpg" ? "image/jpeg" : ext === ".svg" ? "image/svg+xml" : `image/${ext.slice(1)}`;
|
|
486
|
+
imageParts.push({ type: "image_url", image_url: { url: `data:${mime};base64,${b64}` } });
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
let body = fs.readFileSync(file, "utf8");
|
|
490
|
+
if (body.length > MAX_MENTION_CHARS)
|
|
491
|
+
body = body.slice(0, MAX_MENTION_CHARS) + "\n…[truncated]";
|
|
492
|
+
textContent += `\n\n--- Attached file: ${file} ---\n\`\`\`\n${body}\n\`\`\``;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
catch (err) {
|
|
496
|
+
textContent += `\n\n[could not read attachment ${path.basename(file)}: ${err.message}]`;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (imageParts.length === 0) {
|
|
500
|
+
return { content: textContent, estimate: textContent };
|
|
501
|
+
}
|
|
502
|
+
const parts = [
|
|
503
|
+
{ type: "text", text: textContent || "(see attached image)" },
|
|
504
|
+
...imageParts,
|
|
505
|
+
];
|
|
506
|
+
return { content: parts, estimate: `${textContent} [${imageParts.length} image(s)]` };
|
|
507
|
+
}
|
|
508
|
+
async run(userInput, options = {}) {
|
|
509
|
+
// Snapshot before a genuine top-level turn (not sub-agents or isolated runs).
|
|
510
|
+
if (this.subagentDepth === 0 && !this.suppressCheckpoint) {
|
|
511
|
+
this.createCheckpoint(userInput);
|
|
512
|
+
}
|
|
513
|
+
if (!this.runLifecycleHooks("userPromptSubmit", { prompt: userInput })) {
|
|
514
|
+
ui.warn("Prompt blocked by a userPromptSubmit hook.");
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const text = expandFileMentions(userInput, this.ctx.cwd);
|
|
518
|
+
const { content, estimate } = this.buildUserContent(text, options.attachments ?? []);
|
|
519
|
+
if (this.shouldAutoCompress(estimate)) {
|
|
520
|
+
ui.warn(`Context is large (~${this.estimatedContextTokens(estimate).toLocaleString()} estimated tokens); compressing before continuing.`);
|
|
521
|
+
try {
|
|
522
|
+
const result = await this.compressContext("auto");
|
|
523
|
+
if (result.compressed) {
|
|
524
|
+
ui.success(`Context compressed: ${result.messagesBefore} messages -> ${result.summaryChars.toLocaleString()} chars summary.`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
catch (err) {
|
|
528
|
+
ui.warn(`Auto-compress failed: ${err.message}`);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
this.messages.push({ role: "user", content });
|
|
532
|
+
const supportsTools = findModel(this.config.model)?.supportsTools !== false;
|
|
533
|
+
const tools = supportsTools ? toOpenAITools() : [];
|
|
534
|
+
for (let iter = 0; iter < MAX_TOOL_ITERATIONS; iter++) {
|
|
535
|
+
let textStarted = false;
|
|
536
|
+
let reasoningStarted = false;
|
|
537
|
+
const thinkingMode = this.config.thinkingMode ?? "off";
|
|
538
|
+
let reasoningLines = 1;
|
|
539
|
+
let reasoningCut = false;
|
|
540
|
+
const spinner = new Spinner("thinking…");
|
|
541
|
+
spinner.start();
|
|
542
|
+
// Reasoning is always captured for the API contract (see client.stream);
|
|
543
|
+
// these callbacks only control how/whether it is displayed.
|
|
544
|
+
const printReasoning = (delta) => {
|
|
545
|
+
if (!reasoningStarted) {
|
|
546
|
+
spinner.stop();
|
|
547
|
+
process.stdout.write(chalk.dim.italic("\n [thinking] "));
|
|
548
|
+
reasoningStarted = true;
|
|
549
|
+
}
|
|
550
|
+
if (thinkingMode === "full") {
|
|
551
|
+
process.stdout.write(chalk.dim.italic(delta.replace(/\n/g, "\n ")));
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
// collapsed: show only the first few lines, then stop printing the rest.
|
|
555
|
+
if (reasoningCut)
|
|
556
|
+
return;
|
|
557
|
+
const segments = delta.split("\n");
|
|
558
|
+
for (let i = 0; i < segments.length; i++) {
|
|
559
|
+
if (i > 0) {
|
|
560
|
+
reasoningLines++;
|
|
561
|
+
if (reasoningLines > COLLAPSED_REASONING_LINES) {
|
|
562
|
+
process.stdout.write(chalk.dim.italic(" …"));
|
|
563
|
+
reasoningCut = true;
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
process.stdout.write("\n ");
|
|
567
|
+
}
|
|
568
|
+
if (segments[i])
|
|
569
|
+
process.stdout.write(chalk.dim.italic(segments[i]));
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
const abortController = new AbortController();
|
|
573
|
+
const stopAbortListener = this.listenForGenerationAbort(abortController);
|
|
574
|
+
let turn;
|
|
575
|
+
try {
|
|
576
|
+
turn = await this.client.stream(this.messages, tools, this.config.model, {
|
|
577
|
+
onReasoning: thinkingMode === "off" ? undefined : printReasoning,
|
|
578
|
+
onText: (delta) => {
|
|
579
|
+
if (!textStarted) {
|
|
580
|
+
spinner.stop();
|
|
581
|
+
if (reasoningStarted)
|
|
582
|
+
process.stdout.write("\n");
|
|
583
|
+
ui.assistantLabel();
|
|
584
|
+
textStarted = true;
|
|
585
|
+
}
|
|
586
|
+
process.stdout.write(delta);
|
|
587
|
+
},
|
|
588
|
+
}, { signal: abortController.signal });
|
|
589
|
+
}
|
|
590
|
+
catch (err) {
|
|
591
|
+
spinner.stop();
|
|
592
|
+
stopAbortListener();
|
|
593
|
+
if (abortController.signal.aborted) {
|
|
594
|
+
this.stripReasoningContent();
|
|
595
|
+
ui.warn("\nInterrupted.");
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
throw err;
|
|
599
|
+
}
|
|
600
|
+
finally {
|
|
601
|
+
stopAbortListener();
|
|
602
|
+
}
|
|
603
|
+
spinner.stop();
|
|
604
|
+
if (textStarted)
|
|
605
|
+
process.stdout.write("\n");
|
|
606
|
+
// Track usage if available.
|
|
607
|
+
if (turn.usage) {
|
|
608
|
+
this.totalUsage.promptTokens += turn.usage.promptTokens;
|
|
609
|
+
this.totalUsage.completionTokens += turn.usage.completionTokens;
|
|
610
|
+
this.totalUsage.totalTokens += turn.usage.totalTokens;
|
|
611
|
+
}
|
|
612
|
+
// Record the assistant message (content + any tool calls). In DeepSeek
|
|
613
|
+
// "thinking" mode, the reasoning_content that produced a tool call MUST be
|
|
614
|
+
// sent back together with the tool_calls or the next request fails with
|
|
615
|
+
// 400 ("reasoning_content ... must be passed back"). We attach it here and
|
|
616
|
+
// strip it once the turn concludes (see below) so stale reasoning is never
|
|
617
|
+
// resent on later user turns — the other half of DeepSeek's contract.
|
|
618
|
+
const hasContent = typeof turn.content === "string" && turn.content.length > 0;
|
|
619
|
+
const hasToolCalls = turn.toolCalls.length > 0;
|
|
620
|
+
const assistantMsg = {
|
|
621
|
+
role: "assistant",
|
|
622
|
+
content: hasContent ? turn.content : null,
|
|
623
|
+
};
|
|
624
|
+
if (hasToolCalls) {
|
|
625
|
+
assistantMsg.tool_calls = turn.toolCalls;
|
|
626
|
+
if (turn.reasoning) {
|
|
627
|
+
assistantMsg.reasoning_content = turn.reasoning;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
// DeepSeek rejects assistant messages that have neither content nor
|
|
631
|
+
// tool_calls. When both are missing (e.g. all streamed tool_calls were
|
|
632
|
+
// stripped because they lacked a function name), fall back to a sensible
|
|
633
|
+
// empty string so the message stays valid for future turns.
|
|
634
|
+
if (!assistantMsg.content && !assistantMsg.tool_calls) {
|
|
635
|
+
assistantMsg.content = "";
|
|
636
|
+
}
|
|
637
|
+
this.messages.push(assistantMsg);
|
|
638
|
+
if (turn.toolCalls.length === 0) {
|
|
639
|
+
// Final answer reached: drop reasoning_content kept for in-flight tool calls.
|
|
640
|
+
this.stripReasoningContent();
|
|
641
|
+
this.runLifecycleHooks("stop", {});
|
|
642
|
+
// Show usage after final answer.
|
|
643
|
+
if (this.totalUsage.totalTokens > 0) {
|
|
644
|
+
ui.usage(this.totalUsage, this.config.model);
|
|
645
|
+
}
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
// Execute each requested tool call and feed results back.
|
|
649
|
+
for (const call of turn.toolCalls) {
|
|
650
|
+
const result = await this.executeToolCall(call);
|
|
651
|
+
this.messages.push({
|
|
652
|
+
role: "tool",
|
|
653
|
+
tool_call_id: call.id,
|
|
654
|
+
content: result,
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
this.stripReasoningContent();
|
|
659
|
+
ui.warn(`\nStopped after ${MAX_TOOL_ITERATIONS} tool iterations.`);
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Remove reasoning_content from stored messages. DeepSeek thinking mode requires
|
|
663
|
+
* reasoning_content to accompany tool_calls during an active tool chain, but it
|
|
664
|
+
* must not be resent on subsequent user turns once the turn has concluded.
|
|
665
|
+
*/
|
|
666
|
+
stripReasoningContent() {
|
|
667
|
+
for (const message of this.messages) {
|
|
668
|
+
const withReasoning = message;
|
|
669
|
+
if (withReasoning.reasoning_content !== undefined) {
|
|
670
|
+
delete withReasoning.reasoning_content;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
/** Run lifecycle hooks (sessionStart / userPromptSubmit / stop). Returns false if blocked. */
|
|
675
|
+
runLifecycleHooks(event, context) {
|
|
676
|
+
const results = runHooks(event, this.ctx.cwd, context);
|
|
677
|
+
for (const hook of results) {
|
|
678
|
+
if (hook.output || !hook.ok)
|
|
679
|
+
ui.toolResult(`hook(${event}): ${hook.command}`, !hook.ok);
|
|
680
|
+
}
|
|
681
|
+
return !results.some((hook) => !hook.ok && hook.blocking);
|
|
682
|
+
}
|
|
683
|
+
/** Fire sessionStart hooks (call once when a session begins). */
|
|
684
|
+
sessionStart() {
|
|
685
|
+
this.runLifecycleHooks("sessionStart", {});
|
|
686
|
+
}
|
|
687
|
+
/** Snapshot the conversation before a turn so it can be rewound later. */
|
|
688
|
+
createCheckpoint(label) {
|
|
689
|
+
this.checkpoints.push({
|
|
690
|
+
id: ++this.checkpointSeq,
|
|
691
|
+
label: label.replace(/\s+/g, " ").trim().slice(0, 60) || "(turn)",
|
|
692
|
+
time: new Date().toISOString(),
|
|
693
|
+
messages: this.messages.slice(),
|
|
694
|
+
contextSummary: this.contextSummary,
|
|
695
|
+
backups: new Map(),
|
|
696
|
+
});
|
|
697
|
+
if (this.checkpoints.length > MAX_CHECKPOINTS)
|
|
698
|
+
this.checkpoints.shift();
|
|
699
|
+
}
|
|
700
|
+
/** Record a file's pre-change content into the most recent checkpoint (first touch only). */
|
|
701
|
+
recordFileBackup(absPath, previous) {
|
|
702
|
+
const active = this.checkpoints[this.checkpoints.length - 1];
|
|
703
|
+
if (active && !active.backups.has(absPath))
|
|
704
|
+
active.backups.set(absPath, previous);
|
|
705
|
+
}
|
|
706
|
+
listCheckpoints() {
|
|
707
|
+
return this.checkpoints.map((c, index) => ({
|
|
708
|
+
index,
|
|
709
|
+
label: c.label,
|
|
710
|
+
time: c.time,
|
|
711
|
+
files: c.backups.size,
|
|
712
|
+
}));
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Rewind to checkpoint `index`: restore files edited since then (deleting ones
|
|
716
|
+
* that were created), reset the conversation to that point, and drop later checkpoints.
|
|
717
|
+
*/
|
|
718
|
+
rewindTo(index) {
|
|
719
|
+
if (index < 0 || index >= this.checkpoints.length) {
|
|
720
|
+
return { ok: false, restoredFiles: 0, messages: this.messageCount() };
|
|
721
|
+
}
|
|
722
|
+
// Restore newest → target so the target's (oldest) backup wins for each path.
|
|
723
|
+
const seen = new Set();
|
|
724
|
+
for (let i = this.checkpoints.length - 1; i >= index; i--) {
|
|
725
|
+
for (const [filePath, content] of this.checkpoints[i].backups) {
|
|
726
|
+
seen.add(filePath);
|
|
727
|
+
try {
|
|
728
|
+
if (content === null) {
|
|
729
|
+
if (fs.existsSync(filePath))
|
|
730
|
+
fs.rmSync(filePath);
|
|
731
|
+
}
|
|
732
|
+
else {
|
|
733
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
734
|
+
fs.writeFileSync(filePath, content, "utf8");
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
catch {
|
|
738
|
+
// Best-effort restore; keep going.
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
const target = this.checkpoints[index];
|
|
743
|
+
this.messages = target.messages.slice();
|
|
744
|
+
this.contextSummary = target.contextSummary;
|
|
745
|
+
this.checkpoints = this.checkpoints.slice(0, index);
|
|
746
|
+
this.stripReasoningContent();
|
|
747
|
+
return { ok: true, restoredFiles: seen.size, messages: this.messageCount() };
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Run an isolated sub-agent to completion and return its final answer.
|
|
751
|
+
* The sub-agent has a fresh conversation, the standard tools (minus `task`,
|
|
752
|
+
* to prevent runaway recursion), and shares the parent's usage accounting.
|
|
753
|
+
*/
|
|
754
|
+
async runSubagent(prompt, opts = {}) {
|
|
755
|
+
if (this.subagentDepth >= 2) {
|
|
756
|
+
return "Sub-agent depth limit reached; refusing to nest further.";
|
|
757
|
+
}
|
|
758
|
+
this.subagentDepth++;
|
|
759
|
+
const messages = [
|
|
760
|
+
{
|
|
761
|
+
role: "system",
|
|
762
|
+
content: systemPrompt(this.ctx.cwd, this.contextSummary) +
|
|
763
|
+
"\n\nYou are a sub-agent handling a delegated, self-contained task. Work autonomously with the available tools and finish with a concise final answer for the calling agent.",
|
|
764
|
+
},
|
|
765
|
+
{ role: "user", content: prompt },
|
|
766
|
+
];
|
|
767
|
+
// Sub-agents never receive the task tool; honor an optional allowlist.
|
|
768
|
+
let tools = toOpenAITools().filter((t) => t.function.name !== "task");
|
|
769
|
+
if (opts.tools && opts.tools.length > 0) {
|
|
770
|
+
const allow = new Set(opts.tools);
|
|
771
|
+
tools = tools.filter((t) => allow.has(t.function.name));
|
|
772
|
+
}
|
|
773
|
+
ui.info(chalk.dim(" ↳ sub-agent started"));
|
|
774
|
+
try {
|
|
775
|
+
let finalText = "";
|
|
776
|
+
for (let iter = 0; iter < MAX_TOOL_ITERATIONS; iter++) {
|
|
777
|
+
const turn = await this.client.stream(messages, tools, this.config.model);
|
|
778
|
+
if (turn.usage) {
|
|
779
|
+
this.totalUsage.promptTokens += turn.usage.promptTokens;
|
|
780
|
+
this.totalUsage.completionTokens += turn.usage.completionTokens;
|
|
781
|
+
this.totalUsage.totalTokens += turn.usage.totalTokens;
|
|
782
|
+
}
|
|
783
|
+
const assistantMsg = {
|
|
784
|
+
role: "assistant",
|
|
785
|
+
content: turn.content || (turn.toolCalls.length > 0 ? null : ""),
|
|
786
|
+
};
|
|
787
|
+
if (turn.toolCalls.length > 0) {
|
|
788
|
+
assistantMsg.tool_calls = turn.toolCalls;
|
|
789
|
+
if (turn.reasoning)
|
|
790
|
+
assistantMsg.reasoning_content = turn.reasoning;
|
|
791
|
+
}
|
|
792
|
+
messages.push(assistantMsg);
|
|
793
|
+
if (turn.toolCalls.length === 0) {
|
|
794
|
+
finalText = turn.content;
|
|
795
|
+
break;
|
|
796
|
+
}
|
|
797
|
+
for (const call of turn.toolCalls) {
|
|
798
|
+
const result = await this.executeToolCall(call);
|
|
799
|
+
messages.push({ role: "tool", tool_call_id: call.id, content: result });
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
ui.info(chalk.dim(" ↳ sub-agent done"));
|
|
803
|
+
return finalText || "(sub-agent produced no final answer)";
|
|
804
|
+
}
|
|
805
|
+
finally {
|
|
806
|
+
this.subagentDepth--;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
async executeToolCall(call) {
|
|
810
|
+
const tool = getTool(call.function.name);
|
|
811
|
+
if (!tool) {
|
|
812
|
+
ui.toolResult(`unknown tool: ${call.function.name}`, true);
|
|
813
|
+
return `Error: tool '${call.function.name}' does not exist.`;
|
|
814
|
+
}
|
|
815
|
+
let args;
|
|
816
|
+
try {
|
|
817
|
+
args = call.function.arguments ? JSON.parse(call.function.arguments) : {};
|
|
818
|
+
}
|
|
819
|
+
catch {
|
|
820
|
+
ui.toolResult(`${tool.name}: invalid arguments`, true);
|
|
821
|
+
return `Error: could not parse arguments as JSON: ${call.function.arguments}`;
|
|
822
|
+
}
|
|
823
|
+
const preview = tool.preview ? tool.preview(args, this.ctx) : tool.name;
|
|
824
|
+
ui.toolCall(preview);
|
|
825
|
+
// Permission gate for side-effecting tools.
|
|
826
|
+
if (tool.needsApproval && !this.permissions.isAllowed(tool.name)) {
|
|
827
|
+
const decision = await this.permissions.request(tool.name, preview);
|
|
828
|
+
if (decision === "deny") {
|
|
829
|
+
ui.toolResult("denied by user", true);
|
|
830
|
+
return "User denied permission to run this tool. Do not retry; ask the user how to proceed or try a different approach.";
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
try {
|
|
834
|
+
const preHooks = runHooks("preToolUse", this.ctx.cwd, { toolName: tool.name, preview });
|
|
835
|
+
for (const hook of preHooks) {
|
|
836
|
+
ui.toolResult(`hook: ${hook.command}`, !hook.ok);
|
|
837
|
+
}
|
|
838
|
+
const blockingHook = preHooks.find((hook) => !hook.ok && hook.blocking);
|
|
839
|
+
if (blockingHook) {
|
|
840
|
+
return `Tool blocked by preToolUse hook.\n${renderHookRuns(preHooks)}`;
|
|
841
|
+
}
|
|
842
|
+
const result = await tool.run(args, this.ctx);
|
|
843
|
+
ui.toolResult(result.summary ?? tool.name, Boolean(result.isError));
|
|
844
|
+
if (result.display)
|
|
845
|
+
ui.diff(result.display);
|
|
846
|
+
const postHooks = runHooks("postToolUse", this.ctx.cwd, {
|
|
847
|
+
toolName: tool.name,
|
|
848
|
+
preview,
|
|
849
|
+
status: result.isError ? "error" : "success",
|
|
850
|
+
});
|
|
851
|
+
for (const hook of postHooks) {
|
|
852
|
+
ui.toolResult(`hook: ${hook.command}`, !hook.ok);
|
|
853
|
+
}
|
|
854
|
+
const hookText = [renderHookRuns(preHooks), renderHookRuns(postHooks)].filter(Boolean).join("\n");
|
|
855
|
+
return hookText ? `${result.content}\n\nHook results:\n${hookText}` : result.content;
|
|
856
|
+
}
|
|
857
|
+
catch (err) {
|
|
858
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
859
|
+
ui.toolResult(`${tool.name}: ${msg}`, true);
|
|
860
|
+
return `Error running ${tool.name}: ${msg}`;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
//# sourceMappingURL=agent.js.map
|