@dreb/coding-agent 2.72.0 → 2.73.0

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.
@@ -41,7 +41,7 @@ import { DEFAULT_BG_PARENT_TURN_LIMIT, DEFAULT_MAX_CONCURRENT_SUBAGENTS, } from
41
41
  import { createSyntheticSourceInfo } from "./source-info.js";
42
42
  import { buildSystemPrompt } from "./system-prompt.js";
43
43
  import { resolveThinkingDisplay } from "./thinking.js";
44
- import { createAllToolDefinitions, createSubagentConcurrencyGate, discoverAgentTypes, getRunningBackgroundAgents, } from "./tools/index.js";
44
+ import { createAllToolDefinitions, createSubagentConcurrencyGate, discoverAgentTypes, formatAgentOutputBlock, getRunningBackgroundAgents, } from "./tools/index.js";
45
45
  import { expandSkillContent } from "./tools/skill.js";
46
46
  import { createToolDefinitionFromAgentTool, wrapToolDefinition } from "./tools/tool-definition-wrapper.js";
47
47
  // ============================================================================
@@ -84,6 +84,11 @@ export class AgentSession {
84
84
  _unsubscribeAgent;
85
85
  _eventListeners = [];
86
86
  _agentEventQueue = Promise.resolve();
87
+ /**
88
+ * Agent events queued on `_agentEventQueue` that haven't finished processing yet.
89
+ * Persistence happens there, so the session log lags agent state until this is 0.
90
+ */
91
+ _agentEventsInFlight = 0;
87
92
  /** Tracks pending steering messages for UI display. Removed when delivered. */
88
93
  _steeringMessages = [];
89
94
  /** Tracks pending follow-up messages for UI display. Removed when delivered. */
@@ -108,6 +113,14 @@ export class AgentSession {
108
113
  // Bash execution state
109
114
  _bashAbortController = undefined;
110
115
  _pendingBashMessages = [];
116
+ /**
117
+ * Background-agent cancellation notices that arrived while the parent was busy (see
118
+ * `_canDeliverCancellationNotices`). Appending them then could land a user message
119
+ * between an assistant tool call and its result, or ahead of messages not yet persisted.
120
+ * They are held until the parent is idle again, or are persisted to their session before
121
+ * it is replaced.
122
+ */
123
+ _pendingCancellationNotices = [];
111
124
  // Extension system
112
125
  _extensionRunner = undefined;
113
126
  _turnIndex = 0;
@@ -646,11 +659,19 @@ export class AgentSession {
646
659
  if (cancelled) {
647
660
  parts.push("This agent was cancelled by the user.");
648
661
  }
649
- if (!cancelled && (result.exitCode !== 0 || result.errorMessage)) {
662
+ const failed = !cancelled && (result.exitCode !== 0 || !!result.errorMessage);
663
+ if (failed) {
650
664
  parts.push(`Error: ${result.errorMessage || "unknown"}`);
651
665
  }
666
+ if (result.notes && result.notes.length > 0) {
667
+ parts.push(...result.notes);
668
+ }
652
669
  if (result.output) {
653
- parts.push(result.output);
670
+ // The agent's own words are delimited from the system lines around them. A chain's
671
+ // output is already a per-step report with each step's output delimited
672
+ // (formatSingleResult), so it is not wrapped again.
673
+ const isChain = result.steps !== undefined;
674
+ parts.push(isChain ? result.output : formatAgentOutputBlock(result.output, cancelled || failed));
654
675
  }
655
676
  if (result.sessionFile) {
656
677
  parts.push(`Session log: ${result.sessionFile}`);
@@ -674,13 +695,15 @@ export class AgentSession {
674
695
  timestamp: Date.now(),
675
696
  };
676
697
  if (cancelled) {
677
- // Cancelled by user (Esc) — add to context and render in chat,
678
- // but do NOT trigger a response (the user hit Esc to stop, not to ask a question)
698
+ // Cancelled by the user (single-agent stop, Esc, or the parent stop) — add to
699
+ // context and render in chat, but do NOT trigger a response: the user chose to
700
+ // stop this work, and waking the parent could relaunch it behind their back.
701
+ // While the parent is busy (streaming, still persisting the run, retrying, or
702
+ // compacting) the notice is deferred, so it can never split an assistant tool call
703
+ // from its result.
704
+ this._pendingCancellationNotices.push(message);
679
705
  try {
680
- this.agent.appendMessage(message);
681
- this.sessionManager.appendMessage(message);
682
- this._emit({ type: "message_start", message });
683
- this._emit({ type: "message_end", message });
706
+ this._flushPendingCancellationNotices();
684
707
  }
685
708
  catch (err) {
686
709
  log.warn(`[subagent] Failed to deliver cancellation message for agent ${agentId}: ${err instanceof Error ? err.message : String(err)}`);
@@ -696,6 +719,8 @@ export class AgentSession {
696
719
  this.agent.steer(message);
697
720
  }
698
721
  else {
722
+ // Earlier deferred cancellation notices precede this completion in context.
723
+ this._flushPendingCancellationNotices();
699
724
  // Fallback: if streaming started between the isStreaming check and this call, deliver as follow-up
700
725
  this.agent.prompt(message).catch((promptErr) => {
701
726
  log.warn(`[subagent] prompt() failed for background agent ${agentId}: ${promptErr instanceof Error ? promptErr.message : String(promptErr)}`);
@@ -714,7 +739,8 @@ export class AgentSession {
714
739
  type: "background_agent_end",
715
740
  agentId,
716
741
  agentType: result.agent,
717
- success: result.exitCode === 0,
742
+ success: !cancelled && result.exitCode === 0,
743
+ cancelled,
718
744
  model: result.model,
719
745
  thinking: result.thinking,
720
746
  steps: result.steps,
@@ -744,6 +770,7 @@ export class AgentSession {
744
770
  // _processAgentEvent, slow earlier queued events can delay agent_end processing
745
771
  // and waitForRetry() can miss the in-flight retry.
746
772
  this._createRetryPromiseForAgentEnd(event);
773
+ this._agentEventsInFlight++;
747
774
  this._agentEventQueue = this._agentEventQueue.then(() => this._processAgentEvent(event), () => {
748
775
  // Prior event failed — already warned by the .catch() below.
749
776
  // Swallow the old rejection and continue with the current event.
@@ -847,101 +874,119 @@ export class AgentSession {
847
874
  return undefined;
848
875
  }
849
876
  async _processAgentEvent(event) {
850
- // When a user message starts, check if it's from either queue and remove it BEFORE emitting
851
- // This ensures the UI sees the updated queue state
852
- if (event.type === "message_start" && event.message.role === "user") {
853
- this._overflowRecoveryAttempted = false;
854
- const messageText = this._getUserMessageText(event.message);
855
- if (messageText) {
856
- // Check steering queue first
857
- const steeringIndex = this._steeringMessages.findIndex((message) => message.text === messageText);
858
- if (steeringIndex !== -1) {
859
- this._steeringMessages.splice(steeringIndex, 1);
860
- }
861
- else {
862
- // Check follow-up queue
863
- const followUpIndex = this._followUpMessages.findIndex((message) => message.text === messageText);
864
- if (followUpIndex !== -1) {
865
- this._followUpMessages.splice(followUpIndex, 1);
877
+ // The finally runs inline: an extra async wrapper here would add microtask hops to every
878
+ // queued event and delay event delivery relative to prompt() resolution.
879
+ try {
880
+ // When a user message starts, check if it's from either queue and remove it BEFORE emitting
881
+ // This ensures the UI sees the updated queue state
882
+ if (event.type === "message_start" && event.message.role === "user") {
883
+ this._overflowRecoveryAttempted = false;
884
+ const messageText = this._getUserMessageText(event.message);
885
+ if (messageText) {
886
+ // Check steering queue first
887
+ const steeringIndex = this._steeringMessages.findIndex((message) => message.text === messageText);
888
+ if (steeringIndex !== -1) {
889
+ this._steeringMessages.splice(steeringIndex, 1);
890
+ }
891
+ else {
892
+ // Check follow-up queue
893
+ const followUpIndex = this._followUpMessages.findIndex((message) => message.text === messageText);
894
+ if (followUpIndex !== -1) {
895
+ this._followUpMessages.splice(followUpIndex, 1);
896
+ }
866
897
  }
867
898
  }
868
899
  }
869
- }
870
- // Emit to extensions first
871
- await this._emitExtensionEvent(event);
872
- // Notify all listeners
873
- this._emit(event);
874
- // Keep a small, mode-independent rolling context for the headless arbiter.
875
- if (event.type === "message_end") {
876
- this._dispatchArbiter.onMessageEnd(event.message);
877
- }
878
- else if (event.type === "tool_execution_end") {
879
- this._dispatchArbiter.onToolEnd({
880
- toolName: event.toolName,
881
- isError: event.isError,
882
- result: event.result,
883
- });
884
- }
885
- // Handle session persistence
886
- if (event.type === "message_end") {
887
- // Check if this is a custom message from extensions
888
- if (event.message.role === "custom") {
889
- // Persist as CustomMessageEntry
890
- this.sessionManager.appendCustomMessageEntry(event.message.customType, event.message.content, event.message.display, event.message.details);
891
- }
892
- else if (event.message.role === "user" ||
893
- event.message.role === "assistant" ||
894
- event.message.role === "toolResult") {
895
- // Regular LLM message - persist as SessionMessageEntry
896
- this.sessionManager.appendMessage(event.message);
897
- }
898
- // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere
899
- // Track assistant message for auto-compaction (checked on agent_end)
900
- if (event.message.role === "assistant") {
901
- this._lastAssistantMessage = event.message;
902
- const assistantMsg = event.message;
903
- if (assistantMsg.stopReason !== "error") {
904
- this._overflowRecoveryAttempted = false;
900
+ // Emit to extensions first
901
+ await this._emitExtensionEvent(event);
902
+ // Notify all listeners
903
+ this._emit(event);
904
+ // Keep a small, mode-independent rolling context for the headless arbiter.
905
+ if (event.type === "message_end") {
906
+ this._dispatchArbiter.onMessageEnd(event.message);
907
+ }
908
+ else if (event.type === "tool_execution_end") {
909
+ this._dispatchArbiter.onToolEnd({
910
+ toolName: event.toolName,
911
+ isError: event.isError,
912
+ result: event.result,
913
+ });
914
+ }
915
+ // Handle session persistence
916
+ if (event.type === "message_end") {
917
+ // Check if this is a custom message from extensions
918
+ if (event.message.role === "custom") {
919
+ // Persist as CustomMessageEntry
920
+ this.sessionManager.appendCustomMessageEntry(event.message.customType, event.message.content, event.message.display, event.message.details);
905
921
  }
906
- // Reset retry counter immediately on successful assistant response
907
- // This prevents accumulation across multiple LLM calls within a turn
908
- if (assistantMsg.stopReason !== "error" && this._retryAttempt > 0) {
909
- this._emit({
910
- type: "auto_retry_end",
911
- success: true,
912
- attempt: this._retryAttempt,
913
- });
914
- this._retryAttempt = 0;
922
+ else if (event.message.role === "user" ||
923
+ event.message.role === "assistant" ||
924
+ event.message.role === "toolResult") {
925
+ // Regular LLM message - persist as SessionMessageEntry
926
+ this.sessionManager.appendMessage(event.message);
915
927
  }
916
- const durationMs = assistantMsg.durationMs ?? 0;
917
- if (assistantMsg.stopReason !== "error" &&
918
- assistantMsg.stopReason !== "aborted" &&
919
- assistantMsg.usage.output > 0 &&
920
- durationMs >= MIN_PERFORMANCE_DURATION_MS) {
921
- this.performanceTracker.record({
922
- timestamp: new Date().toISOString(),
923
- sessionId: this.sessionId,
924
- provider: assistantMsg.provider,
925
- modelId: assistantMsg.model,
926
- outputTokens: assistantMsg.usage.output,
927
- durationMs,
928
- tps: (assistantMsg.usage.output * 1000) / durationMs,
929
- });
928
+ // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere
929
+ // Track assistant message for auto-compaction (checked on agent_end)
930
+ if (event.message.role === "assistant") {
931
+ this._lastAssistantMessage = event.message;
932
+ const assistantMsg = event.message;
933
+ if (assistantMsg.stopReason !== "error") {
934
+ this._overflowRecoveryAttempted = false;
935
+ }
936
+ // Reset retry counter immediately on successful assistant response
937
+ // This prevents accumulation across multiple LLM calls within a turn
938
+ if (assistantMsg.stopReason !== "error" && this._retryAttempt > 0) {
939
+ this._emit({
940
+ type: "auto_retry_end",
941
+ success: true,
942
+ attempt: this._retryAttempt,
943
+ });
944
+ this._retryAttempt = 0;
945
+ }
946
+ const durationMs = assistantMsg.durationMs ?? 0;
947
+ if (assistantMsg.stopReason !== "error" &&
948
+ assistantMsg.stopReason !== "aborted" &&
949
+ assistantMsg.usage.output > 0 &&
950
+ durationMs >= MIN_PERFORMANCE_DURATION_MS) {
951
+ this.performanceTracker.record({
952
+ timestamp: new Date().toISOString(),
953
+ sessionId: this.sessionId,
954
+ provider: assistantMsg.provider,
955
+ modelId: assistantMsg.model,
956
+ outputTokens: assistantMsg.usage.output,
957
+ durationMs,
958
+ tps: (assistantMsg.usage.output * 1000) / durationMs,
959
+ });
960
+ }
930
961
  }
931
962
  }
932
- }
933
- // Check auto-retry and auto-compaction after agent completes
934
- if (event.type === "agent_end" && this._lastAssistantMessage) {
935
- const msg = this._lastAssistantMessage;
936
- this._lastAssistantMessage = undefined;
937
- // Check for retryable errors first (overloaded, rate limit, server errors)
938
- if (this._isRetryableError(msg)) {
939
- const didRetry = await this._handleRetryableError(msg);
940
- if (didRetry)
941
- return; // Retry was initiated, don't proceed to compaction
963
+ // Check auto-retry and auto-compaction after agent completes
964
+ if (event.type === "agent_end") {
965
+ if (this._lastAssistantMessage) {
966
+ const msg = this._lastAssistantMessage;
967
+ this._lastAssistantMessage = undefined;
968
+ // Check for retryable errors first (overloaded, rate limit, server errors)
969
+ if (this._isRetryableError(msg)) {
970
+ const didRetry = await this._handleRetryableError(msg);
971
+ // Retry was initiated, don't proceed to compaction. Deferred cancellation
972
+ // notices stay held while the retry is pending (isRetrying) and are delivered
973
+ // after the retried run's agent_end.
974
+ if (didRetry)
975
+ return;
976
+ }
977
+ this._resolveRetry();
978
+ await this._checkCompaction(msg);
979
+ }
980
+ // Deferred cancellation notices are delivered in the finally below once this
981
+ // agent_end has finished processing (after any retry decision or compaction above).
942
982
  }
943
- this._resolveRetry();
944
- await this._checkCompaction(msg);
983
+ }
984
+ finally {
985
+ this._agentEventsInFlight--;
986
+ // This event is handled and persisted. If the parent is now idle, deliver cancellation
987
+ // notices deferred while it was busy. After a run's final agent_end, that places them
988
+ // after everything the run persisted.
989
+ this._flushPendingCancellationNotices();
945
990
  }
946
991
  }
947
992
  /** Resolve the pending retry promise */
@@ -1404,8 +1449,9 @@ export class AgentSession {
1404
1449
  }
1405
1450
  return;
1406
1451
  }
1407
- // Flush any pending bash messages before the new prompt
1452
+ // Flush any pending bash messages and cancellation notices before the new prompt
1408
1453
  this._flushPendingBashMessages();
1454
+ this._flushPendingCancellationNotices();
1409
1455
  // Validate model
1410
1456
  if (!this.model) {
1411
1457
  throw new Error("No model selected.\n\n" +
@@ -1769,6 +1815,7 @@ export class AgentSession {
1769
1815
  }
1770
1816
  this._disconnectFromAgent();
1771
1817
  await this.abort();
1818
+ await this._persistPendingCancellationNoticesBeforeSessionChange();
1772
1819
  this.agent.reset();
1773
1820
  this._dispatchArbiter.clearContext();
1774
1821
  this.sessionManager.newSession({ parentSession: options?.parentSession, cwd: this.cwd });
@@ -2119,6 +2166,9 @@ export class AgentSession {
2119
2166
  finally {
2120
2167
  this._compactionAbortController = undefined;
2121
2168
  this._reconnectToAgent();
2169
+ // Cancellation notices that arrived while compacting were held back; deliver them
2170
+ // now that the compacted context is in place.
2171
+ this._flushPendingCancellationNotices();
2122
2172
  }
2123
2173
  }
2124
2174
  /**
@@ -3117,6 +3167,60 @@ export class AgentSession {
3117
3167
  get hasPendingBashMessages() {
3118
3168
  return this._pendingBashMessages.length > 0;
3119
3169
  }
3170
+ /**
3171
+ * Whether deferred cancellation notices can be appended now without landing inside
3172
+ * work that is still changing the transcript. Not while the agent streams, while
3173
+ * earlier agent events are still being processed or persisted (the session log would
3174
+ * get the notice ahead of them), while an auto-retry is pending (its backoff or the
3175
+ * retried run), or while compaction or branch summarization runs.
3176
+ */
3177
+ _canDeliverCancellationNotices() {
3178
+ return !this.agent.state.isStreaming && this._agentEventsInFlight === 0 && !this.isRetrying && !this.isCompacting;
3179
+ }
3180
+ /**
3181
+ * Append deferred background-agent cancellation notices to agent state and the session
3182
+ * log, in arrival order, and render them, if the parent is idle (see
3183
+ * `_canDeliverCancellationNotices`). Otherwise they stay queued. They are retried after
3184
+ * each processed agent event (`_processAgentEvent`), before the next prompt, and after manual compaction or
3185
+ * branch navigation. Never triggers a response.
3186
+ */
3187
+ _flushPendingCancellationNotices() {
3188
+ if (this._pendingCancellationNotices.length === 0 || !this._canDeliverCancellationNotices())
3189
+ return;
3190
+ this._appendPendingCancellationNotices();
3191
+ }
3192
+ _appendPendingCancellationNotices() {
3193
+ while (this._pendingCancellationNotices.length > 0) {
3194
+ const message = this._pendingCancellationNotices.shift();
3195
+ this.agent.appendMessage(message);
3196
+ this.sessionManager.appendMessage(message);
3197
+ this._emit({ type: "message_start", message });
3198
+ this._emit({ type: "message_end", message });
3199
+ }
3200
+ }
3201
+ /**
3202
+ * Persist cancellation notices still deferred for the current session before it is replaced
3203
+ * (new session, resume/switch, fork). The run they wait on will never end in this
3204
+ * session, so they are written now instead of being dropped. First every event that session
3205
+ * already produced is processed, so the notices land after the last message it persisted.
3206
+ *
3207
+ * Callers that abort the run first (new session, switch) get a normal append to agent state
3208
+ * and the log. A fork keeps the run going, so if it is still streaming the notices are only
3209
+ * written to the outgoing session's log. Live context is rebuilt from the fork, and must
3210
+ * not get a user message between a tool call and its result.
3211
+ */
3212
+ async _persistPendingCancellationNoticesBeforeSessionChange() {
3213
+ if (this._pendingCancellationNotices.length === 0)
3214
+ return;
3215
+ await this._agentEventQueue.catch(() => undefined);
3216
+ if (this.agent.state.isStreaming) {
3217
+ for (const message of this._pendingCancellationNotices.splice(0)) {
3218
+ this.sessionManager.appendMessage(message);
3219
+ }
3220
+ return;
3221
+ }
3222
+ this._appendPendingCancellationNotices();
3223
+ }
3120
3224
  /**
3121
3225
  * Flush pending bash messages to agent state and session.
3122
3226
  * Called after agent turn completes to maintain proper message ordering.
@@ -3167,6 +3271,7 @@ export class AgentSession {
3167
3271
  }
3168
3272
  this._disconnectFromAgent();
3169
3273
  await this.abort();
3274
+ await this._persistPendingCancellationNoticesBeforeSessionChange();
3170
3275
  this._steeringMessages = [];
3171
3276
  this._followUpMessages = [];
3172
3277
  this._pendingNextTurnMessages = [];
@@ -3297,6 +3402,8 @@ export class AgentSession {
3297
3402
  }
3298
3403
  skipConversationRestore = result?.skipConversationRestore ?? false;
3299
3404
  }
3405
+ // Cancellation notices belong to the outgoing session: persist them there.
3406
+ await this._persistPendingCancellationNoticesBeforeSessionChange();
3300
3407
  // Clear pending messages (bound to old session state)
3301
3408
  this._pendingNextTurnMessages = [];
3302
3409
  branch(previousSessionFile);
@@ -3508,6 +3615,8 @@ export class AgentSession {
3508
3615
  }
3509
3616
  finally {
3510
3617
  this._branchSummaryAbortController = undefined;
3618
+ // Deliver cancellation notices held back while the branch summary was generated.
3619
+ this._flushPendingCancellationNotices();
3511
3620
  }
3512
3621
  }
3513
3622
  /**