@pentoshi/clai 3.6.0 → 3.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/compaction-summary.d.ts +17 -0
- package/dist/agent/compaction-summary.js +64 -0
- package/dist/agent/compaction-summary.js.map +1 -0
- package/dist/agent/confirm-port.d.ts +3 -1
- package/dist/agent/confirm-port.js +5 -1
- package/dist/agent/confirm-port.js.map +1 -1
- package/dist/agent/context-manager.js +45 -22
- package/dist/agent/context-manager.js.map +1 -1
- package/dist/agent/events.d.ts +6 -0
- package/dist/agent/evidence-governor.d.ts +37 -0
- package/dist/agent/evidence-governor.js +70 -0
- package/dist/agent/evidence-governor.js.map +1 -0
- package/dist/agent/loop-guard.d.ts +29 -11
- package/dist/agent/loop-guard.js +93 -39
- package/dist/agent/loop-guard.js.map +1 -1
- package/dist/agent/must-continue.d.ts +56 -0
- package/dist/agent/must-continue.js +240 -0
- package/dist/agent/must-continue.js.map +1 -0
- package/dist/agent/outcomes.d.ts +101 -0
- package/dist/agent/outcomes.js +359 -0
- package/dist/agent/outcomes.js.map +1 -0
- package/dist/agent/plan-decision.d.ts +33 -0
- package/dist/agent/plan-decision.js +104 -0
- package/dist/agent/plan-decision.js.map +1 -0
- package/dist/agent/plan-tool.d.ts +12 -0
- package/dist/agent/plan-tool.js +309 -104
- package/dist/agent/plan-tool.js.map +1 -1
- package/dist/agent/project-root.js +3 -0
- package/dist/agent/project-root.js.map +1 -1
- package/dist/agent/prompt-composer.d.ts +21 -0
- package/dist/agent/prompt-composer.js +46 -0
- package/dist/agent/prompt-composer.js.map +1 -0
- package/dist/agent/runner.d.ts +11 -1
- package/dist/agent/runner.js +1115 -965
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/scope-context.d.ts +3 -0
- package/dist/agent/scope-context.js +24 -0
- package/dist/agent/scope-context.js.map +1 -0
- package/dist/agent/scratch-write.d.ts +4 -0
- package/dist/agent/scratch-write.js +54 -0
- package/dist/agent/scratch-write.js.map +1 -0
- package/dist/agent/session-policy.d.ts +7 -0
- package/dist/agent/session-policy.js +61 -0
- package/dist/agent/session-policy.js.map +1 -1
- package/dist/agent/session-state.d.ts +41 -0
- package/dist/agent/session-state.js +107 -0
- package/dist/agent/session-state.js.map +1 -0
- package/dist/agent/step-budget.d.ts +14 -0
- package/dist/agent/step-budget.js +26 -0
- package/dist/agent/step-budget.js.map +1 -0
- package/dist/agent/task-analyzer.d.ts +12 -3
- package/dist/agent/task-analyzer.js +263 -23
- package/dist/agent/task-analyzer.js.map +1 -1
- package/dist/agent/task-evidence.d.ts +99 -29
- package/dist/agent/task-evidence.js +480 -91
- package/dist/agent/task-evidence.js.map +1 -1
- package/dist/agent/task-plan.d.ts +57 -0
- package/dist/agent/task-plan.js +147 -1
- package/dist/agent/task-plan.js.map +1 -1
- package/dist/agent/tool-call-parser.d.ts +4 -12
- package/dist/agent/tool-call-parser.js +34 -67
- package/dist/agent/tool-call-parser.js.map +1 -1
- package/dist/agent/tool-history.d.ts +7 -0
- package/dist/agent/tool-history.js +50 -0
- package/dist/agent/tool-history.js.map +1 -1
- package/dist/agent/tool-output-formatting.d.ts +13 -1
- package/dist/agent/tool-output-formatting.js +75 -22
- package/dist/agent/tool-output-formatting.js.map +1 -1
- package/dist/agent/turn-outcome.d.ts +11 -0
- package/dist/agent/turn-outcome.js +17 -0
- package/dist/agent/turn-outcome.js.map +1 -0
- package/dist/agent/turn-state.d.ts +11 -0
- package/dist/agent/turn-state.js +28 -0
- package/dist/agent/turn-state.js.map +1 -0
- package/dist/agent/workspace-orient.d.ts +11 -0
- package/dist/agent/workspace-orient.js +103 -12
- package/dist/agent/workspace-orient.js.map +1 -1
- package/dist/app/adapters/agent-event-adapter.d.ts +2 -13
- package/dist/app/adapters/agent-event-adapter.js +63 -25
- package/dist/app/adapters/agent-event-adapter.js.map +1 -1
- package/dist/app/adapters/current-agent-adapter.d.ts +0 -5
- package/dist/app/adapters/current-agent-adapter.js +4 -7
- package/dist/app/adapters/current-agent-adapter.js.map +1 -1
- package/dist/app/adapters/current-jobs-adapter.js +1 -0
- package/dist/app/adapters/current-jobs-adapter.js.map +1 -1
- package/dist/app/adapters/current-terminal-adapter.d.ts +0 -7
- package/dist/app/adapters/current-terminal-adapter.js +0 -7
- package/dist/app/adapters/current-terminal-adapter.js.map +1 -1
- package/dist/app/adapters/current-updates-adapter.d.ts +0 -5
- package/dist/app/adapters/current-updates-adapter.js +0 -5
- package/dist/app/adapters/current-updates-adapter.js.map +1 -1
- package/dist/app/adapters/in-memory-clipboard-adapter.d.ts +0 -5
- package/dist/app/adapters/in-memory-clipboard-adapter.js +0 -5
- package/dist/app/adapters/in-memory-clipboard-adapter.js.map +1 -1
- package/dist/app/commands/command.d.ts +0 -5
- package/dist/app/commands/command.js.map +1 -1
- package/dist/app/commands/registry.d.ts +0 -14
- package/dist/app/commands/registry.js +0 -20
- package/dist/app/commands/registry.js.map +1 -1
- package/dist/app/controllers/disposable.d.ts +0 -5
- package/dist/app/controllers/disposable.js +0 -5
- package/dist/app/controllers/disposable.js.map +1 -1
- package/dist/app/controllers/job-controller.d.ts +1 -5
- package/dist/app/controllers/job-controller.js +0 -4
- package/dist/app/controllers/job-controller.js.map +1 -1
- package/dist/app/controllers/plan-controller.d.ts +5 -8
- package/dist/app/controllers/plan-controller.js +30 -12
- package/dist/app/controllers/plan-controller.js.map +1 -1
- package/dist/app/controllers/session-controller.d.ts +17 -32
- package/dist/app/controllers/session-controller.js +56 -35
- package/dist/app/controllers/session-controller.js.map +1 -1
- package/dist/app/controllers/turn-controller.d.ts +2 -6
- package/dist/app/controllers/turn-controller.js +8 -8
- package/dist/app/controllers/turn-controller.js.map +1 -1
- package/dist/app/events/app-event.d.ts +4 -7
- package/dist/app/events/app-event.js +0 -6
- package/dist/app/events/app-event.js.map +1 -1
- package/dist/app/events/event-buffer.d.ts +6 -17
- package/dist/app/events/event-buffer.js +27 -24
- package/dist/app/events/event-buffer.js.map +1 -1
- package/dist/app/events/sequencer.d.ts +0 -9
- package/dist/app/events/sequencer.js +0 -4
- package/dist/app/events/sequencer.js.map +1 -1
- package/dist/app/ports/agent-port.d.ts +13 -3
- package/dist/app/ports/clipboard-port.d.ts +0 -5
- package/dist/app/ports/confirm-port.d.ts +0 -7
- package/dist/app/ports/jobs-port.d.ts +2 -5
- package/dist/app/ports/persistence-port.d.ts +0 -4
- package/dist/app/ports/secret-port.d.ts +0 -5
- package/dist/app/ports/terminal-port.d.ts +0 -6
- package/dist/app/ports/updates-port.d.ts +0 -4
- package/dist/attachments/service.d.ts +29 -0
- package/dist/attachments/service.js +47 -0
- package/dist/attachments/service.js.map +1 -0
- package/dist/commands/providers.js +0 -3
- package/dist/commands/providers.js.map +1 -1
- package/dist/commands/search-providers.d.ts +0 -33
- package/dist/commands/search-providers.js +0 -33
- package/dist/commands/search-providers.js.map +1 -1
- package/dist/commands/update.js +1 -9
- package/dist/commands/update.js.map +1 -1
- package/dist/index.js +22 -42
- package/dist/index.js.map +1 -1
- package/dist/llm/adapters/ollama-tools.d.ts +0 -4
- package/dist/llm/adapters/ollama-tools.js +0 -4
- package/dist/llm/adapters/ollama-tools.js.map +1 -1
- package/dist/llm/capabilities.d.ts +10 -10
- package/dist/llm/capabilities.js +43 -17
- package/dist/llm/capabilities.js.map +1 -1
- package/dist/llm/groq.js +0 -6
- package/dist/llm/groq.js.map +1 -1
- package/dist/llm/http.d.ts +0 -17
- package/dist/llm/http.js +0 -45
- package/dist/llm/http.js.map +1 -1
- package/dist/llm/router.d.ts +0 -6
- package/dist/llm/router.js +0 -15
- package/dist/llm/router.js.map +1 -1
- package/dist/llm/tool-protocol.d.ts +0 -6
- package/dist/llm/tool-protocol.js +3 -7
- package/dist/llm/tool-protocol.js.map +1 -1
- package/dist/modes/agent.d.ts +2 -1
- package/dist/modes/agent.js +4 -2
- package/dist/modes/agent.js.map +1 -1
- package/dist/modes/ask.d.ts +0 -18
- package/dist/modes/ask.js +0 -49
- package/dist/modes/ask.js.map +1 -1
- package/dist/os/cwd.d.ts +0 -21
- package/dist/os/cwd.js +0 -24
- package/dist/os/cwd.js.map +1 -1
- package/dist/os/pkgmgr.js +0 -6
- package/dist/os/pkgmgr.js.map +1 -1
- package/dist/prompts/index.d.ts +6 -18
- package/dist/prompts/index.js +67 -293
- package/dist/prompts/index.js.map +1 -1
- package/dist/prompts/system.agent.md +242 -0
- package/dist/prompts/system.ask.md +56 -0
- package/dist/repl/prompt-line.js +0 -36
- package/dist/repl/prompt-line.js.map +1 -1
- package/dist/repl/slash-commands.d.ts +0 -9
- package/dist/repl/slash-commands.js +2 -23
- package/dist/repl/slash-commands.js.map +1 -1
- package/dist/repl.d.ts +5 -0
- package/dist/repl.js +169 -55
- package/dist/repl.js.map +1 -1
- package/dist/safety/classifier.js +17 -9
- package/dist/safety/classifier.js.map +1 -1
- package/dist/safety/engagement-policy.d.ts +46 -0
- package/dist/safety/engagement-policy.js +198 -0
- package/dist/safety/engagement-policy.js.map +1 -0
- package/dist/store/engagement.d.ts +115 -0
- package/dist/store/engagement.js +278 -0
- package/dist/store/engagement.js.map +1 -0
- package/dist/store/logs.d.ts +5 -0
- package/dist/store/logs.js +52 -2
- package/dist/store/logs.js.map +1 -1
- package/dist/store/plan.d.ts +57 -5
- package/dist/store/plan.js +182 -8
- package/dist/store/plan.js.map +1 -1
- package/dist/store/scope.d.ts +4 -1
- package/dist/store/scope.js +2 -2
- package/dist/store/scope.js.map +1 -1
- package/dist/tools/definitions.js +70 -1
- package/dist/tools/definitions.js.map +1 -1
- package/dist/tools/elevated-shell.d.ts +60 -0
- package/dist/tools/elevated-shell.js +233 -0
- package/dist/tools/elevated-shell.js.map +1 -0
- package/dist/tools/file-diff.d.ts +125 -0
- package/dist/tools/file-diff.js +495 -0
- package/dist/tools/file-diff.js.map +1 -0
- package/dist/tools/fs.d.ts +11 -13
- package/dist/tools/fs.js +269 -78
- package/dist/tools/fs.js.map +1 -1
- package/dist/tools/http.d.ts +15 -0
- package/dist/tools/http.js +202 -61
- package/dist/tools/http.js.map +1 -1
- package/dist/tools/jobs.d.ts +71 -10
- package/dist/tools/jobs.js +403 -124
- package/dist/tools/jobs.js.map +1 -1
- package/dist/tools/nmap-runner.d.ts +11 -0
- package/dist/tools/nmap-runner.js +115 -74
- package/dist/tools/nmap-runner.js.map +1 -1
- package/dist/tools/pentest-workflows.d.ts +8 -0
- package/dist/tools/pentest-workflows.js +71 -0
- package/dist/tools/pentest-workflows.js.map +1 -0
- package/dist/tools/registry.d.ts +9 -0
- package/dist/tools/registry.js +227 -56
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/shell.d.ts +3 -0
- package/dist/tools/shell.js +35 -13
- package/dist/tools/shell.js.map +1 -1
- package/dist/tools/tool-types.d.ts +11 -0
- package/dist/tui-v2/app/App.js +25 -39
- package/dist/tui-v2/app/App.js.map +1 -1
- package/dist/tui-v2/app/command-handlers.js +20 -1
- package/dist/tui-v2/app/command-handlers.js.map +1 -1
- package/dist/tui-v2/app/commands/picker-commands.js +3 -0
- package/dist/tui-v2/app/commands/picker-commands.js.map +1 -1
- package/dist/tui-v2/app/commands/session-commands.js +5 -0
- package/dist/tui-v2/app/commands/session-commands.js.map +1 -1
- package/dist/tui-v2/app/plan-lifecycle.d.ts +25 -3
- package/dist/tui-v2/app/plan-lifecycle.js +87 -24
- package/dist/tui-v2/app/plan-lifecycle.js.map +1 -1
- package/dist/tui-v2/bootstrap/patch-opentui-text.d.ts +6 -0
- package/dist/tui-v2/bootstrap/patch-opentui-text.js +53 -0
- package/dist/tui-v2/bootstrap/patch-opentui-text.js.map +1 -0
- package/dist/tui-v2/bootstrap/start-tui-v2.js +7 -0
- package/dist/tui-v2/bootstrap/start-tui-v2.js.map +1 -1
- package/dist/tui-v2/components/jobs/jobs-panel.js +23 -14
- package/dist/tui-v2/components/jobs/jobs-panel.js.map +1 -1
- package/dist/tui-v2/components/modal/confirm-modal.d.ts +1 -1
- package/dist/tui-v2/components/modal/confirm-modal.js +18 -7
- package/dist/tui-v2/components/modal/confirm-modal.js.map +1 -1
- package/dist/tui-v2/components/overlay/overlay-host.js +1 -1
- package/dist/tui-v2/components/overlay/overlay-host.js.map +1 -1
- package/dist/tui-v2/components/pager/pager.d.ts +6 -0
- package/dist/tui-v2/components/pager/pager.js +313 -67
- package/dist/tui-v2/components/pager/pager.js.map +1 -1
- package/dist/tui-v2/components/status/status-line.d.ts +9 -0
- package/dist/tui-v2/components/status/status-line.js +17 -3
- package/dist/tui-v2/components/status/status-line.js.map +1 -1
- package/dist/tui-v2/components/transcript/assistant-message.js +1 -1
- package/dist/tui-v2/components/transcript/assistant-message.js.map +1 -1
- package/dist/tui-v2/components/transcript/compacted-row.js +1 -1
- package/dist/tui-v2/components/transcript/compacted-row.js.map +1 -1
- package/dist/tui-v2/components/transcript/tool-card.d.ts +9 -1
- package/dist/tui-v2/components/transcript/tool-card.js +114 -9
- package/dist/tui-v2/components/transcript/tool-card.js.map +1 -1
- package/dist/tui-v2/components/transcript/transcript-row.js +2 -2
- package/dist/tui-v2/components/transcript/transcript-row.js.map +1 -1
- package/dist/tui-v2/composer/composer-editor.js +16 -0
- package/dist/tui-v2/composer/composer-editor.js.map +1 -1
- package/dist/tui-v2/controllers/overlay-controller.d.ts +16 -2
- package/dist/tui-v2/controllers/overlay-controller.js +92 -12
- package/dist/tui-v2/controllers/overlay-controller.js.map +1 -1
- package/dist/tui-v2/rendering/artifact-pager-source.d.ts +19 -0
- package/dist/tui-v2/rendering/artifact-pager-source.js +103 -0
- package/dist/tui-v2/rendering/artifact-pager-source.js.map +1 -0
- package/dist/tui-v2/rendering/file-diff-view.d.ts +63 -0
- package/dist/tui-v2/rendering/file-diff-view.js +263 -0
- package/dist/tui-v2/rendering/file-diff-view.js.map +1 -0
- package/dist/tui-v2/rendering/open-tool-output.d.ts +9 -1
- package/dist/tui-v2/rendering/open-tool-output.js +65 -12
- package/dist/tui-v2/rendering/open-tool-output.js.map +1 -1
- package/dist/tui-v2/rendering/pager-chrome.d.ts +12 -0
- package/dist/tui-v2/rendering/pager-chrome.js +56 -0
- package/dist/tui-v2/rendering/pager-chrome.js.map +1 -0
- package/dist/tui-v2/rendering/plan-view.js +1 -1
- package/dist/tui-v2/rendering/plan-view.js.map +1 -1
- package/dist/tui-v2/rendering/syntax-highlight.d.ts +37 -0
- package/dist/tui-v2/rendering/syntax-highlight.js +1303 -0
- package/dist/tui-v2/rendering/syntax-highlight.js.map +1 -0
- package/dist/tui-v2/rendering/theme.d.ts +28 -0
- package/dist/tui-v2/rendering/theme.js +28 -0
- package/dist/tui-v2/rendering/theme.js.map +1 -1
- package/dist/tui-v2/rendering/tool-presenter.d.ts +5 -0
- package/dist/tui-v2/rendering/tool-presenter.js +35 -3
- package/dist/tui-v2/rendering/tool-presenter.js.map +1 -1
- package/dist/tui-v2/state/transcript-hydrate.js +5 -0
- package/dist/tui-v2/state/transcript-hydrate.js.map +1 -1
- package/dist/tui-v2/state/transcript-reducer.js +12 -2
- package/dist/tui-v2/state/transcript-reducer.js.map +1 -1
- package/dist/tui-v2/state/transcript-store.d.ts +7 -0
- package/dist/tui-v2/state/transcript-store.js +46 -0
- package/dist/tui-v2/state/transcript-store.js.map +1 -1
- package/dist/tui-v2/state/transcript-types.d.ts +12 -0
- package/dist/tui-v2/state/transcript-types.js +9 -0
- package/dist/tui-v2/state/transcript-types.js.map +1 -1
- package/dist/types.d.ts +17 -1
- package/dist/ui/mentions.d.ts +6 -0
- package/dist/ui/mentions.js +50 -12
- package/dist/ui/mentions.js.map +1 -1
- package/dist/ui/plan-pane.js +1 -1
- package/dist/ui/plan-pane.js.map +1 -1
- package/package.json +16 -11
package/dist/agent/runner.js
CHANGED
|
@@ -1,125 +1,66 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
|
-
import {
|
|
3
|
-
import { join, relative, resolve } from "node:path";
|
|
2
|
+
import { join } from "node:path";
|
|
4
3
|
import { streamWithProvider, completeWithProvider } from "../llm/router.js";
|
|
5
4
|
import { resolveToolDialect } from "../llm/capabilities.js";
|
|
6
5
|
import { syntheticToolCallId, isTextOnlyModel, fromWireName, } from "../llm/tool-protocol.js";
|
|
7
6
|
import { sanitizeAssistantText } from "../ui/ansi-box.js";
|
|
8
7
|
import { randomUUID } from "node:crypto";
|
|
9
8
|
import { jobManager } from "../tools/jobs.js";
|
|
10
|
-
import { renderAgentSystemPrompt, renderCompactAgentSystemPrompt, scratchDirFor, toolNudge, } from "../prompts/index.js";
|
|
9
|
+
import { agentModeDirective, planModeDirective, renderAgentSystemPrompt, renderCompactAgentSystemPrompt, scratchDirFor, toolNudge, } from "../prompts/index.js";
|
|
11
10
|
import { getConfig } from "../store/config.js";
|
|
12
11
|
import { groqInputTokenBudget } from "../llm/groq.js";
|
|
13
12
|
import { classifyToolCall, isPentestToolCall, scopeHint, scopeTargetForToolCall, } from "../safety/classifier.js";
|
|
14
13
|
import { availableToolNames, normalizeToolCall, runToolCall, BATCH_SAFE_TOOLS, } from "../tools/registry.js";
|
|
15
14
|
import { getToolDefinitions, getCompactToolDefinitions, PLAN_TOOL_NAMES, } from "../tools/definitions.js";
|
|
16
|
-
import { appendAssistantWithTools, appendToolResult, fillMissingToolResults, } from "./tool-history.js";
|
|
17
|
-
import { looksInteractiveStdin } from "../tools/shell.js";
|
|
15
|
+
import { appendAssistantWithTools, appendToolResult, assertValidToolProtocol, fillMissingToolResults, } from "./tool-history.js";
|
|
18
16
|
import { formatViewportHint, registerViewport } from "../ui/output-pane.js";
|
|
19
|
-
import { compactMessagesWithSummary,
|
|
17
|
+
import { compactMessagesWithSummary, estimateMessagesTokens, AUTO_COMPACT_TOKEN_BUDGET, COMPACTION_MEMORY_PREFIX, } from "./context-manager.js";
|
|
20
18
|
import { auditLog } from "../store/logs.js";
|
|
21
19
|
import { loadProjectContext } from "../store/project.js";
|
|
22
|
-
import { loadScope, isScopeActive
|
|
20
|
+
import { loadScope, isScopeActive } from "../store/scope.js";
|
|
23
21
|
import { ensureProviderConfigured } from "../commands/providers.js";
|
|
24
22
|
import { createThinkingStreamParser, rememberThinkingFromText, renderThinkingSummary, stripThinking, } from "../ui/thinking.js";
|
|
25
23
|
import { renderMarkdown, indentAndWrapText } from "../ui/markdown.js";
|
|
26
24
|
import { startThinkingSpinner } from "../ui/spinner.js";
|
|
27
25
|
import { safeCwd } from "../os/cwd.js";
|
|
28
|
-
import { analyzeTask } from "./task-analyzer.js";
|
|
26
|
+
import { analyzeTask, formatTaskAnalysisHint, isNarrowExplicitNmapOperation, } from "./task-analyzer.js";
|
|
27
|
+
import { computeMaxIterations, computeStepBudget } from "./step-budget.js";
|
|
28
|
+
import { isScratchOnlyWrite } from "./scratch-write.js";
|
|
29
|
+
import { COMPACTION_SYSTEM_PROMPT, } from "./compaction-summary.js";
|
|
29
30
|
import { LoopGuard } from "./loop-guard.js";
|
|
30
|
-
import { loadPlan, savePlan, markTask, } from "../store/plan.js";
|
|
31
|
-
import {
|
|
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";
|
|
33
|
-
import { createSessionPolicy, isPreApprovalAllowedTool, isPlanApprovedByStatus, planHasOpenWork, isAbortError, shouldEnableImageOcr, } from "./session-policy.js";
|
|
31
|
+
import { loadPlan, savePlan, markTask, readyPlanTasks, isPlanTerminal, isPlanSuccessful, } from "../store/plan.js";
|
|
32
|
+
import { fsWrite, isOutsideWorkingDirectory, resolveFsToolPath, } from "../tools/fs.js";
|
|
33
|
+
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, narrowNmapOperationDirective, pentestWorkflowDirective, pentestNoLocalServerDirective, shouldDimToolChatter, looksLikePromptLeak, } from "./tool-call-parser.js";
|
|
34
|
+
import { createSessionPolicy, isPreApprovalAllowedTool, isPlanModeAllowedShellCommand, isPlanModeAllowedTool, isPlanApprovedByStatus, planHasOpenWork, isAbortError, shouldEnableImageOcr, } from "./session-policy.js";
|
|
34
35
|
import { saveToolOutput, summarizeOutput, formatToolContext, } from "./tool-output-formatting.js";
|
|
35
36
|
import { renderPlanForTerminal, planContextMessage, handlePlanTool, resolvePlanTaskId, } from "./plan-tool.js";
|
|
36
|
-
import { applyDestinationCwd, canMarkTaskDone,
|
|
37
|
+
import { absorbLooseWorkIntoLedger, applyDestinationCwd, canMarkTaskDone, hasLocalRuntimeProof, hasRemoteWorkProof, isBatchSoftFailTool, isDevServerCall, isEvidenceWorkTool, isFeatureImplementationCall, isPackageInstallCommand, isPlanPreflightTool, isPortListeningOutput, isReadOnlyReconTool, isRemoteActiveTestCall, isRemoteObservationTask, isRemoteReconToolCall, isRuntimeObservationTask, isScaffoldCreateCommand, isServerReadyOutput, ledgerFromTaskEvidence, pickPendingTaskForToolCall, recordTaskWorkSuccess, resolveUserDestinationHint, taskEvidenceFromLedger, toolStallBudgetMs, userAskedForFeatureApp, } from "./task-evidence.js";
|
|
38
|
+
import { buildSessionStateBlock, inferNextHint, upsertSessionStateMessage, } from "./session-state.js";
|
|
39
|
+
import { detectPackageManager } from "./workspace-orient.js";
|
|
40
|
+
import { budgetRemaining, consumeBudget, createRecoveryBudgets, freestyleClaimsAppReady, looksLikeShallowPentestReport, recoveryForErrorDiagnosis, recoveryForFailedProbe, recoveryForFreshness, recoveryForMissingFeature, recoveryForMissingPlan, recoveryForNarration, recoveryForPrematureComplete, recoveryForRuntimeVerify, recoveryForShallowPentest, } from "./must-continue.js";
|
|
41
|
+
import { scopeContextMessage } from "./scope-context.js";
|
|
42
|
+
import { EngagementPolicyEngine, actionFromUrl, engagementActionForToolCall, evaluateEngagementAction, } from "../safety/engagement-policy.js";
|
|
43
|
+
import { patchPlanMeta } from "../store/plan.js";
|
|
37
44
|
import { extractProjectRootFromPlan, extractProjectRootFromScaffold, extractProjectRootFromText, getActiveProjectRoot, setActiveProjectRootIfValid, } from "./project-root.js";
|
|
38
|
-
import { buildWorkspaceOrientation, guessProjectFolderName, isScaffoldCancelledOutput, scaffoldLooksMaterialized, scaffoldTargetConflictMessage, } from "./workspace-orient.js";
|
|
45
|
+
import { buildWorkspaceOrientation, discoverImmediateProjectRoots, guessProjectFolderName, isBareParentDirectory, isScaffoldCancelledOutput, scaffoldLooksMaterialized, scaffoldTargetConflictMessage, resolveScaffoldTargetPath, } from "./workspace-orient.js";
|
|
39
46
|
import { inquirerConfirmPort, restoreInteractiveStdin, ensurePentestAuthorization, confirmToolExecution, } from "./confirm-port.js";
|
|
40
47
|
import { buildRichStopSummary } from "./stop-summary.js";
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
48
|
+
import { composeAgentSystemPrompt } from "./prompt-composer.js";
|
|
49
|
+
import { createGovernorState, governProgress, } from "./evidence-governor.js";
|
|
50
|
+
import { createTurnState, transitionTurn, } from "./turn-state.js";
|
|
51
|
+
import { deriveOutcomeStatus, inferOutcomeKind, openOutcomeState, recordAnswerEvidence, recordFailedHypothesis, recordToolEvidence, saveOutcomeState, validateCriterionEvidence, } from "./outcomes.js";
|
|
52
|
+
import { createTurnOutcome, renderTurnOutcome } from "./turn-outcome.js";
|
|
53
|
+
import { beginEngagementAction, finishEngagementAction, recordEngagementCheckpoint, reconcileEngagementJob, openEngagement, saveEngagement, } from "../store/engagement.js";
|
|
45
54
|
export * from "./tool-call-parser.js";
|
|
46
55
|
export { createSessionPolicy, isPreApprovalAllowedTool, isPlanApprovedByStatus, planHasOpenWork, shouldEnableImageOcr, } from "./session-policy.js";
|
|
47
56
|
export function styleToolChatter(call, text) {
|
|
48
57
|
return shouldDimToolChatter(call) ? chalk.dim(text) : text;
|
|
49
58
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
*/
|
|
56
|
-
const SCRATCH_WRITABLE_TOOLS = new Set([
|
|
57
|
-
"fs.write",
|
|
58
|
-
"fs.writeMany",
|
|
59
|
-
"fs.edit",
|
|
60
|
-
"fs.replaceLines",
|
|
61
|
-
"fs.append",
|
|
62
|
-
"fs.delete",
|
|
63
|
-
]);
|
|
64
|
-
/**
|
|
65
|
-
* Expand `~` the same way `src/tools/fs.ts` does so callers can compare an
|
|
66
|
-
* already-expanded scratch path against paths supplied by the model.
|
|
67
|
-
*/
|
|
68
|
-
function expandHomeLocal(path) {
|
|
69
|
-
if (path === "~")
|
|
70
|
-
return homedir();
|
|
71
|
-
if (path.startsWith("~/") || path.startsWith("~\\")) {
|
|
72
|
-
return resolve(homedir(), path.slice(2));
|
|
73
|
-
}
|
|
74
|
-
return path;
|
|
75
|
-
}
|
|
76
|
-
/**
|
|
77
|
-
* Extract the target path(s) a write-shaped tool call would touch. Returns
|
|
78
|
-
* an empty array when the call has no resolvable path (so the caller can
|
|
79
|
-
* treat it as NOT scratch-only and fall through to the normal gates).
|
|
80
|
-
*/
|
|
81
|
-
function scratchWriteTargetPaths(call) {
|
|
82
|
-
if (call.name === "fs.writeMany") {
|
|
83
|
-
const files = call.args.files;
|
|
84
|
-
if (!Array.isArray(files))
|
|
85
|
-
return [];
|
|
86
|
-
const paths = [];
|
|
87
|
-
for (const entry of files) {
|
|
88
|
-
if (entry && typeof entry === "object" && "path" in entry) {
|
|
89
|
-
const p = entry.path;
|
|
90
|
-
if (typeof p === "string" && p.length > 0)
|
|
91
|
-
paths.push(p);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
return paths;
|
|
95
|
-
}
|
|
96
|
-
const pathArg = call.args.path;
|
|
97
|
-
if (typeof pathArg !== "string" || pathArg.length === 0)
|
|
98
|
-
return [];
|
|
99
|
-
return [pathArg];
|
|
100
|
-
}
|
|
101
|
-
/**
|
|
102
|
-
* True iff every target path this call would write is inside the resolved
|
|
103
|
-
* scratch directory. A path is considered inside when its `path.relative`
|
|
104
|
-
* against the scratch root is empty (the scratch root itself) or does not
|
|
105
|
-
* start with `..` (no parent traversal). Calls without a recognizable
|
|
106
|
-
* target path return false so they fall through to the normal gates.
|
|
107
|
-
*/
|
|
108
|
-
function isScratchOnlyWrite(call, scratchDir) {
|
|
109
|
-
if (!SCRATCH_WRITABLE_TOOLS.has(call.name))
|
|
110
|
-
return false;
|
|
111
|
-
const paths = scratchWriteTargetPaths(call);
|
|
112
|
-
if (paths.length === 0)
|
|
113
|
-
return false;
|
|
114
|
-
const resolvedScratch = resolve(scratchDir);
|
|
115
|
-
return paths.every((raw) => {
|
|
116
|
-
const expanded = expandHomeLocal(raw);
|
|
117
|
-
const resolved = resolve(expanded);
|
|
118
|
-
const rel = relative(resolvedScratch, resolved);
|
|
119
|
-
return rel === "" || (!rel.startsWith("..") && rel !== "..");
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
export async function runAgentLoop(prompt, options = {}) {
|
|
59
|
+
export async function runAgentTurn(prompt, options = {}) {
|
|
60
|
+
const agentMode = options.mode === "plan" || options.mode === "agent" || options.mode === "ask"
|
|
61
|
+
? options.mode
|
|
62
|
+
: "agent";
|
|
63
|
+
const isPlanMode = agentMode === "plan";
|
|
123
64
|
const writesDirectly = !options.onEvent;
|
|
124
65
|
const emit = (event) => options.onEvent?.(event);
|
|
125
66
|
// Whether the CURRENT model iteration has already committed its visible
|
|
@@ -221,6 +162,9 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
221
162
|
if (artifactPath) {
|
|
222
163
|
event.artifactPath = artifactPath;
|
|
223
164
|
}
|
|
165
|
+
if (result.fileChanges && result.fileChanges.length > 0) {
|
|
166
|
+
event.fileChanges = result.fileChanges;
|
|
167
|
+
}
|
|
224
168
|
emit(event);
|
|
225
169
|
};
|
|
226
170
|
/** Strip a known prefix from a string, returning the remainder unchanged. */
|
|
@@ -243,20 +187,36 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
243
187
|
// conversation back to the caller. Assigned once `messages` is built below;
|
|
244
188
|
// all later mutations are in-place so this reference stays current.
|
|
245
189
|
let liveMessages = [];
|
|
246
|
-
const finishTurn = (answer, steps) => {
|
|
190
|
+
const finishTurn = (answer, steps, status = "succeeded", remainingCriteria = [], reason) => {
|
|
191
|
+
const outcome = createTurnOutcome({
|
|
192
|
+
status,
|
|
193
|
+
answer,
|
|
194
|
+
steps,
|
|
195
|
+
remainingCriteria,
|
|
196
|
+
reason,
|
|
197
|
+
});
|
|
198
|
+
const rendered = renderTurnOutcome(outcome);
|
|
199
|
+
writeAssistantMessage(rendered);
|
|
247
200
|
if (options.onMessages) {
|
|
248
201
|
try {
|
|
249
|
-
options.onMessages(buildTurnHistory(liveMessages,
|
|
202
|
+
options.onMessages(buildTurnHistory(liveMessages, rendered));
|
|
250
203
|
}
|
|
251
204
|
catch {
|
|
252
205
|
// Persisting history must never break the turn.
|
|
253
206
|
}
|
|
254
207
|
}
|
|
255
|
-
|
|
256
|
-
|
|
208
|
+
options.onOutcome?.(outcome);
|
|
209
|
+
emit({ type: "turn-end", outcome, finalAnswer: rendered, steps });
|
|
210
|
+
return outcome;
|
|
257
211
|
};
|
|
258
212
|
try {
|
|
259
|
-
emit({
|
|
213
|
+
emit({
|
|
214
|
+
type: "turn-start",
|
|
215
|
+
prompt,
|
|
216
|
+
...(options.displayPrompt !== undefined
|
|
217
|
+
? { displayPrompt: options.displayPrompt }
|
|
218
|
+
: {}),
|
|
219
|
+
});
|
|
260
220
|
const config = getConfig();
|
|
261
221
|
const maxSteps = options.maxSteps ?? 70;
|
|
262
222
|
const confirmPort = options.confirm ?? inquirerConfirmPort;
|
|
@@ -274,6 +234,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
274
234
|
// agent burns its turn searching the date instead of writing files.
|
|
275
235
|
const buildLikeTurn = looksLikeBuildTask(prompt, options.history);
|
|
276
236
|
const pentestLikeTurn = looksLikePentestTask(prompt, options.history);
|
|
237
|
+
const narrowNmapOperation = isNarrowExplicitNmapOperation(prompt);
|
|
277
238
|
// A plain informational follow-up ("what do you know so far", "summarize
|
|
278
239
|
// the findings") in a resumed/continuing build or pentest session must
|
|
279
240
|
// NOT inherit that session's "must act" behavior — it should be answered
|
|
@@ -331,16 +292,33 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
331
292
|
session.planApproved.value = true;
|
|
332
293
|
}
|
|
333
294
|
const destinationHint = resolveUserDestinationHint(prompt);
|
|
295
|
+
const orientationSourceText = [
|
|
296
|
+
prompt,
|
|
297
|
+
activePlan?.goal,
|
|
298
|
+
activePlan?.detail,
|
|
299
|
+
activePlan?.tasks.map((task) => task.title).join(" "),
|
|
300
|
+
].filter(Boolean).join("\n");
|
|
301
|
+
const fromPlan = extractProjectRootFromPlan(activePlan);
|
|
302
|
+
const fromPrompt = extractProjectRootFromText(prompt);
|
|
303
|
+
const guessedName = guessProjectFolderName(orientationSourceText);
|
|
304
|
+
const orientationParent = destinationHint ?? (isBareParentDirectory(safeCwd()) ? safeCwd() : undefined);
|
|
305
|
+
const guessedProject = orientationParent && guessedName ? join(orientationParent, guessedName) : undefined;
|
|
306
|
+
const discoveredProjects = orientationParent
|
|
307
|
+
? discoverImmediateProjectRoots(orientationParent)
|
|
308
|
+
: [];
|
|
334
309
|
// Sticky project root so relative fs paths never hit the agent package.
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
310
|
+
// Preference is explicit durable plan metadata, explicit prompt paths,
|
|
311
|
+
// exact natural-language folder guesses, then one unambiguous discovered
|
|
312
|
+
// project. Never pin bare Desktop/home or invent a path before it exists.
|
|
313
|
+
let pinnedProject = false;
|
|
314
|
+
for (const candidate of [fromPlan, fromPrompt, guessedProject]) {
|
|
315
|
+
if (setActiveProjectRootIfValid(candidate)) {
|
|
316
|
+
pinnedProject = true;
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (!pinnedProject && discoveredProjects.length === 1) {
|
|
321
|
+
setActiveProjectRootIfValid(discoveredProjects[0]);
|
|
344
322
|
}
|
|
345
323
|
const buildSystemContent = (native) => {
|
|
346
324
|
const sections = [
|
|
@@ -401,25 +379,38 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
401
379
|
if (activePlan) {
|
|
402
380
|
systemSections.push(planContextMessage(activePlan, session.planApproved.value));
|
|
403
381
|
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
382
|
+
if (isPlanMode) {
|
|
383
|
+
systemSections.push(planModeDirective());
|
|
384
|
+
}
|
|
385
|
+
else if (agentMode === "agent") {
|
|
386
|
+
systemSections.push(agentModeDirective());
|
|
387
|
+
}
|
|
388
|
+
// Build focus card: orientation + feature quality, not forced plan theater.
|
|
410
389
|
if (buildLikeTurn &&
|
|
411
|
-
!
|
|
390
|
+
!informationalQuery &&
|
|
391
|
+
!idleOrSocialPrompt &&
|
|
392
|
+
!isPlanMode) {
|
|
393
|
+
systemSections.push(buildWorkflowDirective());
|
|
394
|
+
}
|
|
395
|
+
if (isPlanMode &&
|
|
396
|
+
buildLikeTurn &&
|
|
412
397
|
!informationalQuery &&
|
|
413
398
|
!idleOrSocialPrompt) {
|
|
414
399
|
systemSections.push(buildWorkflowDirective());
|
|
415
400
|
}
|
|
416
|
-
//
|
|
401
|
+
// A bounded explicit nmap request is one operation, not an invitation to
|
|
402
|
+
// manufacture a full engagement plan or add unrelated recon steps.
|
|
403
|
+
if (narrowNmapOperation &&
|
|
404
|
+
!informationalQuery &&
|
|
405
|
+
!idleOrSocialPrompt &&
|
|
406
|
+
!isPlanMode) {
|
|
407
|
+
systemSections.push(narrowNmapOperationDirective());
|
|
408
|
+
}
|
|
409
|
+
// Broader pentest / security engagements need a different shape than a coding
|
|
417
410
|
// build: recon first, then a plan built from real findings, then
|
|
418
|
-
// incremental task additions as new attack surface appears.
|
|
419
|
-
// directive is only injected before a plan exists; once a plan is in
|
|
420
|
-
// place (or being refined), the ACTIVE PLAN block already carries the
|
|
421
|
-
// current task state and recon-vs-active-tool guidance.
|
|
411
|
+
// incremental task additions as new attack surface appears.
|
|
422
412
|
if (pentestLikeTurn &&
|
|
413
|
+
!narrowNmapOperation &&
|
|
423
414
|
!activePlan &&
|
|
424
415
|
!informationalQuery &&
|
|
425
416
|
!idleOrSocialPrompt) {
|
|
@@ -435,19 +426,90 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
435
426
|
if (pentestSession && !idleOrSocialPrompt) {
|
|
436
427
|
systemSections.push(pentestNoLocalServerDirective());
|
|
437
428
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
429
|
+
{
|
|
430
|
+
const engScope = await loadScope().catch(() => undefined);
|
|
431
|
+
const scopeBlock = scopeContextMessage(engScope);
|
|
432
|
+
if (scopeBlock && (pentestSession || pentestLikeTurn) && !idleOrSocialPrompt) {
|
|
433
|
+
systemSections.push(scopeBlock);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
// Soft task analysis for multi-step work (never a forced plan script).
|
|
437
|
+
{
|
|
438
|
+
const earlyAnalysis = analyzeTask(prompt);
|
|
439
|
+
if (!idleOrSocialPrompt &&
|
|
440
|
+
!informationalQuery &&
|
|
441
|
+
!narrowNmapOperation &&
|
|
442
|
+
(earlyAnalysis.shouldPlan ||
|
|
443
|
+
earlyAnalysis.complexity === "complex" ||
|
|
444
|
+
buildLikeTurn ||
|
|
445
|
+
pentestLikeTurn)) {
|
|
446
|
+
systemSections.push(formatTaskAnalysisHint(earlyAnalysis));
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const promptSections = () => {
|
|
450
|
+
const sections = systemSections.slice(1).map((content) => ({
|
|
451
|
+
kind: content.startsWith("ACTIVE PLAN")
|
|
452
|
+
? "plan"
|
|
453
|
+
: content.startsWith("ENGAGEMENT SCOPE")
|
|
454
|
+
? "scope"
|
|
455
|
+
: content.includes("MODE")
|
|
456
|
+
? "mode"
|
|
457
|
+
: content.includes("OUTCOME")
|
|
458
|
+
? "outcome"
|
|
459
|
+
: content.includes("WORKFLOW") || content.includes("FOCUS")
|
|
460
|
+
? "focus"
|
|
461
|
+
: "context",
|
|
462
|
+
content,
|
|
463
|
+
mandatory: content.startsWith("ACTIVE PLAN") ||
|
|
464
|
+
content.startsWith("ENGAGEMENT SCOPE") ||
|
|
465
|
+
content.includes("MODE") ||
|
|
466
|
+
content.includes("OUTCOME"),
|
|
467
|
+
}));
|
|
468
|
+
const has = (kind) => sections.some((section) => section.kind === kind);
|
|
469
|
+
if (!has("outcome")) {
|
|
470
|
+
sections.push({
|
|
471
|
+
kind: "outcome",
|
|
472
|
+
content: `OUTCOME CONTRACT\nGoal: ${prompt}\nSuccess requires evidence that the requested result is complete; otherwise return partial, blocked, failed, aborted, or paused_budget with remaining criteria.`,
|
|
473
|
+
mandatory: true,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
if (!has("plan")) {
|
|
477
|
+
sections.push({
|
|
478
|
+
kind: "plan",
|
|
479
|
+
content: "ACTIVE PLAN\nNo persisted plan is active for this turn.",
|
|
480
|
+
mandatory: true,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
if (!has("scope")) {
|
|
484
|
+
sections.push({
|
|
485
|
+
kind: "scope",
|
|
486
|
+
content: "ENGAGEMENT SCOPE\nNo active remote-security scope applies to this turn.",
|
|
487
|
+
mandatory: true,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
sections.push({
|
|
491
|
+
kind: "context",
|
|
492
|
+
content: `TASK STATE\nMode: ${agentMode}. Current request: ${prompt}`,
|
|
493
|
+
mandatory: true,
|
|
494
|
+
});
|
|
495
|
+
return sections;
|
|
496
|
+
};
|
|
497
|
+
const composeCurrentSystemPrompt = (native) => composeAgentSystemPrompt({
|
|
498
|
+
mode: agentMode,
|
|
499
|
+
nativeToolsActive: native,
|
|
500
|
+
maxTokens: inputTokenBudget
|
|
501
|
+
? Math.min(2_000, Math.floor(inputTokenBudget * 0.4))
|
|
502
|
+
: undefined,
|
|
503
|
+
sections: [
|
|
504
|
+
{
|
|
505
|
+
kind: "constitution",
|
|
506
|
+
content: buildSystemContent(native),
|
|
507
|
+
mandatory: true,
|
|
508
|
+
},
|
|
509
|
+
...promptSections(),
|
|
510
|
+
],
|
|
511
|
+
}).content;
|
|
512
|
+
const fullSystemPrompt = composeCurrentSystemPrompt(nativeToolsActive);
|
|
451
513
|
const userMessage = { role: "user", content: prompt };
|
|
452
514
|
if (options.images && options.images.length > 0) {
|
|
453
515
|
userMessage.images = options.images;
|
|
@@ -458,6 +520,8 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
458
520
|
userMessage,
|
|
459
521
|
];
|
|
460
522
|
liveMessages = messages;
|
|
523
|
+
/** Assigned after session flags exist — see below. */
|
|
524
|
+
let refreshSessionState = () => undefined;
|
|
461
525
|
const recoveryUserMessage = (content) => {
|
|
462
526
|
const message = { role: "user", content };
|
|
463
527
|
if (options.images && options.images.length > 0) {
|
|
@@ -468,19 +532,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
468
532
|
}
|
|
469
533
|
return message;
|
|
470
534
|
};
|
|
471
|
-
// Every provider must receive a syntactically valid assistant turn between
|
|
472
|
-
// the original user prompt and a recovery nudge. In particular, Gemini
|
|
473
|
-
// serializes an empty assistant message as an empty `model` text part,
|
|
474
|
-
// which can cause every retry to return empty as well. Keep hidden thinking
|
|
475
|
-
// out of history, but record a compact non-empty sentinel when there was no
|
|
476
|
-
// visible output.
|
|
477
|
-
//
|
|
478
|
-
// The Ink reducer intentionally keeps streamed text transient until it
|
|
479
|
-
// receives `assistant-message`, because a stream may turn out to be raw
|
|
480
|
-
// tool JSON. Recovery paths used to add prose to model history and retry
|
|
481
|
-
// without that event, so the user could watch a valid-looking response
|
|
482
|
-
// vanish when the retry emitted its first tool call. Surface only prose
|
|
483
|
-
// that is safe to render; malformed/bare tool payloads remain hidden.
|
|
484
535
|
const recoveryProse = (content) => {
|
|
485
536
|
const text = textBeforeToolCall(stripSentinelTokens(content)).trim();
|
|
486
537
|
if (!text ||
|
|
@@ -491,10 +542,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
491
542
|
return text;
|
|
492
543
|
};
|
|
493
544
|
const pushAssistantHistory = (content) => {
|
|
494
|
-
// Preserve genuine streamed prose before a recovery retry so the visible
|
|
495
|
-
// text isn't wiped by the next tool-call/turn event. Skip when this
|
|
496
|
-
// iteration already surfaced its prose (the normal tool path commits
|
|
497
|
-
// `beforeTool` itself) so the same text is never rendered twice.
|
|
498
545
|
const cleaned = sanitizeAssistantText(content);
|
|
499
546
|
if (!visibleCommitted) {
|
|
500
547
|
const prose = recoveryProse(cleaned);
|
|
@@ -508,109 +555,229 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
508
555
|
: "[No visible assistant response was produced.]",
|
|
509
556
|
});
|
|
510
557
|
};
|
|
511
|
-
// Track recent tool calls to detect models stuck in a loop calling the
|
|
512
|
-
// same tool with the same arguments over and over (e.g. pentest.recon
|
|
513
|
-
// called 3× on the same target without summarizing).
|
|
514
558
|
const loopGuard = new LoopGuard();
|
|
559
|
+
const engagementPolicy = new EngagementPolicyEngine();
|
|
515
560
|
// Track consecutive thinking-only responses so we can nudge the model
|
|
516
561
|
// to actually act instead of silently returning an empty answer.
|
|
517
562
|
let emptyVisibleRetries = 0;
|
|
518
|
-
// A model that spent an entire completion in hidden reasoning gets one
|
|
519
|
-
// visible-output retry with provider thinking disabled. This is per-turn
|
|
520
|
-
// only: a subsequent successful response restores the configured setting.
|
|
521
563
|
let retryWithoutThinking = false;
|
|
522
564
|
// Track tool calls truncated by the token limit so we can ask the model
|
|
523
565
|
// to retry in smaller pieces instead of leaking broken JSON as an answer.
|
|
524
566
|
let truncatedToolRetries = 0;
|
|
525
|
-
// Track bare-args JSON tool calls (missing the {name,args} wrapper / fence)
|
|
526
|
-
// so we can nudge the model to re-emit a proper fenced call a few times
|
|
527
|
-
// before giving up, instead of leaking the JSON as a final answer.
|
|
528
567
|
let bareToolJsonRetries = 0;
|
|
529
568
|
// Track a ```tool fence that is present but whose JSON could not be parsed
|
|
530
569
|
// (e.g. malformed extra/missing braces that are NOT simple truncation). We
|
|
531
570
|
// retry instead of leaking the raw block as the final answer.
|
|
532
571
|
let malformedFenceRetries = 0;
|
|
533
|
-
// For volatile live-info prompts, make one corrective pass if a model
|
|
534
|
-
// ignores the freshness guard and tries to answer from stale memory.
|
|
535
572
|
let sawFreshWebSearch = false;
|
|
536
|
-
|
|
537
|
-
// Guard against a model that declares an approved plan "complete" while
|
|
538
|
-
// tasks are still pending and it never ran the work. We nudge it back to
|
|
539
|
-
// executing the next task a bounded number of times before giving up.
|
|
540
|
-
let prematureCompletionRetries = 0;
|
|
541
|
-
let runtimeVerificationRetries = 0;
|
|
542
|
-
let featureImplRetries = 0;
|
|
543
|
-
let forcePlanRetries = 0;
|
|
544
|
-
let errorFixNarrationRetries = 0;
|
|
545
|
-
let failedProbeFixRetries = 0;
|
|
573
|
+
const recovery = createRecoveryBudgets();
|
|
546
574
|
let sawServerStart = false;
|
|
547
575
|
let sawPlanCreateOk = false;
|
|
548
576
|
let sawServerTail = false;
|
|
549
577
|
let sawLocalHttpProbe = false;
|
|
550
|
-
/** Last localhost probe returned 4xx/5xx / connection refused — must fix. */
|
|
551
578
|
let sawFailedLocalHttpProbe = false;
|
|
552
|
-
/** Local app was scaffolded/installed/written this turn (plan optional). */
|
|
553
579
|
let sawLocalAppMaterialWork = false;
|
|
554
|
-
/** Official scaffolder succeeded this turn. */
|
|
555
580
|
let sawScaffoldOk = false;
|
|
556
|
-
/** Real product source written (not just scaffold defaults). */
|
|
557
581
|
let sawFeatureImplWrite = false;
|
|
582
|
+
let sawActivePentestTest = false;
|
|
558
583
|
const featureAppAsk = userAskedForFeatureApp(prompt);
|
|
559
|
-
/** Successful work tools under the current in_progress plan task. */
|
|
560
584
|
let taskWorkLedger = null;
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
585
|
+
/**
|
|
586
|
+
* Successful real tools this turn that may not yet be credited to a task
|
|
587
|
+
* (preflight tool.check before in_progress, or work before plan existed).
|
|
588
|
+
* Absorbed into the task ledger when opening or marking done.
|
|
589
|
+
*/
|
|
590
|
+
const sessionLooseWork = [];
|
|
591
|
+
const planHasVerifiedRuntime = (plan) => plan.tasks.some((task) => hasLocalRuntimeProof(task.evidence));
|
|
592
|
+
const planHasVerifiedRemoteWork = (plan) => plan.tasks.some((task) => hasRemoteWorkProof(task.evidence));
|
|
593
|
+
/** Rehydrate turn-local runtime/remote flags from durable plan evidence (resume). */
|
|
594
|
+
const rehydrateSessionFlagsFromPlan = (plan) => {
|
|
595
|
+
if (!plan)
|
|
596
|
+
return;
|
|
597
|
+
for (const task of plan.tasks) {
|
|
598
|
+
const e = task.evidence;
|
|
599
|
+
if (!e)
|
|
600
|
+
continue;
|
|
601
|
+
if (e.sawDevServerStart || e.sawServerReady || e.sawPortListening) {
|
|
602
|
+
sawServerStart = true;
|
|
603
|
+
}
|
|
604
|
+
if (e.sawServerReady || e.sawDevServerStart)
|
|
605
|
+
sawServerTail = true;
|
|
606
|
+
if (e.sawLocalHttpProbeOk)
|
|
607
|
+
sawLocalHttpProbe = true;
|
|
608
|
+
if (e.sawRemoteActiveTestOk)
|
|
609
|
+
sawActivePentestTest = true;
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
rehydrateSessionFlagsFromPlan(activePlan);
|
|
613
|
+
/** Merge loose turn work + live ledger for a task before evidence gates. */
|
|
614
|
+
const ledgerForTaskGate = (plan, taskId) => {
|
|
615
|
+
const task = plan.tasks.find((candidate) => candidate.id === taskId);
|
|
616
|
+
const durableLedger = ledgerFromTaskEvidence(taskId, task?.evidence);
|
|
617
|
+
let ledger = taskWorkLedger?.taskId === taskId &&
|
|
618
|
+
taskWorkLedger.successWorkCount >= durableLedger.successWorkCount
|
|
619
|
+
? taskWorkLedger
|
|
620
|
+
: durableLedger;
|
|
621
|
+
ledger = absorbLooseWorkIntoLedger(ledger, taskId, task?.title ?? "", sessionLooseWork, { planKind: plan.kind });
|
|
622
|
+
// Keep the live ledger in sync so subsequent tools append correctly.
|
|
623
|
+
if (ledger && ledger.successWorkCount > 0) {
|
|
624
|
+
if (!taskWorkLedger ||
|
|
625
|
+
taskWorkLedger.taskId !== taskId ||
|
|
626
|
+
taskWorkLedger.successWorkCount < ledger.successWorkCount) {
|
|
627
|
+
taskWorkLedger = ledger;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return ledger;
|
|
631
|
+
};
|
|
632
|
+
const completionGateForTask = (plan, taskId) => {
|
|
633
|
+
const task = plan.tasks.find((candidate) => candidate.id === taskId);
|
|
634
|
+
const ledger = ledgerForTaskGate(plan, taskId);
|
|
635
|
+
return canMarkTaskDone(ledger, taskId, {
|
|
636
|
+
taskTitle: task?.title,
|
|
637
|
+
featureAppRequired: featureAppAsk,
|
|
638
|
+
existingProject: scaffoldLooksMaterialized(getActiveProjectRoot()),
|
|
639
|
+
runtimeVerified: planHasVerifiedRuntime(plan),
|
|
640
|
+
planKind: plan.kind,
|
|
641
|
+
remoteWorkVerified: planHasVerifiedRemoteWork(plan),
|
|
642
|
+
});
|
|
643
|
+
};
|
|
644
|
+
const reconcileOpenTaskBeforeFinalizing = async () => {
|
|
645
|
+
const plan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
646
|
+
const open = plan?.tasks.find((task) => task.state === "in_progress");
|
|
647
|
+
if (!plan || !open)
|
|
648
|
+
return plan;
|
|
649
|
+
const gate = completionGateForTask(plan, open.id);
|
|
650
|
+
if (!gate.ok)
|
|
651
|
+
return plan;
|
|
652
|
+
const reconciledTaskIds = [open.id];
|
|
653
|
+
markTask(plan, open.id, "done", "Completion reconciled from verified task evidence.");
|
|
654
|
+
while (true) {
|
|
655
|
+
const observation = readyPlanTasks(plan).find((task) => isRuntimeObservationTask(task.title) ||
|
|
656
|
+
(plan.kind === "pentest" && isRemoteObservationTask(task.title)));
|
|
657
|
+
if (!observation)
|
|
658
|
+
break;
|
|
659
|
+
const observationGate = completionGateForTask(plan, observation.id);
|
|
660
|
+
if (!observationGate.ok)
|
|
661
|
+
break;
|
|
662
|
+
markTask(plan, observation.id, "done", plan.kind === "pentest"
|
|
663
|
+
? "Satisfied by verified remote evidence from the preceding task."
|
|
664
|
+
: "Satisfied by the verified runtime evidence from the preceding task.");
|
|
665
|
+
reconciledTaskIds.push(observation.id);
|
|
666
|
+
}
|
|
667
|
+
if (plan.status === "draft" || plan.status === "approved") {
|
|
668
|
+
plan.status = "in_progress";
|
|
669
|
+
}
|
|
670
|
+
if (isPlanTerminal(plan)) {
|
|
671
|
+
plan.status = isPlanSuccessful(plan) ? "completed" : "abandoned";
|
|
672
|
+
}
|
|
673
|
+
await savePlan(plan).catch(() => undefined);
|
|
674
|
+
writePlanUpdate(plan, renderPlanForTerminal(plan) + "\n");
|
|
675
|
+
writeNotice("info", `reconciled ${reconciledTaskIds.map((id) => `[${id}]`).join(", ")} from verified evidence`, chalk.dim(` ℹ reconciled ${reconciledTaskIds.map((id) => `[${id}]`).join(", ")} from verified evidence — no duplicate verification\n`));
|
|
676
|
+
taskWorkLedger = null;
|
|
677
|
+
return plan;
|
|
678
|
+
};
|
|
679
|
+
async function persistProjectRootOnPlan(root) {
|
|
680
|
+
const live = await loadPlan(session.sessionId).catch(() => undefined);
|
|
681
|
+
if (!live)
|
|
682
|
+
return;
|
|
683
|
+
const pm = detectPackageManager(root);
|
|
684
|
+
patchPlanMeta(live, {
|
|
685
|
+
projectRoot: root,
|
|
686
|
+
...(pm ? { packageManager: pm } : {}),
|
|
687
|
+
});
|
|
688
|
+
await savePlan(live).catch(() => undefined);
|
|
689
|
+
}
|
|
690
|
+
refreshSessionState = (plan) => {
|
|
691
|
+
if (idleOrSocialPrompt || informationalQuery)
|
|
692
|
+
return;
|
|
693
|
+
if (!buildLikeTurn && !pentestLikeTurn && !plan && !activePlan)
|
|
694
|
+
return;
|
|
695
|
+
const p = plan ?? activePlan;
|
|
696
|
+
const root = getActiveProjectRoot() ?? p?.meta?.projectRoot;
|
|
697
|
+
const pm = p?.meta?.packageManager ??
|
|
698
|
+
(root ? detectPackageManager(root) : undefined);
|
|
699
|
+
const open = p?.tasks.find((t) => t.state === "in_progress");
|
|
700
|
+
const pending = p?.tasks
|
|
701
|
+
.filter((t) => t.state === "pending")
|
|
702
|
+
.map((t) => `[${t.id}] ${t.title}`);
|
|
703
|
+
const done = p?.tasks
|
|
704
|
+
.filter((t) => t.state === "done" || t.state === "skipped")
|
|
705
|
+
.map((t) => t.id);
|
|
706
|
+
const snap = {
|
|
707
|
+
goal: p?.goal ?? prompt.slice(0, 160),
|
|
708
|
+
projectRoot: root,
|
|
709
|
+
packageManager: pm,
|
|
710
|
+
planStatus: p?.status,
|
|
711
|
+
planKind: p?.kind,
|
|
712
|
+
openTask: open ? `[${open.id}] ${open.title}` : undefined,
|
|
713
|
+
pendingTasks: pending,
|
|
714
|
+
doneTasks: done,
|
|
715
|
+
featureAppRequired: featureAppAsk,
|
|
716
|
+
featureSeen: sawFeatureImplWrite,
|
|
717
|
+
scaffoldOk: sawScaffoldOk,
|
|
718
|
+
serverStarted: sawServerStart,
|
|
719
|
+
serverProbedOk: sawLocalHttpProbe,
|
|
720
|
+
lastProbeFailed: sawFailedLocalHttpProbe,
|
|
721
|
+
lastOkTool: taskWorkLedger?.lastOkTool,
|
|
722
|
+
engagementNote: pentestSession
|
|
723
|
+
? "remote/security engagement — no local dev server as completion"
|
|
724
|
+
: undefined,
|
|
725
|
+
};
|
|
726
|
+
snap.nextHint = inferNextHint(snap);
|
|
727
|
+
upsertSessionStateMessage(messages, buildSessionStateBlock(snap));
|
|
728
|
+
};
|
|
729
|
+
refreshSessionState(activePlan);
|
|
578
730
|
let pendingCalls = [];
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
// final answer) the agent may take. Recovery iterations — nudging a model
|
|
582
|
-
// that only produced thinking, asking it to re-emit a malformed tool call,
|
|
583
|
-
// a freshness retry, or a loop-guard summary — do NOT consume this budget;
|
|
584
|
-
// they get a separate hard ceiling so a wedged model can't spin forever.
|
|
585
|
-
//
|
|
586
|
-
// Complexity is a coarse signal from prompt length, but short follow-up
|
|
587
|
-
// prompts ("do it", "build fully on your own", "app is not complete") in
|
|
588
|
-
// the middle of a multi-file build must NOT be capped like a one-shot
|
|
589
|
-
// lookup — that was the reason a React scaffold stopped half-built after
|
|
590
|
-
// 10 steps. We bump the budget when the prompt (or recent history) looks
|
|
591
|
-
// like a build/scaffold or a continuation of one.
|
|
731
|
+
let narrowNmapDispatchCount = 0;
|
|
732
|
+
const deferredPostToolMessages = [];
|
|
592
733
|
const analysis = analyzeTask(prompt);
|
|
593
734
|
const hasHistory = (options.history?.length ?? 0) > 0;
|
|
594
735
|
const buildLike = buildLikeTurn;
|
|
595
736
|
const pentestLike = looksLikePentestTask(prompt, options.history);
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
737
|
+
const continueExistingOutcome = /^(?:continue|resume|proceed|keep\s+going|finish|next)\b/i.test(prompt.trim()) ||
|
|
738
|
+
Boolean(activePlan && !isPlanTerminal(activePlan));
|
|
739
|
+
const outcomeState = await openOutcomeState({
|
|
740
|
+
sessionId: session.sessionId,
|
|
741
|
+
userIntent: prompt,
|
|
742
|
+
kind: inferOutcomeKind({ userIntent: prompt, buildLike, pentestLike }),
|
|
743
|
+
continueExisting: continueExistingOutcome,
|
|
744
|
+
});
|
|
745
|
+
await saveOutcomeState(outcomeState);
|
|
746
|
+
let governorState = createGovernorState();
|
|
747
|
+
let governorPauseReason;
|
|
748
|
+
let turnState = createTurnState();
|
|
749
|
+
const moveTurn = (to, reason) => {
|
|
750
|
+
if (turnState.state === to)
|
|
751
|
+
return;
|
|
752
|
+
try {
|
|
753
|
+
turnState = transitionTurn(turnState, to, reason);
|
|
754
|
+
}
|
|
755
|
+
catch {
|
|
756
|
+
// Recovery paths may skip an intermediate presentation state; route
|
|
757
|
+
// active work through verifying/exploring rather than forging state.
|
|
758
|
+
if (to === "succeeded" || to === "partial") {
|
|
759
|
+
if (turnState.state === "understanding") {
|
|
760
|
+
turnState = transitionTurn(turnState, "exploring", "response prepared for verification");
|
|
761
|
+
}
|
|
762
|
+
if (turnState.state === "acting" || turnState.state === "exploring") {
|
|
763
|
+
turnState = transitionTurn(turnState, "verifying", reason);
|
|
764
|
+
}
|
|
765
|
+
turnState = transitionTurn(turnState, to, reason);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
let retryDependenciesChanged = false;
|
|
770
|
+
let retryEnvironmentChanged = false;
|
|
771
|
+
const stepBudget = computeStepBudget({
|
|
772
|
+
analysis,
|
|
773
|
+
maxSteps,
|
|
774
|
+
buildLike,
|
|
775
|
+
pentestLike,
|
|
776
|
+
hasHistory,
|
|
777
|
+
});
|
|
778
|
+
// Iteration count is only an emergency protection for recovery/model loops;
|
|
779
|
+
// normal continuation is governed by evidence and resource deltas above.
|
|
780
|
+
const maxIterations = Math.max(210, computeMaxIterations(stepBudget));
|
|
614
781
|
let productiveSteps = 0;
|
|
615
782
|
let step = -1;
|
|
616
783
|
let nextToolEventId = 0;
|
|
@@ -629,10 +796,12 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
629
796
|
},
|
|
630
797
|
};
|
|
631
798
|
async function executeSingleTool(rawCall, toolEventId, parentSignal) {
|
|
632
|
-
// Resolved once per call so the scratch-only exemption can compare the
|
|
633
|
-
// model-supplied paths against the canonical per-project scratch root.
|
|
634
799
|
const scratchDir = scratchDirFor(safeCwd());
|
|
635
800
|
let call = normalizeToolCall(rawCall);
|
|
801
|
+
let dispatchedTaskId;
|
|
802
|
+
let engagementLease;
|
|
803
|
+
let engagementGraph;
|
|
804
|
+
let engagementRecord;
|
|
636
805
|
if (call.args?.__nativeParseError) {
|
|
637
806
|
const raw = String(call.args._raw ?? "").slice(0, 200);
|
|
638
807
|
const reason = "Tool call arguments were not valid JSON (truncated or malformed). " +
|
|
@@ -649,14 +818,41 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
649
818
|
const result = { ok: true, output: recoveryText };
|
|
650
819
|
return { ok: true, call, result, contextOutput: recoveryText };
|
|
651
820
|
}
|
|
652
|
-
|
|
821
|
+
if (narrowNmapOperation) {
|
|
822
|
+
const allowed = new Set(["net.scan", "shell.tail", "shell.jobs"]);
|
|
823
|
+
if (!allowed.has(call.name)) {
|
|
824
|
+
const reason = `Narrow nmap request: ${call.name} was not run because the user requested only one nmap operation. ` +
|
|
825
|
+
`Call net.scan with the requested target/options; do not create a plan or add DNS, WHOIS, HTTP, recon, or vulnerability steps.`;
|
|
826
|
+
const result = { ok: false, output: reason, exitCode: 1 };
|
|
827
|
+
emitToolResult(toolEventId, result, reason);
|
|
828
|
+
return { ok: false, call, result, contextOutput: reason };
|
|
829
|
+
}
|
|
830
|
+
if (call.name === "net.scan") {
|
|
831
|
+
if (narrowNmapDispatchCount >= 1) {
|
|
832
|
+
const reason = "Narrow nmap request: a scan has already been dispatched this turn. " +
|
|
833
|
+
"Do not broaden or retry it automatically; report the existing result/job status and ask before another scan.";
|
|
834
|
+
const result = { ok: false, output: reason, exitCode: 1 };
|
|
835
|
+
emitToolResult(toolEventId, result, reason);
|
|
836
|
+
return { ok: false, call, result, contextOutput: reason };
|
|
837
|
+
}
|
|
838
|
+
narrowNmapDispatchCount += 1;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
const retryReasonRaw = call.args._retryReason;
|
|
842
|
+
const retryReason = retryReasonRaw && typeof retryReasonRaw === "object"
|
|
843
|
+
? {
|
|
844
|
+
code: String(retryReasonRaw.code ?? ""),
|
|
845
|
+
detail: String(retryReasonRaw.detail ?? ""),
|
|
846
|
+
}
|
|
847
|
+
: undefined;
|
|
848
|
+
const loopCheck = loopGuard.shouldBlock(call.name, call.args, {
|
|
849
|
+
dependenciesChanged: retryDependenciesChanged,
|
|
850
|
+
environmentChanged: retryEnvironmentChanged,
|
|
851
|
+
...(retryReason ? { retryReason } : {}),
|
|
852
|
+
});
|
|
653
853
|
if (loopCheck.block) {
|
|
654
|
-
const
|
|
655
|
-
call.name
|
|
656
|
-
call.name === "fs.edit" ||
|
|
657
|
-
call.name === "fs.replaceLines" ||
|
|
658
|
-
call.name === "fs.append";
|
|
659
|
-
const reason = `${call.name} was already called with the same arguments — ${isWrite ? "moving on" : "forcing summary"}`;
|
|
854
|
+
const reason = loopCheck.reason ??
|
|
855
|
+
`${call.name} was already called with the same arguments. Use the prior result and choose a different next step.`;
|
|
660
856
|
writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
|
|
661
857
|
const result = { ok: false, output: reason, exitCode: 1 };
|
|
662
858
|
return {
|
|
@@ -664,7 +860,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
664
860
|
call,
|
|
665
861
|
result,
|
|
666
862
|
contextOutput: reason,
|
|
667
|
-
blockOrCancel: true,
|
|
668
863
|
};
|
|
669
864
|
}
|
|
670
865
|
if (loopCheck.reason) {
|
|
@@ -684,7 +879,32 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
684
879
|
const live = await loadPlan(session.sessionId).catch(() => undefined);
|
|
685
880
|
const resolved = (live ? resolvePlanTaskId(live, taskIdRaw) : undefined) ??
|
|
686
881
|
taskIdRaw;
|
|
687
|
-
const
|
|
882
|
+
const target = live?.tasks.find((task) => task.id === resolved);
|
|
883
|
+
// Soft-auto: pending + deps complete is allowed through to plan-tool,
|
|
884
|
+
// which will open then complete in one call. Only hard-block when
|
|
885
|
+
// the task is not ready for that path (failed / deps / missing).
|
|
886
|
+
const depsIncomplete = target?.dependencies?.some((dependency) => {
|
|
887
|
+
const dependencyTask = live?.tasks.find((t) => t.id === dependency);
|
|
888
|
+
return (!dependencyTask ||
|
|
889
|
+
(dependencyTask.state !== "done" && dependencyTask.state !== "skipped"));
|
|
890
|
+
}) ?? false;
|
|
891
|
+
const canSoftComplete = target?.state === "pending" && !depsIncomplete;
|
|
892
|
+
const gate = !live
|
|
893
|
+
? {
|
|
894
|
+
ok: false,
|
|
895
|
+
reason: `Task ${resolved} cannot be marked done because its active plan is unavailable.`,
|
|
896
|
+
}
|
|
897
|
+
: target?.state === "in_progress" || canSoftComplete
|
|
898
|
+
? completionGateForTask(live, resolved)
|
|
899
|
+
: target?.state === "failed"
|
|
900
|
+
? {
|
|
901
|
+
ok: false,
|
|
902
|
+
reason: `Task ${resolved} is failed — retry with in_progress first, then mark done after recovery work.`,
|
|
903
|
+
}
|
|
904
|
+
: {
|
|
905
|
+
ok: false,
|
|
906
|
+
reason: `Task ${resolved} must be in_progress before it can be marked done. Start or retry the task, perform fresh work, then complete it.`,
|
|
907
|
+
};
|
|
688
908
|
if (!gate.ok) {
|
|
689
909
|
writeNotice("warn", gate.reason, chalk.yellow(` ⚠ ${gate.reason}\n`));
|
|
690
910
|
if (!alreadyPrintedIds.has(toolEventId)) {
|
|
@@ -712,6 +932,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
712
932
|
const planResult = await handlePlanTool(call, session, {
|
|
713
933
|
loopGuard,
|
|
714
934
|
step,
|
|
935
|
+
autoApprove: !isPlanMode,
|
|
715
936
|
});
|
|
716
937
|
if (planResult.handled) {
|
|
717
938
|
loopGuard.recordAttempt(step, call.name, call.args, planResult.ok, 0);
|
|
@@ -726,9 +947,29 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
726
947
|
? resolvePlanTaskId(planResult.plan, taskIdRaw)
|
|
727
948
|
: undefined) ?? taskIdRaw;
|
|
728
949
|
if (stateRaw === "in_progress" && resolved) {
|
|
729
|
-
|
|
950
|
+
// Keep accumulated evidence when recon already credited this task
|
|
951
|
+
// before an explicit in_progress (common on pentest plans).
|
|
952
|
+
// Also absorb turn-level preflight (tool.check before open).
|
|
953
|
+
const persisted = planResult.plan?.tasks.find((task) => task.id === resolved);
|
|
954
|
+
const baseLed = taskWorkLedger?.taskId === resolved
|
|
955
|
+
? taskWorkLedger
|
|
956
|
+
: ledgerFromTaskEvidence(resolved, persisted?.evidence);
|
|
957
|
+
const led = absorbLooseWorkIntoLedger(baseLed, resolved, persisted?.title ?? "", sessionLooseWork, { planKind: planResult.plan?.kind }) ?? baseLed;
|
|
958
|
+
taskWorkLedger = led;
|
|
959
|
+
if (planResult.plan && led && led.successWorkCount > 0 && persisted) {
|
|
960
|
+
persisted.evidence = taskEvidenceFromLedger(led);
|
|
961
|
+
await savePlan(planResult.plan).catch(() => undefined);
|
|
962
|
+
}
|
|
730
963
|
}
|
|
731
964
|
else if (stateRaw === "done" && resolved) {
|
|
965
|
+
// Persist absorbed evidence before clearing the live ledger.
|
|
966
|
+
if (planResult.plan && taskWorkLedger?.taskId === resolved) {
|
|
967
|
+
const t = planResult.plan.tasks.find((x) => x.id === resolved);
|
|
968
|
+
if (t) {
|
|
969
|
+
t.evidence = taskEvidenceFromLedger(taskWorkLedger);
|
|
970
|
+
await savePlan(planResult.plan).catch(() => undefined);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
732
973
|
taskWorkLedger = null;
|
|
733
974
|
}
|
|
734
975
|
else if ((stateRaw === "failed" || stateRaw === "skipped") &&
|
|
@@ -767,27 +1008,22 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
767
1008
|
decision,
|
|
768
1009
|
scope: isScopeActive(scope) ? (scope.name ?? "(unnamed)") : "(none)",
|
|
769
1010
|
});
|
|
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
1011
|
const livePlanForPreGate = await loadPlan(session.sessionId).catch(() => undefined);
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
});
|
|
779
|
-
if (codingNeedsPlan &&
|
|
780
|
-
!livePlanForPreGate &&
|
|
781
|
-
!sawPlanCreateOk &&
|
|
1012
|
+
// Plan mode: gather freely while the draft awaits accept. Once the user
|
|
1013
|
+
// approves (planApproved), mutates must run even if mode still says "plan"
|
|
1014
|
+
// for a beat — otherwise implement loops forever on gather-only blocks.
|
|
1015
|
+
if (isPlanMode &&
|
|
1016
|
+
!session.planApproved.value &&
|
|
782
1017
|
!isScratchOnlyWrite(call, scratchDir)) {
|
|
783
1018
|
const cmd = typeof call.args.command === "string" ? call.args.command : "";
|
|
784
|
-
const
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
if (!
|
|
788
|
-
const reason = `plan
|
|
789
|
-
`
|
|
790
|
-
`
|
|
1019
|
+
const shellBlocked = (call.name === "shell.exec" || call.name === "shell.start") &&
|
|
1020
|
+
!isPlanModeAllowedShellCommand(cmd);
|
|
1021
|
+
const allowed = isPlanModeAllowedTool(call.name) && !shellBlocked;
|
|
1022
|
+
if (!allowed) {
|
|
1023
|
+
const reason = `plan mode — ${call.name} is blocked (gather-only). ` +
|
|
1024
|
+
`Use any recon/enum/scan/research tool; do not write project files or run active exploits. ` +
|
|
1025
|
+
`Put exploit/implement steps in plan.create tasks for after accept. ` +
|
|
1026
|
+
`Accept the plan (y/i or /implement) to switch to agent and execute.`;
|
|
791
1027
|
writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
|
|
792
1028
|
if (!alreadyPrintedIds.has(toolEventId)) {
|
|
793
1029
|
const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
|
|
@@ -813,7 +1049,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
813
1049
|
const planNow = livePlanForPreGate ??
|
|
814
1050
|
(await loadPlan(session.sessionId).catch(() => undefined));
|
|
815
1051
|
if (planNow && !session.planApproved.value) {
|
|
816
|
-
const reason = `plan awaiting approval — ${call.name} is blocked until
|
|
1052
|
+
const reason = `plan awaiting approval — ${call.name} is blocked until the plan is accepted (/implement or Accept)`;
|
|
817
1053
|
writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
|
|
818
1054
|
const result = { ok: false, output: reason, exitCode: 1 };
|
|
819
1055
|
return {
|
|
@@ -825,18 +1061,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
825
1061
|
};
|
|
826
1062
|
}
|
|
827
1063
|
}
|
|
828
|
-
// Task-scoped execution gate
|
|
829
|
-
// Once a plan is approved, every non-plan tool call must run while
|
|
830
|
-
// exactly one task is "in_progress". This stops a model from batching
|
|
831
|
-
// tool calls for many/all tasks in one turn and only touching task
|
|
832
|
-
// state at the very end (or never) — the failure mode where a model
|
|
833
|
-
// claimed most tasks "done" in prose without ever recording it in the
|
|
834
|
-
// plan. Multiple tool calls per task are still fine; they just must be
|
|
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).
|
|
840
1064
|
if (session.planApproved.value) {
|
|
841
1065
|
const livePlanForGate = await loadPlan(session.sessionId).catch(() => undefined);
|
|
842
1066
|
if (livePlanForGate) {
|
|
@@ -845,13 +1069,17 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
845
1069
|
if (unfinished && !inProgress) {
|
|
846
1070
|
// tool.check / fs.list preflight: allow without auto-opening a task
|
|
847
1071
|
// (auto-start on preflight made models skip task.update and confused scope).
|
|
848
|
-
|
|
849
|
-
|
|
1072
|
+
const skipTaskGate = isPlanPreflightTool(call.name) ||
|
|
1073
|
+
(livePlanForGate.kind === "pentest" &&
|
|
1074
|
+
isReadOnlyReconTool(call.name));
|
|
1075
|
+
if (skipTaskGate) {
|
|
1076
|
+
// fall through
|
|
850
1077
|
}
|
|
851
1078
|
else {
|
|
852
|
-
const pending = livePlanForGate
|
|
853
|
-
//
|
|
854
|
-
|
|
1079
|
+
const pending = readyPlanTasks(livePlanForGate);
|
|
1080
|
+
// Title/command matching is only a soft ownership hint. If no
|
|
1081
|
+
// heuristic matches, preserve plan order instead of blocking.
|
|
1082
|
+
const nextPending = pickPendingTaskForToolCall(pending, call, livePlanForGate.tasks.map((t) => t.title)) ?? pending[0];
|
|
855
1083
|
if (nextPending) {
|
|
856
1084
|
markTask(livePlanForGate, nextPending.id, "in_progress");
|
|
857
1085
|
if (livePlanForGate.status === "draft" ||
|
|
@@ -859,78 +1087,19 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
859
1087
|
livePlanForGate.status = "in_progress";
|
|
860
1088
|
}
|
|
861
1089
|
await savePlan(livePlanForGate).catch(() => undefined);
|
|
862
|
-
|
|
1090
|
+
// Preserve evidence already credited to this task (e.g. pentest
|
|
1091
|
+
// recon that ran before the task was formally opened).
|
|
1092
|
+
if (!taskWorkLedger ||
|
|
1093
|
+
taskWorkLedger.taskId !== nextPending.id) {
|
|
1094
|
+
taskWorkLedger = ledgerFromTaskEvidence(nextPending.id, nextPending.evidence);
|
|
1095
|
+
}
|
|
863
1096
|
writePlanUpdate(livePlanForGate, renderPlanForTerminal(livePlanForGate) + "\n");
|
|
864
1097
|
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
1098
|
}
|
|
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
1099
|
}
|
|
898
|
-
const result = { ok: false, output: scopeMsg, exitCode: 1 };
|
|
899
|
-
emitToolResult(toolEventId, result, scopeMsg);
|
|
900
|
-
writeToolOutput(toolEventId, "failed\n", chalk.red(" ✗") + "\n");
|
|
901
|
-
return {
|
|
902
|
-
ok: false,
|
|
903
|
-
call,
|
|
904
|
-
result,
|
|
905
|
-
contextOutput: scopeMsg,
|
|
906
|
-
};
|
|
907
|
-
}
|
|
908
|
-
}
|
|
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
1100
|
}
|
|
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
1101
|
}
|
|
931
1102
|
}
|
|
932
|
-
// Prefer user Desktop (etc.) as cwd when model omitted it.
|
|
933
|
-
// Also prefer sticky project root for install/run when set.
|
|
934
1103
|
call = applyDestinationCwd(call, destinationHint ?? getActiveProjectRoot());
|
|
935
1104
|
// Soft preflight: refuse scaffold into an existing non-empty project
|
|
936
1105
|
// (avoids endless "Operation cancelled" retries across all stacks).
|
|
@@ -940,21 +1109,29 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
940
1109
|
const cwdArg = typeof call.args.cwd === "string" ? call.args.cwd : undefined;
|
|
941
1110
|
const conflict = scaffoldTargetConflictMessage(call.args.command, cwdArg);
|
|
942
1111
|
if (conflict) {
|
|
943
|
-
|
|
1112
|
+
const target = resolveScaffoldTargetPath(call.args.command, cwdArg);
|
|
1113
|
+
const materialized = scaffoldLooksMaterialized(target);
|
|
1114
|
+
if (target && materialized && setActiveProjectRootIfValid(target, { force: true })) {
|
|
1115
|
+
await persistProjectRootOnPlan(target);
|
|
1116
|
+
}
|
|
1117
|
+
const message = materialized
|
|
1118
|
+
? `Scaffold skipped: the target already contains a usable project${target ? ` at ${target}` : ""}. Continue that project directly; do not re-run the scaffolder.`
|
|
1119
|
+
: `Scaffold was not run: the existing target${target ? ` at ${target}` : ""} is incomplete. Inspect and repair it before completing the scaffold task; do not retry the scaffolder into this non-empty directory.`;
|
|
1120
|
+
writeNotice("info", message, chalk.dim(` ℹ ${message}\n`));
|
|
944
1121
|
if (!alreadyPrintedIds.has(toolEventId)) {
|
|
945
1122
|
const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
|
|
946
1123
|
chalk.gray(` ${formatToolArgs(call)}`);
|
|
947
1124
|
writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
|
|
948
1125
|
alreadyPrintedIds.add(toolEventId);
|
|
949
1126
|
}
|
|
950
|
-
const result = { ok:
|
|
951
|
-
emitToolResult(toolEventId, result,
|
|
952
|
-
writeToolOutput(toolEventId, "
|
|
1127
|
+
const result = { ok: true, output: message, exitCode: 0 };
|
|
1128
|
+
emitToolResult(toolEventId, result, message);
|
|
1129
|
+
writeToolOutput(toolEventId, "ok\n", chalk.green(" ✓") + "\n");
|
|
953
1130
|
return {
|
|
954
|
-
ok:
|
|
1131
|
+
ok: true,
|
|
955
1132
|
call,
|
|
956
1133
|
result,
|
|
957
|
-
contextOutput:
|
|
1134
|
+
contextOutput: message,
|
|
958
1135
|
};
|
|
959
1136
|
}
|
|
960
1137
|
}
|
|
@@ -967,20 +1144,49 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
967
1144
|
alreadyPrintedIds.add(toolEventId);
|
|
968
1145
|
}
|
|
969
1146
|
const scopeTarget = scopeTargetForToolCall(call);
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1147
|
+
const engagementAction = pentestSession || isPentestToolCall(call) || Boolean(scope)
|
|
1148
|
+
? engagementActionForToolCall(call)
|
|
1149
|
+
: undefined;
|
|
1150
|
+
const engagementDecision = engagementAction
|
|
1151
|
+
? evaluateEngagementAction(scope, engagementAction)
|
|
1152
|
+
: undefined;
|
|
1153
|
+
if (engagementAction && engagementDecision) {
|
|
1154
|
+
if (scope) {
|
|
1155
|
+
engagementGraph = await openEngagement(scope);
|
|
1156
|
+
engagementRecord = beginEngagementAction(engagementGraph, {
|
|
1157
|
+
tool: call.name,
|
|
1158
|
+
target: engagementDecision.normalizedTarget || engagementAction.target,
|
|
1159
|
+
phase: engagementDecision.phase,
|
|
1160
|
+
capability: engagementDecision.capability,
|
|
1161
|
+
authorized: engagementDecision.allowed,
|
|
1162
|
+
reason: engagementDecision.reason,
|
|
1163
|
+
});
|
|
1164
|
+
await saveEngagement(engagementGraph);
|
|
1165
|
+
}
|
|
1166
|
+
await auditLog("engagement.policy", {
|
|
1167
|
+
...(engagementGraph ? { engagementId: engagementGraph.id } : {}),
|
|
1168
|
+
...(engagementRecord ? { actionId: engagementRecord.id } : {}),
|
|
1169
|
+
tool: call.name,
|
|
1170
|
+
target: engagementDecision.normalizedTarget,
|
|
1171
|
+
phase: engagementDecision.phase,
|
|
1172
|
+
capability: engagementDecision.capability,
|
|
1173
|
+
allowed: engagementDecision.allowed,
|
|
1174
|
+
reason: engagementDecision.reason,
|
|
1175
|
+
});
|
|
1176
|
+
if (!engagementDecision.allowed) {
|
|
1177
|
+
const target = engagementDecision.normalizedTarget || scopeTarget || engagementAction.target;
|
|
1178
|
+
const reason = `Blocked engagement action for ${target}: ${engagementDecision.reason}. ` +
|
|
1179
|
+
scopeHint(target);
|
|
1180
|
+
writeToolBlocked(toolEventId, call.name, reason, chalk.red(` ✗ ${reason}\n`));
|
|
1181
|
+
const result = { ok: false, output: reason, exitCode: 1 };
|
|
1182
|
+
emitToolResult(toolEventId, result, reason);
|
|
1183
|
+
return { ok: false, call, result, contextOutput: reason };
|
|
1184
|
+
}
|
|
973
1185
|
}
|
|
974
1186
|
if (decision.level === "block") {
|
|
975
1187
|
writeToolBlocked(toolEventId, call.name, decision.reason, chalk.red(` ✗ blocked: ${decision.reason}`) + "\n");
|
|
976
1188
|
const message = `Blocked: ${call.name} — ${decision.reason}`;
|
|
977
1189
|
const result = { ok: false, output: message, exitCode: 1 };
|
|
978
|
-
// Safety classifier blocks are recoverable model mistakes: feed the
|
|
979
|
-
// failed result back to the model and let it choose a safer next step
|
|
980
|
-
// instead of ending the entire agent turn. Hard workflow gates
|
|
981
|
-
// (plan not approved, task not in_progress, auth declined, aborts)
|
|
982
|
-
// still use blockOrCancel above/below because continuing would violate
|
|
983
|
-
// user/session control rather than merely correcting a bad command.
|
|
984
1190
|
return {
|
|
985
1191
|
ok: false,
|
|
986
1192
|
call,
|
|
@@ -1014,29 +1220,43 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1014
1220
|
if (needsPentestAuth) {
|
|
1015
1221
|
pentestJustConfirmed = true;
|
|
1016
1222
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1223
|
+
// Always confirm destructive deletes and any write outside cwd —
|
|
1224
|
+
// even when permissions=allow-all or -y (user requirement).
|
|
1225
|
+
let forceConfirm = call.name === "fs.delete";
|
|
1226
|
+
if (call.name === "fs.write" ||
|
|
1227
|
+
call.name === "fs.writeMany" ||
|
|
1228
|
+
call.name === "fs.edit" ||
|
|
1229
|
+
call.name === "fs.append" ||
|
|
1230
|
+
call.name === "fs.replaceLines" ||
|
|
1231
|
+
call.name === "fs.delete") {
|
|
1232
|
+
const paths = [];
|
|
1233
|
+
if (typeof call.args.path === "string")
|
|
1234
|
+
paths.push(call.args.path);
|
|
1235
|
+
if (Array.isArray(call.args.files)) {
|
|
1236
|
+
for (const entry of call.args.files) {
|
|
1237
|
+
if (entry &&
|
|
1238
|
+
typeof entry === "object" &&
|
|
1239
|
+
typeof entry.path === "string") {
|
|
1240
|
+
paths.push(entry.path);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
for (const p of paths) {
|
|
1245
|
+
try {
|
|
1246
|
+
if (isOutsideWorkingDirectory(resolveFsToolPath(p))) {
|
|
1247
|
+
forceConfirm = true;
|
|
1248
|
+
break;
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
catch {
|
|
1252
|
+
forceConfirm = true;
|
|
1253
|
+
break;
|
|
1035
1254
|
}
|
|
1036
1255
|
}
|
|
1037
1256
|
}
|
|
1038
|
-
if (decision.level === "confirm"
|
|
1039
|
-
|
|
1257
|
+
if ((decision.level === "confirm" || forceConfirm) &&
|
|
1258
|
+
!pentestJustConfirmed) {
|
|
1259
|
+
const ok = await confirmToolExecution(call, forceConfirm ? false : Boolean(options.autoConfirm), session, confirmPort, forceConfirm ? { forceConfirm: true } : undefined);
|
|
1040
1260
|
restoreInteractiveStdin();
|
|
1041
1261
|
if (!ok) {
|
|
1042
1262
|
const lastAnswer = "Cancelled.";
|
|
@@ -1057,24 +1277,40 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1057
1277
|
releasePrompt();
|
|
1058
1278
|
}
|
|
1059
1279
|
parentSignal.throwIfAborted();
|
|
1280
|
+
const planAtDispatch = await loadPlan(session.sessionId).catch(() => undefined);
|
|
1281
|
+
dispatchedTaskId = planAtDispatch?.tasks.find((task) => task.state === "in_progress")?.id;
|
|
1282
|
+
if (!dispatchedTaskId && planAtDispatch?.kind === "pentest") {
|
|
1283
|
+
const candidate = pickPendingTaskForToolCall(readyPlanTasks(planAtDispatch), call, planAtDispatch.tasks.map((task) => task.title));
|
|
1284
|
+
dispatchedTaskId = candidate?.id;
|
|
1285
|
+
}
|
|
1286
|
+
if (dispatchedTaskId &&
|
|
1287
|
+
(!taskWorkLedger || taskWorkLedger.taskId !== dispatchedTaskId)) {
|
|
1288
|
+
const dispatchedTask = planAtDispatch?.tasks.find((task) => task.id === dispatchedTaskId);
|
|
1289
|
+
taskWorkLedger = ledgerFromTaskEvidence(dispatchedTaskId, dispatchedTask?.evidence);
|
|
1290
|
+
}
|
|
1291
|
+
if (engagementAction) {
|
|
1292
|
+
engagementLease = engagementPolicy.acquire(scope, engagementAction);
|
|
1293
|
+
if (!engagementLease.decision.allowed) {
|
|
1294
|
+
const reason = `Blocked engagement action: ${engagementLease.decision.reason}`;
|
|
1295
|
+
const result = { ok: false, output: reason, exitCode: 1 };
|
|
1296
|
+
emitToolResult(toolEventId, result, reason);
|
|
1297
|
+
return { ok: false, call, result, contextOutput: reason };
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
if (turnState.state === "understanding" || turnState.state === "exploring") {
|
|
1301
|
+
moveTurn("acting", `executing ${call.name}`);
|
|
1302
|
+
}
|
|
1060
1303
|
options.onToolStart?.(call);
|
|
1061
1304
|
// Card was "queued" since writeToolCall; flip to running only when work starts.
|
|
1062
1305
|
emit({ type: "tool-start", id: toolEventId });
|
|
1063
1306
|
writeStatus(call.name, chalk.dim(` → ${call.name}\n`));
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
looksInteractiveStdin(call.args.command)) ||
|
|
1067
|
-
call.name === "net.scan" ||
|
|
1068
|
-
call.name === "pentest.recon";
|
|
1069
|
-
if (interactiveCommand && process.stdin.isTTY) {
|
|
1070
|
-
writeNotice("warn", "this command may prompt for a password — type it when asked", chalk.yellow(" ⚠ this command may prompt for a password — type it when asked\n"));
|
|
1071
|
-
}
|
|
1307
|
+
// Elevation uses the secure secret modal (TUI) or is refused — never
|
|
1308
|
+
// a raw TTY "Password:" that freezes the UI. No misleading notice.
|
|
1072
1309
|
const toolAc = new AbortController();
|
|
1073
1310
|
const onParentAbort = () => toolAc.abort();
|
|
1074
1311
|
parentSignal.addEventListener("abort", onParentAbort);
|
|
1075
1312
|
let result;
|
|
1076
1313
|
let liveBytes = 0;
|
|
1077
|
-
const shouldDimLive = !interactiveCommand;
|
|
1078
1314
|
// Stream every live byte — never drop mid-run. After the tool finishes we
|
|
1079
1315
|
// still replace the spool with the authoritative full `result.output`.
|
|
1080
1316
|
const printLive = (chunk) => {
|
|
@@ -1087,23 +1323,32 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1087
1323
|
liveBytes += chunk.length;
|
|
1088
1324
|
const indented = chunk.replace(/\r/g, "").replace(/\n(?!$)/g, "\n ");
|
|
1089
1325
|
const body = indented.startsWith("\n") ? indented : ` ${indented}`;
|
|
1090
|
-
writeToolOutput(toolEventId, chunk,
|
|
1326
|
+
writeToolOutput(toolEventId, chunk, chalk.dim(body));
|
|
1091
1327
|
};
|
|
1092
1328
|
const jobId = randomUUID().slice(0, 8);
|
|
1329
|
+
const emptyJobArtifact = () => ({
|
|
1330
|
+
path: "",
|
|
1331
|
+
chunks: [],
|
|
1332
|
+
bytes: 0,
|
|
1333
|
+
droppedBytes: 0,
|
|
1334
|
+
redacted: false,
|
|
1335
|
+
sha256: "",
|
|
1336
|
+
});
|
|
1093
1337
|
const backgroundJob = {
|
|
1094
1338
|
id: jobId,
|
|
1095
1339
|
command: `${call.name} ${formatToolArgs(call)}`,
|
|
1340
|
+
commandDisplay: `${call.name} ${formatToolArgs(call)}`,
|
|
1096
1341
|
cwd: safeCwd(),
|
|
1097
1342
|
status: "running",
|
|
1098
1343
|
startedAt: new Date().toISOString(),
|
|
1099
1344
|
artifactPath: "",
|
|
1345
|
+
stdoutArtifact: "",
|
|
1346
|
+
stderrArtifact: "",
|
|
1347
|
+
artifacts: { stdout: emptyJobArtifact(), stderr: emptyJobArtifact() },
|
|
1348
|
+
redactionProfile: "provider-secrets-v1",
|
|
1349
|
+
ownerSessionId: session.sessionId,
|
|
1100
1350
|
};
|
|
1101
1351
|
jobManager.registerJob(jobId, backgroundJob, toolAc);
|
|
1102
|
-
// Long-lived commands should use shell.start/background jobs. Reset this
|
|
1103
|
-
// watchdog whenever a blocking tool emits output so only a genuinely
|
|
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
1352
|
const TOOL_STALL_ABORT_MS = toolStallBudgetMs(call);
|
|
1108
1353
|
const stallSecs = Math.round(TOOL_STALL_ABORT_MS / 1000);
|
|
1109
1354
|
let stallTimer;
|
|
@@ -1132,6 +1377,33 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1132
1377
|
},
|
|
1133
1378
|
confirmed: true,
|
|
1134
1379
|
userPrompt: prompt,
|
|
1380
|
+
...(engagementAction && scope
|
|
1381
|
+
? {
|
|
1382
|
+
engagementAuthorization: {
|
|
1383
|
+
target: engagementDecision?.normalizedTarget || engagementAction.target,
|
|
1384
|
+
...(scope.expiresAt ? { expiresAt: scope.expiresAt } : {}),
|
|
1385
|
+
},
|
|
1386
|
+
authorizeNetworkHop: async (url, resolvedAddresses) => {
|
|
1387
|
+
const hop = actionFromUrl({
|
|
1388
|
+
url,
|
|
1389
|
+
method: engagementAction.method,
|
|
1390
|
+
phase: engagementAction.phase,
|
|
1391
|
+
capability: engagementAction.capability,
|
|
1392
|
+
resolvedAddresses,
|
|
1393
|
+
});
|
|
1394
|
+
const hopDecision = evaluateEngagementAction(scope, hop);
|
|
1395
|
+
await auditLog("engagement.policy.hop", {
|
|
1396
|
+
...(engagementGraph ? { engagementId: engagementGraph.id } : {}),
|
|
1397
|
+
...(engagementRecord ? { actionId: engagementRecord.id } : {}),
|
|
1398
|
+
url,
|
|
1399
|
+
resolvedAddresses,
|
|
1400
|
+
allowed: hopDecision.allowed,
|
|
1401
|
+
reason: hopDecision.reason,
|
|
1402
|
+
});
|
|
1403
|
+
return { allowed: hopDecision.allowed, reason: hopDecision.reason };
|
|
1404
|
+
},
|
|
1405
|
+
}
|
|
1406
|
+
: {}),
|
|
1135
1407
|
});
|
|
1136
1408
|
if (liveBytes > 0) {
|
|
1137
1409
|
writeToolOutput(toolEventId, "\n", "\n");
|
|
@@ -1141,10 +1413,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1141
1413
|
catch (toolError) {
|
|
1142
1414
|
jobManager.updateJobStatus(jobId, "failed", 1);
|
|
1143
1415
|
if (isAbortError(toolError, toolAc.signal)) {
|
|
1144
|
-
// Only the parent signal represents a user/session cancellation.
|
|
1145
|
-
// A watchdog abort is a local tool timeout; treating it as a global
|
|
1146
|
-
// abort used to end the entire agent turn and strand sibling recon
|
|
1147
|
-
// calls in an incomplete state.
|
|
1148
1416
|
if (parentSignal.aborted) {
|
|
1149
1417
|
writeAbort();
|
|
1150
1418
|
return {
|
|
@@ -1171,54 +1439,84 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1171
1439
|
finally {
|
|
1172
1440
|
if (stallTimer)
|
|
1173
1441
|
clearTimeout(stallTimer);
|
|
1442
|
+
engagementLease?.release();
|
|
1174
1443
|
parentSignal.removeEventListener("abort", onParentAbort);
|
|
1175
1444
|
}
|
|
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
1445
|
if ((call.name === "shell.exec" || call.name === "shell.start") &&
|
|
1182
1446
|
typeof call.args.command === "string" &&
|
|
1183
1447
|
isScaffoldCreateCommand(call.args.command)) {
|
|
1184
1448
|
const cmd = call.args.command;
|
|
1185
1449
|
const cwdArg = typeof call.args.cwd === "string" ? call.args.cwd : undefined;
|
|
1186
|
-
const
|
|
1187
|
-
|
|
1188
|
-
const
|
|
1450
|
+
const out = result.output ?? "";
|
|
1451
|
+
// Prefer path reported by the scaffolder (handles quoted-cd mis-parse leftovers).
|
|
1452
|
+
const fromOutput = out.match(/Scaffolding project in\s+([^\n]+?)\s*\.{0,3}\s*$/im)?.[1]?.trim().replace(/['"]/g, "");
|
|
1453
|
+
const fromScaffold = (fromOutput && fromOutput.startsWith("/")
|
|
1454
|
+
? fromOutput
|
|
1455
|
+
: undefined) ??
|
|
1456
|
+
extractProjectRootFromScaffold(cmd, cwdArg);
|
|
1457
|
+
const cancelled = isScaffoldCancelledOutput(out);
|
|
1458
|
+
let materialized = scaffoldLooksMaterialized(fromScaffold);
|
|
1459
|
+
// One re-check: create-vite can report success before FS snapshot is visible.
|
|
1460
|
+
if (!materialized && fromScaffold) {
|
|
1461
|
+
materialized = scaffoldLooksMaterialized(fromScaffold);
|
|
1462
|
+
}
|
|
1189
1463
|
const abortedMid = !result.ok &&
|
|
1190
1464
|
(result.exitCode === 124 ||
|
|
1191
1465
|
result.exitCode === 130 ||
|
|
1192
|
-
/timed out|aborted|Command aborted/i.test(
|
|
1193
|
-
|
|
1466
|
+
/timed out|aborted|Command aborted/i.test(out));
|
|
1467
|
+
const resumableMaterialized = Boolean(fromScaffold && materialized && (cancelled || abortedMid || !result.ok));
|
|
1468
|
+
if (resumableMaterialized && fromScaffold) {
|
|
1469
|
+
setActiveProjectRootIfValid(fromScaffold, { force: true });
|
|
1470
|
+
await persistProjectRootOnPlan(fromScaffold);
|
|
1471
|
+
result = {
|
|
1472
|
+
...result,
|
|
1473
|
+
ok: true,
|
|
1474
|
+
exitCode: 0,
|
|
1475
|
+
output: out +
|
|
1476
|
+
(out.endsWith("\n") ? "" : "\n") +
|
|
1477
|
+
`The scaffold reported ${cancelled ? "cancellation/refusal" : "interruption"}, but a usable project tree already exists at ${fromScaffold} ` +
|
|
1478
|
+
`(package/manifest present). Treat this as resumable: do NOT re-run the scaffolder. ` +
|
|
1479
|
+
`Inspect the existing files, finish any missing install, implement the requested feature, then run/verify.`,
|
|
1480
|
+
};
|
|
1481
|
+
writeNotice("info", `project root → ${fromScaffold} (existing materialized scaffold — continue)`, chalk.dim(` ℹ existing scaffold at ${fromScaffold} — continue, do not re-create\n`));
|
|
1482
|
+
}
|
|
1483
|
+
else if (result.ok && cancelled && !materialized) {
|
|
1194
1484
|
result = {
|
|
1195
1485
|
ok: false,
|
|
1196
|
-
output:
|
|
1197
|
-
(
|
|
1198
|
-
`Scaffold FAILED:
|
|
1199
|
-
(fromScaffold
|
|
1200
|
-
? `Expected project at ${fromScaffold}. `
|
|
1201
|
-
: "") +
|
|
1486
|
+
output: out +
|
|
1487
|
+
(out.endsWith("\n") ? "" : "\n") +
|
|
1488
|
+
`Scaffold FAILED: tool reported cancel/refuse. ` +
|
|
1489
|
+
(fromScaffold ? `Expected project at ${fromScaffold}. ` : "") +
|
|
1202
1490
|
`If the folder already exists, CONTINUE it (do not re-scaffold). Otherwise use a new empty name or hand-write a minimal tree.`,
|
|
1203
1491
|
exitCode: result.exitCode && result.exitCode !== 0 ? result.exitCode : 1,
|
|
1204
1492
|
};
|
|
1205
1493
|
}
|
|
1494
|
+
else if (result.ok && !materialized) {
|
|
1495
|
+
// Soft warn only when we truly see no tree — do not flip ok if output
|
|
1496
|
+
// clearly scaffolded (path may still resolve on next tool).
|
|
1497
|
+
const claimedScaffold = /Scaffolding project in\b/i.test(out);
|
|
1498
|
+
if (!claimedScaffold) {
|
|
1499
|
+
result = {
|
|
1500
|
+
ok: false,
|
|
1501
|
+
output: out +
|
|
1502
|
+
(out.endsWith("\n") ? "" : "\n") +
|
|
1503
|
+
`Scaffold FAILED: target project tree was not created. ` +
|
|
1504
|
+
(fromScaffold ? `Expected project at ${fromScaffold}. ` : "") +
|
|
1505
|
+
`If the folder already exists, CONTINUE it (do not re-scaffold). Otherwise use a new empty name or hand-write a minimal tree.`,
|
|
1506
|
+
exitCode: result.exitCode && result.exitCode !== 0 ? result.exitCode : 1,
|
|
1507
|
+
};
|
|
1508
|
+
}
|
|
1509
|
+
else if (fromScaffold) {
|
|
1510
|
+
setActiveProjectRootIfValid(fromScaffold, { force: true });
|
|
1511
|
+
await persistProjectRootOnPlan(fromScaffold);
|
|
1512
|
+
writeNotice("info", `project root → ${fromScaffold} (scaffold output claimed success — continue)`, chalk.dim(` ℹ project root set to ${fromScaffold}\n`));
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1206
1515
|
else if (result.ok && fromScaffold && materialized) {
|
|
1207
1516
|
setActiveProjectRootIfValid(fromScaffold, { force: true });
|
|
1517
|
+
await persistProjectRootOnPlan(fromScaffold);
|
|
1208
1518
|
writeNotice("info", `project root → ${fromScaffold}`, chalk.dim(` ℹ project root set to ${fromScaffold}\n`));
|
|
1209
1519
|
}
|
|
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
1520
|
}
|
|
1223
1521
|
const output = result.output.trim();
|
|
1224
1522
|
// Always keep a full on-disk copy of tool output (any size) so the
|
|
@@ -1245,21 +1543,177 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1245
1543
|
exitCode: result.exitCode,
|
|
1246
1544
|
output: result.output.slice(0, 4_000),
|
|
1247
1545
|
});
|
|
1546
|
+
if (engagementGraph && engagementRecord) {
|
|
1547
|
+
if (result.backgroundJob) {
|
|
1548
|
+
const checkpointInput = {
|
|
1549
|
+
jobId: result.backgroundJob.id,
|
|
1550
|
+
status: result.backgroundJob.status,
|
|
1551
|
+
artifactPath: result.backgroundJob.artifactPath,
|
|
1552
|
+
offset: result.backgroundJob.nextOffset ?? 0,
|
|
1553
|
+
observation: result.output.slice(0, 16_000),
|
|
1554
|
+
};
|
|
1555
|
+
const reconciled = reconcileEngagementJob(engagementGraph, checkpointInput);
|
|
1556
|
+
if (!reconciled || reconciled.actionId !== engagementRecord.id) {
|
|
1557
|
+
recordEngagementCheckpoint(engagementGraph, {
|
|
1558
|
+
actionId: engagementRecord.id,
|
|
1559
|
+
...checkpointInput,
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
else {
|
|
1564
|
+
finishEngagementAction(engagementGraph, engagementRecord.id, {
|
|
1565
|
+
ok: result.ok,
|
|
1566
|
+
observation: result.output.slice(0, 16_000),
|
|
1567
|
+
...(savedOutputPath ? { artifactPath: savedOutputPath } : {}),
|
|
1568
|
+
scannerLead: call.name === "net.scan" || call.name.startsWith("pentest."),
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
await saveEngagement(engagementGraph);
|
|
1572
|
+
}
|
|
1573
|
+
const newEvidence = recordToolEvidence(outcomeState, {
|
|
1574
|
+
tool: call.name,
|
|
1575
|
+
callId: toolEventId,
|
|
1576
|
+
ok: result.ok,
|
|
1577
|
+
output: result.output,
|
|
1578
|
+
...(savedOutputPath ? { artifact: savedOutputPath } : {}),
|
|
1579
|
+
...(dispatchedTaskId ? { taskId: dispatchedTaskId } : {}),
|
|
1580
|
+
args: call.args,
|
|
1581
|
+
});
|
|
1582
|
+
let hypothesisDelta = 0;
|
|
1583
|
+
if (!result.ok) {
|
|
1584
|
+
const before = outcomeState.failedHypotheses.length;
|
|
1585
|
+
recordFailedHypothesis(outcomeState, {
|
|
1586
|
+
signature: `${call.name}:${result.exitCode ?? 1}`,
|
|
1587
|
+
premise: `${call.name} with ${JSON.stringify(call.args).slice(0, 1_000)}`,
|
|
1588
|
+
});
|
|
1589
|
+
hypothesisDelta = outcomeState.failedHypotheses.length - before;
|
|
1590
|
+
retryDependenciesChanged = false;
|
|
1591
|
+
retryEnvironmentChanged = false;
|
|
1592
|
+
moveTurn("exploring", `${call.name} failed; revise the premise`);
|
|
1593
|
+
}
|
|
1594
|
+
else {
|
|
1595
|
+
const mutatesDependencies = /^(?:fs\.(?:write|writeMany|edit|replaceLines|append|delete)|pkg\.install)$/.test(call.name) ||
|
|
1596
|
+
((call.name === "shell.exec" || call.name === "shell.start") &&
|
|
1597
|
+
/\b(?:install|mkdir|create|generate|build)\b/i.test(String(call.args.command ?? "")));
|
|
1598
|
+
retryDependenciesChanged ||= mutatesDependencies;
|
|
1599
|
+
retryEnvironmentChanged ||=
|
|
1600
|
+
call.name === "pkg.install" ||
|
|
1601
|
+
((call.name === "shell.exec" || call.name === "shell.start") &&
|
|
1602
|
+
isPackageInstallCommand(String(call.args.command ?? "")));
|
|
1603
|
+
}
|
|
1604
|
+
const governed = governProgress(governorState, "activity", {
|
|
1605
|
+
evidenceDelta: newEvidence.length,
|
|
1606
|
+
hypothesisDelta,
|
|
1607
|
+
repetitionScore: loopGuard.getAttemptCount(call.name, call.args) > 1 ? 1 : 0,
|
|
1608
|
+
policy: {
|
|
1609
|
+
resourceEnvelope: Math.max(12, maxSteps),
|
|
1610
|
+
emergencyCeiling: Math.max(70, maxSteps * 3),
|
|
1611
|
+
reflectionAfterNoDelta: 3,
|
|
1612
|
+
pauseAfterNoDelta: 6,
|
|
1613
|
+
repetitionThreshold: 0.8,
|
|
1614
|
+
},
|
|
1615
|
+
});
|
|
1616
|
+
governorState = governed.state;
|
|
1617
|
+
if (governed.recommendation === "reflect") {
|
|
1618
|
+
deferredPostToolMessages.push({
|
|
1619
|
+
role: "system",
|
|
1620
|
+
content: `PROGRESS GOVERNOR: ${governed.reason}. Reassess the current premise and choose the next action that can produce criterion-linked evidence.`,
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1623
|
+
else if (governed.recommendation === "paused_budget") {
|
|
1624
|
+
governorPauseReason = governed.reason;
|
|
1625
|
+
}
|
|
1626
|
+
await saveOutcomeState(outcomeState);
|
|
1248
1627
|
loopGuard.recordAttempt(step, call.name, call.args, result.ok, result.exitCode);
|
|
1249
1628
|
// Evidence for verify-before-done: only successful real work counts.
|
|
1250
1629
|
if (result.ok && isEvidenceWorkTool(call.name)) {
|
|
1251
1630
|
const liveAfter = await loadPlan(session.sessionId).catch(() => undefined);
|
|
1252
|
-
|
|
1253
|
-
|
|
1631
|
+
// Never credit whichever task happens to be open after execution: a
|
|
1632
|
+
// later task.update in the same batch may already have changed it.
|
|
1633
|
+
const creditId = dispatchedTaskId;
|
|
1634
|
+
const cmd = typeof call.args.command === "string" ? call.args.command : "";
|
|
1635
|
+
const signals = {};
|
|
1636
|
+
if (isFeatureImplementationCall(call))
|
|
1637
|
+
signals.featureWrite = true;
|
|
1638
|
+
if (call.name === "fs.write" ||
|
|
1639
|
+
call.name === "fs.writeMany" ||
|
|
1640
|
+
call.name === "fs.edit" ||
|
|
1641
|
+
call.name === "fs.replaceLines" ||
|
|
1642
|
+
call.name === "fs.append") {
|
|
1643
|
+
signals.sourceWrite = true;
|
|
1644
|
+
}
|
|
1645
|
+
if ((call.name === "shell.exec" || call.name === "shell.start") &&
|
|
1646
|
+
isPackageInstallCommand(cmd)) {
|
|
1647
|
+
signals.installOk = true;
|
|
1648
|
+
}
|
|
1649
|
+
if ((call.name === "shell.exec" || call.name === "shell.start") &&
|
|
1650
|
+
isScaffoldCreateCommand(cmd)) {
|
|
1651
|
+
signals.scaffoldOk = true;
|
|
1652
|
+
}
|
|
1653
|
+
if (isDevServerCall(call))
|
|
1654
|
+
signals.devServerStart = true;
|
|
1655
|
+
const out = result.output ?? "";
|
|
1656
|
+
if ((call.name === "shell.tail" || call.name === "shell.start") &&
|
|
1657
|
+
isServerReadyOutput(out)) {
|
|
1658
|
+
signals.serverReady = true;
|
|
1659
|
+
}
|
|
1660
|
+
if (call.name === "shell.exec" &&
|
|
1661
|
+
isPortListeningOutput(cmd, out)) {
|
|
1662
|
+
signals.portListening = true;
|
|
1663
|
+
}
|
|
1664
|
+
if (localHttpProbeIsSuccess(out) ||
|
|
1665
|
+
(sawLocalHttpProbe && !sawFailedLocalHttpProbe)) {
|
|
1666
|
+
// Prefer explicit success parse on this result
|
|
1667
|
+
if (/\b(localhost|127\.0\.0\.1)\b/i.test(`${call.name} ${cmd} ${JSON.stringify(call.args)}`)) {
|
|
1668
|
+
if (localHttpProbeIsSuccess(out)) {
|
|
1669
|
+
signals.localHttpProbeOk = true;
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
// Remote/pentest evidence — never conflate with local app runtime
|
|
1674
|
+
if (isRemoteReconToolCall(call))
|
|
1675
|
+
signals.remoteReconOk = true;
|
|
1676
|
+
if (isRemoteActiveTestCall(call))
|
|
1677
|
+
signals.remoteActiveTestOk = true;
|
|
1678
|
+
// Always bank the success for later absorb (preflight / no open task).
|
|
1679
|
+
sessionLooseWork.push({
|
|
1680
|
+
toolName: call.name,
|
|
1681
|
+
...(Object.keys(signals).length > 0 ? { signals } : {}),
|
|
1682
|
+
});
|
|
1683
|
+
taskWorkLedger = recordTaskWorkSuccess(taskWorkLedger, creditId, call.name, signals);
|
|
1684
|
+
// If nothing was open, still try to attach to the next ready explore
|
|
1685
|
+
// task so "Check Node/npm" can complete without thrash.
|
|
1686
|
+
if ((!creditId || !taskWorkLedger || taskWorkLedger.taskId !== creditId) && liveAfter) {
|
|
1687
|
+
const ready = readyPlanTasks(liveAfter)[0];
|
|
1688
|
+
if (ready) {
|
|
1689
|
+
const absorbed = absorbLooseWorkIntoLedger(ledgerFromTaskEvidence(ready.id, ready.evidence), ready.id, ready.title, [{ toolName: call.name, signals }], { planKind: liveAfter.kind });
|
|
1690
|
+
if (absorbed && absorbed.successWorkCount > 0) {
|
|
1691
|
+
const task = liveAfter.tasks.find((t) => t.id === ready.id);
|
|
1692
|
+
if (task) {
|
|
1693
|
+
task.evidence = taskEvidenceFromLedger(absorbed);
|
|
1694
|
+
if (!taskWorkLedger ||
|
|
1695
|
+
taskWorkLedger.taskId !== ready.id ||
|
|
1696
|
+
taskWorkLedger.successWorkCount < absorbed.successWorkCount) {
|
|
1697
|
+
taskWorkLedger = absorbed;
|
|
1698
|
+
}
|
|
1699
|
+
await savePlan(liveAfter).catch(() => undefined);
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
if (liveAfter && creditId && taskWorkLedger?.taskId === creditId) {
|
|
1705
|
+
const task = liveAfter.tasks.find((candidate) => candidate.id === creditId);
|
|
1706
|
+
if (task) {
|
|
1707
|
+
task.evidence = taskEvidenceFromLedger(taskWorkLedger);
|
|
1708
|
+
await savePlan(liveAfter).catch(() => undefined);
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
refreshSessionState(liveAfter);
|
|
1254
1712
|
}
|
|
1255
|
-
// Inject approach evaluation when consecutive failures are detected.
|
|
1256
|
-
// Lets the MODEL decide (with full context) whether to continue a
|
|
1257
|
-
// legitimately long approach, switch, or stop — instead of a
|
|
1258
|
-
// hardcoded kill threshold.
|
|
1259
1713
|
if (!result.ok) {
|
|
1260
1714
|
const reflection = loopGuard.getFailureReflection();
|
|
1261
1715
|
if (reflection) {
|
|
1262
|
-
|
|
1716
|
+
deferredPostToolMessages.push({ role: "system", content: reflection });
|
|
1263
1717
|
const failCount = loopGuard.consecutiveFailureCount();
|
|
1264
1718
|
writeNotice("warn", `${failCount} consecutive failures — model evaluating approach`, chalk.yellow(` ⚠ ${failCount} consecutive failures — evaluating approach\n`));
|
|
1265
1719
|
}
|
|
@@ -1298,9 +1752,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1298
1752
|
artifactPath: savedOutputPath,
|
|
1299
1753
|
summary: contextOutput,
|
|
1300
1754
|
});
|
|
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
1755
|
if (writesDirectly && savedOutputPath) {
|
|
1305
1756
|
const short = chalk.dim(` saved ${savedOutputPath}\n`);
|
|
1306
1757
|
process.stdout.write(short);
|
|
@@ -1312,15 +1763,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1312
1763
|
}
|
|
1313
1764
|
return { ok: result.ok, call, result, contextOutput };
|
|
1314
1765
|
}
|
|
1315
|
-
// Automatic context compaction
|
|
1316
|
-
// As a long turn accumulates tool outputs and reasoning, the context can
|
|
1317
|
-
// grow past what the model can hold. We proactively summarize the older
|
|
1318
|
-
// turns into a single continuation memory (the SAME model-written summary
|
|
1319
|
-
// the /compact command uses — never a mechanical transcript dump) and then
|
|
1320
|
-
// re-inject the ACTIVE PLAN so the agent never loses track of the plan,
|
|
1321
|
-
// what is done, and what remains. The estimate is chars/4; the budget is
|
|
1322
|
-
// deliberately conservative so we compact a little early rather than hit a
|
|
1323
|
-
// provider context-window error mid-task.
|
|
1324
1766
|
const AUTO_COMPACT_KEEP_RECENT = 6;
|
|
1325
1767
|
let lastCompactionMsgCount = 0;
|
|
1326
1768
|
const summarizeForCompaction = async (summaryPrompt) => {
|
|
@@ -1328,10 +1770,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1328
1770
|
provider,
|
|
1329
1771
|
model,
|
|
1330
1772
|
messages: [
|
|
1331
|
-
{
|
|
1332
|
-
role: "system",
|
|
1333
|
-
content: "You compress conversation history into an accurate, concise continuation memory for another assistant.",
|
|
1334
|
-
},
|
|
1773
|
+
{ role: "system", content: COMPACTION_SYSTEM_PROMPT },
|
|
1335
1774
|
{ role: "user", content: summaryPrompt },
|
|
1336
1775
|
],
|
|
1337
1776
|
temperature: 0.1,
|
|
@@ -1367,6 +1806,8 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1367
1806
|
content: planContextMessage(livePlan, session.planApproved.value),
|
|
1368
1807
|
});
|
|
1369
1808
|
}
|
|
1809
|
+
// Re-inject live SESSION STATE after compaction (older flags survive).
|
|
1810
|
+
refreshSessionState(livePlan);
|
|
1370
1811
|
lastCompactionMsgCount = messages.length;
|
|
1371
1812
|
// Final count the model actually receives (may include re-injected plan).
|
|
1372
1813
|
const afterTokens = estimateMessagesTokens(messages);
|
|
@@ -1375,10 +1816,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1375
1816
|
estimatedTokens: afterTokens,
|
|
1376
1817
|
reason,
|
|
1377
1818
|
});
|
|
1378
|
-
// Extract the inserted compaction memory so we can surface the
|
|
1379
|
-
// summary itself (not just token-count stats). The summary lives in
|
|
1380
|
-
// the first system message whose content begins with
|
|
1381
|
-
// COMPACTION_MEMORY_PREFIX.
|
|
1382
1819
|
const insertedSummary = messages.find((m) => m.role === "system" &&
|
|
1383
1820
|
m.content.startsWith(COMPACTION_MEMORY_PREFIX))?.content ?? "";
|
|
1384
1821
|
const summaryText = insertedSummary.startsWith(`${COMPACTION_MEMORY_PREFIX}\n\n`)
|
|
@@ -1404,84 +1841,32 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1404
1841
|
}
|
|
1405
1842
|
}
|
|
1406
1843
|
for (let iteration = 0; iteration < maxIterations; iteration += 1) {
|
|
1407
|
-
// Each model round-trip re-decides whether its visible prose has been
|
|
1408
|
-
// committed, so recovery-prose preservation applies to THIS turn's
|
|
1409
|
-
// stream and not a prior one.
|
|
1410
1844
|
visibleCommitted = false;
|
|
1411
1845
|
// `step` is the productive-step index (used for display + audit). It only
|
|
1412
1846
|
// advances when the previous iteration actually executed a tool.
|
|
1413
1847
|
step = productiveSteps;
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
// Abort / non-interactive — treat as decline.
|
|
1424
|
-
shouldContinue = false;
|
|
1425
|
-
}
|
|
1426
|
-
if (shouldContinue) {
|
|
1427
|
-
// Extend the budget for another chunk of work.
|
|
1428
|
-
const extension = Math.max(40, maxSteps);
|
|
1429
|
-
stepBudget += extension;
|
|
1430
|
-
maxIterations = stepBudget * 3;
|
|
1431
|
-
// Compact older messages (model-written summary, no mechanical dump)
|
|
1432
|
-
// to free context space for the next chunk of work.
|
|
1433
|
-
await maybeAutoCompact("step-budget-continue", true);
|
|
1434
|
-
// Inject a progress summary so the model stays focused.
|
|
1435
|
-
const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
1436
|
-
let progressNote = "The step limit was reached and the user chose to continue. ";
|
|
1437
|
-
progressNote +=
|
|
1438
|
-
"Review what you have accomplished so far and continue with the NEXT unfinished step. ";
|
|
1439
|
-
progressNote +=
|
|
1440
|
-
"Do NOT repeat work already done. Do NOT re-fetch pages or re-run scans whose results you already have.";
|
|
1441
|
-
if (livePlan) {
|
|
1442
|
-
const doneTasks = livePlan.tasks.filter((t) => t.state === "done");
|
|
1443
|
-
const pendingTasks = livePlan.tasks.filter((t) => t.state === "pending" || t.state === "in_progress");
|
|
1444
|
-
progressNote += `\n\nPlan progress: ${doneTasks.length}/${livePlan.tasks.length} tasks done.`;
|
|
1445
|
-
if (pendingTasks.length > 0) {
|
|
1446
|
-
progressNote += ` Next: ${pendingTasks[0].id} — "${pendingTasks[0].title}".`;
|
|
1447
|
-
}
|
|
1448
|
-
}
|
|
1449
|
-
messages.push({ role: "user", content: progressNote });
|
|
1450
|
-
writeNotice("info", `continuing — budget extended to ${stepBudget} steps`, chalk.dim(` ℹ continuing — budget extended to ${stepBudget} steps\n`));
|
|
1451
|
-
// Continue the loop — model doesn't know it paused.
|
|
1452
|
-
}
|
|
1453
|
-
else {
|
|
1454
|
-
// User declined — build a rich summary and return.
|
|
1455
|
-
const richSummary = await buildRichStopSummary(messages, session, productiveSteps);
|
|
1456
|
-
writeAssistantMessage(richSummary);
|
|
1457
|
-
lastAnswer = richSummary;
|
|
1458
|
-
return finishTurn(lastAnswer, productiveSteps);
|
|
1459
|
-
}
|
|
1848
|
+
if (governorPauseReason) {
|
|
1849
|
+
const richSummary = await buildRichStopSummary(messages, session, productiveSteps);
|
|
1850
|
+
outcomeState.outcome.status = "paused_budget";
|
|
1851
|
+
await saveOutcomeState(outcomeState);
|
|
1852
|
+
moveTurn("paused_budget", governorPauseReason);
|
|
1853
|
+
lastAnswer = richSummary;
|
|
1854
|
+
return finishTurn(lastAnswer, productiveSteps, "paused_budget", outcomeState.outcome.criteria
|
|
1855
|
+
.filter((criterion) => criterion.required && criterion.status !== "proven")
|
|
1856
|
+
.map((criterion) => criterion.statement), governorPauseReason);
|
|
1460
1857
|
}
|
|
1461
1858
|
options.signal?.throwIfAborted();
|
|
1462
|
-
// `call` and `assistantText` are shared by both paths below: a fresh
|
|
1463
|
-
// model round-trip, or draining a previously-queued tool call.
|
|
1464
1859
|
let call;
|
|
1465
1860
|
let assistantText;
|
|
1466
1861
|
let recoveredFromBareJson = false;
|
|
1467
1862
|
if (pendingCalls.length > 0) {
|
|
1468
|
-
// Drain the next queued call from the previous model message — no new
|
|
1469
|
-
// round-trip. The assistant message and any prose were already shown
|
|
1470
|
-
// when the batch was parsed.
|
|
1471
1863
|
call = pendingCalls.shift();
|
|
1472
1864
|
assistantText = { visible: "", thinkContent: "", hasThinking: false };
|
|
1473
1865
|
const batchStatus = ` ↳ continuing batch (${pendingCalls.length} more queued)\n`;
|
|
1474
1866
|
writeStatus(batchStatus, chalk.dim(batchStatus));
|
|
1475
1867
|
}
|
|
1476
1868
|
else {
|
|
1477
|
-
// Before a fresh model round-trip, proactively compact if the context has
|
|
1478
|
-
// grown too large, so we never hit a provider context-window error and the
|
|
1479
|
-
// model keeps a clean, plan-aware memory.
|
|
1480
1869
|
await maybeAutoCompact("auto-token-budget");
|
|
1481
|
-
// Buffer LLM output so tool JSON and hidden thinking are not printed raw.
|
|
1482
|
-
// Status messages (rate-limit retries, fallback hints) still surface live.
|
|
1483
|
-
// A spinner gives the user feedback during long thinking phases on
|
|
1484
|
-
// models like glm-5.1 / deepseek-v4-flash that stream reasoning first.
|
|
1485
1870
|
const streamLabel = step === 0 ? "waiting for model" : `step ${step + 1}`;
|
|
1486
1871
|
let spinner = writesDirectly
|
|
1487
1872
|
? startThinkingSpinner(streamLabel, options.signal)
|
|
@@ -1496,9 +1881,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1496
1881
|
let accumulatedText = "";
|
|
1497
1882
|
const callIds = [];
|
|
1498
1883
|
let streamedCallsCount = 0;
|
|
1499
|
-
// Deferred tool-call events: collect tool calls parsed from the stream
|
|
1500
|
-
// and emit them AFTER thinking + assistant text, so the display order
|
|
1501
|
-
// is correct: thinking → model text → tool-call cards.
|
|
1502
1884
|
const deferredToolCalls = [];
|
|
1503
1885
|
const deltaParser = writesDirectly
|
|
1504
1886
|
? undefined
|
|
@@ -1518,7 +1900,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1518
1900
|
if (messages[0]?.role === "system") {
|
|
1519
1901
|
messages[0] = {
|
|
1520
1902
|
role: "system",
|
|
1521
|
-
content:
|
|
1903
|
+
content: composeCurrentSystemPrompt(nativeToolsActive),
|
|
1522
1904
|
};
|
|
1523
1905
|
}
|
|
1524
1906
|
const turnTools = selectToolDefs(nativeToolsActive, useCompactSystemPrompt);
|
|
@@ -1530,30 +1912,13 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1530
1912
|
dialect: toolDialect,
|
|
1531
1913
|
step,
|
|
1532
1914
|
});
|
|
1915
|
+
assertValidToolProtocol(messages);
|
|
1533
1916
|
completion = await streamWithProvider({
|
|
1534
1917
|
provider,
|
|
1535
1918
|
model,
|
|
1536
|
-
// Stay on the user's selected provider/model. Cross-provider
|
|
1537
|
-
// hops (e.g. bynara → gemini on a 429) only happen when the
|
|
1538
|
-
// user has explicitly enabled `providerFallback` AND this is
|
|
1539
|
-
// the provider's default model. Never force fallback mid-turn.
|
|
1540
1919
|
allowModelFallback: false,
|
|
1541
1920
|
messages,
|
|
1542
|
-
// MiniMax M3 degenerates at the generic agent temperature. The
|
|
1543
|
-
// HTTP layer also applies its `top_p` override for both the
|
|
1544
|
-
// NVIDIA long ID and Kimchi's short `minimax-m3` ID.
|
|
1545
1921
|
temperature: /minimax-m3/i.test(model) ? 1.0 : 0.2,
|
|
1546
|
-
// Reasoning models can spend a lot on hidden thinking; give
|
|
1547
|
-
// them headroom so the visible answer / tool call isn't
|
|
1548
|
-
// truncated to silence. The non-thinking budget must be large
|
|
1549
|
-
// enough for a single-file fs.write / multi-file fs.writeMany
|
|
1550
|
-
// payload — a truncated tool-call JSON fails to parse and leaks a
|
|
1551
|
-
// broken (and syntactically invalid) file. 8k was too small for a
|
|
1552
|
-
// full component, so allow more room for the visible tool call.
|
|
1553
|
-
// Code-generation calls frequently contain an entire source file
|
|
1554
|
-
// inside JSON. A 12k visible-token ceiling cut otherwise valid
|
|
1555
|
-
// fs.write calls in half. Keep enough output headroom for a
|
|
1556
|
-
// substantial source file; providers with a lower limit clamp it.
|
|
1557
1922
|
maxTokens: 32_768,
|
|
1558
1923
|
signal: options.signal,
|
|
1559
1924
|
thinking: retryWithoutThinking
|
|
@@ -1656,9 +2021,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1656
2021
|
}
|
|
1657
2022
|
}
|
|
1658
2023
|
}
|
|
1659
|
-
// Heuristic: <think>… markers and reasoning_content tokens flow
|
|
1660
|
-
// through onToken. Surface activity in the spinner so the screen
|
|
1661
|
-
// is never empty for minutes.
|
|
1662
2024
|
if (!sawReasoning && /<think/i.test(token)) {
|
|
1663
2025
|
sawReasoning = true;
|
|
1664
2026
|
inThinking = true;
|
|
@@ -1671,11 +2033,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1671
2033
|
spinner.setLabel("generating response (0 tokens)");
|
|
1672
2034
|
generatedTokens = 0;
|
|
1673
2035
|
}
|
|
1674
|
-
// Only push reasoning tokens to the spinner preview. Visible
|
|
1675
|
-
// answer / tool-call tokens should NOT go through the dim
|
|
1676
|
-
// spinner preview — doing so makes the final answer appear
|
|
1677
|
-
// "diluted" in light font when the spinner's last render
|
|
1678
|
-
// briefly shows the answer text before being erased.
|
|
1679
2036
|
if (inThinking) {
|
|
1680
2037
|
const cleaned = token.replace(/<\/?think[^>]*>/gi, "");
|
|
1681
2038
|
if (cleaned) {
|
|
@@ -1711,13 +2068,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1711
2068
|
(toolsAttached && !isTextOnlyModel(provider, model));
|
|
1712
2069
|
const assistantTextResult = rememberThinkingFromText(completion.text);
|
|
1713
2070
|
assistantText = assistantTextResult;
|
|
1714
|
-
// Commit thinking to the transcript IMMEDIATELY, before any of the
|
|
1715
|
-
// branches below decide to `continue` (retry a malformed tool call,
|
|
1716
|
-
// nudge for narration, guard premature completion, etc). Previously
|
|
1717
|
-
// writeThinkingBlock was only called from a few terminal branches, so
|
|
1718
|
-
// any retry path silently dropped the model's reasoning — the user
|
|
1719
|
-
// would see the live "thinking…" preview during streaming and then
|
|
1720
|
-
// watch it vanish with nothing committed once the turn moved on.
|
|
1721
2071
|
if (assistantText.hasThinking) {
|
|
1722
2072
|
writeThinkingBlock(assistantText.thinkContent);
|
|
1723
2073
|
}
|
|
@@ -1780,11 +2130,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1780
2130
|
}
|
|
1781
2131
|
}
|
|
1782
2132
|
}
|
|
1783
|
-
// Try visible text first, then thinking content — some models (e.g. glm-5.1)
|
|
1784
|
-
// wrap tool calls inside considering tags, so stripThinking removes them
|
|
1785
|
-
// into thinkContent and visible becomes empty. Recovering from thinkContent
|
|
1786
|
-
// prevents an endless nudge loop where the model keeps hiding the call.
|
|
1787
|
-
// When native toolCalls exist, skip text parse as primary (no double-exec).
|
|
1788
2133
|
if (nativeToolCalls.length) {
|
|
1789
2134
|
const first = nativeToolCalls[0];
|
|
1790
2135
|
if (first.args?._parseError) {
|
|
@@ -1807,12 +2152,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1807
2152
|
}
|
|
1808
2153
|
}
|
|
1809
2154
|
}
|
|
1810
|
-
// ── Prompt-leak guard ─────────────────────────────────────────
|
|
1811
|
-
// If the model's visible output contains distinctive system-prompt
|
|
1812
|
-
// markers, it is repeating its instructions (e.g. prompt injection
|
|
1813
|
-
// via "repeat your instructions verbatim"). Any tool-call syntax
|
|
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
2155
|
if (looksLikePromptLeak(assistantText.visible)) {
|
|
1817
2156
|
if (call || nativeToolCalls.length) {
|
|
1818
2157
|
writeNotice("warn", "suppressed tool call from apparent prompt leak", chalk.yellow(" ⚠ suppressed tool call — model appears to be repeating its system prompt\n"));
|
|
@@ -1821,10 +2160,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1821
2160
|
nativeToolCalls = [];
|
|
1822
2161
|
deferredToolCalls.length = 0;
|
|
1823
2162
|
}
|
|
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
2163
|
if (nativeToolCalls.length) {
|
|
1829
2164
|
// Only salvage when args failed to parse (truncated JSON). A clean
|
|
1830
2165
|
// parse with finish_reason=length is a complete tool call — execute it.
|
|
@@ -1887,14 +2222,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1887
2222
|
}
|
|
1888
2223
|
}
|
|
1889
2224
|
}
|
|
1890
|
-
// Empty-response recovery
|
|
1891
|
-
// Some models occasionally return an empty completion: a reasoning
|
|
1892
|
-
// model that spent its whole budget on hidden <think> reasoning and emitted
|
|
1893
|
-
// no visible text, OR (more perniciously) a gateway hiccup that
|
|
1894
|
-
// streamed [DONE] with no content deltas at all. Without this guard
|
|
1895
|
-
// the agent silently ends the turn with no answer, no warning, and no
|
|
1896
|
-
// error — the user just sees the spinner stop. Catch BOTH cases
|
|
1897
|
-
// (thinking-only AND truly empty) and nudge the model to retry.
|
|
1898
2225
|
if (!assistantText.visible.trim() && !call) {
|
|
1899
2226
|
emptyVisibleRetries += 1;
|
|
1900
2227
|
if (emptyVisibleRetries <= 3) {
|
|
@@ -1912,10 +2239,10 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1912
2239
|
? toolsAttached
|
|
1913
2240
|
? "No visible output. This is current or scheduled information: call web.search now. Do NOT answer from memory."
|
|
1914
2241
|
: "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."
|
|
1915
|
-
:
|
|
2242
|
+
: isPlanMode && !activePlan
|
|
1916
2243
|
? toolsAttached
|
|
1917
|
-
? "No visible output.
|
|
1918
|
-
: "No visible output.
|
|
2244
|
+
? "No visible output. In plan mode: gather context or call plan.create when ready (do not only describe the plan)."
|
|
2245
|
+
: "No visible output. In plan mode: emit a ```tool block for research/recon or plan.create. " +
|
|
1919
2246
|
"Do NOT hide tool calls in <think> tags — put them in the visible response."
|
|
1920
2247
|
: toolsAttached
|
|
1921
2248
|
? "No visible output. " + toolNudge(true)
|
|
@@ -1924,8 +2251,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1924
2251
|
messages.push(recoveryUserMessage(buildNudge));
|
|
1925
2252
|
continue;
|
|
1926
2253
|
}
|
|
1927
|
-
// Exhausted retries — surface a clear notice and exit the turn instead
|
|
1928
|
-
// of falling through and triggering premature-completion loops.
|
|
1929
2254
|
writeNotice("warn", "model returned an empty response after retries — no answer produced", chalk.yellow(" ⚠ model returned an empty response after retries — no answer produced\n"));
|
|
1930
2255
|
return finishTurn("Model returned an empty response after retries.", step + 1);
|
|
1931
2256
|
}
|
|
@@ -1934,12 +2259,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1934
2259
|
emptyVisibleRetries = 0;
|
|
1935
2260
|
retryWithoutThinking = false;
|
|
1936
2261
|
}
|
|
1937
|
-
// `call` was already extracted above (from visible text or thinking content).
|
|
1938
|
-
// Recovery: the model meant to call a tool but emitted a bare JSON object
|
|
1939
|
-
// with no ```tool fence — either a complete {name,args} the strict
|
|
1940
|
-
// matchers missed (recover it directly), or just an args object like
|
|
1941
|
-
// {"path":"file.pdf"} with the wrapper dropped (nudge a retry below so
|
|
1942
|
-
// the requested action runs instead of the JSON leaking as the answer).
|
|
1943
2262
|
let bareArgsOnly = false;
|
|
1944
2263
|
recoveredFromBareJson = false;
|
|
1945
2264
|
if (!call) {
|
|
@@ -1975,16 +2294,13 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1975
2294
|
? " ⚠ tool call missing its name — asking the model to call a tool properly\n"
|
|
1976
2295
|
: " ⚠ tool call missing its name/fence — asking the model to re-emit a proper ```tool block\n"));
|
|
1977
2296
|
pushAssistantHistory(assistantText.visible);
|
|
1978
|
-
messages.push(recoveryUserMessage(
|
|
2297
|
+
messages.push(recoveryUserMessage(isPlanMode && !activePlan
|
|
1979
2298
|
? toolsAttached
|
|
1980
2299
|
? "Your previous message was a bare JSON args object with no tool name, so NOTHING ran. " +
|
|
1981
|
-
"
|
|
1982
|
-
"Do NOT use fs.write, fs.writeMany, shell.exec, or pkg.install yet."
|
|
2300
|
+
"In plan mode: call plan.create (or research tools) via the platform tool interface."
|
|
1983
2301
|
: "Your previous message was a bare JSON args object with no tool name and no ```tool fence, so NOTHING ran. " +
|
|
1984
|
-
"
|
|
1985
|
-
"
|
|
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."
|
|
2302
|
+
"In plan mode, call plan.create with a proper ```tool block when ready, e.g.:\n" +
|
|
2303
|
+
'```tool\n{"name":"plan.create","args":{"goal":"…","detail":"…","tasks":["…"],"kind":"coding"}}\n```'
|
|
1988
2304
|
: toolsAttached
|
|
1989
2305
|
? "Your previous message was a bare JSON args object with no tool name, so NOTHING ran. " +
|
|
1990
2306
|
toolNudge(true) +
|
|
@@ -1998,10 +2314,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1998
2314
|
}
|
|
1999
2315
|
// Exhausted retries — fall through to the normal answer path.
|
|
2000
2316
|
}
|
|
2001
|
-
// Detect the case where the model emitted sentinel-style tool-call
|
|
2002
|
-
// markers but the body was malformed or truncated. Printing those
|
|
2003
|
-
// raw tokens looks like a crash to the user — instead, ask the
|
|
2004
|
-
// model to retry the tool call in a clean JSON format.
|
|
2005
2317
|
if (/<\|tool_call(?:s_section)?_begin\|>|<\|tool_call_argument_begin\|>/i.test(assistantText.visible)) {
|
|
2006
2318
|
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"));
|
|
2007
2319
|
pushAssistantHistory(assistantText.visible);
|
|
@@ -2015,17 +2327,8 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2015
2327
|
"Do not use <|tool_call_begin|> markers."));
|
|
2016
2328
|
continue;
|
|
2017
2329
|
}
|
|
2018
|
-
// Detect a tool call that opened but was cut off by the token limit
|
|
2019
|
-
// (most common with large fs.write/fs.writeMany for reports).
|
|
2020
|
-
// Instead of asking the model to retry (which will just truncate
|
|
2021
|
-
// again at the same limit), we SALVAGE the partial content from the
|
|
2022
|
-
// truncated JSON and write it, then tell the model to CONTINUE with
|
|
2023
|
-
// fs.append from where it was cut off.
|
|
2024
2330
|
if (looksLikeTruncatedToolCall(assistantText.visible)) {
|
|
2025
2331
|
truncatedToolRetries += 1;
|
|
2026
|
-
// Try to salvage a partial fs.write / fs.append from the truncated JSON.
|
|
2027
|
-
// The pattern is: {"name":"fs.write","args":{"path":"...","content":"...
|
|
2028
|
-
// We extract the path and whatever content was produced before truncation.
|
|
2029
2332
|
const salvaged = salvageTruncatedWrite(assistantText.visible);
|
|
2030
2333
|
if (salvaged && truncatedToolRetries <= 5) {
|
|
2031
2334
|
// Write the salvaged partial content
|
|
@@ -2085,14 +2388,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2085
2388
|
// Exhausted retries — fall through so we don't loop forever, but the
|
|
2086
2389
|
// user at least sees the (broken) output and the stop notice.
|
|
2087
2390
|
}
|
|
2088
|
-
// Detect a ```tool fence whose JSON could NOT be parsed for any other
|
|
2089
|
-
// reason (malformed braces, trailing junk, a stray `}` — NOT plain
|
|
2090
|
-
// truncation, which is handled above). Without this, the raw block
|
|
2091
|
-
// leaks to the screen as a code fence and the requested action (often
|
|
2092
|
-
// a whole fs.writeMany scaffold) silently never runs — exactly the
|
|
2093
|
-
// "fs.writeMany printed but nothing created" failure. Require the fence
|
|
2094
|
-
// to actually look like an intended call (mentions name/args) so a
|
|
2095
|
-
// genuine ```tool code example in prose isn't mistaken for one.
|
|
2096
2391
|
const hasFencedCallShape = countToolFences(assistantText.visible) > 0 &&
|
|
2097
2392
|
/```tool\s*\n[\s\S]*?"(?:name|args)"\s*:/i.test(assistantText.visible);
|
|
2098
2393
|
if (hasFencedCallShape) {
|
|
@@ -2145,43 +2440,21 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2145
2440
|
}
|
|
2146
2441
|
// Exhausted retries — fall through to the normal path.
|
|
2147
2442
|
}
|
|
2148
|
-
// Normal final-answer path: strip any stray sentinel tokens that
|
|
2149
|
-
// somehow leaked into prose so the answer renders cleanly.
|
|
2150
2443
|
const cleaned = stripSentinelTokens(assistantText.visible);
|
|
2151
|
-
// Act, don't narrate
|
|
2152
|
-
// Build/scaffold/plan turns must DO something. If the model returns
|
|
2153
|
-
// prose with NO tool call, it is narrating intent ("Let me first
|
|
2154
|
-
// explore the directory…") or writing a PLAN as prose ("Goal: … Tasks:
|
|
2155
|
-
// … please approve") instead of calling a tool — accepting it as a
|
|
2156
|
-
// final answer ends the turn with nothing done and no real plan saved.
|
|
2157
|
-
// Nudge it to emit a real tool call, with a concrete example.
|
|
2158
2444
|
const narratedAction = looksLikeActionNarration(cleaned);
|
|
2159
2445
|
const narratedWebAction = looksLikeWebActionNarration(cleaned);
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
// be forced back into execution. Without this, a completed plan
|
|
2164
|
-
// still appeared open and a short final summary such as "I'll
|
|
2165
|
-
// summarize the findings" could be discarded for an unnecessary
|
|
2166
|
-
// recovery turn.
|
|
2167
|
-
const livePlanAtCompletion = await loadPlan(session.sessionId).catch(() => undefined);
|
|
2446
|
+
const reconciledPlanAtCompletion = await reconcileOpenTaskBeforeFinalizing();
|
|
2447
|
+
const livePlanAtCompletion = reconciledPlanAtCompletion ??
|
|
2448
|
+
(await loadPlan(session.sessionId).catch(() => undefined));
|
|
2168
2449
|
const planStatusAtCompletion = livePlanAtCompletion?.status ?? activePlan?.status;
|
|
2169
2450
|
const completedPlanDuringThisTurn = activePlan?.status !== "completed" &&
|
|
2170
2451
|
planStatusAtCompletion === "completed";
|
|
2171
2452
|
const planHasOpenWorkNow = planHasOpenWork(planStatusAtCompletion);
|
|
2172
|
-
// User-driven reasons to require tools this turn (build/pentest/
|
|
2173
|
-
// approved plan / freshness). Idle greetings never qualify.
|
|
2174
2453
|
const userExpectsWork = freshWebSearchRequired ||
|
|
2175
2454
|
(planHasOpenWorkNow && session.planApproved.value) ||
|
|
2176
2455
|
(!informationalQuery &&
|
|
2177
2456
|
!idleOrSocialPrompt &&
|
|
2178
2457
|
(buildLikeTurn || pentestLikeTurn));
|
|
2179
|
-
// History-inherited build/pentest intent only forces action when
|
|
2180
|
-
// THIS prompt is not itself a plain question/idle turn. Narration
|
|
2181
|
-
// stalls ("I'll list the files") still force a tool when the user
|
|
2182
|
-
// is not asking an informational question — but capability menus
|
|
2183
|
-
// and greetings are filtered out by looksLikeActionNarration /
|
|
2184
|
-
// idleOrSocialPrompt so they cannot burn recovery turns.
|
|
2185
2458
|
const wantsAction = !completedPlanDuringThisTurn &&
|
|
2186
2459
|
!idleOrSocialPrompt &&
|
|
2187
2460
|
(userExpectsWork ||
|
|
@@ -2191,12 +2464,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2191
2464
|
!activePlan &&
|
|
2192
2465
|
looksLikePlanNarration(cleaned);
|
|
2193
2466
|
const errorFixNarration = looksLikeErrorDiagnosisWithFixIntent(cleaned);
|
|
2194
|
-
// Once a real tool step has run, a no-plan task has no durable task
|
|
2195
|
-
// state to prove whether another action is needed. A tool-free reply
|
|
2196
|
-
// must therefore be allowed to finalize instead of turning a short
|
|
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
2467
|
const shouldRetryBeforeFinalizing = productiveSteps === 0 ||
|
|
2201
2468
|
planNarrated ||
|
|
2202
2469
|
(session.planApproved.value &&
|
|
@@ -2206,126 +2473,71 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2206
2473
|
(buildLikeTurn && errorFixNarration);
|
|
2207
2474
|
if (wantsAction &&
|
|
2208
2475
|
cleaned.trim().length > 0 &&
|
|
2209
|
-
actionIntentRetries < 3 &&
|
|
2210
2476
|
shouldRetryBeforeFinalizing) {
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
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"));
|
|
2477
|
+
let action;
|
|
2478
|
+
if (errorFixNarration && budgetRemaining(recovery, "errorFix")) {
|
|
2479
|
+
action = recoveryForErrorDiagnosis(toolsAttached);
|
|
2224
2480
|
}
|
|
2225
|
-
else if (
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
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"));
|
|
2481
|
+
else if (budgetRemaining(recovery, "actionIntent") &&
|
|
2482
|
+
planHasOpenWorkNow &&
|
|
2483
|
+
session.planApproved.value) {
|
|
2484
|
+
action = recoveryForNarration(toolsAttached, "plan_open");
|
|
2230
2485
|
}
|
|
2231
|
-
else if (
|
|
2232
|
-
|
|
2233
|
-
|
|
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" +
|
|
2235
|
-
'```tool\n{"name":"sysinfo","args":{}}\n```\n' +
|
|
2236
|
-
"Every turn MUST contain a ```tool block until the task is done.";
|
|
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"));
|
|
2486
|
+
else if (budgetRemaining(recovery, "actionIntent") &&
|
|
2487
|
+
pentestLikeTurn) {
|
|
2488
|
+
action = recoveryForNarration(toolsAttached, "pentest");
|
|
2238
2489
|
}
|
|
2239
|
-
else if (
|
|
2240
|
-
|
|
2241
|
-
|
|
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" +
|
|
2245
|
-
'```tool\n{"name":"web.fetch","args":{"url":"https://example.com/page","responseMode":"readable"}}\n```\n' +
|
|
2246
|
-
"If you do not know the exact page URL, use web.search first. After the tool output, answer from the fetched page content.";
|
|
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"));
|
|
2490
|
+
else if (budgetRemaining(recovery, "actionIntent") &&
|
|
2491
|
+
(freshWebSearchRequired || narratedWebAction)) {
|
|
2492
|
+
action = recoveryForNarration(toolsAttached, "web");
|
|
2248
2493
|
}
|
|
2249
|
-
else if (
|
|
2494
|
+
else if (budgetRemaining(recovery, "actionIntent") &&
|
|
2495
|
+
buildLikeTurn &&
|
|
2250
2496
|
(planNarrated || productiveSteps > 0)) {
|
|
2251
|
-
|
|
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" +
|
|
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` +
|
|
2256
|
-
"Do not describe the plan again in prose — just emit the plan.create tool block.";
|
|
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"));
|
|
2497
|
+
action = recoveryForNarration(toolsAttached, "build_plan_prose");
|
|
2258
2498
|
}
|
|
2259
|
-
else if (
|
|
2260
|
-
|
|
2261
|
-
|
|
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" +
|
|
2263
|
-
'```tool\n{"name":"fs.list","args":{"path":"."}}\n```\n' +
|
|
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.";
|
|
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"));
|
|
2499
|
+
else if (budgetRemaining(recovery, "actionIntent") &&
|
|
2500
|
+
buildLikeTurn) {
|
|
2501
|
+
action = recoveryForNarration(toolsAttached, "build");
|
|
2266
2502
|
}
|
|
2267
|
-
else {
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2503
|
+
else if (budgetRemaining(recovery, "actionIntent")) {
|
|
2504
|
+
action = recoveryForNarration(toolsAttached, "generic");
|
|
2505
|
+
}
|
|
2506
|
+
if (action) {
|
|
2507
|
+
consumeBudget(recovery, action.budgetKey);
|
|
2508
|
+
writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
|
|
2509
|
+
pushAssistantHistory(assistantText.visible);
|
|
2510
|
+
messages.push(recoveryUserMessage(action.message));
|
|
2511
|
+
continue;
|
|
2275
2512
|
}
|
|
2276
|
-
pushAssistantHistory(assistantText.visible);
|
|
2277
|
-
messages.push(recoveryUserMessage(nudge));
|
|
2278
|
-
continue;
|
|
2279
2513
|
}
|
|
2280
2514
|
if (freshWebSearchRequired &&
|
|
2281
2515
|
!sawFreshWebSearch &&
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2516
|
+
budgetRemaining(recovery, "freshnessUsed")) {
|
|
2517
|
+
const action = recoveryForFreshness(freshnessGuardMessage() +
|
|
2518
|
+
(toolsAttached
|
|
2519
|
+
? " Call the web_search tool now."
|
|
2520
|
+
: " Reply with ONLY a fenced ```tool block for web.search now."));
|
|
2521
|
+
consumeBudget(recovery, action.budgetKey);
|
|
2522
|
+
writeNotice("info", action.notice, chalk.dim(` ℹ ${action.notice}\n`));
|
|
2285
2523
|
pushAssistantHistory(assistantText.visible);
|
|
2286
|
-
messages.push({
|
|
2287
|
-
role: "user",
|
|
2288
|
-
content: freshnessGuardMessage() +
|
|
2289
|
-
(toolsAttached
|
|
2290
|
-
? " Call the web_search tool now."
|
|
2291
|
-
: " Reply with ONLY a fenced ```tool block for web.search now."),
|
|
2292
|
-
});
|
|
2524
|
+
messages.push({ role: "user", content: action.message });
|
|
2293
2525
|
continue;
|
|
2294
2526
|
}
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
!pentestSession &&
|
|
2300
|
-
codingBuildRequiresPlan(prompt, {
|
|
2301
|
-
informational: informationalQuery,
|
|
2302
|
-
idle: idleOrSocialPrompt,
|
|
2303
|
-
pentest: false,
|
|
2304
|
-
}) &&
|
|
2305
|
-
forcePlanRetries < 2) {
|
|
2527
|
+
if (isPlanMode &&
|
|
2528
|
+
!informationalQuery &&
|
|
2529
|
+
!idleOrSocialPrompt &&
|
|
2530
|
+
budgetRemaining(recovery, "forcePlan")) {
|
|
2306
2531
|
const planAtEnd = await loadPlan(session.sessionId).catch(() => undefined);
|
|
2307
2532
|
if (!planAtEnd && !sawPlanCreateOk) {
|
|
2308
|
-
|
|
2533
|
+
const action = recoveryForMissingPlan(toolsAttached);
|
|
2534
|
+
consumeBudget(recovery, action.budgetKey);
|
|
2309
2535
|
pushAssistantHistory(assistantText.visible);
|
|
2310
|
-
|
|
2311
|
-
|
|
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"));
|
|
2536
|
+
messages.push({ role: "user", content: action.message });
|
|
2537
|
+
writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
|
|
2323
2538
|
continue;
|
|
2324
2539
|
}
|
|
2325
2540
|
}
|
|
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
2541
|
if (buildLike &&
|
|
2330
2542
|
!pentestLike &&
|
|
2331
2543
|
!pentestSession &&
|
|
@@ -2334,166 +2546,131 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2334
2546
|
!sawFeatureImplWrite &&
|
|
2335
2547
|
(sawScaffoldOk || sawLocalAppMaterialWork) &&
|
|
2336
2548
|
productiveSteps > 0 &&
|
|
2337
|
-
|
|
2338
|
-
|
|
2549
|
+
budgetRemaining(recovery, "featureImpl")) {
|
|
2550
|
+
const action = recoveryForMissingFeature(getActiveProjectRoot());
|
|
2551
|
+
consumeBudget(recovery, action.budgetKey);
|
|
2339
2552
|
pushAssistantHistory(assistantText.visible);
|
|
2340
|
-
|
|
2341
|
-
|
|
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"));
|
|
2553
|
+
messages.push({ role: "user", content: action.message });
|
|
2554
|
+
writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
|
|
2352
2555
|
continue;
|
|
2353
2556
|
}
|
|
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
2557
|
if (buildLike &&
|
|
2361
2558
|
!pentestLike &&
|
|
2362
2559
|
!pentestSession &&
|
|
2363
|
-
(
|
|
2364
|
-
runtimeVerificationRetries < 2 &&
|
|
2365
|
-
// Feature apps must implement first (handled above); only verify live after that
|
|
2560
|
+
budgetRemaining(recovery, "runtimeVerify") &&
|
|
2366
2561
|
(!featureAppAsk || sawFeatureImplWrite)) {
|
|
2367
2562
|
const runtimePlan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
(
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
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.",
|
|
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"));
|
|
2399
|
-
continue;
|
|
2563
|
+
// Durable plan evidence or multi-signal proof this turn is enough
|
|
2564
|
+
const planRuntimeOk = Boolean(runtimePlan && planHasVerifiedRuntime(runtimePlan));
|
|
2565
|
+
const sessionRuntimeOk = sawServerStart &&
|
|
2566
|
+
(sawServerTail || sawLocalHttpProbe || planRuntimeOk);
|
|
2567
|
+
if (!planRuntimeOk && !sessionRuntimeOk) {
|
|
2568
|
+
const codingPlanFinished = Boolean(runtimePlan &&
|
|
2569
|
+
session.planApproved.value &&
|
|
2570
|
+
runtimePlan.kind !== "pentest" &&
|
|
2571
|
+
runtimePlan.tasks.length > 0 &&
|
|
2572
|
+
runtimePlan.tasks.every((task) => task.state === "done" || task.state === "skipped"));
|
|
2573
|
+
const freestyleLocalAppDone = !session.planApproved.value &&
|
|
2574
|
+
sawLocalAppMaterialWork &&
|
|
2575
|
+
productiveSteps > 0 &&
|
|
2576
|
+
freestyleClaimsAppReady(cleaned) &&
|
|
2577
|
+
(getActiveProjectRoot() !== undefined ||
|
|
2578
|
+
/\b(?:npm|pnpm|yarn|bun)\s+run\s+dev\b/i.test(cleaned) ||
|
|
2579
|
+
/\bopen\s+http:\/\/localhost\b/i.test(cleaned));
|
|
2580
|
+
if (codingPlanFinished || freestyleLocalAppDone) {
|
|
2581
|
+
const action = recoveryForRuntimeVerify(getActiveProjectRoot());
|
|
2582
|
+
consumeBudget(recovery, action.budgetKey);
|
|
2583
|
+
pushAssistantHistory(assistantText.visible);
|
|
2584
|
+
messages.push({ role: "user", content: action.message });
|
|
2585
|
+
writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
|
|
2586
|
+
continue;
|
|
2587
|
+
}
|
|
2400
2588
|
}
|
|
2401
2589
|
}
|
|
2402
|
-
// Failed localhost probe (e.g. HTTP 500): model must FIX, not stop.
|
|
2403
2590
|
if (buildLike &&
|
|
2404
2591
|
!pentestLike &&
|
|
2405
2592
|
!pentestSession &&
|
|
2406
2593
|
sawFailedLocalHttpProbe &&
|
|
2407
2594
|
!sawLocalHttpProbe &&
|
|
2408
|
-
|
|
2595
|
+
budgetRemaining(recovery, "failedProbe") &&
|
|
2409
2596
|
cleaned.trim().length > 0) {
|
|
2410
|
-
|
|
2597
|
+
const action = recoveryForFailedProbe();
|
|
2598
|
+
consumeBudget(recovery, action.budgetKey);
|
|
2411
2599
|
pushAssistantHistory(assistantText.visible);
|
|
2412
|
-
messages.push({
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2600
|
+
messages.push({ role: "user", content: action.message });
|
|
2601
|
+
writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
|
|
2602
|
+
continue;
|
|
2603
|
+
}
|
|
2604
|
+
if ((pentestLike || pentestSession) &&
|
|
2605
|
+
budgetRemaining(recovery, "shallowPentest") &&
|
|
2606
|
+
looksLikeShallowPentestReport(cleaned, {
|
|
2607
|
+
productiveSteps,
|
|
2608
|
+
sawActiveTest: sawActivePentestTest,
|
|
2609
|
+
})) {
|
|
2610
|
+
const action = recoveryForShallowPentest();
|
|
2611
|
+
consumeBudget(recovery, action.budgetKey);
|
|
2612
|
+
pushAssistantHistory(assistantText.visible);
|
|
2613
|
+
messages.push({ role: "user", content: action.message });
|
|
2614
|
+
writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
|
|
2420
2615
|
continue;
|
|
2421
2616
|
}
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
// while tasks are still pending/in_progress — without having run the
|
|
2425
|
-
// work — it is fabricating completion (the exact "all tasks completed,
|
|
2426
|
-
// running at localhost:5173" failure). Force it back to executing the
|
|
2427
|
-
// next real task instead of accepting the false claim.
|
|
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) {
|
|
2617
|
+
if (session.planApproved.value &&
|
|
2618
|
+
budgetRemaining(recovery, "prematureComplete")) {
|
|
2431
2619
|
const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
2432
2620
|
const unfinished = livePlan?.tasks.filter((t) => t.state === "pending" || t.state === "in_progress");
|
|
2433
2621
|
if (livePlan && unfinished && unfinished.length > 0) {
|
|
2434
|
-
prematureCompletionRetries += 1;
|
|
2435
2622
|
const next = unfinished[0];
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
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. `;
|
|
2452
|
-
}
|
|
2453
|
-
else {
|
|
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"}). `;
|
|
2455
|
-
}
|
|
2456
|
-
instruction += `Continue task by task until EVERY task is actually finished. Do not stop after only diagnosing an error.`;
|
|
2457
|
-
messages.push({
|
|
2458
|
-
role: "user",
|
|
2459
|
-
content: `You have NOT finished the approved plan: ${unfinished.length} task(s) remain ` +
|
|
2460
|
-
`(${unfinished.map((t) => `[${t.id}] ${t.title}`).join("; ")}). ` +
|
|
2461
|
-
`Do NOT claim the work is complete unless a tool call actually succeeded and you saw the output. ` +
|
|
2462
|
-
instruction,
|
|
2623
|
+
const action = recoveryForPrematureComplete({
|
|
2624
|
+
unfinished,
|
|
2625
|
+
next,
|
|
2626
|
+
pentest: livePlan.kind === "pentest" || pentestSession,
|
|
2627
|
+
errorFix: errorFixNarration,
|
|
2463
2628
|
});
|
|
2629
|
+
consumeBudget(recovery, action.budgetKey);
|
|
2630
|
+
writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
|
|
2631
|
+
pushAssistantHistory(assistantText.visible);
|
|
2632
|
+
messages.push({ role: "user", content: action.message });
|
|
2464
2633
|
continue;
|
|
2465
2634
|
}
|
|
2466
2635
|
}
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
// unchallenged — append an explicit, honest status so the user knows the
|
|
2470
|
-
// build did not actually complete.
|
|
2471
|
-
let completionWarning = "";
|
|
2472
|
-
let completionWarningText = "";
|
|
2636
|
+
let outcomeStatus = "succeeded";
|
|
2637
|
+
const remainingCriteria = [];
|
|
2473
2638
|
if (session.planApproved.value) {
|
|
2474
2639
|
const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
2475
|
-
const unfinished = livePlan?.tasks.filter((t) => t.state === "pending" || t.state === "in_progress");
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
unfinished
|
|
2483
|
-
.map((t) => chalk.yellow(` • [${t.id}] ${t.title}`))
|
|
2484
|
-
.join("\n") +
|
|
2485
|
-
chalk.dim("\n The summary above may overstate progress. Re-run with /implement, or ask clai to finish the remaining tasks.\n");
|
|
2486
|
-
}
|
|
2487
|
-
}
|
|
2488
|
-
if (cleaned) {
|
|
2489
|
-
writeAssistantMessage(cleaned);
|
|
2640
|
+
const unfinished = livePlan?.tasks.filter((t) => t.state === "pending" || t.state === "in_progress") ?? [];
|
|
2641
|
+
const failedTasks = livePlan?.tasks.filter((t) => t.state === "failed") ?? [];
|
|
2642
|
+
remainingCriteria.push(...unfinished.map((task) => `[${task.id}] ${task.title}`), ...failedTasks.map((task) => `[${task.id}] retry failed task: ${task.title}`));
|
|
2643
|
+
if (failedTasks.length > 0)
|
|
2644
|
+
outcomeStatus = "failed";
|
|
2645
|
+
else if (unfinished.length > 0)
|
|
2646
|
+
outcomeStatus = "partial";
|
|
2490
2647
|
}
|
|
2491
|
-
|
|
2492
|
-
|
|
2648
|
+
recordAnswerEvidence(outcomeState, cleaned);
|
|
2649
|
+
outcomeState.outcome.status = deriveOutcomeStatus(outcomeState.outcome, outcomeState.evidence);
|
|
2650
|
+
await saveOutcomeState(outcomeState);
|
|
2651
|
+
const unsupportedCriteria = outcomeState.outcome.criteria.filter((criterion) => criterion.required &&
|
|
2652
|
+
!validateCriterionEvidence(criterion, outcomeState.evidence).ok);
|
|
2653
|
+
if (unsupportedCriteria.length > 0 && outcomeStatus === "succeeded") {
|
|
2654
|
+
outcomeStatus = "partial";
|
|
2493
2655
|
}
|
|
2494
|
-
|
|
2656
|
+
remainingCriteria.push(...unsupportedCriteria
|
|
2657
|
+
.map((criterion) => criterion.statement)
|
|
2658
|
+
.filter((statement) => !remainingCriteria.includes(statement)));
|
|
2659
|
+
moveTurn("verifying", "evaluating current criterion-linked evidence");
|
|
2660
|
+
moveTurn(outcomeStatus, `turn completed with ${outcomeStatus} evidence status`);
|
|
2661
|
+
await auditLog("agent.final", {
|
|
2662
|
+
provider,
|
|
2663
|
+
model,
|
|
2664
|
+
steps: step + 1,
|
|
2665
|
+
outcomeStatus,
|
|
2666
|
+
remainingCriteria,
|
|
2667
|
+
});
|
|
2495
2668
|
lastAnswer = cleaned;
|
|
2496
|
-
return finishTurn(lastAnswer, step + 1
|
|
2669
|
+
return finishTurn(lastAnswer, step + 1, outcomeStatus, remainingCriteria, outcomeStatus === "failed"
|
|
2670
|
+
? "One or more required plan tasks failed."
|
|
2671
|
+
: outcomeStatus === "partial"
|
|
2672
|
+
? "Required outcome criteria remain unsupported by current evidence."
|
|
2673
|
+
: undefined);
|
|
2497
2674
|
}
|
|
2498
2675
|
// A valid primary tool call exists for this fresh model turn. Show any
|
|
2499
2676
|
// prose / thinking that preceded it, record the assistant message ONCE.
|
|
@@ -2538,11 +2715,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2538
2715
|
let toRun = bound;
|
|
2539
2716
|
let activeDeferredToolCalls = deferredToolCalls;
|
|
2540
2717
|
let deferReason = "Cancelled — not executed this turn (deferred or omitted).";
|
|
2541
|
-
// A plan must be based on the outputs of prior reconnaissance, never
|
|
2542
|
-
// on calls the model merely proposed in the same response. If a model
|
|
2543
|
-
// emits plan.create alongside gathering calls, run only the calls
|
|
2544
|
-
// before it, then let the next model turn analyse their actual results
|
|
2545
|
-
// and emit one standalone plan.create.
|
|
2546
2718
|
const planCallIndex = bound.findIndex((b) => b.call.name === "plan.create");
|
|
2547
2719
|
if (planCallIndex > 0) {
|
|
2548
2720
|
const deferredCount = bound.length - planCallIndex;
|
|
@@ -2571,32 +2743,9 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2571
2743
|
`the follow-on call(s) were not. Wait for the plan to be reviewed, then proceed task by task.`,
|
|
2572
2744
|
});
|
|
2573
2745
|
}
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
toRun = toRun.slice(0, MAX_CALLS_PER_MODEL_TURN);
|
|
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.";
|
|
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`));
|
|
2582
|
-
messages.push({
|
|
2583
|
-
role: "system",
|
|
2584
|
-
content: `You emitted too many tool calls in one response. Only the first ${MAX_CALLS_PER_MODEL_TURN} were executed; ` +
|
|
2585
|
-
`${omittedCallCount} were not run. After reviewing results, issue a small, prioritized next batch.`,
|
|
2586
|
-
});
|
|
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
|
-
}
|
|
2746
|
+
// Preserve model/document order. In particular, never move a later
|
|
2747
|
+
// in_progress transition ahead of the preceding work or done receipt;
|
|
2748
|
+
// doing so inverts dependency order and desynchronizes the task pane.
|
|
2600
2749
|
// Re-index toRun positions for UI callIds[] (0..n-1 this turn).
|
|
2601
2750
|
toRun = toRun.map((b, index) => ({ ...b, index }));
|
|
2602
2751
|
const allCalls = toRun.map((b) => b.call);
|
|
@@ -2609,16 +2758,11 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2609
2758
|
if (allCalls.length > 1) {
|
|
2610
2759
|
writeNotice("info", `${allCalls.length} tool calls in this message — running scoped (independent read-only lookups in parallel, everything else in order)`, chalk.dim(` ℹ ${allCalls.length} tool calls — read-only lookups in parallel, the rest in order\n`));
|
|
2611
2760
|
}
|
|
2612
|
-
// Emit only the calls that will actually execute, after thinking
|
|
2613
|
-
// + assistant text so transcript order remains correct.
|
|
2614
2761
|
for (const deferred of activeDeferredToolCalls.slice(0, allCalls.length)) {
|
|
2615
2762
|
if (!deferred.call.name || deferred.call.name === "…")
|
|
2616
2763
|
continue;
|
|
2617
2764
|
writeToolCall(deferred.eventId, deferred.call, deferred.rendered);
|
|
2618
2765
|
}
|
|
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
2766
|
if (historyNativeCalls.length) {
|
|
2623
2767
|
appendAssistantWithTools(messages, beforeTool ?? "", historyNativeCalls);
|
|
2624
2768
|
}
|
|
@@ -2629,32 +2773,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2629
2773
|
.join("\n\n");
|
|
2630
2774
|
pushAssistantHistory(standardizedContent);
|
|
2631
2775
|
}
|
|
2632
|
-
// Scoped-parallel batch execution
|
|
2633
|
-
// The model may emit several calls in one message. We partition them,
|
|
2634
|
-
// IN DOCUMENT ORDER, into segments:
|
|
2635
|
-
// • A run of consecutive READ-ONLY, safe-classified calls (the same
|
|
2636
|
-
// allowlist tool.batch uses) executes CONCURRENTLY — this is where
|
|
2637
|
-
// independent lookups within a single task fan out (e.g. whois +
|
|
2638
|
-
// dns + http.fetch during recon).
|
|
2639
|
-
// • Every other call (plan.create/task.update, and any mutating or
|
|
2640
|
-
// confirm-level tool: fs.write*, shell.exec, pkg.install, net.scan)
|
|
2641
|
-
// runs ALONE as a sequential barrier.
|
|
2642
|
-
// Because task.update is never parallel-safe, it always acts as a
|
|
2643
|
-
// barrier: it commits before the work it gates and after the work it
|
|
2644
|
-
// closes. That keeps execution strictly task-by-task and eliminates the
|
|
2645
|
-
// plan-state races / overlapping writes that a blanket Promise.all
|
|
2646
|
-
// caused, while still letting one task's independent lookups run in
|
|
2647
|
-
// parallel. A failed independent read-only lookup does not prevent
|
|
2648
|
-
// later recon from running; aborts, blocks, and sequential-barrier
|
|
2649
|
-
// failures still stop the batch so the model can react safely.
|
|
2650
2776
|
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
|
-
*/
|
|
2658
2777
|
const isParallelSafe = (c) => {
|
|
2659
2778
|
if (c.name === "pentest.recon" ||
|
|
2660
2779
|
c.name === "net.context" ||
|
|
@@ -2675,19 +2794,10 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2675
2794
|
};
|
|
2676
2795
|
/** Tools whose failure must NOT cancel sibling calls in this turn. */
|
|
2677
2796
|
const shouldSoftFailTool = (name) => {
|
|
2678
|
-
if (name
|
|
2797
|
+
if (isBatchSoftFailTool(name))
|
|
2679
2798
|
return true;
|
|
2680
2799
|
if (BATCH_SAFE_TOOLS.has(name))
|
|
2681
2800
|
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
2801
|
return false;
|
|
2692
2802
|
};
|
|
2693
2803
|
// Recon waves often emit 6–10 lookups; 4 forced a second sequential wave.
|
|
@@ -2716,17 +2826,40 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2716
2826
|
truncatedToolRetries = 0;
|
|
2717
2827
|
malformedFenceRetries = 0;
|
|
2718
2828
|
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
2829
|
if (res.ok && isEvidenceWorkTool(res.call.name)) {
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2830
|
+
recovery.prematureComplete = 0;
|
|
2831
|
+
recovery.actionIntent = 0;
|
|
2832
|
+
recovery.errorFix = 0;
|
|
2725
2833
|
}
|
|
2726
2834
|
if (res.ok && res.call.name === "shell.start")
|
|
2727
2835
|
sawServerStart = true;
|
|
2728
|
-
if (res.ok && res.call.name === "shell.tail")
|
|
2836
|
+
if (res.ok && res.call.name === "shell.tail") {
|
|
2729
2837
|
sawServerTail = true;
|
|
2838
|
+
const tailOut = res.result.output ?? res.contextOutput ?? "";
|
|
2839
|
+
if (isServerReadyOutput(tailOut)) {
|
|
2840
|
+
sawServerStart = true;
|
|
2841
|
+
sawServerTail = true;
|
|
2842
|
+
}
|
|
2843
|
+
}
|
|
2844
|
+
if (res.ok &&
|
|
2845
|
+
res.call.name === "shell.exec" &&
|
|
2846
|
+
isPortListeningOutput(String(res.call.args.command ?? ""), res.result.output ?? res.contextOutput ?? "")) {
|
|
2847
|
+
sawServerStart = true;
|
|
2848
|
+
}
|
|
2849
|
+
if (res.ok &&
|
|
2850
|
+
(pentestLike || pentestSession) &&
|
|
2851
|
+
(res.call.name === "http.fetch" ||
|
|
2852
|
+
res.call.name === "shell.exec" ||
|
|
2853
|
+
res.call.name === "net.scan" ||
|
|
2854
|
+
res.call.name === "pentest.recon")) {
|
|
2855
|
+
const blob = `${res.call.name} ${JSON.stringify(res.call.args)}`;
|
|
2856
|
+
if (/\b(sqlmap|hydra|nikto|nuclei|ffuf|gobuster|exploit|payload|idor|xss|union\s+select)\b/i.test(blob) ||
|
|
2857
|
+
(res.call.name === "http.fetch" &&
|
|
2858
|
+
typeof res.call.args.method === "string" &&
|
|
2859
|
+
!/^get$/i.test(res.call.args.method))) {
|
|
2860
|
+
sawActivePentestTest = true;
|
|
2861
|
+
}
|
|
2862
|
+
}
|
|
2730
2863
|
if (res.ok &&
|
|
2731
2864
|
((res.call.name === "http.fetch" &&
|
|
2732
2865
|
/^(?:https?:\/\/)?(?:localhost|127\.0\.0\.1|\[::1\])(?::|\/|$)/i.test(String(res.call.args.url ?? ""))) ||
|
|
@@ -2740,7 +2873,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2740
2873
|
else if (localHttpProbeIsSuccess(out)) {
|
|
2741
2874
|
sawLocalHttpProbe = true;
|
|
2742
2875
|
sawFailedLocalHttpProbe = false;
|
|
2743
|
-
|
|
2876
|
+
recovery.failedProbe = 0;
|
|
2744
2877
|
}
|
|
2745
2878
|
else if (res.call.name === "shell.exec" &&
|
|
2746
2879
|
!localHttpProbeIsFailure(out)) {
|
|
@@ -2775,8 +2908,13 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2775
2908
|
}
|
|
2776
2909
|
}
|
|
2777
2910
|
if (res.call.name === "plan.create" && res.ok) {
|
|
2778
|
-
awaitingPlanApproval = true;
|
|
2779
2911
|
sawPlanCreateOk = true;
|
|
2912
|
+
if (isPlanMode) {
|
|
2913
|
+
awaitingPlanApproval = true;
|
|
2914
|
+
}
|
|
2915
|
+
else {
|
|
2916
|
+
session.planApproved.value = true;
|
|
2917
|
+
}
|
|
2780
2918
|
}
|
|
2781
2919
|
if (res.lastAnswer === "Aborted.")
|
|
2782
2920
|
aborted = true;
|
|
@@ -2789,7 +2927,11 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2789
2927
|
};
|
|
2790
2928
|
const groups = groupToolCallsForExecution(allCalls, isParallelSafe, PARALLEL_LIMIT);
|
|
2791
2929
|
for (const group of groups) {
|
|
2792
|
-
if (aborted ||
|
|
2930
|
+
if (aborted ||
|
|
2931
|
+
blocked ||
|
|
2932
|
+
failed ||
|
|
2933
|
+
awaitingPlanApproval ||
|
|
2934
|
+
governorPauseReason)
|
|
2793
2935
|
break;
|
|
2794
2936
|
if (group.length === 1) {
|
|
2795
2937
|
const call = group[0];
|
|
@@ -2839,9 +2981,11 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2839
2981
|
? "Cancelled — earlier tool was blocked or declined."
|
|
2840
2982
|
: awaitingPlanApproval
|
|
2841
2983
|
? "Deferred — waiting for plan approval."
|
|
2842
|
-
:
|
|
2843
|
-
?
|
|
2844
|
-
:
|
|
2984
|
+
: governorPauseReason
|
|
2985
|
+
? `Deferred — progress governor paused execution: ${governorPauseReason}`
|
|
2986
|
+
: failed
|
|
2987
|
+
? "Cancelled — earlier tool in this batch failed."
|
|
2988
|
+
: "Cancelled — not executed.";
|
|
2845
2989
|
const result = {
|
|
2846
2990
|
ok: false,
|
|
2847
2991
|
output: reason,
|
|
@@ -2871,29 +3015,31 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2871
3015
|
}
|
|
2872
3016
|
fillMissingToolResults(messages, historyNativeCalls, "Cancelled — not executed this turn.");
|
|
2873
3017
|
}
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
// prevents calls accidentally batched after plan.create from executing
|
|
2878
|
-
// before /implement approval.
|
|
3018
|
+
if (deferredPostToolMessages.length > 0) {
|
|
3019
|
+
messages.push(...deferredPostToolMessages.splice(0));
|
|
3020
|
+
}
|
|
2879
3021
|
if (awaitingPlanApproval) {
|
|
2880
3022
|
pendingCalls = [];
|
|
2881
|
-
|
|
3023
|
+
outcomeState.outcome.status = "partial";
|
|
3024
|
+
await saveOutcomeState(outcomeState);
|
|
3025
|
+
moveTurn("partial", "draft plan awaits approval");
|
|
3026
|
+
return finishTurn("", productiveSteps, "partial", ["Approve or revise the draft plan before implementation."]);
|
|
2882
3027
|
}
|
|
2883
3028
|
if (aborted) {
|
|
2884
3029
|
lastAnswer = "Aborted.";
|
|
3030
|
+
outcomeState.outcome.status = "aborted";
|
|
3031
|
+
await saveOutcomeState(outcomeState);
|
|
3032
|
+
moveTurn("aborted", "turn aborted");
|
|
2885
3033
|
writeAbort();
|
|
2886
|
-
return finishTurn(lastAnswer, productiveSteps);
|
|
3034
|
+
return finishTurn(lastAnswer, productiveSteps, "aborted");
|
|
2887
3035
|
}
|
|
2888
3036
|
if (blocked && blockedResult) {
|
|
2889
3037
|
lastAnswer = blockedResult.lastAnswer || "Blocked or Cancelled.";
|
|
2890
|
-
|
|
3038
|
+
outcomeState.outcome.status = "blocked";
|
|
3039
|
+
await saveOutcomeState(outcomeState);
|
|
3040
|
+
moveTurn("blocked", lastAnswer);
|
|
3041
|
+
return finishTurn(lastAnswer, productiveSteps, "blocked");
|
|
2891
3042
|
}
|
|
2892
|
-
// A plain failure just stops the remaining calls; we fall through so
|
|
2893
|
-
// the model sees the failed tool's output and decides what to do next.
|
|
2894
|
-
// Compact older messages when the running estimate exceeds budget. Uses
|
|
2895
|
-
// the model-written summary path (with plan re-injection) — never a
|
|
2896
|
-
// mechanical transcript dump.
|
|
2897
3043
|
await maybeAutoCompact("post-tool-token-budget");
|
|
2898
3044
|
if (options.onMessages) {
|
|
2899
3045
|
try {
|
|
@@ -2905,16 +3051,20 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2905
3051
|
}
|
|
2906
3052
|
}
|
|
2907
3053
|
}
|
|
2908
|
-
// maxIterations ceiling reached (safety net — normally the step budget
|
|
2909
|
-
// gate with user confirmation handles stopping gracefully).
|
|
2910
3054
|
const richSummary = await buildRichStopSummary(messages, session, productiveSteps);
|
|
2911
|
-
writeAssistantMessage(richSummary);
|
|
2912
3055
|
lastAnswer = richSummary;
|
|
2913
|
-
|
|
3056
|
+
outcomeState.outcome.status = "paused_budget";
|
|
3057
|
+
await saveOutcomeState(outcomeState);
|
|
3058
|
+
moveTurn("paused_budget", "emergency iteration ceiling reached");
|
|
3059
|
+
return finishTurn(lastAnswer, productiveSteps, "paused_budget", ["Continue unfinished work in a subsequent turn."], "The emergency iteration ceiling was reached.");
|
|
2914
3060
|
}
|
|
2915
3061
|
catch (error) {
|
|
2916
3062
|
const isAbort = isAbortError(error, options.signal);
|
|
2917
3063
|
const msg = isAbort ? "Aborted." : `Error: ${error instanceof Error ? error.message : String(error)}`;
|
|
3064
|
+
if (isAbort) {
|
|
3065
|
+
writeAbort();
|
|
3066
|
+
return finishTurn(msg, 0, "aborted", [], "The turn was aborted.");
|
|
3067
|
+
}
|
|
2918
3068
|
if (options.onMessages) {
|
|
2919
3069
|
try {
|
|
2920
3070
|
options.onMessages(buildTurnHistory(liveMessages, msg));
|
|
@@ -2923,10 +3073,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2923
3073
|
// ignore
|
|
2924
3074
|
}
|
|
2925
3075
|
}
|
|
2926
|
-
if (isAbort) {
|
|
2927
|
-
writeAbort();
|
|
2928
|
-
return "Aborted.";
|
|
2929
|
-
}
|
|
2930
3076
|
emit({
|
|
2931
3077
|
type: "turn-error",
|
|
2932
3078
|
message: error instanceof Error ? error.message : String(error),
|
|
@@ -2934,4 +3080,8 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2934
3080
|
throw error;
|
|
2935
3081
|
}
|
|
2936
3082
|
}
|
|
3083
|
+
/** Compatibility boundary for callers that still consume rendered text. */
|
|
3084
|
+
export async function runAgentLoop(prompt, options = {}) {
|
|
3085
|
+
return renderTurnOutcome(await runAgentTurn(prompt, options));
|
|
3086
|
+
}
|
|
2937
3087
|
//# sourceMappingURL=runner.js.map
|