codeep 3.3.3 → 3.4.1
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/dist/acp/commands.d.ts +50 -1
- package/dist/acp/commands.js +545 -109
- package/dist/acp/protocol.d.ts +14 -5
- package/dist/acp/server.d.ts +36 -1
- package/dist/acp/server.js +581 -155
- package/dist/acp/serverHandlers.d.ts +2 -1
- package/dist/acp/serverHandlers.js +3 -0
- package/dist/acp/session.d.ts +28 -2
- package/dist/acp/session.js +25 -6
- package/dist/acp/transport.d.ts +40 -4
- package/dist/acp/transport.js +218 -25
- package/dist/acp/turns.d.ts +20 -0
- package/dist/acp/turns.js +30 -0
- package/dist/api/index.js +2 -0
- package/dist/api/ollamaNative.d.ts +3 -0
- package/dist/api/ollamaNative.js +35 -3
- package/dist/config/index.d.ts +21 -4
- package/dist/config/index.js +178 -123
- package/dist/renderer/agentExecution.d.ts +30 -2
- package/dist/renderer/agentExecution.js +248 -92
- package/dist/renderer/commands/helpers.d.ts +18 -2
- package/dist/renderer/commands/helpers.js +28 -5
- package/dist/renderer/commands.d.ts +2 -0
- package/dist/renderer/commands.js +180 -64
- package/dist/renderer/main.d.ts +41 -0
- package/dist/renderer/main.js +181 -80
- package/dist/utils/agent.d.ts +69 -4
- package/dist/utils/agent.js +416 -248
- package/dist/utils/agentChat.js +82 -10
- package/dist/utils/agents.d.ts +2 -1
- package/dist/utils/agents.js +100 -29
- package/dist/utils/auditLog.d.ts +4 -3
- package/dist/utils/auditLog.js +92 -9
- package/dist/utils/checkpoints.js +11 -6
- package/dist/utils/codeReview.js +28 -23
- package/dist/utils/codeepCloud.d.ts +14 -2
- package/dist/utils/codeepCloud.js +56 -20
- package/dist/utils/customCommands.js +7 -2
- package/dist/utils/git.d.ts +262 -4
- package/dist/utils/git.js +1928 -61
- package/dist/utils/gitHookInstaller.d.ts +32 -1
- package/dist/utils/gitHookInstaller.js +76 -8
- package/dist/utils/gitignore.d.ts +8 -0
- package/dist/utils/gitignore.js +41 -10
- package/dist/utils/headlessReview.d.ts +11 -0
- package/dist/utils/headlessReview.js +33 -5
- package/dist/utils/history.d.ts +22 -6
- package/dist/utils/history.js +140 -26
- package/dist/utils/logger.js +6 -7
- package/dist/utils/mcpConfig.d.ts +24 -0
- package/dist/utils/mcpConfig.js +36 -5
- package/dist/utils/mentions.d.ts +28 -5
- package/dist/utils/mentions.js +253 -45
- package/dist/utils/personalities.js +16 -6
- package/dist/utils/planMode.d.ts +13 -7
- package/dist/utils/planMode.js +32 -12
- package/dist/utils/projectIntelligence.d.ts +2 -0
- package/dist/utils/projectIntelligence.js +27 -8
- package/dist/utils/projectPaths.d.ts +53 -0
- package/dist/utils/projectPaths.js +146 -0
- package/dist/utils/shell.d.ts +119 -0
- package/dist/utils/shell.js +417 -45
- package/dist/utils/skillBundles.js +17 -7
- package/dist/utils/skillBundlesCloud.js +20 -3
- package/dist/utils/skills.d.ts +24 -2
- package/dist/utils/skills.js +235 -43
- package/dist/utils/smartContext.js +97 -23
- package/dist/utils/telegramApproval.d.ts +10 -2
- package/dist/utils/telegramApproval.js +22 -4
- package/dist/utils/toolExecution.d.ts +50 -2
- package/dist/utils/toolExecution.js +418 -16
- package/dist/utils/toolParsing.d.ts +7 -1
- package/dist/utils/toolParsing.js +12 -3
- package/dist/utils/userProfile.js +58 -16
- package/dist/utils/verify.d.ts +25 -4
- package/dist/utils/verify.js +259 -74
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/utils/agent.js
CHANGED
|
@@ -37,10 +37,12 @@ function calculateDynamicTimeout(iteration, baseTimeout) {
|
|
|
37
37
|
return Math.max(calculatedTimeout, 120000);
|
|
38
38
|
}
|
|
39
39
|
import { parseToolCalls, executeTool, createActionLog } from './tools.js';
|
|
40
|
+
import { trustBearingWrite, forgetHooksDirectory, NO_CONFIRMER_REFUSAL } from './toolExecution.js';
|
|
40
41
|
import { config } from '../config/index.js';
|
|
41
42
|
import { supportsNativeTools } from '../config/providers.js';
|
|
43
|
+
import { isMcpToolName, isVirtualMcpToolName } from './mcpRegistry.js';
|
|
42
44
|
import { startSession, endSession, undoLastAction, undoAllActions, getCurrentSession, getRecentSessions, formatSession } from './history.js';
|
|
43
|
-
import { runAllVerifications, formatErrorsForAgent, hasVerificationErrors, getVerificationSummary } from './verify.js';
|
|
45
|
+
import { runAllVerifications, formatErrorsForAgent, hasVerificationErrors, getVerificationSummary, failedChecks, checksNotRun } from './verify.js';
|
|
44
46
|
import { gatherSmartContext, formatSmartContext, extractTargetFile } from './smartContext.js';
|
|
45
47
|
import { planTasks, formatTaskPlan } from './taskPlanner.js';
|
|
46
48
|
import { getTaskContextPrompt } from './taskContext.js';
|
|
@@ -138,6 +140,85 @@ export function buildDangerousTools(extra = []) {
|
|
|
138
140
|
tools.add(t);
|
|
139
141
|
return tools;
|
|
140
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* Whether a tool call must go through the permission prompt this run.
|
|
145
|
+
*
|
|
146
|
+
* MCP tools (`<server>__<tool>`) can do anything their server can — write
|
|
147
|
+
* files, run SQL, drive a browser — and their names never appear in the
|
|
148
|
+
* built-in set, so a mode that confirms dangerous operations confirms these
|
|
149
|
+
* too. The resource/prompt wrappers only read, and stay unprompted.
|
|
150
|
+
*/
|
|
151
|
+
export function requiresPermission(tool, dangerousTools) {
|
|
152
|
+
return dangerousTools.has(tool) || (isMcpToolName(tool) && !isVirtualMcpToolName(tool));
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Read a sub-agent's `model:` setting ("provider/model" or a bare model) as
|
|
156
|
+
* the runtime for its nested run. A known provider prefix switches provider;
|
|
157
|
+
* anything else is a model on the current provider. The protocol is kept when
|
|
158
|
+
* the provider stays the same and supports it, and is otherwise that
|
|
159
|
+
* provider's default.
|
|
160
|
+
*/
|
|
161
|
+
export async function resolveDelegateModel(spec, current) {
|
|
162
|
+
const slash = spec.indexOf('/');
|
|
163
|
+
if (slash < 0)
|
|
164
|
+
return { ...current, model: spec };
|
|
165
|
+
const providerId = spec.slice(0, slash);
|
|
166
|
+
const model = spec.slice(slash + 1);
|
|
167
|
+
const { getProvider } = await import('../config/providers.js');
|
|
168
|
+
const provider = getProvider(providerId);
|
|
169
|
+
if (!provider)
|
|
170
|
+
return { ...current, model };
|
|
171
|
+
const protocol = providerId === current.providerId && provider.protocols[current.protocol]
|
|
172
|
+
? current.protocol
|
|
173
|
+
: provider.defaultProtocol;
|
|
174
|
+
// getApiKey() only reads the in-memory cache, so warm it for a provider the
|
|
175
|
+
// parent run has not used. Reading a key changes no settings.
|
|
176
|
+
try {
|
|
177
|
+
const { getApiKey, loadApiKey } = await import('../config/index.js');
|
|
178
|
+
if (!getApiKey(providerId))
|
|
179
|
+
await loadApiKey(providerId);
|
|
180
|
+
}
|
|
181
|
+
catch { /* the request itself reports a missing key */ }
|
|
182
|
+
return { providerId, model, protocol };
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* The user-facing account of checks that still fail when verification stops.
|
|
186
|
+
* The command and the first few errors are enough to act on; the full output
|
|
187
|
+
* went to the model.
|
|
188
|
+
*/
|
|
189
|
+
function describeVerificationFailure(results) {
|
|
190
|
+
const failed = failedChecks(results);
|
|
191
|
+
const lines = [`✗ Verification failed: ${failed.length}/${results.length} checks`];
|
|
192
|
+
for (const r of failed) {
|
|
193
|
+
lines.push(`- ${r.type}: \`${r.command}\``);
|
|
194
|
+
for (const e of r.errors.slice(0, 5)) {
|
|
195
|
+
const where = e.file ? `${e.file}${e.line ? `:${e.line}` : ''}: ` : '';
|
|
196
|
+
const message = e.message.length > 200 ? e.message.slice(0, 200) + '…' : e.message;
|
|
197
|
+
lines.push(` - ${where}${message}`);
|
|
198
|
+
}
|
|
199
|
+
if (r.errors.length > 5)
|
|
200
|
+
lines.push(` - …and ${r.errors.length - 5} more`);
|
|
201
|
+
}
|
|
202
|
+
return lines.join('\n');
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* The user-facing account of checks that could not be carried out, or '' when
|
|
206
|
+
* every check ran. They prove nothing either way, so the user is told the
|
|
207
|
+
* change was not verified by them rather than that it passed or failed.
|
|
208
|
+
*/
|
|
209
|
+
function describeChecksNotRun(results) {
|
|
210
|
+
const notRun = checksNotRun(results);
|
|
211
|
+
if (notRun.length === 0)
|
|
212
|
+
return '';
|
|
213
|
+
const lines = [`⚠ Verification could not run: ${notRun.length}/${results.length} checks`];
|
|
214
|
+
for (const r of notRun)
|
|
215
|
+
lines.push(`- ${r.type}: \`${r.command}\` — ${r.notRun}`);
|
|
216
|
+
return lines.join('\n');
|
|
217
|
+
}
|
|
218
|
+
/** A result whose whole response is a notice runAgent wrote (see unstreamedText). */
|
|
219
|
+
function asNotice(result) {
|
|
220
|
+
return { ...result, unstreamedText: result.finalResponse.trim() };
|
|
221
|
+
}
|
|
141
222
|
/**
|
|
142
223
|
* Build the result for a run that paused at a safety limit. Pausing is a normal,
|
|
143
224
|
* resumable state — not an error — so the summary tells the user how to resume.
|
|
@@ -216,7 +297,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
216
297
|
// A structured custom bot is resolved once per run. This keeps a cloud sync
|
|
217
298
|
// or file edit from changing policy halfway through an in-flight request.
|
|
218
299
|
const activePersonality = opts.personalityOverride ?? getActivePersonality(projectContext.root);
|
|
219
|
-
const currentRuntime = {
|
|
300
|
+
const currentRuntime = opts.modelOverride ?? {
|
|
220
301
|
providerId: String(config.get('provider')),
|
|
221
302
|
model: String(config.get('model')),
|
|
222
303
|
protocol: config.get('protocol'),
|
|
@@ -466,6 +547,13 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
466
547
|
messages.push({ role: 'user', content: initialPrompt });
|
|
467
548
|
let iteration = 0;
|
|
468
549
|
let finalResponse = '';
|
|
550
|
+
// What the loop added to finalResponse after the model's last reply (see
|
|
551
|
+
// AgentResult.unstreamedText). Reset whenever a reply replaces the response.
|
|
552
|
+
let appended = '';
|
|
553
|
+
const appendToResponse = (text) => {
|
|
554
|
+
finalResponse += text;
|
|
555
|
+
appended += text;
|
|
556
|
+
};
|
|
469
557
|
// Initialised rather than merely declared: the `finally` reads it to decide
|
|
470
558
|
// the audit outcome, and TypeScript is right that a throw before assignment
|
|
471
559
|
// would leave it unset.
|
|
@@ -476,10 +564,18 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
476
564
|
// once or twice. More retries than that usually means the model is stuck, not
|
|
477
565
|
// that it needs a third chance — bail out instead of spamming identical hints.
|
|
478
566
|
const maxIncompleteWorkRetries = 2;
|
|
479
|
-
// Track tools permanently allowed this session via allow_always
|
|
480
|
-
|
|
567
|
+
// Track tools permanently allowed this session via allow_always. A delegated
|
|
568
|
+
// sub-agent shares its parent's sets, so an answer holds across delegation.
|
|
569
|
+
const alwaysAllowedTools = opts.permissionMemory?.alwaysAllowed ?? new Set();
|
|
481
570
|
// Track tools permanently rejected this session via reject_always
|
|
482
|
-
const alwaysRejectedTools = new Set();
|
|
571
|
+
const alwaysRejectedTools = opts.permissionMemory?.alwaysRejected ?? new Set();
|
|
572
|
+
// Files that decide what runs later and were refused for good this session.
|
|
573
|
+
// Kept apart from the tool set on purpose: the TUI's only "no" button answers
|
|
574
|
+
// reject_always, so saying no to one `.git/config` prompt would otherwise
|
|
575
|
+
// turn off delete_file — and every other use of that tool — for the rest of
|
|
576
|
+
// the run. Keyed by the resolved path, so one answer covers every spelling
|
|
577
|
+
// of the same file.
|
|
578
|
+
const alwaysRejectedPaths = opts.permissionMemory?.alwaysRejectedPaths ?? new Set();
|
|
483
579
|
// Tools that require permission when onRequestPermission is set (configurable)
|
|
484
580
|
const dangerousTools = buildDangerousTools(opts.extraDangerousTools);
|
|
485
581
|
// Delegation handler: run a named (or generic) sub-agent in its own fresh
|
|
@@ -502,8 +598,11 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
502
598
|
catch { /* fall back to a generic sub-agent */ }
|
|
503
599
|
let roleAddendum = def?.prompt
|
|
504
600
|
|| 'You are a general-purpose sub-agent. Complete the task in your own context and return a concise, self-contained summary of what you did and the outcome.';
|
|
505
|
-
if (def?.tools)
|
|
506
|
-
roleAddendum +=
|
|
601
|
+
if (def?.tools) {
|
|
602
|
+
roleAddendum += def.tools.length
|
|
603
|
+
? `\n\nYou may use ONLY these tools: ${def.tools.join(', ')}.`
|
|
604
|
+
: '\n\nYou may not use any tools.';
|
|
605
|
+
}
|
|
507
606
|
if (def?.personality) {
|
|
508
607
|
try {
|
|
509
608
|
const { findPersonality } = await import('./personalities.js');
|
|
@@ -516,22 +615,15 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
516
615
|
const label = def?.name || 'agent';
|
|
517
616
|
opts.onIteration?.(iteration, `⤷ delegating to ${label}…`);
|
|
518
617
|
const tag = (text) => `⤷ ${label}: ${text}`;
|
|
519
|
-
// Model override
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
618
|
+
// Model override for the nested run only. It travels as an option, never
|
|
619
|
+
// through config: config is saved to disk and read by every session in
|
|
620
|
+
// this process, so swapping it there leaked the sub-agent's model into
|
|
621
|
+
// concurrent runs, reset the user's protocol on the way back, and stayed
|
|
622
|
+
// behind if the process died mid-delegation.
|
|
623
|
+
let modelOverride = opts.modelOverride;
|
|
523
624
|
if (def?.model) {
|
|
524
625
|
try {
|
|
525
|
-
|
|
526
|
-
if (m.includes('/')) {
|
|
527
|
-
const { setProvider } = await import('../config/index.js');
|
|
528
|
-
setProvider(m.slice(0, m.indexOf('/')));
|
|
529
|
-
config.set('model', m.slice(m.indexOf('/') + 1));
|
|
530
|
-
}
|
|
531
|
-
else {
|
|
532
|
-
config.set('model', m);
|
|
533
|
-
}
|
|
534
|
-
swapped = true;
|
|
626
|
+
modelOverride = await resolveDelegateModel(String(def.model), currentRuntime);
|
|
535
627
|
}
|
|
536
628
|
catch { /* keep parent's model */ }
|
|
537
629
|
}
|
|
@@ -545,7 +637,13 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
545
637
|
maxIterations: def?.maxIterations ?? Math.min(15, opts.maxIterations),
|
|
546
638
|
maxDuration: opts.maxDuration,
|
|
547
639
|
abortSignal: opts.abortSignal,
|
|
640
|
+
// The sub-agent works under the parent's rules: a dry run stays dry,
|
|
641
|
+
// tools gated for this run stay gated, and "always" answers carry over.
|
|
642
|
+
dryRun: opts.dryRun,
|
|
548
643
|
onRequestPermission: opts.onRequestPermission,
|
|
644
|
+
extraDangerousTools: opts.extraDangerousTools,
|
|
645
|
+
permissionMemory: { alwaysAllowed: alwaysAllowedTools, alwaysRejected: alwaysRejectedTools, alwaysRejectedPaths },
|
|
646
|
+
modelOverride,
|
|
549
647
|
onExecuteCommand: opts.onExecuteCommand,
|
|
550
648
|
fs: opts.fs,
|
|
551
649
|
mcpSessionId: opts.mcpSessionId,
|
|
@@ -560,16 +658,186 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
560
658
|
catch (err) {
|
|
561
659
|
return fail(`Sub-agent "${label}" failed: ${err.message}`);
|
|
562
660
|
}
|
|
563
|
-
|
|
564
|
-
|
|
661
|
+
};
|
|
662
|
+
// One path from "the model asked for a tool" to "the tool ran", shared by
|
|
663
|
+
// the main loop and the verification fix loop, so a tool call cannot skip a
|
|
664
|
+
// gate by arriving through the other one. Returns the tool's result, plus
|
|
665
|
+
// `refusal` (the text for the model) when a gate stopped it. Either way the
|
|
666
|
+
// result has already been reported through onToolResult and logged.
|
|
667
|
+
const dispatchToolCall = async (toolCall) => {
|
|
668
|
+
const refuse = (error, refusal) => {
|
|
669
|
+
const result = {
|
|
670
|
+
success: false,
|
|
671
|
+
output: '',
|
|
672
|
+
error,
|
|
673
|
+
tool: toolCall.tool,
|
|
674
|
+
parameters: toolCall.parameters,
|
|
675
|
+
};
|
|
676
|
+
opts.onToolResult?.(result, toolCall);
|
|
677
|
+
actions.push(createActionLog(toolCall, result));
|
|
678
|
+
return { result, refusal };
|
|
679
|
+
};
|
|
680
|
+
// Structured custom-bot policy is a runtime security boundary, not a
|
|
681
|
+
// prompt suggestion. It runs before permission UI or external ACP
|
|
682
|
+
// terminal delegation, so disallowed commands cannot escape via a
|
|
683
|
+
// different execution surface.
|
|
684
|
+
if (activePersonality && !isPersonalityToolCallAllowed(activePersonality, toolCall, registeredMcpToolNames)) {
|
|
685
|
+
const allowed = activePersonality.declaredTools?.join(', ') || 'none';
|
|
686
|
+
const refused = refuse(`Tool "${toolCall.tool}" is blocked by custom bot "${activePersonality.displayName}".`, `Tool ${toolCall.tool} is blocked by the active custom bot. Allowed capabilities: ${allowed}.`);
|
|
687
|
+
// The one event nothing recorded before. A boundary you cannot audit
|
|
688
|
+
// is a boundary you have to take on faith.
|
|
689
|
+
recordAuditEvent(auditRoot, {
|
|
690
|
+
ts: Date.now(), run: auditRun, tool: toolCall.tool, action: 'refused',
|
|
691
|
+
target: describeAuditTarget(toolCall), outcome: 'refused',
|
|
692
|
+
detail: `blocked by custom bot "${activePersonality.displayName}"; allowed: ${allowed}`,
|
|
693
|
+
});
|
|
694
|
+
return refused;
|
|
695
|
+
}
|
|
696
|
+
// Tool scoping for delegated sub-agents: reject any tool outside the
|
|
697
|
+
// agent's allowlist up front — no permission prompt, no execution.
|
|
698
|
+
if (opts.allowedTools && !opts.allowedTools.includes(toolCall.tool)) {
|
|
699
|
+
return refuse(`Tool "${toolCall.tool}" is not available to this sub-agent.`, `Tool ${toolCall.tool} is not allowed for this sub-agent. Use only: ${opts.allowedTools.join(', ')}.`);
|
|
700
|
+
}
|
|
701
|
+
const denied = () => refuse(`User rejected permission for ${toolCall.tool}`, `Tool ${toolCall.tool} was denied by user. Do not attempt this action again.`);
|
|
702
|
+
// Writing a file that decides what runs later is code execution on a
|
|
703
|
+
// delay, not an edit: git runs `core.fsmonitor` itself on the next
|
|
704
|
+
// `git status` the status line makes, a `.codeep/hooks/` script runs on
|
|
705
|
+
// the next tool call, an MCP entry spawns a process. A prompt injection
|
|
706
|
+
// that gets one of these written has walked around every other gate, so
|
|
707
|
+
// the write is confirmed in EVERY confirmation mode — not only the tiers
|
|
708
|
+
// that happen to list write_file — and an "always allow" answer given for
|
|
709
|
+
// the tool never covers it. With nobody to ask, it fails the way a write
|
|
710
|
+
// the editor refused fails: proceeding quietly is the one outcome that
|
|
711
|
+
// cannot be taken back.
|
|
712
|
+
const trustBearing = trustBearingWrite(toolCall, projectContext.root || process.cwd());
|
|
713
|
+
if (trustBearing) {
|
|
714
|
+
if (!opts.onRequestPermission) {
|
|
715
|
+
const refusal = refuse(`Refused ${toolCall.tool} on ${trustBearing.path}: ${trustBearing.reason} ${NO_CONFIRMER_REFUSAL}`, `Tool ${toolCall.tool} was refused on ${trustBearing.path}. ${trustBearing.reason} Nobody could be asked to confirm it. Do not try again — tell the user to edit that file themselves.`);
|
|
716
|
+
recordAuditEvent(auditRoot, {
|
|
717
|
+
ts: Date.now(), run: auditRun, tool: toolCall.tool, action: 'refused',
|
|
718
|
+
target: describeAuditTarget(toolCall), outcome: 'refused',
|
|
719
|
+
detail: `${trustBearing.path} decides what runs later and no confirmation was possible`,
|
|
720
|
+
});
|
|
721
|
+
return refusal;
|
|
722
|
+
}
|
|
723
|
+
// An "always deny" already given: for the tool, when the user really
|
|
724
|
+
// chose that in an ordinary prompt, or for this file.
|
|
725
|
+
if (alwaysRejectedTools.has(toolCall.tool) || alwaysRejectedPaths.has(trustBearing.file))
|
|
726
|
+
return denied();
|
|
727
|
+
const decision = classifyPermissionOutcome(await opts.onRequestPermission(toolCall, trustBearing));
|
|
728
|
+
// Neither answer is remembered for the TOOL. "Always allow" is not
|
|
729
|
+
// remembered at all: it was an answer about THIS file, and the next
|
|
730
|
+
// `.git/config` write must be asked about again. "Always deny" is
|
|
731
|
+
// remembered against the file — the fail-closed half of the same rule.
|
|
732
|
+
// Against the tool it would be a trap: the TUI offers Allow, Always
|
|
733
|
+
// Allow and Deny, and that Deny answers reject_always, so refusing one
|
|
734
|
+
// `.git/config` prompt would silently disable delete_file for the rest
|
|
735
|
+
// of the run.
|
|
736
|
+
if (decision !== 'allow-once' && decision !== 'allow-always') {
|
|
737
|
+
if (decision === 'deny-always')
|
|
738
|
+
alwaysRejectedPaths.add(trustBearing.file);
|
|
739
|
+
return denied();
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
else if (opts.onRequestPermission && requiresPermission(toolCall.tool, dangerousTools) && !alwaysAllowedTools.has(toolCall.tool)) {
|
|
743
|
+
// Every other tool: the run's dangerous set decides, and only when
|
|
744
|
+
// there is a callback to ask through (e.g. ACP/Zed).
|
|
745
|
+
// Skip without asking if permanently rejected this session
|
|
746
|
+
if (alwaysRejectedTools.has(toolCall.tool))
|
|
747
|
+
return denied();
|
|
748
|
+
// `null` and not nothing: this branch runs only when the call writes no
|
|
749
|
+
// such file, and saying so spares the dialog the second lookup.
|
|
750
|
+
const outcome = await opts.onRequestPermission(toolCall, null);
|
|
751
|
+
// Fail CLOSED: allow ONLY on an explicit allow outcome; reject_* and
|
|
752
|
+
// any malformed/unknown outcome deny (see classifyPermissionOutcome).
|
|
753
|
+
const decision = classifyPermissionOutcome(outcome);
|
|
754
|
+
if (decision === 'allow-always') {
|
|
755
|
+
alwaysAllowedTools.add(toolCall.tool);
|
|
756
|
+
}
|
|
757
|
+
else if (decision !== 'allow-once') {
|
|
758
|
+
if (decision === 'deny-always')
|
|
759
|
+
alwaysRejectedTools.add(toolCall.tool);
|
|
760
|
+
return denied();
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
let toolResult;
|
|
764
|
+
// A dry run simulates every tool, delegation included — a sub-agent
|
|
765
|
+
// started from here would otherwise do the real work.
|
|
766
|
+
if (opts.dryRun) {
|
|
767
|
+
toolResult = {
|
|
768
|
+
success: true,
|
|
769
|
+
output: `[DRY RUN] Would execute: ${toolCall.tool}`,
|
|
770
|
+
tool: toolCall.tool,
|
|
771
|
+
parameters: toolCall.parameters,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
else if (toolCall.tool === 'delegate') {
|
|
775
|
+
toolResult = await runDelegate(toolCall);
|
|
776
|
+
}
|
|
777
|
+
else if (opts.onExecuteCommand && toolCall.tool === 'execute_command') {
|
|
778
|
+
// Delegate to external terminal (e.g. Zed ACP terminal)
|
|
779
|
+
// Note: onExecuteCommand runs after the permission gate above
|
|
780
|
+
const command = toolCall.parameters.command;
|
|
781
|
+
const args = toolCall.parameters.args || [];
|
|
782
|
+
const cwd = projectContext.root || process.cwd();
|
|
783
|
+
if (!command) {
|
|
784
|
+
toolResult = {
|
|
785
|
+
success: false,
|
|
786
|
+
output: '',
|
|
787
|
+
error: 'execute_command called with missing command field',
|
|
788
|
+
tool: toolCall.tool,
|
|
789
|
+
parameters: toolCall.parameters,
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
else {
|
|
565
793
|
try {
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
794
|
+
// Runs in the editor's terminal instead of ours, so executeTool's
|
|
795
|
+
// own invalidation never fires — but `git config core.hooksPath
|
|
796
|
+
// .evil` moves this repository's hooks just the same. Drop the
|
|
797
|
+
// cached answer here too, or the next write to the new hook
|
|
798
|
+
// directory goes through unasked.
|
|
799
|
+
forgetHooksDirectory();
|
|
800
|
+
const commandResult = await opts.onExecuteCommand(command, args, cwd);
|
|
801
|
+
toolResult = {
|
|
802
|
+
success: commandResult.exitCode === 0,
|
|
803
|
+
output: commandResult.stdout || '(no output)',
|
|
804
|
+
error: commandResult.exitCode !== 0 ? (commandResult.stderr || `exited with code ${commandResult.exitCode}`) : undefined,
|
|
805
|
+
tool: toolCall.tool,
|
|
806
|
+
parameters: toolCall.parameters,
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
catch (err) {
|
|
810
|
+
// The callback decides where the command runs and whether it can
|
|
811
|
+
// fall back to running here. When it throws, the command may already
|
|
812
|
+
// have run in the editor, so running it again locally could run it
|
|
813
|
+
// twice. Report the failure instead.
|
|
814
|
+
debug('onExecuteCommand callback threw:', err);
|
|
815
|
+
toolResult = {
|
|
816
|
+
success: false,
|
|
817
|
+
output: '',
|
|
818
|
+
error: `Command could not be run: ${err?.message ?? String(err)}`,
|
|
819
|
+
tool: toolCall.tool,
|
|
820
|
+
parameters: toolCall.parameters,
|
|
821
|
+
};
|
|
569
822
|
}
|
|
570
|
-
catch { /* ignore restore failure */ }
|
|
571
823
|
}
|
|
572
824
|
}
|
|
825
|
+
else {
|
|
826
|
+
toolResult = await executeTool(toolCall, projectContext.root || process.cwd(), opts.fs, opts.mcpSessionId, opts.abortSignal);
|
|
827
|
+
}
|
|
828
|
+
opts.onToolResult?.(toolResult, toolCall);
|
|
829
|
+
// Log action
|
|
830
|
+
const actionLog = createActionLog(toolCall, toolResult);
|
|
831
|
+
actions.push(actionLog);
|
|
832
|
+
// createActionLog already classified this; reuse its verdict rather than
|
|
833
|
+
// re-deriving the action type in a second place that could drift.
|
|
834
|
+
recordAuditEvent(auditRoot, {
|
|
835
|
+
ts: Date.now(), run: auditRun, tool: toolCall.tool, action: actionLog.type,
|
|
836
|
+
target: describeAuditTarget(toolCall),
|
|
837
|
+
outcome: toolResult.success ? 'ok' : 'error',
|
|
838
|
+
detail: toolResult.success ? undefined : toolResult.error,
|
|
839
|
+
});
|
|
840
|
+
return { result: toolResult };
|
|
573
841
|
};
|
|
574
842
|
const maxTimeoutRetries = 3;
|
|
575
843
|
const maxConsecutiveTimeouts = 30; // Allow more consecutive timeouts before giving up
|
|
@@ -588,7 +856,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
588
856
|
// Check timeout
|
|
589
857
|
if (Date.now() - startTime > opts.maxDuration) {
|
|
590
858
|
const durationMin = Math.round(opts.maxDuration / 60000);
|
|
591
|
-
result = buildPausedResult('time_limit', { iterations: iteration, actions, durationMin });
|
|
859
|
+
result = asNotice(buildPausedResult('time_limit', { iterations: iteration, actions, durationMin }));
|
|
592
860
|
if (!opts.nested)
|
|
593
861
|
writeProgressLog(projectContext.root || '', prompt, result, projectContext.name);
|
|
594
862
|
return result;
|
|
@@ -596,13 +864,13 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
596
864
|
// Check abort signal
|
|
597
865
|
if (opts.abortSignal?.aborted) {
|
|
598
866
|
debug('Agent aborted at iteration', iteration);
|
|
599
|
-
result = {
|
|
867
|
+
result = asNotice({
|
|
600
868
|
success: false,
|
|
601
869
|
iterations: iteration,
|
|
602
870
|
actions,
|
|
603
871
|
finalResponse: 'Agent was stopped by user',
|
|
604
872
|
aborted: true,
|
|
605
|
-
};
|
|
873
|
+
});
|
|
606
874
|
return result;
|
|
607
875
|
}
|
|
608
876
|
iteration++;
|
|
@@ -670,13 +938,13 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
670
938
|
const err = error;
|
|
671
939
|
// Handle user abort (not timeout)
|
|
672
940
|
if (err.name === 'AbortError') {
|
|
673
|
-
result = {
|
|
941
|
+
result = asNotice({
|
|
674
942
|
success: false,
|
|
675
943
|
iterations: iteration,
|
|
676
944
|
actions,
|
|
677
945
|
finalResponse: 'Agent was stopped by user',
|
|
678
946
|
aborted: true,
|
|
679
|
-
};
|
|
947
|
+
});
|
|
680
948
|
return result;
|
|
681
949
|
}
|
|
682
950
|
// Handle timeout with retry
|
|
@@ -689,13 +957,13 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
689
957
|
// Too many retries for this iteration
|
|
690
958
|
if (consecutiveTimeouts >= maxConsecutiveTimeouts) {
|
|
691
959
|
// Too many consecutive timeouts overall, give up
|
|
692
|
-
result = {
|
|
960
|
+
result = asNotice({
|
|
693
961
|
success: false,
|
|
694
962
|
iterations: iteration,
|
|
695
963
|
actions,
|
|
696
964
|
finalResponse: 'Agent stopped due to repeated API timeouts',
|
|
697
965
|
error: `API timed out ${consecutiveTimeouts} times consecutively. Try increasing the timeout in settings or simplifying the task.`,
|
|
698
|
-
};
|
|
966
|
+
});
|
|
699
967
|
return result;
|
|
700
968
|
}
|
|
701
969
|
// Skip this iteration and try next
|
|
@@ -715,13 +983,13 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
715
983
|
}
|
|
716
984
|
// Don't retry on 4xx client errors except 429 (rate limit)
|
|
717
985
|
if (err instanceof ApiError && err.status >= 400 && err.status < 500 && err.status !== 429) {
|
|
718
|
-
result = {
|
|
986
|
+
result = asNotice({
|
|
719
987
|
success: false,
|
|
720
988
|
iterations: iteration,
|
|
721
989
|
actions,
|
|
722
990
|
finalResponse: '',
|
|
723
991
|
error: err.message,
|
|
724
|
-
};
|
|
992
|
+
});
|
|
725
993
|
return result;
|
|
726
994
|
}
|
|
727
995
|
// All non-abort errors are retryable — retry with backoff
|
|
@@ -739,7 +1007,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
739
1007
|
// Rate limit exhausted — stop immediately, no point hammering a throttled API
|
|
740
1008
|
consecutiveRateLimits++;
|
|
741
1009
|
if (consecutiveRateLimits >= maxConsecutiveRateLimits) {
|
|
742
|
-
result = {
|
|
1010
|
+
result = asNotice({
|
|
743
1011
|
success: false,
|
|
744
1012
|
iterations: iteration,
|
|
745
1013
|
actions,
|
|
@@ -747,7 +1015,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
747
1015
|
? `Agent paused after ${actions.length} action(s) — API rate limit reached. Wait a moment and try again.`
|
|
748
1016
|
: 'API rate limit reached. Wait a moment and run the agent again.',
|
|
749
1017
|
error: `Rate limited (429) after ${maxTimeoutRetries} retries: ${err.message}`,
|
|
750
|
-
};
|
|
1018
|
+
});
|
|
751
1019
|
return result;
|
|
752
1020
|
}
|
|
753
1021
|
}
|
|
@@ -757,7 +1025,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
757
1025
|
// Don't throw — skip this iteration like timeouts do
|
|
758
1026
|
consecutiveTimeouts++;
|
|
759
1027
|
if (consecutiveTimeouts >= maxConsecutiveTimeouts) {
|
|
760
|
-
result = {
|
|
1028
|
+
result = asNotice({
|
|
761
1029
|
success: false,
|
|
762
1030
|
iterations: iteration,
|
|
763
1031
|
actions,
|
|
@@ -765,7 +1033,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
765
1033
|
? `Agent made progress (${actions.length} actions) but API errors prevented completion. You can continue by running the agent again.`
|
|
766
1034
|
: 'Agent could not complete the task due to repeated API errors. Check your API key and network connection.',
|
|
767
1035
|
error: `API failed after ${maxTimeoutRetries} retries: ${err.message}`,
|
|
768
|
-
};
|
|
1036
|
+
});
|
|
769
1037
|
return result;
|
|
770
1038
|
}
|
|
771
1039
|
messages.push({
|
|
@@ -792,7 +1060,8 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
792
1060
|
const lastUsage = getLastUsage();
|
|
793
1061
|
const inputTokens = lastUsage?.promptTokens ?? 0;
|
|
794
1062
|
if (inputTokens > 0) {
|
|
795
|
-
|
|
1063
|
+
// The model this run talks to, which a sub-agent may have overridden.
|
|
1064
|
+
const contextWindow = getModelContextWindow(String(chatRuntime.model ?? config.get('model')));
|
|
796
1065
|
const pct = Math.round(inputTokens / contextWindow * 100);
|
|
797
1066
|
const threshold = pct >= 95 ? 95 : pct >= 80 ? 80 : 0;
|
|
798
1067
|
if (threshold > 0 && threshold > lastBudgetWarning) {
|
|
@@ -815,7 +1084,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
815
1084
|
}
|
|
816
1085
|
// Warn the user if Ollama model fails to produce tool calls early on
|
|
817
1086
|
if (toolCalls.length === 0 && iteration <= 2 && providerId === 'ollama') {
|
|
818
|
-
const model = config.get('model');
|
|
1087
|
+
const model = String(chatRuntime.model ?? config.get('model'));
|
|
819
1088
|
const paramMatch = model.toLowerCase().match(/(\d+(?:\.\d+)?)b/);
|
|
820
1089
|
const params = paramMatch ? parseFloat(paramMatch[1]) : null;
|
|
821
1090
|
if (params !== null && params < 7) {
|
|
@@ -827,6 +1096,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
827
1096
|
debug(`No tool calls at iteration ${iteration}, content length: ${content.length}`);
|
|
828
1097
|
// Remove <think>...</think> tags from response (some models include thinking)
|
|
829
1098
|
// Also remove Tool parameters/tool call artifacts that AI sometimes includes in text
|
|
1099
|
+
appended = '';
|
|
830
1100
|
finalResponse = content
|
|
831
1101
|
.replace(/<think>[\s\S]*?<\/think>/gi, '')
|
|
832
1102
|
.replace(/<tool_call>[\s\S]*?<\/tool_call>/gi, '')
|
|
@@ -855,6 +1125,9 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
855
1125
|
role: 'user',
|
|
856
1126
|
content: 'Continue. Execute the tool calls now.'
|
|
857
1127
|
});
|
|
1128
|
+
// The fragment is not an answer. Left in place, it would make a run
|
|
1129
|
+
// that later hits the step limit look finished.
|
|
1130
|
+
finalResponse = '';
|
|
858
1131
|
continue;
|
|
859
1132
|
}
|
|
860
1133
|
// Reset counter once model produces real output or we give up
|
|
@@ -868,144 +1141,16 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
868
1141
|
// Execute tool calls
|
|
869
1142
|
const toolResults = [];
|
|
870
1143
|
for (const toolCall of toolCalls) {
|
|
1144
|
+
// Stop kills a running command at once; the calls queued behind it in
|
|
1145
|
+
// the same reply must not go ahead and write files after that.
|
|
1146
|
+
if (opts.abortSignal?.aborted)
|
|
1147
|
+
break;
|
|
871
1148
|
opts.onToolCall?.(toolCall);
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
// different execution surface.
|
|
876
|
-
if (activePersonality && !isPersonalityToolCallAllowed(activePersonality, toolCall, registeredMcpToolNames)) {
|
|
877
|
-
const allowed = activePersonality.declaredTools?.join(', ') || 'none';
|
|
878
|
-
const denied = {
|
|
879
|
-
success: false,
|
|
880
|
-
output: '',
|
|
881
|
-
error: `Tool "${toolCall.tool}" is blocked by custom bot "${activePersonality.displayName}".`,
|
|
882
|
-
tool: toolCall.tool,
|
|
883
|
-
parameters: toolCall.parameters,
|
|
884
|
-
};
|
|
885
|
-
opts.onToolResult?.(denied, toolCall);
|
|
886
|
-
actions.push(createActionLog(toolCall, denied));
|
|
887
|
-
// The one event nothing recorded before. A boundary you cannot audit
|
|
888
|
-
// is a boundary you have to take on faith.
|
|
889
|
-
recordAuditEvent(auditRoot, {
|
|
890
|
-
ts: Date.now(), run: auditRun, tool: toolCall.tool, action: 'refused',
|
|
891
|
-
target: describeAuditTarget(toolCall), outcome: 'refused',
|
|
892
|
-
detail: `blocked by custom bot "${activePersonality.displayName}"; allowed: ${allowed}`,
|
|
893
|
-
});
|
|
894
|
-
toolResults.push(`Tool ${toolCall.tool} is blocked by the active custom bot. Allowed capabilities: ${allowed}.`);
|
|
1149
|
+
const { result: toolResult, refusal } = await dispatchToolCall(toolCall);
|
|
1150
|
+
if (refusal) {
|
|
1151
|
+
toolResults.push(refusal);
|
|
895
1152
|
continue;
|
|
896
1153
|
}
|
|
897
|
-
// Tool scoping for delegated sub-agents: reject any tool outside the
|
|
898
|
-
// agent's allowlist up front — no permission prompt, no execution.
|
|
899
|
-
if (opts.allowedTools && !opts.allowedTools.includes(toolCall.tool)) {
|
|
900
|
-
const denied = {
|
|
901
|
-
success: false,
|
|
902
|
-
output: '',
|
|
903
|
-
error: `Tool "${toolCall.tool}" is not available to this sub-agent.`,
|
|
904
|
-
tool: toolCall.tool,
|
|
905
|
-
parameters: toolCall.parameters,
|
|
906
|
-
};
|
|
907
|
-
opts.onToolResult?.(denied, toolCall);
|
|
908
|
-
actions.push(createActionLog(toolCall, denied));
|
|
909
|
-
toolResults.push(`Tool ${toolCall.tool} is not allowed for this sub-agent. Use only: ${opts.allowedTools.join(', ')}.`);
|
|
910
|
-
continue;
|
|
911
|
-
}
|
|
912
|
-
// Permission check for dangerous tools (only when callback is provided, e.g. ACP/Zed)
|
|
913
|
-
if (opts.onRequestPermission && dangerousTools.has(toolCall.tool) && !alwaysAllowedTools.has(toolCall.tool)) {
|
|
914
|
-
const rejectResult = () => {
|
|
915
|
-
const toolResult = {
|
|
916
|
-
success: false,
|
|
917
|
-
output: '',
|
|
918
|
-
error: `User rejected permission for ${toolCall.tool}`,
|
|
919
|
-
tool: toolCall.tool,
|
|
920
|
-
parameters: toolCall.parameters,
|
|
921
|
-
};
|
|
922
|
-
opts.onToolResult?.(toolResult, toolCall);
|
|
923
|
-
actions.push(createActionLog(toolCall, toolResult));
|
|
924
|
-
toolResults.push(`Tool ${toolCall.tool} was denied by user. Do not attempt this action again.`);
|
|
925
|
-
return toolResult;
|
|
926
|
-
};
|
|
927
|
-
// Skip without asking if permanently rejected this session
|
|
928
|
-
if (alwaysRejectedTools.has(toolCall.tool)) {
|
|
929
|
-
rejectResult();
|
|
930
|
-
continue;
|
|
931
|
-
}
|
|
932
|
-
const outcome = await opts.onRequestPermission(toolCall);
|
|
933
|
-
// Fail CLOSED: allow ONLY on an explicit allow outcome; reject_* and
|
|
934
|
-
// any malformed/unknown outcome deny (see classifyPermissionOutcome).
|
|
935
|
-
const decision = classifyPermissionOutcome(outcome);
|
|
936
|
-
if (decision === 'allow-always') {
|
|
937
|
-
alwaysAllowedTools.add(toolCall.tool);
|
|
938
|
-
}
|
|
939
|
-
else if (decision === 'allow-once') {
|
|
940
|
-
// proceed this once
|
|
941
|
-
}
|
|
942
|
-
else {
|
|
943
|
-
if (decision === 'deny-always')
|
|
944
|
-
alwaysRejectedTools.add(toolCall.tool);
|
|
945
|
-
rejectResult();
|
|
946
|
-
continue;
|
|
947
|
-
}
|
|
948
|
-
}
|
|
949
|
-
let toolResult;
|
|
950
|
-
if (toolCall.tool === 'delegate') {
|
|
951
|
-
toolResult = await runDelegate(toolCall);
|
|
952
|
-
}
|
|
953
|
-
else if (opts.dryRun) {
|
|
954
|
-
toolResult = {
|
|
955
|
-
success: true,
|
|
956
|
-
output: `[DRY RUN] Would execute: ${toolCall.tool}`,
|
|
957
|
-
tool: toolCall.tool,
|
|
958
|
-
parameters: toolCall.parameters,
|
|
959
|
-
};
|
|
960
|
-
}
|
|
961
|
-
else if (opts.onExecuteCommand && toolCall.tool === 'execute_command') {
|
|
962
|
-
// Delegate to external terminal (e.g. Zed ACP terminal)
|
|
963
|
-
// Note: onExecuteCommand runs after the permission gate above
|
|
964
|
-
const command = toolCall.parameters.command;
|
|
965
|
-
const args = toolCall.parameters.args || [];
|
|
966
|
-
const cwd = projectContext.root || process.cwd();
|
|
967
|
-
if (!command) {
|
|
968
|
-
toolResult = {
|
|
969
|
-
success: false,
|
|
970
|
-
output: '',
|
|
971
|
-
error: 'execute_command called with missing command field',
|
|
972
|
-
tool: toolCall.tool,
|
|
973
|
-
parameters: toolCall.parameters,
|
|
974
|
-
};
|
|
975
|
-
}
|
|
976
|
-
else {
|
|
977
|
-
try {
|
|
978
|
-
const commandResult = await opts.onExecuteCommand(command, args, cwd);
|
|
979
|
-
toolResult = {
|
|
980
|
-
success: commandResult.exitCode === 0,
|
|
981
|
-
output: commandResult.stdout || '(no output)',
|
|
982
|
-
error: commandResult.exitCode !== 0 ? (commandResult.stderr || `exited with code ${commandResult.exitCode}`) : undefined,
|
|
983
|
-
tool: toolCall.tool,
|
|
984
|
-
parameters: toolCall.parameters,
|
|
985
|
-
};
|
|
986
|
-
}
|
|
987
|
-
catch (err) {
|
|
988
|
-
debug('onExecuteCommand callback threw, falling back to local execution:', err);
|
|
989
|
-
// Fallback to local execution if callback throws
|
|
990
|
-
toolResult = await executeTool(toolCall, cwd, opts.fs, opts.mcpSessionId);
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
}
|
|
994
|
-
else {
|
|
995
|
-
toolResult = await executeTool(toolCall, projectContext.root || process.cwd(), opts.fs, opts.mcpSessionId);
|
|
996
|
-
}
|
|
997
|
-
opts.onToolResult?.(toolResult, toolCall);
|
|
998
|
-
// Log action
|
|
999
|
-
const actionLog = createActionLog(toolCall, toolResult);
|
|
1000
|
-
actions.push(actionLog);
|
|
1001
|
-
// createActionLog already classified this; reuse its verdict rather than
|
|
1002
|
-
// re-deriving the action type in a second place that could drift.
|
|
1003
|
-
recordAuditEvent(auditRoot, {
|
|
1004
|
-
ts: Date.now(), run: auditRun, tool: toolCall.tool, action: actionLog.type,
|
|
1005
|
-
target: describeAuditTarget(toolCall),
|
|
1006
|
-
outcome: toolResult.success ? 'ok' : 'error',
|
|
1007
|
-
detail: toolResult.success ? undefined : toolResult.error,
|
|
1008
|
-
});
|
|
1009
1154
|
// ── Infinite loop detection for write/edit ──────────────────────────
|
|
1010
1155
|
if (toolCall.tool === 'write_file' || toolCall.tool === 'edit_file') {
|
|
1011
1156
|
const filePath = toolCall.parameters.path || '';
|
|
@@ -1069,7 +1214,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
1069
1214
|
}
|
|
1070
1215
|
// Check if we hit max iterations — build partial summary from actions log
|
|
1071
1216
|
if (iteration >= opts.maxIterations && !finalResponse) {
|
|
1072
|
-
result = buildPausedResult('iteration_limit', { iterations: iteration, actions, maxIterations: opts.maxIterations });
|
|
1217
|
+
result = asNotice(buildPausedResult('iteration_limit', { iterations: iteration, actions, maxIterations: opts.maxIterations }));
|
|
1073
1218
|
if (!opts.nested)
|
|
1074
1219
|
writeProgressLog(projectContext.root || '', prompt, result, projectContext.name);
|
|
1075
1220
|
return result;
|
|
@@ -1088,12 +1233,20 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
1088
1233
|
runLint: false,
|
|
1089
1234
|
};
|
|
1090
1235
|
const hasPermittedVerification = verificationPolicy.runBuild || verificationPolicy.runTest || verificationPolicy.runTypecheck;
|
|
1236
|
+
// Set when the checks still fail as verification stops, whatever stopped
|
|
1237
|
+
// it: attempts used up, the step limit, or a fix request that errored.
|
|
1238
|
+
let verificationFailure;
|
|
1239
|
+
let stillFailing = [];
|
|
1091
1240
|
if (autoVerify !== 'off' && !opts.dryRun && hasPermittedVerification) {
|
|
1092
1241
|
// Check if we made any file changes worth verifying
|
|
1093
1242
|
const hasFileChanges = actions.some(a => a.type === 'write' || a.type === 'edit' || a.type === 'delete');
|
|
1094
1243
|
if (hasFileChanges) {
|
|
1095
1244
|
let fixAttempt = 0;
|
|
1096
1245
|
let previousErrorSignature = '';
|
|
1246
|
+
// The latest failing verification, cleared once a later one passes.
|
|
1247
|
+
let unresolved = null;
|
|
1248
|
+
// Set once a verification finishes with no failing check.
|
|
1249
|
+
let verified = false;
|
|
1097
1250
|
while (fixAttempt < maxFixAttempts) {
|
|
1098
1251
|
// Check abort signal
|
|
1099
1252
|
if (opts.abortSignal?.aborted) {
|
|
@@ -1101,45 +1254,66 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
1101
1254
|
}
|
|
1102
1255
|
opts.onIteration?.(iteration, `Verification attempt ${fixAttempt + 1}/${maxFixAttempts}`);
|
|
1103
1256
|
// Run verifications based on selected mode
|
|
1104
|
-
const verifyResults = await runAllVerifications(projectContext.root || process.cwd(),
|
|
1257
|
+
const verifyResults = await runAllVerifications(projectContext.root || process.cwd(), {
|
|
1258
|
+
...verificationPolicy,
|
|
1259
|
+
signal: opts.abortSignal,
|
|
1260
|
+
});
|
|
1261
|
+
// Stopping kills the running checks, which then read as "could not
|
|
1262
|
+
// run". That is not a verdict: the run was stopped (handled below).
|
|
1263
|
+
if (opts.abortSignal?.aborted)
|
|
1264
|
+
break;
|
|
1105
1265
|
opts.onVerification?.(verifyResults);
|
|
1106
|
-
//
|
|
1266
|
+
// Point the agent at errors in files it touched, so it doesn't wander
|
|
1267
|
+
// off into unrelated code. This only narrows what the agent is shown;
|
|
1268
|
+
// a failing check stays failed. When nothing would be left — every
|
|
1269
|
+
// error is in a file this run didn't touch (a rename breaks an
|
|
1270
|
+
// importer, a test file fails) or the output couldn't be parsed —
|
|
1271
|
+
// the full list stays, since hiding it would pass a broken build.
|
|
1107
1272
|
const touchedFiles = new Set(actions
|
|
1108
1273
|
.filter(a => a.type === 'write' || a.type === 'edit')
|
|
1109
1274
|
.map(a => a.target));
|
|
1275
|
+
let errorsOutsideTouchedFiles = false;
|
|
1110
1276
|
for (const vr of verifyResults) {
|
|
1111
|
-
|
|
1277
|
+
if (vr.success)
|
|
1278
|
+
continue;
|
|
1279
|
+
const related = vr.errors.filter(e => {
|
|
1112
1280
|
if (!e.file)
|
|
1113
1281
|
return true; // Keep errors without file info (build failures etc)
|
|
1114
1282
|
return touchedFiles.has(e.file) || [...touchedFiles].some(f => e.file.endsWith(f) || f.endsWith(e.file));
|
|
1115
1283
|
});
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1284
|
+
if (related.some(e => e.severity === 'error')) {
|
|
1285
|
+
vr.errors = related;
|
|
1286
|
+
}
|
|
1287
|
+
else if (vr.errors.some(e => e.file)) {
|
|
1288
|
+
errorsOutsideTouchedFiles = true;
|
|
1119
1289
|
}
|
|
1120
1290
|
}
|
|
1121
|
-
//
|
|
1291
|
+
// Checks that could not run are reported, not fixed: there is
|
|
1292
|
+
// nothing in them for the model to act on, and they say nothing
|
|
1293
|
+
// about whether the change is right.
|
|
1122
1294
|
if (!hasVerificationErrors(verifyResults)) {
|
|
1295
|
+
unresolved = null;
|
|
1296
|
+
verified = true;
|
|
1123
1297
|
const summary = getVerificationSummary(verifyResults);
|
|
1124
|
-
|
|
1298
|
+
if (summary.passed > 0) {
|
|
1299
|
+
appendToResponse(`\n\n✓ Verification passed: ${summary.passed}/${summary.total} checks`);
|
|
1300
|
+
}
|
|
1301
|
+
const notRun = describeChecksNotRun(verifyResults);
|
|
1302
|
+
if (notRun)
|
|
1303
|
+
appendToResponse(`\n\n${notRun}`);
|
|
1125
1304
|
break;
|
|
1126
1305
|
}
|
|
1306
|
+
unresolved = verifyResults;
|
|
1127
1307
|
fixAttempt++;
|
|
1128
|
-
//
|
|
1129
|
-
// instead of stopping — let it keep working freely without the verification constraint
|
|
1308
|
+
// Out of attempts: stop here and report the failure below.
|
|
1130
1309
|
if (fixAttempt >= maxFixAttempts) {
|
|
1131
|
-
const errorMessage = formatErrorsForAgent(verifyResults);
|
|
1132
|
-
messages.push({ role: 'assistant', content: finalResponse });
|
|
1133
|
-
messages.push({
|
|
1134
|
-
role: 'user',
|
|
1135
|
-
content: `${errorMessage}\n\nVerification has failed ${fixAttempt} time(s). Stop trying the same approach. Step back, re-read ALL relevant files, and think about the root cause from scratch. Try a fundamentally different solution.`,
|
|
1136
|
-
});
|
|
1137
|
-
// Re-enter the main agent loop — it will continue until maxIterations
|
|
1138
|
-
iteration++;
|
|
1139
1310
|
break;
|
|
1140
1311
|
}
|
|
1141
1312
|
// Detect if the same errors are repeating (previous fix attempt didn't help)
|
|
1142
|
-
|
|
1313
|
+
let errorMessage = formatErrorsForAgent(verifyResults);
|
|
1314
|
+
if (errorsOutsideTouchedFiles) {
|
|
1315
|
+
errorMessage += '\n\nSome of these errors are in files you did not change and may predate this task. Fix them only if your change caused them.';
|
|
1316
|
+
}
|
|
1143
1317
|
const currentErrorSignature = errorMessage.slice(0, 200);
|
|
1144
1318
|
const errorsRepeating = previousErrorSignature !== '' && currentErrorSignature === previousErrorSignature;
|
|
1145
1319
|
previousErrorSignature = currentErrorSignature;
|
|
@@ -1170,58 +1344,22 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
1170
1344
|
if (fixToolCalls.length === 0) {
|
|
1171
1345
|
// Agent gave up or thinks it's fixed
|
|
1172
1346
|
finalResponse = fixContent.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
|
|
1347
|
+
appended = '';
|
|
1173
1348
|
continue; // Re-run verification
|
|
1174
1349
|
}
|
|
1175
1350
|
// Execute fix tool calls
|
|
1176
1351
|
messages.push({ role: 'assistant', content: fixContent });
|
|
1177
1352
|
const fixResults = [];
|
|
1178
1353
|
for (const toolCall of fixToolCalls) {
|
|
1354
|
+
if (opts.abortSignal?.aborted)
|
|
1355
|
+
break;
|
|
1179
1356
|
opts.onToolCall?.(toolCall);
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
error: `Tool "${toolCall.tool}" is blocked by custom bot "${activePersonality.displayName}".`,
|
|
1185
|
-
tool: toolCall.tool,
|
|
1186
|
-
parameters: toolCall.parameters,
|
|
1187
|
-
};
|
|
1188
|
-
opts.onToolResult?.(denied, toolCall);
|
|
1189
|
-
actions.push(createActionLog(toolCall, denied));
|
|
1190
|
-
// The one event nothing recorded before. A boundary you cannot audit
|
|
1191
|
-
// is a boundary you have to take on faith.
|
|
1192
|
-
recordAuditEvent(auditRoot, {
|
|
1193
|
-
ts: Date.now(), run: auditRun, tool: toolCall.tool, action: 'refused',
|
|
1194
|
-
target: describeAuditTarget(toolCall), outcome: 'refused',
|
|
1195
|
-
detail: `blocked by custom bot "${activePersonality.displayName}"`,
|
|
1196
|
-
});
|
|
1197
|
-
fixResults.push(`Tool ${toolCall.tool} blocked by the active custom bot.`);
|
|
1198
|
-
continue;
|
|
1199
|
-
}
|
|
1200
|
-
if (opts.allowedTools && !opts.allowedTools.includes(toolCall.tool)) {
|
|
1201
|
-
const denied = {
|
|
1202
|
-
success: false,
|
|
1203
|
-
output: '',
|
|
1204
|
-
error: `Tool "${toolCall.tool}" is not available to this sub-agent.`,
|
|
1205
|
-
tool: toolCall.tool,
|
|
1206
|
-
parameters: toolCall.parameters,
|
|
1207
|
-
};
|
|
1208
|
-
opts.onToolResult?.(denied, toolCall);
|
|
1209
|
-
actions.push(createActionLog(toolCall, denied));
|
|
1210
|
-
fixResults.push(`Tool ${toolCall.tool} is not allowed for this sub-agent.`);
|
|
1357
|
+
// Same gates as the main loop: a fix is still a tool call.
|
|
1358
|
+
const { result: toolResult, refusal } = await dispatchToolCall(toolCall);
|
|
1359
|
+
if (refusal) {
|
|
1360
|
+
fixResults.push(refusal);
|
|
1211
1361
|
continue;
|
|
1212
1362
|
}
|
|
1213
|
-
const toolResult = await executeTool(toolCall, projectContext.root || process.cwd(), opts.fs, opts.mcpSessionId);
|
|
1214
|
-
opts.onToolResult?.(toolResult, toolCall);
|
|
1215
|
-
const actionLog = createActionLog(toolCall, toolResult);
|
|
1216
|
-
actions.push(actionLog);
|
|
1217
|
-
// createActionLog already classified this; reuse its verdict rather than
|
|
1218
|
-
// re-deriving the action type in a second place that could drift.
|
|
1219
|
-
recordAuditEvent(auditRoot, {
|
|
1220
|
-
ts: Date.now(), run: auditRun, tool: toolCall.tool, action: actionLog.type,
|
|
1221
|
-
target: describeAuditTarget(toolCall),
|
|
1222
|
-
outcome: toolResult.success ? 'ok' : 'error',
|
|
1223
|
-
detail: toolResult.success ? undefined : toolResult.error,
|
|
1224
|
-
});
|
|
1225
1363
|
if (toolResult.success) {
|
|
1226
1364
|
const truncated = truncateToolResult(toolResult.output, toolCall.tool);
|
|
1227
1365
|
fixResults.push(`Tool ${toolCall.tool} succeeded:\n${truncated}`);
|
|
@@ -1240,6 +1378,32 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
1240
1378
|
break;
|
|
1241
1379
|
}
|
|
1242
1380
|
}
|
|
1381
|
+
// Stopped by the user before verification had its answer: that is a
|
|
1382
|
+
// stopped run, whatever the last check said.
|
|
1383
|
+
if (!verified && opts.abortSignal?.aborted) {
|
|
1384
|
+
debug('Agent aborted during verification');
|
|
1385
|
+
const stopped = finalResponse ? '\n\nAgent was stopped by user before verification finished' : 'Agent was stopped by user';
|
|
1386
|
+
result = {
|
|
1387
|
+
success: false,
|
|
1388
|
+
iterations: iteration,
|
|
1389
|
+
actions,
|
|
1390
|
+
finalResponse: `${finalResponse}${stopped}`,
|
|
1391
|
+
aborted: true,
|
|
1392
|
+
unstreamedText: `${appended}${stopped}`.trim(),
|
|
1393
|
+
};
|
|
1394
|
+
return result;
|
|
1395
|
+
}
|
|
1396
|
+
// Never end a run whose checks still fail as if it had passed: say so
|
|
1397
|
+
// in the response and fail the run, so nothing downstream (the
|
|
1398
|
+
// completion notice, auto-commit, the progress log) treats it as done.
|
|
1399
|
+
if (unresolved) {
|
|
1400
|
+
appendToResponse(`\n\n${describeVerificationFailure(unresolved)}`);
|
|
1401
|
+
const notRun = describeChecksNotRun(unresolved);
|
|
1402
|
+
if (notRun)
|
|
1403
|
+
appendToResponse(`\n\n${notRun}`);
|
|
1404
|
+
stillFailing = failedChecks(unresolved).map(r => r.command);
|
|
1405
|
+
verificationFailure = `Verification failed: ${stillFailing.join(', ')}`;
|
|
1406
|
+
}
|
|
1243
1407
|
}
|
|
1244
1408
|
}
|
|
1245
1409
|
// Pipeline (Phase 2): optional automatic review pass. After a top-level run
|
|
@@ -1262,17 +1426,19 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
1262
1426
|
});
|
|
1263
1427
|
const body = (review.output || '').replace(/^\[reviewer\]\s*/, '').trim();
|
|
1264
1428
|
if (body)
|
|
1265
|
-
|
|
1429
|
+
appendToResponse(`\n\n---\n### Auto-review (reviewer)\n${body}`);
|
|
1266
1430
|
}
|
|
1267
1431
|
catch {
|
|
1268
1432
|
// A failed review must never fail the run.
|
|
1269
1433
|
}
|
|
1270
1434
|
}
|
|
1271
1435
|
result = {
|
|
1272
|
-
success:
|
|
1436
|
+
success: !verificationFailure,
|
|
1273
1437
|
iterations: iteration,
|
|
1274
1438
|
actions,
|
|
1275
1439
|
finalResponse,
|
|
1440
|
+
...(verificationFailure ? { error: verificationFailure, failedChecks: stillFailing } : {}),
|
|
1441
|
+
unstreamedText: appended.trim(),
|
|
1276
1442
|
};
|
|
1277
1443
|
if (!opts.nested)
|
|
1278
1444
|
writeProgressLog(projectContext.root || '', prompt, result, projectContext.name);
|
|
@@ -1281,13 +1447,13 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
1281
1447
|
catch (error) {
|
|
1282
1448
|
const err = error;
|
|
1283
1449
|
auditFailure = err.message;
|
|
1284
|
-
result = {
|
|
1450
|
+
result = asNotice({
|
|
1285
1451
|
success: false,
|
|
1286
1452
|
iterations: iteration,
|
|
1287
1453
|
actions,
|
|
1288
1454
|
finalResponse: '',
|
|
1289
1455
|
error: err.message,
|
|
1290
|
-
};
|
|
1456
|
+
});
|
|
1291
1457
|
return result;
|
|
1292
1458
|
}
|
|
1293
1459
|
finally {
|
|
@@ -1354,15 +1520,17 @@ export function getAgentHistory() {
|
|
|
1354
1520
|
}));
|
|
1355
1521
|
}
|
|
1356
1522
|
/**
|
|
1357
|
-
*
|
|
1523
|
+
* Actions of the run undo acts on (see getCurrentSession). Pass the workspace
|
|
1524
|
+
* to leave out a run in another one. `result` is 'undone' for an action that
|
|
1525
|
+
* has since been undone, 'success' otherwise.
|
|
1358
1526
|
*/
|
|
1359
|
-
export function getCurrentSessionActions() {
|
|
1360
|
-
const session = getCurrentSession();
|
|
1527
|
+
export function getCurrentSessionActions(projectRoot) {
|
|
1528
|
+
const session = getCurrentSession(projectRoot);
|
|
1361
1529
|
if (!session)
|
|
1362
1530
|
return [];
|
|
1363
1531
|
return session.actions.map(a => ({
|
|
1364
1532
|
type: a.type,
|
|
1365
1533
|
target: a.path || '',
|
|
1366
|
-
result: 'success',
|
|
1534
|
+
result: a.undone ? 'undone' : 'success',
|
|
1367
1535
|
}));
|
|
1368
1536
|
}
|