@pentoshi/clai 3.5.1 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/agent/context-manager.js +8 -2
- package/dist/agent/context-manager.js.map +1 -1
- package/dist/agent/loop-guard.d.ts +2 -0
- package/dist/agent/loop-guard.js +39 -2
- package/dist/agent/loop-guard.js.map +1 -1
- package/dist/agent/plan-tool.d.ts +22 -0
- package/dist/agent/plan-tool.js +357 -44
- package/dist/agent/plan-tool.js.map +1 -1
- package/dist/agent/project-root.d.ts +33 -0
- package/dist/agent/project-root.js +158 -0
- package/dist/agent/project-root.js.map +1 -0
- package/dist/agent/runner.js +1119 -240
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/task-evidence.d.ts +103 -0
- package/dist/agent/task-evidence.js +413 -0
- package/dist/agent/task-evidence.js.map +1 -0
- package/dist/agent/tool-call-parser.d.ts +16 -0
- package/dist/agent/tool-call-parser.js +86 -28
- package/dist/agent/tool-call-parser.js.map +1 -1
- package/dist/agent/tool-history.d.ts +27 -0
- package/dist/agent/tool-history.js +150 -0
- package/dist/agent/tool-history.js.map +1 -0
- package/dist/agent/workspace-orient.d.ts +64 -0
- package/dist/agent/workspace-orient.js +418 -0
- package/dist/agent/workspace-orient.js.map +1 -0
- package/dist/app/controllers/session-controller.js +4 -0
- package/dist/app/controllers/session-controller.js.map +1 -1
- package/dist/commands/doctor.js +3 -0
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/update.js +1 -1
- package/dist/llm/adapters/anthropic-tools.d.ts +99 -0
- package/dist/llm/adapters/anthropic-tools.js +225 -0
- package/dist/llm/adapters/anthropic-tools.js.map +1 -0
- package/dist/llm/adapters/gemini-tools.d.ts +54 -0
- package/dist/llm/adapters/gemini-tools.js +139 -0
- package/dist/llm/adapters/gemini-tools.js.map +1 -0
- package/dist/llm/adapters/ollama-tools.d.ts +22 -0
- package/dist/llm/adapters/ollama-tools.js +62 -0
- package/dist/llm/adapters/ollama-tools.js.map +1 -0
- package/dist/llm/adapters/openai-tools.d.ts +39 -0
- package/dist/llm/adapters/openai-tools.js +71 -0
- package/dist/llm/adapters/openai-tools.js.map +1 -0
- package/dist/llm/agentrouter.js +23 -4
- package/dist/llm/agentrouter.js.map +1 -1
- package/dist/llm/anthropic.js +93 -92
- package/dist/llm/anthropic.js.map +1 -1
- package/dist/llm/aws-mantle.js +93 -56
- package/dist/llm/aws-mantle.js.map +1 -1
- package/dist/llm/bynara.js +23 -4
- package/dist/llm/bynara.js.map +1 -1
- package/dist/llm/capabilities.d.ts +7 -0
- package/dist/llm/capabilities.js +74 -0
- package/dist/llm/capabilities.js.map +1 -1
- package/dist/llm/gemini.js +63 -48
- package/dist/llm/gemini.js.map +1 -1
- package/dist/llm/groq.js +23 -4
- package/dist/llm/groq.js.map +1 -1
- package/dist/llm/http.d.ts +22 -17
- package/dist/llm/http.js +99 -26
- package/dist/llm/http.js.map +1 -1
- package/dist/llm/kimchi.js +23 -4
- package/dist/llm/kimchi.js.map +1 -1
- package/dist/llm/nvidia.js +23 -4
- package/dist/llm/nvidia.js.map +1 -1
- package/dist/llm/ollama.js +49 -31
- package/dist/llm/ollama.js.map +1 -1
- package/dist/llm/openai.js +23 -4
- package/dist/llm/openai.js.map +1 -1
- package/dist/llm/openrouter.js +23 -4
- package/dist/llm/openrouter.js.map +1 -1
- package/dist/llm/qwen-cloud.js +23 -4
- package/dist/llm/qwen-cloud.js.map +1 -1
- package/dist/llm/router.js +48 -6
- package/dist/llm/router.js.map +1 -1
- package/dist/llm/tool-protocol.d.ts +73 -0
- package/dist/llm/tool-protocol.js +282 -0
- package/dist/llm/tool-protocol.js.map +1 -0
- package/dist/modes/ask.js +74 -20
- package/dist/modes/ask.js.map +1 -1
- package/dist/prompts/index.d.ts +13 -5
- package/dist/prompts/index.js +116 -18
- package/dist/prompts/index.js.map +1 -1
- package/dist/repl.js +7 -0
- package/dist/repl.js.map +1 -1
- package/dist/store/config.d.ts +7 -0
- package/dist/store/config.js +1 -0
- package/dist/store/config.js.map +1 -1
- package/dist/store/plan.d.ts +2 -0
- package/dist/store/plan.js.map +1 -1
- package/dist/tools/capabilities.d.ts +2 -0
- package/dist/tools/capabilities.js +101 -6
- package/dist/tools/capabilities.js.map +1 -1
- package/dist/tools/command-intent.d.ts +1 -0
- package/dist/tools/command-intent.js +31 -0
- package/dist/tools/command-intent.js.map +1 -1
- package/dist/tools/definitions.d.ts +20 -0
- package/dist/tools/definitions.js +509 -0
- package/dist/tools/definitions.js.map +1 -0
- package/dist/tools/fs.js +12 -3
- package/dist/tools/fs.js.map +1 -1
- package/dist/tools/net-ping-sweep.js +69 -16
- package/dist/tools/net-ping-sweep.js.map +1 -1
- package/dist/tools/nmap-runner.d.ts +8 -7
- package/dist/tools/nmap-runner.js +126 -65
- package/dist/tools/nmap-runner.js.map +1 -1
- package/dist/tools/registry.js +45 -7
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/validate.d.ts +11 -0
- package/dist/tools/validate.js +53 -0
- package/dist/tools/validate.js.map +1 -1
- package/dist/tools/web/search.js +48 -4
- package/dist/tools/web/search.js.map +1 -1
- package/dist/tui-v2/components/transcript/tool-card.js +1 -1
- package/dist/tui-v2/components/transcript/tool-card.js.map +1 -1
- package/dist/tui-v2/rendering/tool-presenter.d.ts +6 -1
- package/dist/tui-v2/rendering/tool-presenter.js +66 -2
- package/dist/tui-v2/rendering/tool-presenter.js.map +1 -1
- package/dist/types.d.ts +58 -0
- package/dist/ui/ansi-box.d.ts +2 -0
- package/dist/ui/ansi-box.js +8 -1
- package/dist/ui/ansi-box.js.map +1 -1
- package/package.json +1 -1
package/dist/agent/runner.js
CHANGED
|
@@ -2,13 +2,18 @@ import chalk from "chalk";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join, relative, resolve } from "node:path";
|
|
4
4
|
import { streamWithProvider, completeWithProvider } from "../llm/router.js";
|
|
5
|
+
import { resolveToolDialect } from "../llm/capabilities.js";
|
|
6
|
+
import { syntheticToolCallId, isTextOnlyModel, fromWireName, } from "../llm/tool-protocol.js";
|
|
7
|
+
import { sanitizeAssistantText } from "../ui/ansi-box.js";
|
|
5
8
|
import { randomUUID } from "node:crypto";
|
|
6
9
|
import { jobManager } from "../tools/jobs.js";
|
|
7
|
-
import { renderAgentSystemPrompt, renderCompactAgentSystemPrompt, scratchDirFor, } from "../prompts/index.js";
|
|
10
|
+
import { renderAgentSystemPrompt, renderCompactAgentSystemPrompt, scratchDirFor, toolNudge, } from "../prompts/index.js";
|
|
8
11
|
import { getConfig } from "../store/config.js";
|
|
9
12
|
import { groqInputTokenBudget } from "../llm/groq.js";
|
|
10
13
|
import { classifyToolCall, isPentestToolCall, scopeHint, scopeTargetForToolCall, } from "../safety/classifier.js";
|
|
11
14
|
import { availableToolNames, normalizeToolCall, runToolCall, BATCH_SAFE_TOOLS, } from "../tools/registry.js";
|
|
15
|
+
import { getToolDefinitions, getCompactToolDefinitions, PLAN_TOOL_NAMES, } from "../tools/definitions.js";
|
|
16
|
+
import { appendAssistantWithTools, appendToolResult, fillMissingToolResults, } from "./tool-history.js";
|
|
12
17
|
import { looksInteractiveStdin } from "../tools/shell.js";
|
|
13
18
|
import { formatViewportHint, registerViewport } from "../ui/output-pane.js";
|
|
14
19
|
import { compactMessagesWithSummary, estimateTokens, estimateMessagesTokens, AUTO_COMPACT_TOKEN_BUDGET, COMPACTION_MEMORY_PREFIX, } from "./context-manager.js";
|
|
@@ -22,12 +27,15 @@ import { startThinkingSpinner } from "../ui/spinner.js";
|
|
|
22
27
|
import { safeCwd } from "../os/cwd.js";
|
|
23
28
|
import { analyzeTask } from "./task-analyzer.js";
|
|
24
29
|
import { LoopGuard } from "./loop-guard.js";
|
|
25
|
-
import { loadPlan } from "../store/plan.js";
|
|
30
|
+
import { loadPlan, savePlan, markTask, } from "../store/plan.js";
|
|
26
31
|
import { pathInsideSandbox, fsWrite } from "../tools/fs.js";
|
|
27
|
-
import { stripSentinelTokens, parseToolCall, recognizeBareToolJson, looksLikeTruncatedToolCall, salvageTruncatedWrite, countToolFences, parseAllToolCalls, groupToolCallsForExecution, buildTurnHistory, collapseRepeatedText, textBeforeToolCall, formatToolArgs, looksLikePentestTask, looksLikeBuildTask, looksLikeInformationalQuery, looksLikeIdleOrSocialPrompt, looksLikeActionNarration, looksLikeWebActionNarration, looksLikePlanNarration, requiresFreshWebSearch, freshnessGuardMessage, buildWorkflowDirective, pentestWorkflowDirective, pentestNoLocalServerDirective, shouldDimToolChatter, looksLikePromptLeak, } from "./tool-call-parser.js";
|
|
32
|
+
import { stripSentinelTokens, parseToolCall, recognizeBareToolJson, looksLikeTruncatedToolCall, salvageTruncatedWrite, salvageTruncatedWriteFromNative, countToolFences, parseAllToolCalls, groupToolCallsForExecution, buildTurnHistory, collapseRepeatedText, textBeforeToolCall, formatToolArgs, looksLikePentestTask, looksLikeBuildTask, looksLikeInformationalQuery, looksLikeIdleOrSocialPrompt, looksLikeActionNarration, looksLikeWebActionNarration, looksLikePlanNarration, looksLikeErrorDiagnosisWithFixIntent, localHttpProbeIsFailure, localHttpProbeIsSuccess, requiresFreshWebSearch, freshnessGuardMessage, buildWorkflowDirective, pentestWorkflowDirective, pentestNoLocalServerDirective, shouldDimToolChatter, looksLikePromptLeak, } from "./tool-call-parser.js";
|
|
28
33
|
import { createSessionPolicy, isPreApprovalAllowedTool, isPlanApprovedByStatus, planHasOpenWork, isAbortError, shouldEnableImageOcr, } from "./session-policy.js";
|
|
29
34
|
import { saveToolOutput, summarizeOutput, formatToolContext, } from "./tool-output-formatting.js";
|
|
30
|
-
import { planContextMessage, handlePlanTool, } from "./plan-tool.js";
|
|
35
|
+
import { renderPlanForTerminal, planContextMessage, handlePlanTool, resolvePlanTaskId, } from "./plan-tool.js";
|
|
36
|
+
import { applyDestinationCwd, canMarkTaskDone, codingBuildRequiresPlan, incompleteFeatureBeforeServerMessage, isBuildPrePlanAllowedTool, isEvidenceWorkTool, isFeatureImplementationCall, isPlanPreflightTool, isReadOnlyVersionProbeCommand, isScaffoldCreateCommand, openTaskLedger, pickPendingTaskForToolCall, recordTaskWorkSuccess, resolveUserDestinationHint, toolStallBudgetMs, userAskedForFeatureApp, workOutOfScopeForTask, } from "./task-evidence.js";
|
|
37
|
+
import { extractProjectRootFromPlan, extractProjectRootFromScaffold, extractProjectRootFromText, getActiveProjectRoot, setActiveProjectRootIfValid, } from "./project-root.js";
|
|
38
|
+
import { buildWorkspaceOrientation, guessProjectFolderName, isScaffoldCancelledOutput, scaffoldLooksMaterialized, scaffoldTargetConflictMessage, } from "./workspace-orient.js";
|
|
31
39
|
import { inquirerConfirmPort, restoreInteractiveStdin, ensurePentestAuthorization, confirmToolExecution, } from "./confirm-port.js";
|
|
32
40
|
import { buildRichStopSummary } from "./stop-summary.js";
|
|
33
41
|
// Re-exported so existing imports of these names from "./runner.js" keep
|
|
@@ -150,13 +158,14 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
150
158
|
const writeAssistantMessage = (text) => {
|
|
151
159
|
// Never surface an empty message: the reducer drops it and a direct
|
|
152
160
|
// stdout writer would print a stray blank line.
|
|
153
|
-
|
|
161
|
+
const clean = sanitizeAssistantText(text);
|
|
162
|
+
if (!clean.trim())
|
|
154
163
|
return;
|
|
155
164
|
visibleCommitted = true;
|
|
156
|
-
emit({ type: "assistant-message", text });
|
|
157
|
-
const rendered = renderMarkdown(
|
|
165
|
+
emit({ type: "assistant-message", text: clean });
|
|
166
|
+
const rendered = renderMarkdown(clean);
|
|
158
167
|
if (writesDirectly) {
|
|
159
|
-
process.stdout.write(
|
|
168
|
+
process.stdout.write(clean.endsWith("\n") ? rendered : `${rendered}\n`);
|
|
160
169
|
}
|
|
161
170
|
};
|
|
162
171
|
const writeThinkingBlock = (content) => {
|
|
@@ -290,17 +299,20 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
290
299
|
// provider's 413 as a context-window failure after the fact.
|
|
291
300
|
const inputTokenBudget = provider === "groq" ? groqInputTokenBudget(model) : undefined;
|
|
292
301
|
const useCompactSystemPrompt = inputTokenBudget !== undefined;
|
|
293
|
-
const
|
|
294
|
-
(
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
302
|
+
const resolveNativeTools = (p, m) => {
|
|
303
|
+
const dialect = resolveToolDialect(p, m, config.toolCalling);
|
|
304
|
+
return { dialect, native: dialect !== "none" };
|
|
305
|
+
};
|
|
306
|
+
let { dialect: toolDialect, native: nativeToolsActive } = resolveNativeTools(provider, model);
|
|
307
|
+
const selectToolDefs = (native, compact) => {
|
|
308
|
+
if (!native)
|
|
309
|
+
return undefined;
|
|
310
|
+
const base = compact
|
|
311
|
+
? getCompactToolDefinitions()
|
|
312
|
+
: getToolDefinitions();
|
|
313
|
+
const allow = new Set([...toolNames, ...PLAN_TOOL_NAMES]);
|
|
314
|
+
return base.filter((d) => allow.has(d.name));
|
|
315
|
+
};
|
|
304
316
|
let lastAnswer = "";
|
|
305
317
|
const session = options.session ?? createSessionPolicy();
|
|
306
318
|
// Active plan context
|
|
@@ -308,7 +320,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
308
320
|
// context. When the user has approved it (via /implement) we instruct the
|
|
309
321
|
// agent to execute task by task; otherwise the agent should refine/wait.
|
|
310
322
|
const activePlan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
311
|
-
if (activePlan) {
|
|
323
|
+
if (activePlan && isPlanApprovedByStatus(activePlan.status)) {
|
|
312
324
|
// session.planApproved is in-memory only (never persisted), so a
|
|
313
325
|
// resumed session (via /history) or a fresh SessionPolicy after
|
|
314
326
|
// context compaction always starts it back at false — even when the
|
|
@@ -316,9 +328,77 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
316
328
|
// completed via /implement. Re-derive the flag from the plan's status
|
|
317
329
|
// on every load so resuming a session never re-blocks tool calls
|
|
318
330
|
// behind a stale "awaiting approval" gate for a plan that already ran.
|
|
319
|
-
|
|
320
|
-
|
|
331
|
+
session.planApproved.value = true;
|
|
332
|
+
}
|
|
333
|
+
const destinationHint = resolveUserDestinationHint(prompt);
|
|
334
|
+
// Sticky project root so relative fs paths never hit the agent package.
|
|
335
|
+
// Only pin paths that already exist (or were previously validated) — never
|
|
336
|
+
// invent Desktop/todo-app before the folder is real, and never pin bare Desktop.
|
|
337
|
+
{
|
|
338
|
+
const fromPrompt = extractProjectRootFromText(prompt);
|
|
339
|
+
const fromPlan = extractProjectRootFromPlan(activePlan);
|
|
340
|
+
const root = fromPlan ?? fromPrompt;
|
|
341
|
+
if (root)
|
|
342
|
+
setActiveProjectRootIfValid(root);
|
|
343
|
+
// Do NOT setActiveProjectRoot(destinationHint) — bare Desktop is a parent only.
|
|
344
|
+
}
|
|
345
|
+
const buildSystemContent = (native) => {
|
|
346
|
+
const sections = [
|
|
347
|
+
(useCompactSystemPrompt
|
|
348
|
+
? renderCompactAgentSystemPrompt
|
|
349
|
+
: renderAgentSystemPrompt)(toolNames.join(", "), {
|
|
350
|
+
nativeTools: native,
|
|
351
|
+
}),
|
|
352
|
+
];
|
|
353
|
+
if (projectContext) {
|
|
354
|
+
sections.push(`Project context from .clai/context.md:\n${projectContext}`);
|
|
355
|
+
}
|
|
356
|
+
const projectRoot = getActiveProjectRoot();
|
|
357
|
+
if (projectRoot) {
|
|
358
|
+
sections.push(`ACTIVE PROJECT ROOT: ${projectRoot}\n` +
|
|
359
|
+
`All relative paths (./src/…, manifests, configs) resolve under this directory — NOT the agent process cwd. ` +
|
|
360
|
+
`Prefer absolute paths under this root. shell cwd for install / run / build must be this root ` +
|
|
361
|
+
`(or its parent when creating a NEW named subfolder with a scaffolder). ` +
|
|
362
|
+
`Never write user app source into the agent package tree.`);
|
|
363
|
+
}
|
|
364
|
+
else if (destinationHint) {
|
|
365
|
+
sections.push(`USER DESTINATION: create or continue work under "${destinationHint}" (parent folder). ` +
|
|
366
|
+
`Pick or detect a project subfolder; do not scaffold into the agent working tree unless the user asked for that.`);
|
|
367
|
+
}
|
|
368
|
+
// Stack-agnostic PWD / existing-project snapshot so weak models cannot
|
|
369
|
+
// skip explore and re-scaffold into non-empty dirs.
|
|
370
|
+
if (buildLikeTurn &&
|
|
371
|
+
!informationalQuery &&
|
|
372
|
+
!idleOrSocialPrompt) {
|
|
373
|
+
const guessedName = guessProjectFolderName([prompt, activePlan?.goal, activePlan?.detail, activePlan?.tasks.map((t) => t.title).join(" ")]
|
|
374
|
+
.filter(Boolean)
|
|
375
|
+
.join("\n"));
|
|
376
|
+
const extraPaths = [];
|
|
377
|
+
if (destinationHint && guessedName) {
|
|
378
|
+
extraPaths.push(join(destinationHint, guessedName));
|
|
379
|
+
}
|
|
380
|
+
const fromText = extractProjectRootFromPlan(activePlan) ??
|
|
381
|
+
extractProjectRootFromText(prompt);
|
|
382
|
+
if (fromText)
|
|
383
|
+
extraPaths.push(fromText);
|
|
384
|
+
const orientInput = {
|
|
385
|
+
cwd: safeCwd(),
|
|
386
|
+
extraPaths,
|
|
387
|
+
};
|
|
388
|
+
if (destinationHint)
|
|
389
|
+
orientInput.destinationHint = destinationHint;
|
|
390
|
+
const candidate = getActiveProjectRoot() ?? fromText;
|
|
391
|
+
if (candidate)
|
|
392
|
+
orientInput.candidateProject = candidate;
|
|
393
|
+
sections.push(buildWorkspaceOrientation(orientInput));
|
|
321
394
|
}
|
|
395
|
+
if (freshWebSearchRequired) {
|
|
396
|
+
sections.push(freshnessGuardMessage());
|
|
397
|
+
}
|
|
398
|
+
return sections.join("\n\n");
|
|
399
|
+
};
|
|
400
|
+
const systemSections = [buildSystemContent(nativeToolsActive)];
|
|
401
|
+
if (activePlan) {
|
|
322
402
|
systemSections.push(planContextMessage(activePlan, session.planApproved.value));
|
|
323
403
|
}
|
|
324
404
|
// For build/scaffold turns with no active plan yet, inject an explicit
|
|
@@ -415,15 +495,16 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
415
495
|
// text isn't wiped by the next tool-call/turn event. Skip when this
|
|
416
496
|
// iteration already surfaced its prose (the normal tool path commits
|
|
417
497
|
// `beforeTool` itself) so the same text is never rendered twice.
|
|
498
|
+
const cleaned = sanitizeAssistantText(content);
|
|
418
499
|
if (!visibleCommitted) {
|
|
419
|
-
const prose = recoveryProse(
|
|
500
|
+
const prose = recoveryProse(cleaned);
|
|
420
501
|
if (prose)
|
|
421
502
|
writeAssistantMessage(prose);
|
|
422
503
|
}
|
|
423
504
|
messages.push({
|
|
424
505
|
role: "assistant",
|
|
425
|
-
content:
|
|
426
|
-
?
|
|
506
|
+
content: cleaned.trim()
|
|
507
|
+
? cleaned
|
|
427
508
|
: "[No visible assistant response was produced.]",
|
|
428
509
|
});
|
|
429
510
|
};
|
|
@@ -458,9 +539,25 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
458
539
|
// executing the next task a bounded number of times before giving up.
|
|
459
540
|
let prematureCompletionRetries = 0;
|
|
460
541
|
let runtimeVerificationRetries = 0;
|
|
542
|
+
let featureImplRetries = 0;
|
|
543
|
+
let forcePlanRetries = 0;
|
|
544
|
+
let errorFixNarrationRetries = 0;
|
|
545
|
+
let failedProbeFixRetries = 0;
|
|
461
546
|
let sawServerStart = false;
|
|
547
|
+
let sawPlanCreateOk = false;
|
|
462
548
|
let sawServerTail = false;
|
|
463
549
|
let sawLocalHttpProbe = false;
|
|
550
|
+
/** Last localhost probe returned 4xx/5xx / connection refused — must fix. */
|
|
551
|
+
let sawFailedLocalHttpProbe = false;
|
|
552
|
+
/** Local app was scaffolded/installed/written this turn (plan optional). */
|
|
553
|
+
let sawLocalAppMaterialWork = false;
|
|
554
|
+
/** Official scaffolder succeeded this turn. */
|
|
555
|
+
let sawScaffoldOk = false;
|
|
556
|
+
/** Real product source written (not just scaffold defaults). */
|
|
557
|
+
let sawFeatureImplWrite = false;
|
|
558
|
+
const featureAppAsk = userAskedForFeatureApp(prompt);
|
|
559
|
+
/** Successful work tools under the current in_progress plan task. */
|
|
560
|
+
let taskWorkLedger = null;
|
|
464
561
|
// Guard against a model that NARRATES intent ("let me explore the
|
|
465
562
|
// directory…") but emits no tool call, so nothing runs and the turn ends
|
|
466
563
|
// prematurely. On build/scaffold/plan turns where nothing has executed yet,
|
|
@@ -536,6 +633,15 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
536
633
|
// model-supplied paths against the canonical per-project scratch root.
|
|
537
634
|
const scratchDir = scratchDirFor(safeCwd());
|
|
538
635
|
let call = normalizeToolCall(rawCall);
|
|
636
|
+
if (call.args?.__nativeParseError) {
|
|
637
|
+
const raw = String(call.args._raw ?? "").slice(0, 200);
|
|
638
|
+
const reason = "Tool call arguments were not valid JSON (truncated or malformed). " +
|
|
639
|
+
"Retry with smaller content, or use fs.writeMany / fs.append continuation. " +
|
|
640
|
+
(raw ? `Partial: ${raw}` : "");
|
|
641
|
+
const result = { ok: false, output: reason, exitCode: 1 };
|
|
642
|
+
emitToolResult(toolEventId, result, reason);
|
|
643
|
+
return { ok: false, call, result, contextOutput: reason };
|
|
644
|
+
}
|
|
539
645
|
if (call.name === "image.ocr" && !imageOcrEnabled) {
|
|
540
646
|
writeNotice("info", "skipped OCR because the original image is attached to the vision model", chalk.dim(" ℹ skipped OCR — inspecting the attached image directly\n"));
|
|
541
647
|
const recoveryText = "The original image is attached to this message and you can inspect it directly. " +
|
|
@@ -565,12 +671,71 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
565
671
|
writeNotice("info", loopCheck.reason, chalk.dim(` ℹ ${loopCheck.reason}\n`));
|
|
566
672
|
}
|
|
567
673
|
if (call.name === "plan.create" || call.name === "task.update") {
|
|
674
|
+
// Evidence gate: refuse done until at least one successful work tool
|
|
675
|
+
// ran under this task (model must see results and be satisfied).
|
|
676
|
+
if (call.name === "task.update") {
|
|
677
|
+
const stateRaw = typeof call.args.state === "string" ? call.args.state : "";
|
|
678
|
+
const taskIdRaw = typeof call.args.taskId === "string"
|
|
679
|
+
? call.args.taskId
|
|
680
|
+
: typeof call.args.id === "string"
|
|
681
|
+
? call.args.id
|
|
682
|
+
: "";
|
|
683
|
+
if (stateRaw === "done" && taskIdRaw) {
|
|
684
|
+
const live = await loadPlan(session.sessionId).catch(() => undefined);
|
|
685
|
+
const resolved = (live ? resolvePlanTaskId(live, taskIdRaw) : undefined) ??
|
|
686
|
+
taskIdRaw;
|
|
687
|
+
const gate = canMarkTaskDone(taskWorkLedger, resolved);
|
|
688
|
+
if (!gate.ok) {
|
|
689
|
+
writeNotice("warn", gate.reason, chalk.yellow(` ⚠ ${gate.reason}\n`));
|
|
690
|
+
if (!alreadyPrintedIds.has(toolEventId)) {
|
|
691
|
+
const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
|
|
692
|
+
chalk.gray(` ${formatToolArgs(call)}`);
|
|
693
|
+
writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
|
|
694
|
+
alreadyPrintedIds.add(toolEventId);
|
|
695
|
+
}
|
|
696
|
+
const result = {
|
|
697
|
+
ok: false,
|
|
698
|
+
output: gate.reason,
|
|
699
|
+
exitCode: 1,
|
|
700
|
+
};
|
|
701
|
+
emitToolResult(toolEventId, result, gate.reason);
|
|
702
|
+
writeToolOutput(toolEventId, "failed\n", chalk.red(" ✗") + "\n");
|
|
703
|
+
return {
|
|
704
|
+
ok: false,
|
|
705
|
+
call,
|
|
706
|
+
result,
|
|
707
|
+
contextOutput: gate.reason,
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
568
712
|
const planResult = await handlePlanTool(call, session, {
|
|
569
713
|
loopGuard,
|
|
570
714
|
step,
|
|
571
715
|
});
|
|
572
716
|
if (planResult.handled) {
|
|
573
717
|
loopGuard.recordAttempt(step, call.name, call.args, planResult.ok, 0);
|
|
718
|
+
if (planResult.ok && call.name === "task.update") {
|
|
719
|
+
const stateRaw = typeof call.args.state === "string" ? call.args.state : "";
|
|
720
|
+
const taskIdRaw = typeof call.args.taskId === "string"
|
|
721
|
+
? call.args.taskId
|
|
722
|
+
: typeof call.args.id === "string"
|
|
723
|
+
? call.args.id
|
|
724
|
+
: "";
|
|
725
|
+
const resolved = (planResult.plan
|
|
726
|
+
? resolvePlanTaskId(planResult.plan, taskIdRaw)
|
|
727
|
+
: undefined) ?? taskIdRaw;
|
|
728
|
+
if (stateRaw === "in_progress" && resolved) {
|
|
729
|
+
taskWorkLedger = openTaskLedger(resolved);
|
|
730
|
+
}
|
|
731
|
+
else if (stateRaw === "done" && resolved) {
|
|
732
|
+
taskWorkLedger = null;
|
|
733
|
+
}
|
|
734
|
+
else if ((stateRaw === "failed" || stateRaw === "skipped") &&
|
|
735
|
+
taskWorkLedger?.taskId === resolved) {
|
|
736
|
+
taskWorkLedger = null;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
574
739
|
if (!alreadyPrintedIds.has(toolEventId)) {
|
|
575
740
|
const toolCallLine = chalk.cyan(` ▶ ${call.name}`) + chalk.gray(` ${formatToolArgs(call)}`);
|
|
576
741
|
writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
|
|
@@ -578,6 +743,10 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
578
743
|
}
|
|
579
744
|
if (planResult.plan) {
|
|
580
745
|
writePlanUpdate(planResult.plan, planResult.display);
|
|
746
|
+
// Refresh sticky root only if path already exists (not bare Desktop).
|
|
747
|
+
const root = extractProjectRootFromPlan(planResult.plan);
|
|
748
|
+
if (root)
|
|
749
|
+
setActiveProjectRootIfValid(root);
|
|
581
750
|
}
|
|
582
751
|
const result = { ok: planResult.ok, output: planResult.modelNote };
|
|
583
752
|
emitToolResult(toolEventId, result, planResult.modelNote);
|
|
@@ -598,11 +767,52 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
598
767
|
decision,
|
|
599
768
|
scope: isScopeActive(scope) ? (scope.name ?? "(unnamed)") : "(none)",
|
|
600
769
|
});
|
|
770
|
+
// Coding builds: no freestyle scaffold/write until plan.create exists.
|
|
771
|
+
// Explore (fs.list/read, tool.check) + plan.create only; then wait for /implement.
|
|
772
|
+
const livePlanForPreGate = await loadPlan(session.sessionId).catch(() => undefined);
|
|
773
|
+
const codingNeedsPlan = buildLikeTurn &&
|
|
774
|
+
codingBuildRequiresPlan(prompt, {
|
|
775
|
+
informational: informationalQuery,
|
|
776
|
+
idle: idleOrSocialPrompt,
|
|
777
|
+
pentest: pentestLikeTurn,
|
|
778
|
+
});
|
|
779
|
+
if (codingNeedsPlan &&
|
|
780
|
+
!livePlanForPreGate &&
|
|
781
|
+
!sawPlanCreateOk &&
|
|
782
|
+
!isScratchOnlyWrite(call, scratchDir)) {
|
|
783
|
+
const cmd = typeof call.args.command === "string" ? call.args.command : "";
|
|
784
|
+
const allowedPrePlan = isBuildPrePlanAllowedTool(call.name) ||
|
|
785
|
+
isPreApprovalAllowedTool(call.name) ||
|
|
786
|
+
(call.name === "shell.exec" && isReadOnlyVersionProbeCommand(cmd));
|
|
787
|
+
if (!allowedPrePlan) {
|
|
788
|
+
const reason = `plan required first — ${call.name} is blocked on coding builds until plan.create. ` +
|
|
789
|
+
`Explore with fs.list / fs.read / tool.check if needed, then call plan.create ` +
|
|
790
|
+
`(kind=coding, 4–8 tasks including feature work + final run/verify). Stop and wait for /implement.`;
|
|
791
|
+
writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
|
|
792
|
+
if (!alreadyPrintedIds.has(toolEventId)) {
|
|
793
|
+
const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
|
|
794
|
+
chalk.gray(` ${formatToolArgs(call)}`);
|
|
795
|
+
writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
|
|
796
|
+
alreadyPrintedIds.add(toolEventId);
|
|
797
|
+
}
|
|
798
|
+
const result = { ok: false, output: reason, exitCode: 1 };
|
|
799
|
+
emitToolResult(toolEventId, result, reason);
|
|
800
|
+
writeToolOutput(toolEventId, "failed\n", chalk.red(" ✗") + "\n");
|
|
801
|
+
return {
|
|
802
|
+
ok: false,
|
|
803
|
+
call,
|
|
804
|
+
result,
|
|
805
|
+
contextOutput: reason,
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
}
|
|
601
809
|
const isMutatingAction = (decision.level === "confirm" || decision.level === "block") &&
|
|
602
810
|
!isPreApprovalAllowedTool(call.name) &&
|
|
603
811
|
!isScratchOnlyWrite(call, scratchDir);
|
|
604
812
|
if (isMutatingAction) {
|
|
605
|
-
|
|
813
|
+
const planNow = livePlanForPreGate ??
|
|
814
|
+
(await loadPlan(session.sessionId).catch(() => undefined));
|
|
815
|
+
if (planNow && !session.planApproved.value) {
|
|
606
816
|
const reason = `plan awaiting approval — ${call.name} is blocked until you /implement (or /discard)`;
|
|
607
817
|
writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
|
|
608
818
|
const result = { ok: false, output: reason, exitCode: 1 };
|
|
@@ -623,30 +833,131 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
623
833
|
// claimed most tasks "done" in prose without ever recording it in the
|
|
624
834
|
// plan. Multiple tool calls per task are still fine; they just must be
|
|
625
835
|
// bracketed by task.update in_progress → (work) → task.update done.
|
|
836
|
+
//
|
|
837
|
+
// GPT-OSS etc. often mark tN done then immediately fs.list/read for tN+1
|
|
838
|
+
// without opening the next task. Auto-start the first pending task so
|
|
839
|
+
// work continues without a wasted blocked turn (still recorded in plan).
|
|
626
840
|
if (session.planApproved.value) {
|
|
627
841
|
const livePlanForGate = await loadPlan(session.sessionId).catch(() => undefined);
|
|
628
842
|
if (livePlanForGate) {
|
|
629
843
|
const unfinished = livePlanForGate.tasks.some((t) => t.state === "pending" || t.state === "in_progress");
|
|
630
844
|
const inProgress = livePlanForGate.tasks.find((t) => t.state === "in_progress");
|
|
631
845
|
if (unfinished && !inProgress) {
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
846
|
+
// tool.check / fs.list preflight: allow without auto-opening a task
|
|
847
|
+
// (auto-start on preflight made models skip task.update and confused scope).
|
|
848
|
+
if (isPlanPreflightTool(call.name)) {
|
|
849
|
+
// fall through — no task ledger yet
|
|
850
|
+
}
|
|
851
|
+
else {
|
|
852
|
+
const pending = livePlanForGate.tasks.filter((t) => t.state === "pending");
|
|
853
|
+
// Match tool → task (npm install must not auto-start "localStorage")
|
|
854
|
+
const nextPending = pickPendingTaskForToolCall(pending, call, livePlanForGate.tasks.map((t) => t.title));
|
|
855
|
+
if (nextPending) {
|
|
856
|
+
markTask(livePlanForGate, nextPending.id, "in_progress");
|
|
857
|
+
if (livePlanForGate.status === "draft" ||
|
|
858
|
+
livePlanForGate.status === "approved") {
|
|
859
|
+
livePlanForGate.status = "in_progress";
|
|
860
|
+
}
|
|
861
|
+
await savePlan(livePlanForGate).catch(() => undefined);
|
|
862
|
+
taskWorkLedger = openTaskLedger(nextPending.id);
|
|
863
|
+
writePlanUpdate(livePlanForGate, renderPlanForTerminal(livePlanForGate) + "\n");
|
|
864
|
+
writeNotice("info", `auto-started [${nextPending.id}] so work can continue`, chalk.dim(` ℹ no task was in_progress — auto-started [${nextPending.id}] "${nextPending.title}" before ${call.name}\n`));
|
|
865
|
+
}
|
|
866
|
+
else {
|
|
867
|
+
const reason = `${call.name} blocked — no matching pending task is in_progress for this tool. ` +
|
|
868
|
+
`Call task.update in_progress on the correct task (e.g. install vs implement vs run/verify), then retry.`;
|
|
869
|
+
writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
|
|
870
|
+
const result = { ok: false, output: reason, exitCode: 1 };
|
|
871
|
+
return {
|
|
872
|
+
ok: false,
|
|
873
|
+
call,
|
|
874
|
+
result,
|
|
875
|
+
contextOutput: reason,
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
// Keep work inside the open task (no early server start during install).
|
|
883
|
+
if (session.planApproved.value) {
|
|
884
|
+
const liveForScope = await loadPlan(session.sessionId).catch(() => undefined);
|
|
885
|
+
const openTask = liveForScope?.tasks.find((t) => t.state === "in_progress");
|
|
886
|
+
if (openTask) {
|
|
887
|
+
const scopeMsg = workOutOfScopeForTask(openTask.title, call, liveForScope?.tasks
|
|
888
|
+
? { planTaskTitles: liveForScope.tasks.map((t) => t.title) }
|
|
889
|
+
: undefined);
|
|
890
|
+
if (scopeMsg) {
|
|
891
|
+
writeNotice("warn", scopeMsg, chalk.yellow(` ⚠ ${scopeMsg}\n`));
|
|
892
|
+
if (!alreadyPrintedIds.has(toolEventId)) {
|
|
893
|
+
const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
|
|
894
|
+
chalk.gray(` ${formatToolArgs(call)}`);
|
|
895
|
+
writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
|
|
896
|
+
alreadyPrintedIds.add(toolEventId);
|
|
897
|
+
}
|
|
898
|
+
const result = { ok: false, output: scopeMsg, exitCode: 1 };
|
|
899
|
+
emitToolResult(toolEventId, result, scopeMsg);
|
|
900
|
+
writeToolOutput(toolEventId, "failed\n", chalk.red(" ✗") + "\n");
|
|
641
901
|
return {
|
|
642
902
|
ok: false,
|
|
643
903
|
call,
|
|
644
904
|
result,
|
|
645
|
-
contextOutput:
|
|
905
|
+
contextOutput: scopeMsg,
|
|
646
906
|
};
|
|
647
907
|
}
|
|
648
908
|
}
|
|
649
909
|
}
|
|
910
|
+
// Freestyle or any path: block server start until product feature exists.
|
|
911
|
+
{
|
|
912
|
+
const featureBlock = incompleteFeatureBeforeServerMessage(prompt, sawFeatureImplWrite, call);
|
|
913
|
+
if (featureBlock) {
|
|
914
|
+
writeNotice("warn", featureBlock, chalk.yellow(` ⚠ ${featureBlock}\n`));
|
|
915
|
+
if (!alreadyPrintedIds.has(toolEventId)) {
|
|
916
|
+
const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
|
|
917
|
+
chalk.gray(` ${formatToolArgs(call)}`);
|
|
918
|
+
writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
|
|
919
|
+
alreadyPrintedIds.add(toolEventId);
|
|
920
|
+
}
|
|
921
|
+
const result = { ok: false, output: featureBlock, exitCode: 1 };
|
|
922
|
+
emitToolResult(toolEventId, result, featureBlock);
|
|
923
|
+
writeToolOutput(toolEventId, "failed\n", chalk.red(" ✗") + "\n");
|
|
924
|
+
return {
|
|
925
|
+
ok: false,
|
|
926
|
+
call,
|
|
927
|
+
result,
|
|
928
|
+
contextOutput: featureBlock,
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
// Prefer user Desktop (etc.) as cwd when model omitted it.
|
|
933
|
+
// Also prefer sticky project root for install/run when set.
|
|
934
|
+
call = applyDestinationCwd(call, destinationHint ?? getActiveProjectRoot());
|
|
935
|
+
// Soft preflight: refuse scaffold into an existing non-empty project
|
|
936
|
+
// (avoids endless "Operation cancelled" retries across all stacks).
|
|
937
|
+
if ((call.name === "shell.exec" || call.name === "shell.start") &&
|
|
938
|
+
typeof call.args.command === "string" &&
|
|
939
|
+
isScaffoldCreateCommand(call.args.command)) {
|
|
940
|
+
const cwdArg = typeof call.args.cwd === "string" ? call.args.cwd : undefined;
|
|
941
|
+
const conflict = scaffoldTargetConflictMessage(call.args.command, cwdArg);
|
|
942
|
+
if (conflict) {
|
|
943
|
+
writeNotice("warn", conflict, chalk.yellow(` ⚠ ${conflict}\n`));
|
|
944
|
+
if (!alreadyPrintedIds.has(toolEventId)) {
|
|
945
|
+
const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
|
|
946
|
+
chalk.gray(` ${formatToolArgs(call)}`);
|
|
947
|
+
writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
|
|
948
|
+
alreadyPrintedIds.add(toolEventId);
|
|
949
|
+
}
|
|
950
|
+
const result = { ok: false, output: conflict, exitCode: 1 };
|
|
951
|
+
emitToolResult(toolEventId, result, conflict);
|
|
952
|
+
writeToolOutput(toolEventId, "failed\n", chalk.red(" ✗") + "\n");
|
|
953
|
+
return {
|
|
954
|
+
ok: false,
|
|
955
|
+
call,
|
|
956
|
+
result,
|
|
957
|
+
contextOutput: conflict,
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
}
|
|
650
961
|
if (call.name === "web.search") {
|
|
651
962
|
sawFreshWebSearch = true;
|
|
652
963
|
}
|
|
@@ -790,8 +1101,11 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
790
1101
|
jobManager.registerJob(jobId, backgroundJob, toolAc);
|
|
791
1102
|
// Long-lived commands should use shell.start/background jobs. Reset this
|
|
792
1103
|
// watchdog whenever a blocking tool emits output so only a genuinely
|
|
793
|
-
// stalled operation is cancelled.
|
|
794
|
-
|
|
1104
|
+
// stalled operation is cancelled. Scaffold/install can go quiet for many
|
|
1105
|
+
// minutes while downloading packages — use a much larger budget there
|
|
1106
|
+
// (otherwise create-next-app is SIGINT'd mid-install → exit 130 + partial tree).
|
|
1107
|
+
const TOOL_STALL_ABORT_MS = toolStallBudgetMs(call);
|
|
1108
|
+
const stallSecs = Math.round(TOOL_STALL_ABORT_MS / 1000);
|
|
795
1109
|
let stallTimer;
|
|
796
1110
|
let stalledByWatchdog = false;
|
|
797
1111
|
const resetStallTimer = () => {
|
|
@@ -800,7 +1114,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
800
1114
|
stallTimer = setTimeout(() => {
|
|
801
1115
|
if (!toolAc.signal.aborted) {
|
|
802
1116
|
stalledByWatchdog = true;
|
|
803
|
-
writeNotice("warn", `${call.name} has been running for
|
|
1117
|
+
writeNotice("warn", `${call.name} has been running for >${stallSecs}s without output — cancelling stalled tool`, chalk.yellow(` ⏳ ${call.name} stalled for >${stallSecs}s without output — cancelling\n`));
|
|
804
1118
|
toolAc.abort();
|
|
805
1119
|
}
|
|
806
1120
|
}, TOOL_STALL_ABORT_MS);
|
|
@@ -859,6 +1173,53 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
859
1173
|
clearTimeout(stallTimer);
|
|
860
1174
|
parentSignal.removeEventListener("abort", onParentAbort);
|
|
861
1175
|
}
|
|
1176
|
+
// After a REAL successful scaffold, pin project root. Cancelled / empty
|
|
1177
|
+
// targets must NOT pin a root or count as success (exit 0 + "cancelled").
|
|
1178
|
+
// Must run before emit/evidence so the model sees failure, not a false ok.
|
|
1179
|
+
// If the process was aborted mid-install but a usable tree is already on
|
|
1180
|
+
// disk, pin the root and tell the model to CONTINUE (do not re-scaffold).
|
|
1181
|
+
if ((call.name === "shell.exec" || call.name === "shell.start") &&
|
|
1182
|
+
typeof call.args.command === "string" &&
|
|
1183
|
+
isScaffoldCreateCommand(call.args.command)) {
|
|
1184
|
+
const cmd = call.args.command;
|
|
1185
|
+
const cwdArg = typeof call.args.cwd === "string" ? call.args.cwd : undefined;
|
|
1186
|
+
const fromScaffold = extractProjectRootFromScaffold(cmd, cwdArg);
|
|
1187
|
+
const cancelled = isScaffoldCancelledOutput(result.output ?? "");
|
|
1188
|
+
const materialized = scaffoldLooksMaterialized(fromScaffold);
|
|
1189
|
+
const abortedMid = !result.ok &&
|
|
1190
|
+
(result.exitCode === 124 ||
|
|
1191
|
+
result.exitCode === 130 ||
|
|
1192
|
+
/timed out|aborted|Command aborted/i.test(result.output ?? ""));
|
|
1193
|
+
if (result.ok && (cancelled || !materialized)) {
|
|
1194
|
+
result = {
|
|
1195
|
+
ok: false,
|
|
1196
|
+
output: (result.output ?? "") +
|
|
1197
|
+
(result.output?.endsWith("\n") ? "" : "\n") +
|
|
1198
|
+
`Scaffold FAILED: ${cancelled ? "tool reported cancel/refuse" : "target project tree was not created"}. ` +
|
|
1199
|
+
(fromScaffold
|
|
1200
|
+
? `Expected project at ${fromScaffold}. `
|
|
1201
|
+
: "") +
|
|
1202
|
+
`If the folder already exists, CONTINUE it (do not re-scaffold). Otherwise use a new empty name or hand-write a minimal tree.`,
|
|
1203
|
+
exitCode: result.exitCode && result.exitCode !== 0 ? result.exitCode : 1,
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
else if (result.ok && fromScaffold && materialized) {
|
|
1207
|
+
setActiveProjectRootIfValid(fromScaffold, { force: true });
|
|
1208
|
+
writeNotice("info", `project root → ${fromScaffold}`, chalk.dim(` ℹ project root set to ${fromScaffold}\n`));
|
|
1209
|
+
}
|
|
1210
|
+
else if (abortedMid && fromScaffold && materialized) {
|
|
1211
|
+
setActiveProjectRootIfValid(fromScaffold, { force: true });
|
|
1212
|
+
result = {
|
|
1213
|
+
...result,
|
|
1214
|
+
output: (result.output ?? "") +
|
|
1215
|
+
(result.output?.endsWith("\n") ? "" : "\n") +
|
|
1216
|
+
`Scaffold command was interrupted, but a project tree already exists at ${fromScaffold} ` +
|
|
1217
|
+
`(package/manifest present). Do NOT re-run the scaffolder. CONTINUE: finish any missing install ` +
|
|
1218
|
+
`(\`npm install\` / stack equivalent), implement the requested feature, then run/verify.`,
|
|
1219
|
+
};
|
|
1220
|
+
writeNotice("info", `project root → ${fromScaffold} (partial scaffold — continue)`, chalk.dim(` ℹ partial scaffold at ${fromScaffold} — continue, do not re-create\n`));
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
862
1223
|
const output = result.output.trim();
|
|
863
1224
|
// Always keep a full on-disk copy of tool output (any size) so the
|
|
864
1225
|
// pager never depends on a truncated in-memory preview.
|
|
@@ -885,6 +1246,12 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
885
1246
|
output: result.output.slice(0, 4_000),
|
|
886
1247
|
});
|
|
887
1248
|
loopGuard.recordAttempt(step, call.name, call.args, result.ok, result.exitCode);
|
|
1249
|
+
// Evidence for verify-before-done: only successful real work counts.
|
|
1250
|
+
if (result.ok && isEvidenceWorkTool(call.name)) {
|
|
1251
|
+
const liveAfter = await loadPlan(session.sessionId).catch(() => undefined);
|
|
1252
|
+
const openId = liveAfter?.tasks.find((t) => t.state === "in_progress")?.id;
|
|
1253
|
+
taskWorkLedger = recordTaskWorkSuccess(taskWorkLedger, openId ?? taskWorkLedger?.taskId, call.name);
|
|
1254
|
+
}
|
|
888
1255
|
// Inject approach evaluation when consecutive failures are detected.
|
|
889
1256
|
// Lets the MODEL decide (with full context) whether to continue a
|
|
890
1257
|
// legitimately long approach, switch, or stop — instead of a
|
|
@@ -1143,7 +1510,26 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1143
1510
|
emit({ type: "thinking-delta", text });
|
|
1144
1511
|
});
|
|
1145
1512
|
let completion;
|
|
1513
|
+
let toolsAttached = false;
|
|
1146
1514
|
try {
|
|
1515
|
+
// Re-resolve dialect each step so /model or sticky fallback apply.
|
|
1516
|
+
({ dialect: toolDialect, native: nativeToolsActive } =
|
|
1517
|
+
resolveNativeTools(provider, model));
|
|
1518
|
+
if (messages[0]?.role === "system") {
|
|
1519
|
+
messages[0] = {
|
|
1520
|
+
role: "system",
|
|
1521
|
+
content: buildSystemContent(nativeToolsActive),
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
const turnTools = selectToolDefs(nativeToolsActive, useCompactSystemPrompt);
|
|
1525
|
+
toolsAttached = Boolean(turnTools?.length);
|
|
1526
|
+
await auditLog("agent.turn", {
|
|
1527
|
+
provider,
|
|
1528
|
+
model,
|
|
1529
|
+
tool_protocol: toolsAttached ? "native" : "text",
|
|
1530
|
+
dialect: toolDialect,
|
|
1531
|
+
step,
|
|
1532
|
+
});
|
|
1147
1533
|
completion = await streamWithProvider({
|
|
1148
1534
|
provider,
|
|
1149
1535
|
model,
|
|
@@ -1173,36 +1559,101 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1173
1559
|
thinking: retryWithoutThinking
|
|
1174
1560
|
? { ...config.thinking, enabled: false, effort: "low" }
|
|
1175
1561
|
: config.thinking,
|
|
1562
|
+
...(toolsAttached
|
|
1563
|
+
? {
|
|
1564
|
+
tools: turnTools,
|
|
1565
|
+
toolChoice: freshWebSearchRequired && !sawFreshWebSearch
|
|
1566
|
+
? { type: "function", name: "web.search" }
|
|
1567
|
+
: "auto",
|
|
1568
|
+
parallelToolCalls: true,
|
|
1569
|
+
// P2-3: emit tool cards as soon as the function name arrives.
|
|
1570
|
+
onToolCallDelta: (delta) => {
|
|
1571
|
+
if (!delta.name)
|
|
1572
|
+
return;
|
|
1573
|
+
const name = fromWireName(delta.name) ?? delta.name;
|
|
1574
|
+
const existing = deferredToolCalls[delta.index];
|
|
1575
|
+
if (existing) {
|
|
1576
|
+
if (delta.argumentsBytes &&
|
|
1577
|
+
delta.argumentsBytes >= 4096 &&
|
|
1578
|
+
!writesDirectly) {
|
|
1579
|
+
emit({
|
|
1580
|
+
type: "status",
|
|
1581
|
+
text: `${name} (${Math.round(delta.argumentsBytes / 1024)}KB args)`,
|
|
1582
|
+
});
|
|
1583
|
+
}
|
|
1584
|
+
return;
|
|
1585
|
+
}
|
|
1586
|
+
// Ensure slots are dense so index maps to deferredToolCalls[i].
|
|
1587
|
+
while (deferredToolCalls.length < delta.index) {
|
|
1588
|
+
deferredToolCalls.push({
|
|
1589
|
+
eventId: `tool-${++nextToolEventId}`,
|
|
1590
|
+
call: { name: "…", args: {} },
|
|
1591
|
+
rendered: "",
|
|
1592
|
+
});
|
|
1593
|
+
}
|
|
1594
|
+
const call = normalizeToolCall({
|
|
1595
|
+
name,
|
|
1596
|
+
args: {},
|
|
1597
|
+
});
|
|
1598
|
+
const eventId = `tool-${++nextToolEventId}`;
|
|
1599
|
+
callIds.push(eventId);
|
|
1600
|
+
alreadyPrintedIds.add(eventId);
|
|
1601
|
+
const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
|
|
1602
|
+
chalk.gray(` ${formatToolArgs(call)}`);
|
|
1603
|
+
const entry = {
|
|
1604
|
+
eventId,
|
|
1605
|
+
call,
|
|
1606
|
+
rendered: styleToolChatter(call, toolCallLine) + "\n",
|
|
1607
|
+
};
|
|
1608
|
+
if (deferredToolCalls.length === delta.index) {
|
|
1609
|
+
deferredToolCalls.push(entry);
|
|
1610
|
+
}
|
|
1611
|
+
else {
|
|
1612
|
+
deferredToolCalls[delta.index] = entry;
|
|
1613
|
+
}
|
|
1614
|
+
streamedCallsCount = Math.max(streamedCallsCount, deferredToolCalls.length);
|
|
1615
|
+
if (!writesDirectly) {
|
|
1616
|
+
emit({ type: "status", text: call.name });
|
|
1617
|
+
}
|
|
1618
|
+
else {
|
|
1619
|
+
spinner.stop();
|
|
1620
|
+
spinner = startThinkingSpinner(`tool ${call.name}…`, options.signal);
|
|
1621
|
+
}
|
|
1622
|
+
},
|
|
1623
|
+
}
|
|
1624
|
+
: {}),
|
|
1176
1625
|
}, (token) => {
|
|
1177
1626
|
deltaParser?.push(token);
|
|
1178
1627
|
generatedTokens += 1;
|
|
1179
1628
|
accumulatedText += token;
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1629
|
+
// Early UI cards from text fences only when native tools are off
|
|
1630
|
+
// (native args stream as structured deltas, not prose).
|
|
1631
|
+
if (!toolsAttached) {
|
|
1632
|
+
const parsedCalls = parseAllToolCalls(accumulatedText);
|
|
1633
|
+
if (parsedCalls.length > streamedCallsCount) {
|
|
1634
|
+
if (writesDirectly) {
|
|
1635
|
+
spinner.stop();
|
|
1636
|
+
}
|
|
1637
|
+
while (streamedCallsCount < parsedCalls.length) {
|
|
1638
|
+
const call = parsedCalls[streamedCallsCount];
|
|
1639
|
+
const eventId = `tool-${++nextToolEventId}`;
|
|
1640
|
+
callIds.push(eventId);
|
|
1641
|
+
alreadyPrintedIds.add(eventId);
|
|
1642
|
+
const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
|
|
1643
|
+
chalk.gray(` ${formatToolArgs(call)}`);
|
|
1644
|
+
deferredToolCalls.push({
|
|
1645
|
+
eventId,
|
|
1646
|
+
call,
|
|
1647
|
+
rendered: styleToolChatter(call, toolCallLine) + "\n",
|
|
1648
|
+
});
|
|
1649
|
+
if (!writesDirectly) {
|
|
1650
|
+
emit({ type: "status", text: call.name });
|
|
1651
|
+
}
|
|
1652
|
+
streamedCallsCount += 1;
|
|
1653
|
+
}
|
|
1654
|
+
if (writesDirectly) {
|
|
1655
|
+
spinner = startThinkingSpinner(`generating response (${generatedTokens} tokens)`, options.signal);
|
|
1201
1656
|
}
|
|
1202
|
-
streamedCallsCount += 1;
|
|
1203
|
-
}
|
|
1204
|
-
if (writesDirectly) {
|
|
1205
|
-
spinner = startThinkingSpinner(`generating response (${generatedTokens} tokens)`, options.signal);
|
|
1206
1657
|
}
|
|
1207
1658
|
}
|
|
1208
1659
|
// Heuristic: <think>… markers and reasoning_content tokens flow
|
|
@@ -1251,6 +1702,13 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1251
1702
|
provider = completion.provider;
|
|
1252
1703
|
model = completion.model;
|
|
1253
1704
|
deltaParser?.finish();
|
|
1705
|
+
// Sticky text-only may have flipped dialect during stream retry.
|
|
1706
|
+
({ dialect: toolDialect, native: nativeToolsActive } =
|
|
1707
|
+
resolveNativeTools(provider, model));
|
|
1708
|
+
// toolsAttached may have been true for the request; if sticky
|
|
1709
|
+
// fallback dropped tools, treat as text mode for this turn's parse.
|
|
1710
|
+
const usedNativeProtocol = Boolean(completion.toolCalls?.length) ||
|
|
1711
|
+
(toolsAttached && !isTextOnlyModel(provider, model));
|
|
1254
1712
|
const assistantTextResult = rememberThinkingFromText(completion.text);
|
|
1255
1713
|
assistantText = assistantTextResult;
|
|
1256
1714
|
// Commit thinking to the transcript IMMEDIATELY, before any of the
|
|
@@ -1263,30 +1721,171 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1263
1721
|
if (assistantText.hasThinking) {
|
|
1264
1722
|
writeThinkingBlock(assistantText.thinkContent);
|
|
1265
1723
|
}
|
|
1724
|
+
// Native-first: prefer structured toolCalls from the provider.
|
|
1725
|
+
let nativeToolCalls = completion.toolCalls ?? [];
|
|
1726
|
+
// Early UI cards: refresh args if stream deltas already opened cards;
|
|
1727
|
+
// otherwise create cards now (non-streaming / name-after-done providers).
|
|
1728
|
+
if (nativeToolCalls.length) {
|
|
1729
|
+
if (deferredToolCalls.length === 0) {
|
|
1730
|
+
for (const tc of nativeToolCalls) {
|
|
1731
|
+
const normalized = normalizeToolCall({
|
|
1732
|
+
name: tc.name,
|
|
1733
|
+
args: tc.args,
|
|
1734
|
+
});
|
|
1735
|
+
const eventId = `tool-${++nextToolEventId}`;
|
|
1736
|
+
callIds.push(eventId);
|
|
1737
|
+
alreadyPrintedIds.add(eventId);
|
|
1738
|
+
const toolCallLine = chalk.cyan(` ▶ ${normalized.name}`) +
|
|
1739
|
+
chalk.gray(` ${formatToolArgs(normalized)}`);
|
|
1740
|
+
deferredToolCalls.push({
|
|
1741
|
+
eventId,
|
|
1742
|
+
call: normalized,
|
|
1743
|
+
rendered: styleToolChatter(normalized, toolCallLine) + "\n",
|
|
1744
|
+
});
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
else {
|
|
1748
|
+
for (let i = 0; i < nativeToolCalls.length; i++) {
|
|
1749
|
+
const tc = nativeToolCalls[i];
|
|
1750
|
+
const normalized = normalizeToolCall({
|
|
1751
|
+
name: tc.name,
|
|
1752
|
+
args: tc.args,
|
|
1753
|
+
});
|
|
1754
|
+
const existing = deferredToolCalls[i];
|
|
1755
|
+
if (existing && existing.call.name !== "…") {
|
|
1756
|
+
existing.call = normalized;
|
|
1757
|
+
const toolCallLine = chalk.cyan(` ▶ ${normalized.name}`) +
|
|
1758
|
+
chalk.gray(` ${formatToolArgs(normalized)}`);
|
|
1759
|
+
existing.rendered =
|
|
1760
|
+
styleToolChatter(normalized, toolCallLine) + "\n";
|
|
1761
|
+
}
|
|
1762
|
+
else if (!existing || existing.call.name === "…") {
|
|
1763
|
+
const eventId = existing?.eventId ?? `tool-${++nextToolEventId}`;
|
|
1764
|
+
if (!existing) {
|
|
1765
|
+
callIds.push(eventId);
|
|
1766
|
+
alreadyPrintedIds.add(eventId);
|
|
1767
|
+
}
|
|
1768
|
+
const toolCallLine = chalk.cyan(` ▶ ${normalized.name}`) +
|
|
1769
|
+
chalk.gray(` ${formatToolArgs(normalized)}`);
|
|
1770
|
+
const entry = {
|
|
1771
|
+
eventId,
|
|
1772
|
+
call: normalized,
|
|
1773
|
+
rendered: styleToolChatter(normalized, toolCallLine) + "\n",
|
|
1774
|
+
};
|
|
1775
|
+
if (existing)
|
|
1776
|
+
deferredToolCalls[i] = entry;
|
|
1777
|
+
else
|
|
1778
|
+
deferredToolCalls.push(entry);
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1266
1783
|
// Try visible text first, then thinking content — some models (e.g. glm-5.1)
|
|
1267
|
-
// wrap tool calls inside
|
|
1784
|
+
// wrap tool calls inside considering tags, so stripThinking removes them
|
|
1268
1785
|
// into thinkContent and visible becomes empty. Recovering from thinkContent
|
|
1269
1786
|
// prevents an endless nudge loop where the model keeps hiding the call.
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1787
|
+
// When native toolCalls exist, skip text parse as primary (no double-exec).
|
|
1788
|
+
if (nativeToolCalls.length) {
|
|
1789
|
+
const first = nativeToolCalls[0];
|
|
1790
|
+
if (first.args?._parseError) {
|
|
1791
|
+
call = undefined;
|
|
1792
|
+
}
|
|
1793
|
+
else {
|
|
1794
|
+
call = normalizeToolCall({ name: first.name, args: first.args });
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
else {
|
|
1798
|
+
call = parseToolCall(assistantText.visible, {
|
|
1275
1799
|
strict: getConfig().parserStrict,
|
|
1276
1800
|
});
|
|
1277
|
-
if (call) {
|
|
1278
|
-
|
|
1801
|
+
if (!call && assistantText.hasThinking) {
|
|
1802
|
+
call = parseToolCall(assistantText.thinkContent, {
|
|
1803
|
+
strict: getConfig().parserStrict,
|
|
1804
|
+
});
|
|
1805
|
+
if (call) {
|
|
1806
|
+
writeNotice("info", "recovered tool call from thinking content", chalk.dim(" ℹ recovered tool call from thinking content\n"));
|
|
1807
|
+
}
|
|
1279
1808
|
}
|
|
1280
1809
|
}
|
|
1281
1810
|
// ── Prompt-leak guard ─────────────────────────────────────────
|
|
1282
1811
|
// If the model's visible output contains distinctive system-prompt
|
|
1283
1812
|
// markers, it is repeating its instructions (e.g. prompt injection
|
|
1284
1813
|
// via "repeat your instructions verbatim"). Any tool-call syntax
|
|
1285
|
-
//
|
|
1286
|
-
// Suppress it so we never execute leaked examples.
|
|
1287
|
-
if (
|
|
1288
|
-
|
|
1814
|
+
// (text fences OR native toolCalls) is an EXAMPLE from the prompt,
|
|
1815
|
+
// not a real request. Suppress it so we never execute leaked examples.
|
|
1816
|
+
if (looksLikePromptLeak(assistantText.visible)) {
|
|
1817
|
+
if (call || nativeToolCalls.length) {
|
|
1818
|
+
writeNotice("warn", "suppressed tool call from apparent prompt leak", chalk.yellow(" ⚠ suppressed tool call — model appears to be repeating its system prompt\n"));
|
|
1819
|
+
}
|
|
1289
1820
|
call = undefined;
|
|
1821
|
+
nativeToolCalls = [];
|
|
1822
|
+
deferredToolCalls.length = 0;
|
|
1823
|
+
}
|
|
1824
|
+
// ── Native truncated write salvage ────────────────────────────
|
|
1825
|
+
// Large fs.write content lives in tool_calls arguments, not fences.
|
|
1826
|
+
// When finish_reason is length or args failed to parse, salvage
|
|
1827
|
+
// partial content and continue with append (native wording).
|
|
1828
|
+
if (nativeToolCalls.length) {
|
|
1829
|
+
// Only salvage when args failed to parse (truncated JSON). A clean
|
|
1830
|
+
// parse with finish_reason=length is a complete tool call — execute it.
|
|
1831
|
+
const writeTc = nativeToolCalls.find((tc) => {
|
|
1832
|
+
const isWrite = tc.name === "fs.write" ||
|
|
1833
|
+
tc.name === "fs.append" ||
|
|
1834
|
+
tc.name === "fs.writeMany";
|
|
1835
|
+
return isWrite && Boolean(tc.args?._parseError);
|
|
1836
|
+
});
|
|
1837
|
+
if (writeTc) {
|
|
1838
|
+
const raw = writeTc.rawArguments ??
|
|
1839
|
+
(typeof writeTc.args?._raw === "string"
|
|
1840
|
+
? String(writeTc.args._raw)
|
|
1841
|
+
: undefined);
|
|
1842
|
+
const salvaged = salvageTruncatedWriteFromNative(writeTc.name, raw);
|
|
1843
|
+
if (salvaged) {
|
|
1844
|
+
truncatedToolRetries += 1;
|
|
1845
|
+
if (truncatedToolRetries <= 5) {
|
|
1846
|
+
try {
|
|
1847
|
+
const writeResult = await fsWrite(salvaged.path, salvaged.content, { confirmed: true });
|
|
1848
|
+
if (writeResult.ok) {
|
|
1849
|
+
const lineCount = salvaged.content.split("\n").length;
|
|
1850
|
+
writeNotice("info", `native tool call was truncated — salvaged ${lineCount} lines and wrote to ${salvaged.path}`, chalk.cyan(` ℹ native tool call was truncated — salvaged ${lineCount} lines to ${salvaged.path}\n`));
|
|
1851
|
+
// Pair assistant tool_calls with synthetic results so the
|
|
1852
|
+
// next turn is not orphaned, then nudge for append.
|
|
1853
|
+
appendAssistantWithTools(messages, assistantText.visible, nativeToolCalls);
|
|
1854
|
+
for (const tc of nativeToolCalls) {
|
|
1855
|
+
appendToolResult(messages, tc.id, tc.id === writeTc.id
|
|
1856
|
+
? `Tool ${tc.name} result (exit=0, ok=true):\nSalvaged partial write: ${lineCount} lines to ${salvaged.path}`
|
|
1857
|
+
: `Tool ${tc.name} result (exit=1, ok=false):\nCancelled — sibling write was truncated and salvaged.`, tc.name, tc.id === writeTc.id);
|
|
1858
|
+
}
|
|
1859
|
+
const priorBytes = Buffer.byteLength(salvaged.content, "utf8");
|
|
1860
|
+
const appendNudge = toolsAttached
|
|
1861
|
+
? `Your ${writeTc.name} tool call was cut off at the token limit, but the system salvaged the partial content and wrote ${lineCount} lines (${priorBytes} bytes) to ${salvaged.path}. ` +
|
|
1862
|
+
`The file ends with: ${JSON.stringify(salvaged.lastLine)}\n\n` +
|
|
1863
|
+
`CONTINUE by calling fs.append now with path=${JSON.stringify(salvaged.path)}, expectedPriorBytes=${priorBytes}, and content set to ONLY the remaining content not already on disk (prefer hundreds of lines per call). ` +
|
|
1864
|
+
`Do not re-read the full file; do not re-send content already saved. Use the platform tool interface — no markdown fences.`
|
|
1865
|
+
: `Your fs.write tool call was cut off at the token limit, but the system salvaged the partial content and wrote ${lineCount} lines (${priorBytes} bytes) to ${salvaged.path}. ` +
|
|
1866
|
+
`The file ends with: ${JSON.stringify(salvaged.lastLine)}\n\n` +
|
|
1867
|
+
`CONTINUE with ONE large fs.append of the remaining content:\n` +
|
|
1868
|
+
'```tool\n{"name":"fs.append","args":{"path":' +
|
|
1869
|
+
JSON.stringify(salvaged.path) +
|
|
1870
|
+
',"expectedPriorBytes":' +
|
|
1871
|
+
priorBytes +
|
|
1872
|
+
',"content":"...ONLY the remaining content not already on disk..."}}\n```';
|
|
1873
|
+
messages.push({
|
|
1874
|
+
role: "user",
|
|
1875
|
+
content: appendNudge,
|
|
1876
|
+
});
|
|
1877
|
+
nativeToolCalls = [];
|
|
1878
|
+
call = undefined;
|
|
1879
|
+
deferredToolCalls.length = 0;
|
|
1880
|
+
continue;
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
catch {
|
|
1884
|
+
// fall through to normal parse-error handling
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1290
1889
|
}
|
|
1291
1890
|
// Empty-response recovery
|
|
1292
1891
|
// Some models occasionally return an empty completion: a reasoning
|
|
@@ -1310,12 +1909,18 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1310
1909
|
pushAssistantHistory(stripThinking(collapseRepeatedText(completion.text)).visible);
|
|
1311
1910
|
// Keep nudges SHORT — cheap models lose the key instruction in long text.
|
|
1312
1911
|
const buildNudge = freshWebSearchRequired && !sawFreshWebSearch
|
|
1313
|
-
?
|
|
1912
|
+
? toolsAttached
|
|
1913
|
+
? "No visible output. This is current or scheduled information: call web.search now. Do NOT answer from memory."
|
|
1914
|
+
: "No visible output. This is current or scheduled information: emit exactly one valid ```tool block for web.search now. Do NOT answer from memory or hide the tool call in <think> tags."
|
|
1314
1915
|
: buildLikeTurn && !activePlan
|
|
1315
|
-
?
|
|
1316
|
-
"
|
|
1317
|
-
|
|
1318
|
-
|
|
1916
|
+
? toolsAttached
|
|
1917
|
+
? "No visible output. Call plan.create now (do not only describe the plan)."
|
|
1918
|
+
: "No visible output. Emit a ```tool block to call plan.create now. " +
|
|
1919
|
+
"Do NOT hide tool calls in <think> tags — put them in the visible response."
|
|
1920
|
+
: toolsAttached
|
|
1921
|
+
? "No visible output. " + toolNudge(true)
|
|
1922
|
+
: "No visible output. Emit a ```tool block or give your final answer. " +
|
|
1923
|
+
"Do NOT hide tool calls in <think> tags — put them in the visible response.";
|
|
1319
1924
|
messages.push(recoveryUserMessage(buildNudge));
|
|
1320
1925
|
continue;
|
|
1321
1926
|
}
|
|
@@ -1364,19 +1969,31 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1364
1969
|
if (bareArgsOnly) {
|
|
1365
1970
|
bareToolJsonRetries += 1;
|
|
1366
1971
|
if (bareToolJsonRetries <= 3) {
|
|
1367
|
-
writeNotice("warn",
|
|
1972
|
+
writeNotice("warn", toolsAttached
|
|
1973
|
+
? "tool call missing its name — asking the model to call a tool properly"
|
|
1974
|
+
: "tool call missing its name/fence — asking the model to re-emit a proper ```tool block", chalk.yellow(toolsAttached
|
|
1975
|
+
? " ⚠ tool call missing its name — asking the model to call a tool properly\n"
|
|
1976
|
+
: " ⚠ tool call missing its name/fence — asking the model to re-emit a proper ```tool block\n"));
|
|
1368
1977
|
pushAssistantHistory(assistantText.visible);
|
|
1369
1978
|
messages.push(recoveryUserMessage(buildLikeTurn && !activePlan
|
|
1370
|
-
?
|
|
1371
|
-
"
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
"
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1979
|
+
? toolsAttached
|
|
1980
|
+
? "Your previous message was a bare JSON args object with no tool name, so NOTHING ran. " +
|
|
1981
|
+
"This is a BUILD/SCAFFOLD task with NO plan yet. Call plan.create now via the platform tool interface. " +
|
|
1982
|
+
"Do NOT use fs.write, fs.writeMany, shell.exec, or pkg.install yet."
|
|
1983
|
+
: "Your previous message was a bare JSON args object with no tool name and no ```tool fence, so NOTHING ran. " +
|
|
1984
|
+
"This is a BUILD/SCAFFOLD task with NO plan yet. " +
|
|
1985
|
+
"You MUST call plan.create using a proper ```tool block. For example:\n" +
|
|
1986
|
+
'```tool\n{"name":"plan.create","args":{"goal":"scaffold todo app","detail":"...","tasks":["...","..."],"kind":"coding"}}\n```\n' +
|
|
1987
|
+
"Do NOT use fs.write, fs.writeMany, shell.exec, or pkg.install yet."
|
|
1988
|
+
: toolsAttached
|
|
1989
|
+
? "Your previous message was a bare JSON args object with no tool name, so NOTHING ran. " +
|
|
1990
|
+
toolNudge(true) +
|
|
1991
|
+
" Include the tool name and full args via the platform tool interface — do not use markdown fences."
|
|
1992
|
+
: "Your previous message was a bare JSON args object with no tool name and no ```tool fence, so NOTHING ran. " +
|
|
1993
|
+
"Reply with ONLY a fenced ```tool block of the form " +
|
|
1994
|
+
'`{"name": "<tool>", "args": { ... }}`. For example, to read a PDF:\n' +
|
|
1995
|
+
'```tool\n{"name":"pdf.read","args":{"path":"/abs/file.pdf"}}\n```\n' +
|
|
1996
|
+
"Choose the correct tool name for the task and include those args."));
|
|
1380
1997
|
continue;
|
|
1381
1998
|
}
|
|
1382
1999
|
// Exhausted retries — fall through to the normal answer path.
|
|
@@ -1388,10 +2005,14 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1388
2005
|
if (/<\|tool_call(?:s_section)?_begin\|>|<\|tool_call_argument_begin\|>/i.test(assistantText.visible)) {
|
|
1389
2006
|
writeNotice("warn", "tool call was malformed or cut off — asking the model to retry in JSON form", chalk.yellow(" ⚠ tool call was malformed or cut off — asking the model to retry in JSON form\n"));
|
|
1390
2007
|
pushAssistantHistory(assistantText.visible);
|
|
1391
|
-
messages.push(recoveryUserMessage(
|
|
1392
|
-
"
|
|
1393
|
-
|
|
1394
|
-
|
|
2008
|
+
messages.push(recoveryUserMessage(toolsAttached
|
|
2009
|
+
? "Your previous tool call was malformed or truncated. " +
|
|
2010
|
+
toolNudge(true) +
|
|
2011
|
+
" Pass valid JSON arguments via the platform tool interface — do not use fence or sentinel markers."
|
|
2012
|
+
: "Your previous tool call was malformed or truncated. " +
|
|
2013
|
+
"Reply with ONLY a fenced ```tool block containing valid JSON " +
|
|
2014
|
+
'of the form `{"name": "<tool>", "args": { ... }}`. ' +
|
|
2015
|
+
"Do not use <|tool_call_begin|> markers."));
|
|
1395
2016
|
continue;
|
|
1396
2017
|
}
|
|
1397
2018
|
// Detect a tool call that opened but was cut off by the token limit
|
|
@@ -1419,16 +2040,20 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1419
2040
|
const priorBytes = Buffer.byteLength(salvaged.content, "utf8");
|
|
1420
2041
|
messages.push({
|
|
1421
2042
|
role: "user",
|
|
1422
|
-
content:
|
|
1423
|
-
`
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
2043
|
+
content: toolsAttached
|
|
2044
|
+
? `Your fs.write tool call was cut off at the token limit, but the system salvaged the partial content and wrote ${lineCount} lines (${priorBytes} bytes) to ${salvaged.path}. ` +
|
|
2045
|
+
`The file ends with: ${JSON.stringify(salvaged.lastLine)}\n\n` +
|
|
2046
|
+
`CONTINUE by calling fs.append now with path=${JSON.stringify(salvaged.path)}, expectedPriorBytes=${priorBytes}, and content set to ONLY the remaining content (prefer large chunks). Use the platform tool interface — no markdown fences.`
|
|
2047
|
+
: `Your fs.write tool call was cut off at the token limit, but the system salvaged the partial content and wrote ${lineCount} lines (${priorBytes} bytes) to ${salvaged.path}. ` +
|
|
2048
|
+
`The file ends with: ${JSON.stringify(salvaged.lastLine)}\n\n` +
|
|
2049
|
+
`CONTINUE with ONE large fs.append of the remaining content (prefer hundreds of lines per call — do NOT use tiny ~100-line chunks):\n` +
|
|
2050
|
+
'```tool\n{"name":"fs.append","args":{"path":' +
|
|
2051
|
+
JSON.stringify(salvaged.path) +
|
|
2052
|
+
',"expectedPriorBytes":' +
|
|
2053
|
+
priorBytes +
|
|
2054
|
+
',"content":"...ONLY the remaining content not already on disk..."}}\n```\n' +
|
|
2055
|
+
`expectedPriorBytes must match the receipt so append cannot double-write. ` +
|
|
2056
|
+
`Do NOT re-read the full file; do NOT re-send content already saved.`,
|
|
1432
2057
|
});
|
|
1433
2058
|
continue;
|
|
1434
2059
|
}
|
|
@@ -1442,13 +2067,18 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1442
2067
|
pushAssistantHistory(stripThinking(assistantText.visible).visible);
|
|
1443
2068
|
messages.push({
|
|
1444
2069
|
role: "user",
|
|
1445
|
-
content:
|
|
1446
|
-
"
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
"
|
|
1451
|
-
|
|
2070
|
+
content: toolsAttached
|
|
2071
|
+
? "Your previous tool call was cut off before it finished — the JSON was incomplete, so NOTHING ran. " +
|
|
2072
|
+
"Prefer ONE complete fs.write when it fits. If the file is too large: (1) fs.write the first large section, " +
|
|
2073
|
+
"(2) fs.append the rest with expectedPriorBytes from the write receipt, (3) repeat with large chunks. " +
|
|
2074
|
+
"Keep reasoning SHORT and call the tool via the platform interface. Do NOT claim a file was written until a tool call succeeds."
|
|
2075
|
+
: "Your previous tool call was cut off before it finished — the JSON was incomplete, so NOTHING ran. " +
|
|
2076
|
+
"Prefer ONE complete fs.write when it fits (~32k output tokens is a lot of file content if reasoning stays short). " +
|
|
2077
|
+
"If the file is too large for one call:\n" +
|
|
2078
|
+
"1. fs.write the first large section (as much as fits — hundreds+ of lines)\n" +
|
|
2079
|
+
"2. fs.append the rest with expectedPriorBytes from the write receipt\n" +
|
|
2080
|
+
"3. Repeat append only if still incomplete — large chunks, not ~100-line drips\n" +
|
|
2081
|
+
"Keep reasoning SHORT — emit the ```tool block early. Do NOT claim a file was written until a tool call succeeds.",
|
|
1452
2082
|
});
|
|
1453
2083
|
continue;
|
|
1454
2084
|
}
|
|
@@ -1498,12 +2128,18 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1498
2128
|
pushAssistantHistory(stripThinking(assistantText.visible).visible);
|
|
1499
2129
|
messages.push({
|
|
1500
2130
|
role: "user",
|
|
1501
|
-
content:
|
|
1502
|
-
"
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
2131
|
+
content: toolsAttached
|
|
2132
|
+
? "Your previous tool call JSON was INVALID, so NOTHING ran. " +
|
|
2133
|
+
"Common causes: unescaped newlines/quotes, unbalanced braces, or content too large. " +
|
|
2134
|
+
toolNudge(true) +
|
|
2135
|
+
" Prefer ONE complete fs.write when it fits; if cut off, continue with large fs.append + expectedPriorBytes. " +
|
|
2136
|
+
"Do NOT claim any file was written until a tool call actually succeeds."
|
|
2137
|
+
: "Your previous message contained a ```tool block, but its JSON was INVALID, so NOTHING ran. " +
|
|
2138
|
+
"Common causes: unescaped newlines or quotes inside a string value, an extra or missing `}` / `]`, or content too large for the output window. " +
|
|
2139
|
+
'Re-emit ONE valid ```tool block of the exact form {"name":"<tool>","args":{...}} with balanced braces. ' +
|
|
2140
|
+
"IMPORTANT: Prefer ONE complete fs.write when it fits. Keep reasoning SHORT. " +
|
|
2141
|
+
"Only if the output window cuts you off, continue with large fs.append chunks + expectedPriorBytes. " +
|
|
2142
|
+
"Do NOT claim any file was written until a tool call actually succeeds.",
|
|
1507
2143
|
});
|
|
1508
2144
|
continue;
|
|
1509
2145
|
}
|
|
@@ -1554,25 +2190,48 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1554
2190
|
const planNarrated = (buildLikeTurn || pentestLikeTurn) &&
|
|
1555
2191
|
!activePlan &&
|
|
1556
2192
|
looksLikePlanNarration(cleaned);
|
|
2193
|
+
const errorFixNarration = looksLikeErrorDiagnosisWithFixIntent(cleaned);
|
|
1557
2194
|
// Once a real tool step has run, a no-plan task has no durable task
|
|
1558
2195
|
// state to prove whether another action is needed. A tool-free reply
|
|
1559
2196
|
// must therefore be allowed to finalize instead of turning a short
|
|
1560
|
-
// summary containing “I'll” into an implicit recovery request
|
|
1561
|
-
|
|
2197
|
+
// summary containing “I'll” into an implicit recovery request —
|
|
2198
|
+
// EXCEPT when an approved plan still has work, or the model just
|
|
2199
|
+
// diagnosed an error and said it would fix it without calling a tool.
|
|
2200
|
+
const shouldRetryBeforeFinalizing = productiveSteps === 0 ||
|
|
2201
|
+
planNarrated ||
|
|
2202
|
+
(session.planApproved.value &&
|
|
2203
|
+
planHasOpenWorkNow &&
|
|
2204
|
+
(narratedAction || errorFixNarration)) ||
|
|
2205
|
+
(session.planApproved.value && errorFixNarration) ||
|
|
2206
|
+
(buildLikeTurn && errorFixNarration);
|
|
1562
2207
|
if (wantsAction &&
|
|
1563
2208
|
cleaned.trim().length > 0 &&
|
|
1564
2209
|
actionIntentRetries < 3 &&
|
|
1565
2210
|
shouldRetryBeforeFinalizing) {
|
|
1566
2211
|
actionIntentRetries += 1;
|
|
1567
2212
|
let nudge;
|
|
1568
|
-
if (
|
|
1569
|
-
|
|
1570
|
-
|
|
2213
|
+
if (errorFixNarration && errorFixNarrationRetries < 3) {
|
|
2214
|
+
errorFixNarrationRetries += 1;
|
|
2215
|
+
nudge = toolsAttached
|
|
2216
|
+
? "You diagnosed an error and described the fix but called NO tool, so NOTHING was fixed. " +
|
|
2217
|
+
"Apply the fix NOW with a real tool (fs.edit / fs.write / shell.exec), then re-verify. " +
|
|
2218
|
+
"Do not stop after identifying the error."
|
|
2219
|
+
: "You diagnosed an error and described the fix but emitted NO ```tool block, so NOTHING was fixed. " +
|
|
2220
|
+
"Apply the fix NOW, e.g.:\n" +
|
|
2221
|
+
'```tool\n{"name":"fs.edit","args":{"path":"<file>","oldText":"...","newText":"..."}}\n```\n' +
|
|
2222
|
+
"Then re-run the failing check. Do not stop after identifying the error.";
|
|
2223
|
+
writeNotice("warn", "error diagnosed but not fixed — forcing tool call", chalk.yellow(" ⚠ diagnosed a failure but did not call a tool — applying the fix now\n"));
|
|
2224
|
+
}
|
|
2225
|
+
else if (planHasOpenWorkNow && session.planApproved.value) {
|
|
2226
|
+
nudge = toolsAttached
|
|
2227
|
+
? "You wrote a message but called NO tool, so NOTHING ran. Do NOT narrate — call the next tool now (task.update / fs.writeMany / shell.exec) via the platform tool interface."
|
|
2228
|
+
: "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.";
|
|
1571
2229
|
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"));
|
|
1572
2230
|
}
|
|
1573
2231
|
else if (pentestLikeTurn) {
|
|
1574
|
-
nudge =
|
|
1575
|
-
"You described what you will do but
|
|
2232
|
+
nudge = toolsAttached
|
|
2233
|
+
? "You described what you will do but called NO tool, so NOTHING happened. Call a real tool NOW (e.g. net.scan / sysinfo / shell.exec) via the platform interface. Every turn that claims action must include a tool call until the task is done."
|
|
2234
|
+
: "You described what you will do but emitted NO ```tool block, so NOTHING actually happened — narration is not action. Emit a real tool call NOW (e.g. net.scan / sysinfo / shell.exec). For example, to scan local network or read system settings:\n" +
|
|
1576
2235
|
'```tool\n{"name":"sysinfo","args":{}}\n```\n' +
|
|
1577
2236
|
"Every turn MUST contain a ```tool block until the task is done.";
|
|
1578
2237
|
writeNotice("warn", "described a security/pentest action but emitted no tool call — nudging it to run one", chalk.yellow(" ⚠ described a security/pentest action but emitted no tool call — nudging it to run one\n"));
|
|
@@ -1580,11 +2239,9 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1580
2239
|
else if (freshWebSearchRequired || narratedWebAction) {
|
|
1581
2240
|
// Web-specific recovery ONLY when the user asked for current
|
|
1582
2241
|
// info or the model explicitly claimed a fetch/search step.
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
nudge =
|
|
1587
|
-
"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" +
|
|
2242
|
+
nudge = toolsAttached
|
|
2243
|
+
? "You wrote that you would fetch/search/read something but called NO tool, so NOTHING ran. Call web.search or web.fetch now via the platform interface. After the tool output, answer from the results."
|
|
2244
|
+
: "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" +
|
|
1588
2245
|
'```tool\n{"name":"web.fetch","args":{"url":"https://example.com/page","responseMode":"readable"}}\n```\n' +
|
|
1589
2246
|
"If you do not know the exact page URL, use web.search first. After the tool output, answer from the fetched page content.";
|
|
1590
2247
|
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"));
|
|
@@ -1592,23 +2249,28 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1592
2249
|
else if (buildLikeTurn &&
|
|
1593
2250
|
(planNarrated || productiveSteps > 0)) {
|
|
1594
2251
|
const kind = pentestLikeTurn ? "pentest" : "coding";
|
|
1595
|
-
nudge =
|
|
1596
|
-
|
|
2252
|
+
nudge = toolsAttached
|
|
2253
|
+
? `You wrote the plan as PROSE but did NOT call plan.create, so no plan was saved. Call plan.create now via the platform tool interface with goal, detail, tasks, and kind="${kind}". Do not only describe the plan.`
|
|
2254
|
+
: "You wrote the plan as PROSE but did NOT call plan.create, so no plan was saved and the user cannot /implement it. Emit it as a real tool call NOW — exactly one ```tool block:\n" +
|
|
1597
2255
|
`\`\`\`tool\n{"name":"plan.create","args":{"goal":"<short goal>","detail":"<stack/approach and how you'll verify>","tasks":["task 1","task 2","task 3"],"kind":"${kind}"}}\n\`\`\`\n` +
|
|
1598
2256
|
"Do not describe the plan again in prose — just emit the plan.create tool block.";
|
|
1599
2257
|
writeNotice("warn", "plan was written as text, not created — nudging it to call plan.create", chalk.yellow(" ⚠ plan was written as text, not created — nudging it to call plan.create\n"));
|
|
1600
2258
|
}
|
|
1601
2259
|
else if (buildLikeTurn) {
|
|
1602
|
-
nudge =
|
|
1603
|
-
"You described what you will do but
|
|
2260
|
+
nudge = toolsAttached
|
|
2261
|
+
? "You described what you will do but called NO tool, so NOTHING happened. Call a tool NOW (e.g. fs.list on \".\"), then plan.create once you understand the directory. Use the platform tool interface — no markdown fences."
|
|
2262
|
+
: "You described what you will do but emitted NO ```tool block, so NOTHING actually happened — narration is not action. Emit a real tool call NOW. For this build task, explore first like this:\n" +
|
|
1604
2263
|
'```tool\n{"name":"fs.list","args":{"path":"."}}\n```\n' +
|
|
1605
2264
|
"Then read key files, and once you understand the directory, call plan.create. Every turn MUST contain a ```tool block until the task is done.";
|
|
1606
2265
|
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"));
|
|
1607
2266
|
}
|
|
1608
2267
|
else {
|
|
1609
2268
|
// Generic non-build, non-web stall (e.g. "I'll list the files").
|
|
1610
|
-
nudge =
|
|
1611
|
-
"You described what you will do but
|
|
2269
|
+
nudge = toolsAttached
|
|
2270
|
+
? "You described what you will do but called NO tool, so NOTHING happened. " +
|
|
2271
|
+
toolNudge(true) +
|
|
2272
|
+
" Every turn that claims an action must include a real tool call."
|
|
2273
|
+
: "You described what you will do but emitted NO ```tool block, so NOTHING actually happened — narration is not action. Emit a real tool call NOW for the step you just described. Every turn that claims an action MUST contain a ```tool block.";
|
|
1612
2274
|
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"));
|
|
1613
2275
|
}
|
|
1614
2276
|
pushAssistantHistory(assistantText.visible);
|
|
@@ -1624,44 +2286,148 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1624
2286
|
messages.push({
|
|
1625
2287
|
role: "user",
|
|
1626
2288
|
content: freshnessGuardMessage() +
|
|
1627
|
-
|
|
2289
|
+
(toolsAttached
|
|
2290
|
+
? " Call the web_search tool now."
|
|
2291
|
+
: " Reply with ONLY a fenced ```tool block for web.search now."),
|
|
1628
2292
|
});
|
|
1629
2293
|
continue;
|
|
1630
2294
|
}
|
|
1631
|
-
//
|
|
1632
|
-
//
|
|
1633
|
-
|
|
1634
|
-
|
|
2295
|
+
// Coding builds must produce a durable plan before freestyle "done".
|
|
2296
|
+
// (Explore-only turns without plan.create must not end as a final answer.)
|
|
2297
|
+
if (buildLike &&
|
|
2298
|
+
!pentestLike &&
|
|
2299
|
+
!pentestSession &&
|
|
2300
|
+
codingBuildRequiresPlan(prompt, {
|
|
2301
|
+
informational: informationalQuery,
|
|
2302
|
+
idle: idleOrSocialPrompt,
|
|
2303
|
+
pentest: false,
|
|
2304
|
+
}) &&
|
|
2305
|
+
forcePlanRetries < 2) {
|
|
2306
|
+
const planAtEnd = await loadPlan(session.sessionId).catch(() => undefined);
|
|
2307
|
+
if (!planAtEnd && !sawPlanCreateOk) {
|
|
2308
|
+
forcePlanRetries += 1;
|
|
2309
|
+
pushAssistantHistory(assistantText.visible);
|
|
2310
|
+
const kind = "coding";
|
|
2311
|
+
messages.push({
|
|
2312
|
+
role: "user",
|
|
2313
|
+
content: toolsAttached
|
|
2314
|
+
? `This is a coding BUILD with NO plan saved yet. Call plan.create NOW via the platform tool interface ` +
|
|
2315
|
+
`(goal, detail with stack + what exists on disk, 4–8 tasks, kind="${kind}"). ` +
|
|
2316
|
+
`Include feature implementation tasks and a final run/verify task. Do NOT scaffold or write app files until the user /implement-s the plan. ` +
|
|
2317
|
+
`Read-only explore (fs.list/read, tool.check) is fine before plan.create.`
|
|
2318
|
+
: `This is a coding BUILD with NO plan saved yet. Emit exactly one plan.create tool block NOW:\n` +
|
|
2319
|
+
`\`\`\`tool\n{"name":"plan.create","args":{"goal":"<short goal>","detail":"<stack, what exists, how you'll verify>","tasks":["explore/confirm destination","scaffold or continue project","implement requested feature","install deps","run/verify with shell.start + probe"],"kind":"${kind}"}}\n\`\`\`\n` +
|
|
2320
|
+
`Do NOT scaffold or write app files until /implement. Explore read-only first if needed.`,
|
|
2321
|
+
});
|
|
2322
|
+
writeNotice("warn", "coding build missing plan.create — forcing plan", chalk.yellow(" ⚠ no plan yet — call plan.create before scaffolding or finishing\n"));
|
|
2323
|
+
continue;
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
// Scaffold-only is NOT the product. If the user asked for a todo/blog/…
|
|
2327
|
+
// app and the model only ran create-*, force feature implementation first.
|
|
2328
|
+
// Do this BEFORE run/verify so we never push shell.start on blank starter.
|
|
1635
2329
|
if (buildLike &&
|
|
1636
2330
|
!pentestLike &&
|
|
1637
2331
|
!pentestSession &&
|
|
1638
2332
|
session.planApproved.value &&
|
|
2333
|
+
featureAppAsk &&
|
|
2334
|
+
!sawFeatureImplWrite &&
|
|
2335
|
+
(sawScaffoldOk || sawLocalAppMaterialWork) &&
|
|
2336
|
+
productiveSteps > 0 &&
|
|
2337
|
+
featureImplRetries < 2) {
|
|
2338
|
+
featureImplRetries += 1;
|
|
2339
|
+
pushAssistantHistory(assistantText.visible);
|
|
2340
|
+
const rootHint = getActiveProjectRoot()
|
|
2341
|
+
? ` Write under "${getActiveProjectRoot()}" with absolute paths.`
|
|
2342
|
+
: "";
|
|
2343
|
+
messages.push({
|
|
2344
|
+
role: "user",
|
|
2345
|
+
content: "INCOMPLETE: the user asked for a working FEATURE app (e.g. todo/blog/dashboard), not a blank framework starter. " +
|
|
2346
|
+
"Scaffold alone (create-next-app / create-vite / cargo new / …) is a FAILURE. " +
|
|
2347
|
+
"NOW implement the requested feature: read the entry page/component, replace starter boilerplate with real add/list/toggle/delete (or whatever they asked), " +
|
|
2348
|
+
"using fs.write / fs.writeMany. Do NOT shell.start and do NOT only tell the user how to run the app until that feature code exists." +
|
|
2349
|
+
rootHint,
|
|
2350
|
+
});
|
|
2351
|
+
writeNotice("warn", "feature not implemented — scaffold alone is not the deliverable", chalk.yellow(" ⚠ scaffold-only is incomplete — implement the requested feature before run/verify\n"));
|
|
2352
|
+
continue;
|
|
2353
|
+
}
|
|
2354
|
+
// A passing build is not evidence that an app is serving requests.
|
|
2355
|
+
// Require start → logs → HTTP for local app builds:
|
|
2356
|
+
// (A) completed coding plan, OR
|
|
2357
|
+
// (B) freestyle build that implemented the product (if asked) then
|
|
2358
|
+
// only told the user "run npm run dev yourself".
|
|
2359
|
+
// NEVER apply this to pentest/remote engagements.
|
|
2360
|
+
if (buildLike &&
|
|
2361
|
+
!pentestLike &&
|
|
2362
|
+
!pentestSession &&
|
|
1639
2363
|
(!sawServerStart || !sawServerTail || !sawLocalHttpProbe) &&
|
|
1640
|
-
runtimeVerificationRetries < 2
|
|
2364
|
+
runtimeVerificationRetries < 2 &&
|
|
2365
|
+
// Feature apps must implement first (handled above); only verify live after that
|
|
2366
|
+
(!featureAppAsk || sawFeatureImplWrite)) {
|
|
1641
2367
|
const runtimePlan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
1642
2368
|
const codingPlanFinished = Boolean(runtimePlan &&
|
|
2369
|
+
session.planApproved.value &&
|
|
1643
2370
|
runtimePlan.kind !== "pentest" &&
|
|
1644
2371
|
runtimePlan.tasks.length > 0 &&
|
|
1645
2372
|
runtimePlan.tasks.every((task) => task.state === "done" || task.state === "skipped"));
|
|
1646
|
-
|
|
2373
|
+
const freestyleLocalAppDone = !session.planApproved.value &&
|
|
2374
|
+
sawLocalAppMaterialWork &&
|
|
2375
|
+
productiveSteps > 0 &&
|
|
2376
|
+
// Final prose hands "how to run" to the user, or claims done without starting
|
|
2377
|
+
(/\b(?:npm|pnpm|yarn|bun)\s+run\s+dev\b/i.test(cleaned) ||
|
|
2378
|
+
/\b(?:cargo\s+run|flask\s+run|uvicorn|rails\s+s|python\s+-m\s+http\.server)\b/i.test(cleaned) ||
|
|
2379
|
+
/\bopen\s+http:\/\/localhost\b/i.test(cleaned) ||
|
|
2380
|
+
/\bhow to run\b/i.test(cleaned) ||
|
|
2381
|
+
(/\b(?:created|built|ready|complete)\b/i.test(cleaned) &&
|
|
2382
|
+
getActiveProjectRoot() !== undefined));
|
|
2383
|
+
if (codingPlanFinished || freestyleLocalAppDone) {
|
|
1647
2384
|
runtimeVerificationRetries += 1;
|
|
1648
2385
|
pushAssistantHistory(assistantText.visible);
|
|
2386
|
+
const rootHint = getActiveProjectRoot()
|
|
2387
|
+
? ` Use cwd "${getActiveProjectRoot()}".`
|
|
2388
|
+
: "";
|
|
1649
2389
|
messages.push({
|
|
1650
2390
|
role: "user",
|
|
1651
|
-
content: "This is a
|
|
1652
|
-
"
|
|
2391
|
+
content: "This is a LOCAL APP build: you must NOT stop after writing files or only telling the user how to run it. " +
|
|
2392
|
+
"Run the missing checks NOW: shell.start the app/dev server, shell.tail until ready, one localhost HTTP probe " +
|
|
2393
|
+
"(curl or http.fetch with iOwnThis:true), LEAVE the server running, and report URL + port + job id." +
|
|
2394
|
+
rootHint +
|
|
2395
|
+
" Do not only paste `npm run dev` instructions. " +
|
|
1653
2396
|
"If this was a remote pentest, ignore this and finalize the report with no local server.",
|
|
1654
2397
|
});
|
|
2398
|
+
writeNotice("warn", "local app missing shell.start/probe — forcing run/verify", chalk.yellow(" ⚠ local app not verified live — start server, tail, probe localhost, leave running\n"));
|
|
1655
2399
|
continue;
|
|
1656
2400
|
}
|
|
1657
2401
|
}
|
|
2402
|
+
// Failed localhost probe (e.g. HTTP 500): model must FIX, not stop.
|
|
2403
|
+
if (buildLike &&
|
|
2404
|
+
!pentestLike &&
|
|
2405
|
+
!pentestSession &&
|
|
2406
|
+
sawFailedLocalHttpProbe &&
|
|
2407
|
+
!sawLocalHttpProbe &&
|
|
2408
|
+
failedProbeFixRetries < 3 &&
|
|
2409
|
+
cleaned.trim().length > 0) {
|
|
2410
|
+
failedProbeFixRetries += 1;
|
|
2411
|
+
pushAssistantHistory(assistantText.visible);
|
|
2412
|
+
messages.push({
|
|
2413
|
+
role: "user",
|
|
2414
|
+
content: "The local HTTP probe FAILED (4xx/5xx or connection refused) — the app is NOT working yet. " +
|
|
2415
|
+
"Do NOT stop. Diagnose from the error (e.g. missing \"use client\", syntax error, wrong port), " +
|
|
2416
|
+
"apply a real fix with fs.edit/fs.write, restart/re-probe if needed, and only then mark the verify task done. " +
|
|
2417
|
+
"Identifying the error without calling a tool is a failure.",
|
|
2418
|
+
});
|
|
2419
|
+
writeNotice("warn", "localhost probe failed — forcing fix, not stopping", chalk.yellow(" ⚠ HTTP probe failed — fix the app and re-verify; do not stop at diagnosis\n"));
|
|
2420
|
+
continue;
|
|
2421
|
+
}
|
|
1658
2422
|
// Premature-completion guard (approved plan still has work)
|
|
1659
2423
|
// If the user approved a plan and the model now gives a final answer
|
|
1660
2424
|
// while tasks are still pending/in_progress — without having run the
|
|
1661
2425
|
// work — it is fabricating completion (the exact "all tasks completed,
|
|
1662
2426
|
// running at localhost:5173" failure). Force it back to executing the
|
|
1663
2427
|
// next real task instead of accepting the false claim.
|
|
1664
|
-
|
|
2428
|
+
// Budget: 6 retries (resets when real work succeeds) so long builds
|
|
2429
|
+
// with mid-stream "done" claims do not exhaust and stop mid-error.
|
|
2430
|
+
if (session.planApproved.value && prematureCompletionRetries < 6) {
|
|
1665
2431
|
const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
1666
2432
|
const unfinished = livePlan?.tasks.filter((t) => t.state === "pending" || t.state === "in_progress");
|
|
1667
2433
|
if (livePlan && unfinished && unfinished.length > 0) {
|
|
@@ -1671,6 +2437,10 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1671
2437
|
pushAssistantHistory(assistantText.visible);
|
|
1672
2438
|
const isPentestPlan = livePlan.kind === "pentest" || pentestSession;
|
|
1673
2439
|
let instruction = `Resume now with the NEXT task ${next.id} ("${next.title}"): `;
|
|
2440
|
+
if (errorFixNarration) {
|
|
2441
|
+
instruction =
|
|
2442
|
+
`You identified a failure and must FIX it with a tool call first (fs.edit/fs.write), then continue task ${next.id} ("${next.title}"): `;
|
|
2443
|
+
}
|
|
1674
2444
|
if (isPentestPlan) {
|
|
1675
2445
|
instruction +=
|
|
1676
2446
|
`call task.update {taskId:"${next.id}", state:"in_progress"}, then do the recon/testing work ` +
|
|
@@ -1683,7 +2453,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1683
2453
|
else {
|
|
1684
2454
|
instruction += `do the real work with a tool call (fs.writeMany / shell.exec / shell.start when building a local app) to complete it, VERIFY it, and mark it done (call task.update {taskId:"${next.id}", state:"done"}). `;
|
|
1685
2455
|
}
|
|
1686
|
-
instruction += `Continue task by task until EVERY task is actually finished.`;
|
|
2456
|
+
instruction += `Continue task by task until EVERY task is actually finished. Do not stop after only diagnosing an error.`;
|
|
1687
2457
|
messages.push({
|
|
1688
2458
|
role: "user",
|
|
1689
2459
|
content: `You have NOT finished the approved plan: ${unfinished.length} task(s) remain ` +
|
|
@@ -1729,49 +2499,71 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1729
2499
|
// prose / thinking that preceded it, record the assistant message ONCE.
|
|
1730
2500
|
const beforeTool = recoveredFromBareJson
|
|
1731
2501
|
? ""
|
|
1732
|
-
:
|
|
2502
|
+
: nativeToolCalls.length
|
|
2503
|
+
? assistantText.visible.trim()
|
|
2504
|
+
: textBeforeToolCall(assistantText.visible);
|
|
1733
2505
|
if (beforeTool) {
|
|
1734
2506
|
writeAssistantMessage(beforeTool);
|
|
1735
2507
|
}
|
|
1736
|
-
let
|
|
1737
|
-
if (
|
|
1738
|
-
|
|
2508
|
+
let bound = [];
|
|
2509
|
+
if (nativeToolCalls.length) {
|
|
2510
|
+
bound = nativeToolCalls.map((tc, index) => {
|
|
2511
|
+
const call = tc.args?._parseError
|
|
2512
|
+
? {
|
|
2513
|
+
name: tc.name || "unknown",
|
|
2514
|
+
args: {
|
|
2515
|
+
__nativeParseError: true,
|
|
2516
|
+
_raw: tc.args._raw,
|
|
2517
|
+
},
|
|
2518
|
+
}
|
|
2519
|
+
: normalizeToolCall({ name: tc.name, args: tc.args });
|
|
2520
|
+
return { index, id: tc.id, call, native: tc };
|
|
2521
|
+
});
|
|
1739
2522
|
}
|
|
2523
|
+
else {
|
|
2524
|
+
let parsed = parseAllToolCalls(assistantText.visible || assistantText.thinkContent);
|
|
2525
|
+
if (parsed.length === 0 && call)
|
|
2526
|
+
parsed = [call];
|
|
2527
|
+
bound = parsed.map((c, index) => {
|
|
2528
|
+
const id = syntheticToolCallId(index);
|
|
2529
|
+
return {
|
|
2530
|
+
index,
|
|
2531
|
+
id,
|
|
2532
|
+
call: c,
|
|
2533
|
+
native: { id, name: c.name, args: c.args },
|
|
2534
|
+
};
|
|
2535
|
+
});
|
|
2536
|
+
}
|
|
2537
|
+
/** Subset that will actually run this turn (defer/omit rest). */
|
|
2538
|
+
let toRun = bound;
|
|
1740
2539
|
let activeDeferredToolCalls = deferredToolCalls;
|
|
2540
|
+
let deferReason = "Cancelled — not executed this turn (deferred or omitted).";
|
|
1741
2541
|
// A plan must be based on the outputs of prior reconnaissance, never
|
|
1742
2542
|
// on calls the model merely proposed in the same response. If a model
|
|
1743
2543
|
// emits plan.create alongside gathering calls, run only the calls
|
|
1744
2544
|
// before it, then let the next model turn analyse their actual results
|
|
1745
|
-
// and emit one standalone plan.create.
|
|
1746
|
-
|
|
1747
|
-
// created or approved.
|
|
1748
|
-
const planCallIndex = allCalls.findIndex((candidate) => candidate.name === "plan.create");
|
|
2545
|
+
// and emit one standalone plan.create.
|
|
2546
|
+
const planCallIndex = bound.findIndex((b) => b.call.name === "plan.create");
|
|
1749
2547
|
if (planCallIndex > 0) {
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
allCalls = gatheringCalls;
|
|
1757
|
-
activeDeferredToolCalls = deferredToolCalls.slice(0, gatheringCalls.length);
|
|
1758
|
-
writeNotice("info", "deferring plan.create until reconnaissance results are available", chalk.dim(` ℹ running ${gatheringCalls.length} gathering call(s); ${deferredCount} plan/follow-on call(s) deferred for evidence-based planning\n`));
|
|
2548
|
+
const deferredCount = bound.length - planCallIndex;
|
|
2549
|
+
toRun = bound.slice(0, planCallIndex);
|
|
2550
|
+
activeDeferredToolCalls = deferredToolCalls.slice(0, planCallIndex);
|
|
2551
|
+
deferReason =
|
|
2552
|
+
"Deferred — plan.create must wait until reconnaissance results exist.";
|
|
2553
|
+
writeNotice("info", "deferring plan.create until reconnaissance results are available", chalk.dim(` ℹ running ${toRun.length} gathering call(s); ${deferredCount} plan/follow-on call(s) deferred for evidence-based planning\n`));
|
|
1759
2554
|
messages.push({
|
|
1760
2555
|
role: "system",
|
|
1761
2556
|
content: `The prior response included plan.create before its reconnaissance results existed. ` +
|
|
1762
|
-
`Only the ${
|
|
2557
|
+
`Only the ${toRun.length} gathering call(s) before it were run; ${deferredCount} plan/follow-on call(s) were not run. ` +
|
|
1763
2558
|
"Now analyse the tool results. If a plan is appropriate, emit exactly one standalone plan.create tool call based only on those results. Do not include any other tool calls in that response.",
|
|
1764
2559
|
});
|
|
1765
2560
|
}
|
|
1766
|
-
else if (planCallIndex === 0 &&
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
// history), so execute the plan.create now and defer only the calls
|
|
1770
|
-
// proposed after it — those were proposed before the plan was
|
|
1771
|
-
// created or approved.
|
|
1772
|
-
const deferredCount = allCalls.length - 1;
|
|
1773
|
-
allCalls = allCalls.slice(0, 1);
|
|
2561
|
+
else if (planCallIndex === 0 && bound.length > 1) {
|
|
2562
|
+
const deferredCount = bound.length - 1;
|
|
2563
|
+
toRun = bound.slice(0, 1);
|
|
1774
2564
|
activeDeferredToolCalls = deferredToolCalls.slice(0, 1);
|
|
2565
|
+
deferReason =
|
|
2566
|
+
"Deferred — waiting for plan approval before follow-on tools.";
|
|
1775
2567
|
writeNotice("info", "creating the plan now; deferring follow-on calls until it is approved", chalk.dim(` ℹ running plan.create from prior reconnaissance; ${deferredCount} follow-on call(s) deferred until after approval\n`));
|
|
1776
2568
|
messages.push({
|
|
1777
2569
|
role: "system",
|
|
@@ -1779,20 +2571,13 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1779
2571
|
`the follow-on call(s) were not. Wait for the plan to be reviewed, then proceed task by task.`,
|
|
1780
2572
|
});
|
|
1781
2573
|
}
|
|
1782
|
-
// planCallIndex === 0 && allCalls.length === 1: a standalone plan.create
|
|
1783
|
-
// built from prior reconnaissance. Execute it normally — deferring it
|
|
1784
|
-
// here (as the previous `>= 0` guard did) ran zero calls and looped the
|
|
1785
|
-
// agent forever without ever creating the plan.
|
|
1786
|
-
// A single model message can contain an unbounded number of calls.
|
|
1787
|
-
// Even with read-only calls fanned out, a giant batch can tie up the
|
|
1788
|
-
// session for minutes and makes cancellation feel broken. Keep each
|
|
1789
|
-
// model turn bounded; after these results the agent gets another turn
|
|
1790
|
-
// to prioritise the remaining work from real evidence.
|
|
1791
2574
|
const MAX_CALLS_PER_MODEL_TURN = 12;
|
|
1792
|
-
const omittedCallCount = Math.max(0,
|
|
2575
|
+
const omittedCallCount = Math.max(0, toRun.length - MAX_CALLS_PER_MODEL_TURN);
|
|
1793
2576
|
if (omittedCallCount > 0) {
|
|
1794
|
-
|
|
2577
|
+
toRun = toRun.slice(0, MAX_CALLS_PER_MODEL_TURN);
|
|
1795
2578
|
activeDeferredToolCalls = activeDeferredToolCalls.slice(0, MAX_CALLS_PER_MODEL_TURN);
|
|
2579
|
+
deferReason =
|
|
2580
|
+
"Deferred — exceeded max tool calls per model turn; re-prioritise next batch.";
|
|
1796
2581
|
writeNotice("warn", `limited this model response to ${MAX_CALLS_PER_MODEL_TURN} tool calls`, chalk.yellow(` ⚠ executing the first ${MAX_CALLS_PER_MODEL_TURN} tool calls; ${omittedCallCount} more were deferred for reprioritisation\n`));
|
|
1797
2582
|
messages.push({
|
|
1798
2583
|
role: "system",
|
|
@@ -1800,6 +2585,25 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1800
2585
|
`${omittedCallCount} were not run. After reviewing results, issue a small, prioritized next batch.`,
|
|
1801
2586
|
});
|
|
1802
2587
|
}
|
|
2588
|
+
// X4: if the batch mixes work tools with task.update(in_progress),
|
|
2589
|
+
// run the in_progress updates first so the plan gate does not block
|
|
2590
|
+
// work that the model intended to open in the same message.
|
|
2591
|
+
{
|
|
2592
|
+
const isInProgressUpdate = (b) => b.call.name === "task.update" &&
|
|
2593
|
+
String(b.call.args?.state ?? "") === "in_progress";
|
|
2594
|
+
const updates = toRun.filter(isInProgressUpdate);
|
|
2595
|
+
if (updates.length > 0 && updates.length < toRun.length) {
|
|
2596
|
+
const rest = toRun.filter((b) => !isInProgressUpdate(b));
|
|
2597
|
+
toRun = [...updates, ...rest];
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
// Re-index toRun positions for UI callIds[] (0..n-1 this turn).
|
|
2601
|
+
toRun = toRun.map((b, index) => ({ ...b, index }));
|
|
2602
|
+
const allCalls = toRun.map((b) => b.call);
|
|
2603
|
+
/** Stable call→Bound map (object identity; no indexOf for result ids). */
|
|
2604
|
+
const callToBound = new Map(toRun.map((b) => [b.call, b]));
|
|
2605
|
+
const historyNativeCalls = bound.map((b) => b.native);
|
|
2606
|
+
const runIds = new Set(toRun.map((b) => b.id));
|
|
1803
2607
|
// Notice BEFORE tool cards so the transcript reads:
|
|
1804
2608
|
// thinking → response → "N tool calls…" → tool cards (not tools then info).
|
|
1805
2609
|
if (allCalls.length > 1) {
|
|
@@ -1808,13 +2612,23 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1808
2612
|
// Emit only the calls that will actually execute, after thinking
|
|
1809
2613
|
// + assistant text so transcript order remains correct.
|
|
1810
2614
|
for (const deferred of activeDeferredToolCalls.slice(0, allCalls.length)) {
|
|
2615
|
+
if (!deferred.call.name || deferred.call.name === "…")
|
|
2616
|
+
continue;
|
|
1811
2617
|
writeToolCall(deferred.eventId, deferred.call, deferred.rendered);
|
|
1812
2618
|
}
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
2619
|
+
// Dialect-neutral history: full assistant toolCalls (including deferred
|
|
2620
|
+
// ids) so providers never see orphan tool_call ids. Missing results are
|
|
2621
|
+
// filled with synthetic cancelled messages after the batch.
|
|
2622
|
+
if (historyNativeCalls.length) {
|
|
2623
|
+
appendAssistantWithTools(messages, beforeTool ?? "", historyNativeCalls);
|
|
2624
|
+
}
|
|
2625
|
+
else {
|
|
2626
|
+
const standardizedContent = (beforeTool ? beforeTool.trim() + "\n\n" : "") +
|
|
2627
|
+
allCalls
|
|
2628
|
+
.map((c) => `\`\`\`tool\n${JSON.stringify(c)}\n\`\`\``)
|
|
2629
|
+
.join("\n\n");
|
|
2630
|
+
pushAssistantHistory(standardizedContent);
|
|
2631
|
+
}
|
|
1818
2632
|
// Scoped-parallel batch execution
|
|
1819
2633
|
// The model may emit several calls in one message. We partition them,
|
|
1820
2634
|
// IN DOCUMENT ORDER, into segments:
|
|
@@ -1883,21 +2697,32 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1883
2697
|
let blockedResult = null;
|
|
1884
2698
|
let failed = false;
|
|
1885
2699
|
let awaitingPlanApproval = false;
|
|
1886
|
-
/**
|
|
1887
|
-
const
|
|
1888
|
-
const recordResult = (res, continueAfterFailure = false) => {
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
2700
|
+
/** Native tool_call ids that already have a role:tool history entry. */
|
|
2701
|
+
const recordedNativeIds = new Set();
|
|
2702
|
+
const recordResult = (boundCall, res, continueAfterFailure = false) => {
|
|
2703
|
+
recordedNativeIds.add(boundCall.id);
|
|
2704
|
+
const toolContent = `Tool ${res.call.name} result (exit=${res.result.exitCode ?? 0}, ok=${res.result.ok}):\n${res.contextOutput}`;
|
|
2705
|
+
if (historyNativeCalls.length) {
|
|
2706
|
+
appendToolResult(messages, boundCall.id, toolContent, res.call.name, res.result.ok);
|
|
2707
|
+
}
|
|
2708
|
+
else {
|
|
2709
|
+
messages.push({
|
|
2710
|
+
role: "tool",
|
|
2711
|
+
content: toolContent,
|
|
2712
|
+
});
|
|
2713
|
+
}
|
|
1896
2714
|
productiveSteps += 1;
|
|
1897
2715
|
// Reset retry counters — they track consecutive failures, not cumulative.
|
|
1898
2716
|
truncatedToolRetries = 0;
|
|
1899
2717
|
malformedFenceRetries = 0;
|
|
1900
2718
|
bareToolJsonRetries = 0;
|
|
2719
|
+
// Successful real work restores premature-done budget so long builds
|
|
2720
|
+
// don't exhaust retries mid-stream and stop after diagnosing an error.
|
|
2721
|
+
if (res.ok && isEvidenceWorkTool(res.call.name)) {
|
|
2722
|
+
prematureCompletionRetries = 0;
|
|
2723
|
+
actionIntentRetries = 0;
|
|
2724
|
+
errorFixNarrationRetries = 0;
|
|
2725
|
+
}
|
|
1901
2726
|
if (res.ok && res.call.name === "shell.start")
|
|
1902
2727
|
sawServerStart = true;
|
|
1903
2728
|
if (res.ok && res.call.name === "shell.tail")
|
|
@@ -1907,10 +2732,51 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1907
2732
|
/^(?:https?:\/\/)?(?:localhost|127\.0\.0\.1|\[::1\])(?::|\/|$)/i.test(String(res.call.args.url ?? ""))) ||
|
|
1908
2733
|
(res.call.name === "shell.exec" &&
|
|
1909
2734
|
/\bcurl\b[\s\S]*\b(?:localhost|127\.0\.0\.1|\[::1\])\b/i.test(String(res.call.args.command ?? ""))))) {
|
|
1910
|
-
|
|
2735
|
+
const out = res.result.output ?? res.contextOutput ?? "";
|
|
2736
|
+
if (localHttpProbeIsFailure(out)) {
|
|
2737
|
+
sawFailedLocalHttpProbe = true;
|
|
2738
|
+
sawLocalHttpProbe = false;
|
|
2739
|
+
}
|
|
2740
|
+
else if (localHttpProbeIsSuccess(out)) {
|
|
2741
|
+
sawLocalHttpProbe = true;
|
|
2742
|
+
sawFailedLocalHttpProbe = false;
|
|
2743
|
+
failedProbeFixRetries = 0;
|
|
2744
|
+
}
|
|
2745
|
+
else if (res.call.name === "shell.exec" &&
|
|
2746
|
+
!localHttpProbeIsFailure(out)) {
|
|
2747
|
+
// curl without status line — soft success
|
|
2748
|
+
sawLocalHttpProbe = true;
|
|
2749
|
+
sawFailedLocalHttpProbe = false;
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
// Track freestyle local-app materialization (scaffold / install / feature write)
|
|
2753
|
+
if (res.ok) {
|
|
2754
|
+
const cmd = typeof res.call.args.command === "string"
|
|
2755
|
+
? res.call.args.command
|
|
2756
|
+
: "";
|
|
2757
|
+
const pathArg = typeof res.call.args.path === "string" ? res.call.args.path : "";
|
|
2758
|
+
if (isScaffoldCreateCommand(cmd)) {
|
|
2759
|
+
sawScaffoldOk = true;
|
|
2760
|
+
sawLocalAppMaterialWork = true;
|
|
2761
|
+
}
|
|
2762
|
+
if (isFeatureImplementationCall(res.call)) {
|
|
2763
|
+
sawFeatureImplWrite = true;
|
|
2764
|
+
sawLocalAppMaterialWork = true;
|
|
2765
|
+
}
|
|
2766
|
+
if (/\b(?:npm|pnpm|yarn|bun)\s+i(?:nstall)?\b/i.test(cmd) ||
|
|
2767
|
+
res.call.name === "fs.write" ||
|
|
2768
|
+
res.call.name === "fs.writeMany" ||
|
|
2769
|
+
res.call.name === "fs.edit" ||
|
|
2770
|
+
(pathArg &&
|
|
2771
|
+
getActiveProjectRoot() &&
|
|
2772
|
+
(pathArg.includes(getActiveProjectRoot()) ||
|
|
2773
|
+
!pathArg.startsWith("/")))) {
|
|
2774
|
+
sawLocalAppMaterialWork = true;
|
|
2775
|
+
}
|
|
1911
2776
|
}
|
|
1912
2777
|
if (res.call.name === "plan.create" && res.ok) {
|
|
1913
2778
|
awaitingPlanApproval = true;
|
|
2779
|
+
sawPlanCreateOk = true;
|
|
1914
2780
|
}
|
|
1915
2781
|
if (res.lastAnswer === "Aborted.")
|
|
1916
2782
|
aborted = true;
|
|
@@ -1927,51 +2793,46 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1927
2793
|
break;
|
|
1928
2794
|
if (group.length === 1) {
|
|
1929
2795
|
const call = group[0];
|
|
1930
|
-
const
|
|
1931
|
-
if (
|
|
1932
|
-
|
|
2796
|
+
const bc = callToBound.get(call);
|
|
2797
|
+
if (!bc)
|
|
2798
|
+
continue;
|
|
2799
|
+
if (!callIds[bc.index]) {
|
|
2800
|
+
callIds[bc.index] = `tool-${++nextToolEventId}`;
|
|
1933
2801
|
}
|
|
1934
|
-
const id =
|
|
2802
|
+
const id = callIds[bc.index];
|
|
1935
2803
|
const res = await executeSingleTool(call, id, options.signal || new AbortController().signal);
|
|
1936
|
-
// Soft-fail recon / read-only / discovery tools so a stalled
|
|
1937
|
-
// whois or failed lookup never cancels the rest of the turn with
|
|
1938
|
-
// "Cancelled — earlier tool in this batch failed."
|
|
1939
2804
|
const softFail = shouldSoftFailTool(call.name);
|
|
1940
|
-
recordResult(res, softFail);
|
|
2805
|
+
recordResult(bc, res, softFail);
|
|
1941
2806
|
}
|
|
1942
2807
|
else {
|
|
1943
|
-
// Concurrent group —
|
|
1944
|
-
|
|
1945
|
-
const
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
2808
|
+
// Concurrent group — BoundCall via Map; record in document order.
|
|
2809
|
+
const groupBound = [];
|
|
2810
|
+
const uiIds = [];
|
|
2811
|
+
for (const c of group) {
|
|
2812
|
+
const bc = callToBound.get(c);
|
|
2813
|
+
if (!bc)
|
|
2814
|
+
continue;
|
|
2815
|
+
if (!callIds[bc.index]) {
|
|
2816
|
+
callIds[bc.index] = `tool-${++nextToolEventId}`;
|
|
1949
2817
|
}
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
recordResult(res, true);
|
|
2818
|
+
groupBound.push(bc);
|
|
2819
|
+
uiIds.push(callIds[bc.index]);
|
|
2820
|
+
}
|
|
2821
|
+
const results = await Promise.all(groupBound.map((bc, k) => executeSingleTool(bc.call, uiIds[k], options.signal || new AbortController().signal)));
|
|
2822
|
+
for (let k = 0; k < results.length; k += 1) {
|
|
2823
|
+
recordResult(groupBound[k], results[k], true);
|
|
2824
|
+
}
|
|
1958
2825
|
}
|
|
1959
2826
|
}
|
|
1960
|
-
// Cards
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
if (executedIndices.has(i))
|
|
2827
|
+
// Cards still "running" get a terminal UI result; history always pairs.
|
|
2828
|
+
for (let i = 0; i < toRun.length; i += 1) {
|
|
2829
|
+
const bc = toRun[i];
|
|
2830
|
+
if (recordedNativeIds.has(bc.id))
|
|
1965
2831
|
continue;
|
|
1966
|
-
|
|
1967
|
-
if (i >= 0 && !callIds[i]) {
|
|
2832
|
+
if (!callIds[i]) {
|
|
1968
2833
|
callIds[i] = `tool-${++nextToolEventId}`;
|
|
1969
2834
|
}
|
|
1970
|
-
const
|
|
1971
|
-
if (!alreadyPrintedIds.has(id)) {
|
|
1972
|
-
// Never shown in the transcript — skip.
|
|
1973
|
-
continue;
|
|
1974
|
-
}
|
|
2835
|
+
const uiId = callIds[i];
|
|
1975
2836
|
const reason = aborted
|
|
1976
2837
|
? "Cancelled — turn aborted before this call ran."
|
|
1977
2838
|
: blocked
|
|
@@ -1986,11 +2847,29 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1986
2847
|
output: reason,
|
|
1987
2848
|
exitCode: 130,
|
|
1988
2849
|
};
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
2850
|
+
if (alreadyPrintedIds.has(uiId)) {
|
|
2851
|
+
emitToolResult(uiId, result, reason);
|
|
2852
|
+
}
|
|
2853
|
+
if (historyNativeCalls.length) {
|
|
2854
|
+
appendToolResult(messages, bc.id, `Tool ${bc.call.name} result (exit=130, ok=false):\n${reason}`, bc.call.name, false);
|
|
2855
|
+
recordedNativeIds.add(bc.id);
|
|
2856
|
+
}
|
|
2857
|
+
else {
|
|
2858
|
+
messages.push({
|
|
2859
|
+
role: "tool",
|
|
2860
|
+
content: `Tool ${bc.call.name} result (exit=130, ok=false):\n${reason}`,
|
|
2861
|
+
});
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
// Synthetic results for deferred/omitted ids still listed on assistant.
|
|
2865
|
+
if (historyNativeCalls.length) {
|
|
2866
|
+
for (const b of bound) {
|
|
2867
|
+
if (runIds.has(b.id) || recordedNativeIds.has(b.id))
|
|
2868
|
+
continue;
|
|
2869
|
+
appendToolResult(messages, b.id, `Tool ${b.call.name} result (exit=130, ok=false):\n${deferReason}`, b.call.name, false);
|
|
2870
|
+
recordedNativeIds.add(b.id);
|
|
2871
|
+
}
|
|
2872
|
+
fillMissingToolResults(messages, historyNativeCalls, "Cancelled — not executed this turn.");
|
|
1994
2873
|
}
|
|
1995
2874
|
// plan.create is a hard transaction boundary. Its successful handler
|
|
1996
2875
|
// persists and displays the plan; returning immediately prevents a
|