@pentoshi/clai 2.0.15 → 2.0.16
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/agent/runner.d.ts +4 -0
- package/dist/agent/runner.js +362 -395
- package/dist/agent/runner.js.map +1 -1
- package/dist/commands/providers.js +10 -11
- package/dist/commands/providers.js.map +1 -1
- package/dist/commands/search-providers.js +10 -11
- package/dist/commands/search-providers.js.map +1 -1
- package/dist/commands/update.js +1 -1
- package/dist/llm/groq.d.ts +1 -0
- package/dist/llm/groq.js +45 -1
- package/dist/llm/groq.js.map +1 -1
- package/dist/llm/provider.js +4 -7
- package/dist/llm/provider.js.map +1 -1
- package/dist/llm/router.js +8 -2
- package/dist/llm/router.js.map +1 -1
- package/dist/modes/ask.js +5 -3
- package/dist/modes/ask.js.map +1 -1
- package/dist/prompts/index.d.ts +2 -2
- package/dist/prompts/index.js +6 -3
- package/dist/prompts/index.js.map +1 -1
- package/dist/safety/classifier.js +10 -50
- package/dist/safety/classifier.js.map +1 -1
- package/dist/store/keys.js +50 -18
- package/dist/store/keys.js.map +1 -1
- package/dist/tools/http.d.ts +1 -0
- package/dist/tools/http.js +109 -9
- package/dist/tools/http.js.map +1 -1
- package/dist/tools/jobs.d.ts +4 -0
- package/dist/tools/jobs.js +29 -1
- package/dist/tools/jobs.js.map +1 -1
- package/dist/tools/registry.js +5 -8
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/web/fetch-core.js +6 -9
- package/dist/tools/web/fetch-core.js.map +1 -1
- package/dist/tools/web/fetch.js +26 -0
- package/dist/tools/web/fetch.js.map +1 -1
- package/dist/tools/web/readable.d.ts +7 -3
- package/dist/tools/web/readable.js +235 -11
- package/dist/tools/web/readable.js.map +1 -1
- package/dist/tools/web/types.d.ts +7 -8
- package/dist/tools/web/types.js +3 -3
- package/dist/tools/web/types.js.map +1 -1
- package/dist/tui/App.js +74 -6
- package/dist/tui/App.js.map +1 -1
- package/dist/tui/components/SecretInputPanel.d.ts +1 -0
- package/dist/tui/components/SecretInputPanel.js +15 -2
- package/dist/tui/components/SecretInputPanel.js.map +1 -1
- package/dist/tui/format-keys.js +16 -15
- package/dist/tui/format-keys.js.map +1 -1
- package/dist/tui/hooks/useAgentRunner.js +1 -1
- package/dist/tui/hooks/useAgentRunner.js.map +1 -1
- package/dist/tui/render-lines.js +179 -29
- package/dist/tui/render-lines.js.map +1 -1
- package/dist/ui/thinking.js +14 -2
- package/dist/ui/thinking.js.map +1 -1
- package/package.json +1 -1
package/dist/agent/runner.js
CHANGED
|
@@ -4,6 +4,8 @@ import { mkdir, writeFile } from "node:fs/promises";
|
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { streamWithProvider } from "../llm/router.js";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { jobManager } from "../tools/jobs.js";
|
|
7
9
|
import { currentDateTimeContext, renderAgentSystemPrompt, } from "../prompts/index.js";
|
|
8
10
|
import { getConfig } from "../store/config.js";
|
|
9
11
|
import { classifyToolCall, isPentestToolCall, scopeHint, scopeTargetForToolCall, } from "../safety/classifier.js";
|
|
@@ -270,23 +272,17 @@ function stripLoneFence(text) {
|
|
|
270
272
|
return (fenced?.[1] ?? text).trim();
|
|
271
273
|
}
|
|
272
274
|
/**
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
* recognizes it. Returns:
|
|
277
|
-
* - { call } when the object is a complete {name, args} tool call, or
|
|
278
|
-
* - { argsOnly: true } when it looks like a bare args object (so the caller
|
|
279
|
-
* can nudge the model to re-emit a properly named, fenced tool call).
|
|
280
|
-
* Returns undefined for anything that is plainly a normal prose/JSON answer.
|
|
275
|
+
* Try to recover a bare-args tool call from a single candidate text snippet.
|
|
276
|
+
* Returns the recognized result or undefined if the text isn't a recoverable
|
|
277
|
+
* tool call. Used by both the whole-text path and the embedded-fence path.
|
|
281
278
|
*/
|
|
282
|
-
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
if (!inner.startsWith("{") || !inner.endsWith("}"))
|
|
279
|
+
function tryRecognizeBareArgs(inner) {
|
|
280
|
+
const trimmed = inner.trim();
|
|
281
|
+
if (!trimmed.startsWith("{") || !trimmed.endsWith("}"))
|
|
286
282
|
return undefined;
|
|
287
283
|
let parsed;
|
|
288
284
|
try {
|
|
289
|
-
parsed = JSON.parse(
|
|
285
|
+
parsed = JSON.parse(trimmed);
|
|
290
286
|
}
|
|
291
287
|
catch {
|
|
292
288
|
return undefined;
|
|
@@ -295,28 +291,64 @@ export function recognizeBareToolJson(text) {
|
|
|
295
291
|
return undefined;
|
|
296
292
|
}
|
|
297
293
|
const obj = parsed;
|
|
298
|
-
// Complete {name, args} call the earlier matchers didn't catch
|
|
299
|
-
|
|
300
|
-
const direct = tryParseCall(inner);
|
|
294
|
+
// Complete {name, args} call the earlier matchers didn't catch.
|
|
295
|
+
const direct = tryParseCall(trimmed);
|
|
301
296
|
if (direct)
|
|
302
297
|
return { call: direct };
|
|
303
|
-
// Bare args object: every key is a known tool-arg key
|
|
304
|
-
// least one identifying arg. Don't treat huge/odd objects as tool args.
|
|
298
|
+
// Bare args object: every key is a known tool-arg key.
|
|
305
299
|
const keys = Object.keys(obj);
|
|
306
300
|
if (keys.length === 0 || keys.length > 6)
|
|
307
301
|
return undefined;
|
|
308
302
|
const allKnown = keys.every((key) => TOOL_ARG_KEYS.has(key));
|
|
309
303
|
if (!allKnown)
|
|
310
304
|
return undefined;
|
|
311
|
-
// Try to infer the tool from the arg shape so an unambiguous bare object
|
|
312
|
-
// (e.g. {"command":"…"}) runs immediately instead of forcing the user to
|
|
313
|
-
// type "run". Ambiguous shapes fall back to a re-emit nudge.
|
|
314
305
|
const inferred = inferToolFromArgs(obj);
|
|
315
306
|
if (inferred) {
|
|
316
307
|
return { call: { name: inferred, args: obj } };
|
|
317
308
|
}
|
|
318
309
|
return { argsOnly: true };
|
|
319
310
|
}
|
|
311
|
+
/**
|
|
312
|
+
* When a model means to call a tool but emits ONLY a bare JSON object —
|
|
313
|
+
* either a proper {"name","args"} that the strict matchers missed, or a bare
|
|
314
|
+
* args object like {"path":"file.pdf"} with the wrapper/fence dropped — this
|
|
315
|
+
* recognizes it. Returns:
|
|
316
|
+
* - { call } when the object is a complete {name, args} tool call, or
|
|
317
|
+
* - { argsOnly: true } when it looks like a bare args object (so the caller
|
|
318
|
+
* can nudge the model to re-emit a properly named, fenced tool call).
|
|
319
|
+
* Returns undefined for anything that is plainly a normal prose/JSON answer.
|
|
320
|
+
*
|
|
321
|
+
* Also handles the case where a model emits prose followed by a non-`tool`
|
|
322
|
+
* fenced code block (e.g. ```web\n{"url":"..."}\n```) that contains a bare
|
|
323
|
+
* args object — the fence is scanned even when it's not the sole content.
|
|
324
|
+
*/
|
|
325
|
+
export function recognizeBareToolJson(text) {
|
|
326
|
+
// ── Primary path: the whole (de-fenced) text is a bare JSON object ──────
|
|
327
|
+
const inner = stripLoneFence(text);
|
|
328
|
+
const primary = tryRecognizeBareArgs(inner);
|
|
329
|
+
if (primary)
|
|
330
|
+
return primary;
|
|
331
|
+
// ── Secondary path: scan for any fenced block embedded in the text ──────
|
|
332
|
+
// This catches models that prepend prose before emitting a bare-args fence,
|
|
333
|
+
// e.g. "Let me fetch it.\n\n```web\n{\"url\":\"https://...\"}\n```"
|
|
334
|
+
// We skip ```tool fences — those are handled by parseToolCall already.
|
|
335
|
+
const embeddedFenceRe = /```([a-zA-Z]*)\s*\n?([\s\S]*?)```/g;
|
|
336
|
+
let m;
|
|
337
|
+
while ((m = embeddedFenceRe.exec(text)) !== null) {
|
|
338
|
+
const lang = m[1] ?? "";
|
|
339
|
+
const body = (m[2] ?? "").trim();
|
|
340
|
+
// Skip ```tool blocks — parseToolCall owns those.
|
|
341
|
+
if (lang.toLowerCase() === "tool")
|
|
342
|
+
continue;
|
|
343
|
+
// Skip empty or multi-line JSON that spans more than a simple object.
|
|
344
|
+
if (!body.startsWith("{") || !body.endsWith("}"))
|
|
345
|
+
continue;
|
|
346
|
+
const result = tryRecognizeBareArgs(body);
|
|
347
|
+
if (result)
|
|
348
|
+
return result;
|
|
349
|
+
}
|
|
350
|
+
return undefined;
|
|
351
|
+
}
|
|
320
352
|
/**
|
|
321
353
|
* Detect an opened-but-unparseable tool call. This happens when the model's
|
|
322
354
|
* output is truncated by the token limit mid-JSON: we see the ```tool fence
|
|
@@ -585,7 +617,7 @@ export function looksLikeBuildTask(prompt, history) {
|
|
|
585
617
|
}
|
|
586
618
|
// Matrix of action-verb narration: the model says it is *about to* do
|
|
587
619
|
// something but hasn't. Used to detect "narrate, don't act" stalls.
|
|
588
|
-
const ACTION_NARRATION_RE = /\b(?:let me|let's|i'?ll|i will|i'?m going to|i am going to|i need to|i should|i'?m about to|going to|now i'?ll|first[,]?\s*i'?ll)\s+(?:now\s+|first\s+|quickly\s+|just\s+|go\s+ahead\s+and\s+)?(?:explore|list|read|check|inspect|examine|look|create|run|start|write|build|add|scaffold|set\s*up|setup|install|initialize|init|generate|make|review|open|find|search|verify|update|edit|modify|fix|implement)\b/i;
|
|
620
|
+
const ACTION_NARRATION_RE = /\b(?:let me|let's|i'?ll|i will|i'?m going to|i am going to|i need to|i should|i'?m about to|going to|now i'?ll|first[,]?\s*i'?ll)\s+(?:now\s+|first\s+|quickly\s+|just\s+|go\s+ahead\s+and\s+)?(?:explore|list|read|fetch|browse|check|inspect|examine|look|create|run|start|write|build|add|scaffold|set\s*up|setup|install|initialize|init|generate|make|review|open|find|search|verify|update|edit|modify|fix|implement)\b/i;
|
|
589
621
|
/**
|
|
590
622
|
* Detect a message that narrates an *upcoming* action ("let me explore the
|
|
591
623
|
* directory", "I'll create the components") rather than an actual answer or
|
|
@@ -804,6 +836,12 @@ function formatToolContext(call, result) {
|
|
|
804
836
|
const output = result.output.trim();
|
|
805
837
|
if (!output)
|
|
806
838
|
return "";
|
|
839
|
+
if (call.name === "web.fetch" || call.name === "http.fetch") {
|
|
840
|
+
const saved = result.outputPath
|
|
841
|
+
? `\nFull output saved to: ${result.outputPath}`
|
|
842
|
+
: "";
|
|
843
|
+
return `${output}${saved}`.trim();
|
|
844
|
+
}
|
|
807
845
|
let reduced;
|
|
808
846
|
try {
|
|
809
847
|
const command = call.name === "shell.exec" ? String(call.args.command ?? "") : call.name;
|
|
@@ -1261,6 +1299,258 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1261
1299
|
let productiveSteps = 0;
|
|
1262
1300
|
let step = -1;
|
|
1263
1301
|
let nextToolEventId = 0;
|
|
1302
|
+
const promptMutex = {
|
|
1303
|
+
promise: Promise.resolve(),
|
|
1304
|
+
async acquire() {
|
|
1305
|
+
let release = () => { };
|
|
1306
|
+
const next = new Promise((r) => { release = r; });
|
|
1307
|
+
const current = this.promise;
|
|
1308
|
+
this.promise = current.then(() => next);
|
|
1309
|
+
await current;
|
|
1310
|
+
return release;
|
|
1311
|
+
}
|
|
1312
|
+
};
|
|
1313
|
+
async function executeSingleTool(rawCall, toolEventId, parentSignal) {
|
|
1314
|
+
let call = normalizeToolCall(rawCall);
|
|
1315
|
+
if (call.name === "image.ocr" && !imageOcrEnabled) {
|
|
1316
|
+
writeNotice("info", "skipped OCR because the original image is attached to the vision model", chalk.dim(" ℹ skipped OCR — inspecting the attached image directly\n"));
|
|
1317
|
+
const recoveryText = "The original image is attached to this message and you can inspect it directly. " +
|
|
1318
|
+
"Do not call image.ocr or infer text from OCR. Answer the user's question from the actual image pixels now.";
|
|
1319
|
+
const result = { ok: true, output: recoveryText };
|
|
1320
|
+
return { ok: true, call, result, contextOutput: recoveryText };
|
|
1321
|
+
}
|
|
1322
|
+
const loopCheck = loopGuard.shouldBlock(call.name, call.args);
|
|
1323
|
+
if (loopCheck.block) {
|
|
1324
|
+
const isWrite = call.name === "fs.write" ||
|
|
1325
|
+
call.name === "fs.writeMany" ||
|
|
1326
|
+
call.name === "fs.edit";
|
|
1327
|
+
const reason = `${call.name} was already called with the same arguments — ${isWrite ? "moving on" : "forcing summary"}`;
|
|
1328
|
+
writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
|
|
1329
|
+
const result = { ok: false, output: reason, exitCode: 1 };
|
|
1330
|
+
return { ok: false, call, result, contextOutput: reason, blockOrCancel: true };
|
|
1331
|
+
}
|
|
1332
|
+
if (loopCheck.reason) {
|
|
1333
|
+
writeNotice("info", loopCheck.reason, chalk.dim(` ℹ ${loopCheck.reason}\n`));
|
|
1334
|
+
}
|
|
1335
|
+
if (call.name === "plan.create" || call.name === "task.update") {
|
|
1336
|
+
const planResult = await handlePlanTool(call, session, { loopGuard, step });
|
|
1337
|
+
if (planResult.handled) {
|
|
1338
|
+
loopGuard.recordAttempt(step, call.name, call.args, planResult.ok, 0);
|
|
1339
|
+
if (planResult.plan) {
|
|
1340
|
+
writePlanUpdate(planResult.plan, planResult.display);
|
|
1341
|
+
}
|
|
1342
|
+
const result = { ok: planResult.ok, output: planResult.modelNote };
|
|
1343
|
+
return { ok: planResult.ok, call, result, contextOutput: planResult.modelNote };
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
const scope = await loadScope();
|
|
1347
|
+
const decision = classifyToolCall(call, { scope });
|
|
1348
|
+
await auditLog("tool.classified", {
|
|
1349
|
+
call,
|
|
1350
|
+
decision,
|
|
1351
|
+
scope: isScopeActive(scope) ? (scope.name ?? "(unnamed)") : "(none)",
|
|
1352
|
+
});
|
|
1353
|
+
if (activePlan &&
|
|
1354
|
+
!session.planApproved.value &&
|
|
1355
|
+
!isPreApprovalAllowedTool(call.name)) {
|
|
1356
|
+
const reason = `plan awaiting approval — ${call.name} is blocked until you /implement (or /discard)`;
|
|
1357
|
+
writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
|
|
1358
|
+
const result = { ok: false, output: reason, exitCode: 1 };
|
|
1359
|
+
return { ok: false, call, result, contextOutput: reason, blockOrCancel: true };
|
|
1360
|
+
}
|
|
1361
|
+
if (call.name === "web.search") {
|
|
1362
|
+
sawFreshWebSearch = true;
|
|
1363
|
+
}
|
|
1364
|
+
const toolCallLine = chalk.cyan(` ▶ ${call.name}`) + chalk.gray(` ${formatToolArgs(call)}`);
|
|
1365
|
+
writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
|
|
1366
|
+
const scopeTarget = scopeTargetForToolCall(call);
|
|
1367
|
+
if (scopeTarget &&
|
|
1368
|
+
(!isScopeActive(scope) || !targetInScope(scopeTarget, scope))) {
|
|
1369
|
+
writeNotice("info", `scope optional: ${scopeHint(scopeTarget)}`, chalk.dim(` scope optional: ${scopeHint(scopeTarget)}\n`));
|
|
1370
|
+
}
|
|
1371
|
+
if (decision.level === "block") {
|
|
1372
|
+
writeToolBlocked(toolEventId, call.name, decision.reason, chalk.red(` ✗ blocked: ${decision.reason}`) + "\n");
|
|
1373
|
+
const lastAnswer = `Blocked: ${call.name} — ${decision.reason}`;
|
|
1374
|
+
const result = { ok: false, output: lastAnswer, exitCode: 1 };
|
|
1375
|
+
return { ok: false, call, result, contextOutput: lastAnswer, lastAnswer, blockOrCancel: true };
|
|
1376
|
+
}
|
|
1377
|
+
let authorized = true;
|
|
1378
|
+
let pentestJustConfirmed = false;
|
|
1379
|
+
const releasePrompt = await promptMutex.acquire();
|
|
1380
|
+
try {
|
|
1381
|
+
parentSignal.throwIfAborted();
|
|
1382
|
+
const needsPentestAuth = isPentestToolCall(call) &&
|
|
1383
|
+
!getConfig().pentestAuthorized &&
|
|
1384
|
+
!session.pentestAuthorized.value;
|
|
1385
|
+
authorized = await ensurePentestAuthorization(call, Boolean(options.autoConfirm), session, confirmPort);
|
|
1386
|
+
restoreInteractiveStdin();
|
|
1387
|
+
if (!authorized) {
|
|
1388
|
+
const lastAnswer = "Pentest authorization not confirmed.";
|
|
1389
|
+
writeToolBlocked(toolEventId, call.name, lastAnswer, chalk.red(` ✗ ${lastAnswer}`) + "\n");
|
|
1390
|
+
const result = { ok: false, output: lastAnswer, exitCode: 1 };
|
|
1391
|
+
return { ok: false, call, result, contextOutput: lastAnswer, lastAnswer, blockOrCancel: true };
|
|
1392
|
+
}
|
|
1393
|
+
if (needsPentestAuth) {
|
|
1394
|
+
pentestJustConfirmed = true;
|
|
1395
|
+
}
|
|
1396
|
+
const forceManualConfirm = call.name === "fs.delete";
|
|
1397
|
+
if (decision.level === "confirm" && !pentestJustConfirmed) {
|
|
1398
|
+
const ok = await confirmToolExecution(call, forceManualConfirm ? false : Boolean(options.autoConfirm), session, confirmPort);
|
|
1399
|
+
restoreInteractiveStdin();
|
|
1400
|
+
if (!ok) {
|
|
1401
|
+
const lastAnswer = "Cancelled.";
|
|
1402
|
+
writeNotice("warn", "cancelled", chalk.yellow(` ✗ cancelled`) + "\n");
|
|
1403
|
+
const result = { ok: false, output: lastAnswer, exitCode: 1 };
|
|
1404
|
+
return { ok: false, call, result, contextOutput: lastAnswer, lastAnswer, blockOrCancel: true };
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
finally {
|
|
1409
|
+
releasePrompt();
|
|
1410
|
+
}
|
|
1411
|
+
parentSignal.throwIfAborted();
|
|
1412
|
+
options.onToolStart?.(call);
|
|
1413
|
+
const interactiveCommand = (call.name === "shell.exec" &&
|
|
1414
|
+
typeof call.args.command === "string" &&
|
|
1415
|
+
looksInteractiveStdin(call.args.command)) ||
|
|
1416
|
+
call.name === "net.scan" ||
|
|
1417
|
+
call.name === "pentest.recon";
|
|
1418
|
+
if (interactiveCommand && process.stdin.isTTY) {
|
|
1419
|
+
writeNotice("warn", "this command may prompt for a password — type it when asked", chalk.yellow(" ⚠ this command may prompt for a password — type it when asked\n"));
|
|
1420
|
+
}
|
|
1421
|
+
const toolAc = new AbortController();
|
|
1422
|
+
const onParentAbort = () => toolAc.abort();
|
|
1423
|
+
parentSignal.addEventListener("abort", onParentAbort);
|
|
1424
|
+
let result;
|
|
1425
|
+
let liveBytes = 0;
|
|
1426
|
+
const liveCap = 16_000;
|
|
1427
|
+
let liveTruncatedNotified = false;
|
|
1428
|
+
let lastProgressAt = 0;
|
|
1429
|
+
const shouldDimLive = !interactiveCommand;
|
|
1430
|
+
const writeToolInfo = (text) => {
|
|
1431
|
+
writeToolOutput(toolEventId, `${text}\n`, chalk.dim(` ${text}\n`));
|
|
1432
|
+
};
|
|
1433
|
+
const printLive = (chunk) => {
|
|
1434
|
+
if (call.name === "fs.read" ||
|
|
1435
|
+
call.name === "fs.list" ||
|
|
1436
|
+
call.name === "fs.search")
|
|
1437
|
+
return;
|
|
1438
|
+
if (liveBytes >= liveCap) {
|
|
1439
|
+
if (!liveTruncatedNotified) {
|
|
1440
|
+
liveTruncatedNotified = true;
|
|
1441
|
+
writeToolInfo("... live preview truncated, full output saved");
|
|
1442
|
+
writeToolInfo("(tool still running — ESC or Ctrl+C to abort)");
|
|
1443
|
+
lastProgressAt = Date.now();
|
|
1444
|
+
}
|
|
1445
|
+
const now = Date.now();
|
|
1446
|
+
if (now - lastProgressAt > 5_000) {
|
|
1447
|
+
lastProgressAt = now;
|
|
1448
|
+
writeToolOutput(toolEventId, ".", chalk.dim("."));
|
|
1449
|
+
}
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
const remaining = liveCap - liveBytes;
|
|
1453
|
+
const slice = chunk.length > remaining ? chunk.slice(0, remaining) : chunk;
|
|
1454
|
+
liveBytes += slice.length;
|
|
1455
|
+
const indented = slice.replace(/\r/g, "").replace(/\n(?!$)/g, "\n ");
|
|
1456
|
+
const body = indented.startsWith("\n") ? indented : ` ${indented}`;
|
|
1457
|
+
writeToolOutput(toolEventId, slice, shouldDimLive ? chalk.dim(body) : body);
|
|
1458
|
+
};
|
|
1459
|
+
const jobId = randomUUID().slice(0, 8);
|
|
1460
|
+
const backgroundJob = {
|
|
1461
|
+
id: jobId,
|
|
1462
|
+
command: `${call.name} ${formatToolArgs(call)}`,
|
|
1463
|
+
cwd: safeCwd(),
|
|
1464
|
+
status: "running",
|
|
1465
|
+
startedAt: new Date().toISOString(),
|
|
1466
|
+
artifactPath: "",
|
|
1467
|
+
};
|
|
1468
|
+
jobManager.registerJob(jobId, backgroundJob, toolAc);
|
|
1469
|
+
try {
|
|
1470
|
+
result = await runToolCall(call, {
|
|
1471
|
+
signal: toolAc.signal,
|
|
1472
|
+
requestSecret: options.requestSecret,
|
|
1473
|
+
onOutput: (chunk) => {
|
|
1474
|
+
if (toolAc.signal.aborted)
|
|
1475
|
+
return;
|
|
1476
|
+
printLive(chunk);
|
|
1477
|
+
},
|
|
1478
|
+
});
|
|
1479
|
+
if (liveBytes > 0 || liveTruncatedNotified) {
|
|
1480
|
+
writeToolOutput(toolEventId, "\n", "\n");
|
|
1481
|
+
}
|
|
1482
|
+
jobManager.updateJobStatus(jobId, result.ok ? "exited" : "failed", result.exitCode);
|
|
1483
|
+
}
|
|
1484
|
+
catch (toolError) {
|
|
1485
|
+
jobManager.updateJobStatus(jobId, "failed", 1);
|
|
1486
|
+
if (isAbortError(toolError, toolAc.signal)) {
|
|
1487
|
+
writeAbort();
|
|
1488
|
+
return { ok: false, call, result: { ok: false, output: "Aborted." }, contextOutput: "Aborted.", lastAnswer: "Aborted." };
|
|
1489
|
+
}
|
|
1490
|
+
const errMsg = toolError instanceof Error ? toolError.message : String(toolError);
|
|
1491
|
+
result = { ok: false, output: `Tool error: ${errMsg}`, exitCode: 1 };
|
|
1492
|
+
}
|
|
1493
|
+
finally {
|
|
1494
|
+
parentSignal.removeEventListener("abort", onParentAbort);
|
|
1495
|
+
}
|
|
1496
|
+
const output = result.output.trim();
|
|
1497
|
+
const displayMax = 6_000;
|
|
1498
|
+
const savedOutputPath = result.outputPath ??
|
|
1499
|
+
(output.length > displayMax
|
|
1500
|
+
? await saveToolOutput(call, output)
|
|
1501
|
+
: undefined);
|
|
1502
|
+
const resultWithArtifact = {
|
|
1503
|
+
...result,
|
|
1504
|
+
outputPath: savedOutputPath,
|
|
1505
|
+
truncated: result.truncated ?? Boolean(savedOutputPath),
|
|
1506
|
+
};
|
|
1507
|
+
if (savedOutputPath) {
|
|
1508
|
+
const storedJob = jobManager.getJob(jobId);
|
|
1509
|
+
if (storedJob) {
|
|
1510
|
+
storedJob.artifactPath = savedOutputPath;
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
const contextOutput = formatToolContext(call, resultWithArtifact);
|
|
1514
|
+
emitToolResult(toolEventId, resultWithArtifact, contextOutput, savedOutputPath);
|
|
1515
|
+
options.onToolResult?.(call, resultWithArtifact);
|
|
1516
|
+
await auditLog("tool.result", {
|
|
1517
|
+
call,
|
|
1518
|
+
ok: result.ok,
|
|
1519
|
+
exitCode: result.exitCode,
|
|
1520
|
+
output: result.output.slice(0, 4_000),
|
|
1521
|
+
});
|
|
1522
|
+
loopGuard.recordAttempt(step, call.name, call.args, result.ok, result.exitCode);
|
|
1523
|
+
const statusIcon = result.ok ? chalk.green(" ✓") : chalk.red(" ✗");
|
|
1524
|
+
writeToolOutput(toolEventId, result.ok ? "ok\n" : "failed\n", statusIcon + "\n");
|
|
1525
|
+
if (output) {
|
|
1526
|
+
const displaySummary = summarizeOutput(output, displayMax);
|
|
1527
|
+
const displayText = displaySummary.truncated
|
|
1528
|
+
? `${displaySummary.text}${savedOutputPath ? chalk.dim(`\n ... full output saved to ${savedOutputPath}`) : chalk.dim("\n ... output truncated")}`
|
|
1529
|
+
: displaySummary.text;
|
|
1530
|
+
if (liveBytes > 0) {
|
|
1531
|
+
if (savedOutputPath) {
|
|
1532
|
+
writeToolInfo(`full output saved to ${savedOutputPath}`);
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
else {
|
|
1536
|
+
const renderedOutput = indentAndWrapText(displayText);
|
|
1537
|
+
writeToolOutput(toolEventId, displayText, styleToolChatter(call, renderedOutput) + "\n");
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
if (output) {
|
|
1541
|
+
const viewport = registerViewport({
|
|
1542
|
+
toolName: call.name,
|
|
1543
|
+
argsDisplay: formatToolArgs(call),
|
|
1544
|
+
artifactPath: savedOutputPath,
|
|
1545
|
+
summary: contextOutput,
|
|
1546
|
+
});
|
|
1547
|
+
if (savedOutputPath) {
|
|
1548
|
+
const viewportHint = `${formatViewportHint(viewport)}\n`;
|
|
1549
|
+
writeStatus(viewportHint, viewportHint);
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
return { ok: result.ok, call, result, contextOutput };
|
|
1553
|
+
}
|
|
1264
1554
|
for (let iteration = 0; iteration < maxIterations; iteration += 1) {
|
|
1265
1555
|
// `step` is the productive-step index (used for display + audit). It only
|
|
1266
1556
|
// advances when the previous iteration actually executed a tool.
|
|
@@ -1539,14 +1829,18 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1539
1829
|
// … please approve") instead of calling a tool — accepting it as a
|
|
1540
1830
|
// final answer ends the turn with nothing done and no real plan saved.
|
|
1541
1831
|
// Nudge it to emit a real tool call, with a concrete example.
|
|
1542
|
-
const
|
|
1832
|
+
const narratedAction = looksLikeActionNarration(cleaned);
|
|
1833
|
+
const wantsAction = buildLikeTurn ||
|
|
1834
|
+
(activePlan && session.planApproved.value) ||
|
|
1835
|
+
freshWebSearchRequired ||
|
|
1836
|
+
narratedAction;
|
|
1543
1837
|
const planNarrated = buildLikeTurn && !activePlan && looksLikePlanNarration(cleaned);
|
|
1544
1838
|
if (wantsAction &&
|
|
1545
1839
|
cleaned.trim().length > 0 &&
|
|
1546
1840
|
actionIntentRetries < 3 &&
|
|
1547
1841
|
(productiveSteps === 0 ||
|
|
1548
1842
|
planNarrated ||
|
|
1549
|
-
|
|
1843
|
+
narratedAction)) {
|
|
1550
1844
|
actionIntentRetries += 1;
|
|
1551
1845
|
let nudge;
|
|
1552
1846
|
if (activePlan && session.planApproved.value) {
|
|
@@ -1554,6 +1848,13 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1554
1848
|
"You wrote a message but emitted NO ```tool block, so NOTHING ran. Do NOT narrate what you will do — DO it. Emit the next tool call now (task.update / fs.writeMany / shell.exec) in a single ```tool block.";
|
|
1555
1849
|
writeNotice("warn", "described an action but emitted no tool call — nudging it to run one", chalk.yellow(" ⚠ described an action but emitted no tool call — nudging it to run one\n"));
|
|
1556
1850
|
}
|
|
1851
|
+
else if (!buildLikeTurn) {
|
|
1852
|
+
nudge =
|
|
1853
|
+
"You wrote that you would fetch/search/read something but emitted NO ```tool block, so NOTHING ran. Do NOT narrate the next browsing step — DO it. Emit exactly one valid ```tool block now. If you know the exact page, use:\n" +
|
|
1854
|
+
'```tool\n{"name":"web.fetch","args":{"url":"https://example.com/page","responseMode":"readable"}}\n```\n' +
|
|
1855
|
+
"If you do not know the exact page URL, use web.search first. After the tool output, answer from the fetched page content.";
|
|
1856
|
+
writeNotice("warn", "described a web action but emitted no tool call — nudging it to run one", chalk.yellow(" ⚠ described a web action but emitted no tool call — nudging it to run one\n"));
|
|
1857
|
+
}
|
|
1557
1858
|
else if (planNarrated || productiveSteps > 0) {
|
|
1558
1859
|
// It explored and/or wrote a plan as prose but never called
|
|
1559
1860
|
// plan.create — emit the plan as a real tool call so the user gets
|
|
@@ -1648,9 +1949,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1648
1949
|
return finishTurn(lastAnswer, step + 1);
|
|
1649
1950
|
}
|
|
1650
1951
|
// A valid primary tool call exists for this fresh model turn. Show any
|
|
1651
|
-
// prose / thinking that preceded it, record the assistant message ONCE
|
|
1652
|
-
// then queue any additional tool calls from the same message so they
|
|
1653
|
-
// run in order on the next iterations (no extra round-trip).
|
|
1952
|
+
// prose / thinking that preceded it, record the assistant message ONCE.
|
|
1654
1953
|
const beforeTool = recoveredFromBareJson
|
|
1655
1954
|
? ""
|
|
1656
1955
|
: textBeforeToolCall(assistantText.visible);
|
|
@@ -1664,388 +1963,56 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1664
1963
|
role: "assistant",
|
|
1665
1964
|
content: collapseRepeatedText(assistantText.visible),
|
|
1666
1965
|
});
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
allCalls[0] &&
|
|
1671
|
-
sameToolCall(allCalls[0], call)) {
|
|
1672
|
-
pendingCalls = allCalls.slice(1);
|
|
1673
|
-
writeNotice("info", `${allCalls.length} tool calls in this message — running them in order`, chalk.dim(` ℹ ${allCalls.length} tool calls in this message — running them in order\n`));
|
|
1674
|
-
}
|
|
1966
|
+
let allCalls = parseAllToolCalls(assistantText.visible || assistantText.thinkContent);
|
|
1967
|
+
if (allCalls.length === 0 && call) {
|
|
1968
|
+
allCalls = [call];
|
|
1675
1969
|
}
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
// runs and is safety-classified as the shell command it really is —
|
|
1684
|
-
// instead of dead-ending on "Unknown tool: sed".
|
|
1685
|
-
call = normalizeToolCall(call);
|
|
1686
|
-
if (call.name === "image.ocr" && !imageOcrEnabled) {
|
|
1687
|
-
pendingCalls = pendingCalls.filter((queued) => queued.name !== "image.ocr");
|
|
1688
|
-
writeNotice("info", "skipped OCR because the original image is attached to the vision model", chalk.dim(" ℹ skipped OCR — inspecting the attached image directly\n"));
|
|
1689
|
-
messages.push(recoveryUserMessage("The original image is attached to this message and you can inspect it directly. " +
|
|
1690
|
-
"Do not call image.ocr or infer text from OCR. Answer the user's question from the actual image pixels now."));
|
|
1691
|
-
continue;
|
|
1692
|
-
}
|
|
1693
|
-
const toolEventId = `tool-${++nextToolEventId}`;
|
|
1694
|
-
// ── Duplicate-call detection ──────────────────────────────────────────
|
|
1695
|
-
// If the model calls the exact same tool with the exact same args
|
|
1696
|
-
// repeatedly, it's stuck in a loop. Inject a corrective message
|
|
1697
|
-
// telling it to summarize the results it already has.
|
|
1698
|
-
const loopCheck = loopGuard.shouldBlock(call.name, call.args);
|
|
1699
|
-
if (loopCheck.block) {
|
|
1700
|
-
const isWrite = call.name === "fs.write" ||
|
|
1701
|
-
call.name === "fs.writeMany" ||
|
|
1702
|
-
call.name === "fs.edit";
|
|
1703
|
-
const reason = `${call.name} was already called with the same arguments — ${isWrite ? "moving on" : "forcing summary"}`;
|
|
1704
|
-
writeNotice("warn", reason, chalk.yellow(` ⚠ ${call.name} was already called with the same arguments — ${isWrite ? "moving on" : "forcing summary"}\n`));
|
|
1705
|
-
// A repeat means this batch went off the rails — drop any queued calls
|
|
1706
|
-
// and let the model react. The assistant message was already recorded.
|
|
1707
|
-
pendingCalls = [];
|
|
1708
|
-
messages.push({
|
|
1709
|
-
role: "user",
|
|
1710
|
-
content: isWrite
|
|
1711
|
-
? `You already wrote that exact file with ${call.name}. It is saved. ` +
|
|
1712
|
-
"Do NOT write it again. Move on to the NEXT file or step. If every file is written, " +
|
|
1713
|
-
"verify the project (list the tree, run the build/install command) and give your final answer."
|
|
1714
|
-
: `You already called ${call.name} with the same arguments and received results. ` +
|
|
1715
|
-
"Do NOT call it again. Summarize the findings you already have and give your final answer NOW.",
|
|
1716
|
-
});
|
|
1717
|
-
continue;
|
|
1718
|
-
}
|
|
1719
|
-
if (loopCheck.reason) {
|
|
1720
|
-
writeNotice("info", loopCheck.reason, chalk.dim(` ℹ ${loopCheck.reason}\n`));
|
|
1721
|
-
}
|
|
1722
|
-
// ── Plan / task tools (session-scoped, handled inline) ─────────────
|
|
1723
|
-
// These don't go through the generic registry because they need the
|
|
1724
|
-
// session id and mutate the live plan that the user can view (Ctrl+P).
|
|
1725
|
-
if (call.name === "plan.create" || call.name === "task.update") {
|
|
1726
|
-
const planResult = await handlePlanTool(call, session, {
|
|
1727
|
-
loopGuard,
|
|
1728
|
-
step,
|
|
1970
|
+
if (allCalls.length > 1) {
|
|
1971
|
+
writeNotice("info", `${allCalls.length} tool calls in this message — running them in parallel`, chalk.dim(` ℹ ${allCalls.length} tool calls in this message — running them in parallel\n`));
|
|
1972
|
+
}
|
|
1973
|
+
// Execute all calls in parallel
|
|
1974
|
+
const toolPromises = allCalls.map((c) => {
|
|
1975
|
+
const id = `tool-${++nextToolEventId}`;
|
|
1976
|
+
return executeSingleTool(c, id, options.signal || new AbortController().signal);
|
|
1729
1977
|
});
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1978
|
+
const results = await Promise.all(toolPromises);
|
|
1979
|
+
let aborted = false;
|
|
1980
|
+
let blocked = false;
|
|
1981
|
+
let blockedResult = null;
|
|
1982
|
+
for (const res of results) {
|
|
1983
|
+
if (res.lastAnswer === "Aborted.") {
|
|
1984
|
+
aborted = true;
|
|
1985
|
+
}
|
|
1986
|
+
if (res.blockOrCancel) {
|
|
1987
|
+
blocked = true;
|
|
1988
|
+
blockedResult = res;
|
|
1735
1989
|
}
|
|
1736
|
-
// plan.create means "STOP and wait for /implement" — abandon any
|
|
1737
|
-
// other calls the model batched alongside it.
|
|
1738
|
-
if (call.name === "plan.create")
|
|
1739
|
-
pendingCalls = [];
|
|
1740
1990
|
messages.push({
|
|
1741
1991
|
role: "tool",
|
|
1742
|
-
content: `Tool ${call.name} result (ok=${
|
|
1992
|
+
content: `Tool ${res.call.name} result (exit=${res.result.exitCode ?? 0}, ok=${res.result.ok}):\n${res.contextOutput}`,
|
|
1743
1993
|
});
|
|
1744
|
-
|
|
1745
|
-
}
|
|
1746
|
-
}
|
|
1747
|
-
const scope = await loadScope();
|
|
1748
|
-
const decision = classifyToolCall(call, { scope });
|
|
1749
|
-
await auditLog("tool.classified", {
|
|
1750
|
-
call,
|
|
1751
|
-
decision,
|
|
1752
|
-
scope: isScopeActive(scope) ? (scope.name ?? "(unnamed)") : "(none)",
|
|
1753
|
-
});
|
|
1754
|
-
// ── Plan-awaiting-approval gate ────────────────────────────────────
|
|
1755
|
-
// When an active plan exists but the user has NOT approved it with
|
|
1756
|
-
// /implement, the agent must NOT execute the plan. Any free-text the
|
|
1757
|
-
// user typed after the plan was shown is a PLAN REVISION, not a "go"
|
|
1758
|
-
// signal — the agent should re-plan (plan.create) and wait again. We
|
|
1759
|
-
// hard-block execution tools here so a model that ignores the prompt
|
|
1760
|
-
// directive (or recovers a stray tool call) can't start running the
|
|
1761
|
-
// plan. Read-only exploration is still allowed so it can refine the
|
|
1762
|
-
// plan intelligently.
|
|
1763
|
-
if (activePlan &&
|
|
1764
|
-
!session.planApproved.value &&
|
|
1765
|
-
!isPreApprovalAllowedTool(call.name)) {
|
|
1766
|
-
const reason = `plan awaiting approval — ${call.name} is blocked until you /implement (or /discard)`;
|
|
1767
|
-
writeNotice("warn", reason, chalk.yellow(` ⚠ plan awaiting approval — ${call.name} is blocked until you /implement (or /discard)\n`));
|
|
1768
|
-
pendingCalls = [];
|
|
1769
|
-
messages.push({
|
|
1770
|
-
role: "user",
|
|
1771
|
-
content: `There is an ACTIVE PLAN that has NOT been approved yet, so you must NOT execute it — ` +
|
|
1772
|
-
`you tried to call ${call.name}, which is blocked. The user's latest message is a PLAN REVISION, ` +
|
|
1773
|
-
`not approval. Update the plan to incorporate their feedback by calling plan.create again with the ` +
|
|
1774
|
-
`revised goal/detail/tasks, then STOP and wait. The user approves with /implement or cancels with /discard. ` +
|
|
1775
|
-
`Do NOT run any execution tool (shell.exec, pkg.install, fs.write, net.scan, tool.check, etc.) until they /implement.`,
|
|
1776
|
-
});
|
|
1777
|
-
continue;
|
|
1778
|
-
}
|
|
1779
|
-
if (call.name === "web.search") {
|
|
1780
|
-
sawFreshWebSearch = true;
|
|
1781
|
-
}
|
|
1782
|
-
// Show tool call
|
|
1783
|
-
const toolCallLine = chalk.cyan(` ▶ ${call.name}`) + chalk.gray(` ${formatToolArgs(call)}`);
|
|
1784
|
-
writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
|
|
1785
|
-
const scopeTarget = scopeTargetForToolCall(call);
|
|
1786
|
-
if (scopeTarget &&
|
|
1787
|
-
(!isScopeActive(scope) || !targetInScope(scopeTarget, scope))) {
|
|
1788
|
-
writeNotice("info", `scope optional: ${scopeHint(scopeTarget)}`, chalk.dim(` scope optional: ${scopeHint(scopeTarget)}\n`));
|
|
1789
|
-
}
|
|
1790
|
-
if (decision.level === "block") {
|
|
1791
|
-
writeToolBlocked(toolEventId, call.name, decision.reason, chalk.red(` ✗ blocked: ${decision.reason}`) + "\n");
|
|
1792
|
-
lastAnswer = `Blocked: ${call.name} — ${decision.reason}`;
|
|
1793
|
-
return finishTurn(lastAnswer, productiveSteps);
|
|
1794
|
-
}
|
|
1795
|
-
// Pentest authorization — if user confirms this, skip the per-tool confirm
|
|
1796
|
-
let pentestJustConfirmed = false;
|
|
1797
|
-
const needsPentestAuth = isPentestToolCall(call) &&
|
|
1798
|
-
!getConfig().pentestAuthorized &&
|
|
1799
|
-
!session.pentestAuthorized.value;
|
|
1800
|
-
const authorized = await ensurePentestAuthorization(call, Boolean(options.autoConfirm), session, confirmPort);
|
|
1801
|
-
// inquirer's confirm() creates its own readline interface which resets
|
|
1802
|
-
// raw mode AND pauses stdin when it finishes. Re-assert raw mode and
|
|
1803
|
-
// resume stdin so the outer keypress handler (ESC/Ctrl+C abort, Ctrl+O
|
|
1804
|
-
// output pane) keeps working during the next streaming/tool phase.
|
|
1805
|
-
restoreInteractiveStdin();
|
|
1806
|
-
if (!authorized) {
|
|
1807
|
-
lastAnswer = "Pentest authorization not confirmed.";
|
|
1808
|
-
writeToolBlocked(toolEventId, call.name, lastAnswer, chalk.red(` ✗ ${lastAnswer}`) + "\n");
|
|
1809
|
-
return finishTurn(lastAnswer, productiveSteps);
|
|
1810
|
-
}
|
|
1811
|
-
if (needsPentestAuth) {
|
|
1812
|
-
pentestJustConfirmed = true;
|
|
1813
|
-
}
|
|
1814
|
-
// Confirm if needed (safe tools auto-execute, pentest-auth'd tools skip)
|
|
1815
|
-
// fs.delete and shell deletions NEVER auto-confirm even with -y flag.
|
|
1816
|
-
const forceManualConfirm = call.name === "fs.delete";
|
|
1817
|
-
if (decision.level === "confirm" && !pentestJustConfirmed) {
|
|
1818
|
-
const ok = await confirmToolExecution(call, forceManualConfirm ? false : Boolean(options.autoConfirm), session, confirmPort);
|
|
1819
|
-
// Re-assert raw mode and resume stdin after inquirer's confirm()
|
|
1820
|
-
// (see restoreInteractiveStdin / the comment above).
|
|
1821
|
-
restoreInteractiveStdin();
|
|
1822
|
-
if (!ok) {
|
|
1823
|
-
lastAnswer = "Cancelled.";
|
|
1824
|
-
writeNotice("warn", "cancelled", chalk.yellow(` ✗ cancelled`) + "\n");
|
|
1825
|
-
return finishTurn(lastAnswer, productiveSteps);
|
|
1826
|
-
}
|
|
1827
|
-
}
|
|
1828
|
-
// Execute tool
|
|
1829
|
-
options.signal?.throwIfAborted();
|
|
1830
|
-
options.onToolStart?.(call);
|
|
1831
|
-
// Heads-up when the command is about to run something that may pause
|
|
1832
|
-
// for a password prompt (sudo, ssh, gpg, ...). The shell tool already
|
|
1833
|
-
// routes such commands through inherited stdin so the user can type
|
|
1834
|
-
// directly into the controlling TTY; we just warn them to expect it.
|
|
1835
|
-
const interactiveCommand = (call.name === "shell.exec" &&
|
|
1836
|
-
typeof call.args.command === "string" &&
|
|
1837
|
-
looksInteractiveStdin(call.args.command)) ||
|
|
1838
|
-
call.name === "net.scan" ||
|
|
1839
|
-
call.name === "pentest.recon";
|
|
1840
|
-
if (interactiveCommand && process.stdin.isTTY) {
|
|
1841
|
-
writeNotice("warn", "this command may prompt for a password — type it when asked", chalk.yellow(" ⚠ this command may prompt for a password — type it when asked\n"));
|
|
1842
|
-
}
|
|
1843
|
-
let result;
|
|
1844
|
-
let liveBytes = 0;
|
|
1845
|
-
const liveCap = 16_000; // Stop streaming after this many bytes to avoid flooding the terminal.
|
|
1846
|
-
let liveTruncatedNotified = false;
|
|
1847
|
-
let lastProgressAt = 0;
|
|
1848
|
-
// When the underlying command may pause for a password prompt
|
|
1849
|
-
// (sudo / ssh / etc.) we stream the live preview *without* the dim
|
|
1850
|
-
// attribute so the prompt is fully readable. Otherwise we keep the
|
|
1851
|
-
// dim styling that makes ordinary tool chatter visually distinct
|
|
1852
|
-
// from the model's prose.
|
|
1853
|
-
const shouldDimLive = !interactiveCommand;
|
|
1854
|
-
const printLive = (chunk) => {
|
|
1855
|
-
// Suppress live preview for fs.read / fs.list — those are read-only
|
|
1856
|
-
// and the final summary is already concise. Stream shell-style tools
|
|
1857
|
-
// (shell.exec, net.scan, pentest.recon, pkg.install).
|
|
1858
|
-
if (call.name === "fs.read" ||
|
|
1859
|
-
call.name === "fs.list" ||
|
|
1860
|
-
call.name === "fs.search")
|
|
1861
|
-
return;
|
|
1862
|
-
if (liveBytes >= liveCap) {
|
|
1863
|
-
if (!liveTruncatedNotified) {
|
|
1864
|
-
liveTruncatedNotified = true;
|
|
1865
|
-
writeNotice("info", "live preview truncated, full output saved", chalk.dim("\n … live preview truncated, full output saved\n"));
|
|
1866
|
-
writeNotice("info", "tool still running — ESC or Ctrl+C to abort", chalk.dim(" (tool still running — ESC or Ctrl+C to abort)\n"));
|
|
1867
|
-
lastProgressAt = Date.now();
|
|
1868
|
-
}
|
|
1869
|
-
// After truncation, show a dot every 5 seconds so the user knows
|
|
1870
|
-
// the tool is still running and the terminal isn't frozen.
|
|
1871
|
-
const now = Date.now();
|
|
1872
|
-
if (now - lastProgressAt > 5_000) {
|
|
1873
|
-
lastProgressAt = now;
|
|
1874
|
-
writeToolOutput(toolEventId, ".", chalk.dim("."));
|
|
1875
|
-
}
|
|
1876
|
-
return;
|
|
1877
|
-
}
|
|
1878
|
-
const remaining = liveCap - liveBytes;
|
|
1879
|
-
const slice = chunk.length > remaining ? chunk.slice(0, remaining) : chunk;
|
|
1880
|
-
liveBytes += slice.length;
|
|
1881
|
-
// Indent each line so live output lines up under the tool call.
|
|
1882
|
-
const indented = slice.replace(/\r/g, "").replace(/\n(?!$)/g, "\n ");
|
|
1883
|
-
const body = indented.startsWith("\n") ? indented : ` ${indented}`;
|
|
1884
|
-
// Skip the dim wrapper for interactive commands so a sudo password
|
|
1885
|
-
// prompt is rendered at full brightness; everything else stays dim
|
|
1886
|
-
// so tool chatter is visually distinct from the model's prose.
|
|
1887
|
-
writeToolOutput(toolEventId, slice, shouldDimLive ? chalk.dim(body) : body);
|
|
1888
|
-
};
|
|
1889
|
-
try {
|
|
1890
|
-
result = await runToolCall(call, {
|
|
1891
|
-
signal: options.signal,
|
|
1892
|
-
requestSecret: options.requestSecret,
|
|
1893
|
-
onOutput: (chunk) => {
|
|
1894
|
-
if (options.signal?.aborted)
|
|
1895
|
-
return;
|
|
1896
|
-
printLive(chunk);
|
|
1897
|
-
},
|
|
1898
|
-
});
|
|
1899
|
-
// Newline separator if live output or progress dots didn't already end with one.
|
|
1900
|
-
if (liveBytes > 0 || liveTruncatedNotified) {
|
|
1901
|
-
writeToolOutput(toolEventId, "\n", "\n");
|
|
1994
|
+
productiveSteps += 1;
|
|
1902
1995
|
}
|
|
1903
|
-
|
|
1904
|
-
catch (toolError) {
|
|
1905
|
-
if (isAbortError(toolError, options.signal)) {
|
|
1996
|
+
if (aborted) {
|
|
1906
1997
|
lastAnswer = "Aborted.";
|
|
1907
1998
|
writeAbort();
|
|
1908
1999
|
return lastAnswer;
|
|
1909
2000
|
}
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
// Stop-on-error: if this call failed, abandon any remaining queued calls
|
|
1914
|
-
// from the same message so the model sees the failure and decides what to
|
|
1915
|
-
// do next instead of blindly running steps that depended on it.
|
|
1916
|
-
if (!result.ok && pendingCalls.length > 0) {
|
|
1917
|
-
const cancelledQueuedStatus = ` ↳ ${pendingCalls.length} queued call(s) cancelled because this step failed\n`;
|
|
1918
|
-
writeStatus(cancelledQueuedStatus, chalk.dim(cancelledQueuedStatus));
|
|
1919
|
-
pendingCalls = [];
|
|
1920
|
-
}
|
|
1921
|
-
const output = result.output.trim();
|
|
1922
|
-
const displayMax = 6_000;
|
|
1923
|
-
// If the tool already produced an artifact (shell.exec now streams to one
|
|
1924
|
-
// as it runs), respect that path. Otherwise, fall back to the post-hoc
|
|
1925
|
-
// save for tools that return their full output in memory.
|
|
1926
|
-
const savedOutputPath = result.outputPath ??
|
|
1927
|
-
(output.length > displayMax
|
|
1928
|
-
? await saveToolOutput(call, output)
|
|
1929
|
-
: undefined);
|
|
1930
|
-
const resultWithArtifact = {
|
|
1931
|
-
...result,
|
|
1932
|
-
outputPath: savedOutputPath,
|
|
1933
|
-
truncated: result.truncated ?? Boolean(savedOutputPath),
|
|
1934
|
-
};
|
|
1935
|
-
const contextOutput = formatToolContext(call, resultWithArtifact);
|
|
1936
|
-
emitToolResult(toolEventId, resultWithArtifact, contextOutput, savedOutputPath);
|
|
1937
|
-
options.onToolResult?.(call, resultWithArtifact);
|
|
1938
|
-
await auditLog("tool.result", {
|
|
1939
|
-
call,
|
|
1940
|
-
ok: result.ok,
|
|
1941
|
-
exitCode: result.exitCode,
|
|
1942
|
-
output: result.output.slice(0, 4_000),
|
|
1943
|
-
});
|
|
1944
|
-
// Record the attempt in the loop guard for dedup tracking.
|
|
1945
|
-
loopGuard.recordAttempt(step, call.name, call.args, result.ok, result.exitCode);
|
|
1946
|
-
// A tool actually executed this iteration — count it against the
|
|
1947
|
-
// productive-step budget. Recovery iterations (thinking-only nudges,
|
|
1948
|
-
// malformed-call retries, freshness/loop-guard prompts) reach `continue`
|
|
1949
|
-
// before this point and therefore never consume the budget.
|
|
1950
|
-
productiveSteps += 1;
|
|
1951
|
-
// ── Auto-retry on "command not found" ──────────────────────────
|
|
1952
|
-
// Detect missing tools and instruct the model to install + retry.
|
|
1953
|
-
const NOT_FOUND_RE = /command not found|ENOENT.*spawn|is not recognized/i;
|
|
1954
|
-
if (!result.ok && NOT_FOUND_RE.test(output)) {
|
|
1955
|
-
const cmdName = call.name === "shell.exec"
|
|
1956
|
-
? String(call.args.command ?? "").split(/\s+/)[0]
|
|
1957
|
-
: call.name === "net.scan"
|
|
1958
|
-
? "nmap"
|
|
1959
|
-
: call.name === "image.ocr"
|
|
1960
|
-
? "tesseract"
|
|
1961
|
-
: undefined;
|
|
1962
|
-
if (cmdName) {
|
|
1963
|
-
writeNotice("warn", `${cmdName} not found — asking model to install and retry`, chalk.yellow(` ⚠ ${cmdName} not found — asking model to install and retry\n`));
|
|
1964
|
-
messages.push({
|
|
1965
|
-
role: "tool",
|
|
1966
|
-
content: `Tool failed: "${cmdName}" is not installed.\n` +
|
|
1967
|
-
`You MUST: 1) use pkg.install to install "${cmdName}", ` +
|
|
1968
|
-
`2) then RETRY the original command. Do NOT stop or give up.`,
|
|
1969
|
-
});
|
|
1970
|
-
continue;
|
|
2001
|
+
if (blocked && blockedResult) {
|
|
2002
|
+
lastAnswer = blockedResult.lastAnswer || "Blocked or Cancelled.";
|
|
2003
|
+
return finishTurn(lastAnswer, productiveSteps);
|
|
1971
2004
|
}
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
writeNotice("warn", "sudo needs an interactive terminal — asking the model to retry without -S/askpass", chalk.yellow(" ⚠ sudo needs an interactive terminal — asking the model to retry without -S/askpass\n"));
|
|
1982
|
-
messages.push({
|
|
1983
|
-
role: "tool",
|
|
1984
|
-
content: "Tool failed: sudo could not read a password.\n" +
|
|
1985
|
-
"On the next attempt: call shell.exec with `sudo <command>` directly. " +
|
|
1986
|
-
"clai inherits stdin from the user's terminal, so the user can type the password live. " +
|
|
1987
|
-
'DO NOT use `echo "<pwd>" | sudo -S`, DO NOT use SUDO_ASKPASS, DO NOT ask the user for the password in chat. ' +
|
|
1988
|
-
"Just run `sudo <command>` and the password prompt will be visible.",
|
|
1989
|
-
});
|
|
1990
|
-
continue;
|
|
1991
|
-
}
|
|
1992
|
-
// Print tool result
|
|
1993
|
-
const statusIcon = result.ok ? chalk.green(" ✓") : chalk.red(" ✗");
|
|
1994
|
-
writeToolOutput(toolEventId, result.ok ? "ok\n" : "failed\n", statusIcon + "\n");
|
|
1995
|
-
if (output) {
|
|
1996
|
-
const displaySummary = summarizeOutput(output, displayMax);
|
|
1997
|
-
const displayText = displaySummary.truncated
|
|
1998
|
-
? `${displaySummary.text}${savedOutputPath ? chalk.dim(`\n ... full output saved to ${savedOutputPath}`) : chalk.dim("\n ... output truncated")}`
|
|
1999
|
-
: displaySummary.text;
|
|
2000
|
-
// If we already streamed live output for this call, skip re-printing
|
|
2001
|
-
// the same bytes. Just note where the full output lives if it was saved.
|
|
2002
|
-
if (liveBytes > 0) {
|
|
2003
|
-
if (savedOutputPath) {
|
|
2004
|
-
writeNotice("info", `full output saved to ${savedOutputPath}`, chalk.dim(` full output saved to ${savedOutputPath}\n`));
|
|
2005
|
+
// Compact older messages when the running estimate exceeds budget
|
|
2006
|
+
if (estimateMessagesTokens(messages) > 24_000) {
|
|
2007
|
+
const compacted = compactMessages(messages);
|
|
2008
|
+
if (compacted.length < messages.length) {
|
|
2009
|
+
messages.splice(0, messages.length, ...compacted);
|
|
2010
|
+
await auditLog("agent.compact", {
|
|
2011
|
+
newLength: messages.length,
|
|
2012
|
+
estimatedTokens: estimateMessagesTokens(messages),
|
|
2013
|
+
});
|
|
2005
2014
|
}
|
|
2006
2015
|
}
|
|
2007
|
-
else {
|
|
2008
|
-
const renderedOutput = indentAndWrapText(displayText);
|
|
2009
|
-
writeToolOutput(toolEventId, displayText, styleToolChatter(call, renderedOutput) + "\n");
|
|
2010
|
-
}
|
|
2011
|
-
}
|
|
2012
|
-
if (isAbortError(undefined, options.signal)) {
|
|
2013
|
-
lastAnswer = "Aborted.";
|
|
2014
|
-
writeAbort();
|
|
2015
|
-
return lastAnswer;
|
|
2016
|
-
}
|
|
2017
|
-
// Register a collapse/expand viewport so the user can pull the full raw
|
|
2018
|
-
// output back with Ctrl+O or `/output last` after the AI summary lands.
|
|
2019
|
-
if (output) {
|
|
2020
|
-
const viewport = registerViewport({
|
|
2021
|
-
toolName: call.name,
|
|
2022
|
-
argsDisplay: formatToolArgs(call),
|
|
2023
|
-
artifactPath: savedOutputPath,
|
|
2024
|
-
summary: contextOutput,
|
|
2025
|
-
});
|
|
2026
|
-
// Only print the Ctrl+O hint when there's a real artifact file
|
|
2027
|
-
// (large output saved to disk). Avoid spamming the hint for
|
|
2028
|
-
// every tiny tool call — the user can always use /output last.
|
|
2029
|
-
if (savedOutputPath) {
|
|
2030
|
-
const viewportHint = `${formatViewportHint(viewport)}\n`;
|
|
2031
|
-
writeStatus(viewportHint, viewportHint);
|
|
2032
|
-
}
|
|
2033
|
-
}
|
|
2034
|
-
messages.push({
|
|
2035
|
-
role: "tool",
|
|
2036
|
-
content: `Tool ${call.name} result (exit=${result.exitCode ?? 0}, ok=${result.ok}):\n${contextOutput}`,
|
|
2037
|
-
});
|
|
2038
|
-
// Compact older messages when the running estimate exceeds budget so
|
|
2039
|
-
// free-tier context windows are not blown by long pentest sessions.
|
|
2040
|
-
if (estimateMessagesTokens(messages) > 24_000) {
|
|
2041
|
-
const compacted = compactMessages(messages);
|
|
2042
|
-
if (compacted.length < messages.length) {
|
|
2043
|
-
messages.splice(0, messages.length, ...compacted);
|
|
2044
|
-
await auditLog("agent.compact", {
|
|
2045
|
-
newLength: messages.length,
|
|
2046
|
-
estimatedTokens: estimateMessagesTokens(messages),
|
|
2047
|
-
});
|
|
2048
|
-
}
|
|
2049
2016
|
}
|
|
2050
2017
|
}
|
|
2051
2018
|
lastAnswer = `Stopped after ${productiveSteps} steps.`;
|