@pentoshi/clai 3.9.1 → 3.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/compaction-attempt.d.ts +22 -0
- package/dist/agent/compaction-attempt.js +69 -0
- package/dist/agent/compaction-attempt.js.map +1 -0
- package/dist/agent/compaction-summary.d.ts +27 -0
- package/dist/agent/compaction-summary.js +62 -0
- package/dist/agent/compaction-summary.js.map +1 -1
- package/dist/agent/context-breakdown.d.ts +2 -0
- package/dist/agent/context-breakdown.js +37 -0
- package/dist/agent/context-breakdown.js.map +1 -1
- package/dist/agent/context-manager.d.ts +7 -1
- package/dist/agent/context-manager.js +64 -15
- package/dist/agent/context-manager.js.map +1 -1
- package/dist/agent/continue-orient.d.ts +8 -0
- package/dist/agent/continue-orient.js +26 -3
- package/dist/agent/continue-orient.js.map +1 -1
- package/dist/agent/durable-envelope.d.ts +39 -0
- package/dist/agent/durable-envelope.js +195 -0
- package/dist/agent/durable-envelope.js.map +1 -0
- package/dist/agent/events.d.ts +21 -0
- package/dist/agent/loop-guard.d.ts +13 -5
- package/dist/agent/loop-guard.js +114 -1
- package/dist/agent/loop-guard.js.map +1 -1
- package/dist/agent/outcomes.d.ts +29 -0
- package/dist/agent/outcomes.js +118 -3
- package/dist/agent/outcomes.js.map +1 -1
- package/dist/agent/plan-decision.js +8 -3
- package/dist/agent/plan-decision.js.map +1 -1
- package/dist/agent/plan-tool.d.ts +5 -0
- package/dist/agent/plan-tool.js +166 -6
- package/dist/agent/plan-tool.js.map +1 -1
- package/dist/agent/reliability-policy.d.ts +11 -6
- package/dist/agent/reliability-policy.js +19 -13
- package/dist/agent/reliability-policy.js.map +1 -1
- package/dist/agent/request-budget.d.ts +24 -0
- package/dist/agent/request-budget.js +59 -0
- package/dist/agent/request-budget.js.map +1 -0
- package/dist/agent/responder-context.js +2 -2
- package/dist/agent/responder-context.js.map +1 -1
- package/dist/agent/responder-parent.d.ts +30 -0
- package/dist/agent/responder-parent.js +82 -0
- package/dist/agent/responder-parent.js.map +1 -0
- package/dist/agent/runner.d.ts +14 -1
- package/dist/agent/runner.js +850 -237
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/scope-context.d.ts +5 -0
- package/dist/agent/scope-context.js +7 -1
- package/dist/agent/scope-context.js.map +1 -1
- package/dist/agent/session-policy.js +1 -0
- package/dist/agent/session-policy.js.map +1 -1
- package/dist/agent/stop-summary.js +13 -7
- package/dist/agent/stop-summary.js.map +1 -1
- package/dist/agent/stream-recovery.d.ts +83 -0
- package/dist/agent/stream-recovery.js +270 -0
- package/dist/agent/stream-recovery.js.map +1 -0
- package/dist/agent/task-analyzer.js +7 -3
- package/dist/agent/task-analyzer.js.map +1 -1
- package/dist/agent/task-evidence.js +2 -0
- package/dist/agent/task-evidence.js.map +1 -1
- package/dist/agent/task-sync.d.ts +11 -0
- package/dist/agent/task-sync.js +31 -0
- package/dist/agent/task-sync.js.map +1 -1
- package/dist/agent/tool-call-parser.d.ts +6 -13
- package/dist/agent/tool-call-parser.js +186 -82
- package/dist/agent/tool-call-parser.js.map +1 -1
- package/dist/agent/tool-history.d.ts +8 -2
- package/dist/agent/tool-history.js +8 -1
- package/dist/agent/tool-history.js.map +1 -1
- package/dist/agent/turn-outcome.d.ts +4 -1
- package/dist/agent/turn-outcome.js +16 -2
- package/dist/agent/turn-outcome.js.map +1 -1
- package/dist/app/adapters/agent-event-adapter.js +27 -0
- package/dist/app/adapters/agent-event-adapter.js.map +1 -1
- package/dist/app/adapters/current-agent-adapter.js +1 -0
- package/dist/app/adapters/current-agent-adapter.js.map +1 -1
- package/dist/app/adapters/current-jobs-adapter.js +4 -3
- package/dist/app/adapters/current-jobs-adapter.js.map +1 -1
- package/dist/app/adapters/current-store-adapter.js +4 -3
- package/dist/app/adapters/current-store-adapter.js.map +1 -1
- package/dist/app/adapters/current-updates-adapter.d.ts +1 -1
- package/dist/app/adapters/current-updates-adapter.js +34 -5
- package/dist/app/adapters/current-updates-adapter.js.map +1 -1
- package/dist/app/adapters/quiet-meta-tools.js +2 -1
- package/dist/app/adapters/quiet-meta-tools.js.map +1 -1
- package/dist/app/controllers/job-controller.d.ts +4 -3
- package/dist/app/controllers/job-controller.js +9 -6
- package/dist/app/controllers/job-controller.js.map +1 -1
- package/dist/app/controllers/plan-controller.js +14 -0
- package/dist/app/controllers/plan-controller.js.map +1 -1
- package/dist/app/controllers/responder-settlement.js +2 -2
- package/dist/app/controllers/responder-settlement.js.map +1 -1
- package/dist/app/controllers/session-compact-helper.d.ts +23 -1
- package/dist/app/controllers/session-compact-helper.js +117 -7
- package/dist/app/controllers/session-compact-helper.js.map +1 -1
- package/dist/app/controllers/session-context-usage.d.ts +24 -0
- package/dist/app/controllers/session-context-usage.js +89 -0
- package/dist/app/controllers/session-context-usage.js.map +1 -0
- package/dist/app/controllers/session-controller.d.ts +18 -17
- package/dist/app/controllers/session-controller.js +127 -119
- package/dist/app/controllers/session-controller.js.map +1 -1
- package/dist/app/controllers/session-prompt-queue.d.ts +3 -0
- package/dist/app/controllers/session-prompt-queue.js +20 -1
- package/dist/app/controllers/session-prompt-queue.js.map +1 -1
- package/dist/app/controllers/session-responder.js +16 -6
- package/dist/app/controllers/session-responder.js.map +1 -1
- package/dist/app/controllers/session-turn-request.d.ts +18 -0
- package/dist/app/controllers/session-turn-request.js +31 -0
- package/dist/app/controllers/session-turn-request.js.map +1 -0
- package/dist/app/controllers/turn-continuation.d.ts +14 -0
- package/dist/app/controllers/turn-continuation.js +46 -0
- package/dist/app/controllers/turn-continuation.js.map +1 -0
- package/dist/app/events/app-event.d.ts +21 -0
- package/dist/app/events/app-event.js +1 -0
- package/dist/app/events/app-event.js.map +1 -1
- package/dist/app/ports/agent-port.d.ts +7 -0
- package/dist/app/ports/jobs-port.d.ts +4 -3
- package/dist/app/ports/persistence-port.d.ts +11 -0
- package/dist/app/ports/updates-port.d.ts +8 -0
- package/dist/attachments/service.js +13 -2
- package/dist/attachments/service.js.map +1 -1
- package/dist/commands/providers.js +13 -10
- package/dist/commands/providers.js.map +1 -1
- package/dist/commands/update.d.ts +4 -0
- package/dist/commands/update.js +14 -0
- package/dist/commands/update.js.map +1 -1
- package/dist/llm/adapters/anthropic-tools.d.ts +14 -1
- package/dist/llm/adapters/anthropic-tools.js +36 -5
- package/dist/llm/adapters/anthropic-tools.js.map +1 -1
- package/dist/llm/adapters/gemini-tools.d.ts +3 -1
- package/dist/llm/adapters/gemini-tools.js +5 -2
- package/dist/llm/adapters/gemini-tools.js.map +1 -1
- package/dist/llm/adapters/ollama-tools.js +8 -3
- package/dist/llm/adapters/ollama-tools.js.map +1 -1
- package/dist/llm/adapters/openai-tools.js +8 -1
- package/dist/llm/adapters/openai-tools.js.map +1 -1
- package/dist/llm/agentrouter.js +2 -0
- package/dist/llm/agentrouter.js.map +1 -1
- package/dist/llm/anthropic.d.ts +8 -0
- package/dist/llm/anthropic.js +68 -9
- package/dist/llm/anthropic.js.map +1 -1
- package/dist/llm/aws-mantle.js +60 -12
- package/dist/llm/aws-mantle.js.map +1 -1
- package/dist/llm/bynara.js +2 -0
- package/dist/llm/bynara.js.map +1 -1
- package/dist/llm/capabilities.d.ts +7 -0
- package/dist/llm/capabilities.js +22 -0
- package/dist/llm/capabilities.js.map +1 -1
- package/dist/llm/gemini.d.ts +3 -0
- package/dist/llm/gemini.js +71 -11
- package/dist/llm/gemini.js.map +1 -1
- package/dist/llm/groq.d.ts +20 -1
- package/dist/llm/groq.js +32 -4
- package/dist/llm/groq.js.map +1 -1
- package/dist/llm/http.d.ts +29 -0
- package/dist/llm/http.js +122 -29
- package/dist/llm/http.js.map +1 -1
- package/dist/llm/kimchi.js +2 -0
- package/dist/llm/kimchi.js.map +1 -1
- package/dist/llm/nvidia.js +2 -0
- package/dist/llm/nvidia.js.map +1 -1
- package/dist/llm/ollama.d.ts +5 -0
- package/dist/llm/ollama.js +53 -6
- package/dist/llm/ollama.js.map +1 -1
- package/dist/llm/openai.js +2 -0
- package/dist/llm/openai.js.map +1 -1
- package/dist/llm/openrouter.js +2 -0
- package/dist/llm/openrouter.js.map +1 -1
- package/dist/llm/qwen-cloud.js +2 -0
- package/dist/llm/qwen-cloud.js.map +1 -1
- package/dist/llm/router.d.ts +16 -0
- package/dist/llm/router.js +79 -23
- package/dist/llm/router.js.map +1 -1
- package/dist/llm/sampling.d.ts +32 -0
- package/dist/llm/sampling.js +48 -0
- package/dist/llm/sampling.js.map +1 -0
- package/dist/llm/stream-progress.d.ts +4 -0
- package/dist/llm/stream-progress.js +43 -0
- package/dist/llm/stream-progress.js.map +1 -0
- package/dist/llm/system-messages.d.ts +29 -0
- package/dist/llm/system-messages.js +51 -0
- package/dist/llm/system-messages.js.map +1 -0
- package/dist/llm/token-usage.d.ts +4 -1
- package/dist/llm/token-usage.js +56 -4
- package/dist/llm/token-usage.js.map +1 -1
- package/dist/llm/tool-protocol.js +30 -0
- package/dist/llm/tool-protocol.js.map +1 -1
- package/dist/os/process-tree.d.ts +15 -0
- package/dist/os/process-tree.js +77 -0
- package/dist/os/process-tree.js.map +1 -0
- package/dist/prompts/embedded.js +1 -1
- package/dist/prompts/embedded.js.map +1 -1
- package/dist/prompts/system.agent.md +13 -12
- package/dist/repl/prompt-line.d.ts +5 -0
- package/dist/repl/prompt-line.js +7 -1
- package/dist/repl/prompt-line.js.map +1 -1
- package/dist/repl.js +61 -41
- package/dist/repl.js.map +1 -1
- package/dist/safety/classifier.js +98 -44
- package/dist/safety/classifier.js.map +1 -1
- package/dist/safety/engagement-policy.d.ts +8 -0
- package/dist/safety/engagement-policy.js +52 -7
- package/dist/safety/engagement-policy.js.map +1 -1
- package/dist/safety/patterns.d.ts +44 -0
- package/dist/safety/patterns.js +168 -37
- package/dist/safety/patterns.js.map +1 -1
- package/dist/store/config.d.ts +9 -1
- package/dist/store/config.js +51 -1
- package/dist/store/config.js.map +1 -1
- package/dist/store/history-index.d.ts +12 -0
- package/dist/store/history-index.js +47 -18
- package/dist/store/history-index.js.map +1 -1
- package/dist/store/history.d.ts +5 -2
- package/dist/store/history.js +153 -62
- package/dist/store/history.js.map +1 -1
- package/dist/store/plan.d.ts +81 -5
- package/dist/store/plan.js +406 -46
- package/dist/store/plan.js.map +1 -1
- package/dist/store/redaction-cache.d.ts +12 -0
- package/dist/store/redaction-cache.js +51 -0
- package/dist/store/redaction-cache.js.map +1 -0
- package/dist/store/responder-settlement.d.ts +5 -1
- package/dist/store/responder-settlement.js +42 -30
- package/dist/store/responder-settlement.js.map +1 -1
- package/dist/store/scope.js +36 -27
- package/dist/store/scope.js.map +1 -1
- package/dist/store/task-transitions.d.ts +19 -0
- package/dist/store/task-transitions.js +46 -0
- package/dist/store/task-transitions.js.map +1 -0
- package/dist/tools/capabilities.js +33 -18
- package/dist/tools/capabilities.js.map +1 -1
- package/dist/tools/command-intent.d.ts +24 -0
- package/dist/tools/command-intent.js +158 -4
- package/dist/tools/command-intent.js.map +1 -1
- package/dist/tools/definitions.d.ts +5 -1
- package/dist/tools/definitions.js +162 -21
- package/dist/tools/definitions.js.map +1 -1
- package/dist/tools/fs.d.ts +16 -0
- package/dist/tools/fs.js +122 -46
- package/dist/tools/fs.js.map +1 -1
- package/dist/tools/image.js +1 -1
- package/dist/tools/image.js.map +1 -1
- package/dist/tools/jobs.d.ts +78 -4
- package/dist/tools/jobs.js +542 -115
- package/dist/tools/jobs.js.map +1 -1
- package/dist/tools/net-ping-sweep.d.ts +3 -1
- package/dist/tools/net-ping-sweep.js +64 -26
- package/dist/tools/net-ping-sweep.js.map +1 -1
- package/dist/tools/nmap-runner.js +5 -2
- package/dist/tools/nmap-runner.js.map +1 -1
- package/dist/tools/pdf.js +50 -7
- package/dist/tools/pdf.js.map +1 -1
- package/dist/tools/registry.js +154 -96
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/shell.d.ts +24 -2
- package/dist/tools/shell.js +113 -28
- package/dist/tools/shell.js.map +1 -1
- package/dist/tools/tool-types.d.ts +2 -0
- package/dist/tools/validate.d.ts +8 -5
- package/dist/tools/validate.js +177 -7
- package/dist/tools/validate.js.map +1 -1
- package/dist/tools/wordlists.js +69 -17
- package/dist/tools/wordlists.js.map +1 -1
- package/dist/tui/state.d.ts +4 -0
- package/dist/tui/state.js +88 -2
- package/dist/tui/state.js.map +1 -1
- package/dist/tui-v2/actions/keymap.js +3 -8
- package/dist/tui-v2/actions/keymap.js.map +1 -1
- package/dist/tui-v2/app/App.js +56 -5
- package/dist/tui-v2/app/App.js.map +1 -1
- package/dist/tui-v2/app/commands/config-commands.js +5 -2
- package/dist/tui-v2/app/commands/config-commands.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/plan-lifecycle.d.ts +1 -1
- package/dist/tui-v2/app/plan-lifecycle.js +2 -1
- package/dist/tui-v2/app/plan-lifecycle.js.map +1 -1
- package/dist/tui-v2/components/composer/composer-input-box.d.ts +1 -1
- package/dist/tui-v2/components/composer/composer-input-box.js +12 -3
- package/dist/tui-v2/components/composer/composer-input-box.js.map +1 -1
- package/dist/tui-v2/components/jobs/jobs-panel.js +19 -6
- package/dist/tui-v2/components/jobs/jobs-panel.js.map +1 -1
- package/dist/tui-v2/components/plan/plan-view.js +9 -6
- package/dist/tui-v2/components/plan/plan-view.js.map +1 -1
- package/dist/tui-v2/components/status/status-line.d.ts +15 -0
- package/dist/tui-v2/components/status/status-line.js +33 -25
- package/dist/tui-v2/components/status/status-line.js.map +1 -1
- package/dist/tui-v2/components/transcript/assistant-message.js +17 -9
- package/dist/tui-v2/components/transcript/assistant-message.js.map +1 -1
- package/dist/tui-v2/components/transcript/compacted-row.js +49 -22
- package/dist/tui-v2/components/transcript/compacted-row.js.map +1 -1
- package/dist/tui-v2/components/transcript/intro-card.js +3 -0
- package/dist/tui-v2/components/transcript/intro-card.js.map +1 -1
- package/dist/tui-v2/components/transcript/thinking-block.js +2 -1
- package/dist/tui-v2/components/transcript/thinking-block.js.map +1 -1
- package/dist/tui-v2/components/transcript/transcript-view.js +27 -2
- package/dist/tui-v2/components/transcript/transcript-view.js.map +1 -1
- package/dist/tui-v2/components/transcript/use-transcript-selection.js +57 -6
- package/dist/tui-v2/components/transcript/use-transcript-selection.js.map +1 -1
- package/dist/tui-v2/composer/composer-editor.js +20 -12
- package/dist/tui-v2/composer/composer-editor.js.map +1 -1
- package/dist/tui-v2/rendering/incremental-strip.d.ts +11 -0
- package/dist/tui-v2/rendering/incremental-strip.js +89 -0
- package/dist/tui-v2/rendering/incremental-strip.js.map +1 -0
- package/dist/tui-v2/rendering/plan-view.d.ts +12 -0
- package/dist/tui-v2/rendering/plan-view.js +71 -2
- package/dist/tui-v2/rendering/plan-view.js.map +1 -1
- package/dist/tui-v2/rendering/streaming-markdown.d.ts +25 -0
- package/dist/tui-v2/rendering/streaming-markdown.js +108 -0
- package/dist/tui-v2/rendering/streaming-markdown.js.map +1 -0
- package/dist/tui-v2/rendering/thinking-tail.d.ts +5 -0
- package/dist/tui-v2/rendering/thinking-tail.js +26 -0
- package/dist/tui-v2/rendering/thinking-tail.js.map +1 -0
- package/dist/tui-v2/state/transcript-hydrate.js +6 -2
- package/dist/tui-v2/state/transcript-hydrate.js.map +1 -1
- package/dist/tui-v2/state/transcript-reducer.js +146 -25
- package/dist/tui-v2/state/transcript-reducer.js.map +1 -1
- package/dist/tui-v2/state/transcript-types.d.ts +6 -0
- package/dist/tui-v2/state/transcript-types.js +17 -0
- package/dist/tui-v2/state/transcript-types.js.map +1 -1
- package/dist/types.d.ts +29 -0
- package/dist/types.js.map +1 -1
- package/dist/ui/intro-header.d.ts +1 -0
- package/dist/ui/intro-header.js +10 -13
- package/dist/ui/intro-header.js.map +1 -1
- package/dist/ui/markdown.js +9 -2
- package/dist/ui/markdown.js.map +1 -1
- package/dist/ui/mentions.d.ts +5 -0
- package/dist/ui/mentions.js +66 -7
- package/dist/ui/mentions.js.map +1 -1
- package/dist/ui/thinking.d.ts +5 -1
- package/dist/ui/thinking.js +3 -2
- package/dist/ui/thinking.js.map +1 -1
- package/dist/version.generated.d.ts +2 -2
- package/dist/version.generated.js +2 -2
- package/package.json +2 -1
package/dist/agent/runner.js
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { completeWithProvider, streamWithProvider } from "../llm/router.js";
|
|
4
|
+
import { streamAlreadyEmitted } from "../llm/stream-progress.js";
|
|
5
|
+
import { classifyStreamFailure, planStreamRecovery, recordRecoveryAttempt, createStreamRecoveryState, resetStreamRecoveryState, } from "./stream-recovery.js";
|
|
4
6
|
import { resolveToolDialect } from "../llm/capabilities.js";
|
|
5
7
|
import { syntheticToolCallId, isTextOnlyModel, fromWireName, } from "../llm/tool-protocol.js";
|
|
6
8
|
import { sanitizeAssistantText } from "../ui/ansi-box.js";
|
|
7
9
|
import { randomUUID } from "node:crypto";
|
|
8
10
|
import { jobManager, } from "../tools/jobs.js";
|
|
9
|
-
import { responderContextMessage, upsertResponderContextMessage, upsertResponderResultLedger, } from "./responder-context.js";
|
|
11
|
+
import { isResponderResultLedgerMessage, responderContextMessage, upsertResponderContextMessage, upsertResponderResultLedger, } from "./responder-context.js";
|
|
10
12
|
import { agentModeDirective, planModeDirective, renderAgentSystemPrompt, renderCompactAgentSystemPrompt, scratchDirFor, toolNudge, } from "../prompts/index.js";
|
|
11
13
|
import { getConfig } from "../store/config.js";
|
|
12
14
|
import { beginSessionWorkspace, getActiveSessionWorkspace, } from "../store/session-workspace.js";
|
|
13
15
|
import { groqInputTokenBudget } from "../llm/groq.js";
|
|
14
|
-
import { classifyToolCall, isPentestToolCall,
|
|
16
|
+
import { classifyToolCall, isPentestToolCall, scopeTargetForToolCall, } from "../safety/classifier.js";
|
|
15
17
|
/**
|
|
16
18
|
* Scope/engagement classification runs on every tool call and parses
|
|
17
19
|
* model-supplied arguments (URLs, hosts, commands). A malformed argument must
|
|
@@ -26,20 +28,20 @@ function safeScopeTargetForToolCall(call) {
|
|
|
26
28
|
return undefined;
|
|
27
29
|
}
|
|
28
30
|
}
|
|
29
|
-
function
|
|
31
|
+
function safeEngagementActionsForToolCall(call) {
|
|
30
32
|
try {
|
|
31
|
-
return
|
|
33
|
+
return engagementActionsForToolCall(call);
|
|
32
34
|
}
|
|
33
35
|
catch {
|
|
34
|
-
return
|
|
36
|
+
return [];
|
|
35
37
|
}
|
|
36
38
|
}
|
|
37
39
|
import { availableToolNames, normalizeToolCall, runToolCall, BATCH_SAFE_TOOLS, } from "../tools/registry.js";
|
|
38
|
-
import { getToolDefinitions, getCompactToolDefinitions,
|
|
40
|
+
import { getToolDefinitions, getCompactToolDefinitions, RUNNER_META_TOOL_NAMES, } from "../tools/definitions.js";
|
|
39
41
|
import { appendAssistantWithTools, ensureUniqueToolCallIds, appendToolResult, assertValidToolProtocol, fillMissingToolResults, repairToolProtocol, } from "./tool-history.js";
|
|
40
42
|
import { formatViewportHint, registerViewport } from "../ui/output-pane.js";
|
|
41
43
|
import { compactMessagesWithSummary, shouldApplyAutoCompact, COMPACTION_MEMORY_PREFIX, PLAN_IMPLEMENT_MEMORY_PREFIX, isCompactionMemoryMessage, } from "./context-manager.js";
|
|
42
|
-
import { buildContextBreakdown, contextBreakdownAuditPayload, } from "./context-breakdown.js";
|
|
44
|
+
import { buildContextBreakdown, contextBreakdownAuditPayload, describeDominantContextBlock, toolSchemaHash, } from "./context-breakdown.js";
|
|
43
45
|
import { autoCompactTriggerTokens, dedupeToolContextOutput, freeTierGuardNotices, getReliabilityPolicy, resolveStepMaxTokens, } from "./reliability-policy.js";
|
|
44
46
|
import { auditLog } from "../store/logs.js";
|
|
45
47
|
import { loadProjectContext } from "../store/project.js";
|
|
@@ -52,24 +54,29 @@ import { safeCwd } from "../os/cwd.js";
|
|
|
52
54
|
import { analyzeTask, formatTaskAnalysisHint, isNarrowExplicitNmapOperation, } from "./task-analyzer.js";
|
|
53
55
|
import { computeMaxIterations, computeStepBudget } from "./step-budget.js";
|
|
54
56
|
import { isScratchOnlyWrite } from "./scratch-write.js";
|
|
57
|
+
import { buildDurableEnvelope, WorkLedger, } from "./durable-envelope.js";
|
|
55
58
|
import { COMPACTION_SYSTEM_PROMPT, } from "./compaction-summary.js";
|
|
56
59
|
import { maybeAppendPlanModeReminder, PLAN_REMINDER_TOAST, } from "./plan-mode-reminders.js";
|
|
57
60
|
import { LoopGuard } from "./loop-guard.js";
|
|
58
|
-
import {
|
|
59
|
-
import {
|
|
61
|
+
import { CompactionAttemptLedger, compactionAttemptKey, } from "./compaction-attempt.js";
|
|
62
|
+
import { resolveRequestBudget } from "./request-budget.js";
|
|
63
|
+
import { loadPlan, mutatePlan, markTask, appendPlanTask, readyPlanTasks, foregroundRemaining, responderOpenTasks, isPlanTerminal, isPlanSuccessful, } from "../store/plan.js";
|
|
64
|
+
import { stat } from "node:fs/promises";
|
|
65
|
+
import { isOutsideWorkingDirectory, resolveFsToolPath, } from "../tools/fs.js";
|
|
60
66
|
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";
|
|
61
67
|
import { createSessionPolicy, isPreApprovalAllowedTool, isPlanModeAllowedShellCommand, isPlanModeAllowedTool, isPlanApprovedByStatus, planHasOpenWork, isAbortError, shouldEnableImageOcr, } from "./session-policy.js";
|
|
62
68
|
import { saveToolOutput, summarizeOutput, formatToolContext, } from "./tool-output-formatting.js";
|
|
63
69
|
import { codingSessionFromContext, isProtocolPlaceholderOutput, progressPauseMode, } from "./progress-pause-policy.js";
|
|
64
|
-
import { renderPlanForTerminal, planContextMessage, handlePlanTool, resolvePlanTaskId, } from "./plan-tool.js";
|
|
65
|
-
import {
|
|
66
|
-
import {
|
|
70
|
+
import { renderPlanForTerminal, planContextMessage, upsertPlanContextMessage, handlePlanTool, resolvePlanTaskId, } from "./plan-tool.js";
|
|
71
|
+
import { readDeclaredParentTaskId, resolveResponderParent, isExplicitResponderDelegation, delegationTaskTitle, } from "./responder-parent.js";
|
|
72
|
+
import { readTaskUpdateArgs, distinctAdvancingTaskIds, isSimultaneousTaskAdvance, batchUpdateSignature, buildMultiUpdateReminder, buildMultiOpenRejection, multiUpdateToast, multiOpenToast, openingTaskIds, } from "./task-sync.js";
|
|
73
|
+
import { absorbLooseWorkIntoLedger, applyDestinationCwd, canMarkTaskDone, hasLocalRuntimeProof, hasRemoteWorkProof, isDevServerCall, isEvidenceWorkTool, isFeatureImplementationCall, isPackageInstallCommand, isPlanPreflightTool, isPortListeningOutput, isReadOnlyReconTool, isRemoteActiveTestCall, isRemoteObservationTask, isRemoteReconToolCall, isRuntimeObservationTask, isScaffoldCreateCommand, isServerReadyOutput, ledgerFromTaskEvidence, pickPendingTaskForToolCall, recordTaskWorkSuccess, resolveUserDestinationHint, taskEvidenceFromLedger, TOOL_ABORT_GRACE_MS, toolHardBudgetMs, toolStallBudgetMs, userAskedForFeatureApp, } from "./task-evidence.js";
|
|
67
74
|
import { buildSessionStateBlock, inferNextHint, upsertSessionStateMessage, } from "./session-state.js";
|
|
68
|
-
import { buildContinueOrientation } from "./continue-orient.js";
|
|
75
|
+
import { buildContinueOrientation, } from "./continue-orient.js";
|
|
69
76
|
import { detectPackageManager } from "./workspace-orient.js";
|
|
70
77
|
import { budgetRemaining, consumeBudget, createRecoveryBudgets, freestyleClaimsAppReady, looksLikeShallowPentestReport, recoveryForErrorDiagnosis, recoveryForFailedProbe, recoveryForFreshness, recoveryForMissingFeature, recoveryForMissingPlan, recoveryForNarration, recoveryForPrematureComplete, recoveryForRuntimeVerify, recoveryForShallowPentest, } from "./must-continue.js";
|
|
71
|
-
import { scopeContextMessage } from "./scope-context.js";
|
|
72
|
-
import { EngagementPolicyEngine, actionFromUrl,
|
|
78
|
+
import { outOfScopeToolMessage, scopeContextMessage } from "./scope-context.js";
|
|
79
|
+
import { EngagementPolicyEngine, actionFromUrl, engagementActionsForToolCall, evaluateEngagementAction, } from "../safety/engagement-policy.js";
|
|
73
80
|
import { patchPlanMeta } from "../store/plan.js";
|
|
74
81
|
import { extractProjectRootFromPlan, extractProjectRootFromScaffold, extractProjectRootFromText, getActiveProjectRoot, setActiveProjectRootIfValid, } from "./project-root.js";
|
|
75
82
|
import { buildWorkspaceOrientation, discoverImmediateProjectRoots, guessProjectFolderName, isBareParentDirectory, isScaffoldCancelledOutput, scaffoldLooksMaterialized, scaffoldTargetConflictMessage, resolveScaffoldTargetPath, } from "./workspace-orient.js";
|
|
@@ -79,28 +86,73 @@ import { composeAgentSystemPrompt } from "./prompt-composer.js";
|
|
|
79
86
|
import { createGovernorState, governProgress, } from "./evidence-governor.js";
|
|
80
87
|
import { createTurnState, transitionTurn, } from "./turn-state.js";
|
|
81
88
|
import { deriveOutcomeStatus, inferOutcomeKind, openOutcomeState, recordAnswerEvidence, recordFailedHypothesis, recordToolEvidence, saveOutcomeState, validateCriterionEvidence, } from "./outcomes.js";
|
|
82
|
-
import { createTurnOutcome, renderTurnOutcome } from "./turn-outcome.js";
|
|
89
|
+
import { createTurnOutcome, normalizeTurnOutcomeInput, renderTurnOutcome, } from "./turn-outcome.js";
|
|
83
90
|
import { beginEngagementAction, finishEngagementAction, recordEngagementCheckpoint, reconcileEngagementJob, openEngagement, saveEngagement, } from "../store/engagement.js";
|
|
84
91
|
export * from "./tool-call-parser.js";
|
|
85
92
|
export { createSessionPolicy, isPreApprovalAllowedTool, isPlanApprovedByStatus, planHasOpenWork, shouldEnableImageOcr, } from "./session-policy.js";
|
|
86
93
|
export function styleToolChatter(call, text) {
|
|
87
94
|
return shouldDimToolChatter(call) ? chalk.dim(text) : text;
|
|
88
95
|
}
|
|
89
|
-
|
|
96
|
+
/**
|
|
97
|
+
* A foreground task waits for a responder child only when the plan
|
|
98
|
+
* Declares that dependency. Report titles carry no scheduling meaning: any
|
|
99
|
+
* Dependency-ready foreground work is executed while children keep running, and
|
|
100
|
+
* Their results arrive as addenda.
|
|
101
|
+
*
|
|
102
|
+
* A declared dependency blocks only while the child is genuinely live (running
|
|
103
|
+
* Job or an undelivered/unanalyzed receipt), so an orphaned child can never
|
|
104
|
+
* Stall the turn forever.
|
|
105
|
+
*/
|
|
106
|
+
export function shouldYieldForDeclaredResponderDependency(plan, runningJobs, notifications, currentNotificationId) {
|
|
90
107
|
if (!plan)
|
|
91
108
|
return false;
|
|
92
109
|
const unfinished = plan.tasks.filter((task) => !task.responderOwned &&
|
|
93
110
|
(task.state === "pending" || task.state === "in_progress"));
|
|
94
|
-
if (unfinished.length === 0
|
|
95
|
-
!unfinished.every((task) => classifyTaskTitle(task.title, { planKind: plan.kind }) === "report")) {
|
|
111
|
+
if (unfinished.length === 0)
|
|
96
112
|
return false;
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
113
|
+
const childById = new Map(plan.tasks.filter((task) => task.responderOwned).map((task) => [task.id, task]));
|
|
114
|
+
const isLive = (child) => {
|
|
115
|
+
if (child.state === "done" || child.state === "skipped" || child.state === "failed") {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
const running = runningJobs.some((job) => job.responder &&
|
|
119
|
+
(job.taskId === child.id || (!!child.jobId && job.id === child.jobId)));
|
|
120
|
+
if (running)
|
|
121
|
+
return true;
|
|
122
|
+
return notifications.some((notification) => notification.responder &&
|
|
101
123
|
!notification.archivedAt &&
|
|
102
124
|
!notification.readAt &&
|
|
103
|
-
!notification.analyzedAt
|
|
125
|
+
!notification.analyzedAt &&
|
|
126
|
+
(!currentNotificationId || notification.id !== currentNotificationId) &&
|
|
127
|
+
(notification.taskId === child.id ||
|
|
128
|
+
(!!child.jobId && notification.jobId === child.jobId)));
|
|
129
|
+
};
|
|
130
|
+
return unfinished.every((task) => (task.dependencies ?? []).some((dependency) => {
|
|
131
|
+
const child = childById.get(dependency);
|
|
132
|
+
return !!child && isLive(child);
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Cancellable backoff. Resolves after `ms`, or rejects immediately if the
|
|
137
|
+
* signal aborts (double-Esc) so recovery waits never trap a cancelled turn.
|
|
138
|
+
*/
|
|
139
|
+
function delay(ms, signal) {
|
|
140
|
+
if (ms <= 0)
|
|
141
|
+
return Promise.resolve();
|
|
142
|
+
if (signal?.aborted) {
|
|
143
|
+
return Promise.reject(signal.reason ?? new Error("Aborted"));
|
|
144
|
+
}
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
const onAbort = () => {
|
|
147
|
+
clearTimeout(timer);
|
|
148
|
+
reject(signal?.reason ?? new Error("Aborted"));
|
|
149
|
+
};
|
|
150
|
+
const timer = setTimeout(() => {
|
|
151
|
+
signal?.removeEventListener("abort", onAbort);
|
|
152
|
+
resolve();
|
|
153
|
+
}, ms);
|
|
154
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
155
|
+
});
|
|
104
156
|
}
|
|
105
157
|
export async function runAgentTurn(prompt, options = {}) {
|
|
106
158
|
const agentMode = options.mode === "plan" || options.mode === "agent" || options.mode === "ask"
|
|
@@ -223,33 +275,61 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
223
275
|
};
|
|
224
276
|
/** Strip a known prefix from a string, returning the remainder unchanged. */
|
|
225
277
|
const insertedText = (value, prefix) => value.startsWith(prefix) ? value.slice(prefix.length) : value;
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
278
|
+
const writeCompactionStarted = (id, beforeTokens) => {
|
|
279
|
+
emit({ type: "compaction-start", id, beforeTokens });
|
|
280
|
+
if (writesDirectly) {
|
|
281
|
+
process.stdout.write(chalk.dim(" ✦ Compacted Context · streaming Markdown\n\n"));
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
const writeCompactionDelta = (id, text) => {
|
|
285
|
+
if (!text)
|
|
286
|
+
return;
|
|
287
|
+
emit({ type: "compaction-delta", id, text });
|
|
288
|
+
if (writesDirectly)
|
|
289
|
+
process.stdout.write(text);
|
|
290
|
+
};
|
|
291
|
+
const writeCompactionCompleted = (id, summary, beforeTokens, afterTokens) => {
|
|
292
|
+
emit({
|
|
293
|
+
type: "compaction-completed",
|
|
294
|
+
id,
|
|
295
|
+
summary,
|
|
296
|
+
beforeTokens,
|
|
297
|
+
afterTokens,
|
|
298
|
+
});
|
|
233
299
|
if (writesDirectly) {
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
300
|
+
const footer = chalk.dim(`\n ~${beforeTokens.toLocaleString()} → ~${afterTokens.toLocaleString()} tokens`);
|
|
301
|
+
process.stdout.write(`${footer}\n`);
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
const writeCompactionFailed = (id, message, retainedTokens) => {
|
|
305
|
+
emit({ type: "compaction-failed", id, message, retainedTokens });
|
|
306
|
+
if (writesDirectly) {
|
|
307
|
+
process.stdout.write(chalk.yellow(`\n Compaction failed: ${message} (~${retainedTokens.toLocaleString()} tokens retained)\n`));
|
|
238
308
|
}
|
|
239
309
|
};
|
|
240
310
|
// Points at the live message array so finishTurn can hand the full
|
|
241
311
|
// conversation back to the caller. Assigned once `messages` is built below;
|
|
242
312
|
// all later mutations are in-place so this reference stays current.
|
|
243
313
|
let liveMessages = [];
|
|
314
|
+
let suppressOutcomeDiagnostics = false;
|
|
315
|
+
const unreadResponderNotificationIds = new Set();
|
|
316
|
+
const releaseUnreadResponderClaims = () => {
|
|
317
|
+
for (const notificationId of unreadResponderNotificationIds) {
|
|
318
|
+
jobManager.releaseResponderNotificationClaim(notificationId);
|
|
319
|
+
}
|
|
320
|
+
};
|
|
244
321
|
const finishTurn = (answer, steps, status = "succeeded", remainingCriteria = [], reason) => {
|
|
245
|
-
|
|
322
|
+
releaseUnreadResponderClaims();
|
|
323
|
+
const outcome = createTurnOutcome(normalizeTurnOutcomeInput({
|
|
246
324
|
status,
|
|
247
325
|
answer,
|
|
248
326
|
steps,
|
|
249
327
|
remainingCriteria,
|
|
250
328
|
reason,
|
|
329
|
+
}));
|
|
330
|
+
const rendered = renderTurnOutcome(outcome, {
|
|
331
|
+
diagnostics: !suppressOutcomeDiagnostics,
|
|
251
332
|
});
|
|
252
|
-
const rendered = renderTurnOutcome(outcome);
|
|
253
333
|
writeAssistantMessage(rendered);
|
|
254
334
|
if (options.onMessages) {
|
|
255
335
|
try {
|
|
@@ -300,6 +380,7 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
300
380
|
// freshness retries — a false "act don't narrate" path burned tokens on
|
|
301
381
|
// web.search recovery loops after a simple "hi".
|
|
302
382
|
const idleOrSocialPrompt = looksLikeIdleOrSocialPrompt(prompt);
|
|
383
|
+
suppressOutcomeDiagnostics = informationalQuery || idleOrSocialPrompt;
|
|
303
384
|
const freshWebSearchRequired = !buildLikeTurn &&
|
|
304
385
|
!pentestLikeTurn &&
|
|
305
386
|
!idleOrSocialPrompt &&
|
|
@@ -325,7 +406,7 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
325
406
|
const base = compact
|
|
326
407
|
? getCompactToolDefinitions()
|
|
327
408
|
: getToolDefinitions();
|
|
328
|
-
const allow = new Set([...toolNames, ...
|
|
409
|
+
const allow = new Set([...toolNames, ...RUNNER_META_TOOL_NAMES]);
|
|
329
410
|
return base.filter((d) => allow.has(d.name));
|
|
330
411
|
};
|
|
331
412
|
let lastAnswer = "";
|
|
@@ -446,9 +527,9 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
446
527
|
return sections.join("\n\n");
|
|
447
528
|
};
|
|
448
529
|
const systemSections = [buildSystemContent(nativeToolsActive)];
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
530
|
+
// The live plan is mutable state: it is injected once as a keyed request
|
|
531
|
+
// suffix (upsertPlanContextMessage) instead of being frozen into the stable
|
|
532
|
+
// system prefix, so the model never sees a stale and a fresh plan together.
|
|
452
533
|
// Soft mid-work recovery (any domain): re-attach to jobs / open tasks /
|
|
453
534
|
// last tools after interrupt or "continue" — not a hard gate.
|
|
454
535
|
if (!informationalQuery && !idleOrSocialPrompt) {
|
|
@@ -460,6 +541,7 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
460
541
|
recentJobs: jobManager.getRecentJobs(12, session.sessionId),
|
|
461
542
|
informationalQuery,
|
|
462
543
|
idleOrSocial: idleOrSocialPrompt,
|
|
544
|
+
...(options.previousTurn ? { previousTurn: options.previousTurn } : {}),
|
|
463
545
|
});
|
|
464
546
|
if (continueBrief) {
|
|
465
547
|
systemSections.push(continueBrief);
|
|
@@ -562,7 +644,7 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
562
644
|
if (!has("plan")) {
|
|
563
645
|
sections.push({
|
|
564
646
|
kind: "plan",
|
|
565
|
-
content: "
|
|
647
|
+
content: "PLAN PROTOCOL\nThe live plan, when one exists, is appended to this request as a single ACTIVE PLAN message. Treat that message as the only authoritative plan state; never rely on plan details quoted in earlier turns.",
|
|
566
648
|
mandatory: true,
|
|
567
649
|
});
|
|
568
650
|
}
|
|
@@ -613,13 +695,31 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
613
695
|
userMessage,
|
|
614
696
|
];
|
|
615
697
|
liveMessages = messages;
|
|
616
|
-
|
|
698
|
+
if (activePlan) {
|
|
699
|
+
upsertPlanContextMessage(messages, planContextMessage(activePlan, session.planApproved.value));
|
|
700
|
+
}
|
|
701
|
+
const responderWakeTurn = options.displayPrompt === null &&
|
|
702
|
+
prompt.startsWith("Responder result arrived");
|
|
617
703
|
const responderWakeNotificationId = responderWakeTurn
|
|
618
704
|
? /^notification=(.+)$/m.exec(prompt)?.[1]?.trim()
|
|
619
705
|
: undefined;
|
|
620
|
-
const
|
|
621
|
-
|
|
622
|
-
|
|
706
|
+
const responderWakeJobId = responderWakeTurn
|
|
707
|
+
? /^job=(.+)$/m.exec(prompt)?.[1]?.trim()
|
|
708
|
+
: undefined;
|
|
709
|
+
const responderWakeResultRevision = responderWakeTurn
|
|
710
|
+
? Number(/^resultRevision=(\d+)$/m.exec(prompt)?.[1]) || undefined
|
|
711
|
+
: undefined;
|
|
712
|
+
const matchesWakeRevision = (notification) => responderWakeResultRevision === undefined ||
|
|
713
|
+
(notification.resultRevision ?? 1) === responderWakeResultRevision;
|
|
714
|
+
const wakeNotification = responderWakeNotificationId
|
|
715
|
+
? jobManager
|
|
716
|
+
.getPendingNotifications(session.sessionId)
|
|
717
|
+
.find((notification) => notification.id === responderWakeNotificationId &&
|
|
718
|
+
(!responderWakeJobId || notification.jobId === responderWakeJobId) &&
|
|
719
|
+
matchesWakeRevision(notification))
|
|
720
|
+
: undefined;
|
|
721
|
+
if (wakeNotification) {
|
|
722
|
+
unreadResponderNotificationIds.add(wakeNotification.id);
|
|
623
723
|
}
|
|
624
724
|
const refreshResponderInbox = () => {
|
|
625
725
|
const running = jobManager
|
|
@@ -630,6 +730,7 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
630
730
|
.getPendingNotifications(session.sessionId)
|
|
631
731
|
.filter((notification) => notification.responder &&
|
|
632
732
|
unreadResponderNotificationIds.has(notification.id) &&
|
|
733
|
+
matchesWakeRevision(notification) &&
|
|
633
734
|
!notification.readAt &&
|
|
634
735
|
!notification.analyzedAt &&
|
|
635
736
|
!notification.archivedAt)
|
|
@@ -692,10 +793,44 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
692
793
|
};
|
|
693
794
|
const loopGuard = new LoopGuard();
|
|
694
795
|
const engagementPolicy = new EngagementPolicyEngine();
|
|
796
|
+
const probeStateKey = (call) => {
|
|
797
|
+
const project = (job) => job
|
|
798
|
+
? [
|
|
799
|
+
job.id,
|
|
800
|
+
job.status,
|
|
801
|
+
job.exitCode ?? null,
|
|
802
|
+
job.signal ?? null,
|
|
803
|
+
job.stdoutArtifact,
|
|
804
|
+
job.artifacts.stdout.bytes,
|
|
805
|
+
job.artifacts.stdout.sha256,
|
|
806
|
+
job.stderrArtifact,
|
|
807
|
+
job.artifacts.stderr.bytes,
|
|
808
|
+
job.artifacts.stderr.sha256,
|
|
809
|
+
]
|
|
810
|
+
: undefined;
|
|
811
|
+
if (call.name === "shell.tail" && typeof call.args.id === "string") {
|
|
812
|
+
const projected = project(jobManager.getJob(call.args.id));
|
|
813
|
+
return projected ? JSON.stringify(projected) : undefined;
|
|
814
|
+
}
|
|
815
|
+
if (call.name === "shell.jobs") {
|
|
816
|
+
return JSON.stringify(jobManager
|
|
817
|
+
.getRecentJobs(100, session.sessionId)
|
|
818
|
+
.map((job) => project(job))
|
|
819
|
+
.filter(Boolean));
|
|
820
|
+
}
|
|
821
|
+
return undefined;
|
|
822
|
+
};
|
|
695
823
|
// Track consecutive thinking-only responses so we can nudge the model
|
|
696
824
|
// to actually act instead of silently returning an empty answer.
|
|
697
825
|
let emptyVisibleRetries = 0;
|
|
698
826
|
let retryWithoutThinking = false;
|
|
827
|
+
// Robust stream-failure recovery. When a provider stream/complete fails we
|
|
828
|
+
// try working approaches (backoff, compaction, thinking-off, provider
|
|
829
|
+
// fallback) before surrendering the turn — see ./stream-recovery. Both are
|
|
830
|
+
// reset on any successful stream so each failure episode gets a fresh
|
|
831
|
+
// budget and we only give up in the worst case.
|
|
832
|
+
let allowModelFallback = false;
|
|
833
|
+
const recoveryState = createStreamRecoveryState();
|
|
699
834
|
// Track tool calls truncated by the token limit so we can ask the model
|
|
700
835
|
// to retry in smaller pieces instead of leaking broken JSON as an answer.
|
|
701
836
|
let truncatedToolRetries = 0;
|
|
@@ -777,7 +912,7 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
777
912
|
});
|
|
778
913
|
};
|
|
779
914
|
const reconcileOpenTaskBeforeFinalizing = async () => {
|
|
780
|
-
|
|
915
|
+
let plan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
781
916
|
const open = plan?.tasks.find((task) => task.state === "in_progress" && !task.responderOwned);
|
|
782
917
|
if (!plan || !open)
|
|
783
918
|
return plan;
|
|
@@ -785,42 +920,80 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
785
920
|
if (!gate.ok)
|
|
786
921
|
return plan;
|
|
787
922
|
const reconciledTaskIds = [open.id];
|
|
788
|
-
|
|
923
|
+
const working = plan;
|
|
924
|
+
markTask(working, open.id, "done", "Completion reconciled from verified task evidence.");
|
|
789
925
|
while (true) {
|
|
790
|
-
const observation = readyPlanTasks(
|
|
791
|
-
(
|
|
926
|
+
const observation = readyPlanTasks(working).find((task) => isRuntimeObservationTask(task.title) ||
|
|
927
|
+
(working.kind === "pentest" && isRemoteObservationTask(task.title)));
|
|
792
928
|
if (!observation)
|
|
793
929
|
break;
|
|
794
|
-
const observationGate = completionGateForTask(
|
|
930
|
+
const observationGate = completionGateForTask(working, observation.id);
|
|
795
931
|
if (!observationGate.ok)
|
|
796
932
|
break;
|
|
797
|
-
markTask(
|
|
933
|
+
markTask(working, observation.id, "done", working.kind === "pentest"
|
|
798
934
|
? "Satisfied by verified remote evidence from the preceding task."
|
|
799
935
|
: "Satisfied by the verified runtime evidence from the preceding task.");
|
|
800
936
|
reconciledTaskIds.push(observation.id);
|
|
801
937
|
}
|
|
802
|
-
if (
|
|
803
|
-
|
|
938
|
+
if (working.status === "draft" || working.status === "approved") {
|
|
939
|
+
working.status = "in_progress";
|
|
804
940
|
}
|
|
805
|
-
if (isPlanTerminal(
|
|
806
|
-
|
|
941
|
+
if (isPlanTerminal(working)) {
|
|
942
|
+
working.status = isPlanSuccessful(working) ? "completed" : "abandoned";
|
|
807
943
|
}
|
|
808
|
-
|
|
944
|
+
// Replay the same reconciliation as a reducer so a concurrent
|
|
945
|
+
// responder settlement is preserved instead of overwritten.
|
|
946
|
+
const reconciledNotes = new Map(reconciledTaskIds.map((id) => [
|
|
947
|
+
id,
|
|
948
|
+
working.tasks.find((task) => task.id === id)?.note,
|
|
949
|
+
]));
|
|
950
|
+
const reconcileResult = await mutatePlan(session.sessionId, (draft) => {
|
|
951
|
+
let changed = false;
|
|
952
|
+
for (const [id, note] of reconciledNotes) {
|
|
953
|
+
const task = draft.tasks.find((candidate) => candidate.id === id);
|
|
954
|
+
if (!task || task.state === "done")
|
|
955
|
+
continue;
|
|
956
|
+
task.state = "done";
|
|
957
|
+
if (note !== undefined)
|
|
958
|
+
task.note = note;
|
|
959
|
+
changed = true;
|
|
960
|
+
}
|
|
961
|
+
if (draft.status === "draft" || draft.status === "approved") {
|
|
962
|
+
draft.status = "in_progress";
|
|
963
|
+
changed = true;
|
|
964
|
+
}
|
|
965
|
+
if (isPlanTerminal(draft)) {
|
|
966
|
+
draft.status = isPlanSuccessful(draft) ? "completed" : "abandoned";
|
|
967
|
+
changed = true;
|
|
968
|
+
}
|
|
969
|
+
return changed;
|
|
970
|
+
}).catch(() => undefined);
|
|
971
|
+
if (reconcileResult?.ok && reconcileResult.plan)
|
|
972
|
+
plan = reconcileResult.plan;
|
|
809
973
|
writePlanUpdate(plan, renderPlanForTerminal(plan) + "\n");
|
|
810
974
|
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`));
|
|
811
975
|
taskWorkLedger = null;
|
|
812
976
|
return plan;
|
|
813
977
|
};
|
|
814
978
|
async function persistProjectRootOnPlan(root) {
|
|
815
|
-
const live = await loadPlan(session.sessionId).catch(() => undefined);
|
|
816
|
-
if (!live)
|
|
817
|
-
return;
|
|
818
979
|
const pm = detectPackageManager(root);
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
980
|
+
// Metadata patches go through the transactional boundary so a
|
|
981
|
+
// concurrent task transition or responder settlement is not clobbered.
|
|
982
|
+
await mutatePlan(session.sessionId, (draft) => {
|
|
983
|
+
patchPlanMeta(draft, {
|
|
984
|
+
projectRoot: root,
|
|
985
|
+
...(pm ? { packageManager: pm } : {}),
|
|
986
|
+
});
|
|
987
|
+
}).catch(() => undefined);
|
|
988
|
+
}
|
|
989
|
+
/** Persist task evidence without rewriting the whole plan. */
|
|
990
|
+
async function persistTaskEvidence(taskId, evidence) {
|
|
991
|
+
await mutatePlan(session.sessionId, (draft) => {
|
|
992
|
+
const task = draft.tasks.find((candidate) => candidate.id === taskId);
|
|
993
|
+
if (!task)
|
|
994
|
+
return false;
|
|
995
|
+
task.evidence = evidence;
|
|
996
|
+
}).catch(() => undefined);
|
|
824
997
|
}
|
|
825
998
|
refreshSessionState = (plan) => {
|
|
826
999
|
if (idleOrSocialPrompt || informationalQuery)
|
|
@@ -871,6 +1044,11 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
871
1044
|
: undefined,
|
|
872
1045
|
};
|
|
873
1046
|
snap.nextHint = inferNextHint(snap);
|
|
1047
|
+
// One live plan copy, refreshed at the same protocol-safe points as
|
|
1048
|
+
// SESSION STATE so advancing tasks are never contradicted by a stale copy.
|
|
1049
|
+
if (p) {
|
|
1050
|
+
upsertPlanContextMessage(messages, planContextMessage(p, session.planApproved.value));
|
|
1051
|
+
}
|
|
874
1052
|
upsertSessionStateMessage(messages, buildSessionStateBlock(snap));
|
|
875
1053
|
};
|
|
876
1054
|
refreshSessionState(activePlan);
|
|
@@ -907,7 +1085,10 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
907
1085
|
kind: inferOutcomeKind({ userIntent: prompt, buildLike, pentestLike }),
|
|
908
1086
|
continueExisting: continueExistingOutcome,
|
|
909
1087
|
});
|
|
1088
|
+
loopGuard.restoreCompletedOperations(outcomeState.completedOperations ?? []);
|
|
910
1089
|
await saveOutcomeState(outcomeState);
|
|
1090
|
+
// Canonical mutation/artifact ledger feeding the durable compaction envelope.
|
|
1091
|
+
const workLedger = new WorkLedger();
|
|
911
1092
|
let governorState = createGovernorState();
|
|
912
1093
|
let governorPauseReason;
|
|
913
1094
|
let turnState = createTurnState();
|
|
@@ -962,10 +1143,54 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
962
1143
|
return release;
|
|
963
1144
|
},
|
|
964
1145
|
};
|
|
1146
|
+
/**
|
|
1147
|
+
* Apply a salvaged partial write through the NORMAL tool path so the
|
|
1148
|
+
* classifier, scope/engagement gates, confirmation prompt, and receipts
|
|
1149
|
+
* all apply exactly as they would for a model-emitted call. Salvage must
|
|
1150
|
+
* never mutate a file with `confirmed: true`, and an `fs.append` that was
|
|
1151
|
+
* cut off must stay an append (with its precondition) instead of becoming
|
|
1152
|
+
* a full overwrite.
|
|
1153
|
+
*/
|
|
1154
|
+
async function applySalvagedWrite(salvaged) {
|
|
1155
|
+
const args = {
|
|
1156
|
+
path: salvaged.path,
|
|
1157
|
+
content: salvaged.content,
|
|
1158
|
+
};
|
|
1159
|
+
if (salvaged.operation === "append") {
|
|
1160
|
+
args.position = "end";
|
|
1161
|
+
if (typeof salvaged.expectedPriorBytes === "number") {
|
|
1162
|
+
args.expectedPriorBytes = salvaged.expectedPriorBytes;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
const call = {
|
|
1166
|
+
name: salvaged.operation === "append" ? "fs.append" : "fs.write",
|
|
1167
|
+
args,
|
|
1168
|
+
};
|
|
1169
|
+
const eventId = `tool-${++nextToolEventId}`;
|
|
1170
|
+
const res = await executeSingleTool(call, eventId, options.signal || new AbortController().signal);
|
|
1171
|
+
const ok = res.ok && res.result.ok;
|
|
1172
|
+
let bytesOnDisk = Buffer.byteLength(salvaged.content, "utf8");
|
|
1173
|
+
if (ok) {
|
|
1174
|
+
try {
|
|
1175
|
+
const stats = await stat(resolveFsToolPath(salvaged.path));
|
|
1176
|
+
bytesOnDisk = stats.size;
|
|
1177
|
+
}
|
|
1178
|
+
catch {
|
|
1179
|
+
// Keep the content-length estimate when the file cannot be stat'ed.
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
return {
|
|
1183
|
+
ok,
|
|
1184
|
+
cancelled: Boolean(res.blockOrCancel),
|
|
1185
|
+
output: res.result.output,
|
|
1186
|
+
bytesOnDisk,
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
965
1189
|
async function executeSingleTool(rawCall, toolEventId, parentSignal) {
|
|
966
1190
|
const scratchDir = scratchDirFor(safeCwd());
|
|
967
1191
|
let call = normalizeToolCall(rawCall);
|
|
968
1192
|
let dispatchedTaskId;
|
|
1193
|
+
let delegation;
|
|
969
1194
|
let engagementLease;
|
|
970
1195
|
let engagementGraph;
|
|
971
1196
|
let engagementRecord;
|
|
@@ -986,7 +1211,13 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
986
1211
|
return { ok: true, call, result, contextOutput: recoveryText };
|
|
987
1212
|
}
|
|
988
1213
|
if (narrowNmapOperation) {
|
|
989
|
-
const allowed = new Set([
|
|
1214
|
+
const allowed = new Set([
|
|
1215
|
+
"net.scan",
|
|
1216
|
+
"shell.tail",
|
|
1217
|
+
"shell.jobs",
|
|
1218
|
+
"job.read",
|
|
1219
|
+
"task.read",
|
|
1220
|
+
]);
|
|
990
1221
|
if (!allowed.has(call.name)) {
|
|
991
1222
|
const reason = `Narrow nmap request: ${call.name} was not run because the user requested only one nmap operation. ` +
|
|
992
1223
|
`Call net.scan with the requested target/options; do not create a plan or add DNS, WHOIS, HTTP, recon, or vulnerability steps.`;
|
|
@@ -1026,17 +1257,27 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
1026
1257
|
detail: String(retryReasonRaw.detail ?? ""),
|
|
1027
1258
|
}
|
|
1028
1259
|
: undefined;
|
|
1029
|
-
|
|
1030
|
-
// Successful re-calls always run for real — no "already succeeded /
|
|
1031
|
-
// use prior results" nags (those caused model abnormalities).
|
|
1260
|
+
const currentProbeState = probeStateKey(call);
|
|
1032
1261
|
const loopCheck = loopGuard.shouldBlock(call.name, call.args, {
|
|
1033
1262
|
dependenciesChanged: retryDependenciesChanged,
|
|
1034
1263
|
environmentChanged: retryEnvironmentChanged,
|
|
1264
|
+
...(currentProbeState ? { stateKey: currentProbeState } : {}),
|
|
1035
1265
|
...(retryReason ? { retryReason } : {}),
|
|
1036
1266
|
});
|
|
1037
1267
|
if (loopCheck.block) {
|
|
1038
1268
|
const reason = loopCheck.reason ??
|
|
1039
1269
|
`${call.name} previously failed with identical arguments. Change the command/args and retry.`;
|
|
1270
|
+
if (loopCheck.kind === "unchanged-success") {
|
|
1271
|
+
const result = { ok: true, output: reason, exitCode: 0 };
|
|
1272
|
+
emitToolResult(toolEventId, result, reason);
|
|
1273
|
+
return {
|
|
1274
|
+
ok: true,
|
|
1275
|
+
call,
|
|
1276
|
+
result,
|
|
1277
|
+
contextOutput: reason,
|
|
1278
|
+
suppressedRepeat: true,
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1040
1281
|
writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
|
|
1041
1282
|
const result = { ok: false, output: reason, exitCode: 1 };
|
|
1042
1283
|
emitToolResult(toolEventId, result, reason);
|
|
@@ -1050,29 +1291,63 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
1050
1291
|
if (call.name === "plan.create" ||
|
|
1051
1292
|
call.name === "task.add" ||
|
|
1052
1293
|
call.name === "task.move" ||
|
|
1294
|
+
call.name === "job.read" ||
|
|
1053
1295
|
call.name === "task.read" ||
|
|
1054
1296
|
call.name === "task.update") {
|
|
1055
|
-
if (call.name === "task.read") {
|
|
1056
|
-
const
|
|
1297
|
+
if (call.name === "job.read" || call.name === "task.read") {
|
|
1298
|
+
const requestedNotificationId = typeof call.args.notificationId === "string"
|
|
1057
1299
|
? call.args.notificationId.trim()
|
|
1058
1300
|
: "";
|
|
1059
|
-
const
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1301
|
+
const requestedJobId = typeof call.args.jobId === "string" ? call.args.jobId.trim() : "";
|
|
1302
|
+
const pending = jobManager.getPendingNotifications(session.sessionId);
|
|
1303
|
+
const eligible = responderWakeTurn
|
|
1304
|
+
? pending.filter(matchesWakeRevision)
|
|
1305
|
+
: pending;
|
|
1306
|
+
const byNotification = requestedNotificationId
|
|
1307
|
+
? eligible.find((candidate) => candidate.id === requestedNotificationId)
|
|
1308
|
+
: undefined;
|
|
1309
|
+
const byJob = requestedJobId
|
|
1310
|
+
? eligible.find((candidate) => candidate.jobId === requestedJobId)
|
|
1311
|
+
: undefined;
|
|
1312
|
+
const identifiersConflict = Boolean((byNotification && requestedJobId && byNotification.jobId !== requestedJobId) ||
|
|
1313
|
+
(byJob && requestedNotificationId && byJob.id !== requestedNotificationId));
|
|
1314
|
+
const notification = identifiersConflict
|
|
1315
|
+
? undefined
|
|
1316
|
+
: (byNotification ?? byJob);
|
|
1317
|
+
const visible = Boolean(notification && unreadResponderNotificationIds.has(notification.id));
|
|
1318
|
+
const wakeIdentityMatches = Boolean(responderWakeTurn &&
|
|
1319
|
+
(requestedNotificationId || requestedJobId) &&
|
|
1320
|
+
(!requestedNotificationId ||
|
|
1321
|
+
requestedNotificationId === responderWakeNotificationId) &&
|
|
1322
|
+
(!requestedJobId || requestedJobId === responderWakeJobId));
|
|
1323
|
+
const staleWakeSettled = wakeIdentityMatches && !identifiersConflict && !notification;
|
|
1324
|
+
const persistedRead = Boolean(notification &&
|
|
1064
1325
|
visible &&
|
|
1065
|
-
jobManager.markRead(
|
|
1066
|
-
const
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1326
|
+
jobManager.markRead(notification.id, session.sessionId));
|
|
1327
|
+
const marked = persistedRead || staleWakeSettled;
|
|
1328
|
+
const identifier = requestedJobId || requestedNotificationId;
|
|
1329
|
+
const revisionLabel = responderWakeResultRevision
|
|
1330
|
+
? ` revision ${responderWakeResultRevision}`
|
|
1331
|
+
: "";
|
|
1332
|
+
const output = persistedRead
|
|
1333
|
+
? `Responder job ${notification.jobId} (${notification.id}) marked delivered and read after model analysis.`
|
|
1334
|
+
: staleWakeSettled
|
|
1335
|
+
? `Responder result ${identifier}${revisionLabel} was already settled or discarded; the stale wake is acknowledged idempotently.`
|
|
1336
|
+
: !requestedNotificationId && !requestedJobId
|
|
1337
|
+
? `${call.name} failed: jobId or notificationId is required.`
|
|
1338
|
+
: identifiersConflict
|
|
1339
|
+
? `${call.name} failed: jobId and notificationId refer to different Responder results.`
|
|
1340
|
+
: !notification
|
|
1341
|
+
? `${call.name} failed: Responder result ${identifier} is unavailable, consumed, or archived.`
|
|
1342
|
+
: !visible
|
|
1343
|
+
? `${call.name} failed: Responder result ${identifier} was not delivered to this model turn. Analyze a delivered result before marking it read.`
|
|
1344
|
+
: `${call.name} failed: read state for Responder result ${identifier} could not be persisted.`;
|
|
1345
|
+
if (persistedRead && notification) {
|
|
1074
1346
|
upsertResponderResultLedger(messages, notification);
|
|
1075
|
-
unreadResponderNotificationIds.delete(
|
|
1347
|
+
unreadResponderNotificationIds.delete(notification.id);
|
|
1348
|
+
}
|
|
1349
|
+
else if (staleWakeSettled && responderWakeNotificationId) {
|
|
1350
|
+
unreadResponderNotificationIds.delete(responderWakeNotificationId);
|
|
1076
1351
|
}
|
|
1077
1352
|
if (!alreadyPrintedIds.has(toolEventId)) {
|
|
1078
1353
|
const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
|
|
@@ -1189,7 +1464,7 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
1189
1464
|
taskWorkLedger = led;
|
|
1190
1465
|
if (planResult.plan && led && led.successWorkCount > 0 && persisted) {
|
|
1191
1466
|
persisted.evidence = taskEvidenceFromLedger(led);
|
|
1192
|
-
await
|
|
1467
|
+
await persistTaskEvidence(persisted.id, persisted.evidence);
|
|
1193
1468
|
}
|
|
1194
1469
|
}
|
|
1195
1470
|
else if (stateRaw === "done" && resolved) {
|
|
@@ -1198,7 +1473,7 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
1198
1473
|
const t = planResult.plan.tasks.find((x) => x.id === resolved);
|
|
1199
1474
|
if (t) {
|
|
1200
1475
|
t.evidence = taskEvidenceFromLedger(taskWorkLedger);
|
|
1201
|
-
await
|
|
1476
|
+
await persistTaskEvidence(t.id, t.evidence);
|
|
1202
1477
|
}
|
|
1203
1478
|
}
|
|
1204
1479
|
taskWorkLedger = null;
|
|
@@ -1327,7 +1602,18 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
1327
1602
|
livePlanForGate.status === "approved") {
|
|
1328
1603
|
livePlanForGate.status = "in_progress";
|
|
1329
1604
|
}
|
|
1330
|
-
|
|
1605
|
+
// Opening a task is a transition applied by
|
|
1606
|
+
// the reducer, which also enforces the single-active invariant.
|
|
1607
|
+
await mutatePlan(session.sessionId, (draft) => {
|
|
1608
|
+
const target = draft.tasks.find((candidate) => candidate.id === nextPending.id);
|
|
1609
|
+
if (!target || target.state === "in_progress")
|
|
1610
|
+
return false;
|
|
1611
|
+
target.state = "in_progress";
|
|
1612
|
+
if (draft.status === "draft" || draft.status === "approved") {
|
|
1613
|
+
draft.status = "in_progress";
|
|
1614
|
+
}
|
|
1615
|
+
return true;
|
|
1616
|
+
}).catch(() => undefined);
|
|
1331
1617
|
// Preserve evidence already credited to this task (e.g. pentest
|
|
1332
1618
|
// recon that ran before the task was formally opened).
|
|
1333
1619
|
if (!taskWorkLedger ||
|
|
@@ -1385,22 +1671,29 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
1385
1671
|
alreadyPrintedIds.add(toolEventId);
|
|
1386
1672
|
}
|
|
1387
1673
|
const scopeTarget = safeScopeTargetForToolCall(call);
|
|
1388
|
-
const
|
|
1389
|
-
?
|
|
1390
|
-
:
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1674
|
+
const engagementActions = pentestSession || isPentestToolCall(call) || Boolean(scope)
|
|
1675
|
+
? safeEngagementActionsForToolCall(call)
|
|
1676
|
+
: [];
|
|
1677
|
+
// The primary action carries the URL/port/path detail used for leases and
|
|
1678
|
+
// network-hop authorization; every action (one per named target) must pass
|
|
1679
|
+
// the scope check below before the tool runs.
|
|
1680
|
+
const engagementAction = engagementActions[0];
|
|
1681
|
+
let engagementDecision;
|
|
1682
|
+
for (const action of engagementActions) {
|
|
1683
|
+
const decisionForAction = evaluateEngagementAction(scope, action);
|
|
1684
|
+
if (!decisionForAction)
|
|
1685
|
+
continue;
|
|
1686
|
+
if (action === engagementAction)
|
|
1687
|
+
engagementDecision = decisionForAction;
|
|
1395
1688
|
if (scope) {
|
|
1396
1689
|
engagementGraph = await openEngagement(scope);
|
|
1397
1690
|
engagementRecord = beginEngagementAction(engagementGraph, {
|
|
1398
1691
|
tool: call.name,
|
|
1399
|
-
target:
|
|
1400
|
-
phase:
|
|
1401
|
-
capability:
|
|
1402
|
-
authorized:
|
|
1403
|
-
reason:
|
|
1692
|
+
target: decisionForAction.normalizedTarget || action.target,
|
|
1693
|
+
phase: decisionForAction.phase,
|
|
1694
|
+
capability: decisionForAction.capability,
|
|
1695
|
+
authorized: decisionForAction.allowed,
|
|
1696
|
+
reason: decisionForAction.reason,
|
|
1404
1697
|
});
|
|
1405
1698
|
await saveEngagement(engagementGraph);
|
|
1406
1699
|
}
|
|
@@ -1408,16 +1701,22 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
1408
1701
|
...(engagementGraph ? { engagementId: engagementGraph.id } : {}),
|
|
1409
1702
|
...(engagementRecord ? { actionId: engagementRecord.id } : {}),
|
|
1410
1703
|
tool: call.name,
|
|
1411
|
-
target:
|
|
1412
|
-
phase:
|
|
1413
|
-
capability:
|
|
1414
|
-
allowed:
|
|
1415
|
-
reason:
|
|
1704
|
+
target: decisionForAction.normalizedTarget,
|
|
1705
|
+
phase: decisionForAction.phase,
|
|
1706
|
+
capability: decisionForAction.capability,
|
|
1707
|
+
allowed: decisionForAction.allowed,
|
|
1708
|
+
reason: decisionForAction.reason,
|
|
1416
1709
|
});
|
|
1417
|
-
if (!
|
|
1418
|
-
const target =
|
|
1419
|
-
|
|
1420
|
-
|
|
1710
|
+
if (!decisionForAction.allowed) {
|
|
1711
|
+
const target = decisionForAction.normalizedTarget ||
|
|
1712
|
+
action.target ||
|
|
1713
|
+
scopeTarget ||
|
|
1714
|
+
"requested target";
|
|
1715
|
+
const reason = outOfScopeToolMessage({
|
|
1716
|
+
target,
|
|
1717
|
+
reason: decisionForAction.reason,
|
|
1718
|
+
allowed: scope?.authorizedTargets,
|
|
1719
|
+
});
|
|
1421
1720
|
writeToolBlocked(toolEventId, call.name, reason, chalk.red(` ✗ ${reason}\n`));
|
|
1422
1721
|
const result = { ok: false, output: reason, exitCode: 1 };
|
|
1423
1722
|
emitToolResult(toolEventId, result, reason);
|
|
@@ -1524,6 +1823,53 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
1524
1823
|
const candidate = pickPendingTaskForToolCall(readyPlanTasks(planAtDispatch), call, planAtDispatch.tasks.map((task) => task.title));
|
|
1525
1824
|
dispatchedTaskId = candidate?.id;
|
|
1526
1825
|
}
|
|
1826
|
+
// An explicitly declared responder parent wins over inference.
|
|
1827
|
+
const declaredParent = readDeclaredParentTaskId(call);
|
|
1828
|
+
if (declaredParent) {
|
|
1829
|
+
const resolvedParent = resolveResponderParent({
|
|
1830
|
+
plan: planAtDispatch,
|
|
1831
|
+
declared: declaredParent,
|
|
1832
|
+
activeForegroundTaskIds: dispatchedTaskId ? [dispatchedTaskId] : [],
|
|
1833
|
+
});
|
|
1834
|
+
if (!resolvedParent.ok) {
|
|
1835
|
+
const reason = `${call.name} failed: ${resolvedParent.reason}`;
|
|
1836
|
+
const result = { ok: false, output: reason, exitCode: 1 };
|
|
1837
|
+
emitToolResult(toolEventId, result, reason);
|
|
1838
|
+
return { ok: false, call, result, contextOutput: reason };
|
|
1839
|
+
}
|
|
1840
|
+
dispatchedTaskId = resolvedParent.taskId ?? dispatchedTaskId;
|
|
1841
|
+
}
|
|
1842
|
+
// For an explicit delegation, create the child subtask before the
|
|
1843
|
+
// Process starts and launch the job already bound to it. The job therefore
|
|
1844
|
+
// Never carries the foreground parent in `taskId`, and settlement has a
|
|
1845
|
+
// Durable row to advance even if this turn dies right after spawn.
|
|
1846
|
+
if (isExplicitResponderDelegation(call) && planAtDispatch) {
|
|
1847
|
+
delegation = { id: `dg-${randomUUID().slice(0, 8)}` };
|
|
1848
|
+
const created = await mutatePlan(session.sessionId, (draft) => {
|
|
1849
|
+
const parentExists = !!dispatchedTaskId &&
|
|
1850
|
+
draft.tasks.some((task) => task.id === dispatchedTaskId);
|
|
1851
|
+
const child = appendPlanTask(draft, {
|
|
1852
|
+
title: delegationTaskTitle(call),
|
|
1853
|
+
state: "in_progress",
|
|
1854
|
+
note: `delegation=${delegation.id} awaiting launch`,
|
|
1855
|
+
dependencies: [],
|
|
1856
|
+
resourceLocks: [],
|
|
1857
|
+
...(parentExists ? { parentTaskId: dispatchedTaskId } : {}),
|
|
1858
|
+
responderOwned: true,
|
|
1859
|
+
delegationId: delegation.id,
|
|
1860
|
+
});
|
|
1861
|
+
delegation.taskId = child.id;
|
|
1862
|
+
return true;
|
|
1863
|
+
}).catch(() => undefined);
|
|
1864
|
+
if (!created?.ok || !delegation.taskId) {
|
|
1865
|
+
delegation = undefined;
|
|
1866
|
+
writeNotice("warn", "Responder delegation record could not be persisted — the job will be linked after launch", chalk.yellow(" ⚠ responder delegation record not persisted; linking after launch\n"));
|
|
1867
|
+
}
|
|
1868
|
+
else if (created.plan) {
|
|
1869
|
+
pendingSessionStatePlan = created.plan;
|
|
1870
|
+
writePlanUpdate(created.plan, renderPlanForTerminal(created.plan) + "\n");
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1527
1873
|
if (dispatchedTaskId &&
|
|
1528
1874
|
(!taskWorkLedger || taskWorkLedger.taskId !== dispatchedTaskId)) {
|
|
1529
1875
|
const dispatchedTask = planAtDispatch?.tasks.find((task) => task.id === dispatchedTaskId);
|
|
@@ -1532,7 +1878,12 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
1532
1878
|
if (engagementAction) {
|
|
1533
1879
|
engagementLease = engagementPolicy.acquire(scope, engagementAction);
|
|
1534
1880
|
if (!engagementLease.decision.allowed) {
|
|
1535
|
-
const
|
|
1881
|
+
const target = engagementLease.decision.normalizedTarget || engagementAction.target;
|
|
1882
|
+
const reason = outOfScopeToolMessage({
|
|
1883
|
+
target,
|
|
1884
|
+
reason: engagementLease.decision.reason,
|
|
1885
|
+
allowed: scope?.authorizedTargets,
|
|
1886
|
+
});
|
|
1536
1887
|
const result = { ok: false, output: reason, exitCode: 1 };
|
|
1537
1888
|
emitToolResult(toolEventId, result, reason);
|
|
1538
1889
|
return { ok: false, call, result, contextOutput: reason };
|
|
@@ -1634,7 +1985,9 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
1634
1985
|
confirmed: true,
|
|
1635
1986
|
userPrompt: prompt,
|
|
1636
1987
|
sessionId: session.sessionId,
|
|
1637
|
-
...(
|
|
1988
|
+
...(delegation?.taskId ? { taskId: delegation.taskId } : {}),
|
|
1989
|
+
...(delegation ? { delegationId: delegation.id } : {}),
|
|
1990
|
+
...(dispatchedTaskId ? { parentTaskId: dispatchedTaskId } : {}),
|
|
1638
1991
|
wakeOnCompletion: true,
|
|
1639
1992
|
monitor: {
|
|
1640
1993
|
toolName: call.name,
|
|
@@ -1745,13 +2098,18 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
1745
2098
|
// User Esc/Ctrl+C: force-settle may resolve with a cancel result
|
|
1746
2099
|
// instead of throwing — still end the turn as aborted.
|
|
1747
2100
|
if (parentSignal.aborted && !stalledByWatchdog && !hardTimedOut) {
|
|
1748
|
-
|
|
2101
|
+
const result = {
|
|
2102
|
+
ok: false,
|
|
2103
|
+
output: "Cancelled by user.",
|
|
2104
|
+
exitCode: 130,
|
|
2105
|
+
};
|
|
2106
|
+
emitToolResult(toolEventId, result, result.output);
|
|
1749
2107
|
return {
|
|
1750
2108
|
ok: false,
|
|
1751
2109
|
call,
|
|
1752
|
-
result
|
|
1753
|
-
contextOutput:
|
|
1754
|
-
|
|
2110
|
+
result,
|
|
2111
|
+
contextOutput: result.output,
|
|
2112
|
+
aborted: true,
|
|
1755
2113
|
};
|
|
1756
2114
|
}
|
|
1757
2115
|
if (liveBytes > 0) {
|
|
@@ -1763,13 +2121,18 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
1763
2121
|
jobManager.updateJobStatus(jobId, "failed", 1);
|
|
1764
2122
|
if (isAbortError(toolError, toolAc.signal) || forceSettled) {
|
|
1765
2123
|
if (parentSignal.aborted && !stalledByWatchdog && !hardTimedOut) {
|
|
1766
|
-
|
|
2124
|
+
const result = {
|
|
2125
|
+
ok: false,
|
|
2126
|
+
output: "Cancelled by user.",
|
|
2127
|
+
exitCode: 130,
|
|
2128
|
+
};
|
|
2129
|
+
emitToolResult(toolEventId, result, result.output);
|
|
1767
2130
|
return {
|
|
1768
2131
|
ok: false,
|
|
1769
2132
|
call,
|
|
1770
|
-
result
|
|
1771
|
-
contextOutput:
|
|
1772
|
-
|
|
2133
|
+
result,
|
|
2134
|
+
contextOutput: result.output,
|
|
2135
|
+
aborted: true,
|
|
1773
2136
|
};
|
|
1774
2137
|
}
|
|
1775
2138
|
result = {
|
|
@@ -1873,6 +2236,27 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
1873
2236
|
writeNotice("info", `project root → ${fromScaffold}`, chalk.dim(` ℹ project root set to ${fromScaffold}\n`));
|
|
1874
2237
|
}
|
|
1875
2238
|
}
|
|
2239
|
+
if (delegation?.taskId && !result.backgroundJob) {
|
|
2240
|
+
// The delegation never became a durable job: settle its child instead of
|
|
2241
|
+
// Leaving a permanently yellow subtask behind.
|
|
2242
|
+
const delegationId = delegation.id;
|
|
2243
|
+
const settledState = result.ok ? "skipped" : "failed";
|
|
2244
|
+
const settlement = await mutatePlan(session.sessionId, (draft) => {
|
|
2245
|
+
const child = draft.tasks.find((task) => task.delegationId === delegationId);
|
|
2246
|
+
if (!child)
|
|
2247
|
+
return false;
|
|
2248
|
+
child.state = settledState;
|
|
2249
|
+
child.note = result.ok
|
|
2250
|
+
? `delegation=${delegationId} ran in the foreground; no durable job was created`
|
|
2251
|
+
: `delegation=${delegationId} failed to launch`;
|
|
2252
|
+
return true;
|
|
2253
|
+
}).catch(() => undefined);
|
|
2254
|
+
if (settlement?.ok && settlement.plan) {
|
|
2255
|
+
pendingSessionStatePlan = settlement.plan;
|
|
2256
|
+
writePlanUpdate(settlement.plan, renderPlanForTerminal(settlement.plan) + "\n");
|
|
2257
|
+
}
|
|
2258
|
+
delegation = undefined;
|
|
2259
|
+
}
|
|
1876
2260
|
if (result.backgroundJob) {
|
|
1877
2261
|
const durableJob = jobManager.getJob(result.backgroundJob.id);
|
|
1878
2262
|
// Responder linkage is opt-in: only jobs launched with responder:true
|
|
@@ -1880,9 +2264,10 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
1880
2264
|
// Plain background jobs stay pollable (shell.jobs/shell.tail) as before.
|
|
1881
2265
|
if (durableJob?.responder) {
|
|
1882
2266
|
const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
1883
|
-
let linkedTaskId =
|
|
2267
|
+
let linkedTaskId = delegation?.taskId;
|
|
1884
2268
|
let linkedParentTaskId;
|
|
1885
2269
|
let responderTaskId;
|
|
2270
|
+
let responderChildId;
|
|
1886
2271
|
if (durableJob && livePlan) {
|
|
1887
2272
|
const existing = livePlan.tasks.find((task) => task.jobId === durableJob.id);
|
|
1888
2273
|
const parentTaskId = dispatchedTaskId && livePlan.tasks.some((task) => task.id === dispatchedTaskId)
|
|
@@ -1897,46 +2282,64 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
1897
2282
|
: "in_progress";
|
|
1898
2283
|
const note = `job=${durableJob.id} pid=${durableJob.pid ?? "?"} status=${durableJob.status} ` +
|
|
1899
2284
|
`artifact=${durableJob.stdoutArtifact}`;
|
|
1900
|
-
const
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
2285
|
+
const responderTitle = `Responder · ${durableJob.name ?? durableJob.commandDisplay.slice(0, 96)}`;
|
|
2286
|
+
// Upsert the child by delegation/job identity
|
|
2287
|
+
// inside the transactional boundary. A concurrent settlement that
|
|
2288
|
+
// already turned the child green is therefore never reverted, and the
|
|
2289
|
+
// child is never written as the foreground parent.
|
|
2290
|
+
const upsert = await mutatePlan(session.sessionId, (draft) => {
|
|
2291
|
+
const target = (durableJob.delegationId
|
|
2292
|
+
? draft.tasks.find((task) => task.delegationId === durableJob.delegationId)
|
|
2293
|
+
: undefined) ??
|
|
2294
|
+
draft.tasks.find((task) => task.jobId === durableJob.id);
|
|
2295
|
+
const child = target ??
|
|
2296
|
+
appendPlanTask(draft, {
|
|
2297
|
+
title: responderTitle,
|
|
2298
|
+
state: terminalState,
|
|
2299
|
+
note,
|
|
2300
|
+
dependencies: [],
|
|
2301
|
+
resourceLocks: [],
|
|
2302
|
+
parentTaskId,
|
|
2303
|
+
jobId: durableJob.id,
|
|
2304
|
+
processId: durableJob.pid,
|
|
2305
|
+
responderOwned: true,
|
|
2306
|
+
...(durableJob.delegationId
|
|
2307
|
+
? { delegationId: durableJob.delegationId }
|
|
2308
|
+
: {}),
|
|
2309
|
+
});
|
|
2310
|
+
// Never regress a child that process settlement already finished.
|
|
2311
|
+
const settledTerminal = child.state === "done" || child.state === "failed";
|
|
2312
|
+
if (!settledTerminal) {
|
|
2313
|
+
child.state = terminalState;
|
|
2314
|
+
child.note = note;
|
|
2315
|
+
}
|
|
2316
|
+
child.jobId = durableJob.id;
|
|
2317
|
+
child.processId = durableJob.pid;
|
|
2318
|
+
child.responderOwned = true;
|
|
2319
|
+
if (durableJob.delegationId) {
|
|
2320
|
+
child.delegationId = durableJob.delegationId;
|
|
2321
|
+
}
|
|
2322
|
+
if (parentTaskId)
|
|
2323
|
+
child.parentTaskId = parentTaskId;
|
|
2324
|
+
if (isPlanTerminal(draft)) {
|
|
2325
|
+
draft.status = isPlanSuccessful(draft) ? "completed" : "abandoned";
|
|
2326
|
+
}
|
|
2327
|
+
else if (draft.status !== "draft") {
|
|
2328
|
+
draft.status = "in_progress";
|
|
2329
|
+
}
|
|
2330
|
+
responderChildId = child.id;
|
|
2331
|
+
return true;
|
|
2332
|
+
}).catch(() => undefined);
|
|
2333
|
+
if (!upsert?.ok || !responderChildId) {
|
|
1932
2334
|
writeNotice("warn", `Responder job ${durableJob.id} started, but its plan subtask could not be persisted`, chalk.yellow(` ⚠ job ${durableJob.id} is running, but task linkage persistence failed\n`));
|
|
1933
2335
|
}
|
|
1934
|
-
|
|
1935
|
-
linkedTaskId =
|
|
2336
|
+
else {
|
|
2337
|
+
linkedTaskId = responderChildId;
|
|
1936
2338
|
linkedParentTaskId = parentTaskId;
|
|
1937
|
-
responderTaskId =
|
|
1938
|
-
pendingSessionStatePlan = livePlan;
|
|
1939
|
-
|
|
2339
|
+
responderTaskId = responderChildId;
|
|
2340
|
+
pendingSessionStatePlan = upsert.plan ?? livePlan;
|
|
2341
|
+
const rendered = upsert.plan ?? livePlan;
|
|
2342
|
+
writePlanUpdate(rendered, renderPlanForTerminal(rendered) + "\n");
|
|
1940
2343
|
}
|
|
1941
2344
|
}
|
|
1942
2345
|
if (durableJob) {
|
|
@@ -2021,13 +2424,17 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
2021
2424
|
}
|
|
2022
2425
|
await saveEngagement(engagementGraph);
|
|
2023
2426
|
}
|
|
2427
|
+
workLedger.recordToolCall(call, result.ok, savedOutputPath);
|
|
2428
|
+
const completedProbeState = probeStateKey(call);
|
|
2024
2429
|
const newEvidence = recordToolEvidence(outcomeState, {
|
|
2025
2430
|
tool: call.name,
|
|
2026
2431
|
callId: toolEventId,
|
|
2027
2432
|
ok: result.ok,
|
|
2433
|
+
...(result.exitCode !== undefined ? { exitCode: result.exitCode } : {}),
|
|
2028
2434
|
output: result.output,
|
|
2029
2435
|
...(savedOutputPath ? { artifact: savedOutputPath } : {}),
|
|
2030
2436
|
...(dispatchedTaskId ? { taskId: dispatchedTaskId } : {}),
|
|
2437
|
+
...(completedProbeState ? { stateKey: completedProbeState } : {}),
|
|
2031
2438
|
args: call.args,
|
|
2032
2439
|
});
|
|
2033
2440
|
let hypothesisDelta = 0;
|
|
@@ -2100,7 +2507,7 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
2100
2507
|
}
|
|
2101
2508
|
}
|
|
2102
2509
|
await saveOutcomeState(outcomeState);
|
|
2103
|
-
loopGuard.recordAttempt(step, call.name, call.args, result.ok, result.exitCode, result.output);
|
|
2510
|
+
loopGuard.recordAttempt(step, call.name, call.args, result.ok, result.exitCode, result.output, completedProbeState ? { stateKey: completedProbeState } : undefined);
|
|
2104
2511
|
// Evidence for verify-before-done: only successful real work counts.
|
|
2105
2512
|
if (result.ok && isEvidenceWorkTool(call.name)) {
|
|
2106
2513
|
const liveAfter = await loadPlan(session.sessionId).catch(() => undefined);
|
|
@@ -2172,7 +2579,7 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
2172
2579
|
taskWorkLedger.successWorkCount < absorbed.successWorkCount) {
|
|
2173
2580
|
taskWorkLedger = absorbed;
|
|
2174
2581
|
}
|
|
2175
|
-
await
|
|
2582
|
+
await persistTaskEvidence(task.id, task.evidence);
|
|
2176
2583
|
}
|
|
2177
2584
|
}
|
|
2178
2585
|
}
|
|
@@ -2181,7 +2588,7 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
2181
2588
|
const task = liveAfter.tasks.find((candidate) => candidate.id === creditId);
|
|
2182
2589
|
if (task) {
|
|
2183
2590
|
task.evidence = taskEvidenceFromLedger(taskWorkLedger);
|
|
2184
|
-
await
|
|
2591
|
+
await persistTaskEvidence(task.id, task.evidence);
|
|
2185
2592
|
}
|
|
2186
2593
|
}
|
|
2187
2594
|
// Do NOT refreshSessionState here. executeSingleTool often finishes
|
|
@@ -2249,13 +2656,24 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
2249
2656
|
// Align with /compact default: small recency + dense memory (not keepRecent=6 fat tails).
|
|
2250
2657
|
const AUTO_COMPACT_KEEP_RECENT = 2;
|
|
2251
2658
|
let lastCompactionMsgCount = 0;
|
|
2659
|
+
const compactionAttempts = new CompactionAttemptLedger();
|
|
2660
|
+
let activeCompactionId;
|
|
2252
2661
|
/** E5: identical tool bodies within this turn → pointer instead of re-append. */
|
|
2253
2662
|
const toolResultHashes = new Map();
|
|
2254
2663
|
/** E4: consecutive free-tier stream failures this turn. */
|
|
2255
2664
|
let freeTierConsecutiveFailures = 0;
|
|
2256
2665
|
let freeTierLargeContextWarned = false;
|
|
2257
|
-
|
|
2258
|
-
|
|
2666
|
+
// Surface the free-tier "failed N times / switch provider" advisory at most
|
|
2667
|
+
// once per turn — the recovery planner already narrates each retry, so
|
|
2668
|
+
// repeating this on every failure just adds noise.
|
|
2669
|
+
let freeTierAdvisoryShown = false;
|
|
2670
|
+
const summarizeForCompaction = async (summaryPrompt, stage) => {
|
|
2671
|
+
const streamFinalSummary = stage?.phase !== "map";
|
|
2672
|
+
const compactionId = streamFinalSummary ? activeCompactionId : undefined;
|
|
2673
|
+
const deltaParser = compactionId
|
|
2674
|
+
? createThinkingStreamParser((text) => writeCompactionDelta(compactionId, text), undefined, { remember: false })
|
|
2675
|
+
: undefined;
|
|
2676
|
+
const request = {
|
|
2259
2677
|
provider,
|
|
2260
2678
|
model,
|
|
2261
2679
|
messages: [
|
|
@@ -2263,11 +2681,32 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
2263
2681
|
{ role: "user", content: summaryPrompt },
|
|
2264
2682
|
],
|
|
2265
2683
|
temperature: 0.1,
|
|
2266
|
-
// Prefer dense memory; enough room for findings without forcing a tiny stub.
|
|
2267
2684
|
maxTokens: 2_048,
|
|
2268
2685
|
signal: options.signal,
|
|
2686
|
+
};
|
|
2687
|
+
const response = await streamWithProvider(request, (token) => deltaParser?.push(token), { onStatus: () => undefined });
|
|
2688
|
+
deltaParser?.finish();
|
|
2689
|
+
const parsed = stripThinking(response.text);
|
|
2690
|
+
if (parsed.visible.trim() || !parsed.hasThinking)
|
|
2691
|
+
return response.text;
|
|
2692
|
+
const retry = await completeWithProvider({
|
|
2693
|
+
...request,
|
|
2694
|
+
messages: [
|
|
2695
|
+
{
|
|
2696
|
+
role: "system",
|
|
2697
|
+
content: `${COMPACTION_SYSTEM_PROMPT}\nReturn only the continuation-memory summary. Do not include analysis, reasoning, or <think> tags.`,
|
|
2698
|
+
},
|
|
2699
|
+
{ role: "user", content: summaryPrompt },
|
|
2700
|
+
],
|
|
2701
|
+
temperature: 0,
|
|
2702
|
+
maxTokens: 8_192,
|
|
2703
|
+
thinking: { enabled: false, effort: "none" },
|
|
2269
2704
|
});
|
|
2270
|
-
|
|
2705
|
+
const retryVisible = stripThinking(retry.text).visible.trim();
|
|
2706
|
+
if (retryVisible && compactionId) {
|
|
2707
|
+
writeCompactionDelta(compactionId, retryVisible);
|
|
2708
|
+
}
|
|
2709
|
+
return retry.text;
|
|
2271
2710
|
};
|
|
2272
2711
|
/**
|
|
2273
2712
|
* Estimate the complete next model request, including attached native-tool
|
|
@@ -2280,20 +2719,85 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
2280
2719
|
const nextTools = selectToolDefs(native, useCompactSystemPrompt);
|
|
2281
2720
|
return buildContextBreakdown(contextMessages, nextTools).estimatedTotalTokens;
|
|
2282
2721
|
};
|
|
2722
|
+
/**
|
|
2723
|
+
* Canonical state that must survive compaction verbatim. Built from the
|
|
2724
|
+
* plan store, outcome contract, responder ledger and mutation ledger — never
|
|
2725
|
+
* from the narrative summary.
|
|
2726
|
+
*/
|
|
2727
|
+
async function buildTurnDurableEnvelope() {
|
|
2728
|
+
const plan = (await loadPlan(session.sessionId).catch(() => undefined)) ?? undefined;
|
|
2729
|
+
const root = getActiveProjectRoot() ?? plan?.meta?.projectRoot;
|
|
2730
|
+
const consumed = [];
|
|
2731
|
+
for (const message of messages) {
|
|
2732
|
+
if (!isResponderResultLedgerMessage(message))
|
|
2733
|
+
continue;
|
|
2734
|
+
for (const line of message.content.split("\n")) {
|
|
2735
|
+
const match = /notification=(\S+)/.exec(line);
|
|
2736
|
+
if (match?.[1])
|
|
2737
|
+
consumed.push(match[1]);
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
const unread = jobManager
|
|
2741
|
+
.getPendingNotifications(session.sessionId)
|
|
2742
|
+
.map((notification) => notification.id);
|
|
2743
|
+
const toEnvelopeJob = (job) => ({
|
|
2744
|
+
id: job.id,
|
|
2745
|
+
status: job.status,
|
|
2746
|
+
command: job.commandDisplay || job.command,
|
|
2747
|
+
...(job.taskId ? { taskId: job.taskId } : {}),
|
|
2748
|
+
...(job.stdoutArtifact ? { artifact: job.stdoutArtifact } : {}),
|
|
2749
|
+
});
|
|
2750
|
+
const liveJobs = jobManager
|
|
2751
|
+
.getRunningJobs(session.sessionId)
|
|
2752
|
+
.map(toEnvelopeJob);
|
|
2753
|
+
const liveIds = new Set(liveJobs.map((job) => job.id));
|
|
2754
|
+
const finishedJobs = jobManager
|
|
2755
|
+
.getRecentJobs(12, session.sessionId)
|
|
2756
|
+
.filter((job) => !liveIds.has(job.id))
|
|
2757
|
+
.map(toEnvelopeJob);
|
|
2758
|
+
return buildDurableEnvelope({
|
|
2759
|
+
...(plan ? { plan } : {}),
|
|
2760
|
+
outcome: outcomeState,
|
|
2761
|
+
ledger: workLedger,
|
|
2762
|
+
...(root ? { projectRoot: root } : {}),
|
|
2763
|
+
...(root
|
|
2764
|
+
? { packageManager: plan?.meta?.packageManager ?? detectPackageManager(root) }
|
|
2765
|
+
: {}),
|
|
2766
|
+
responder: { unread, consumed: [...new Set(consumed)] },
|
|
2767
|
+
...(liveJobs.length > 0 ? { liveJobs } : {}),
|
|
2768
|
+
...(finishedJobs.length > 0 ? { finishedJobs } : {}),
|
|
2769
|
+
});
|
|
2770
|
+
}
|
|
2283
2771
|
async function maybeAutoCompact(reason, force = false) {
|
|
2284
2772
|
const beforeTokens = estimateNextRequestTokens(messages);
|
|
2285
|
-
|
|
2286
|
-
const compactTrigger =
|
|
2773
|
+
const budget = resolveRequestBudget({ provider, model });
|
|
2774
|
+
const compactTrigger = budget.effectiveTrigger;
|
|
2287
2775
|
if (!force && beforeTokens < compactTrigger)
|
|
2288
2776
|
return;
|
|
2777
|
+
// Structural eligibility only: there must be closed history to summarize.
|
|
2289
2778
|
if (messages.length <= AUTO_COMPACT_KEEP_RECENT + 2)
|
|
2290
2779
|
return;
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2780
|
+
const durableEnvelope = await buildTurnDurableEnvelope();
|
|
2781
|
+
const attemptKey = compactionAttemptKey({
|
|
2782
|
+
messages,
|
|
2783
|
+
provider,
|
|
2784
|
+
model,
|
|
2785
|
+
dialect: toolDialect,
|
|
2786
|
+
triggerTokens: compactTrigger,
|
|
2787
|
+
schemaHash: toolSchemaHash(selectToolDefs(nativeToolsActive, useCompactSystemPrompt)),
|
|
2788
|
+
...(durableEnvelope ? { durableEnvelope } : {}),
|
|
2789
|
+
});
|
|
2790
|
+
if (!force && compactionAttempts.isSuppressed(attemptKey))
|
|
2294
2791
|
return;
|
|
2792
|
+
const compactionId = `compact-${randomUUID().slice(0, 12)}`;
|
|
2793
|
+
activeCompactionId = compactionId;
|
|
2794
|
+
writeCompactionStarted(compactionId, beforeTokens);
|
|
2295
2795
|
try {
|
|
2296
|
-
const result = await compactMessagesWithSummary(messages, summarizeForCompaction, {
|
|
2796
|
+
const result = await compactMessagesWithSummary(messages, summarizeForCompaction, {
|
|
2797
|
+
budgetTokens: 0,
|
|
2798
|
+
keepRecent: AUTO_COMPACT_KEEP_RECENT,
|
|
2799
|
+
...(durableEnvelope ? { durableEnvelope } : {}),
|
|
2800
|
+
});
|
|
2297
2801
|
const summaryBody = result.messages.find((m) => isCompactionMemoryMessage(m))?.content ??
|
|
2298
2802
|
"";
|
|
2299
2803
|
if (!shouldApplyAutoCompact({
|
|
@@ -2303,9 +2807,27 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
2303
2807
|
afterTokens: result.afterTokens,
|
|
2304
2808
|
afterMessages: result.messages,
|
|
2305
2809
|
})) {
|
|
2810
|
+
writeCompactionFailed(compactionId, "The generated summary was not accepted; the original context was retained.", beforeTokens);
|
|
2811
|
+
return;
|
|
2812
|
+
}
|
|
2813
|
+
// otherwise the oversized request is sent anyway and corrective
|
|
2814
|
+
// compaction is suppressed as "already compacted".
|
|
2815
|
+
const candidateTokens = estimateNextRequestTokens(result.messages);
|
|
2816
|
+
if (!force && candidateTokens >= compactTrigger) {
|
|
2817
|
+
const dominant = describeDominantContextBlock(result.messages);
|
|
2818
|
+
compactionAttempts.recordFailure(attemptKey);
|
|
2819
|
+
await auditLog("agent.compact.overflow", {
|
|
2820
|
+
reason,
|
|
2821
|
+
candidateTokens,
|
|
2822
|
+
trigger: compactTrigger,
|
|
2823
|
+
dominant,
|
|
2824
|
+
});
|
|
2825
|
+
writeNotice("warn", `context is still ~${candidateTokens.toLocaleString()} tokens after compaction (limit ~${compactTrigger.toLocaleString()}) — largest block: ${dominant}`, chalk.yellow(` ⚠ context still ~${candidateTokens.toLocaleString()} tokens after compaction; largest block: ${dominant}\n`));
|
|
2826
|
+
writeCompactionFailed(compactionId, `Summary remained over the context limit; largest block: ${dominant}.`, beforeTokens);
|
|
2306
2827
|
return;
|
|
2307
2828
|
}
|
|
2308
2829
|
messages.splice(0, messages.length, ...result.messages);
|
|
2830
|
+
compactionAttempts.recordSuccess(attemptKey);
|
|
2309
2831
|
loopGuard.resetReadOnly();
|
|
2310
2832
|
// Token stats use the same complete request estimate as the trigger.
|
|
2311
2833
|
const compactedTokens = estimateNextRequestTokens(messages);
|
|
@@ -2313,10 +2835,7 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
2313
2835
|
// after older turns (which carried the plan context) were summarized.
|
|
2314
2836
|
const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
2315
2837
|
if (livePlan) {
|
|
2316
|
-
messages.
|
|
2317
|
-
role: "system",
|
|
2318
|
-
content: planContextMessage(livePlan, session.planApproved.value),
|
|
2319
|
-
});
|
|
2838
|
+
upsertPlanContextMessage(messages, planContextMessage(livePlan, session.planApproved.value));
|
|
2320
2839
|
}
|
|
2321
2840
|
// Re-inject live SESSION STATE after compaction (older flags survive).
|
|
2322
2841
|
refreshSessionState(livePlan);
|
|
@@ -2337,22 +2856,25 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
2337
2856
|
? PLAN_IMPLEMENT_MEMORY_PREFIX
|
|
2338
2857
|
: COMPACTION_MEMORY_PREFIX);
|
|
2339
2858
|
// Card shows pre/post of the summarization; plan re-injection is noted.
|
|
2340
|
-
|
|
2859
|
+
writeCompactionCompleted(compactionId, summaryText, beforeTokens, compactedTokens);
|
|
2341
2860
|
const planNote = afterTokens > compactedTokens
|
|
2342
2861
|
? ` (compacted to ~${compactedTokens.toLocaleString()}, +plan → ~${afterTokens.toLocaleString()})`
|
|
2343
2862
|
: "";
|
|
2344
2863
|
writeNotice("info", `context auto-compacted to fit the window (~${beforeTokens.toLocaleString()} → ~${compactedTokens.toLocaleString()} tokens${planNote})`, chalk.dim(` ℹ context auto-compacted (~${beforeTokens.toLocaleString()} → ~${compactedTokens.toLocaleString()} tokens${planNote})\n`));
|
|
2345
2864
|
}
|
|
2346
2865
|
catch (error) {
|
|
2866
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2867
|
+
writeCompactionFailed(compactionId, /aborted/i.test(message) ? "Compaction was cancelled." : message, beforeTokens);
|
|
2347
2868
|
if (error instanceof Error &&
|
|
2348
2869
|
(error.name === "AbortError" || error.message.includes("aborted"))) {
|
|
2349
2870
|
throw error;
|
|
2350
2871
|
}
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2872
|
+
compactionAttempts.recordFailure(attemptKey);
|
|
2873
|
+
await auditLog("agent.compact.failed", { reason: message });
|
|
2874
|
+
}
|
|
2875
|
+
finally {
|
|
2876
|
+
if (activeCompactionId === compactionId)
|
|
2877
|
+
activeCompactionId = undefined;
|
|
2356
2878
|
}
|
|
2357
2879
|
}
|
|
2358
2880
|
for (let iteration = 0; iteration < maxIterations; iteration += 1) {
|
|
@@ -2515,16 +3037,17 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
2515
3037
|
});
|
|
2516
3038
|
try {
|
|
2517
3039
|
if (responderDelivery &&
|
|
2518
|
-
!jobManager.
|
|
3040
|
+
!jobManager.markDeliveryStarted(responderDelivery.id, session.sessionId)) {
|
|
2519
3041
|
jobManager.releaseResponderNotificationClaim(responderDelivery.id);
|
|
2520
|
-
throw new Error(`failed to
|
|
3042
|
+
throw new Error(`failed to record responder delivery attempt ${responderDelivery.id}`);
|
|
2521
3043
|
}
|
|
2522
3044
|
completion = await streamWithProvider({
|
|
2523
3045
|
provider,
|
|
2524
3046
|
model,
|
|
2525
|
-
allowModelFallback
|
|
3047
|
+
allowModelFallback,
|
|
2526
3048
|
messages,
|
|
2527
|
-
|
|
3049
|
+
// Sampling is provider/model policy (llm/sampling.ts).
|
|
3050
|
+
// Sending a fixed 0.2 here overrode it for every model.
|
|
2528
3051
|
maxTokens: stepMaxTokens,
|
|
2529
3052
|
signal: options.signal,
|
|
2530
3053
|
thinking: retryWithoutThinking
|
|
@@ -2663,42 +3186,88 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
2663
3186
|
}
|
|
2664
3187
|
});
|
|
2665
3188
|
freeTierConsecutiveFailures = 0;
|
|
3189
|
+
// Stream succeeded → the failure episode is over. Reset the recovery
|
|
3190
|
+
// budget and the one-shot fallback flag so a later, unrelated failure
|
|
3191
|
+
// starts fresh (and we never give up while making progress).
|
|
3192
|
+
resetStreamRecoveryState(recoveryState);
|
|
3193
|
+
allowModelFallback = false;
|
|
2666
3194
|
}
|
|
2667
3195
|
catch (streamError) {
|
|
3196
|
+
// User cancelled (double-Esc) — never try to recover, just stop.
|
|
3197
|
+
if (options.signal?.aborted)
|
|
3198
|
+
throw streamError;
|
|
2668
3199
|
// E4: track free-tier failures for advisory notices (never blocks).
|
|
2669
3200
|
freeTierConsecutiveFailures += 1;
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
// tail ends on re-injected system context and the model has nothing
|
|
2682
|
-
// to answer. Append a trailing user nudge (so the turn no longer
|
|
2683
|
-
// ends on system messages) and retry, bounded like the
|
|
2684
|
-
// successful-but-empty path below.
|
|
2685
|
-
if (!options.signal?.aborted &&
|
|
2686
|
-
isEmptyCompletionError(streamError) &&
|
|
2687
|
-
emptyVisibleRetries < 3) {
|
|
2688
|
-
emptyVisibleRetries += 1;
|
|
2689
|
-
writeNotice("warn", "model streamed an empty response — nudging it to continue", chalk.yellow(" ⚠ model streamed an empty response — nudging it to continue\n"));
|
|
2690
|
-
messages.push(recoveryUserMessage("Your previous response was empty. Continue the task now: emit your next tool call, " +
|
|
2691
|
-
"or give your final answer if every required step is already complete and verified. " +
|
|
2692
|
-
"Do not reply with an empty message."));
|
|
2693
|
-
continue;
|
|
3201
|
+
if (!freeTierAdvisoryShown) {
|
|
3202
|
+
for (const notice of freeTierGuardNotices({
|
|
3203
|
+
provider,
|
|
3204
|
+
estimatedInputTokens: contextBreakdown.estimatedTotalTokens,
|
|
3205
|
+
consecutiveFailures: freeTierConsecutiveFailures,
|
|
3206
|
+
})) {
|
|
3207
|
+
if (notice.includes("Large context"))
|
|
3208
|
+
continue; // already shown above
|
|
3209
|
+
writeNotice("warn", notice, chalk.yellow(` ⚠ ${notice}\n`));
|
|
3210
|
+
freeTierAdvisoryShown = true;
|
|
3211
|
+
}
|
|
2694
3212
|
}
|
|
2695
|
-
|
|
3213
|
+
// Robust recovery: a single flaky provider/model (empty admission,
|
|
3214
|
+
// connection glitch, capacity 5xx, rate limit, or an oversized
|
|
3215
|
+
// request) must not kill the turn. Classify the failure and take a
|
|
3216
|
+
// bounded, escalating recovery step — back off, compact, drop
|
|
3217
|
+
// thinking, or let the router fall back to another provider/model.
|
|
3218
|
+
// We only rethrow (stop the turn) in the worst case: every approach
|
|
3219
|
+
// for that failure class is exhausted or the total budget is spent.
|
|
3220
|
+
const failureKind = classifyStreamFailure(streamError);
|
|
3221
|
+
const plan = planStreamRecovery({
|
|
3222
|
+
kind: failureKind,
|
|
3223
|
+
state: recoveryState,
|
|
3224
|
+
});
|
|
3225
|
+
if (plan.action === "give-up") {
|
|
3226
|
+
throw streamError;
|
|
3227
|
+
}
|
|
3228
|
+
recordRecoveryAttempt(recoveryState, failureKind);
|
|
3229
|
+
// The router refuses transparent retries after emission,
|
|
3230
|
+
// but the recovery ladder may still re-run the step. Say so, since
|
|
3231
|
+
// the visible answer restarts from scratch.
|
|
3232
|
+
if (streamAlreadyEmitted(streamError)) {
|
|
3233
|
+
const restartNotice = "partial answer discarded after a mid-stream failure — the reply restarts below";
|
|
3234
|
+
writeNotice("warn", restartNotice, chalk.yellow(` ⚠ ${restartNotice}\n`));
|
|
3235
|
+
}
|
|
3236
|
+
if (plan.notice) {
|
|
3237
|
+
writeNotice("warn", plan.notice, chalk.yellow(` ⚠ ${plan.notice}\n`));
|
|
3238
|
+
}
|
|
3239
|
+
if (plan.disableThinking)
|
|
3240
|
+
retryWithoutThinking = true;
|
|
3241
|
+
if (plan.allowModelFallback)
|
|
3242
|
+
allowModelFallback = true;
|
|
3243
|
+
if (plan.forceCompact) {
|
|
3244
|
+
await maybeAutoCompact(`stream-recovery:${failureKind}`, true);
|
|
3245
|
+
}
|
|
3246
|
+
if (plan.nudge) {
|
|
3247
|
+
messages.push(recoveryUserMessage(plan.nudge));
|
|
3248
|
+
}
|
|
3249
|
+
if (plan.delayMs > 0) {
|
|
3250
|
+
emit({
|
|
3251
|
+
type: "status",
|
|
3252
|
+
text: `retrying in ${Math.ceil(plan.delayMs / 1000)}s (${failureKind})`,
|
|
3253
|
+
});
|
|
3254
|
+
await delay(plan.delayMs, options.signal);
|
|
3255
|
+
}
|
|
3256
|
+
continue;
|
|
2696
3257
|
}
|
|
2697
3258
|
}
|
|
2698
3259
|
finally {
|
|
2699
3260
|
// Always clear the spinner — abort, network error, or success.
|
|
2700
3261
|
spinner.stop();
|
|
2701
3262
|
}
|
|
3263
|
+
if (responderDelivery) {
|
|
3264
|
+
// The result text is now part of this turn, so consumption is durable.
|
|
3265
|
+
// A stream that aborted or threw above never reaches this point and the
|
|
3266
|
+
// receipt stays deliverable.
|
|
3267
|
+
if (!jobManager.markDelivered(responderDelivery.id, session.sessionId)) {
|
|
3268
|
+
jobManager.releaseResponderNotificationClaim(responderDelivery.id);
|
|
3269
|
+
}
|
|
3270
|
+
}
|
|
2702
3271
|
provider = completion.provider;
|
|
2703
3272
|
model = completion.model;
|
|
2704
3273
|
if (completion.usage) {
|
|
@@ -2707,6 +3276,22 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
2707
3276
|
usage: completion.usage,
|
|
2708
3277
|
model: completion.model,
|
|
2709
3278
|
});
|
|
3279
|
+
// Cache telemetry: without read/create counts there is no way to tell
|
|
3280
|
+
// whether the stable prefix is actually being reused.
|
|
3281
|
+
const cacheRead = completion.usage.cachedPromptTokens ?? 0;
|
|
3282
|
+
const cacheCreated = completion.usage.cacheCreationTokens ?? 0;
|
|
3283
|
+
if (cacheRead > 0 || cacheCreated > 0) {
|
|
3284
|
+
await auditLog("agent.prompt.cache", {
|
|
3285
|
+
provider: completion.provider,
|
|
3286
|
+
model: completion.model,
|
|
3287
|
+
promptTokens: completion.usage.promptTokens,
|
|
3288
|
+
cacheReadTokens: cacheRead,
|
|
3289
|
+
cacheCreationTokens: cacheCreated,
|
|
3290
|
+
hitRatio: completion.usage.promptTokens > 0
|
|
3291
|
+
? Number((cacheRead / completion.usage.promptTokens).toFixed(3))
|
|
3292
|
+
: 0,
|
|
3293
|
+
});
|
|
3294
|
+
}
|
|
2710
3295
|
}
|
|
2711
3296
|
deltaParser?.finish();
|
|
2712
3297
|
// Sticky text-only may have flipped dialect during stream retry.
|
|
@@ -2838,25 +3423,28 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
2838
3423
|
truncatedToolRetries += 1;
|
|
2839
3424
|
if (truncatedToolRetries <= 5) {
|
|
2840
3425
|
try {
|
|
2841
|
-
const writeResult = await
|
|
3426
|
+
const writeResult = await applySalvagedWrite(salvaged);
|
|
2842
3427
|
if (writeResult.ok) {
|
|
2843
3428
|
const lineCount = salvaged.content.split("\n").length;
|
|
2844
3429
|
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`));
|
|
2845
3430
|
// Pair assistant tool_calls with synthetic results so the
|
|
2846
3431
|
// next turn is not orphaned, then nudge for append.
|
|
2847
|
-
appendAssistantWithTools(messages, assistantText.visible, nativeToolCalls);
|
|
3432
|
+
appendAssistantWithTools(messages, assistantText.visible, nativeToolCalls, completion.reasoningBlock);
|
|
2848
3433
|
for (const tc of nativeToolCalls) {
|
|
2849
3434
|
appendToolResult(messages, tc.id, tc.id === writeTc.id
|
|
2850
3435
|
? `Tool ${tc.name} result (exit=0, ok=true):\nSalvaged partial write: ${lineCount} lines to ${salvaged.path}`
|
|
2851
3436
|
: `Tool ${tc.name} result (exit=1, ok=false):\nCancelled — sibling write was truncated and salvaged.`, tc.name, tc.id === writeTc.id);
|
|
2852
3437
|
}
|
|
2853
|
-
const priorBytes =
|
|
3438
|
+
const priorBytes = writeResult.bytesOnDisk;
|
|
3439
|
+
const salvagedToolName = salvaged.operation === "append"
|
|
3440
|
+
? "fs.append"
|
|
3441
|
+
: "fs.write";
|
|
2854
3442
|
const appendNudge = toolsAttached
|
|
2855
|
-
? `Your ${
|
|
3443
|
+
? `Your ${salvagedToolName} tool call was cut off at the token limit, but the system salvaged the partial content and wrote ${lineCount} lines (file is now ${priorBytes} bytes) to ${salvaged.path}. ` +
|
|
2856
3444
|
`The file ends with: ${JSON.stringify(salvaged.lastLine)}\n\n` +
|
|
2857
3445
|
`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). ` +
|
|
2858
3446
|
`Do not re-read the full file; do not re-send content already saved. Use the platform tool interface — no markdown fences.`
|
|
2859
|
-
: `Your
|
|
3447
|
+
: `Your ${salvagedToolName} tool call was cut off at the token limit, but the system salvaged the partial content and wrote ${lineCount} lines (file is now ${priorBytes} bytes) to ${salvaged.path}. ` +
|
|
2860
3448
|
`The file ends with: ${JSON.stringify(salvaged.lastLine)}\n\n` +
|
|
2861
3449
|
`CONTINUE with ONE large fs.append of the remaining content:\n` +
|
|
2862
3450
|
'```tool\n{"name":"fs.append","args":{"path":' +
|
|
@@ -2990,23 +3578,23 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
2990
3578
|
truncatedToolRetries += 1;
|
|
2991
3579
|
const salvaged = salvageTruncatedWrite(assistantText.visible);
|
|
2992
3580
|
if (salvaged && truncatedToolRetries <= 5) {
|
|
2993
|
-
// Write the salvaged partial content
|
|
3581
|
+
// Write the salvaged partial content through the normal
|
|
3582
|
+
// authorization path (classifier + confirmation + receipt).
|
|
2994
3583
|
try {
|
|
2995
|
-
const writeResult = await
|
|
2996
|
-
confirmed: true,
|
|
2997
|
-
});
|
|
3584
|
+
const writeResult = await applySalvagedWrite(salvaged);
|
|
2998
3585
|
if (writeResult.ok) {
|
|
2999
3586
|
const lineCount = salvaged.content.split("\n").length;
|
|
3000
3587
|
writeNotice("info", `tool call was truncated — salvaged ${lineCount} lines and wrote to ${salvaged.path}`, chalk.cyan(` ℹ tool call was truncated — salvaged ${lineCount} lines to ${salvaged.path}\n`));
|
|
3001
3588
|
pushAssistantHistory(stripThinking(assistantText.visible).visible);
|
|
3002
|
-
const priorBytes =
|
|
3589
|
+
const priorBytes = writeResult.bytesOnDisk;
|
|
3590
|
+
const salvagedToolName = salvaged.operation === "append" ? "fs.append" : "fs.write";
|
|
3003
3591
|
messages.push({
|
|
3004
3592
|
role: "user",
|
|
3005
3593
|
content: toolsAttached
|
|
3006
|
-
? `Your
|
|
3594
|
+
? `Your ${salvagedToolName} tool call was cut off at the token limit, but the system salvaged the partial content and wrote ${lineCount} lines (file is now ${priorBytes} bytes) to ${salvaged.path}. ` +
|
|
3007
3595
|
`The file ends with: ${JSON.stringify(salvaged.lastLine)}\n\n` +
|
|
3008
3596
|
`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.`
|
|
3009
|
-
: `Your
|
|
3597
|
+
: `Your ${salvagedToolName} tool call was cut off at the token limit, but the system salvaged the partial content and wrote ${lineCount} lines (file is now ${priorBytes} bytes) to ${salvaged.path}. ` +
|
|
3010
3598
|
`The file ends with: ${JSON.stringify(salvaged.lastLine)}\n\n` +
|
|
3011
3599
|
`CONTINUE with ONE large fs.append of the remaining content (prefer hundreds of lines per call — do NOT use tiny ~100-line chunks):\n` +
|
|
3012
3600
|
'```tool\n{"name":"fs.append","args":{"path":' +
|
|
@@ -3055,9 +3643,7 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
3055
3643
|
const salvaged = salvageTruncatedWrite(assistantText.visible);
|
|
3056
3644
|
if (salvaged) {
|
|
3057
3645
|
try {
|
|
3058
|
-
const writeResult = await
|
|
3059
|
-
confirmed: true,
|
|
3060
|
-
});
|
|
3646
|
+
const writeResult = await applySalvagedWrite(salvaged);
|
|
3061
3647
|
if (writeResult.ok) {
|
|
3062
3648
|
const lineCount = salvaged.content.split("\n").length;
|
|
3063
3649
|
writeNotice("info", `malformed tool call salvaged — wrote ${lineCount} lines to ${salvaged.path}`, chalk.cyan(` ℹ malformed tool call salvaged — wrote ${lineCount} lines to ${salvaged.path}\n`));
|
|
@@ -3104,7 +3690,7 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
3104
3690
|
const unread = [...unreadResponderNotificationIds];
|
|
3105
3691
|
pushAssistantHistory(assistantText.visible);
|
|
3106
3692
|
messages.push(recoveryUserMessage(`You have ${unread.length} delivered Responder result(s) that remain unread: ${unread.join(", ")}. ` +
|
|
3107
|
-
"If analysis is incomplete, call the
|
|
3693
|
+
"If analysis is incomplete, call only the bounded evidence tool needed now. If each result has been analyzed and is satisfactory, you MUST call job.read with its jobId or exact notificationId before giving a final response. job.read does not require an active plan; do not create or update a plan merely to acknowledge a result."));
|
|
3108
3694
|
continue;
|
|
3109
3695
|
}
|
|
3110
3696
|
const narratedAction = looksLikeActionNarration(cleaned);
|
|
@@ -3283,7 +3869,7 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
3283
3869
|
const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
3284
3870
|
const unfinished = livePlan?.tasks.filter((task) => !task.responderOwned &&
|
|
3285
3871
|
(task.state === "pending" || task.state === "in_progress"));
|
|
3286
|
-
const deferReport =
|
|
3872
|
+
const deferReport = shouldYieldForDeclaredResponderDependency(livePlan, jobManager.getRunningJobs(session.sessionId), jobManager.getPendingNotifications(session.sessionId), responderWakeNotificationId);
|
|
3287
3873
|
if (livePlan &&
|
|
3288
3874
|
unfinished &&
|
|
3289
3875
|
unfinished.length > 0 &&
|
|
@@ -3305,9 +3891,17 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
3305
3891
|
const remainingCriteria = [];
|
|
3306
3892
|
if (session.planApproved.value) {
|
|
3307
3893
|
const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
|
|
3308
|
-
|
|
3309
|
-
|
|
3894
|
+
// Foreground work decides the turn outcome. Responder children run
|
|
3895
|
+
// concurrently by design and are reported separately.
|
|
3896
|
+
const unfinished = livePlan ? foregroundRemaining(livePlan) : [];
|
|
3897
|
+
const failedTasks = livePlan?.tasks.filter((task) => !task.responderOwned && task.state === "failed") ?? [];
|
|
3310
3898
|
remainingCriteria.push(...unfinished.map((task) => `[${task.id}] ${task.title}`), ...failedTasks.map((task) => `[${task.id}] retry failed task: ${task.title}`));
|
|
3899
|
+
const openResponderChildren = livePlan
|
|
3900
|
+
? responderOpenTasks(livePlan)
|
|
3901
|
+
: [];
|
|
3902
|
+
if (openResponderChildren.length > 0) {
|
|
3903
|
+
remainingCriteria.push(...openResponderChildren.map((task) => `[${task.id}] responder result awaiting analysis: ${task.title}`));
|
|
3904
|
+
}
|
|
3311
3905
|
if (failedTasks.length > 0)
|
|
3312
3906
|
outcomeStatus = "failed";
|
|
3313
3907
|
else if (unfinished.length > 0)
|
|
@@ -3449,7 +4043,7 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
3449
4043
|
writeToolCall(deferred.eventId, deferred.call, deferred.rendered);
|
|
3450
4044
|
}
|
|
3451
4045
|
if (historyNativeCalls.length) {
|
|
3452
|
-
appendAssistantWithTools(messages, beforeTool ?? "", historyNativeCalls);
|
|
4046
|
+
appendAssistantWithTools(messages, beforeTool ?? "", historyNativeCalls, completion.reasoningBlock);
|
|
3453
4047
|
}
|
|
3454
4048
|
else {
|
|
3455
4049
|
const standardizedContent = (beforeTool ? beforeTool.trim() + "\n\n" : "") +
|
|
@@ -3498,7 +4092,8 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
3498
4092
|
if (res.ok && res.call.name === "plan.create") {
|
|
3499
4093
|
planCreatedThisTurn = true;
|
|
3500
4094
|
}
|
|
3501
|
-
|
|
4095
|
+
if (!res.suppressedRepeat)
|
|
4096
|
+
productiveSteps += 1;
|
|
3502
4097
|
// E5: collapse identical large tool bodies within this turn to a pointer.
|
|
3503
4098
|
const deduped = dedupeToolContextOutput({
|
|
3504
4099
|
content: res.contextOutput,
|
|
@@ -3663,7 +4258,7 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
3663
4258
|
}
|
|
3664
4259
|
// User Esc/Ctrl+C only — never cancel siblings because a delete failed
|
|
3665
4260
|
// or a confirm was declined; the model must see every tool result.
|
|
3666
|
-
if (res.
|
|
4261
|
+
if (res.aborted)
|
|
3667
4262
|
aborted = true;
|
|
3668
4263
|
};
|
|
3669
4264
|
// Multi-task sync guard: when one model message advances more than one
|
|
@@ -3684,7 +4279,24 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
3684
4279
|
: parsed.taskId;
|
|
3685
4280
|
intents.push({ call: b.call, taskId: resolvedId, state: parsed.state });
|
|
3686
4281
|
}
|
|
3687
|
-
|
|
4282
|
+
const openIds = openingTaskIds(intents);
|
|
4283
|
+
if (openIds.length > 1) {
|
|
4284
|
+
// A multi-open is never confirmable; the store keeps at
|
|
4285
|
+
// most one active foreground task.
|
|
4286
|
+
session.pendingTaskBatch.value = undefined;
|
|
4287
|
+
const openDescriptors = openIds.map((taskId) => ({
|
|
4288
|
+
taskId,
|
|
4289
|
+
title: livePlanForBatch?.tasks.find((t) => t.id === taskId)?.title ?? "",
|
|
4290
|
+
targetState: "in_progress",
|
|
4291
|
+
}));
|
|
4292
|
+
batchReminderNote = buildMultiOpenRejection(openDescriptors);
|
|
4293
|
+
for (const intent of intents) {
|
|
4294
|
+
if (intent.state === "in_progress")
|
|
4295
|
+
batchRemindCalls.add(intent.call);
|
|
4296
|
+
}
|
|
4297
|
+
writeNotice("warn", multiOpenToast(openIds.length), chalk.yellow(` ⚠ ${multiOpenToast(openIds.length)}\n`));
|
|
4298
|
+
}
|
|
4299
|
+
else if (isSimultaneousTaskAdvance(intents)) {
|
|
3688
4300
|
const signature = batchUpdateSignature(intents);
|
|
3689
4301
|
if (session.pendingTaskBatch.value === signature) {
|
|
3690
4302
|
session.pendingTaskBatch.value = undefined;
|
|
@@ -3805,7 +4417,7 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
3805
4417
|
return finishTurn("", productiveSteps, "partial", ["Approve or revise the draft plan before implementation."]);
|
|
3806
4418
|
}
|
|
3807
4419
|
if (aborted) {
|
|
3808
|
-
lastAnswer = "
|
|
4420
|
+
lastAnswer = "";
|
|
3809
4421
|
outcomeState.outcome.status = "aborted";
|
|
3810
4422
|
await saveOutcomeState(outcomeState);
|
|
3811
4423
|
moveTurn("aborted", "turn aborted");
|
|
@@ -3836,11 +4448,12 @@ export async function runAgentTurn(prompt, options = {}) {
|
|
|
3836
4448
|
}
|
|
3837
4449
|
catch (error) {
|
|
3838
4450
|
const isAbort = isAbortError(error, options.signal);
|
|
3839
|
-
const msg = isAbort ? "Aborted." : `Error: ${error instanceof Error ? error.message : String(error)}`;
|
|
3840
4451
|
if (isAbort) {
|
|
3841
4452
|
writeAbort();
|
|
3842
|
-
return finishTurn(
|
|
4453
|
+
return finishTurn("", 0, "aborted");
|
|
3843
4454
|
}
|
|
4455
|
+
releaseUnreadResponderClaims();
|
|
4456
|
+
const msg = `Error: ${error instanceof Error ? error.message : String(error)}`;
|
|
3844
4457
|
if (options.onMessages) {
|
|
3845
4458
|
try {
|
|
3846
4459
|
options.onMessages(buildTurnHistory(liveMessages, msg));
|