@cjhyy/code-shell-core 0.5.0-rc.0 → 0.5.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/agent-definition-registry.d.ts +29 -0
- package/dist/agent/agent-definition-registry.js +74 -0
- package/dist/agent/agent-definition.d.ts +37 -0
- package/dist/agent/agent-definition.js +59 -0
- package/dist/arena/arena.js +11 -12
- package/dist/arena/context/context-tools.js +2 -4
- package/dist/arena/context/within-root.d.ts +7 -0
- package/dist/arena/context/within-root.js +15 -0
- package/dist/arena/iterate/phases/argue.js +8 -2
- package/dist/arena/iterate/phases/revise.js +1 -1
- package/dist/arena/iterate/phases/tournament.js +3 -3
- package/dist/arena/phases/adjudication.js +1 -4
- package/dist/arena/phases/build-consensus.js +1 -4
- package/dist/arena/phases/cross-review.js +2 -8
- package/dist/arena/phases/debate-rounds.js +1 -4
- package/dist/arena/phases/participant-research.js +1 -4
- package/dist/arena/phases/planning-detail-expansion.js +8 -10
- package/dist/arena/planner.js +0 -1
- package/dist/arena/providers/docs.d.ts +1 -0
- package/dist/arena/providers/docs.js +5 -2
- package/dist/arena/providers/repo.d.ts +1 -0
- package/dist/arena/providers/repo.js +5 -2
- package/dist/arena/strategies/utils.js +36 -3
- package/dist/arena/transitions.d.ts +7 -2
- package/dist/arena/transitions.js +9 -4
- package/dist/arena/types.d.ts +8 -2
- package/dist/automation/cron-expr.d.ts +31 -0
- package/dist/automation/cron-expr.js +151 -0
- package/dist/automation/index.d.ts +41 -0
- package/dist/automation/index.js +39 -0
- package/dist/automation/runner.d.ts +67 -0
- package/dist/automation/runner.js +56 -0
- package/dist/automation/scheduler.d.ts +156 -0
- package/dist/automation/scheduler.js +546 -0
- package/dist/automation/store.d.ts +39 -0
- package/dist/automation/store.js +119 -0
- package/dist/automation/write-policy.d.ts +34 -0
- package/dist/automation/write-policy.js +113 -0
- package/dist/automation/write-run.d.ts +51 -0
- package/dist/automation/write-run.js +38 -0
- package/dist/capability-control/index.d.ts +5 -0
- package/dist/capability-control/index.js +3 -0
- package/dist/capability-control/overlay.d.ts +49 -0
- package/dist/capability-control/overlay.js +89 -0
- package/dist/capability-control/project.d.ts +65 -0
- package/dist/capability-control/project.js +147 -0
- package/dist/capability-control/service.d.ts +70 -0
- package/dist/capability-control/service.js +147 -0
- package/dist/capability-control/types.d.ts +62 -0
- package/dist/capability-control/types.js +17 -0
- package/dist/cli/agent-server-stdio.d.ts +12 -1
- package/dist/cli/agent-server-stdio.js +118 -18
- package/dist/cli/agent-server-tcp.d.ts +20 -0
- package/dist/cli/agent-server-tcp.js +120 -0
- package/dist/cli/graceful-shutdown.d.ts +27 -0
- package/dist/cli/graceful-shutdown.js +30 -0
- package/dist/context/compaction.js +23 -15
- package/dist/context/manager.d.ts +0 -20
- package/dist/context/manager.js +0 -37
- package/dist/cron/cron-runtime.d.ts +2 -0
- package/dist/cron/cron-runtime.js +2 -0
- package/dist/cron/cron-store.d.ts +2 -0
- package/dist/cron/cron-store.js +2 -0
- package/dist/cron/scheduler.d.ts +5 -32
- package/dist/cron/scheduler.js +5 -113
- package/dist/data/openrouter-sync.js +1 -1
- package/dist/engine/engine.d.ts +256 -16
- package/dist/engine/engine.js +856 -200
- package/dist/engine/goal.d.ts +42 -0
- package/dist/engine/goal.js +52 -0
- package/dist/engine/image-compression.d.ts +81 -0
- package/dist/engine/image-compression.js +186 -0
- package/dist/engine/image-policy.d.ts +152 -0
- package/dist/engine/image-policy.js +165 -0
- package/dist/engine/reactive-threshold.d.ts +13 -0
- package/dist/engine/reactive-threshold.js +17 -0
- package/dist/engine/runtime.js +7 -0
- package/dist/engine/session-title.d.ts +13 -0
- package/dist/engine/session-title.js +34 -0
- package/dist/engine/turn-loop.d.ts +29 -8
- package/dist/engine/turn-loop.js +498 -316
- package/dist/git/parse-log.d.ts +13 -0
- package/dist/git/parse-log.js +21 -0
- package/dist/git/utils.d.ts +8 -6
- package/dist/git/utils.js +57 -26
- package/dist/git/worktree.js +33 -18
- package/dist/hooks/events.d.ts +23 -1
- package/dist/hooks/goal-stop-hook.d.ts +30 -0
- package/dist/hooks/goal-stop-hook.js +102 -0
- package/dist/hooks/hook-output.d.ts +33 -0
- package/dist/hooks/hook-output.js +79 -0
- package/dist/hooks/registry.d.ts +7 -0
- package/dist/hooks/registry.js +38 -1
- package/dist/hooks/shell-runner.d.ts +2 -0
- package/dist/hooks/shell-runner.js +43 -2
- package/dist/index.d.ts +32 -7
- package/dist/index.js +30 -3
- package/dist/llm/capabilities/reasoning-control.d.ts +24 -0
- package/dist/llm/capabilities/reasoning-control.js +38 -0
- package/dist/llm/capabilities/rules.js +33 -3
- package/dist/llm/capabilities/types.d.ts +22 -2
- package/dist/llm/clamp-max-tokens.d.ts +11 -0
- package/dist/llm/clamp-max-tokens.js +17 -0
- package/dist/llm/client-base.d.ts +41 -4
- package/dist/llm/client-base.js +67 -13
- package/dist/llm/client-factory.d.ts +3 -3
- package/dist/llm/client-factory.js +2 -2
- package/dist/llm/model-pool.d.ts +15 -11
- package/dist/llm/model-pool.js +23 -20
- package/dist/llm/provider-catalog.d.ts +2 -2
- package/dist/llm/providers/anthropic.d.ts +32 -2
- package/dist/llm/providers/anthropic.js +147 -8
- package/dist/llm/providers/openai.d.ts +11 -2
- package/dist/llm/providers/openai.js +253 -52
- package/dist/llm/reasoning-setting.d.ts +51 -0
- package/dist/llm/reasoning-setting.js +32 -0
- package/dist/llm/stop-reason.d.ts +13 -0
- package/dist/llm/stop-reason.js +21 -0
- package/dist/llm/strip-vision.d.ts +32 -0
- package/dist/llm/strip-vision.js +94 -0
- package/dist/llm/types.d.ts +5 -6
- package/dist/logging/logger.js +18 -4
- package/dist/logging/sanitize-messages.d.ts +10 -0
- package/dist/logging/sanitize-messages.js +90 -0
- package/dist/lsp/client.js +28 -8
- package/dist/lsp/manager.js +2 -1
- package/dist/lsp/root-path.d.ts +9 -0
- package/dist/lsp/root-path.js +12 -0
- package/dist/onboarding.js +18 -11
- package/dist/plugins/gitOps.d.ts +34 -0
- package/dist/plugins/gitOps.js +59 -3
- package/dist/plugins/installer/codex/convertAgents.d.ts +7 -0
- package/dist/plugins/installer/codex/convertAgents.js +55 -0
- package/dist/plugins/installer/codex/convertMcp.d.ts +8 -0
- package/dist/plugins/installer/codex/convertMcp.js +45 -0
- package/dist/plugins/installer/codex/convertSkills.d.ts +12 -0
- package/dist/plugins/installer/codex/convertSkills.js +33 -0
- package/dist/plugins/installer/detectFormat.d.ts +2 -0
- package/dist/plugins/installer/detectFormat.js +6 -0
- package/dist/plugins/installer/install.d.ts +7 -0
- package/dist/plugins/installer/install.js +99 -0
- package/dist/plugins/installer/installFromSource.d.ts +13 -0
- package/dist/plugins/installer/installFromSource.js +45 -0
- package/dist/plugins/installer/list.d.ts +9 -0
- package/dist/plugins/installer/list.js +30 -0
- package/dist/plugins/installer/loadPluginAgents.d.ts +3 -0
- package/dist/plugins/installer/loadPluginAgents.js +23 -0
- package/dist/plugins/installer/loadPluginMcp.d.ts +7 -0
- package/dist/plugins/installer/loadPluginMcp.js +74 -0
- package/dist/plugins/installer/parseSource.d.ts +20 -0
- package/dist/plugins/installer/parseSource.js +74 -0
- package/dist/plugins/installer/paths.d.ts +5 -0
- package/dist/plugins/installer/paths.js +25 -0
- package/dist/plugins/installer/types.d.ts +49 -0
- package/dist/plugins/installer/types.js +27 -0
- package/dist/plugins/installer/uninstall.d.ts +2 -0
- package/dist/plugins/installer/uninstall.js +14 -0
- package/dist/plugins/installer/update.d.ts +14 -0
- package/dist/plugins/installer/update.js +65 -0
- package/dist/plugins/loadPluginHooks.d.ts +8 -1
- package/dist/plugins/loadPluginHooks.js +11 -1
- package/dist/plugins/marketplaceManager.d.ts +8 -1
- package/dist/plugins/marketplaceManager.js +38 -4
- package/dist/plugins/pluginCommandHook.js +34 -0
- package/dist/plugins/pluginInstaller.d.ts +21 -0
- package/dist/plugins/pluginInstaller.js +128 -29
- package/dist/plugins/schemas.js +40 -6
- package/dist/plugins/types.d.ts +4 -0
- package/dist/preset/index.js +16 -0
- package/dist/prompt/composer.d.ts +10 -0
- package/dist/prompt/composer.js +29 -4
- package/dist/prompt/sections/orchestration.md +8 -0
- package/dist/protocol/chat-session-manager.d.ts +8 -1
- package/dist/protocol/chat-session-manager.js +10 -0
- package/dist/protocol/chat-session.d.ts +17 -0
- package/dist/protocol/chat-session.js +28 -0
- package/dist/protocol/redact.d.ts +50 -0
- package/dist/protocol/redact.js +71 -0
- package/dist/protocol/server.d.ts +41 -2
- package/dist/protocol/server.js +231 -33
- package/dist/protocol/tcp-transport.d.ts +45 -0
- package/dist/protocol/tcp-transport.js +74 -0
- package/dist/protocol/types.d.ts +23 -0
- package/dist/protocol/types.js +6 -0
- package/dist/remote/bridge.d.ts +11 -2
- package/dist/remote/bridge.js +61 -33
- package/dist/run/ArtifactTracker.js +5 -4
- package/dist/run/EngineRunner.d.ts +27 -1
- package/dist/run/EngineRunner.js +46 -8
- package/dist/run/FileRunStore.js +32 -8
- package/dist/run/RunLock.d.ts +28 -2
- package/dist/run/RunLock.js +49 -7
- package/dist/run/RunManager.js +51 -3
- package/dist/run/RunQueue.d.ts +1 -0
- package/dist/run/RunQueue.js +8 -2
- package/dist/run/factory.d.ts +7 -0
- package/dist/run/factory.js +1 -0
- package/dist/run/index.d.ts +2 -2
- package/dist/run/index.js +1 -1
- package/dist/run/redirect-target.d.ts +7 -0
- package/dist/run/redirect-target.js +13 -0
- package/dist/run/types.js +1 -1
- package/dist/services/browser-open.d.ts +13 -0
- package/dist/services/browser-open.js +16 -0
- package/dist/services/dream-consolidation.d.ts +57 -0
- package/dist/services/dream-consolidation.js +151 -0
- package/dist/services/memory-orchestrator.js +18 -0
- package/dist/services/notifier.d.ts +8 -0
- package/dist/services/notifier.js +39 -25
- package/dist/services/oauth.js +5 -7
- package/dist/services/session-memory-sort.d.ts +8 -0
- package/dist/services/session-memory-sort.js +9 -0
- package/dist/services/session-memory.js +8 -9
- package/dist/session/file-history.js +10 -3
- package/dist/session/session-manager.d.ts +23 -1
- package/dist/session/session-manager.js +73 -2
- package/dist/settings/disk-defaults.d.ts +35 -0
- package/dist/settings/disk-defaults.js +24 -0
- package/dist/settings/manager.d.ts +45 -1
- package/dist/settings/manager.js +121 -17
- package/dist/settings/manager.test.d.ts +1 -0
- package/dist/settings/manager.test.js +73 -0
- package/dist/settings/personalization.d.ts +19 -0
- package/dist/settings/personalization.js +7 -0
- package/dist/settings/schema.d.ts +1163 -126
- package/dist/settings/schema.js +126 -10
- package/dist/skills/scanner.d.ts +13 -6
- package/dist/skills/scanner.js +23 -4
- package/dist/tool-system/builtin/add-marketplace.d.ts +12 -0
- package/dist/tool-system/builtin/add-marketplace.js +76 -0
- package/dist/tool-system/builtin/agent-registry.d.ts +15 -0
- package/dist/tool-system/builtin/agent-registry.js +14 -4
- package/dist/tool-system/builtin/agent.d.ts +55 -0
- package/dist/tool-system/builtin/agent.js +165 -17
- package/dist/tool-system/builtin/apply-patch/index.js +22 -3
- package/dist/tool-system/builtin/arena.js +6 -7
- package/dist/tool-system/builtin/complete-goal.d.ts +25 -0
- package/dist/tool-system/builtin/complete-goal.js +45 -0
- package/dist/tool-system/builtin/cron.js +53 -9
- package/dist/tool-system/builtin/edit.d.ts +2 -1
- package/dist/tool-system/builtin/edit.js +6 -1
- package/dist/tool-system/builtin/generate-image.d.ts +25 -0
- package/dist/tool-system/builtin/generate-image.js +146 -0
- package/dist/tool-system/builtin/glob.js +7 -0
- package/dist/tool-system/builtin/grep.js +6 -0
- package/dist/tool-system/builtin/index.d.ts +18 -1
- package/dist/tool-system/builtin/index.js +62 -1
- package/dist/tool-system/builtin/notebook-edit.d.ts +2 -1
- package/dist/tool-system/builtin/notebook-edit.js +8 -1
- package/dist/tool-system/builtin/read.d.ts +2 -1
- package/dist/tool-system/builtin/read.js +9 -1
- package/dist/tool-system/builtin/skill.js +23 -5
- package/dist/tool-system/builtin/sleep.js +9 -3
- package/dist/tool-system/builtin/task.js +4 -1
- package/dist/tool-system/builtin/update-automation-memory.d.ts +24 -0
- package/dist/tool-system/builtin/update-automation-memory.js +60 -0
- package/dist/tool-system/builtin/view-image.d.ts +18 -0
- package/dist/tool-system/builtin/view-image.js +96 -0
- package/dist/tool-system/builtin/web-search.d.ts +4 -1
- package/dist/tool-system/builtin/web-search.js +36 -3
- package/dist/tool-system/builtin/write.d.ts +2 -1
- package/dist/tool-system/builtin/write.js +7 -1
- package/dist/tool-system/context.d.ts +43 -0
- package/dist/tool-system/executor.d.ts +0 -1
- package/dist/tool-system/executor.js +34 -40
- package/dist/tool-system/investigation-guard.d.ts +4 -1
- package/dist/tool-system/investigation-guard.js +19 -3
- package/dist/tool-system/mcp-manager.d.ts +36 -12
- package/dist/tool-system/mcp-manager.js +173 -10
- package/dist/tool-system/path-policy.d.ts +94 -0
- package/dist/tool-system/path-policy.js +279 -0
- package/dist/tool-system/permission.js +43 -50
- package/dist/tool-system/plan-mode-allowlist.d.ts +21 -0
- package/dist/tool-system/plan-mode-allowlist.js +43 -0
- package/dist/tool-system/registry.js +12 -1
- package/dist/tool-system/validation.d.ts +10 -0
- package/dist/tool-system/validation.js +16 -3
- package/dist/types.d.ts +90 -15
- package/dist/utils/format.js +4 -2
- package/dist/utils/lockfile.js +9 -2
- package/dist/utils/theme.d.ts +6 -0
- package/dist/utils/theme.js +4 -2
- package/package.json +2 -1
package/dist/engine/turn-loop.js
CHANGED
|
@@ -11,6 +11,28 @@ import { logger } from "../logging/logger.js";
|
|
|
11
11
|
import { checkTokenBudget, createBudgetTracker } from "./token-budget.js";
|
|
12
12
|
import { StreamingToolQueue } from "./streaming-tool-queue.js";
|
|
13
13
|
import { estimateTokens } from "../context/compaction.js";
|
|
14
|
+
import { isTruncatedStop } from "../llm/stop-reason.js";
|
|
15
|
+
import { isAbortError } from "../llm/client-base.js";
|
|
16
|
+
import { crossedReactiveThreshold } from "./reactive-threshold.js";
|
|
17
|
+
import { COMPLETE_GOAL_TOOL_NAME } from "../tool-system/builtin/complete-goal.js";
|
|
18
|
+
import { createGoalBudgetTracker, recordGoalUsage, goalBudgetExceeded, } from "./goal.js";
|
|
19
|
+
/**
|
|
20
|
+
* 把一个 ToolResult 映射成发给 LLM 的 tool_result ContentBlock。
|
|
21
|
+
* 有 contentBlocks(view_image 的图片块)就原样用作 content;否则
|
|
22
|
+
* 用文本(成功用 result,失败用 "Error: ...")。抽成纯函数以便单测。
|
|
23
|
+
*/
|
|
24
|
+
export function toolResultToBlock(result) {
|
|
25
|
+
const block = {
|
|
26
|
+
type: "tool_result",
|
|
27
|
+
tool_use_id: result.id,
|
|
28
|
+
content: result.error
|
|
29
|
+
? `Error: ${result.error}`
|
|
30
|
+
: result.contentBlocks ?? (result.result ?? "(no output)"),
|
|
31
|
+
};
|
|
32
|
+
if (result.isError || result.error)
|
|
33
|
+
block.is_error = true;
|
|
34
|
+
return block;
|
|
35
|
+
}
|
|
14
36
|
export class TurnLoop {
|
|
15
37
|
deps;
|
|
16
38
|
config;
|
|
@@ -26,6 +48,12 @@ export class TurnLoop {
|
|
|
26
48
|
currentTurnLog = logger;
|
|
27
49
|
/** Last emitted ctx token estimate; used to skip no-op usage_update events. */
|
|
28
50
|
lastCtxEmit = -1;
|
|
51
|
+
/**
|
|
52
|
+
* Consecutive on_stop blocks (Goal mode kept the agent going). Reset to 0
|
|
53
|
+
* on any unblocked completion. When it reaches config.maxStopBlocks the
|
|
54
|
+
* loop forces a stop so a stuck goal can't loop forever.
|
|
55
|
+
*/
|
|
56
|
+
stopBlockCount = 0;
|
|
29
57
|
constructor(deps, config) {
|
|
30
58
|
this.deps = deps;
|
|
31
59
|
this.config = config;
|
|
@@ -131,298 +159,486 @@ export class TurnLoop {
|
|
|
131
159
|
let messages = [...initialMessages];
|
|
132
160
|
let finalText = "";
|
|
133
161
|
const budgetTracker = createBudgetTracker();
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
this.
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
// post_compact hook: ContextManager just finished a manage() pass.
|
|
182
|
-
// If any non-micro tier fired, give handlers a chance to inject a
|
|
183
|
-
// <system-reminder> ("context was compacted — recall earlier
|
|
184
|
-
// decisions from the transcript") into THIS turn before the model
|
|
185
|
-
// call. Microcompact is lossless (just clearing redundant
|
|
186
|
-
// tool_results) so we suppress hook emits for it to keep token
|
|
187
|
-
// overhead down.
|
|
188
|
-
const pending = this.deps.consumePendingCompactInfo?.();
|
|
189
|
-
if (pending && pending.strategy !== "micro") {
|
|
190
|
-
const compactHook = await this.emitHook("post_compact", {
|
|
191
|
-
strategy: pending.strategy,
|
|
192
|
-
beforeTokens: pending.before,
|
|
193
|
-
afterTokens: pending.after,
|
|
162
|
+
// Goal-mode run-scoped budget tracker (P0). Null when no goal. Stamps a
|
|
163
|
+
// wall-clock start now and accumulates prompt+completion tokens across
|
|
164
|
+
// every turn; the guardrail below force-stops the run once any configured
|
|
165
|
+
// budget is blown — the unattended-safety backstop.
|
|
166
|
+
const goalTracker = this.config.goal
|
|
167
|
+
? createGoalBudgetTracker(this.config.goal, Date.now())
|
|
168
|
+
: null;
|
|
169
|
+
// run() must never reject: the engine's post-run bookkeeping (saveState
|
|
170
|
+
// with the terminal reason, on_session_end hook) runs AFTER this call and
|
|
171
|
+
// outside the engine's try, so a throw here would leave the session
|
|
172
|
+
// frozen at status "active" on disk. Per-turn errors are already turned
|
|
173
|
+
// into return-reasons by callModelWithFallback; this outer guard catches
|
|
174
|
+
// throws from the surrounding scaffolding (contextManager.manageAsync,
|
|
175
|
+
// hook emits, guards) and surfaces them as a model_error result.
|
|
176
|
+
try {
|
|
177
|
+
while (this.turnCount < this.config.maxTurns) {
|
|
178
|
+
this.turnCount++;
|
|
179
|
+
// Abort fast-path: bail at the loop TOP before doing any per-turn work.
|
|
180
|
+
// Without this, an aborted child (parent abort, or the 30min per-call
|
|
181
|
+
// registry timeout) would run a full contextManager.manageAsync (itself
|
|
182
|
+
// an LLM summarization call) + model call + tool batch before the
|
|
183
|
+
// post-model check at the bottom of the loop noticed — exactly the
|
|
184
|
+
// sub-agent leak where a synchronous child kept burning turns/tokens for
|
|
185
|
+
// minutes after the parent Agent call already returned. The model call's
|
|
186
|
+
// own signal check only fires AFTER the call resolves; this guards the
|
|
187
|
+
// boundary between turns. (Mirrors Claude Code's query.ts, where the
|
|
188
|
+
// aborted check short-circuits before re-entering the streaming loop.)
|
|
189
|
+
if (this.config.signal?.aborted) {
|
|
190
|
+
return { text: finalText, reason: "aborted_streaming", messages };
|
|
191
|
+
}
|
|
192
|
+
const state = initialTurnState(this.turnCount);
|
|
193
|
+
// Per-turn correlation ID. Every log written through `tlog` (or any
|
|
194
|
+
// child derived from it) is stamped with `turn` + `turnId`, so
|
|
195
|
+
// `jq 'select(.turnId == "...")'` reconstructs one turn's timeline.
|
|
196
|
+
// Span is *not* used for the loop itself because there are 6+ early
|
|
197
|
+
// returns; instead, each return-causing branch logs its own terminal
|
|
198
|
+
// event (model_error, completed, etc.).
|
|
199
|
+
const turnId = newTurnId();
|
|
200
|
+
const tlog = logger.child({ turn: this.turnCount, turnId });
|
|
201
|
+
this.currentTurnLog = tlog;
|
|
202
|
+
const turnStartedAt = Date.now();
|
|
203
|
+
tlog.info("turn.start", { cat: "turn", messageCount: messages.length });
|
|
204
|
+
// Tag downstream tool-exec / permission lines with this turn's IDs.
|
|
205
|
+
this.deps.toolExecutor.setLogger(tlog);
|
|
206
|
+
this.config.onStream?.({ type: "stream_request_start", turnNumber: this.turnCount });
|
|
207
|
+
const turnStartHook = await this.emitHook("on_turn_start", {
|
|
208
|
+
turnNumber: this.turnCount,
|
|
194
209
|
});
|
|
195
|
-
const
|
|
196
|
-
if (
|
|
197
|
-
messages.push(
|
|
210
|
+
const turnStartInjection = wrapHookMessages(turnStartHook.messages);
|
|
211
|
+
if (turnStartInjection) {
|
|
212
|
+
messages.push(turnStartInjection);
|
|
198
213
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
214
|
+
// Approaching max turns: inject a warning so the model can wrap up
|
|
215
|
+
const turnsRemaining = this.config.maxTurns - this.turnCount;
|
|
216
|
+
if (turnsRemaining === 2) {
|
|
217
|
+
messages.push({
|
|
218
|
+
role: "user",
|
|
219
|
+
content: "<system-reminder>Warning: you have only 2 turns remaining before the turn limit is reached. " +
|
|
220
|
+
"Start wrapping up your work and prepare a summary of what you've accomplished and what remains to be done.</system-reminder>",
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
else if (turnsRemaining === 0) {
|
|
224
|
+
messages.push({
|
|
225
|
+
role: "user",
|
|
226
|
+
content: "<system-reminder>This is your LAST turn. You MUST respond with a final text summary now. " +
|
|
227
|
+
"Do NOT call any tools. Summarize what you have accomplished and list any remaining work.</system-reminder>",
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
// Pre-check: context management (async — may trigger LLM summarization)
|
|
231
|
+
messages = await this.deps.contextManager.manageAsync(messages);
|
|
232
|
+
// manageAsync can itself issue an LLM summarization call lasting several
|
|
233
|
+
// seconds; if the signal aborted during it, stop here rather than
|
|
234
|
+
// proceeding into the (expensive) main model call. Belt to the loop-top
|
|
235
|
+
// brace: this catches an abort that landed *inside* context management.
|
|
236
|
+
if (this.config.signal?.aborted) {
|
|
237
|
+
return { text: finalText, reason: "aborted_streaming", messages };
|
|
238
|
+
}
|
|
239
|
+
// No pre-llm ctx emit here: the messages-only estimate would be ~16k
|
|
240
|
+
// smaller than the real prompt (system + tools not included), making
|
|
241
|
+
// the bar visibly drop on every submit. Post-llm/post-tool-result
|
|
242
|
+
// events carry an accurate value; if compaction shrank the array, the
|
|
243
|
+
// dedicated context_compact event has already informed the UI.
|
|
244
|
+
// post_compact hook: ContextManager just finished a manage() pass.
|
|
245
|
+
// If any non-micro tier fired, give handlers a chance to inject a
|
|
246
|
+
// <system-reminder> ("context was compacted — recall earlier
|
|
247
|
+
// decisions from the transcript") into THIS turn before the model
|
|
248
|
+
// call. Microcompact is lossless (just clearing redundant
|
|
249
|
+
// tool_results) so we suppress hook emits for it to keep token
|
|
250
|
+
// overhead down.
|
|
251
|
+
const pending = this.deps.consumePendingCompactInfo?.();
|
|
252
|
+
if (pending && pending.strategy !== "micro") {
|
|
253
|
+
const compactHook = await this.emitHook("post_compact", {
|
|
254
|
+
strategy: pending.strategy,
|
|
255
|
+
beforeTokens: pending.before,
|
|
256
|
+
afterTokens: pending.after,
|
|
257
|
+
});
|
|
258
|
+
const compactInjection = wrapHookMessages(compactHook.messages);
|
|
259
|
+
if (compactInjection) {
|
|
260
|
+
messages.push(compactInjection);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// Model call (with streaming fallback and max_output_tokens continuation)
|
|
264
|
+
// Track tool IDs streamed during this turn to avoid duplicate UI events
|
|
265
|
+
this.streamedToolIds.clear();
|
|
266
|
+
// Streaming tool queue: start concurrency-safe tools during streaming
|
|
267
|
+
const streamingQueue = new StreamingToolQueue(this.deps.toolExecutor);
|
|
268
|
+
let response;
|
|
269
|
+
try {
|
|
270
|
+
response = await this.callModelWithFallback(messages);
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
if (err instanceof ContextLimitError) {
|
|
274
|
+
// Progressive recovery: drop oldest API rounds, up to 3 retries
|
|
275
|
+
const { dropOldestRounds } = await import("../context/compaction.js");
|
|
276
|
+
let recovered = false;
|
|
277
|
+
for (let retry = 1; retry <= 3; retry++) {
|
|
278
|
+
tlog.warn("turn.ptl_recovery", { cat: "turn", retry, roundsToDrop: retry });
|
|
279
|
+
messages = dropOldestRounds(messages, retry);
|
|
280
|
+
try {
|
|
281
|
+
response = await this.callModelWithFallback(messages);
|
|
282
|
+
recovered = true;
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
catch (retryErr) {
|
|
286
|
+
if (!(retryErr instanceof ContextLimitError)) {
|
|
287
|
+
this.config.onStream?.({ type: "error", error: retryErr.message });
|
|
288
|
+
return { text: finalText, reason: "model_error", messages };
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (!recovered) {
|
|
293
|
+
this.patchOrphanedToolUses(messages);
|
|
294
|
+
this.config.onStream?.({
|
|
295
|
+
type: "error",
|
|
296
|
+
error: "Context limit exceeded after 3 recovery attempts",
|
|
297
|
+
});
|
|
298
|
+
return { text: finalText, reason: "prompt_too_long", messages };
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
this.patchOrphanedToolUses(messages);
|
|
303
|
+
this.config.onStream?.({ type: "error", error: err.message });
|
|
304
|
+
return { text: finalText, reason: "model_error", messages };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
// UI ctx bar: prefer the provider's authoritative promptTokens.
|
|
308
|
+
if (response.usage?.promptTokens !== undefined) {
|
|
309
|
+
this.emitCtxFromUsage(response.usage.promptTokens, messages);
|
|
310
|
+
}
|
|
311
|
+
// Feed actual token usage back to the context manager so subsequent
|
|
312
|
+
// compaction decisions use hybrid (actual + delta) estimation rather than
|
|
313
|
+
// pure heuristics. Without this the manager falls back to char/4 estimates.
|
|
314
|
+
if (response.usage?.promptTokens !== undefined) {
|
|
315
|
+
this.deps.contextManager.recordActualUsage(response.usage.promptTokens, messages.length);
|
|
316
|
+
}
|
|
317
|
+
// Truncation that cut off a TOOL CALL: the model overflowed
|
|
318
|
+
// max_output_tokens mid tool-call, so the arg JSON is incomplete (e.g. a
|
|
319
|
+
// Write whose `content` was clipped, leaving file_path unset). Executing
|
|
320
|
+
// it raised a misleading "Missing required parameter: file_path". Instead,
|
|
321
|
+
// tell the model its output was truncated and let the next turn retry —
|
|
322
|
+
// bounded by the outer maxTurns loop.
|
|
323
|
+
if (isTruncatedStop(response.stopReason) && response.toolCalls.length > 0) {
|
|
324
|
+
tlog.info("turn.truncated_tool_call", {
|
|
325
|
+
cat: "turn",
|
|
326
|
+
toolCount: response.toolCalls.length,
|
|
327
|
+
});
|
|
328
|
+
if (response.text) {
|
|
329
|
+
messages.push({ role: "assistant", content: response.text });
|
|
330
|
+
}
|
|
331
|
+
messages.push({
|
|
332
|
+
role: "user",
|
|
333
|
+
content: "<system-reminder>Your previous response was truncated by the max output token limit before the tool call finished, so its arguments are incomplete. Do not assume it ran. Either retry with a smaller/more focused tool call (e.g. write the file in sections via Edit), or raise this model's maxOutputTokens.</system-reminder>",
|
|
334
|
+
});
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
// Handle max_output_tokens: if response was truncated, do continuation
|
|
338
|
+
// (up to 3 times). Truncation is reported as finish_reason "length"
|
|
339
|
+
// (OpenAI) or stop_reason "max_tokens" (Anthropic) — isTruncatedStop
|
|
340
|
+
// accepts both, so the OpenAI streaming path triggers continuation too.
|
|
341
|
+
if (isTruncatedStop(response.stopReason) &&
|
|
342
|
+
response.toolCalls.length === 0 &&
|
|
343
|
+
response.text) {
|
|
344
|
+
let combinedText = response.text;
|
|
345
|
+
for (let retry = 0; retry < 3; retry++) {
|
|
346
|
+
// Don't fire another continuation call if the user cancelled — without
|
|
347
|
+
// this an abort during a truncated response could still issue up to 3
|
|
348
|
+
// more model calls, emitting text after Stop.
|
|
349
|
+
if (this.config.signal?.aborted)
|
|
350
|
+
break;
|
|
351
|
+
tlog.info("turn.max_tokens_continuation", { cat: "turn", retry: retry + 1 });
|
|
352
|
+
const contMessages = [
|
|
353
|
+
...messages,
|
|
354
|
+
{ role: "assistant", content: combinedText },
|
|
355
|
+
{
|
|
356
|
+
role: "user",
|
|
357
|
+
content: "<system-reminder>Your previous response was truncated due to length. Please continue from where you left off.</system-reminder>",
|
|
358
|
+
},
|
|
359
|
+
];
|
|
217
360
|
try {
|
|
218
|
-
|
|
219
|
-
|
|
361
|
+
const contResponse = await this.deps.model.call(this.deps.systemPrompt, contMessages, this.deps.tools, this.config.onStream, this.config.signal);
|
|
362
|
+
combinedText += contResponse.text;
|
|
363
|
+
if (!isTruncatedStop(contResponse.stopReason) || contResponse.toolCalls.length > 0) {
|
|
364
|
+
response = { ...contResponse, text: combinedText };
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
220
369
|
break;
|
|
221
370
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
371
|
+
}
|
|
372
|
+
response = { ...response, text: combinedText };
|
|
373
|
+
}
|
|
374
|
+
// After any continuation, send latest usage so ctx bar reflects real context
|
|
375
|
+
if (response.usage?.promptTokens !== undefined) {
|
|
376
|
+
this.emitCtxFromUsage(response.usage.promptTokens, messages);
|
|
377
|
+
}
|
|
378
|
+
// Goal-mode run-scoped accounting: add this turn's total token usage
|
|
379
|
+
// (prompt + completion) to the running total. Done after continuation so
|
|
380
|
+
// continued output is counted against the budget.
|
|
381
|
+
if (goalTracker && response.usage) {
|
|
382
|
+
const used = (response.usage.promptTokens ?? 0) + (response.usage.completionTokens ?? 0);
|
|
383
|
+
recordGoalUsage(goalTracker, used);
|
|
384
|
+
}
|
|
385
|
+
// Aborted?
|
|
386
|
+
if (this.config.signal?.aborted) {
|
|
387
|
+
return { text: finalText, reason: "aborted_streaming", messages };
|
|
388
|
+
}
|
|
389
|
+
// Accumulate text
|
|
390
|
+
if (response.text) {
|
|
391
|
+
finalText = response.text;
|
|
392
|
+
}
|
|
393
|
+
// Goal budget guardrail (P0): a run that has blown its token/time budget
|
|
394
|
+
// is force-stopped regardless of what the model wants to do next (stop OR
|
|
395
|
+
// continue with tool calls). This is the unattended-safety backstop, so
|
|
396
|
+
// it sits BEFORE the "tool calls?" branch — both paths pass the gate.
|
|
397
|
+
if (goalTracker && goalBudgetExceeded(goalTracker, Date.now())) {
|
|
398
|
+
tlog.info("turn.goal_budget_exhausted", {
|
|
399
|
+
cat: "goal",
|
|
400
|
+
tokensUsed: goalTracker.tokensUsed,
|
|
401
|
+
tokenBudget: this.config.goal?.tokenBudget,
|
|
402
|
+
timeBudgetMs: this.config.goal?.timeBudgetMs,
|
|
403
|
+
});
|
|
404
|
+
this.config.onStream?.({
|
|
405
|
+
type: "assistant_message",
|
|
406
|
+
message: {
|
|
407
|
+
role: "assistant",
|
|
408
|
+
content: "(Goal 预算已耗尽,强制停止。)",
|
|
409
|
+
},
|
|
410
|
+
});
|
|
411
|
+
return { text: finalText, reason: "goal_budget_exhausted", messages };
|
|
412
|
+
}
|
|
413
|
+
// Post-check: tool calls?
|
|
414
|
+
if (response.toolCalls.length === 0) {
|
|
415
|
+
// No tool use — final answer
|
|
416
|
+
this.config.onStream?.({
|
|
417
|
+
type: "assistant_message",
|
|
418
|
+
message: { role: "assistant", content: finalText },
|
|
419
|
+
});
|
|
420
|
+
await this.emitHook("on_turn_end", {
|
|
421
|
+
turnNumber: this.turnCount,
|
|
422
|
+
hasToolUse: false,
|
|
423
|
+
});
|
|
424
|
+
messages.push({ role: "assistant", content: finalText });
|
|
425
|
+
// on_stop seam: the model wants to stop. Give handlers (Goal mode)
|
|
426
|
+
// a chance to BLOCK termination and keep the agent working. A
|
|
427
|
+
// handler returning continueSession=true injects its messages and
|
|
428
|
+
// we run another turn instead of returning. Bounded by
|
|
429
|
+
// maxStopBlocks (consecutive) and the outer maxTurns ceiling.
|
|
430
|
+
const maxStopBlocks = this.config.maxStopBlocks ?? 8;
|
|
431
|
+
const stopHook = await this.emitHook("on_stop", {
|
|
432
|
+
goal: this.config.goal,
|
|
433
|
+
finalText,
|
|
434
|
+
turnCount: this.turnCount,
|
|
435
|
+
});
|
|
436
|
+
// The judge's structured verdict (set by GoalStopHook in result.data)
|
|
437
|
+
// rides back here so we can show goal progress WITHOUT a second LLM
|
|
438
|
+
// call — `gaps` is whatever the judge already computed.
|
|
439
|
+
const goalVerdict = stopHook.data?.goalVerdict;
|
|
440
|
+
if (stopHook.continueSession && this.stopBlockCount < maxStopBlocks) {
|
|
441
|
+
this.stopBlockCount++;
|
|
442
|
+
// Goal visibility: one not_met marker per re-prompt. round counts
|
|
443
|
+
// up with stopBlockCount so the UI can show "第 N 轮".
|
|
444
|
+
this.config.onStream?.({
|
|
445
|
+
type: "goal_progress",
|
|
446
|
+
status: "not_met",
|
|
447
|
+
round: this.stopBlockCount,
|
|
448
|
+
gaps: goalVerdict?.gaps || undefined,
|
|
449
|
+
});
|
|
450
|
+
const injection = wrapHookMessages(stopHook.messages);
|
|
451
|
+
if (injection) {
|
|
452
|
+
messages.push(injection);
|
|
453
|
+
}
|
|
454
|
+
else {
|
|
455
|
+
// No guidance from the handler — inject a generic nudge so the
|
|
456
|
+
// model knows it must keep going rather than re-emitting the
|
|
457
|
+
// same final answer.
|
|
458
|
+
messages.push({
|
|
459
|
+
role: "user",
|
|
460
|
+
content: "<system-reminder>The goal is not yet complete. Continue working toward it.</system-reminder>",
|
|
461
|
+
});
|
|
227
462
|
}
|
|
463
|
+
tlog.info("turn.stop_blocked", {
|
|
464
|
+
cat: "turn",
|
|
465
|
+
stopBlockCount: this.stopBlockCount,
|
|
466
|
+
maxStopBlocks,
|
|
467
|
+
hasGuidance: !!injection,
|
|
468
|
+
});
|
|
469
|
+
continue;
|
|
228
470
|
}
|
|
229
|
-
if (
|
|
230
|
-
|
|
471
|
+
if (stopHook.continueSession && this.stopBlockCount >= maxStopBlocks) {
|
|
472
|
+
// Cap hit: stop anyway, but tell the user why we're not looping
|
|
473
|
+
// forever on an unsatisfiable goal.
|
|
474
|
+
tlog.info("turn.stop_block_cap", {
|
|
475
|
+
cat: "turn",
|
|
476
|
+
stopBlockCount: this.stopBlockCount,
|
|
477
|
+
maxStopBlocks,
|
|
478
|
+
});
|
|
231
479
|
this.config.onStream?.({
|
|
232
|
-
type: "
|
|
233
|
-
|
|
480
|
+
type: "goal_progress",
|
|
481
|
+
status: "exhausted",
|
|
482
|
+
round: this.stopBlockCount,
|
|
483
|
+
});
|
|
484
|
+
this.config.onStream?.({
|
|
485
|
+
type: "assistant_message",
|
|
486
|
+
message: {
|
|
487
|
+
role: "assistant",
|
|
488
|
+
content: `(Goal 续跑已达 ${maxStopBlocks} 次上限,先停下。)`,
|
|
489
|
+
},
|
|
234
490
|
});
|
|
235
|
-
return { text: finalText, reason: "prompt_too_long", messages };
|
|
236
491
|
}
|
|
492
|
+
else if (this.config.goal && goalVerdict?.met) {
|
|
493
|
+
// Goal run completed cleanly: the judge says met. round = total
|
|
494
|
+
// rounds = prior blocks + this accepted final round.
|
|
495
|
+
this.config.onStream?.({
|
|
496
|
+
type: "goal_progress",
|
|
497
|
+
status: "met",
|
|
498
|
+
round: this.stopBlockCount + 1,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
this.stopBlockCount = 0;
|
|
502
|
+
return { text: finalText, reason: "completed", messages };
|
|
237
503
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
504
|
+
// Tool execution phase
|
|
505
|
+
tlog.info("turn.tool_use", { cat: "turn", tools: response.toolCalls.map((t) => t.toolName) });
|
|
506
|
+
const toolCalls = response.toolCalls.slice(0, this.config.maxToolCallsPerTurn);
|
|
507
|
+
// Add assistant message with tool_use blocks to messages
|
|
508
|
+
const assistantBlocks = [];
|
|
509
|
+
if (response.text) {
|
|
510
|
+
assistantBlocks.push({ type: "text", text: response.text });
|
|
242
511
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
}
|
|
254
|
-
// Handle max_output_tokens: if response was truncated, do continuation (up to 3 times)
|
|
255
|
-
if (response.stopReason === "max_tokens" &&
|
|
256
|
-
response.toolCalls.length === 0 &&
|
|
257
|
-
response.text) {
|
|
258
|
-
let combinedText = response.text;
|
|
259
|
-
for (let retry = 0; retry < 3; retry++) {
|
|
260
|
-
tlog.info("turn.max_tokens_continuation", { cat: "turn", retry: retry + 1 });
|
|
261
|
-
const contMessages = [
|
|
262
|
-
...messages,
|
|
263
|
-
{ role: "assistant", content: combinedText },
|
|
264
|
-
{
|
|
265
|
-
role: "user",
|
|
266
|
-
content: "<system-reminder>Your previous response was truncated due to length. Please continue from where you left off.</system-reminder>",
|
|
267
|
-
},
|
|
268
|
-
];
|
|
269
|
-
try {
|
|
270
|
-
const contResponse = await this.deps.model.call(this.deps.systemPrompt, contMessages, this.deps.tools, this.config.onStream, this.config.signal);
|
|
271
|
-
combinedText += contResponse.text;
|
|
272
|
-
if (contResponse.stopReason !== "max_tokens" || contResponse.toolCalls.length > 0) {
|
|
273
|
-
response = { ...contResponse, text: combinedText };
|
|
274
|
-
break;
|
|
275
|
-
}
|
|
512
|
+
for (const tc of toolCalls) {
|
|
513
|
+
assistantBlocks.push({
|
|
514
|
+
type: "tool_use",
|
|
515
|
+
id: tc.id,
|
|
516
|
+
name: tc.toolName,
|
|
517
|
+
input: tc.args,
|
|
518
|
+
});
|
|
519
|
+
// Only emit tool_use_start if not already emitted during streaming
|
|
520
|
+
if (!this.streamedToolIds.has(tc.id)) {
|
|
521
|
+
this.config.onStream?.({ type: "tool_use_start", toolCall: tc });
|
|
276
522
|
}
|
|
277
|
-
|
|
278
|
-
|
|
523
|
+
// Record in transcript
|
|
524
|
+
this.deps.transcript.appendToolUse(tc.toolName, tc.id, tc.args);
|
|
525
|
+
}
|
|
526
|
+
messages.push({ role: "assistant", content: assistantBlocks });
|
|
527
|
+
// Execute tools — enqueue concurrency-safe tools for early start,
|
|
528
|
+
// drain remaining (unsafe) tools sequentially.
|
|
529
|
+
for (const tc of toolCalls) {
|
|
530
|
+
streamingQueue.enqueue(tc);
|
|
531
|
+
}
|
|
532
|
+
const results = await streamingQueue.drain();
|
|
533
|
+
// Record results in transcript and stream
|
|
534
|
+
const resultBlocks = [];
|
|
535
|
+
for (const result of results) {
|
|
536
|
+
resultBlocks.push(toolResultToBlock(result));
|
|
537
|
+
this.deps.transcript.appendToolResult(result.id, result.toolName, result.result, result.error);
|
|
538
|
+
this.config.onStream?.({ type: "tool_result", result });
|
|
539
|
+
}
|
|
540
|
+
// Fire-and-forget tool use summary (non-blocking)
|
|
541
|
+
if (this.config.onStream) {
|
|
542
|
+
import("./tool-summary.js").then(({ generateToolUseSummary }) => {
|
|
543
|
+
if (!this.deps.model.summarize)
|
|
544
|
+
return;
|
|
545
|
+
generateToolUseSummary(toolCalls, results, this.deps.model.summarize).then((summary) => {
|
|
546
|
+
if (summary) {
|
|
547
|
+
this.config.onStream?.({ type: "tool_summary", summary });
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
messages.push({ role: "user", content: resultBlocks });
|
|
553
|
+
// Tool results just pushed; recompute ctx so the bar updates *before*
|
|
554
|
+
// the next model round-trip — large tool outputs can move it sharply.
|
|
555
|
+
this.emitCtxFromMessages(messages);
|
|
556
|
+
// Goal mode P0: explicit completion. If the model called complete_goal,
|
|
557
|
+
// it has DECLARED the goal done — short-circuit to "completed" WITHOUT
|
|
558
|
+
// running the judge hook. The tool's result is already in `messages`
|
|
559
|
+
// above so the summary lands in the transcript. Reset the stop-block
|
|
560
|
+
// counter so a prior judge-driven block streak doesn't leak out.
|
|
561
|
+
if (goalTracker && toolCalls.some((tc) => tc.toolName === COMPLETE_GOAL_TOOL_NAME)) {
|
|
562
|
+
tlog.info("turn.goal_self_reported_complete", { cat: "goal" });
|
|
563
|
+
this.stopBlockCount = 0;
|
|
564
|
+
return { text: finalText, reason: "completed", messages };
|
|
565
|
+
}
|
|
566
|
+
// Token budget check
|
|
567
|
+
const totalOutputTokens = this.deps.model.getOutputTokens?.() ?? 0;
|
|
568
|
+
const budgetDecision = checkTokenBudget(totalOutputTokens, this.config.tokenBudget ?? Infinity, budgetTracker);
|
|
569
|
+
if (budgetDecision === "stop") {
|
|
570
|
+
tlog.info("turn.budget_stop", {
|
|
571
|
+
cat: "turn",
|
|
572
|
+
outputTokens: totalOutputTokens,
|
|
573
|
+
budget: this.config.tokenBudget,
|
|
574
|
+
});
|
|
575
|
+
this.config.onStream?.({
|
|
576
|
+
type: "assistant_message",
|
|
577
|
+
message: { role: "assistant", content: finalText },
|
|
578
|
+
});
|
|
579
|
+
messages.push({ role: "assistant", content: finalText });
|
|
580
|
+
return { text: finalText, reason: "completed", messages };
|
|
581
|
+
}
|
|
582
|
+
if (budgetDecision === "nudge") {
|
|
583
|
+
messages.push({
|
|
584
|
+
role: "user",
|
|
585
|
+
content: "<system-reminder>You are approaching the token budget limit. Please start wrapping up your work and provide a summary.</system-reminder>",
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
// Investigation guard: end-of-turn check. If too many consecutive
|
|
589
|
+
// read-only turns went by without any user-visible text or side-effecting
|
|
590
|
+
// tool, inject a reminder that will land at the top of the next turn.
|
|
591
|
+
const guard = this.deps.toolExecutor.getInvestigationGuard();
|
|
592
|
+
if (guard) {
|
|
593
|
+
guard.noteText(response.text);
|
|
594
|
+
const turnReminder = guard.turnEnded(this.turnCount);
|
|
595
|
+
if (turnReminder) {
|
|
596
|
+
messages.push({ role: "user", content: turnReminder });
|
|
597
|
+
tlog.info("guard.silent_turn", { cat: "guard", turn: this.turnCount });
|
|
279
598
|
}
|
|
280
599
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
this.
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
}
|
|
295
|
-
// Post-check: tool calls?
|
|
296
|
-
if (response.toolCalls.length === 0) {
|
|
297
|
-
// No tool use — final answer
|
|
298
|
-
this.config.onStream?.({
|
|
299
|
-
type: "assistant_message",
|
|
300
|
-
message: { role: "assistant", content: finalText },
|
|
301
|
-
});
|
|
600
|
+
// Task guard: nudge the model if it has an in_progress task that
|
|
601
|
+
// hasn't moved in several turns. TaskCreate is sticky in working
|
|
602
|
+
// memory for the first few turns only; without this, the spinner
|
|
603
|
+
// runs forever on tasks the model has mentally finished.
|
|
604
|
+
const taskGuard = this.deps.toolExecutor.getTaskGuard();
|
|
605
|
+
if (taskGuard) {
|
|
606
|
+
const taskReminder = taskGuard.turnEnded(this.turnCount);
|
|
607
|
+
if (taskReminder) {
|
|
608
|
+
messages.push({ role: "user", content: taskReminder });
|
|
609
|
+
tlog.info("guard.stale_task", { cat: "guard", turn: this.turnCount });
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
// Hook: turn end
|
|
302
613
|
await this.emitHook("on_turn_end", {
|
|
303
614
|
turnNumber: this.turnCount,
|
|
304
|
-
hasToolUse:
|
|
615
|
+
hasToolUse: true,
|
|
616
|
+
toolCallCount: toolCalls.length,
|
|
305
617
|
});
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
tlog.info("turn.tool_use", { cat: "turn", tools: response.toolCalls.map((t) => t.toolName) });
|
|
311
|
-
const toolCalls = response.toolCalls.slice(0, this.config.maxToolCallsPerTurn);
|
|
312
|
-
// Add assistant message with tool_use blocks to messages
|
|
313
|
-
const assistantBlocks = [];
|
|
314
|
-
if (response.text) {
|
|
315
|
-
assistantBlocks.push({ type: "text", text: response.text });
|
|
316
|
-
}
|
|
317
|
-
for (const tc of toolCalls) {
|
|
318
|
-
assistantBlocks.push({
|
|
319
|
-
type: "tool_use",
|
|
320
|
-
id: tc.id,
|
|
321
|
-
name: tc.toolName,
|
|
322
|
-
input: tc.args,
|
|
323
|
-
});
|
|
324
|
-
// Only emit tool_use_start if not already emitted during streaming
|
|
325
|
-
if (!this.streamedToolIds.has(tc.id)) {
|
|
326
|
-
this.config.onStream?.({ type: "tool_use_start", toolCall: tc });
|
|
327
|
-
}
|
|
328
|
-
// Record in transcript
|
|
329
|
-
this.deps.transcript.appendToolUse(tc.toolName, tc.id, tc.args);
|
|
330
|
-
}
|
|
331
|
-
messages.push({ role: "assistant", content: assistantBlocks });
|
|
332
|
-
// Execute tools — enqueue concurrency-safe tools for early start,
|
|
333
|
-
// drain remaining (unsafe) tools sequentially.
|
|
334
|
-
for (const tc of toolCalls) {
|
|
335
|
-
streamingQueue.enqueue(tc);
|
|
336
|
-
}
|
|
337
|
-
const results = await streamingQueue.drain();
|
|
338
|
-
// Record results in transcript and stream
|
|
339
|
-
const resultBlocks = [];
|
|
340
|
-
for (const result of results) {
|
|
341
|
-
const content = result.error ? `Error: ${result.error}` : (result.result ?? "(no output)");
|
|
342
|
-
resultBlocks.push({
|
|
343
|
-
type: "tool_result",
|
|
344
|
-
tool_use_id: result.id,
|
|
345
|
-
content,
|
|
346
|
-
});
|
|
347
|
-
this.deps.transcript.appendToolResult(result.id, result.toolName, result.result, result.error);
|
|
348
|
-
this.config.onStream?.({ type: "tool_result", result });
|
|
349
|
-
}
|
|
350
|
-
// Fire-and-forget tool use summary (non-blocking)
|
|
351
|
-
if (this.config.onStream) {
|
|
352
|
-
import("./tool-summary.js").then(({ generateToolUseSummary }) => {
|
|
353
|
-
if (!this.deps.model.summarize)
|
|
354
|
-
return;
|
|
355
|
-
generateToolUseSummary(toolCalls, results, this.deps.model.summarize).then((summary) => {
|
|
356
|
-
if (summary) {
|
|
357
|
-
this.config.onStream?.({ type: "tool_summary", summary });
|
|
358
|
-
}
|
|
359
|
-
});
|
|
360
|
-
});
|
|
361
|
-
}
|
|
362
|
-
messages.push({ role: "user", content: resultBlocks });
|
|
363
|
-
// Tool results just pushed; recompute ctx so the bar updates *before*
|
|
364
|
-
// the next model round-trip — large tool outputs can move it sharply.
|
|
365
|
-
this.emitCtxFromMessages(messages);
|
|
366
|
-
// Token budget check
|
|
367
|
-
const totalOutputTokens = this.deps.model.getOutputTokens?.() ?? 0;
|
|
368
|
-
const budgetDecision = checkTokenBudget(totalOutputTokens, this.config.tokenBudget ?? Infinity, budgetTracker);
|
|
369
|
-
if (budgetDecision === "stop") {
|
|
370
|
-
tlog.info("turn.budget_stop", {
|
|
618
|
+
// Record turn boundary
|
|
619
|
+
this.deps.transcript.appendTurnBoundary();
|
|
620
|
+
this.config.onTurnBoundary?.(this.turnCount);
|
|
621
|
+
tlog.info("turn.end", {
|
|
371
622
|
cat: "turn",
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
});
|
|
375
|
-
this.config.onStream?.({
|
|
376
|
-
type: "assistant_message",
|
|
377
|
-
message: { role: "assistant", content: finalText },
|
|
623
|
+
duration_ms: Date.now() - turnStartedAt,
|
|
624
|
+
outcome: "continue",
|
|
378
625
|
});
|
|
379
|
-
messages.push({ role: "assistant", content: finalText });
|
|
380
|
-
return { text: finalText, reason: "completed", messages };
|
|
381
|
-
}
|
|
382
|
-
if (budgetDecision === "nudge") {
|
|
383
|
-
messages.push({
|
|
384
|
-
role: "user",
|
|
385
|
-
content: "<system-reminder>You are approaching the token budget limit. Please start wrapping up your work and provide a summary.</system-reminder>",
|
|
386
|
-
});
|
|
387
|
-
}
|
|
388
|
-
// Investigation guard: end-of-turn check. If too many consecutive
|
|
389
|
-
// read-only turns went by without any user-visible text or side-effecting
|
|
390
|
-
// tool, inject a reminder that will land at the top of the next turn.
|
|
391
|
-
const guard = this.deps.toolExecutor.getInvestigationGuard();
|
|
392
|
-
if (guard) {
|
|
393
|
-
guard.noteText(response.text);
|
|
394
|
-
const turnReminder = guard.turnEnded(this.turnCount);
|
|
395
|
-
if (turnReminder) {
|
|
396
|
-
messages.push({ role: "user", content: turnReminder });
|
|
397
|
-
tlog.info("guard.silent_turn", { cat: "guard", turn: this.turnCount });
|
|
398
|
-
}
|
|
399
626
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
tlog.info("guard.stale_task", { cat: "guard", turn: this.turnCount });
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
// Hook: turn end
|
|
413
|
-
await this.emitHook("on_turn_end", {
|
|
414
|
-
turnNumber: this.turnCount,
|
|
415
|
-
hasToolUse: true,
|
|
416
|
-
toolCallCount: toolCalls.length,
|
|
417
|
-
});
|
|
418
|
-
// Record turn boundary
|
|
419
|
-
this.deps.transcript.appendTurnBoundary();
|
|
420
|
-
this.config.onTurnBoundary?.(this.turnCount);
|
|
421
|
-
tlog.info("turn.end", {
|
|
627
|
+
}
|
|
628
|
+
catch (err) {
|
|
629
|
+
// Unexpected throw from the per-turn scaffolding (manageAsync, hooks,
|
|
630
|
+
// guards). Patch any dangling tool_use so a later resume isn't poisoned,
|
|
631
|
+
// surface the error to the UI, and return a terminal reason so the
|
|
632
|
+
// engine's post-run saveState records model_error instead of leaving
|
|
633
|
+
// the session stuck at "active".
|
|
634
|
+
this.patchOrphanedToolUses(messages);
|
|
635
|
+
this.currentTurnLog.error("turn.unhandled_error", {
|
|
422
636
|
cat: "turn",
|
|
423
|
-
|
|
424
|
-
|
|
637
|
+
error: err.message,
|
|
638
|
+
stack: err.stack?.split("\n").slice(0, 4).join("\n"),
|
|
425
639
|
});
|
|
640
|
+
this.config.onStream?.({ type: "error", error: err.message });
|
|
641
|
+
return { text: finalText, reason: "model_error", messages };
|
|
426
642
|
}
|
|
427
643
|
// Max turns reached — do one final summarization call (no tools)
|
|
428
644
|
logger.warn("turn.max_turns_reached", {
|
|
@@ -464,6 +680,7 @@ export class TurnLoop {
|
|
|
464
680
|
async callModelWithFallback(messages) {
|
|
465
681
|
// Wrap stream callback to track tool_use_start events and reactive compaction
|
|
466
682
|
let streamingResponseTokens = 0;
|
|
683
|
+
let reactiveBucket = -1;
|
|
467
684
|
const wrappedStream = this.config.onStream
|
|
468
685
|
? (event) => {
|
|
469
686
|
if (event.type === "tool_use_start" && event.toolCall?.id) {
|
|
@@ -474,8 +691,12 @@ export class TurnLoop {
|
|
|
474
691
|
streamingResponseTokens += Math.ceil(event.text.length / 4);
|
|
475
692
|
}
|
|
476
693
|
// Reactive compaction warning: if nearing context limit mid-stream,
|
|
477
|
-
// log a warning (actual compaction happens between turns)
|
|
478
|
-
|
|
694
|
+
// log a warning (actual compaction happens between turns). Gated to
|
|
695
|
+
// fire once per 2000-token bucket crossed — the old `% 2000 === 0`
|
|
696
|
+
// check essentially never matched the running accumulator.
|
|
697
|
+
const probe = crossedReactiveThreshold(streamingResponseTokens, reactiveBucket);
|
|
698
|
+
if (probe.crossed) {
|
|
699
|
+
reactiveBucket = probe.bucket;
|
|
479
700
|
if (this.deps.contextManager.shouldReactiveCompact(messages, streamingResponseTokens)) {
|
|
480
701
|
this.currentTurnLog.warn("turn.reactive_compact_warning", {
|
|
481
702
|
cat: "turn",
|
|
@@ -494,6 +715,12 @@ export class TurnLoop {
|
|
|
494
715
|
// If it's a context or rate limit error, don't fallback — propagate
|
|
495
716
|
if (err instanceof ContextLimitError)
|
|
496
717
|
throw err;
|
|
718
|
+
// User cancelled (ESC / Stop / run signal). Falling back to a
|
|
719
|
+
// non-streaming call here re-sends the whole request the user just
|
|
720
|
+
// aborted. Propagate so the run unwinds cleanly instead of doing
|
|
721
|
+
// more work after cancellation.
|
|
722
|
+
if (isAbortError(err))
|
|
723
|
+
throw err;
|
|
497
724
|
// Streaming might have partially emitted — send tombstone to revoke
|
|
498
725
|
this.config.onStream?.({ type: "tombstone", messageId: `turn_${this.turnCount}` });
|
|
499
726
|
this.currentTurnLog.warn("turn.streaming_fallback", {
|
|
@@ -504,51 +731,6 @@ export class TurnLoop {
|
|
|
504
731
|
return await this.deps.model.callWithoutStreaming(this.deps.systemPrompt, messages, this.deps.tools, this.config.signal);
|
|
505
732
|
}
|
|
506
733
|
}
|
|
507
|
-
/**
|
|
508
|
-
* Execute tools with overlap: start concurrent-safe (read-only) tools
|
|
509
|
-
* immediately in parallel while sequential (write) tools run one-by-one.
|
|
510
|
-
* Both groups run simultaneously — we don't wait for safe tools to finish
|
|
511
|
-
* before starting unsafe ones.
|
|
512
|
-
*/
|
|
513
|
-
async executeToolsOverlapped(calls) {
|
|
514
|
-
if (calls.length <= 1) {
|
|
515
|
-
// Single tool — no overlap needed
|
|
516
|
-
return this.deps.toolExecutor.executeAll(calls);
|
|
517
|
-
}
|
|
518
|
-
const safe = [];
|
|
519
|
-
const unsafe = [];
|
|
520
|
-
for (const call of calls) {
|
|
521
|
-
if (this.deps.toolExecutor.isConcurrencySafe(call.toolName)) {
|
|
522
|
-
safe.push(call);
|
|
523
|
-
}
|
|
524
|
-
else {
|
|
525
|
-
unsafe.push(call);
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
// If all same type, delegate directly
|
|
529
|
-
if (safe.length === 0 || unsafe.length === 0) {
|
|
530
|
-
return this.deps.toolExecutor.executeAll(calls);
|
|
531
|
-
}
|
|
532
|
-
// Run both groups simultaneously:
|
|
533
|
-
// - safe tools all in parallel
|
|
534
|
-
// - unsafe tools sequentially (but started at the same time as safe group)
|
|
535
|
-
const resultMap = new Map();
|
|
536
|
-
const safePromise = Promise.all(safe.map((c) => this.deps.toolExecutor.executeSingle(c)));
|
|
537
|
-
const unsafePromise = (async () => {
|
|
538
|
-
const results = [];
|
|
539
|
-
for (const call of unsafe) {
|
|
540
|
-
results.push(await this.deps.toolExecutor.executeSingle(call));
|
|
541
|
-
}
|
|
542
|
-
return results;
|
|
543
|
-
})();
|
|
544
|
-
const [safeResults, unsafeResults] = await Promise.all([safePromise, unsafePromise]);
|
|
545
|
-
for (const r of safeResults)
|
|
546
|
-
resultMap.set(r.id, r);
|
|
547
|
-
for (const r of unsafeResults)
|
|
548
|
-
resultMap.set(r.id, r);
|
|
549
|
-
// Return results in original call order for deterministic transcript
|
|
550
|
-
return calls.map((c) => resultMap.get(c.id));
|
|
551
|
-
}
|
|
552
734
|
get currentTurn() {
|
|
553
735
|
return this.turnCount;
|
|
554
736
|
}
|