@cjhyy/code-shell-core 0.7.0-beta.1 → 0.7.1

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 (146) hide show
  1. package/dist/arena/arena.d.ts +2 -0
  2. package/dist/arena/arena.js +30 -1
  3. package/dist/arena/phases/adjudication.d.ts +2 -1
  4. package/dist/arena/phases/adjudication.js +2 -1
  5. package/dist/arena/phases/build-consensus.d.ts +2 -1
  6. package/dist/arena/phases/build-consensus.js +3 -1
  7. package/dist/arena/phases/cross-review.d.ts +3 -1
  8. package/dist/arena/phases/cross-review.js +8 -2
  9. package/dist/arena/phases/debate-rounds.d.ts +2 -1
  10. package/dist/arena/phases/debate-rounds.js +4 -2
  11. package/dist/arena/phases/participant-research.d.ts +2 -1
  12. package/dist/arena/phases/participant-research.js +3 -1
  13. package/dist/arena/phases/planning-detail-expansion.d.ts +2 -1
  14. package/dist/arena/phases/planning-detail-expansion.js +2 -1
  15. package/dist/arena/planner.d.ts +2 -1
  16. package/dist/arena/planner.js +2 -1
  17. package/dist/arena/types.d.ts +5 -1
  18. package/dist/cc-orchestrator/agent-adapter.d.ts +2 -0
  19. package/dist/cc-orchestrator/agent-adapter.js +4 -0
  20. package/dist/cc-orchestrator/codex-session-history.d.ts +14 -1
  21. package/dist/cc-orchestrator/codex-session-history.js +64 -4
  22. package/dist/cc-orchestrator/external-agent-changes.js +22 -5
  23. package/dist/cc-orchestrator/external-agent-driver.d.ts +1 -1
  24. package/dist/cc-orchestrator/external-agent-driver.js +202 -38
  25. package/dist/cc-orchestrator/session-history.d.ts +35 -0
  26. package/dist/cc-orchestrator/session-history.js +96 -13
  27. package/dist/cli/agent-server-tcp.js +13 -2
  28. package/dist/context/manager.d.ts +3 -3
  29. package/dist/context/manager.js +6 -6
  30. package/dist/context/token-counter.js +5 -3
  31. package/dist/context/tool-result-storage.d.ts +6 -0
  32. package/dist/context/tool-result-storage.js +25 -4
  33. package/dist/credentials/access.d.ts +11 -1
  34. package/dist/credentials/access.js +77 -1
  35. package/dist/credentials/index.d.ts +3 -1
  36. package/dist/credentials/index.js +2 -0
  37. package/dist/credentials/oauth.d.ts +25 -0
  38. package/dist/credentials/oauth.js +179 -0
  39. package/dist/credentials/store.d.ts +2 -1
  40. package/dist/credentials/store.js +19 -8
  41. package/dist/credentials/types.d.ts +84 -1
  42. package/dist/credentials/types.js +16 -1
  43. package/dist/engine/engine.d.ts +67 -34
  44. package/dist/engine/engine.js +448 -247
  45. package/dist/engine/goal.d.ts +19 -0
  46. package/dist/engine/goal.js +16 -6
  47. package/dist/engine/input-attachments.js +156 -13
  48. package/dist/engine/run-image-input.d.ts +22 -0
  49. package/dist/engine/run-image-input.js +195 -0
  50. package/dist/engine/session-title.d.ts +2 -1
  51. package/dist/engine/session-title.js +4 -1
  52. package/dist/engine/steer-queue.d.ts +3 -1
  53. package/dist/engine/steer-queue.js +10 -2
  54. package/dist/engine/turn-loop.d.ts +48 -1
  55. package/dist/engine/turn-loop.js +307 -37
  56. package/dist/engine/types.d.ts +6 -2
  57. package/dist/git/worktree/crud.d.ts +3 -0
  58. package/dist/git/worktree/crud.js +32 -3
  59. package/dist/git/worktree/git-exec.d.ts +2 -2
  60. package/dist/git/worktree/git-exec.js +47 -11
  61. package/dist/git/worktree/query.d.ts +8 -7
  62. package/dist/git/worktree/query.js +27 -20
  63. package/dist/hooks/events.d.ts +3 -0
  64. package/dist/hooks/events.js +0 -3
  65. package/dist/hooks/goal-stop-hook.d.ts +44 -2
  66. package/dist/hooks/goal-stop-hook.js +775 -52
  67. package/dist/hooks/registry.js +3 -0
  68. package/dist/hooks/shell-runner.d.ts +12 -1
  69. package/dist/hooks/shell-runner.js +160 -9
  70. package/dist/index.d.ts +7 -6
  71. package/dist/index.js +6 -5
  72. package/dist/llm/client-base.js +12 -10
  73. package/dist/llm/types.d.ts +12 -5
  74. package/dist/plugins/pluginCommandHook.d.ts +4 -4
  75. package/dist/plugins/pluginCommandHook.js +111 -13
  76. package/dist/preset/index.js +14 -4
  77. package/dist/protocol/chat-session-manager.d.ts +13 -2
  78. package/dist/protocol/chat-session-manager.js +90 -18
  79. package/dist/protocol/chat-session.d.ts +12 -0
  80. package/dist/protocol/chat-session.js +30 -5
  81. package/dist/protocol/client.d.ts +5 -2
  82. package/dist/protocol/client.js +22 -1
  83. package/dist/protocol/server.d.ts +25 -11
  84. package/dist/protocol/server.js +291 -73
  85. package/dist/protocol/types.d.ts +36 -2
  86. package/dist/protocol/types.js +2 -0
  87. package/dist/services/dream-consolidation.d.ts +3 -0
  88. package/dist/services/dream-consolidation.js +4 -1
  89. package/dist/services/index.d.ts +1 -1
  90. package/dist/services/index.js +1 -1
  91. package/dist/services/oauth.d.ts +34 -10
  92. package/dist/services/oauth.js +233 -98
  93. package/dist/session/session-manager.d.ts +35 -6
  94. package/dist/session/session-manager.js +396 -27
  95. package/dist/session/transcript.d.ts +30 -1
  96. package/dist/session/transcript.js +119 -4
  97. package/dist/tool-system/builtin/agent-notifications.d.ts +11 -4
  98. package/dist/tool-system/builtin/agent-notifications.js +19 -7
  99. package/dist/tool-system/builtin/agent.js +5 -1
  100. package/dist/tool-system/builtin/arena.js +1 -0
  101. package/dist/tool-system/builtin/background-jobs.d.ts +28 -5
  102. package/dist/tool-system/builtin/background-jobs.js +109 -7
  103. package/dist/tool-system/builtin/background-work.d.ts +6 -1
  104. package/dist/tool-system/builtin/background-work.js +5 -1
  105. package/dist/tool-system/builtin/bash.d.ts +3 -5
  106. package/dist/tool-system/builtin/bash.js +10 -5
  107. package/dist/tool-system/builtin/browser-tools.d.ts +2 -2
  108. package/dist/tool-system/builtin/cron-list.definition.d.ts +3 -0
  109. package/dist/tool-system/builtin/cron-list.definition.js +6 -0
  110. package/dist/tool-system/builtin/cron.d.ts +1 -2
  111. package/dist/tool-system/builtin/cron.js +9 -7
  112. package/dist/tool-system/builtin/drive-claude-code.d.ts +7 -0
  113. package/dist/tool-system/builtin/drive-claude-code.js +307 -20
  114. package/dist/tool-system/builtin/edit.d.ts +2 -1
  115. package/dist/tool-system/builtin/edit.js +12 -4
  116. package/dist/tool-system/builtin/generate-video.d.ts +4 -0
  117. package/dist/tool-system/builtin/generate-video.js +138 -21
  118. package/dist/tool-system/builtin/glob.d.ts +2 -1
  119. package/dist/tool-system/builtin/glob.js +28 -3
  120. package/dist/tool-system/builtin/grep.d.ts +1 -0
  121. package/dist/tool-system/builtin/grep.js +82 -17
  122. package/dist/tool-system/builtin/index.d.ts +25 -11
  123. package/dist/tool-system/builtin/index.js +60 -5
  124. package/dist/tool-system/builtin/sleep.d.ts +1 -2
  125. package/dist/tool-system/builtin/sleep.definition.d.ts +8 -0
  126. package/dist/tool-system/builtin/sleep.definition.js +28 -0
  127. package/dist/tool-system/builtin/sleep.js +1 -22
  128. package/dist/tool-system/builtin/video-providers.d.ts +12 -15
  129. package/dist/tool-system/builtin/video-providers.js +1 -0
  130. package/dist/tool-system/builtin/view-image.d.ts +2 -2
  131. package/dist/tool-system/builtin/web-fetch.js +44 -3
  132. package/dist/tool-system/builtin/worktree.js +25 -7
  133. package/dist/tool-system/builtin/write.d.ts +2 -1
  134. package/dist/tool-system/builtin/write.js +14 -4
  135. package/dist/tool-system/context.d.ts +35 -5
  136. package/dist/tool-system/executor.js +24 -8
  137. package/dist/tool-system/mcp-manager.d.ts +20 -2
  138. package/dist/tool-system/mcp-manager.js +111 -12
  139. package/dist/tool-system/path-policy.d.ts +19 -0
  140. package/dist/tool-system/path-policy.js +62 -1
  141. package/dist/tool-system/permission.d.ts +43 -3
  142. package/dist/tool-system/permission.js +383 -30
  143. package/dist/tool-system/registry.d.ts +3 -2
  144. package/dist/tool-system/registry.js +52 -34
  145. package/dist/types.d.ts +38 -7
  146. package/package.json +1 -1
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { initialTurnState, newTurnId } from "./turn-state.js";
8
8
  import { formatFriendlyError } from "./friendly-error.js";
