@pentoshi/clai 3.6.0 → 3.7.3
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 +312 -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 +1226 -971
- 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 +111 -29
- package/dist/agent/task-evidence.js +519 -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/embedded.d.ts +1 -0
- package/dist/prompts/embedded.js +9 -0
- package/dist/prompts/embedded.js.map +1 -0
- package/dist/prompts/index.d.ts +6 -18
- package/dist/prompts/index.js +104 -293
- package/dist/prompts/index.js.map +1 -1
- package/dist/prompts/system.agent.md +228 -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 +244 -57
- 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/tools/web/fetch-core.d.ts +7 -0
- package/dist/tools/web/fetch-core.js +40 -1
- package/dist/tools/web/fetch-core.js.map +1 -1
- package/dist/tools/web/fetch.js +8 -1
- package/dist/tools/web/fetch.js.map +1 -1
- package/dist/tui-v2/app/App.js +34 -41
- 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 +18 -12
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, TOOL_ABORT_GRACE_MS, toolHardBudgetMs, 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
1099
|
}
|
|
879
1100
|
}
|
|
880
1101
|
}
|
|
881
1102
|
}
|
|
882
|
-
// Keep work inside the open task (no early server start during install).
|
|
883
|
-
if (session.planApproved.value) {
|
|
884
|
-
const liveForScope = await loadPlan(session.sessionId).catch(() => undefined);
|
|
885
|
-
const openTask = liveForScope?.tasks.find((t) => t.state === "in_progress");
|
|
886
|
-
if (openTask) {
|
|
887
|
-
const scopeMsg = workOutOfScopeForTask(openTask.title, call, liveForScope?.tasks
|
|
888
|
-
? { planTaskTitles: liveForScope.tasks.map((t) => t.title) }
|
|
889
|
-
: undefined);
|
|
890
|
-
if (scopeMsg) {
|
|
891
|
-
writeNotice("warn", scopeMsg, chalk.yellow(` ⚠ ${scopeMsg}\n`));
|
|
892
|
-
if (!alreadyPrintedIds.has(toolEventId)) {
|
|
893
|
-
const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
|
|
894
|
-
chalk.gray(` ${formatToolArgs(call)}`);
|
|
895
|
-
writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
|
|
896
|
-
alreadyPrintedIds.add(toolEventId);
|
|
897
|
-
}
|
|
898
|
-
const result = { ok: false, output: scopeMsg, exitCode: 1 };
|
|
899
|
-
emitToolResult(toolEventId, result, scopeMsg);
|
|
900
|
-
writeToolOutput(toolEventId, "failed\n", chalk.red(" ✗") + "\n");
|
|
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
|
-
}
|
|
921
|
-
const result = { ok: false, output: featureBlock, exitCode: 1 };
|
|
922
|
-
emitToolResult(toolEventId, result, featureBlock);
|
|
923
|
-
writeToolOutput(toolEventId, "failed\n", chalk.red(" ✗") + "\n");
|
|
924
|
-
return {
|
|
925
|
-
ok: false,
|
|
926
|
-
call,
|
|
927
|
-
result,
|
|
928
|
-
contextOutput: featureBlock,
|
|
929
|
-
};
|
|
930
|
-
}
|
|
931
|
-
}
|
|
932
|
-
// Prefer user Desktop (etc.) as cwd when model omitted it.
|
|
933
|
-
// Also prefer sticky project root for install/run when set.
|
|
934
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,27 +1323,42 @@ 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);
|
|
1353
|
+
const TOOL_HARD_BUDGET_MS = toolHardBudgetMs(call);
|
|
1108
1354
|
const stallSecs = Math.round(TOOL_STALL_ABORT_MS / 1000);
|
|
1355
|
+
const hardSecs = Math.round(TOOL_HARD_BUDGET_MS / 1000);
|
|
1109
1356
|
let stallTimer;
|
|
1357
|
+
let hardTimer;
|
|
1358
|
+
let graceTimer;
|
|
1110
1359
|
let stalledByWatchdog = false;
|
|
1360
|
+
let hardTimedOut = false;
|
|
1361
|
+
let forceSettled = false;
|
|
1111
1362
|
const resetStallTimer = () => {
|
|
1112
1363
|
if (stallTimer)
|
|
1113
1364
|
clearTimeout(stallTimer);
|
|
@@ -1118,10 +1369,17 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1118
1369
|
toolAc.abort();
|
|
1119
1370
|
}
|
|
1120
1371
|
}, TOOL_STALL_ABORT_MS);
|
|
1372
|
+
// Node: do not keep the process alive solely for the stall timer.
|
|
1373
|
+
stallTimer.unref?.();
|
|
1121
1374
|
};
|
|
1122
1375
|
resetStallTimer();
|
|
1123
|
-
|
|
1124
|
-
|
|
1376
|
+
/**
|
|
1377
|
+
* Force-settle a hung tool promise after abort or hard budget.
|
|
1378
|
+
* Some transports ignore AbortSignal; without this race the agent
|
|
1379
|
+
* could freeze for minutes after "cancelling stalled tool".
|
|
1380
|
+
*/
|
|
1381
|
+
const runToolWithForcedSettle = () => {
|
|
1382
|
+
const work = runToolCall(call, {
|
|
1125
1383
|
signal: toolAc.signal,
|
|
1126
1384
|
requestSecret: options.requestSecret,
|
|
1127
1385
|
onOutput: (chunk) => {
|
|
@@ -1132,7 +1390,120 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1132
1390
|
},
|
|
1133
1391
|
confirmed: true,
|
|
1134
1392
|
userPrompt: prompt,
|
|
1393
|
+
...(engagementAction && scope
|
|
1394
|
+
? {
|
|
1395
|
+
engagementAuthorization: {
|
|
1396
|
+
target: engagementDecision?.normalizedTarget || engagementAction.target,
|
|
1397
|
+
...(scope.expiresAt ? { expiresAt: scope.expiresAt } : {}),
|
|
1398
|
+
},
|
|
1399
|
+
authorizeNetworkHop: async (url, resolvedAddresses) => {
|
|
1400
|
+
const hop = actionFromUrl({
|
|
1401
|
+
url,
|
|
1402
|
+
method: engagementAction.method,
|
|
1403
|
+
phase: engagementAction.phase,
|
|
1404
|
+
capability: engagementAction.capability,
|
|
1405
|
+
resolvedAddresses,
|
|
1406
|
+
});
|
|
1407
|
+
const hopDecision = evaluateEngagementAction(scope, hop);
|
|
1408
|
+
await auditLog("engagement.policy.hop", {
|
|
1409
|
+
...(engagementGraph ? { engagementId: engagementGraph.id } : {}),
|
|
1410
|
+
...(engagementRecord ? { actionId: engagementRecord.id } : {}),
|
|
1411
|
+
url,
|
|
1412
|
+
resolvedAddresses,
|
|
1413
|
+
allowed: hopDecision.allowed,
|
|
1414
|
+
reason: hopDecision.reason,
|
|
1415
|
+
});
|
|
1416
|
+
return { allowed: hopDecision.allowed, reason: hopDecision.reason };
|
|
1417
|
+
},
|
|
1418
|
+
}
|
|
1419
|
+
: {}),
|
|
1420
|
+
});
|
|
1421
|
+
return new Promise((resolve, reject) => {
|
|
1422
|
+
let settled = false;
|
|
1423
|
+
const finishOk = (r) => {
|
|
1424
|
+
if (settled)
|
|
1425
|
+
return;
|
|
1426
|
+
settled = true;
|
|
1427
|
+
if (graceTimer)
|
|
1428
|
+
clearTimeout(graceTimer);
|
|
1429
|
+
resolve(r);
|
|
1430
|
+
};
|
|
1431
|
+
const finishErr = (err) => {
|
|
1432
|
+
if (settled)
|
|
1433
|
+
return;
|
|
1434
|
+
settled = true;
|
|
1435
|
+
if (graceTimer)
|
|
1436
|
+
clearTimeout(graceTimer);
|
|
1437
|
+
reject(err);
|
|
1438
|
+
};
|
|
1439
|
+
const forceCancelResult = () => {
|
|
1440
|
+
forceSettled = true;
|
|
1441
|
+
if (stalledByWatchdog) {
|
|
1442
|
+
return {
|
|
1443
|
+
ok: false,
|
|
1444
|
+
output: `Tool timed out after ${stallSecs}s without output (force-cancelled).`,
|
|
1445
|
+
exitCode: 124,
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1448
|
+
if (hardTimedOut) {
|
|
1449
|
+
return {
|
|
1450
|
+
ok: false,
|
|
1451
|
+
output: `Tool hard-timeout after ${hardSecs}s — cancelled.`,
|
|
1452
|
+
exitCode: 124,
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
return {
|
|
1456
|
+
ok: false,
|
|
1457
|
+
output: "Tool aborted before it could complete (force-cancelled).",
|
|
1458
|
+
exitCode: 130,
|
|
1459
|
+
};
|
|
1460
|
+
};
|
|
1461
|
+
const armGraceForceSettle = () => {
|
|
1462
|
+
if (settled || graceTimer)
|
|
1463
|
+
return;
|
|
1464
|
+
graceTimer = setTimeout(() => {
|
|
1465
|
+
if (settled)
|
|
1466
|
+
return;
|
|
1467
|
+
writeNotice("warn", `${call.name} did not stop after cancel — force-settling`, chalk.yellow(` ⏳ ${call.name} ignore cancel — force-settling hung tool\n`));
|
|
1468
|
+
finishOk(forceCancelResult());
|
|
1469
|
+
}, TOOL_ABORT_GRACE_MS);
|
|
1470
|
+
graceTimer.unref?.();
|
|
1471
|
+
};
|
|
1472
|
+
work.then(finishOk, finishErr);
|
|
1473
|
+
// Hard wall-clock: abort + force-settle after budget.
|
|
1474
|
+
hardTimer = setTimeout(() => {
|
|
1475
|
+
if (settled)
|
|
1476
|
+
return;
|
|
1477
|
+
hardTimedOut = true;
|
|
1478
|
+
writeNotice("warn", `${call.name} exceeded ${hardSecs}s hard budget — cancelling`, chalk.yellow(` ⏳ ${call.name} hard-timeout (${hardSecs}s) — cancelling\n`));
|
|
1479
|
+
if (!toolAc.signal.aborted)
|
|
1480
|
+
toolAc.abort();
|
|
1481
|
+
armGraceForceSettle();
|
|
1482
|
+
}, TOOL_HARD_BUDGET_MS);
|
|
1483
|
+
hardTimer.unref?.();
|
|
1484
|
+
// After any abort (stall, user Esc/Ctrl+C, parent), force-settle
|
|
1485
|
+
// if the tool promise does not resolve within the grace window.
|
|
1486
|
+
const onToolAbort = () => armGraceForceSettle();
|
|
1487
|
+
if (toolAc.signal.aborted)
|
|
1488
|
+
onToolAbort();
|
|
1489
|
+
else
|
|
1490
|
+
toolAc.signal.addEventListener("abort", onToolAbort, { once: true });
|
|
1135
1491
|
});
|
|
1492
|
+
};
|
|
1493
|
+
try {
|
|
1494
|
+
result = await runToolWithForcedSettle();
|
|
1495
|
+
// User Esc/Ctrl+C: force-settle may resolve with a cancel result
|
|
1496
|
+
// instead of throwing — still end the turn as aborted.
|
|
1497
|
+
if (parentSignal.aborted && !stalledByWatchdog && !hardTimedOut) {
|
|
1498
|
+
writeAbort();
|
|
1499
|
+
return {
|
|
1500
|
+
ok: false,
|
|
1501
|
+
call,
|
|
1502
|
+
result: { ok: false, output: "Aborted." },
|
|
1503
|
+
contextOutput: "Aborted.",
|
|
1504
|
+
lastAnswer: "Aborted.",
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1136
1507
|
if (liveBytes > 0) {
|
|
1137
1508
|
writeToolOutput(toolEventId, "\n", "\n");
|
|
1138
1509
|
}
|
|
@@ -1140,12 +1511,8 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1140
1511
|
}
|
|
1141
1512
|
catch (toolError) {
|
|
1142
1513
|
jobManager.updateJobStatus(jobId, "failed", 1);
|
|
1143
|
-
if (isAbortError(toolError, toolAc.signal)) {
|
|
1144
|
-
|
|
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
|
-
if (parentSignal.aborted) {
|
|
1514
|
+
if (isAbortError(toolError, toolAc.signal) || forceSettled) {
|
|
1515
|
+
if (parentSignal.aborted && !stalledByWatchdog && !hardTimedOut) {
|
|
1149
1516
|
writeAbort();
|
|
1150
1517
|
return {
|
|
1151
1518
|
ok: false,
|
|
@@ -1159,8 +1526,10 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1159
1526
|
ok: false,
|
|
1160
1527
|
output: stalledByWatchdog
|
|
1161
1528
|
? `Tool timed out after ${TOOL_STALL_ABORT_MS / 1_000}s without output.`
|
|
1162
|
-
:
|
|
1163
|
-
|
|
1529
|
+
: hardTimedOut
|
|
1530
|
+
? `Tool hard-timeout after ${hardSecs}s — cancelled.`
|
|
1531
|
+
: "Tool aborted before it could complete.",
|
|
1532
|
+
exitCode: stalledByWatchdog || hardTimedOut ? 124 : 130,
|
|
1164
1533
|
};
|
|
1165
1534
|
}
|
|
1166
1535
|
else {
|
|
@@ -1171,54 +1540,88 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1171
1540
|
finally {
|
|
1172
1541
|
if (stallTimer)
|
|
1173
1542
|
clearTimeout(stallTimer);
|
|
1543
|
+
if (hardTimer)
|
|
1544
|
+
clearTimeout(hardTimer);
|
|
1545
|
+
if (graceTimer)
|
|
1546
|
+
clearTimeout(graceTimer);
|
|
1547
|
+
engagementLease?.release();
|
|
1174
1548
|
parentSignal.removeEventListener("abort", onParentAbort);
|
|
1175
1549
|
}
|
|
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
1550
|
if ((call.name === "shell.exec" || call.name === "shell.start") &&
|
|
1182
1551
|
typeof call.args.command === "string" &&
|
|
1183
1552
|
isScaffoldCreateCommand(call.args.command)) {
|
|
1184
1553
|
const cmd = call.args.command;
|
|
1185
1554
|
const cwdArg = typeof call.args.cwd === "string" ? call.args.cwd : undefined;
|
|
1186
|
-
const
|
|
1187
|
-
|
|
1188
|
-
const
|
|
1555
|
+
const out = result.output ?? "";
|
|
1556
|
+
// Prefer path reported by the scaffolder (handles quoted-cd mis-parse leftovers).
|
|
1557
|
+
const fromOutput = out.match(/Scaffolding project in\s+([^\n]+?)\s*\.{0,3}\s*$/im)?.[1]?.trim().replace(/['"]/g, "");
|
|
1558
|
+
const fromScaffold = (fromOutput && fromOutput.startsWith("/")
|
|
1559
|
+
? fromOutput
|
|
1560
|
+
: undefined) ??
|
|
1561
|
+
extractProjectRootFromScaffold(cmd, cwdArg);
|
|
1562
|
+
const cancelled = isScaffoldCancelledOutput(out);
|
|
1563
|
+
let materialized = scaffoldLooksMaterialized(fromScaffold);
|
|
1564
|
+
// One re-check: create-vite can report success before FS snapshot is visible.
|
|
1565
|
+
if (!materialized && fromScaffold) {
|
|
1566
|
+
materialized = scaffoldLooksMaterialized(fromScaffold);
|
|
1567
|
+
}
|
|
1189
1568
|
const abortedMid = !result.ok &&
|
|
1190
1569
|
(result.exitCode === 124 ||
|
|
1191
1570
|
result.exitCode === 130 ||
|
|
1192
|
-
/timed out|aborted|Command aborted/i.test(
|
|
1193
|
-
|
|
1571
|
+
/timed out|aborted|Command aborted/i.test(out));
|
|
1572
|
+
const resumableMaterialized = Boolean(fromScaffold && materialized && (cancelled || abortedMid || !result.ok));
|
|
1573
|
+
if (resumableMaterialized && fromScaffold) {
|
|
1574
|
+
setActiveProjectRootIfValid(fromScaffold, { force: true });
|
|
1575
|
+
await persistProjectRootOnPlan(fromScaffold);
|
|
1576
|
+
result = {
|
|
1577
|
+
...result,
|
|
1578
|
+
ok: true,
|
|
1579
|
+
exitCode: 0,
|
|
1580
|
+
output: out +
|
|
1581
|
+
(out.endsWith("\n") ? "" : "\n") +
|
|
1582
|
+
`The scaffold reported ${cancelled ? "cancellation/refusal" : "interruption"}, but a usable project tree already exists at ${fromScaffold} ` +
|
|
1583
|
+
`(package/manifest present). Treat this as resumable: do NOT re-run the scaffolder. ` +
|
|
1584
|
+
`Inspect the existing files, finish any missing install, implement the requested feature, then run/verify.`,
|
|
1585
|
+
};
|
|
1586
|
+
writeNotice("info", `project root → ${fromScaffold} (existing materialized scaffold — continue)`, chalk.dim(` ℹ existing scaffold at ${fromScaffold} — continue, do not re-create\n`));
|
|
1587
|
+
}
|
|
1588
|
+
else if (result.ok && cancelled && !materialized) {
|
|
1194
1589
|
result = {
|
|
1195
1590
|
ok: false,
|
|
1196
|
-
output:
|
|
1197
|
-
(
|
|
1198
|
-
`Scaffold FAILED:
|
|
1199
|
-
(fromScaffold
|
|
1200
|
-
? `Expected project at ${fromScaffold}. `
|
|
1201
|
-
: "") +
|
|
1591
|
+
output: out +
|
|
1592
|
+
(out.endsWith("\n") ? "" : "\n") +
|
|
1593
|
+
`Scaffold FAILED: tool reported cancel/refuse. ` +
|
|
1594
|
+
(fromScaffold ? `Expected project at ${fromScaffold}. ` : "") +
|
|
1202
1595
|
`If the folder already exists, CONTINUE it (do not re-scaffold). Otherwise use a new empty name or hand-write a minimal tree.`,
|
|
1203
1596
|
exitCode: result.exitCode && result.exitCode !== 0 ? result.exitCode : 1,
|
|
1204
1597
|
};
|
|
1205
1598
|
}
|
|
1599
|
+
else if (result.ok && !materialized) {
|
|
1600
|
+
// Soft warn only when we truly see no tree — do not flip ok if output
|
|
1601
|
+
// clearly scaffolded (path may still resolve on next tool).
|
|
1602
|
+
const claimedScaffold = /Scaffolding project in\b/i.test(out);
|
|
1603
|
+
if (!claimedScaffold) {
|
|
1604
|
+
result = {
|
|
1605
|
+
ok: false,
|
|
1606
|
+
output: out +
|
|
1607
|
+
(out.endsWith("\n") ? "" : "\n") +
|
|
1608
|
+
`Scaffold FAILED: target project tree was not created. ` +
|
|
1609
|
+
(fromScaffold ? `Expected project at ${fromScaffold}. ` : "") +
|
|
1610
|
+
`If the folder already exists, CONTINUE it (do not re-scaffold). Otherwise use a new empty name or hand-write a minimal tree.`,
|
|
1611
|
+
exitCode: result.exitCode && result.exitCode !== 0 ? result.exitCode : 1,
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1614
|
+
else if (fromScaffold) {
|
|
1615
|
+
setActiveProjectRootIfValid(fromScaffold, { force: true });
|
|
1616
|
+
await persistProjectRootOnPlan(fromScaffold);
|
|
1617
|
+
writeNotice("info", `project root → ${fromScaffold} (scaffold output claimed success — continue)`, chalk.dim(` ℹ project root set to ${fromScaffold}\n`));
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1206
1620
|
else if (result.ok && fromScaffold && materialized) {
|
|
1207
1621
|
setActiveProjectRootIfValid(fromScaffold, { force: true });
|
|
1622
|
+
await persistProjectRootOnPlan(fromScaffold);
|
|
1208
1623
|
writeNotice("info", `project root → ${fromScaffold}`, chalk.dim(` ℹ project root set to ${fromScaffold}\n`));
|
|
1209
1624
|
}
|
|
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
1625
|
}
|
|
1223
1626
|
const output = result.output.trim();
|
|
1224
1627
|
// Always keep a full on-disk copy of tool output (any size) so the
|
|
@@ -1245,21 +1648,177 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1245
1648
|
exitCode: result.exitCode,
|
|
1246
1649
|
output: result.output.slice(0, 4_000),
|
|
1247
1650
|
});
|
|
1651
|
+
if (engagementGraph && engagementRecord) {
|
|
1652
|
+
if (result.backgroundJob) {
|
|
1653
|
+
const checkpointInput = {
|
|
1654
|
+
jobId: result.backgroundJob.id,
|
|
1655
|
+
status: result.backgroundJob.status,
|
|
1656
|
+
artifactPath: result.backgroundJob.artifactPath,
|
|
1657
|
+
offset: result.backgroundJob.nextOffset ?? 0,
|
|
1658
|
+
observation: result.output.slice(0, 16_000),
|
|
1659
|
+
};
|
|
1660
|
+
const reconciled = reconcileEngagementJob(engagementGraph, checkpointInput);
|
|
1661
|
+
if (!reconciled || reconciled.actionId !== engagementRecord.id) {
|
|
1662
|
+
recordEngagementCheckpoint(engagementGraph, {
|
|
1663
|
+
actionId: engagementRecord.id,
|
|
1664
|
+
...checkpointInput,
|
|
1665
|
+
});
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
else {
|
|
1669
|
+
finishEngagementAction(engagementGraph, engagementRecord.id, {
|
|
1670
|
+
ok: result.ok,
|
|
1671
|
+
observation: result.output.slice(0, 16_000),
|
|
1672
|
+
...(savedOutputPath ? { artifactPath: savedOutputPath } : {}),
|
|
1673
|
+
scannerLead: call.name === "net.scan" || call.name.startsWith("pentest."),
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
await saveEngagement(engagementGraph);
|
|
1677
|
+
}
|
|
1678
|
+
const newEvidence = recordToolEvidence(outcomeState, {
|
|
1679
|
+
tool: call.name,
|
|
1680
|
+
callId: toolEventId,
|
|
1681
|
+
ok: result.ok,
|
|
1682
|
+
output: result.output,
|
|
1683
|
+
...(savedOutputPath ? { artifact: savedOutputPath } : {}),
|
|
1684
|
+
...(dispatchedTaskId ? { taskId: dispatchedTaskId } : {}),
|
|
1685
|
+
args: call.args,
|
|
1686
|
+
});
|
|
1687
|
+
let hypothesisDelta = 0;
|
|
1688
|
+
if (!result.ok) {
|
|
1689
|
+
const before = outcomeState.failedHypotheses.length;
|
|
1690
|
+
recordFailedHypothesis(outcomeState, {
|
|
1691
|
+
signature: `${call.name}:${result.exitCode ?? 1}`,
|
|
1692
|
+
premise: `${call.name} with ${JSON.stringify(call.args).slice(0, 1_000)}`,
|
|
1693
|
+
});
|
|
1694
|
+
hypothesisDelta = outcomeState.failedHypotheses.length - before;
|
|
1695
|
+
retryDependenciesChanged = false;
|
|
1696
|
+
retryEnvironmentChanged = false;
|
|
1697
|
+
moveTurn("exploring", `${call.name} failed; revise the premise`);
|
|
1698
|
+
}
|
|
1699
|
+
else {
|
|
1700
|
+
const mutatesDependencies = /^(?:fs\.(?:write|writeMany|edit|replaceLines|append|delete)|pkg\.install)$/.test(call.name) ||
|
|
1701
|
+
((call.name === "shell.exec" || call.name === "shell.start") &&
|
|
1702
|
+
/\b(?:install|mkdir|create|generate|build)\b/i.test(String(call.args.command ?? "")));
|
|
1703
|
+
retryDependenciesChanged ||= mutatesDependencies;
|
|
1704
|
+
retryEnvironmentChanged ||=
|
|
1705
|
+
call.name === "pkg.install" ||
|
|
1706
|
+
((call.name === "shell.exec" || call.name === "shell.start") &&
|
|
1707
|
+
isPackageInstallCommand(String(call.args.command ?? "")));
|
|
1708
|
+
}
|
|
1709
|
+
const governed = governProgress(governorState, "activity", {
|
|
1710
|
+
evidenceDelta: newEvidence.length,
|
|
1711
|
+
hypothesisDelta,
|
|
1712
|
+
repetitionScore: loopGuard.getAttemptCount(call.name, call.args) > 1 ? 1 : 0,
|
|
1713
|
+
policy: {
|
|
1714
|
+
resourceEnvelope: Math.max(12, maxSteps),
|
|
1715
|
+
emergencyCeiling: Math.max(70, maxSteps * 3),
|
|
1716
|
+
reflectionAfterNoDelta: 3,
|
|
1717
|
+
pauseAfterNoDelta: 6,
|
|
1718
|
+
repetitionThreshold: 0.8,
|
|
1719
|
+
},
|
|
1720
|
+
});
|
|
1721
|
+
governorState = governed.state;
|
|
1722
|
+
if (governed.recommendation === "reflect") {
|
|
1723
|
+
deferredPostToolMessages.push({
|
|
1724
|
+
role: "system",
|
|
1725
|
+
content: `PROGRESS GOVERNOR: ${governed.reason}. Reassess the current premise and choose the next action that can produce criterion-linked evidence.`,
|
|
1726
|
+
});
|
|
1727
|
+
}
|
|
1728
|
+
else if (governed.recommendation === "paused_budget") {
|
|
1729
|
+
governorPauseReason = governed.reason;
|
|
1730
|
+
}
|
|
1731
|
+
await saveOutcomeState(outcomeState);
|
|
1248
1732
|
loopGuard.recordAttempt(step, call.name, call.args, result.ok, result.exitCode);
|
|
1249
1733
|
// Evidence for verify-before-done: only successful real work counts.
|
|
1250
1734
|
if (result.ok && isEvidenceWorkTool(call.name)) {
|
|
1251
1735
|
const liveAfter = await loadPlan(session.sessionId).catch(() => undefined);
|
|
1252
|
-
|
|
1253
|
-
|
|
1736
|
+
// Never credit whichever task happens to be open after execution: a
|
|
1737
|
+
// later task.update in the same batch may already have changed it.
|
|
1738
|
+
const creditId = dispatchedTaskId;
|
|
1739
|
+
const cmd = typeof call.args.command === "string" ? call.args.command : "";
|
|
1740
|
+
const signals = {};
|
|
1741
|
+
if (isFeatureImplementationCall(call))
|
|
1742
|
+
signals.featureWrite = true;
|
|
1743
|
+
if (call.name === "fs.write" ||
|
|
1744
|
+
call.name === "fs.writeMany" ||
|
|
1745
|
+
call.name === "fs.edit" ||
|
|
1746
|
+
call.name === "fs.replaceLines" ||
|
|
1747
|
+
call.name === "fs.append") {
|
|
1748
|
+
signals.sourceWrite = true;
|
|
1749
|
+
}
|
|
1750
|
+
if ((call.name === "shell.exec" || call.name === "shell.start") &&
|
|
1751
|
+
isPackageInstallCommand(cmd)) {
|
|
1752
|
+
signals.installOk = true;
|
|
1753
|
+
}
|
|
1754
|
+
if ((call.name === "shell.exec" || call.name === "shell.start") &&
|
|
1755
|
+
isScaffoldCreateCommand(cmd)) {
|
|
1756
|
+
signals.scaffoldOk = true;
|
|
1757
|
+
}
|
|
1758
|
+
if (isDevServerCall(call))
|
|
1759
|
+
signals.devServerStart = true;
|
|
1760
|
+
const out = result.output ?? "";
|
|
1761
|
+
if ((call.name === "shell.tail" || call.name === "shell.start") &&
|
|
1762
|
+
isServerReadyOutput(out)) {
|
|
1763
|
+
signals.serverReady = true;
|
|
1764
|
+
}
|
|
1765
|
+
if (call.name === "shell.exec" &&
|
|
1766
|
+
isPortListeningOutput(cmd, out)) {
|
|
1767
|
+
signals.portListening = true;
|
|
1768
|
+
}
|
|
1769
|
+
if (localHttpProbeIsSuccess(out) ||
|
|
1770
|
+
(sawLocalHttpProbe && !sawFailedLocalHttpProbe)) {
|
|
1771
|
+
// Prefer explicit success parse on this result
|
|
1772
|
+
if (/\b(localhost|127\.0\.0\.1)\b/i.test(`${call.name} ${cmd} ${JSON.stringify(call.args)}`)) {
|
|
1773
|
+
if (localHttpProbeIsSuccess(out)) {
|
|
1774
|
+
signals.localHttpProbeOk = true;
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
// Remote/pentest evidence — never conflate with local app runtime
|
|
1779
|
+
if (isRemoteReconToolCall(call))
|
|
1780
|
+
signals.remoteReconOk = true;
|
|
1781
|
+
if (isRemoteActiveTestCall(call))
|
|
1782
|
+
signals.remoteActiveTestOk = true;
|
|
1783
|
+
// Always bank the success for later absorb (preflight / no open task).
|
|
1784
|
+
sessionLooseWork.push({
|
|
1785
|
+
toolName: call.name,
|
|
1786
|
+
...(Object.keys(signals).length > 0 ? { signals } : {}),
|
|
1787
|
+
});
|
|
1788
|
+
taskWorkLedger = recordTaskWorkSuccess(taskWorkLedger, creditId, call.name, signals);
|
|
1789
|
+
// If nothing was open, still try to attach to the next ready explore
|
|
1790
|
+
// task so "Check Node/npm" can complete without thrash.
|
|
1791
|
+
if ((!creditId || !taskWorkLedger || taskWorkLedger.taskId !== creditId) && liveAfter) {
|
|
1792
|
+
const ready = readyPlanTasks(liveAfter)[0];
|
|
1793
|
+
if (ready) {
|
|
1794
|
+
const absorbed = absorbLooseWorkIntoLedger(ledgerFromTaskEvidence(ready.id, ready.evidence), ready.id, ready.title, [{ toolName: call.name, signals }], { planKind: liveAfter.kind });
|
|
1795
|
+
if (absorbed && absorbed.successWorkCount > 0) {
|
|
1796
|
+
const task = liveAfter.tasks.find((t) => t.id === ready.id);
|
|
1797
|
+
if (task) {
|
|
1798
|
+
task.evidence = taskEvidenceFromLedger(absorbed);
|
|
1799
|
+
if (!taskWorkLedger ||
|
|
1800
|
+
taskWorkLedger.taskId !== ready.id ||
|
|
1801
|
+
taskWorkLedger.successWorkCount < absorbed.successWorkCount) {
|
|
1802
|
+
taskWorkLedger = absorbed;
|
|
1803
|
+
}
|
|
1804
|
+
await savePlan(liveAfter).catch(() => undefined);
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
if (liveAfter && creditId && taskWorkLedger?.taskId === creditId) {
|
|
1810
|
+
const task = liveAfter.tasks.find((candidate) => candidate.id === creditId);
|
|
1811
|
+
if (task) {
|
|
1812
|
+
task.evidence = taskEvidenceFromLedger(taskWorkLedger);
|
|
1813
|
+
await savePlan(liveAfter).catch(() => undefined);
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
refreshSessionState(liveAfter);
|
|
1254
1817
|
}
|
|
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
1818
|
if (!result.ok) {
|
|
1260
1819
|
const reflection = loopGuard.getFailureReflection();
|
|
1261
1820
|
if (reflection) {
|
|
1262
|
-
|
|
1821
|
+
deferredPostToolMessages.push({ role: "system", content: reflection });
|
|
1263
1822
|
const failCount = loopGuard.consecutiveFailureCount();
|
|
1264
1823
|
writeNotice("warn", `${failCount} consecutive failures — model evaluating approach`, chalk.yellow(` ⚠ ${failCount} consecutive failures — evaluating approach\n`));
|
|
1265
1824
|
}
|
|
@@ -1298,9 +1857,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1298
1857
|
artifactPath: savedOutputPath,
|
|
1299
1858
|
summary: contextOutput,
|
|
1300
1859
|
});
|
|
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
1860
|
if (writesDirectly && savedOutputPath) {
|
|
1305
1861
|
const short = chalk.dim(` saved ${savedOutputPath}\n`);
|
|
1306
1862
|
process.stdout.write(short);
|
|
@@ -1312,15 +1868,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1312
1868
|
}
|
|
1313
1869
|
return { ok: result.ok, call, result, contextOutput };
|
|
1314
1870
|
}
|
|
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
1871
|
const AUTO_COMPACT_KEEP_RECENT = 6;
|
|
1325
1872
|
let lastCompactionMsgCount = 0;
|
|
1326
1873
|
const summarizeForCompaction = async (summaryPrompt) => {
|
|
@@ -1328,10 +1875,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1328
1875
|
provider,
|
|
1329
1876
|
model,
|
|
1330
1877
|
messages: [
|
|
1331
|
-
{
|
|
1332
|
-
role: "system",
|
|
1333
|
-
content: "You compress conversation history into an accurate, concise continuation memory for another assistant.",
|
|
1334
|
-
},
|
|
1878
|
+
{ role: "system", content: COMPACTION_SYSTEM_PROMPT },
|
|
1335
1879
|
{ role: "user", content: summaryPrompt },
|
|
1336
1880
|
],
|
|
1337
1881
|
temperature: 0.1,
|
|
@@ -1367,6 +1911,8 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1367
1911
|
content: planContextMessage(livePlan, session.planApproved.value),
|
|
1368
1912
|
});
|
|
1369
1913
|
}
|
|
1914
|
+
// Re-inject live SESSION STATE after compaction (older flags survive).
|
|
1915
|
+
refreshSessionState(livePlan);
|
|
1370
1916
|
lastCompactionMsgCount = messages.length;
|
|
1371
1917
|
// Final count the model actually receives (may include re-injected plan).
|
|
1372
1918
|
const afterTokens = estimateMessagesTokens(messages);
|
|
@@ -1375,10 +1921,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1375
1921
|
estimatedTokens: afterTokens,
|
|
1376
1922
|
reason,
|
|
1377
1923
|
});
|
|
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
1924
|
const insertedSummary = messages.find((m) => m.role === "system" &&
|
|
1383
1925
|
m.content.startsWith(COMPACTION_MEMORY_PREFIX))?.content ?? "";
|
|
1384
1926
|
const summaryText = insertedSummary.startsWith(`${COMPACTION_MEMORY_PREFIX}\n\n`)
|
|
@@ -1404,84 +1946,32 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1404
1946
|
}
|
|
1405
1947
|
}
|
|
1406
1948
|
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
1949
|
visibleCommitted = false;
|
|
1411
1950
|
// `step` is the productive-step index (used for display + audit). It only
|
|
1412
1951
|
// advances when the previous iteration actually executed a tool.
|
|
1413
1952
|
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
|
-
}
|
|
1953
|
+
if (governorPauseReason) {
|
|
1954
|
+
const richSummary = await buildRichStopSummary(messages, session, productiveSteps);
|
|
1955
|
+
outcomeState.outcome.status = "paused_budget";
|
|
1956
|
+
await saveOutcomeState(outcomeState);
|
|
1957
|
+
moveTurn("paused_budget", governorPauseReason);
|
|
1958
|
+
lastAnswer = richSummary;
|
|
1959
|
+
return finishTurn(lastAnswer, productiveSteps, "paused_budget", outcomeState.outcome.criteria
|
|
1960
|
+
.filter((criterion) => criterion.required && criterion.status !== "proven")
|
|
1961
|
+
.map((criterion) => criterion.statement), governorPauseReason);
|
|
1460
1962
|
}
|
|
1461
1963
|
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
1964
|
let call;
|
|
1465
1965
|
let assistantText;
|
|
1466
1966
|
let recoveredFromBareJson = false;
|
|
1467
1967
|
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
1968
|
call = pendingCalls.shift();
|
|
1472
1969
|
assistantText = { visible: "", thinkContent: "", hasThinking: false };
|
|
1473
1970
|
const batchStatus = ` ↳ continuing batch (${pendingCalls.length} more queued)\n`;
|
|
1474
1971
|
writeStatus(batchStatus, chalk.dim(batchStatus));
|
|
1475
1972
|
}
|
|
1476
1973
|
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
1974
|
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
1975
|
const streamLabel = step === 0 ? "waiting for model" : `step ${step + 1}`;
|
|
1486
1976
|
let spinner = writesDirectly
|
|
1487
1977
|
? startThinkingSpinner(streamLabel, options.signal)
|
|
@@ -1496,9 +1986,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1496
1986
|
let accumulatedText = "";
|
|
1497
1987
|
const callIds = [];
|
|
1498
1988
|
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
1989
|
const deferredToolCalls = [];
|
|
1503
1990
|
const deltaParser = writesDirectly
|
|
1504
1991
|
? undefined
|
|
@@ -1518,7 +2005,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1518
2005
|
if (messages[0]?.role === "system") {
|
|
1519
2006
|
messages[0] = {
|
|
1520
2007
|
role: "system",
|
|
1521
|
-
content:
|
|
2008
|
+
content: composeCurrentSystemPrompt(nativeToolsActive),
|
|
1522
2009
|
};
|
|
1523
2010
|
}
|
|
1524
2011
|
const turnTools = selectToolDefs(nativeToolsActive, useCompactSystemPrompt);
|
|
@@ -1530,30 +2017,13 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1530
2017
|
dialect: toolDialect,
|
|
1531
2018
|
step,
|
|
1532
2019
|
});
|
|
2020
|
+
assertValidToolProtocol(messages);
|
|
1533
2021
|
completion = await streamWithProvider({
|
|
1534
2022
|
provider,
|
|
1535
2023
|
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
2024
|
allowModelFallback: false,
|
|
1541
2025
|
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
2026
|
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
2027
|
maxTokens: 32_768,
|
|
1558
2028
|
signal: options.signal,
|
|
1559
2029
|
thinking: retryWithoutThinking
|
|
@@ -1656,9 +2126,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1656
2126
|
}
|
|
1657
2127
|
}
|
|
1658
2128
|
}
|
|
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
2129
|
if (!sawReasoning && /<think/i.test(token)) {
|
|
1663
2130
|
sawReasoning = true;
|
|
1664
2131
|
inThinking = true;
|
|
@@ -1671,11 +2138,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1671
2138
|
spinner.setLabel("generating response (0 tokens)");
|
|
1672
2139
|
generatedTokens = 0;
|
|
1673
2140
|
}
|
|
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
2141
|
if (inThinking) {
|
|
1680
2142
|
const cleaned = token.replace(/<\/?think[^>]*>/gi, "");
|
|
1681
2143
|
if (cleaned) {
|
|
@@ -1711,13 +2173,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1711
2173
|
(toolsAttached && !isTextOnlyModel(provider, model));
|
|
1712
2174
|
const assistantTextResult = rememberThinkingFromText(completion.text);
|
|
1713
2175
|
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
2176
|
if (assistantText.hasThinking) {
|
|
1722
2177
|
writeThinkingBlock(assistantText.thinkContent);
|
|
1723
2178
|
}
|
|
@@ -1780,11 +2235,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1780
2235
|
}
|
|
1781
2236
|
}
|
|
1782
2237
|
}
|
|
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
2238
|
if (nativeToolCalls.length) {
|
|
1789
2239
|
const first = nativeToolCalls[0];
|
|
1790
2240
|
if (first.args?._parseError) {
|
|
@@ -1807,12 +2257,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1807
2257
|
}
|
|
1808
2258
|
}
|
|
1809
2259
|
}
|
|
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
2260
|
if (looksLikePromptLeak(assistantText.visible)) {
|
|
1817
2261
|
if (call || nativeToolCalls.length) {
|
|
1818
2262
|
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 +2265,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1821
2265
|
nativeToolCalls = [];
|
|
1822
2266
|
deferredToolCalls.length = 0;
|
|
1823
2267
|
}
|
|
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
2268
|
if (nativeToolCalls.length) {
|
|
1829
2269
|
// Only salvage when args failed to parse (truncated JSON). A clean
|
|
1830
2270
|
// parse with finish_reason=length is a complete tool call — execute it.
|
|
@@ -1887,14 +2327,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1887
2327
|
}
|
|
1888
2328
|
}
|
|
1889
2329
|
}
|
|
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
2330
|
if (!assistantText.visible.trim() && !call) {
|
|
1899
2331
|
emptyVisibleRetries += 1;
|
|
1900
2332
|
if (emptyVisibleRetries <= 3) {
|
|
@@ -1912,10 +2344,10 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1912
2344
|
? toolsAttached
|
|
1913
2345
|
? "No visible output. This is current or scheduled information: call web.search now. Do NOT answer from memory."
|
|
1914
2346
|
: "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
|
-
:
|
|
2347
|
+
: isPlanMode && !activePlan
|
|
1916
2348
|
? toolsAttached
|
|
1917
|
-
? "No visible output.
|
|
1918
|
-
: "No visible output.
|
|
2349
|
+
? "No visible output. In plan mode: gather context or call plan.create when ready (do not only describe the plan)."
|
|
2350
|
+
: "No visible output. In plan mode: emit a ```tool block for research/recon or plan.create. " +
|
|
1919
2351
|
"Do NOT hide tool calls in <think> tags — put them in the visible response."
|
|
1920
2352
|
: toolsAttached
|
|
1921
2353
|
? "No visible output. " + toolNudge(true)
|
|
@@ -1924,8 +2356,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1924
2356
|
messages.push(recoveryUserMessage(buildNudge));
|
|
1925
2357
|
continue;
|
|
1926
2358
|
}
|
|
1927
|
-
// Exhausted retries — surface a clear notice and exit the turn instead
|
|
1928
|
-
// of falling through and triggering premature-completion loops.
|
|
1929
2359
|
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
2360
|
return finishTurn("Model returned an empty response after retries.", step + 1);
|
|
1931
2361
|
}
|
|
@@ -1934,12 +2364,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1934
2364
|
emptyVisibleRetries = 0;
|
|
1935
2365
|
retryWithoutThinking = false;
|
|
1936
2366
|
}
|
|
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
2367
|
let bareArgsOnly = false;
|
|
1944
2368
|
recoveredFromBareJson = false;
|
|
1945
2369
|
if (!call) {
|
|
@@ -1975,16 +2399,13 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1975
2399
|
? " ⚠ tool call missing its name — asking the model to call a tool properly\n"
|
|
1976
2400
|
: " ⚠ tool call missing its name/fence — asking the model to re-emit a proper ```tool block\n"));
|
|
1977
2401
|
pushAssistantHistory(assistantText.visible);
|
|
1978
|
-
messages.push(recoveryUserMessage(
|
|
2402
|
+
messages.push(recoveryUserMessage(isPlanMode && !activePlan
|
|
1979
2403
|
? toolsAttached
|
|
1980
2404
|
? "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."
|
|
2405
|
+
"In plan mode: call plan.create (or research tools) via the platform tool interface."
|
|
1983
2406
|
: "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."
|
|
2407
|
+
"In plan mode, call plan.create with a proper ```tool block when ready, e.g.:\n" +
|
|
2408
|
+
'```tool\n{"name":"plan.create","args":{"goal":"…","detail":"…","tasks":["…"],"kind":"coding"}}\n```'
|
|
1988
2409
|
: toolsAttached
|
|
1989
2410
|
? "Your previous message was a bare JSON args object with no tool name, so NOTHING ran. " +
|
|
1990
2411
|
toolNudge(true) +
|
|
@@ -1998,10 +2419,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1998
2419
|
}
|
|
1999
2420
|
// Exhausted retries — fall through to the normal answer path.
|
|
2000
2421
|
}
|
|
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
2422
|
if (/<\|tool_call(?:s_section)?_begin\|>|<\|tool_call_argument_begin\|>/i.test(assistantText.visible)) {
|
|
2006
2423
|
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
2424
|
pushAssistantHistory(assistantText.visible);
|
|
@@ -2015,17 +2432,8 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2015
2432
|
"Do not use <|tool_call_begin|> markers."));
|
|
2016
2433
|
continue;
|
|
2017
2434
|
}
|
|
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
2435
|
if (looksLikeTruncatedToolCall(assistantText.visible)) {
|
|
2025
2436
|
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
2437
|
const salvaged = salvageTruncatedWrite(assistantText.visible);
|
|
2030
2438
|
if (salvaged && truncatedToolRetries <= 5) {
|
|
2031
2439
|
// Write the salvaged partial content
|
|
@@ -2085,14 +2493,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2085
2493
|
// Exhausted retries — fall through so we don't loop forever, but the
|
|
2086
2494
|
// user at least sees the (broken) output and the stop notice.
|
|
2087
2495
|
}
|
|
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
2496
|
const hasFencedCallShape = countToolFences(assistantText.visible) > 0 &&
|
|
2097
2497
|
/```tool\s*\n[\s\S]*?"(?:name|args)"\s*:/i.test(assistantText.visible);
|
|
2098
2498
|
if (hasFencedCallShape) {
|
|
@@ -2145,43 +2545,21 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2145
2545
|
}
|
|
2146
2546
|
// Exhausted retries — fall through to the normal path.
|
|
2147
2547
|
}
|
|
2148
|
-
// Normal final-answer path: strip any stray sentinel tokens that
|
|
2149
|
-
// somehow leaked into prose so the answer renders cleanly.
|
|
2150
2548
|
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
2549
|
const narratedAction = looksLikeActionNarration(cleaned);
|
|
2159
2550
|
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);
|
|
2551
|
+
const reconciledPlanAtCompletion = await reconcileOpenTaskBeforeFinalizing();
|
|
2552
|
+
const livePlanAtCompletion = reconciledPlanAtCompletion ??
|
|
2553
|
+
(await loadPlan(session.sessionId).catch(() => undefined));
|
|
2168
2554
|
const planStatusAtCompletion = livePlanAtCompletion?.status ?? activePlan?.status;
|
|
2169
2555
|
const completedPlanDuringThisTurn = activePlan?.status !== "completed" &&
|
|
2170
2556
|
planStatusAtCompletion === "completed";
|
|
2171
2557
|
const planHasOpenWorkNow = planHasOpenWork(planStatusAtCompletion);
|
|
2172
|
-
// User-driven reasons to require tools this turn (build/pentest/
|
|
2173
|
-
// approved plan / freshness). Idle greetings never qualify.
|
|
2174
2558
|
const userExpectsWork = freshWebSearchRequired ||
|
|
2175
2559
|
(planHasOpenWorkNow && session.planApproved.value) ||
|
|
2176
2560
|
(!informationalQuery &&
|
|
2177
2561
|
!idleOrSocialPrompt &&
|
|
2178
2562
|
(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
2563
|
const wantsAction = !completedPlanDuringThisTurn &&
|
|
2186
2564
|
!idleOrSocialPrompt &&
|
|
2187
2565
|
(userExpectsWork ||
|
|
@@ -2191,12 +2569,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2191
2569
|
!activePlan &&
|
|
2192
2570
|
looksLikePlanNarration(cleaned);
|
|
2193
2571
|
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
2572
|
const shouldRetryBeforeFinalizing = productiveSteps === 0 ||
|
|
2201
2573
|
planNarrated ||
|
|
2202
2574
|
(session.planApproved.value &&
|
|
@@ -2206,126 +2578,71 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2206
2578
|
(buildLikeTurn && errorFixNarration);
|
|
2207
2579
|
if (wantsAction &&
|
|
2208
2580
|
cleaned.trim().length > 0 &&
|
|
2209
|
-
actionIntentRetries < 3 &&
|
|
2210
2581
|
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"));
|
|
2582
|
+
let action;
|
|
2583
|
+
if (errorFixNarration && budgetRemaining(recovery, "errorFix")) {
|
|
2584
|
+
action = recoveryForErrorDiagnosis(toolsAttached);
|
|
2224
2585
|
}
|
|
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"));
|
|
2586
|
+
else if (budgetRemaining(recovery, "actionIntent") &&
|
|
2587
|
+
planHasOpenWorkNow &&
|
|
2588
|
+
session.planApproved.value) {
|
|
2589
|
+
action = recoveryForNarration(toolsAttached, "plan_open");
|
|
2230
2590
|
}
|
|
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"));
|
|
2591
|
+
else if (budgetRemaining(recovery, "actionIntent") &&
|
|
2592
|
+
pentestLikeTurn) {
|
|
2593
|
+
action = recoveryForNarration(toolsAttached, "pentest");
|
|
2238
2594
|
}
|
|
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"));
|
|
2595
|
+
else if (budgetRemaining(recovery, "actionIntent") &&
|
|
2596
|
+
(freshWebSearchRequired || narratedWebAction)) {
|
|
2597
|
+
action = recoveryForNarration(toolsAttached, "web");
|
|
2248
2598
|
}
|
|
2249
|
-
else if (
|
|
2599
|
+
else if (budgetRemaining(recovery, "actionIntent") &&
|
|
2600
|
+
buildLikeTurn &&
|
|
2250
2601
|
(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"));
|
|
2602
|
+
action = recoveryForNarration(toolsAttached, "build_plan_prose");
|
|
2258
2603
|
}
|
|
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"));
|
|
2604
|
+
else if (budgetRemaining(recovery, "actionIntent") &&
|
|
2605
|
+
buildLikeTurn) {
|
|
2606
|
+
action = recoveryForNarration(toolsAttached, "build");
|
|
2266
2607
|
}
|
|
2267
|
-
else {
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2608
|
+
else if (budgetRemaining(recovery, "actionIntent")) {
|
|
2609
|
+
action = recoveryForNarration(toolsAttached, "generic");
|
|
2610
|
+
}
|
|
2611
|
+
if (action) {
|
|
2612
|
+
consumeBudget(recovery, action.budgetKey);
|
|
2613
|
+
writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
|
|
2614
|
+
pushAssistantHistory(assistantText.visible);
|
|
2615
|
+
messages.push(recoveryUserMessage(action.message));
|
|
2616
|
+
continue;
|
|
2275
2617
|
}
|
|
2276
|
-
pushAssistantHistory(assistantText.visible);
|
|
2277
|
-
messages.push(recoveryUserMessage(nudge));
|
|
2278
|
-
continue;
|
|
2279
2618
|
}
|
|
2280
2619
|
if (freshWebSearchRequired &&
|
|
2281
2620
|
!sawFreshWebSearch &&
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2621
|
+
budgetRemaining(recovery, "freshnessUsed")) {
|
|
2622
|
+
const action = recoveryForFreshness(freshnessGuardMessage() +
|
|
2623
|
+
(toolsAttached
|
|
2624
|
+
? " Call the web_search tool now."
|
|
2625
|
+
: " Reply with ONLY a fenced ```tool block for web.search now."));
|
|
2626
|
+
consumeBudget(recovery, action.budgetKey);
|
|
2627
|
+
writeNotice("info", action.notice, chalk.dim(` ℹ ${action.notice}\n`));
|
|
2285
2628
|
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
|
-
});
|
|
2629
|
+
messages.push({ role: "user", content: action.message });
|
|
2293
2630
|
continue;
|
|
2294
2631
|
}
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
!pentestSession &&
|
|
2300
|
-
codingBuildRequiresPlan(prompt, {
|
|
2301
|
-
informational: informationalQuery,
|
|
2302
|
-
idle: idleOrSocialPrompt,
|
|
2303
|
-
pentest: false,
|
|
2304
|
-
}) &&
|
|
2305
|
-
forcePlanRetries < 2) {
|
|
2632
|
+
if (isPlanMode &&
|
|
2633
|
+
!informationalQuery &&
|
|
2634
|
+
!idleOrSocialPrompt &&
|
|
2635
|
+
budgetRemaining(recovery, "forcePlan")) {
|
|
2306
2636
|
const planAtEnd = await loadPlan(session.sessionId).catch(() => undefined);
|
|
2307
2637
|
if (!planAtEnd && !sawPlanCreateOk) {
|
|
2308
|
-
|
|
2638
|
+
const action = recoveryForMissingPlan(toolsAttached);
|
|
2639
|
+
consumeBudget(recovery, action.budgetKey);
|
|
2309
2640
|
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"));
|
|
2641
|
+
messages.push({ role: "user", content: action.message });
|
|
2642
|
+
writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
|
|
2323
2643
|
continue;
|
|
2324
2644
|
}
|
|
2325
2645
|
}
|
|
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
2646
|
if (buildLike &&
|
|
2330
2647
|
!pentestLike &&
|
|
2331
2648
|
!pentestSession &&
|
|
@@ -2334,166 +2651,131 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2334
2651
|
!sawFeatureImplWrite &&
|
|
2335
2652
|
(sawScaffoldOk || sawLocalAppMaterialWork) &&
|
|
2336
2653
|
productiveSteps > 0 &&
|
|
2337
|
-
|
|
2338
|
-
|
|
2654
|
+
budgetRemaining(recovery, "featureImpl")) {
|
|
2655
|
+
const action = recoveryForMissingFeature(getActiveProjectRoot());
|
|
2656
|
+
consumeBudget(recovery, action.budgetKey);
|
|
2339
2657
|
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"));
|
|
2658
|
+
messages.push({ role: "user", content: action.message });
|
|
2659
|
+
writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
|
|
2352
2660
|
continue;
|
|
2353
2661
|
}
|
|
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
2662
|
if (buildLike &&
|
|
2361
2663
|
!pentestLike &&
|
|
2362
2664
|
!pentestSession &&
|
|
2363
|
-
(
|
|
2364
|
-
runtimeVerificationRetries < 2 &&
|
|
2365
|
-
// Feature apps must implement first (handled above); only verify live after that
|
|
2665
|
+
budgetRemaining(recovery, "runtimeVerify") &&
|
|
2366
2666
|
(!featureAppAsk || sawFeatureImplWrite)) {
|
|
2367
2667
|
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;
|
|
2668
|
+
// Durable plan evidence or multi-signal proof this turn is enough
|
|
2669
|
+
const planRuntimeOk = Boolean(runtimePlan && planHasVerifiedRuntime(runtimePlan));
|
|
2670
|
+
const sessionRuntimeOk = sawServerStart &&
|
|
2671
|
+
(sawServerTail || sawLocalHttpProbe || planRuntimeOk);
|
|
2672
|
+
if (!planRuntimeOk && !sessionRuntimeOk) {
|
|
2673
|
+
const codingPlanFinished = Boolean(runtimePlan &&
|
|
2674
|
+
session.planApproved.value &&
|
|
2675
|
+
runtimePlan.kind !== "pentest" &&
|
|
2676
|
+
runtimePlan.tasks.length > 0 &&
|
|
2677
|
+
runtimePlan.tasks.every((task) => task.state === "done" || task.state === "skipped"));
|
|
2678
|
+
const freestyleLocalAppDone = !session.planApproved.value &&
|
|
2679
|
+
sawLocalAppMaterialWork &&
|
|
2680
|
+
productiveSteps > 0 &&
|
|
2681
|
+
freestyleClaimsAppReady(cleaned) &&
|
|
2682
|
+
(getActiveProjectRoot() !== undefined ||
|
|
2683
|
+
/\b(?:npm|pnpm|yarn|bun)\s+run\s+dev\b/i.test(cleaned) ||
|
|
2684
|
+
/\bopen\s+http:\/\/localhost\b/i.test(cleaned));
|
|
2685
|
+
if (codingPlanFinished || freestyleLocalAppDone) {
|
|
2686
|
+
const action = recoveryForRuntimeVerify(getActiveProjectRoot());
|
|
2687
|
+
consumeBudget(recovery, action.budgetKey);
|
|
2688
|
+
pushAssistantHistory(assistantText.visible);
|
|
2689
|
+
messages.push({ role: "user", content: action.message });
|
|
2690
|
+
writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
|
|
2691
|
+
continue;
|
|
2692
|
+
}
|
|
2400
2693
|
}
|
|
2401
2694
|
}
|
|
2402
|
-
// Failed localhost probe (e.g. HTTP 500): model must FIX, not stop.
|
|
2403
2695
|
if (buildLike &&
|
|
2404
2696
|
!pentestLike &&
|
|
2405
2697
|
!pentestSession &&
|
|
2406
2698
|
sawFailedLocalHttpProbe &&
|
|
2407
2699
|
!sawLocalHttpProbe &&
|
|
2408
|
-
|
|
2700
|
+
budgetRemaining(recovery, "failedProbe") &&
|
|
2409
2701
|
cleaned.trim().length > 0) {
|
|
2410
|
-
|
|
2702
|
+
const action = recoveryForFailedProbe();
|
|
2703
|
+
consumeBudget(recovery, action.budgetKey);
|
|
2411
2704
|
pushAssistantHistory(assistantText.visible);
|
|
2412
|
-
messages.push({
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2705
|
+
messages.push({ role: "user", content: action.message });
|
|
2706
|
+
writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
|
|
2707
|
+
continue;
|
|
2708
|
+
}
|
|
2709
|
+
if ((pentestLike || pentestSession) &&
|
|
2710
|
+
budgetRemaining(recovery, "shallowPentest") &&
|
|
2711
|
+
looksLikeShallowPentestReport(cleaned, {
|
|
2712
|
+
productiveSteps,
|
|
2713
|
+
sawActiveTest: sawActivePentestTest,
|
|
2714
|
+
})) {
|
|
2715
|
+
const action = recoveryForShallowPentest();
|
|
2716
|
+
consumeBudget(recovery, action.budgetKey);
|
|
2717
|
+
pushAssistantHistory(assistantText.visible);
|
|
2718
|
+
messages.push({ role: "user", content: action.message });
|
|
2719
|
+
writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
|
|
2420
2720
|
continue;
|
|
2421
2721
|
}
|
|
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) {
|
|
2722
|
+
if (session.planApproved.value &&
|
|
2723
|
+
budgetRemaining(recovery, "prematureComplete")) {
|
|
2431
2724
|
const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
2432
2725
|
const unfinished = livePlan?.tasks.filter((t) => t.state === "pending" || t.state === "in_progress");
|
|
2433
2726
|
if (livePlan && unfinished && unfinished.length > 0) {
|
|
2434
|
-
prematureCompletionRetries += 1;
|
|
2435
2727
|
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,
|
|
2728
|
+
const action = recoveryForPrematureComplete({
|
|
2729
|
+
unfinished,
|
|
2730
|
+
next,
|
|
2731
|
+
pentest: livePlan.kind === "pentest" || pentestSession,
|
|
2732
|
+
errorFix: errorFixNarration,
|
|
2463
2733
|
});
|
|
2734
|
+
consumeBudget(recovery, action.budgetKey);
|
|
2735
|
+
writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
|
|
2736
|
+
pushAssistantHistory(assistantText.visible);
|
|
2737
|
+
messages.push({ role: "user", content: action.message });
|
|
2464
2738
|
continue;
|
|
2465
2739
|
}
|
|
2466
2740
|
}
|
|
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 = "";
|
|
2741
|
+
let outcomeStatus = "succeeded";
|
|
2742
|
+
const remainingCriteria = [];
|
|
2473
2743
|
if (session.planApproved.value) {
|
|
2474
2744
|
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);
|
|
2745
|
+
const unfinished = livePlan?.tasks.filter((t) => t.state === "pending" || t.state === "in_progress") ?? [];
|
|
2746
|
+
const failedTasks = livePlan?.tasks.filter((t) => t.state === "failed") ?? [];
|
|
2747
|
+
remainingCriteria.push(...unfinished.map((task) => `[${task.id}] ${task.title}`), ...failedTasks.map((task) => `[${task.id}] retry failed task: ${task.title}`));
|
|
2748
|
+
if (failedTasks.length > 0)
|
|
2749
|
+
outcomeStatus = "failed";
|
|
2750
|
+
else if (unfinished.length > 0)
|
|
2751
|
+
outcomeStatus = "partial";
|
|
2490
2752
|
}
|
|
2491
|
-
|
|
2492
|
-
|
|
2753
|
+
recordAnswerEvidence(outcomeState, cleaned);
|
|
2754
|
+
outcomeState.outcome.status = deriveOutcomeStatus(outcomeState.outcome, outcomeState.evidence);
|
|
2755
|
+
await saveOutcomeState(outcomeState);
|
|
2756
|
+
const unsupportedCriteria = outcomeState.outcome.criteria.filter((criterion) => criterion.required &&
|
|
2757
|
+
!validateCriterionEvidence(criterion, outcomeState.evidence).ok);
|
|
2758
|
+
if (unsupportedCriteria.length > 0 && outcomeStatus === "succeeded") {
|
|
2759
|
+
outcomeStatus = "partial";
|
|
2493
2760
|
}
|
|
2494
|
-
|
|
2761
|
+
remainingCriteria.push(...unsupportedCriteria
|
|
2762
|
+
.map((criterion) => criterion.statement)
|
|
2763
|
+
.filter((statement) => !remainingCriteria.includes(statement)));
|
|
2764
|
+
moveTurn("verifying", "evaluating current criterion-linked evidence");
|
|
2765
|
+
moveTurn(outcomeStatus, `turn completed with ${outcomeStatus} evidence status`);
|
|
2766
|
+
await auditLog("agent.final", {
|
|
2767
|
+
provider,
|
|
2768
|
+
model,
|
|
2769
|
+
steps: step + 1,
|
|
2770
|
+
outcomeStatus,
|
|
2771
|
+
remainingCriteria,
|
|
2772
|
+
});
|
|
2495
2773
|
lastAnswer = cleaned;
|
|
2496
|
-
return finishTurn(lastAnswer, step + 1
|
|
2774
|
+
return finishTurn(lastAnswer, step + 1, outcomeStatus, remainingCriteria, outcomeStatus === "failed"
|
|
2775
|
+
? "One or more required plan tasks failed."
|
|
2776
|
+
: outcomeStatus === "partial"
|
|
2777
|
+
? "Required outcome criteria remain unsupported by current evidence."
|
|
2778
|
+
: undefined);
|
|
2497
2779
|
}
|
|
2498
2780
|
// A valid primary tool call exists for this fresh model turn. Show any
|
|
2499
2781
|
// prose / thinking that preceded it, record the assistant message ONCE.
|
|
@@ -2538,11 +2820,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2538
2820
|
let toRun = bound;
|
|
2539
2821
|
let activeDeferredToolCalls = deferredToolCalls;
|
|
2540
2822
|
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
2823
|
const planCallIndex = bound.findIndex((b) => b.call.name === "plan.create");
|
|
2547
2824
|
if (planCallIndex > 0) {
|
|
2548
2825
|
const deferredCount = bound.length - planCallIndex;
|
|
@@ -2571,32 +2848,9 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2571
2848
|
`the follow-on call(s) were not. Wait for the plan to be reviewed, then proceed task by task.`,
|
|
2572
2849
|
});
|
|
2573
2850
|
}
|
|
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
|
-
}
|
|
2851
|
+
// Preserve model/document order. In particular, never move a later
|
|
2852
|
+
// in_progress transition ahead of the preceding work or done receipt;
|
|
2853
|
+
// doing so inverts dependency order and desynchronizes the task pane.
|
|
2600
2854
|
// Re-index toRun positions for UI callIds[] (0..n-1 this turn).
|
|
2601
2855
|
toRun = toRun.map((b, index) => ({ ...b, index }));
|
|
2602
2856
|
const allCalls = toRun.map((b) => b.call);
|
|
@@ -2609,16 +2863,11 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2609
2863
|
if (allCalls.length > 1) {
|
|
2610
2864
|
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
2865
|
}
|
|
2612
|
-
// Emit only the calls that will actually execute, after thinking
|
|
2613
|
-
// + assistant text so transcript order remains correct.
|
|
2614
2866
|
for (const deferred of activeDeferredToolCalls.slice(0, allCalls.length)) {
|
|
2615
2867
|
if (!deferred.call.name || deferred.call.name === "…")
|
|
2616
2868
|
continue;
|
|
2617
2869
|
writeToolCall(deferred.eventId, deferred.call, deferred.rendered);
|
|
2618
2870
|
}
|
|
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
2871
|
if (historyNativeCalls.length) {
|
|
2623
2872
|
appendAssistantWithTools(messages, beforeTool ?? "", historyNativeCalls);
|
|
2624
2873
|
}
|
|
@@ -2629,32 +2878,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2629
2878
|
.join("\n\n");
|
|
2630
2879
|
pushAssistantHistory(standardizedContent);
|
|
2631
2880
|
}
|
|
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
2881
|
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
2882
|
const isParallelSafe = (c) => {
|
|
2659
2883
|
if (c.name === "pentest.recon" ||
|
|
2660
2884
|
c.name === "net.context" ||
|
|
@@ -2675,19 +2899,10 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2675
2899
|
};
|
|
2676
2900
|
/** Tools whose failure must NOT cancel sibling calls in this turn. */
|
|
2677
2901
|
const shouldSoftFailTool = (name) => {
|
|
2678
|
-
if (name
|
|
2902
|
+
if (isBatchSoftFailTool(name))
|
|
2679
2903
|
return true;
|
|
2680
2904
|
if (BATCH_SAFE_TOOLS.has(name))
|
|
2681
2905
|
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
2906
|
return false;
|
|
2692
2907
|
};
|
|
2693
2908
|
// Recon waves often emit 6–10 lookups; 4 forced a second sequential wave.
|
|
@@ -2716,17 +2931,40 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2716
2931
|
truncatedToolRetries = 0;
|
|
2717
2932
|
malformedFenceRetries = 0;
|
|
2718
2933
|
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
2934
|
if (res.ok && isEvidenceWorkTool(res.call.name)) {
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2935
|
+
recovery.prematureComplete = 0;
|
|
2936
|
+
recovery.actionIntent = 0;
|
|
2937
|
+
recovery.errorFix = 0;
|
|
2725
2938
|
}
|
|
2726
2939
|
if (res.ok && res.call.name === "shell.start")
|
|
2727
2940
|
sawServerStart = true;
|
|
2728
|
-
if (res.ok && res.call.name === "shell.tail")
|
|
2941
|
+
if (res.ok && res.call.name === "shell.tail") {
|
|
2729
2942
|
sawServerTail = true;
|
|
2943
|
+
const tailOut = res.result.output ?? res.contextOutput ?? "";
|
|
2944
|
+
if (isServerReadyOutput(tailOut)) {
|
|
2945
|
+
sawServerStart = true;
|
|
2946
|
+
sawServerTail = true;
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
2949
|
+
if (res.ok &&
|
|
2950
|
+
res.call.name === "shell.exec" &&
|
|
2951
|
+
isPortListeningOutput(String(res.call.args.command ?? ""), res.result.output ?? res.contextOutput ?? "")) {
|
|
2952
|
+
sawServerStart = true;
|
|
2953
|
+
}
|
|
2954
|
+
if (res.ok &&
|
|
2955
|
+
(pentestLike || pentestSession) &&
|
|
2956
|
+
(res.call.name === "http.fetch" ||
|
|
2957
|
+
res.call.name === "shell.exec" ||
|
|
2958
|
+
res.call.name === "net.scan" ||
|
|
2959
|
+
res.call.name === "pentest.recon")) {
|
|
2960
|
+
const blob = `${res.call.name} ${JSON.stringify(res.call.args)}`;
|
|
2961
|
+
if (/\b(sqlmap|hydra|nikto|nuclei|ffuf|gobuster|exploit|payload|idor|xss|union\s+select)\b/i.test(blob) ||
|
|
2962
|
+
(res.call.name === "http.fetch" &&
|
|
2963
|
+
typeof res.call.args.method === "string" &&
|
|
2964
|
+
!/^get$/i.test(res.call.args.method))) {
|
|
2965
|
+
sawActivePentestTest = true;
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2730
2968
|
if (res.ok &&
|
|
2731
2969
|
((res.call.name === "http.fetch" &&
|
|
2732
2970
|
/^(?:https?:\/\/)?(?:localhost|127\.0\.0\.1|\[::1\])(?::|\/|$)/i.test(String(res.call.args.url ?? ""))) ||
|
|
@@ -2740,7 +2978,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2740
2978
|
else if (localHttpProbeIsSuccess(out)) {
|
|
2741
2979
|
sawLocalHttpProbe = true;
|
|
2742
2980
|
sawFailedLocalHttpProbe = false;
|
|
2743
|
-
|
|
2981
|
+
recovery.failedProbe = 0;
|
|
2744
2982
|
}
|
|
2745
2983
|
else if (res.call.name === "shell.exec" &&
|
|
2746
2984
|
!localHttpProbeIsFailure(out)) {
|
|
@@ -2775,8 +3013,13 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2775
3013
|
}
|
|
2776
3014
|
}
|
|
2777
3015
|
if (res.call.name === "plan.create" && res.ok) {
|
|
2778
|
-
awaitingPlanApproval = true;
|
|
2779
3016
|
sawPlanCreateOk = true;
|
|
3017
|
+
if (isPlanMode) {
|
|
3018
|
+
awaitingPlanApproval = true;
|
|
3019
|
+
}
|
|
3020
|
+
else {
|
|
3021
|
+
session.planApproved.value = true;
|
|
3022
|
+
}
|
|
2780
3023
|
}
|
|
2781
3024
|
if (res.lastAnswer === "Aborted.")
|
|
2782
3025
|
aborted = true;
|
|
@@ -2789,7 +3032,11 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2789
3032
|
};
|
|
2790
3033
|
const groups = groupToolCallsForExecution(allCalls, isParallelSafe, PARALLEL_LIMIT);
|
|
2791
3034
|
for (const group of groups) {
|
|
2792
|
-
if (aborted ||
|
|
3035
|
+
if (aborted ||
|
|
3036
|
+
blocked ||
|
|
3037
|
+
failed ||
|
|
3038
|
+
awaitingPlanApproval ||
|
|
3039
|
+
governorPauseReason)
|
|
2793
3040
|
break;
|
|
2794
3041
|
if (group.length === 1) {
|
|
2795
3042
|
const call = group[0];
|
|
@@ -2839,9 +3086,11 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2839
3086
|
? "Cancelled — earlier tool was blocked or declined."
|
|
2840
3087
|
: awaitingPlanApproval
|
|
2841
3088
|
? "Deferred — waiting for plan approval."
|
|
2842
|
-
:
|
|
2843
|
-
?
|
|
2844
|
-
:
|
|
3089
|
+
: governorPauseReason
|
|
3090
|
+
? `Deferred — progress governor paused execution: ${governorPauseReason}`
|
|
3091
|
+
: failed
|
|
3092
|
+
? "Cancelled — earlier tool in this batch failed."
|
|
3093
|
+
: "Cancelled — not executed.";
|
|
2845
3094
|
const result = {
|
|
2846
3095
|
ok: false,
|
|
2847
3096
|
output: reason,
|
|
@@ -2871,29 +3120,31 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2871
3120
|
}
|
|
2872
3121
|
fillMissingToolResults(messages, historyNativeCalls, "Cancelled — not executed this turn.");
|
|
2873
3122
|
}
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
// prevents calls accidentally batched after plan.create from executing
|
|
2878
|
-
// before /implement approval.
|
|
3123
|
+
if (deferredPostToolMessages.length > 0) {
|
|
3124
|
+
messages.push(...deferredPostToolMessages.splice(0));
|
|
3125
|
+
}
|
|
2879
3126
|
if (awaitingPlanApproval) {
|
|
2880
3127
|
pendingCalls = [];
|
|
2881
|
-
|
|
3128
|
+
outcomeState.outcome.status = "partial";
|
|
3129
|
+
await saveOutcomeState(outcomeState);
|
|
3130
|
+
moveTurn("partial", "draft plan awaits approval");
|
|
3131
|
+
return finishTurn("", productiveSteps, "partial", ["Approve or revise the draft plan before implementation."]);
|
|
2882
3132
|
}
|
|
2883
3133
|
if (aborted) {
|
|
2884
3134
|
lastAnswer = "Aborted.";
|
|
3135
|
+
outcomeState.outcome.status = "aborted";
|
|
3136
|
+
await saveOutcomeState(outcomeState);
|
|
3137
|
+
moveTurn("aborted", "turn aborted");
|
|
2885
3138
|
writeAbort();
|
|
2886
|
-
return finishTurn(lastAnswer, productiveSteps);
|
|
3139
|
+
return finishTurn(lastAnswer, productiveSteps, "aborted");
|
|
2887
3140
|
}
|
|
2888
3141
|
if (blocked && blockedResult) {
|
|
2889
3142
|
lastAnswer = blockedResult.lastAnswer || "Blocked or Cancelled.";
|
|
2890
|
-
|
|
3143
|
+
outcomeState.outcome.status = "blocked";
|
|
3144
|
+
await saveOutcomeState(outcomeState);
|
|
3145
|
+
moveTurn("blocked", lastAnswer);
|
|
3146
|
+
return finishTurn(lastAnswer, productiveSteps, "blocked");
|
|
2891
3147
|
}
|
|
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
3148
|
await maybeAutoCompact("post-tool-token-budget");
|
|
2898
3149
|
if (options.onMessages) {
|
|
2899
3150
|
try {
|
|
@@ -2905,16 +3156,20 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2905
3156
|
}
|
|
2906
3157
|
}
|
|
2907
3158
|
}
|
|
2908
|
-
// maxIterations ceiling reached (safety net — normally the step budget
|
|
2909
|
-
// gate with user confirmation handles stopping gracefully).
|
|
2910
3159
|
const richSummary = await buildRichStopSummary(messages, session, productiveSteps);
|
|
2911
|
-
writeAssistantMessage(richSummary);
|
|
2912
3160
|
lastAnswer = richSummary;
|
|
2913
|
-
|
|
3161
|
+
outcomeState.outcome.status = "paused_budget";
|
|
3162
|
+
await saveOutcomeState(outcomeState);
|
|
3163
|
+
moveTurn("paused_budget", "emergency iteration ceiling reached");
|
|
3164
|
+
return finishTurn(lastAnswer, productiveSteps, "paused_budget", ["Continue unfinished work in a subsequent turn."], "The emergency iteration ceiling was reached.");
|
|
2914
3165
|
}
|
|
2915
3166
|
catch (error) {
|
|
2916
3167
|
const isAbort = isAbortError(error, options.signal);
|
|
2917
3168
|
const msg = isAbort ? "Aborted." : `Error: ${error instanceof Error ? error.message : String(error)}`;
|
|
3169
|
+
if (isAbort) {
|
|
3170
|
+
writeAbort();
|
|
3171
|
+
return finishTurn(msg, 0, "aborted", [], "The turn was aborted.");
|
|
3172
|
+
}
|
|
2918
3173
|
if (options.onMessages) {
|
|
2919
3174
|
try {
|
|
2920
3175
|
options.onMessages(buildTurnHistory(liveMessages, msg));
|
|
@@ -2923,10 +3178,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2923
3178
|
// ignore
|
|
2924
3179
|
}
|
|
2925
3180
|
}
|
|
2926
|
-
if (isAbort) {
|
|
2927
|
-
writeAbort();
|
|
2928
|
-
return "Aborted.";
|
|
2929
|
-
}
|
|
2930
3181
|
emit({
|
|
2931
3182
|
type: "turn-error",
|
|
2932
3183
|
message: error instanceof Error ? error.message : String(error),
|
|
@@ -2934,4 +3185,8 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2934
3185
|
throw error;
|
|
2935
3186
|
}
|
|
2936
3187
|
}
|
|
3188
|
+
/** Compatibility boundary for callers that still consume rendered text. */
|
|
3189
|
+
export async function runAgentLoop(prompt, options = {}) {
|
|
3190
|
+
return renderTurnOutcome(await runAgentTurn(prompt, options));
|
|
3191
|
+
}
|
|
2937
3192
|
//# sourceMappingURL=runner.js.map
|