@pentoshi/clai 3.5.1 → 3.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- 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 +53 -24
- 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 -9
- package/dist/agent/loop-guard.js +117 -26
- 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 +34 -0
- package/dist/agent/plan-tool.js +594 -76
- package/dist/agent/plan-tool.js.map +1 -1
- package/dist/agent/project-root.d.ts +33 -0
- package/dist/agent/project-root.js +161 -0
- package/dist/agent/project-root.js.map +1 -0
- 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 +1915 -886
- 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 +173 -0
- package/dist/agent/task-evidence.js +802 -0
- package/dist/agent/task-evidence.js.map +1 -0
- 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 +19 -11
- package/dist/agent/tool-call-parser.js +96 -71
- package/dist/agent/tool-call-parser.js.map +1 -1
- package/dist/agent/tool-history.d.ts +34 -0
- package/dist/agent/tool-history.js +200 -0
- package/dist/agent/tool-history.js.map +1 -0
- 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 +75 -0
- package/dist/agent/workspace-orient.js +509 -0
- package/dist/agent/workspace-orient.js.map +1 -0
- 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 +60 -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/doctor.js +3 -0
- package/dist/commands/doctor.js.map +1 -1
- 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/anthropic-tools.d.ts +99 -0
- package/dist/llm/adapters/anthropic-tools.js +225 -0
- package/dist/llm/adapters/anthropic-tools.js.map +1 -0
- package/dist/llm/adapters/gemini-tools.d.ts +54 -0
- package/dist/llm/adapters/gemini-tools.js +139 -0
- package/dist/llm/adapters/gemini-tools.js.map +1 -0
- package/dist/llm/adapters/ollama-tools.d.ts +18 -0
- package/dist/llm/adapters/ollama-tools.js +58 -0
- package/dist/llm/adapters/ollama-tools.js.map +1 -0
- package/dist/llm/adapters/openai-tools.d.ts +39 -0
- package/dist/llm/adapters/openai-tools.js +71 -0
- package/dist/llm/adapters/openai-tools.js.map +1 -0
- package/dist/llm/agentrouter.js +23 -4
- package/dist/llm/agentrouter.js.map +1 -1
- package/dist/llm/anthropic.js +93 -92
- package/dist/llm/anthropic.js.map +1 -1
- package/dist/llm/aws-mantle.js +93 -56
- package/dist/llm/aws-mantle.js.map +1 -1
- package/dist/llm/bynara.js +23 -4
- package/dist/llm/bynara.js.map +1 -1
- package/dist/llm/capabilities.d.ts +13 -6
- package/dist/llm/capabilities.js +113 -13
- package/dist/llm/capabilities.js.map +1 -1
- package/dist/llm/gemini.js +63 -48
- package/dist/llm/gemini.js.map +1 -1
- package/dist/llm/groq.js +23 -10
- package/dist/llm/groq.js.map +1 -1
- package/dist/llm/http.d.ts +22 -34
- package/dist/llm/http.js +97 -69
- package/dist/llm/http.js.map +1 -1
- package/dist/llm/kimchi.js +23 -4
- package/dist/llm/kimchi.js.map +1 -1
- package/dist/llm/nvidia.js +23 -4
- package/dist/llm/nvidia.js.map +1 -1
- package/dist/llm/ollama.js +49 -31
- package/dist/llm/ollama.js.map +1 -1
- package/dist/llm/openai.js +23 -4
- package/dist/llm/openai.js.map +1 -1
- package/dist/llm/openrouter.js +23 -4
- package/dist/llm/openrouter.js.map +1 -1
- package/dist/llm/qwen-cloud.js +23 -4
- package/dist/llm/qwen-cloud.js.map +1 -1
- package/dist/llm/router.d.ts +0 -6
- package/dist/llm/router.js +48 -21
- package/dist/llm/router.js.map +1 -1
- package/dist/llm/tool-protocol.d.ts +67 -0
- package/dist/llm/tool-protocol.js +278 -0
- package/dist/llm/tool-protocol.js.map +1 -0
- 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 +71 -66
- package/dist/modes/ask.js.map +1 -1
- package/dist/os/cwd.d.ts +0 -21
- package/dist/os/cwd.js +0 -24
- package/dist/os/cwd.js.map +1 -1
- package/dist/os/pkgmgr.js +0 -6
- package/dist/os/pkgmgr.js.map +1 -1
- package/dist/prompts/index.d.ts +17 -21
- package/dist/prompts/index.js +131 -259
- package/dist/prompts/index.js.map +1 -1
- package/dist/prompts/system.agent.md +242 -0
- package/dist/prompts/system.ask.md +56 -0
- package/dist/repl/prompt-line.js +0 -36
- package/dist/repl/prompt-line.js.map +1 -1
- package/dist/repl/slash-commands.d.ts +0 -9
- package/dist/repl/slash-commands.js +2 -23
- package/dist/repl/slash-commands.js.map +1 -1
- package/dist/repl.d.ts +5 -0
- package/dist/repl.js +176 -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/config.d.ts +7 -0
- package/dist/store/config.js +1 -0
- package/dist/store/config.js.map +1 -1
- 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 +59 -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/capabilities.d.ts +2 -0
- package/dist/tools/capabilities.js +101 -6
- package/dist/tools/capabilities.js.map +1 -1
- package/dist/tools/command-intent.d.ts +1 -0
- package/dist/tools/command-intent.js +31 -0
- package/dist/tools/command-intent.js.map +1 -1
- package/dist/tools/definitions.d.ts +20 -0
- package/dist/tools/definitions.js +578 -0
- package/dist/tools/definitions.js.map +1 -0
- 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 +280 -80
- 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/net-ping-sweep.js +69 -16
- package/dist/tools/net-ping-sweep.js.map +1 -1
- package/dist/tools/nmap-runner.d.ts +19 -7
- package/dist/tools/nmap-runner.js +182 -80
- 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 +272 -63
- 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/validate.d.ts +11 -0
- package/dist/tools/validate.js +53 -0
- package/dist/tools/validate.js.map +1 -1
- package/dist/tools/web/search.js +48 -4
- package/dist/tools/web/search.js.map +1 -1
- package/dist/tui-v2/app/App.js +25 -39
- package/dist/tui-v2/app/App.js.map +1 -1
- package/dist/tui-v2/app/command-handlers.js +20 -1
- package/dist/tui-v2/app/command-handlers.js.map +1 -1
- package/dist/tui-v2/app/commands/picker-commands.js +3 -0
- package/dist/tui-v2/app/commands/picker-commands.js.map +1 -1
- package/dist/tui-v2/app/commands/session-commands.js +5 -0
- package/dist/tui-v2/app/commands/session-commands.js.map +1 -1
- package/dist/tui-v2/app/plan-lifecycle.d.ts +25 -3
- package/dist/tui-v2/app/plan-lifecycle.js +87 -24
- package/dist/tui-v2/app/plan-lifecycle.js.map +1 -1
- package/dist/tui-v2/bootstrap/patch-opentui-text.d.ts +6 -0
- package/dist/tui-v2/bootstrap/patch-opentui-text.js +53 -0
- package/dist/tui-v2/bootstrap/patch-opentui-text.js.map +1 -0
- package/dist/tui-v2/bootstrap/start-tui-v2.js +7 -0
- package/dist/tui-v2/bootstrap/start-tui-v2.js.map +1 -1
- package/dist/tui-v2/components/jobs/jobs-panel.js +23 -14
- package/dist/tui-v2/components/jobs/jobs-panel.js.map +1 -1
- package/dist/tui-v2/components/modal/confirm-modal.d.ts +1 -1
- package/dist/tui-v2/components/modal/confirm-modal.js +18 -7
- package/dist/tui-v2/components/modal/confirm-modal.js.map +1 -1
- package/dist/tui-v2/components/overlay/overlay-host.js +1 -1
- package/dist/tui-v2/components/overlay/overlay-host.js.map +1 -1
- package/dist/tui-v2/components/pager/pager.d.ts +6 -0
- package/dist/tui-v2/components/pager/pager.js +313 -67
- package/dist/tui-v2/components/pager/pager.js.map +1 -1
- package/dist/tui-v2/components/status/status-line.d.ts +9 -0
- package/dist/tui-v2/components/status/status-line.js +17 -3
- package/dist/tui-v2/components/status/status-line.js.map +1 -1
- package/dist/tui-v2/components/transcript/assistant-message.js +1 -1
- package/dist/tui-v2/components/transcript/assistant-message.js.map +1 -1
- package/dist/tui-v2/components/transcript/compacted-row.js +1 -1
- package/dist/tui-v2/components/transcript/compacted-row.js.map +1 -1
- package/dist/tui-v2/components/transcript/tool-card.d.ts +9 -1
- package/dist/tui-v2/components/transcript/tool-card.js +115 -10
- 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 +11 -1
- package/dist/tui-v2/rendering/tool-presenter.js +101 -5
- 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 +75 -1
- package/dist/ui/ansi-box.d.ts +2 -0
- package/dist/ui/ansi-box.js +8 -1
- package/dist/ui/ansi-box.js.map +1 -1
- package/dist/ui/mentions.d.ts +6 -0
- package/dist/ui/mentions.js +50 -12
- package/dist/ui/mentions.js.map +1 -1
- package/dist/ui/plan-pane.js +1 -1
- package/dist/ui/plan-pane.js.map +1 -1
- package/package.json +16 -11
package/dist/agent/runner.js
CHANGED
|
@@ -1,117 +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";
|
|
4
|
+
import { resolveToolDialect } from "../llm/capabilities.js";
|
|
5
|
+
import { syntheticToolCallId, isTextOnlyModel, fromWireName, } from "../llm/tool-protocol.js";
|
|
6
|
+
import { sanitizeAssistantText } from "../ui/ansi-box.js";
|
|
5
7
|
import { randomUUID } from "node:crypto";
|
|
6
8
|
import { jobManager } from "../tools/jobs.js";
|
|
7
|
-
import { renderAgentSystemPrompt, renderCompactAgentSystemPrompt, scratchDirFor, } from "../prompts/index.js";
|
|
9
|
+
import { agentModeDirective, planModeDirective, renderAgentSystemPrompt, renderCompactAgentSystemPrompt, scratchDirFor, toolNudge, } from "../prompts/index.js";
|
|
8
10
|
import { getConfig } from "../store/config.js";
|
|
9
11
|
import { groqInputTokenBudget } from "../llm/groq.js";
|
|
10
12
|
import { classifyToolCall, isPentestToolCall, scopeHint, scopeTargetForToolCall, } from "../safety/classifier.js";
|
|
11
13
|
import { availableToolNames, normalizeToolCall, runToolCall, BATCH_SAFE_TOOLS, } from "../tools/registry.js";
|
|
12
|
-
import {
|
|
14
|
+
import { getToolDefinitions, getCompactToolDefinitions, PLAN_TOOL_NAMES, } from "../tools/definitions.js";
|
|
15
|
+
import { appendAssistantWithTools, appendToolResult, assertValidToolProtocol, fillMissingToolResults, } from "./tool-history.js";
|
|
13
16
|
import { formatViewportHint, registerViewport } from "../ui/output-pane.js";
|
|
14
|
-
import { compactMessagesWithSummary,
|
|
17
|
+
import { compactMessagesWithSummary, estimateMessagesTokens, AUTO_COMPACT_TOKEN_BUDGET, COMPACTION_MEMORY_PREFIX, } from "./context-manager.js";
|
|
15
18
|
import { auditLog } from "../store/logs.js";
|
|
16
19
|
import { loadProjectContext } from "../store/project.js";
|
|
17
|
-
import { loadScope, isScopeActive
|
|
20
|
+
import { loadScope, isScopeActive } from "../store/scope.js";
|
|
18
21
|
import { ensureProviderConfigured } from "../commands/providers.js";
|
|
19
22
|
import { createThinkingStreamParser, rememberThinkingFromText, renderThinkingSummary, stripThinking, } from "../ui/thinking.js";
|
|
20
23
|
import { renderMarkdown, indentAndWrapText } from "../ui/markdown.js";
|
|
21
24
|
import { startThinkingSpinner } from "../ui/spinner.js";
|
|
22
25
|
import { safeCwd } from "../os/cwd.js";
|
|
23
|
-
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";
|
|
24
30
|
import { LoopGuard } from "./loop-guard.js";
|
|
25
|
-
import { loadPlan } from "../store/plan.js";
|
|
26
|
-
import {
|
|
27
|
-
import { stripSentinelTokens, parseToolCall, recognizeBareToolJson, looksLikeTruncatedToolCall, salvageTruncatedWrite, countToolFences, parseAllToolCalls, groupToolCallsForExecution, buildTurnHistory, collapseRepeatedText, textBeforeToolCall, formatToolArgs, looksLikePentestTask, looksLikeBuildTask, looksLikeInformationalQuery, looksLikeIdleOrSocialPrompt, looksLikeActionNarration, looksLikeWebActionNarration, looksLikePlanNarration, requiresFreshWebSearch, freshnessGuardMessage, buildWorkflowDirective, pentestWorkflowDirective, pentestNoLocalServerDirective, shouldDimToolChatter, looksLikePromptLeak, } from "./tool-call-parser.js";
|
|
28
|
-
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";
|
|
29
35
|
import { saveToolOutput, summarizeOutput, formatToolContext, } from "./tool-output-formatting.js";
|
|
30
|
-
import { planContextMessage, handlePlanTool, } from "./plan-tool.js";
|
|
36
|
+
import { renderPlanForTerminal, planContextMessage, handlePlanTool, resolvePlanTaskId, } from "./plan-tool.js";
|
|
37
|
+
import { absorbLooseWorkIntoLedger, applyDestinationCwd, canMarkTaskDone, hasLocalRuntimeProof, hasRemoteWorkProof, isBatchSoftFailTool, isDevServerCall, isEvidenceWorkTool, isFeatureImplementationCall, isPackageInstallCommand, isPlanPreflightTool, isPortListeningOutput, isReadOnlyReconTool, isRemoteActiveTestCall, isRemoteObservationTask, isRemoteReconToolCall, isRuntimeObservationTask, isScaffoldCreateCommand, isServerReadyOutput, ledgerFromTaskEvidence, pickPendingTaskForToolCall, recordTaskWorkSuccess, resolveUserDestinationHint, taskEvidenceFromLedger, toolStallBudgetMs, userAskedForFeatureApp, } from "./task-evidence.js";
|
|
38
|
+
import { buildSessionStateBlock, inferNextHint, upsertSessionStateMessage, } from "./session-state.js";
|
|
39
|
+
import { detectPackageManager } from "./workspace-orient.js";
|
|
40
|
+
import { budgetRemaining, consumeBudget, createRecoveryBudgets, freestyleClaimsAppReady, looksLikeShallowPentestReport, recoveryForErrorDiagnosis, recoveryForFailedProbe, recoveryForFreshness, recoveryForMissingFeature, recoveryForMissingPlan, recoveryForNarration, recoveryForPrematureComplete, recoveryForRuntimeVerify, recoveryForShallowPentest, } from "./must-continue.js";
|
|
41
|
+
import { scopeContextMessage } from "./scope-context.js";
|
|
42
|
+
import { EngagementPolicyEngine, actionFromUrl, engagementActionForToolCall, evaluateEngagementAction, } from "../safety/engagement-policy.js";
|
|
43
|
+
import { patchPlanMeta } from "../store/plan.js";
|
|
44
|
+
import { extractProjectRootFromPlan, extractProjectRootFromScaffold, extractProjectRootFromText, getActiveProjectRoot, setActiveProjectRootIfValid, } from "./project-root.js";
|
|
45
|
+
import { buildWorkspaceOrientation, discoverImmediateProjectRoots, guessProjectFolderName, isBareParentDirectory, isScaffoldCancelledOutput, scaffoldLooksMaterialized, scaffoldTargetConflictMessage, resolveScaffoldTargetPath, } from "./workspace-orient.js";
|
|
31
46
|
import { inquirerConfirmPort, restoreInteractiveStdin, ensurePentestAuthorization, confirmToolExecution, } from "./confirm-port.js";
|
|
32
47
|
import { buildRichStopSummary } from "./stop-summary.js";
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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";
|
|
37
54
|
export * from "./tool-call-parser.js";
|
|
38
55
|
export { createSessionPolicy, isPreApprovalAllowedTool, isPlanApprovedByStatus, planHasOpenWork, shouldEnableImageOcr, } from "./session-policy.js";
|
|
39
56
|
export function styleToolChatter(call, text) {
|
|
40
57
|
return shouldDimToolChatter(call) ? chalk.dim(text) : text;
|
|
41
58
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
*/
|
|
48
|
-
const SCRATCH_WRITABLE_TOOLS = new Set([
|
|
49
|
-
"fs.write",
|
|
50
|
-
"fs.writeMany",
|
|
51
|
-
"fs.edit",
|
|
52
|
-
"fs.replaceLines",
|
|
53
|
-
"fs.append",
|
|
54
|
-
"fs.delete",
|
|
55
|
-
]);
|
|
56
|
-
/**
|
|
57
|
-
* Expand `~` the same way `src/tools/fs.ts` does so callers can compare an
|
|
58
|
-
* already-expanded scratch path against paths supplied by the model.
|
|
59
|
-
*/
|
|
60
|
-
function expandHomeLocal(path) {
|
|
61
|
-
if (path === "~")
|
|
62
|
-
return homedir();
|
|
63
|
-
if (path.startsWith("~/") || path.startsWith("~\\")) {
|
|
64
|
-
return resolve(homedir(), path.slice(2));
|
|
65
|
-
}
|
|
66
|
-
return path;
|
|
67
|
-
}
|
|
68
|
-
/**
|
|
69
|
-
* Extract the target path(s) a write-shaped tool call would touch. Returns
|
|
70
|
-
* an empty array when the call has no resolvable path (so the caller can
|
|
71
|
-
* treat it as NOT scratch-only and fall through to the normal gates).
|
|
72
|
-
*/
|
|
73
|
-
function scratchWriteTargetPaths(call) {
|
|
74
|
-
if (call.name === "fs.writeMany") {
|
|
75
|
-
const files = call.args.files;
|
|
76
|
-
if (!Array.isArray(files))
|
|
77
|
-
return [];
|
|
78
|
-
const paths = [];
|
|
79
|
-
for (const entry of files) {
|
|
80
|
-
if (entry && typeof entry === "object" && "path" in entry) {
|
|
81
|
-
const p = entry.path;
|
|
82
|
-
if (typeof p === "string" && p.length > 0)
|
|
83
|
-
paths.push(p);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
return paths;
|
|
87
|
-
}
|
|
88
|
-
const pathArg = call.args.path;
|
|
89
|
-
if (typeof pathArg !== "string" || pathArg.length === 0)
|
|
90
|
-
return [];
|
|
91
|
-
return [pathArg];
|
|
92
|
-
}
|
|
93
|
-
/**
|
|
94
|
-
* True iff every target path this call would write is inside the resolved
|
|
95
|
-
* scratch directory. A path is considered inside when its `path.relative`
|
|
96
|
-
* against the scratch root is empty (the scratch root itself) or does not
|
|
97
|
-
* start with `..` (no parent traversal). Calls without a recognizable
|
|
98
|
-
* target path return false so they fall through to the normal gates.
|
|
99
|
-
*/
|
|
100
|
-
function isScratchOnlyWrite(call, scratchDir) {
|
|
101
|
-
if (!SCRATCH_WRITABLE_TOOLS.has(call.name))
|
|
102
|
-
return false;
|
|
103
|
-
const paths = scratchWriteTargetPaths(call);
|
|
104
|
-
if (paths.length === 0)
|
|
105
|
-
return false;
|
|
106
|
-
const resolvedScratch = resolve(scratchDir);
|
|
107
|
-
return paths.every((raw) => {
|
|
108
|
-
const expanded = expandHomeLocal(raw);
|
|
109
|
-
const resolved = resolve(expanded);
|
|
110
|
-
const rel = relative(resolvedScratch, resolved);
|
|
111
|
-
return rel === "" || (!rel.startsWith("..") && rel !== "..");
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
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";
|
|
115
64
|
const writesDirectly = !options.onEvent;
|
|
116
65
|
const emit = (event) => options.onEvent?.(event);
|
|
117
66
|
// Whether the CURRENT model iteration has already committed its visible
|
|
@@ -150,13 +99,14 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
150
99
|
const writeAssistantMessage = (text) => {
|
|
151
100
|
// Never surface an empty message: the reducer drops it and a direct
|
|
152
101
|
// stdout writer would print a stray blank line.
|
|
153
|
-
|
|
102
|
+
const clean = sanitizeAssistantText(text);
|
|
103
|
+
if (!clean.trim())
|
|
154
104
|
return;
|
|
155
105
|
visibleCommitted = true;
|
|
156
|
-
emit({ type: "assistant-message", text });
|
|
157
|
-
const rendered = renderMarkdown(
|
|
106
|
+
emit({ type: "assistant-message", text: clean });
|
|
107
|
+
const rendered = renderMarkdown(clean);
|
|
158
108
|
if (writesDirectly) {
|
|
159
|
-
process.stdout.write(
|
|
109
|
+
process.stdout.write(clean.endsWith("\n") ? rendered : `${rendered}\n`);
|
|
160
110
|
}
|
|
161
111
|
};
|
|
162
112
|
const writeThinkingBlock = (content) => {
|
|
@@ -212,6 +162,9 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
212
162
|
if (artifactPath) {
|
|
213
163
|
event.artifactPath = artifactPath;
|
|
214
164
|
}
|
|
165
|
+
if (result.fileChanges && result.fileChanges.length > 0) {
|
|
166
|
+
event.fileChanges = result.fileChanges;
|
|
167
|
+
}
|
|
215
168
|
emit(event);
|
|
216
169
|
};
|
|
217
170
|
/** Strip a known prefix from a string, returning the remainder unchanged. */
|
|
@@ -234,20 +187,36 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
234
187
|
// conversation back to the caller. Assigned once `messages` is built below;
|
|
235
188
|
// all later mutations are in-place so this reference stays current.
|
|
236
189
|
let liveMessages = [];
|
|
237
|
-
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);
|
|
238
200
|
if (options.onMessages) {
|
|
239
201
|
try {
|
|
240
|
-
options.onMessages(buildTurnHistory(liveMessages,
|
|
202
|
+
options.onMessages(buildTurnHistory(liveMessages, rendered));
|
|
241
203
|
}
|
|
242
204
|
catch {
|
|
243
205
|
// Persisting history must never break the turn.
|
|
244
206
|
}
|
|
245
207
|
}
|
|
246
|
-
|
|
247
|
-
|
|
208
|
+
options.onOutcome?.(outcome);
|
|
209
|
+
emit({ type: "turn-end", outcome, finalAnswer: rendered, steps });
|
|
210
|
+
return outcome;
|
|
248
211
|
};
|
|
249
212
|
try {
|
|
250
|
-
emit({
|
|
213
|
+
emit({
|
|
214
|
+
type: "turn-start",
|
|
215
|
+
prompt,
|
|
216
|
+
...(options.displayPrompt !== undefined
|
|
217
|
+
? { displayPrompt: options.displayPrompt }
|
|
218
|
+
: {}),
|
|
219
|
+
});
|
|
251
220
|
const config = getConfig();
|
|
252
221
|
const maxSteps = options.maxSteps ?? 70;
|
|
253
222
|
const confirmPort = options.confirm ?? inquirerConfirmPort;
|
|
@@ -265,6 +234,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
265
234
|
// agent burns its turn searching the date instead of writing files.
|
|
266
235
|
const buildLikeTurn = looksLikeBuildTask(prompt, options.history);
|
|
267
236
|
const pentestLikeTurn = looksLikePentestTask(prompt, options.history);
|
|
237
|
+
const narrowNmapOperation = isNarrowExplicitNmapOperation(prompt);
|
|
268
238
|
// A plain informational follow-up ("what do you know so far", "summarize
|
|
269
239
|
// the findings") in a resumed/continuing build or pentest session must
|
|
270
240
|
// NOT inherit that session's "must act" behavior — it should be answered
|
|
@@ -290,17 +260,20 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
290
260
|
// provider's 413 as a context-window failure after the fact.
|
|
291
261
|
const inputTokenBudget = provider === "groq" ? groqInputTokenBudget(model) : undefined;
|
|
292
262
|
const useCompactSystemPrompt = inputTokenBudget !== undefined;
|
|
293
|
-
const
|
|
294
|
-
(
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
263
|
+
const resolveNativeTools = (p, m) => {
|
|
264
|
+
const dialect = resolveToolDialect(p, m, config.toolCalling);
|
|
265
|
+
return { dialect, native: dialect !== "none" };
|
|
266
|
+
};
|
|
267
|
+
let { dialect: toolDialect, native: nativeToolsActive } = resolveNativeTools(provider, model);
|
|
268
|
+
const selectToolDefs = (native, compact) => {
|
|
269
|
+
if (!native)
|
|
270
|
+
return undefined;
|
|
271
|
+
const base = compact
|
|
272
|
+
? getCompactToolDefinitions()
|
|
273
|
+
: getToolDefinitions();
|
|
274
|
+
const allow = new Set([...toolNames, ...PLAN_TOOL_NAMES]);
|
|
275
|
+
return base.filter((d) => allow.has(d.name));
|
|
276
|
+
};
|
|
304
277
|
let lastAnswer = "";
|
|
305
278
|
const session = options.session ?? createSessionPolicy();
|
|
306
279
|
// Active plan context
|
|
@@ -308,7 +281,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
308
281
|
// context. When the user has approved it (via /implement) we instruct the
|
|
309
282
|
// agent to execute task by task; otherwise the agent should refine/wait.
|
|
310
283
|
const activePlan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
311
|
-
if (activePlan) {
|
|
284
|
+
if (activePlan && isPlanApprovedByStatus(activePlan.status)) {
|
|
312
285
|
// session.planApproved is in-memory only (never persisted), so a
|
|
313
286
|
// resumed session (via /history) or a fresh SessionPolicy after
|
|
314
287
|
// context compaction always starts it back at false — even when the
|
|
@@ -316,30 +289,128 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
316
289
|
// completed via /implement. Re-derive the flag from the plan's status
|
|
317
290
|
// on every load so resuming a session never re-blocks tool calls
|
|
318
291
|
// behind a stale "awaiting approval" gate for a plan that already ran.
|
|
319
|
-
|
|
320
|
-
|
|
292
|
+
session.planApproved.value = true;
|
|
293
|
+
}
|
|
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
|
+
: [];
|
|
309
|
+
// Sticky project root so relative fs paths never hit the agent package.
|
|
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;
|
|
321
318
|
}
|
|
319
|
+
}
|
|
320
|
+
if (!pinnedProject && discoveredProjects.length === 1) {
|
|
321
|
+
setActiveProjectRootIfValid(discoveredProjects[0]);
|
|
322
|
+
}
|
|
323
|
+
const buildSystemContent = (native) => {
|
|
324
|
+
const sections = [
|
|
325
|
+
(useCompactSystemPrompt
|
|
326
|
+
? renderCompactAgentSystemPrompt
|
|
327
|
+
: renderAgentSystemPrompt)(toolNames.join(", "), {
|
|
328
|
+
nativeTools: native,
|
|
329
|
+
}),
|
|
330
|
+
];
|
|
331
|
+
if (projectContext) {
|
|
332
|
+
sections.push(`Project context from .clai/context.md:\n${projectContext}`);
|
|
333
|
+
}
|
|
334
|
+
const projectRoot = getActiveProjectRoot();
|
|
335
|
+
if (projectRoot) {
|
|
336
|
+
sections.push(`ACTIVE PROJECT ROOT: ${projectRoot}\n` +
|
|
337
|
+
`All relative paths (./src/…, manifests, configs) resolve under this directory — NOT the agent process cwd. ` +
|
|
338
|
+
`Prefer absolute paths under this root. shell cwd for install / run / build must be this root ` +
|
|
339
|
+
`(or its parent when creating a NEW named subfolder with a scaffolder). ` +
|
|
340
|
+
`Never write user app source into the agent package tree.`);
|
|
341
|
+
}
|
|
342
|
+
else if (destinationHint) {
|
|
343
|
+
sections.push(`USER DESTINATION: create or continue work under "${destinationHint}" (parent folder). ` +
|
|
344
|
+
`Pick or detect a project subfolder; do not scaffold into the agent working tree unless the user asked for that.`);
|
|
345
|
+
}
|
|
346
|
+
// Stack-agnostic PWD / existing-project snapshot so weak models cannot
|
|
347
|
+
// skip explore and re-scaffold into non-empty dirs.
|
|
348
|
+
if (buildLikeTurn &&
|
|
349
|
+
!informationalQuery &&
|
|
350
|
+
!idleOrSocialPrompt) {
|
|
351
|
+
const guessedName = guessProjectFolderName([prompt, activePlan?.goal, activePlan?.detail, activePlan?.tasks.map((t) => t.title).join(" ")]
|
|
352
|
+
.filter(Boolean)
|
|
353
|
+
.join("\n"));
|
|
354
|
+
const extraPaths = [];
|
|
355
|
+
if (destinationHint && guessedName) {
|
|
356
|
+
extraPaths.push(join(destinationHint, guessedName));
|
|
357
|
+
}
|
|
358
|
+
const fromText = extractProjectRootFromPlan(activePlan) ??
|
|
359
|
+
extractProjectRootFromText(prompt);
|
|
360
|
+
if (fromText)
|
|
361
|
+
extraPaths.push(fromText);
|
|
362
|
+
const orientInput = {
|
|
363
|
+
cwd: safeCwd(),
|
|
364
|
+
extraPaths,
|
|
365
|
+
};
|
|
366
|
+
if (destinationHint)
|
|
367
|
+
orientInput.destinationHint = destinationHint;
|
|
368
|
+
const candidate = getActiveProjectRoot() ?? fromText;
|
|
369
|
+
if (candidate)
|
|
370
|
+
orientInput.candidateProject = candidate;
|
|
371
|
+
sections.push(buildWorkspaceOrientation(orientInput));
|
|
372
|
+
}
|
|
373
|
+
if (freshWebSearchRequired) {
|
|
374
|
+
sections.push(freshnessGuardMessage());
|
|
375
|
+
}
|
|
376
|
+
return sections.join("\n\n");
|
|
377
|
+
};
|
|
378
|
+
const systemSections = [buildSystemContent(nativeToolsActive)];
|
|
379
|
+
if (activePlan) {
|
|
322
380
|
systemSections.push(planContextMessage(activePlan, session.planApproved.value));
|
|
323
381
|
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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.
|
|
330
389
|
if (buildLikeTurn &&
|
|
331
|
-
!
|
|
390
|
+
!informationalQuery &&
|
|
391
|
+
!idleOrSocialPrompt &&
|
|
392
|
+
!isPlanMode) {
|
|
393
|
+
systemSections.push(buildWorkflowDirective());
|
|
394
|
+
}
|
|
395
|
+
if (isPlanMode &&
|
|
396
|
+
buildLikeTurn &&
|
|
332
397
|
!informationalQuery &&
|
|
333
398
|
!idleOrSocialPrompt) {
|
|
334
399
|
systemSections.push(buildWorkflowDirective());
|
|
335
400
|
}
|
|
336
|
-
//
|
|
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
|
|
337
410
|
// build: recon first, then a plan built from real findings, then
|
|
338
|
-
// incremental task additions as new attack surface appears.
|
|
339
|
-
// directive is only injected before a plan exists; once a plan is in
|
|
340
|
-
// place (or being refined), the ACTIVE PLAN block already carries the
|
|
341
|
-
// current task state and recon-vs-active-tool guidance.
|
|
411
|
+
// incremental task additions as new attack surface appears.
|
|
342
412
|
if (pentestLikeTurn &&
|
|
413
|
+
!narrowNmapOperation &&
|
|
343
414
|
!activePlan &&
|
|
344
415
|
!informationalQuery &&
|
|
345
416
|
!idleOrSocialPrompt) {
|
|
@@ -355,19 +426,90 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
355
426
|
if (pentestSession && !idleOrSocialPrompt) {
|
|
356
427
|
systemSections.push(pentestNoLocalServerDirective());
|
|
357
428
|
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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);
|
|
371
513
|
const userMessage = { role: "user", content: prompt };
|
|
372
514
|
if (options.images && options.images.length > 0) {
|
|
373
515
|
userMessage.images = options.images;
|
|
@@ -378,6 +520,8 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
378
520
|
userMessage,
|
|
379
521
|
];
|
|
380
522
|
liveMessages = messages;
|
|
523
|
+
/** Assigned after session flags exist — see below. */
|
|
524
|
+
let refreshSessionState = () => undefined;
|
|
381
525
|
const recoveryUserMessage = (content) => {
|
|
382
526
|
const message = { role: "user", content };
|
|
383
527
|
if (options.images && options.images.length > 0) {
|
|
@@ -388,19 +532,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
388
532
|
}
|
|
389
533
|
return message;
|
|
390
534
|
};
|
|
391
|
-
// Every provider must receive a syntactically valid assistant turn between
|
|
392
|
-
// the original user prompt and a recovery nudge. In particular, Gemini
|
|
393
|
-
// serializes an empty assistant message as an empty `model` text part,
|
|
394
|
-
// which can cause every retry to return empty as well. Keep hidden thinking
|
|
395
|
-
// out of history, but record a compact non-empty sentinel when there was no
|
|
396
|
-
// visible output.
|
|
397
|
-
//
|
|
398
|
-
// The Ink reducer intentionally keeps streamed text transient until it
|
|
399
|
-
// receives `assistant-message`, because a stream may turn out to be raw
|
|
400
|
-
// tool JSON. Recovery paths used to add prose to model history and retry
|
|
401
|
-
// without that event, so the user could watch a valid-looking response
|
|
402
|
-
// vanish when the retry emitted its first tool call. Surface only prose
|
|
403
|
-
// that is safe to render; malformed/bare tool payloads remain hidden.
|
|
404
535
|
const recoveryProse = (content) => {
|
|
405
536
|
const text = textBeforeToolCall(stripSentinelTokens(content)).trim();
|
|
406
537
|
if (!text ||
|
|
@@ -411,109 +542,242 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
411
542
|
return text;
|
|
412
543
|
};
|
|
413
544
|
const pushAssistantHistory = (content) => {
|
|
414
|
-
|
|
415
|
-
// text isn't wiped by the next tool-call/turn event. Skip when this
|
|
416
|
-
// iteration already surfaced its prose (the normal tool path commits
|
|
417
|
-
// `beforeTool` itself) so the same text is never rendered twice.
|
|
545
|
+
const cleaned = sanitizeAssistantText(content);
|
|
418
546
|
if (!visibleCommitted) {
|
|
419
|
-
const prose = recoveryProse(
|
|
547
|
+
const prose = recoveryProse(cleaned);
|
|
420
548
|
if (prose)
|
|
421
549
|
writeAssistantMessage(prose);
|
|
422
550
|
}
|
|
423
551
|
messages.push({
|
|
424
552
|
role: "assistant",
|
|
425
|
-
content:
|
|
426
|
-
?
|
|
553
|
+
content: cleaned.trim()
|
|
554
|
+
? cleaned
|
|
427
555
|
: "[No visible assistant response was produced.]",
|
|
428
556
|
});
|
|
429
557
|
};
|
|
430
|
-
// Track recent tool calls to detect models stuck in a loop calling the
|
|
431
|
-
// same tool with the same arguments over and over (e.g. pentest.recon
|
|
432
|
-
// called 3× on the same target without summarizing).
|
|
433
558
|
const loopGuard = new LoopGuard();
|
|
559
|
+
const engagementPolicy = new EngagementPolicyEngine();
|
|
434
560
|
// Track consecutive thinking-only responses so we can nudge the model
|
|
435
561
|
// to actually act instead of silently returning an empty answer.
|
|
436
562
|
let emptyVisibleRetries = 0;
|
|
437
|
-
// A model that spent an entire completion in hidden reasoning gets one
|
|
438
|
-
// visible-output retry with provider thinking disabled. This is per-turn
|
|
439
|
-
// only: a subsequent successful response restores the configured setting.
|
|
440
563
|
let retryWithoutThinking = false;
|
|
441
564
|
// Track tool calls truncated by the token limit so we can ask the model
|
|
442
565
|
// to retry in smaller pieces instead of leaking broken JSON as an answer.
|
|
443
566
|
let truncatedToolRetries = 0;
|
|
444
|
-
// Track bare-args JSON tool calls (missing the {name,args} wrapper / fence)
|
|
445
|
-
// so we can nudge the model to re-emit a proper fenced call a few times
|
|
446
|
-
// before giving up, instead of leaking the JSON as a final answer.
|
|
447
567
|
let bareToolJsonRetries = 0;
|
|
448
568
|
// Track a ```tool fence that is present but whose JSON could not be parsed
|
|
449
569
|
// (e.g. malformed extra/missing braces that are NOT simple truncation). We
|
|
450
570
|
// retry instead of leaking the raw block as the final answer.
|
|
451
571
|
let malformedFenceRetries = 0;
|
|
452
|
-
// For volatile live-info prompts, make one corrective pass if a model
|
|
453
|
-
// ignores the freshness guard and tries to answer from stale memory.
|
|
454
572
|
let sawFreshWebSearch = false;
|
|
455
|
-
|
|
456
|
-
// Guard against a model that declares an approved plan "complete" while
|
|
457
|
-
// tasks are still pending and it never ran the work. We nudge it back to
|
|
458
|
-
// executing the next task a bounded number of times before giving up.
|
|
459
|
-
let prematureCompletionRetries = 0;
|
|
460
|
-
let runtimeVerificationRetries = 0;
|
|
573
|
+
const recovery = createRecoveryBudgets();
|
|
461
574
|
let sawServerStart = false;
|
|
575
|
+
let sawPlanCreateOk = false;
|
|
462
576
|
let sawServerTail = false;
|
|
463
577
|
let sawLocalHttpProbe = false;
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
let
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
578
|
+
let sawFailedLocalHttpProbe = false;
|
|
579
|
+
let sawLocalAppMaterialWork = false;
|
|
580
|
+
let sawScaffoldOk = false;
|
|
581
|
+
let sawFeatureImplWrite = false;
|
|
582
|
+
let sawActivePentestTest = false;
|
|
583
|
+
const featureAppAsk = userAskedForFeatureApp(prompt);
|
|
584
|
+
let taskWorkLedger = null;
|
|
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);
|
|
481
730
|
let pendingCalls = [];
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
// final answer) the agent may take. Recovery iterations — nudging a model
|
|
485
|
-
// that only produced thinking, asking it to re-emit a malformed tool call,
|
|
486
|
-
// a freshness retry, or a loop-guard summary — do NOT consume this budget;
|
|
487
|
-
// they get a separate hard ceiling so a wedged model can't spin forever.
|
|
488
|
-
//
|
|
489
|
-
// Complexity is a coarse signal from prompt length, but short follow-up
|
|
490
|
-
// prompts ("do it", "build fully on your own", "app is not complete") in
|
|
491
|
-
// the middle of a multi-file build must NOT be capped like a one-shot
|
|
492
|
-
// lookup — that was the reason a React scaffold stopped half-built after
|
|
493
|
-
// 10 steps. We bump the budget when the prompt (or recent history) looks
|
|
494
|
-
// like a build/scaffold or a continuation of one.
|
|
731
|
+
let narrowNmapDispatchCount = 0;
|
|
732
|
+
const deferredPostToolMessages = [];
|
|
495
733
|
const analysis = analyzeTask(prompt);
|
|
496
734
|
const hasHistory = (options.history?.length ?? 0) > 0;
|
|
497
735
|
const buildLike = buildLikeTurn;
|
|
498
736
|
const pentestLike = looksLikePentestTask(prompt, options.history);
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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));
|
|
517
781
|
let productiveSteps = 0;
|
|
518
782
|
let step = -1;
|
|
519
783
|
let nextToolEventId = 0;
|
|
@@ -532,10 +796,21 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
532
796
|
},
|
|
533
797
|
};
|
|
534
798
|
async function executeSingleTool(rawCall, toolEventId, parentSignal) {
|
|
535
|
-
// Resolved once per call so the scratch-only exemption can compare the
|
|
536
|
-
// model-supplied paths against the canonical per-project scratch root.
|
|
537
799
|
const scratchDir = scratchDirFor(safeCwd());
|
|
538
800
|
let call = normalizeToolCall(rawCall);
|
|
801
|
+
let dispatchedTaskId;
|
|
802
|
+
let engagementLease;
|
|
803
|
+
let engagementGraph;
|
|
804
|
+
let engagementRecord;
|
|
805
|
+
if (call.args?.__nativeParseError) {
|
|
806
|
+
const raw = String(call.args._raw ?? "").slice(0, 200);
|
|
807
|
+
const reason = "Tool call arguments were not valid JSON (truncated or malformed). " +
|
|
808
|
+
"Retry with smaller content, or use fs.writeMany / fs.append continuation. " +
|
|
809
|
+
(raw ? `Partial: ${raw}` : "");
|
|
810
|
+
const result = { ok: false, output: reason, exitCode: 1 };
|
|
811
|
+
emitToolResult(toolEventId, result, reason);
|
|
812
|
+
return { ok: false, call, result, contextOutput: reason };
|
|
813
|
+
}
|
|
539
814
|
if (call.name === "image.ocr" && !imageOcrEnabled) {
|
|
540
815
|
writeNotice("info", "skipped OCR because the original image is attached to the vision model", chalk.dim(" ℹ skipped OCR — inspecting the attached image directly\n"));
|
|
541
816
|
const recoveryText = "The original image is attached to this message and you can inspect it directly. " +
|
|
@@ -543,14 +818,41 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
543
818
|
const result = { ok: true, output: recoveryText };
|
|
544
819
|
return { ok: true, call, result, contextOutput: recoveryText };
|
|
545
820
|
}
|
|
546
|
-
|
|
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
|
+
});
|
|
547
853
|
if (loopCheck.block) {
|
|
548
|
-
const
|
|
549
|
-
call.name
|
|
550
|
-
call.name === "fs.edit" ||
|
|
551
|
-
call.name === "fs.replaceLines" ||
|
|
552
|
-
call.name === "fs.append";
|
|
553
|
-
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.`;
|
|
554
856
|
writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
|
|
555
857
|
const result = { ok: false, output: reason, exitCode: 1 };
|
|
556
858
|
return {
|
|
@@ -558,19 +860,123 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
558
860
|
call,
|
|
559
861
|
result,
|
|
560
862
|
contextOutput: reason,
|
|
561
|
-
blockOrCancel: true,
|
|
562
863
|
};
|
|
563
864
|
}
|
|
564
865
|
if (loopCheck.reason) {
|
|
565
866
|
writeNotice("info", loopCheck.reason, chalk.dim(` ℹ ${loopCheck.reason}\n`));
|
|
566
867
|
}
|
|
567
868
|
if (call.name === "plan.create" || call.name === "task.update") {
|
|
869
|
+
// Evidence gate: refuse done until at least one successful work tool
|
|
870
|
+
// ran under this task (model must see results and be satisfied).
|
|
871
|
+
if (call.name === "task.update") {
|
|
872
|
+
const stateRaw = typeof call.args.state === "string" ? call.args.state : "";
|
|
873
|
+
const taskIdRaw = typeof call.args.taskId === "string"
|
|
874
|
+
? call.args.taskId
|
|
875
|
+
: typeof call.args.id === "string"
|
|
876
|
+
? call.args.id
|
|
877
|
+
: "";
|
|
878
|
+
if (stateRaw === "done" && taskIdRaw) {
|
|
879
|
+
const live = await loadPlan(session.sessionId).catch(() => undefined);
|
|
880
|
+
const resolved = (live ? resolvePlanTaskId(live, taskIdRaw) : undefined) ??
|
|
881
|
+
taskIdRaw;
|
|
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
|
+
};
|
|
908
|
+
if (!gate.ok) {
|
|
909
|
+
writeNotice("warn", gate.reason, chalk.yellow(` ⚠ ${gate.reason}\n`));
|
|
910
|
+
if (!alreadyPrintedIds.has(toolEventId)) {
|
|
911
|
+
const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
|
|
912
|
+
chalk.gray(` ${formatToolArgs(call)}`);
|
|
913
|
+
writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
|
|
914
|
+
alreadyPrintedIds.add(toolEventId);
|
|
915
|
+
}
|
|
916
|
+
const result = {
|
|
917
|
+
ok: false,
|
|
918
|
+
output: gate.reason,
|
|
919
|
+
exitCode: 1,
|
|
920
|
+
};
|
|
921
|
+
emitToolResult(toolEventId, result, gate.reason);
|
|
922
|
+
writeToolOutput(toolEventId, "failed\n", chalk.red(" ✗") + "\n");
|
|
923
|
+
return {
|
|
924
|
+
ok: false,
|
|
925
|
+
call,
|
|
926
|
+
result,
|
|
927
|
+
contextOutput: gate.reason,
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
}
|
|
568
932
|
const planResult = await handlePlanTool(call, session, {
|
|
569
933
|
loopGuard,
|
|
570
934
|
step,
|
|
935
|
+
autoApprove: !isPlanMode,
|
|
571
936
|
});
|
|
572
937
|
if (planResult.handled) {
|
|
573
938
|
loopGuard.recordAttempt(step, call.name, call.args, planResult.ok, 0);
|
|
939
|
+
if (planResult.ok && call.name === "task.update") {
|
|
940
|
+
const stateRaw = typeof call.args.state === "string" ? call.args.state : "";
|
|
941
|
+
const taskIdRaw = typeof call.args.taskId === "string"
|
|
942
|
+
? call.args.taskId
|
|
943
|
+
: typeof call.args.id === "string"
|
|
944
|
+
? call.args.id
|
|
945
|
+
: "";
|
|
946
|
+
const resolved = (planResult.plan
|
|
947
|
+
? resolvePlanTaskId(planResult.plan, taskIdRaw)
|
|
948
|
+
: undefined) ?? taskIdRaw;
|
|
949
|
+
if (stateRaw === "in_progress" && resolved) {
|
|
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
|
+
}
|
|
963
|
+
}
|
|
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
|
+
}
|
|
973
|
+
taskWorkLedger = null;
|
|
974
|
+
}
|
|
975
|
+
else if ((stateRaw === "failed" || stateRaw === "skipped") &&
|
|
976
|
+
taskWorkLedger?.taskId === resolved) {
|
|
977
|
+
taskWorkLedger = null;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
574
980
|
if (!alreadyPrintedIds.has(toolEventId)) {
|
|
575
981
|
const toolCallLine = chalk.cyan(` ▶ ${call.name}`) + chalk.gray(` ${formatToolArgs(call)}`);
|
|
576
982
|
writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
|
|
@@ -578,6 +984,10 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
578
984
|
}
|
|
579
985
|
if (planResult.plan) {
|
|
580
986
|
writePlanUpdate(planResult.plan, planResult.display);
|
|
987
|
+
// Refresh sticky root only if path already exists (not bare Desktop).
|
|
988
|
+
const root = extractProjectRootFromPlan(planResult.plan);
|
|
989
|
+
if (root)
|
|
990
|
+
setActiveProjectRootIfValid(root);
|
|
581
991
|
}
|
|
582
992
|
const result = { ok: planResult.ok, output: planResult.modelNote };
|
|
583
993
|
emitToolResult(toolEventId, result, planResult.modelNote);
|
|
@@ -598,12 +1008,48 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
598
1008
|
decision,
|
|
599
1009
|
scope: isScopeActive(scope) ? (scope.name ?? "(unnamed)") : "(none)",
|
|
600
1010
|
});
|
|
1011
|
+
const livePlanForPreGate = await loadPlan(session.sessionId).catch(() => undefined);
|
|
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 &&
|
|
1017
|
+
!isScratchOnlyWrite(call, scratchDir)) {
|
|
1018
|
+
const cmd = typeof call.args.command === "string" ? call.args.command : "";
|
|
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.`;
|
|
1027
|
+
writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
|
|
1028
|
+
if (!alreadyPrintedIds.has(toolEventId)) {
|
|
1029
|
+
const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
|
|
1030
|
+
chalk.gray(` ${formatToolArgs(call)}`);
|
|
1031
|
+
writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
|
|
1032
|
+
alreadyPrintedIds.add(toolEventId);
|
|
1033
|
+
}
|
|
1034
|
+
const result = { ok: false, output: reason, exitCode: 1 };
|
|
1035
|
+
emitToolResult(toolEventId, result, reason);
|
|
1036
|
+
writeToolOutput(toolEventId, "failed\n", chalk.red(" ✗") + "\n");
|
|
1037
|
+
return {
|
|
1038
|
+
ok: false,
|
|
1039
|
+
call,
|
|
1040
|
+
result,
|
|
1041
|
+
contextOutput: reason,
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
601
1045
|
const isMutatingAction = (decision.level === "confirm" || decision.level === "block") &&
|
|
602
1046
|
!isPreApprovalAllowedTool(call.name) &&
|
|
603
1047
|
!isScratchOnlyWrite(call, scratchDir);
|
|
604
1048
|
if (isMutatingAction) {
|
|
605
|
-
|
|
606
|
-
|
|
1049
|
+
const planNow = livePlanForPreGate ??
|
|
1050
|
+
(await loadPlan(session.sessionId).catch(() => undefined));
|
|
1051
|
+
if (planNow && !session.planApproved.value) {
|
|
1052
|
+
const reason = `plan awaiting approval — ${call.name} is blocked until the plan is accepted (/implement or Accept)`;
|
|
607
1053
|
writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
|
|
608
1054
|
const result = { ok: false, output: reason, exitCode: 1 };
|
|
609
1055
|
return {
|
|
@@ -615,38 +1061,80 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
615
1061
|
};
|
|
616
1062
|
}
|
|
617
1063
|
}
|
|
618
|
-
// Task-scoped execution gate
|
|
619
|
-
// Once a plan is approved, every non-plan tool call must run while
|
|
620
|
-
// exactly one task is "in_progress". This stops a model from batching
|
|
621
|
-
// tool calls for many/all tasks in one turn and only touching task
|
|
622
|
-
// state at the very end (or never) — the failure mode where a model
|
|
623
|
-
// claimed most tasks "done" in prose without ever recording it in the
|
|
624
|
-
// plan. Multiple tool calls per task are still fine; they just must be
|
|
625
|
-
// bracketed by task.update in_progress → (work) → task.update done.
|
|
626
1064
|
if (session.planApproved.value) {
|
|
627
1065
|
const livePlanForGate = await loadPlan(session.sessionId).catch(() => undefined);
|
|
628
1066
|
if (livePlanForGate) {
|
|
629
1067
|
const unfinished = livePlanForGate.tasks.some((t) => t.state === "pending" || t.state === "in_progress");
|
|
630
1068
|
const inProgress = livePlanForGate.tasks.find((t) => t.state === "in_progress");
|
|
631
1069
|
if (unfinished && !inProgress) {
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
1070
|
+
// tool.check / fs.list preflight: allow without auto-opening a task
|
|
1071
|
+
// (auto-start on preflight made models skip task.update and confused scope).
|
|
1072
|
+
const skipTaskGate = isPlanPreflightTool(call.name) ||
|
|
1073
|
+
(livePlanForGate.kind === "pentest" &&
|
|
1074
|
+
isReadOnlyReconTool(call.name));
|
|
1075
|
+
if (skipTaskGate) {
|
|
1076
|
+
// fall through
|
|
1077
|
+
}
|
|
1078
|
+
else {
|
|
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];
|
|
1083
|
+
if (nextPending) {
|
|
1084
|
+
markTask(livePlanForGate, nextPending.id, "in_progress");
|
|
1085
|
+
if (livePlanForGate.status === "draft" ||
|
|
1086
|
+
livePlanForGate.status === "approved") {
|
|
1087
|
+
livePlanForGate.status = "in_progress";
|
|
1088
|
+
}
|
|
1089
|
+
await savePlan(livePlanForGate).catch(() => undefined);
|
|
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
|
+
}
|
|
1096
|
+
writePlanUpdate(livePlanForGate, renderPlanForTerminal(livePlanForGate) + "\n");
|
|
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`));
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
647
1100
|
}
|
|
648
1101
|
}
|
|
649
1102
|
}
|
|
1103
|
+
call = applyDestinationCwd(call, destinationHint ?? getActiveProjectRoot());
|
|
1104
|
+
// Soft preflight: refuse scaffold into an existing non-empty project
|
|
1105
|
+
// (avoids endless "Operation cancelled" retries across all stacks).
|
|
1106
|
+
if ((call.name === "shell.exec" || call.name === "shell.start") &&
|
|
1107
|
+
typeof call.args.command === "string" &&
|
|
1108
|
+
isScaffoldCreateCommand(call.args.command)) {
|
|
1109
|
+
const cwdArg = typeof call.args.cwd === "string" ? call.args.cwd : undefined;
|
|
1110
|
+
const conflict = scaffoldTargetConflictMessage(call.args.command, cwdArg);
|
|
1111
|
+
if (conflict) {
|
|
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`));
|
|
1121
|
+
if (!alreadyPrintedIds.has(toolEventId)) {
|
|
1122
|
+
const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
|
|
1123
|
+
chalk.gray(` ${formatToolArgs(call)}`);
|
|
1124
|
+
writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
|
|
1125
|
+
alreadyPrintedIds.add(toolEventId);
|
|
1126
|
+
}
|
|
1127
|
+
const result = { ok: true, output: message, exitCode: 0 };
|
|
1128
|
+
emitToolResult(toolEventId, result, message);
|
|
1129
|
+
writeToolOutput(toolEventId, "ok\n", chalk.green(" ✓") + "\n");
|
|
1130
|
+
return {
|
|
1131
|
+
ok: true,
|
|
1132
|
+
call,
|
|
1133
|
+
result,
|
|
1134
|
+
contextOutput: message,
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
650
1138
|
if (call.name === "web.search") {
|
|
651
1139
|
sawFreshWebSearch = true;
|
|
652
1140
|
}
|
|
@@ -656,20 +1144,49 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
656
1144
|
alreadyPrintedIds.add(toolEventId);
|
|
657
1145
|
}
|
|
658
1146
|
const scopeTarget = scopeTargetForToolCall(call);
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
+
}
|
|
662
1185
|
}
|
|
663
1186
|
if (decision.level === "block") {
|
|
664
1187
|
writeToolBlocked(toolEventId, call.name, decision.reason, chalk.red(` ✗ blocked: ${decision.reason}`) + "\n");
|
|
665
1188
|
const message = `Blocked: ${call.name} — ${decision.reason}`;
|
|
666
1189
|
const result = { ok: false, output: message, exitCode: 1 };
|
|
667
|
-
// Safety classifier blocks are recoverable model mistakes: feed the
|
|
668
|
-
// failed result back to the model and let it choose a safer next step
|
|
669
|
-
// instead of ending the entire agent turn. Hard workflow gates
|
|
670
|
-
// (plan not approved, task not in_progress, auth declined, aborts)
|
|
671
|
-
// still use blockOrCancel above/below because continuing would violate
|
|
672
|
-
// user/session control rather than merely correcting a bad command.
|
|
673
1190
|
return {
|
|
674
1191
|
ok: false,
|
|
675
1192
|
call,
|
|
@@ -703,29 +1220,43 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
703
1220
|
if (needsPentestAuth) {
|
|
704
1221
|
pentestJustConfirmed = true;
|
|
705
1222
|
}
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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;
|
|
724
1254
|
}
|
|
725
1255
|
}
|
|
726
1256
|
}
|
|
727
|
-
if (decision.level === "confirm"
|
|
728
|
-
|
|
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);
|
|
729
1260
|
restoreInteractiveStdin();
|
|
730
1261
|
if (!ok) {
|
|
731
1262
|
const lastAnswer = "Cancelled.";
|
|
@@ -746,24 +1277,40 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
746
1277
|
releasePrompt();
|
|
747
1278
|
}
|
|
748
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
|
+
}
|
|
749
1303
|
options.onToolStart?.(call);
|
|
750
1304
|
// Card was "queued" since writeToolCall; flip to running only when work starts.
|
|
751
1305
|
emit({ type: "tool-start", id: toolEventId });
|
|
752
1306
|
writeStatus(call.name, chalk.dim(` → ${call.name}\n`));
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
looksInteractiveStdin(call.args.command)) ||
|
|
756
|
-
call.name === "net.scan" ||
|
|
757
|
-
call.name === "pentest.recon";
|
|
758
|
-
if (interactiveCommand && process.stdin.isTTY) {
|
|
759
|
-
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"));
|
|
760
|
-
}
|
|
1307
|
+
// Elevation uses the secure secret modal (TUI) or is refused — never
|
|
1308
|
+
// a raw TTY "Password:" that freezes the UI. No misleading notice.
|
|
761
1309
|
const toolAc = new AbortController();
|
|
762
1310
|
const onParentAbort = () => toolAc.abort();
|
|
763
1311
|
parentSignal.addEventListener("abort", onParentAbort);
|
|
764
1312
|
let result;
|
|
765
1313
|
let liveBytes = 0;
|
|
766
|
-
const shouldDimLive = !interactiveCommand;
|
|
767
1314
|
// Stream every live byte — never drop mid-run. After the tool finishes we
|
|
768
1315
|
// still replace the spool with the authoritative full `result.output`.
|
|
769
1316
|
const printLive = (chunk) => {
|
|
@@ -776,22 +1323,34 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
776
1323
|
liveBytes += chunk.length;
|
|
777
1324
|
const indented = chunk.replace(/\r/g, "").replace(/\n(?!$)/g, "\n ");
|
|
778
1325
|
const body = indented.startsWith("\n") ? indented : ` ${indented}`;
|
|
779
|
-
writeToolOutput(toolEventId, chunk,
|
|
1326
|
+
writeToolOutput(toolEventId, chunk, chalk.dim(body));
|
|
780
1327
|
};
|
|
781
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
|
+
});
|
|
782
1337
|
const backgroundJob = {
|
|
783
1338
|
id: jobId,
|
|
784
1339
|
command: `${call.name} ${formatToolArgs(call)}`,
|
|
1340
|
+
commandDisplay: `${call.name} ${formatToolArgs(call)}`,
|
|
785
1341
|
cwd: safeCwd(),
|
|
786
1342
|
status: "running",
|
|
787
1343
|
startedAt: new Date().toISOString(),
|
|
788
1344
|
artifactPath: "",
|
|
1345
|
+
stdoutArtifact: "",
|
|
1346
|
+
stderrArtifact: "",
|
|
1347
|
+
artifacts: { stdout: emptyJobArtifact(), stderr: emptyJobArtifact() },
|
|
1348
|
+
redactionProfile: "provider-secrets-v1",
|
|
1349
|
+
ownerSessionId: session.sessionId,
|
|
789
1350
|
};
|
|
790
1351
|
jobManager.registerJob(jobId, backgroundJob, toolAc);
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
// stalled operation is cancelled.
|
|
794
|
-
const TOOL_STALL_ABORT_MS = 60_000; // 1 minute
|
|
1352
|
+
const TOOL_STALL_ABORT_MS = toolStallBudgetMs(call);
|
|
1353
|
+
const stallSecs = Math.round(TOOL_STALL_ABORT_MS / 1000);
|
|
795
1354
|
let stallTimer;
|
|
796
1355
|
let stalledByWatchdog = false;
|
|
797
1356
|
const resetStallTimer = () => {
|
|
@@ -800,7 +1359,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
800
1359
|
stallTimer = setTimeout(() => {
|
|
801
1360
|
if (!toolAc.signal.aborted) {
|
|
802
1361
|
stalledByWatchdog = true;
|
|
803
|
-
writeNotice("warn", `${call.name} has been running for
|
|
1362
|
+
writeNotice("warn", `${call.name} has been running for >${stallSecs}s without output — cancelling stalled tool`, chalk.yellow(` ⏳ ${call.name} stalled for >${stallSecs}s without output — cancelling\n`));
|
|
804
1363
|
toolAc.abort();
|
|
805
1364
|
}
|
|
806
1365
|
}, TOOL_STALL_ABORT_MS);
|
|
@@ -818,6 +1377,33 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
818
1377
|
},
|
|
819
1378
|
confirmed: true,
|
|
820
1379
|
userPrompt: prompt,
|
|
1380
|
+
...(engagementAction && scope
|
|
1381
|
+
? {
|
|
1382
|
+
engagementAuthorization: {
|
|
1383
|
+
target: engagementDecision?.normalizedTarget || engagementAction.target,
|
|
1384
|
+
...(scope.expiresAt ? { expiresAt: scope.expiresAt } : {}),
|
|
1385
|
+
},
|
|
1386
|
+
authorizeNetworkHop: async (url, resolvedAddresses) => {
|
|
1387
|
+
const hop = actionFromUrl({
|
|
1388
|
+
url,
|
|
1389
|
+
method: engagementAction.method,
|
|
1390
|
+
phase: engagementAction.phase,
|
|
1391
|
+
capability: engagementAction.capability,
|
|
1392
|
+
resolvedAddresses,
|
|
1393
|
+
});
|
|
1394
|
+
const hopDecision = evaluateEngagementAction(scope, hop);
|
|
1395
|
+
await auditLog("engagement.policy.hop", {
|
|
1396
|
+
...(engagementGraph ? { engagementId: engagementGraph.id } : {}),
|
|
1397
|
+
...(engagementRecord ? { actionId: engagementRecord.id } : {}),
|
|
1398
|
+
url,
|
|
1399
|
+
resolvedAddresses,
|
|
1400
|
+
allowed: hopDecision.allowed,
|
|
1401
|
+
reason: hopDecision.reason,
|
|
1402
|
+
});
|
|
1403
|
+
return { allowed: hopDecision.allowed, reason: hopDecision.reason };
|
|
1404
|
+
},
|
|
1405
|
+
}
|
|
1406
|
+
: {}),
|
|
821
1407
|
});
|
|
822
1408
|
if (liveBytes > 0) {
|
|
823
1409
|
writeToolOutput(toolEventId, "\n", "\n");
|
|
@@ -827,10 +1413,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
827
1413
|
catch (toolError) {
|
|
828
1414
|
jobManager.updateJobStatus(jobId, "failed", 1);
|
|
829
1415
|
if (isAbortError(toolError, toolAc.signal)) {
|
|
830
|
-
// Only the parent signal represents a user/session cancellation.
|
|
831
|
-
// A watchdog abort is a local tool timeout; treating it as a global
|
|
832
|
-
// abort used to end the entire agent turn and strand sibling recon
|
|
833
|
-
// calls in an incomplete state.
|
|
834
1416
|
if (parentSignal.aborted) {
|
|
835
1417
|
writeAbort();
|
|
836
1418
|
return {
|
|
@@ -857,8 +1439,85 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
857
1439
|
finally {
|
|
858
1440
|
if (stallTimer)
|
|
859
1441
|
clearTimeout(stallTimer);
|
|
1442
|
+
engagementLease?.release();
|
|
860
1443
|
parentSignal.removeEventListener("abort", onParentAbort);
|
|
861
1444
|
}
|
|
1445
|
+
if ((call.name === "shell.exec" || call.name === "shell.start") &&
|
|
1446
|
+
typeof call.args.command === "string" &&
|
|
1447
|
+
isScaffoldCreateCommand(call.args.command)) {
|
|
1448
|
+
const cmd = call.args.command;
|
|
1449
|
+
const cwdArg = typeof call.args.cwd === "string" ? call.args.cwd : undefined;
|
|
1450
|
+
const out = result.output ?? "";
|
|
1451
|
+
// Prefer path reported by the scaffolder (handles quoted-cd mis-parse leftovers).
|
|
1452
|
+
const fromOutput = out.match(/Scaffolding project in\s+([^\n]+?)\s*\.{0,3}\s*$/im)?.[1]?.trim().replace(/['"]/g, "");
|
|
1453
|
+
const fromScaffold = (fromOutput && fromOutput.startsWith("/")
|
|
1454
|
+
? fromOutput
|
|
1455
|
+
: undefined) ??
|
|
1456
|
+
extractProjectRootFromScaffold(cmd, cwdArg);
|
|
1457
|
+
const cancelled = isScaffoldCancelledOutput(out);
|
|
1458
|
+
let materialized = scaffoldLooksMaterialized(fromScaffold);
|
|
1459
|
+
// One re-check: create-vite can report success before FS snapshot is visible.
|
|
1460
|
+
if (!materialized && fromScaffold) {
|
|
1461
|
+
materialized = scaffoldLooksMaterialized(fromScaffold);
|
|
1462
|
+
}
|
|
1463
|
+
const abortedMid = !result.ok &&
|
|
1464
|
+
(result.exitCode === 124 ||
|
|
1465
|
+
result.exitCode === 130 ||
|
|
1466
|
+
/timed out|aborted|Command aborted/i.test(out));
|
|
1467
|
+
const resumableMaterialized = Boolean(fromScaffold && materialized && (cancelled || abortedMid || !result.ok));
|
|
1468
|
+
if (resumableMaterialized && fromScaffold) {
|
|
1469
|
+
setActiveProjectRootIfValid(fromScaffold, { force: true });
|
|
1470
|
+
await persistProjectRootOnPlan(fromScaffold);
|
|
1471
|
+
result = {
|
|
1472
|
+
...result,
|
|
1473
|
+
ok: true,
|
|
1474
|
+
exitCode: 0,
|
|
1475
|
+
output: out +
|
|
1476
|
+
(out.endsWith("\n") ? "" : "\n") +
|
|
1477
|
+
`The scaffold reported ${cancelled ? "cancellation/refusal" : "interruption"}, but a usable project tree already exists at ${fromScaffold} ` +
|
|
1478
|
+
`(package/manifest present). Treat this as resumable: do NOT re-run the scaffolder. ` +
|
|
1479
|
+
`Inspect the existing files, finish any missing install, implement the requested feature, then run/verify.`,
|
|
1480
|
+
};
|
|
1481
|
+
writeNotice("info", `project root → ${fromScaffold} (existing materialized scaffold — continue)`, chalk.dim(` ℹ existing scaffold at ${fromScaffold} — continue, do not re-create\n`));
|
|
1482
|
+
}
|
|
1483
|
+
else if (result.ok && cancelled && !materialized) {
|
|
1484
|
+
result = {
|
|
1485
|
+
ok: false,
|
|
1486
|
+
output: out +
|
|
1487
|
+
(out.endsWith("\n") ? "" : "\n") +
|
|
1488
|
+
`Scaffold FAILED: tool reported cancel/refuse. ` +
|
|
1489
|
+
(fromScaffold ? `Expected project at ${fromScaffold}. ` : "") +
|
|
1490
|
+
`If the folder already exists, CONTINUE it (do not re-scaffold). Otherwise use a new empty name or hand-write a minimal tree.`,
|
|
1491
|
+
exitCode: result.exitCode && result.exitCode !== 0 ? result.exitCode : 1,
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
else if (result.ok && !materialized) {
|
|
1495
|
+
// Soft warn only when we truly see no tree — do not flip ok if output
|
|
1496
|
+
// clearly scaffolded (path may still resolve on next tool).
|
|
1497
|
+
const claimedScaffold = /Scaffolding project in\b/i.test(out);
|
|
1498
|
+
if (!claimedScaffold) {
|
|
1499
|
+
result = {
|
|
1500
|
+
ok: false,
|
|
1501
|
+
output: out +
|
|
1502
|
+
(out.endsWith("\n") ? "" : "\n") +
|
|
1503
|
+
`Scaffold FAILED: target project tree was not created. ` +
|
|
1504
|
+
(fromScaffold ? `Expected project at ${fromScaffold}. ` : "") +
|
|
1505
|
+
`If the folder already exists, CONTINUE it (do not re-scaffold). Otherwise use a new empty name or hand-write a minimal tree.`,
|
|
1506
|
+
exitCode: result.exitCode && result.exitCode !== 0 ? result.exitCode : 1,
|
|
1507
|
+
};
|
|
1508
|
+
}
|
|
1509
|
+
else if (fromScaffold) {
|
|
1510
|
+
setActiveProjectRootIfValid(fromScaffold, { force: true });
|
|
1511
|
+
await persistProjectRootOnPlan(fromScaffold);
|
|
1512
|
+
writeNotice("info", `project root → ${fromScaffold} (scaffold output claimed success — continue)`, chalk.dim(` ℹ project root set to ${fromScaffold}\n`));
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
else if (result.ok && fromScaffold && materialized) {
|
|
1516
|
+
setActiveProjectRootIfValid(fromScaffold, { force: true });
|
|
1517
|
+
await persistProjectRootOnPlan(fromScaffold);
|
|
1518
|
+
writeNotice("info", `project root → ${fromScaffold}`, chalk.dim(` ℹ project root set to ${fromScaffold}\n`));
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
862
1521
|
const output = result.output.trim();
|
|
863
1522
|
// Always keep a full on-disk copy of tool output (any size) so the
|
|
864
1523
|
// pager never depends on a truncated in-memory preview.
|
|
@@ -884,15 +1543,177 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
884
1543
|
exitCode: result.exitCode,
|
|
885
1544
|
output: result.output.slice(0, 4_000),
|
|
886
1545
|
});
|
|
1546
|
+
if (engagementGraph && engagementRecord) {
|
|
1547
|
+
if (result.backgroundJob) {
|
|
1548
|
+
const checkpointInput = {
|
|
1549
|
+
jobId: result.backgroundJob.id,
|
|
1550
|
+
status: result.backgroundJob.status,
|
|
1551
|
+
artifactPath: result.backgroundJob.artifactPath,
|
|
1552
|
+
offset: result.backgroundJob.nextOffset ?? 0,
|
|
1553
|
+
observation: result.output.slice(0, 16_000),
|
|
1554
|
+
};
|
|
1555
|
+
const reconciled = reconcileEngagementJob(engagementGraph, checkpointInput);
|
|
1556
|
+
if (!reconciled || reconciled.actionId !== engagementRecord.id) {
|
|
1557
|
+
recordEngagementCheckpoint(engagementGraph, {
|
|
1558
|
+
actionId: engagementRecord.id,
|
|
1559
|
+
...checkpointInput,
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
else {
|
|
1564
|
+
finishEngagementAction(engagementGraph, engagementRecord.id, {
|
|
1565
|
+
ok: result.ok,
|
|
1566
|
+
observation: result.output.slice(0, 16_000),
|
|
1567
|
+
...(savedOutputPath ? { artifactPath: savedOutputPath } : {}),
|
|
1568
|
+
scannerLead: call.name === "net.scan" || call.name.startsWith("pentest."),
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
await saveEngagement(engagementGraph);
|
|
1572
|
+
}
|
|
1573
|
+
const newEvidence = recordToolEvidence(outcomeState, {
|
|
1574
|
+
tool: call.name,
|
|
1575
|
+
callId: toolEventId,
|
|
1576
|
+
ok: result.ok,
|
|
1577
|
+
output: result.output,
|
|
1578
|
+
...(savedOutputPath ? { artifact: savedOutputPath } : {}),
|
|
1579
|
+
...(dispatchedTaskId ? { taskId: dispatchedTaskId } : {}),
|
|
1580
|
+
args: call.args,
|
|
1581
|
+
});
|
|
1582
|
+
let hypothesisDelta = 0;
|
|
1583
|
+
if (!result.ok) {
|
|
1584
|
+
const before = outcomeState.failedHypotheses.length;
|
|
1585
|
+
recordFailedHypothesis(outcomeState, {
|
|
1586
|
+
signature: `${call.name}:${result.exitCode ?? 1}`,
|
|
1587
|
+
premise: `${call.name} with ${JSON.stringify(call.args).slice(0, 1_000)}`,
|
|
1588
|
+
});
|
|
1589
|
+
hypothesisDelta = outcomeState.failedHypotheses.length - before;
|
|
1590
|
+
retryDependenciesChanged = false;
|
|
1591
|
+
retryEnvironmentChanged = false;
|
|
1592
|
+
moveTurn("exploring", `${call.name} failed; revise the premise`);
|
|
1593
|
+
}
|
|
1594
|
+
else {
|
|
1595
|
+
const mutatesDependencies = /^(?:fs\.(?:write|writeMany|edit|replaceLines|append|delete)|pkg\.install)$/.test(call.name) ||
|
|
1596
|
+
((call.name === "shell.exec" || call.name === "shell.start") &&
|
|
1597
|
+
/\b(?:install|mkdir|create|generate|build)\b/i.test(String(call.args.command ?? "")));
|
|
1598
|
+
retryDependenciesChanged ||= mutatesDependencies;
|
|
1599
|
+
retryEnvironmentChanged ||=
|
|
1600
|
+
call.name === "pkg.install" ||
|
|
1601
|
+
((call.name === "shell.exec" || call.name === "shell.start") &&
|
|
1602
|
+
isPackageInstallCommand(String(call.args.command ?? "")));
|
|
1603
|
+
}
|
|
1604
|
+
const governed = governProgress(governorState, "activity", {
|
|
1605
|
+
evidenceDelta: newEvidence.length,
|
|
1606
|
+
hypothesisDelta,
|
|
1607
|
+
repetitionScore: loopGuard.getAttemptCount(call.name, call.args) > 1 ? 1 : 0,
|
|
1608
|
+
policy: {
|
|
1609
|
+
resourceEnvelope: Math.max(12, maxSteps),
|
|
1610
|
+
emergencyCeiling: Math.max(70, maxSteps * 3),
|
|
1611
|
+
reflectionAfterNoDelta: 3,
|
|
1612
|
+
pauseAfterNoDelta: 6,
|
|
1613
|
+
repetitionThreshold: 0.8,
|
|
1614
|
+
},
|
|
1615
|
+
});
|
|
1616
|
+
governorState = governed.state;
|
|
1617
|
+
if (governed.recommendation === "reflect") {
|
|
1618
|
+
deferredPostToolMessages.push({
|
|
1619
|
+
role: "system",
|
|
1620
|
+
content: `PROGRESS GOVERNOR: ${governed.reason}. Reassess the current premise and choose the next action that can produce criterion-linked evidence.`,
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1623
|
+
else if (governed.recommendation === "paused_budget") {
|
|
1624
|
+
governorPauseReason = governed.reason;
|
|
1625
|
+
}
|
|
1626
|
+
await saveOutcomeState(outcomeState);
|
|
887
1627
|
loopGuard.recordAttempt(step, call.name, call.args, result.ok, result.exitCode);
|
|
888
|
-
//
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
1628
|
+
// Evidence for verify-before-done: only successful real work counts.
|
|
1629
|
+
if (result.ok && isEvidenceWorkTool(call.name)) {
|
|
1630
|
+
const liveAfter = await loadPlan(session.sessionId).catch(() => undefined);
|
|
1631
|
+
// Never credit whichever task happens to be open after execution: a
|
|
1632
|
+
// later task.update in the same batch may already have changed it.
|
|
1633
|
+
const creditId = dispatchedTaskId;
|
|
1634
|
+
const cmd = typeof call.args.command === "string" ? call.args.command : "";
|
|
1635
|
+
const signals = {};
|
|
1636
|
+
if (isFeatureImplementationCall(call))
|
|
1637
|
+
signals.featureWrite = true;
|
|
1638
|
+
if (call.name === "fs.write" ||
|
|
1639
|
+
call.name === "fs.writeMany" ||
|
|
1640
|
+
call.name === "fs.edit" ||
|
|
1641
|
+
call.name === "fs.replaceLines" ||
|
|
1642
|
+
call.name === "fs.append") {
|
|
1643
|
+
signals.sourceWrite = true;
|
|
1644
|
+
}
|
|
1645
|
+
if ((call.name === "shell.exec" || call.name === "shell.start") &&
|
|
1646
|
+
isPackageInstallCommand(cmd)) {
|
|
1647
|
+
signals.installOk = true;
|
|
1648
|
+
}
|
|
1649
|
+
if ((call.name === "shell.exec" || call.name === "shell.start") &&
|
|
1650
|
+
isScaffoldCreateCommand(cmd)) {
|
|
1651
|
+
signals.scaffoldOk = true;
|
|
1652
|
+
}
|
|
1653
|
+
if (isDevServerCall(call))
|
|
1654
|
+
signals.devServerStart = true;
|
|
1655
|
+
const out = result.output ?? "";
|
|
1656
|
+
if ((call.name === "shell.tail" || call.name === "shell.start") &&
|
|
1657
|
+
isServerReadyOutput(out)) {
|
|
1658
|
+
signals.serverReady = true;
|
|
1659
|
+
}
|
|
1660
|
+
if (call.name === "shell.exec" &&
|
|
1661
|
+
isPortListeningOutput(cmd, out)) {
|
|
1662
|
+
signals.portListening = true;
|
|
1663
|
+
}
|
|
1664
|
+
if (localHttpProbeIsSuccess(out) ||
|
|
1665
|
+
(sawLocalHttpProbe && !sawFailedLocalHttpProbe)) {
|
|
1666
|
+
// Prefer explicit success parse on this result
|
|
1667
|
+
if (/\b(localhost|127\.0\.0\.1)\b/i.test(`${call.name} ${cmd} ${JSON.stringify(call.args)}`)) {
|
|
1668
|
+
if (localHttpProbeIsSuccess(out)) {
|
|
1669
|
+
signals.localHttpProbeOk = true;
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
// Remote/pentest evidence — never conflate with local app runtime
|
|
1674
|
+
if (isRemoteReconToolCall(call))
|
|
1675
|
+
signals.remoteReconOk = true;
|
|
1676
|
+
if (isRemoteActiveTestCall(call))
|
|
1677
|
+
signals.remoteActiveTestOk = true;
|
|
1678
|
+
// Always bank the success for later absorb (preflight / no open task).
|
|
1679
|
+
sessionLooseWork.push({
|
|
1680
|
+
toolName: call.name,
|
|
1681
|
+
...(Object.keys(signals).length > 0 ? { signals } : {}),
|
|
1682
|
+
});
|
|
1683
|
+
taskWorkLedger = recordTaskWorkSuccess(taskWorkLedger, creditId, call.name, signals);
|
|
1684
|
+
// If nothing was open, still try to attach to the next ready explore
|
|
1685
|
+
// task so "Check Node/npm" can complete without thrash.
|
|
1686
|
+
if ((!creditId || !taskWorkLedger || taskWorkLedger.taskId !== creditId) && liveAfter) {
|
|
1687
|
+
const ready = readyPlanTasks(liveAfter)[0];
|
|
1688
|
+
if (ready) {
|
|
1689
|
+
const absorbed = absorbLooseWorkIntoLedger(ledgerFromTaskEvidence(ready.id, ready.evidence), ready.id, ready.title, [{ toolName: call.name, signals }], { planKind: liveAfter.kind });
|
|
1690
|
+
if (absorbed && absorbed.successWorkCount > 0) {
|
|
1691
|
+
const task = liveAfter.tasks.find((t) => t.id === ready.id);
|
|
1692
|
+
if (task) {
|
|
1693
|
+
task.evidence = taskEvidenceFromLedger(absorbed);
|
|
1694
|
+
if (!taskWorkLedger ||
|
|
1695
|
+
taskWorkLedger.taskId !== ready.id ||
|
|
1696
|
+
taskWorkLedger.successWorkCount < absorbed.successWorkCount) {
|
|
1697
|
+
taskWorkLedger = absorbed;
|
|
1698
|
+
}
|
|
1699
|
+
await savePlan(liveAfter).catch(() => undefined);
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
if (liveAfter && creditId && taskWorkLedger?.taskId === creditId) {
|
|
1705
|
+
const task = liveAfter.tasks.find((candidate) => candidate.id === creditId);
|
|
1706
|
+
if (task) {
|
|
1707
|
+
task.evidence = taskEvidenceFromLedger(taskWorkLedger);
|
|
1708
|
+
await savePlan(liveAfter).catch(() => undefined);
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
refreshSessionState(liveAfter);
|
|
1712
|
+
}
|
|
892
1713
|
if (!result.ok) {
|
|
893
1714
|
const reflection = loopGuard.getFailureReflection();
|
|
894
1715
|
if (reflection) {
|
|
895
|
-
|
|
1716
|
+
deferredPostToolMessages.push({ role: "system", content: reflection });
|
|
896
1717
|
const failCount = loopGuard.consecutiveFailureCount();
|
|
897
1718
|
writeNotice("warn", `${failCount} consecutive failures — model evaluating approach`, chalk.yellow(` ⚠ ${failCount} consecutive failures — evaluating approach\n`));
|
|
898
1719
|
}
|
|
@@ -931,9 +1752,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
931
1752
|
artifactPath: savedOutputPath,
|
|
932
1753
|
summary: contextOutput,
|
|
933
1754
|
});
|
|
934
|
-
// Classic REPL only: short path notice. Never push long
|
|
935
|
-
// "Ctrl+O or /output last … (path)" strings into the TUI status
|
|
936
|
-
// footer — they collide with activity and garble the chrome.
|
|
937
1755
|
if (writesDirectly && savedOutputPath) {
|
|
938
1756
|
const short = chalk.dim(` saved ${savedOutputPath}\n`);
|
|
939
1757
|
process.stdout.write(short);
|
|
@@ -945,15 +1763,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
945
1763
|
}
|
|
946
1764
|
return { ok: result.ok, call, result, contextOutput };
|
|
947
1765
|
}
|
|
948
|
-
// Automatic context compaction
|
|
949
|
-
// As a long turn accumulates tool outputs and reasoning, the context can
|
|
950
|
-
// grow past what the model can hold. We proactively summarize the older
|
|
951
|
-
// turns into a single continuation memory (the SAME model-written summary
|
|
952
|
-
// the /compact command uses — never a mechanical transcript dump) and then
|
|
953
|
-
// re-inject the ACTIVE PLAN so the agent never loses track of the plan,
|
|
954
|
-
// what is done, and what remains. The estimate is chars/4; the budget is
|
|
955
|
-
// deliberately conservative so we compact a little early rather than hit a
|
|
956
|
-
// provider context-window error mid-task.
|
|
957
1766
|
const AUTO_COMPACT_KEEP_RECENT = 6;
|
|
958
1767
|
let lastCompactionMsgCount = 0;
|
|
959
1768
|
const summarizeForCompaction = async (summaryPrompt) => {
|
|
@@ -961,10 +1770,7 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
961
1770
|
provider,
|
|
962
1771
|
model,
|
|
963
1772
|
messages: [
|
|
964
|
-
{
|
|
965
|
-
role: "system",
|
|
966
|
-
content: "You compress conversation history into an accurate, concise continuation memory for another assistant.",
|
|
967
|
-
},
|
|
1773
|
+
{ role: "system", content: COMPACTION_SYSTEM_PROMPT },
|
|
968
1774
|
{ role: "user", content: summaryPrompt },
|
|
969
1775
|
],
|
|
970
1776
|
temperature: 0.1,
|
|
@@ -1000,6 +1806,8 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1000
1806
|
content: planContextMessage(livePlan, session.planApproved.value),
|
|
1001
1807
|
});
|
|
1002
1808
|
}
|
|
1809
|
+
// Re-inject live SESSION STATE after compaction (older flags survive).
|
|
1810
|
+
refreshSessionState(livePlan);
|
|
1003
1811
|
lastCompactionMsgCount = messages.length;
|
|
1004
1812
|
// Final count the model actually receives (may include re-injected plan).
|
|
1005
1813
|
const afterTokens = estimateMessagesTokens(messages);
|
|
@@ -1008,10 +1816,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1008
1816
|
estimatedTokens: afterTokens,
|
|
1009
1817
|
reason,
|
|
1010
1818
|
});
|
|
1011
|
-
// Extract the inserted compaction memory so we can surface the
|
|
1012
|
-
// summary itself (not just token-count stats). The summary lives in
|
|
1013
|
-
// the first system message whose content begins with
|
|
1014
|
-
// COMPACTION_MEMORY_PREFIX.
|
|
1015
1819
|
const insertedSummary = messages.find((m) => m.role === "system" &&
|
|
1016
1820
|
m.content.startsWith(COMPACTION_MEMORY_PREFIX))?.content ?? "";
|
|
1017
1821
|
const summaryText = insertedSummary.startsWith(`${COMPACTION_MEMORY_PREFIX}\n\n`)
|
|
@@ -1037,84 +1841,32 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1037
1841
|
}
|
|
1038
1842
|
}
|
|
1039
1843
|
for (let iteration = 0; iteration < maxIterations; iteration += 1) {
|
|
1040
|
-
// Each model round-trip re-decides whether its visible prose has been
|
|
1041
|
-
// committed, so recovery-prose preservation applies to THIS turn's
|
|
1042
|
-
// stream and not a prior one.
|
|
1043
1844
|
visibleCommitted = false;
|
|
1044
1845
|
// `step` is the productive-step index (used for display + audit). It only
|
|
1045
1846
|
// advances when the previous iteration actually executed a tool.
|
|
1046
1847
|
step = productiveSteps;
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
// Abort / non-interactive — treat as decline.
|
|
1057
|
-
shouldContinue = false;
|
|
1058
|
-
}
|
|
1059
|
-
if (shouldContinue) {
|
|
1060
|
-
// Extend the budget for another chunk of work.
|
|
1061
|
-
const extension = Math.max(40, maxSteps);
|
|
1062
|
-
stepBudget += extension;
|
|
1063
|
-
maxIterations = stepBudget * 3;
|
|
1064
|
-
// Compact older messages (model-written summary, no mechanical dump)
|
|
1065
|
-
// to free context space for the next chunk of work.
|
|
1066
|
-
await maybeAutoCompact("step-budget-continue", true);
|
|
1067
|
-
// Inject a progress summary so the model stays focused.
|
|
1068
|
-
const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
1069
|
-
let progressNote = "The step limit was reached and the user chose to continue. ";
|
|
1070
|
-
progressNote +=
|
|
1071
|
-
"Review what you have accomplished so far and continue with the NEXT unfinished step. ";
|
|
1072
|
-
progressNote +=
|
|
1073
|
-
"Do NOT repeat work already done. Do NOT re-fetch pages or re-run scans whose results you already have.";
|
|
1074
|
-
if (livePlan) {
|
|
1075
|
-
const doneTasks = livePlan.tasks.filter((t) => t.state === "done");
|
|
1076
|
-
const pendingTasks = livePlan.tasks.filter((t) => t.state === "pending" || t.state === "in_progress");
|
|
1077
|
-
progressNote += `\n\nPlan progress: ${doneTasks.length}/${livePlan.tasks.length} tasks done.`;
|
|
1078
|
-
if (pendingTasks.length > 0) {
|
|
1079
|
-
progressNote += ` Next: ${pendingTasks[0].id} — "${pendingTasks[0].title}".`;
|
|
1080
|
-
}
|
|
1081
|
-
}
|
|
1082
|
-
messages.push({ role: "user", content: progressNote });
|
|
1083
|
-
writeNotice("info", `continuing — budget extended to ${stepBudget} steps`, chalk.dim(` ℹ continuing — budget extended to ${stepBudget} steps\n`));
|
|
1084
|
-
// Continue the loop — model doesn't know it paused.
|
|
1085
|
-
}
|
|
1086
|
-
else {
|
|
1087
|
-
// User declined — build a rich summary and return.
|
|
1088
|
-
const richSummary = await buildRichStopSummary(messages, session, productiveSteps);
|
|
1089
|
-
writeAssistantMessage(richSummary);
|
|
1090
|
-
lastAnswer = richSummary;
|
|
1091
|
-
return finishTurn(lastAnswer, productiveSteps);
|
|
1092
|
-
}
|
|
1848
|
+
if (governorPauseReason) {
|
|
1849
|
+
const richSummary = await buildRichStopSummary(messages, session, productiveSteps);
|
|
1850
|
+
outcomeState.outcome.status = "paused_budget";
|
|
1851
|
+
await saveOutcomeState(outcomeState);
|
|
1852
|
+
moveTurn("paused_budget", governorPauseReason);
|
|
1853
|
+
lastAnswer = richSummary;
|
|
1854
|
+
return finishTurn(lastAnswer, productiveSteps, "paused_budget", outcomeState.outcome.criteria
|
|
1855
|
+
.filter((criterion) => criterion.required && criterion.status !== "proven")
|
|
1856
|
+
.map((criterion) => criterion.statement), governorPauseReason);
|
|
1093
1857
|
}
|
|
1094
1858
|
options.signal?.throwIfAborted();
|
|
1095
|
-
// `call` and `assistantText` are shared by both paths below: a fresh
|
|
1096
|
-
// model round-trip, or draining a previously-queued tool call.
|
|
1097
1859
|
let call;
|
|
1098
1860
|
let assistantText;
|
|
1099
1861
|
let recoveredFromBareJson = false;
|
|
1100
1862
|
if (pendingCalls.length > 0) {
|
|
1101
|
-
// Drain the next queued call from the previous model message — no new
|
|
1102
|
-
// round-trip. The assistant message and any prose were already shown
|
|
1103
|
-
// when the batch was parsed.
|
|
1104
1863
|
call = pendingCalls.shift();
|
|
1105
1864
|
assistantText = { visible: "", thinkContent: "", hasThinking: false };
|
|
1106
1865
|
const batchStatus = ` ↳ continuing batch (${pendingCalls.length} more queued)\n`;
|
|
1107
1866
|
writeStatus(batchStatus, chalk.dim(batchStatus));
|
|
1108
1867
|
}
|
|
1109
1868
|
else {
|
|
1110
|
-
// Before a fresh model round-trip, proactively compact if the context has
|
|
1111
|
-
// grown too large, so we never hit a provider context-window error and the
|
|
1112
|
-
// model keeps a clean, plan-aware memory.
|
|
1113
1869
|
await maybeAutoCompact("auto-token-budget");
|
|
1114
|
-
// Buffer LLM output so tool JSON and hidden thinking are not printed raw.
|
|
1115
|
-
// Status messages (rate-limit retries, fallback hints) still surface live.
|
|
1116
|
-
// A spinner gives the user feedback during long thinking phases on
|
|
1117
|
-
// models like glm-5.1 / deepseek-v4-flash that stream reasoning first.
|
|
1118
1870
|
const streamLabel = step === 0 ? "waiting for model" : `step ${step + 1}`;
|
|
1119
1871
|
let spinner = writesDirectly
|
|
1120
1872
|
? startThinkingSpinner(streamLabel, options.signal)
|
|
@@ -1129,9 +1881,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1129
1881
|
let accumulatedText = "";
|
|
1130
1882
|
const callIds = [];
|
|
1131
1883
|
let streamedCallsCount = 0;
|
|
1132
|
-
// Deferred tool-call events: collect tool calls parsed from the stream
|
|
1133
|
-
// and emit them AFTER thinking + assistant text, so the display order
|
|
1134
|
-
// is correct: thinking → model text → tool-call cards.
|
|
1135
1884
|
const deferredToolCalls = [];
|
|
1136
1885
|
const deltaParser = writesDirectly
|
|
1137
1886
|
? undefined
|
|
@@ -1143,71 +1892,135 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1143
1892
|
emit({ type: "thinking-delta", text });
|
|
1144
1893
|
});
|
|
1145
1894
|
let completion;
|
|
1895
|
+
let toolsAttached = false;
|
|
1146
1896
|
try {
|
|
1897
|
+
// Re-resolve dialect each step so /model or sticky fallback apply.
|
|
1898
|
+
({ dialect: toolDialect, native: nativeToolsActive } =
|
|
1899
|
+
resolveNativeTools(provider, model));
|
|
1900
|
+
if (messages[0]?.role === "system") {
|
|
1901
|
+
messages[0] = {
|
|
1902
|
+
role: "system",
|
|
1903
|
+
content: composeCurrentSystemPrompt(nativeToolsActive),
|
|
1904
|
+
};
|
|
1905
|
+
}
|
|
1906
|
+
const turnTools = selectToolDefs(nativeToolsActive, useCompactSystemPrompt);
|
|
1907
|
+
toolsAttached = Boolean(turnTools?.length);
|
|
1908
|
+
await auditLog("agent.turn", {
|
|
1909
|
+
provider,
|
|
1910
|
+
model,
|
|
1911
|
+
tool_protocol: toolsAttached ? "native" : "text",
|
|
1912
|
+
dialect: toolDialect,
|
|
1913
|
+
step,
|
|
1914
|
+
});
|
|
1915
|
+
assertValidToolProtocol(messages);
|
|
1147
1916
|
completion = await streamWithProvider({
|
|
1148
1917
|
provider,
|
|
1149
1918
|
model,
|
|
1150
|
-
// Stay on the user's selected provider/model. Cross-provider
|
|
1151
|
-
// hops (e.g. bynara → gemini on a 429) only happen when the
|
|
1152
|
-
// user has explicitly enabled `providerFallback` AND this is
|
|
1153
|
-
// the provider's default model. Never force fallback mid-turn.
|
|
1154
1919
|
allowModelFallback: false,
|
|
1155
1920
|
messages,
|
|
1156
|
-
// MiniMax M3 degenerates at the generic agent temperature. The
|
|
1157
|
-
// HTTP layer also applies its `top_p` override for both the
|
|
1158
|
-
// NVIDIA long ID and Kimchi's short `minimax-m3` ID.
|
|
1159
1921
|
temperature: /minimax-m3/i.test(model) ? 1.0 : 0.2,
|
|
1160
|
-
// Reasoning models can spend a lot on hidden thinking; give
|
|
1161
|
-
// them headroom so the visible answer / tool call isn't
|
|
1162
|
-
// truncated to silence. The non-thinking budget must be large
|
|
1163
|
-
// enough for a single-file fs.write / multi-file fs.writeMany
|
|
1164
|
-
// payload — a truncated tool-call JSON fails to parse and leaks a
|
|
1165
|
-
// broken (and syntactically invalid) file. 8k was too small for a
|
|
1166
|
-
// full component, so allow more room for the visible tool call.
|
|
1167
|
-
// Code-generation calls frequently contain an entire source file
|
|
1168
|
-
// inside JSON. A 12k visible-token ceiling cut otherwise valid
|
|
1169
|
-
// fs.write calls in half. Keep enough output headroom for a
|
|
1170
|
-
// substantial source file; providers with a lower limit clamp it.
|
|
1171
1922
|
maxTokens: 32_768,
|
|
1172
1923
|
signal: options.signal,
|
|
1173
1924
|
thinking: retryWithoutThinking
|
|
1174
1925
|
? { ...config.thinking, enabled: false, effort: "low" }
|
|
1175
1926
|
: config.thinking,
|
|
1927
|
+
...(toolsAttached
|
|
1928
|
+
? {
|
|
1929
|
+
tools: turnTools,
|
|
1930
|
+
toolChoice: freshWebSearchRequired && !sawFreshWebSearch
|
|
1931
|
+
? { type: "function", name: "web.search" }
|
|
1932
|
+
: "auto",
|
|
1933
|
+
parallelToolCalls: true,
|
|
1934
|
+
// P2-3: emit tool cards as soon as the function name arrives.
|
|
1935
|
+
onToolCallDelta: (delta) => {
|
|
1936
|
+
if (!delta.name)
|
|
1937
|
+
return;
|
|
1938
|
+
const name = fromWireName(delta.name) ?? delta.name;
|
|
1939
|
+
const existing = deferredToolCalls[delta.index];
|
|
1940
|
+
if (existing) {
|
|
1941
|
+
if (delta.argumentsBytes &&
|
|
1942
|
+
delta.argumentsBytes >= 4096 &&
|
|
1943
|
+
!writesDirectly) {
|
|
1944
|
+
emit({
|
|
1945
|
+
type: "status",
|
|
1946
|
+
text: `${name} (${Math.round(delta.argumentsBytes / 1024)}KB args)`,
|
|
1947
|
+
});
|
|
1948
|
+
}
|
|
1949
|
+
return;
|
|
1950
|
+
}
|
|
1951
|
+
// Ensure slots are dense so index maps to deferredToolCalls[i].
|
|
1952
|
+
while (deferredToolCalls.length < delta.index) {
|
|
1953
|
+
deferredToolCalls.push({
|
|
1954
|
+
eventId: `tool-${++nextToolEventId}`,
|
|
1955
|
+
call: { name: "…", args: {} },
|
|
1956
|
+
rendered: "",
|
|
1957
|
+
});
|
|
1958
|
+
}
|
|
1959
|
+
const call = normalizeToolCall({
|
|
1960
|
+
name,
|
|
1961
|
+
args: {},
|
|
1962
|
+
});
|
|
1963
|
+
const eventId = `tool-${++nextToolEventId}`;
|
|
1964
|
+
callIds.push(eventId);
|
|
1965
|
+
alreadyPrintedIds.add(eventId);
|
|
1966
|
+
const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
|
|
1967
|
+
chalk.gray(` ${formatToolArgs(call)}`);
|
|
1968
|
+
const entry = {
|
|
1969
|
+
eventId,
|
|
1970
|
+
call,
|
|
1971
|
+
rendered: styleToolChatter(call, toolCallLine) + "\n",
|
|
1972
|
+
};
|
|
1973
|
+
if (deferredToolCalls.length === delta.index) {
|
|
1974
|
+
deferredToolCalls.push(entry);
|
|
1975
|
+
}
|
|
1976
|
+
else {
|
|
1977
|
+
deferredToolCalls[delta.index] = entry;
|
|
1978
|
+
}
|
|
1979
|
+
streamedCallsCount = Math.max(streamedCallsCount, deferredToolCalls.length);
|
|
1980
|
+
if (!writesDirectly) {
|
|
1981
|
+
emit({ type: "status", text: call.name });
|
|
1982
|
+
}
|
|
1983
|
+
else {
|
|
1984
|
+
spinner.stop();
|
|
1985
|
+
spinner = startThinkingSpinner(`tool ${call.name}…`, options.signal);
|
|
1986
|
+
}
|
|
1987
|
+
},
|
|
1988
|
+
}
|
|
1989
|
+
: {}),
|
|
1176
1990
|
}, (token) => {
|
|
1177
1991
|
deltaParser?.push(token);
|
|
1178
1992
|
generatedTokens += 1;
|
|
1179
1993
|
accumulatedText += token;
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1994
|
+
// Early UI cards from text fences only when native tools are off
|
|
1995
|
+
// (native args stream as structured deltas, not prose).
|
|
1996
|
+
if (!toolsAttached) {
|
|
1997
|
+
const parsedCalls = parseAllToolCalls(accumulatedText);
|
|
1998
|
+
if (parsedCalls.length > streamedCallsCount) {
|
|
1999
|
+
if (writesDirectly) {
|
|
2000
|
+
spinner.stop();
|
|
2001
|
+
}
|
|
2002
|
+
while (streamedCallsCount < parsedCalls.length) {
|
|
2003
|
+
const call = parsedCalls[streamedCallsCount];
|
|
2004
|
+
const eventId = `tool-${++nextToolEventId}`;
|
|
2005
|
+
callIds.push(eventId);
|
|
2006
|
+
alreadyPrintedIds.add(eventId);
|
|
2007
|
+
const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
|
|
2008
|
+
chalk.gray(` ${formatToolArgs(call)}`);
|
|
2009
|
+
deferredToolCalls.push({
|
|
2010
|
+
eventId,
|
|
2011
|
+
call,
|
|
2012
|
+
rendered: styleToolChatter(call, toolCallLine) + "\n",
|
|
2013
|
+
});
|
|
2014
|
+
if (!writesDirectly) {
|
|
2015
|
+
emit({ type: "status", text: call.name });
|
|
2016
|
+
}
|
|
2017
|
+
streamedCallsCount += 1;
|
|
2018
|
+
}
|
|
2019
|
+
if (writesDirectly) {
|
|
2020
|
+
spinner = startThinkingSpinner(`generating response (${generatedTokens} tokens)`, options.signal);
|
|
1201
2021
|
}
|
|
1202
|
-
streamedCallsCount += 1;
|
|
1203
|
-
}
|
|
1204
|
-
if (writesDirectly) {
|
|
1205
|
-
spinner = startThinkingSpinner(`generating response (${generatedTokens} tokens)`, options.signal);
|
|
1206
2022
|
}
|
|
1207
2023
|
}
|
|
1208
|
-
// Heuristic: <think>… markers and reasoning_content tokens flow
|
|
1209
|
-
// through onToken. Surface activity in the spinner so the screen
|
|
1210
|
-
// is never empty for minutes.
|
|
1211
2024
|
if (!sawReasoning && /<think/i.test(token)) {
|
|
1212
2025
|
sawReasoning = true;
|
|
1213
2026
|
inThinking = true;
|
|
@@ -1220,11 +2033,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1220
2033
|
spinner.setLabel("generating response (0 tokens)");
|
|
1221
2034
|
generatedTokens = 0;
|
|
1222
2035
|
}
|
|
1223
|
-
// Only push reasoning tokens to the spinner preview. Visible
|
|
1224
|
-
// answer / tool-call tokens should NOT go through the dim
|
|
1225
|
-
// spinner preview — doing so makes the final answer appear
|
|
1226
|
-
// "diluted" in light font when the spinner's last render
|
|
1227
|
-
// briefly shows the answer text before being erased.
|
|
1228
2036
|
if (inThinking) {
|
|
1229
2037
|
const cleaned = token.replace(/<\/?think[^>]*>/gi, "");
|
|
1230
2038
|
if (cleaned) {
|
|
@@ -1251,51 +2059,169 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1251
2059
|
provider = completion.provider;
|
|
1252
2060
|
model = completion.model;
|
|
1253
2061
|
deltaParser?.finish();
|
|
2062
|
+
// Sticky text-only may have flipped dialect during stream retry.
|
|
2063
|
+
({ dialect: toolDialect, native: nativeToolsActive } =
|
|
2064
|
+
resolveNativeTools(provider, model));
|
|
2065
|
+
// toolsAttached may have been true for the request; if sticky
|
|
2066
|
+
// fallback dropped tools, treat as text mode for this turn's parse.
|
|
2067
|
+
const usedNativeProtocol = Boolean(completion.toolCalls?.length) ||
|
|
2068
|
+
(toolsAttached && !isTextOnlyModel(provider, model));
|
|
1254
2069
|
const assistantTextResult = rememberThinkingFromText(completion.text);
|
|
1255
2070
|
assistantText = assistantTextResult;
|
|
1256
|
-
// Commit thinking to the transcript IMMEDIATELY, before any of the
|
|
1257
|
-
// branches below decide to `continue` (retry a malformed tool call,
|
|
1258
|
-
// nudge for narration, guard premature completion, etc). Previously
|
|
1259
|
-
// writeThinkingBlock was only called from a few terminal branches, so
|
|
1260
|
-
// any retry path silently dropped the model's reasoning — the user
|
|
1261
|
-
// would see the live "thinking…" preview during streaming and then
|
|
1262
|
-
// watch it vanish with nothing committed once the turn moved on.
|
|
1263
2071
|
if (assistantText.hasThinking) {
|
|
1264
2072
|
writeThinkingBlock(assistantText.thinkContent);
|
|
1265
2073
|
}
|
|
1266
|
-
//
|
|
1267
|
-
|
|
1268
|
-
//
|
|
1269
|
-
//
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
2074
|
+
// Native-first: prefer structured toolCalls from the provider.
|
|
2075
|
+
let nativeToolCalls = completion.toolCalls ?? [];
|
|
2076
|
+
// Early UI cards: refresh args if stream deltas already opened cards;
|
|
2077
|
+
// otherwise create cards now (non-streaming / name-after-done providers).
|
|
2078
|
+
if (nativeToolCalls.length) {
|
|
2079
|
+
if (deferredToolCalls.length === 0) {
|
|
2080
|
+
for (const tc of nativeToolCalls) {
|
|
2081
|
+
const normalized = normalizeToolCall({
|
|
2082
|
+
name: tc.name,
|
|
2083
|
+
args: tc.args,
|
|
2084
|
+
});
|
|
2085
|
+
const eventId = `tool-${++nextToolEventId}`;
|
|
2086
|
+
callIds.push(eventId);
|
|
2087
|
+
alreadyPrintedIds.add(eventId);
|
|
2088
|
+
const toolCallLine = chalk.cyan(` ▶ ${normalized.name}`) +
|
|
2089
|
+
chalk.gray(` ${formatToolArgs(normalized)}`);
|
|
2090
|
+
deferredToolCalls.push({
|
|
2091
|
+
eventId,
|
|
2092
|
+
call: normalized,
|
|
2093
|
+
rendered: styleToolChatter(normalized, toolCallLine) + "\n",
|
|
2094
|
+
});
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
else {
|
|
2098
|
+
for (let i = 0; i < nativeToolCalls.length; i++) {
|
|
2099
|
+
const tc = nativeToolCalls[i];
|
|
2100
|
+
const normalized = normalizeToolCall({
|
|
2101
|
+
name: tc.name,
|
|
2102
|
+
args: tc.args,
|
|
2103
|
+
});
|
|
2104
|
+
const existing = deferredToolCalls[i];
|
|
2105
|
+
if (existing && existing.call.name !== "…") {
|
|
2106
|
+
existing.call = normalized;
|
|
2107
|
+
const toolCallLine = chalk.cyan(` ▶ ${normalized.name}`) +
|
|
2108
|
+
chalk.gray(` ${formatToolArgs(normalized)}`);
|
|
2109
|
+
existing.rendered =
|
|
2110
|
+
styleToolChatter(normalized, toolCallLine) + "\n";
|
|
2111
|
+
}
|
|
2112
|
+
else if (!existing || existing.call.name === "…") {
|
|
2113
|
+
const eventId = existing?.eventId ?? `tool-${++nextToolEventId}`;
|
|
2114
|
+
if (!existing) {
|
|
2115
|
+
callIds.push(eventId);
|
|
2116
|
+
alreadyPrintedIds.add(eventId);
|
|
2117
|
+
}
|
|
2118
|
+
const toolCallLine = chalk.cyan(` ▶ ${normalized.name}`) +
|
|
2119
|
+
chalk.gray(` ${formatToolArgs(normalized)}`);
|
|
2120
|
+
const entry = {
|
|
2121
|
+
eventId,
|
|
2122
|
+
call: normalized,
|
|
2123
|
+
rendered: styleToolChatter(normalized, toolCallLine) + "\n",
|
|
2124
|
+
};
|
|
2125
|
+
if (existing)
|
|
2126
|
+
deferredToolCalls[i] = entry;
|
|
2127
|
+
else
|
|
2128
|
+
deferredToolCalls.push(entry);
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
if (nativeToolCalls.length) {
|
|
2134
|
+
const first = nativeToolCalls[0];
|
|
2135
|
+
if (first.args?._parseError) {
|
|
2136
|
+
call = undefined;
|
|
2137
|
+
}
|
|
2138
|
+
else {
|
|
2139
|
+
call = normalizeToolCall({ name: first.name, args: first.args });
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
else {
|
|
2143
|
+
call = parseToolCall(assistantText.visible, {
|
|
1275
2144
|
strict: getConfig().parserStrict,
|
|
1276
2145
|
});
|
|
1277
|
-
if (call) {
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
2146
|
+
if (!call && assistantText.hasThinking) {
|
|
2147
|
+
call = parseToolCall(assistantText.thinkContent, {
|
|
2148
|
+
strict: getConfig().parserStrict,
|
|
2149
|
+
});
|
|
2150
|
+
if (call) {
|
|
2151
|
+
writeNotice("info", "recovered tool call from thinking content", chalk.dim(" ℹ recovered tool call from thinking content\n"));
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
if (looksLikePromptLeak(assistantText.visible)) {
|
|
2156
|
+
if (call || nativeToolCalls.length) {
|
|
2157
|
+
writeNotice("warn", "suppressed tool call from apparent prompt leak", chalk.yellow(" ⚠ suppressed tool call — model appears to be repeating its system prompt\n"));
|
|
2158
|
+
}
|
|
1289
2159
|
call = undefined;
|
|
2160
|
+
nativeToolCalls = [];
|
|
2161
|
+
deferredToolCalls.length = 0;
|
|
2162
|
+
}
|
|
2163
|
+
if (nativeToolCalls.length) {
|
|
2164
|
+
// Only salvage when args failed to parse (truncated JSON). A clean
|
|
2165
|
+
// parse with finish_reason=length is a complete tool call — execute it.
|
|
2166
|
+
const writeTc = nativeToolCalls.find((tc) => {
|
|
2167
|
+
const isWrite = tc.name === "fs.write" ||
|
|
2168
|
+
tc.name === "fs.append" ||
|
|
2169
|
+
tc.name === "fs.writeMany";
|
|
2170
|
+
return isWrite && Boolean(tc.args?._parseError);
|
|
2171
|
+
});
|
|
2172
|
+
if (writeTc) {
|
|
2173
|
+
const raw = writeTc.rawArguments ??
|
|
2174
|
+
(typeof writeTc.args?._raw === "string"
|
|
2175
|
+
? String(writeTc.args._raw)
|
|
2176
|
+
: undefined);
|
|
2177
|
+
const salvaged = salvageTruncatedWriteFromNative(writeTc.name, raw);
|
|
2178
|
+
if (salvaged) {
|
|
2179
|
+
truncatedToolRetries += 1;
|
|
2180
|
+
if (truncatedToolRetries <= 5) {
|
|
2181
|
+
try {
|
|
2182
|
+
const writeResult = await fsWrite(salvaged.path, salvaged.content, { confirmed: true });
|
|
2183
|
+
if (writeResult.ok) {
|
|
2184
|
+
const lineCount = salvaged.content.split("\n").length;
|
|
2185
|
+
writeNotice("info", `native tool call was truncated — salvaged ${lineCount} lines and wrote to ${salvaged.path}`, chalk.cyan(` ℹ native tool call was truncated — salvaged ${lineCount} lines to ${salvaged.path}\n`));
|
|
2186
|
+
// Pair assistant tool_calls with synthetic results so the
|
|
2187
|
+
// next turn is not orphaned, then nudge for append.
|
|
2188
|
+
appendAssistantWithTools(messages, assistantText.visible, nativeToolCalls);
|
|
2189
|
+
for (const tc of nativeToolCalls) {
|
|
2190
|
+
appendToolResult(messages, tc.id, tc.id === writeTc.id
|
|
2191
|
+
? `Tool ${tc.name} result (exit=0, ok=true):\nSalvaged partial write: ${lineCount} lines to ${salvaged.path}`
|
|
2192
|
+
: `Tool ${tc.name} result (exit=1, ok=false):\nCancelled — sibling write was truncated and salvaged.`, tc.name, tc.id === writeTc.id);
|
|
2193
|
+
}
|
|
2194
|
+
const priorBytes = Buffer.byteLength(salvaged.content, "utf8");
|
|
2195
|
+
const appendNudge = toolsAttached
|
|
2196
|
+
? `Your ${writeTc.name} tool call was cut off at the token limit, but the system salvaged the partial content and wrote ${lineCount} lines (${priorBytes} bytes) to ${salvaged.path}. ` +
|
|
2197
|
+
`The file ends with: ${JSON.stringify(salvaged.lastLine)}\n\n` +
|
|
2198
|
+
`CONTINUE by calling fs.append now with path=${JSON.stringify(salvaged.path)}, expectedPriorBytes=${priorBytes}, and content set to ONLY the remaining content not already on disk (prefer hundreds of lines per call). ` +
|
|
2199
|
+
`Do not re-read the full file; do not re-send content already saved. Use the platform tool interface — no markdown fences.`
|
|
2200
|
+
: `Your fs.write tool call was cut off at the token limit, but the system salvaged the partial content and wrote ${lineCount} lines (${priorBytes} bytes) to ${salvaged.path}. ` +
|
|
2201
|
+
`The file ends with: ${JSON.stringify(salvaged.lastLine)}\n\n` +
|
|
2202
|
+
`CONTINUE with ONE large fs.append of the remaining content:\n` +
|
|
2203
|
+
'```tool\n{"name":"fs.append","args":{"path":' +
|
|
2204
|
+
JSON.stringify(salvaged.path) +
|
|
2205
|
+
',"expectedPriorBytes":' +
|
|
2206
|
+
priorBytes +
|
|
2207
|
+
',"content":"...ONLY the remaining content not already on disk..."}}\n```';
|
|
2208
|
+
messages.push({
|
|
2209
|
+
role: "user",
|
|
2210
|
+
content: appendNudge,
|
|
2211
|
+
});
|
|
2212
|
+
nativeToolCalls = [];
|
|
2213
|
+
call = undefined;
|
|
2214
|
+
deferredToolCalls.length = 0;
|
|
2215
|
+
continue;
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
catch {
|
|
2219
|
+
// fall through to normal parse-error handling
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
1290
2224
|
}
|
|
1291
|
-
// Empty-response recovery
|
|
1292
|
-
// Some models occasionally return an empty completion: a reasoning
|
|
1293
|
-
// model that spent its whole budget on hidden <think> reasoning and emitted
|
|
1294
|
-
// no visible text, OR (more perniciously) a gateway hiccup that
|
|
1295
|
-
// streamed [DONE] with no content deltas at all. Without this guard
|
|
1296
|
-
// the agent silently ends the turn with no answer, no warning, and no
|
|
1297
|
-
// error — the user just sees the spinner stop. Catch BOTH cases
|
|
1298
|
-
// (thinking-only AND truly empty) and nudge the model to retry.
|
|
1299
2225
|
if (!assistantText.visible.trim() && !call) {
|
|
1300
2226
|
emptyVisibleRetries += 1;
|
|
1301
2227
|
if (emptyVisibleRetries <= 3) {
|
|
@@ -1310,17 +2236,21 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1310
2236
|
pushAssistantHistory(stripThinking(collapseRepeatedText(completion.text)).visible);
|
|
1311
2237
|
// Keep nudges SHORT — cheap models lose the key instruction in long text.
|
|
1312
2238
|
const buildNudge = freshWebSearchRequired && !sawFreshWebSearch
|
|
1313
|
-
?
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
"
|
|
2239
|
+
? toolsAttached
|
|
2240
|
+
? "No visible output. This is current or scheduled information: call web.search now. Do NOT answer from memory."
|
|
2241
|
+
: "No visible output. This is current or scheduled information: emit exactly one valid ```tool block for web.search now. Do NOT answer from memory or hide the tool call in <think> tags."
|
|
2242
|
+
: isPlanMode && !activePlan
|
|
2243
|
+
? toolsAttached
|
|
2244
|
+
? "No visible output. In plan mode: gather context or call plan.create when ready (do not only describe the plan)."
|
|
2245
|
+
: "No visible output. In plan mode: emit a ```tool block for research/recon or plan.create. " +
|
|
2246
|
+
"Do NOT hide tool calls in <think> tags — put them in the visible response."
|
|
2247
|
+
: toolsAttached
|
|
2248
|
+
? "No visible output. " + toolNudge(true)
|
|
2249
|
+
: "No visible output. Emit a ```tool block or give your final answer. " +
|
|
2250
|
+
"Do NOT hide tool calls in <think> tags — put them in the visible response.";
|
|
1319
2251
|
messages.push(recoveryUserMessage(buildNudge));
|
|
1320
2252
|
continue;
|
|
1321
2253
|
}
|
|
1322
|
-
// Exhausted retries — surface a clear notice and exit the turn instead
|
|
1323
|
-
// of falling through and triggering premature-completion loops.
|
|
1324
2254
|
writeNotice("warn", "model returned an empty response after retries — no answer produced", chalk.yellow(" ⚠ model returned an empty response after retries — no answer produced\n"));
|
|
1325
2255
|
return finishTurn("Model returned an empty response after retries.", step + 1);
|
|
1326
2256
|
}
|
|
@@ -1329,12 +2259,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1329
2259
|
emptyVisibleRetries = 0;
|
|
1330
2260
|
retryWithoutThinking = false;
|
|
1331
2261
|
}
|
|
1332
|
-
// `call` was already extracted above (from visible text or thinking content).
|
|
1333
|
-
// Recovery: the model meant to call a tool but emitted a bare JSON object
|
|
1334
|
-
// with no ```tool fence — either a complete {name,args} the strict
|
|
1335
|
-
// matchers missed (recover it directly), or just an args object like
|
|
1336
|
-
// {"path":"file.pdf"} with the wrapper dropped (nudge a retry below so
|
|
1337
|
-
// the requested action runs instead of the JSON leaking as the answer).
|
|
1338
2262
|
let bareArgsOnly = false;
|
|
1339
2263
|
recoveredFromBareJson = false;
|
|
1340
2264
|
if (!call) {
|
|
@@ -1364,47 +2288,47 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1364
2288
|
if (bareArgsOnly) {
|
|
1365
2289
|
bareToolJsonRetries += 1;
|
|
1366
2290
|
if (bareToolJsonRetries <= 3) {
|
|
1367
|
-
writeNotice("warn",
|
|
2291
|
+
writeNotice("warn", toolsAttached
|
|
2292
|
+
? "tool call missing its name — asking the model to call a tool properly"
|
|
2293
|
+
: "tool call missing its name/fence — asking the model to re-emit a proper ```tool block", chalk.yellow(toolsAttached
|
|
2294
|
+
? " ⚠ tool call missing its name — asking the model to call a tool properly\n"
|
|
2295
|
+
: " ⚠ tool call missing its name/fence — asking the model to re-emit a proper ```tool block\n"));
|
|
1368
2296
|
pushAssistantHistory(assistantText.visible);
|
|
1369
|
-
messages.push(recoveryUserMessage(
|
|
1370
|
-
?
|
|
1371
|
-
"
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
2297
|
+
messages.push(recoveryUserMessage(isPlanMode && !activePlan
|
|
2298
|
+
? toolsAttached
|
|
2299
|
+
? "Your previous message was a bare JSON args object with no tool name, so NOTHING ran. " +
|
|
2300
|
+
"In plan mode: call plan.create (or research tools) via the platform tool interface."
|
|
2301
|
+
: "Your previous message was a bare JSON args object with no tool name and no ```tool fence, so NOTHING ran. " +
|
|
2302
|
+
"In plan mode, call plan.create with a proper ```tool block when ready, e.g.:\n" +
|
|
2303
|
+
'```tool\n{"name":"plan.create","args":{"goal":"…","detail":"…","tasks":["…"],"kind":"coding"}}\n```'
|
|
2304
|
+
: toolsAttached
|
|
2305
|
+
? "Your previous message was a bare JSON args object with no tool name, so NOTHING ran. " +
|
|
2306
|
+
toolNudge(true) +
|
|
2307
|
+
" Include the tool name and full args via the platform tool interface — do not use markdown fences."
|
|
2308
|
+
: "Your previous message was a bare JSON args object with no tool name and no ```tool fence, so NOTHING ran. " +
|
|
2309
|
+
"Reply with ONLY a fenced ```tool block of the form " +
|
|
2310
|
+
'`{"name": "<tool>", "args": { ... }}`. For example, to read a PDF:\n' +
|
|
2311
|
+
'```tool\n{"name":"pdf.read","args":{"path":"/abs/file.pdf"}}\n```\n' +
|
|
2312
|
+
"Choose the correct tool name for the task and include those args."));
|
|
1380
2313
|
continue;
|
|
1381
2314
|
}
|
|
1382
2315
|
// Exhausted retries — fall through to the normal answer path.
|
|
1383
2316
|
}
|
|
1384
|
-
// Detect the case where the model emitted sentinel-style tool-call
|
|
1385
|
-
// markers but the body was malformed or truncated. Printing those
|
|
1386
|
-
// raw tokens looks like a crash to the user — instead, ask the
|
|
1387
|
-
// model to retry the tool call in a clean JSON format.
|
|
1388
2317
|
if (/<\|tool_call(?:s_section)?_begin\|>|<\|tool_call_argument_begin\|>/i.test(assistantText.visible)) {
|
|
1389
2318
|
writeNotice("warn", "tool call was malformed or cut off — asking the model to retry in JSON form", chalk.yellow(" ⚠ tool call was malformed or cut off — asking the model to retry in JSON form\n"));
|
|
1390
2319
|
pushAssistantHistory(assistantText.visible);
|
|
1391
|
-
messages.push(recoveryUserMessage(
|
|
1392
|
-
"
|
|
1393
|
-
|
|
1394
|
-
|
|
2320
|
+
messages.push(recoveryUserMessage(toolsAttached
|
|
2321
|
+
? "Your previous tool call was malformed or truncated. " +
|
|
2322
|
+
toolNudge(true) +
|
|
2323
|
+
" Pass valid JSON arguments via the platform tool interface — do not use fence or sentinel markers."
|
|
2324
|
+
: "Your previous tool call was malformed or truncated. " +
|
|
2325
|
+
"Reply with ONLY a fenced ```tool block containing valid JSON " +
|
|
2326
|
+
'of the form `{"name": "<tool>", "args": { ... }}`. ' +
|
|
2327
|
+
"Do not use <|tool_call_begin|> markers."));
|
|
1395
2328
|
continue;
|
|
1396
2329
|
}
|
|
1397
|
-
// Detect a tool call that opened but was cut off by the token limit
|
|
1398
|
-
// (most common with large fs.write/fs.writeMany for reports).
|
|
1399
|
-
// Instead of asking the model to retry (which will just truncate
|
|
1400
|
-
// again at the same limit), we SALVAGE the partial content from the
|
|
1401
|
-
// truncated JSON and write it, then tell the model to CONTINUE with
|
|
1402
|
-
// fs.append from where it was cut off.
|
|
1403
2330
|
if (looksLikeTruncatedToolCall(assistantText.visible)) {
|
|
1404
2331
|
truncatedToolRetries += 1;
|
|
1405
|
-
// Try to salvage a partial fs.write / fs.append from the truncated JSON.
|
|
1406
|
-
// The pattern is: {"name":"fs.write","args":{"path":"...","content":"...
|
|
1407
|
-
// We extract the path and whatever content was produced before truncation.
|
|
1408
2332
|
const salvaged = salvageTruncatedWrite(assistantText.visible);
|
|
1409
2333
|
if (salvaged && truncatedToolRetries <= 5) {
|
|
1410
2334
|
// Write the salvaged partial content
|
|
@@ -1419,16 +2343,20 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1419
2343
|
const priorBytes = Buffer.byteLength(salvaged.content, "utf8");
|
|
1420
2344
|
messages.push({
|
|
1421
2345
|
role: "user",
|
|
1422
|
-
content:
|
|
1423
|
-
`
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
2346
|
+
content: toolsAttached
|
|
2347
|
+
? `Your fs.write tool call was cut off at the token limit, but the system salvaged the partial content and wrote ${lineCount} lines (${priorBytes} bytes) to ${salvaged.path}. ` +
|
|
2348
|
+
`The file ends with: ${JSON.stringify(salvaged.lastLine)}\n\n` +
|
|
2349
|
+
`CONTINUE by calling fs.append now with path=${JSON.stringify(salvaged.path)}, expectedPriorBytes=${priorBytes}, and content set to ONLY the remaining content (prefer large chunks). Use the platform tool interface — no markdown fences.`
|
|
2350
|
+
: `Your fs.write tool call was cut off at the token limit, but the system salvaged the partial content and wrote ${lineCount} lines (${priorBytes} bytes) to ${salvaged.path}. ` +
|
|
2351
|
+
`The file ends with: ${JSON.stringify(salvaged.lastLine)}\n\n` +
|
|
2352
|
+
`CONTINUE with ONE large fs.append of the remaining content (prefer hundreds of lines per call — do NOT use tiny ~100-line chunks):\n` +
|
|
2353
|
+
'```tool\n{"name":"fs.append","args":{"path":' +
|
|
2354
|
+
JSON.stringify(salvaged.path) +
|
|
2355
|
+
',"expectedPriorBytes":' +
|
|
2356
|
+
priorBytes +
|
|
2357
|
+
',"content":"...ONLY the remaining content not already on disk..."}}\n```\n' +
|
|
2358
|
+
`expectedPriorBytes must match the receipt so append cannot double-write. ` +
|
|
2359
|
+
`Do NOT re-read the full file; do NOT re-send content already saved.`,
|
|
1432
2360
|
});
|
|
1433
2361
|
continue;
|
|
1434
2362
|
}
|
|
@@ -1442,27 +2370,24 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1442
2370
|
pushAssistantHistory(stripThinking(assistantText.visible).visible);
|
|
1443
2371
|
messages.push({
|
|
1444
2372
|
role: "user",
|
|
1445
|
-
content:
|
|
1446
|
-
"
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
"
|
|
1451
|
-
|
|
2373
|
+
content: toolsAttached
|
|
2374
|
+
? "Your previous tool call was cut off before it finished — the JSON was incomplete, so NOTHING ran. " +
|
|
2375
|
+
"Prefer ONE complete fs.write when it fits. If the file is too large: (1) fs.write the first large section, " +
|
|
2376
|
+
"(2) fs.append the rest with expectedPriorBytes from the write receipt, (3) repeat with large chunks. " +
|
|
2377
|
+
"Keep reasoning SHORT and call the tool via the platform interface. Do NOT claim a file was written until a tool call succeeds."
|
|
2378
|
+
: "Your previous tool call was cut off before it finished — the JSON was incomplete, so NOTHING ran. " +
|
|
2379
|
+
"Prefer ONE complete fs.write when it fits (~32k output tokens is a lot of file content if reasoning stays short). " +
|
|
2380
|
+
"If the file is too large for one call:\n" +
|
|
2381
|
+
"1. fs.write the first large section (as much as fits — hundreds+ of lines)\n" +
|
|
2382
|
+
"2. fs.append the rest with expectedPriorBytes from the write receipt\n" +
|
|
2383
|
+
"3. Repeat append only if still incomplete — large chunks, not ~100-line drips\n" +
|
|
2384
|
+
"Keep reasoning SHORT — emit the ```tool block early. Do NOT claim a file was written until a tool call succeeds.",
|
|
1452
2385
|
});
|
|
1453
2386
|
continue;
|
|
1454
2387
|
}
|
|
1455
2388
|
// Exhausted retries — fall through so we don't loop forever, but the
|
|
1456
2389
|
// user at least sees the (broken) output and the stop notice.
|
|
1457
2390
|
}
|
|
1458
|
-
// Detect a ```tool fence whose JSON could NOT be parsed for any other
|
|
1459
|
-
// reason (malformed braces, trailing junk, a stray `}` — NOT plain
|
|
1460
|
-
// truncation, which is handled above). Without this, the raw block
|
|
1461
|
-
// leaks to the screen as a code fence and the requested action (often
|
|
1462
|
-
// a whole fs.writeMany scaffold) silently never runs — exactly the
|
|
1463
|
-
// "fs.writeMany printed but nothing created" failure. Require the fence
|
|
1464
|
-
// to actually look like an intended call (mentions name/args) so a
|
|
1465
|
-
// genuine ```tool code example in prose isn't mistaken for one.
|
|
1466
2391
|
const hasFencedCallShape = countToolFences(assistantText.visible) > 0 &&
|
|
1467
2392
|
/```tool\s*\n[\s\S]*?"(?:name|args)"\s*:/i.test(assistantText.visible);
|
|
1468
2393
|
if (hasFencedCallShape) {
|
|
@@ -1498,54 +2423,38 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1498
2423
|
pushAssistantHistory(stripThinking(assistantText.visible).visible);
|
|
1499
2424
|
messages.push({
|
|
1500
2425
|
role: "user",
|
|
1501
|
-
content:
|
|
1502
|
-
"
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
2426
|
+
content: toolsAttached
|
|
2427
|
+
? "Your previous tool call JSON was INVALID, so NOTHING ran. " +
|
|
2428
|
+
"Common causes: unescaped newlines/quotes, unbalanced braces, or content too large. " +
|
|
2429
|
+
toolNudge(true) +
|
|
2430
|
+
" Prefer ONE complete fs.write when it fits; if cut off, continue with large fs.append + expectedPriorBytes. " +
|
|
2431
|
+
"Do NOT claim any file was written until a tool call actually succeeds."
|
|
2432
|
+
: "Your previous message contained a ```tool block, but its JSON was INVALID, so NOTHING ran. " +
|
|
2433
|
+
"Common causes: unescaped newlines or quotes inside a string value, an extra or missing `}` / `]`, or content too large for the output window. " +
|
|
2434
|
+
'Re-emit ONE valid ```tool block of the exact form {"name":"<tool>","args":{...}} with balanced braces. ' +
|
|
2435
|
+
"IMPORTANT: Prefer ONE complete fs.write when it fits. Keep reasoning SHORT. " +
|
|
2436
|
+
"Only if the output window cuts you off, continue with large fs.append chunks + expectedPriorBytes. " +
|
|
2437
|
+
"Do NOT claim any file was written until a tool call actually succeeds.",
|
|
1507
2438
|
});
|
|
1508
2439
|
continue;
|
|
1509
2440
|
}
|
|
1510
2441
|
// Exhausted retries — fall through to the normal path.
|
|
1511
2442
|
}
|
|
1512
|
-
// Normal final-answer path: strip any stray sentinel tokens that
|
|
1513
|
-
// somehow leaked into prose so the answer renders cleanly.
|
|
1514
2443
|
const cleaned = stripSentinelTokens(assistantText.visible);
|
|
1515
|
-
// Act, don't narrate
|
|
1516
|
-
// Build/scaffold/plan turns must DO something. If the model returns
|
|
1517
|
-
// prose with NO tool call, it is narrating intent ("Let me first
|
|
1518
|
-
// explore the directory…") or writing a PLAN as prose ("Goal: … Tasks:
|
|
1519
|
-
// … please approve") instead of calling a tool — accepting it as a
|
|
1520
|
-
// final answer ends the turn with nothing done and no real plan saved.
|
|
1521
|
-
// Nudge it to emit a real tool call, with a concrete example.
|
|
1522
2444
|
const narratedAction = looksLikeActionNarration(cleaned);
|
|
1523
2445
|
const narratedWebAction = looksLikeWebActionNarration(cleaned);
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
// be forced back into execution. Without this, a completed plan
|
|
1528
|
-
// still appeared open and a short final summary such as "I'll
|
|
1529
|
-
// summarize the findings" could be discarded for an unnecessary
|
|
1530
|
-
// recovery turn.
|
|
1531
|
-
const livePlanAtCompletion = await loadPlan(session.sessionId).catch(() => undefined);
|
|
2446
|
+
const reconciledPlanAtCompletion = await reconcileOpenTaskBeforeFinalizing();
|
|
2447
|
+
const livePlanAtCompletion = reconciledPlanAtCompletion ??
|
|
2448
|
+
(await loadPlan(session.sessionId).catch(() => undefined));
|
|
1532
2449
|
const planStatusAtCompletion = livePlanAtCompletion?.status ?? activePlan?.status;
|
|
1533
2450
|
const completedPlanDuringThisTurn = activePlan?.status !== "completed" &&
|
|
1534
2451
|
planStatusAtCompletion === "completed";
|
|
1535
2452
|
const planHasOpenWorkNow = planHasOpenWork(planStatusAtCompletion);
|
|
1536
|
-
// User-driven reasons to require tools this turn (build/pentest/
|
|
1537
|
-
// approved plan / freshness). Idle greetings never qualify.
|
|
1538
2453
|
const userExpectsWork = freshWebSearchRequired ||
|
|
1539
2454
|
(planHasOpenWorkNow && session.planApproved.value) ||
|
|
1540
2455
|
(!informationalQuery &&
|
|
1541
2456
|
!idleOrSocialPrompt &&
|
|
1542
2457
|
(buildLikeTurn || pentestLikeTurn));
|
|
1543
|
-
// History-inherited build/pentest intent only forces action when
|
|
1544
|
-
// THIS prompt is not itself a plain question/idle turn. Narration
|
|
1545
|
-
// stalls ("I'll list the files") still force a tool when the user
|
|
1546
|
-
// is not asking an informational question — but capability menus
|
|
1547
|
-
// and greetings are filtered out by looksLikeActionNarration /
|
|
1548
|
-
// idleOrSocialPrompt so they cannot burn recovery turns.
|
|
1549
2458
|
const wantsAction = !completedPlanDuringThisTurn &&
|
|
1550
2459
|
!idleOrSocialPrompt &&
|
|
1551
2460
|
(userExpectsWork ||
|
|
@@ -1554,224 +2463,279 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1554
2463
|
const planNarrated = (buildLikeTurn || pentestLikeTurn) &&
|
|
1555
2464
|
!activePlan &&
|
|
1556
2465
|
looksLikePlanNarration(cleaned);
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
2466
|
+
const errorFixNarration = looksLikeErrorDiagnosisWithFixIntent(cleaned);
|
|
2467
|
+
const shouldRetryBeforeFinalizing = productiveSteps === 0 ||
|
|
2468
|
+
planNarrated ||
|
|
2469
|
+
(session.planApproved.value &&
|
|
2470
|
+
planHasOpenWorkNow &&
|
|
2471
|
+
(narratedAction || errorFixNarration)) ||
|
|
2472
|
+
(session.planApproved.value && errorFixNarration) ||
|
|
2473
|
+
(buildLikeTurn && errorFixNarration);
|
|
1562
2474
|
if (wantsAction &&
|
|
1563
2475
|
cleaned.trim().length > 0 &&
|
|
1564
|
-
actionIntentRetries < 3 &&
|
|
1565
2476
|
shouldRetryBeforeFinalizing) {
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
2477
|
+
let action;
|
|
2478
|
+
if (errorFixNarration && budgetRemaining(recovery, "errorFix")) {
|
|
2479
|
+
action = recoveryForErrorDiagnosis(toolsAttached);
|
|
2480
|
+
}
|
|
2481
|
+
else if (budgetRemaining(recovery, "actionIntent") &&
|
|
2482
|
+
planHasOpenWorkNow &&
|
|
2483
|
+
session.planApproved.value) {
|
|
2484
|
+
action = recoveryForNarration(toolsAttached, "plan_open");
|
|
1572
2485
|
}
|
|
1573
|
-
else if (
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
'```tool\n{"name":"sysinfo","args":{}}\n```\n' +
|
|
1577
|
-
"Every turn MUST contain a ```tool block until the task is done.";
|
|
1578
|
-
writeNotice("warn", "described a security/pentest action but emitted no tool call — nudging it to run one", chalk.yellow(" ⚠ described a security/pentest action but emitted no tool call — nudging it to run one\n"));
|
|
2486
|
+
else if (budgetRemaining(recovery, "actionIntent") &&
|
|
2487
|
+
pentestLikeTurn) {
|
|
2488
|
+
action = recoveryForNarration(toolsAttached, "pentest");
|
|
1579
2489
|
}
|
|
1580
|
-
else if (
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
// Previously every non-build stall used this path, so a "hi"
|
|
1584
|
-
// greeting that said "I'll start executing" was forced into
|
|
1585
|
-
// pointless web.search recovery loops.
|
|
1586
|
-
nudge =
|
|
1587
|
-
"You wrote that you would fetch/search/read something but emitted NO ```tool block, so NOTHING ran. Do NOT narrate the next browsing step — DO it. Emit exactly one valid ```tool block now. If you know the exact page, use:\n" +
|
|
1588
|
-
'```tool\n{"name":"web.fetch","args":{"url":"https://example.com/page","responseMode":"readable"}}\n```\n' +
|
|
1589
|
-
"If you do not know the exact page URL, use web.search first. After the tool output, answer from the fetched page content.";
|
|
1590
|
-
writeNotice("warn", "described a web action but emitted no tool call — nudging it to run one", chalk.yellow(" ⚠ described a web action but emitted no tool call — nudging it to run one\n"));
|
|
2490
|
+
else if (budgetRemaining(recovery, "actionIntent") &&
|
|
2491
|
+
(freshWebSearchRequired || narratedWebAction)) {
|
|
2492
|
+
action = recoveryForNarration(toolsAttached, "web");
|
|
1591
2493
|
}
|
|
1592
|
-
else if (
|
|
2494
|
+
else if (budgetRemaining(recovery, "actionIntent") &&
|
|
2495
|
+
buildLikeTurn &&
|
|
1593
2496
|
(planNarrated || productiveSteps > 0)) {
|
|
1594
|
-
|
|
1595
|
-
nudge =
|
|
1596
|
-
"You wrote the plan as PROSE but did NOT call plan.create, so no plan was saved and the user cannot /implement it. Emit it as a real tool call NOW — exactly one ```tool block:\n" +
|
|
1597
|
-
`\`\`\`tool\n{"name":"plan.create","args":{"goal":"<short goal>","detail":"<stack/approach and how you'll verify>","tasks":["task 1","task 2","task 3"],"kind":"${kind}"}}\n\`\`\`\n` +
|
|
1598
|
-
"Do not describe the plan again in prose — just emit the plan.create tool block.";
|
|
1599
|
-
writeNotice("warn", "plan was written as text, not created — nudging it to call plan.create", chalk.yellow(" ⚠ plan was written as text, not created — nudging it to call plan.create\n"));
|
|
2497
|
+
action = recoveryForNarration(toolsAttached, "build_plan_prose");
|
|
1600
2498
|
}
|
|
1601
|
-
else if (
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
'```tool\n{"name":"fs.list","args":{"path":"."}}\n```\n' +
|
|
1605
|
-
"Then read key files, and once you understand the directory, call plan.create. Every turn MUST contain a ```tool block until the task is done.";
|
|
1606
|
-
writeNotice("warn", "described an action but emitted no tool call — nudging it to run one", chalk.yellow(" ⚠ described an action but emitted no tool call — nudging it to run one\n"));
|
|
2499
|
+
else if (budgetRemaining(recovery, "actionIntent") &&
|
|
2500
|
+
buildLikeTurn) {
|
|
2501
|
+
action = recoveryForNarration(toolsAttached, "build");
|
|
1607
2502
|
}
|
|
1608
|
-
else {
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
2503
|
+
else if (budgetRemaining(recovery, "actionIntent")) {
|
|
2504
|
+
action = recoveryForNarration(toolsAttached, "generic");
|
|
2505
|
+
}
|
|
2506
|
+
if (action) {
|
|
2507
|
+
consumeBudget(recovery, action.budgetKey);
|
|
2508
|
+
writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
|
|
2509
|
+
pushAssistantHistory(assistantText.visible);
|
|
2510
|
+
messages.push(recoveryUserMessage(action.message));
|
|
2511
|
+
continue;
|
|
1613
2512
|
}
|
|
1614
|
-
pushAssistantHistory(assistantText.visible);
|
|
1615
|
-
messages.push(recoveryUserMessage(nudge));
|
|
1616
|
-
continue;
|
|
1617
2513
|
}
|
|
1618
2514
|
if (freshWebSearchRequired &&
|
|
1619
2515
|
!sawFreshWebSearch &&
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
2516
|
+
budgetRemaining(recovery, "freshnessUsed")) {
|
|
2517
|
+
const action = recoveryForFreshness(freshnessGuardMessage() +
|
|
2518
|
+
(toolsAttached
|
|
2519
|
+
? " Call the web_search tool now."
|
|
2520
|
+
: " Reply with ONLY a fenced ```tool block for web.search now."));
|
|
2521
|
+
consumeBudget(recovery, action.budgetKey);
|
|
2522
|
+
writeNotice("info", action.notice, chalk.dim(` ℹ ${action.notice}\n`));
|
|
1623
2523
|
pushAssistantHistory(assistantText.visible);
|
|
1624
|
-
messages.push({
|
|
1625
|
-
role: "user",
|
|
1626
|
-
content: freshnessGuardMessage() +
|
|
1627
|
-
" Reply with ONLY a fenced ```tool block for web.search now.",
|
|
1628
|
-
});
|
|
2524
|
+
messages.push({ role: "user", content: action.message });
|
|
1629
2525
|
continue;
|
|
1630
2526
|
}
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
2527
|
+
if (isPlanMode &&
|
|
2528
|
+
!informationalQuery &&
|
|
2529
|
+
!idleOrSocialPrompt &&
|
|
2530
|
+
budgetRemaining(recovery, "forcePlan")) {
|
|
2531
|
+
const planAtEnd = await loadPlan(session.sessionId).catch(() => undefined);
|
|
2532
|
+
if (!planAtEnd && !sawPlanCreateOk) {
|
|
2533
|
+
const action = recoveryForMissingPlan(toolsAttached);
|
|
2534
|
+
consumeBudget(recovery, action.budgetKey);
|
|
2535
|
+
pushAssistantHistory(assistantText.visible);
|
|
2536
|
+
messages.push({ role: "user", content: action.message });
|
|
2537
|
+
writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
|
|
2538
|
+
continue;
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
1635
2541
|
if (buildLike &&
|
|
1636
2542
|
!pentestLike &&
|
|
1637
2543
|
!pentestSession &&
|
|
1638
2544
|
session.planApproved.value &&
|
|
1639
|
-
|
|
1640
|
-
|
|
2545
|
+
featureAppAsk &&
|
|
2546
|
+
!sawFeatureImplWrite &&
|
|
2547
|
+
(sawScaffoldOk || sawLocalAppMaterialWork) &&
|
|
2548
|
+
productiveSteps > 0 &&
|
|
2549
|
+
budgetRemaining(recovery, "featureImpl")) {
|
|
2550
|
+
const action = recoveryForMissingFeature(getActiveProjectRoot());
|
|
2551
|
+
consumeBudget(recovery, action.budgetKey);
|
|
2552
|
+
pushAssistantHistory(assistantText.visible);
|
|
2553
|
+
messages.push({ role: "user", content: action.message });
|
|
2554
|
+
writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
|
|
2555
|
+
continue;
|
|
2556
|
+
}
|
|
2557
|
+
if (buildLike &&
|
|
2558
|
+
!pentestLike &&
|
|
2559
|
+
!pentestSession &&
|
|
2560
|
+
budgetRemaining(recovery, "runtimeVerify") &&
|
|
2561
|
+
(!featureAppAsk || sawFeatureImplWrite)) {
|
|
1641
2562
|
const runtimePlan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
if (
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
2563
|
+
// Durable plan evidence or multi-signal proof this turn is enough
|
|
2564
|
+
const planRuntimeOk = Boolean(runtimePlan && planHasVerifiedRuntime(runtimePlan));
|
|
2565
|
+
const sessionRuntimeOk = sawServerStart &&
|
|
2566
|
+
(sawServerTail || sawLocalHttpProbe || planRuntimeOk);
|
|
2567
|
+
if (!planRuntimeOk && !sessionRuntimeOk) {
|
|
2568
|
+
const codingPlanFinished = Boolean(runtimePlan &&
|
|
2569
|
+
session.planApproved.value &&
|
|
2570
|
+
runtimePlan.kind !== "pentest" &&
|
|
2571
|
+
runtimePlan.tasks.length > 0 &&
|
|
2572
|
+
runtimePlan.tasks.every((task) => task.state === "done" || task.state === "skipped"));
|
|
2573
|
+
const freestyleLocalAppDone = !session.planApproved.value &&
|
|
2574
|
+
sawLocalAppMaterialWork &&
|
|
2575
|
+
productiveSteps > 0 &&
|
|
2576
|
+
freestyleClaimsAppReady(cleaned) &&
|
|
2577
|
+
(getActiveProjectRoot() !== undefined ||
|
|
2578
|
+
/\b(?:npm|pnpm|yarn|bun)\s+run\s+dev\b/i.test(cleaned) ||
|
|
2579
|
+
/\bopen\s+http:\/\/localhost\b/i.test(cleaned));
|
|
2580
|
+
if (codingPlanFinished || freestyleLocalAppDone) {
|
|
2581
|
+
const action = recoveryForRuntimeVerify(getActiveProjectRoot());
|
|
2582
|
+
consumeBudget(recovery, action.budgetKey);
|
|
2583
|
+
pushAssistantHistory(assistantText.visible);
|
|
2584
|
+
messages.push({ role: "user", content: action.message });
|
|
2585
|
+
writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
|
|
2586
|
+
continue;
|
|
2587
|
+
}
|
|
1656
2588
|
}
|
|
1657
2589
|
}
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
2590
|
+
if (buildLike &&
|
|
2591
|
+
!pentestLike &&
|
|
2592
|
+
!pentestSession &&
|
|
2593
|
+
sawFailedLocalHttpProbe &&
|
|
2594
|
+
!sawLocalHttpProbe &&
|
|
2595
|
+
budgetRemaining(recovery, "failedProbe") &&
|
|
2596
|
+
cleaned.trim().length > 0) {
|
|
2597
|
+
const action = recoveryForFailedProbe();
|
|
2598
|
+
consumeBudget(recovery, action.budgetKey);
|
|
2599
|
+
pushAssistantHistory(assistantText.visible);
|
|
2600
|
+
messages.push({ role: "user", content: action.message });
|
|
2601
|
+
writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
|
|
2602
|
+
continue;
|
|
2603
|
+
}
|
|
2604
|
+
if ((pentestLike || pentestSession) &&
|
|
2605
|
+
budgetRemaining(recovery, "shallowPentest") &&
|
|
2606
|
+
looksLikeShallowPentestReport(cleaned, {
|
|
2607
|
+
productiveSteps,
|
|
2608
|
+
sawActiveTest: sawActivePentestTest,
|
|
2609
|
+
})) {
|
|
2610
|
+
const action = recoveryForShallowPentest();
|
|
2611
|
+
consumeBudget(recovery, action.budgetKey);
|
|
2612
|
+
pushAssistantHistory(assistantText.visible);
|
|
2613
|
+
messages.push({ role: "user", content: action.message });
|
|
2614
|
+
writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
|
|
2615
|
+
continue;
|
|
2616
|
+
}
|
|
2617
|
+
if (session.planApproved.value &&
|
|
2618
|
+
budgetRemaining(recovery, "prematureComplete")) {
|
|
1665
2619
|
const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
1666
2620
|
const unfinished = livePlan?.tasks.filter((t) => t.state === "pending" || t.state === "in_progress");
|
|
1667
2621
|
if (livePlan && unfinished && unfinished.length > 0) {
|
|
1668
|
-
prematureCompletionRetries += 1;
|
|
1669
2622
|
const next = unfinished[0];
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
instruction +=
|
|
1676
|
-
`call task.update {taskId:"${next.id}", state:"in_progress"}, then do the recon/testing work ` +
|
|
1677
|
-
`(dns/http/net.scan/http.fetch/tool.batch — NOT a local dev server), VERIFY with real tool output, ` +
|
|
1678
|
-
`then task.update done. Do NOT shell.start / npm run dev / explore the clai workspace. `;
|
|
1679
|
-
}
|
|
1680
|
-
else if (next.state === "pending") {
|
|
1681
|
-
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. `;
|
|
1682
|
-
}
|
|
1683
|
-
else {
|
|
1684
|
-
instruction += `do the real work with a tool call (fs.writeMany / shell.exec / shell.start when building a local app) to complete it, VERIFY it, and mark it done (call task.update {taskId:"${next.id}", state:"done"}). `;
|
|
1685
|
-
}
|
|
1686
|
-
instruction += `Continue task by task until EVERY task is actually finished.`;
|
|
1687
|
-
messages.push({
|
|
1688
|
-
role: "user",
|
|
1689
|
-
content: `You have NOT finished the approved plan: ${unfinished.length} task(s) remain ` +
|
|
1690
|
-
`(${unfinished.map((t) => `[${t.id}] ${t.title}`).join("; ")}). ` +
|
|
1691
|
-
`Do NOT claim the work is complete unless a tool call actually succeeded and you saw the output. ` +
|
|
1692
|
-
instruction,
|
|
2623
|
+
const action = recoveryForPrematureComplete({
|
|
2624
|
+
unfinished,
|
|
2625
|
+
next,
|
|
2626
|
+
pentest: livePlan.kind === "pentest" || pentestSession,
|
|
2627
|
+
errorFix: errorFixNarration,
|
|
1693
2628
|
});
|
|
2629
|
+
consumeBudget(recovery, action.budgetKey);
|
|
2630
|
+
writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
|
|
2631
|
+
pushAssistantHistory(assistantText.visible);
|
|
2632
|
+
messages.push({ role: "user", content: action.message });
|
|
1694
2633
|
continue;
|
|
1695
2634
|
}
|
|
1696
2635
|
}
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
// unchallenged — append an explicit, honest status so the user knows the
|
|
1700
|
-
// build did not actually complete.
|
|
1701
|
-
let completionWarning = "";
|
|
1702
|
-
let completionWarningText = "";
|
|
2636
|
+
let outcomeStatus = "succeeded";
|
|
2637
|
+
const remainingCriteria = [];
|
|
1703
2638
|
if (session.planApproved.value) {
|
|
1704
2639
|
const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
1705
|
-
const unfinished = livePlan?.tasks.filter((t) => t.state === "pending" || t.state === "in_progress");
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
unfinished
|
|
1713
|
-
.map((t) => chalk.yellow(` • [${t.id}] ${t.title}`))
|
|
1714
|
-
.join("\n") +
|
|
1715
|
-
chalk.dim("\n The summary above may overstate progress. Re-run with /implement, or ask clai to finish the remaining tasks.\n");
|
|
1716
|
-
}
|
|
2640
|
+
const unfinished = livePlan?.tasks.filter((t) => t.state === "pending" || t.state === "in_progress") ?? [];
|
|
2641
|
+
const failedTasks = livePlan?.tasks.filter((t) => t.state === "failed") ?? [];
|
|
2642
|
+
remainingCriteria.push(...unfinished.map((task) => `[${task.id}] ${task.title}`), ...failedTasks.map((task) => `[${task.id}] retry failed task: ${task.title}`));
|
|
2643
|
+
if (failedTasks.length > 0)
|
|
2644
|
+
outcomeStatus = "failed";
|
|
2645
|
+
else if (unfinished.length > 0)
|
|
2646
|
+
outcomeStatus = "partial";
|
|
1717
2647
|
}
|
|
1718
|
-
|
|
1719
|
-
|
|
2648
|
+
recordAnswerEvidence(outcomeState, cleaned);
|
|
2649
|
+
outcomeState.outcome.status = deriveOutcomeStatus(outcomeState.outcome, outcomeState.evidence);
|
|
2650
|
+
await saveOutcomeState(outcomeState);
|
|
2651
|
+
const unsupportedCriteria = outcomeState.outcome.criteria.filter((criterion) => criterion.required &&
|
|
2652
|
+
!validateCriterionEvidence(criterion, outcomeState.evidence).ok);
|
|
2653
|
+
if (unsupportedCriteria.length > 0 && outcomeStatus === "succeeded") {
|
|
2654
|
+
outcomeStatus = "partial";
|
|
1720
2655
|
}
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
2656
|
+
remainingCriteria.push(...unsupportedCriteria
|
|
2657
|
+
.map((criterion) => criterion.statement)
|
|
2658
|
+
.filter((statement) => !remainingCriteria.includes(statement)));
|
|
2659
|
+
moveTurn("verifying", "evaluating current criterion-linked evidence");
|
|
2660
|
+
moveTurn(outcomeStatus, `turn completed with ${outcomeStatus} evidence status`);
|
|
2661
|
+
await auditLog("agent.final", {
|
|
2662
|
+
provider,
|
|
2663
|
+
model,
|
|
2664
|
+
steps: step + 1,
|
|
2665
|
+
outcomeStatus,
|
|
2666
|
+
remainingCriteria,
|
|
2667
|
+
});
|
|
1725
2668
|
lastAnswer = cleaned;
|
|
1726
|
-
return finishTurn(lastAnswer, step + 1
|
|
2669
|
+
return finishTurn(lastAnswer, step + 1, outcomeStatus, remainingCriteria, outcomeStatus === "failed"
|
|
2670
|
+
? "One or more required plan tasks failed."
|
|
2671
|
+
: outcomeStatus === "partial"
|
|
2672
|
+
? "Required outcome criteria remain unsupported by current evidence."
|
|
2673
|
+
: undefined);
|
|
1727
2674
|
}
|
|
1728
2675
|
// A valid primary tool call exists for this fresh model turn. Show any
|
|
1729
2676
|
// prose / thinking that preceded it, record the assistant message ONCE.
|
|
1730
2677
|
const beforeTool = recoveredFromBareJson
|
|
1731
2678
|
? ""
|
|
1732
|
-
:
|
|
2679
|
+
: nativeToolCalls.length
|
|
2680
|
+
? assistantText.visible.trim()
|
|
2681
|
+
: textBeforeToolCall(assistantText.visible);
|
|
1733
2682
|
if (beforeTool) {
|
|
1734
2683
|
writeAssistantMessage(beforeTool);
|
|
1735
2684
|
}
|
|
1736
|
-
let
|
|
1737
|
-
if (
|
|
1738
|
-
|
|
2685
|
+
let bound = [];
|
|
2686
|
+
if (nativeToolCalls.length) {
|
|
2687
|
+
bound = nativeToolCalls.map((tc, index) => {
|
|
2688
|
+
const call = tc.args?._parseError
|
|
2689
|
+
? {
|
|
2690
|
+
name: tc.name || "unknown",
|
|
2691
|
+
args: {
|
|
2692
|
+
__nativeParseError: true,
|
|
2693
|
+
_raw: tc.args._raw,
|
|
2694
|
+
},
|
|
2695
|
+
}
|
|
2696
|
+
: normalizeToolCall({ name: tc.name, args: tc.args });
|
|
2697
|
+
return { index, id: tc.id, call, native: tc };
|
|
2698
|
+
});
|
|
1739
2699
|
}
|
|
2700
|
+
else {
|
|
2701
|
+
let parsed = parseAllToolCalls(assistantText.visible || assistantText.thinkContent);
|
|
2702
|
+
if (parsed.length === 0 && call)
|
|
2703
|
+
parsed = [call];
|
|
2704
|
+
bound = parsed.map((c, index) => {
|
|
2705
|
+
const id = syntheticToolCallId(index);
|
|
2706
|
+
return {
|
|
2707
|
+
index,
|
|
2708
|
+
id,
|
|
2709
|
+
call: c,
|
|
2710
|
+
native: { id, name: c.name, args: c.args },
|
|
2711
|
+
};
|
|
2712
|
+
});
|
|
2713
|
+
}
|
|
2714
|
+
/** Subset that will actually run this turn (defer/omit rest). */
|
|
2715
|
+
let toRun = bound;
|
|
1740
2716
|
let activeDeferredToolCalls = deferredToolCalls;
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
// emits plan.create alongside gathering calls, run only the calls
|
|
1744
|
-
// before it, then let the next model turn analyse their actual results
|
|
1745
|
-
// and emit one standalone plan.create. Calls after the attempted plan
|
|
1746
|
-
// are intentionally discarded: they were proposed before a plan was
|
|
1747
|
-
// created or approved.
|
|
1748
|
-
const planCallIndex = allCalls.findIndex((candidate) => candidate.name === "plan.create");
|
|
2717
|
+
let deferReason = "Cancelled — not executed this turn (deferred or omitted).";
|
|
2718
|
+
const planCallIndex = bound.findIndex((b) => b.call.name === "plan.create");
|
|
1749
2719
|
if (planCallIndex > 0) {
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
allCalls = gatheringCalls;
|
|
1757
|
-
activeDeferredToolCalls = deferredToolCalls.slice(0, gatheringCalls.length);
|
|
1758
|
-
writeNotice("info", "deferring plan.create until reconnaissance results are available", chalk.dim(` ℹ running ${gatheringCalls.length} gathering call(s); ${deferredCount} plan/follow-on call(s) deferred for evidence-based planning\n`));
|
|
2720
|
+
const deferredCount = bound.length - planCallIndex;
|
|
2721
|
+
toRun = bound.slice(0, planCallIndex);
|
|
2722
|
+
activeDeferredToolCalls = deferredToolCalls.slice(0, planCallIndex);
|
|
2723
|
+
deferReason =
|
|
2724
|
+
"Deferred — plan.create must wait until reconnaissance results exist.";
|
|
2725
|
+
writeNotice("info", "deferring plan.create until reconnaissance results are available", chalk.dim(` ℹ running ${toRun.length} gathering call(s); ${deferredCount} plan/follow-on call(s) deferred for evidence-based planning\n`));
|
|
1759
2726
|
messages.push({
|
|
1760
2727
|
role: "system",
|
|
1761
2728
|
content: `The prior response included plan.create before its reconnaissance results existed. ` +
|
|
1762
|
-
`Only the ${
|
|
2729
|
+
`Only the ${toRun.length} gathering call(s) before it were run; ${deferredCount} plan/follow-on call(s) were not run. ` +
|
|
1763
2730
|
"Now analyse the tool results. If a plan is appropriate, emit exactly one standalone plan.create tool call based only on those results. Do not include any other tool calls in that response.",
|
|
1764
2731
|
});
|
|
1765
2732
|
}
|
|
1766
|
-
else if (planCallIndex === 0 &&
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
// history), so execute the plan.create now and defer only the calls
|
|
1770
|
-
// proposed after it — those were proposed before the plan was
|
|
1771
|
-
// created or approved.
|
|
1772
|
-
const deferredCount = allCalls.length - 1;
|
|
1773
|
-
allCalls = allCalls.slice(0, 1);
|
|
2733
|
+
else if (planCallIndex === 0 && bound.length > 1) {
|
|
2734
|
+
const deferredCount = bound.length - 1;
|
|
2735
|
+
toRun = bound.slice(0, 1);
|
|
1774
2736
|
activeDeferredToolCalls = deferredToolCalls.slice(0, 1);
|
|
2737
|
+
deferReason =
|
|
2738
|
+
"Deferred — waiting for plan approval before follow-on tools.";
|
|
1775
2739
|
writeNotice("info", "creating the plan now; deferring follow-on calls until it is approved", chalk.dim(` ℹ running plan.create from prior reconnaissance; ${deferredCount} follow-on call(s) deferred until after approval\n`));
|
|
1776
2740
|
messages.push({
|
|
1777
2741
|
role: "system",
|
|
@@ -1779,68 +2743,37 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1779
2743
|
`the follow-on call(s) were not. Wait for the plan to be reviewed, then proceed task by task.`,
|
|
1780
2744
|
});
|
|
1781
2745
|
}
|
|
1782
|
-
//
|
|
1783
|
-
//
|
|
1784
|
-
//
|
|
1785
|
-
//
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
const
|
|
1792
|
-
const omittedCallCount = Math.max(0, allCalls.length - MAX_CALLS_PER_MODEL_TURN);
|
|
1793
|
-
if (omittedCallCount > 0) {
|
|
1794
|
-
allCalls = allCalls.slice(0, MAX_CALLS_PER_MODEL_TURN);
|
|
1795
|
-
activeDeferredToolCalls = activeDeferredToolCalls.slice(0, MAX_CALLS_PER_MODEL_TURN);
|
|
1796
|
-
writeNotice("warn", `limited this model response to ${MAX_CALLS_PER_MODEL_TURN} tool calls`, chalk.yellow(` ⚠ executing the first ${MAX_CALLS_PER_MODEL_TURN} tool calls; ${omittedCallCount} more were deferred for reprioritisation\n`));
|
|
1797
|
-
messages.push({
|
|
1798
|
-
role: "system",
|
|
1799
|
-
content: `You emitted too many tool calls in one response. Only the first ${MAX_CALLS_PER_MODEL_TURN} were executed; ` +
|
|
1800
|
-
`${omittedCallCount} were not run. After reviewing results, issue a small, prioritized next batch.`,
|
|
1801
|
-
});
|
|
1802
|
-
}
|
|
2746
|
+
// Preserve model/document order. In particular, never move a later
|
|
2747
|
+
// in_progress transition ahead of the preceding work or done receipt;
|
|
2748
|
+
// doing so inverts dependency order and desynchronizes the task pane.
|
|
2749
|
+
// Re-index toRun positions for UI callIds[] (0..n-1 this turn).
|
|
2750
|
+
toRun = toRun.map((b, index) => ({ ...b, index }));
|
|
2751
|
+
const allCalls = toRun.map((b) => b.call);
|
|
2752
|
+
/** Stable call→Bound map (object identity; no indexOf for result ids). */
|
|
2753
|
+
const callToBound = new Map(toRun.map((b) => [b.call, b]));
|
|
2754
|
+
const historyNativeCalls = bound.map((b) => b.native);
|
|
2755
|
+
const runIds = new Set(toRun.map((b) => b.id));
|
|
1803
2756
|
// Notice BEFORE tool cards so the transcript reads:
|
|
1804
2757
|
// thinking → response → "N tool calls…" → tool cards (not tools then info).
|
|
1805
2758
|
if (allCalls.length > 1) {
|
|
1806
2759
|
writeNotice("info", `${allCalls.length} tool calls in this message — running scoped (independent read-only lookups in parallel, everything else in order)`, chalk.dim(` ℹ ${allCalls.length} tool calls — read-only lookups in parallel, the rest in order\n`));
|
|
1807
2760
|
}
|
|
1808
|
-
// Emit only the calls that will actually execute, after thinking
|
|
1809
|
-
// + assistant text so transcript order remains correct.
|
|
1810
2761
|
for (const deferred of activeDeferredToolCalls.slice(0, allCalls.length)) {
|
|
2762
|
+
if (!deferred.call.name || deferred.call.name === "…")
|
|
2763
|
+
continue;
|
|
1811
2764
|
writeToolCall(deferred.eventId, deferred.call, deferred.rendered);
|
|
1812
2765
|
}
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
// independent lookups within a single task fan out (e.g. whois +
|
|
1824
|
-
// dns + http.fetch during recon).
|
|
1825
|
-
// • Every other call (plan.create/task.update, and any mutating or
|
|
1826
|
-
// confirm-level tool: fs.write*, shell.exec, pkg.install, net.scan)
|
|
1827
|
-
// runs ALONE as a sequential barrier.
|
|
1828
|
-
// Because task.update is never parallel-safe, it always acts as a
|
|
1829
|
-
// barrier: it commits before the work it gates and after the work it
|
|
1830
|
-
// closes. That keeps execution strictly task-by-task and eliminates the
|
|
1831
|
-
// plan-state races / overlapping writes that a blanket Promise.all
|
|
1832
|
-
// caused, while still letting one task's independent lookups run in
|
|
1833
|
-
// parallel. A failed independent read-only lookup does not prevent
|
|
1834
|
-
// later recon from running; aborts, blocks, and sequential-barrier
|
|
1835
|
-
// failures still stop the batch so the model can react safely.
|
|
2766
|
+
if (historyNativeCalls.length) {
|
|
2767
|
+
appendAssistantWithTools(messages, beforeTool ?? "", historyNativeCalls);
|
|
2768
|
+
}
|
|
2769
|
+
else {
|
|
2770
|
+
const standardizedContent = (beforeTool ? beforeTool.trim() + "\n\n" : "") +
|
|
2771
|
+
allCalls
|
|
2772
|
+
.map((c) => `\`\`\`tool\n${JSON.stringify(c)}\n\`\`\``)
|
|
2773
|
+
.join("\n\n");
|
|
2774
|
+
pushAssistantHistory(standardizedContent);
|
|
2775
|
+
}
|
|
1836
2776
|
const scopeForBatch = await loadScope().catch(() => undefined);
|
|
1837
|
-
/**
|
|
1838
|
-
* Tools that may share a concurrent wave. Pure read-only BATCH_SAFE
|
|
1839
|
-
* tools always qualify when classified safe. Heavy discovery wrappers
|
|
1840
|
-
* (pentest.recon, net.context) also run concurrent with dns/http so
|
|
1841
|
-
* nmap does not serialize the entire recon wave.
|
|
1842
|
-
* net.scan stays a barrier when it needs confirm/sudo UX.
|
|
1843
|
-
*/
|
|
1844
2777
|
const isParallelSafe = (c) => {
|
|
1845
2778
|
if (c.name === "pentest.recon" ||
|
|
1846
2779
|
c.name === "net.context" ||
|
|
@@ -1861,19 +2794,10 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1861
2794
|
};
|
|
1862
2795
|
/** Tools whose failure must NOT cancel sibling calls in this turn. */
|
|
1863
2796
|
const shouldSoftFailTool = (name) => {
|
|
1864
|
-
if (name
|
|
2797
|
+
if (isBatchSoftFailTool(name))
|
|
1865
2798
|
return true;
|
|
1866
2799
|
if (BATCH_SAFE_TOOLS.has(name))
|
|
1867
2800
|
return true;
|
|
1868
|
-
if (name === "pentest.recon" ||
|
|
1869
|
-
name === "net.scan" ||
|
|
1870
|
-
name === "net.pingSweep" ||
|
|
1871
|
-
name === "net.context" ||
|
|
1872
|
-
name === "shell.jobs" ||
|
|
1873
|
-
name === "shell.tail" ||
|
|
1874
|
-
name === "tool.check") {
|
|
1875
|
-
return true;
|
|
1876
|
-
}
|
|
1877
2801
|
return false;
|
|
1878
2802
|
};
|
|
1879
2803
|
// Recon waves often emit 6–10 lookups; 4 forced a second sequential wave.
|
|
@@ -1883,34 +2807,114 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1883
2807
|
let blockedResult = null;
|
|
1884
2808
|
let failed = false;
|
|
1885
2809
|
let awaitingPlanApproval = false;
|
|
1886
|
-
/**
|
|
1887
|
-
const
|
|
1888
|
-
const recordResult = (res, continueAfterFailure = false) => {
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
2810
|
+
/** Native tool_call ids that already have a role:tool history entry. */
|
|
2811
|
+
const recordedNativeIds = new Set();
|
|
2812
|
+
const recordResult = (boundCall, res, continueAfterFailure = false) => {
|
|
2813
|
+
recordedNativeIds.add(boundCall.id);
|
|
2814
|
+
const toolContent = `Tool ${res.call.name} result (exit=${res.result.exitCode ?? 0}, ok=${res.result.ok}):\n${res.contextOutput}`;
|
|
2815
|
+
if (historyNativeCalls.length) {
|
|
2816
|
+
appendToolResult(messages, boundCall.id, toolContent, res.call.name, res.result.ok);
|
|
2817
|
+
}
|
|
2818
|
+
else {
|
|
2819
|
+
messages.push({
|
|
2820
|
+
role: "tool",
|
|
2821
|
+
content: toolContent,
|
|
2822
|
+
});
|
|
2823
|
+
}
|
|
1896
2824
|
productiveSteps += 1;
|
|
1897
2825
|
// Reset retry counters — they track consecutive failures, not cumulative.
|
|
1898
2826
|
truncatedToolRetries = 0;
|
|
1899
2827
|
malformedFenceRetries = 0;
|
|
1900
2828
|
bareToolJsonRetries = 0;
|
|
2829
|
+
if (res.ok && isEvidenceWorkTool(res.call.name)) {
|
|
2830
|
+
recovery.prematureComplete = 0;
|
|
2831
|
+
recovery.actionIntent = 0;
|
|
2832
|
+
recovery.errorFix = 0;
|
|
2833
|
+
}
|
|
1901
2834
|
if (res.ok && res.call.name === "shell.start")
|
|
1902
2835
|
sawServerStart = true;
|
|
1903
|
-
if (res.ok && res.call.name === "shell.tail")
|
|
2836
|
+
if (res.ok && res.call.name === "shell.tail") {
|
|
1904
2837
|
sawServerTail = true;
|
|
2838
|
+
const tailOut = res.result.output ?? res.contextOutput ?? "";
|
|
2839
|
+
if (isServerReadyOutput(tailOut)) {
|
|
2840
|
+
sawServerStart = true;
|
|
2841
|
+
sawServerTail = true;
|
|
2842
|
+
}
|
|
2843
|
+
}
|
|
2844
|
+
if (res.ok &&
|
|
2845
|
+
res.call.name === "shell.exec" &&
|
|
2846
|
+
isPortListeningOutput(String(res.call.args.command ?? ""), res.result.output ?? res.contextOutput ?? "")) {
|
|
2847
|
+
sawServerStart = true;
|
|
2848
|
+
}
|
|
2849
|
+
if (res.ok &&
|
|
2850
|
+
(pentestLike || pentestSession) &&
|
|
2851
|
+
(res.call.name === "http.fetch" ||
|
|
2852
|
+
res.call.name === "shell.exec" ||
|
|
2853
|
+
res.call.name === "net.scan" ||
|
|
2854
|
+
res.call.name === "pentest.recon")) {
|
|
2855
|
+
const blob = `${res.call.name} ${JSON.stringify(res.call.args)}`;
|
|
2856
|
+
if (/\b(sqlmap|hydra|nikto|nuclei|ffuf|gobuster|exploit|payload|idor|xss|union\s+select)\b/i.test(blob) ||
|
|
2857
|
+
(res.call.name === "http.fetch" &&
|
|
2858
|
+
typeof res.call.args.method === "string" &&
|
|
2859
|
+
!/^get$/i.test(res.call.args.method))) {
|
|
2860
|
+
sawActivePentestTest = true;
|
|
2861
|
+
}
|
|
2862
|
+
}
|
|
1905
2863
|
if (res.ok &&
|
|
1906
2864
|
((res.call.name === "http.fetch" &&
|
|
1907
2865
|
/^(?:https?:\/\/)?(?:localhost|127\.0\.0\.1|\[::1\])(?::|\/|$)/i.test(String(res.call.args.url ?? ""))) ||
|
|
1908
2866
|
(res.call.name === "shell.exec" &&
|
|
1909
2867
|
/\bcurl\b[\s\S]*\b(?:localhost|127\.0\.0\.1|\[::1\])\b/i.test(String(res.call.args.command ?? ""))))) {
|
|
1910
|
-
|
|
2868
|
+
const out = res.result.output ?? res.contextOutput ?? "";
|
|
2869
|
+
if (localHttpProbeIsFailure(out)) {
|
|
2870
|
+
sawFailedLocalHttpProbe = true;
|
|
2871
|
+
sawLocalHttpProbe = false;
|
|
2872
|
+
}
|
|
2873
|
+
else if (localHttpProbeIsSuccess(out)) {
|
|
2874
|
+
sawLocalHttpProbe = true;
|
|
2875
|
+
sawFailedLocalHttpProbe = false;
|
|
2876
|
+
recovery.failedProbe = 0;
|
|
2877
|
+
}
|
|
2878
|
+
else if (res.call.name === "shell.exec" &&
|
|
2879
|
+
!localHttpProbeIsFailure(out)) {
|
|
2880
|
+
// curl without status line — soft success
|
|
2881
|
+
sawLocalHttpProbe = true;
|
|
2882
|
+
sawFailedLocalHttpProbe = false;
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
2885
|
+
// Track freestyle local-app materialization (scaffold / install / feature write)
|
|
2886
|
+
if (res.ok) {
|
|
2887
|
+
const cmd = typeof res.call.args.command === "string"
|
|
2888
|
+
? res.call.args.command
|
|
2889
|
+
: "";
|
|
2890
|
+
const pathArg = typeof res.call.args.path === "string" ? res.call.args.path : "";
|
|
2891
|
+
if (isScaffoldCreateCommand(cmd)) {
|
|
2892
|
+
sawScaffoldOk = true;
|
|
2893
|
+
sawLocalAppMaterialWork = true;
|
|
2894
|
+
}
|
|
2895
|
+
if (isFeatureImplementationCall(res.call)) {
|
|
2896
|
+
sawFeatureImplWrite = true;
|
|
2897
|
+
sawLocalAppMaterialWork = true;
|
|
2898
|
+
}
|
|
2899
|
+
if (/\b(?:npm|pnpm|yarn|bun)\s+i(?:nstall)?\b/i.test(cmd) ||
|
|
2900
|
+
res.call.name === "fs.write" ||
|
|
2901
|
+
res.call.name === "fs.writeMany" ||
|
|
2902
|
+
res.call.name === "fs.edit" ||
|
|
2903
|
+
(pathArg &&
|
|
2904
|
+
getActiveProjectRoot() &&
|
|
2905
|
+
(pathArg.includes(getActiveProjectRoot()) ||
|
|
2906
|
+
!pathArg.startsWith("/")))) {
|
|
2907
|
+
sawLocalAppMaterialWork = true;
|
|
2908
|
+
}
|
|
1911
2909
|
}
|
|
1912
2910
|
if (res.call.name === "plan.create" && res.ok) {
|
|
1913
|
-
|
|
2911
|
+
sawPlanCreateOk = true;
|
|
2912
|
+
if (isPlanMode) {
|
|
2913
|
+
awaitingPlanApproval = true;
|
|
2914
|
+
}
|
|
2915
|
+
else {
|
|
2916
|
+
session.planApproved.value = true;
|
|
2917
|
+
}
|
|
1914
2918
|
}
|
|
1915
2919
|
if (res.lastAnswer === "Aborted.")
|
|
1916
2920
|
aborted = true;
|
|
@@ -1923,98 +2927,119 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
1923
2927
|
};
|
|
1924
2928
|
const groups = groupToolCallsForExecution(allCalls, isParallelSafe, PARALLEL_LIMIT);
|
|
1925
2929
|
for (const group of groups) {
|
|
1926
|
-
if (aborted ||
|
|
2930
|
+
if (aborted ||
|
|
2931
|
+
blocked ||
|
|
2932
|
+
failed ||
|
|
2933
|
+
awaitingPlanApproval ||
|
|
2934
|
+
governorPauseReason)
|
|
1927
2935
|
break;
|
|
1928
2936
|
if (group.length === 1) {
|
|
1929
2937
|
const call = group[0];
|
|
1930
|
-
const
|
|
1931
|
-
if (
|
|
1932
|
-
|
|
2938
|
+
const bc = callToBound.get(call);
|
|
2939
|
+
if (!bc)
|
|
2940
|
+
continue;
|
|
2941
|
+
if (!callIds[bc.index]) {
|
|
2942
|
+
callIds[bc.index] = `tool-${++nextToolEventId}`;
|
|
1933
2943
|
}
|
|
1934
|
-
const id =
|
|
2944
|
+
const id = callIds[bc.index];
|
|
1935
2945
|
const res = await executeSingleTool(call, id, options.signal || new AbortController().signal);
|
|
1936
|
-
// Soft-fail recon / read-only / discovery tools so a stalled
|
|
1937
|
-
// whois or failed lookup never cancels the rest of the turn with
|
|
1938
|
-
// "Cancelled — earlier tool in this batch failed."
|
|
1939
2946
|
const softFail = shouldSoftFailTool(call.name);
|
|
1940
|
-
recordResult(res, softFail);
|
|
2947
|
+
recordResult(bc, res, softFail);
|
|
1941
2948
|
}
|
|
1942
2949
|
else {
|
|
1943
|
-
// Concurrent group —
|
|
1944
|
-
|
|
1945
|
-
const
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
2950
|
+
// Concurrent group — BoundCall via Map; record in document order.
|
|
2951
|
+
const groupBound = [];
|
|
2952
|
+
const uiIds = [];
|
|
2953
|
+
for (const c of group) {
|
|
2954
|
+
const bc = callToBound.get(c);
|
|
2955
|
+
if (!bc)
|
|
2956
|
+
continue;
|
|
2957
|
+
if (!callIds[bc.index]) {
|
|
2958
|
+
callIds[bc.index] = `tool-${++nextToolEventId}`;
|
|
1949
2959
|
}
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
recordResult(res, true);
|
|
1958
|
-
}
|
|
1959
|
-
}
|
|
1960
|
-
// Cards were emitted for every call up front. If the batch stopped
|
|
1961
|
-
// early (failure / abort / plan gate), any card still on "running"
|
|
1962
|
-
// must get a terminal result so the TUI never spins forever.
|
|
1963
|
-
for (let i = 0; i < allCalls.length; i += 1) {
|
|
1964
|
-
if (executedIndices.has(i))
|
|
1965
|
-
continue;
|
|
1966
|
-
const call = allCalls[i];
|
|
1967
|
-
if (i >= 0 && !callIds[i]) {
|
|
1968
|
-
callIds[i] = `tool-${++nextToolEventId}`;
|
|
2960
|
+
groupBound.push(bc);
|
|
2961
|
+
uiIds.push(callIds[bc.index]);
|
|
2962
|
+
}
|
|
2963
|
+
const results = await Promise.all(groupBound.map((bc, k) => executeSingleTool(bc.call, uiIds[k], options.signal || new AbortController().signal)));
|
|
2964
|
+
for (let k = 0; k < results.length; k += 1) {
|
|
2965
|
+
recordResult(groupBound[k], results[k], true);
|
|
2966
|
+
}
|
|
1969
2967
|
}
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
2968
|
+
}
|
|
2969
|
+
// Cards still "running" get a terminal UI result; history always pairs.
|
|
2970
|
+
for (let i = 0; i < toRun.length; i += 1) {
|
|
2971
|
+
const bc = toRun[i];
|
|
2972
|
+
if (recordedNativeIds.has(bc.id))
|
|
1973
2973
|
continue;
|
|
2974
|
+
if (!callIds[i]) {
|
|
2975
|
+
callIds[i] = `tool-${++nextToolEventId}`;
|
|
1974
2976
|
}
|
|
2977
|
+
const uiId = callIds[i];
|
|
1975
2978
|
const reason = aborted
|
|
1976
2979
|
? "Cancelled — turn aborted before this call ran."
|
|
1977
2980
|
: blocked
|
|
1978
2981
|
? "Cancelled — earlier tool was blocked or declined."
|
|
1979
2982
|
: awaitingPlanApproval
|
|
1980
2983
|
? "Deferred — waiting for plan approval."
|
|
1981
|
-
:
|
|
1982
|
-
?
|
|
1983
|
-
:
|
|
2984
|
+
: governorPauseReason
|
|
2985
|
+
? `Deferred — progress governor paused execution: ${governorPauseReason}`
|
|
2986
|
+
: failed
|
|
2987
|
+
? "Cancelled — earlier tool in this batch failed."
|
|
2988
|
+
: "Cancelled — not executed.";
|
|
1984
2989
|
const result = {
|
|
1985
2990
|
ok: false,
|
|
1986
2991
|
output: reason,
|
|
1987
2992
|
exitCode: 130,
|
|
1988
2993
|
};
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
2994
|
+
if (alreadyPrintedIds.has(uiId)) {
|
|
2995
|
+
emitToolResult(uiId, result, reason);
|
|
2996
|
+
}
|
|
2997
|
+
if (historyNativeCalls.length) {
|
|
2998
|
+
appendToolResult(messages, bc.id, `Tool ${bc.call.name} result (exit=130, ok=false):\n${reason}`, bc.call.name, false);
|
|
2999
|
+
recordedNativeIds.add(bc.id);
|
|
3000
|
+
}
|
|
3001
|
+
else {
|
|
3002
|
+
messages.push({
|
|
3003
|
+
role: "tool",
|
|
3004
|
+
content: `Tool ${bc.call.name} result (exit=130, ok=false):\n${reason}`,
|
|
3005
|
+
});
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
3008
|
+
// Synthetic results for deferred/omitted ids still listed on assistant.
|
|
3009
|
+
if (historyNativeCalls.length) {
|
|
3010
|
+
for (const b of bound) {
|
|
3011
|
+
if (runIds.has(b.id) || recordedNativeIds.has(b.id))
|
|
3012
|
+
continue;
|
|
3013
|
+
appendToolResult(messages, b.id, `Tool ${b.call.name} result (exit=130, ok=false):\n${deferReason}`, b.call.name, false);
|
|
3014
|
+
recordedNativeIds.add(b.id);
|
|
3015
|
+
}
|
|
3016
|
+
fillMissingToolResults(messages, historyNativeCalls, "Cancelled — not executed this turn.");
|
|
3017
|
+
}
|
|
3018
|
+
if (deferredPostToolMessages.length > 0) {
|
|
3019
|
+
messages.push(...deferredPostToolMessages.splice(0));
|
|
1994
3020
|
}
|
|
1995
|
-
// plan.create is a hard transaction boundary. Its successful handler
|
|
1996
|
-
// persists and displays the plan; returning immediately prevents a
|
|
1997
|
-
// stale pre-loop activePlan snapshot from nudging a duplicate plan and
|
|
1998
|
-
// prevents calls accidentally batched after plan.create from executing
|
|
1999
|
-
// before /implement approval.
|
|
2000
3021
|
if (awaitingPlanApproval) {
|
|
2001
3022
|
pendingCalls = [];
|
|
2002
|
-
|
|
3023
|
+
outcomeState.outcome.status = "partial";
|
|
3024
|
+
await saveOutcomeState(outcomeState);
|
|
3025
|
+
moveTurn("partial", "draft plan awaits approval");
|
|
3026
|
+
return finishTurn("", productiveSteps, "partial", ["Approve or revise the draft plan before implementation."]);
|
|
2003
3027
|
}
|
|
2004
3028
|
if (aborted) {
|
|
2005
3029
|
lastAnswer = "Aborted.";
|
|
3030
|
+
outcomeState.outcome.status = "aborted";
|
|
3031
|
+
await saveOutcomeState(outcomeState);
|
|
3032
|
+
moveTurn("aborted", "turn aborted");
|
|
2006
3033
|
writeAbort();
|
|
2007
|
-
return finishTurn(lastAnswer, productiveSteps);
|
|
3034
|
+
return finishTurn(lastAnswer, productiveSteps, "aborted");
|
|
2008
3035
|
}
|
|
2009
3036
|
if (blocked && blockedResult) {
|
|
2010
3037
|
lastAnswer = blockedResult.lastAnswer || "Blocked or Cancelled.";
|
|
2011
|
-
|
|
3038
|
+
outcomeState.outcome.status = "blocked";
|
|
3039
|
+
await saveOutcomeState(outcomeState);
|
|
3040
|
+
moveTurn("blocked", lastAnswer);
|
|
3041
|
+
return finishTurn(lastAnswer, productiveSteps, "blocked");
|
|
2012
3042
|
}
|
|
2013
|
-
// A plain failure just stops the remaining calls; we fall through so
|
|
2014
|
-
// the model sees the failed tool's output and decides what to do next.
|
|
2015
|
-
// Compact older messages when the running estimate exceeds budget. Uses
|
|
2016
|
-
// the model-written summary path (with plan re-injection) — never a
|
|
2017
|
-
// mechanical transcript dump.
|
|
2018
3043
|
await maybeAutoCompact("post-tool-token-budget");
|
|
2019
3044
|
if (options.onMessages) {
|
|
2020
3045
|
try {
|
|
@@ -2026,16 +3051,20 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2026
3051
|
}
|
|
2027
3052
|
}
|
|
2028
3053
|
}
|
|
2029
|
-
// maxIterations ceiling reached (safety net — normally the step budget
|
|
2030
|
-
// gate with user confirmation handles stopping gracefully).
|
|
2031
3054
|
const richSummary = await buildRichStopSummary(messages, session, productiveSteps);
|
|
2032
|
-
writeAssistantMessage(richSummary);
|
|
2033
3055
|
lastAnswer = richSummary;
|
|
2034
|
-
|
|
3056
|
+
outcomeState.outcome.status = "paused_budget";
|
|
3057
|
+
await saveOutcomeState(outcomeState);
|
|
3058
|
+
moveTurn("paused_budget", "emergency iteration ceiling reached");
|
|
3059
|
+
return finishTurn(lastAnswer, productiveSteps, "paused_budget", ["Continue unfinished work in a subsequent turn."], "The emergency iteration ceiling was reached.");
|
|
2035
3060
|
}
|
|
2036
3061
|
catch (error) {
|
|
2037
3062
|
const isAbort = isAbortError(error, options.signal);
|
|
2038
3063
|
const msg = isAbort ? "Aborted." : `Error: ${error instanceof Error ? error.message : String(error)}`;
|
|
3064
|
+
if (isAbort) {
|
|
3065
|
+
writeAbort();
|
|
3066
|
+
return finishTurn(msg, 0, "aborted", [], "The turn was aborted.");
|
|
3067
|
+
}
|
|
2039
3068
|
if (options.onMessages) {
|
|
2040
3069
|
try {
|
|
2041
3070
|
options.onMessages(buildTurnHistory(liveMessages, msg));
|
|
@@ -2044,10 +3073,6 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2044
3073
|
// ignore
|
|
2045
3074
|
}
|
|
2046
3075
|
}
|
|
2047
|
-
if (isAbort) {
|
|
2048
|
-
writeAbort();
|
|
2049
|
-
return "Aborted.";
|
|
2050
|
-
}
|
|
2051
3076
|
emit({
|
|
2052
3077
|
type: "turn-error",
|
|
2053
3078
|
message: error instanceof Error ? error.message : String(error),
|
|
@@ -2055,4 +3080,8 @@ export async function runAgentLoop(prompt, options = {}) {
|
|
|
2055
3080
|
throw error;
|
|
2056
3081
|
}
|
|
2057
3082
|
}
|
|
3083
|
+
/** Compatibility boundary for callers that still consume rendered text. */
|
|
3084
|
+
export async function runAgentLoop(prompt, options = {}) {
|
|
3085
|
+
return renderTurnOutcome(await runAgentTurn(prompt, options));
|
|
3086
|
+
}
|
|
2058
3087
|
//# sourceMappingURL=runner.js.map
|