9
+ import { projectGoalJudgeToolResult, } from "../hooks/goal-stop-hook.js";
9
10
  import { wrapHookMessages } from "../hooks/inject.js";
10
11
  import { ContextLimitError } from "../exceptions.js";
11
12
  import { logger } from "../logging/logger.js";
@@ -19,7 +20,7 @@ import { COMPLETE_GOAL_TOOL_NAME } from "../tool-system/builtin/complete-goal.js
19
20
  import { CANCEL_GOAL_TOOL_NAME } from "../tool-system/builtin/cancel-goal.js";
20
21
  import { redactSensitiveToolResultsInMessages, toolResultForDisplay, toolResultTranscriptText, toolResultsForDisplay, } from "../tool-system/tool-result-redaction.js";
21
22
  import { addTokenUsage, cacheHitRateFromUsage, cumulativeCacheHitRate, } from "./session-usage.js";
22
- import { createGoalBudgetTracker, recordGoalUsage, goalBudgetExceeded, applyGoalExtension, limitProximity, GOAL_DEFAULT_MAX_STOP_BLOCKS, } from "./goal.js";
23
+ import { createGoalBudgetTracker, recordGoalUsage, goalBudgetTerminationReason, applyGoalExtension, limitProximity, GOAL_DEFAULT_MAX_STOP_BLOCKS, } from "./goal.js";
23
24
  /**
24
25
  * 把一个 ToolResult 映射成发给 LLM 的 tool_result ContentBlock。
25
26
  * 有 contentBlocks(view_image 的图片块)就原样用作 content;否则
@@ -62,6 +63,11 @@ export class TurnLoop {
62
63
  currentCumulativeUsage;
63
64
  sensitiveToolResultRedactions = new Map();
64
65
  pendingImageMessages = new Set();
66
+ volatileContextMessages = new Set();
67
+ /** Last outer-loop turn for which the model returned a complete response. */
68
+ lastCompletedModelTurn = 0;
69
+ /** Last model turn whose transcript/persistence boundary was finalized. */
70
+ lastFinalizedTurn = 0;
65
71
  /**
66
72
  * Consecutive on_stop blocks (Goal mode kept the agent going). Reset to 0
67
73
  * on any unblocked completion. When it reaches config.maxStopBlocks the
@@ -126,6 +132,20 @@ export class TurnLoop {
126
132
  }
127
133
  return { ...next, maxStopBlocks: nextCap };
128
134
  }
135
+ /**
136
+ * Private Goal-judge accounting seam. Judge calls bypass ModelFacade, so the
137
+ * Engine forwards their provider usage here while the run-scoped tracker is
138
+ * live. The returned reason lets the hook suppress verdict side effects when
139
+ * this very request crossed a hard token/time budget.
140
+ */
141
+ recordGoalJudgeUsage(usage) {
142
+ if (!this.goalTracker)
143
+ return undefined;
144
+ if (usage) {
145
+ recordGoalUsage(this.goalTracker, (usage.promptTokens ?? 0) + (usage.completionTokens ?? 0));
146
+ }
147
+ return goalBudgetTerminationReason(this.goalTracker, Date.now());
148
+ }
129
149
  /**
130
150
  * Goal mode only: if the run is nearing EITHER stop ceiling (maxTurns or
131
151
  * maxStopBlocks) and we haven't announced it yet, emit one approaching_limit
@@ -160,6 +180,9 @@ export class TurnLoop {
160
180
  for (const msg of this.config.freshImageMessages ?? []) {
161
181
  this.pendingImageMessages.add(msg);
162
182
  }
183
+ for (const msg of this.config.volatileContextMessages ?? []) {
184
+ this.volatileContextMessages.add(msg);
185
+ }
163
186
  // Wrap onStream so a single throwing handler can't silently break
164
187
  // the channel for the rest of the run. A 2026-05-25 incident saw a
165
188
  // sub-agent's events stop reaching the renderer ~23s into its run —
@@ -212,6 +235,21 @@ export class TurnLoop {
212
235
  return;
213
236
  this.deps.transcript.appendTurnStopped();
214
237
  }
238
+ /**
239
+ * Close one completed model turn exactly once. The transcript boundary must
240
+ * be durable before the Engine flushes usage, heartbeat, and turnCount via
241
+ * onTurnBoundary, so crash recovery never folds two assistant responses into
242
+ * the same transcript turn or observes state ahead of the transcript.
243
+ */
244
+ finalizeModelTurn() {
245
+ if (this.lastCompletedModelTurn !== this.turnCount ||
246
+ this.lastFinalizedTurn === this.turnCount) {
247
+ return;
248
+ }
249
+ this.lastFinalizedTurn = this.turnCount;
250
+ this.deps.transcript.appendTurnBoundary();
251
+ this.config.onTurnBoundary?.(this.turnCount);
252
+ }
215
253
  prepareMessagesForModel(messages) {
216
254
  const preserveMessages = this.pendingImageMessages.size > 0 ? this.pendingImageMessages : undefined;
217
255
  const result = downgradeImagePayloadsInHistory(messages, { preserveMessages });
@@ -224,6 +262,23 @@ export class TurnLoop {
224
262
  }
225
263
  return result.messages;
226
264
  }
