@cjhyy/code-shell-core 0.5.0-rc.1 → 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.
Files changed (278) hide show
  1. package/dist/agent/agent-definition-registry.d.ts +18 -3
  2. package/dist/agent/agent-definition-registry.js +47 -18
  3. package/dist/agent/agent-definition.d.ts +17 -0
  4. package/dist/agent/agent-definition.js +22 -1
  5. package/dist/arena/arena.js +11 -12
  6. package/dist/arena/context/context-tools.js +2 -4
  7. package/dist/arena/context/within-root.d.ts +7 -0
  8. package/dist/arena/context/within-root.js +15 -0
  9. package/dist/arena/iterate/phases/argue.js +8 -2
  10. package/dist/arena/iterate/phases/revise.js +1 -1
  11. package/dist/arena/iterate/phases/tournament.js +3 -3
  12. package/dist/arena/phases/adjudication.js +1 -4
  13. package/dist/arena/phases/build-consensus.js +1 -4
  14. package/dist/arena/phases/cross-review.js +2 -8
  15. package/dist/arena/phases/debate-rounds.js +1 -4
  16. package/dist/arena/phases/participant-research.js +1 -4
  17. package/dist/arena/phases/planning-detail-expansion.js +8 -10
  18. package/dist/arena/planner.js +0 -1
  19. package/dist/arena/providers/docs.d.ts +1 -0
  20. package/dist/arena/providers/docs.js +5 -2
  21. package/dist/arena/providers/repo.d.ts +1 -0
  22. package/dist/arena/providers/repo.js +5 -2
  23. package/dist/arena/strategies/utils.js +36 -3
  24. package/dist/arena/transitions.d.ts +7 -2
  25. package/dist/arena/transitions.js +9 -4
  26. package/dist/arena/types.d.ts +8 -2
  27. package/dist/automation/cron-expr.d.ts +31 -0
  28. package/dist/automation/cron-expr.js +151 -0
  29. package/dist/automation/index.d.ts +41 -0
  30. package/dist/automation/index.js +39 -0
  31. package/dist/automation/runner.d.ts +67 -0
  32. package/dist/automation/runner.js +56 -0
  33. package/dist/automation/scheduler.d.ts +156 -0
  34. package/dist/automation/scheduler.js +546 -0
  35. package/dist/automation/store.d.ts +39 -0
  36. package/dist/automation/store.js +119 -0
  37. package/dist/automation/write-policy.d.ts +34 -0
  38. package/dist/automation/write-policy.js +113 -0
  39. package/dist/automation/write-run.d.ts +51 -0
  40. package/dist/automation/write-run.js +38 -0
  41. package/dist/capability-control/index.d.ts +5 -0
  42. package/dist/capability-control/index.js +3 -0
  43. package/dist/capability-control/overlay.d.ts +49 -0
  44. package/dist/capability-control/overlay.js +89 -0
  45. package/dist/capability-control/project.d.ts +65 -0
  46. package/dist/capability-control/project.js +147 -0
  47. package/dist/capability-control/service.d.ts +70 -0
  48. package/dist/capability-control/service.js +147 -0
  49. package/dist/capability-control/types.d.ts +62 -0
  50. package/dist/capability-control/types.js +17 -0
  51. package/dist/cli/agent-server-stdio.d.ts +12 -1
  52. package/dist/cli/agent-server-stdio.js +114 -19
  53. package/dist/cli/agent-server-tcp.d.ts +20 -0
  54. package/dist/cli/agent-server-tcp.js +120 -0
  55. package/dist/cli/graceful-shutdown.d.ts +27 -0
  56. package/dist/cli/graceful-shutdown.js +30 -0
  57. package/dist/context/compaction.js +23 -15
  58. package/dist/context/manager.d.ts +0 -20
  59. package/dist/context/manager.js +0 -37
  60. package/dist/cron/cron-runtime.d.ts +2 -0
  61. package/dist/cron/cron-runtime.js +2 -0
  62. package/dist/cron/cron-store.d.ts +2 -0
  63. package/dist/cron/cron-store.js +2 -0
  64. package/dist/cron/scheduler.d.ts +5 -32
  65. package/dist/cron/scheduler.js +5 -113
  66. package/dist/data/openrouter-sync.js +1 -1
  67. package/dist/engine/engine.d.ts +208 -14
  68. package/dist/engine/engine.js +754 -169
  69. package/dist/engine/goal.d.ts +42 -0
  70. package/dist/engine/goal.js +52 -0
  71. package/dist/engine/image-compression.d.ts +81 -0
  72. package/dist/engine/image-compression.js +186 -0
  73. package/dist/engine/image-policy.d.ts +152 -0
  74. package/dist/engine/image-policy.js +165 -0
  75. package/dist/engine/reactive-threshold.d.ts +13 -0
  76. package/dist/engine/reactive-threshold.js +17 -0
  77. package/dist/engine/runtime.js +7 -0
  78. package/dist/engine/session-title.d.ts +13 -0
  79. package/dist/engine/session-title.js +34 -0
  80. package/dist/engine/turn-loop.d.ts +29 -8
  81. package/dist/engine/turn-loop.js +498 -316
  82. package/dist/git/parse-log.d.ts +13 -0
  83. package/dist/git/parse-log.js +21 -0
  84. package/dist/git/utils.d.ts +8 -6
  85. package/dist/git/utils.js +57 -26
  86. package/dist/git/worktree.js +33 -18
  87. package/dist/hooks/events.d.ts +23 -1
  88. package/dist/hooks/goal-stop-hook.d.ts +30 -0
  89. package/dist/hooks/goal-stop-hook.js +102 -0
  90. package/dist/hooks/hook-output.d.ts +33 -0
  91. package/dist/hooks/hook-output.js +79 -0
  92. package/dist/hooks/registry.d.ts +7 -0
  93. package/dist/hooks/registry.js +38 -1
  94. package/dist/hooks/shell-runner.d.ts +2 -0
  95. package/dist/hooks/shell-runner.js +43 -2
  96. package/dist/index.d.ts +31 -6
  97. package/dist/index.js +30 -3
  98. package/dist/llm/capabilities/reasoning-control.d.ts +24 -0
  99. package/dist/llm/capabilities/reasoning-control.js +38 -0
  100. package/dist/llm/capabilities/rules.js +33 -3
  101. package/dist/llm/capabilities/types.d.ts +22 -2
  102. package/dist/llm/clamp-max-tokens.d.ts +11 -0
  103. package/dist/llm/clamp-max-tokens.js +17 -0
  104. package/dist/llm/client-base.d.ts +41 -4
  105. package/dist/llm/client-base.js +67 -13
  106. package/dist/llm/client-factory.d.ts +3 -3
  107. package/dist/llm/client-factory.js +2 -2
  108. package/dist/llm/model-pool.d.ts +15 -11
  109. package/dist/llm/model-pool.js +23 -20
  110. package/dist/llm/provider-catalog.d.ts +2 -2
  111. package/dist/llm/providers/anthropic.d.ts +32 -2
  112. package/dist/llm/providers/anthropic.js +147 -8
  113. package/dist/llm/providers/openai.d.ts +11 -2
  114. package/dist/llm/providers/openai.js +253 -52
  115. package/dist/llm/reasoning-setting.d.ts +51 -0
  116. package/dist/llm/reasoning-setting.js +32 -0
  117. package/dist/llm/stop-reason.d.ts +13 -0
  118. package/dist/llm/stop-reason.js +21 -0
  119. package/dist/llm/strip-vision.d.ts +32 -0
  120. package/dist/llm/strip-vision.js +94 -0
  121. package/dist/llm/types.d.ts +5 -6
  122. package/dist/logging/logger.js +18 -4
  123. package/dist/logging/sanitize-messages.d.ts +10 -0
  124. package/dist/logging/sanitize-messages.js +90 -0
  125. package/dist/lsp/client.js +28 -8
  126. package/dist/lsp/manager.js +2 -1
  127. package/dist/lsp/root-path.d.ts +9 -0
  128. package/dist/lsp/root-path.js +12 -0
  129. package/dist/onboarding.js +18 -11
  130. package/dist/plugins/gitOps.d.ts +34 -0
  131. package/dist/plugins/gitOps.js +59 -3
  132. package/dist/plugins/installer/codex/convertAgents.d.ts +7 -0
  133. package/dist/plugins/installer/codex/convertAgents.js +55 -0
  134. package/dist/plugins/installer/codex/convertMcp.d.ts +8 -0
  135. package/dist/plugins/installer/codex/convertMcp.js +45 -0
  136. package/dist/plugins/installer/codex/convertSkills.d.ts +12 -0
  137. package/dist/plugins/installer/codex/convertSkills.js +33 -0
  138. package/dist/plugins/installer/detectFormat.d.ts +2 -0
  139. package/dist/plugins/installer/detectFormat.js +6 -0
  140. package/dist/plugins/installer/install.d.ts +7 -0
  141. package/dist/plugins/installer/install.js +99 -0
  142. package/dist/plugins/installer/installFromSource.d.ts +13 -0
  143. package/dist/plugins/installer/installFromSource.js +45 -0
  144. package/dist/plugins/installer/list.d.ts +9 -0
  145. package/dist/plugins/installer/list.js +30 -0
  146. package/dist/plugins/installer/loadPluginAgents.d.ts +3 -0
  147. package/dist/plugins/installer/loadPluginAgents.js +23 -0
  148. package/dist/plugins/installer/loadPluginMcp.d.ts +7 -0
  149. package/dist/plugins/installer/loadPluginMcp.js +74 -0
  150. package/dist/plugins/installer/parseSource.d.ts +20 -0
  151. package/dist/plugins/installer/parseSource.js +74 -0
  152. package/dist/plugins/installer/paths.d.ts +5 -0
  153. package/dist/plugins/installer/paths.js +25 -0
  154. package/dist/plugins/installer/types.d.ts +49 -0
  155. package/dist/plugins/installer/types.js +27 -0
  156. package/dist/plugins/installer/uninstall.d.ts +2 -0
  157. package/dist/plugins/installer/uninstall.js +14 -0
  158. package/dist/plugins/installer/update.d.ts +14 -0
  159. package/dist/plugins/installer/update.js +65 -0
  160. package/dist/plugins/loadPluginHooks.d.ts +8 -1
  161. package/dist/plugins/loadPluginHooks.js +11 -1
  162. package/dist/plugins/marketplaceManager.d.ts +8 -1
  163. package/dist/plugins/marketplaceManager.js +38 -4
  164. package/dist/plugins/pluginCommandHook.js +34 -0
  165. package/dist/plugins/pluginInstaller.d.ts +21 -0
  166. package/dist/plugins/pluginInstaller.js +128 -29
  167. package/dist/plugins/schemas.js +40 -6
  168. package/dist/plugins/types.d.ts +4 -0
  169. package/dist/preset/index.js +16 -0
  170. package/dist/prompt/composer.d.ts +4 -0
  171. package/dist/prompt/composer.js +28 -4
  172. package/dist/prompt/sections/orchestration.md +8 -0
  173. package/dist/protocol/chat-session-manager.d.ts +8 -1
  174. package/dist/protocol/chat-session-manager.js +10 -0
  175. package/dist/protocol/chat-session.d.ts +17 -0
  176. package/dist/protocol/chat-session.js +28 -0
  177. package/dist/protocol/redact.d.ts +50 -0
  178. package/dist/protocol/redact.js +71 -0
  179. package/dist/protocol/server.d.ts +41 -2
  180. package/dist/protocol/server.js +231 -33
  181. package/dist/protocol/tcp-transport.d.ts +45 -0
  182. package/dist/protocol/tcp-transport.js +74 -0
  183. package/dist/protocol/types.d.ts +23 -0
  184. package/dist/protocol/types.js +6 -0
  185. package/dist/remote/bridge.d.ts +11 -2
  186. package/dist/remote/bridge.js +61 -33
  187. package/dist/run/ArtifactTracker.js +5 -4
  188. package/dist/run/EngineRunner.d.ts +27 -1
  189. package/dist/run/EngineRunner.js +46 -8
  190. package/dist/run/FileRunStore.js +32 -8
  191. package/dist/run/RunLock.d.ts +28 -2
  192. package/dist/run/RunLock.js +49 -7
  193. package/dist/run/RunManager.js +51 -3
  194. package/dist/run/RunQueue.d.ts +1 -0
  195. package/dist/run/RunQueue.js +8 -2
  196. package/dist/run/factory.d.ts +7 -0
  197. package/dist/run/factory.js +1 -0
  198. package/dist/run/index.d.ts +2 -2
  199. package/dist/run/index.js +1 -1
  200. package/dist/run/redirect-target.d.ts +7 -0
  201. package/dist/run/redirect-target.js +13 -0
  202. package/dist/run/types.js +1 -1
  203. package/dist/services/browser-open.d.ts +13 -0
  204. package/dist/services/browser-open.js +16 -0
  205. package/dist/services/dream-consolidation.d.ts +57 -0
  206. package/dist/services/dream-consolidation.js +151 -0
  207. package/dist/services/memory-orchestrator.js +18 -0
  208. package/dist/services/notifier.d.ts +8 -0
  209. package/dist/services/notifier.js +39 -25
  210. package/dist/services/oauth.js +5 -7
  211. package/dist/services/session-memory-sort.d.ts +8 -0
  212. package/dist/services/session-memory-sort.js +9 -0
  213. package/dist/services/session-memory.js +8 -9
  214. package/dist/session/file-history.js +10 -3
  215. package/dist/session/session-manager.d.ts +23 -1
  216. package/dist/session/session-manager.js +73 -2
  217. package/dist/settings/disk-defaults.d.ts +35 -0
  218. package/dist/settings/disk-defaults.js +24 -0
  219. package/dist/settings/manager.d.ts +33 -0
  220. package/dist/settings/manager.js +94 -1
  221. package/dist/settings/personalization.d.ts +19 -0
  222. package/dist/settings/personalization.js +7 -0
  223. package/dist/settings/schema.d.ts +1142 -126
  224. package/dist/settings/schema.js +119 -10
  225. package/dist/tool-system/builtin/add-marketplace.d.ts +12 -0
  226. package/dist/tool-system/builtin/add-marketplace.js +76 -0
  227. package/dist/tool-system/builtin/agent-registry.d.ts +11 -0
  228. package/dist/tool-system/builtin/agent-registry.js +8 -4
  229. package/dist/tool-system/builtin/agent.d.ts +29 -10
  230. package/dist/tool-system/builtin/agent.js +108 -52
  231. package/dist/tool-system/builtin/apply-patch/index.js +22 -3
  232. package/dist/tool-system/builtin/arena.js +6 -7
  233. package/dist/tool-system/builtin/complete-goal.d.ts +25 -0
  234. package/dist/tool-system/builtin/complete-goal.js +45 -0
  235. package/dist/tool-system/builtin/cron.js +53 -9
  236. package/dist/tool-system/builtin/edit.d.ts +2 -1
  237. package/dist/tool-system/builtin/edit.js +6 -1
  238. package/dist/tool-system/builtin/generate-image.d.ts +25 -0
  239. package/dist/tool-system/builtin/generate-image.js +146 -0
  240. package/dist/tool-system/builtin/glob.js +7 -0
  241. package/dist/tool-system/builtin/grep.js +6 -0
  242. package/dist/tool-system/builtin/index.d.ts +18 -1
  243. package/dist/tool-system/builtin/index.js +62 -1
  244. package/dist/tool-system/builtin/notebook-edit.d.ts +2 -1
  245. package/dist/tool-system/builtin/notebook-edit.js +8 -1
  246. package/dist/tool-system/builtin/read.d.ts +2 -1
  247. package/dist/tool-system/builtin/read.js +9 -1
  248. package/dist/tool-system/builtin/sleep.js +9 -3
  249. package/dist/tool-system/builtin/task.js +4 -1
  250. package/dist/tool-system/builtin/update-automation-memory.d.ts +24 -0
  251. package/dist/tool-system/builtin/update-automation-memory.js +60 -0
  252. package/dist/tool-system/builtin/view-image.d.ts +18 -0
  253. package/dist/tool-system/builtin/view-image.js +96 -0
  254. package/dist/tool-system/builtin/web-search.d.ts +4 -1
  255. package/dist/tool-system/builtin/web-search.js +36 -3
  256. package/dist/tool-system/builtin/write.d.ts +2 -1
  257. package/dist/tool-system/builtin/write.js +7 -1
  258. package/dist/tool-system/context.d.ts +12 -0
  259. package/dist/tool-system/executor.d.ts +0 -1
  260. package/dist/tool-system/executor.js +34 -40
  261. package/dist/tool-system/investigation-guard.d.ts +4 -1
  262. package/dist/tool-system/investigation-guard.js +19 -3
  263. package/dist/tool-system/mcp-manager.d.ts +36 -12
  264. package/dist/tool-system/mcp-manager.js +173 -10
  265. package/dist/tool-system/path-policy.d.ts +94 -0
  266. package/dist/tool-system/path-policy.js +279 -0
  267. package/dist/tool-system/permission.js +43 -50
  268. package/dist/tool-system/plan-mode-allowlist.d.ts +21 -0
  269. package/dist/tool-system/plan-mode-allowlist.js +43 -0
  270. package/dist/tool-system/registry.js +12 -1
  271. package/dist/tool-system/validation.d.ts +10 -0
  272. package/dist/tool-system/validation.js +16 -3
  273. package/dist/types.d.ts +90 -15
  274. package/dist/utils/format.js +4 -2
  275. package/dist/utils/lockfile.js +9 -2
  276. package/dist/utils/theme.d.ts +6 -0
  277. package/dist/utils/theme.js +4 -2
  278. package/package.json +2 -1
