@davait/aipictury 1.9.2
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/LICENSE.md +77 -0
- package/README.md +39 -0
- package/ext/about.js +209 -0
- package/ext/account.js +242 -0
- package/ext/accountUi.js +287 -0
- package/ext/agent.js +1929 -0
- package/ext/browser.js +263 -0
- package/ext/cloud.js +385 -0
- package/ext/context.js +241 -0
- package/ext/deps.js +142 -0
- package/ext/dialogs.js +803 -0
- package/ext/diff.js +150 -0
- package/ext/extension.js +2483 -0
- package/ext/feedback.js +55 -0
- package/ext/files.js +565 -0
- package/ext/firestore.js +131 -0
- package/ext/gpuRun.js +77 -0
- package/ext/gpuServer.js +76 -0
- package/ext/hooks.js +153 -0
- package/ext/log.js +29 -0
- package/ext/lsp.js +122 -0
- package/ext/mcp.js +469 -0
- package/ext/mcpAuth.js +128 -0
- package/ext/media/vendor/firebase-config.json +9 -0
- package/ext/media/vendor/signin.js +1 -0
- package/ext/mediagen.js +174 -0
- package/ext/memory.js +117 -0
- package/ext/notebook.js +82 -0
- package/ext/package.json +1234 -0
- package/ext/paths.js +50 -0
- package/ext/permissions.js +229 -0
- package/ext/plans.js +110 -0
- package/ext/plugins.js +243 -0
- package/ext/prefs.js +157 -0
- package/ext/remote.js +170 -0
- package/ext/review.js +126 -0
- package/ext/sandbox.js +79 -0
- package/ext/scheduler.js +87 -0
- package/ext/sessions.js +228 -0
- package/ext/sessionsView.js +449 -0
- package/ext/shell.js +357 -0
- package/ext/storage.js +80 -0
- package/ext/tasklist.js +112 -0
- package/ext/text.js +31 -0
- package/ext/tools.js +781 -0
- package/ext/usage.js +77 -0
- package/ext/voice.js +135 -0
- package/ext/workbench.js +162 -0
- package/ext/workflow.js +51 -0
- package/ext/worktree.js +83 -0
- package/main.js +758 -0
- package/package.json +43 -0
- package/terminal.js +124 -0
- package/vscode-shim.js +231 -0
package/ext/agent.js
ADDED
|
@@ -0,0 +1,1929 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// The agent loop: ask the server for the model's next move, carry it out, ask again.
|
|
3
|
+
//
|
|
4
|
+
// The server never sees the workspace. It is given the conversation and the shape of
|
|
5
|
+
// the tools, and answers with text and tool calls; everything those calls do happens
|
|
6
|
+
// here, under the approval rules the user set.
|
|
7
|
+
|
|
8
|
+
const os = require('os')
|
|
9
|
+
const vscode = require('vscode')
|
|
10
|
+
const { toolSchema, normalizeQuestions, normalizeFindings, IMPLEMENTATIONS, WRITES, RUNS, resolve } = require('./tools')
|
|
11
|
+
const decoder = new TextDecoder('utf-8', { ignoreBOM: true })
|
|
12
|
+
const files = require('./files')
|
|
13
|
+
const shell = require('./shell')
|
|
14
|
+
const mcp = require('./mcp')
|
|
15
|
+
const context = require('./context')
|
|
16
|
+
const { safeSlice } = require('./text')
|
|
17
|
+
const { runCommand, stopBackground, plain, commandEnv } = shell
|
|
18
|
+
const { summarize, hunks, applyHunks } = require('./diff')
|
|
19
|
+
const mediagen = require('./mediagen')
|
|
20
|
+
const memory = require('./memory')
|
|
21
|
+
const deps = require('./deps')
|
|
22
|
+
const workbench = require('./workbench')
|
|
23
|
+
const permissions = require('./permissions')
|
|
24
|
+
const userHooks = require('./hooks')
|
|
25
|
+
const sandboxing = require('./sandbox')
|
|
26
|
+
const tasklist = require('./tasklist')
|
|
27
|
+
const workflows = require('./workflow')
|
|
28
|
+
const browserTool = require('./browser')
|
|
29
|
+
const scheduler = require('./scheduler')
|
|
30
|
+
const worktrees = require('./worktree')
|
|
31
|
+
const notebooks = require('./notebook')
|
|
32
|
+
const prefs = require('./prefs')
|
|
33
|
+
const log = require('./log')
|
|
34
|
+
const { root: workspaceRoot } = require('./paths')
|
|
35
|
+
|
|
36
|
+
// A local model answers slowly but steadily. What must never happen is waiting for
|
|
37
|
+
// ever: the connection is given a while to open, and then the stream has its own
|
|
38
|
+
// deadline. The server sends a "still waiting" event every 10s while the model is quiet
|
|
39
|
+
// (reading a long prompt, or composing a tool call, which Ollama holds back until it is
|
|
40
|
+
// whole — five silent minutes for a large file). So silence on the wire means the link
|
|
41
|
+
// is gone, never that the model is slow, and it can be short.
|
|
42
|
+
const OPEN_TIMEOUT = 90_000
|
|
43
|
+
const STALL_TIMEOUT = 90_000
|
|
44
|
+
// A server that sends "still waiting" without saying for how long (an older one) is held
|
|
45
|
+
// to this, in seconds.
|
|
46
|
+
const QUIET_LIMIT = 30 * 60
|
|
47
|
+
|
|
48
|
+
/** An error the run can recover from by asking again: a dropped link, a busy GPU. */
|
|
49
|
+
function failure(message, { transient = false, busy = false } = {}) {
|
|
50
|
+
const error = new Error(message)
|
|
51
|
+
error.transient = transient || busy
|
|
52
|
+
error.busy = busy
|
|
53
|
+
return error
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Rejects if the promise has not settled in time, naming what was being waited for. */
|
|
57
|
+
function withDeadline(promise, ms, what) {
|
|
58
|
+
let timer
|
|
59
|
+
const limit = new Promise((_, reject) => {
|
|
60
|
+
timer = setTimeout(() => reject(failure(
|
|
61
|
+
`the connection to the server was lost while ${what} (nothing for ${Math.round(ms / 1000)}s)`,
|
|
62
|
+
{ transient: true })), ms)
|
|
63
|
+
})
|
|
64
|
+
return Promise.race([promise, limit]).finally(() => clearTimeout(timer))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The permission modes shown beside Send. Stored in aipictury.approval.
|
|
68
|
+
const MODES = {
|
|
69
|
+
ask: { label: 'Manual', icon: '\u270B',
|
|
70
|
+
hint: 'Asks before every file change and command.' },
|
|
71
|
+
edits: { label: 'Edit Automatically', icon: '\u270E',
|
|
72
|
+
hint: 'Applies file edits without asking; still asks before running commands.' },
|
|
73
|
+
plan: { label: 'Plan', icon: '\u25F4',
|
|
74
|
+
hint: 'Read-only: investigates and proposes a plan, changes nothing.' },
|
|
75
|
+
never: { label: 'Auto', icon: '\u26A1',
|
|
76
|
+
hint: 'Goes ahead with routine work; asks before anything risky — judged by fixed rules and the model.' },
|
|
77
|
+
bypass: { label: 'Bypass permissions', icon: '\u26A0',
|
|
78
|
+
hint: 'Does everything without asking, with no checks at all. Only with aipictury.allowDangerouslySkipPermissions.' },
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The mode in force. Bypass needs its own setting: without it, it is Auto. */
|
|
82
|
+
function modeNow() {
|
|
83
|
+
const config = vscode.workspace.getConfiguration('aipictury')
|
|
84
|
+
const mode = config.get('approval', 'ask')
|
|
85
|
+
return mode === 'bypass' && !config.get('allowDangerouslySkipPermissions', false) ? 'never' : mode
|
|
86
|
+
}
|
|
87
|
+
const unattended = (mode) => mode === 'never' || mode === 'bypass'
|
|
88
|
+
|
|
89
|
+
const PLAN_NOTE = `
|
|
90
|
+
|
|
91
|
+
You are in PLAN mode. You may only read and search the workspace — files cannot be
|
|
92
|
+
changed, and only read-only commands run (ls, cat, git status/log/diff, grep…).
|
|
93
|
+
Investigate what the user asked about, then write a concise plan: the goal, which files
|
|
94
|
+
change and how, the order, and anything you are unsure about. Describe the code, do not
|
|
95
|
+
write it out in full. When the plan is ready, present it with exit_plan_mode: the user
|
|
96
|
+
approves it (and you may then make the changes) or asks for changes to it.`
|
|
97
|
+
|
|
98
|
+
const AUTO_NOTE = `
|
|
99
|
+
|
|
100
|
+
You are in AUTO mode: the user is not approving each step. Still call ask_user whenever
|
|
101
|
+
you choose between real options — it keeps a record of the choice. Unless you mark the
|
|
102
|
+
call important, your recommended (first) option is taken at once, you carry on without
|
|
103
|
+
waiting, and the user can change it when you finish. Mark important only a decision that
|
|
104
|
+
would be costly to undo.`
|
|
105
|
+
|
|
106
|
+
const SYSTEM = `You are AIPictury's coding agent, working inside the user's editor on
|
|
107
|
+
their own machine. You can read and change the workspace with the tools provided.
|
|
108
|
+
|
|
109
|
+
How to work:
|
|
110
|
+
- Look before you act. Find things with grep (contents) and glob (file names); ask
|
|
111
|
+
code_intel where a symbol is defined or used; read the file you are about to change;
|
|
112
|
+
check the reported problems when fixing a bug. For a broad search across the codebase,
|
|
113
|
+
hand it to a helper with task (subagent_type "explore") so your context stays small.
|
|
114
|
+
- Make the change with edit_file when the file already exists, quoting enough context
|
|
115
|
+
that old_text appears exactly once (or replace_all). Use write_file for a new file, or
|
|
116
|
+
a rewrite. After a change, the editor's new errors in that file come back with the
|
|
117
|
+
result: fix them.
|
|
118
|
+
- Use the file tools for files — never cat, sed, awk or echo in run_command to read,
|
|
119
|
+
search or edit them. run_command is for tests, builds, git, installs and servers
|
|
120
|
+
(run_in_background for servers and watchers).
|
|
121
|
+
- Change what was asked for and what that change requires — nothing else.
|
|
122
|
+
- For a task of three or more steps, keep a to-do list with todo_write.
|
|
123
|
+
- Make pictures and video with generate_media when the user wants an image, photo,
|
|
124
|
+
illustration, icon, background or clip. To replace one the code uses, find it first
|
|
125
|
+
(search, read the component), then save_to that same path: it keeps the size and
|
|
126
|
+
format. To change an existing picture, pass it as source. Describe what to draw in
|
|
127
|
+
full — subject, setting, light, style, composition — never a bare label.
|
|
128
|
+
- Research online when the task needs current or external facts: web_search to find
|
|
129
|
+
pages, fetch_url to read one. Bring what you learn back into your answer, and cite the
|
|
130
|
+
page you took it from inline, as a Markdown link: [Node.js releases](https://nodejs.org/…).
|
|
131
|
+
- When you are done, say in a few lines what you changed and what the user should check.
|
|
132
|
+
Do not paste the whole file back: they can see the diff in their editor.
|
|
133
|
+
|
|
134
|
+
When a decision belongs to the user — which library or stack, the scope, naming or
|
|
135
|
+
structure they will live with, a real trade-off between options, anything costly to
|
|
136
|
+
undo — do not settle it in your reasoning: ask with ask_user. Give 2 to 4 concrete
|
|
137
|
+
options, your recommended one first with " (Recommended)" at the end of its label, each
|
|
138
|
+
with a one-line description of what it means. The user can always write their own
|
|
139
|
+
answer. Ask related decisions together (up to 4 in one call), before you start the work
|
|
140
|
+
they shape. Do not ask about what you can find out by reading the code, or small choices
|
|
141
|
+
with an obvious answer.
|
|
142
|
+
In particular: before you install a package, or pick a library, framework, database,
|
|
143
|
+
provider, service or platform the user did not name and the project does not already
|
|
144
|
+
use, ask — look at the project first (package.json, the code), then ask with what you
|
|
145
|
+
found in mind. Installing something they did not choose is costly to undo. Where data is
|
|
146
|
+
kept (in memory, a file, a database) and which platform or provider to deploy to, store
|
|
147
|
+
files with or pay through are theirs to choose too. Look first — a few reads at most —
|
|
148
|
+
then ask; do not keep exploring for an answer that only they can give.
|
|
149
|
+
|
|
150
|
+
When the user states something to keep — a rule, a preference, a convention, anything
|
|
151
|
+
said with "remember", "always", "never", "from now on", "recordá", "regla", "siempre",
|
|
152
|
+
"nunca", "de ahora en más" — save it with the remember tool before carrying on: scope
|
|
153
|
+
"project" for this codebase, "user" for how they like to work everywhere. The rules
|
|
154
|
+
already saved are listed at the end of these instructions; follow them.
|
|
155
|
+
|
|
156
|
+
Put code in the tool call, not in your reply and not in your reasoning. Showing the
|
|
157
|
+
user a block of code is not the same as changing their file, and only the tool call
|
|
158
|
+
changes anything.
|
|
159
|
+
|
|
160
|
+
Your answers are rendered as Markdown: fenced code blocks tagged with their language,
|
|
161
|
+
tables where they help, and \`backticks\` for paths and identifiers. Keep prose short —
|
|
162
|
+
this is a side panel, not an essay.`
|
|
163
|
+
|
|
164
|
+
/** One streamed turn from the server. Returns what the model said and wants done. */
|
|
165
|
+
async function turn(server, messages, { role = 'agent', tools, model, think = true, effort, signal, onDelta, onThinking,
|
|
166
|
+
onWaiting, onSpeed, watchReasoning = false, watchLoops = false, steer,
|
|
167
|
+
thinkBudget = 0 } = {}) {
|
|
168
|
+
const sent = compact(messages)
|
|
169
|
+
// Our own switch for cutting the reasoning short, tied to the caller's Stop as well.
|
|
170
|
+
const cut = new AbortController()
|
|
171
|
+
const onStop = () => cut.abort()
|
|
172
|
+
signal?.addEventListener('abort', onStop, { once: true })
|
|
173
|
+
// The user said something mid-generation: this answer is cut so the next one hears it.
|
|
174
|
+
let interrupted = false
|
|
175
|
+
const onSteer = () => { interrupted = true; cut.abort() }
|
|
176
|
+
if (steer?.aborted) onSteer()
|
|
177
|
+
steer?.addEventListener('abort', onSteer, { once: true })
|
|
178
|
+
const body = { messages: sent, tools: tools || toolSchema(), role, think }
|
|
179
|
+
if (effort) body.effort = effort
|
|
180
|
+
if (model) body.model = model // the picker's choice overrides the role default
|
|
181
|
+
const stopped = () => { const stop = new Error('stopped'); stop.name = 'AbortError'; return stop }
|
|
182
|
+
let response
|
|
183
|
+
try {
|
|
184
|
+
response = await withDeadline(
|
|
185
|
+
fetch(`${server}/api/agent/turn`, {
|
|
186
|
+
method: 'POST',
|
|
187
|
+
headers: { 'Content-Type': 'application/json' },
|
|
188
|
+
body: JSON.stringify(body),
|
|
189
|
+
signal: cut.signal,
|
|
190
|
+
}), OPEN_TIMEOUT, 'opening the connection')
|
|
191
|
+
} catch (error) {
|
|
192
|
+
cut.abort()
|
|
193
|
+
signal?.removeEventListener('abort', onStop)
|
|
194
|
+
steer?.removeEventListener('abort', onSteer)
|
|
195
|
+
if (signal?.aborted) throw stopped()
|
|
196
|
+
if (interrupted) return { text: '', thinking: '', calls: [], meta: {}, interrupted: true }
|
|
197
|
+
if (error.transient) throw error
|
|
198
|
+
// fetch itself failed: the server is restarting, or the Wi-Fi link blinked.
|
|
199
|
+
throw failure(`the server could not be reached (${error.cause?.code || error.message})`,
|
|
200
|
+
{ transient: true })
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!response.ok) {
|
|
204
|
+
const detail = await response.text().catch(() => '')
|
|
205
|
+
signal?.removeEventListener('abort', onStop)
|
|
206
|
+
// 409 is the one the user meets in practice: a render already holds the GPU.
|
|
207
|
+
if (response.status === 409) {
|
|
208
|
+
throw failure('a generation is using the GPU right now, so the agent waits for it '
|
|
209
|
+
+ 'to finish', { busy: true })
|
|
210
|
+
}
|
|
211
|
+
throw failure(`the server answered ${response.status}: ${detail.slice(0, 300)}`,
|
|
212
|
+
{ transient: response.status >= 500 || response.status === 429 })
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const reader = response.body.getReader()
|
|
216
|
+
const decode = new TextDecoder()
|
|
217
|
+
let buffer = ''
|
|
218
|
+
let text = ''
|
|
219
|
+
let thinking = ''
|
|
220
|
+
let calls = []
|
|
221
|
+
let meta = {}
|
|
222
|
+
|
|
223
|
+
let overthought = false
|
|
224
|
+
let looped = false
|
|
225
|
+
let checkedAt = 0 // reasoning length at the last loop check
|
|
226
|
+
for (;;) {
|
|
227
|
+
let chunk
|
|
228
|
+
try {
|
|
229
|
+
chunk = await withDeadline(reader.read(), STALL_TIMEOUT, 'answering')
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (overthought || looped || interrupted) break // we cut it ourselves
|
|
232
|
+
// Close our end, so the server stops the generation instead of finishing a
|
|
233
|
+
// turn nobody will read while the retry waits behind it for the GPU.
|
|
234
|
+
cut.abort()
|
|
235
|
+
signal?.removeEventListener('abort', onStop)
|
|
236
|
+
if (signal?.aborted) throw stopped()
|
|
237
|
+
if (error.transient) throw error
|
|
238
|
+
throw failure(`the connection to the server broke while answering (${error.message})`,
|
|
239
|
+
{ transient: true })
|
|
240
|
+
}
|
|
241
|
+
const { done, value } = chunk
|
|
242
|
+
if (done) break
|
|
243
|
+
buffer += decode.decode(value, { stream: true })
|
|
244
|
+
const lines = buffer.split('\n')
|
|
245
|
+
buffer = lines.pop()
|
|
246
|
+
for (const line of lines) {
|
|
247
|
+
if (!line.trim()) continue
|
|
248
|
+
let event
|
|
249
|
+
try { event = JSON.parse(line) } catch { continue }
|
|
250
|
+
if (event.delta) { text += event.delta; onDelta?.(event.delta) }
|
|
251
|
+
if (event.thinking) { thinking += event.thinking; onThinking?.(event.thinking) }
|
|
252
|
+
if (event.tool_calls) calls = event.tool_calls
|
|
253
|
+
if (event.waiting) {
|
|
254
|
+
onWaiting?.(event)
|
|
255
|
+
// The server says how long it will wait before giving up; its "still waiting" events
|
|
256
|
+
// keep the stream alive, so this is what stops a wait that outlives that promise.
|
|
257
|
+
if ((event.silent ?? event.waiting) > (event.limit || QUIET_LIMIT) + 30) {
|
|
258
|
+
cut.abort()
|
|
259
|
+
signal?.removeEventListener('abort', onStop)
|
|
260
|
+
throw failure(`the model made no progress for ${Math.round(event.waiting / 60)} minutes`, { transient: true })
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (event.speed) onSpeed?.(event.speed)
|
|
264
|
+
if (event.done || event.error) meta = event
|
|
265
|
+
// The effort chosen caps the reasoning: past its budget, the reasoning is cut and the
|
|
266
|
+
// model acts on what it worked out, as with a loop.
|
|
267
|
+
if (thinkBudget && !looped && !overthought && !text && !calls.length && thinking.length > thinkBudget) {
|
|
268
|
+
looped = true
|
|
269
|
+
cut.abort()
|
|
270
|
+
}
|
|
271
|
+
if (watchReasoning && !overthought && !text && !calls.length && overthinking(thinking)) {
|
|
272
|
+
overthought = true
|
|
273
|
+
cut.abort() // stop spending minutes on a draft
|
|
274
|
+
}
|
|
275
|
+
// Checked every ~1,500 characters rather than per token: the check reads the
|
|
276
|
+
// whole reasoning, and a loop is only visible after a few paragraphs anyway.
|
|
277
|
+
if (watchLoops && !overthought && !looped && !text && !calls.length
|
|
278
|
+
&& thinking.length - checkedAt >= 1_500) {
|
|
279
|
+
checkedAt = thinking.length
|
|
280
|
+
if (thinkingLoops(thinking)) {
|
|
281
|
+
// Going round in circles. When what repeats is a code draft, the fitting
|
|
282
|
+
// instruction is the draft one — write the files — not "act on your notes".
|
|
283
|
+
if (watchReasoning && (CODE_BLOCK.test(thinking) || looksLikeCode(thinking))) overthought = true
|
|
284
|
+
else looped = true
|
|
285
|
+
cut.abort()
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (overthought || looped || interrupted) break
|
|
290
|
+
}
|
|
291
|
+
signal?.removeEventListener('abort', onStop)
|
|
292
|
+
steer?.removeEventListener('abort', onSteer)
|
|
293
|
+
if (signal?.aborted) throw stopped()
|
|
294
|
+
if (overthought) return { text: '', thinking, calls: [], meta: {}, overthought: true }
|
|
295
|
+
if (looped) return { text: '', thinking, calls: [], meta: {}, looped: true, budget: thinkBudget && thinking.length > thinkBudget }
|
|
296
|
+
if (interrupted) return { text, thinking, calls: [], meta: {}, interrupted: true }
|
|
297
|
+
// An error the server reported mid-stream (the model stopped early, Ollama failed):
|
|
298
|
+
// the turn is incomplete, and asking again is the fix.
|
|
299
|
+
if (meta.error) throw failure(meta.error, { transient: true })
|
|
300
|
+
calibrate(size(sent), meta.context_used)
|
|
301
|
+
if (meta.context_window) setWindow(meta.context_window)
|
|
302
|
+
return { text, thinking, calls, meta }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Asks the user about a change or a command, unless their settings already answered.
|
|
306
|
+
* Asked in the panel — the change shown hunk by hunk, each accepted or rejected, or the
|
|
307
|
+
* whole of it; "no" can say what to do instead — as Claude Code asks in its chat, not in
|
|
308
|
+
* a dialog that hides the editor. Without a panel (tests, a closed view) a dialog asks.
|
|
309
|
+
* Returns { ok, all, feedback, content } — content when only part of a change was kept
|
|
310
|
+
* or the user edited the proposal. */
|
|
311
|
+
async function approve(call, session, signal, hooks = {}, preview = null, { force = false, because = null, server = '' } = {}) {
|
|
312
|
+
const mode = modeNow()
|
|
313
|
+
const name = call.function.name
|
|
314
|
+
const args = call.function.arguments || {}
|
|
315
|
+
const isRun = RUNS.has(name) || mcp.isMcp(name) || name === 'browser'
|
|
316
|
+
if (mode === 'plan' && WRITES.has(name)) return { ok: false } // plan mode changes nothing
|
|
317
|
+
// An "ask" rule asks whatever the mode and any earlier yes: that is what it is for.
|
|
318
|
+
if (!force) {
|
|
319
|
+
if (mode === 'bypass') return { ok: true }
|
|
320
|
+
if (mode === 'never') {
|
|
321
|
+
// Auto: edits inside the workspace go ahead (a checkpoint can undo them); anything else
|
|
322
|
+
// is judged — fixed rules, routine work, then the model — and asked when it is risky.
|
|
323
|
+
if (WRITES.has(name)) return { ok: true }
|
|
324
|
+
const verdict = await judge(call, session, signal, hooks, server)
|
|
325
|
+
if (verdict.verdict === 'allow') return { ok: true, judged: verdict }
|
|
326
|
+
because = `Auto mode asks: ${verdict.reason || 'it could not tell whether this is safe'}`
|
|
327
|
+
}
|
|
328
|
+
if (mode === 'edits' && !isRun) return { ok: true }
|
|
329
|
+
if (session.allowAll) return { ok: true }
|
|
330
|
+
if (RUNS.has(name) && session.allowCommands?.some((p) => String(args.command || '').trim().startsWith(p))) return { ok: true }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (hooks.approve) {
|
|
334
|
+
const prefix = RUNS.has(name) ? commandPrefix(args.command) : undefined
|
|
335
|
+
const request = RUNS.has(name)
|
|
336
|
+
? { kind: 'command', command: String(args.command || ''), description: args.description || '', prefix }
|
|
337
|
+
: mcp.isMcp(name) ? { kind: 'mcp', tool: name, args }
|
|
338
|
+
: WRITES.has(name) ? { kind: 'edit', path: args.path, tool: name, before: preview?.before ?? null, after: preview?.after ?? null,
|
|
339
|
+
hunks: preview ? hunks(preview.before ?? '', preview.after ?? '') : [] }
|
|
340
|
+
: { kind: 'tool', tool: name, args }
|
|
341
|
+
// A standing yes for a command, an MCP tool or another tool can be kept beyond this
|
|
342
|
+
// conversation: the rule it would save is shown, and where to save it is chosen.
|
|
343
|
+
if (request.kind !== 'edit') { request.rule = permissions.ruleFor(name, args, prefix); request.destinations = permissions.DESTINATIONS }
|
|
344
|
+
if (name === 'browser') {
|
|
345
|
+
request.site = browserTool.hostOf(args.page || '')
|
|
346
|
+
if (!because) because = `the browser would ${browserTool.describe(args)} on ${request.site || 'a page'}`
|
|
347
|
+
}
|
|
348
|
+
if (because) request.because = because
|
|
349
|
+
const answer = await Promise.race([hooks.approve(request, signal), stopped(signal)])
|
|
350
|
+
if (answer === STOPPED || !answer) return { ok: false }
|
|
351
|
+
if (answer.decision === 'allow_all') {
|
|
352
|
+
const where = answer.destination || 'conversation'
|
|
353
|
+
if (where !== 'conversation' && request.rule) {
|
|
354
|
+
// Not saved is said, not swallowed: the yes still counts for this conversation.
|
|
355
|
+
try { permissions.remember(folderPath(), where, request.rule) } catch (error) {
|
|
356
|
+
log.warn(`could not save the rule ${request.rule}`, error)
|
|
357
|
+
vscode.window.showWarningMessage(`AIPictury could not save the rule ${request.rule}: ${error.message}. It applies to this conversation only.`)
|
|
358
|
+
if (RUNS.has(name) && answer.prefix) (session.allowCommands ||= []).push(answer.prefix)
|
|
359
|
+
else if (name === 'browser') (session.allowSites ||= []).push(request.site)
|
|
360
|
+
else (session.allowTools ||= []).push(name)
|
|
361
|
+
}
|
|
362
|
+
} else if (name === 'browser') (session.allowSites ||= []).push(request.site)
|
|
363
|
+
else if (RUNS.has(name) && answer.prefix) (session.allowCommands ||= []).push(answer.prefix)
|
|
364
|
+
else if (mcp.isMcp(name) || request.kind === 'tool') (session.allowTools ||= []).push(name)
|
|
365
|
+
else session.allowAll = true
|
|
366
|
+
}
|
|
367
|
+
if (answer.decision === 'deny') return { ok: false, feedback: answer.feedback || '' }
|
|
368
|
+
let content
|
|
369
|
+
if (preview && Array.isArray(answer.keep) && answer.keep.length < request.hunks.length) {
|
|
370
|
+
content = applyHunks(preview.before ?? '', preview.after ?? '', new Set(answer.keep))
|
|
371
|
+
}
|
|
372
|
+
if (typeof answer.edited === 'string' && answer.edited !== preview?.after) content = answer.edited
|
|
373
|
+
return { ok: true, content, partial: Array.isArray(answer.keep) && answer.keep.length < request.hunks.length,
|
|
374
|
+
edited: typeof answer.edited === 'string' && answer.edited !== preview?.after }
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const what = RUNS.has(name) ? `Run this command?\n\n${args.command}`
|
|
378
|
+
: mcp.isMcp(name) ? `Use ${name}?\n\n${JSON.stringify(args).slice(0, 400)}`
|
|
379
|
+
: `${name === 'write_file' ? 'Write' : 'Edit'} ${args.path}?`
|
|
380
|
+
// Stop has to work while a question is on screen, or the only way out is to answer it.
|
|
381
|
+
const asked = vscode.window.showWarningMessage(what, { modal: true },
|
|
382
|
+
'Allow', 'Allow for this conversation', ...(isRun ? [] : ['Show diff first']))
|
|
383
|
+
const choice = await Promise.race([asked, stopped(signal)])
|
|
384
|
+
if (choice === STOPPED) return { ok: false }
|
|
385
|
+
if (choice === 'Show diff first') {
|
|
386
|
+
await showDiff(args.path, preview)
|
|
387
|
+
const second = await Promise.race([vscode.window.showWarningMessage(`Apply the change to ${args.path}?`,
|
|
388
|
+
{ modal: true }, 'Allow', 'Allow for this conversation'), stopped(signal)])
|
|
389
|
+
if (second === STOPPED) return { ok: false }
|
|
390
|
+
if (second === 'Allow for this conversation') { session.allowAll = true; return { ok: true } }
|
|
391
|
+
return { ok: second === 'Allow' }
|
|
392
|
+
}
|
|
393
|
+
if (choice === 'Allow for this conversation') { session.allowAll = true; return { ok: true } }
|
|
394
|
+
return { ok: choice === 'Allow' }
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Auto mode's judgement of one call, from the server — remembered for the conversation, so
|
|
398
|
+
* the same command is judged once. Anything that goes wrong is an "ask". */
|
|
399
|
+
async function judge(call, session, signal, hooks, server) {
|
|
400
|
+
const name = call.function.name
|
|
401
|
+
const args = call.function.arguments || {}
|
|
402
|
+
const key = JSON.stringify([name, args])
|
|
403
|
+
const cache = (session.judged ||= new Map())
|
|
404
|
+
if (cache.has(key)) return cache.get(key)
|
|
405
|
+
if (!server) return { verdict: 'ask', reason: 'no server to judge it' }
|
|
406
|
+
const controller = new AbortController()
|
|
407
|
+
const stop = () => controller.abort()
|
|
408
|
+
signal?.addEventListener('abort', stop, { once: true })
|
|
409
|
+
const timer = setTimeout(stop, 45_000)
|
|
410
|
+
try {
|
|
411
|
+
const response = await fetch(`${server}/api/agent/classify`, { method: 'POST', signal: controller.signal,
|
|
412
|
+
headers: { 'Content-Type': 'application/json' },
|
|
413
|
+
body: JSON.stringify({ tool: name, args, request: String(hooks.userSaid?.() || '').slice(-1500),
|
|
414
|
+
root: folderPath(), model: hooks.model || undefined }) })
|
|
415
|
+
if (!response.ok) return { verdict: 'ask', reason: `the judge answered ${response.status}` }
|
|
416
|
+
const verdict = await response.json()
|
|
417
|
+
log.info(`Auto mode: ${verdict.verdict} ${name} (${verdict.by}: ${verdict.reason})`)
|
|
418
|
+
if (verdict.by !== 'error') cache.set(key, verdict)
|
|
419
|
+
return verdict
|
|
420
|
+
} catch (error) {
|
|
421
|
+
return { verdict: 'ask', reason: signal?.aborted ? 'stopped' : `it could not be judged (${error.message})` }
|
|
422
|
+
} finally {
|
|
423
|
+
clearTimeout(timer)
|
|
424
|
+
signal?.removeEventListener('abort', stop)
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** How a command runs with the sandbox settings: { run } sandboxed, { outside } left out of it
|
|
429
|
+
* (excluded, or asked for), { unavailable } on a machine without one, or null with it off.
|
|
430
|
+
* A setting made in VS Code wins over the settings files' "sandbox". */
|
|
431
|
+
function sandboxFor(args, folder) {
|
|
432
|
+
const config = vscode.workspace.getConfiguration('aipictury')
|
|
433
|
+
const set = (key) => { const i = config.inspect(`sandbox.${key}`); return i?.workspaceValue ?? i?.globalValue }
|
|
434
|
+
const s = sandboxing.settings(prefs.load(folder).merged.sandbox, { enabled: set('enabled'), allowNetwork: set('allowNetwork'),
|
|
435
|
+
autoAllow: set('autoAllow'), excludedCommands: set('excludedCommands'), writablePaths: set('writablePaths') })
|
|
436
|
+
if (!s.enabled) return null
|
|
437
|
+
if (args.outside_sandbox || sandboxing.excluded(args.command, s.excluded)) return { s, outside: true, asked: !!args.outside_sandbox }
|
|
438
|
+
if (!sandboxing.available()) return { s, unavailable: true }
|
|
439
|
+
return { s, run: { root: folder, writable: s.writable, network: s.allowNetwork } }
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** The workspace folder, for rules written relative to it — or none, with no folder open. */
|
|
443
|
+
function folderPath() { try { return workspaceRoot().fsPath } catch { return undefined } }
|
|
444
|
+
|
|
445
|
+
// Tools that steer the conversation itself: no rule applies to them.
|
|
446
|
+
const CONTROL = new Set(['ask_user', 'exit_plan_mode', 'enter_plan_mode', 'todo_write'])
|
|
447
|
+
|
|
448
|
+
/** The part of a command a standing yes covers: its program and, for tools with verbs
|
|
449
|
+
* (git, npm, cargo…), the verb — "npm test", "git status" — never the arguments. */
|
|
450
|
+
function commandPrefix(command) {
|
|
451
|
+
const words = String(command || '').trim().split(/\s+/)
|
|
452
|
+
const verbs = /^(git|npm|pnpm|yarn|bun|npx|cargo|go|pip|pip3|python|python3|node|docker|make|uv|poetry|dotnet|mvn|gradle|kubectl)$/
|
|
453
|
+
return words.slice(0, verbs.test(words[0]) && words[1] && !words[1].startsWith('-') ? 2 : 1).join(' ')
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// A sentinel for "the user pressed stop while this was being asked".
|
|
457
|
+
const STOPPED = Symbol('stopped')
|
|
458
|
+
|
|
459
|
+
/** Resolves if and when the run is aborted. */
|
|
460
|
+
function stopped(signal) {
|
|
461
|
+
if (!signal) return new Promise(() => {})
|
|
462
|
+
if (signal.aborted) return Promise.resolve(STOPPED)
|
|
463
|
+
return new Promise((resolve) =>
|
|
464
|
+
signal.addEventListener('abort', () => resolve(STOPPED), { once: true }))
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** What a write or an edit would make of a file, without making it. */
|
|
468
|
+
async function previewChange(call, session) {
|
|
469
|
+
const name = call.function.name
|
|
470
|
+
const args = call.function.arguments || {}
|
|
471
|
+
if (!['write_file', 'edit_file'].includes(name)) return null
|
|
472
|
+
const uri = resolve(args.path)
|
|
473
|
+
let raw = null
|
|
474
|
+
try { raw = decoder.decode(await vscode.workspace.fs.readFile(uri)) } catch { /* a new file */ }
|
|
475
|
+
const before = raw === null ? null : files.parseText(raw).text
|
|
476
|
+
if (name === 'write_file') return { before, after: String(args.content ?? '').replace(/\r\n/g, '\n') }
|
|
477
|
+
if (before === null) return null
|
|
478
|
+
await files.checkFresh(session, uri, args.path)
|
|
479
|
+
const edits = Array.isArray(args.edits) && args.edits.length ? args.edits
|
|
480
|
+
: [{ old_text: args.old_text, new_text: args.new_text, replace_all: args.replace_all }]
|
|
481
|
+
let text = before
|
|
482
|
+
for (const [i, e] of edits.entries()) {
|
|
483
|
+
text = files.replaceOnce(text, e.old_text, e.new_text, !!e.replace_all, edits.length > 1 ? `edit ${i + 1}: ` : '').text
|
|
484
|
+
}
|
|
485
|
+
return { before, after: text }
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Shows what a write would do, against what is on disk, in VS Code's diff. */
|
|
489
|
+
async function showDiff(relPath, preview) {
|
|
490
|
+
if (!preview) return
|
|
491
|
+
const uri = resolve(relPath)
|
|
492
|
+
const proposed = uri.with({ scheme: 'aipictury-proposed', query: String(Date.now()) })
|
|
493
|
+
proposedContents.set(proposed.toString(), preview.after)
|
|
494
|
+
await vscode.commands.executeCommand('vscode.diff', uri, proposed, `${relPath} — proposed by AIPictury`, { preview: true })
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// What a file held before the agent changed it, for VS Code's side-by-side diff. In
|
|
498
|
+
// memory only, and only the most recent few — it is a convenience, not a history.
|
|
499
|
+
const befores = new Map()
|
|
500
|
+
function rememberBefore(path, text) {
|
|
501
|
+
const key = `${Date.now().toString(36)}-${befores.size}`
|
|
502
|
+
befores.set(key, { path, text })
|
|
503
|
+
while (befores.size > 50) befores.delete(befores.keys().next().value)
|
|
504
|
+
return key
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Opens VS Code's own side-by-side diff: what the file was, against what it is now. */
|
|
508
|
+
async function openSideBySide(key) {
|
|
509
|
+
const entry = befores.get(key)
|
|
510
|
+
if (!entry) {
|
|
511
|
+
vscode.window.showInformationMessage('That earlier version is no longer in memory; '
|
|
512
|
+
+ 'the change is still shown in the panel.')
|
|
513
|
+
return
|
|
514
|
+
}
|
|
515
|
+
const current = resolve(entry.path)
|
|
516
|
+
const was = current.with({ scheme: 'aipictury-proposed', query: `before-${key}` })
|
|
517
|
+
proposedContents.set(was.toString(), entry.text)
|
|
518
|
+
await vscode.commands.executeCommand('vscode.diff', was, current,
|
|
519
|
+
`${entry.path} — before ↔ after`, { preview: true })
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Backing store for the proposed side of a diff.
|
|
523
|
+
const proposedContents = new Map()
|
|
524
|
+
const proposedProvider = {
|
|
525
|
+
provideTextDocumentContent(uri) { return proposedContents.get(uri.toString()) ?? '' },
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** The model's question, answered. Blocks on the panel's chooser — except in Auto mode,
|
|
529
|
+
* where a question not marked important takes the recommended options at once and is
|
|
530
|
+
* kept as a decision the user can change when the run ends. */
|
|
531
|
+
async function askUser(args, report, signal, hooks) {
|
|
532
|
+
let questions
|
|
533
|
+
try {
|
|
534
|
+
questions = normalizeQuestions(args)
|
|
535
|
+
} catch (error) {
|
|
536
|
+
report({ tool: 'ask_user', args, status: 'failed', result: error.message })
|
|
537
|
+
return `error: ${error.message}`
|
|
538
|
+
}
|
|
539
|
+
const mode = modeNow()
|
|
540
|
+
const recommended = (q) => q.options[0].label.replace(/\s*\(recommended\)\s*$/i, '')
|
|
541
|
+
if ((unattended(mode) && !args.important) || !hooks.askUser) {
|
|
542
|
+
const picks = questions.map((q) => ({ ...q, picked: [q.options[0].label], other: '' }))
|
|
543
|
+
hooks.onDecision?.(picks)
|
|
544
|
+
report({ tool: 'ask_user', args: { questions }, status: 'decided', result: picks })
|
|
545
|
+
return 'Auto mode: the user is not asked now. Proceed with your recommended option'
|
|
546
|
+
+ `${picks.length > 1 ? 's' : ''}: ${questions.map((q) => `${q.header} → ${recommended(q)}`).join('; ')}. `
|
|
547
|
+
+ 'The user can change these when you finish.'
|
|
548
|
+
}
|
|
549
|
+
report({ tool: 'ask_user', args: { questions }, status: 'asking-user' })
|
|
550
|
+
const answer = await hooks.askUser(questions, signal)
|
|
551
|
+
if (!answer) { // Stop, while the question was open
|
|
552
|
+
report({ tool: 'ask_user', args: { questions }, status: 'refused' })
|
|
553
|
+
return 'stopped by the user before answering'
|
|
554
|
+
}
|
|
555
|
+
if (answer.dismissed) {
|
|
556
|
+
report({ tool: 'ask_user', args: { questions }, status: 'dismissed' })
|
|
557
|
+
return 'The user closed the question without answering. Use your best judgement, and say '
|
|
558
|
+
+ 'in your answer what you chose so they can change it.'
|
|
559
|
+
}
|
|
560
|
+
report({ tool: 'ask_user', args: { questions }, status: 'answered', result: answer.answers })
|
|
561
|
+
const lines = answer.answers.map((a) => {
|
|
562
|
+
const chosen = [...(a.picked || []), ...(a.other ? [`their own answer: "${a.other}"`] : [])]
|
|
563
|
+
return `- ${a.header} (${a.question}): ${chosen.join(', ') || 'no answer'}`
|
|
564
|
+
})
|
|
565
|
+
return `The user answered:\n${lines.join('\n')}\nCarry on with these.`
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** A picture or clip made on the server and written into the workspace. */
|
|
569
|
+
async function makeMedia(call, session, report, signal, server, hooks = {}) {
|
|
570
|
+
const args = call.function.arguments || {}
|
|
571
|
+
const mode = modeNow()
|
|
572
|
+
if (mode === 'plan') {
|
|
573
|
+
report({ tool: 'generate_media', args, status: 'plan' })
|
|
574
|
+
return 'Plan mode: nothing was generated. Describe the picture in your plan instead.'
|
|
575
|
+
}
|
|
576
|
+
// Writing a file needs the same yes as any write: asked for in Manual only.
|
|
577
|
+
const ask = async (question) => {
|
|
578
|
+
if (unattended(mode) || mode === 'edits' || session.allowAll) return true
|
|
579
|
+
report({ tool: 'generate_media', args, status: 'asking' })
|
|
580
|
+
if (hooks.approve) {
|
|
581
|
+
const answer = await Promise.race([hooks.approve({ kind: 'media', question, path: args.save_to }, signal), stopped(signal)])
|
|
582
|
+
if (answer === STOPPED || !answer || answer.decision === 'deny') return false
|
|
583
|
+
if (answer.decision === 'allow_all') session.allowAll = true
|
|
584
|
+
return true
|
|
585
|
+
}
|
|
586
|
+
const choice = await Promise.race([
|
|
587
|
+
vscode.window.showWarningMessage(question, { modal: true }, 'Allow', 'Allow for this conversation'),
|
|
588
|
+
stopped(signal)])
|
|
589
|
+
if (choice === 'Allow for this conversation') { session.allowAll = true; return true }
|
|
590
|
+
return choice === 'Allow'
|
|
591
|
+
}
|
|
592
|
+
try {
|
|
593
|
+
const result = await mediagen.generate(server, args, {
|
|
594
|
+
ask, signal, report: (update) => report({ tool: 'generate_media', args, ...update }) })
|
|
595
|
+
if (result.refused) report({ tool: 'generate_media', args, status: 'refused' })
|
|
596
|
+
return result.text
|
|
597
|
+
} catch (error) {
|
|
598
|
+
if (error.name === 'AbortError' || signal?.aborted) return 'stopped by the user'
|
|
599
|
+
report({ tool: 'generate_media', args, status: 'failed', result: error.message })
|
|
600
|
+
return `error: ${error.message}`
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** The to-do list the model keeps, cleaned: at most 30 items, a known status each. */
|
|
605
|
+
function normalizeTodos(todos) {
|
|
606
|
+
let list = todos
|
|
607
|
+
if (typeof list === 'string') { try { list = JSON.parse(list) } catch { list = [] } }
|
|
608
|
+
if (!Array.isArray(list)) throw new Error('todos must be a list of { content, status }')
|
|
609
|
+
const out = list.slice(0, 30).map((t) => ({
|
|
610
|
+
content: String(t?.content || t?.task || '').trim(),
|
|
611
|
+
status: ['pending', 'in_progress', 'completed'].includes(t?.status) ? t.status : 'pending',
|
|
612
|
+
activeForm: String(t?.activeForm || t?.active_form || '').trim(),
|
|
613
|
+
})).filter((t) => t.content)
|
|
614
|
+
if (!out.length) throw new Error('todos is empty — give at least one item')
|
|
615
|
+
return out
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** Carries out one tool call and returns what goes back to the model: text, or
|
|
619
|
+
* { text, images } when there is a picture for it to see. */
|
|
620
|
+
/** The hooks' context for this conversation: where, who, and how to ask for trust. */
|
|
621
|
+
const hookContext = (session, signal, hooks, subject) => ({ folder: folderPath(), subject, sessionId: session?.id || '',
|
|
622
|
+
trust: hooks.trustHooks, signal, log: (line) => log.warn(line) })
|
|
623
|
+
|
|
624
|
+
/** One tool call, with the user's PreToolUse and PostToolUse hooks around it. */
|
|
625
|
+
async function execute(call, session, report, signal, server, hooks = {}) {
|
|
626
|
+
const name = call.function.name
|
|
627
|
+
let args = call.function.arguments || {}
|
|
628
|
+
if (typeof args === 'string') { try { args = JSON.parse(args) } catch { args = {} } }
|
|
629
|
+
const folder = folderPath()
|
|
630
|
+
const ctx = hookContext(session, signal, hooks, name)
|
|
631
|
+
const asClaude = { tool_name: userHooks.claudeName(name), tool_input: userHooks.toolInput(name, args, folder) }
|
|
632
|
+
const pre = await userHooks.fire('PreToolUse', asClaude, ctx)
|
|
633
|
+
if (pre.stop) {
|
|
634
|
+
session.hookStop = pre.stopReason || 'a hook stopped the run'
|
|
635
|
+
report({ tool: name, args, status: 'refused', result: `stopped by a hook: ${session.hookStop}` })
|
|
636
|
+
return `Stopped by a hook: ${session.hookStop}`
|
|
637
|
+
}
|
|
638
|
+
if (pre.block || pre.permission === 'deny') {
|
|
639
|
+
const why = [...pre.reasons, ...pre.permissionReasons].filter(Boolean).join('; ') || 'no reason given'
|
|
640
|
+
report({ tool: name, args, status: 'refused', result: `blocked by a hook: ${why}` })
|
|
641
|
+
return `A PreToolUse hook blocked this: ${why}. Do not try to reach the same result another way; `
|
|
642
|
+
+ 'if it matters, say in your reply what you could not do and why.'
|
|
643
|
+
}
|
|
644
|
+
const result = await executeTool(call, session, report, signal, server,
|
|
645
|
+
pre.permission ? { ...hooks, hookPermission: pre.permission, hookReason: pre.permissionReasons.filter(Boolean).join('; ') } : hooks)
|
|
646
|
+
const text = typeof result === 'object' ? result.text : result
|
|
647
|
+
const post = await userHooks.fire('PostToolUse', { ...asClaude, tool_response: String(text ?? '').slice(0, 20_000) }, ctx)
|
|
648
|
+
if (post.stop) session.hookStop = post.stopReason || 'a hook stopped the run'
|
|
649
|
+
const notes = [...(post.block ? post.reasons : []), ...post.context]
|
|
650
|
+
if (!notes.length) return result
|
|
651
|
+
const added = `\n\n[A PostToolUse hook says: ${notes.join('\n')}]`
|
|
652
|
+
return typeof result === 'object' ? { ...result, text: `${result.text}${added}` } : `${result}${added}`
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// The tools that take a path: in a worktree, their paths are the worktree's.
|
|
656
|
+
const PATH_TOOLS = new Set(['read_file', 'write_file', 'edit_file', 'notebook_edit', 'list_files', 'grep', 'glob', 'code_intel', 'search'])
|
|
657
|
+
|
|
658
|
+
async function executeTool(call, session, report, signal, server, hooks = {}) {
|
|
659
|
+
const name = call.function.name
|
|
660
|
+
let args = call.function.arguments || {}
|
|
661
|
+
if (typeof args === 'string') { try { args = JSON.parse(args) } catch { args = {} } }
|
|
662
|
+
if (session.worktree && PATH_TOOLS.has(name)) args = { ...args, path: worktrees.inside(session.worktree, args.path) }
|
|
663
|
+
// The browser: the site a call goes to or acts on — for the rules, the question and the judge.
|
|
664
|
+
if (name === 'browser') args = { ...args, page: browserTool.targetUrl(args) || undefined }
|
|
665
|
+
const mode = modeNow()
|
|
666
|
+
const planning = mode === 'plan'
|
|
667
|
+
const ctx = { session, signal, readSpecial: hooks.readSpecial }
|
|
668
|
+
const fail = (error) => {
|
|
669
|
+
report({ tool: name, args, status: 'failed', result: error.message })
|
|
670
|
+
return `error: ${error.message}`
|
|
671
|
+
}
|
|
672
|
+
// The permission rules of the settings files: a deny stops the call here, whatever the mode.
|
|
673
|
+
const rule = CONTROL.has(name) ? { decision: null } : permissions.decide(name, args, { folder: folderPath(), extraAllow: session.commandAllow, extraDeny: session.commandDeny })
|
|
674
|
+
if (rule.decision === 'deny') {
|
|
675
|
+
const where = rule.file ? ` (${rule.level} settings, ${rule.file.replace(os.homedir(), '~')})` : ''
|
|
676
|
+
report({ tool: name, args, status: 'refused', result: `denied by ${rule.rule}` })
|
|
677
|
+
return `Denied by the permission rule ${rule.rule}${where}. Do not try to reach the same result another way; `
|
|
678
|
+
+ 'if it matters, say in your reply what you could not do and why.'
|
|
679
|
+
}
|
|
680
|
+
if (name === 'ask_user') return askUser(args, report, signal, hooks)
|
|
681
|
+
if (name === 'generate_media') return makeMedia(call, session, report, signal, server, hooks)
|
|
682
|
+
|
|
683
|
+
if (name === 'remember') {
|
|
684
|
+
report({ tool: name, args, status: 'running' })
|
|
685
|
+
try {
|
|
686
|
+
const rule = String(args.rule || '').trim()
|
|
687
|
+
if (!rule) throw new Error('rule is required')
|
|
688
|
+
const result = args.scope === 'user' ? await memory.addUserRule(server, rule) : await memory.addProjectRule(rule)
|
|
689
|
+
report({ tool: name, args, status: 'done', result })
|
|
690
|
+
return result
|
|
691
|
+
} catch (error) { return fail(error) }
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if (name === 'todo_write') {
|
|
695
|
+
try {
|
|
696
|
+
const todos = normalizeTodos(args.todos)
|
|
697
|
+
session.todos = todos
|
|
698
|
+
hooks.onTodos?.(todos)
|
|
699
|
+
report({ tool: name, args: { todos }, status: 'done', todos })
|
|
700
|
+
const doing = todos.find((t) => t.status === 'in_progress')
|
|
701
|
+
return `To-do list saved (${todos.filter((t) => t.status === 'completed').length}/${todos.length} done`
|
|
702
|
+
+ `${doing ? `; now: ${doing.content}` : ''}). Keep it up to date as you go.`
|
|
703
|
+
} catch (error) { return fail(error) }
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// A git worktree for this conversation: changes away from the user's checkout.
|
|
707
|
+
if (name === 'enter_worktree' || name === 'exit_worktree') {
|
|
708
|
+
try {
|
|
709
|
+
const base = folderPath()
|
|
710
|
+
let text
|
|
711
|
+
if (name === 'enter_worktree') {
|
|
712
|
+
if (session.worktree) throw new Error(`already in the worktree ${session.worktree.name} — exit_worktree first`)
|
|
713
|
+
session.worktree = await worktrees.enter(base, args.name)
|
|
714
|
+
session.cwd = session.worktree.path
|
|
715
|
+
text = `Working in the worktree ${session.worktree.relative} on the branch ${session.worktree.branch}. Paths are now relative to it, and commands run in it.`
|
|
716
|
+
} else {
|
|
717
|
+
if (!session.worktree) throw new Error('not in a worktree')
|
|
718
|
+
const wt = session.worktree
|
|
719
|
+
const done = await worktrees.exit(base, wt, { action: args.action === 'remove' ? 'remove' : 'keep', discard: !!args.discard_changes })
|
|
720
|
+
delete session.worktree
|
|
721
|
+
session.cwd = base
|
|
722
|
+
text = done.kept ? `Left the worktree. It is kept at ${wt.relative} on ${wt.branch} (${done.commits} commit(s), ${done.dirty.length} uncommitted change(s)) — tell the user, so they can merge it.`
|
|
723
|
+
: 'Left the worktree and removed it.'
|
|
724
|
+
}
|
|
725
|
+
report({ tool: name, args, status: 'done', result: text })
|
|
726
|
+
return text
|
|
727
|
+
} catch (error) { return fail(error) }
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// This conversation's schedule: cron prompts and wake-ups.
|
|
731
|
+
if (['cron_create', 'cron_list', 'cron_delete', 'schedule_wakeup'].includes(name)) {
|
|
732
|
+
try {
|
|
733
|
+
if (!hooks.schedule) throw new Error('scheduling is not available here')
|
|
734
|
+
const jobs = hooks.schedule.get()
|
|
735
|
+
let text
|
|
736
|
+
if (name === 'cron_list') text = jobs.length ? jobs.map((j) => scheduler.describe(j)).join('\n') : 'Nothing is scheduled.'
|
|
737
|
+
else if (name === 'cron_delete') {
|
|
738
|
+
if (!jobs.some((j) => j.id === args.id)) throw new Error(`no scheduled prompt ${args.id}`)
|
|
739
|
+
hooks.schedule.set(jobs.filter((j) => j.id !== args.id))
|
|
740
|
+
text = `${args.id} removed.`
|
|
741
|
+
} else {
|
|
742
|
+
const minutes = Number(args.delay_minutes)
|
|
743
|
+
if (name === 'schedule_wakeup' && !(minutes >= 1 && minutes <= 1440)) throw new Error('delay_minutes is 1 to 1440')
|
|
744
|
+
const j = name === 'schedule_wakeup'
|
|
745
|
+
? scheduler.job(jobs, { schedule: `${Math.round(minutes)}m`, prompt: args.prompt, recurring: false, kind: 'wakeup' })
|
|
746
|
+
: scheduler.job(jobs, { schedule: args.schedule, prompt: args.prompt, recurring: args.recurring !== false })
|
|
747
|
+
hooks.schedule.set([...jobs, j])
|
|
748
|
+
text = `Scheduled as ${j.id}: ${scheduler.describe(j)}`
|
|
749
|
+
}
|
|
750
|
+
report({ tool: name, args, status: 'done', result: text })
|
|
751
|
+
return text
|
|
752
|
+
} catch (error) { return fail(error) }
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// The task list with dependencies — the same list todo_write keeps.
|
|
756
|
+
if (['task_create', 'task_update', 'task_list', 'task_get'].includes(name)) {
|
|
757
|
+
try {
|
|
758
|
+
const list = tasklist.fromTodos(session.todos || [])
|
|
759
|
+
let text
|
|
760
|
+
if (name === 'task_list') text = tasklist.summary(list)
|
|
761
|
+
else if (name === 'task_get') text = tasklist.detail(list, args.taskId)
|
|
762
|
+
else {
|
|
763
|
+
const done = name === 'task_create' ? tasklist.create(list, args) : tasklist.update(list, args)
|
|
764
|
+
session.todos = done.list
|
|
765
|
+
hooks.onTodos?.(done.list)
|
|
766
|
+
text = name === 'task_create' ? `Task #${done.task.id} added${done.task.blockedBy.length ? `, waiting for ${done.task.blockedBy.map((i) => `#${i}`).join(', ')}` : ''}.`
|
|
767
|
+
: args.status === 'deleted' ? `Task #${done.task.id} deleted.`
|
|
768
|
+
: `Task #${done.task.id} is ${done.task.status}.${done.freed?.length ? ` Now free to start: ${done.freed.map((t) => `#${t.id} ${t.content}`).join(', ')}.` : ''}`
|
|
769
|
+
}
|
|
770
|
+
report({ tool: name, args, status: 'done', result: text, todos: session.todos })
|
|
771
|
+
return text
|
|
772
|
+
} catch (error) { return fail(error) }
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
if (name === 'exit_plan_mode') {
|
|
776
|
+
if (!planning) return 'You are not in plan mode — carry on with the task.'
|
|
777
|
+
const plan = String(args.plan || '').trim()
|
|
778
|
+
if (!plan) return fail(new Error('plan is required'))
|
|
779
|
+
report({ tool: name, args: { plan }, status: 'asking-user' })
|
|
780
|
+
const answer = hooks.approvePlan ? await Promise.race([hooks.approvePlan(plan, signal), stopped(signal)]) : null
|
|
781
|
+
if (answer === STOPPED || !answer) {
|
|
782
|
+
report({ tool: name, args: { plan }, status: 'refused' })
|
|
783
|
+
return 'The plan was not approved yet. Stop here: the user will reply.'
|
|
784
|
+
}
|
|
785
|
+
if (!answer.approved) {
|
|
786
|
+
report({ tool: name, args: { plan }, status: 'revise', result: answer.feedback || '' })
|
|
787
|
+
return `The user wants the plan changed${answer.feedback ? `: ${answer.feedback}` : ''}. Revise it and present it again with exit_plan_mode.`
|
|
788
|
+
}
|
|
789
|
+
await vscode.workspace.getConfiguration('aipictury').update('approval', answer.mode || 'edits', vscode.ConfigurationTarget.Global)
|
|
790
|
+
report({ tool: name, args: { plan }, status: 'approved', result: answer.mode })
|
|
791
|
+
const edited = answer.plan && answer.plan.trim() !== plan
|
|
792
|
+
return `The user approved the plan and switched you to ${MODES[answer.mode || 'edits']?.label || answer.mode} mode. `
|
|
793
|
+
+ 'Carry it out now, step by step: you can change files and run commands.'
|
|
794
|
+
+ (edited ? `\n\nThey edited the plan first — follow their version:\n\n${answer.plan.trim()}` : '')
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
if (name === 'enter_plan_mode') {
|
|
798
|
+
if (planning) return 'You are already in Plan mode: investigate, then present the plan with exit_plan_mode.'
|
|
799
|
+
const reason = String(args.reason || '').trim()
|
|
800
|
+
// Auto mode does not stop for this: the plan itself is still put to the user.
|
|
801
|
+
if (!unattended(mode)) {
|
|
802
|
+
report({ tool: name, args, status: 'asking' })
|
|
803
|
+
const answer = hooks.approve
|
|
804
|
+
? await Promise.race([hooks.approve({ kind: 'plan-mode', reason }, signal), stopped(signal)]) : null
|
|
805
|
+
if (answer === STOPPED || !answer || answer.decision === 'deny') {
|
|
806
|
+
report({ tool: name, args, status: 'refused', result: answer?.feedback })
|
|
807
|
+
return answer?.feedback ? `The user does not want a plan first and said: ${answer.feedback}`
|
|
808
|
+
: 'The user does not want a plan first: go ahead with the task directly.'
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
await vscode.workspace.getConfiguration('aipictury').update('approval', 'plan', vscode.ConfigurationTarget.Global)
|
|
812
|
+
report({ tool: name, args, status: 'approved', result: 'plan' })
|
|
813
|
+
return 'You are in Plan mode now. Investigate with the read-only tools, then present your plan with '
|
|
814
|
+
+ 'exit_plan_mode; nothing can be changed until the user approves it.'
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
if (name === 'push_notification') {
|
|
818
|
+
const message = String(args.message || '').trim()
|
|
819
|
+
if (!message) return fail(new Error('message is required'))
|
|
820
|
+
const title = String(args.title || '').trim().slice(0, 60) || 'AIPictury'
|
|
821
|
+
const sent = workbench.notify(title, message)
|
|
822
|
+
report({ tool: name, args: { title, message }, status: 'done', result: sent ? 'sent' : 'not sent' })
|
|
823
|
+
return sent ? 'The user was notified.' : 'Not sent: the user is looking at VS Code right now, or notifications are off — say it in your reply.'
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (name === 'report_findings') {
|
|
827
|
+
const findings = normalizeFindings(args)
|
|
828
|
+
report({ tool: name, args: { count: findings.length }, status: 'done', findings })
|
|
829
|
+
if (!findings.length) return 'Reported: no findings. Tell the user in a sentence what you checked.'
|
|
830
|
+
const counts = ['high', 'medium', 'low'].map((s) => [s, findings.filter((f) => f.severity === s).length])
|
|
831
|
+
.filter(([, n]) => n).map(([s, n]) => `${n} ${s}`).join(', ')
|
|
832
|
+
return `The user sees ${findings.length} finding${findings.length === 1 ? '' : 's'} (${counts}) as a list they can open. `
|
|
833
|
+
+ 'Summarize in a sentence or two; do not repeat the list.'
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
if (name === 'tool_search') {
|
|
837
|
+
const found = mcp.search(args, (session.loadedMcp ||= new Set()))
|
|
838
|
+
report({ tool: name, args, status: 'done', result: found.split('\n')[0] })
|
|
839
|
+
return found
|
|
840
|
+
}
|
|
841
|
+
if (name === 'list_mcp_resources' || name === 'read_mcp_resource') {
|
|
842
|
+
report({ tool: name, args, status: 'running' })
|
|
843
|
+
try {
|
|
844
|
+
const text = name === 'list_mcp_resources' ? await mcp.listResources(args) : await mcp.readResource(args)
|
|
845
|
+
report({ tool: name, args, status: 'done', result: text })
|
|
846
|
+
return text
|
|
847
|
+
} catch (error) { return fail(error) }
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// A notebook cell runs code: the user is asked (a native Execute / Cancel), unless in Bypass.
|
|
851
|
+
if (name === 'notebook_run_cell') {
|
|
852
|
+
if (planning) { report({ tool: name, args, status: 'plan' }); return 'Plan mode: no cell was run. Describe it in your plan instead.' }
|
|
853
|
+
report({ tool: name, args, status: 'running' })
|
|
854
|
+
try {
|
|
855
|
+
const ask = hooks.askRun || (async (question, code) => (await Promise.race([vscode.window.showQuickPick(
|
|
856
|
+
[{ label: 'Execute', description: 'run the cell' }, { label: 'Cancel' }], { title: question, placeHolder: code.split('\n').slice(0, 3).join(' ⏎ ').slice(0, 200) }),
|
|
857
|
+
stopped(signal)]))?.label === 'Execute')
|
|
858
|
+
const text = await notebooks.runCell(args, { ask, skipAsk: mode === 'bypass', signal })
|
|
859
|
+
report({ tool: name, args, status: 'done', result: text })
|
|
860
|
+
return text
|
|
861
|
+
} catch (error) { return fail(error) }
|
|
862
|
+
}
|
|
863
|
+
if (name === 'publish_page') {
|
|
864
|
+
try {
|
|
865
|
+
if (!String(args.markdown || args.html || '').trim()) throw new Error('give the page markdown or html')
|
|
866
|
+
const response = await fetch(`${server}/api/pages`, { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
867
|
+
body: JSON.stringify({ title: String(args.title || 'Page').slice(0, 200), markdown: args.markdown, html: args.html }), signal })
|
|
868
|
+
const data = await response.json().catch(() => ({}))
|
|
869
|
+
if (!response.ok) throw new Error(data.detail || `the server answered ${response.status}`)
|
|
870
|
+
const url = `${server}${data.path}`
|
|
871
|
+
report({ tool: name, args: { title: args.title }, status: 'done', result: url, url })
|
|
872
|
+
return `Published at ${url} — give the user this link.`
|
|
873
|
+
} catch (error) { return fail(error) }
|
|
874
|
+
}
|
|
875
|
+
if (name === 'send_user_file') {
|
|
876
|
+
try {
|
|
877
|
+
const uri = resolve(args.path)
|
|
878
|
+
const stat = await vscode.workspace.fs.stat(uri)
|
|
879
|
+
if (stat.type & vscode.FileType.Directory) throw new Error(`${args.path} is a folder — send a file`)
|
|
880
|
+
report({ tool: name, args, status: 'done', result: args.message || '', file: { path: args.path, size: stat.size, message: args.message || '' } })
|
|
881
|
+
return `The user has ${args.path} as a card in the conversation, to open or save.`
|
|
882
|
+
} catch (error) { return fail(error) }
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
if (name === 'task') return runHelper(args, { server, session, signal, report, hooks, model: hooks.model })
|
|
886
|
+
if (name === 'workflow') return runWorkflow(args, { server, session, signal, report, hooks, model: hooks.model })
|
|
887
|
+
if (name === 'send_message') return sendToHelper(args, { server, session, signal, report, hooks, model: hooks.model })
|
|
888
|
+
if (name === 'list_agents') { const text = listHelpers(session); report({ tool: name, args, status: 'done', result: text }); return text }
|
|
889
|
+
if (name === 'task_stop') {
|
|
890
|
+
const h = findHelper(session, args.id)
|
|
891
|
+
if (h) {
|
|
892
|
+
if (h.status !== 'running') return `${h.id} is not running (${h.status}).`
|
|
893
|
+
h.controller?.abort()
|
|
894
|
+
report({ tool: name, args, status: 'done', result: `${h.id} stopped` })
|
|
895
|
+
return `${h.id} stopped.`
|
|
896
|
+
}
|
|
897
|
+
try { const text = shell.killShell({ shell_id: args.id }); report({ tool: name, args, status: 'done', result: text }); return text } catch (error) { return fail(error) }
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const isMcp = mcp.isMcp(name)
|
|
901
|
+
const isRun = RUNS.has(name)
|
|
902
|
+
// PowerShell is never taken as read-only: the read-only check knows bash, not PowerShell.
|
|
903
|
+
const readOnlyRun = isRun && name !== 'powershell' && shell.isReadOnly(args.command)
|
|
904
|
+
// Read-only, but of somewhere outside the workspace (and the added folders): asked first.
|
|
905
|
+
const readsOutside = readOnlyRun && shell.readsOutside(args.command)
|
|
906
|
+
if (planning && (WRITES.has(name) || (isRun && !readOnlyRun) || (isMcp && !mcp.readOnly(name)))) {
|
|
907
|
+
report({ tool: name, args, status: 'plan' })
|
|
908
|
+
return isRun ? 'Plan mode: only read-only commands (ls, cat, git status/log/diff, grep…) run here. '
|
|
909
|
+
+ 'Describe this one in your plan instead.' : 'Plan mode: this change was not applied. Describe it in your plan instead.'
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// The browser in Plan mode looks — opens and reads pages — but does not act on them.
|
|
913
|
+
if (name === 'browser' && planning && !['navigate', 'back', 'close', ...browserTool.LOOKS].includes(args.action)) {
|
|
914
|
+
report({ tool: name, args, status: 'plan' })
|
|
915
|
+
return 'Plan mode: the browser only opens and reads pages here. Describe this step in your plan instead.'
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
let preview
|
|
919
|
+
let approval = { ok: true }
|
|
920
|
+
// The browser acts on a site once the user has said yes to it (localhost needs no yes); a
|
|
921
|
+
// password, card or similar field is asked about every time, whatever the mode.
|
|
922
|
+
const site = name === 'browser' ? browserTool.hostOf(args.page || '') : ''
|
|
923
|
+
const browserAsks = name === 'browser' && browserTool.ACTS.has(args.action) && args.action !== 'close'
|
|
924
|
+
&& !browserTool.isLocal(args.page || '') && !session.allowSites?.includes(site)
|
|
925
|
+
const tender = name === 'browser' && browserTool.sensitive(args)
|
|
926
|
+
if (tender) Object.assign(rule, { decision: 'ask', rule: null, hook: null, why: `the browser always asks: ${tender}` })
|
|
927
|
+
// A PreToolUse hook's say: allow skips the question (a deny rule has already stopped the
|
|
928
|
+
// call), ask asks whatever the mode.
|
|
929
|
+
if (hooks.hookPermission === 'allow' && rule.decision !== 'ask') rule.decision = 'allow'
|
|
930
|
+
// The sandbox: a command kept in it (no network) needs no question; one leaving it is asked.
|
|
931
|
+
const box = isRun ? sandboxFor(args, folderPath()) : null
|
|
932
|
+
if (box?.run && box.s.autoAllow && !box.s.allowNetwork && rule.decision !== 'ask' && hooks.hookPermission !== 'ask') rule.decision = 'allow'
|
|
933
|
+
if (box?.asked || box?.unavailable) {
|
|
934
|
+
Object.assign(rule, { decision: 'ask', rule: null, hook: null,
|
|
935
|
+
sandbox: box.asked ? 'the agent asks to run this outside the sandbox' : 'the sandbox is on, but this machine has none (sandbox-exec / bubblewrap)' })
|
|
936
|
+
}
|
|
937
|
+
if (hooks.hookPermission === 'ask') Object.assign(rule, { decision: 'ask', rule: null, hook: hooks.hookReason || 'a hook' })
|
|
938
|
+
const standing = session.allowTools?.includes(name)
|
|
939
|
+
const needsYes = rule.decision === 'ask'
|
|
940
|
+
|| (rule.decision !== 'allow' && !standing && (WRITES.has(name) || (isRun && (!readOnlyRun || readsOutside)) || (isMcp && !mcp.readOnly(name)) || browserAsks))
|
|
941
|
+
if (needsYes) {
|
|
942
|
+
if (WRITES.has(name)) { try { preview = await previewChange(call, session) } catch (error) { return fail(error) } }
|
|
943
|
+
// Say in the panel that a question is waiting: a silent pause reads as a hang.
|
|
944
|
+
report({ tool: name, args, status: 'asking' })
|
|
945
|
+
approval = await approve({ ...call, function: { ...call.function, arguments: args } }, session, signal, hooks, preview,
|
|
946
|
+
rule.decision === 'ask' ? { force: true, server, because: rule.why || rule.sandbox || (rule.hook ? `asked by a hook: ${rule.hook}` : `asked by the rule ${rule.rule}`) } : { server })
|
|
947
|
+
if (!approval.ok) {
|
|
948
|
+
report({ tool: name, args, status: 'refused', result: approval.feedback })
|
|
949
|
+
return approval.feedback
|
|
950
|
+
? `The user declined this and said: ${approval.feedback}`
|
|
951
|
+
: 'The user declined this. Do not try it again in another way; ask what they want, or carry on without it.'
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
report({ tool: name, args, status: 'running' })
|
|
955
|
+
let before
|
|
956
|
+
if (WRITES.has(name)) {
|
|
957
|
+
try { before = files.parseText(decoder.decode(await vscode.workspace.fs.readFile(resolve(args.path)))).text } catch { before = null }
|
|
958
|
+
hooks.onFileChange?.(args.path, before) // for checkpoints: what to restore
|
|
959
|
+
}
|
|
960
|
+
try {
|
|
961
|
+
let result
|
|
962
|
+
if (name === 'monitor') {
|
|
963
|
+
const task = shell.startMonitor(String(args.command || ''), { session, sandbox: box?.run, description: String(args.description || ''),
|
|
964
|
+
limitMs: Math.max(1, Math.min(120, Number(args.timeout_minutes) || 30)) * 60_000,
|
|
965
|
+
onLines: (t, lines, info) => hooks.onMonitor?.(t, lines, info) })
|
|
966
|
+
hooks.onTasks?.(shell.taskList())
|
|
967
|
+
result = `Watching as ${task.id}: each new output line will reach you as an event — between your steps, or as a new turn `
|
|
968
|
+
+ `once you have finished. Stop it with kill_shell (shell_id "${task.id}").`
|
|
969
|
+
} else if (isRun) {
|
|
970
|
+
result = await runCommand(String(args.command || ''), { signal, session, timeout: args.timeout, sandbox: name === 'powershell' ? null : box?.run, powershell: name === 'powershell',
|
|
971
|
+
background: !!args.run_in_background, onTasks: () => hooks.onTasks?.(shell.taskList()) })
|
|
972
|
+
} else if (isMcp) {
|
|
973
|
+
result = await mcp.call(name, args)
|
|
974
|
+
} else if (approval.content !== undefined && WRITES.has(name)) {
|
|
975
|
+
// Only part of the change was kept, or the user edited it: write what they approved.
|
|
976
|
+
result = await files.writeFile({ path: args.path, content: approval.content }, { ...ctx, session: null })
|
|
977
|
+
files.remember(session, resolve(args.path), await files.stat(resolve(args.path)))
|
|
978
|
+
result = `${approval.edited ? 'The user edited your change before accepting it' : 'The user accepted only part of your change'}; `
|
|
979
|
+
+ `${args.path} now holds what they approved — read it before changing it further.`
|
|
980
|
+
} else if (IMPLEMENTATIONS[name]) {
|
|
981
|
+
result = await IMPLEMENTATIONS[name](args, ctx)
|
|
982
|
+
} else {
|
|
983
|
+
throw new Error(`there is no tool called ${name}`)
|
|
984
|
+
}
|
|
985
|
+
// A web tool hands back what the model reads and, beside it, what the panel shows.
|
|
986
|
+
let web
|
|
987
|
+
let images
|
|
988
|
+
let text = result
|
|
989
|
+
if (result && typeof result === 'object' && 'text' in result) ({ text, web, images } = result)
|
|
990
|
+
let diff
|
|
991
|
+
if (WRITES.has(name)) {
|
|
992
|
+
try {
|
|
993
|
+
const uri = resolve(args.path)
|
|
994
|
+
const after = files.parseText(decoder.decode(await vscode.workspace.fs.readFile(uri))).text
|
|
995
|
+
diff = summarize(before, after)
|
|
996
|
+
diff.key = rememberBefore(args.path, before ?? '')
|
|
997
|
+
if (!/\.ipynb$/.test(args.path)) text += await files.diagnosticsAfter(uri)
|
|
998
|
+
} catch { /* the file could not be read back; the row still says it was written */ }
|
|
999
|
+
}
|
|
1000
|
+
report({ tool: name, args, status: 'done', result: text, diff, web, tasks: isRun ? shell.taskList() : undefined })
|
|
1001
|
+
return images?.length ? { text, images } : text
|
|
1002
|
+
} catch (error) {
|
|
1003
|
+
// A failed tool is information for the model, not the end of the conversation.
|
|
1004
|
+
return fail(error)
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// ---- helper agents (the task tool) ----
|
|
1009
|
+
// A piece of work handed to a fresh agent, as Claude Code's subagents are: its own
|
|
1010
|
+
// context, so the files it reads and the searches it runs never fill the main one; only
|
|
1011
|
+
// its final report comes back. "explore" only looks; "general" may change things, asking
|
|
1012
|
+
// as the main agent would. The panel shows one row for it, with its latest activity.
|
|
1013
|
+
const HELPER_STEPS = 40
|
|
1014
|
+
|
|
1015
|
+
const HELPER_SYSTEM = (type) => `You are a helper agent working for AIPictury's coding agent, inside the
|
|
1016
|
+
user's workspace. You were given one task; do it with the tools and then reply with your
|
|
1017
|
+
final report — that report is all the main agent will see, so make it complete and
|
|
1018
|
+
specific: file paths with line numbers, the facts found, what you changed. Be efficient:
|
|
1019
|
+
search (grep, glob, code_intel) before reading whole files, and read only what you need.
|
|
1020
|
+
${type === 'explore' ? 'You are read-only: find and explain, do not change anything.' : 'You may change files and run commands when the task needs it.'}`
|
|
1021
|
+
|
|
1022
|
+
// Claude Code's tool names, in an agent's "tools:", as ours.
|
|
1023
|
+
const AGENT_TOOLS = { Read: ['read_file', 'list_files'], LS: ['list_files'], Grep: ['grep'], Glob: ['glob'], Bash: ['run_command', 'bash_output', 'kill_shell'],
|
|
1024
|
+
Edit: ['edit_file'], MultiEdit: ['edit_file'], Write: ['write_file'], NotebookEdit: ['notebook_edit'], WebFetch: ['fetch_url'],
|
|
1025
|
+
WebSearch: ['web_search'], TodoWrite: [] }
|
|
1026
|
+
|
|
1027
|
+
/** The helpers of a conversation: id or name → their record (messages, state, status). */
|
|
1028
|
+
function helpersOf(session) { return (session.helpers ||= new Map()) }
|
|
1029
|
+
|
|
1030
|
+
/** A helper by its id or its name. */
|
|
1031
|
+
function findHelper(session, key) {
|
|
1032
|
+
const all = helpersOf(session)
|
|
1033
|
+
return all.get(String(key)) || [...all.values()].find((h) => h.name && h.name === String(key))
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
async function runHelper(args, { server, session, signal, report, hooks, model }) {
|
|
1037
|
+
// A helper with a role (an agent file: .aipictury/agents, .claude/agents, a plugin's agents/):
|
|
1038
|
+
// its instructions, and only the tools it lists.
|
|
1039
|
+
const role = !['explore', 'general', undefined, ''].includes(args.subagent_type)
|
|
1040
|
+
? context.agents().find((a) => a.name === args.subagent_type) : null
|
|
1041
|
+
if (!role && !['explore', 'general', undefined, ''].includes(args.subagent_type)) {
|
|
1042
|
+
return `error: no helper called ${args.subagent_type} — use "explore", "general" or one of: ${context.agents().map((a) => a.name).join(', ') || '(none)'}`
|
|
1043
|
+
}
|
|
1044
|
+
const name = String(args.name || '').trim().replace(/[^\w.-]+/g, '-').slice(0, 40) || null
|
|
1045
|
+
if (name && findHelper(session, name)) return `error: there is already a helper called ${name} — send_message to it, or choose another name`
|
|
1046
|
+
const allowedTools = role?.tools.length ? new Set(role.tools.flatMap((t) => AGENT_TOOLS[t] || [t])) : null
|
|
1047
|
+
const writes = !allowedTools || [...allowedTools].some((t) => WRITES.has(t) || t === 'run_command')
|
|
1048
|
+
const type = role ? (writes ? 'general' : 'explore') : args.subagent_type === 'general' ? 'general' : 'explore'
|
|
1049
|
+
const sub = { allowAll: session.allowAll, allowCommands: session.allowCommands, allowSites: session.allowSites, seen: new Map(), cwd: session.cwd, worktree: session.worktree }
|
|
1050
|
+
// isolation: "worktree" — the helper works in a checkout of its own.
|
|
1051
|
+
let ownTree = null
|
|
1052
|
+
if (args.isolation === 'worktree') {
|
|
1053
|
+
try { ownTree = await worktrees.enter(folderPath(), `helper-${Date.now().toString(36)}`) } catch (error) { return `error: no worktree for the helper: ${error.message}` }
|
|
1054
|
+
sub.worktree = ownTree
|
|
1055
|
+
sub.cwd = ownTree.path
|
|
1056
|
+
}
|
|
1057
|
+
const readOnly = type === 'explore' || modeNow() === 'plan'
|
|
1058
|
+
const tools = toolSchema({ readOnly, helper: true, extra: [...mcp.schema({ readOnly, loaded: (session.loadedMcp ||= new Set()) }), ...mcp.resourceSchema()] })
|
|
1059
|
+
.filter((t) => !allowedTools || allowedTools.has(t.function.name) || (t.function.name.startsWith('mcp__') && allowedTools.has(t.function.name)))
|
|
1060
|
+
const id = `agent_${helpersOf(session).size + 1}`
|
|
1061
|
+
const h = {
|
|
1062
|
+
id, name, type, role: role?.name || null, description: String(args.description || '').slice(0, 80), tools, readOnly, sub, ownTree,
|
|
1063
|
+
messages: [{ role: 'system', content: HELPER_SYSTEM(type) + (role ? `\n\n## Your role: ${role.name}\n${role.body}` : '') + context.instructionsBlock() },
|
|
1064
|
+
{ role: 'user', content: String(args.prompt || args.description || '') }],
|
|
1065
|
+
state: { steps: 0, tokens: 0, last: '' }, status: 'running', started: Date.now(), inbox: [], background: !!args.run_in_background,
|
|
1066
|
+
}
|
|
1067
|
+
helpersOf(session).set(id, h)
|
|
1068
|
+
const run = () => helperLoop(h, { args, server, session, signal: h.background ? null : signal, report, hooks, model })
|
|
1069
|
+
if (!h.background) return run()
|
|
1070
|
+
// In the background: the main agent carries on; the report arrives as an event.
|
|
1071
|
+
run().then((text) => hooks?.onHelperDone?.(h, text)).catch(() => {})
|
|
1072
|
+
return `Started ${id}${name ? ` (${name})` : ''} in the background. Its report will reach you as an event when it is done; `
|
|
1073
|
+
+ `send_message to it meanwhile, list_agents to see it, task_stop to stop it.`
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/** A helper's work, until its report — started, or resumed by send_message. */
|
|
1077
|
+
async function helperLoop(h, { args, server, session, signal, report, hooks, model }) {
|
|
1078
|
+
const { state, sub, tools, readOnly, type, ownTree } = h
|
|
1079
|
+
h.status = 'running'
|
|
1080
|
+
h.controller = new AbortController()
|
|
1081
|
+
const stopAll = () => h.controller.abort()
|
|
1082
|
+
signal?.addEventListener('abort', stopAll, { once: true })
|
|
1083
|
+
const halt = h.controller.signal
|
|
1084
|
+
const started = Date.now()
|
|
1085
|
+
const show = (status, extra = {}) => report({ tool: 'task', args: { ...args, subagent_type: h.role || type }, status,
|
|
1086
|
+
progress: { steps: state.steps, tokens: state.tokens, seconds: Math.round((Date.now() - h.started) / 1000), last: state.last }, ...extra })
|
|
1087
|
+
show('running')
|
|
1088
|
+
const { messages } = h
|
|
1089
|
+
const recent = []
|
|
1090
|
+
let known = null
|
|
1091
|
+
const holds = new Map()
|
|
1092
|
+
try {
|
|
1093
|
+
for (;;) {
|
|
1094
|
+
if (halt.aborted) throw Object.assign(new Error('stopped'), { name: 'AbortError' })
|
|
1095
|
+
// Messages sent to it meanwhile (send_message) join between its steps.
|
|
1096
|
+
if (h.inbox.length) messages.push(...h.inbox.splice(0).map((text) => ({ role: 'user', content: `[A message from the main agent:]\n${text}` })))
|
|
1097
|
+
const result = await steadyTurn(server, messages, { tools, model, signal: halt, think: false })
|
|
1098
|
+
state.tokens += (result.meta?.tokens || 0)
|
|
1099
|
+
const { text, calls } = result
|
|
1100
|
+
messages.push({ role: 'assistant', content: text, tool_calls: calls })
|
|
1101
|
+
if (!calls.length && state.steps < HELPER_STEPS && (state.stopBlocks || 0) < 3) {
|
|
1102
|
+
const held = await userHooks.fire('SubagentStop', { stop_hook_active: (state.stopBlocks || 0) > 0 }, hookContext(session, halt, hooks || {}))
|
|
1103
|
+
if (held.block) {
|
|
1104
|
+
state.stopBlocks = (state.stopBlocks || 0) + 1
|
|
1105
|
+
messages.push({ role: 'user', content: `A SubagentStop hook says this is not finished yet: ${held.reasons.join('\n')}\nCarry on, then report.` })
|
|
1106
|
+
continue
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
if (!calls.length && h.inbox.length) continue
|
|
1110
|
+
if (!calls.length || state.steps >= HELPER_STEPS) {
|
|
1111
|
+
const final = String(text || '').trim() || '(the helper finished without a report)'
|
|
1112
|
+
let where = ''
|
|
1113
|
+
if (ownTree) {
|
|
1114
|
+
const left = await worktrees.changes(ownTree)
|
|
1115
|
+
if (left.commits || left.dirty.length) where = `\n\nIts changes are in the worktree ${ownTree.relative} (branch ${ownTree.branch}): ${left.commits} commit(s), ${left.dirty.length} uncommitted change(s).`
|
|
1116
|
+
else if (!h.name) await worktrees.exit(folderPath(), ownTree, { action: 'remove' })
|
|
1117
|
+
}
|
|
1118
|
+
h.status = 'done'
|
|
1119
|
+
h.report = final
|
|
1120
|
+
show('done', { result: final })
|
|
1121
|
+
return `Report from ${h.id}${h.name ? ` (${h.name})` : ''} (${type}, ${state.steps} steps, ${Math.round((Date.now() - started) / 1000)}s):\n\n${final}${where}`
|
|
1122
|
+
}
|
|
1123
|
+
for (const call of calls) {
|
|
1124
|
+
state.steps += 1
|
|
1125
|
+
state.last = `${call.function.name} ${String(call.function.arguments?.path || call.function.arguments?.pattern
|
|
1126
|
+
|| call.function.arguments?.command || call.function.arguments?.query || '').slice(0, 80)}`.trim()
|
|
1127
|
+
show('running')
|
|
1128
|
+
recent.push(signature(call))
|
|
1129
|
+
if (isSpinning(recent)) {
|
|
1130
|
+
messages.push({ role: 'tool', tool_name: call.function.name, content: 'You are repeating yourself. Write your final report now.' })
|
|
1131
|
+
break
|
|
1132
|
+
}
|
|
1133
|
+
// A helper cannot ask the user: a package they have not chosen goes back as a question.
|
|
1134
|
+
const brought = deps.brings(call.function.name, parsedArgs(call))
|
|
1135
|
+
if (brought.length && hooks?.userSaid) {
|
|
1136
|
+
known ??= await deps.declared()
|
|
1137
|
+
const open = deps.unchosen(brought, hooks.userSaid(), known, holds)
|
|
1138
|
+
if (open.length) {
|
|
1139
|
+
messages.push({ role: 'tool', tool_name: call.function.name, content: deps.holdNote(open, { helper: true }) })
|
|
1140
|
+
continue
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
// Its steps are not drawn one by one: the row shows the latest, as Claude Code's does.
|
|
1144
|
+
const out = await execute(call, readOnly && type === 'general' ? { ...sub } : sub, () => {}, halt, server,
|
|
1145
|
+
{ ...hooks, askUser: null, onTodos: null, approvePlan: null })
|
|
1146
|
+
const content = typeof out === 'object' ? out.text : out
|
|
1147
|
+
messages.push({ role: 'tool', tool_name: call.function.name, content: capResult(content) })
|
|
1148
|
+
}
|
|
1149
|
+
const fitted = compact(messages)
|
|
1150
|
+
if (fitted !== messages) messages.splice(0, messages.length, ...fitted)
|
|
1151
|
+
}
|
|
1152
|
+
} catch (error) {
|
|
1153
|
+
if (error.name === 'AbortError') { h.status = 'stopped'; show('refused'); return `${h.id} was stopped` }
|
|
1154
|
+
h.status = 'failed'
|
|
1155
|
+
show('failed', { result: error.message })
|
|
1156
|
+
return `error: the helper failed: ${error.message}`
|
|
1157
|
+
} finally {
|
|
1158
|
+
signal?.removeEventListener('abort', stopAll)
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/** workflow: steps run as helpers in the order their dependencies give — one at a time (one GPU) —
|
|
1163
|
+
* each told what the steps it depends on found; one report of all of them. */
|
|
1164
|
+
async function runWorkflow(args, ctx) {
|
|
1165
|
+
let order
|
|
1166
|
+
try { order = workflows.plan(args.steps) } catch (error) { return `error: ${error.message}` }
|
|
1167
|
+
const results = new Map()
|
|
1168
|
+
const show = (status, extra = {}) => ctx.report({ tool: 'workflow', args: { goal: args.goal, steps: order.length }, status,
|
|
1169
|
+
progress: { done: results.size, total: order.length, current: extra.current }, ...extra })
|
|
1170
|
+
for (const s of order) {
|
|
1171
|
+
if (ctx.signal?.aborted) break
|
|
1172
|
+
show('running', { current: s.name })
|
|
1173
|
+
const out = await runHelper({ description: s.name, prompt: workflows.stepPrompt(s, args.goal, results), subagent_type: s.subagent_type || 'general' },
|
|
1174
|
+
{ ...ctx, report: () => {} })
|
|
1175
|
+
results.set(s.name, String(out).replace(/^Report from [^\n]*\n\n/, ''))
|
|
1176
|
+
}
|
|
1177
|
+
show('done', { result: `${results.size}/${order.length} steps` })
|
|
1178
|
+
return workflows.combine(order, results, { goal: args.goal, stopped: !!ctx.signal?.aborted })
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/** send_message: a message to a named (or numbered) helper — between its steps while it works,
|
|
1182
|
+
* or it carries on from where it was when it has reported. */
|
|
1183
|
+
async function sendToHelper(args, ctx) {
|
|
1184
|
+
const h = findHelper(ctx.session, args.to)
|
|
1185
|
+
if (!h) return `error: no helper ${args.to} — list_agents shows them`
|
|
1186
|
+
const text = String(args.message || '').trim()
|
|
1187
|
+
if (!text) return 'error: an empty message'
|
|
1188
|
+
if (h.status === 'running') { h.inbox.push(text); return `Sent to ${h.id}: it reads it before its next step.` }
|
|
1189
|
+
h.inbox.push(text)
|
|
1190
|
+
const run = () => helperLoop(h, { args: { description: h.description, subagent_type: h.role || h.type }, ...ctx, signal: h.background ? null : ctx.signal })
|
|
1191
|
+
if (!h.background) return run()
|
|
1192
|
+
run().then((out) => ctx.hooks?.onHelperDone?.(h, out)).catch(() => {})
|
|
1193
|
+
return `Sent to ${h.id}: it carries on in the background; its report will reach you as an event.`
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
function listHelpers(session) {
|
|
1197
|
+
const all = [...helpersOf(session).values()]
|
|
1198
|
+
if (!all.length) return 'No helpers in this conversation yet.'
|
|
1199
|
+
return all.map((h) => `${h.id}${h.name ? ` (${h.name})` : ''} — ${h.role || h.type}, ${h.status}${h.background ? ', in the background' : ''}`
|
|
1200
|
+
+ ` · ${h.state.steps} steps, ${h.state.tokens} tokens, ${Math.round((Date.now() - h.started) / 1000)} s · ${h.description}`).join('\n')
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// A fenced block with a few lines in it: enough to be a change the model meant to make
|
|
1204
|
+
// rather than an inline example.
|
|
1205
|
+
const CODE_BLOCK = /```[\w-]*\n(?:[^\n]*\n){3,}?```/
|
|
1206
|
+
|
|
1207
|
+
// A request that asks for something to be made or changed, in English or Spanish.
|
|
1208
|
+
const ASKS_CHANGE = /\b(add|change|fix|implement|rename|refactor|update|write|create|remove|delete|replace|modify|edit|convert|move|make|build|set ?up|install|apply|insert|append|generate|go ahead|do it|agreg|añad|cambi|arregl|corrig|renombr|actualiz|escrib|cre[aá]|elimin|borr|reemplaz|modific|mov[eé]|hacé|hacelo|hazlo|haz |gener|instal|aplic|insert|dale)/i
|
|
1209
|
+
|
|
1210
|
+
/** A tool call's arguments as an object, whether they came parsed or as JSON text. */
|
|
1211
|
+
function parsedArgs(call) {
|
|
1212
|
+
const args = call.function.arguments || {}
|
|
1213
|
+
if (typeof args !== 'string') return args
|
|
1214
|
+
try { return JSON.parse(args) } catch { return {} }
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/** Whether the user's latest message asks for a change (not only to find or explain). */
|
|
1218
|
+
function askedForChange(messages, fromUser) {
|
|
1219
|
+
const last = [...messages].reverse().find((m) => fromUser.has(m))
|
|
1220
|
+
if (!last) return true // no record of what was asked: keep the safety net
|
|
1221
|
+
const text = typeof last.content === 'string' ? last.content
|
|
1222
|
+
: (last.content || []).map((p) => p.text || '').join(' ')
|
|
1223
|
+
return ASKS_CHANGE.test(text)
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/**
|
|
1227
|
+
* True when every fenced block in the text only quotes what the tools returned — code the
|
|
1228
|
+
* model read and is showing back, as in "where is route defined?", not a change it drafted.
|
|
1229
|
+
* Line numbers from read_file ("12\t…") are ignored, as is indentation.
|
|
1230
|
+
*/
|
|
1231
|
+
function onlyQuotes(text, messages) {
|
|
1232
|
+
const blocks = [...String(text || '').matchAll(/```[\w-]*\n([\s\S]*?)```/g)].map((m) => m[1])
|
|
1233
|
+
if (!blocks.length) return false
|
|
1234
|
+
const seen = new Set()
|
|
1235
|
+
for (const m of messages) {
|
|
1236
|
+
if (m.role !== 'tool') continue
|
|
1237
|
+
for (const line of String(m.content || '').split('\n')) seen.add(line.replace(/^\s*\d+\t/, '').trim())
|
|
1238
|
+
}
|
|
1239
|
+
return blocks.every((block) => block.split('\n').map((l) => l.trim()).filter(Boolean).every((l) => seen.has(l)))
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// ---- keeping the conversation inside the model's window ----
|
|
1243
|
+
// The model sees a 32k-token window. The agent used to resend its whole history every
|
|
1244
|
+
// turn — every file read, every page fetched — so a long task eventually filled the
|
|
1245
|
+
// window and Ollama failed with a 500 (or spent minutes re-reading 32k tokens). This
|
|
1246
|
+
// keeps what is sent under budget the way Claude Code's auto-compact does: recent steps
|
|
1247
|
+
// stay whole, older tool output is replaced by a note saying it can be fetched again.
|
|
1248
|
+
// The budget is in tokens, and the character count that fits is *measured*: code and
|
|
1249
|
+
// repetitive text tokenize far denser than prose, so a fixed chars-per-token guess
|
|
1250
|
+
// either wastes the window or overflows it. Every turn Ollama reports how many tokens
|
|
1251
|
+
// the prompt really took; the ratio is learned from that, starting conservative.
|
|
1252
|
+
// Of the 128k window (config: agent_num_ctx). Compaction keeps what is sent under this;
|
|
1253
|
+
// the rest is headroom for the answer, the tools and the reasoning. Far larger than the
|
|
1254
|
+
// old 18k, so summaries are rare and, when they happen, the model has room to work.
|
|
1255
|
+
// 112k of the 131k window (measured: a larger window no longer fits the GPU and the 27B
|
|
1256
|
+
// model drops from 40 to 11 tok/s at 196k): the rest is room for the tools (~6k) and the answer.
|
|
1257
|
+
const TARGET_TOKENS = 112_000
|
|
1258
|
+
let charsPerToken = 2.5 // conservative start; replaced by measurement
|
|
1259
|
+
// The window the model in use is loaded with — 131k, or less for a model measured not to
|
|
1260
|
+
// fit the GPU at that (the server says which). A smaller window gets a smaller budget.
|
|
1261
|
+
const FULL_WINDOW = 131_072
|
|
1262
|
+
let windowTokens = FULL_WINDOW
|
|
1263
|
+
const targetTokens = () => (windowTokens >= FULL_WINDOW ? TARGET_TOKENS : Math.round(windowTokens * 0.75))
|
|
1264
|
+
const setWindow = (tokens) => { windowTokens = Number(tokens) > 0 ? Number(tokens) : FULL_WINDOW }
|
|
1265
|
+
const budget = () => Math.round(targetTokens() * charsPerToken)
|
|
1266
|
+
const TOOL_RESULT_CAP = 16_000 // one tool result may not take more than this
|
|
1267
|
+
|
|
1268
|
+
/** Roughly how many tokens these messages take, at the density measured so far. */
|
|
1269
|
+
function estimateTokens(messages) { return Math.round(size(messages) / charsPerToken) }
|
|
1270
|
+
|
|
1271
|
+
/** Learns the real density from what a turn cost. The prompt count also includes the
|
|
1272
|
+
* tool list and the chat template, so the ratio errs small — i.e. safe. */
|
|
1273
|
+
function calibrate(sentChars, promptTokens) {
|
|
1274
|
+
if (!promptTokens || promptTokens < 500 || !sentChars) return
|
|
1275
|
+
const measured = sentChars / promptTokens
|
|
1276
|
+
charsPerToken = Math.min(4, Math.max(1.5, measured))
|
|
1277
|
+
}
|
|
1278
|
+
const KEEP_RECENT = 6 // the newest messages are never shortened
|
|
1279
|
+
|
|
1280
|
+
/** Caps one tool result as it enters the conversation. */
|
|
1281
|
+
function capResult(text) {
|
|
1282
|
+
const t = String(text)
|
|
1283
|
+
if (t.length <= TOOL_RESULT_CAP) return t
|
|
1284
|
+
return `${safeSlice(t, 0, TOOL_RESULT_CAP)}\n… [${t.length - TOOL_RESULT_CAP} more characters not shown — `
|
|
1285
|
+
+ 'read a narrower range, or search, if you need them]'
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// A picture costs the model roughly a thousand tokens; counted as characters here.
|
|
1289
|
+
const IMAGE_COST = 4_000
|
|
1290
|
+
const size = (messages) => messages.reduce((n, m) => n + String(m.content || '').length
|
|
1291
|
+
+ (m.tool_calls ? JSON.stringify(m.tool_calls).length : 0)
|
|
1292
|
+
+ (m.images ? m.images.length * IMAGE_COST : 0), 0)
|
|
1293
|
+
|
|
1294
|
+
/** What is actually sent: the same conversation, shortened from the oldest end until
|
|
1295
|
+
* it fits. The system prompt and the first question are always kept. */
|
|
1296
|
+
function compact(messages, limit = budget()) {
|
|
1297
|
+
if (size(messages) <= limit) return messages
|
|
1298
|
+
const out = messages.map((m) => ({ ...m }))
|
|
1299
|
+
const firstUser = out.findIndex((m) => m.role === 'user')
|
|
1300
|
+
const protectedIdx = new Set([0, firstUser])
|
|
1301
|
+
const middle = []
|
|
1302
|
+
for (let i = 1; i < out.length - KEEP_RECENT; i += 1) if (!protectedIdx.has(i)) middle.push(i)
|
|
1303
|
+
|
|
1304
|
+
// 1. Old tool output becomes a one-line note.
|
|
1305
|
+
for (const i of middle) {
|
|
1306
|
+
if (size(out) <= limit) return out
|
|
1307
|
+
if (out[i].role === 'tool' && String(out[i].content).length > 200) {
|
|
1308
|
+
out[i].content = `[earlier output of ${out[i].tool_name || 'a tool'} omitted to fit the `
|
|
1309
|
+
+ 'context window — call it again if you still need it]'
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
// 2. Old reasoning-out-loud from the assistant is shortened.
|
|
1313
|
+
for (const i of middle) {
|
|
1314
|
+
if (size(out) <= limit) return out
|
|
1315
|
+
if (out[i].role === 'assistant' && String(out[i].content || '').length > 400) {
|
|
1316
|
+
out[i].content = `${safeSlice(out[i].content, 0, 400)} …`
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
// 3. Still too big: drop whole old steps (an assistant turn and its tool results
|
|
1320
|
+
// together, so every tool result still follows the call that asked for it).
|
|
1321
|
+
let dropped = 0
|
|
1322
|
+
while (size(out) > limit) {
|
|
1323
|
+
const at = out.findIndex((m, i) => i > firstUser && i < out.length - KEEP_RECENT
|
|
1324
|
+
&& m.role === 'assistant')
|
|
1325
|
+
if (at === -1) break
|
|
1326
|
+
let end = at + 1
|
|
1327
|
+
while (end < out.length - KEEP_RECENT && out[end].role === 'tool') end += 1
|
|
1328
|
+
out.splice(at, end - at)
|
|
1329
|
+
dropped += 1
|
|
1330
|
+
}
|
|
1331
|
+
if (dropped) {
|
|
1332
|
+
out.splice(firstUser + 1, 0, { role: 'user',
|
|
1333
|
+
content: `[${dropped} earlier step(s) omitted to fit the context window.]` })
|
|
1334
|
+
}
|
|
1335
|
+
// 4. Last resort: the recent steps alone are too big (a few large files in a row).
|
|
1336
|
+
// Shorten their tool output too, oldest first, keeping the newest result whole.
|
|
1337
|
+
for (let i = firstUser + 1; i < out.length - 1 && size(out) > limit; i += 1) {
|
|
1338
|
+
if (out[i].role === 'tool' && String(out[i].content).length > 3000) {
|
|
1339
|
+
out[i].content = `${safeSlice(out[i].content, 0, 3000)}\n… [shortened to fit the `
|
|
1340
|
+
+ 'context window — read it again if you need the rest]'
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
return out
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// A line that reads as source code, for spotting a file written out in the reasoning.
|
|
1347
|
+
const CODE_LINE = /^\s*(def |class |import |from \S+ import|function |const |let |var |export |return |async |public |private |#include|<\/?[a-zA-Z][\w-]*[ >]|[}\])];?\s*$|.*[{(]\s*$|.*=>)/
|
|
1348
|
+
|
|
1349
|
+
/** True when the reasoning contains a real block of code rather than a passing mention. */
|
|
1350
|
+
function looksLikeCode(text) {
|
|
1351
|
+
return String(text || '').split('\n').filter((line) => CODE_LINE.test(line)).length >= 5
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// ---- summarizing the older part of a long conversation ----
|
|
1355
|
+
// Dropping old steps loses them — including an instruction the user gave halfway
|
|
1356
|
+
// through. When the conversation nears the window, the older part is summarized
|
|
1357
|
+
// instead: the user's goals and instructions, decisions, what was changed, what is left.
|
|
1358
|
+
// The summary replaces those messages in what is kept, and is itself folded into the
|
|
1359
|
+
// next summary, so a long session keeps its thread. Rules saved with `remember` sit in
|
|
1360
|
+
// the system prompt and never depend on this.
|
|
1361
|
+
const SUMMARY_AT = 0.9 // share of the budget that triggers a summary (~100k tokens)
|
|
1362
|
+
const SUMMARY_MARK = '[Summary of the earlier part of this conversation'
|
|
1363
|
+
|
|
1364
|
+
function asDialogue(messages) {
|
|
1365
|
+
return messages.map((m) => {
|
|
1366
|
+
if (m.role === 'tool') return `TOOL RESULT (${m.tool_name || 'tool'}): ${safeSlice(m.content || '', 0, 1500)}`
|
|
1367
|
+
if (m.role === 'assistant') {
|
|
1368
|
+
const calls = (m.tool_calls || []).map((c) => `${c.function.name}(${safeSlice(JSON.stringify(c.function.arguments || {}), 0, 300)})`)
|
|
1369
|
+
return `ASSISTANT: ${safeSlice(m.content || '', 0, 3000)}${calls.length ? `\n called: ${calls.join('; ')}` : ''}`
|
|
1370
|
+
}
|
|
1371
|
+
return `USER: ${safeSlice(m.content || '', 0, 6000)}`
|
|
1372
|
+
}).join('\n\n')
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/** Replaces the older part of `messages` with a summary written by the model. Returns
|
|
1376
|
+
* the new list, or null when there is too little to summarize or the model failed. */
|
|
1377
|
+
const READ_TPS = 1000 // prompt tokens/s this GPU reads uncached (measured)
|
|
1378
|
+
|
|
1379
|
+
/** How far a summary has got, as a percentage the panel can show. Reading the
|
|
1380
|
+
* conversation is the first 40% — timed against how long a prompt that size takes to
|
|
1381
|
+
* read — and writing the summary the rest, by its length against the length expected.
|
|
1382
|
+
* It stops at 95%: only the end says 100. */
|
|
1383
|
+
function summaryProgress(dialogueChars, onProgress) {
|
|
1384
|
+
const started = Date.now()
|
|
1385
|
+
const readFor = Math.max(3, dialogueChars / charsPerToken / READ_TPS)
|
|
1386
|
+
const expected = Math.min(5000, Math.max(1500, dialogueChars * 0.08))
|
|
1387
|
+
let written = 0
|
|
1388
|
+
let reading = true
|
|
1389
|
+
const tick = () => {
|
|
1390
|
+
const seconds = (Date.now() - started) / 1000
|
|
1391
|
+
const percent = reading
|
|
1392
|
+
? Math.min(40, (seconds / readFor) * 40)
|
|
1393
|
+
: 40 + Math.min(55, (written / expected) * 55)
|
|
1394
|
+
// `stage`, not `phase`: the caller sends this inside a message whose `phase` says what
|
|
1395
|
+
// kind of message it is — the same name overwrote it, and every tick read as the end.
|
|
1396
|
+
onProgress?.({ percent: Math.round(percent), stage: reading ? 'reading' : 'writing', seconds: Math.round(seconds) })
|
|
1397
|
+
}
|
|
1398
|
+
const timer = setInterval(tick, 500)
|
|
1399
|
+
tick()
|
|
1400
|
+
return {
|
|
1401
|
+
wrote: (chars) => { reading = false; written += chars },
|
|
1402
|
+
stop: () => clearInterval(timer),
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/** What a summary would cover — the messages between the first question and the recent
|
|
1407
|
+
* ones kept whole — or null when there is nothing worth summarizing. One plan for both
|
|
1408
|
+
* deciding to summarize and summarizing: they disagreed, and a run announced a summary on
|
|
1409
|
+
* every step that then had nothing to do (the conversation was large, but in its recent
|
|
1410
|
+
* messages, which are never summarized). */
|
|
1411
|
+
function summaryPlan(messages, force = false) {
|
|
1412
|
+
if (!force && size(messages) < budget() * SUMMARY_AT) return null
|
|
1413
|
+
const firstUser = messages.findIndex((m) => m.role === 'user')
|
|
1414
|
+
let end = messages.length - KEEP_RECENT
|
|
1415
|
+
// Never split an assistant's tool calls from their results.
|
|
1416
|
+
while (end > firstUser + 1 && messages[end] && messages[end].role === 'tool') end -= 1
|
|
1417
|
+
const older = messages.slice(firstUser + 1, end)
|
|
1418
|
+
if (older.length < (force ? 1 : 4)) return null
|
|
1419
|
+
return { firstUser, end, older }
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
async function summarizeEarlier(server, messages, { model, signal, force = false, onProgress, focus = '' } = {}) {
|
|
1423
|
+
const plan = summaryPlan(messages, force)
|
|
1424
|
+
if (!plan) return null
|
|
1425
|
+
const { firstUser, end, older } = plan
|
|
1426
|
+
const dialogue = safeSlice(asDialogue(older), -Math.round(budget() * 0.9))
|
|
1427
|
+
const progress = summaryProgress(dialogue.length, onProgress)
|
|
1428
|
+
try {
|
|
1429
|
+
const { text } = await turn(server, [
|
|
1430
|
+
{ role: 'system', content: 'You write the working notes that let a coding agent carry on '
|
|
1431
|
+
+ 'a conversation after its early part is removed from its memory.' },
|
|
1432
|
+
{ role: 'user', content: 'Summarize the conversation below so it can be continued without '
|
|
1433
|
+
+ 'it. Keep, precisely: the user\'s goals and every instruction, preference or rule they '
|
|
1434
|
+
+ 'gave; decisions made and why; files created or changed and what they now contain, in '
|
|
1435
|
+
+ 'outline; commands run and how they ended; problems still open and what is left to do. '
|
|
1436
|
+
+ 'Be concise, but never drop an instruction. Write in the language the user writes in.'
|
|
1437
|
+
+ (focus ? ` Pay particular attention to: ${focus}.` : '')
|
|
1438
|
+
+ `\n\n<conversation>\n${dialogue}\n</conversation>` },
|
|
1439
|
+
], { tools: [], think: false, model, signal, onDelta: (d) => progress.wrote(d.length) })
|
|
1440
|
+
if (!text.trim()) return null
|
|
1441
|
+
return [
|
|
1442
|
+
...messages.slice(0, firstUser + 1),
|
|
1443
|
+
{ role: 'user', content: `${SUMMARY_MARK} — written to keep the context window in bounds]\n${text.trim()}` },
|
|
1444
|
+
...messages.slice(end),
|
|
1445
|
+
]
|
|
1446
|
+
} catch (error) {
|
|
1447
|
+
if (error.name === 'AbortError') throw error
|
|
1448
|
+
return null // mechanical compaction still keeps the turn inside the window
|
|
1449
|
+
} finally {
|
|
1450
|
+
progress.stop()
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// ---- a budget for the reasoning ----
|
|
1455
|
+
// Measured on a real session: asked to build a frontend, the model spent 4½ minutes and
|
|
1456
|
+
// 9,316 tokens writing every component out in its reasoning, with no tool call — and it
|
|
1457
|
+
// could never have finished, since writing it all again in the tool calls would have
|
|
1458
|
+
// overflowed the window. Reasoning is kept (it is useful to watch), but once it has
|
|
1459
|
+
// turned into a long draft of code with nothing applied, that generation is stopped and
|
|
1460
|
+
// the model is told to write the files, with reasoning off for that one step.
|
|
1461
|
+
const THINK_BUDGET = 6_000 // characters of reasoning (~1.5k tokens) before a code draft is cut
|
|
1462
|
+
|
|
1463
|
+
/** True when the reasoning has become a draft of the code rather than a plan. */
|
|
1464
|
+
function overthinking(thinking) {
|
|
1465
|
+
return thinking.length > THINK_BUDGET && (CODE_BLOCK.test(thinking) || looksLikeCode(thinking))
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
// ---- reasoning that goes round in circles ----
|
|
1469
|
+
// A model that loses its thread starts restating what it already thought — the same
|
|
1470
|
+
// paragraphs, back and forth, for minutes. Measured by word 8-grams: in ordinary
|
|
1471
|
+
// reasoning almost every 8-word run is new, while in a loop most of the latest
|
|
1472
|
+
// stretch has already been said. When it has, the reasoning is stopped and the model
|
|
1473
|
+
// is handed back what it had worked out, with the repeats removed, and told to act.
|
|
1474
|
+
const GRAM = 8
|
|
1475
|
+
const LOOP_TAIL = 240 // words: the latest stretch of reasoning that is judged
|
|
1476
|
+
const LOOP_SHARE = 0.6 // share of that stretch already said before = a loop
|
|
1477
|
+
const LOOP_MIN_WORDS = 500 // too short to tell before this
|
|
1478
|
+
|
|
1479
|
+
const words = (text) => String(text || '').toLowerCase().match(/[\p{L}\p{N}_]+/gu) || []
|
|
1480
|
+
|
|
1481
|
+
/** True when most of the latest reasoning repeats what was already reasoned. */
|
|
1482
|
+
function thinkingLoops(thinking) {
|
|
1483
|
+
const w = words(thinking)
|
|
1484
|
+
if (w.length < LOOP_MIN_WORDS) return false
|
|
1485
|
+
const seen = new Set()
|
|
1486
|
+
const tailStart = w.length - LOOP_TAIL
|
|
1487
|
+
let judged = 0
|
|
1488
|
+
let repeated = 0
|
|
1489
|
+
for (let i = 0; i + GRAM <= w.length; i += 1) {
|
|
1490
|
+
const gram = w.slice(i, i + GRAM).join(' ')
|
|
1491
|
+
if (i >= tailStart) {
|
|
1492
|
+
judged += 1
|
|
1493
|
+
if (seen.has(gram)) repeated += 1
|
|
1494
|
+
}
|
|
1495
|
+
seen.add(gram)
|
|
1496
|
+
}
|
|
1497
|
+
return judged > 0 && repeated / judged >= LOOP_SHARE
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
/** The reasoning with its repeats removed: each sentence kept once, in order. */
|
|
1501
|
+
function distill(thinking, limit = 6_000) {
|
|
1502
|
+
const seen = new Set()
|
|
1503
|
+
const lines = []
|
|
1504
|
+
// Line by line, sentence by sentence, so lists and steps keep their shape.
|
|
1505
|
+
for (const line of String(thinking || '').split('\n')) {
|
|
1506
|
+
const kept = []
|
|
1507
|
+
for (const sentence of line.split(/(?<=[.!?])\s+/)) {
|
|
1508
|
+
const w = words(sentence)
|
|
1509
|
+
if (!w.length) continue
|
|
1510
|
+
const grams = []
|
|
1511
|
+
for (let i = 0; i + GRAM <= w.length; i += 1) grams.push(w.slice(i, i + GRAM).join(' '))
|
|
1512
|
+
const keys = grams.length ? grams : [w.join(' ')]
|
|
1513
|
+
const old = keys.filter((g) => seen.has(g)).length
|
|
1514
|
+
keys.forEach((g) => seen.add(g))
|
|
1515
|
+
if (old / keys.length < 0.5) kept.push(sentence.trim()) // not said already
|
|
1516
|
+
}
|
|
1517
|
+
if (kept.length) lines.push(kept.join(' '))
|
|
1518
|
+
}
|
|
1519
|
+
let notes = lines.join('\n')
|
|
1520
|
+
// The latest conclusions matter most: when it is long, keep the start and the end.
|
|
1521
|
+
if (notes.length > limit) {
|
|
1522
|
+
notes = `${safeSlice(notes, 0, limit * 0.3)} […] ${safeSlice(notes, -limit * 0.7)}`
|
|
1523
|
+
}
|
|
1524
|
+
return notes
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
// ---- a question on the side (/btw) ----
|
|
1528
|
+
// Asked about the conversation without adding to it, as Claude Code's /btw. The prompt is
|
|
1529
|
+
// the conversation exactly as the next turn would send it — the same system prompt, the
|
|
1530
|
+
// same tools — with the question after it, so the model's prompt cache serves all of it
|
|
1531
|
+
// and the conversation's next turn still finds its own prefix cached.
|
|
1532
|
+
/** The tools of one step — the same for the run and for /btw, so the prompt cache serves both.
|
|
1533
|
+
* MCP tools past a score of them are given whole only once loaded by tool_search. */
|
|
1534
|
+
function stepTools(planning, session = {}) {
|
|
1535
|
+
const all = toolSchema({ readOnly: planning, autoMemory: vscode.workspace.getConfiguration('aipictury').get('autoMemory', true),
|
|
1536
|
+
extra: [...mcp.schema({ readOnly: planning, loaded: (session.loadedMcp ||= new Set()) }), ...mcp.resourceSchema()] })
|
|
1537
|
+
// A tool denied outright for this run (--disallowedTools "Bash") is not offered at all.
|
|
1538
|
+
const off = new Set((session.commandDeny || []).flatMap((rule) => permissions.toolsNamed(rule)))
|
|
1539
|
+
return off.size ? all.filter((t) => !off.has(t.function?.name)) : all
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
async function sideQuestion(server, history, question, { model, signal, onDelta, session, thread = [], effort } = {}) {
|
|
1543
|
+
const mode = modeNow()
|
|
1544
|
+
const rules = await memory.block(server)
|
|
1545
|
+
// The earlier side questions of the thread come after the conversation, so its prefix stays
|
|
1546
|
+
// cached, and a follow-up ("and what about…?") knows what it follows.
|
|
1547
|
+
const before = thread.flatMap((x) => [{ role: 'user', content: `[A side question:] ${x.question}` }, { role: 'assistant', content: x.answer }])
|
|
1548
|
+
const messages = [{ role: 'system', content: systemPrompt(mode, rules, effortOf(effort || vscode.workspace.getConfiguration('aipictury').get('effort', 'high'))) }, ...history, ...before,
|
|
1549
|
+
{ role: 'user', content: '[A side question — answer it briefly from what you already know of this '
|
|
1550
|
+
+ 'conversation and the code you have seen. Do not call any tool, and do not continue the task.]\n'
|
|
1551
|
+
+ question }]
|
|
1552
|
+
const tools = stepTools(mode === 'plan', session)
|
|
1553
|
+
const { text } = await steadyTurn(server, messages, { tools, model, signal, onDelta, think: false })
|
|
1554
|
+
return String(text || '').trim() || '(no answer — ask again, or ask the agent directly)'
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
// ---- getting through a lost connection ----
|
|
1558
|
+
// The server sits on Wi-Fi and shares its GPU with renders. A blip, a restart or a
|
|
1559
|
+
// render in progress must not end the task: the run waits and asks again from exactly
|
|
1560
|
+
// where it was — the conversation so far, tool results included, is all still here.
|
|
1561
|
+
const RETRY_WAITS = [5, 10, 20, 40, 60, 60, 120, 120] // seconds, then it gives up
|
|
1562
|
+
const BUSY_WAIT = 20 // seconds between GPU checks
|
|
1563
|
+
const BUSY_LIMIT = 90 // 30 minutes of a render
|
|
1564
|
+
|
|
1565
|
+
/** Waits `seconds` unless Stop is pressed first. */
|
|
1566
|
+
function pause(seconds, signal) {
|
|
1567
|
+
return new Promise((resolve, reject) => {
|
|
1568
|
+
const timer = setTimeout(done, seconds * 1000)
|
|
1569
|
+
function done() { signal?.removeEventListener('abort', onStop); resolve() }
|
|
1570
|
+
function onStop() {
|
|
1571
|
+
clearTimeout(timer)
|
|
1572
|
+
const stop = new Error('stopped'); stop.name = 'AbortError'; reject(stop)
|
|
1573
|
+
}
|
|
1574
|
+
if (signal?.aborted) return onStop()
|
|
1575
|
+
signal?.addEventListener('abort', onStop, { once: true })
|
|
1576
|
+
})
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
/** One turn, asked again through transient failures. Non-transient errors pass through. */
|
|
1580
|
+
async function steadyTurn(server, messages, options, onRetry) {
|
|
1581
|
+
let failures = 0
|
|
1582
|
+
let busy = 0
|
|
1583
|
+
for (;;) {
|
|
1584
|
+
try {
|
|
1585
|
+
return await turn(server, messages, options)
|
|
1586
|
+
} catch (error) {
|
|
1587
|
+
if (error.name === 'AbortError' || !error.transient) throw error
|
|
1588
|
+
let wait
|
|
1589
|
+
if (error.busy) {
|
|
1590
|
+
busy += 1
|
|
1591
|
+
if (busy > BUSY_LIMIT) throw error
|
|
1592
|
+
wait = BUSY_WAIT
|
|
1593
|
+
} else {
|
|
1594
|
+
if (failures >= RETRY_WAITS.length) throw error
|
|
1595
|
+
wait = RETRY_WAITS[failures]
|
|
1596
|
+
failures += 1
|
|
1597
|
+
}
|
|
1598
|
+
onRetry?.({ error: error.message, wait, attempt: error.busy ? busy : failures,
|
|
1599
|
+
of: error.busy ? BUSY_LIMIT : RETRY_WAITS.length, busy: !!error.busy })
|
|
1600
|
+
await pause(wait, options.signal)
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
// How Auto mode stops: not by capping work, but by noticing when the agent is spinning
|
|
1606
|
+
// — repeating the same call, or a short cycle of calls, without getting anywhere. A
|
|
1607
|
+
// weaker local model loops where a stronger one would finish; this catches that.
|
|
1608
|
+
const REPEAT_LIMIT = 3 // the same exact call this many times = stuck
|
|
1609
|
+
const WINDOW = 8 // over the last this-many calls
|
|
1610
|
+
|
|
1611
|
+
/** A stable signature for a call, so "the same call" is recognised regardless of key order. */
|
|
1612
|
+
function signature(call) {
|
|
1613
|
+
const args = call.function.arguments || {}
|
|
1614
|
+
const keys = Object.keys(args).sort()
|
|
1615
|
+
return `${call.function.name}(${keys.map((k) => `${k}=${JSON.stringify(args[k])}`).join(',')})`
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
/** True when the recent calls show the agent is stuck rather than making progress. */
|
|
1619
|
+
function isSpinning(recent) {
|
|
1620
|
+
const window = recent.slice(-WINDOW)
|
|
1621
|
+
// The same call, over and over.
|
|
1622
|
+
for (const sig of new Set(window)) {
|
|
1623
|
+
if (window.filter((s) => s === sig).length >= REPEAT_LIMIT) return true
|
|
1624
|
+
}
|
|
1625
|
+
// A short cycle repeated: A,B,A,B,A,B or A,B,C,A,B,C.
|
|
1626
|
+
for (const period of [2, 3]) {
|
|
1627
|
+
if (window.length >= period * 3) {
|
|
1628
|
+
const tail = window.slice(-period * 3)
|
|
1629
|
+
const unit = tail.slice(0, period).join('|')
|
|
1630
|
+
if ([1, 2].every((n) => tail.slice(period * n, period * (n + 1)).join('|') === unit)) {
|
|
1631
|
+
return true
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
return false
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
/** Runs the whole exchange: turns and tool calls until the model has nothing left. */
|
|
1639
|
+
async function run(options) {
|
|
1640
|
+
// Whatever happens, the work done so far comes back. A thrown error used to discard
|
|
1641
|
+
// every tool call and result of the run, leaving only the user's message — so
|
|
1642
|
+
// "continue" started the task over from nothing. Now the caller always receives the
|
|
1643
|
+
// conversation as far as it got, and the error beside it.
|
|
1644
|
+
const messages = []
|
|
1645
|
+
// One run at a time on the GPU (gpuRun.js): another app holding it, nothing is started.
|
|
1646
|
+
const last = [...(options.history || [])].reverse().find((m) => m.role === 'user')
|
|
1647
|
+
const label = String(last?.content || '').split('\n')[0]
|
|
1648
|
+
try {
|
|
1649
|
+
return await require('./gpuRun').holding({ server: options.server, client: vscode.cli ? 'cli' : 'vscode', label },
|
|
1650
|
+
() => work(options, messages))
|
|
1651
|
+
} catch (error) {
|
|
1652
|
+
const kept = messages.slice(1)
|
|
1653
|
+
if (error.name === 'AbortError') return { messages: kept, stopped: true }
|
|
1654
|
+
if (error.busy) return { messages: kept, error: error.message, busy: true, gpu: error.gpu }
|
|
1655
|
+
return { messages: kept, error: error.message }
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
// How hard the model reasons, as Claude Code's effort levels: low answers without
|
|
1660
|
+
// reasoning (fastest), medium and high cap the reasoning (about 1.5k and 6k tokens), max
|
|
1661
|
+
// lets it reason as long as it needs — a loop or a code draft is still cut.
|
|
1662
|
+
const EFFORT = {
|
|
1663
|
+
low: { think: false, budget: 0 },
|
|
1664
|
+
medium: { think: true, budget: 6_000 },
|
|
1665
|
+
high: { think: true, budget: 24_000 },
|
|
1666
|
+
max: { think: true, budget: 0 },
|
|
1667
|
+
// Ultracode: as max, and large tasks are planned as workflows of helpers.
|
|
1668
|
+
ultra: { think: true, budget: 0 },
|
|
1669
|
+
}
|
|
1670
|
+
const effortOf = (value) => (EFFORT[value] ? value : 'high')
|
|
1671
|
+
|
|
1672
|
+
const TODO_NOTE = `
|
|
1673
|
+
|
|
1674
|
+
Use todo_write to plan and track a task of three or more steps: the user sees the list.`
|
|
1675
|
+
|
|
1676
|
+
/** The system prompt for this step: the mode may change mid-run (a plan approved, the
|
|
1677
|
+
* user switching), and the project's instruction files are read fresh. */
|
|
1678
|
+
const ULTRA_NOTE = `
|
|
1679
|
+
|
|
1680
|
+
ULTRACODE: for a substantial task — several parts, several files, research then changes — plan it as a
|
|
1681
|
+
workflow: the workflow tool runs its steps as helpers, in order, each given what the earlier ones found,
|
|
1682
|
+
and returns one report. Then check the result yourself before you finish.`
|
|
1683
|
+
|
|
1684
|
+
// Text added after the system prompt for every run (the CLI's --append-system-prompt).
|
|
1685
|
+
let appended = ''
|
|
1686
|
+
function appendSystem(text) { appended = String(text || '').trim() }
|
|
1687
|
+
|
|
1688
|
+
function systemPrompt(mode, rules, effort) {
|
|
1689
|
+
const dirs = require('./paths').extraRoots()
|
|
1690
|
+
const added = dirs.length ? `\n\nBesides the workspace, you may also work in these folders (name their files by absolute path): ${dirs.join(', ')}.` : ''
|
|
1691
|
+
return SYSTEM + TODO_NOTE + (effort === 'ultra' ? ULTRA_NOTE : '') + (mode === 'plan' ? PLAN_NOTE : unattended(mode) ? AUTO_NOTE : '')
|
|
1692
|
+
+ context.instructionsBlock() + rules + context.styleBlock() + added + (appended ? `\n\n${appended}` : '')
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
async function work({ server, model, history, session, signal,
|
|
1696
|
+
onDelta, onThinking, onTool, onTurn, onNudge, onWaiting, onSpeed, takeQueued, onSteerable,
|
|
1697
|
+
askUser: ask, onDecision, approve: approveHook, approvePlan, onTodos, onTasks, onFileChange,
|
|
1698
|
+
readSpecial, effort: chosenEffort, onMessages, window: modelWindow, trustHooks, trustMcp, onMonitor, schedule, onHelperDone }, messages) {
|
|
1699
|
+
setWindow(modelWindow)
|
|
1700
|
+
// 0 (the default) means no limit: the agent runs until it is done or is stopped.
|
|
1701
|
+
const settings = () => vscode.workspace.getConfiguration('aipictury')
|
|
1702
|
+
const configured = settings().get('maxSteps', 0)
|
|
1703
|
+
const maxSteps = configured > 0 ? configured : Infinity
|
|
1704
|
+
// The rules are read once per request; a rule saved with remember is added as it is saved.
|
|
1705
|
+
const rules = await memory.block(server)
|
|
1706
|
+
await mcp.ensure({ trust: trustMcp }).catch(() => [])
|
|
1707
|
+
messages.push({ role: 'system', content: systemPrompt(modeNow(), rules, effortOf(chosenEffort || settings().get('effort', 'high'))) }, ...history)
|
|
1708
|
+
// What the user wrote, as opposed to the notes the loop adds in their voice.
|
|
1709
|
+
const fromUser = new Set(history.filter((m) => m.role === 'user'))
|
|
1710
|
+
let known = null // the dependencies the workspace declares, read when first needed
|
|
1711
|
+
const holds = new Map() // package → times a call bringing it in was held
|
|
1712
|
+
const hooks = { askUser: ask, onDecision, approve: approveHook, approvePlan, onTodos, onTasks, onFileChange, readSpecial, model, trustHooks, onMonitor, schedule, onHelperDone,
|
|
1713
|
+
// For helpers: what the user said, so a package is judged chosen by them, not by a task text.
|
|
1714
|
+
userSaid: () => deps.saidByUser(messages, fromUser) }
|
|
1715
|
+
let steps = 0
|
|
1716
|
+
let changed = false // has anything actually been written this time round?
|
|
1717
|
+
let nudged = false // the reminder below is given once, never in a loop
|
|
1718
|
+
const recent = [] // recent call signatures, to notice spinning
|
|
1719
|
+
let spinNudged = false
|
|
1720
|
+
let stopBlocks = 0 // times a Stop hook has kept the run going
|
|
1721
|
+
|
|
1722
|
+
let actNow = false // the previous turn drafted code in its reasoning: act this time
|
|
1723
|
+
let truncations = 0 // turns in a row cut off at the output limit
|
|
1724
|
+
let retrySummaryAt = 0 // after a summary failed: the size to reach before trying again
|
|
1725
|
+
for (;;) {
|
|
1726
|
+
if (signal?.aborted) return { messages: messages.slice(1), stopped: true }
|
|
1727
|
+
// Messages the user sent while this was running enter here, between steps — never
|
|
1728
|
+
// between a tool call and its result — and the next turn reasons about them.
|
|
1729
|
+
const queued = takeQueued?.() || []
|
|
1730
|
+
for (const m of queued) fromUser.add(m)
|
|
1731
|
+
messages.push(...queued)
|
|
1732
|
+
// A summary the run needs — only when there is something to summarize — shown as it
|
|
1733
|
+
// happens (the input held), then what it saved. One that failed is not tried again on
|
|
1734
|
+
// every step: only once the conversation has grown by a fifth since.
|
|
1735
|
+
if (summaryPlan(messages) && size(messages) > retrySummaryAt) {
|
|
1736
|
+
const before = estimateTokens(messages)
|
|
1737
|
+
const started = Date.now()
|
|
1738
|
+
onNudge?.('summarizing', { phase: 'start', tokens: before })
|
|
1739
|
+
await userHooks.fire('PreCompact', { trigger: 'auto', custom_instructions: '' }, hookContext(session, signal, hooks))
|
|
1740
|
+
const summarized = await summarizeEarlier(server, messages, { model, signal,
|
|
1741
|
+
onProgress: (p) => onNudge?.('summarizing', { phase: 'progress', ...p }) })
|
|
1742
|
+
if (summarized) {
|
|
1743
|
+
messages.splice(0, messages.length, ...summarized)
|
|
1744
|
+
retrySummaryAt = 0
|
|
1745
|
+
} else {
|
|
1746
|
+
retrySummaryAt = size(messages) * 1.2
|
|
1747
|
+
}
|
|
1748
|
+
onNudge?.('summarizing', { phase: 'done', ok: !!summarized, before,
|
|
1749
|
+
after: estimateTokens(messages), seconds: Math.round((Date.now() - started) / 1000) })
|
|
1750
|
+
}
|
|
1751
|
+
const mode = modeNow()
|
|
1752
|
+
const planning = mode === 'plan'
|
|
1753
|
+
const think = settings().get('showThinking', true)
|
|
1754
|
+
const effort = EFFORT[effortOf(chosenEffort || settings().get('effort', 'high'))]
|
|
1755
|
+
messages[0].content = systemPrompt(mode, rules, effortOf(chosenEffort || settings().get('effort', 'high')))
|
|
1756
|
+
const tools = stepTools(planning, session)
|
|
1757
|
+
const thinking = think && effort.think && !actNow
|
|
1758
|
+
// A fresh switch per turn: a message queued while the model generates cuts this
|
|
1759
|
+
// generation, so the correction is heard now rather than after it finishes.
|
|
1760
|
+
const steer = new AbortController()
|
|
1761
|
+
onSteerable?.(steer)
|
|
1762
|
+
const result = await steadyTurn(server, messages, { tools, model, signal, onDelta, onThinking,
|
|
1763
|
+
onWaiting, onSpeed, think: thinking, effort: effortOf(chosenEffort || settings().get('effort', 'high')),
|
|
1764
|
+
thinkBudget: thinking ? effort.budget : 0,
|
|
1765
|
+
watchReasoning: thinking && !planning, watchLoops: thinking, steer: steer.signal },
|
|
1766
|
+
(retry) => onNudge?.('retry', retry))
|
|
1767
|
+
onSteerable?.(null)
|
|
1768
|
+
actNow = false
|
|
1769
|
+
if (result.interrupted) {
|
|
1770
|
+
onNudge?.('steered')
|
|
1771
|
+
// What it had already said stays in the conversation; it continues from there.
|
|
1772
|
+
if (String(result.text || '').trim()) {
|
|
1773
|
+
messages.push({ role: 'assistant', content: `${result.text}\n[interrupted by the user's next message]` })
|
|
1774
|
+
}
|
|
1775
|
+
continue // the queued message is taken at the top of the loop
|
|
1776
|
+
}
|
|
1777
|
+
if (result.looped) {
|
|
1778
|
+
// Keep what it worked out, drop the circles, and have it act on it — with the
|
|
1779
|
+
// reasoning off for that one step, so it cannot start the same circle again.
|
|
1780
|
+
actNow = true
|
|
1781
|
+
onNudge?.(result.budget ? 'budget' : 'looped')
|
|
1782
|
+
const notes = distill(result.thinking)
|
|
1783
|
+
messages.push({ role: 'user', content:
|
|
1784
|
+
(result.budget
|
|
1785
|
+
? 'Your reasoning reached the length the chosen effort allows, so it was stopped. '
|
|
1786
|
+
+ 'This is what you had worked out:\n\n'
|
|
1787
|
+
: 'Your reasoning started repeating itself — the same points, round and round — so it '
|
|
1788
|
+
+ 'was stopped. This is what you had worked out, with the repeats removed:\n\n')
|
|
1789
|
+
+ `<your-reasoning>\n${notes}\n</your-reasoning>\n\n`
|
|
1790
|
+
+ 'Do not work through it again. Decide from it and act now: make the next tool call, '
|
|
1791
|
+
+ 'or give your answer if the task is done. Where you were torn between options, '
|
|
1792
|
+
+ 'pick the simplest one that works.' })
|
|
1793
|
+
continue
|
|
1794
|
+
}
|
|
1795
|
+
if (result.overthought) {
|
|
1796
|
+
actNow = true
|
|
1797
|
+
onNudge?.('overthought')
|
|
1798
|
+
messages.push({ role: 'user', content:
|
|
1799
|
+
'You have planned enough and started writing the code in your reasoning, where it '
|
|
1800
|
+
+ 'changes nothing. Stop planning. Write the files now with write_file (or change '
|
|
1801
|
+
+ 'existing ones with edit_file), one call per file, then continue until the task '
|
|
1802
|
+
+ 'is done.' })
|
|
1803
|
+
continue
|
|
1804
|
+
}
|
|
1805
|
+
const { text, calls, meta } = result
|
|
1806
|
+
truncations = meta.truncated ? truncations + 1 : 0
|
|
1807
|
+
// Cut off three times running despite being told to write in parts: that is a loop
|
|
1808
|
+
// too, and each round costs minutes of GPU. Stop, keeping everything done.
|
|
1809
|
+
if (truncations >= 3) return { messages: messages.slice(1), meta, stuck: true }
|
|
1810
|
+
if (meta.truncated) {
|
|
1811
|
+
// It ran into the output cap mid-answer. A tool call that long is either a file
|
|
1812
|
+
// too big for one call or the model repeating itself inside one — either way,
|
|
1813
|
+
// writing it in parts is the way through. Nothing half-written is applied.
|
|
1814
|
+
onNudge?.('truncated')
|
|
1815
|
+
messages.push({ role: 'assistant', content: text || '(the answer was cut off at the output limit)' })
|
|
1816
|
+
messages.push({ role: 'user', content:
|
|
1817
|
+
'Your last answer hit the output limit (about 32k tokens) and was cut off, so nothing '
|
|
1818
|
+
+ 'in it was applied. If you were writing a large file, write it in parts: create it '
|
|
1819
|
+
+ 'with the first part using write_file, then add each further part with edit_file '
|
|
1820
|
+
+ '(quote the last lines as old_text and repeat them followed by the new part). Keep '
|
|
1821
|
+
+ 'each call well under the limit. If you were repeating yourself, stop and take the '
|
|
1822
|
+
+ 'next concrete step.' })
|
|
1823
|
+
onTurn?.({ text, thinking: result.thinking, meta, calls: [] })
|
|
1824
|
+
continue
|
|
1825
|
+
}
|
|
1826
|
+
messages.push({ role: 'assistant', content: text, tool_calls: calls })
|
|
1827
|
+
onTurn?.({ text, thinking: result.thinking, meta, calls })
|
|
1828
|
+
onMessages?.(messages.slice(1))
|
|
1829
|
+
|
|
1830
|
+
if (!calls.length) {
|
|
1831
|
+
// A model that has written out the code but called nothing has described the
|
|
1832
|
+
// change instead of making it. That is worth one reminder — from the code it
|
|
1833
|
+
// just produced, applying it is a small step, and the alternative is a user who
|
|
1834
|
+
// believes their file was edited when it was not.
|
|
1835
|
+
// With reasoning shown, a model sometimes writes the file out in its thinking and
|
|
1836
|
+
// stops there. A substantial answer means it was explaining, not editing, so the
|
|
1837
|
+
// unfenced-code check only applies when the answer itself is thin.
|
|
1838
|
+
const narrated = looksLikeCode(result.thinking) && String(text || '').trim().length < 200
|
|
1839
|
+
// Code that only quotes what the tools returned is an answer, not an unapplied change.
|
|
1840
|
+
const drafted = (CODE_BLOCK.test(text) && !onlyQuotes(text, messages))
|
|
1841
|
+
|| (CODE_BLOCK.test(result.thinking || '') && !onlyQuotes(result.thinking, messages))
|
|
1842
|
+
|| narrated
|
|
1843
|
+
// Only when the user asked for a change: to "find and tell me" the reminder would
|
|
1844
|
+
// push the agent into editing something nobody asked it to touch.
|
|
1845
|
+
if (!planning && !changed && !nudged && drafted && askedForChange(messages, fromUser)) {
|
|
1846
|
+
nudged = true
|
|
1847
|
+
onNudge?.()
|
|
1848
|
+
messages.push({ role: 'user', content:
|
|
1849
|
+
'You wrote the code but did not apply it — showing it is not the same as '
|
|
1850
|
+
+ 'changing the file. Make the change now with write_file or edit_file, then '
|
|
1851
|
+
+ 'confirm in one line. If nothing actually needs changing, say why.' })
|
|
1852
|
+
continue
|
|
1853
|
+
}
|
|
1854
|
+
// About to finish, but the user has said more meanwhile: carry on with it.
|
|
1855
|
+
const late = takeQueued?.() || []
|
|
1856
|
+
if (late.length) { for (const m of late) fromUser.add(m); messages.push(...late); continue }
|
|
1857
|
+
// The user's Stop hooks may say the work is not finished (tests not run…): the agent
|
|
1858
|
+
// carries on with their reason — at most three times, so a hook cannot keep it going for ever.
|
|
1859
|
+
const stopHook = await userHooks.fire('Stop', { stop_hook_active: stopBlocks > 0 }, hookContext(session, signal, hooks))
|
|
1860
|
+
if (stopHook.block && stopBlocks < 3 && !signal?.aborted) {
|
|
1861
|
+
stopBlocks += 1
|
|
1862
|
+
const why = stopHook.reasons.join('\n')
|
|
1863
|
+
onNudge?.('hook', { text: `a Stop hook: ${why}` })
|
|
1864
|
+
messages.push({ role: 'user', content: `A Stop hook says the work is not finished yet: ${why}\nCarry on, then finish.` })
|
|
1865
|
+
continue
|
|
1866
|
+
}
|
|
1867
|
+
return { messages: messages.slice(1), meta, nudged }
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
for (const call of calls) {
|
|
1871
|
+
if (signal?.aborted) return { messages: messages.slice(1), meta, stopped: true }
|
|
1872
|
+
steps += 1
|
|
1873
|
+
if (steps > maxSteps) {
|
|
1874
|
+
messages.push({ role: 'tool', tool_name: call.function.name,
|
|
1875
|
+
content: `stopped: this request has already taken ${maxSteps} steps` })
|
|
1876
|
+
return { messages: messages.slice(1), meta, exhausted: true }
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
recent.push(signature(call))
|
|
1880
|
+
if (isSpinning(recent)) {
|
|
1881
|
+
if (!spinNudged) {
|
|
1882
|
+
// One chance to break out of it on its own: tell the model plainly.
|
|
1883
|
+
spinNudged = true
|
|
1884
|
+
recent.length = 0
|
|
1885
|
+
onNudge?.()
|
|
1886
|
+
messages.push({ role: 'tool', tool_name: call.function.name, content:
|
|
1887
|
+
'You are repeating the same action without making progress. Stop repeating '
|
|
1888
|
+
+ 'it. Either finish now with a short summary of what you have done, or take a '
|
|
1889
|
+
+ 'clearly different approach.' })
|
|
1890
|
+
break // skip the rest of this batch, get a fresh turn
|
|
1891
|
+
}
|
|
1892
|
+
// Still spinning after the reminder — end it. This is what makes Auto stop.
|
|
1893
|
+
return { messages: messages.slice(1), meta, stuck: true }
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
// Each call its own row in the panel, even when it repeats an earlier one exactly.
|
|
1897
|
+
const callId = `c${Date.now().toString(36)}-${steps}`
|
|
1898
|
+
// A package the user has not chosen is not installed or imported: the model asks first.
|
|
1899
|
+
const brought = deps.brings(call.function.name, parsedArgs(call))
|
|
1900
|
+
if (brought.length) {
|
|
1901
|
+
known ??= await deps.declared()
|
|
1902
|
+
const open = deps.unchosen(brought, deps.saidByUser(messages, fromUser), known, holds)
|
|
1903
|
+
if (open.length) {
|
|
1904
|
+
const note = deps.holdNote(open)
|
|
1905
|
+
onTool?.({ tool: call.function.name, args: parsedArgs(call), status: 'failed', result: note, id: callId })
|
|
1906
|
+
messages.push({ role: 'tool', tool_name: call.function.name, content: note })
|
|
1907
|
+
continue
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
const result = await execute(call, session, (event) => onTool?.({ ...event, id: callId }), signal, server, hooks)
|
|
1911
|
+
if (WRITES.has(call.function.name) || call.function.name === 'generate_media') changed = true
|
|
1912
|
+
const content = typeof result === 'object' ? result.text : result
|
|
1913
|
+
const toolMessage = { role: 'tool', tool_name: call.function.name, content: capResult(content) }
|
|
1914
|
+
if (typeof result === 'object' && result.images?.length) toolMessage.images = result.images
|
|
1915
|
+
messages.push(toolMessage)
|
|
1916
|
+
onMessages?.(messages.slice(1))
|
|
1917
|
+
if (session.hookStop) {
|
|
1918
|
+
onNudge?.('hook', { text: `a hook stopped the run: ${session.hookStop}` })
|
|
1919
|
+
delete session.hookStop
|
|
1920
|
+
return { messages: messages.slice(1), meta, stopped: true }
|
|
1921
|
+
}
|
|
1922
|
+
// Approving a plan switches the mode: the next step gets the tools that go with it.
|
|
1923
|
+
if (call.function.name === 'exit_plan_mode' && modeNow() !== 'plan') break
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
module.exports = { appendSystem, helpersOf, findHelper, listHelpers, modeNow, setWindow, targetTokens, sideQuestion, run, turn, steadyTurn, execute, runHelper, normalizeTodos, commandPrefix, previewChange, approve, EFFORT, effortOf, systemPrompt, estimateTokens, summaryPlan, plain, commandEnv, AUTO_NOTE, thinkingLoops, distill, summarizeEarlier, runCommand, openSideBySide, stopBackground, compact, capResult, looksLikeCode, onlyQuotes, askedForChange, overthinking, calibrate, budget, SYSTEM, MODES,
|
|
1929
|
+
TARGET_TOKENS, proposedProvider, proposedContents }
|