265
+ stripVolatileContextMessages(messages) {
266
+ if (this.volatileContextMessages.size === 0)
267
+ return messages;
268
+ let changed = false;
269
+ const stripped = messages.filter((message) => {
270
+ const keep = !this.volatileContextMessages.has(message);
271
+ if (!keep)
272
+ changed = true;
273
+ return keep;
274
+ });
275
+ return changed ? stripped : messages;
276
+ }
277
+ appendVolatileContextMessages(messages) {
278
+ if (this.volatileContextMessages.size === 0)
279
+ return messages;
280
+ return [...this.stripVolatileContextMessages(messages), ...this.volatileContextMessages];
281
+ }
227
282
  markPendingImagesConsumed(messages) {
228
283
  if (this.pendingImageMessages.size === 0)
229
284
  return messages;
@@ -307,6 +362,7 @@ export class TurnLoop {
307
362
  recordResponseUsage(usage) {
308
363
  this.currentTurnUsage = addTokenUsage(this.currentTurnUsage, usage);
309
364
  this.currentCumulativeUsage = this.deps.recordCumulativeUsage?.(usage);
365
+ this.deps.recordCacheReadDiagnostics?.(usage);
310
366
  }
311
367
  emitCtxFromUsage(usage, messages) {
312
368
  if (!this.config.onStream)
@@ -379,13 +435,20 @@ export class TurnLoop {
379
435
  let messages = [...initialMessages];
380
436
  let finalText = "";
381
437
  const budgetTracker = createBudgetTracker();
438
+ // Recent execution evidence for the built-in Goal judge. Values enter this
439
+ // window only after irreversible sensitive-data removal, non-text omission,
440
+ // and per-item truncation at the current-round ToolResult boundary.
441
+ const goalToolResults = [];
442
+ const sensitiveGoalResultTools = this.config.goal
443
+ ? new Set(this.deps.tools.filter((tool) => tool.sensitiveResult).map((tool) => tool.name))
444
+ : undefined;
382
445
  // Goal-mode run-scoped budget tracker (P0). Null when no goal. Stamps a
383
446
  // wall-clock start now and accumulates prompt+completion tokens across
384
447
  // every turn; the guardrail below force-stops the run once any configured
385
448
  // budget is blown — the unattended-safety backstop.
386
- this.goalTracker = this.config.goal
387
- ? createGoalBudgetTracker(this.config.goal, Date.now())
388
- : null;
449
+ if (!this.goalTracker && this.config.goal) {
450
+ this.goalTracker = createGoalBudgetTracker(this.config.goal, Date.now());
451
+ }
389
452
  const goalTracker = this.goalTracker;
390
453
  // Fresh run: re-arm the approaching-limit announcement.
391
454
  this.approachAnnounced = false;
@@ -398,6 +461,25 @@ export class TurnLoop {
398
461
  // hook emits, guards) and surfaces them as a model_error result.
399
462
  try {
400
463
  while (this.turnCount < this.config.maxTurns) {
464
+ const preTurnGoalTermination = goalTracker
465
+ ? goalBudgetTerminationReason(goalTracker, Date.now())
466
+ : undefined;
467
+ if (preTurnGoalTermination) {
468
+ this.config.onStream?.({
469
+ type: "assistant_message",
470
+ message: {
471
+ role: "assistant",
472
+ content: "(Goal 预算已耗尽,强制停止。)",
473
+ },
474
+ });
475
+ messages = this.redactConsumedSensitiveToolResults(messages);
476
+ return {
477
+ text: finalText,
478
+ reason: "goal_budget_exhausted",
479
+ messages,
480
+ goalTermination: preTurnGoalTermination,
481
+ };
482
+ }
401
483
  this.turnCount++;
402
484
  this.currentTurnUsage = {
403
485
  promptTokens: 0,
@@ -425,7 +507,7 @@ export class TurnLoop {
425
507
  // they join THIS step's request — no abort, no lost in-flight work. Same
426
508
  // loop-top user-push pattern as turnStartInjection / turn-limit warnings
427
509
  // below. Push to transcript too so they persist + survive resume.
428
- this.consumeQueuedSteer(messages, "normal_step");
510
+ await this.consumeQueuedSteer(messages, "normal_step");
429
511
  const state = initialTurnState(this.turnCount);
430
512
  // Per-turn correlation ID. Every log written through `tlog` (or any
431
513
  // child derived from it) is stamped with `turn` + `turnId`, so
@@ -487,6 +569,7 @@ export class TurnLoop {
487
569
  // pendingImageMessages are preserved through this next model request.
488
570
  const hasPendingSensitiveToolResults = this.sensitiveToolResultRedactions.size > 0;
489
571
  messages = this.prepareMessagesForModel(messages);
572
+ messages = this.stripVolatileContextMessages(messages);
490
573
  if (hasPendingSensitiveToolResults) {
491
574
  tlog.info("turn.sensitive_tool_result_context_management_skipped", {
492
575
  cat: "turn",
@@ -495,7 +578,7 @@ export class TurnLoop {
495
578
  }
496
579
  else {
497
580
  // Context management (async — may trigger LLM summarization)
498
- messages = await this.deps.contextManager.manageAsync(messages);
581
+ messages = await this.deps.contextManager.manageAsync(messages, this.config.signal);
499
582
  // manageAsync can itself issue an LLM summarization call lasting several
500
583
  // seconds; if the signal aborted during it, stop here rather than
501
584
  // proceeding into the (expensive) main model call. Belt to the loop-top
@@ -531,6 +614,7 @@ export class TurnLoop {
531
614
  messages.push(compactInjection);
532
615
  }
533
616
  }
617
+ messages = this.appendVolatileContextMessages(messages);
534
618
  // Model call (with streaming fallback and max_output_tokens continuation)
535
619
  // Track tool IDs streamed during this turn to avoid duplicate UI events
536
620
  this.streamedToolIds.clear();
@@ -592,28 +676,50 @@ export class TurnLoop {
592
676
  return { text: finalText, reason: "model_error", messages };
593
677
  }
594
678
  }
679
+ this.lastCompletedModelTurn = this.turnCount;
595
680
  messages = this.redactConsumedSensitiveToolResults(messages);
596
681
  // Record the response once into current-turn and whole-session counters.
597
682
  if (response.usage?.promptTokens !== undefined) {
598
683
  this.recordResponseUsage(response.usage);
599
684
  this.emitCtxFromUsage(response.usage, messages);
600
685
  }
686
+ // Charge every provider response to the run-scoped Goal budget before
687
+ // any truncation path can retry or continue the outer turn loop.
688
+ if (goalTracker && response.usage) {
689
+ const used = (response.usage.promptTokens ?? 0) + (response.usage.completionTokens ?? 0);
690
+ recordGoalUsage(goalTracker, used);
691
+ }
692
+ let budgetTermination = goalTracker
693
+ ? goalBudgetTerminationReason(goalTracker, Date.now())
694
+ : undefined;
601
695
  // Feed actual token usage back to the context manager so subsequent
602
696
  // compaction decisions use hybrid (actual + delta) estimation rather than
603
697
  // pure heuristics. Without this the manager falls back to char/4 estimates.
604
698
  if (response.usage?.promptTokens !== undefined) {
605
- const anchor = this.deps.contextManager.recordActualUsage(response.usage.promptTokens, messages.length, messages);
699
+ const contextAnchorMessages = this.stripVolatileContextMessages(messages);
700
+ const anchor = this.deps.contextManager.recordActualUsage(response.usage.promptTokens, contextAnchorMessages.length, contextAnchorMessages);
606
701
  if (anchor)
607
702
  this.deps.recordContextUsageAnchor?.(anchor);
608
703
  }
609
704
  messages = this.markPendingImagesConsumed(messages);
705
+ // stopBlockCount guards only the no-tool final-answer loop evaluated by
706
+ // on_stop (text -> block -> text -> block). A response that structurally
707
+ // contains tool_use has left that streak, regardless of whether the call
708
+ // will execute successfully. Reset here, before truncated-tool and later
709
+ // queued-steer paths can continue the outer loop. Repeated ineffective
710
+ // tool_use turns still consume turns and are bounded by maxTurns.
711
+ if (response.toolCalls.length > 0) {
712
+ this.stopBlockCount = 0;
713
+ }
610
714
  // Truncation that cut off a TOOL CALL: the model overflowed
611
715
  // max_output_tokens mid tool-call, so the arg JSON is incomplete (e.g. a
612
716
  // Write whose `content` was clipped, leaving file_path unset). Executing
613
717
  // it raised a misleading "Missing required parameter: file_path". Instead,
614
718
  // tell the model its output was truncated and let the next turn retry —
615
719
  // bounded by the outer maxTurns loop.
616
- if (isTruncatedStop(response.stopReason) && response.toolCalls.length > 0) {
720
+ if (!budgetTermination &&
721
+ isTruncatedStop(response.stopReason) &&
722
+ response.toolCalls.length > 0) {
617
723
  tlog.info("turn.truncated_tool_call", {
618
724
  cat: "turn",
619
725
  toolCount: response.toolCalls.length,
@@ -625,13 +731,15 @@ export class TurnLoop {
625
731
  role: "user",
626
732
  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>",
627
733
  });
734
+ this.finalizeModelTurn();
628
735
  continue;
629
736
  }
630
737
  // Handle max_output_tokens: if response was truncated, do continuation
631
738
  // (up to 3 times). Truncation is reported as finish_reason "length"
632
739
  // (OpenAI) or stop_reason "max_tokens" (Anthropic) — isTruncatedStop
633
740
  // accepts both, so the OpenAI streaming path triggers continuation too.
634
- if (isTruncatedStop(response.stopReason) &&
741
+ if (!budgetTermination &&
742
+ isTruncatedStop(response.stopReason) &&
635
743
  response.toolCalls.length === 0 &&
636
744
  response.text) {
637
745
  let combinedText = response.text;
@@ -642,6 +750,11 @@ export class TurnLoop {
642
750
  // more model calls, emitting text after Stop.
643
751
  if (this.config.signal?.aborted)
644
752
  break;
753
+ budgetTermination = goalTracker
754
+ ? goalBudgetTerminationReason(goalTracker, Date.now())
755
+ : undefined;
756
+ if (budgetTermination)
757
+ break;
645
758
  tlog.info("turn.max_tokens_continuation", { cat: "turn", retry: retry + 1 });
646
759
  const contMessages = [
647
760
  ...messages,
@@ -653,18 +766,40 @@ export class TurnLoop {
653
766
  ];
654
767
  try {
655
768
  const contResponse = await this.deps.model.call(this.deps.systemPrompt, this.prepareMessagesForModel(contMessages), this.deps.tools, this.config.onStream, this.config.signal);
769
+ // Continuations are separate provider responses, so preserve the
770
+ // same structural tool_use invariant before processing this one.
771
+ if (contResponse.toolCalls.length > 0) {
772
+ this.stopBlockCount = 0;
773
+ }
656
774
  if (contResponse.usage?.promptTokens !== undefined) {
657
775
  this.recordResponseUsage(contResponse.usage);
658
776
  continuedResponse = true;
659
777
  }
778
+ if (goalTracker && contResponse.usage) {
779
+ const used = (contResponse.usage.promptTokens ?? 0) +
780
+ (contResponse.usage.completionTokens ?? 0);
781
+ recordGoalUsage(goalTracker, used);
782
+ }
783
+ budgetTermination = goalTracker
784
+ ? goalBudgetTerminationReason(goalTracker, Date.now())
785
+ : undefined;
660
786
  combinedText += contResponse.text;
661
- if (!isTruncatedStop(contResponse.stopReason) || contResponse.toolCalls.length > 0) {
787
+ if (budgetTermination ||
788
+ !isTruncatedStop(contResponse.stopReason) ||
789
+ contResponse.toolCalls.length > 0) {
662
790
  response = { ...contResponse, text: combinedText };
663
791
  break;
664
792
  }
665
793
  }
666
- catch {
667
- break;
794
+ catch (err) {
795
+ if (isAbortError(err) || this.config.signal?.aborted) {
796
+ this.markStopped();
797
+ this.finalizeModelTurn();
798
+ return { text: finalText, reason: "aborted_streaming", messages };
799
+ }
800
+ this.config.onStream?.({ type: "error", error: formatFriendlyError(err) });
801
+ this.finalizeModelTurn();
802
+ return { text: finalText, reason: "model_error", messages };
668
803
  }
669
804
  }
670
805
  response = { ...response, text: combinedText };
@@ -672,16 +807,10 @@ export class TurnLoop {
672
807
  this.emitCtxFromUsage(response.usage, messages);
673
808
  }
674
809
  }
675
- // Goal-mode run-scoped accounting: add this turn's total token usage
676
- // (prompt + completion) to the running total. Done after continuation so
677
- // continued output is counted against the budget.
678
- if (goalTracker && response.usage) {
679
- const used = (response.usage.promptTokens ?? 0) + (response.usage.completionTokens ?? 0);
680
- recordGoalUsage(goalTracker, used);
681
- }
682
810
  // Aborted?
683
811
  if (this.config.signal?.aborted) {
684
812
  this.markStopped();
813
+ this.finalizeModelTurn();
685
814
  return { text: finalText, reason: "aborted_streaming", messages };
686
815
  }
687
816
  // Accumulate text
@@ -692,7 +821,10 @@ export class TurnLoop {
692
821
  // is force-stopped regardless of what the model wants to do next (stop OR
693
822
  // continue with tool calls). This is the unattended-safety backstop, so
694
823
  // it sits BEFORE the "tool calls?" branch — both paths pass the gate.
695
- if (goalTracker && goalBudgetExceeded(goalTracker, Date.now())) {
824
+ budgetTermination = goalTracker
825
+ ? (budgetTermination ?? goalBudgetTerminationReason(goalTracker, Date.now()))
826
+ : undefined;
827
+ if (goalTracker && budgetTermination) {
696
828
  tlog.info("turn.goal_budget_exhausted", {
697
829
  cat: "goal",
698
830
  tokensUsed: goalTracker.tokensUsed,
@@ -707,7 +839,13 @@ export class TurnLoop {
707
839
  content: "(Goal 预算已耗尽,强制停止。)",
708
840
  },
709
841
  });
710
- return { text: finalText, reason: "goal_budget_exhausted", messages };
842
+ this.finalizeModelTurn();
843
+ return {
844
+ text: finalText,
845
+ reason: "goal_budget_exhausted",
846
+ messages,
847
+ goalTermination: budgetTermination,
848
+ };
711
849
  }
712
850
  // Post-check: tool calls?
713
851
  if (response.toolCalls.length === 0) {
@@ -722,7 +860,8 @@ export class TurnLoop {
722
860
  hasToolUse: false,
723
861
  });
724
862
  messages.push({ role: "assistant", content: finalText });
725
- if (this.consumeQueuedSteer(messages, "finalize_backfill")) {
863
+ this.finalizeModelTurn();
864
+ if (await this.consumeQueuedSteer(messages, "finalize_backfill")) {
726
865
  continue;
727
866
  }
728
867
  // on_stop seam: the model wants to stop. Give handlers (Goal mode)
@@ -731,11 +870,79 @@ export class TurnLoop {
731
870
  // we run another turn instead of returning. Bounded by
732
871
  // maxStopBlocks (consecutive) and the outer maxTurns ceiling.
733
872
  const maxStopBlocks = this.config.maxStopBlocks ?? GOAL_DEFAULT_MAX_STOP_BLOCKS;
873
+ if (goalTracker) {
874
+ this.deps.updateGoalJudgeContext?.({
875
+ toolResults: goalToolResults.map((item) => ({
876
+ ...item,
877
+ })),
878
+ progress: {
879
+ turnCount: this.turnCount,
880
+ stopRound: this.stopBlockCount + 1,
881
+ elapsedMs: Math.max(0, Date.now() - goalTracker.startedAtMs),
882
+ tokensUsed: goalTracker.tokensUsed,
883
+ tokenBudget: goalTracker.goal.tokenBudget,
884
+ timeBudgetMs: goalTracker.goal.timeBudgetMs,
885
+ maxTurns: this.config.maxTurns,
886
+ maxStopBlocks,
887
+ },
888
+ });
889
+ }
734
890
  const stopHook = await this.emitHook("on_stop", {
735
891
  goal: this.config.goal,
736
892
  finalText,
737
893
  turnCount: this.turnCount,
738
894
  });
895
+ // The primary-model judge is a real billed request. Its hook forwards
896
+ // usage through recordGoalJudgeUsage above; re-check immediately so an
897
+ // over-budget verdict cannot authorize another main-model turn.
898
+ const hookGoalTermination = stopHook.goalTermination;
899
+ const hookBudgetTermination = hookGoalTermination === "token_budget_exhausted" ||
900
+ hookGoalTermination === "time_budget_exhausted"
901
+ ? hookGoalTermination
902
+ : undefined;
903
+ const postJudgeBudgetTermination = goalTracker
904
+ ? (hookBudgetTermination ?? goalBudgetTerminationReason(goalTracker, Date.now()))
905
+ : undefined;
906
+ if (goalTracker && postJudgeBudgetTermination) {
907
+ tlog.info("turn.goal_budget_exhausted_after_judge", {
908
+ cat: "goal",
909
+ reason: postJudgeBudgetTermination,
910
+ tokensUsed: goalTracker.tokensUsed,
911
+ tokenBudget: goalTracker.goal.tokenBudget,
912
+ timeBudgetMs: goalTracker.goal.timeBudgetMs,
913
+ });
914
+ this.config.onStream?.({
915
+ type: "assistant_message",
916
+ messageId: assistantMessageId,
917
+ message: {
918
+ role: "assistant",
919
+ content: "(Goal 预算已耗尽,强制停止。)",
920
+ },
921
+ });
922
+ return {
923
+ text: finalText,
924
+ reason: "goal_budget_exhausted",
925
+ messages,
926
+ goalTermination: postJudgeBudgetTermination,
927
+ };
928
+ }
929
+ if (goalTracker && hookGoalTermination === "judge_prompt_too_large") {
930
+ tlog.warn("turn.goal_judge_prompt_too_large", { cat: "goal" });
931
+ this.config.onStream?.({
932
+ type: "assistant_message",
933
+ messageId: assistantMessageId,
934
+ message: {
935
+ role: "assistant",
936
+ content: "(Goal 裁判输入超过安全上限,无法判定;本次已停止,目标已保留。)",
937
+ },
938
+ });
939
+ return {
940
+ text: finalText,
941
+ reason: "completed",
942
+ messages,
943
+ goalTermination: hookGoalTermination,
944
+ };
945
+ }
739
946
  // The judge's structured verdict (set by GoalStopHook in result.data)
740
947
  // rides back here so we can show goal progress WITHOUT a second LLM
741
948
  // call — `gaps` is whatever the judge already computed.
@@ -775,6 +982,7 @@ export class TurnLoop {
775
982
  });
776
983
  continue;
777
984
  }
985
+ let goalTermination;
778
986
  if (stopHook.continueSession && this.stopBlockCount >= maxStopBlocks) {
779
987
  // Cap hit: stop anyway, but tell the user why we're not looping
780
988
  // forever on an unsatisfiable goal.
@@ -788,6 +996,7 @@ export class TurnLoop {
788
996
  status: "exhausted",
789
997
  round: this.stopBlockCount,
790
998
  });
999
+ goalTermination = "stop_blocks_exhausted";
791
1000
  this.config.onStream?.({
792
1001
  type: "assistant_message",
793
1002
  messageId: assistantMessageId,
@@ -807,11 +1016,11 @@ export class TurnLoop {
807
1016
  });
808
1017
  }
809
1018
  this.stopBlockCount = 0;
810
- if (this.consumeQueuedSteer(messages, "finalize_backfill")) {
1019
+ if (await this.consumeQueuedSteer(messages, "finalize_backfill")) {
811
1020
  continue;
812
1021
  }
813
1022
  messages = this.redactConsumedSensitiveToolResults(messages);
814
- return { text: finalText, reason: "completed", messages };
1023
+ return { text: finalText, reason: "completed", messages, goalTermination };
815
1024
  }
816
1025
  // Tool execution phase
817
1026
  tlog.info("turn.tool_use", {
@@ -854,6 +1063,16 @@ export class TurnLoop {
854
1063
  // Record results in transcript and stream
855
1064
  const resultBlocks = [];
856
1065
  for (const result of results) {
1066
+ if (goalTracker) {
1067
+ goalToolResults.push(projectGoalJudgeToolResult(result, this.turnCount, sensitiveGoalResultTools?.has(result.toolName) === true));
1068
+ // Never evict the front of one legal batch: the default executor may
1069
+ // return 25 results in a turn. Keep at least maxToolCallsPerTurn so
1070
+ // the judge renderer can preserve metadata for the entire newest batch.
1071
+ const residencyLimit = Math.max(20, this.config.maxToolCallsPerTurn);
1072
+ if (goalToolResults.length > residencyLimit) {
1073
+ goalToolResults.splice(0, goalToolResults.length - residencyLimit);
1074
+ }
1075
+ }
857
1076
  resultBlocks.push(toolResultToBlock(result));
858
1077
  const streamResult = toolResultForDisplay(result);
859
1078
  const transcriptResult = toolResultTranscriptText(result);
@@ -930,7 +1149,8 @@ export class TurnLoop {
930
1149
  tlog.info("turn.goal_self_reported_complete", { cat: "goal" });
931
1150
  this.stopBlockCount = 0;
932
1151
  this.deps.clearPersistedGoal?.();
933
- if (this.consumeQueuedSteer(messages, "finalize_backfill")) {
1152
+ this.finalizeModelTurn();
1153
+ if (await this.consumeQueuedSteer(messages, "finalize_backfill")) {
934
1154
  continue;
935
1155
  }
936
1156
  messages = this.redactConsumedSensitiveToolResults(messages);
@@ -946,7 +1166,8 @@ export class TurnLoop {
946
1166
  tlog.info("turn.goal_user_cancelled", { cat: "goal" });
947
1167
  this.stopBlockCount = 0;
948
1168
  this.deps.clearPersistedGoal?.();
949
- if (this.consumeQueuedSteer(messages, "finalize_backfill")) {
1169
+ this.finalizeModelTurn();
1170
+ if (await this.consumeQueuedSteer(messages, "finalize_backfill")) {
950
1171
  continue;
951
1172
  }
952
1173
  return { text: finalText, reason: "completed", messages };
@@ -966,7 +1187,8 @@ export class TurnLoop {
966
1187
  message: { role: "assistant", content: finalText },
967
1188
  });
968
1189
  messages.push({ role: "assistant", content: finalText });
969
- if (this.consumeQueuedSteer(messages, "finalize_backfill")) {
1190
+ this.finalizeModelTurn();
1191
+ if (await this.consumeQueuedSteer(messages, "finalize_backfill")) {
970
1192
  continue;
971
1193
  }
972
1194
  messages = this.redactConsumedSensitiveToolResults(messages);
@@ -1008,9 +1230,7 @@ export class TurnLoop {
1008
1230
  hasToolUse: true,
1009
1231
  toolCallCount: toolCalls.length,
1010
1232
  });
1011
- // Record turn boundary
1012
- this.deps.transcript.appendTurnBoundary();
1013
- this.config.onTurnBoundary?.(this.turnCount);
1233
+ this.finalizeModelTurn();
1014
1234
  tlog.info("turn.end", {
1015
1235
  cat: "turn",
1016
1236
  duration_ms: Date.now() - turnStartedAt,
@@ -1025,6 +1245,9 @@ export class TurnLoop {
1025
1245
  // engine's post-run saveState records model_error instead of leaving
1026
1246
  // the session stuck at "active".
1027
1247
  this.patchOrphanedToolUses(messages);
1248
+ // If the model already returned, this is still a completed model turn.
1249
+ // The idempotent helper is a no-op when its normal branch finalized first.
1250
+ this.finalizeModelTurn();
1028
1251
  // A user-initiated Stop also bubbles here (an AbortError thrown from the
1029
1252
  // per-turn scaffolding). Treat it as a clean abort, not a model_error —
1030
1253
  // no error event, so the UI shows only the "你停止了本轮" line.
@@ -1048,7 +1271,7 @@ export class TurnLoop {
1048
1271
  maxTurns: this.config.maxTurns,
1049
1272
  turnCount: this.turnCount,
1050
1273
  });
1051
- this.consumeQueuedSteer(messages, "finalize_backfill");
1274
+ await this.consumeQueuedSteer(messages, "finalize_backfill");
1052
1275
  const hasPendingSensitiveToolResults = this.sensitiveToolResultRedactions.size > 0;
1053
1276
  messages = this.prepareMessagesForModel(messages);
1054
1277
  if (hasPendingSensitiveToolResults) {
@@ -1069,12 +1292,28 @@ export class TurnLoop {
1069
1292
  try {
1070
1293
  const summaryResponse = await this.deps.model.call(this.deps.systemPrompt, this.prepareMessagesForModel(messages), [], // No tools available for summary turn
1071
1294
  this.config.onStream, this.config.signal, this.modelCallRecordingOptions());
1295
+ if (summaryResponse.usage?.promptTokens !== undefined) {
1296
+ this.recordResponseUsage(summaryResponse.usage);
1297
+ }
1298
+ if (goalTracker && summaryResponse.usage) {
1299
+ recordGoalUsage(goalTracker, (summaryResponse.usage.promptTokens ?? 0) + (summaryResponse.usage.completionTokens ?? 0));
1300
+ }
1301
+ if (this.config.signal?.aborted) {
1302
+ this.markStopped();
1303
+ messages = this.redactConsumedSensitiveToolResults(messages);
1304
+ return { text: finalText, reason: "aborted_streaming", messages };
1305
+ }
1072
1306
  messages = this.markPendingImagesConsumed(messages);
1073
1307
  if (summaryResponse.text) {
1074
1308
  finalText = summaryResponse.text;
1075
1309
  }
1076
1310
  }
1077
- catch {
1311
+ catch (err) {
1312
+ if (isAbortError(err) || this.config.signal?.aborted) {
1313
+ this.markStopped();
1314
+ messages = this.redactConsumedSensitiveToolResults(messages);
1315
+ return { text: finalText, reason: "aborted_streaming", messages };
1316
+ }
1078
1317
  // If even summary fails, just return what we have
1079
1318
  this.currentTurnLog.warn("turn.summary_failed", { cat: "turn" });
1080
1319
  }
@@ -1086,7 +1325,12 @@ export class TurnLoop {
1086
1325
  messages.push({ role: "assistant", content: finalText });
1087
1326
  }
1088
1327
  messages = this.redactConsumedSensitiveToolResults(messages);
1089
- return { text: finalText, reason: "max_turns", messages };
1328
+ return {
1329
+ text: finalText,
1330
+ reason: "max_turns",
1331
+ messages,
1332
+ ...(this.config.goal ? { goalTermination: "max_turns_exhausted" } : {}),
1333
+ };
1090
1334
  }
1091
1335
  /**
1092
1336
  * Call model with streaming fallback.
@@ -1168,10 +1412,12 @@ export class TurnLoop {
1168
1412
  get currentTurn() {
1169
1413
  return this.turnCount;
1170
1414
  }
1171
- consumeQueuedSteer(messages, source) {
1415
+ async consumeQueuedSteer(messages, source) {
1172
1416
  const steered = this.deps.consumeSteer?.(source) ?? [];
1173
1417
  let consumed = false;
1174
- for (const { id, text, clientMessageId } of steered) {
1418
+ for (let index = 0; index < steered.length; index++) {
1419
+ const item = steered[index];
1420
+ const { id, text, clientMessageId } = item;
1175
1421
  if (!text)
1176
1422
  continue;
1177
1423
  if (clientMessageId && this.deps.claimClientMessageId?.(clientMessageId, "steer") === false) {
@@ -1183,9 +1429,33 @@ export class TurnLoop {
1183
1429
  });
1184
1430
  continue;
1185
1431
  }
1432
+ let content;
1433
+ try {
1434
+ content = this.deps.buildSteerUserMessageContent
1435
+ ? await this.deps.buildSteerUserMessageContent(item)
1436
+ : text;
1437
+ }
1438
+ catch (err) {
1439
+ if (clientMessageId)
1440
+ this.deps.releaseClientMessageId?.(clientMessageId);
1441
+ const pendingSuffix = steered.slice(index);
1442
+ this.deps.restoreSteer?.(pendingSuffix);
1443
+ logger.warn("steer.prepare.failed_requeued", {
1444
+ clientMessageId,
1445
+ steerId: id,
1446
+ sessionId: this.deps.sessionId,
1447
+ source,
1448
+ restoredCount: pendingSuffix.length,
1449
+ error: err.message,
1450
+ });
1451
+ break;
1452
+ }
1186
1453
  consumed = true;
1187
- messages.push({ role: "user", content: text });
1188
- this.deps.transcript.appendMessage("user", text, { steerId: id, clientMessageId });
1454
+ this.deps.setOriginClientMessageId?.(clientMessageId);
1455
+ const message = { role: "user", content };
1456
+ messages.push(message);
1457
+ this.trackFreshImageMessage(message);
1458
+ this.deps.transcript.appendMessage("user", content, { steerId: id, clientMessageId });
1189
1459
  this.config.onStream?.({ type: "steer_injected", text, id });
1190
1460
  }
1191
1461
  return consumed;