@@ -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
- while (this.turnCount < this.config.maxTurns) {
135
- this.turnCount++;
136
- const state = initialTurnState(this.turnCount);
137
- // Per-turn correlation ID. Every log written through `tlog` (or any
138
- // child derived from it) is stamped with `turn` + `turnId`, so
139
- // `jq 'select(.turnId == "...")'` reconstructs one turn's timeline.
140
- // Span is *not* used for the loop itself because there are 6+ early
141
- // returns; instead, each return-causing branch logs its own terminal
142
- // event (model_error, completed, etc.).
143
- const turnId = newTurnId();
144
- const tlog = logger.child({ turn: this.turnCount, turnId });
145
- this.currentTurnLog = tlog;
146
- const turnStartedAt = Date.now();
147
- tlog.info("turn.start", { cat: "turn", messageCount: messages.length });
148
- // Tag downstream tool-exec / permission lines with this turn's IDs.
149
- this.deps.toolExecutor.setLogger(tlog);
150
- this.config.onStream?.({ type: "stream_request_start", turnNumber: this.turnCount });
151
- const turnStartHook = await this.emitHook("on_turn_start", {
152
- turnNumber: this.turnCount,
153
- });
154
- const turnStartInjection = wrapHookMessages(turnStartHook.messages);
155
- if (turnStartInjection) {
156
- messages.push(turnStartInjection);
157
- }
158
- // Approaching max turns: inject a warning so the model can wrap up
159
- const turnsRemaining = this.config.maxTurns - this.turnCount;
160
- if (turnsRemaining === 2) {
161
- messages.push({
162
- role: "user",
163
- content: "<system-reminder>Warning: you have only 2 turns remaining before the turn limit is reached. " +
164
- "Start wrapping up your work and prepare a summary of what you've accomplished and what remains to be done.</system-reminder>",
165
- });
166
- }
167
- else if (turnsRemaining === 0) {
168
- messages.push({
169
- role: "user",
170
- content: "<system-reminder>This is your LAST turn. You MUST respond with a final text summary now. " +
171
- "Do NOT call any tools. Summarize what you have accomplished and list any remaining work.</system-reminder>",
172
- });
173
- }
174
- // Pre-check: context management (async — may trigger LLM summarization)
175
- messages = await this.deps.contextManager.manageAsync(messages);
176
- // No pre-llm ctx emit here: the messages-only estimate would be ~16k
177
- // smaller than the real prompt (system + tools not included), making
178
- // the bar visibly drop on every submit. Post-llm/post-tool-result
179
- // events carry an accurate value; if compaction shrank the array, the
180
- // dedicated context_compact event has already informed the UI.
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 compactInjection = wrapHookMessages(compactHook.messages);
196
- if (compactInjection) {
197
- messages.push(compactInjection);
210
+ const turnStartInjection = wrapHookMessages(turnStartHook.messages);
211
+ if (turnStartInjection) {
212
+ messages.push(turnStartInjection);
198
213
  }
199
- }
200
- // Model call (with streaming fallback and max_output_tokens continuation)
201
- // Track tool IDs streamed during this turn to avoid duplicate UI events
202
- this.streamedToolIds.clear();
203
- // Streaming tool queue: start concurrency-safe tools during streaming
204
- const streamingQueue = new StreamingToolQueue(this.deps.toolExecutor);
205
- let response;
206
- try {
207
- response = await this.callModelWithFallback(messages);
208
- }
209
- catch (err) {
210
- if (err instanceof ContextLimitError) {
211
- // Progressive recovery: drop oldest API rounds, up to 3 retries
212
- const { dropOldestRounds } = await import("../context/compaction.js");
213
- let recovered = false;
214
- for (let retry = 1; retry <= 3; retry++) {
215
- tlog.warn("turn.ptl_recovery", { cat: "turn", retry, roundsToDrop: retry });
216
- messages = dropOldestRounds(messages, retry);
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
- response = await this.callModelWithFallback(messages);
219
- recovered = true;
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
- catch (retryErr) {
223
- if (!(retryErr instanceof ContextLimitError)) {
224
- this.config.onStream?.({ type: "error", error: retryErr.message });
225
- return { text: finalText, reason: "model_error", messages };
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 (!recovered) {
230
- this.patchOrphanedToolUses(messages);
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: "error",
233
- error: "Context limit exceeded after 3 recovery attempts",
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
- else {
239
- this.patchOrphanedToolUses(messages);
240
- this.config.onStream?.({ type: "error", error: err.message });
241
- return { text: finalText, reason: "model_error", messages };
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
- // UI ctx bar: prefer the provider's authoritative promptTokens.
245
- if (response.usage?.promptTokens !== undefined) {
246
- this.emitCtxFromUsage(response.usage.promptTokens, messages);
247
- }
248
- // Feed actual token usage back to the context manager so subsequent
249
- // compaction decisions use hybrid (actual + delta) estimation rather than
250
- // pure heuristics. Without this the manager falls back to char/4 estimates.
251
- if (response.usage?.promptTokens !== undefined) {
252
- this.deps.contextManager.recordActualUsage(response.usage.promptTokens, messages.length);
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
- catch {
278
- break;
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
- response = { ...response, text: combinedText };
282
- }
283
- // After any continuation, send latest usage so ctx bar reflects real context
284
- if (response.usage?.promptTokens !== undefined) {
285
- this.emitCtxFromUsage(response.usage.promptTokens, messages);
286
- }
287
- // Aborted?
288
- if (this.config.signal?.aborted) {
289
- return { text: finalText, reason: "aborted_streaming", messages };
290
- }
291
- // Accumulate text
292
- if (response.text) {
293
- finalText = response.text;
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: false,
615
+ hasToolUse: true,
616
+ toolCallCount: toolCalls.length,
305
617
  });
306
- messages.push({ role: "assistant", content: finalText });
307
- return { text: finalText, reason: "completed", messages };
308
- }
309
- // Tool execution phase
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
- outputTokens: totalOutputTokens,
373
- budget: this.config.tokenBudget,
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
- // Task guard: nudge the model if it has an in_progress task that
401
- // hasn't moved in several turns. TaskCreate is sticky in working
402
- // memory for the first few turns only; without this, the spinner
403
- // runs forever on tasks the model has mentally finished.
404
- const taskGuard = this.deps.toolExecutor.getTaskGuard();
405
- if (taskGuard) {
406
- const taskReminder = taskGuard.turnEnded(this.turnCount);
407
- if (taskReminder) {
408
- messages.push({ role: "user", content: taskReminder });
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
- duration_ms: Date.now() - turnStartedAt,
424
- outcome: "continue",
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
- if (streamingResponseTokens > 0 && streamingResponseTokens % 2000 === 0) {
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
  }