@pentoshi/clai 3.0.0 → 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/classic-renderer.js +9 -1
- package/dist/agent/classic-renderer.js.map +1 -1
- package/dist/agent/context-manager.js +8 -2
- package/dist/agent/context-manager.js.map +1 -1
- package/dist/agent/events.d.ts +5 -0
- 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 +434 -49
- 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 +1204 -253
- 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 +21 -0
- package/dist/agent/tool-call-parser.js +101 -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/adapters/agent-event-adapter.js +5 -0
- package/dist/app/adapters/agent-event-adapter.js.map +1 -1
- package/dist/app/controllers/session-controller.js +4 -0
- package/dist/app/controllers/session-controller.js.map +1 -1
- package/dist/app/events/app-event.d.ts +4 -0
- package/dist/app/events/app-event.js.map +1 -1
- package/dist/commands/doctor.js +14 -1
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/update.js +1 -1
- package/dist/index.js +40 -8
- package/dist/index.js.map +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 +122 -23
- 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.d.ts +6 -0
- package/dist/tools/fs.js +68 -9
- 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 +122 -33
- 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/runtime.d.ts +19 -0
- package/dist/tui/runtime.js +92 -0
- package/dist/tui/runtime.js.map +1 -0
- package/dist/tui/state.js +2 -0
- package/dist/tui/state.js.map +1 -1
- package/dist/tui-v2/app/App.d.ts +3 -3
- package/dist/tui-v2/app/App.js +22 -10
- package/dist/tui-v2/app/App.js.map +1 -1
- package/dist/tui-v2/app/plan-lifecycle.d.ts +6 -0
- package/dist/tui-v2/app/plan-lifecycle.js +43 -7
- package/dist/tui-v2/app/plan-lifecycle.js.map +1 -1
- package/dist/tui-v2/components/modal/confirm-modal.d.ts +5 -6
- package/dist/tui-v2/components/modal/confirm-modal.js +60 -21
- package/dist/tui-v2/components/modal/confirm-modal.js.map +1 -1
- package/dist/tui-v2/components/modal/secret-modal.d.ts +3 -9
- package/dist/tui-v2/components/modal/secret-modal.js +17 -17
- package/dist/tui-v2/components/modal/secret-modal.js.map +1 -1
- package/dist/tui-v2/components/overlay/overlay-host.d.ts +12 -3
- package/dist/tui-v2/components/overlay/overlay-host.js +19 -4
- package/dist/tui-v2/components/overlay/overlay-host.js.map +1 -1
- package/dist/tui-v2/components/plan/plan-view.d.ts +1 -1
- package/dist/tui-v2/components/plan/plan-view.js +8 -4
- package/dist/tui-v2/components/plan/plan-view.js.map +1 -1
- package/dist/tui-v2/components/status/status-line.js +10 -1
- package/dist/tui-v2/components/status/status-line.js.map +1 -1
- package/dist/tui-v2/components/transcript/assistant-message.js +6 -0
- package/dist/tui-v2/components/transcript/assistant-message.js.map +1 -1
- package/dist/tui-v2/components/transcript/compacted-row.d.ts +5 -5
- package/dist/tui-v2/components/transcript/compacted-row.js +15 -40
- package/dist/tui-v2/components/transcript/compacted-row.js.map +1 -1
- package/dist/tui-v2/components/transcript/tool-card.js +43 -13
- package/dist/tui-v2/components/transcript/tool-card.js.map +1 -1
- package/dist/tui-v2/components/transcript/transcript-row.js +8 -1
- package/dist/tui-v2/components/transcript/transcript-row.js.map +1 -1
- package/dist/tui-v2/rendering/batch-sections.d.ts +12 -0
- package/dist/tui-v2/rendering/batch-sections.js +68 -0
- package/dist/tui-v2/rendering/batch-sections.js.map +1 -1
- package/dist/tui-v2/rendering/strip-tool-surfaces.d.ts +10 -0
- package/dist/tui-v2/rendering/strip-tool-surfaces.js +32 -0
- package/dist/tui-v2/rendering/strip-tool-surfaces.js.map +1 -0
- package/dist/tui-v2/rendering/tool-presenter.d.ts +6 -1
- package/dist/tui-v2/rendering/tool-presenter.js +68 -2
- package/dist/tui-v2/rendering/tool-presenter.js.map +1 -1
- package/dist/tui-v2/state/transcript-hydrate.js +6 -2
- package/dist/tui-v2/state/transcript-hydrate.js.map +1 -1
- package/dist/tui-v2/state/transcript-reducer.js +74 -16
- package/dist/tui-v2/state/transcript-reducer.js.map +1 -1
- package/dist/tui-v2/state/transcript-types.d.ts +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 +13 -4
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, 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
|
|
@@ -129,8 +137,16 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
129
137
|
};
|
|
130
138
|
const writeStatus = (text, rendered = chalk.dim(text)) => {
|
|
131
139
|
// Footer activity is single-line; strip classic stdout newlines/indents.
|
|
132
|
-
|
|
133
|
-
|
|
140
|
+
// Never surface /output path hints as activity (garbles the status bar).
|
|
141
|
+
let cleaned = text.replace(/\s+/g, " ").trim();
|
|
142
|
+
if (/\/output\b|open full output|Ctrl\+O or|\.clai\/outputs/i.test(cleaned)) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (cleaned.length > 64) {
|
|
146
|
+
const short = cleaned.match(/^[\w./-]+/);
|
|
147
|
+
cleaned = short ? short[0] : cleaned.slice(0, 61) + "…";
|
|
148
|
+
}
|
|
149
|
+
emit({ type: "status", text: cleaned || "working" });
|
|
134
150
|
if (writesDirectly)
|
|
135
151
|
process.stdout.write(rendered);
|
|
136
152
|
};
|
|
@@ -142,13 +158,14 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
142
158
|
const writeAssistantMessage = (text) => {
|
|
143
159
|
// Never surface an empty message: the reducer drops it and a direct
|
|
144
160
|
// stdout writer would print a stray blank line.
|
|
145
|
-
|
|
161
|
+
const clean = sanitizeAssistantText(text);
|
|
162
|
+
if (!clean.trim())
|
|
146
163
|
return;
|
|
147
164
|
visibleCommitted = true;
|
|
148
|
-
emit({ type: "assistant-message", text });
|
|
149
|
-
const rendered = renderMarkdown(
|
|
165
|
+
emit({ type: "assistant-message", text: clean });
|
|
166
|
+
const rendered = renderMarkdown(clean);
|
|
150
167
|
if (writesDirectly) {
|
|
151
|
-
process.stdout.write(
|
|
168
|
+
process.stdout.write(clean.endsWith("\n") ? rendered : `${rendered}\n`);
|
|
152
169
|
}
|
|
153
170
|
};
|
|
154
171
|
const writeThinkingBlock = (content) => {
|
|
@@ -282,17 +299,20 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
282
299
|
// provider's 413 as a context-window failure after the fact.
|
|
283
300
|
const inputTokenBudget = provider === "groq" ? groqInputTokenBudget(model) : undefined;
|
|
284
301
|
const useCompactSystemPrompt = inputTokenBudget !== undefined;
|
|
285
|
-
const
|
|
286
|
-
(
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
+
};
|
|
296
316
|
let lastAnswer = "";
|
|
297
317
|
const session = options.session ?? createSessionPolicy();
|
|
298
318
|
// Active plan context
|
|
@@ -300,7 +320,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
300
320
|
// context. When the user has approved it (via /implement) we instruct the
|
|
301
321
|
// agent to execute task by task; otherwise the agent should refine/wait.
|
|
302
322
|
const activePlan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
303
|
-
if (activePlan) {
|
|
323
|
+
if (activePlan && isPlanApprovedByStatus(activePlan.status)) {
|
|
304
324
|
// session.planApproved is in-memory only (never persisted), so a
|
|
305
325
|
// resumed session (via /history) or a fresh SessionPolicy after
|
|
306
326
|
// context compaction always starts it back at false — even when the
|
|
@@ -308,9 +328,77 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
308
328
|
// completed via /implement. Re-derive the flag from the plan's status
|
|
309
329
|
// on every load so resuming a session never re-blocks tool calls
|
|
310
330
|
// behind a stale "awaiting approval" gate for a plan that already ran.
|
|
311
|
-
|
|
312
|
-
|
|
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.`);
|
|
313
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));
|
|
394
|
+
}
|
|
395
|
+
if (freshWebSearchRequired) {
|
|
396
|
+
sections.push(freshnessGuardMessage());
|
|
397
|
+
}
|
|
398
|
+
return sections.join("\n\n");
|
|
399
|
+
};
|
|
400
|
+
const systemSections = [buildSystemContent(nativeToolsActive)];
|
|
401
|
+
if (activePlan) {
|
|
314
402
|
systemSections.push(planContextMessage(activePlan, session.planApproved.value));
|
|
315
403
|
}
|
|
316
404
|
// For build/scaffold turns with no active plan yet, inject an explicit
|
|
@@ -337,6 +425,16 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
337
425
|
!idleOrSocialPrompt) {
|
|
338
426
|
systemSections.push(pentestWorkflowDirective());
|
|
339
427
|
}
|
|
428
|
+
// Any remote/security engagement (even with an existing or completed plan)
|
|
429
|
+
// must never fall into the coding "start localhost dev server" habit.
|
|
430
|
+
const pentestSession = pentestLikeTurn ||
|
|
431
|
+
activePlan?.kind === "pentest" ||
|
|
432
|
+
(activePlan?.kind !== "coding" &&
|
|
433
|
+
Boolean(activePlan?.goal &&
|
|
434
|
+
/pentest|vulnerab|recon|security assess|attack surface|red team/i.test(activePlan.goal)));
|
|
435
|
+
if (pentestSession && !idleOrSocialPrompt) {
|
|
436
|
+
systemSections.push(pentestNoLocalServerDirective());
|
|
437
|
+
}
|
|
340
438
|
const renderedSystemPrompt = systemSections.join("\n\n");
|
|
341
439
|
// Reserve most of a constrained model's input budget for the user message,
|
|
342
440
|
// recent conversation, tool results, and provider framing. Dynamic project
|
|
@@ -397,15 +495,16 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
397
495
|
// text isn't wiped by the next tool-call/turn event. Skip when this
|
|
398
496
|
// iteration already surfaced its prose (the normal tool path commits
|
|
399
497
|
// `beforeTool` itself) so the same text is never rendered twice.
|
|
498
|
+
const cleaned = sanitizeAssistantText(content);
|
|
400
499
|
if (!visibleCommitted) {
|
|
401
|
-
const prose = recoveryProse(
|
|
500
|
+
const prose = recoveryProse(cleaned);
|
|
402
501
|
if (prose)
|
|
403
502
|
writeAssistantMessage(prose);
|
|
404
503
|
}
|
|
405
504
|
messages.push({
|
|
406
505
|
role: "assistant",
|
|
407
|
-
content:
|
|
408
|
-
?
|
|
506
|
+
content: cleaned.trim()
|
|
507
|
+
? cleaned
|
|
409
508
|
: "[No visible assistant response was produced.]",
|
|
410
509
|
});
|
|
411
510
|
};
|
|
@@ -440,9 +539,25 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
440
539
|
// executing the next task a bounded number of times before giving up.
|
|
441
540
|
let prematureCompletionRetries = 0;
|
|
442
541
|
let runtimeVerificationRetries = 0;
|
|
542
|
+
let featureImplRetries = 0;
|
|
543
|
+
let forcePlanRetries = 0;
|
|
544
|
+
let errorFixNarrationRetries = 0;
|
|
545
|
+
let failedProbeFixRetries = 0;
|
|
443
546
|
let sawServerStart = false;
|
|
547
|
+
let sawPlanCreateOk = false;
|
|
444
548
|
let sawServerTail = false;
|
|
445
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;
|
|
446
561
|
// Guard against a model that NARRATES intent ("let me explore the
|
|
447
562
|
// directory…") but emits no tool call, so nothing runs and the turn ends
|
|
448
563
|
// prematurely. On build/scaffold/plan turns where nothing has executed yet,
|
|
@@ -518,6 +633,15 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
518
633
|
// model-supplied paths against the canonical per-project scratch root.
|
|
519
634
|
const scratchDir = scratchDirFor(safeCwd());
|
|
520
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
|
+
}
|
|
521
645
|
if (call.name === "image.ocr" && !imageOcrEnabled) {
|
|
522
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"));
|
|
523
647
|
const recoveryText = "The original image is attached to this message and you can inspect it directly. " +
|
|
@@ -547,12 +671,71 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
547
671
|
writeNotice("info", loopCheck.reason, chalk.dim(` ℹ ${loopCheck.reason}\n`));
|
|
548
672
|
}
|
|
549
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
|
+
}
|
|
550
712
|
const planResult = await handlePlanTool(call, session, {
|
|
551
713
|
loopGuard,
|
|
552
714
|
step,
|
|
553
715
|
});
|
|
554
716
|
if (planResult.handled) {
|
|
555
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
|
+
}
|
|
556
739
|
if (!alreadyPrintedIds.has(toolEventId)) {
|
|
557
740
|
const toolCallLine = chalk.cyan(` ▶ ${call.name}`) + chalk.gray(` ${formatToolArgs(call)}`);
|
|
558
741
|
writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
|
|
@@ -560,6 +743,10 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
560
743
|
}
|
|
561
744
|
if (planResult.plan) {
|
|
562
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);
|
|
563
750
|
}
|
|
564
751
|
const result = { ok: planResult.ok, output: planResult.modelNote };
|
|
565
752
|
emitToolResult(toolEventId, result, planResult.modelNote);
|
|
@@ -580,11 +767,52 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
580
767
|
decision,
|
|
581
768
|
scope: isScopeActive(scope) ? (scope.name ?? "(unnamed)") : "(none)",
|
|
582
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
|
+
}
|
|
583
809
|
const isMutatingAction = (decision.level === "confirm" || decision.level === "block") &&
|
|
584
810
|
!isPreApprovalAllowedTool(call.name) &&
|
|
585
811
|
!isScratchOnlyWrite(call, scratchDir);
|
|
586
812
|
if (isMutatingAction) {
|
|
587
|
-
|
|
813
|
+
const planNow = livePlanForPreGate ??
|
|
814
|
+
(await loadPlan(session.sessionId).catch(() => undefined));
|
|
815
|
+
if (planNow && !session.planApproved.value) {
|
|
588
816
|
const reason = `plan awaiting approval — ${call.name} is blocked until you /implement (or /discard)`;
|
|
589
817
|
writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
|
|
590
818
|
const result = { ok: false, output: reason, exitCode: 1 };
|
|
@@ -605,30 +833,131 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
605
833
|
// claimed most tasks "done" in prose without ever recording it in the
|
|
606
834
|
// plan. Multiple tool calls per task are still fine; they just must be
|
|
607
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).
|
|
608
840
|
if (session.planApproved.value) {
|
|
609
841
|
const livePlanForGate = await loadPlan(session.sessionId).catch(() => undefined);
|
|
610
842
|
if (livePlanForGate) {
|
|
611
843
|
const unfinished = livePlanForGate.tasks.some((t) => t.state === "pending" || t.state === "in_progress");
|
|
612
844
|
const inProgress = livePlanForGate.tasks.find((t) => t.state === "in_progress");
|
|
613
845
|
if (unfinished && !inProgress) {
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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");
|
|
623
901
|
return {
|
|
624
902
|
ok: false,
|
|
625
903
|
call,
|
|
626
904
|
result,
|
|
627
|
-
contextOutput:
|
|
905
|
+
contextOutput: scopeMsg,
|
|
628
906
|
};
|
|
629
907
|
}
|
|
630
908
|
}
|
|
631
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
|
+
}
|
|
632
961
|
if (call.name === "web.search") {
|
|
633
962
|
sawFreshWebSearch = true;
|
|
634
963
|
}
|
|
@@ -729,6 +1058,9 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
729
1058
|
}
|
|
730
1059
|
parentSignal.throwIfAborted();
|
|
731
1060
|
options.onToolStart?.(call);
|
|
1061
|
+
// Card was "queued" since writeToolCall; flip to running only when work starts.
|
|
1062
|
+
emit({ type: "tool-start", id: toolEventId });
|
|
1063
|
+
writeStatus(call.name, chalk.dim(` → ${call.name}\n`));
|
|
732
1064
|
const interactiveCommand = (call.name === "shell.exec" &&
|
|
733
1065
|
typeof call.args.command === "string" &&
|
|
734
1066
|
looksInteractiveStdin(call.args.command)) ||
|
|
@@ -769,8 +1101,11 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
769
1101
|
jobManager.registerJob(jobId, backgroundJob, toolAc);
|
|
770
1102
|
// Long-lived commands should use shell.start/background jobs. Reset this
|
|
771
1103
|
// watchdog whenever a blocking tool emits output so only a genuinely
|
|
772
|
-
// stalled operation is cancelled.
|
|
773
|
-
|
|
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);
|
|
774
1109
|
let stallTimer;
|
|
775
1110
|
let stalledByWatchdog = false;
|
|
776
1111
|
const resetStallTimer = () => {
|
|
@@ -779,7 +1114,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
779
1114
|
stallTimer = setTimeout(() => {
|
|
780
1115
|
if (!toolAc.signal.aborted) {
|
|
781
1116
|
stalledByWatchdog = true;
|
|
782
|
-
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`));
|
|
783
1118
|
toolAc.abort();
|
|
784
1119
|
}
|
|
785
1120
|
}, TOOL_STALL_ABORT_MS);
|
|
@@ -838,6 +1173,53 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
838
1173
|
clearTimeout(stallTimer);
|
|
839
1174
|
parentSignal.removeEventListener("abort", onParentAbort);
|
|
840
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
|
+
}
|
|
841
1223
|
const output = result.output.trim();
|
|
842
1224
|
// Always keep a full on-disk copy of tool output (any size) so the
|
|
843
1225
|
// pager never depends on a truncated in-memory preview.
|
|
@@ -864,6 +1246,12 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
864
1246
|
output: result.output.slice(0, 4_000),
|
|
865
1247
|
});
|
|
866
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
|
+
}
|
|
867
1255
|
// Inject approach evaluation when consecutive failures are detected.
|
|
868
1256
|
// Lets the MODEL decide (with full context) whether to continue a
|
|
869
1257
|
// legitimately long approach, switch, or stop — instead of a
|
|
@@ -910,9 +1298,16 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
910
1298
|
artifactPath: savedOutputPath,
|
|
911
1299
|
summary: contextOutput,
|
|
912
1300
|
});
|
|
913
|
-
|
|
1301
|
+
// Classic REPL only: short path notice. Never push long
|
|
1302
|
+
// "Ctrl+O or /output last … (path)" strings into the TUI status
|
|
1303
|
+
// footer — they collide with activity and garble the chrome.
|
|
1304
|
+
if (writesDirectly && savedOutputPath) {
|
|
1305
|
+
const short = chalk.dim(` saved ${savedOutputPath}\n`);
|
|
1306
|
+
process.stdout.write(short);
|
|
1307
|
+
}
|
|
1308
|
+
else if (writesDirectly) {
|
|
914
1309
|
const viewportHint = `${formatViewportHint(viewport)}\n`;
|
|
915
|
-
|
|
1310
|
+
process.stdout.write(viewportHint);
|
|
916
1311
|
}
|
|
917
1312
|
}
|
|
918
1313
|
return { ok: result.ok, call, result, contextOutput };
|
|
@@ -1115,7 +1510,26 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1115
1510
|
emit({ type: "thinking-delta", text });
|
|
1116
1511
|
});
|
|
1117
1512
|
let completion;
|
|
1513
|
+
let toolsAttached = false;
|
|
1118
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
|
+
});
|
|
1119
1533
|
completion = await streamWithProvider({
|
|
1120
1534
|
provider,
|
|
1121
1535
|
model,
|
|
@@ -1145,36 +1559,101 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1145
1559
|
thinking: retryWithoutThinking
|
|
1146
1560
|
? { ...config.thinking, enabled: false, effort: "low" }
|
|
1147
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
|
+
: {}),
|
|
1148
1625
|
}, (token) => {
|
|
1149
1626
|
deltaParser?.push(token);
|
|
1150
1627
|
generatedTokens += 1;
|
|
1151
1628
|
accumulatedText += token;
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
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);
|
|
1173
1656
|
}
|
|
1174
|
-
streamedCallsCount += 1;
|
|
1175
|
-
}
|
|
1176
|
-
if (writesDirectly) {
|
|
1177
|
-
spinner = startThinkingSpinner(`generating response (${generatedTokens} tokens)`, options.signal);
|
|
1178
1657
|
}
|
|
1179
1658
|
}
|
|
1180
1659
|
// Heuristic: <think>… markers and reasoning_content tokens flow
|
|
@@ -1223,6 +1702,13 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1223
1702
|
provider = completion.provider;
|
|
1224
1703
|
model = completion.model;
|
|
1225
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));
|
|
1226
1712
|
const assistantTextResult = rememberThinkingFromText(completion.text);
|
|
1227
1713
|
assistantText = assistantTextResult;
|
|
1228
1714
|
// Commit thinking to the transcript IMMEDIATELY, before any of the
|
|
@@ -1235,30 +1721,171 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1235
1721
|
if (assistantText.hasThinking) {
|
|
1236
1722
|
writeThinkingBlock(assistantText.thinkContent);
|
|
1237
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
|
+
}
|
|
1238
1783
|
// Try visible text first, then thinking content — some models (e.g. glm-5.1)
|
|
1239
|
-
// wrap tool calls inside
|
|
1784
|
+
// wrap tool calls inside considering tags, so stripThinking removes them
|
|
1240
1785
|
// into thinkContent and visible becomes empty. Recovering from thinkContent
|
|
1241
1786
|
// prevents an endless nudge loop where the model keeps hiding the call.
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
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, {
|
|
1247
1799
|
strict: getConfig().parserStrict,
|
|
1248
1800
|
});
|
|
1249
|
-
if (call) {
|
|
1250
|
-
|
|
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
|
+
}
|
|
1251
1808
|
}
|
|
1252
1809
|
}
|
|
1253
1810
|
// ── Prompt-leak guard ─────────────────────────────────────────
|
|
1254
1811
|
// If the model's visible output contains distinctive system-prompt
|
|
1255
1812
|
// markers, it is repeating its instructions (e.g. prompt injection
|
|
1256
1813
|
// via "repeat your instructions verbatim"). Any tool-call syntax
|
|
1257
|
-
//
|
|
1258
|
-
// Suppress it so we never execute leaked examples.
|
|
1259
|
-
if (
|
|
1260
|
-
|
|
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
|
+
}
|
|
1261
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
|
+
}
|
|
1262
1889
|
}
|
|
1263
1890
|
// Empty-response recovery
|
|
1264
1891
|
// Some models occasionally return an empty completion: a reasoning
|
|
@@ -1282,12 +1909,18 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1282
1909
|
pushAssistantHistory(stripThinking(collapseRepeatedText(completion.text)).visible);
|
|
1283
1910
|
// Keep nudges SHORT — cheap models lose the key instruction in long text.
|
|
1284
1911
|
const buildNudge = freshWebSearchRequired && !sawFreshWebSearch
|
|
1285
|
-
?
|
|
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."
|
|
1286
1915
|
: buildLikeTurn && !activePlan
|
|
1287
|
-
?
|
|
1288
|
-
"
|
|
1289
|
-
|
|
1290
|
-
|
|
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.";
|
|
1291
1924
|
messages.push(recoveryUserMessage(buildNudge));
|
|
1292
1925
|
continue;
|
|
1293
1926
|
}
|
|
@@ -1336,19 +1969,31 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1336
1969
|
if (bareArgsOnly) {
|
|
1337
1970
|
bareToolJsonRetries += 1;
|
|
1338
1971
|
if (bareToolJsonRetries <= 3) {
|
|
1339
|
-
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"));
|
|
1340
1977
|
pushAssistantHistory(assistantText.visible);
|
|
1341
1978
|
messages.push(recoveryUserMessage(buildLikeTurn && !activePlan
|
|
1342
|
-
?
|
|
1343
|
-
"
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
"
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
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."));
|
|
1352
1997
|
continue;
|
|
1353
1998
|
}
|
|
1354
1999
|
// Exhausted retries — fall through to the normal answer path.
|
|
@@ -1360,10 +2005,14 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1360
2005
|
if (/<\|tool_call(?:s_section)?_begin\|>|<\|tool_call_argument_begin\|>/i.test(assistantText.visible)) {
|
|
1361
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"));
|
|
1362
2007
|
pushAssistantHistory(assistantText.visible);
|
|
1363
|
-
messages.push(recoveryUserMessage(
|
|
1364
|
-
"
|
|
1365
|
-
|
|
1366
|
-
|
|
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."));
|
|
1367
2016
|
continue;
|
|
1368
2017
|
}
|
|
1369
2018
|
// Detect a tool call that opened but was cut off by the token limit
|
|
@@ -1388,15 +2037,23 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1388
2037
|
const lineCount = salvaged.content.split("\n").length;
|
|
1389
2038
|
writeNotice("info", `tool call was truncated — salvaged ${lineCount} lines and wrote to ${salvaged.path}`, chalk.cyan(` ℹ tool call was truncated — salvaged ${lineCount} lines to ${salvaged.path}\n`));
|
|
1390
2039
|
pushAssistantHistory(stripThinking(assistantText.visible).visible);
|
|
2040
|
+
const priorBytes = Buffer.byteLength(salvaged.content, "utf8");
|
|
1391
2041
|
messages.push({
|
|
1392
2042
|
role: "user",
|
|
1393
|
-
content:
|
|
1394
|
-
`
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
`
|
|
1398
|
-
|
|
1399
|
-
|
|
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.`,
|
|
1400
2057
|
});
|
|
1401
2058
|
continue;
|
|
1402
2059
|
}
|
|
@@ -1410,13 +2067,18 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1410
2067
|
pushAssistantHistory(stripThinking(assistantText.visible).visible);
|
|
1411
2068
|
messages.push({
|
|
1412
2069
|
role: "user",
|
|
1413
|
-
content:
|
|
1414
|
-
"Your
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
"
|
|
1419
|
-
|
|
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.",
|
|
1420
2082
|
});
|
|
1421
2083
|
continue;
|
|
1422
2084
|
}
|
|
@@ -1451,7 +2113,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1451
2113
|
content: `The system extracted and wrote ${lineCount} lines to ${salvaged.path} from your malformed tool call. ` +
|
|
1452
2114
|
`The file content ends at: "${salvaged.lastLine}"\n\n` +
|
|
1453
2115
|
`If the file is complete, proceed with the next step. ` +
|
|
1454
|
-
`If more content is needed, use fs.append
|
|
2116
|
+
`If more content is needed, use one large fs.append with expectedPriorBytes from the write receipt (not tiny chunks).`,
|
|
1455
2117
|
});
|
|
1456
2118
|
continue;
|
|
1457
2119
|
}
|
|
@@ -1466,14 +2128,18 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1466
2128
|
pushAssistantHistory(stripThinking(assistantText.visible).visible);
|
|
1467
2129
|
messages.push({
|
|
1468
2130
|
role: "user",
|
|
1469
|
-
content:
|
|
1470
|
-
"
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
"
|
|
1476
|
-
|
|
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.",
|
|
1477
2143
|
});
|
|
1478
2144
|
continue;
|
|
1479
2145
|
}
|
|
@@ -1524,25 +2190,48 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1524
2190
|
const planNarrated = (buildLikeTurn || pentestLikeTurn) &&
|
|
1525
2191
|
!activePlan &&
|
|
1526
2192
|
looksLikePlanNarration(cleaned);
|
|
2193
|
+
const errorFixNarration = looksLikeErrorDiagnosisWithFixIntent(cleaned);
|
|
1527
2194
|
// Once a real tool step has run, a no-plan task has no durable task
|
|
1528
2195
|
// state to prove whether another action is needed. A tool-free reply
|
|
1529
2196
|
// must therefore be allowed to finalize instead of turning a short
|
|
1530
|
-
// summary containing “I'll” into an implicit recovery request
|
|
1531
|
-
|
|
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);
|
|
1532
2207
|
if (wantsAction &&
|
|
1533
2208
|
cleaned.trim().length > 0 &&
|
|
1534
2209
|
actionIntentRetries < 3 &&
|
|
1535
2210
|
shouldRetryBeforeFinalizing) {
|
|
1536
2211
|
actionIntentRetries += 1;
|
|
1537
2212
|
let nudge;
|
|
1538
|
-
if (
|
|
1539
|
-
|
|
1540
|
-
|
|
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.";
|
|
1541
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"));
|
|
1542
2230
|
}
|
|
1543
2231
|
else if (pentestLikeTurn) {
|
|
1544
|
-
nudge =
|
|
1545
|
-
"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" +
|
|
1546
2235
|
'```tool\n{"name":"sysinfo","args":{}}\n```\n' +
|
|
1547
2236
|
"Every turn MUST contain a ```tool block until the task is done.";
|
|
1548
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"));
|
|
@@ -1550,11 +2239,9 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1550
2239
|
else if (freshWebSearchRequired || narratedWebAction) {
|
|
1551
2240
|
// Web-specific recovery ONLY when the user asked for current
|
|
1552
2241
|
// info or the model explicitly claimed a fetch/search step.
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
nudge =
|
|
1557
|
-
"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" +
|
|
1558
2245
|
'```tool\n{"name":"web.fetch","args":{"url":"https://example.com/page","responseMode":"readable"}}\n```\n' +
|
|
1559
2246
|
"If you do not know the exact page URL, use web.search first. After the tool output, answer from the fetched page content.";
|
|
1560
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"));
|
|
@@ -1562,23 +2249,28 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1562
2249
|
else if (buildLikeTurn &&
|
|
1563
2250
|
(planNarrated || productiveSteps > 0)) {
|
|
1564
2251
|
const kind = pentestLikeTurn ? "pentest" : "coding";
|
|
1565
|
-
nudge =
|
|
1566
|
-
|
|
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" +
|
|
1567
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` +
|
|
1568
2256
|
"Do not describe the plan again in prose — just emit the plan.create tool block.";
|
|
1569
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"));
|
|
1570
2258
|
}
|
|
1571
2259
|
else if (buildLikeTurn) {
|
|
1572
|
-
nudge =
|
|
1573
|
-
"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" +
|
|
1574
2263
|
'```tool\n{"name":"fs.list","args":{"path":"."}}\n```\n' +
|
|
1575
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.";
|
|
1576
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"));
|
|
1577
2266
|
}
|
|
1578
2267
|
else {
|
|
1579
2268
|
// Generic non-build, non-web stall (e.g. "I'll list the files").
|
|
1580
|
-
nudge =
|
|
1581
|
-
"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.";
|
|
1582
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"));
|
|
1583
2275
|
}
|
|
1584
2276
|
pushAssistantHistory(assistantText.visible);
|
|
@@ -1594,45 +2286,148 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1594
2286
|
messages.push({
|
|
1595
2287
|
role: "user",
|
|
1596
2288
|
content: freshnessGuardMessage() +
|
|
1597
|
-
|
|
2289
|
+
(toolsAttached
|
|
2290
|
+
? " Call the web_search tool now."
|
|
2291
|
+
: " Reply with ONLY a fenced ```tool block for web.search now."),
|
|
1598
2292
|
});
|
|
1599
2293
|
continue;
|
|
1600
2294
|
}
|
|
1601
|
-
//
|
|
1602
|
-
//
|
|
1603
|
-
// before accepting the model's final claim.
|
|
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.)
|
|
1604
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.
|
|
2329
|
+
if (buildLike &&
|
|
2330
|
+
!pentestLike &&
|
|
2331
|
+
!pentestSession &&
|
|
1605
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 &&
|
|
1606
2363
|
(!sawServerStart || !sawServerTail || !sawLocalHttpProbe) &&
|
|
1607
|
-
runtimeVerificationRetries < 2
|
|
2364
|
+
runtimeVerificationRetries < 2 &&
|
|
2365
|
+
// Feature apps must implement first (handled above); only verify live after that
|
|
2366
|
+
(!featureAppAsk || sawFeatureImplWrite)) {
|
|
1608
2367
|
const runtimePlan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
1609
|
-
const
|
|
2368
|
+
const codingPlanFinished = Boolean(runtimePlan &&
|
|
2369
|
+
session.planApproved.value &&
|
|
2370
|
+
runtimePlan.kind !== "pentest" &&
|
|
1610
2371
|
runtimePlan.tasks.length > 0 &&
|
|
1611
2372
|
runtimePlan.tasks.every((task) => task.state === "done" || task.state === "skipped"));
|
|
1612
|
-
|
|
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) {
|
|
1613
2384
|
runtimeVerificationRetries += 1;
|
|
1614
2385
|
pushAssistantHistory(assistantText.visible);
|
|
1615
|
-
const
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
!sawLocalHttpProbe
|
|
1619
|
-
? "a successful bounded localhost HTTP probe"
|
|
1620
|
-
: "",
|
|
1621
|
-
].filter(Boolean);
|
|
2386
|
+
const rootHint = getActiveProjectRoot()
|
|
2387
|
+
? ` Use cwd "${getActiveProjectRoot()}".`
|
|
2388
|
+
: "";
|
|
1622
2389
|
messages.push({
|
|
1623
2390
|
role: "user",
|
|
1624
|
-
content: "
|
|
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. " +
|
|
2396
|
+
"If this was a remote pentest, ignore this and finalize the report with no local server.",
|
|
1625
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"));
|
|
1626
2399
|
continue;
|
|
1627
2400
|
}
|
|
1628
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
|
+
}
|
|
1629
2422
|
// Premature-completion guard (approved plan still has work)
|
|
1630
2423
|
// If the user approved a plan and the model now gives a final answer
|
|
1631
2424
|
// while tasks are still pending/in_progress — without having run the
|
|
1632
2425
|
// work — it is fabricating completion (the exact "all tasks completed,
|
|
1633
2426
|
// running at localhost:5173" failure). Force it back to executing the
|
|
1634
2427
|
// next real task instead of accepting the false claim.
|
|
1635
|
-
|
|
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) {
|
|
1636
2431
|
const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
1637
2432
|
const unfinished = livePlan?.tasks.filter((t) => t.state === "pending" || t.state === "in_progress");
|
|
1638
2433
|
if (livePlan && unfinished && unfinished.length > 0) {
|
|
@@ -1640,20 +2435,30 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1640
2435
|
const next = unfinished[0];
|
|
1641
2436
|
writeNotice("warn", `${unfinished.length} plan task(s) still unfinished — not accepting a "done" claim; resuming execution`, chalk.yellow(` ⚠ ${unfinished.length} plan task(s) still unfinished — not accepting a "done" claim; resuming execution\n`));
|
|
1642
2437
|
pushAssistantHistory(assistantText.visible);
|
|
2438
|
+
const isPentestPlan = livePlan.kind === "pentest" || pentestSession;
|
|
1643
2439
|
let instruction = `Resume now with the NEXT task ${next.id} ("${next.title}"): `;
|
|
1644
|
-
if (
|
|
1645
|
-
instruction
|
|
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
|
+
}
|
|
2444
|
+
if (isPentestPlan) {
|
|
2445
|
+
instruction +=
|
|
2446
|
+
`call task.update {taskId:"${next.id}", state:"in_progress"}, then do the recon/testing work ` +
|
|
2447
|
+
`(dns/http/net.scan/http.fetch/tool.batch — NOT a local dev server), VERIFY with real tool output, ` +
|
|
2448
|
+
`then task.update done. Do NOT shell.start / npm run dev / explore the clai workspace. `;
|
|
2449
|
+
}
|
|
2450
|
+
else if (next.state === "pending") {
|
|
2451
|
+
instruction += `call task.update {taskId:"${next.id}", state:"in_progress"}, then do the real work with a tool call (fs.writeMany / shell.exec / shell.start when building a local app), VERIFY it, and mark it done. `;
|
|
1646
2452
|
}
|
|
1647
2453
|
else {
|
|
1648
|
-
instruction += `do the real work with a tool call (fs.writeMany / shell.exec / shell.start) to complete it, VERIFY it, and mark it done (call task.update {taskId:"${next.id}", state:"done"}). `;
|
|
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"}). `;
|
|
1649
2455
|
}
|
|
1650
|
-
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.`;
|
|
1651
2457
|
messages.push({
|
|
1652
2458
|
role: "user",
|
|
1653
2459
|
content: `You have NOT finished the approved plan: ${unfinished.length} task(s) remain ` +
|
|
1654
2460
|
`(${unfinished.map((t) => `[${t.id}] ${t.title}`).join("; ")}). ` +
|
|
1655
|
-
`Do NOT claim the work is complete
|
|
1656
|
-
`unless a tool call actually succeeded and you saw the output. ` +
|
|
2461
|
+
`Do NOT claim the work is complete unless a tool call actually succeeded and you saw the output. ` +
|
|
1657
2462
|
instruction,
|
|
1658
2463
|
});
|
|
1659
2464
|
continue;
|
|
@@ -1694,49 +2499,71 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1694
2499
|
// prose / thinking that preceded it, record the assistant message ONCE.
|
|
1695
2500
|
const beforeTool = recoveredFromBareJson
|
|
1696
2501
|
? ""
|
|
1697
|
-
:
|
|
2502
|
+
: nativeToolCalls.length
|
|
2503
|
+
? assistantText.visible.trim()
|
|
2504
|
+
: textBeforeToolCall(assistantText.visible);
|
|
1698
2505
|
if (beforeTool) {
|
|
1699
2506
|
writeAssistantMessage(beforeTool);
|
|
1700
2507
|
}
|
|
1701
|
-
let
|
|
1702
|
-
if (
|
|
1703
|
-
|
|
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
|
+
});
|
|
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
|
+
});
|
|
1704
2536
|
}
|
|
2537
|
+
/** Subset that will actually run this turn (defer/omit rest). */
|
|
2538
|
+
let toRun = bound;
|
|
1705
2539
|
let activeDeferredToolCalls = deferredToolCalls;
|
|
2540
|
+
let deferReason = "Cancelled — not executed this turn (deferred or omitted).";
|
|
1706
2541
|
// A plan must be based on the outputs of prior reconnaissance, never
|
|
1707
2542
|
// on calls the model merely proposed in the same response. If a model
|
|
1708
2543
|
// emits plan.create alongside gathering calls, run only the calls
|
|
1709
2544
|
// before it, then let the next model turn analyse their actual results
|
|
1710
|
-
// and emit one standalone plan.create.
|
|
1711
|
-
|
|
1712
|
-
// created or approved.
|
|
1713
|
-
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");
|
|
1714
2547
|
if (planCallIndex > 0) {
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
allCalls = gatheringCalls;
|
|
1722
|
-
activeDeferredToolCalls = deferredToolCalls.slice(0, gatheringCalls.length);
|
|
1723
|
-
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`));
|
|
1724
2554
|
messages.push({
|
|
1725
2555
|
role: "system",
|
|
1726
2556
|
content: `The prior response included plan.create before its reconnaissance results existed. ` +
|
|
1727
|
-
`Only the ${
|
|
2557
|
+
`Only the ${toRun.length} gathering call(s) before it were run; ${deferredCount} plan/follow-on call(s) were not run. ` +
|
|
1728
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.",
|
|
1729
2559
|
});
|
|
1730
2560
|
}
|
|
1731
|
-
else if (planCallIndex === 0 &&
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
// history), so execute the plan.create now and defer only the calls
|
|
1735
|
-
// proposed after it — those were proposed before the plan was
|
|
1736
|
-
// created or approved.
|
|
1737
|
-
const deferredCount = allCalls.length - 1;
|
|
1738
|
-
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);
|
|
1739
2564
|
activeDeferredToolCalls = deferredToolCalls.slice(0, 1);
|
|
2565
|
+
deferReason =
|
|
2566
|
+
"Deferred — waiting for plan approval before follow-on tools.";
|
|
1740
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`));
|
|
1741
2568
|
messages.push({
|
|
1742
2569
|
role: "system",
|
|
@@ -1744,20 +2571,13 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1744
2571
|
`the follow-on call(s) were not. Wait for the plan to be reviewed, then proceed task by task.`,
|
|
1745
2572
|
});
|
|
1746
2573
|
}
|
|
1747
|
-
// planCallIndex === 0 && allCalls.length === 1: a standalone plan.create
|
|
1748
|
-
// built from prior reconnaissance. Execute it normally — deferring it
|
|
1749
|
-
// here (as the previous `>= 0` guard did) ran zero calls and looped the
|
|
1750
|
-
// agent forever without ever creating the plan.
|
|
1751
|
-
// A single model message can contain an unbounded number of calls.
|
|
1752
|
-
// Even with read-only calls fanned out, a giant batch can tie up the
|
|
1753
|
-
// session for minutes and makes cancellation feel broken. Keep each
|
|
1754
|
-
// model turn bounded; after these results the agent gets another turn
|
|
1755
|
-
// to prioritise the remaining work from real evidence.
|
|
1756
2574
|
const MAX_CALLS_PER_MODEL_TURN = 12;
|
|
1757
|
-
const omittedCallCount = Math.max(0,
|
|
2575
|
+
const omittedCallCount = Math.max(0, toRun.length - MAX_CALLS_PER_MODEL_TURN);
|
|
1758
2576
|
if (omittedCallCount > 0) {
|
|
1759
|
-
|
|
2577
|
+
toRun = toRun.slice(0, MAX_CALLS_PER_MODEL_TURN);
|
|
1760
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.";
|
|
1761
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`));
|
|
1762
2582
|
messages.push({
|
|
1763
2583
|
role: "system",
|
|
@@ -1765,6 +2585,25 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1765
2585
|
`${omittedCallCount} were not run. After reviewing results, issue a small, prioritized next batch.`,
|
|
1766
2586
|
});
|
|
1767
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));
|
|
1768
2607
|
// Notice BEFORE tool cards so the transcript reads:
|
|
1769
2608
|
// thinking → response → "N tool calls…" → tool cards (not tools then info).
|
|
1770
2609
|
if (allCalls.length > 1) {
|
|
@@ -1773,13 +2612,23 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1773
2612
|
// Emit only the calls that will actually execute, after thinking
|
|
1774
2613
|
// + assistant text so transcript order remains correct.
|
|
1775
2614
|
for (const deferred of activeDeferredToolCalls.slice(0, allCalls.length)) {
|
|
2615
|
+
if (!deferred.call.name || deferred.call.name === "…")
|
|
2616
|
+
continue;
|
|
1776
2617
|
writeToolCall(deferred.eventId, deferred.call, deferred.rendered);
|
|
1777
2618
|
}
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
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
|
+
}
|
|
1783
2632
|
// Scoped-parallel batch execution
|
|
1784
2633
|
// The model may emit several calls in one message. We partition them,
|
|
1785
2634
|
// IN DOCUMENT ORDER, into segments:
|
|
@@ -1799,7 +2648,22 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1799
2648
|
// later recon from running; aborts, blocks, and sequential-barrier
|
|
1800
2649
|
// failures still stop the batch so the model can react safely.
|
|
1801
2650
|
const scopeForBatch = await loadScope().catch(() => undefined);
|
|
2651
|
+
/**
|
|
2652
|
+
* Tools that may share a concurrent wave. Pure read-only BATCH_SAFE
|
|
2653
|
+
* tools always qualify when classified safe. Heavy discovery wrappers
|
|
2654
|
+
* (pentest.recon, net.context) also run concurrent with dns/http so
|
|
2655
|
+
* nmap does not serialize the entire recon wave.
|
|
2656
|
+
* net.scan stays a barrier when it needs confirm/sudo UX.
|
|
2657
|
+
*/
|
|
1802
2658
|
const isParallelSafe = (c) => {
|
|
2659
|
+
if (c.name === "pentest.recon" ||
|
|
2660
|
+
c.name === "net.context" ||
|
|
2661
|
+
c.name === "tool.batch" ||
|
|
2662
|
+
c.name === "tool.check" ||
|
|
2663
|
+
c.name === "shell.jobs" ||
|
|
2664
|
+
c.name === "shell.tail") {
|
|
2665
|
+
return true;
|
|
2666
|
+
}
|
|
1803
2667
|
if (!BATCH_SAFE_TOOLS.has(c.name))
|
|
1804
2668
|
return false;
|
|
1805
2669
|
try {
|
|
@@ -1809,27 +2673,56 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1809
2673
|
return false;
|
|
1810
2674
|
}
|
|
1811
2675
|
};
|
|
1812
|
-
|
|
2676
|
+
/** Tools whose failure must NOT cancel sibling calls in this turn. */
|
|
2677
|
+
const shouldSoftFailTool = (name) => {
|
|
2678
|
+
if (name === "tool.batch")
|
|
2679
|
+
return true;
|
|
2680
|
+
if (BATCH_SAFE_TOOLS.has(name))
|
|
2681
|
+
return true;
|
|
2682
|
+
if (name === "pentest.recon" ||
|
|
2683
|
+
name === "net.scan" ||
|
|
2684
|
+
name === "net.pingSweep" ||
|
|
2685
|
+
name === "net.context" ||
|
|
2686
|
+
name === "shell.jobs" ||
|
|
2687
|
+
name === "shell.tail" ||
|
|
2688
|
+
name === "tool.check") {
|
|
2689
|
+
return true;
|
|
2690
|
+
}
|
|
2691
|
+
return false;
|
|
2692
|
+
};
|
|
2693
|
+
// Recon waves often emit 6–10 lookups; 4 forced a second sequential wave.
|
|
2694
|
+
const PARALLEL_LIMIT = 8;
|
|
1813
2695
|
let aborted = false;
|
|
1814
2696
|
let blocked = false;
|
|
1815
2697
|
let blockedResult = null;
|
|
1816
2698
|
let failed = false;
|
|
1817
2699
|
let awaitingPlanApproval = false;
|
|
1818
|
-
/**
|
|
1819
|
-
const
|
|
1820
|
-
const recordResult = (res, continueAfterFailure = false) => {
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
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
|
+
}
|
|
1828
2714
|
productiveSteps += 1;
|
|
1829
2715
|
// Reset retry counters — they track consecutive failures, not cumulative.
|
|
1830
2716
|
truncatedToolRetries = 0;
|
|
1831
2717
|
malformedFenceRetries = 0;
|
|
1832
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
|
+
}
|
|
1833
2726
|
if (res.ok && res.call.name === "shell.start")
|
|
1834
2727
|
sawServerStart = true;
|
|
1835
2728
|
if (res.ok && res.call.name === "shell.tail")
|
|
@@ -1839,10 +2732,51 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1839
2732
|
/^(?:https?:\/\/)?(?:localhost|127\.0\.0\.1|\[::1\])(?::|\/|$)/i.test(String(res.call.args.url ?? ""))) ||
|
|
1840
2733
|
(res.call.name === "shell.exec" &&
|
|
1841
2734
|
/\bcurl\b[\s\S]*\b(?:localhost|127\.0\.0\.1|\[::1\])\b/i.test(String(res.call.args.command ?? ""))))) {
|
|
1842
|
-
|
|
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
|
+
}
|
|
1843
2776
|
}
|
|
1844
2777
|
if (res.call.name === "plan.create" && res.ok) {
|
|
1845
2778
|
awaitingPlanApproval = true;
|
|
2779
|
+
sawPlanCreateOk = true;
|
|
1846
2780
|
}
|
|
1847
2781
|
if (res.lastAnswer === "Aborted.")
|
|
1848
2782
|
aborted = true;
|
|
@@ -1859,47 +2793,46 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1859
2793
|
break;
|
|
1860
2794
|
if (group.length === 1) {
|
|
1861
2795
|
const call = group[0];
|
|
1862
|
-
const
|
|
1863
|
-
if (
|
|
1864
|
-
|
|
2796
|
+
const bc = callToBound.get(call);
|
|
2797
|
+
if (!bc)
|
|
2798
|
+
continue;
|
|
2799
|
+
if (!callIds[bc.index]) {
|
|
2800
|
+
callIds[bc.index] = `tool-${++nextToolEventId}`;
|
|
1865
2801
|
}
|
|
1866
|
-
const id =
|
|
2802
|
+
const id = callIds[bc.index];
|
|
1867
2803
|
const res = await executeSingleTool(call, id, options.signal || new AbortController().signal);
|
|
1868
|
-
|
|
2804
|
+
const softFail = shouldSoftFailTool(call.name);
|
|
2805
|
+
recordResult(bc, res, softFail);
|
|
1869
2806
|
}
|
|
1870
2807
|
else {
|
|
1871
|
-
// Concurrent group —
|
|
1872
|
-
|
|
1873
|
-
const
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
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}`;
|
|
1877
2817
|
}
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
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
|
+
}
|
|
1886
2825
|
}
|
|
1887
2826
|
}
|
|
1888
|
-
// Cards
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
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))
|
|
1893
2831
|
continue;
|
|
1894
|
-
|
|
1895
|
-
if (i >= 0 && !callIds[i]) {
|
|
2832
|
+
if (!callIds[i]) {
|
|
1896
2833
|
callIds[i] = `tool-${++nextToolEventId}`;
|
|
1897
2834
|
}
|
|
1898
|
-
const
|
|
1899
|
-
if (!alreadyPrintedIds.has(id)) {
|
|
1900
|
-
// Never shown in the transcript — skip.
|
|
1901
|
-
continue;
|
|
1902
|
-
}
|
|
2835
|
+
const uiId = callIds[i];
|
|
1903
2836
|
const reason = aborted
|
|
1904
2837
|
? "Cancelled — turn aborted before this call ran."
|
|
1905
2838
|
: blocked
|
|
@@ -1914,11 +2847,29 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1914
2847
|
output: reason,
|
|
1915
2848
|
exitCode: 130,
|
|
1916
2849
|
};
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
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.");
|
|
1922
2873
|
}
|
|
1923
2874
|
// plan.create is a hard transaction boundary. Its successful handler
|
|
1924
2875
|
// persists and displays the plan; returning immediately prevents a
|