@pentoshi/clai 0.12.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/clai.mjs +25 -0
- package/dist/agent/loop-guard.js +10 -2
- package/dist/agent/loop-guard.js.map +1 -1
- package/dist/agent/runner.d.ts +38 -1
- package/dist/agent/runner.js +516 -36
- package/dist/agent/runner.js.map +1 -1
- package/dist/commands/update.js +1 -1
- package/dist/commands/update.js.map +1 -1
- package/dist/llm/anthropic.js +31 -12
- package/dist/llm/anthropic.js.map +1 -1
- package/dist/llm/capabilities.d.ts +13 -0
- package/dist/llm/capabilities.js +107 -24
- package/dist/llm/capabilities.js.map +1 -1
- package/dist/llm/gemini.js +17 -4
- package/dist/llm/gemini.js.map +1 -1
- package/dist/llm/http.d.ts +12 -1
- package/dist/llm/http.js +50 -25
- package/dist/llm/http.js.map +1 -1
- package/dist/llm/ollama.js +16 -8
- package/dist/llm/ollama.js.map +1 -1
- package/dist/modes/agent.d.ts +2 -1
- package/dist/modes/agent.js.map +1 -1
- package/dist/modes/ask.d.ts +2 -1
- package/dist/modes/ask.js +5 -1
- package/dist/modes/ask.js.map +1 -1
- package/dist/os/cwd.d.ts +30 -0
- package/dist/os/cwd.js +76 -0
- package/dist/os/cwd.js.map +1 -0
- package/dist/os/detect.js +2 -1
- package/dist/os/detect.js.map +1 -1
- package/dist/prompts/index.d.ts +1 -1
- package/dist/prompts/index.js +95 -22
- package/dist/prompts/index.js.map +1 -1
- package/dist/repl.d.ts +10 -0
- package/dist/repl.js +258 -28
- package/dist/repl.js.map +1 -1
- package/dist/safety/classifier.js +147 -26
- package/dist/safety/classifier.js.map +1 -1
- package/dist/safety/patterns.d.ts +26 -0
- package/dist/safety/patterns.js +167 -0
- package/dist/safety/patterns.js.map +1 -1
- package/dist/store/config.js +2 -1
- package/dist/store/config.js.map +1 -1
- package/dist/store/history.js +19 -5
- package/dist/store/history.js.map +1 -1
- package/dist/store/plan.d.ts +43 -0
- package/dist/store/plan.js +201 -0
- package/dist/store/plan.js.map +1 -0
- package/dist/store/project.js +3 -2
- package/dist/store/project.js.map +1 -1
- package/dist/tools/capabilities.js +6 -1
- package/dist/tools/capabilities.js.map +1 -1
- package/dist/tools/fs.d.ts +15 -0
- package/dist/tools/fs.js +69 -3
- package/dist/tools/fs.js.map +1 -1
- package/dist/tools/image.d.ts +13 -0
- package/dist/tools/image.js +81 -0
- package/dist/tools/image.js.map +1 -0
- package/dist/tools/jobs.js +2 -1
- package/dist/tools/jobs.js.map +1 -1
- package/dist/tools/pdf.d.ts +18 -0
- package/dist/tools/pdf.js +200 -0
- package/dist/tools/pdf.js.map +1 -0
- package/dist/tools/registry.js +87 -7
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/shell.js +3 -2
- package/dist/tools/shell.js.map +1 -1
- package/dist/types.d.ts +16 -0
- package/dist/ui/keys.d.ts +1 -0
- package/dist/ui/keys.js +4 -0
- package/dist/ui/keys.js.map +1 -1
- package/dist/ui/mentions.d.ts +32 -1
- package/dist/ui/mentions.js +304 -27
- package/dist/ui/mentions.js.map +1 -1
- package/dist/ui/plan-pane.d.ts +19 -0
- package/dist/ui/plan-pane.js +101 -0
- package/dist/ui/plan-pane.js.map +1 -0
- package/package.json +6 -5
package/dist/agent/runner.js
CHANGED
|
@@ -19,12 +19,25 @@ import { ensureProviderConfigured } from "../commands/providers.js";
|
|
|
19
19
|
import { rememberThinkingFromText, renderThinkingSummary, } from "../ui/thinking.js";
|
|
20
20
|
import { renderMarkdown, indentAndWrapText } from "../ui/markdown.js";
|
|
21
21
|
import { startThinkingSpinner } from "../ui/spinner.js";
|
|
22
|
+
import { safeCwd } from "../os/cwd.js";
|
|
22
23
|
import { analyzeTask } from "./task-analyzer.js";
|
|
23
24
|
import { LoopGuard } from "./loop-guard.js";
|
|
25
|
+
import { createPlan, loadPlan, savePlan, markTask, } from "../store/plan.js";
|
|
26
|
+
import { renderPlanChecklist, renderPlanSidePane } from "../ui/plan-pane.js";
|
|
27
|
+
/** Render the plan as a right-side pane on wide terminals, else inline. */
|
|
28
|
+
function renderPlanForTerminal(plan) {
|
|
29
|
+
const cols = process.stdout.columns ?? 0;
|
|
30
|
+
const side = process.stdout.isTTY
|
|
31
|
+
? renderPlanSidePane(plan, cols)
|
|
32
|
+
: undefined;
|
|
33
|
+
return side ?? renderPlanChecklist(plan);
|
|
34
|
+
}
|
|
24
35
|
export function createSessionPolicy() {
|
|
25
36
|
return {
|
|
26
37
|
allow: new Set(),
|
|
27
38
|
pentestAuthorized: { value: false },
|
|
39
|
+
sessionId: `sess-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
40
|
+
planApproved: { value: false },
|
|
28
41
|
};
|
|
29
42
|
}
|
|
30
43
|
function tryParseCall(raw) {
|
|
@@ -139,6 +152,147 @@ export function parseToolCall(text, options = {}) {
|
|
|
139
152
|
}
|
|
140
153
|
return undefined;
|
|
141
154
|
}
|
|
155
|
+
// Argument keys that the built-in tools accept. Used to recognize when a
|
|
156
|
+
// model emitted a bare args object (e.g. {"path":"file.pdf"}) — intending a
|
|
157
|
+
// tool call but forgetting the {"name","args"} wrapper and the ```tool fence.
|
|
158
|
+
const TOOL_ARG_KEYS = new Set([
|
|
159
|
+
"command",
|
|
160
|
+
"path",
|
|
161
|
+
"paths",
|
|
162
|
+
"url",
|
|
163
|
+
"query",
|
|
164
|
+
"target",
|
|
165
|
+
"pattern",
|
|
166
|
+
"tool",
|
|
167
|
+
"tools",
|
|
168
|
+
"files",
|
|
169
|
+
"content",
|
|
170
|
+
"calls",
|
|
171
|
+
"record",
|
|
172
|
+
"ports",
|
|
173
|
+
"profile",
|
|
174
|
+
"id",
|
|
175
|
+
"lang",
|
|
176
|
+
"dpi",
|
|
177
|
+
"psm",
|
|
178
|
+
"recursive",
|
|
179
|
+
"oldText",
|
|
180
|
+
"newText",
|
|
181
|
+
"expectedReplacements",
|
|
182
|
+
"goal",
|
|
183
|
+
"tasks",
|
|
184
|
+
"taskId",
|
|
185
|
+
"state",
|
|
186
|
+
"method",
|
|
187
|
+
"body",
|
|
188
|
+
"headers",
|
|
189
|
+
"maxBytes",
|
|
190
|
+
"maxResults",
|
|
191
|
+
"cwd",
|
|
192
|
+
"name",
|
|
193
|
+
"concurrency",
|
|
194
|
+
]);
|
|
195
|
+
/**
|
|
196
|
+
* Strip a single wrapping ```json / ``` fence (if any) and return the inner
|
|
197
|
+
* text trimmed. Leaves un-fenced text unchanged.
|
|
198
|
+
*/
|
|
199
|
+
function stripLoneFence(text) {
|
|
200
|
+
const fenced = text
|
|
201
|
+
.trim()
|
|
202
|
+
.match(/^```[a-zA-Z]*\s*\n?([\s\S]*?)\n?```$/);
|
|
203
|
+
return (fenced?.[1] ?? text).trim();
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* When a model means to call a tool but emits ONLY a bare JSON object —
|
|
207
|
+
* either a proper {"name","args"} that the strict matchers missed, or a bare
|
|
208
|
+
* args object like {"path":"file.pdf"} with the wrapper/fence dropped — this
|
|
209
|
+
* recognizes it. Returns:
|
|
210
|
+
* - { call } when the object is a complete {name, args} tool call, or
|
|
211
|
+
* - { argsOnly: true } when it looks like a bare args object (so the caller
|
|
212
|
+
* can nudge the model to re-emit a properly named, fenced tool call).
|
|
213
|
+
* Returns undefined for anything that is plainly a normal prose/JSON answer.
|
|
214
|
+
*/
|
|
215
|
+
export function recognizeBareToolJson(text) {
|
|
216
|
+
const inner = stripLoneFence(text);
|
|
217
|
+
// Must be a single JSON object spanning the whole (de-fenced) output.
|
|
218
|
+
if (!inner.startsWith("{") || !inner.endsWith("}"))
|
|
219
|
+
return undefined;
|
|
220
|
+
let parsed;
|
|
221
|
+
try {
|
|
222
|
+
parsed = JSON.parse(inner);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
const obj = parsed;
|
|
231
|
+
// Complete {name, args} call the earlier matchers didn't catch (e.g. not
|
|
232
|
+
// anchored to end-of-string). Recover it directly.
|
|
233
|
+
const direct = tryParseCall(inner);
|
|
234
|
+
if (direct)
|
|
235
|
+
return { call: direct };
|
|
236
|
+
// Bare args object: every key is a known tool-arg key, and it carries at
|
|
237
|
+
// least one identifying arg. Don't treat huge/odd objects as tool args.
|
|
238
|
+
const keys = Object.keys(obj);
|
|
239
|
+
if (keys.length === 0 || keys.length > 6)
|
|
240
|
+
return undefined;
|
|
241
|
+
const allKnown = keys.every((key) => TOOL_ARG_KEYS.has(key));
|
|
242
|
+
if (allKnown)
|
|
243
|
+
return { argsOnly: true };
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Detect an opened-but-unparseable tool call. This happens when the model's
|
|
248
|
+
* output is truncated by the token limit mid-JSON: we see the ```tool fence
|
|
249
|
+
* (or a bare {"name":"...","args" prefix) open, but parseToolCall returns
|
|
250
|
+
* undefined because the JSON never closed. Without this, the broken block
|
|
251
|
+
* leaks to the screen as a "final answer" and the requested action (e.g. a
|
|
252
|
+
* multi-file fs.writeMany scaffold) silently never runs.
|
|
253
|
+
*/
|
|
254
|
+
export function looksLikeTruncatedToolCall(text) {
|
|
255
|
+
// An opened ```tool fence with no closing fence.
|
|
256
|
+
const openFence = /```tool\s*\n?/i.test(text);
|
|
257
|
+
const closeFence = /```tool[\s\S]*?```/i.test(text);
|
|
258
|
+
if (openFence && !closeFence)
|
|
259
|
+
return true;
|
|
260
|
+
// A tool-call JSON object that started but whose braces never balanced.
|
|
261
|
+
const jsonStart = text.search(/\{\s*"name"\s*:\s*"[A-Za-z][\w.]*"\s*,\s*"args"/);
|
|
262
|
+
if (jsonStart >= 0) {
|
|
263
|
+
const slice = text.slice(jsonStart);
|
|
264
|
+
let depth = 0;
|
|
265
|
+
let inString = false;
|
|
266
|
+
let escaped = false;
|
|
267
|
+
let balanced = false;
|
|
268
|
+
for (const ch of slice) {
|
|
269
|
+
if (escaped) {
|
|
270
|
+
escaped = false;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (ch === "\\") {
|
|
274
|
+
escaped = true;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (ch === '"')
|
|
278
|
+
inString = !inString;
|
|
279
|
+
if (inString)
|
|
280
|
+
continue;
|
|
281
|
+
if (ch === "{")
|
|
282
|
+
depth += 1;
|
|
283
|
+
else if (ch === "}") {
|
|
284
|
+
depth -= 1;
|
|
285
|
+
if (depth === 0) {
|
|
286
|
+
balanced = true;
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (!balanced)
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
142
296
|
/** Extract the text before the tool call block for display purposes */
|
|
143
297
|
function textBeforeToolCall(text) {
|
|
144
298
|
const patterns = [
|
|
@@ -174,8 +328,20 @@ function formatToolArgs(call) {
|
|
|
174
328
|
return String(call.args.target ?? "");
|
|
175
329
|
if (call.name === "fs.read" || call.name === "fs.write")
|
|
176
330
|
return String(call.args.path ?? "");
|
|
331
|
+
if (call.name === "fs.writeMany") {
|
|
332
|
+
const files = Array.isArray(call.args.files) ? call.args.files : [];
|
|
333
|
+
const names = files
|
|
334
|
+
.map((f) => f && typeof f === "object"
|
|
335
|
+
? String(f.path ?? "")
|
|
336
|
+
: "")
|
|
337
|
+
.filter(Boolean);
|
|
338
|
+
const preview = names.slice(0, 4).join(", ");
|
|
339
|
+
return `${names.length} file(s)${preview ? `: ${preview}${names.length > 4 ? ", …" : ""}` : ""}`;
|
|
340
|
+
}
|
|
177
341
|
if (call.name === "fs.search")
|
|
178
342
|
return String(call.args.pattern ?? "");
|
|
343
|
+
if (call.name === "image.ocr" || call.name === "pdf.read")
|
|
344
|
+
return String(call.args.path ?? "");
|
|
179
345
|
if (call.name === "http.fetch" || call.name === "web.fetch")
|
|
180
346
|
return String(call.args.url ?? "");
|
|
181
347
|
if (call.name === "web.search")
|
|
@@ -183,7 +349,7 @@ function formatToolArgs(call) {
|
|
|
183
349
|
if (call.name === "pkg.install")
|
|
184
350
|
return String(call.args.tool ?? "");
|
|
185
351
|
if (call.name === "fs.list")
|
|
186
|
-
return String(call.args.path ??
|
|
352
|
+
return String(call.args.path ?? safeCwd());
|
|
187
353
|
return JSON.stringify(call.args);
|
|
188
354
|
}
|
|
189
355
|
const VOLATILE_SIGNAL_RE = /\b(?:current(?:ly)?|latest|newest|today|now|right now|live|recent|breaking|news|release[sd]?|version|prices?|stocks?|market|rates?|weather|forecast|elections?|results?|rankings?|standings?|stats?|cve|advis(?:ory|ories)|vulnerabilit(?:y|ies))\b/i;
|
|
@@ -192,6 +358,44 @@ const ROLE_OF_ENTITY_RE = /\b(?:cm|chief\s+minister|prime\s+minister|president|g
|
|
|
192
358
|
const EXPLICIT_WEB_LOOKUP_RE = /\b(?:search\s+(?:the\s+)?(?:web|internet|online)|look\s*up|google|verify\s+(?:online|on\s+the\s+web)|check\s+(?:online|the\s+web|internet))\b/i;
|
|
193
359
|
const STATIC_DISAMBIGUATION_RE = /\b(?:stand\s+for|stands\s+for|meaning|definition|define|abbreviation|centimeters?|centimetres?)\b/i;
|
|
194
360
|
const LOCAL_RUNTIME_RE = /\b(?:current\s+(?:directory|dir|cwd|working\s+directory|folder|path|user|shell|process(?:es)?|branch|git\s+branch|network|ip|interfaces?|working\s+tree)|pwd|whoami)\b/i;
|
|
361
|
+
// Signals that the current turn is (or continues) a coding / scaffolding
|
|
362
|
+
// task. These are intentionally broad — over-budgeting a build is cheap
|
|
363
|
+
// (the loop still stops as soon as the model gives a final answer) while
|
|
364
|
+
// under-budgeting silently truncates a half-built project.
|
|
365
|
+
const BUILD_TASK_RE = /\b(?:build|create|scaffold|generate|make|set\s*up|setup|bootstrap|init(?:ialize)?|implement|add|write|develop|code|refactor|migrate|convert|wire\s*up|integrate)\b[\s\S]{0,80}\b(?:app|application|project|site|website|web\s*app|server|api|service|component|page|module|feature|cli|script|library|package|frontend|backend|fullstack|game|bot|dashboard|form|endpoint|database|schema|test|tests|suite)\b/i;
|
|
366
|
+
const BUILD_STACK_RE = /\b(?:react|next(?:\.?js)?|vue|svelte|angular|vite|webpack|express|fastify|nest(?:js)?|django|flask|fastapi|rails|laravel|spring|node(?:\.?js)?|typescript|tailwind|redux|prisma|mongoose|graphql|docker|kubernetes)\b/i;
|
|
367
|
+
// Short continuation prompts that, on their own, carry no build signal but
|
|
368
|
+
// clearly mean "keep going with what we were doing".
|
|
369
|
+
const CONTINUATION_RE = /^(?:do\s+it|build\s+it|build\s+fully|build\s+it\s+fully|go\s+ahead|continue|proceed|keep\s+going|finish(?:\s+it)?|complete(?:\s+it)?|yes|ok(?:ay)?|make\s+it|run\s+it|next|on\s+your\s+own|build\s+(?:fully\s+)?on\s+your\s+own)\b/i;
|
|
370
|
+
const INCOMPLETE_RE = /\b(?:not\s+complete|incomplete|isn'?t\s+(?:done|complete|working|finished)|doesn'?t\s+work|still\s+(?:broken|missing|failing)|missing\s+(?:files?|parts?)|finish\s+(?:the|it)|complete\s+(?:the|it))\b/i;
|
|
371
|
+
/**
|
|
372
|
+
* Decide whether this turn should get a generous step budget because it is
|
|
373
|
+
* a multi-file build, a continuation of one, or a "it's not done yet" nudge.
|
|
374
|
+
* Looks at the current prompt first, then falls back to the most recent
|
|
375
|
+
* user/assistant turns so a terse follow-up inherits the build context.
|
|
376
|
+
*/
|
|
377
|
+
export function looksLikeBuildTask(prompt, history) {
|
|
378
|
+
const text = prompt.replace(/\s+/g, " ").trim();
|
|
379
|
+
if (BUILD_TASK_RE.test(text) ||
|
|
380
|
+
BUILD_STACK_RE.test(text) ||
|
|
381
|
+
CONTINUATION_RE.test(text) ||
|
|
382
|
+
INCOMPLETE_RE.test(text)) {
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
// Inspect recent history: if the conversation was already about building
|
|
386
|
+
// something, treat a terse follow-up as part of that build.
|
|
387
|
+
if (history && history.length > 0) {
|
|
388
|
+
const recent = history.slice(-6);
|
|
389
|
+
for (const msg of recent) {
|
|
390
|
+
if (msg.role !== "user" && msg.role !== "assistant")
|
|
391
|
+
continue;
|
|
392
|
+
const h = msg.content.replace(/\s+/g, " ");
|
|
393
|
+
if (BUILD_TASK_RE.test(h) || BUILD_STACK_RE.test(h))
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
195
399
|
export function requiresFreshWebSearch(prompt) {
|
|
196
400
|
const text = prompt.replace(/\s+/g, " ").trim();
|
|
197
401
|
if (!text)
|
|
@@ -332,6 +536,132 @@ async function confirmToolExecution(call, autoConfirm, session) {
|
|
|
332
536
|
default: true,
|
|
333
537
|
});
|
|
334
538
|
}
|
|
539
|
+
/** Build the system-context block describing the session's active plan. */
|
|
540
|
+
function planContextMessage(plan, approved) {
|
|
541
|
+
const lines = [];
|
|
542
|
+
lines.push(`ACTIVE PLAN for this session (goal: ${plan.goal}, status: ${plan.status}):`);
|
|
543
|
+
if (plan.detail.trim())
|
|
544
|
+
lines.push(plan.detail.trim());
|
|
545
|
+
lines.push("Tasks:");
|
|
546
|
+
plan.tasks.forEach((t, i) => {
|
|
547
|
+
lines.push(` ${i + 1}. [${t.id}] (${t.state}) ${t.title}`);
|
|
548
|
+
});
|
|
549
|
+
if (approved) {
|
|
550
|
+
lines.push("The user APPROVED this plan. Execute it task by task NOW: before starting a task call " +
|
|
551
|
+
'task.update with {"taskId":"<id>","state":"in_progress"}, do the work with real tool calls, ' +
|
|
552
|
+
'then call task.update {"taskId":"<id>","state":"done"} (or "failed"/"skipped" with a note). ' +
|
|
553
|
+
"Actually run installs and start servers — never claim something ran without a successful tool call. " +
|
|
554
|
+
"When all tasks are done, verify and give a final summary.");
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
lines.push("This plan is NOT yet approved. If the user is refining it, update it with plan.create again. " +
|
|
558
|
+
"Do NOT execute tasks until the user runs /implement.");
|
|
559
|
+
}
|
|
560
|
+
return lines.join("\n");
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Handle plan.create / task.update inline. These are session-scoped and
|
|
564
|
+
* persisted via the plan store so the user can view the plan (Ctrl+P) and
|
|
565
|
+
* the agent keeps it in context across the whole session.
|
|
566
|
+
*/
|
|
567
|
+
async function handlePlanTool(call, session, ctx) {
|
|
568
|
+
void ctx;
|
|
569
|
+
if (call.name === "plan.create") {
|
|
570
|
+
const goal = typeof call.args.goal === "string" ? call.args.goal : "";
|
|
571
|
+
const detail = typeof call.args.detail === "string" ? call.args.detail : "";
|
|
572
|
+
const kind = typeof call.args.kind === "string" ? call.args.kind : "general";
|
|
573
|
+
const rawTasks = Array.isArray(call.args.tasks) ? call.args.tasks : [];
|
|
574
|
+
const taskTitles = rawTasks
|
|
575
|
+
.map((t) => (typeof t === "string" ? t : ""))
|
|
576
|
+
.filter(Boolean);
|
|
577
|
+
if (!goal || taskTitles.length === 0) {
|
|
578
|
+
return {
|
|
579
|
+
handled: true,
|
|
580
|
+
ok: false,
|
|
581
|
+
display: chalk.red(" ✗ plan.create needs a non-empty goal and at least one task title\n"),
|
|
582
|
+
modelNote: "plan.create failed: provide a string goal and a non-empty tasks array of step titles.",
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
const plan = createPlan({
|
|
586
|
+
sessionId: session.sessionId,
|
|
587
|
+
goal,
|
|
588
|
+
detail,
|
|
589
|
+
taskTitles,
|
|
590
|
+
kind,
|
|
591
|
+
});
|
|
592
|
+
await savePlan(plan).catch(() => undefined);
|
|
593
|
+
// A freshly (re)created plan resets approval — the user must /implement.
|
|
594
|
+
session.planApproved.value = false;
|
|
595
|
+
const checklist = renderPlanForTerminal(plan);
|
|
596
|
+
const display = chalk.cyan(" ● planning\n") +
|
|
597
|
+
checklist +
|
|
598
|
+
"\n" +
|
|
599
|
+
chalk.dim(" ✦ plan created — press Ctrl+P to view it, or type /implement to approve and run it\n");
|
|
600
|
+
return {
|
|
601
|
+
handled: true,
|
|
602
|
+
ok: true,
|
|
603
|
+
display,
|
|
604
|
+
modelNote: `Plan saved with ${plan.tasks.length} task(s). STOP here and wait. ` +
|
|
605
|
+
"Do NOT start executing tasks until the user approves with /implement. " +
|
|
606
|
+
"When approved you will receive a message telling you to begin; then work task by task, " +
|
|
607
|
+
"calling task.update to mark each in_progress before and done after you finish it.",
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
// task.update
|
|
611
|
+
const plan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
612
|
+
if (!plan) {
|
|
613
|
+
return {
|
|
614
|
+
handled: true,
|
|
615
|
+
ok: false,
|
|
616
|
+
display: chalk.red(" ✗ task.update: no active plan — call plan.create first\n"),
|
|
617
|
+
modelNote: "task.update failed: there is no active plan. Call plan.create first.",
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
const taskId = typeof call.args.taskId === "string" ? call.args.taskId : "";
|
|
621
|
+
const stateRaw = typeof call.args.state === "string" ? call.args.state : "";
|
|
622
|
+
const note = typeof call.args.note === "string" ? call.args.note : undefined;
|
|
623
|
+
const validStates = [
|
|
624
|
+
"pending",
|
|
625
|
+
"in_progress",
|
|
626
|
+
"done",
|
|
627
|
+
"failed",
|
|
628
|
+
"skipped",
|
|
629
|
+
];
|
|
630
|
+
if (!validStates.includes(stateRaw)) {
|
|
631
|
+
return {
|
|
632
|
+
handled: true,
|
|
633
|
+
ok: false,
|
|
634
|
+
display: chalk.red(` ✗ task.update: state must be one of ${validStates.join(", ")}\n`),
|
|
635
|
+
modelNote: `task.update failed: state must be one of ${validStates.join(", ")}.`,
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
const ok = markTask(plan, taskId, stateRaw, note);
|
|
639
|
+
if (!ok) {
|
|
640
|
+
const ids = plan.tasks.map((t) => t.id).join(", ");
|
|
641
|
+
return {
|
|
642
|
+
handled: true,
|
|
643
|
+
ok: false,
|
|
644
|
+
display: chalk.red(` ✗ task.update: unknown taskId "${taskId}" (have: ${ids})\n`),
|
|
645
|
+
modelNote: `task.update failed: unknown taskId. Valid ids: ${ids}.`,
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
if (plan.status === "draft" || plan.status === "approved") {
|
|
649
|
+
plan.status = "in_progress";
|
|
650
|
+
}
|
|
651
|
+
const allDone = plan.tasks.every((t) => t.state === "done" || t.state === "skipped" || t.state === "failed");
|
|
652
|
+
if (allDone)
|
|
653
|
+
plan.status = "completed";
|
|
654
|
+
await savePlan(plan).catch(() => undefined);
|
|
655
|
+
const checklist = renderPlanForTerminal(plan);
|
|
656
|
+
return {
|
|
657
|
+
handled: true,
|
|
658
|
+
ok: true,
|
|
659
|
+
display: checklist + "\n",
|
|
660
|
+
modelNote: allDone
|
|
661
|
+
? "Task updated. ALL tasks are now finished. Verify the result and give your final summary."
|
|
662
|
+
: "Task updated. Continue with the next pending task.",
|
|
663
|
+
};
|
|
664
|
+
}
|
|
335
665
|
export async function runAgentLoop(prompt, options = {}) {
|
|
336
666
|
const config = getConfig();
|
|
337
667
|
const maxSteps = options.maxSteps ?? 30;
|
|
@@ -345,17 +675,39 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
345
675
|
if (freshWebSearchRequired) {
|
|
346
676
|
systemSections.push(freshnessGuardMessage());
|
|
347
677
|
}
|
|
348
|
-
const fullSystemPrompt = systemSections.join("\n\n");
|
|
349
|
-
const messages = [
|
|
350
|
-
{ role: "system", content: fullSystemPrompt },
|
|
351
|
-
...(options.history ?? []),
|
|
352
|
-
{ role: "user", content: prompt },
|
|
353
|
-
];
|
|
354
678
|
let provider = options.provider ?? config.defaultProvider;
|
|
355
679
|
await ensureProviderConfigured(provider);
|
|
356
680
|
let model = options.model ?? config.defaultModel;
|
|
357
681
|
let lastAnswer = "";
|
|
358
682
|
const session = options.session ?? createSessionPolicy();
|
|
683
|
+
// ── Active plan context ────────────────────────────────────────────
|
|
684
|
+
// If this session already has a plan, inject it so the model keeps it in
|
|
685
|
+
// context. When the user has approved it (via /implement) we instruct the
|
|
686
|
+
// agent to execute task by task; otherwise the agent should refine/wait.
|
|
687
|
+
const activePlan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
688
|
+
if (activePlan) {
|
|
689
|
+
systemSections.push(planContextMessage(activePlan, session.planApproved.value));
|
|
690
|
+
}
|
|
691
|
+
const fullSystemPrompt = systemSections.join("\n\n");
|
|
692
|
+
const userMessage = { role: "user", content: prompt };
|
|
693
|
+
if (options.images && options.images.length > 0) {
|
|
694
|
+
userMessage.images = options.images;
|
|
695
|
+
}
|
|
696
|
+
const messages = [
|
|
697
|
+
{ role: "system", content: fullSystemPrompt },
|
|
698
|
+
...(options.history ?? []),
|
|
699
|
+
userMessage,
|
|
700
|
+
];
|
|
701
|
+
const recoveryUserMessage = (content) => {
|
|
702
|
+
const message = { role: "user", content };
|
|
703
|
+
if (options.images && options.images.length > 0) {
|
|
704
|
+
// Some OpenAI-compatible gateways/models attend most strongly to the
|
|
705
|
+
// latest user turn. Keep the image attached on recovery nudges so a
|
|
706
|
+
// thinking-only retry does not degrade into OCR/tool guessing.
|
|
707
|
+
message.images = options.images;
|
|
708
|
+
}
|
|
709
|
+
return message;
|
|
710
|
+
};
|
|
359
711
|
// Track recent tool calls to detect models stuck in a loop calling the
|
|
360
712
|
// same tool with the same arguments over and over (e.g. pentest.recon
|
|
361
713
|
// called 3× on the same target without summarizing).
|
|
@@ -363,18 +715,59 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
363
715
|
// Track consecutive thinking-only responses so we can nudge the model
|
|
364
716
|
// to actually act instead of silently returning an empty answer.
|
|
365
717
|
let emptyVisibleRetries = 0;
|
|
718
|
+
// Track tool calls truncated by the token limit so we can ask the model
|
|
719
|
+
// to retry in smaller pieces instead of leaking broken JSON as an answer.
|
|
720
|
+
let truncatedToolRetries = 0;
|
|
721
|
+
// Track bare-args JSON tool calls (missing the {name,args} wrapper / fence)
|
|
722
|
+
// so we can nudge the model to re-emit a proper fenced call a few times
|
|
723
|
+
// before giving up, instead of leaking the JSON as a final answer.
|
|
724
|
+
let bareToolJsonRetries = 0;
|
|
366
725
|
// For volatile live-info prompts, make one corrective pass if a model
|
|
367
726
|
// ignores the freshness guard and tries to answer from stale memory.
|
|
368
727
|
let sawFreshWebSearch = false;
|
|
369
728
|
let freshnessRetryUsed = false;
|
|
370
|
-
// ──
|
|
729
|
+
// ── Step budget ───────────────────────────────────────────────────
|
|
730
|
+
// The budget governs how many *productive* steps (a tool execution or a
|
|
731
|
+
// final answer) the agent may take. Recovery iterations — nudging a model
|
|
732
|
+
// that only produced thinking, asking it to re-emit a malformed tool call,
|
|
733
|
+
// a freshness retry, or a loop-guard summary — do NOT consume this budget;
|
|
734
|
+
// they get a separate hard ceiling so a wedged model can't spin forever.
|
|
735
|
+
//
|
|
736
|
+
// Complexity is a coarse signal from prompt length, but short follow-up
|
|
737
|
+
// prompts ("do it", "build fully on your own", "app is not complete") in
|
|
738
|
+
// the middle of a multi-file build must NOT be capped like a one-shot
|
|
739
|
+
// lookup — that was the reason a React scaffold stopped half-built after
|
|
740
|
+
// 10 steps. We bump the budget when the prompt (or recent history) looks
|
|
741
|
+
// like a build/scaffold or a continuation of one.
|
|
371
742
|
const analysis = analyzeTask(prompt);
|
|
372
|
-
const
|
|
373
|
-
|
|
743
|
+
const hasHistory = (options.history?.length ?? 0) > 0;
|
|
744
|
+
const buildLike = looksLikeBuildTask(prompt, options.history);
|
|
745
|
+
let stepBudget = analysis.complexity === "simple"
|
|
746
|
+
? 15
|
|
374
747
|
: analysis.complexity === "standard"
|
|
375
|
-
?
|
|
748
|
+
? 30
|
|
376
749
|
: maxSteps;
|
|
377
|
-
|
|
750
|
+
if (buildLike) {
|
|
751
|
+
// Scaffolding / multi-file work needs room: many file writes plus a
|
|
752
|
+
// verify/build step. Continuation prompts ("do it") inherit this too.
|
|
753
|
+
stepBudget = Math.max(stepBudget, maxSteps);
|
|
754
|
+
}
|
|
755
|
+
else if (hasHistory) {
|
|
756
|
+
// A follow-up to an ongoing task should never be capped tighter than a
|
|
757
|
+
// standard one-shot, even if it's only a couple of words.
|
|
758
|
+
stepBudget = Math.max(stepBudget, 30);
|
|
759
|
+
}
|
|
760
|
+
// Hard ceiling on total loop iterations (productive + recovery) so a model
|
|
761
|
+
// stuck emitting only thinking or malformed calls can't loop indefinitely.
|
|
762
|
+
const maxIterations = stepBudget * 3;
|
|
763
|
+
let productiveSteps = 0;
|
|
764
|
+
let step = -1;
|
|
765
|
+
for (let iteration = 0; iteration < maxIterations; iteration += 1) {
|
|
766
|
+
// `step` is the productive-step index (used for display + audit). It only
|
|
767
|
+
// advances when the previous iteration actually executed a tool.
|
|
768
|
+
step = productiveSteps;
|
|
769
|
+
if (productiveSteps >= stepBudget)
|
|
770
|
+
break;
|
|
378
771
|
options.signal?.throwIfAborted();
|
|
379
772
|
// Buffer LLM output so tool JSON and hidden thinking are not printed raw.
|
|
380
773
|
// Status messages (rate-limit retries, fallback hints) still surface live.
|
|
@@ -392,9 +785,11 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
392
785
|
temperature: 0.2,
|
|
393
786
|
// Reasoning models can spend a lot on hidden thinking; give
|
|
394
787
|
// them headroom so the visible answer / tool call isn't
|
|
395
|
-
// truncated to silence.
|
|
396
|
-
//
|
|
397
|
-
|
|
788
|
+
// truncated to silence. The non-thinking budget must be large
|
|
789
|
+
// enough for a multi-file fs.writeMany payload — a truncated
|
|
790
|
+
// tool-call JSON fails to parse and used to leak a broken
|
|
791
|
+
// ```tool block to the screen with no files written.
|
|
792
|
+
maxTokens: config.thinking?.enabled ? 16_384 : 8_192,
|
|
398
793
|
signal: options.signal,
|
|
399
794
|
thinking: config.thinking,
|
|
400
795
|
}, (token) => {
|
|
@@ -446,12 +841,10 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
446
841
|
process.stdout.write(`${renderThinkingSummary(assistantText.thinkContent)}\n`);
|
|
447
842
|
process.stdout.write(chalk.yellow(" ⚠ model produced only thinking — nudging it to take action\n"));
|
|
448
843
|
messages.push({ role: "assistant", content: completion.text });
|
|
449
|
-
messages.push(
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
"Do NOT just think — take action NOW.",
|
|
454
|
-
});
|
|
844
|
+
messages.push(recoveryUserMessage("You only produced internal reasoning with no visible answer or tool call. " +
|
|
845
|
+
"You MUST either call a tool using the ```tool format or provide your final answer. " +
|
|
846
|
+
"If images are attached, inspect them directly for visual details (text, colors, layout, spacing, style) instead of using OCR unless explicitly needed. " +
|
|
847
|
+
"Do NOT just think — take action NOW."));
|
|
455
848
|
continue;
|
|
456
849
|
}
|
|
457
850
|
// Exhausted retries — fall through to the normal empty-answer path
|
|
@@ -461,10 +854,42 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
461
854
|
// Reset the counter on any successful visible output.
|
|
462
855
|
emptyVisibleRetries = 0;
|
|
463
856
|
}
|
|
464
|
-
|
|
857
|
+
let call = parseToolCall(assistantText.visible, {
|
|
465
858
|
strict: getConfig().parserStrict,
|
|
466
859
|
});
|
|
860
|
+
// Recovery: the model meant to call a tool but emitted a bare JSON object
|
|
861
|
+
// with no ```tool fence — either a complete {name,args} the strict
|
|
862
|
+
// matchers missed (recover it directly), or just an args object like
|
|
863
|
+
// {"path":"file.pdf"} with the wrapper dropped (nudge a retry below so
|
|
864
|
+
// the requested action runs instead of the JSON leaking as the answer).
|
|
865
|
+
let bareArgsOnly = false;
|
|
866
|
+
let recoveredFromBareJson = false;
|
|
867
|
+
if (!call) {
|
|
868
|
+
const bare = recognizeBareToolJson(assistantText.visible);
|
|
869
|
+
if (bare?.call) {
|
|
870
|
+
call = bare.call;
|
|
871
|
+
recoveredFromBareJson = true;
|
|
872
|
+
process.stdout.write(chalk.dim(" ℹ recovered an unfenced tool call from bare JSON\n"));
|
|
873
|
+
}
|
|
874
|
+
else if (bare?.argsOnly) {
|
|
875
|
+
bareArgsOnly = true;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
467
878
|
if (!call) {
|
|
879
|
+
if (bareArgsOnly) {
|
|
880
|
+
bareToolJsonRetries += 1;
|
|
881
|
+
if (bareToolJsonRetries <= 3) {
|
|
882
|
+
process.stdout.write(chalk.yellow(" ⚠ tool call missing its name/fence — asking the model to re-emit a proper ```tool block\n"));
|
|
883
|
+
messages.push({ role: "assistant", content: assistantText.visible });
|
|
884
|
+
messages.push(recoveryUserMessage("Your previous message was a bare JSON args object with no tool name and no ```tool fence, so NOTHING ran. " +
|
|
885
|
+
"Reply with ONLY a fenced ```tool block of the form " +
|
|
886
|
+
'`{"name": "<tool>", "args": { ... }}`. For example, to read a PDF:\n' +
|
|
887
|
+
'```tool\n{"name":"pdf.read","args":{"path":"/abs/file.pdf"}}\n```\n' +
|
|
888
|
+
"Choose the correct tool name for the task and include those args."));
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
// Exhausted retries — fall through to the normal answer path.
|
|
892
|
+
}
|
|
468
893
|
// Detect the case where the model emitted sentinel-style tool-call
|
|
469
894
|
// markers but the body was malformed or truncated. Printing those
|
|
470
895
|
// raw tokens looks like a crash to the user — instead, ask the
|
|
@@ -472,15 +897,33 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
472
897
|
if (/<\|tool_call(?:s_section)?_begin\|>|<\|tool_call_argument_begin\|>/i.test(assistantText.visible)) {
|
|
473
898
|
process.stdout.write(chalk.yellow(" ⚠ tool call was malformed or cut off — asking the model to retry in JSON form\n"));
|
|
474
899
|
messages.push({ role: "assistant", content: assistantText.visible });
|
|
475
|
-
messages.push(
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
'of the form `{"name": "<tool>", "args": { ... }}`. ' +
|
|
480
|
-
"Do not use <|tool_call_begin|> markers.",
|
|
481
|
-
});
|
|
900
|
+
messages.push(recoveryUserMessage("Your previous tool call was malformed or truncated. " +
|
|
901
|
+
"Reply with ONLY a fenced ```tool block containing valid JSON " +
|
|
902
|
+
'of the form `{"name": "<tool>", "args": { ... }}`. ' +
|
|
903
|
+
"Do not use <|tool_call_begin|> markers."));
|
|
482
904
|
continue;
|
|
483
905
|
}
|
|
906
|
+
// Detect a tool call that opened but was cut off by the token limit
|
|
907
|
+
// (most common with a large multi-file fs.writeMany). Retrying with a
|
|
908
|
+
// nudge to split the work is far better than rendering broken JSON as
|
|
909
|
+
// a final answer and leaving the project half-created.
|
|
910
|
+
if (looksLikeTruncatedToolCall(assistantText.visible)) {
|
|
911
|
+
truncatedToolRetries += 1;
|
|
912
|
+
if (truncatedToolRetries <= 3) {
|
|
913
|
+
process.stdout.write(chalk.yellow(" ⚠ tool call was cut off (output too long) — asking the model to retry in smaller pieces\n"));
|
|
914
|
+
messages.push({ role: "assistant", content: assistantText.visible });
|
|
915
|
+
messages.push({
|
|
916
|
+
role: "user",
|
|
917
|
+
content: "Your previous tool call was cut off before it finished — the JSON was incomplete, so NOTHING ran. " +
|
|
918
|
+
"Retry now with a COMPLETE, valid ```tool block. " +
|
|
919
|
+
"If it was a large fs.writeMany, split it into SMALLER batches (3-5 files per call, and keep each file's content concise) " +
|
|
920
|
+
"so the whole JSON fits in one response. Do NOT claim any file was written until a tool call actually succeeds.",
|
|
921
|
+
});
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
// Exhausted retries — fall through so we don't loop forever, but the
|
|
925
|
+
// user at least sees the (broken) output and the stop notice.
|
|
926
|
+
}
|
|
484
927
|
// Normal final-answer path: strip any stray sentinel tokens that
|
|
485
928
|
// somehow leaked into prose so the answer renders cleanly.
|
|
486
929
|
const cleaned = stripSentinelTokens(assistantText.visible);
|
|
@@ -513,20 +956,31 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
513
956
|
// telling it to summarize the results it already has.
|
|
514
957
|
const loopCheck = loopGuard.shouldBlock(call.name, call.args);
|
|
515
958
|
if (loopCheck.block) {
|
|
516
|
-
|
|
959
|
+
const isWrite = call.name === "fs.write" ||
|
|
960
|
+
call.name === "fs.writeMany" ||
|
|
961
|
+
call.name === "fs.edit";
|
|
962
|
+
process.stdout.write(chalk.yellow(` ⚠ ${call.name} was already called with the same arguments — ${isWrite ? "moving on" : "forcing summary"}\n`));
|
|
517
963
|
messages.push({ role: "assistant", content: assistantText.visible });
|
|
518
964
|
messages.push({
|
|
519
965
|
role: "user",
|
|
520
|
-
content:
|
|
521
|
-
|
|
966
|
+
content: isWrite
|
|
967
|
+
? `You already wrote that exact file with ${call.name}. It is saved. ` +
|
|
968
|
+
"Do NOT write it again. Move on to the NEXT file or step. If every file is written, " +
|
|
969
|
+
"verify the project (list the tree, run the build/install command) and give your final answer."
|
|
970
|
+
: `You already called ${call.name} with the same arguments and received results. ` +
|
|
971
|
+
"Do NOT call it again. Summarize the findings you already have and give your final answer NOW.",
|
|
522
972
|
});
|
|
523
973
|
continue;
|
|
524
974
|
}
|
|
525
975
|
if (loopCheck.reason) {
|
|
526
976
|
process.stdout.write(chalk.dim(` ℹ ${loopCheck.reason}\n`));
|
|
527
977
|
}
|
|
528
|
-
// Print only non-thinking text before the tool call.
|
|
529
|
-
|
|
978
|
+
// Print only non-thinking text before the tool call. When the call was
|
|
979
|
+
// recovered from a bare JSON object (the whole message WAS the call),
|
|
980
|
+
// there is no prose to show — skip it so we don't echo the raw JSON.
|
|
981
|
+
const beforeTool = recoveredFromBareJson
|
|
982
|
+
? ""
|
|
983
|
+
: textBeforeToolCall(assistantText.visible);
|
|
530
984
|
if (beforeTool) {
|
|
531
985
|
process.stdout.write(renderMarkdown(beforeTool) + "\n");
|
|
532
986
|
}
|
|
@@ -534,6 +988,25 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
534
988
|
process.stdout.write(`${renderThinkingSummary(assistantText.thinkContent)}\n`);
|
|
535
989
|
}
|
|
536
990
|
messages.push({ role: "assistant", content: assistantText.visible });
|
|
991
|
+
// ── Plan / task tools (session-scoped, handled inline) ─────────────
|
|
992
|
+
// These don't go through the generic registry because they need the
|
|
993
|
+
// session id and mutate the live plan that the user can view (Ctrl+P).
|
|
994
|
+
if (call.name === "plan.create" || call.name === "task.update") {
|
|
995
|
+
const planResult = await handlePlanTool(call, session, {
|
|
996
|
+
loopGuard,
|
|
997
|
+
step,
|
|
998
|
+
});
|
|
999
|
+
if (planResult.handled) {
|
|
1000
|
+
productiveSteps += 1;
|
|
1001
|
+
loopGuard.recordAttempt(step, call.name, call.args, planResult.ok, 0);
|
|
1002
|
+
process.stdout.write(planResult.display);
|
|
1003
|
+
messages.push({
|
|
1004
|
+
role: "tool",
|
|
1005
|
+
content: `Tool ${call.name} result (ok=${planResult.ok}):\n${planResult.modelNote}`,
|
|
1006
|
+
});
|
|
1007
|
+
continue;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
537
1010
|
const scope = await loadScope();
|
|
538
1011
|
const decision = classifyToolCall(call, { scope });
|
|
539
1012
|
await auditLog("tool.classified", {
|
|
@@ -709,6 +1182,11 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
709
1182
|
});
|
|
710
1183
|
// Record the attempt in the loop guard for dedup tracking.
|
|
711
1184
|
loopGuard.recordAttempt(step, call.name, call.args, result.ok, result.exitCode);
|
|
1185
|
+
// A tool actually executed this iteration — count it against the
|
|
1186
|
+
// productive-step budget. Recovery iterations (thinking-only nudges,
|
|
1187
|
+
// malformed-call retries, freshness/loop-guard prompts) reach `continue`
|
|
1188
|
+
// before this point and therefore never consume the budget.
|
|
1189
|
+
productiveSteps += 1;
|
|
712
1190
|
// ── Auto-retry on "command not found" ──────────────────────────
|
|
713
1191
|
// Detect missing tools and instruct the model to install + retry.
|
|
714
1192
|
const NOT_FOUND_RE = /command not found|ENOENT.*spawn|is not recognized/i;
|
|
@@ -717,7 +1195,9 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
717
1195
|
? String(call.args.command ?? "").split(/\s+/)[0]
|
|
718
1196
|
: call.name === "net.scan"
|
|
719
1197
|
? "nmap"
|
|
720
|
-
:
|
|
1198
|
+
: call.name === "image.ocr"
|
|
1199
|
+
? "tesseract"
|
|
1200
|
+
: undefined;
|
|
721
1201
|
if (cmdName) {
|
|
722
1202
|
process.stdout.write(chalk.yellow(` ⚠ ${cmdName} not found — asking model to install and retry\n`));
|
|
723
1203
|
messages.push({
|
|
@@ -807,7 +1287,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
807
1287
|
}
|
|
808
1288
|
}
|
|
809
1289
|
}
|
|
810
|
-
lastAnswer = `Stopped after ${
|
|
1290
|
+
lastAnswer = `Stopped after ${productiveSteps} steps.`;
|
|
811
1291
|
process.stdout.write(" " + chalk.yellow(lastAnswer) + "\n");
|
|
812
1292
|
return lastAnswer;
|
|
813
1293
|
}
|