agent-nuvira 3.3.13 → 3.3.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -1
- package/dist/agents/agents/planner.d.ts.map +1 -1
- package/dist/agents/agents/planner.js +9 -0
- package/dist/agents/agents/planner.js.map +1 -1
- package/dist/agents/orchestrator.d.ts +31 -0
- package/dist/agents/orchestrator.d.ts.map +1 -1
- package/dist/agents/orchestrator.js +74 -126
- package/dist/agents/orchestrator.js.map +1 -1
- package/dist/cli/chat.d.ts +89 -0
- package/dist/cli/chat.d.ts.map +1 -1
- package/dist/cli/chat.js +765 -33
- package/dist/cli/chat.js.map +1 -1
- package/dist/cli/cli-program.d.ts.map +1 -1
- package/dist/cli/cli-program.js +12 -0
- package/dist/cli/cli-program.js.map +1 -1
- package/dist/cli/config.d.ts +12 -0
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +86 -0
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/decisions.d.ts +28 -0
- package/dist/cli/decisions.d.ts.map +1 -0
- package/dist/cli/decisions.js +140 -0
- package/dist/cli/decisions.js.map +1 -0
- package/dist/cli/doctor.d.ts +6 -0
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/doctor.js +46 -0
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/failover-runner.d.ts.map +1 -1
- package/dist/cli/failover-runner.js +19 -1
- package/dist/cli/failover-runner.js.map +1 -1
- package/dist/cli/feedback.d.ts.map +1 -1
- package/dist/cli/feedback.js +5 -1
- package/dist/cli/feedback.js.map +1 -1
- package/dist/cli/hooks.d.ts +30 -0
- package/dist/cli/hooks.d.ts.map +1 -0
- package/dist/cli/hooks.js +224 -0
- package/dist/cli/hooks.js.map +1 -0
- package/dist/cli/knowledge.d.ts +9 -1
- package/dist/cli/knowledge.d.ts.map +1 -1
- package/dist/cli/knowledge.js +93 -2
- package/dist/cli/knowledge.js.map +1 -1
- package/dist/cli/loop-executor.d.ts.map +1 -1
- package/dist/cli/loop-executor.js +47 -12
- package/dist/cli/loop-executor.js.map +1 -1
- package/dist/cli/model.d.ts +20 -0
- package/dist/cli/model.d.ts.map +1 -1
- package/dist/cli/model.js +294 -14
- package/dist/cli/model.js.map +1 -1
- package/dist/cli/rate.d.ts +78 -0
- package/dist/cli/rate.d.ts.map +1 -0
- package/dist/cli/rate.js +294 -0
- package/dist/cli/rate.js.map +1 -0
- package/dist/config/process-env.d.ts.map +1 -1
- package/dist/config/process-env.js +10 -0
- package/dist/config/process-env.js.map +1 -1
- package/dist/enterprise/vault.d.ts.map +1 -1
- package/dist/enterprise/vault.js +5 -21
- package/dist/enterprise/vault.js.map +1 -1
- package/dist/gateway/hook-contract.d.ts +33 -0
- package/dist/gateway/hook-contract.d.ts.map +1 -1
- package/dist/gateway/hook-contract.js +97 -0
- package/dist/gateway/hook-contract.js.map +1 -1
- package/dist/inference/gemini-adapter.d.ts.map +1 -1
- package/dist/inference/gemini-adapter.js +4 -0
- package/dist/inference/gemini-adapter.js.map +1 -1
- package/dist/inference/interface.d.ts +37 -0
- package/dist/inference/interface.d.ts.map +1 -1
- package/dist/inference/native-tools.d.ts +16 -1
- package/dist/inference/native-tools.d.ts.map +1 -1
- package/dist/inference/native-tools.js +34 -10
- package/dist/inference/native-tools.js.map +1 -1
- package/dist/inference/route-resolver.d.ts +30 -0
- package/dist/inference/route-resolver.d.ts.map +1 -1
- package/dist/inference/route-resolver.js +158 -1
- package/dist/inference/route-resolver.js.map +1 -1
- package/dist/inference/tools.d.ts +16 -1
- package/dist/inference/tools.d.ts.map +1 -1
- package/dist/inference/tools.js +51 -18
- package/dist/inference/tools.js.map +1 -1
- package/dist/learning/acceptance-model.d.ts +247 -0
- package/dist/learning/acceptance-model.d.ts.map +1 -0
- package/dist/learning/acceptance-model.js +708 -0
- package/dist/learning/acceptance-model.js.map +1 -0
- package/dist/learning/auto-router.d.ts +68 -24
- package/dist/learning/auto-router.d.ts.map +1 -1
- package/dist/learning/auto-router.js +301 -96
- package/dist/learning/auto-router.js.map +1 -1
- package/dist/learning/autonomy-policy.d.ts +12 -16
- package/dist/learning/autonomy-policy.d.ts.map +1 -1
- package/dist/learning/autonomy-policy.js +50 -48
- package/dist/learning/autonomy-policy.js.map +1 -1
- package/dist/learning/behavioural-labels.d.ts +86 -0
- package/dist/learning/behavioural-labels.d.ts.map +1 -0
- package/dist/learning/behavioural-labels.js +166 -0
- package/dist/learning/behavioural-labels.js.map +1 -0
- package/dist/learning/capability-evidence.d.ts +167 -0
- package/dist/learning/capability-evidence.d.ts.map +1 -0
- package/dist/learning/capability-evidence.js +210 -0
- package/dist/learning/capability-evidence.js.map +1 -0
- package/dist/learning/capability-readiness.d.ts +64 -0
- package/dist/learning/capability-readiness.d.ts.map +1 -0
- package/dist/learning/capability-readiness.js +74 -0
- package/dist/learning/capability-readiness.js.map +1 -0
- package/dist/learning/capability-types.d.ts +169 -0
- package/dist/learning/capability-types.d.ts.map +1 -0
- package/dist/learning/capability-types.js +20 -0
- package/dist/learning/capability-types.js.map +1 -0
- package/dist/learning/catalog-feed.d.ts +89 -0
- package/dist/learning/catalog-feed.d.ts.map +1 -0
- package/dist/learning/catalog-feed.js +211 -0
- package/dist/learning/catalog-feed.js.map +1 -0
- package/dist/learning/cli-intent-effects.d.ts +60 -0
- package/dist/learning/cli-intent-effects.d.ts.map +1 -0
- package/dist/learning/cli-intent-effects.js +153 -0
- package/dist/learning/cli-intent-effects.js.map +1 -0
- package/dist/learning/command-adaptation.d.ts +41 -0
- package/dist/learning/command-adaptation.d.ts.map +1 -0
- package/dist/learning/command-adaptation.js +122 -0
- package/dist/learning/command-adaptation.js.map +1 -0
- package/dist/learning/consent-picture.d.ts +42 -0
- package/dist/learning/consent-picture.d.ts.map +1 -0
- package/dist/learning/consent-picture.js +53 -0
- package/dist/learning/consent-picture.js.map +1 -0
- package/dist/learning/context-budget.d.ts +37 -6
- package/dist/learning/context-budget.d.ts.map +1 -1
- package/dist/learning/context-budget.js +38 -9
- package/dist/learning/context-budget.js.map +1 -1
- package/dist/learning/decision-log.d.ts +114 -0
- package/dist/learning/decision-log.d.ts.map +1 -0
- package/dist/learning/decision-log.js +296 -0
- package/dist/learning/decision-log.js.map +1 -0
- package/dist/learning/deliverable-class.d.ts.map +1 -1
- package/dist/learning/deliverable-class.js +19 -0
- package/dist/learning/deliverable-class.js.map +1 -1
- package/dist/learning/deliverable-corpus.d.ts +117 -0
- package/dist/learning/deliverable-corpus.d.ts.map +1 -0
- package/dist/learning/deliverable-corpus.js +176 -0
- package/dist/learning/deliverable-corpus.js.map +1 -0
- package/dist/learning/error-repair.d.ts +22 -1
- package/dist/learning/error-repair.d.ts.map +1 -1
- package/dist/learning/error-repair.js +37 -0
- package/dist/learning/error-repair.js.map +1 -1
- package/dist/learning/exhaustion-report.d.ts +136 -0
- package/dist/learning/exhaustion-report.d.ts.map +1 -0
- package/dist/learning/exhaustion-report.js +239 -0
- package/dist/learning/exhaustion-report.js.map +1 -0
- package/dist/learning/failure-bookkeeping.d.ts +16 -0
- package/dist/learning/failure-bookkeeping.d.ts.map +1 -1
- package/dist/learning/failure-bookkeeping.js +51 -5
- package/dist/learning/failure-bookkeeping.js.map +1 -1
- package/dist/learning/gate-friction.d.ts +39 -0
- package/dist/learning/gate-friction.d.ts.map +1 -0
- package/dist/learning/gate-friction.js +91 -0
- package/dist/learning/gate-friction.js.map +1 -0
- package/dist/learning/harness-narration.d.ts +34 -0
- package/dist/learning/harness-narration.d.ts.map +1 -0
- package/dist/learning/harness-narration.js +58 -0
- package/dist/learning/harness-narration.js.map +1 -0
- package/dist/learning/hub-skill-catalog.d.ts.map +1 -1
- package/dist/learning/hub-skill-catalog.js +1 -2
- package/dist/learning/hub-skill-catalog.js.map +1 -1
- package/dist/learning/hybrid-router.d.ts +65 -0
- package/dist/learning/hybrid-router.d.ts.map +1 -1
- package/dist/learning/hybrid-router.js +186 -0
- package/dist/learning/hybrid-router.js.map +1 -1
- package/dist/learning/knowledge-base.d.ts +229 -3
- package/dist/learning/knowledge-base.d.ts.map +1 -1
- package/dist/learning/knowledge-base.js +603 -25
- package/dist/learning/knowledge-base.js.map +1 -1
- package/dist/learning/knowledge-eval.d.ts +171 -0
- package/dist/learning/knowledge-eval.d.ts.map +1 -0
- package/dist/learning/knowledge-eval.js +196 -0
- package/dist/learning/knowledge-eval.js.map +1 -0
- package/dist/learning/knowledge-turn.d.ts +92 -0
- package/dist/learning/knowledge-turn.d.ts.map +1 -0
- package/dist/learning/knowledge-turn.js +190 -0
- package/dist/learning/knowledge-turn.js.map +1 -0
- package/dist/learning/learned-commands.d.ts +64 -0
- package/dist/learning/learned-commands.d.ts.map +1 -0
- package/dist/learning/learned-commands.js +108 -0
- package/dist/learning/learned-commands.js.map +1 -0
- package/dist/learning/lexical-search.d.ts +50 -0
- package/dist/learning/lexical-search.d.ts.map +1 -0
- package/dist/learning/lexical-search.js +111 -0
- package/dist/learning/lexical-search.js.map +1 -0
- package/dist/learning/machine-facts.d.ts +71 -0
- package/dist/learning/machine-facts.d.ts.map +1 -0
- package/dist/learning/machine-facts.js +164 -0
- package/dist/learning/machine-facts.js.map +1 -0
- package/dist/learning/markdown-outline.d.ts +73 -0
- package/dist/learning/markdown-outline.d.ts.map +1 -0
- package/dist/learning/markdown-outline.js +136 -0
- package/dist/learning/markdown-outline.js.map +1 -0
- package/dist/learning/model-first-router.d.ts +33 -1
- package/dist/learning/model-first-router.d.ts.map +1 -1
- package/dist/learning/model-first-router.js +123 -9
- package/dist/learning/model-first-router.js.map +1 -1
- package/dist/learning/model-identity.d.ts +80 -0
- package/dist/learning/model-identity.d.ts.map +1 -0
- package/dist/learning/model-identity.js +115 -0
- package/dist/learning/model-identity.js.map +1 -0
- package/dist/learning/model-registry.d.ts +141 -2
- package/dist/learning/model-registry.d.ts.map +1 -1
- package/dist/learning/model-registry.js +324 -38
- package/dist/learning/model-registry.js.map +1 -1
- package/dist/learning/model-scoring.d.ts.map +1 -1
- package/dist/learning/model-scoring.js +8 -1
- package/dist/learning/model-scoring.js.map +1 -1
- package/dist/learning/model-selection.d.ts.map +1 -1
- package/dist/learning/model-selection.js +41 -0
- package/dist/learning/model-selection.js.map +1 -1
- package/dist/learning/outcome-observation.d.ts +49 -0
- package/dist/learning/outcome-observation.d.ts.map +1 -0
- package/dist/learning/outcome-observation.js +95 -0
- package/dist/learning/outcome-observation.js.map +1 -0
- package/dist/learning/pair-entitlement.d.ts +184 -0
- package/dist/learning/pair-entitlement.d.ts.map +1 -0
- package/dist/learning/pair-entitlement.js +214 -0
- package/dist/learning/pair-entitlement.js.map +1 -0
- package/dist/learning/provider-fallback.d.ts +23 -1
- package/dist/learning/provider-fallback.d.ts.map +1 -1
- package/dist/learning/provider-fallback.js +24 -0
- package/dist/learning/provider-fallback.js.map +1 -1
- package/dist/learning/reasoning-trace.d.ts +65 -1
- package/dist/learning/reasoning-trace.d.ts.map +1 -1
- package/dist/learning/reasoning-trace.js +123 -1
- package/dist/learning/reasoning-trace.js.map +1 -1
- package/dist/learning/requirement-probe.d.ts +55 -0
- package/dist/learning/requirement-probe.d.ts.map +1 -0
- package/dist/learning/requirement-probe.js +77 -0
- package/dist/learning/requirement-probe.js.map +1 -0
- package/dist/learning/resilient-call.d.ts +20 -1
- package/dist/learning/resilient-call.d.ts.map +1 -1
- package/dist/learning/resilient-call.js +42 -4
- package/dist/learning/resilient-call.js.map +1 -1
- package/dist/learning/response-usability.d.ts +85 -0
- package/dist/learning/response-usability.d.ts.map +1 -0
- package/dist/learning/response-usability.js +135 -0
- package/dist/learning/response-usability.js.map +1 -0
- package/dist/learning/router-bandit.d.ts +132 -0
- package/dist/learning/router-bandit.d.ts.map +1 -1
- package/dist/learning/router-bandit.js +253 -0
- package/dist/learning/router-bandit.js.map +1 -1
- package/dist/learning/routing-history.d.ts +41 -2
- package/dist/learning/routing-history.d.ts.map +1 -1
- package/dist/learning/routing-history.js.map +1 -1
- package/dist/learning/run-trace.d.ts +21 -0
- package/dist/learning/run-trace.d.ts.map +1 -1
- package/dist/learning/run-trace.js +38 -0
- package/dist/learning/run-trace.js.map +1 -1
- package/dist/learning/session-grant.d.ts +75 -0
- package/dist/learning/session-grant.d.ts.map +1 -0
- package/dist/learning/session-grant.js +112 -0
- package/dist/learning/session-grant.js.map +1 -0
- package/dist/learning/task-model-continuity.d.ts +73 -0
- package/dist/learning/task-model-continuity.d.ts.map +1 -0
- package/dist/learning/task-model-continuity.js +87 -0
- package/dist/learning/task-model-continuity.js.map +1 -0
- package/dist/learning/turn-feedback.d.ts +93 -0
- package/dist/learning/turn-feedback.d.ts.map +1 -0
- package/dist/learning/turn-feedback.js +127 -0
- package/dist/learning/turn-feedback.js.map +1 -0
- package/dist/learning/turn-report.d.ts +104 -2
- package/dist/learning/turn-report.d.ts.map +1 -1
- package/dist/learning/turn-report.js +161 -6
- package/dist/learning/turn-report.js.map +1 -1
- package/dist/learning/verdict-routing.d.ts +51 -0
- package/dist/learning/verdict-routing.d.ts.map +1 -0
- package/dist/learning/verdict-routing.js +122 -0
- package/dist/learning/verdict-routing.js.map +1 -0
- package/dist/mcp/client.d.ts.map +1 -1
- package/dist/mcp/client.js +5 -0
- package/dist/mcp/client.js.map +1 -1
- package/dist/mcp/manager.d.ts +10 -0
- package/dist/mcp/manager.d.ts.map +1 -1
- package/dist/mcp/manager.js +20 -0
- package/dist/mcp/manager.js.map +1 -1
- package/dist/mcp/mcp-discovery.d.ts +34 -0
- package/dist/mcp/mcp-discovery.d.ts.map +1 -0
- package/dist/mcp/mcp-discovery.js +71 -0
- package/dist/mcp/mcp-discovery.js.map +1 -0
- package/dist/mcp/mcp-schema-cache.d.ts +17 -0
- package/dist/mcp/mcp-schema-cache.d.ts.map +1 -1
- package/dist/mcp/mcp-schema-cache.js +47 -3
- package/dist/mcp/mcp-schema-cache.js.map +1 -1
- package/dist/mcp/types.d.ts +28 -0
- package/dist/mcp/types.d.ts.map +1 -1
- package/dist/mcp/types.js.map +1 -1
- package/dist/parity/drivers.d.ts.map +1 -1
- package/dist/parity/drivers.js +3 -0
- package/dist/parity/drivers.js.map +1 -1
- package/dist/parity/observation.d.ts +12 -0
- package/dist/parity/observation.d.ts.map +1 -1
- package/dist/parity/observation.js +14 -0
- package/dist/parity/observation.js.map +1 -1
- package/dist/tools/ask-user.d.ts.map +1 -1
- package/dist/tools/ask-user.js +4 -0
- package/dist/tools/ask-user.js.map +1 -1
- package/dist/tools/capability-registry.d.ts +150 -0
- package/dist/tools/capability-registry.d.ts.map +1 -0
- package/dist/tools/capability-registry.js +400 -0
- package/dist/tools/capability-registry.js.map +1 -0
- package/dist/tools/child-agent-runtime.d.ts.map +1 -1
- package/dist/tools/child-agent-runtime.js +19 -5
- package/dist/tools/child-agent-runtime.js.map +1 -1
- package/dist/tools/coding-tools.d.ts +17 -5
- package/dist/tools/coding-tools.d.ts.map +1 -1
- package/dist/tools/coding-tools.js +80 -11
- package/dist/tools/coding-tools.js.map +1 -1
- package/dist/tools/command-memo.d.ts +116 -0
- package/dist/tools/command-memo.d.ts.map +1 -0
- package/dist/tools/command-memo.js +202 -0
- package/dist/tools/command-memo.js.map +1 -0
- package/dist/tools/git-tool.d.ts.map +1 -1
- package/dist/tools/git-tool.js +13 -3
- package/dist/tools/git-tool.js.map +1 -1
- package/dist/tools/harness-directive.d.ts +42 -0
- package/dist/tools/harness-directive.d.ts.map +1 -0
- package/dist/tools/harness-directive.js +45 -0
- package/dist/tools/harness-directive.js.map +1 -0
- package/dist/tools/loop-skill-hint.d.ts +74 -174
- package/dist/tools/loop-skill-hint.d.ts.map +1 -1
- package/dist/tools/loop-skill-hint.js +110 -536
- package/dist/tools/loop-skill-hint.js.map +1 -1
- package/dist/tools/mcp-client-tool.d.ts +2 -0
- package/dist/tools/mcp-client-tool.d.ts.map +1 -1
- package/dist/tools/mcp-client-tool.js +3 -0
- package/dist/tools/mcp-client-tool.js.map +1 -1
- package/dist/tools/modality/shared.d.ts +1 -4
- package/dist/tools/modality/shared.d.ts.map +1 -1
- package/dist/tools/modality/shared.js +7 -22
- package/dist/tools/modality/shared.js.map +1 -1
- package/dist/tools/plan-store.d.ts +20 -2
- package/dist/tools/plan-store.d.ts.map +1 -1
- package/dist/tools/plan-store.js +51 -7
- package/dist/tools/plan-store.js.map +1 -1
- package/dist/tools/registry.d.ts +76 -3
- package/dist/tools/registry.d.ts.map +1 -1
- package/dist/tools/registry.js +486 -26
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/remediation-ladder.d.ts.map +1 -1
- package/dist/tools/remediation-ladder.js +8 -0
- package/dist/tools/remediation-ladder.js.map +1 -1
- package/dist/tools/run-cli.d.ts.map +1 -1
- package/dist/tools/run-cli.js +29 -4
- package/dist/tools/run-cli.js.map +1 -1
- package/dist/tools/run-terminal.d.ts +11 -0
- package/dist/tools/run-terminal.d.ts.map +1 -1
- package/dist/tools/run-terminal.js +265 -10
- package/dist/tools/run-terminal.js.map +1 -1
- package/dist/tools/tool-loop.d.ts +321 -13
- package/dist/tools/tool-loop.d.ts.map +1 -1
- package/dist/tools/tool-loop.js +867 -66
- package/dist/tools/tool-loop.js.map +1 -1
- package/dist/utils/atomic-store.d.ts +53 -0
- package/dist/utils/atomic-store.d.ts.map +1 -0
- package/dist/utils/atomic-store.js +178 -0
- package/dist/utils/atomic-store.js.map +1 -0
- package/dist/utils/binary-probe.d.ts +25 -0
- package/dist/utils/binary-probe.d.ts.map +1 -0
- package/dist/utils/binary-probe.js +48 -0
- package/dist/utils/binary-probe.js.map +1 -0
- package/dist/utils/workspace-path.d.ts +50 -0
- package/dist/utils/workspace-path.d.ts.map +1 -0
- package/dist/utils/workspace-path.js +86 -0
- package/dist/utils/workspace-path.js.map +1 -0
- package/dist/web-dashboard/chat-console.d.ts +24 -0
- package/dist/web-dashboard/chat-console.d.ts.map +1 -1
- package/dist/web-dashboard/chat-console.js +38 -0
- package/dist/web-dashboard/chat-console.js.map +1 -1
- package/dist/web-dashboard/project-context.d.ts +27 -2
- package/dist/web-dashboard/project-context.d.ts.map +1 -1
- package/dist/web-dashboard/project-context.js +32 -2
- package/dist/web-dashboard/project-context.js.map +1 -1
- package/dist/web-dashboard/server.d.ts +11 -0
- package/dist/web-dashboard/server.d.ts.map +1 -1
- package/dist/web-dashboard/server.js +362 -11
- package/dist/web-dashboard/server.js.map +1 -1
- package/dist/web-dashboard/src/types.d.ts +170 -1
- package/dist/web-dashboard/src/types.d.ts.map +1 -1
- package/dist/web-dashboard/workspace-guard.d.ts +11 -2
- package/dist/web-dashboard/workspace-guard.d.ts.map +1 -1
- package/dist/web-dashboard/workspace-guard.js +24 -12
- package/dist/web-dashboard/workspace-guard.js.map +1 -1
- package/dist/web-dashboard/workspace-resolution.d.ts +87 -0
- package/dist/web-dashboard/workspace-resolution.d.ts.map +1 -0
- package/dist/web-dashboard/workspace-resolution.js +105 -0
- package/dist/web-dashboard/workspace-resolution.js.map +1 -0
- package/package.json +1 -1
- package/src/web-dashboard/public/assets/index-zjbdJnO-.js +210 -0
- package/src/web-dashboard/public/assets/index-zjbdJnO-.js.map +1 -0
- package/src/web-dashboard/public/index.html +1 -1
- package/dist/agents/nvda-addon.d.ts +0 -14
- package/dist/agents/nvda-addon.js +0 -52
- package/dist/forwarded.d.ts +0 -2
- package/dist/forwarded.js +0 -2
- package/src/web-dashboard/public/assets/index-DhiuR4S2.js +0 -210
- package/src/web-dashboard/public/assets/index-DhiuR4S2.js.map +0 -1
package/dist/tools/tool-loop.js
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* - JSON fallback: the model emits `{"tool":"<name>","arguments":{...}}`
|
|
16
16
|
* blocks after its response text (contract in TOOL_CONTRACT_JSON).
|
|
17
17
|
*/
|
|
18
|
-
import { getTool, toolJsonSchemas } from './registry.js';
|
|
18
|
+
import { getTool, listTools, toolJsonSchemas } from './registry.js';
|
|
19
|
+
import { effectClassOfTool } from './capability-registry.js';
|
|
19
20
|
// A1 — build-command recognition, shared with effect verification so "a build"
|
|
20
21
|
// means the same thing to both the launch check and the honesty guard.
|
|
21
22
|
import { isBuildCommand } from '../utils/effect-verification.js';
|
|
@@ -29,6 +30,10 @@ import { runBeforeToolHooks, runToolOutcomeHooks, toolHookRefusalText, } from '.
|
|
|
29
30
|
// when the run is a resume; `stepDigest` is over the whole input, so a step whose
|
|
30
31
|
// thread or tool schema changed MISSES and is paid for again (see the module).
|
|
31
32
|
import { stepDigest } from '../learning/step-checkpoint.js';
|
|
33
|
+
// P7 — the identical-command guard's memory. The loop drops it when a WRITE is
|
|
34
|
+
// applied, so a repaired project can re-run its own check (see the context below).
|
|
35
|
+
import { resetTerminalFailureStreaks } from './run-terminal.js';
|
|
36
|
+
import { createRunCommandMemo } from './command-memo.js';
|
|
32
37
|
// WS6 (#28) — the declared fault seam. `faultAt` is a null check when this
|
|
33
38
|
// process declared no fault (`NUVIRA_INJECT_FAULT`), so an ordinary turn is
|
|
34
39
|
// unaffected.
|
|
@@ -37,6 +42,8 @@ import { detectPermissionSeeking, isAffirmativeReply, replyAsksTheReader, reques
|
|
|
37
42
|
import { envelopeFromPlan, envelopeFromRequest, getEnvelope, grantEnvelope, isEnvelopeKey, } from '../learning/intent-envelope.js';
|
|
38
43
|
import { detectProcessComplaint, isTraceKey, noProgressNudge, repeatNudge, repeatedFailureNudge, runTraceFor, RunTrace, } from '../learning/run-trace.js';
|
|
39
44
|
import { wantsAuthoredArtifact } from '../learning/deliverable-class.js';
|
|
45
|
+
import { isAuthoredReadBack } from '../learning/turn-report.js';
|
|
46
|
+
import { parseLongFormTarget, countWords } from '../learning/long-form.js';
|
|
40
47
|
import { normalizeFollowups } from './followup-utils.js';
|
|
41
48
|
// The capability mode (balanced | max) — `max` widens the loop's own reasoning
|
|
42
49
|
// budget as well as routing, so "cost is not a concern" means the agent may
|
|
@@ -46,6 +53,7 @@ import { isMaxCapability } from '../config/capability-mode.js';
|
|
|
46
53
|
// compaction digest is injected in every mode, only under `max`, or never.
|
|
47
54
|
import { isWorkDigestEnabled } from '../config/work-digest.js';
|
|
48
55
|
import { assessEditActivity, detectUnverifiedEditClaim, isVerificationTool, verificationNudgeFor, THINK_ONLY_ESCALATION, AUTHORIZED_WORK_NUDGE, deliverableNudge, } from './edit-verification.js';
|
|
56
|
+
import { createHarnessDirectiveSink } from './harness-directive.js';
|
|
49
57
|
import { effectiveToolJsonSchemas, coreToolJsonSchemas, isToolEnabled, toolsetForTool } from './toolsets.js';
|
|
50
58
|
import { deliverablesNamedIn, recordStepHandoff } from '../agents/step-handoff.js';
|
|
51
59
|
import { fenceUntrustedToolOutput } from './untrusted-content.js';
|
|
@@ -254,6 +262,15 @@ export function extractFallbackToolCalls(content) {
|
|
|
254
262
|
// A QUOTED tool block with no closing brace: the model was cut off
|
|
255
263
|
// mid-call. Everything from the marker on is leaked scaffolding, not
|
|
256
264
|
// answer text — strip it and stop (nothing valid can follow).
|
|
265
|
+
//
|
|
266
|
+
// Item 6 — surface it as an UNPARSEABLE call when we saw the tool's name,
|
|
267
|
+
// so the loop refuses it with the truncation cause and the sectioned path.
|
|
268
|
+
// Dropping it silently (the old behaviour) is how a cut-off delivery failed
|
|
269
|
+
// with nothing to tell the model why.
|
|
270
|
+
const name = /^\s*\(?\s*\{\s*"tool"\s*:\s*"([^"]+)"/.exec(cleaned.slice(m.index))?.[1];
|
|
271
|
+
if (name) {
|
|
272
|
+
calls.push({ id: `call_${calls.length + 1}`, name, arguments: {}, argumentsError: 'unparseable' });
|
|
273
|
+
}
|
|
257
274
|
cleaned = cleaned.slice(0, m.index);
|
|
258
275
|
strippedAny = true;
|
|
259
276
|
break;
|
|
@@ -269,15 +286,25 @@ export function extractFallbackToolCalls(content) {
|
|
|
269
286
|
try {
|
|
270
287
|
const parsed = JSON.parse(block);
|
|
271
288
|
if (parsed.tool && typeof parsed.tool === 'string') {
|
|
289
|
+
// Item 6 — the fallback transport is TEXT, so "did the arguments arrive?"
|
|
290
|
+
// is decided HERE. A block that parses but carries no `arguments` object
|
|
291
|
+
// is the empty-payload signature; flagging it lets the loop refuse it with
|
|
292
|
+
// the REAL cause instead of executing `{}` and answering "path is required".
|
|
293
|
+
const rawArgs = parsed.arguments;
|
|
294
|
+
const isPlainObject = rawArgs !== null && typeof rawArgs === 'object' && !Array.isArray(rawArgs);
|
|
272
295
|
calls.push({
|
|
273
296
|
id: `call_${calls.length + 1}`,
|
|
274
297
|
name: parsed.tool,
|
|
275
|
-
arguments:
|
|
298
|
+
arguments: isPlainObject ? rawArgs : {},
|
|
299
|
+
...(isPlainObject ? {} : { argumentsError: rawArgs === undefined ? 'empty' : 'unparseable' }),
|
|
276
300
|
});
|
|
277
301
|
}
|
|
278
302
|
}
|
|
279
303
|
catch {
|
|
280
|
-
//
|
|
304
|
+
// A brace-MATCHED but unparseable block (the model's own JSON typo, e.g. a
|
|
305
|
+
// trailing comma) is dropped from the answer, no call — a truncation has no
|
|
306
|
+
// closing brace and is handled above. Item 6 covers the truncated case there;
|
|
307
|
+
// this stays silent by design so a typo cannot become a phantom refusal.
|
|
281
308
|
}
|
|
282
309
|
}
|
|
283
310
|
// ── Pass 2: our tool's ARGUMENTS keyed by our own tool NAME ─────────────
|
|
@@ -387,31 +414,51 @@ function findMatchingBrace(text, start) {
|
|
|
387
414
|
}
|
|
388
415
|
// ─── Parallel read-only execution (W2) ──────────────────────────────────────
|
|
389
416
|
/**
|
|
390
|
-
* Tools that
|
|
391
|
-
*
|
|
392
|
-
* commands, pipeline dispatch, delegation, `tool_search` (it mutates the
|
|
393
|
-
* tiering state), `plan_todo`, `ask_user` (it blocks on user input) — stays
|
|
394
|
-
* strictly serial and in the model's original order.
|
|
417
|
+
* Tools that READ the world but must still run SERIALLY, because they change
|
|
418
|
+
* HARNESS state or block on input rather than changing the world.
|
|
395
419
|
*
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
420
|
+
* `effectClass: 'read'` is NECESSARY but NOT SUFFICIENT for safe fan-out — this is
|
|
421
|
+
* the second half of the fact, and it is a property of the HARNESS, not of the
|
|
422
|
+
* effect. `tool_search` mutates the tiering state that decides which tools exist
|
|
423
|
+
* in the NEXT step; `ask_user` blocks on the human. Fanning either out would let
|
|
424
|
+
* two siblings disagree about the world they are reading.
|
|
400
425
|
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*/
|
|
404
|
-
|
|
405
|
-
'
|
|
406
|
-
'
|
|
407
|
-
'glob',
|
|
408
|
-
'code_search',
|
|
409
|
-
'web_search',
|
|
410
|
-
'read_page',
|
|
426
|
+
* Pinned by `parallel-tool-exec.test.ts`, which asserts each of these is serial
|
|
427
|
+
* WHILE its declared effect is genuinely `read`.
|
|
428
|
+
*/
|
|
429
|
+
const READ_BUT_SERIAL = new Set([
|
|
430
|
+
'tool_search', // mutates the tiering state
|
|
431
|
+
'ask_user', // blocks on user input
|
|
411
432
|
]);
|
|
412
|
-
/**
|
|
433
|
+
/**
|
|
434
|
+
* Whether a tool may run concurrently with sibling calls in the same step.
|
|
435
|
+
*
|
|
436
|
+
* DERIVED FROM THE CAPABILITY REGISTRY, not a hand-maintained allowlist. This used
|
|
437
|
+
* to be a six-name list that had to be reviewed by hand whenever a read tool was
|
|
438
|
+
* added — so any new read tool, and every read-only MCP tool discovered at
|
|
439
|
+
* runtime, silently lost the fan-out. The effect class answers it now.
|
|
440
|
+
*
|
|
441
|
+
* THE SAFETY DIRECTION IS UNCHANGED, which is the part that matters: a tool the
|
|
442
|
+
* registry does not describe maps to `local-state`, so a newly registered tool is
|
|
443
|
+
* still SERIAL until someone declares it a read. `READ_BUT_SERIAL` is the one
|
|
444
|
+
* hand-written exception, and it is a harness fact rather than an effect guess.
|
|
445
|
+
*/
|
|
413
446
|
export function isParallelSafeTool(name) {
|
|
414
|
-
|
|
447
|
+
if (READ_BUT_SERIAL.has(name))
|
|
448
|
+
return false;
|
|
449
|
+
return effectClassOfTool(name) === 'read';
|
|
450
|
+
}
|
|
451
|
+
let _parallelSafeNames = null;
|
|
452
|
+
/**
|
|
453
|
+
* Every REGISTRY tool that currently qualifies, derived by filtering with
|
|
454
|
+
* `isParallelSafeTool` — so the set and the predicate can never disagree. Built
|
|
455
|
+
* lazily, because the registry must be fully loaded before it can be enumerated.
|
|
456
|
+
*/
|
|
457
|
+
export function parallelSafeToolNames() {
|
|
458
|
+
if (!_parallelSafeNames) {
|
|
459
|
+
_parallelSafeNames = new Set(listTools().map((t) => t.name).filter(isParallelSafeTool));
|
|
460
|
+
}
|
|
461
|
+
return _parallelSafeNames;
|
|
415
462
|
}
|
|
416
463
|
/** Max concurrent read-only calls per step (bounded fan-out) when the model
|
|
417
464
|
* has no harness opinion — see `ModelHarnessProfile.maxParallelReads`, which
|
|
@@ -434,6 +481,36 @@ const MAX_PARALLEL_READS = 4;
|
|
|
434
481
|
* were well able to do (see `THINK_ONLY_ESCALATION`).
|
|
435
482
|
*/
|
|
436
483
|
export const MAX_THINK_CONTINUES = 3;
|
|
484
|
+
/**
|
|
485
|
+
* P2 — how many times the SAME model may answer with nothing before the loop
|
|
486
|
+
* insists on a handoff. One, deliberately: a single empty completion can be a
|
|
487
|
+
* blip at a shared endpoint, but a model that resolves with nothing twice has
|
|
488
|
+
* demonstrated it is not answering this step, and re-calling it is the loop that
|
|
489
|
+
* killed a real turn (five consecutive empties, then the turn reported itself
|
|
490
|
+
* bounded with zero tool calls).
|
|
491
|
+
*/
|
|
492
|
+
export const MAX_SAME_MODEL_EMPTY_RETRIES = 1;
|
|
493
|
+
/**
|
|
494
|
+
* P2 — ceiling on mid-turn handoffs, so a caller whose pool churns through
|
|
495
|
+
* unusable models cannot spin. Each handoff is a genuinely different candidate
|
|
496
|
+
* (the caller must refuse to hand back the same pair), so this only bounds a
|
|
497
|
+
* pathological pool; a real one is exhausted well before it.
|
|
498
|
+
*/
|
|
499
|
+
export const MAX_MODEL_HANDOFFS_PER_TURN = 5;
|
|
500
|
+
/**
|
|
501
|
+
* P3 — the turn's ATTEMPT budget, kept separate from its STEP budget.
|
|
502
|
+
*
|
|
503
|
+
* A mid-turn handoff spends model calls that produced nothing: the empty
|
|
504
|
+
* completion that triggered it, and the first call to the replacement (which
|
|
505
|
+
* may itself come back empty). Charging those to the same budget that governs
|
|
506
|
+
* the actual WORK means a run against two or three dead models exhausts its
|
|
507
|
+
* road before it has done anything — the exact way a task gets abandoned while
|
|
508
|
+
* capable models are still queued. Each handoff therefore GRANTS two extra
|
|
509
|
+
* steps of credit (capped), so hand-offs cannot be cut short by step
|
|
510
|
+
* accounting; everything else — the step budget, the continuation budget, the
|
|
511
|
+
* think-only cap — is untouched.
|
|
512
|
+
*/
|
|
513
|
+
export const MAX_HANDOFF_ATTEMPT_CREDIT = MAX_MODEL_HANDOFFS_PER_TURN * 2;
|
|
437
514
|
/**
|
|
438
515
|
* Consecutive steps that RAN tools and had NO success before the loop asks the
|
|
439
516
|
* model to diagnose the stall. This generalises {@link RunTrace.repeatedFailure}
|
|
@@ -533,6 +610,21 @@ async function runToolLoopInner(opts, progress) {
|
|
|
533
610
|
// Bounded think-only continuation (see THINK_ONLY_ESCALATION). Counts the
|
|
534
611
|
// consecutive reasoning-only steps so the loop cannot spin on them.
|
|
535
612
|
let thinkContinues = 0;
|
|
613
|
+
// P2 — empty-response accounting. `emptyRetriesOnCurrentModel` is the streak
|
|
614
|
+
// of usable-nothing responses from the model currently serving the turn; it
|
|
615
|
+
// resets the moment that model produces anything usable (or is replaced), so
|
|
616
|
+
// "twice in a row" means twice in a row from THAT model. `modelHandoffs`
|
|
617
|
+
// bounds how many times the turn may move.
|
|
618
|
+
let emptyRetriesOnCurrentModel = 0;
|
|
619
|
+
let modelHandoffs = 0;
|
|
620
|
+
// P3 — the ATTEMPT budget (see MAX_HANDOFF_ATTEMPT_CREDIT): step credit
|
|
621
|
+
// granted by mid-turn handoffs, so a run against several dead models cannot
|
|
622
|
+
// exhaust its road before it has done any work.
|
|
623
|
+
let handoffAttemptCredit = 0;
|
|
624
|
+
// Why the turn is ending. The two `break`s in the empty/think-only path mean
|
|
625
|
+
// opposite things (a model shortage vs. one model spinning), so the reason is
|
|
626
|
+
// recorded explicitly rather than re-derived at the exit.
|
|
627
|
+
let exitTermination = null;
|
|
536
628
|
// G13b — bounded "the request asked for a file and none was written" nudges
|
|
537
629
|
// (see wantsAuthoredArtifact).
|
|
538
630
|
let deliverableNudges = 0;
|
|
@@ -561,6 +653,12 @@ async function runToolLoopInner(opts, progress) {
|
|
|
561
653
|
const maxParallelReads = Math.max(1, opts.maxParallelReads ?? MAX_PARALLEL_READS);
|
|
562
654
|
const followups = [];
|
|
563
655
|
const toolCallsRun = [];
|
|
656
|
+
// A call whose arguments never ARRIVED is not a call the model made: it is a
|
|
657
|
+
// payload that did not fit in one model output (measured: 59 of them in one
|
|
658
|
+
// turn). Counted per turn so the loop can escalate, and once-nudged so the
|
|
659
|
+
// escalation itself cannot loop.
|
|
660
|
+
let malformedToolCalls = 0;
|
|
661
|
+
let malformedCallNudges = 0;
|
|
564
662
|
// S5 — the plan_todo loop guard is split by ACTION (see the guard below):
|
|
565
663
|
// repeated CREATES were the observed planner loop; UPDATES are the tracking
|
|
566
664
|
// that keeps the user's checklist honest. Counted across the whole turn, like
|
|
@@ -633,8 +731,31 @@ async function runToolLoopInner(opts, progress) {
|
|
|
633
731
|
// so a `finding` in the same step can check its cited command/path against
|
|
634
732
|
// what really happened (see `enforceEvidenceProvenance`).
|
|
635
733
|
const executedActions = progress.executedActions;
|
|
734
|
+
// P7 — a WRITE makes the world different, so a command that already failed may
|
|
735
|
+
// now succeed. `run_terminal`'s identical-command guard exists to stop blind
|
|
736
|
+
// repeats (the live run burned three back-to-back attempts), but a repaired
|
|
737
|
+
// project re-running its own check is the ONE case that must never be refused:
|
|
738
|
+
// with the guard's cap, that call would be rejected before it could ever
|
|
739
|
+
// succeed and clear the streak — a deadlock where a fixed project could not
|
|
740
|
+
// run its check again. Every tool that mutates the workspace announces it on
|
|
741
|
+
// `autonomy:write-applied`, so the streak is dropped exactly when something
|
|
742
|
+
// changed, and a blind repeat at the same timeout still counts.
|
|
743
|
+
//
|
|
744
|
+
// `run_terminal` announces on the SAME event when IT proceeds autonomously, but
|
|
745
|
+
// that is a statement about its own command, not a change to the world — letting
|
|
746
|
+
// it invalidate the streak would mean the guard could never accumulate on the
|
|
747
|
+
// exact commands it exists for. Payloads name their emitter, so its event is
|
|
748
|
+
// excluded by name while any other write (write_file, edit_file, git, …) counts.
|
|
749
|
+
const baseEmit = context.emit;
|
|
750
|
+
const emitWithWriteInvalidation = (event, data, source) => {
|
|
751
|
+
if (event === 'autonomy:write-applied' && data?.tool !== 'run_terminal') {
|
|
752
|
+
resetTerminalFailureStreaks();
|
|
753
|
+
}
|
|
754
|
+
baseEmit?.(event, data, source);
|
|
755
|
+
};
|
|
636
756
|
const ctx = {
|
|
637
757
|
...context,
|
|
758
|
+
emit: emitWithWriteInvalidation,
|
|
638
759
|
followups: context.followups || sink,
|
|
639
760
|
executedActions,
|
|
640
761
|
writesAuthorized: authorization,
|
|
@@ -648,6 +769,15 @@ async function runToolLoopInner(opts, progress) {
|
|
|
648
769
|
// The run's own behaviour, so a gate can refuse to RE-ASK a question the
|
|
649
770
|
// user already answered (see learning/run-trace.ts).
|
|
650
771
|
runTrace,
|
|
772
|
+
// C3 — the commands THIS run already answered.
|
|
773
|
+
//
|
|
774
|
+
// Created here, so its lifetime is exactly the tool loop's: one answer's
|
|
775
|
+
// worth of steps, discarded after. That is the honest granularity — a probe
|
|
776
|
+
// (`node --version`) is a fact about this run's environment, while
|
|
777
|
+
// idempotent setup (`mkdir -p out`) is only safely skippable while the run
|
|
778
|
+
// that created the directory is still going. A caller may inject its own
|
|
779
|
+
// memo (a test, or a longer-lived session scope), and then it is honoured.
|
|
780
|
+
commandMemo: context.commandMemo ?? createRunCommandMemo(),
|
|
651
781
|
};
|
|
652
782
|
// Resolve the tool set — stable JSON schemas for every native step
|
|
653
783
|
// (derived from the registry's zod schemas, single source of truth).
|
|
@@ -703,8 +833,29 @@ async function runToolLoopInner(opts, progress) {
|
|
|
703
833
|
return added;
|
|
704
834
|
};
|
|
705
835
|
const thread = [...messages];
|
|
836
|
+
// Every harness-authored nudge is delivered through ONE channel: a marked
|
|
837
|
+
// `system` directive that the model ACTS ON, deduped by gate within the turn,
|
|
838
|
+
// instead of a `user` message it answers (see tools/harness-directive.ts).
|
|
839
|
+
const harness = createHarnessDirectiveSink();
|
|
706
840
|
/** Serving pairs the loop has already told the model about, oldest first. */
|
|
707
841
|
const routeFeedState = { pairs: [] };
|
|
842
|
+
// ── C4 residual: the plan the conversation ALREADY has is shown, not hidden ──
|
|
843
|
+
// The C4 experiment measured the shape of this failure: the plan store keeps the
|
|
844
|
+
// plan (and now carries its progress across a re-declaration), but the model was
|
|
845
|
+
// never SHOWN it — the store was read only to answer "does a plan exist". So a
|
|
846
|
+
// later turn could not reference "step 3 is done" (which the tool's own
|
|
847
|
+
// description promises) and re-declared the plan instead. Handing the plan back
|
|
848
|
+
// at the top of every turn makes the carried progress visible and names the one
|
|
849
|
+
// correct move (update), instead of relying on the model to guess that a plan is
|
|
850
|
+
// already tracked.
|
|
851
|
+
//
|
|
852
|
+
// Bounded, and only when there is work left: a finished plan is not re-shown, and
|
|
853
|
+
// a long plan is capped rather than allowed to dominate the context it exists to
|
|
854
|
+
// protect.
|
|
855
|
+
const existingPlan = context.planStore?.snapshot?.() ?? null;
|
|
856
|
+
if (existingPlan && existingPlan.steps.length > 0 && existingPlan.steps.some((s) => s.status !== 'done')) {
|
|
857
|
+
thread.push({ role: 'system', content: planContextBlock(existingPlan) });
|
|
858
|
+
}
|
|
708
859
|
// ── Stage 2: answer a question ABOUT the run from the run ────────────────
|
|
709
860
|
// The live audit's worst moment was the user asking "why are you asking me
|
|
710
861
|
// this again and again?" and the turn replying with an edit plan — not through
|
|
@@ -727,7 +878,9 @@ async function runToolLoopInner(opts, progress) {
|
|
|
727
878
|
'instead. Do not answer a question about your own process with a plan for the work.',
|
|
728
879
|
});
|
|
729
880
|
}
|
|
730
|
-
const
|
|
881
|
+
// C5 — `let`, not `const`: a mid-turn handoff can install a different model, and
|
|
882
|
+
// the budget must follow it (see `threadBudgetAfterSwitch`).
|
|
883
|
+
let budgetChars = opts.threadBudgetChars ?? DEFAULT_THREAD_BUDGET_CHARS;
|
|
731
884
|
// P3d — per-turn parallel suggester: after 2+ successful independent gather
|
|
732
885
|
// steps, one advisory delegate suggestion fires (bounded, deterministic).
|
|
733
886
|
const parallel = makeParallelSuggester();
|
|
@@ -744,6 +897,38 @@ async function runToolLoopInner(opts, progress) {
|
|
|
744
897
|
// clobber it — otherwise the delivered answer is lost and the turn ends
|
|
745
898
|
// with an empty/bounded response (the "where is the essay?" bug).
|
|
746
899
|
let lastContent = '';
|
|
900
|
+
/**
|
|
901
|
+
* How many PRODUCTIVE actions had SUCCEEDED when `lastContent` was written.
|
|
902
|
+
*
|
|
903
|
+
* WHY THIS EXISTS (D1 — the delivered answer contradicted the run's own
|
|
904
|
+
* evidence). `lastContent` used to be replaced only when the new text was at
|
|
905
|
+
* least as long, so the delivered answer was the LONGEST text of the turn —
|
|
906
|
+
* and the model's own later correction could never take over. Measured in
|
|
907
|
+
* `trace-1791300903944-upblb7` (an 82-step run): the longest step was **seq 65
|
|
908
|
+
* (4,024 chars)** — the "Built and verified. Here's the rundown." draft — while
|
|
909
|
+
* seq 80 ("Hold on — that README note claims `sentence-transformers` needs a
|
|
910
|
+
* `torch` build, but I never actually tested that install. Let me check rather
|
|
911
|
+
* than assert.") and seq 82 ("My README claim was **wrong** — torch-2.14.1 and
|
|
912
|
+
* sentence-transformers-6.1.0 do resolve…") are 155 and 165 chars. Both are
|
|
913
|
+
* corrections of the draft, both are shorter, so the run delivered the draft
|
|
914
|
+
* and the user read an assertion the agent had already disproved itself.
|
|
915
|
+
*
|
|
916
|
+
* The rule the length check was reaching for is "a trailing WRAPPER must not
|
|
917
|
+
* clobber the essay". Length is the wrong proxy for that, because a correction
|
|
918
|
+
* is also short. What actually separates them is whether the turn DID something
|
|
919
|
+
* in between: text written after real work is an account of a LATER state and
|
|
920
|
+
* supersedes; a closing paragraph written with no work since is presentation.
|
|
921
|
+
* So a later answer wins when real work happened after the stored one.
|
|
922
|
+
*
|
|
923
|
+
* The presentation calls in `NON_PRODUCTIVE_TOOLS` are excluded on purpose —
|
|
924
|
+
* `suggest_followups` is what the end-of-response contract forces, it changes
|
|
925
|
+
* nothing, and it is precisely the call that produces the trailing wrapper
|
|
926
|
+
* this rule must still ignore (the same set `hasProductiveAction` uses, so the
|
|
927
|
+
* two cannot drift).
|
|
928
|
+
*/
|
|
929
|
+
let workCallsBeforeLastContent = 0;
|
|
930
|
+
/** A capture happened this step; stamp it at the top of the NEXT one (see below). */
|
|
931
|
+
let pendingContentStamp = false;
|
|
747
932
|
let bounded = false;
|
|
748
933
|
// D2.2 — has this turn already pre-flighted the project's build prerequisites?
|
|
749
934
|
// Checked once, before the FIRST build command the turn plans to run.
|
|
@@ -784,7 +969,7 @@ async function runToolLoopInner(opts, progress) {
|
|
|
784
969
|
// many-file edit). Extend the budget a bounded number of times and keep
|
|
785
970
|
// the SAME thread — completed tool calls are never re-run — instead of
|
|
786
971
|
// telling the user "I reached my step limit" with the task unfinished.
|
|
787
|
-
if (steps >= stepLimit) {
|
|
972
|
+
if (steps >= stepLimit + handoffAttemptCredit) {
|
|
788
973
|
if (continuations < maxContinuations) {
|
|
789
974
|
continuations += 1;
|
|
790
975
|
stepLimit += continuationSteps;
|
|
@@ -796,9 +981,23 @@ async function runToolLoopInner(opts, progress) {
|
|
|
796
981
|
// P4 — check cancellation BEFORE each step (a pre-aborted signal never
|
|
797
982
|
// spends a model call) and after tool executions (below).
|
|
798
983
|
if (opts.signal?.aborted) {
|
|
799
|
-
return { content: '', followups, toolCalls: toolCallsRun, steps, ...(opts.resume ? { replayedSteps } : {}), bounded: false, cancelled: true };
|
|
984
|
+
return { content: '', followups, toolCalls: toolCallsRun, steps, ...(opts.resume ? { replayedSteps } : {}), bounded: false, cancelled: true, termination: 'cancelled' };
|
|
800
985
|
}
|
|
801
986
|
steps += 1;
|
|
987
|
+
// D1 — the work completed BEFORE this step's response is written. Snapshotted
|
|
988
|
+
// at the TOP of the iteration, where the previous step's tool calls have
|
|
989
|
+
// already run and been counted, so a step's OWN tool calls can never make its
|
|
990
|
+
// own answer look superseded by the next step's closing wrapper. See
|
|
991
|
+
// `workCallsBeforeLastContent`.
|
|
992
|
+
const workAtIterationStart = countProductiveWork(progress);
|
|
993
|
+
// Stamp an answer captured LAST step with the work count as of the END of that
|
|
994
|
+
// step. Doing it here (rather than at capture) is what keeps a step's OWN tool
|
|
995
|
+
// calls from making its own answer look superseded: the essay step that also
|
|
996
|
+
// wrote a file must not be replaced by the closing paragraph written next.
|
|
997
|
+
if (pendingContentStamp) {
|
|
998
|
+
workCallsBeforeLastContent = workAtIterationStart;
|
|
999
|
+
pendingContentStamp = false;
|
|
1000
|
+
}
|
|
802
1001
|
// Mechanical thread budget: trim BEFORE the model call so a provider
|
|
803
1002
|
// request never exceeds the window (deterministic — no LLM summarizer,
|
|
804
1003
|
// no latency, no drift; see trimThreadBudget).
|
|
@@ -871,7 +1070,7 @@ async function runToolLoopInner(opts, progress) {
|
|
|
871
1070
|
// generation failure: the caller discards the turn. No error text, no
|
|
872
1071
|
// fallback — the fetch itself aborted on the caller's signal.
|
|
873
1072
|
if (opts.signal?.aborted) {
|
|
874
|
-
return { content: '', followups, toolCalls: toolCallsRun, steps, ...(opts.resume ? { replayedSteps } : {}), bounded: false, cancelled: true };
|
|
1073
|
+
return { content: '', followups, toolCalls: toolCallsRun, steps, ...(opts.resume ? { replayedSteps } : {}), bounded: false, cancelled: true, termination: 'cancelled' };
|
|
875
1074
|
}
|
|
876
1075
|
// Generation failure — surface what we have rather than crash the turn.
|
|
877
1076
|
// generationFailed is TRUE only when NOTHING happened yet (no content, no
|
|
@@ -936,6 +1135,10 @@ async function runToolLoopInner(opts, progress) {
|
|
|
936
1135
|
...(opts.resume ? { replayedSteps } : {}),
|
|
937
1136
|
bounded: false,
|
|
938
1137
|
continuations,
|
|
1138
|
+
...(modelHandoffs > 0 ? { modelHandoffs } : {}),
|
|
1139
|
+
// Nothing to deliver means the provider walk itself gave up — every
|
|
1140
|
+
// candidate was tried and none answered. That IS the evidenced shortage.
|
|
1141
|
+
termination: delivered === '' ? 'no-capable-candidate' : 'delivered',
|
|
939
1142
|
generationFailed: delivered === '' || !madeProgress,
|
|
940
1143
|
};
|
|
941
1144
|
}
|
|
@@ -970,10 +1173,21 @@ async function runToolLoopInner(opts, progress) {
|
|
|
970
1173
|
const responseThinkOnly = deps.isThinkOnly
|
|
971
1174
|
? deps.isThinkOnly(response.content)
|
|
972
1175
|
: isThinkOnlyResponse(response.content);
|
|
973
|
-
|
|
1176
|
+
const responseIsSubstantive = response.content.trim() !== '' && !responseThinkOnly && !isBareAcknowledgment(response.content);
|
|
1177
|
+
const workSinceLastContent = workAtIterationStart > workCallsBeforeLastContent;
|
|
1178
|
+
if (responseIsSubstantive && (response.content.length >= lastContent.length || workSinceLastContent)) {
|
|
974
1179
|
lastContent = response.content;
|
|
1180
|
+
// Stamp what this answer is an account OF, so the next step can tell an
|
|
1181
|
+
// update from a wrapper (see `workCallsBeforeLastContent`). Deferred to the
|
|
1182
|
+
// top of the next iteration so it includes THIS step's own work.
|
|
1183
|
+
pendingContentStamp = true;
|
|
975
1184
|
}
|
|
976
1185
|
const { toolCalls } = response;
|
|
1186
|
+
// P2 — the model just produced something usable (text, or at least one tool
|
|
1187
|
+
// call), so its empty-response streak is over: a later blip starts a NEW
|
|
1188
|
+
// streak rather than inheriting one from twenty steps ago.
|
|
1189
|
+
if (toolCalls.length > 0 || !responseThinkOnly)
|
|
1190
|
+
emptyRetriesOnCurrentModel = 0;
|
|
977
1191
|
if (toolCalls.length === 0) {
|
|
978
1192
|
// No tools → end turn UNLESS the content is think-only (
|
|
979
1193
|
// isThinkOnlyResponse: continue instead of ending).
|
|
@@ -994,6 +1208,86 @@ async function runToolLoopInner(opts, progress) {
|
|
|
994
1208
|
// is the exact class of defect this work exists to remove, so the two
|
|
995
1209
|
// cases are named apart even though they are bounded together.
|
|
996
1210
|
const emptyResponse = response.content.trim().length === 0;
|
|
1211
|
+
// ── P2 — MID-TURN MODEL HANDOFF ────────────────────────────────────
|
|
1212
|
+
// An empty completion is a PROVIDER failure, not a state of mind, and
|
|
1213
|
+
// re-asking the model that just returned nothing is what killed a real
|
|
1214
|
+
// run: five consecutive empties from one model, then `bounded: true`
|
|
1215
|
+
// with zero tool calls, while healthy models sat configured and unused.
|
|
1216
|
+
// So once THIS model has had its single retry, the loop asks the caller
|
|
1217
|
+
// for a different candidate. The caller owns the pool (exclusions,
|
|
1218
|
+
// credentials, cooldowns) and refuses to hand back the same pair; a
|
|
1219
|
+
// `false` means there is genuinely nothing else, and we fall through to
|
|
1220
|
+
// the bounded escalation below — unchanged behaviour for a turn with no
|
|
1221
|
+
// alternative (and for every caller that supplies no hook at all).
|
|
1222
|
+
if (emptyResponse &&
|
|
1223
|
+
deps.requestModelSwitch &&
|
|
1224
|
+
modelHandoffs < MAX_MODEL_HANDOFFS_PER_TURN &&
|
|
1225
|
+
emptyRetriesOnCurrentModel >= MAX_SAME_MODEL_EMPTY_RETRIES) {
|
|
1226
|
+
let switched = false;
|
|
1227
|
+
try {
|
|
1228
|
+
switched = (await deps.requestModelSwitch('empty')) === true;
|
|
1229
|
+
}
|
|
1230
|
+
catch {
|
|
1231
|
+
// A broken switch hook must never kill the turn — fall through to the
|
|
1232
|
+
// bounded path exactly as if it had answered `false`.
|
|
1233
|
+
switched = false;
|
|
1234
|
+
}
|
|
1235
|
+
if (switched) {
|
|
1236
|
+
modelHandoffs += 1;
|
|
1237
|
+
// ── C5 — RE-FIT THE THREAD TO THE MODEL THAT NOW ANSWERS ──────
|
|
1238
|
+
// `budgetChars` was resolved before this loop started, from the pair
|
|
1239
|
+
// the turn BEGAN on — and the handoff worth catching is the one to a
|
|
1240
|
+
// SMALLER window, which is precisely the case that overflows. The
|
|
1241
|
+
// caller knows the session, so it re-resolves; `undefined` keeps the
|
|
1242
|
+
// budget (an unknown window must not silently shrink the thread).
|
|
1243
|
+
// The trim runs at the TOP of the next iteration, before the call, so
|
|
1244
|
+
// a NEW budget is applied to the very next request — and when it is
|
|
1245
|
+
// smaller, that trim is what makes the handoff fit.
|
|
1246
|
+
const refitted = deps.threadBudgetAfterSwitch?.();
|
|
1247
|
+
if (typeof refitted === 'number' && refitted > 0 && refitted !== budgetChars) {
|
|
1248
|
+
deps.onEvent?.(` 📐 thread budget re-fitted for the handoff model: ` +
|
|
1249
|
+
`${Math.round(budgetChars / 1000)}K → ${Math.round(refitted / 1000)}K chars.`);
|
|
1250
|
+
budgetChars = refitted;
|
|
1251
|
+
}
|
|
1252
|
+
// P3 — the ATTEMPT budget: the empty completion that triggered this
|
|
1253
|
+
// handoff, and the first call to the replacement model, are not the
|
|
1254
|
+
// user's work — grant the step credit so a run against a couple of
|
|
1255
|
+
// dead models cannot exhaust its road before it has done anything.
|
|
1256
|
+
handoffAttemptCredit = Math.min(MAX_HANDOFF_ATTEMPT_CREDIT, handoffAttemptCredit + 2);
|
|
1257
|
+
// The streak and the think streak belong to the model that failed.
|
|
1258
|
+
emptyRetriesOnCurrentModel = 0;
|
|
1259
|
+
thinkContinues = 0;
|
|
1260
|
+
// ── DROP THE FAILED MODEL'S PRIVATE REASONING ─────────────────
|
|
1261
|
+
// Every assistant turn records `reasoningContent`, and the wire
|
|
1262
|
+
// layer echoes it back on that assistant message — which is
|
|
1263
|
+
// REQUIRED when the SAME model retries (some reasoning models fail
|
|
1264
|
+
// the request without their own prior reasoning; see
|
|
1265
|
+
// `reasoning-cache.ts`). After a HANDOFF the next model is a
|
|
1266
|
+
// DIFFERENT model, and replaying a stranger's chain-of-thought
|
|
1267
|
+
// either gets the request rejected by the target API (foreign
|
|
1268
|
+
// `reasoning_content` / tool-call ids) or anchors the new model to
|
|
1269
|
+
// the reasoning that already failed. Strip it on a pair change; a
|
|
1270
|
+
// same-model retry keeps it untouched, which is where the
|
|
1271
|
+
// requirement actually comes from.
|
|
1272
|
+
// NOTE: `thread` (the loop's own working copy, line ~1398), not
|
|
1273
|
+
// `messages` — the assistant turns live here.
|
|
1274
|
+
for (const prior of thread) {
|
|
1275
|
+
if (prior.role === 'assistant' && prior.reasoningContent) {
|
|
1276
|
+
delete prior.reasoningContent;
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
deps.onEvent?.(` 🔀 That model returned nothing usable — handing the turn to a different model (handoff ${modelHandoffs}/${MAX_MODEL_HANDOFFS_PER_TURN}).`);
|
|
1280
|
+
traceEvent({
|
|
1281
|
+
kind: 'gate',
|
|
1282
|
+
gate: 'handoff',
|
|
1283
|
+
summary: `the provider returned an empty response twice — the turn handed off to a different model ` +
|
|
1284
|
+
`(handoff ${modelHandoffs}/${MAX_MODEL_HANDOFFS_PER_TURN}) instead of re-asking the same one`,
|
|
1285
|
+
});
|
|
1286
|
+
continue;
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
if (emptyResponse)
|
|
1290
|
+
emptyRetriesOnCurrentModel += 1;
|
|
997
1291
|
// BOUNDED (see THINK_ONLY_ESCALATION). Continuing on reasoning is right
|
|
998
1292
|
// for a `<think>`-then-answer model and catastrophic without a limit:
|
|
999
1293
|
// two live eval runs produced 31 reasoning-only steps, one tool call and
|
|
@@ -1024,13 +1318,18 @@ async function runToolLoopInner(opts, progress) {
|
|
|
1024
1318
|
: `the model produced reasoning-only output ${thinkContinues} times with no tool call and no answer — ` +
|
|
1025
1319
|
'one bounded escalation to act or answer',
|
|
1026
1320
|
});
|
|
1027
|
-
|
|
1321
|
+
harness.push(thread, 'think-only', THINK_ONLY_ESCALATION);
|
|
1028
1322
|
continue;
|
|
1029
1323
|
}
|
|
1030
1324
|
// Still spinning after the escalation: END the turn rather than burn the
|
|
1031
1325
|
// rest of the budget. `bounded` is set so every surface reads this as
|
|
1032
|
-
// "stopped before finishing", never as a completed answer
|
|
1326
|
+
// "stopped before finishing", never as a completed answer — and P3 names
|
|
1327
|
+
// WHICH kind of stop this is, because the two demand opposite responses:
|
|
1328
|
+
// every model that answered returned nothing (a model shortage, which
|
|
1329
|
+
// the caller must report as such) versus one model that kept thinking
|
|
1330
|
+
// (a bounded stop that says nothing about the other models).
|
|
1033
1331
|
bounded = true;
|
|
1332
|
+
exitTermination = emptyResponse ? 'no-capable-candidate' : 'reasoning-spin';
|
|
1034
1333
|
deps.onEvent?.(emptyResponse
|
|
1035
1334
|
? ' ⚠️ Empty responses kept coming — ending the turn instead of spinning. This is a PROVIDER failure, not the agent thinking.'
|
|
1036
1335
|
: ' 🧠 Reasoning-only output kept repeating — ending the turn instead of spinning.');
|
|
@@ -1071,11 +1370,8 @@ async function runToolLoopInner(opts, progress) {
|
|
|
1071
1370
|
// would outrank and suppress the nudge entirely.
|
|
1072
1371
|
lastContent = '';
|
|
1073
1372
|
thread.push({ role: 'assistant', content: response.content });
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
content: 'You announced an action you were about to take, but no tool was called and nothing was done. ' +
|
|
1077
|
-
'Either carry it out now with the tools available, or reply with the actual answer — do not end the turn on a promise.',
|
|
1078
|
-
});
|
|
1373
|
+
harness.push(thread, 'promise', 'You announced an action you were about to take, but no tool was called and nothing was done. ' +
|
|
1374
|
+
'Either carry it out now with the tools available, or reply with the actual answer — do not end the turn on a promise.');
|
|
1079
1375
|
continue;
|
|
1080
1376
|
}
|
|
1081
1377
|
// ── G13 — AUTHORIZED-WORK nudge (bounded, once) ──────────────────────
|
|
@@ -1113,7 +1409,7 @@ async function runToolLoopInner(opts, progress) {
|
|
|
1113
1409
|
lastContent = stripTrailingPermissionSeek(response.content.length >= lastContent.length ? response.content : lastContent);
|
|
1114
1410
|
}
|
|
1115
1411
|
thread.push({ role: 'assistant', content: response.content });
|
|
1116
|
-
|
|
1412
|
+
harness.push(thread, 'permission', AUTHORIZED_WORK_NUDGE);
|
|
1117
1413
|
continue;
|
|
1118
1414
|
}
|
|
1119
1415
|
// ── Stage 2 — REPETITION nudge (bounded, once) ───────────────────────
|
|
@@ -1146,7 +1442,7 @@ async function runToolLoopInner(opts, progress) {
|
|
|
1146
1442
|
lastContent = stripTrailingPermissionSeek(closing);
|
|
1147
1443
|
}
|
|
1148
1444
|
thread.push({ role: 'assistant', content: response.content });
|
|
1149
|
-
|
|
1445
|
+
harness.push(thread, 'repeat', repeatNudge(closing, runTrace.priorAnswer(closing)));
|
|
1150
1446
|
continue;
|
|
1151
1447
|
}
|
|
1152
1448
|
// ── ZERO-ACTION gate (bounded, once) ────────────────────────────────
|
|
@@ -1172,7 +1468,7 @@ async function runToolLoopInner(opts, progress) {
|
|
|
1172
1468
|
// The non-answer must not become the delivered answer either way.
|
|
1173
1469
|
lastContent = '';
|
|
1174
1470
|
thread.push({ role: 'assistant', content: response.content });
|
|
1175
|
-
|
|
1471
|
+
harness.push(thread, 'action', zeroActionNudge(currentAsk(opts)));
|
|
1176
1472
|
continue;
|
|
1177
1473
|
}
|
|
1178
1474
|
// S1 (both exits): the MOST SUBSTANTIVE content seen wins here too —
|
|
@@ -1197,7 +1493,7 @@ async function runToolLoopInner(opts, progress) {
|
|
|
1197
1493
|
: 'an authored deliverable was requested and no file was written — one bounded nudge to produce it',
|
|
1198
1494
|
});
|
|
1199
1495
|
thread.push({ role: 'assistant', content: response.content });
|
|
1200
|
-
|
|
1496
|
+
harness.push(thread, 'deliverable', deliverableNudge(authorization.requestedPath));
|
|
1201
1497
|
continue;
|
|
1202
1498
|
}
|
|
1203
1499
|
//
|
|
@@ -1225,10 +1521,7 @@ async function runToolLoopInner(opts, progress) {
|
|
|
1225
1521
|
// preference order. A live turn reached for `node -c` because the nudge
|
|
1226
1522
|
// left the choice to the model; `verificationNudgeFor` reads the
|
|
1227
1523
|
// workspace and asks for the real command, so there is nothing to guess.
|
|
1228
|
-
|
|
1229
|
-
role: 'user',
|
|
1230
|
-
content: verificationNudgeFor(ctx.cwd ?? process.cwd(), progress.mutatedPaths),
|
|
1231
|
-
});
|
|
1524
|
+
harness.push(thread, 'verification', verificationNudgeFor(ctx.cwd ?? process.cwd(), progress.mutatedPaths));
|
|
1232
1525
|
continue;
|
|
1233
1526
|
}
|
|
1234
1527
|
// SELF-REVIEW (no-tools exit) — a substantial, already-verified turn is
|
|
@@ -1244,7 +1537,7 @@ async function runToolLoopInner(opts, progress) {
|
|
|
1244
1537
|
summary: 'a substantial turn that changed files reached its end — one bounded nudge to check the result against the original ask',
|
|
1245
1538
|
});
|
|
1246
1539
|
thread.push({ role: 'assistant', content: response.content });
|
|
1247
|
-
|
|
1540
|
+
harness.push(thread, 'self-review', review);
|
|
1248
1541
|
continue;
|
|
1249
1542
|
}
|
|
1250
1543
|
return {
|
|
@@ -1255,6 +1548,8 @@ async function runToolLoopInner(opts, progress) {
|
|
|
1255
1548
|
...(opts.resume ? { replayedSteps } : {}),
|
|
1256
1549
|
bounded: false,
|
|
1257
1550
|
continuations,
|
|
1551
|
+
...(modelHandoffs > 0 ? { modelHandoffs } : {}),
|
|
1552
|
+
termination: 'delivered',
|
|
1258
1553
|
};
|
|
1259
1554
|
}
|
|
1260
1555
|
// ── Execute tool calls (results fed back, next step) ──────────────
|
|
@@ -1286,6 +1581,9 @@ async function runToolLoopInner(opts, progress) {
|
|
|
1286
1581
|
// their nudge into this queue and it is flushed after the results, keeping
|
|
1287
1582
|
// the assistant→tool group intact. (Without this, a pinned/strict run died
|
|
1288
1583
|
// on the third model call and looked like a provider fault.)
|
|
1584
|
+
// Planning-time directives, flushed as harness directives once the step's
|
|
1585
|
+
// tool-call group is complete. Kept as {gate, content} so the same channel
|
|
1586
|
+
// (and the same per-turn dedupe) covers them.
|
|
1289
1587
|
const deferredUserNudges = [];
|
|
1290
1588
|
const plans = toolCalls.map((call) => {
|
|
1291
1589
|
const tool = getTool(call.name);
|
|
@@ -1361,6 +1659,24 @@ async function runToolLoopInner(opts, progress) {
|
|
|
1361
1659
|
`Call tool_search with {"action":"load","toolset":"${ownerToolset?.name ?? ''}"} first — its tools become callable immediately.`,
|
|
1362
1660
|
};
|
|
1363
1661
|
}
|
|
1662
|
+
if (call.argumentsError &&
|
|
1663
|
+
// 'unparseable' is unambiguous: argument bytes ARRIVED and could not be
|
|
1664
|
+
// used, which is the shape of a truncation. 'empty' is only a defect
|
|
1665
|
+
// when the tool actually REQUIRES an argument — a schema with no required
|
|
1666
|
+
// field is legitimately callable with none, and the Gemini wire omits an
|
|
1667
|
+
// empty `args` object, so a blanket refusal there would be a FALSE refusal
|
|
1668
|
+
// of a real no-argument call (git, plan_todo). Item 6.
|
|
1669
|
+
(call.argumentsError === 'unparseable' || toolRequiresArguments(call.name))) {
|
|
1670
|
+
// F1 — the call was never made: the argument payload did not arrive.
|
|
1671
|
+
// Refusing here is the whole point: executing it as `{}` reported a tool
|
|
1672
|
+
// call that "ran" with no input, hid the real cause (an output-budget
|
|
1673
|
+
// truncation) from the model, and burned 59 round trips in one turn.
|
|
1674
|
+
malformedToolCalls += 1;
|
|
1675
|
+
return {
|
|
1676
|
+
call,
|
|
1677
|
+
refuse: malformedToolCallRefusal({ name: call.name, argumentsError: call.argumentsError }, { finishReason: response.finishReason, attempt: malformedToolCalls }),
|
|
1678
|
+
};
|
|
1679
|
+
}
|
|
1364
1680
|
if (!isToolEnabled(call.name, context.configManager)) {
|
|
1365
1681
|
// I1 execution gate: a disabled tool is rejected at runtime even if
|
|
1366
1682
|
// the model hallucinated its name — the toggle is never cosmetic.
|
|
@@ -1551,7 +1867,7 @@ async function runToolLoopInner(opts, progress) {
|
|
|
1551
1867
|
try {
|
|
1552
1868
|
const findings = checkProjectPrerequisites(createNodePrereqFs(opts.context?.cwd || process.cwd()));
|
|
1553
1869
|
if (findings.length > 0) {
|
|
1554
|
-
deferredUserNudges.push(formatPreflightFindings(findings));
|
|
1870
|
+
deferredUserNudges.push({ gate: 'prerequisite', content: formatPreflightFindings(findings) });
|
|
1555
1871
|
deps.onEvent?.(` 🧱 pre-flight: ${findings.length} missing project prerequisite(s) — handed the exact fix before building.`);
|
|
1556
1872
|
traceEvent({
|
|
1557
1873
|
kind: 'gate',
|
|
@@ -1635,7 +1951,7 @@ async function runToolLoopInner(opts, progress) {
|
|
|
1635
1951
|
? `a workspace-directing turn was about to mutate without a plan — the first mutation batch (${blocked.join(', ')}) was refused once and the model asked to declare a plan first`
|
|
1636
1952
|
: 'a workspace-directing turn was about to mutate without a plan — one bounded nudge to declare one first',
|
|
1637
1953
|
});
|
|
1638
|
-
deferredUserNudges.push(planRequiredNudge(currentAsk(opts)));
|
|
1954
|
+
deferredUserNudges.push({ gate: 'plan', content: planRequiredNudge(currentAsk(opts)) });
|
|
1639
1955
|
}
|
|
1640
1956
|
const executed = new Array(plans.length).fill('');
|
|
1641
1957
|
for (let i = 0; i < plans.length;) {
|
|
@@ -1710,6 +2026,21 @@ async function runToolLoopInner(opts, progress) {
|
|
|
1710
2026
|
const p = a?.path ?? a?.file_path ?? a?.file;
|
|
1711
2027
|
if (typeof p === 'string' && p) {
|
|
1712
2028
|
progress.mutatedPaths.push(p);
|
|
2029
|
+
// Bundle 19 — keep what was WRITTEN, so the turn can be judged on its
|
|
2030
|
+
// artifact. Capped: a turn cannot grow the loop's memory without bound.
|
|
2031
|
+
if (progress.authoredArtifacts.length < AUTHORED_ARTIFACT_MAX) {
|
|
2032
|
+
const written = extractWrittenText(call.arguments);
|
|
2033
|
+
progress.authoredArtifacts.push({
|
|
2034
|
+
tool: call.name,
|
|
2035
|
+
path: p,
|
|
2036
|
+
// The TEXT is capped for memory, but the WORD COUNT is taken from the
|
|
2037
|
+
// FULL text: a long document must be measured whole. Measuring the
|
|
2038
|
+
// first 20k characters instead under-counts a long-with-long-words
|
|
2039
|
+
// document and FALSELY flags it as short — see `detectArtifactShortfall`.
|
|
2040
|
+
content: written.slice(0, AUTHORED_ARTIFACT_MAX_CHARS),
|
|
2041
|
+
words: countWords(written),
|
|
2042
|
+
});
|
|
2043
|
+
}
|
|
1713
2044
|
// Stage 2 — the run's own record of what it CHANGED, so a self-report
|
|
1714
2045
|
// can answer "what have you been doing" from data rather than from a
|
|
1715
2046
|
// re-read of the transcript.
|
|
@@ -1860,7 +2191,7 @@ async function runToolLoopInner(opts, progress) {
|
|
|
1860
2191
|
// complete (every call id has a `tool` result). This is the first point a
|
|
1861
2192
|
// user/system message may safely follow the batch.
|
|
1862
2193
|
for (const nudge of deferredUserNudges)
|
|
1863
|
-
|
|
2194
|
+
harness.push(thread, nudge.gate, nudge.content);
|
|
1864
2195
|
// Track the generalized stall: a step that RAN tools but succeeded at none
|
|
1865
2196
|
// of them extends the streak; any success clears it. A text-only step (no
|
|
1866
2197
|
// tools) leaves it as it was — the model is thinking, not failing.
|
|
@@ -1878,6 +2209,21 @@ async function runToolLoopInner(opts, progress) {
|
|
|
1878
2209
|
// right after the step's results are in the thread, and the next model
|
|
1879
2210
|
// step sees it. Bounded once per turn and keyed on the action so the same
|
|
1880
2211
|
// repeat cannot trigger it twice.
|
|
2212
|
+
// ── A payload that does not fit in one output ────────────────────────────
|
|
2213
|
+
// The refusal above already says so per call; this adds ONE mid-turn nudge
|
|
2214
|
+
// naming the delivery strategy, because a model stuck re-emitting a too-large
|
|
2215
|
+
// call needs the alternative, not a third identical error.
|
|
2216
|
+
if (malformedToolCalls >= 3 && malformedCallNudges < 1) {
|
|
2217
|
+
malformedCallNudges += 1;
|
|
2218
|
+
stepLimit += 1;
|
|
2219
|
+
deps.onEvent?.(` 📦 ${malformedToolCalls} calls arrived with no usable arguments — telling the model to build the artifact in sections.`);
|
|
2220
|
+
traceEvent({
|
|
2221
|
+
kind: 'gate',
|
|
2222
|
+
gate: 'malformed-call',
|
|
2223
|
+
summary: `${malformedToolCalls} tool calls this turn carried no usable arguments (payload larger than one model output) — one bounded nudge to deliver in sections`,
|
|
2224
|
+
});
|
|
2225
|
+
harness.push(thread, 'malformed-call', malformedCallNudge(malformedToolCalls));
|
|
2226
|
+
}
|
|
1881
2227
|
if (diagnosisNudges < 1) {
|
|
1882
2228
|
const repeated = runTrace.repeatedFailure();
|
|
1883
2229
|
if (repeated && repeated.times >= 2) {
|
|
@@ -1890,7 +2236,7 @@ async function runToolLoopInner(opts, progress) {
|
|
|
1890
2236
|
summary: `the same action failed ${repeated.times} times (${repeated.tool}: ${repeated.action.slice(0, 120)}) — ` +
|
|
1891
2237
|
'one bounded nudge to diagnose the root cause and change the approach',
|
|
1892
2238
|
});
|
|
1893
|
-
|
|
2239
|
+
harness.push(thread, 'diagnosis', repeatedFailureNudge(repeated));
|
|
1894
2240
|
}
|
|
1895
2241
|
else if (failedToolSteps >= NO_PROGRESS_STALL_STEPS) {
|
|
1896
2242
|
// Stage 2 — no SINGLE action repeated, but the last N steps each ran
|
|
@@ -1904,7 +2250,7 @@ async function runToolLoopInner(opts, progress) {
|
|
|
1904
2250
|
summary: `${failedToolSteps} consecutive steps ran tools and none succeeded — ` +
|
|
1905
2251
|
'one bounded nudge to diagnose the shared cause and change the approach',
|
|
1906
2252
|
});
|
|
1907
|
-
|
|
2253
|
+
harness.push(thread, 'diagnosis', noProgressNudge(runTrace.recentFailures(3), failedToolSteps));
|
|
1908
2254
|
}
|
|
1909
2255
|
}
|
|
1910
2256
|
// Tiered exposure: after EVERY executed tool call, union any newly
|
|
@@ -1935,7 +2281,7 @@ async function runToolLoopInner(opts, progress) {
|
|
|
1935
2281
|
// P4 — cancellation during/after tool execution: do NOT request another
|
|
1936
2282
|
// model step on a cancelled turn (the user already walked away).
|
|
1937
2283
|
if (opts.signal?.aborted) {
|
|
1938
|
-
return { content: '', followups, toolCalls: toolCallsRun, steps, ...(opts.resume ? { replayedSteps } : {}), bounded: false, cancelled: true };
|
|
2284
|
+
return { content: '', followups, toolCalls: toolCallsRun, steps, ...(opts.resume ? { replayedSteps } : {}), bounded: false, cancelled: true, termination: 'cancelled' };
|
|
1939
2285
|
}
|
|
1940
2286
|
// ── endsAgentStep — a successful dispenser's RESULT is the answer ────────
|
|
1941
2287
|
// `build`/`resume`/`repair`/`document`/`website`/`analyze`/`test`/`publish`
|
|
@@ -1965,6 +2311,8 @@ async function runToolLoopInner(opts, progress) {
|
|
|
1965
2311
|
...(opts.resume ? { replayedSteps } : {}),
|
|
1966
2312
|
bounded: false,
|
|
1967
2313
|
continuations,
|
|
2314
|
+
...(modelHandoffs > 0 ? { modelHandoffs } : {}),
|
|
2315
|
+
termination: 'delivered',
|
|
1968
2316
|
};
|
|
1969
2317
|
}
|
|
1970
2318
|
// End-of-response semantics (the contract: "END EVERY RESPONSE by
|
|
@@ -2011,10 +2359,7 @@ async function runToolLoopInner(opts, progress) {
|
|
|
2011
2359
|
// preference order. A live turn reached for `node -c` because the nudge
|
|
2012
2360
|
// left the choice to the model; `verificationNudgeFor` reads the
|
|
2013
2361
|
// workspace and asks for the real command, so there is nothing to guess.
|
|
2014
|
-
|
|
2015
|
-
role: 'user',
|
|
2016
|
-
content: verificationNudgeFor(ctx.cwd ?? process.cwd(), progress.mutatedPaths),
|
|
2017
|
-
});
|
|
2362
|
+
harness.push(thread, 'verification', verificationNudgeFor(ctx.cwd ?? process.cwd(), progress.mutatedPaths));
|
|
2018
2363
|
continue;
|
|
2019
2364
|
}
|
|
2020
2365
|
// G13b — DELIVERABLE GATE (concluding path). AFTER the verification gate
|
|
@@ -2032,7 +2377,7 @@ async function runToolLoopInner(opts, progress) {
|
|
|
2032
2377
|
: 'an authored deliverable was requested and no file was written — one bounded nudge to produce it',
|
|
2033
2378
|
});
|
|
2034
2379
|
thread.push({ role: 'assistant', content: response.content });
|
|
2035
|
-
|
|
2380
|
+
harness.push(thread, 'deliverable', deliverableNudge(authorization.requestedPath));
|
|
2036
2381
|
continue;
|
|
2037
2382
|
}
|
|
2038
2383
|
// ZERO-ACTION gate (concluding exit) — the same bounded pass as the
|
|
@@ -2050,7 +2395,7 @@ async function runToolLoopInner(opts, progress) {
|
|
|
2050
2395
|
});
|
|
2051
2396
|
lastContent = '';
|
|
2052
2397
|
thread.push({ role: 'assistant', content: response.content });
|
|
2053
|
-
|
|
2398
|
+
harness.push(thread, 'action', zeroActionNudge(currentAsk(opts)));
|
|
2054
2399
|
continue;
|
|
2055
2400
|
}
|
|
2056
2401
|
// SELF-REVIEW (concluding exit) — same bounded pass, placed AFTER the
|
|
@@ -2066,7 +2411,7 @@ async function runToolLoopInner(opts, progress) {
|
|
|
2066
2411
|
summary: 'a substantial turn that changed files reached its end — one bounded nudge to check the result against the original ask',
|
|
2067
2412
|
});
|
|
2068
2413
|
thread.push({ role: 'assistant', content: response.content });
|
|
2069
|
-
|
|
2414
|
+
harness.push(thread, 'self-review', review);
|
|
2070
2415
|
continue;
|
|
2071
2416
|
}
|
|
2072
2417
|
const content = response.content.length >= lastContent.length ? response.content : lastContent;
|
|
@@ -2078,17 +2423,52 @@ async function runToolLoopInner(opts, progress) {
|
|
|
2078
2423
|
...(opts.resume ? { replayedSteps } : {}),
|
|
2079
2424
|
bounded: false,
|
|
2080
2425
|
continuations,
|
|
2426
|
+
...(modelHandoffs > 0 ? { modelHandoffs } : {}),
|
|
2427
|
+
termination: 'delivered',
|
|
2081
2428
|
};
|
|
2082
2429
|
}
|
|
2083
2430
|
}
|
|
2084
2431
|
// Reaching here means the continuation budget is spent (the in-loop check
|
|
2085
2432
|
// extends the bound otherwise) — the turn is honestly bounded.
|
|
2086
2433
|
bounded = true;
|
|
2087
|
-
|
|
2434
|
+
// P3 — the exit SAYS which ending this was. The live trace reported "the step
|
|
2435
|
+
// budget (17) was reached" after FIVE model calls and zero tool calls: the
|
|
2436
|
+
// empty-response path had ended the turn, and this shared tail claimed the road
|
|
2437
|
+
// had run out when it had not. A reader cannot tell a model shortage from a
|
|
2438
|
+
// budget from that line, which is the whole reason `termination` exists.
|
|
2439
|
+
if (exitTermination === 'no-capable-candidate') {
|
|
2440
|
+
deps.onEvent?.(' ⚠️ Every model that answered returned nothing usable — ending the turn and reporting exactly what was tried.');
|
|
2441
|
+
}
|
|
2442
|
+
else if (exitTermination === 'reasoning-spin') {
|
|
2443
|
+
deps.onEvent?.(' 🧠 Reasoning-only output kept repeating — ending the turn instead of spinning.');
|
|
2444
|
+
}
|
|
2445
|
+
else {
|
|
2446
|
+
deps.onEvent?.(` ⚠️ Tool loop reached its ${stepLimit}-step budget — returning the last response.`);
|
|
2447
|
+
}
|
|
2448
|
+
// P3 — BUG SIGNAL. Nothing was delivered and the caller could still have
|
|
2449
|
+
// handed the work to another model, so this ending is a GAP IN THE ROAD, not a
|
|
2450
|
+
// shortage of models. It is said out loud (invariant 2: a failure is never
|
|
2451
|
+
// silent) and named on the result, so the caller's report does not have to
|
|
2452
|
+
// guess which of the two it was serving the user.
|
|
2453
|
+
// `exitTermination === null` is what makes this a BUDGET claim: when the empty
|
|
2454
|
+
// path already chose an ending it said WHY (a model shortage, a reasoning
|
|
2455
|
+
// spin), and re-labelling that as a budget gap would be the exact dishonesty
|
|
2456
|
+
// this work removes — the reason must be the one that actually applied.
|
|
2457
|
+
const budgetGapWithCandidates = exitTermination === null &&
|
|
2458
|
+
lastContent.trim() === '' &&
|
|
2459
|
+
!!deps.requestModelSwitch &&
|
|
2460
|
+
modelHandoffs < MAX_MODEL_HANDOFFS_PER_TURN;
|
|
2461
|
+
if (budgetGapWithCandidates) {
|
|
2462
|
+
logger.warn(` ⚠️ the turn ran out of steps with nothing delivered while a model handoff was still available ` +
|
|
2463
|
+
`(handoffs used ${modelHandoffs}/${MAX_MODEL_HANDOFFS_PER_TURN}) — this is a BUDGET gap, not a model shortage`);
|
|
2464
|
+
}
|
|
2088
2465
|
traceEvent({
|
|
2089
2466
|
kind: 'decision',
|
|
2090
2467
|
gate: 'budget',
|
|
2091
|
-
summary:
|
|
2468
|
+
summary: budgetGapWithCandidates
|
|
2469
|
+
? `the step budget (${stepLimit}) was reached with nothing delivered while model handoffs were still available ` +
|
|
2470
|
+
`(handoffs used ${modelHandoffs}/${MAX_MODEL_HANDOFFS_PER_TURN}) — a budget gap, not a model shortage`
|
|
2471
|
+
: `the step budget (${stepLimit}) was reached — the turn ended on its last response instead of finishing`,
|
|
2092
2472
|
});
|
|
2093
2473
|
return {
|
|
2094
2474
|
content: lastContent || 'I reached my step limit for this request.',
|
|
@@ -2098,6 +2478,11 @@ async function runToolLoopInner(opts, progress) {
|
|
|
2098
2478
|
...(opts.resume ? { replayedSteps } : {}),
|
|
2099
2479
|
bounded: true,
|
|
2100
2480
|
continuations,
|
|
2481
|
+
...(modelHandoffs > 0 ? { modelHandoffs } : {}),
|
|
2482
|
+
// An exit the empty/think path chose says exactly why; otherwise the road
|
|
2483
|
+
// simply ran out, and `budget-exhausted` admits that rather than dressing it
|
|
2484
|
+
// up as a model shortage.
|
|
2485
|
+
termination: exitTermination ?? 'budget-exhausted',
|
|
2101
2486
|
};
|
|
2102
2487
|
}
|
|
2103
2488
|
// ─── Honest-answer guard ────────────────────────────────────────────────────
|
|
@@ -2155,12 +2540,129 @@ export function detectUnverifiedDeliveryClaim(content, toolsRun) {
|
|
|
2155
2540
|
}
|
|
2156
2541
|
return false;
|
|
2157
2542
|
}
|
|
2543
|
+
/**
|
|
2544
|
+
* Tools whose SUCCESS means a file could have been written, even though the loop
|
|
2545
|
+
* cannot see it: an editor (tracked in `mutatedPaths`), a shell whose command
|
|
2546
|
+
* redirected a file, an executed snippet. Used only to keep the file-claim guard
|
|
2547
|
+
* conservative — if any of these SUCCEEDED, the turn's own record does not
|
|
2548
|
+
* contradict a "I wrote the file" claim, so the guard stays silent.
|
|
2549
|
+
*/
|
|
2550
|
+
const FILE_WRITE_CAPABLE_TOOLS = new Set([
|
|
2551
|
+
'write_file',
|
|
2552
|
+
'edit_file',
|
|
2553
|
+
'apply_patch',
|
|
2554
|
+
'multi_edit',
|
|
2555
|
+
'patch_file',
|
|
2556
|
+
'run_terminal',
|
|
2557
|
+
'run_cli',
|
|
2558
|
+
'code_execution',
|
|
2559
|
+
]);
|
|
2560
|
+
/**
|
|
2561
|
+
* Past-tense sentences that ASSERT a FILE was produced. Deliberately about
|
|
2562
|
+
* writing/creating/saving a file — not about reading one, and not about sending
|
|
2563
|
+
* one (`detectUnverifiedDeliveryClaim` covers sends).
|
|
2564
|
+
*/
|
|
2565
|
+
const FILE_WRITE_CLAIM_RE = [
|
|
2566
|
+
/\b(?:has|have)\s+been\s+(?:written|saved|created|produced|generated)\b/i,
|
|
2567
|
+
/\bi(?:'ve|\s+have)\s+(?:just\s+|now\s+|successfully\s+)?(?:written|saved|created|produced|generated)\b/i,
|
|
2568
|
+
/\b(?:the\s+)?(?:file|document|guide|report|chapter|artifact|deliverable|write-?up|script|manifest)\b[^.]{0,50}\b(?:now\s+)?contains\b/i,
|
|
2569
|
+
/\b(?:written|saved|created|produced|generated)\s+(?:it\s+)?(?:to|into)\s+(?:disk\b|\S+\.\w{1,6}\b)/i,
|
|
2570
|
+
/\b(?:the\s+)?(?:file|document|report|guide|artifact|deliverable)\b[^.]{0,40}\b(?:is|are)\s+(?:now\s+)?(?:on\s+disk|written|saved|created|ready)\b/i,
|
|
2571
|
+
];
|
|
2572
|
+
/**
|
|
2573
|
+
* True when the answer CLAIMS a file was produced and the turn's own record says
|
|
2574
|
+
* none was. This is the sibling of `detectUnverifiedDeliveryClaim` (which covers
|
|
2575
|
+
* SENDS), and it closes a gap found live: a turn wrote nothing, then replied
|
|
2576
|
+
* "The guide has been written and saved as GUIDE.md … contains a full ~5,000-word
|
|
2577
|
+
* technical guide", and NO flag could see it.
|
|
2578
|
+
*
|
|
2579
|
+
* Why it is not redundant with `undeliveredArtifact`: that flag is a function of
|
|
2580
|
+
* the ASK (it is gated on the request classifying as an authored deliverable), so
|
|
2581
|
+
* an ask the classifier does not recognise — or a plain code ask that happens to
|
|
2582
|
+
* be answered with a fabricated "I saved it" — produced no disclosure at all. This
|
|
2583
|
+
* one is a function of what the turn DID: a completed file claim, contradicted by
|
|
2584
|
+
* the absence of any write. The two are made mutually exclusive at the call site so
|
|
2585
|
+
* one turn is never reported under two names.
|
|
2586
|
+
*
|
|
2587
|
+
* Conservative by construction, so an honest turn is never flagged: it fires only
|
|
2588
|
+
* when NOTHING was mutated AND no write-capable tool succeeded; a heredoc through
|
|
2589
|
+
* `run_terminal`, an `edit_file`, or any executed snippet therefore exempts the
|
|
2590
|
+
* turn, because the run's record cannot contradict the claim.
|
|
2591
|
+
*/
|
|
2592
|
+
export function detectUnverifiedFileClaim(content, evidence) {
|
|
2593
|
+
const text = (content || '').trim();
|
|
2594
|
+
if (!text)
|
|
2595
|
+
return false;
|
|
2596
|
+
if (evidence.mutatedPaths.length > 0)
|
|
2597
|
+
return false;
|
|
2598
|
+
if (evidence.successfulToolCalls.some((n) => FILE_WRITE_CAPABLE_TOOLS.has(n)))
|
|
2599
|
+
return false;
|
|
2600
|
+
const sentences = text.split(/(?<=[.!?\u3002\uff01\uff1f])\s+|\n+/);
|
|
2601
|
+
for (const sentence of sentences) {
|
|
2602
|
+
const s = sentence.trim();
|
|
2603
|
+
if (!s || NON_CLAIM_CONTEXT_RE.test(s))
|
|
2604
|
+
continue;
|
|
2605
|
+
if (FILE_WRITE_CLAIM_RE.some((re) => re.test(s)))
|
|
2606
|
+
return true;
|
|
2607
|
+
}
|
|
2608
|
+
return false;
|
|
2609
|
+
}
|
|
2158
2610
|
/**
|
|
2159
2611
|
* Tool-shaped actions a first-person imminent promise can drop. Matched by
|
|
2160
2612
|
* STEM (`\w*` suffix) so "scaffold" covers "scaffolding", "write" covers
|
|
2161
2613
|
* "writing", etc. — the promise is usually phrased as a gerund ("start by
|
|
2162
2614
|
* scaffolding").
|
|
2163
2615
|
*/
|
|
2616
|
+
/**
|
|
2617
|
+
* Bundle 23 (item 13) — how far short of a stated magnitude counts as a miss.
|
|
2618
|
+
* Half, deliberately: "about 5000 words" delivered as 4,200 is a tighter draft,
|
|
2619
|
+
* not a shortfall, and an over-eager version of this flag would be ignored like
|
|
2620
|
+
* every other noisy guard. Under half is materially not what was asked for.
|
|
2621
|
+
*/
|
|
2622
|
+
export const ARTIFACT_SHORTFALL_RATIO = 0.5;
|
|
2623
|
+
/**
|
|
2624
|
+
* How much of a delivered artifact travels to the corpus (item 13). Bounded on
|
|
2625
|
+
* purpose: a candidate row is a SAMPLE to fit a signal to, not a copy of the
|
|
2626
|
+
* user's document, and an unbounded row would turn a collection file into a
|
|
2627
|
+
* second copy of everything the agent ever wrote.
|
|
2628
|
+
*/
|
|
2629
|
+
export const AUTHORED_DELIVERABLE_EXCERPT_MAX = 2000;
|
|
2630
|
+
/**
|
|
2631
|
+
* Compare a written artifact against an EXPLICIT magnitude the ask named.
|
|
2632
|
+
*
|
|
2633
|
+
* This is item 13's honest half: the delivered artifact is measured AGAINST THE
|
|
2634
|
+
* REQUEST, deterministically, instead of judging quality from a model's
|
|
2635
|
+
* vocabulary. It fires only when the ask states a number ("about 5000 words",
|
|
2636
|
+
* "a 100 page story") — a plain "write a long document" names no magnitude, so
|
|
2637
|
+
* there is nothing to compare against and this must not invent one.
|
|
2638
|
+
*
|
|
2639
|
+
* Returns null unless the file is under `ARTIFACT_SHORTFALL_RATIO` of the target,
|
|
2640
|
+
* so an honest document is never flagged. An empty file returns null too — a
|
|
2641
|
+
* missing artifact is the omission/undelivered gates' job, not a shortfall's.
|
|
2642
|
+
*/
|
|
2643
|
+
export function detectArtifactShortfall(ask, artifact) {
|
|
2644
|
+
const target = parseLongFormTarget(ask || '');
|
|
2645
|
+
// Only an EXPLICIT magnitude counts: `source` is the quoted number the parser
|
|
2646
|
+
// matched, while the default-book branch (a book with no number in the ask) has
|
|
2647
|
+
// no quotes — inventing a target there would measure the document against a
|
|
2648
|
+
// number the user never said.
|
|
2649
|
+
if (!target || !target.source.startsWith('"'))
|
|
2650
|
+
return null;
|
|
2651
|
+
// `words` is the count of the FULL artifact when the caller supplies it; counting
|
|
2652
|
+
// `content` (which may be a capped prefix) instead would measure the storage
|
|
2653
|
+
// limit, not the document, and could flag a long deliverable as short.
|
|
2654
|
+
const deliveredWords = artifact.words ?? countWords(artifact.content);
|
|
2655
|
+
if (deliveredWords === 0)
|
|
2656
|
+
return null;
|
|
2657
|
+
if (deliveredWords >= target.wordsTarget * ARTIFACT_SHORTFALL_RATIO)
|
|
2658
|
+
return null;
|
|
2659
|
+
return {
|
|
2660
|
+
path: artifact.path,
|
|
2661
|
+
deliveredWords,
|
|
2662
|
+
targetWords: target.wordsTarget,
|
|
2663
|
+
source: target.source,
|
|
2664
|
+
};
|
|
2665
|
+
}
|
|
2164
2666
|
const INTENT_ACTION_STEM = '(?:creat|writ|build|scaffold|implement|generat|add|edit|updat|modif|refactor|run|execut|install|configur|read|inspect|send|post|upload|deploy|publish|fetch|search|apply|fix|delet|remov|mov|renam|copi|compil|set\\s+up)';
|
|
2165
2667
|
/**
|
|
2166
2668
|
* A first-person IMMINENT promise to act. Immediacy is required, because that
|
|
@@ -2225,6 +2727,82 @@ export function detectUnfulfilledIntentPromise(content) {
|
|
|
2225
2727
|
}
|
|
2226
2728
|
return false;
|
|
2227
2729
|
}
|
|
2730
|
+
// ─── Bundle 19 — an artifact that declares ITSELF incomplete ────────────────
|
|
2731
|
+
/**
|
|
2732
|
+
* Statements an ARTIFACT makes about being incomplete — read from the file the turn
|
|
2733
|
+
* WROTE, never from the model's prose.
|
|
2734
|
+
*
|
|
2735
|
+
* WHY THIS EXISTS (measured 2026-10-07). Asked for a ~2500-word design document, a
|
|
2736
|
+
* real turn wrote 25 words to `DESIGN.md` and reported success. The file contained
|
|
2737
|
+
* `## 1. Introduction ... (full content omitted for brevity) ...` — it announced the
|
|
2738
|
+
* content and then omitted it — and its own verification ("read the file back and
|
|
2739
|
+
* confirm all sections are present") is true of a list of headings. No existing flag
|
|
2740
|
+
* could see it: `unverifiedEdit` needs an unobserved mutation (the file WAS read
|
|
2741
|
+
* back), `undeliveredArtifact` needs nothing written (a file exists), and there is
|
|
2742
|
+
* content, so nothing is empty.
|
|
2743
|
+
*
|
|
2744
|
+
* WHY THIS IS NOT A "PHRASE LIST" IN THE SENSE THIS PROGRAMME REMOVES. Those decide
|
|
2745
|
+
* whether work is GOOD, from a model's vocabulary. This reads a factual statement the
|
|
2746
|
+
* artifact makes about itself — the same distinction as `unfulfilledPromise` (an
|
|
2747
|
+
* announced action) and `detectUnverifiedDeliveryClaim` (a claim contradicted by the
|
|
2748
|
+
* run). A document saying "content omitted" is not a quality judgement; it is the
|
|
2749
|
+
* artifact reporting its own incompleteness.
|
|
2750
|
+
*
|
|
2751
|
+
* DELIBERATELY NARROW. Each pattern requires an OMISSION word bound to content or a
|
|
2752
|
+
* section. The bare word `placeholder` is NOT matched (HTML `placeholder=` attributes
|
|
2753
|
+
* would fire on ordinary code), and neither is a bare `TODO` — a TODO is a plan for
|
|
2754
|
+
* later work, not a claim that this file is finished.
|
|
2755
|
+
*/
|
|
2756
|
+
const ARTIFACT_OMISSION_RE = [
|
|
2757
|
+
/\(\s*(?:the\s+)?(?:full\s+)?content\s+omitted/i,
|
|
2758
|
+
/\bcontent\s+(?:is\s+|was\s+)?omitted\s+for\s+(?:brevity|space|length|conciseness|readability)\b/i,
|
|
2759
|
+
/\bomitted\s+for\s+(?:brevity|space|length|conciseness|readability)\b/i,
|
|
2760
|
+
/\b(?:the\s+)?(?:rest|remainder)\s+of\s+(?:this\s+)?(?:document|report|article|file)\s+(?:is|are|was|were)?\s*(?:omitted|not\s+included|intentionally\s+left\s+out)/i,
|
|
2761
|
+
// A section RANGE may sit between the noun and the adverb/verb: the measured
|
|
2762
|
+
// "Sections 2-9 intentionally omitted." failed the adjacency form that only
|
|
2763
|
+
// allowed whitespace there. The range is optional, so "Content omitted." and
|
|
2764
|
+
// "Details omitted." still match; a bare prose "omits" does not.
|
|
2765
|
+
/\b(?:sections?|content|details?)\s+(?:\d+(?:\s*[-–—]\s*\d+)?\s+)?(?:intentionally\s+|deliberately\s+)?omitted\b/i,
|
|
2766
|
+
/\bplaceholder\s+for\s+(?:the\s+)?(?:full|actual|real|complete)\s+(?:content|document|section|report|article)\b/i,
|
|
2767
|
+
];
|
|
2768
|
+
/**
|
|
2769
|
+
* The statement an artifact makes about its own incompleteness, or `null`.
|
|
2770
|
+
* Returns the MATCHED TEXT so the report can quote what the file said about itself.
|
|
2771
|
+
*/
|
|
2772
|
+
export function detectSelfDeclaredOmission(text) {
|
|
2773
|
+
const body = text || '';
|
|
2774
|
+
if (!body)
|
|
2775
|
+
return null;
|
|
2776
|
+
for (const re of ARTIFACT_OMISSION_RE) {
|
|
2777
|
+
const m = body.match(re);
|
|
2778
|
+
if (m?.[0])
|
|
2779
|
+
return m[0].trim();
|
|
2780
|
+
}
|
|
2781
|
+
return null;
|
|
2782
|
+
}
|
|
2783
|
+
/** Cap on the content kept per artifact, and on how many are kept, so a long turn cannot grow the loop's memory. */
|
|
2784
|
+
const AUTHORED_ARTIFACT_MAX_CHARS = 20_000;
|
|
2785
|
+
const AUTHORED_ARTIFACT_MAX = 40;
|
|
2786
|
+
/**
|
|
2787
|
+
* The text a mutation call WROTE, from whichever field carries it. `''` when the call
|
|
2788
|
+
* carries no literal body (an append by path, a patch with no text).
|
|
2789
|
+
*/
|
|
2790
|
+
/**
|
|
2791
|
+
* The text a write actually carried — UNCAPPED, so a caller can measure the WHOLE
|
|
2792
|
+
* artifact (word count) and cap only what it stores. Capping here would make the
|
|
2793
|
+
* measurement a property of the storage limit rather than of the file.
|
|
2794
|
+
*/
|
|
2795
|
+
function extractWrittenText(args) {
|
|
2796
|
+
if (!args || typeof args !== 'object')
|
|
2797
|
+
return '';
|
|
2798
|
+
const a = args;
|
|
2799
|
+
for (const key of ['content', 'new_string', 'newText', 'text', 'body', 'patch']) {
|
|
2800
|
+
const v = a[key];
|
|
2801
|
+
if (typeof v === 'string' && v)
|
|
2802
|
+
return v;
|
|
2803
|
+
}
|
|
2804
|
+
return '';
|
|
2805
|
+
}
|
|
2228
2806
|
// ─── Build honesty (A3 Part 2) ──────────────────────────────────────────────
|
|
2229
2807
|
/**
|
|
2230
2808
|
* Past-tense sentences that ASSERT a BUILD, or its artifact, came out good.
|
|
@@ -2366,6 +2944,7 @@ export async function runToolLoop(opts) {
|
|
|
2366
2944
|
successfulToolCalls: [],
|
|
2367
2945
|
deliveryConfirmed: false,
|
|
2368
2946
|
mutatedPaths: [],
|
|
2947
|
+
authoredArtifacts: [],
|
|
2369
2948
|
verificationEvidence: [],
|
|
2370
2949
|
executedActions: [],
|
|
2371
2950
|
};
|
|
@@ -2406,12 +2985,66 @@ export async function runToolLoop(opts) {
|
|
|
2406
2985
|
// runs whether or not the nudge was enabled, so the flag is never a
|
|
2407
2986
|
// function of configuration.
|
|
2408
2987
|
const activity = assessEditActivity(result.successfulToolCalls, progress.verificationEvidence, progress.mutatedPaths);
|
|
2409
|
-
|
|
2988
|
+
// Bundle 21/25 — the authored-document demotion, applied WHERE THE FLAGS ARE
|
|
2989
|
+
// SET so every surface agrees without a per-caller conditional: the trace
|
|
2990
|
+
// outcome, the console warning, the working-state ledger and the turn report all
|
|
2991
|
+
// read these two flags. For an authored deliverable that was written and then
|
|
2992
|
+
// READ BACK, the read-back IS the meaningful check — a code-style "no observing
|
|
2993
|
+
// run" verdict would contradict the `delivered-and-read-back` verdict the report
|
|
2994
|
+
// deliberately produces. Measured live: a turn that wrote and read back GUIDE.md
|
|
2995
|
+
// recorded `unverifiedEdit: true` on the trace while its report said
|
|
2996
|
+
// `delivered-and-read-back`.
|
|
2997
|
+
const authoredReadBack = isAuthoredReadBack(lastUserText(opts.messages), result.successfulToolCalls);
|
|
2998
|
+
if (activity.needsVerification && !authoredReadBack) {
|
|
2410
2999
|
result.unverifiedEdit = true;
|
|
2411
3000
|
}
|
|
2412
|
-
if (detectUnverifiedEditClaim(result.content, activity.mutations, activity.verifications)
|
|
3001
|
+
if (detectUnverifiedEditClaim(result.content, activity.mutations, activity.verifications) &&
|
|
3002
|
+
!authoredReadBack) {
|
|
2413
3003
|
result.unverifiedEditClaim = true;
|
|
2414
3004
|
}
|
|
3005
|
+
// Bundle 19 — ARTIFACT honesty. A file the turn wrote says its own content was
|
|
3006
|
+
// omitted, so the turn is NOT finished work however complete its prose reads.
|
|
3007
|
+
// Judged on the artifact, never on the model's vocabulary, and recorded with the
|
|
3008
|
+
// artifact's own words so a reader can see exactly what it admitted.
|
|
3009
|
+
for (const artifact of progress.authoredArtifacts) {
|
|
3010
|
+
const statement = detectSelfDeclaredOmission(artifact.content);
|
|
3011
|
+
if (statement) {
|
|
3012
|
+
result.artifactIncomplete = { path: artifact.path, statement };
|
|
3013
|
+
break;
|
|
3014
|
+
}
|
|
3015
|
+
}
|
|
3016
|
+
// Bundle 23 (item 13) — ARTIFACT-VS-ASK honesty. When the ask named an explicit
|
|
3017
|
+
// magnitude and the turn wrote a SINGLE artifact far short of it, the delivered
|
|
3018
|
+
// file is not what was asked for. Skipped when the artifact already admits its
|
|
3019
|
+
// own omission (that is the stronger, self-incriminating signal), and bounded to
|
|
3020
|
+
// one artifact because an append/sectioned delivery cannot be measured here
|
|
3021
|
+
// without risking a false "short".
|
|
3022
|
+
if (!result.artifactIncomplete && progress.authoredArtifacts.length === 1) {
|
|
3023
|
+
const shortfall = detectArtifactShortfall(lastUserText(opts.messages), progress.authoredArtifacts[0]);
|
|
3024
|
+
if (shortfall)
|
|
3025
|
+
result.artifactShortfall = shortfall;
|
|
3026
|
+
}
|
|
3027
|
+
// Item 13 (round 3) — COLLECT the case a quality signal would have to be fit
|
|
3028
|
+
// to, instead of inventing a rule for it. A single authored artifact that
|
|
3029
|
+
// tripped NEITHER check above is the "fluent, on-length" shape: the one no
|
|
3030
|
+
// live run has produced as a miss, and the one no deterministic rule can judge
|
|
3031
|
+
// (judging it from vocabulary is the defect this programme exists to remove).
|
|
3032
|
+
// So the FACTS travel — path, length, a bounded excerpt — and a caller records
|
|
3033
|
+
// them with a later user verdict as the label. No judgement is made here.
|
|
3034
|
+
if (!result.artifactIncomplete && !result.artifactShortfall && progress.authoredArtifacts.length === 1) {
|
|
3035
|
+
const artifact = progress.authoredArtifacts[0];
|
|
3036
|
+
const ask = lastUserText(opts.messages) || '';
|
|
3037
|
+
const target = parseLongFormTarget(ask);
|
|
3038
|
+
result.authoredDeliverable = {
|
|
3039
|
+
path: artifact.path,
|
|
3040
|
+
deliveredWords: artifact.words,
|
|
3041
|
+
// Only an EXPLICIT magnitude is carried — `source` is the quoted number the
|
|
3042
|
+
// parser matched, while the default-book branch invents one. A corpus that
|
|
3043
|
+
// recorded an invented target would fit a signal to a number nobody asked for.
|
|
3044
|
+
...(target && target.source.startsWith('"') ? { targetWords: target.wordsTarget } : {}),
|
|
3045
|
+
excerpt: artifact.content.slice(0, AUTHORED_DELIVERABLE_EXCERPT_MAX),
|
|
3046
|
+
};
|
|
3047
|
+
}
|
|
2415
3048
|
// G13b — DELIVERABLE honesty, the same way and for the same reason: the
|
|
2416
3049
|
// flag is a function of what the turn DID, never of configuration. A turn
|
|
2417
3050
|
// that was asked for a file and wrote none must not be readable as
|
|
@@ -2424,6 +3057,18 @@ export async function runToolLoop(opts) {
|
|
|
2424
3057
|
wantsAuthoredArtifact(lastUserText(opts.messages))) {
|
|
2425
3058
|
result.undeliveredArtifact = true;
|
|
2426
3059
|
}
|
|
3060
|
+
// Bundle 20 — FILE-CLAIM honesty, the complement of the flag above. That one
|
|
3061
|
+
// is a function of the ASK; this is a function of the ANSWER: a reply that says
|
|
3062
|
+
// a file was produced while the turn mutated nothing. Skipped when
|
|
3063
|
+
// `undeliveredArtifact` already fired, so one turn is never reported under two
|
|
3064
|
+
// names.
|
|
3065
|
+
if (!result.undeliveredArtifact &&
|
|
3066
|
+
detectUnverifiedFileClaim(result.content, {
|
|
3067
|
+
mutatedPaths: progress.mutatedPaths,
|
|
3068
|
+
successfulToolCalls: result.successfulToolCalls,
|
|
3069
|
+
})) {
|
|
3070
|
+
result.unverifiedFileClaim = true;
|
|
3071
|
+
}
|
|
2427
3072
|
// ZERO-ACTION honesty — a request that DIRECTED work on the workspace was
|
|
2428
3073
|
// answered with nothing at all: no tool succeeded, nothing was written. The
|
|
2429
3074
|
// flag is a function of what the turn DID, never of configuration, so a
|
|
@@ -2527,6 +3172,22 @@ const NON_PRODUCTIVE_TOOLS = new Set(['suggest_followups']);
|
|
|
2527
3172
|
function hasProductiveAction(progress) {
|
|
2528
3173
|
return progress.successfulToolCalls.some((name) => !NON_PRODUCTIVE_TOOLS.has(name));
|
|
2529
3174
|
}
|
|
3175
|
+
/**
|
|
3176
|
+
* How many PRODUCTIVE actions have SUCCEEDED this turn — the cardinality of the
|
|
3177
|
+
* very predicate `hasProductiveAction` answers, so the two cannot drift. D1 uses
|
|
3178
|
+
* it to tell a later answer written after real work (an update, which supersedes)
|
|
3179
|
+
* from a closing wrapper written after none (presentation, which must not). It
|
|
3180
|
+
* reads the loop's own success record — `successfulToolCalls`, appended only for
|
|
3181
|
+
* calls that actually ran — so a refused or failed call is never mistaken for
|
|
3182
|
+
* work, and `suggest_followups` (which merely concludes) is never counted.
|
|
3183
|
+
*/
|
|
3184
|
+
function countProductiveWork(progress) {
|
|
3185
|
+
let n = 0;
|
|
3186
|
+
for (const name of progress.successfulToolCalls)
|
|
3187
|
+
if (!NON_PRODUCTIVE_TOOLS.has(name))
|
|
3188
|
+
n += 1;
|
|
3189
|
+
return n;
|
|
3190
|
+
}
|
|
2530
3191
|
/**
|
|
2531
3192
|
* Does the ZERO-ACTION gate apply to this turn?
|
|
2532
3193
|
*
|
|
@@ -2638,6 +3299,107 @@ function previewToolResult(result, max = 200) {
|
|
|
2638
3299
|
* Returns null when the result is an ordinary tool/runtime error, so the caller
|
|
2639
3300
|
* keeps its own wording rather than inventing a cause.
|
|
2640
3301
|
*/
|
|
3302
|
+
/** How many steps of an existing plan are re-shown to the model (bounded). */
|
|
3303
|
+
export const PLAN_CONTEXT_MAX_STEPS = 12;
|
|
3304
|
+
/**
|
|
3305
|
+
* The block that hands the model the plan its own conversation already tracks.
|
|
3306
|
+
*
|
|
3307
|
+
* Written to make the correct next move obvious rather than merely possible: the
|
|
3308
|
+
* step ids are given verbatim (so `update` can address them), the statuses are the
|
|
3309
|
+
* stored truth, and the one thing a model tends to do instead — declare the plan
|
|
3310
|
+
* again — is named as wrong, because a re-declaration under the same goal now
|
|
3311
|
+
* carries progress but still replaces the step LIST the user is watching.
|
|
3312
|
+
*/
|
|
3313
|
+
export function planContextBlock(plan) {
|
|
3314
|
+
const shown = plan.steps.slice(0, PLAN_CONTEXT_MAX_STEPS);
|
|
3315
|
+
const hidden = plan.steps.length - shown.length;
|
|
3316
|
+
const lines = shown.map((s) => ` - ${s.id} [${s.status}] ${s.description}${s.note ? ` — ${s.note}` : ''}`);
|
|
3317
|
+
const open = plan.steps.filter((s) => s.status !== 'done').length;
|
|
3318
|
+
return [
|
|
3319
|
+
`📋 The plan this conversation is already tracking (${plan.steps.length - open}/${plan.steps.length} done, revision ${plan.revision}):`,
|
|
3320
|
+
` goal: ${plan.goal}`,
|
|
3321
|
+
...lines,
|
|
3322
|
+
...(hidden > 0 ? [` … and ${hidden} more step(s).`] : []),
|
|
3323
|
+
`Advance THIS plan: call \`plan_todo\` with action "update", the step id above and its new status. ` +
|
|
3324
|
+
'Do NOT declare it again with action "create" — the checklist the user is watching is this one, and ' +
|
|
3325
|
+
're-declaring replaces its steps. Work the next open step.',
|
|
3326
|
+
].join('\n');
|
|
3327
|
+
}
|
|
3328
|
+
/**
|
|
3329
|
+
* The refusal a tool call gets when its ARGUMENTS never arrived.
|
|
3330
|
+
*
|
|
3331
|
+
* Measured defect (dashboard session `4e2b0e51…-1791349543186`, 2026-10-07): the
|
|
3332
|
+
* goal "deliver complete document" produced **59 `write_file` calls with empty
|
|
3333
|
+
* arguments**, plus 14 `run_terminal` and 2 `code_execution` — 73 of the turn's 81
|
|
3334
|
+
* calls. Each one was executed as `{}`, so the model's feedback was
|
|
3335
|
+
* `write_file: path is required`, which names the wrong problem: the model HAD
|
|
3336
|
+
* asked for a write, with a payload (a whole design document) that could not fit
|
|
3337
|
+
* in one output. It diagnosed "my calls were emitted empty", retried 59 times,
|
|
3338
|
+
* and the turn ended `bounded: true` with no document. Two things were missing
|
|
3339
|
+
* and both are here: the REAL cause, and a way to succeed at all.
|
|
3340
|
+
*
|
|
3341
|
+
* The refusal is `Error:`-prefixed because the call genuinely did not run — the
|
|
3342
|
+
* loop's honest accounting (successful calls, mutations, verification) depends
|
|
3343
|
+
* on that prefix, so a call that never reached a tool is never counted as one.
|
|
3344
|
+
*/
|
|
3345
|
+
export function malformedToolCallRefusal(call, opts = {}) {
|
|
3346
|
+
const attempt = opts.attempt ?? 1;
|
|
3347
|
+
const cause = call.argumentsError === 'empty'
|
|
3348
|
+
? 'the call arrived with NO arguments at all'
|
|
3349
|
+
: "the call's arguments arrived incomplete and could not be parsed";
|
|
3350
|
+
const why = opts.finishReason === 'length'
|
|
3351
|
+
? 'the provider stopped this step at its output-token limit (finish_reason: "length"), so the payload was cut off before the arguments were complete'
|
|
3352
|
+
: 'this is what a payload too large to emit in ONE step looks like on the wire — nothing was lost on this side';
|
|
3353
|
+
const escape = 'Send the payload in PIECES instead of one call: `write_file` the FIRST section, then call `write_file` again ' +
|
|
3354
|
+
'with mode:"append" for each further section (or write a smaller file and grow it with `edit_file`). ' +
|
|
3355
|
+
'Do NOT re-send this call unchanged — it cannot succeed, and every attempt is a full round trip.';
|
|
3356
|
+
const escalation = attempt >= 3
|
|
3357
|
+
? ` (This is time ${attempt} this turn that a call arrived with no usable arguments — change the SHAPE of the call, not its wording.)`
|
|
3358
|
+
: '';
|
|
3359
|
+
return `Error: ${call.name}: NOT run — ${cause}, so the tool never saw your input. Reason: ${why}. ${escape}${escalation}`;
|
|
3360
|
+
}
|
|
3361
|
+
/**
|
|
3362
|
+
* Whether a tool's schema declares at least one REQUIRED argument (item 6).
|
|
3363
|
+
*
|
|
3364
|
+
* This is what lets the empty-arguments refusal reach the JSON-fallback and
|
|
3365
|
+
* Gemini wires without refusing a legitimate no-argument call: a `git` or
|
|
3366
|
+
* `plan_todo` call with `{}` is a real call, but a `write_file` with `{}` is a
|
|
3367
|
+
* truncated one. Read from the tool's own JSON schema (derived from its zod
|
|
3368
|
+
* schema, never hand-kept), so a tool added later is judged by what it declares.
|
|
3369
|
+
* When the schema cannot be read the answer is conservative (`true`): a
|
|
3370
|
+
* malformed payload is the more likely cause, and the refusal is guidance, not a
|
|
3371
|
+
* wall — the model can still retry with arguments.
|
|
3372
|
+
*/
|
|
3373
|
+
function toolRequiresArguments(name) {
|
|
3374
|
+
try {
|
|
3375
|
+
const [schema] = toolJsonSchemas([name]);
|
|
3376
|
+
// An unregistered name is not a call to honour — refuse, as before.
|
|
3377
|
+
if (!schema)
|
|
3378
|
+
return true;
|
|
3379
|
+
const required = schema.parameters.required;
|
|
3380
|
+
// No `required` key means the tool declares no required argument, so an empty
|
|
3381
|
+
// payload IS a legitimate call (list_dir). An empty array means the same.
|
|
3382
|
+
if (Array.isArray(required))
|
|
3383
|
+
return required.length > 0;
|
|
3384
|
+
return false;
|
|
3385
|
+
}
|
|
3386
|
+
catch {
|
|
3387
|
+
// A schema that cannot be read is treated conservatively: a malformed payload
|
|
3388
|
+
// is the more likely cause, and the refusal is guidance the model can act on.
|
|
3389
|
+
return true;
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
/**
|
|
3393
|
+
* The one bounded mid-turn nudge for the same defect, pushed after the results of
|
|
3394
|
+
* the step that produced it — so a model that keeps re-sending a payload too large
|
|
3395
|
+
* for one output is told the strategy, not merely that it failed again.
|
|
3396
|
+
*/
|
|
3397
|
+
export function malformedCallNudge(attempts) {
|
|
3398
|
+
return (`Your last ${attempts} tool call(s) this turn arrived WITHOUT usable arguments — the payload did not fit in one ` +
|
|
3399
|
+
'model output and was cut off. Nothing is wrong with your plan; the DELIVERY shape is. Build the artifact in ' +
|
|
3400
|
+
'sections: `write_file` the first part, then `write_file` with mode:"append" for each next part (keep each call ' +
|
|
3401
|
+
'under ~120 lines). Do not repeat the single large call.');
|
|
3402
|
+
}
|
|
2641
3403
|
export function classifyToolRefusal(result) {
|
|
2642
3404
|
const text = result || '';
|
|
2643
3405
|
if (/needs explicit confirmation|requires confirmation|retry with confirm|call ask_user/i.test(text) ||
|
|
@@ -2677,11 +3439,50 @@ export function classifyToolRefusal(result) {
|
|
|
2677
3439
|
return null;
|
|
2678
3440
|
}
|
|
2679
3441
|
/** Compact argument preview for the event line. */
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
3442
|
+
/**
|
|
3443
|
+
* The argument fields that say WHAT a call acts on — the useful half of a
|
|
3444
|
+
* one-field preview, in preference order.
|
|
3445
|
+
*
|
|
3446
|
+
* MEASURED (2026-10-07, live): a routing-design run printed
|
|
3447
|
+
* `⚙ write_file({confirm: true})` five times and `⚙ edit_file({allow_multiple:
|
|
3448
|
+
* false})` four times on its console. Both calls were COMPLETE — the preview
|
|
3449
|
+
* simply took the FIRST key the model happened to emit, which is a boolean
|
|
3450
|
+
* default, so the one thing a watcher needs (WHICH FILE) was invisible. A
|
|
3451
|
+
* progress line that names `confirm` instead of the path is worse than no line:
|
|
3452
|
+
* it looks like the agent is confirming something it never wrote.
|
|
3453
|
+
*
|
|
3454
|
+
* The fallback stays "first entry" so an unknown tool is still previewed rather
|
|
3455
|
+
* than blanked.
|
|
3456
|
+
*/
|
|
3457
|
+
const IDENTIFYING_ARG_KEYS = [
|
|
3458
|
+
'path',
|
|
3459
|
+
'filePath',
|
|
3460
|
+
'file',
|
|
3461
|
+
'paths',
|
|
3462
|
+
'command',
|
|
3463
|
+
'pattern',
|
|
3464
|
+
'query',
|
|
3465
|
+
'url',
|
|
3466
|
+
'goal',
|
|
3467
|
+
'name',
|
|
3468
|
+
'question',
|
|
3469
|
+
'tool',
|
|
3470
|
+
'action',
|
|
3471
|
+
'claim',
|
|
3472
|
+
];
|
|
3473
|
+
/**
|
|
3474
|
+
* A one-field preview of a tool call's arguments, preferring the IDENTIFYING
|
|
3475
|
+
* field ({@link IDENTIFYING_ARG_KEYS}) over whichever key was emitted first.
|
|
3476
|
+
*
|
|
3477
|
+
* Exported for the test that pins the preference — the bug it fixed was not a
|
|
3478
|
+
* crash but a true statement that named the wrong thing.
|
|
3479
|
+
*/
|
|
3480
|
+
export function summarizeArgs(args) {
|
|
3481
|
+
const entries = Object.entries(args);
|
|
3482
|
+
if (entries.length === 0)
|
|
2683
3483
|
return '{}';
|
|
2684
|
-
const [key
|
|
3484
|
+
const picked = IDENTIFYING_ARG_KEYS.map((k) => entries.find(([key]) => key === k)).find((e) => e !== undefined) ?? entries[0];
|
|
3485
|
+
const [key, value] = picked;
|
|
2685
3486
|
const v = typeof value === 'string' ? value : JSON.stringify(value);
|
|
2686
3487
|
return `{${key}: ${v.length > 40 ? v.slice(0, 40) + '…' : v}}`;
|
|
2687
3488
|
}
|