@animalabs/agent-framework 0.6.6 → 0.6.7

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 (128) hide show
  1. package/README.md +78 -0
  2. package/dist/src/agent.d.ts +39 -2
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +172 -56
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/mcp-server.js +0 -0
  7. package/dist/src/api/server.d.ts.map +1 -1
  8. package/dist/src/api/server.js +7 -2
  9. package/dist/src/api/server.js.map +1 -1
  10. package/dist/src/canonical-json.d.ts +2 -0
  11. package/dist/src/canonical-json.d.ts.map +1 -0
  12. package/dist/src/canonical-json.js +23 -0
  13. package/dist/src/canonical-json.js.map +1 -0
  14. package/dist/src/framework.d.ts +147 -3
  15. package/dist/src/framework.d.ts.map +1 -1
  16. package/dist/src/framework.js +2719 -125
  17. package/dist/src/framework.js.map +1 -1
  18. package/dist/src/gate/event-gate.d.ts.map +1 -1
  19. package/dist/src/gate/event-gate.js +9 -2
  20. package/dist/src/gate/event-gate.js.map +1 -1
  21. package/dist/src/index.d.ts +4 -0
  22. package/dist/src/index.d.ts.map +1 -1
  23. package/dist/src/index.js +3 -0
  24. package/dist/src/index.js.map +1 -1
  25. package/dist/src/mcpl/channel-registry.d.ts +3 -1
  26. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  27. package/dist/src/mcpl/channel-registry.js +29 -15
  28. package/dist/src/mcpl/channel-registry.js.map +1 -1
  29. package/dist/src/mcpl/server-connection.d.ts +36 -3
  30. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  31. package/dist/src/mcpl/server-connection.js +142 -28
  32. package/dist/src/mcpl/server-connection.js.map +1 -1
  33. package/dist/src/modules/workspace/index.d.ts +12 -1
  34. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  35. package/dist/src/modules/workspace/index.js +661 -2
  36. package/dist/src/modules/workspace/index.js.map +1 -1
  37. package/dist/src/modules/workspace/types.d.ts +4 -0
  38. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  39. package/dist/src/modules/workspace/types.js.map +1 -1
  40. package/dist/src/recovery/discord-awareness-outbox.d.ts +99 -0
  41. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -0
  42. package/dist/src/recovery/discord-awareness-outbox.js +334 -0
  43. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -0
  44. package/dist/src/recovery/offline-branch.d.ts +45 -0
  45. package/dist/src/recovery/offline-branch.d.ts.map +1 -0
  46. package/dist/src/recovery/offline-branch.js +362 -0
  47. package/dist/src/recovery/offline-branch.js.map +1 -0
  48. package/dist/src/recovery/recover-cli.d.ts +3 -0
  49. package/dist/src/recovery/recover-cli.d.ts.map +1 -0
  50. package/dist/src/recovery/recover-cli.js +129 -0
  51. package/dist/src/recovery/recover-cli.js.map +1 -0
  52. package/dist/src/types/agent.d.ts +22 -0
  53. package/dist/src/types/agent.d.ts.map +1 -1
  54. package/dist/src/types/framework.d.ts +14 -0
  55. package/dist/src/types/framework.d.ts.map +1 -1
  56. package/dist/src/types/module.d.ts +31 -0
  57. package/dist/src/types/module.d.ts.map +1 -1
  58. package/dist/src/types/trace.d.ts +2 -0
  59. package/dist/src/types/trace.d.ts.map +1 -1
  60. package/dist/test/agent-runtime-settings.test.js +200 -93
  61. package/dist/test/agent-runtime-settings.test.js.map +1 -1
  62. package/dist/test/busy-agent-scheduler.test.d.ts +2 -0
  63. package/dist/test/busy-agent-scheduler.test.d.ts.map +1 -0
  64. package/dist/test/busy-agent-scheduler.test.js +62 -0
  65. package/dist/test/busy-agent-scheduler.test.js.map +1 -0
  66. package/dist/test/channel-lifecycle-provenance.test.d.ts +2 -0
  67. package/dist/test/channel-lifecycle-provenance.test.d.ts.map +1 -0
  68. package/dist/test/channel-lifecycle-provenance.test.js +47 -0
  69. package/dist/test/channel-lifecycle-provenance.test.js.map +1 -0
  70. package/dist/test/discord-awareness-outbox.test.d.ts +2 -0
  71. package/dist/test/discord-awareness-outbox.test.d.ts.map +1 -0
  72. package/dist/test/discord-awareness-outbox.test.js +248 -0
  73. package/dist/test/discord-awareness-outbox.test.js.map +1 -0
  74. package/dist/test/helpers/mock-membrane.js +1 -1
  75. package/dist/test/helpers/mock-membrane.js.map +1 -1
  76. package/dist/test/maintenance-timer.test.js +100 -0
  77. package/dist/test/maintenance-timer.test.js.map +1 -1
  78. package/dist/test/mcpl-awareness-barrier.test.d.ts +2 -0
  79. package/dist/test/mcpl-awareness-barrier.test.d.ts.map +1 -0
  80. package/dist/test/mcpl-awareness-barrier.test.js +845 -0
  81. package/dist/test/mcpl-awareness-barrier.test.js.map +1 -0
  82. package/dist/test/mcpl-reconnect-reregistration.test.js +22 -3
  83. package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -1
  84. package/dist/test/mcpl-request-timeout.test.js +15 -0
  85. package/dist/test/mcpl-request-timeout.test.js.map +1 -1
  86. package/dist/test/offline-recovery-branch.test.d.ts +2 -0
  87. package/dist/test/offline-recovery-branch.test.d.ts.map +1 -0
  88. package/dist/test/offline-recovery-branch.test.js +226 -0
  89. package/dist/test/offline-recovery-branch.test.js.map +1 -0
  90. package/dist/test/present-while-acting.test.d.ts +3 -2
  91. package/dist/test/present-while-acting.test.d.ts.map +1 -1
  92. package/dist/test/present-while-acting.test.js +31 -3
  93. package/dist/test/present-while-acting.test.js.map +1 -1
  94. package/dist/test/primary-summary-refusal-fallback-integration.test.d.ts +2 -0
  95. package/dist/test/primary-summary-refusal-fallback-integration.test.d.ts.map +1 -0
  96. package/dist/test/primary-summary-refusal-fallback-integration.test.js +1955 -0
  97. package/dist/test/primary-summary-refusal-fallback-integration.test.js.map +1 -0
  98. package/dist/test/primary-summary-refusal-fallback.test.d.ts +2 -0
  99. package/dist/test/primary-summary-refusal-fallback.test.d.ts.map +1 -0
  100. package/dist/test/primary-summary-refusal-fallback.test.js +753 -0
  101. package/dist/test/primary-summary-refusal-fallback.test.js.map +1 -0
  102. package/dist/test/same-round-think-policy.test.d.ts +2 -0
  103. package/dist/test/same-round-think-policy.test.d.ts.map +1 -0
  104. package/dist/test/same-round-think-policy.test.js +853 -0
  105. package/dist/test/same-round-think-policy.test.js.map +1 -0
  106. package/dist/test/workspace-read-image.test.d.ts +2 -0
  107. package/dist/test/workspace-read-image.test.d.ts.map +1 -0
  108. package/dist/test/workspace-read-image.test.js +518 -0
  109. package/dist/test/workspace-read-image.test.js.map +1 -0
  110. package/dist/tsconfig.tsbuildinfo +1 -1
  111. package/package.json +5 -3
  112. package/src/agent.ts +265 -62
  113. package/src/api/server.ts +8 -2
  114. package/src/canonical-json.ts +22 -0
  115. package/src/framework.ts +3561 -145
  116. package/src/gate/event-gate.ts +15 -3
  117. package/src/index.ts +20 -0
  118. package/src/mcpl/channel-registry.ts +31 -16
  119. package/src/mcpl/server-connection.ts +163 -28
  120. package/src/modules/workspace/index.ts +759 -2
  121. package/src/modules/workspace/types.ts +5 -0
  122. package/src/recovery/discord-awareness-outbox.ts +459 -0
  123. package/src/recovery/offline-branch.ts +510 -0
  124. package/src/recovery/recover-cli.ts +130 -0
  125. package/src/types/agent.ts +26 -0
  126. package/src/types/framework.ts +17 -0
  127. package/src/types/module.ts +33 -0
  128. package/src/types/trace.ts +2 -0
package/src/framework.ts CHANGED
@@ -1,14 +1,19 @@
1
1
  import { join } from 'node:path';
2
2
  import { appendFileSync, mkdirSync } from 'node:fs';
3
+ import { createHash } from 'node:crypto';
3
4
  import { JsStore } from '@animalabs/chronicle';
4
- import type { Membrane, ContentBlock, NormalizedRequest, YieldingStream, ToolResult as MembraneToolResult, ToolResultContentBlock } from '@animalabs/membrane';
5
+ import type { Membrane, ContentBlock, NormalizedRequest, NormalizedResponse, YieldingStream, ToolResult as MembraneToolResult, ToolResultContentBlock } from '@animalabs/membrane';
5
6
  import { MembraneError } from '@animalabs/membrane';
6
7
  import { ContextManager, PassthroughStrategy } from '@animalabs/context-manager';
7
8
  import type {
9
+ BranchGenerationInfo,
8
10
  MessageId,
9
11
  MessageMetadata,
10
12
  MessageQuery,
11
13
  MessageQueryResult,
14
+ PrimarySummaryContract,
15
+ PrimarySummaryIdentity,
16
+ PrimarySummaryProjection,
12
17
  StoredMessage,
13
18
  } from '@animalabs/context-manager';
14
19
  import type {
@@ -47,9 +52,11 @@ import type {
47
52
  AgentRuntimeSettingsPatch,
48
53
  AgentRuntimeSettingsOverrides,
49
54
  AgentRuntimeSettingsSnapshot,
55
+ AgentSettingsExtension,
56
+ SameRoundThinkTextPolicy,
50
57
  } from './types/index.js';
51
58
  import { ProcessQueueImpl } from './queue.js';
52
- import { Agent } from './agent.js';
59
+ import { Agent, type ActivationCompileArtifacts } from './agent.js';
53
60
  import { ModuleRegistry, isStateExistsError } from './module-registry.js';
54
61
  import { McplServerRegistry } from './mcpl/server-registry.js';
55
62
  import { FeatureSetManager } from './mcpl/feature-set-manager.js';
@@ -60,8 +67,10 @@ import { InferenceRouter } from './mcpl/inference-router.js';
60
67
  import { ChannelRegistry } from './mcpl/channel-registry.js';
61
68
  import { ConversationRouter } from './mcpl/conversation-router.js';
62
69
  import { safeSlice } from './safe-slice.js';
70
+ import type { WorkspaceModule } from './modules/workspace/index.js';
63
71
  import { toolResultDataToHistoryString } from './tool-result-history.js';
64
72
  import { splitProseSegments } from './prose-segments.js';
73
+ import { canonicalJsonStringify } from './canonical-json.js';
65
74
 
66
75
  /**
67
76
  * Tools whose presence suppresses auto-routing of the surrounding prose,
@@ -71,15 +80,14 @@ import { splitProseSegments } from './prose-segments.js';
71
80
  * - explicit delivery tools (channel_publish / *send_message /
72
81
  * *reply_message / *send_dm) — already sent the message, so routing the
73
82
  * prose again would double-post.
74
- * `think` is deliberately NOT here: it is silent *reasoning*, but prose
75
- * written around a think is the agent's actual voice and must be delivered.
83
+ * `think` is deliberately NOT here: it is silent *reasoning*, and same-round
84
+ * prose beside it is governed separately by same_round_think_text_policy.
76
85
  *
77
- * Scope: mid-turn rounds apply this PER ROUND (a round's prose is suppressed
78
- * only by a silencing tool in that same round — live delivery can't be
79
- * retracted by a later round's skip_reply); the turn's TRAILING prose keeps
80
- * the historical turn-wide rule (any silencing tool anywhere in the turn
81
- * suppresses it), preserving the "send_message then 'sent it.' postscript"
82
- * double-post guard.
86
+ * Scope: an explicit delivery suppresses prose from that round onward until
87
+ * another external message is injected. The suppression prevents a
88
+ * `send_message` followed by "sent it" from double-posting, but a new message
89
+ * starts a new conversational round and must be answerable with plain prose.
90
+ * `skip_reply` ends the turn at the tool-result boundary.
83
91
  */
84
92
  const SILENCING_TOOLS = new Set([
85
93
  'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
@@ -108,16 +116,31 @@ import type {
108
116
  } from './mcpl/types.js';
109
117
  import type { ContextInjection } from '@animalabs/context-manager';
110
118
  import { formatZonedDateTime, resolveTimeZone } from './timezone.js';
119
+ import {
120
+ DEFAULT_DISCORD_AWARENESS_EMOJI,
121
+ DiscordAwarenessOutbox,
122
+ defaultDiscordAwarenessOutboxPath,
123
+ extractDiscordAwarenessRefs,
124
+ } from './recovery/discord-awareness-outbox.js';
111
125
 
112
126
  const FRAMEWORK_STATE_ID = 'framework/state';
113
127
  const CONVERSATION_ROUTER_STATE_ID = 'framework/conversation-router';
114
128
  const INFERENCE_LOG_ID = 'framework/inference-log';
115
129
  const PROCESS_LOG_ID = 'framework/process-log';
130
+ const PRIMARY_SUMMARY_FALLBACK_STATE_ID = 'framework/primary-summary-fallback';
131
+ const PRIMARY_SUMMARY_SETTLEMENT_METADATA_KEY = 'primarySummarySettlement';
132
+ const PRIMARY_SUMMARY_SETTLEMENT_VERSION = 1;
133
+ const PRIMARY_SUMMARY_SETTLEMENT_MAX_ID_LENGTH = 1024;
134
+ const PRIMARY_SUMMARY_SETTLEMENT_MAX_NAME_LENGTH = 256;
135
+ const PRIMARY_SUMMARY_SETTLEMENT_MAX_REASON_LENGTH = 256;
116
136
  const TURN_CHECKPOINTS_ID = 'framework/turn-checkpoints'; // legacy single-map layout, read-only fallback
117
137
  const TURN_CHECKPOINTS_TREE_ID = 'framework/turn-checkpoints/tree';
118
138
 
119
139
  /** Maximum number of turn checkpoints to keep per agent. */
120
140
  const MAX_TURN_CHECKPOINTS = 20;
141
+ const DEFAULT_DISCORD_AWARENESS_DEADLINE_MS = 10_000;
142
+ const MIN_DISCORD_AWARENESS_DEADLINE_MS = 50;
143
+ const MAX_DISCORD_AWARENESS_DEADLINE_MS = 60_000;
121
144
 
122
145
  interface TurnCheckpoint {
123
146
  agentName: string;
@@ -127,11 +150,165 @@ interface TurnCheckpoint {
127
150
  timestamp: number;
128
151
  }
129
152
 
153
+ type DiscordAwarenessDrainOutcome =
154
+ | { status: 'delivered'; delivered: number; failed: number }
155
+ | { status: 'unavailable'; accounted: number };
156
+
157
+ interface DiscordAwarenessBarrier {
158
+ generation: number;
159
+ requiresBarrier: boolean;
160
+ promise: Promise<DiscordAwarenessDrainOutcome>;
161
+ }
162
+
163
+ function normalizeDiscordAwarenessDeadline(value: number | undefined): number {
164
+ if (value === undefined || !Number.isFinite(value) || value <= 0) {
165
+ return DEFAULT_DISCORD_AWARENESS_DEADLINE_MS;
166
+ }
167
+ return Math.max(
168
+ MIN_DISCORD_AWARENESS_DEADLINE_MS,
169
+ Math.min(MAX_DISCORD_AWARENESS_DEADLINE_MS, Math.floor(value)),
170
+ );
171
+ }
172
+
173
+ class DiscordAwarenessAccountingError extends Error {
174
+ constructor(operation: string, cause: unknown) {
175
+ const detail = cause instanceof Error ? cause.message : String(cause);
176
+ super(`Discord awareness accounting failed during ${operation}: ${detail}`, { cause });
177
+ this.name = 'DiscordAwarenessAccountingError';
178
+ }
179
+ }
180
+
130
181
  interface RedoEntry {
131
182
  branchName: string;
132
183
  checkpoint: TurnCheckpoint;
133
184
  }
134
185
 
186
+ interface InferenceToolSnapshot {
187
+ sameRoundThinkTextPolicy: SameRoundThinkTextPolicy;
188
+ }
189
+
190
+ type PrimarySummaryFallbackStatus =
191
+ | 'success'
192
+ | 'refusal'
193
+ | 'held'
194
+ | 'pending'
195
+ | 'error'
196
+ | 'timeout_unknown';
197
+
198
+ interface PrimarySummaryFallbackRetryDispatch {
199
+ originalRequestId: string;
200
+ baselineRequestId: string;
201
+ intentId: string;
202
+ branch: { id: string; generation: number };
203
+ candidateSummaries: PrimarySummaryIdentity[];
204
+ originalArtifacts: ActivationCompileArtifacts;
205
+ originalProjectionKeys: string[];
206
+ expectedRetryRequestHash: string;
207
+ requestBudgetTokens: number;
208
+ originalRequestInputBoundTokens: number;
209
+ originalProviderInputTokens?: number;
210
+ }
211
+
212
+ interface PrimarySummaryFallbackIntentRecord {
213
+ intentId: string;
214
+ createdAt: number;
215
+ baselineRequestId: string;
216
+ candidateSummaries: PrimarySummaryIdentity[];
217
+ retryRequestHash: string;
218
+ retryCompleteBoundTokens: number;
219
+ requestBudgetTokens: number;
220
+ retryRequestId?: string;
221
+ dispatchedAt?: number;
222
+ quarantinePersistedAt?: number;
223
+ outputs?: PrimarySummaryFallbackOutputRecord[];
224
+ toolDispatches?: PrimarySummaryFallbackToolDispatchRecord[];
225
+ finalizedAt?: number;
226
+ }
227
+
228
+ interface PrimarySummaryFallbackOutputRecord {
229
+ phase: string;
230
+ contentHash: string;
231
+ messageId: string;
232
+ blockTypes: string[];
233
+ persistedAt: number;
234
+ }
235
+
236
+ type PrimarySummarySettlementOutcome = 'success' | 'held';
237
+ type PrimarySummarySettlementRole = 'assistant' | 'user';
238
+ type PrimarySummarySettlementKind = 'assistant_output' | 'generated_tool_result';
239
+
240
+ interface PrimarySummarySettlementMetadata {
241
+ version: number;
242
+ requestId: string;
243
+ settlementId: string;
244
+ agentName: string;
245
+ dispatchKind: PrimarySummaryRequestRecord['dispatchKind'];
246
+ outcome: PrimarySummarySettlementOutcome;
247
+ stopReason?: string;
248
+ holdReason?: string;
249
+ providerInputTokens?: number;
250
+ visibleAssistantOutput: boolean;
251
+ executedToolCalls: number;
252
+ branch: { id: string; name: string; generation: number };
253
+ role: PrimarySummarySettlementRole;
254
+ kind: PrimarySummarySettlementKind;
255
+ entryIndex: number;
256
+ entryCount: number;
257
+ }
258
+
259
+ interface PrimarySummaryLogicalSettlementEntry {
260
+ messages: StoredMessage[];
261
+ settlement: PrimarySummarySettlementMetadata;
262
+ participant: string;
263
+ content: ContentBlock[];
264
+ bodyGroupId?: string;
265
+ }
266
+
267
+ interface PrimarySummaryFallbackToolDispatchRecord {
268
+ callId: string;
269
+ inputHash: string;
270
+ recordedAt: number;
271
+ }
272
+
273
+ interface PrimarySummaryRequestRecord {
274
+ requestId: string;
275
+ agentName: string;
276
+ namespace: string;
277
+ timestamp: number;
278
+ dispatchKind: 'primary' | 'primary_summary_fallback_retry';
279
+ branch: { id: string; name: string; generation: number };
280
+ projection: PrimarySummaryProjection | null;
281
+ requestInputBoundTokens: number;
282
+ requestCompleteBoundTokens: number;
283
+ providerInputTokens?: number;
284
+ systemHash: string;
285
+ modelConfigHash: string;
286
+ toolContractHash: string;
287
+ stopReason?: string;
288
+ visibleAssistantOutput: boolean;
289
+ executedToolCalls: number;
290
+ finalStatus: PrimarySummaryFallbackStatus;
291
+ fallbackIntent?: PrimarySummaryFallbackIntentRecord;
292
+ fallbackStatus?: PrimarySummaryFallbackStatus;
293
+ fallbackHeldReason?: string;
294
+ }
295
+
296
+ interface PrimarySummaryFallbackState {
297
+ requests: PrimarySummaryRequestRecord[];
298
+ }
299
+
300
+ interface ContextBudgetRestartDispatch {
301
+ requestId: string;
302
+ dispatchKind: 'primary' | 'primary_summary_fallback_retry';
303
+ originalRequestId?: string;
304
+ intentId?: string;
305
+ }
306
+
307
+ interface PendingInferenceRequest extends InferenceRequest {
308
+ primarySummaryFallbackRetry?: PrimarySummaryFallbackRetryDispatch;
309
+ contextBudgetRestart?: ContextBudgetRestartDispatch;
310
+ }
311
+
135
312
  /**
136
313
  * Default inference policy - infer if any request exists for the agent.
137
314
  */
@@ -145,6 +322,11 @@ class DefaultInferencePolicy implements InferencePolicy {
145
322
  }
146
323
  }
147
324
 
325
+ function isPermanentDiscordReactionFailure(message: string): boolean {
326
+ return /unknown message|unknown channel|missing access|missing permissions|missing permission|cannot access|channel .* not found|message .* not found/i
327
+ .test(message);
328
+ }
329
+
148
330
  /**
149
331
  * Default error policy - retry with exponential backoff.
150
332
  *
@@ -274,6 +456,13 @@ interface EphemeralRun {
274
456
  toolCallsCount: number;
275
457
  }
276
458
 
459
+ interface ActiveStreamContext {
460
+ requestId: string;
461
+ trigger?: PendingInferenceRequest;
462
+ compiledRequest?: NormalizedRequest;
463
+ artifacts?: ActivationCompileArtifacts;
464
+ }
465
+
277
466
  /** Cap an API error message for inline use in a rewind marker: keep enough to
278
467
  * identify the failure class without pasting a wall of provider JSON into the
279
468
  * agent's context. */
@@ -290,7 +479,7 @@ export class AgentFramework {
290
479
  private moduleRegistry: ModuleRegistry;
291
480
  private inferencePolicy: InferencePolicy;
292
481
  private errorPolicy: ErrorPolicy;
293
- private pendingRequests: InferenceRequest[] = [];
482
+ private pendingRequests: PendingInferenceRequest[] = [];
294
483
  /**
295
484
  * Per-agent channel that triggered the agent's CURRENT inference turn, if any
296
485
  * (item-3 redux). Read by the ChannelRegistry's `activeChannelResolver` to
@@ -312,20 +501,30 @@ export class AgentFramework {
312
501
  private maintenanceRunId = 0;
313
502
  private currentMaintenanceRun: ContextMaintenanceRun | null = null;
314
503
  private maintenanceHistory: ContextMaintenanceRun[] = [];
315
- /** Last time we console-warned about stale (busy-requeued) inference requests, per agent. */
504
+ /** Last time we reported stale (busy-requeued) inference requests, per agent. */
316
505
  private staleWarnAt = new Map<string, number>();
317
506
  /** Per-agent last inference activity (epoch ms), for /healthz + doctor tooling. */
318
507
  private lastInferenceAt = new Map<string, { startedAt?: number; endedAt?: number; failedAt?: number; lastError?: string }>();
319
508
  private processLoggingPersist: boolean;
320
509
  private processLoggingBroadcast: boolean;
321
510
  private activeStreams: Map<string, Promise<void>> = new Map();
511
+ private activeStreamContexts: Map<string, ActiveStreamContext> = new Map();
512
+ private primarySummaryStateLock: Promise<void> = Promise.resolve();
322
513
 
323
514
  /** Per-agent output locus pinned for the CURRENT logical turn (see
324
515
  * resolveTurnLocus in driveStream). Lives here — not in driveStream
325
516
  * locals — so the pin survives a context-budget stream restart, which
326
- * continues the same logical turn in a fresh driveStream. Cleared at the
327
- * start of every non-restart turn. */
517
+ * continues the same logical turn in a fresh driveStream. A mid-turn
518
+ * injected channel message replaces the pin for the next conversational
519
+ * round; otherwise it remains stable. Cleared at the start of every
520
+ * non-restart turn. */
328
521
  private turnLocusPins: Map<string, string> = new Map();
522
+ /** A tool boundary injected fresh external input into the live stream.
523
+ * Presence (including a null value) tells driveStream to clear sticky
524
+ * explicit-send suppression before handling the next model round. A
525
+ * string also moves that round's reply locus to the newest injected
526
+ * channel. */
527
+ private midTurnRoutingResets: Map<string, string | null> = new Map();
329
528
  private pendingAssistantBlocks: Map<string, ContentBlock[]> = new Map();
330
529
  /** Streams the FRAMEWORK cancelled for non-terminal reasons, keyed
331
530
  * `${agentName}:${streamId}`: an endTurn tool result or a context-budget
@@ -439,6 +638,15 @@ export class AgentFramework {
439
638
  * subsystem can be lazily initialized by connectMcplServer when the
440
639
  * framework started with zero configured servers. */
441
640
  private mcplInferenceRoutingConfig: import('./mcpl/types.js').InferenceRoutingPolicy | null = null;
641
+ /** Durable, non-Chronicle projection queue for messages removed by a branch. */
642
+ private discordAwarenessOutbox: DiscordAwarenessOutbox | null = null;
643
+ private discordAwarenessEmoji = DEFAULT_DISCORD_AWARENESS_EMOJI;
644
+ private discordAwarenessDeadlineMs = DEFAULT_DISCORD_AWARENESS_DEADLINE_MS;
645
+ /** Serialize per-server drains so reconnect and an online undo cannot race. */
646
+ private discordAwarenessDrains: Map<string, Promise<DiscordAwarenessDrainOutcome>> = new Map();
647
+ /** Framework-global inference gate; older generations cannot release it. */
648
+ private discordAwarenessBarrier: DiscordAwarenessBarrier | null = null;
649
+ private discordAwarenessBarrierGeneration = 0;
442
650
 
443
651
  // EventGate (null when FrameworkConfig.gate is omitted)
444
652
  private eventGate: EventGate | null = null;
@@ -459,6 +667,9 @@ export class AgentFramework {
459
667
  processLoggingPersist: boolean,
460
668
  processLoggingBroadcast: boolean,
461
669
  timeZone: string,
670
+ discordAwarenessOutbox: DiscordAwarenessOutbox | null,
671
+ discordAwarenessEmoji: string,
672
+ discordAwarenessDeadlineMs: number,
462
673
  ) {
463
674
  this.store = store;
464
675
  this.ownsStore = ownsStore;
@@ -470,6 +681,9 @@ export class AgentFramework {
470
681
  this.processLoggingPersist = processLoggingPersist;
471
682
  this.processLoggingBroadcast = processLoggingBroadcast;
472
683
  this.timeZone = timeZone;
684
+ this.discordAwarenessOutbox = discordAwarenessOutbox;
685
+ this.discordAwarenessEmoji = discordAwarenessEmoji;
686
+ this.discordAwarenessDeadlineMs = discordAwarenessDeadlineMs;
473
687
  this.queue = new ProcessQueueImpl();
474
688
  this.usageTracker = new UsageTracker({
475
689
  emitTrace: (e: UsageUpdatedEvent) => this.emitTrace({ ...e }),
@@ -551,6 +765,18 @@ export class AgentFramework {
551
765
  }
552
766
  }
553
767
 
768
+ try {
769
+ store.registerState({ id: PRIMARY_SUMMARY_FALLBACK_STATE_ID, strategy: 'snapshot' });
770
+ } catch {
771
+ // Already registered
772
+ }
773
+
774
+ const discordAwarenessOutboxPath = config.discordAwarenessOutboxPath
775
+ ?? (config.storePath ? defaultDiscordAwarenessOutboxPath(config.storePath) : undefined);
776
+ const discordAwarenessOutbox = discordAwarenessOutboxPath
777
+ ? new DiscordAwarenessOutbox(discordAwarenessOutboxPath)
778
+ : null;
779
+
554
780
  const framework = new AgentFramework(
555
781
  store,
556
782
  ownsStore,
@@ -562,16 +788,46 @@ export class AgentFramework {
562
788
  processLoggingPersist,
563
789
  processLoggingBroadcast,
564
790
  resolveTimeZone(config.timeZone),
791
+ discordAwarenessOutbox,
792
+ config.discordAwarenessEmoji ?? DEFAULT_DISCORD_AWARENESS_EMOJI,
793
+ normalizeDiscordAwarenessDeadline(config.discordAwarenessDeadlineMs),
565
794
  );
566
795
 
567
- // Restore persisted usage data (if any) from prior session
568
- framework.restoreUsageState();
796
+ // If an offline recovery process crashed after switching Chronicle but
797
+ // before committing its prepared marker batch, the active branch is the
798
+ // commit record. Promote it now; no quarantined content is read.
799
+ if (discordAwarenessOutbox) {
800
+ try {
801
+ const activated = discordAwarenessOutbox.activatePreparedForBranch(
802
+ store.currentBranch().name,
803
+ store.listBranches(),
804
+ );
805
+ if (activated > 0) {
806
+ console.error(
807
+ `[discord-awareness] recovered ${activated} prepared batch(es) for active branch ${store.currentBranch().name}`,
808
+ );
809
+ }
810
+ } catch (error) {
811
+ // The branch may be safe, but reporting the framework ready while its
812
+ // durable awareness projection is unreadable creates a half-ready
813
+ // host whose data plane can never be released safely.
814
+ throw new DiscordAwarenessAccountingError('startup reconciliation', error);
815
+ }
816
+ }
569
817
 
570
818
  // Create agents
571
819
  for (const agentConfig of config.agents) {
572
820
  await framework.createAgent(agentConfig);
573
821
  }
574
822
 
823
+ // Restore persisted usage data (if any) from prior session
824
+ framework.restoreUsageState();
825
+ await framework.reconcilePrimarySummaryFallbackStateOnStartup();
826
+
827
+ // Finish any branch-local suppression interrupted after Chronicle switched
828
+ // branches. This runs before modules, MCPL connections, or inbound traffic.
829
+ await framework.resumePreparedDiscordSuppressions();
830
+
575
831
  // Add modules
576
832
  for (const module of config.modules) {
577
833
  await framework.addModule(module);
@@ -821,11 +1077,10 @@ export class AgentFramework {
821
1077
  }
822
1078
 
823
1079
  private async runQueuedMaintenance(): Promise<void> {
824
- const allTools = this.getAllTools();
825
1080
  const queued = [...this.agents.values()].flatMap((agent) => {
826
1081
  const cm = agent.getContextManager();
827
- const tools = allTools.filter((tool) => agent.canUseTool(tool.name));
828
- cm.setToolDefinitions(tools);
1082
+ const tools = this.getToolsForAgent(agent.name).filter((tool) => agent.canUseTool(tool.name));
1083
+ cm.setToolDefinitions?.(tools);
829
1084
  if (cm.isReady()) return [];
830
1085
  const pending = cm.getPendingWork()?.description;
831
1086
  const progress = this.contextProgress(cm);
@@ -1028,10 +1283,138 @@ export class AgentFramework {
1028
1283
  ...this.mcplTools,
1029
1284
  ...channelTools,
1030
1285
  ...gateTools,
1031
- AgentFramework.AGENT_SETTINGS_TOOL,
1286
+ this.buildAgentSettingsTool(),
1287
+ ...(this.getWorkspaceModule() ? [AgentFramework.SAVE_IMAGE_TOOL] : []),
1032
1288
  ];
1033
1289
  }
1034
1290
 
1291
+ /** The registered workspace module, if any (used by save_image). */
1292
+ private getWorkspaceModule(): WorkspaceModule | undefined {
1293
+ const module = this.moduleRegistry.getAllModules().find((m) => m.name === 'workspace');
1294
+ return module && typeof (module as WorkspaceModule).writeBinary === 'function'
1295
+ ? (module as WorkspaceModule)
1296
+ : undefined;
1297
+ }
1298
+
1299
+ /** Core agent_settings keys — extension keys must not collide with these. */
1300
+ private static readonly AGENT_SETTINGS_CORE_KEYS = [
1301
+ 'context_budget_tokens',
1302
+ 'tail_tokens',
1303
+ 'transition_pace_tokens',
1304
+ 'same_round_think_text_policy',
1305
+ ];
1306
+
1307
+ /**
1308
+ * Collect module-declared agent_settings extensions (Module.getAgentSettingsExtension),
1309
+ * keyed by owning module name. Extensions whose keys collide with the core
1310
+ * settings or an earlier extension are skipped loudly — silent shadowing
1311
+ * would make updates route to the wrong owner.
1312
+ */
1313
+ private collectAgentSettingsExtensions(): Map<string, AgentSettingsExtension> {
1314
+ const result = new Map<string, AgentSettingsExtension>();
1315
+ const taken = new Set<string>(AgentFramework.AGENT_SETTINGS_CORE_KEYS);
1316
+ for (const module of this.moduleRegistry.getAllModules()) {
1317
+ const ext = module.getAgentSettingsExtension?.();
1318
+ if (!ext) continue;
1319
+ const collision = ext.keys.find((k) => taken.has(k));
1320
+ if (collision) {
1321
+ console.error(
1322
+ `[agent-settings] extension from module '${module.name}' skipped: key '${collision}' already taken`,
1323
+ );
1324
+ continue;
1325
+ }
1326
+ ext.keys.forEach((k) => taken.add(k));
1327
+ result.set(module.name, ext);
1328
+ }
1329
+ return result;
1330
+ }
1331
+
1332
+ private captureInferenceToolSnapshot(agent: Agent): InferenceToolSnapshot {
1333
+ return {
1334
+ sameRoundThinkTextPolicy: agent.getEffectiveSameRoundThinkTextPolicy(),
1335
+ };
1336
+ }
1337
+
1338
+ private buildThinkTool(
1339
+ policy: SameRoundThinkTextPolicy,
1340
+ ): import('./types/index.js').ToolDefinition {
1341
+ return {
1342
+ name: 'think',
1343
+ description:
1344
+ 'Reason privately. The think({content}) argument stays in your own context and is NOT sent ' +
1345
+ 'to channels or other surfaces. ' +
1346
+ (policy === 'private'
1347
+ ? 'Your current same_round_think_text_policy is private, so ordinary text emitted in the SAME native assistant round as think() is withheld from channel routing. Later rounds without think() route normally.'
1348
+ : 'Your current same_round_think_text_policy is public, so ordinary text emitted in the SAME native assistant round as think() may still be routed publicly as your speech. Later rounds without think() route normally.'
1349
+ ) +
1350
+ ' Use think() to work things out privately. To inspect or change this policy, use agent_settings get/update on same_round_think_text_policy. To deliberately not reply this turn, call skip_reply instead.',
1351
+ inputSchema: {
1352
+ type: 'object' as const,
1353
+ properties: {
1354
+ content: {
1355
+ type: 'string',
1356
+ description: 'Your private thought / reasoning (optional; not sent anywhere).',
1357
+ },
1358
+ },
1359
+ required: [],
1360
+ },
1361
+ };
1362
+ }
1363
+
1364
+ /** agent_settings tool definition with module extension fields merged in. */
1365
+ private buildAgentSettingsTool(): import('./types/index.js').ToolDefinition {
1366
+ const base = AgentFramework.AGENT_SETTINGS_TOOL;
1367
+ const extensions = this.collectAgentSettingsExtensions();
1368
+ if (extensions.size === 0) return base;
1369
+ const baseSchema = base.inputSchema as {
1370
+ type: string;
1371
+ properties: Record<string, unknown>;
1372
+ required?: string[];
1373
+ };
1374
+ const extProperties: Record<string, unknown> = {};
1375
+ const extKeys: string[] = [];
1376
+ for (const ext of extensions.values()) {
1377
+ Object.assign(extProperties, ext.properties);
1378
+ extKeys.push(...ext.keys);
1379
+ }
1380
+ const settingsProp = baseSchema.properties.settings as { items?: { enum?: string[] } };
1381
+ return {
1382
+ ...base,
1383
+ description:
1384
+ base.description +
1385
+ ` Additional host-managed settings also live here: ${extKeys.join(', ')}.`,
1386
+ inputSchema: {
1387
+ ...baseSchema,
1388
+ properties: {
1389
+ ...baseSchema.properties,
1390
+ ...extProperties,
1391
+ settings: {
1392
+ ...(baseSchema.properties.settings as Record<string, unknown>),
1393
+ items: {
1394
+ ...(settingsProp.items as Record<string, unknown>),
1395
+ enum: [...(settingsProp.items?.enum ?? []), ...extKeys],
1396
+ },
1397
+ },
1398
+ },
1399
+ } as unknown as import('./types/index.js').ToolDefinition['inputSchema'],
1400
+ };
1401
+ }
1402
+
1403
+ private getToolsForAgent(
1404
+ agentName: string,
1405
+ snapshot?: InferenceToolSnapshot,
1406
+ ): import('./types/index.js').ToolDefinition[] {
1407
+ return this.getAllTools().map((tool) => {
1408
+ if (tool.name === 'think') {
1409
+ return this.buildThinkTool(
1410
+ snapshot?.sameRoundThinkTextPolicy
1411
+ ?? this.getAgentRuntimeSettings(agentName).sameRoundThinkTextPolicy,
1412
+ );
1413
+ }
1414
+ return tool;
1415
+ });
1416
+ }
1417
+
1035
1418
  getAgentRuntimeSettings(agentName: string): AgentRuntimeSettingsSnapshot {
1036
1419
  const agent = this.agents.get(agentName);
1037
1420
  if (!agent) throw new Error(`Unknown agent: ${agentName}`);
@@ -1124,7 +1507,7 @@ export class AgentFramework {
1124
1507
  throw new Error(`Agent not found: ${agentName}`);
1125
1508
  }
1126
1509
 
1127
- const tools = this.getAllTools().filter((t) => agent.canUseTool(t.name));
1510
+ const tools = this.getToolsForAgent(agentName).filter((t) => agent.canUseTool(t.name));
1128
1511
 
1129
1512
  // Default: no dynamic injection gathering → fully transparent (no
1130
1513
  // inference, no Chronicle writes, no external RPC). Opt in explicitly.
@@ -1279,6 +1662,22 @@ export class AgentFramework {
1279
1662
  }
1280
1663
  }
1281
1664
 
1665
+ private validatePersistedAgentRuntimeSettings(
1666
+ agentName: string,
1667
+ overrides: AgentRuntimeSettingsOverrides,
1668
+ ): AgentRuntimeSettingsOverrides {
1669
+ if (
1670
+ overrides.sameRoundThinkTextPolicy !== undefined &&
1671
+ overrides.sameRoundThinkTextPolicy !== 'public' &&
1672
+ overrides.sameRoundThinkTextPolicy !== 'private'
1673
+ ) {
1674
+ throw new Error(
1675
+ `Invalid persisted sameRoundThinkTextPolicy for agent "${agentName}": ${JSON.stringify(overrides.sameRoundThinkTextPolicy)}`,
1676
+ );
1677
+ }
1678
+ return overrides;
1679
+ }
1680
+
1282
1681
  private persistAgentRuntimeSettings(
1283
1682
  agentName: string,
1284
1683
  overrides: AgentRuntimeSettingsOverrides,
@@ -1329,17 +1728,1333 @@ export class AgentFramework {
1329
1728
  // Data persists in the store under the namespace for investigation.
1330
1729
  };
1331
1730
 
1332
- return { agent, contextManager, cleanup };
1731
+ return { agent, contextManager, cleanup };
1732
+ }
1733
+
1734
+ private createDeferred<T>(): Deferred<T> {
1735
+ let resolve!: (value: T) => void;
1736
+ let reject!: (reason?: unknown) => void;
1737
+ const promise = new Promise<T>((res, rej) => {
1738
+ resolve = res;
1739
+ reject = rej;
1740
+ });
1741
+ return { promise, resolve, reject };
1742
+ }
1743
+
1744
+ private hashJson(value: unknown): string {
1745
+ return createHash('sha256').update(canonicalJsonStringify(value), 'utf8').digest('hex');
1746
+ }
1747
+
1748
+ private estimateStringTokens(text: string, overhead = 0): number {
1749
+ return Math.ceil(Buffer.byteLength(text, 'utf8') / 4) + overhead;
1750
+ }
1751
+
1752
+ private estimateUnknownTokens(value: unknown, overhead = 0): number {
1753
+ return this.estimateStringTokens(JSON.stringify(value) ?? 'null', overhead);
1754
+ }
1755
+
1756
+ private estimateRequestInputBoundTokens(request: NormalizedRequest): number {
1757
+ let total = 96;
1758
+ total += this.estimateStringTokens(request.system ?? '', 48);
1759
+ for (const message of request.messages) {
1760
+ total += 24;
1761
+ total += this.estimateStringTokens(message.participant, 8);
1762
+ if ((message as { cacheBreakpoint?: boolean }).cacheBreakpoint) total += 8;
1763
+ for (const block of message.content) {
1764
+ switch (block.type) {
1765
+ case 'text':
1766
+ total += this.estimateStringTokens(block.text, 24);
1767
+ break;
1768
+ case 'thinking':
1769
+ total += this.estimateStringTokens(block.thinking, 32);
1770
+ total += block.signature ? this.estimateStringTokens(block.signature, 8) : 0;
1771
+ break;
1772
+ case 'redacted_thinking':
1773
+ total += this.estimateUnknownTokens(block, 32);
1774
+ break;
1775
+ default:
1776
+ total += this.estimateUnknownTokens(block, 32);
1777
+ break;
1778
+ }
1779
+ }
1780
+ }
1781
+ if (request.tools && request.tools.length > 0) {
1782
+ total += 32;
1783
+ for (const tool of request.tools) {
1784
+ total += this.estimateUnknownTokens({
1785
+ name: tool.name,
1786
+ description: tool.description,
1787
+ inputSchema: tool.inputSchema,
1788
+ }, 48);
1789
+ }
1790
+ }
1791
+ total += this.estimateUnknownTokens(request.config, 48);
1792
+ if (request.providerParams !== undefined) {
1793
+ total += this.estimateUnknownTokens(request.providerParams, 32);
1794
+ }
1795
+ total += request.promptCaching ? 16 : 0;
1796
+ total += request.cacheTtl ? this.estimateStringTokens(request.cacheTtl, 8) : 0;
1797
+ total += request.assistantParticipant
1798
+ ? this.estimateStringTokens(request.assistantParticipant, 8)
1799
+ : 0;
1800
+ return total;
1801
+ }
1802
+
1803
+ private estimateRequestCompleteBoundTokens(request: NormalizedRequest): number {
1804
+ const reserve = typeof request.config.maxTokens === 'number' && Number.isFinite(request.config.maxTokens)
1805
+ ? request.config.maxTokens
1806
+ : 0;
1807
+ return this.estimateRequestInputBoundTokens(request) + reserve + 96;
1808
+ }
1809
+
1810
+ private primarySummaryAdmissionBoundTokens(
1811
+ originalRequestInputTokens: number,
1812
+ expandedRequest: NormalizedRequest,
1813
+ originalProviderInputTokens: number | undefined,
1814
+ ): number {
1815
+ const expandedInput = this.estimateRequestInputBoundTokens(expandedRequest);
1816
+ const expandedComplete = this.estimateRequestCompleteBoundTokens(expandedRequest);
1817
+ if (originalProviderInputTokens === undefined) return expandedComplete;
1818
+ const reserve = typeof expandedRequest.config.maxTokens === 'number' && Number.isFinite(expandedRequest.config.maxTokens)
1819
+ ? expandedRequest.config.maxTokens
1820
+ : 0;
1821
+ const positiveDelta = Math.max(0, expandedInput - originalRequestInputTokens);
1822
+ return Math.max(
1823
+ expandedComplete,
1824
+ originalProviderInputTokens + positiveDelta + reserve + 96,
1825
+ );
1826
+ }
1827
+
1828
+ private readPrimarySummaryFallbackState(): PrimarySummaryFallbackState {
1829
+ const raw = (this.store as { getStateJson?: (id: string) => unknown } | undefined)
1830
+ ?.getStateJson?.(PRIMARY_SUMMARY_FALLBACK_STATE_ID);
1831
+ if (!raw || typeof raw !== 'object') return { requests: [] };
1832
+ const requests = Array.isArray((raw as { requests?: unknown[] }).requests)
1833
+ ? ((raw as { requests: PrimarySummaryRequestRecord[] }).requests)
1834
+ : [];
1835
+ return { requests };
1836
+ }
1837
+
1838
+ private writePrimarySummaryFallbackState(state: PrimarySummaryFallbackState): void {
1839
+ this.store.setStateJson(PRIMARY_SUMMARY_FALLBACK_STATE_ID, state);
1840
+ }
1841
+
1842
+ private async withPrimarySummaryStateLock<T>(work: () => Promise<T> | T): Promise<T> {
1843
+ const previous = this.primarySummaryStateLock;
1844
+ let release!: () => void;
1845
+ this.primarySummaryStateLock = new Promise<void>((resolve) => { release = resolve; });
1846
+ await previous;
1847
+ try {
1848
+ return await work();
1849
+ } finally {
1850
+ release();
1851
+ }
1852
+ }
1853
+
1854
+ private async reconcilePrimarySummaryFallbackStateOnStartup(): Promise<void> {
1855
+ const alerts: Array<{ agentName: string; requestId: string; reason: string }> = [];
1856
+ const unsettledRequests = (await this.withPrimarySummaryStateLock(() =>
1857
+ this.readPrimarySummaryFallbackState().requests
1858
+ .filter((record) =>
1859
+ record.finalStatus === 'pending'
1860
+ || record.finalStatus === 'refusal'
1861
+ || record.fallbackStatus === 'pending',
1862
+ )
1863
+ .map((record) => structuredClone(record)),
1864
+ )) as PrimarySummaryRequestRecord[];
1865
+
1866
+ const resolvedRequestIds = new Set<string>();
1867
+ for (const record of unsettledRequests) {
1868
+ const result = await this.finalizePrimarySummarySettlementOnStartup(record);
1869
+ if (result === 'finalized') {
1870
+ resolvedRequestIds.add(record.requestId);
1871
+ continue;
1872
+ }
1873
+ if (result && typeof result === 'object' && 'invalidReason' in result) {
1874
+ resolvedRequestIds.add(record.requestId);
1875
+ alerts.push({ agentName: record.agentName, requestId: record.requestId, reason: result.invalidReason });
1876
+ await this.markPrimarySummaryRequestRestartFailure(record, result.invalidReason);
1877
+ }
1878
+ }
1879
+
1880
+ await this.withPrimarySummaryStateLock(() => {
1881
+ const state = this.readPrimarySummaryFallbackState();
1882
+ let changed = false;
1883
+ state.requests = state.requests.map((record) => {
1884
+ if (resolvedRequestIds.has(record.requestId)) return record;
1885
+ if (record.dispatchKind === 'primary_summary_fallback_retry' && (
1886
+ record.finalStatus === 'pending' || record.finalStatus === 'refusal'
1887
+ )) {
1888
+ changed = true;
1889
+ const reason = 'primary_summary_fallback_unresolved_on_restart';
1890
+ alerts.push({ agentName: record.agentName, requestId: record.requestId, reason });
1891
+ return {
1892
+ ...record,
1893
+ finalStatus: 'held',
1894
+ fallbackHeldReason: reason,
1895
+ };
1896
+ }
1897
+ if (record.fallbackIntent && record.fallbackStatus === 'pending') {
1898
+ changed = true;
1899
+ const reason = 'primary_summary_fallback_unresolved_on_restart';
1900
+ alerts.push({ agentName: record.agentName, requestId: record.requestId, reason });
1901
+ return {
1902
+ ...record,
1903
+ fallbackStatus: 'held',
1904
+ fallbackHeldReason: reason,
1905
+ };
1906
+ }
1907
+ if (record.finalStatus === 'refusal') {
1908
+ changed = true;
1909
+ const reason = 'primary_summary_request_unresolved_on_restart';
1910
+ alerts.push({ agentName: record.agentName, requestId: record.requestId, reason });
1911
+ return {
1912
+ ...record,
1913
+ finalStatus: 'held',
1914
+ fallbackStatus: 'held',
1915
+ fallbackHeldReason: reason,
1916
+ };
1917
+ }
1918
+ if (record.finalStatus === 'pending') {
1919
+ changed = true;
1920
+ const reason = 'primary_summary_request_unresolved_on_restart';
1921
+ alerts.push({ agentName: record.agentName, requestId: record.requestId, reason });
1922
+ return {
1923
+ ...record,
1924
+ finalStatus: 'timeout_unknown',
1925
+ fallbackHeldReason: reason,
1926
+ };
1927
+ }
1928
+ return record;
1929
+ });
1930
+ if (changed) this.writePrimarySummaryFallbackState({ requests: state.requests.slice(-256) });
1931
+ });
1932
+ for (const alert of alerts) {
1933
+ this.opsAlert('refusal-held', alert.agentName, alert.reason, {
1934
+ data: { requestId: alert.requestId },
1935
+ });
1936
+ }
1937
+ }
1938
+
1939
+ private primarySummaryIdentityKey(identity: PrimarySummaryIdentity): string {
1940
+ return [
1941
+ identity.id,
1942
+ identity.contentHash,
1943
+ identity.carrierHash,
1944
+ identity.sourceLeafHash,
1945
+ ].join('\u0000');
1946
+ }
1947
+
1948
+ private primarySummaryProjectionKeys(projection: PrimarySummaryProjection | null): string[] {
1949
+ return projection?.selectedSummaries.map((selection) =>
1950
+ this.primarySummaryIdentityKey(selection.identity),
1951
+ ) ?? [];
1952
+ }
1953
+
1954
+ private primarySummaryFallbackConfig(agent: Agent): {
1955
+ enabled: boolean;
1956
+ maxNewSummaries: number;
1957
+ requestBudgetTokens: number;
1958
+ } {
1959
+ const raw = (agent.refusalHandling as {
1960
+ primarySummaryFallback?: {
1961
+ enabled?: boolean;
1962
+ maxNewSummaries?: number;
1963
+ requestBudgetTokens?: number;
1964
+ };
1965
+ } | undefined)?.primarySummaryFallback;
1966
+ const fallbackBudgetBase = agent.contextBudgetTokens ?? 100_000;
1967
+ return {
1968
+ enabled: raw?.enabled === true,
1969
+ maxNewSummaries:
1970
+ typeof raw?.maxNewSummaries === 'number' && Number.isSafeInteger(raw.maxNewSummaries)
1971
+ ? Math.max(0, raw.maxNewSummaries)
1972
+ : 4,
1973
+ requestBudgetTokens:
1974
+ typeof raw?.requestBudgetTokens === 'number' && Number.isFinite(raw.requestBudgetTokens) && raw.requestBudgetTokens > 0
1975
+ ? raw.requestBudgetTokens
1976
+ : fallbackBudgetBase + agent.maxTokens,
1977
+ };
1978
+ }
1979
+
1980
+ private latestCompatibleHealthyPrimaryRequest(
1981
+ current: PrimarySummaryRequestRecord,
1982
+ state: PrimarySummaryFallbackState,
1983
+ ): PrimarySummaryRequestRecord | null {
1984
+ for (let i = state.requests.length - 1; i >= 0; i--) {
1985
+ const candidate = state.requests[i]!;
1986
+ if (candidate.requestId === current.requestId) continue;
1987
+ if (candidate.agentName !== current.agentName) continue;
1988
+ if (candidate.namespace !== current.namespace) continue;
1989
+ if (candidate.branch.id !== current.branch.id) continue;
1990
+ if (candidate.branch.generation !== current.branch.generation) continue;
1991
+ if (candidate.systemHash !== current.systemHash) continue;
1992
+ if (candidate.modelConfigHash !== current.modelConfigHash) continue;
1993
+ if (candidate.toolContractHash !== current.toolContractHash) continue;
1994
+ if (candidate.dispatchKind !== 'primary') continue;
1995
+ if (candidate.fallbackIntent || candidate.fallbackStatus) continue;
1996
+ if (candidate.finalStatus !== 'success') continue;
1997
+ return candidate;
1998
+ }
1999
+ return null;
2000
+ }
2001
+
2002
+ private async persistPrimarySummaryRequestRecord(record: PrimarySummaryRequestRecord): Promise<void> {
2003
+ await this.withPrimarySummaryStateLock(() => {
2004
+ const state = this.readPrimarySummaryFallbackState();
2005
+ const requests = state.requests.filter((entry) => entry.requestId !== record.requestId);
2006
+ requests.push(record);
2007
+ this.writePrimarySummaryFallbackState({ requests: requests.slice(-256) });
2008
+ });
2009
+ }
2010
+
2011
+ private async updatePrimarySummaryRequestRecord(
2012
+ requestId: string,
2013
+ update: (record: PrimarySummaryRequestRecord) => PrimarySummaryRequestRecord,
2014
+ ): Promise<PrimarySummaryRequestRecord | null> {
2015
+ return this.withPrimarySummaryStateLock(() => {
2016
+ const state = this.readPrimarySummaryFallbackState();
2017
+ const index = state.requests.findIndex((entry) => entry.requestId === requestId);
2018
+ if (index < 0) return null;
2019
+ state.requests[index] = update(state.requests[index]!);
2020
+ this.writePrimarySummaryFallbackState({ requests: state.requests.slice(-256) });
2021
+ return state.requests[index]!;
2022
+ });
2023
+ }
2024
+
2025
+ private async getPrimarySummaryRequestRecord(requestId: string): Promise<PrimarySummaryRequestRecord | null> {
2026
+ return this.withPrimarySummaryStateLock(() => {
2027
+ const state = this.readPrimarySummaryFallbackState();
2028
+ return state.requests.find((entry) => entry.requestId === requestId) ?? null;
2029
+ });
2030
+ }
2031
+
2032
+ private primarySummaryContractsMatch(
2033
+ left: PrimarySummaryContract & { systemHash: string },
2034
+ right: PrimarySummaryContract & { systemHash: string },
2035
+ ): boolean {
2036
+ return left.systemHash === right.systemHash
2037
+ && left.modelConfigHash === right.modelConfigHash
2038
+ && left.toolContractHash === right.toolContractHash;
2039
+ }
2040
+
2041
+ private primarySummaryFallbackOutputPhaseHash(blocks: ReadonlyArray<ContentBlock>): string {
2042
+ return this.hashJson(blocks);
2043
+ }
2044
+
2045
+ private primarySummaryFallbackBlockTypes(blocks: ReadonlyArray<ContentBlock>): string[] {
2046
+ return blocks.map((block) => block.type);
2047
+ }
2048
+
2049
+ private static readonly NON_EXECUTED_TOOL_RESULT_TEXT = '[tool not executed]';
2050
+
2051
+ private buildNonExecutedToolResults(
2052
+ blocks: ReadonlyArray<ContentBlock>,
2053
+ ): ContentBlock[] {
2054
+ const seenToolUseIds = new Set<string>();
2055
+ return blocks
2056
+ .filter((block): block is ContentBlock & { type: 'tool_use'; id: string } => block.type === 'tool_use')
2057
+ .filter((block) => {
2058
+ if (seenToolUseIds.has(block.id)) return false;
2059
+ seenToolUseIds.add(block.id);
2060
+ return true;
2061
+ })
2062
+ .map((block) => ({
2063
+ type: 'tool_result' as const,
2064
+ toolUseId: block.id,
2065
+ content: AgentFramework.NON_EXECUTED_TOOL_RESULT_TEXT,
2066
+ isError: true,
2067
+ }));
2068
+ }
2069
+
2070
+ private buildPrimarySummarySettlementId(
2071
+ record: Pick<PrimarySummaryRequestRecord, 'dispatchKind'>,
2072
+ outcome: PrimarySummarySettlementOutcome,
2073
+ ): string {
2074
+ return `${record.dispatchKind}:${outcome}:terminal:v${PRIMARY_SUMMARY_SETTLEMENT_VERSION}`;
2075
+ }
2076
+
2077
+ private buildPrimarySummarySettlementMetadata(
2078
+ record: PrimarySummaryRequestRecord,
2079
+ options: {
2080
+ outcome: PrimarySummarySettlementOutcome;
2081
+ settlementId?: string;
2082
+ stopReason?: string;
2083
+ holdReason?: string;
2084
+ providerInputTokens?: number;
2085
+ visibleAssistantOutput: boolean;
2086
+ executedToolCalls: number;
2087
+ entryIndex: number;
2088
+ entryCount: number;
2089
+ role: PrimarySummarySettlementRole;
2090
+ kind: PrimarySummarySettlementKind;
2091
+ },
2092
+ ): MessageMetadata {
2093
+ const settlementId = options.settlementId ?? this.buildPrimarySummarySettlementId(record, options.outcome);
2094
+ return {
2095
+ [PRIMARY_SUMMARY_SETTLEMENT_METADATA_KEY]: {
2096
+ version: PRIMARY_SUMMARY_SETTLEMENT_VERSION,
2097
+ requestId: record.requestId,
2098
+ settlementId,
2099
+ agentName: record.agentName,
2100
+ dispatchKind: record.dispatchKind,
2101
+ outcome: options.outcome,
2102
+ ...(options.stopReason ? { stopReason: options.stopReason } : {}),
2103
+ ...(options.holdReason ? { holdReason: options.holdReason } : {}),
2104
+ ...(options.providerInputTokens !== undefined
2105
+ ? { providerInputTokens: options.providerInputTokens }
2106
+ : {}),
2107
+ visibleAssistantOutput: options.visibleAssistantOutput,
2108
+ executedToolCalls: options.executedToolCalls,
2109
+ branch: {
2110
+ id: record.branch.id,
2111
+ name: record.branch.name,
2112
+ generation: record.branch.generation,
2113
+ },
2114
+ role: options.role,
2115
+ kind: options.kind,
2116
+ entryIndex: options.entryIndex,
2117
+ entryCount: options.entryCount,
2118
+ } satisfies PrimarySummarySettlementMetadata,
2119
+ };
2120
+ }
2121
+
2122
+ private isBoundedNonEmptyString(value: unknown, maxLength: number): value is string {
2123
+ return typeof value === 'string' && value.length > 0 && value.length <= maxLength;
2124
+ }
2125
+
2126
+ private isSafeNonNegativeInteger(value: unknown): value is number {
2127
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
2128
+ }
2129
+
2130
+ private primarySummarySettlementRequestId(message: StoredMessage): string | null {
2131
+ const raw = message.metadata?.[PRIMARY_SUMMARY_SETTLEMENT_METADATA_KEY];
2132
+ if (!raw || typeof raw !== 'object') return null;
2133
+ const requestId = (raw as { requestId?: unknown }).requestId;
2134
+ return typeof requestId === 'string' ? requestId : null;
2135
+ }
2136
+
2137
+ private parsePrimarySummarySettlementMetadata(
2138
+ message: StoredMessage,
2139
+ ): PrimarySummarySettlementMetadata | { invalidReason: string } | null {
2140
+ const raw = message.metadata?.[PRIMARY_SUMMARY_SETTLEMENT_METADATA_KEY];
2141
+ if (!raw || typeof raw !== 'object') return null;
2142
+ const candidate = raw as Partial<PrimarySummarySettlementMetadata>;
2143
+ if (
2144
+ candidate.version !== PRIMARY_SUMMARY_SETTLEMENT_VERSION ||
2145
+ !this.isBoundedNonEmptyString(candidate.requestId, PRIMARY_SUMMARY_SETTLEMENT_MAX_ID_LENGTH) ||
2146
+ !this.isBoundedNonEmptyString(candidate.settlementId, PRIMARY_SUMMARY_SETTLEMENT_MAX_ID_LENGTH) ||
2147
+ !this.isBoundedNonEmptyString(candidate.agentName, PRIMARY_SUMMARY_SETTLEMENT_MAX_NAME_LENGTH) ||
2148
+ (candidate.dispatchKind !== 'primary' && candidate.dispatchKind !== 'primary_summary_fallback_retry') ||
2149
+ (candidate.outcome !== 'success' && candidate.outcome !== 'held') ||
2150
+ typeof candidate.visibleAssistantOutput !== 'boolean' ||
2151
+ !this.isSafeNonNegativeInteger(candidate.executedToolCalls) ||
2152
+ !this.isSafeNonNegativeInteger(candidate.entryIndex) ||
2153
+ !this.isSafeNonNegativeInteger(candidate.entryCount) ||
2154
+ (candidate.role !== 'assistant' && candidate.role !== 'user') ||
2155
+ (candidate.kind !== 'assistant_output' && candidate.kind !== 'generated_tool_result') ||
2156
+ !candidate.branch ||
2157
+ !this.isBoundedNonEmptyString(candidate.branch.id, PRIMARY_SUMMARY_SETTLEMENT_MAX_ID_LENGTH) ||
2158
+ !this.isBoundedNonEmptyString(candidate.branch.name, PRIMARY_SUMMARY_SETTLEMENT_MAX_NAME_LENGTH) ||
2159
+ !this.isSafeNonNegativeInteger(candidate.branch.generation) ||
2160
+ candidate.entryCount < 1 ||
2161
+ candidate.entryIndex >= candidate.entryCount ||
2162
+ (candidate.stopReason !== undefined
2163
+ && !this.isBoundedNonEmptyString(candidate.stopReason, PRIMARY_SUMMARY_SETTLEMENT_MAX_REASON_LENGTH)) ||
2164
+ (candidate.holdReason !== undefined
2165
+ && !this.isBoundedNonEmptyString(candidate.holdReason, PRIMARY_SUMMARY_SETTLEMENT_MAX_REASON_LENGTH)) ||
2166
+ (candidate.providerInputTokens !== undefined
2167
+ && !this.isSafeNonNegativeInteger(candidate.providerInputTokens)) ||
2168
+ (candidate.outcome === 'held' && candidate.holdReason === undefined) ||
2169
+ (candidate.outcome === 'success' && candidate.holdReason !== undefined)
2170
+ ) {
2171
+ return { invalidReason: 'primary_summary_settlement_metadata_invalid_on_restart' };
2172
+ }
2173
+ return candidate as PrimarySummarySettlementMetadata;
2174
+ }
2175
+
2176
+ private primarySummarySettlementMetadataConsistent(
2177
+ left: PrimarySummarySettlementMetadata,
2178
+ right: PrimarySummarySettlementMetadata,
2179
+ ): boolean {
2180
+ return left.version === right.version
2181
+ && left.requestId === right.requestId
2182
+ && left.settlementId === right.settlementId
2183
+ && left.agentName === right.agentName
2184
+ && left.dispatchKind === right.dispatchKind
2185
+ && left.outcome === right.outcome
2186
+ && left.stopReason === right.stopReason
2187
+ && left.holdReason === right.holdReason
2188
+ && left.providerInputTokens === right.providerInputTokens
2189
+ && left.visibleAssistantOutput === right.visibleAssistantOutput
2190
+ && left.executedToolCalls === right.executedToolCalls
2191
+ && left.branch.id === right.branch.id
2192
+ && left.branch.name === right.branch.name
2193
+ && left.branch.generation === right.branch.generation
2194
+ && left.entryCount === right.entryCount;
2195
+ }
2196
+
2197
+ private primarySummaryShardEnvelopeConsistent(
2198
+ participant: string,
2199
+ left: PrimarySummarySettlementMetadata,
2200
+ candidateParticipant: string,
2201
+ right: PrimarySummarySettlementMetadata,
2202
+ ): boolean {
2203
+ return participant === candidateParticipant
2204
+ && this.primarySummarySettlementMetadataConsistent(left, right)
2205
+ && left.role === right.role
2206
+ && left.kind === right.kind
2207
+ && left.entryIndex === right.entryIndex;
2208
+ }
2209
+
2210
+ private primarySummaryLogicalSettlementEntries(
2211
+ agent: Agent,
2212
+ requestId: string,
2213
+ ): PrimarySummaryLogicalSettlementEntry[] | { invalidReason: string } {
2214
+ const allMessages = agent.getContextManager().getAllMessages()
2215
+ .slice()
2216
+ .sort((left, right) => left.sequence - right.sequence);
2217
+ const entries: PrimarySummaryLogicalSettlementEntry[] = [];
2218
+ for (let index = 0; index < allMessages.length;) {
2219
+ const message = allMessages[index]!;
2220
+ if (this.primarySummarySettlementRequestId(message) !== requestId) {
2221
+ index++;
2222
+ continue;
2223
+ }
2224
+ const parsed = this.parsePrimarySummarySettlementMetadata(message);
2225
+ if (!parsed) {
2226
+ index++;
2227
+ continue;
2228
+ }
2229
+ if ('invalidReason' in parsed) return parsed;
2230
+
2231
+ if (message.bodyGroupId === undefined) {
2232
+ if (message.shardIndex !== undefined) {
2233
+ return { invalidReason: 'primary_summary_settlement_shard_group_invalid_on_restart' };
2234
+ }
2235
+ entries.push({
2236
+ messages: [message],
2237
+ settlement: parsed,
2238
+ participant: message.participant,
2239
+ content: message.content,
2240
+ });
2241
+ index++;
2242
+ continue;
2243
+ }
2244
+
2245
+ const groupId = message.bodyGroupId;
2246
+ if (!this.isBoundedNonEmptyString(groupId, PRIMARY_SUMMARY_SETTLEMENT_MAX_ID_LENGTH)) {
2247
+ return { invalidReason: 'primary_summary_settlement_shard_group_invalid_on_restart' };
2248
+ }
2249
+ if (index > 0 && allMessages[index - 1]?.bodyGroupId === groupId) {
2250
+ return { invalidReason: 'primary_summary_settlement_shard_group_invalid_on_restart' };
2251
+ }
2252
+
2253
+ const groupMessages: StoredMessage[] = [];
2254
+ while (index < allMessages.length && allMessages[index]!.bodyGroupId === groupId) {
2255
+ groupMessages.push(allMessages[index]!);
2256
+ index++;
2257
+ }
2258
+ if (allMessages.slice(index).some((candidate) => candidate.bodyGroupId === groupId)) {
2259
+ return { invalidReason: 'primary_summary_settlement_shard_group_invalid_on_restart' };
2260
+ }
2261
+
2262
+ const orderedGroupMessages: StoredMessage[] = [];
2263
+ let expectedShardIndex = 0;
2264
+ for (const groupMessage of groupMessages) {
2265
+ if (this.primarySummarySettlementRequestId(groupMessage) !== requestId) {
2266
+ return { invalidReason: 'primary_summary_settlement_shard_group_invalid_on_restart' };
2267
+ }
2268
+ const groupParsed = this.parsePrimarySummarySettlementMetadata(groupMessage);
2269
+ if (!groupParsed) {
2270
+ return { invalidReason: 'primary_summary_settlement_shard_group_invalid_on_restart' };
2271
+ }
2272
+ if ('invalidReason' in groupParsed) return groupParsed;
2273
+ if (!this.primarySummaryShardEnvelopeConsistent(
2274
+ message.participant,
2275
+ parsed,
2276
+ groupMessage.participant,
2277
+ groupParsed,
2278
+ )) {
2279
+ return { invalidReason: 'primary_summary_settlement_shard_group_invalid_on_restart' };
2280
+ }
2281
+ if (groupMessage.shardIndex !== expectedShardIndex) {
2282
+ return { invalidReason: 'primary_summary_settlement_shard_group_invalid_on_restart' };
2283
+ }
2284
+ orderedGroupMessages.push(groupMessage);
2285
+ expectedShardIndex++;
2286
+ }
2287
+
2288
+ entries.push({
2289
+ messages: orderedGroupMessages,
2290
+ settlement: parsed,
2291
+ participant: message.participant,
2292
+ content: orderedGroupMessages.flatMap((groupMessage) => groupMessage.content),
2293
+ bodyGroupId: groupId,
2294
+ });
2295
+ }
2296
+ return entries;
2297
+ }
2298
+
2299
+ private primarySummaryContentMatches(
2300
+ left: ReadonlyArray<ContentBlock>,
2301
+ right: ReadonlyArray<ContentBlock>,
2302
+ ): boolean {
2303
+ return this.primarySummaryFallbackOutputPhaseHash(left) === this.primarySummaryFallbackOutputPhaseHash(right);
2304
+ }
2305
+
2306
+ private async persistPrimarySummaryChronicleEntries(
2307
+ agent: Agent,
2308
+ entries: Array<{ participant: string; content: ContentBlock[]; metadata?: MessageMetadata }>,
2309
+ ): Promise<string[]> {
2310
+ const messageIds: string[] = [];
2311
+ for (const entry of entries) {
2312
+ if (entry.content.length === 0) continue;
2313
+ messageIds.push(agent.getContextManager().addMessage(entry.participant, entry.content, entry.metadata));
2314
+ }
2315
+ return messageIds;
2316
+ }
2317
+
2318
+ private async markPrimarySummaryRequestRestartFailure(
2319
+ record: PrimarySummaryRequestRecord,
2320
+ reason: string,
2321
+ ): Promise<void> {
2322
+ if (record.dispatchKind === 'primary_summary_fallback_retry') {
2323
+ const parent = await this.findPrimarySummaryRetryParent(record.requestId);
2324
+ await this.updatePrimarySummaryRequestRecord(record.requestId, (entry) => ({
2325
+ ...entry,
2326
+ finalStatus: 'held',
2327
+ fallbackHeldReason: reason,
2328
+ }));
2329
+ if (parent) {
2330
+ await this.updatePrimarySummaryRequestRecord(parent.originalRequestId, (entry) => ({
2331
+ ...entry,
2332
+ finalStatus: 'held',
2333
+ fallbackStatus: 'held',
2334
+ fallbackHeldReason: reason,
2335
+ }));
2336
+ }
2337
+ return;
2338
+ }
2339
+ await this.updatePrimarySummaryRequestRecord(record.requestId, (current) => ({
2340
+ ...current,
2341
+ finalStatus: 'held',
2342
+ fallbackStatus: 'held',
2343
+ fallbackHeldReason: reason,
2344
+ }));
2345
+ }
2346
+
2347
+ private async persistPrimarySummaryHeldOutput(
2348
+ agent: Agent,
2349
+ requestId: string,
2350
+ holdReason: string,
2351
+ options: {
2352
+ stopReason?: string;
2353
+ providerInputTokens?: number;
2354
+ visibleAssistantOutput: boolean;
2355
+ executedToolCalls: number;
2356
+ assistantBlocks: ContentBlock[];
2357
+ retryDispatch?: PrimarySummaryFallbackRetryDispatch;
2358
+ retryPhase?: string;
2359
+ holdData?: Record<string, unknown>;
2360
+ },
2361
+ ): Promise<void> {
2362
+ const record = await this.getPrimarySummaryRequestRecord(requestId);
2363
+ if (!record) {
2364
+ throw new Error(`Primary summary request '${requestId}' disappeared before held-output persistence`);
2365
+ }
2366
+ const placeholderBlocks = this.buildNonExecutedToolResults(options.assistantBlocks);
2367
+ const entryCount = placeholderBlocks.length > 0 ? 2 : 1;
2368
+ const assistantMetadata = this.buildPrimarySummarySettlementMetadata(record, {
2369
+ outcome: 'held',
2370
+ stopReason: options.stopReason,
2371
+ holdReason,
2372
+ providerInputTokens: options.providerInputTokens,
2373
+ visibleAssistantOutput: options.visibleAssistantOutput,
2374
+ executedToolCalls: options.executedToolCalls,
2375
+ entryIndex: 0,
2376
+ entryCount,
2377
+ role: 'assistant',
2378
+ kind: 'assistant_output',
2379
+ });
2380
+ const settlementId =
2381
+ (assistantMetadata[PRIMARY_SUMMARY_SETTLEMENT_METADATA_KEY] as PrimarySummarySettlementMetadata).settlementId;
2382
+ const placeholderMetadata = placeholderBlocks.length > 0
2383
+ ? this.buildPrimarySummarySettlementMetadata(record, {
2384
+ outcome: 'held',
2385
+ settlementId,
2386
+ stopReason: options.stopReason,
2387
+ holdReason,
2388
+ providerInputTokens: options.providerInputTokens,
2389
+ visibleAssistantOutput: options.visibleAssistantOutput,
2390
+ executedToolCalls: options.executedToolCalls,
2391
+ entryIndex: 1,
2392
+ entryCount,
2393
+ role: 'user',
2394
+ kind: 'generated_tool_result',
2395
+ })
2396
+ : undefined;
2397
+ const messageIds = await this.persistPrimarySummaryChronicleEntries(agent, [
2398
+ { participant: agent.name, content: options.assistantBlocks, metadata: assistantMetadata },
2399
+ ...(placeholderBlocks.length > 0 && placeholderMetadata
2400
+ ? [{ participant: 'user', content: placeholderBlocks, metadata: placeholderMetadata }]
2401
+ : []),
2402
+ ]);
2403
+ if (options.retryDispatch && options.retryPhase && options.assistantBlocks.length > 0) {
2404
+ await this.recordPrimarySummaryRetryOutput(
2405
+ options.retryDispatch.originalRequestId,
2406
+ options.retryDispatch.intentId,
2407
+ {
2408
+ phase: options.retryPhase,
2409
+ contentHash: this.primarySummaryFallbackOutputPhaseHash(options.assistantBlocks),
2410
+ messageId: messageIds[0] ?? '',
2411
+ blockTypes: this.primarySummaryFallbackBlockTypes(options.assistantBlocks),
2412
+ persistedAt: Date.now(),
2413
+ },
2414
+ );
2415
+ }
2416
+ if (options.retryDispatch) {
2417
+ await this.holdPrimarySummaryRetryFamily(
2418
+ agent,
2419
+ requestId,
2420
+ options.retryDispatch,
2421
+ holdReason,
2422
+ options.holdData,
2423
+ );
2424
+ return;
2425
+ }
2426
+ await this.holdPrimarySummaryRequest(agent, requestId, holdReason, options.holdData);
2427
+ }
2428
+
2429
+ private async findPrimarySummaryRetryParent(
2430
+ retryRequestId: string,
2431
+ ): Promise<{ originalRequestId: string; intentId: string } | null> {
2432
+ return this.withPrimarySummaryStateLock(() => {
2433
+ const state = this.readPrimarySummaryFallbackState();
2434
+ for (const record of state.requests) {
2435
+ const intent = record.fallbackIntent;
2436
+ if (intent?.retryRequestId === retryRequestId) {
2437
+ return { originalRequestId: record.requestId, intentId: intent.intentId };
2438
+ }
2439
+ }
2440
+ return null;
2441
+ });
2442
+ }
2443
+
2444
+ private async finalizePrimarySummarySettlementOnStartup(
2445
+ record: PrimarySummaryRequestRecord,
2446
+ ): Promise<'finalized' | { invalidReason: string } | false> {
2447
+ const agent = this.agents.get(record.agentName);
2448
+ if (!agent) return false;
2449
+ const settlementEntries = this.primarySummaryLogicalSettlementEntries(agent, record.requestId);
2450
+ if ('invalidReason' in settlementEntries) return settlementEntries;
2451
+ if (settlementEntries.length === 0) {
2452
+ return false;
2453
+ }
2454
+ const currentBranch = agent.getCurrentBranchGeneration();
2455
+ const assistantEntry = settlementEntries[0]!;
2456
+ const settlement = assistantEntry.settlement;
2457
+ if (
2458
+ assistantEntry.participant !== record.agentName ||
2459
+ settlement.agentName !== record.agentName ||
2460
+ settlement.dispatchKind !== record.dispatchKind ||
2461
+ settlement.entryIndex !== 0 ||
2462
+ settlement.role !== 'assistant' ||
2463
+ settlement.kind !== 'assistant_output'
2464
+ ) {
2465
+ return { invalidReason: 'primary_summary_settlement_invalid_assistant_marker_on_restart' };
2466
+ }
2467
+ if (!currentBranch) {
2468
+ return { invalidReason: 'primary_summary_settlement_branch_unknown_on_restart' };
2469
+ }
2470
+ if (
2471
+ settlement.branch.id !== record.branch.id ||
2472
+ settlement.branch.name !== record.branch.name ||
2473
+ settlement.branch.generation !== record.branch.generation ||
2474
+ settlement.branch.id !== currentBranch.id ||
2475
+ settlement.branch.name !== currentBranch.name ||
2476
+ settlement.branch.generation !== currentBranch.generation
2477
+ ) {
2478
+ return { invalidReason: 'primary_summary_settlement_branch_mismatch_on_restart' };
2479
+ }
2480
+ if (settlement.entryCount < 1 || settlement.entryCount > 2) {
2481
+ return { invalidReason: 'primary_summary_settlement_entry_count_invalid_on_restart' };
2482
+ }
2483
+ const entriesByIndex = new Map<number, PrimarySummaryLogicalSettlementEntry>();
2484
+ for (const entry of settlementEntries) {
2485
+ if (!this.primarySummarySettlementMetadataConsistent(settlement, entry.settlement)) {
2486
+ return { invalidReason: 'primary_summary_settlement_marker_conflict_on_restart' };
2487
+ }
2488
+ if (entriesByIndex.has(entry.settlement.entryIndex)) {
2489
+ return { invalidReason: 'primary_summary_settlement_duplicate_entry_on_restart' };
2490
+ }
2491
+ entriesByIndex.set(entry.settlement.entryIndex, entry);
2492
+ }
2493
+ const placeholderEntry = entriesByIndex.get(1);
2494
+ if (
2495
+ placeholderEntry
2496
+ && (
2497
+ placeholderEntry.participant !== 'user'
2498
+ || placeholderEntry.settlement.role !== 'user'
2499
+ || placeholderEntry.settlement.kind !== 'generated_tool_result'
2500
+ )
2501
+ ) {
2502
+ return { invalidReason: 'primary_summary_settlement_placeholder_marker_invalid_on_restart' };
2503
+ }
2504
+ if ([...entriesByIndex.keys()].some((index) => index !== 0 && index !== 1)) {
2505
+ return { invalidReason: 'primary_summary_settlement_out_of_order_entries_on_restart' };
2506
+ }
2507
+ const expectedPlaceholder = settlement.outcome === 'held'
2508
+ ? this.buildNonExecutedToolResults(assistantEntry.content)
2509
+ : [];
2510
+ if (settlement.outcome === 'success') {
2511
+ if (settlement.entryCount !== 1 || placeholderEntry) {
2512
+ return { invalidReason: 'primary_summary_success_settlement_shape_invalid_on_restart' };
2513
+ }
2514
+ } else {
2515
+ const expectedEntryCount = expectedPlaceholder.length > 0 ? 2 : 1;
2516
+ if (settlement.entryCount !== expectedEntryCount) {
2517
+ return { invalidReason: 'primary_summary_held_settlement_shape_invalid_on_restart' };
2518
+ }
2519
+ if (placeholderEntry) {
2520
+ if (!this.primarySummaryContentMatches(placeholderEntry.content, expectedPlaceholder)) {
2521
+ return { invalidReason: 'primary_summary_settlement_placeholder_content_mismatch_on_restart' };
2522
+ }
2523
+ } else if (expectedPlaceholder.length > 0) {
2524
+ const placeholderMetadata = this.buildPrimarySummarySettlementMetadata(record, {
2525
+ outcome: 'held',
2526
+ settlementId: settlement.settlementId,
2527
+ stopReason: settlement.stopReason,
2528
+ holdReason: settlement.holdReason,
2529
+ providerInputTokens: settlement.providerInputTokens,
2530
+ visibleAssistantOutput: settlement.visibleAssistantOutput,
2531
+ executedToolCalls: settlement.executedToolCalls,
2532
+ entryIndex: 1,
2533
+ entryCount: settlement.entryCount,
2534
+ role: 'user',
2535
+ kind: 'generated_tool_result',
2536
+ });
2537
+ await this.persistPrimarySummaryChronicleEntries(agent, [
2538
+ { participant: 'user', content: expectedPlaceholder, metadata: placeholderMetadata },
2539
+ ]);
2540
+ }
2541
+ }
2542
+
2543
+ if (record.dispatchKind === 'primary_summary_fallback_retry') {
2544
+ const parent = await this.findPrimarySummaryRetryParent(record.requestId);
2545
+ if (!parent) return { invalidReason: 'primary_summary_fallback_parent_missing_on_restart' };
2546
+ if (settlement.outcome === 'success') {
2547
+ await this.updatePrimarySummaryRequestRecord(record.requestId, (entry) => ({
2548
+ ...entry,
2549
+ stopReason: settlement.stopReason ?? entry.stopReason,
2550
+ providerInputTokens: settlement.providerInputTokens ?? entry.providerInputTokens,
2551
+ visibleAssistantOutput: settlement.visibleAssistantOutput,
2552
+ executedToolCalls: settlement.executedToolCalls,
2553
+ finalStatus: 'success',
2554
+ fallbackHeldReason: undefined,
2555
+ }));
2556
+ await this.updatePrimarySummaryRequestRecord(parent.originalRequestId, (entry) => {
2557
+ const intent = entry.fallbackIntent;
2558
+ return {
2559
+ ...entry,
2560
+ finalStatus: 'success',
2561
+ fallbackStatus: 'success',
2562
+ fallbackHeldReason: undefined,
2563
+ fallbackIntent: intent && intent.intentId === parent.intentId
2564
+ ? { ...intent, finalizedAt: intent.finalizedAt ?? Date.now() }
2565
+ : intent,
2566
+ };
2567
+ });
2568
+ return 'finalized';
2569
+ }
2570
+ await this.updatePrimarySummaryRequestRecord(record.requestId, (entry) => ({
2571
+ ...entry,
2572
+ stopReason: settlement.stopReason ?? entry.stopReason,
2573
+ providerInputTokens: settlement.providerInputTokens ?? entry.providerInputTokens,
2574
+ visibleAssistantOutput: settlement.visibleAssistantOutput,
2575
+ executedToolCalls: settlement.executedToolCalls,
2576
+ finalStatus: 'held',
2577
+ fallbackHeldReason: settlement.holdReason,
2578
+ }));
2579
+ await this.updatePrimarySummaryRequestRecord(parent.originalRequestId, (entry) => ({
2580
+ ...entry,
2581
+ finalStatus: 'held',
2582
+ fallbackStatus: 'held',
2583
+ fallbackHeldReason: settlement.holdReason,
2584
+ }));
2585
+ return 'finalized';
2586
+ }
2587
+
2588
+ await this.updatePrimarySummaryRequestRecord(record.requestId, (entry) => ({
2589
+ ...entry,
2590
+ stopReason: settlement.stopReason ?? entry.stopReason,
2591
+ providerInputTokens: settlement.providerInputTokens ?? entry.providerInputTokens,
2592
+ visibleAssistantOutput: settlement.visibleAssistantOutput,
2593
+ executedToolCalls: settlement.executedToolCalls,
2594
+ finalStatus: settlement.outcome === 'success' ? 'success' : 'held',
2595
+ fallbackStatus: settlement.outcome === 'success' ? entry.fallbackStatus : 'held',
2596
+ fallbackHeldReason: settlement.outcome === 'held' ? settlement.holdReason : undefined,
2597
+ }));
2598
+ return 'finalized';
2599
+ }
2600
+
2601
+ private summarizePrimarySummaryRetryRequest(request: NormalizedRequest): Record<string, unknown> {
2602
+ const blockCounts = new Map<string, number>();
2603
+ for (const message of request.messages) {
2604
+ for (const block of message.content) {
2605
+ blockCounts.set(block.type, (blockCounts.get(block.type) ?? 0) + 1);
2606
+ }
2607
+ }
2608
+ return {
2609
+ kind: 'primary_summary_fallback_retry',
2610
+ requestHash: this.hashJson(request),
2611
+ systemHash: this.hashJson(request.system ?? ''),
2612
+ messageCount: request.messages.length,
2613
+ toolCount: request.tools?.length ?? 0,
2614
+ participantCount: [...new Set(request.messages.map((message) => message.participant))].length,
2615
+ blockCounts: Object.fromEntries([...blockCounts.entries()].sort(([left], [right]) => left.localeCompare(right))),
2616
+ promptCaching: request.promptCaching === true,
2617
+ cacheTtl: request.cacheTtl,
2618
+ assistantParticipant: request.assistantParticipant,
2619
+ requestCompleteBoundTokens: this.estimateRequestCompleteBoundTokens(request),
2620
+ };
2621
+ }
2622
+
2623
+ private summarizePrimarySummaryRetryResponse(response: NormalizedResponse): Record<string, unknown> {
2624
+ const blockCounts = new Map<string, number>();
2625
+ for (const block of response.content) {
2626
+ blockCounts.set(block.type, (blockCounts.get(block.type) ?? 0) + 1);
2627
+ }
2628
+ return {
2629
+ kind: 'primary_summary_fallback_retry',
2630
+ stopReason: response.stopReason,
2631
+ rawAssistantTextHash: response.rawAssistantText
2632
+ ? this.hashJson(response.rawAssistantText)
2633
+ : undefined,
2634
+ blockCounts: Object.fromEntries([...blockCounts.entries()].sort(([left], [right]) => left.localeCompare(right))),
2635
+ toolCallIds: response.toolCalls.map((call: { id: string }) => call.id),
2636
+ usage: response.usage
2637
+ ? {
2638
+ inputTokens: response.usage.inputTokens,
2639
+ outputTokens: response.usage.outputTokens,
2640
+ }
2641
+ : undefined,
2642
+ };
2643
+ }
2644
+
2645
+ private async claimPrimarySummaryRetryIntent(
2646
+ originalRequestId: string,
2647
+ intentId: string,
2648
+ retryRequestId: string,
2649
+ ): Promise<'claimed' | 'duplicate' | 'missing'> {
2650
+ let outcome: 'claimed' | 'duplicate' | 'missing' = 'missing';
2651
+ await this.updatePrimarySummaryRequestRecord(originalRequestId, (record) => {
2652
+ const intent = record.fallbackIntent;
2653
+ if (!intent || intent.intentId !== intentId || record.fallbackStatus !== 'pending') {
2654
+ return record;
2655
+ }
2656
+ if (intent.retryRequestId && intent.retryRequestId !== retryRequestId) {
2657
+ outcome = 'duplicate';
2658
+ return record;
2659
+ }
2660
+ outcome = 'claimed';
2661
+ return {
2662
+ ...record,
2663
+ fallbackIntent: {
2664
+ ...intent,
2665
+ retryRequestId,
2666
+ dispatchedAt: intent.dispatchedAt ?? Date.now(),
2667
+ },
2668
+ };
2669
+ });
2670
+ return outcome;
2671
+ }
2672
+
2673
+ private async validatePrimarySummaryRetryMutation(
2674
+ agent: Agent,
2675
+ requestId: string,
2676
+ retryDispatch: PrimarySummaryFallbackRetryDispatch,
2677
+ compiledRequest: NormalizedRequest,
2678
+ artifacts: ActivationCompileArtifacts,
2679
+ phase: string,
2680
+ ): Promise<{ ok: true } | { ok: false; reason: string; data: Record<string, unknown> }> {
2681
+ const branch = agent.getCurrentBranchGeneration();
2682
+ if (!branch || branch.id !== retryDispatch.branch.id || branch.generation !== retryDispatch.branch.generation) {
2683
+ return {
2684
+ ok: false,
2685
+ reason: `primary_summary_fallback_branch_changed_${phase}`,
2686
+ data: {
2687
+ expectedBranchId: retryDispatch.branch.id,
2688
+ expectedGeneration: retryDispatch.branch.generation,
2689
+ actualBranchId: branch?.id,
2690
+ actualGeneration: branch?.generation,
2691
+ },
2692
+ };
2693
+ }
2694
+ const requestHash = this.hashJson(compiledRequest);
2695
+ if (requestHash !== retryDispatch.expectedRetryRequestHash) {
2696
+ return {
2697
+ ok: false,
2698
+ reason: `primary_summary_fallback_request_changed_${phase}`,
2699
+ data: {
2700
+ expectedRetryRequestHash: retryDispatch.expectedRetryRequestHash,
2701
+ actualRetryRequestHash: requestHash,
2702
+ },
2703
+ };
2704
+ }
2705
+ if (
2706
+ this.primarySummaryProjectionKeys(artifacts.compileResult.primarySummaryProjection ?? null).join('\u0000') !==
2707
+ retryDispatch.originalProjectionKeys.join('\u0000')
2708
+ ) {
2709
+ return {
2710
+ ok: false,
2711
+ reason: `primary_summary_fallback_projection_changed_${phase}`,
2712
+ data: {
2713
+ expectedProjectionKeys: retryDispatch.originalProjectionKeys,
2714
+ actualProjectionKeys: this.primarySummaryProjectionKeys(
2715
+ artifacts.compileResult.primarySummaryProjection ?? null,
2716
+ ),
2717
+ },
2718
+ };
2719
+ }
2720
+ if (!this.primarySummaryContractsMatch(artifacts.contract, retryDispatch.originalArtifacts.contract)) {
2721
+ return {
2722
+ ok: false,
2723
+ reason: `primary_summary_fallback_contract_changed_${phase}`,
2724
+ data: {
2725
+ expectedContract: retryDispatch.originalArtifacts.contract,
2726
+ actualContract: artifacts.contract,
2727
+ },
2728
+ };
2729
+ }
2730
+ const original = await this.getPrimarySummaryRequestRecord(retryDispatch.originalRequestId);
2731
+ if (
2732
+ !original?.fallbackIntent ||
2733
+ original.fallbackIntent.intentId !== retryDispatch.intentId ||
2734
+ original.fallbackIntent.retryRequestId !== requestId ||
2735
+ original.fallbackStatus !== 'pending'
2736
+ ) {
2737
+ return {
2738
+ ok: false,
2739
+ reason: `primary_summary_fallback_intent_changed_${phase}`,
2740
+ data: {
2741
+ expectedIntentId: retryDispatch.intentId,
2742
+ actualIntentId: original?.fallbackIntent?.intentId,
2743
+ retryRequestId: original?.fallbackIntent?.retryRequestId,
2744
+ fallbackStatus: original?.fallbackStatus,
2745
+ },
2746
+ };
2747
+ }
2748
+ return { ok: true };
2749
+ }
2750
+
2751
+ private async recordPrimarySummaryRetryQuarantine(
2752
+ originalRequestId: string,
2753
+ intentId: string,
2754
+ ): Promise<void> {
2755
+ await this.updatePrimarySummaryRequestRecord(originalRequestId, (record) => {
2756
+ const intent = record.fallbackIntent;
2757
+ if (!intent || intent.intentId !== intentId || intent.quarantinePersistedAt) return record;
2758
+ return {
2759
+ ...record,
2760
+ fallbackIntent: {
2761
+ ...intent,
2762
+ quarantinePersistedAt: Date.now(),
2763
+ },
2764
+ };
2765
+ });
2766
+ }
2767
+
2768
+ private async recordPrimarySummaryRetryOutput(
2769
+ originalRequestId: string,
2770
+ intentId: string,
2771
+ output: PrimarySummaryFallbackOutputRecord,
2772
+ ): Promise<void> {
2773
+ await this.updatePrimarySummaryRequestRecord(originalRequestId, (record) => {
2774
+ const intent = record.fallbackIntent;
2775
+ if (!intent || intent.intentId !== intentId) return record;
2776
+ const existing = intent.outputs?.find((entry) => entry.phase === output.phase);
2777
+ if (
2778
+ existing &&
2779
+ existing.contentHash === output.contentHash &&
2780
+ existing.messageId === output.messageId
2781
+ ) {
2782
+ return record;
2783
+ }
2784
+ if (existing) {
2785
+ throw new Error(`Primary summary fallback output phase '${output.phase}' changed after persistence`);
2786
+ }
2787
+ return {
2788
+ ...record,
2789
+ fallbackIntent: {
2790
+ ...intent,
2791
+ outputs: [...(intent.outputs ?? []), output],
2792
+ },
2793
+ };
2794
+ });
2795
+ }
2796
+
2797
+ private async recordPrimarySummaryRetryToolDispatch(
2798
+ originalRequestId: string,
2799
+ intentId: string,
2800
+ call: ToolCall,
2801
+ ): Promise<void> {
2802
+ await this.updatePrimarySummaryRequestRecord(originalRequestId, (record) => {
2803
+ const intent = record.fallbackIntent;
2804
+ if (!intent || intent.intentId !== intentId) return record;
2805
+ const inputHash = this.hashJson(call.input);
2806
+ const existing = intent.toolDispatches?.find((entry) => entry.callId === call.id);
2807
+ if (existing?.inputHash === inputHash) return record;
2808
+ if (existing) {
2809
+ throw new Error(`Primary summary fallback tool dispatch '${call.id}' changed after persistence`);
2810
+ }
2811
+ return {
2812
+ ...record,
2813
+ fallbackIntent: {
2814
+ ...intent,
2815
+ toolDispatches: [
2816
+ ...(intent.toolDispatches ?? []),
2817
+ {
2818
+ callId: call.id,
2819
+ inputHash,
2820
+ recordedAt: Date.now(),
2821
+ },
2822
+ ],
2823
+ },
2824
+ };
2825
+ });
2826
+ }
2827
+
2828
+ private async resolveContextBudgetRestartDispatch(
2829
+ requestId: string,
2830
+ retryDispatch?: PrimarySummaryFallbackRetryDispatch,
2831
+ ): Promise<ContextBudgetRestartDispatch> {
2832
+ const retryIdentity = retryDispatch
2833
+ ? {
2834
+ requestId,
2835
+ dispatchKind: 'primary_summary_fallback_retry' as const,
2836
+ originalRequestId: retryDispatch.originalRequestId,
2837
+ intentId: retryDispatch.intentId,
2838
+ }
2839
+ : null;
2840
+ const record = await this.getPrimarySummaryRequestRecord(requestId);
2841
+ if (record?.dispatchKind === 'primary_summary_fallback_retry' && retryIdentity) {
2842
+ return retryIdentity;
2843
+ }
2844
+ if (retryIdentity) {
2845
+ const original = await this.getPrimarySummaryRequestRecord(retryIdentity.originalRequestId);
2846
+ if (
2847
+ original?.fallbackIntent?.intentId === retryIdentity.intentId
2848
+ && original.fallbackIntent.retryRequestId === requestId
2849
+ ) {
2850
+ return retryIdentity;
2851
+ }
2852
+ }
2853
+ return {
2854
+ requestId,
2855
+ dispatchKind: 'primary',
2856
+ };
2857
+ }
2858
+
2859
+ private contextBudgetRestartMatchesIdentity(
2860
+ request: PendingInferenceRequest,
2861
+ identity: ContextBudgetRestartDispatch,
2862
+ ): boolean {
2863
+ if (request.reason !== 'context_budget_restart') return false;
2864
+ const restart = request.contextBudgetRestart;
2865
+ if (!restart) return false;
2866
+ if (restart.dispatchKind !== identity.dispatchKind) return false;
2867
+ if (restart.requestId !== identity.requestId) return false;
2868
+ if (identity.dispatchKind !== 'primary_summary_fallback_retry') return true;
2869
+ return restart.originalRequestId === identity.originalRequestId
2870
+ && restart.intentId === identity.intentId;
2871
+ }
2872
+
2873
+ private clearQueuedContextBudgetRestart(identity: ContextBudgetRestartDispatch): number {
2874
+ const before = this.pendingRequests.length;
2875
+ this.pendingRequests = this.pendingRequests.filter(
2876
+ (request) => !this.contextBudgetRestartMatchesIdentity(request, identity),
2877
+ );
2878
+ return before - this.pendingRequests.length;
2879
+ }
2880
+
2881
+ private async markPrimarySummaryRetrySuccess(
2882
+ requestId: string,
2883
+ retryDispatch: PrimarySummaryFallbackRetryDispatch,
2884
+ stopReason: string | undefined,
2885
+ providerInputTokens: number | undefined,
2886
+ visibleAssistantOutput: boolean,
2887
+ executedToolCalls: number,
2888
+ ): Promise<void> {
2889
+ await this.updatePrimarySummaryRequestRecord(requestId, (record) => ({
2890
+ ...record,
2891
+ stopReason,
2892
+ providerInputTokens,
2893
+ visibleAssistantOutput,
2894
+ executedToolCalls,
2895
+ finalStatus: 'success',
2896
+ fallbackHeldReason: undefined,
2897
+ }));
2898
+ await this.updatePrimarySummaryRequestRecord(retryDispatch.originalRequestId, (record) => {
2899
+ const intent = record.fallbackIntent;
2900
+ return {
2901
+ ...record,
2902
+ finalStatus: 'success',
2903
+ fallbackStatus: 'success',
2904
+ fallbackHeldReason: undefined,
2905
+ fallbackIntent: intent && intent.intentId === retryDispatch.intentId
2906
+ ? { ...intent, finalizedAt: Date.now() }
2907
+ : intent,
2908
+ };
2909
+ });
1333
2910
  }
1334
2911
 
1335
- private createDeferred<T>(): Deferred<T> {
1336
- let resolve!: (value: T) => void;
1337
- let reject!: (reason?: unknown) => void;
1338
- const promise = new Promise<T>((res, rej) => {
1339
- resolve = res;
1340
- reject = rej;
2912
+ private async holdPrimarySummaryRetryFamily(
2913
+ agent: Agent,
2914
+ requestId: string,
2915
+ retryDispatch: PrimarySummaryFallbackRetryDispatch,
2916
+ reason: string,
2917
+ data?: Record<string, unknown>,
2918
+ options?: { emitChronicleMarker?: boolean; stopReason?: string },
2919
+ ): Promise<void> {
2920
+ const clearedQueuedContextBudgetRestarts = this.clearQueuedContextBudgetRestart({
2921
+ requestId,
2922
+ dispatchKind: 'primary_summary_fallback_retry',
2923
+ originalRequestId: retryDispatch.originalRequestId,
2924
+ intentId: retryDispatch.intentId,
1341
2925
  });
1342
- return { promise, resolve, reject };
2926
+ const holdData = clearedQueuedContextBudgetRestarts > 0
2927
+ ? { ...(data ?? {}), clearedQueuedContextBudgetRestarts }
2928
+ : data;
2929
+ await this.updatePrimarySummaryRequestRecord(requestId, (record) => ({
2930
+ ...record,
2931
+ ...(options?.stopReason ? { stopReason: options.stopReason } : {}),
2932
+ finalStatus: 'held',
2933
+ fallbackHeldReason: reason,
2934
+ }));
2935
+ await this.updatePrimarySummaryRequestRecord(retryDispatch.originalRequestId, (record) => ({
2936
+ ...record,
2937
+ finalStatus: 'held',
2938
+ fallbackStatus: 'held',
2939
+ fallbackHeldReason: reason,
2940
+ }));
2941
+ this.opsAlert('refusal-held', agent.name, reason, { data: holdData });
2942
+ if (options?.emitChronicleMarker === false) return;
2943
+ try {
2944
+ agent.getContextManager().addMessage(
2945
+ 'user',
2946
+ [{
2947
+ type: 'text',
2948
+ text:
2949
+ `[refusal-held] Your previous turn was refused by the provider and ` +
2950
+ `is being held for operator review rather than silently retried. ` +
2951
+ `Automatic raw-expansion recovery was either unsafe or unsuccessful.`,
2952
+ }],
2953
+ { system: true, kind: 'refusal-held', reason },
2954
+ );
2955
+ } catch (error) {
2956
+ console.error('[refusal-held] could not record chronicle marker:', error);
2957
+ }
2958
+ }
2959
+
2960
+ private async primarySummaryRetryAlreadySucceeded(
2961
+ requestId: string,
2962
+ retryDispatch: PrimarySummaryFallbackRetryDispatch,
2963
+ ): Promise<boolean> {
2964
+ const [retryRecord, originalRecord] = await Promise.all([
2965
+ this.getPrimarySummaryRequestRecord(requestId),
2966
+ this.getPrimarySummaryRequestRecord(retryDispatch.originalRequestId),
2967
+ ]);
2968
+ return retryRecord?.finalStatus === 'success'
2969
+ || originalRecord?.fallbackStatus === 'success'
2970
+ || originalRecord?.finalStatus === 'success';
2971
+ }
2972
+
2973
+ private async primarySummaryRetryFamilyAlreadySettled(
2974
+ requestId: string,
2975
+ retryDispatch: PrimarySummaryFallbackRetryDispatch,
2976
+ ): Promise<boolean> {
2977
+ const [retryRecord, originalRecord] = await Promise.all([
2978
+ this.getPrimarySummaryRequestRecord(requestId),
2979
+ this.getPrimarySummaryRequestRecord(retryDispatch.originalRequestId),
2980
+ ]);
2981
+ return retryRecord?.finalStatus === 'held'
2982
+ || retryRecord?.finalStatus === 'success'
2983
+ || originalRecord?.fallbackStatus === 'held'
2984
+ || originalRecord?.fallbackStatus === 'success'
2985
+ || originalRecord?.finalStatus === 'held'
2986
+ || originalRecord?.finalStatus === 'success';
2987
+ }
2988
+
2989
+ private async holdPrimarySummaryRequest(
2990
+ agent: Agent,
2991
+ requestId: string,
2992
+ reason: string,
2993
+ data?: Record<string, unknown>,
2994
+ ): Promise<void> {
2995
+ await this.updatePrimarySummaryRequestRecord(requestId, (record) => ({
2996
+ ...record,
2997
+ finalStatus: 'held',
2998
+ fallbackStatus: 'held',
2999
+ fallbackHeldReason: reason,
3000
+ }));
3001
+ this.opsAlert('refusal-held', agent.name, reason, { data });
3002
+ try {
3003
+ agent.getContextManager().addMessage(
3004
+ 'user',
3005
+ [{
3006
+ type: 'text',
3007
+ text:
3008
+ `[refusal-held] Your previous turn was refused by the provider and ` +
3009
+ `is being held for operator review rather than silently retried. ` +
3010
+ `Automatic raw-expansion recovery was either unsafe or unsuccessful.`,
3011
+ }],
3012
+ { system: true, kind: 'refusal-held', reason },
3013
+ );
3014
+ } catch (error) {
3015
+ console.error('[refusal-held] could not record chronicle marker:', error);
3016
+ }
3017
+ }
3018
+
3019
+ private hasVisibleAssistantOutput(blocks: ReadonlyArray<ContentBlock>): boolean {
3020
+ return blocks.some((block) => block.type !== 'thinking' && block.type !== 'redacted_thinking');
3021
+ }
3022
+
3023
+ private buildPrimarySummaryRequestRecord(
3024
+ agent: Agent,
3025
+ requestId: string,
3026
+ dispatchKind: PrimarySummaryRequestRecord['dispatchKind'],
3027
+ request: NormalizedRequest,
3028
+ artifacts: ActivationCompileArtifacts | undefined,
3029
+ branch: BranchGenerationInfo | null,
3030
+ stopReason: string | undefined,
3031
+ visibleAssistantOutput: boolean,
3032
+ executedToolCalls: number,
3033
+ providerInputTokens: number | undefined,
3034
+ finalStatus: PrimarySummaryFallbackStatus,
3035
+ ): PrimarySummaryRequestRecord {
3036
+ const projection = artifacts?.compileResult.primarySummaryProjection ?? null;
3037
+ return {
3038
+ requestId,
3039
+ agentName: agent.name,
3040
+ namespace: projection?.namespace ?? `agents/${agent.name}`,
3041
+ timestamp: Date.now(),
3042
+ dispatchKind,
3043
+ branch: branch
3044
+ ? { id: branch.id, name: branch.name, generation: branch.generation }
3045
+ : { id: 'unknown', name: 'unknown', generation: -1 },
3046
+ projection,
3047
+ requestInputBoundTokens: this.estimateRequestInputBoundTokens(request),
3048
+ requestCompleteBoundTokens: this.estimateRequestCompleteBoundTokens(request),
3049
+ providerInputTokens,
3050
+ systemHash: artifacts?.contract.systemHash ?? this.hashJson(request.system ?? ''),
3051
+ modelConfigHash: artifacts?.contract.modelConfigHash ?? this.hashJson({ request: request.config }),
3052
+ toolContractHash: artifacts?.contract.toolContractHash ?? this.hashJson(request.tools ?? []),
3053
+ stopReason,
3054
+ visibleAssistantOutput,
3055
+ executedToolCalls,
3056
+ finalStatus,
3057
+ };
1343
3058
  }
1344
3059
 
1345
3060
  /** Liveness ping for an active ephemeral run (no-op otherwise). */
@@ -1633,11 +3348,44 @@ export class AgentFramework {
1633
3348
  inputSchema: { type: 'object' },
1634
3349
  };
1635
3350
 
3351
+ /** Synthesized save_image tool — present when a workspace module is
3352
+ * registered. Lets the agent persist an image it has already seen in its
3353
+ * own context (Discord attachments arrive inlined as base64 and are
3354
+ * otherwise unreachable as files). */
3355
+ private static readonly SAVE_IMAGE_TOOL: import('./types/index.js').ToolDefinition = {
3356
+ name: 'save_recent_image',
3357
+ description:
3358
+ 'Save one or more recent images from your own context to workspace files. ' +
3359
+ 'Images are counted back from the most recent (index 0). A single image is ' +
3360
+ 'written to `path` as given (e.g. "project/photos/cat.png"); when `count` > 1 ' +
3361
+ 'the range index..index+count-1 is saved with numeric suffixes ' +
3362
+ '("cat-0.png", "cat-1.png", …; 0 = the newest of the range). Saved files are ' +
3363
+ 'visible via workspace tools and the /files/ endpoint.',
3364
+ inputSchema: {
3365
+ type: 'object',
3366
+ properties: {
3367
+ path: {
3368
+ type: 'string',
3369
+ description: 'Mount-prefixed destination path, e.g. "project/photos/name.png".',
3370
+ },
3371
+ index: {
3372
+ type: 'number',
3373
+ description: 'Which image, counting back from the most recent (0 = most recent). Default 0.',
3374
+ },
3375
+ count: {
3376
+ type: 'number',
3377
+ description: 'How many images to save, starting at `index` and going further back. Default 1.',
3378
+ },
3379
+ },
3380
+ required: ['path'],
3381
+ },
3382
+ };
3383
+
1636
3384
  private static readonly AGENT_SETTINGS_TOOL: import('./types/index.js').ToolDefinition = {
1637
3385
  name: 'agent_settings',
1638
3386
  description:
1639
3387
  'Read or change your hot runtime settings. This intentionally exposes only ' +
1640
- 'context budget, recent raw tail size, and transition pace; model, prompts, ' +
3388
+ 'context budget, recent raw tail size, transition pace, and same-round think text routing; model, prompts, ' +
1641
3389
  'folding strategy, and other restart-bound configuration are not mutable here. ' +
1642
3390
  'Lower context budgets converge gradually under the transition pace before ' +
1643
3391
  'becoming the hard live limit; increases take effect immediately.',
@@ -1648,9 +3396,16 @@ export class AgentFramework {
1648
3396
  context_budget_tokens: { type: 'number', description: 'Total input context budget, including the reserved response allowance.' },
1649
3397
  tail_tokens: { type: 'number', description: 'Recent raw context retained verbatim.' },
1650
3398
  transition_pace_tokens: { type: 'number', description: 'Maximum ordinary KV re-read/perturbation per compile while converging.' },
3399
+ same_round_think_text_policy: {
3400
+ type: 'string',
3401
+ enum: ['public', 'private'],
3402
+ description:
3403
+ 'Routing policy for ordinary text emitted in the same native assistant round as think(). ' +
3404
+ "Omitted in the recipe preserves the compatibility carry-forward: public.",
3405
+ },
1651
3406
  settings: {
1652
3407
  type: 'array',
1653
- items: { type: 'string', enum: ['context_budget_tokens', 'tail_tokens', 'transition_pace_tokens'] },
3408
+ items: { type: 'string', enum: ['context_budget_tokens', 'tail_tokens', 'transition_pace_tokens', 'same_round_think_text_policy'] },
1654
3409
  description: 'For reset: settings to restore to recipe values. Omit to reset all.',
1655
3410
  },
1656
3411
  },
@@ -1943,6 +3698,9 @@ export class AgentFramework {
1943
3698
  undone?: number;
1944
3699
  requested?: number;
1945
3700
  messagesRemoved?: number;
3701
+ /** Discord addresses removed by message-granular undo. The durable outbox
3702
+ * owns eventual delivery; this is also returned for immediate surfaces. */
3703
+ removedRefs?: Array<{ serverId: string; channelId: string; messageId: string }>;
1946
3704
  hidden?: number;
1947
3705
  /** For `hide`: the Discord (channelId, messageId) of each removed message
1948
3706
  * that carried one — so the surface can mark them with a reaction. */
@@ -2089,8 +3847,26 @@ export class AgentFramework {
2089
3847
  };
2090
3848
  }
2091
3849
  const target = allMessages[allMessages.length - 1 - n];
2092
- const branchName = cm.branchAt(target.id, `undo-msgs/${agentName}/${Date.now()}`);
3850
+ const discarded = allMessages.slice(allMessages.length - n);
3851
+ const removedRefs = extractDiscordAwarenessRefs(discarded);
3852
+ const sourceBranch = this.store.currentBranch().name;
3853
+ const targetBranch = `undo-msgs/${agentName}/${Date.now()}`;
3854
+
3855
+ // Prepare the external side effect before switching Chronicle. If the
3856
+ // process dies after the switch but before activate(), startup promotes
3857
+ // this batch by matching targetBranch to the active branch.
3858
+ const markerBatch = this.discordAwarenessOutbox?.prepare({
3859
+ agentName,
3860
+ sourceBranch,
3861
+ targetBranch,
3862
+ refs: removedRefs,
3863
+ emoji: this.discordAwarenessEmoji,
3864
+ }) ?? null;
3865
+
3866
+ const branchName = cm.branchAt(target.id, targetBranch);
2093
3867
  await cm.switchBranch(branchName);
3868
+ if (markerBatch) this.discordAwarenessOutbox!.activate(markerBatch.id);
3869
+ await this.syncDiscordAwarenessMarkers();
2094
3870
 
2095
3871
  // Materialize config files from the new branch (fire-and-forget; gate
2096
3872
  // picks up via mtime) — mirrors undoLastTurn.
@@ -2109,6 +3885,7 @@ export class AgentFramework {
2109
3885
  return {
2110
3886
  ok: true,
2111
3887
  messagesRemoved: n,
3888
+ removedRefs,
2112
3889
  lastVisible: await this.lastVisiblePreview(agentName),
2113
3890
  };
2114
3891
  }
@@ -2138,6 +3915,8 @@ export class AgentFramework {
2138
3915
  return { ok: true, undone: 0, requested, lastVisible: null };
2139
3916
  }
2140
3917
 
3918
+ await this.syncDiscordAwarenessMarkers();
3919
+
2141
3920
  return { ok: true, undone, requested, lastVisible: await this.lastVisiblePreview(agentName) };
2142
3921
  }
2143
3922
 
@@ -2727,7 +4506,11 @@ export class AgentFramework {
2727
4506
 
2728
4507
  const agent = new Agent(config, contextManager, this.membrane);
2729
4508
  const restoredSettings = this.readAgentRuntimeSettings(config.name);
2730
- if (restoredSettings) agent.restoreRuntimeSettings(restoredSettings);
4509
+ if (restoredSettings) {
4510
+ agent.restoreRuntimeSettings(
4511
+ this.validatePersistedAgentRuntimeSettings(config.name, restoredSettings),
4512
+ );
4513
+ }
2731
4514
  this.agents.set(config.name, agent);
2732
4515
  this.agentConfigs.set(config.name, config);
2733
4516
 
@@ -2763,12 +4546,21 @@ export class AgentFramework {
2763
4546
  // Check for inference requests
2764
4547
  await this.processInferenceRequests();
2765
4548
 
2766
- // Yield to the event loop between iterations.
2767
- // Full 10ms sleep when truly idle; minimal yield when streams are active
2768
- // (needed to let stream microtasks and tool-call callbacks execute).
2769
- if (!event && this.pendingRequests.length === 0) {
4549
+ // Yield to the event loop between iterations. A pending inference request
4550
+ // is not necessarily runnable: while its agent is streaming or waiting for
4551
+ // tools, processInferenceRequests() deliberately requeues it. Treating that
4552
+ // requeued request as "work made progress" creates a microtask-only polling
4553
+ // loop. If activeStreams bookkeeping is absent/stale at the same time, the
4554
+ // old code had no await at all and starved the tool-result and HTTP I/O that
4555
+ // could make the agent runnable again (the Sol outage, 2026-07-15).
4556
+ //
4557
+ // No queue event means no foreground progress, so always take the normal
4558
+ // polling backoff. After an event, retain the low-latency macrotask yield
4559
+ // while background work remains. Both paths give Bun/Node a real event-loop
4560
+ // turn; neither can recurse forever through already-resolved promises.
4561
+ if (!event) {
2770
4562
  await new Promise((resolve) => setTimeout(resolve, 10));
2771
- } else if (this.activeStreams.size > 0) {
4563
+ } else if (this.pendingRequests.length > 0 || this.activeStreams.size > 0) {
2772
4564
  await new Promise((resolve) => setTimeout(resolve, 0));
2773
4565
  }
2774
4566
  }
@@ -2811,6 +4603,7 @@ export class AgentFramework {
2811
4603
  // Cast to AgentState to bypass TypeScript's control flow narrowing
2812
4604
  const currentState = agent.state as AgentState;
2813
4605
  if (currentState.status === 'ready') {
4606
+ const activeStreamContext = this.activeStreamContexts.get(agent.name);
2814
4607
  // Flush pending assistant blocks (tool_use + preamble text) to context
2815
4608
  const pendingBlocks = this.pendingAssistantBlocks.get(agent.name);
2816
4609
  if (pendingBlocks) {
@@ -2883,6 +4676,25 @@ export class AgentFramework {
2883
4676
  }
2884
4677
  }
2885
4678
 
4679
+ // A newly injected message begins a new conversational round inside
4680
+ // the same provider inference. Remember that boundary for
4681
+ // driveStream: an explicit send in the preceding round must not
4682
+ // silence the reply, and the newest channel-bearing injection is
4683
+ // now the reply locus. Map presence matters even without a channel
4684
+ // (CLI/module input): it still clears the prior send suppression.
4685
+ if (midTurnInjections.length > 0) {
4686
+ const injectedChannelId = midTurnInjections.reduce<string | null>(
4687
+ (latest, injection) => {
4688
+ const candidate = injection.metadata?.channelId;
4689
+ return typeof candidate === 'string' && candidate.length > 0
4690
+ ? candidate
4691
+ : latest;
4692
+ },
4693
+ null,
4694
+ );
4695
+ this.midTurnRoutingResets.set(agent.name, injectedChannelId);
4696
+ }
4697
+
2886
4698
  // Check if any tool result requested endTurn
2887
4699
  const shouldEndTurn = currentState.toolResults.some(tc => tc.result.endTurn);
2888
4700
 
@@ -2913,6 +4725,59 @@ export class AgentFramework {
2913
4725
  // a terminal failure — rejecting an ephemeral's promise mid-run
2914
4726
  // and emitting a spurious inference:exhausted (same race shape as
2915
4727
  // endTurn above).
4728
+ const requestId = activeStreamContext?.requestId;
4729
+ const primarySummaryRetry = activeStreamContext?.trigger?.primarySummaryFallbackRetry;
4730
+ const compiledRequest = activeStreamContext?.compiledRequest;
4731
+ const artifacts = activeStreamContext?.artifacts;
4732
+ const restartDispatch = await this.resolveContextBudgetRestartDispatch(
4733
+ requestId ?? `${agent.name}:context_budget_restart`,
4734
+ primarySummaryRetry,
4735
+ );
4736
+ if (
4737
+ restartDispatch.dispatchKind === 'primary_summary_fallback_retry'
4738
+ && requestId
4739
+ && primarySummaryRetry
4740
+ && compiledRequest
4741
+ && artifacts
4742
+ ) {
4743
+ const validation = await this.validatePrimarySummaryRetryMutation(
4744
+ agent,
4745
+ requestId,
4746
+ primarySummaryRetry,
4747
+ compiledRequest,
4748
+ artifacts,
4749
+ 'before_context_budget_restart_hold',
4750
+ );
4751
+ if (!validation.ok) {
4752
+ await this.holdPrimarySummaryRetryFamily(
4753
+ agent,
4754
+ requestId,
4755
+ primarySummaryRetry,
4756
+ validation.reason,
4757
+ validation.data,
4758
+ { emitChronicleMarker: false, stopReason: 'abort' },
4759
+ );
4760
+ } else {
4761
+ await this.holdPrimarySummaryRetryFamily(
4762
+ agent,
4763
+ requestId,
4764
+ primarySummaryRetry,
4765
+ 'primary_summary_fallback_context_budget_restart_required',
4766
+ {
4767
+ inputTokens: agent.lastStreamInputTokens,
4768
+ budget: agent.maxStreamTokens,
4769
+ originalRequestId: primarySummaryRetry.originalRequestId,
4770
+ intentId: primarySummaryRetry.intentId,
4771
+ },
4772
+ { emitChronicleMarker: false, stopReason: 'abort' },
4773
+ );
4774
+ }
4775
+ this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'budget_restart');
4776
+ currentState.stream?.cancel();
4777
+ agent.reset();
4778
+ this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
4779
+ return;
4780
+ }
2916
4781
  if (currentState.stream) {
2917
4782
  this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'budget_restart');
2918
4783
  }
@@ -2929,6 +4794,8 @@ export class AgentFramework {
2929
4794
  reason: 'context_budget_restart',
2930
4795
  source: 'framework',
2931
4796
  timestamp: Date.now(),
4797
+ channelId: activeStreamContext?.trigger?.channelId,
4798
+ contextBudgetRestart: restartDispatch,
2932
4799
  });
2933
4800
  } else if (currentState.stream) {
2934
4801
  // Streaming path: convert results and resume the stream.
@@ -3515,7 +5382,7 @@ export class AgentFramework {
3515
5382
  const state = this.createFrameworkState();
3516
5383
 
3517
5384
  // Group requests by agent
3518
- const requestsByAgent = new Map<string, InferenceRequest[]>();
5385
+ const requestsByAgent = new Map<string, PendingInferenceRequest[]>();
3519
5386
  for (const req of this.pendingRequests) {
3520
5387
  const existing = requestsByAgent.get(req.agentName) ?? [];
3521
5388
  existing.push(req);
@@ -3526,7 +5393,8 @@ export class AgentFramework {
3526
5393
  this.pendingRequests = [];
3527
5394
 
3528
5395
  // Check each agent
3529
- for (const [agentName, requests] of requestsByAgent) {
5396
+ for (const [agentName, queuedRequests] of requestsByAgent) {
5397
+ let requests = queuedRequests;
3530
5398
  const agent = this.agents.get(agentName);
3531
5399
  if (!agent) {
3532
5400
  // Agent not found — request is orphaned. Emit warning and drop.
@@ -3546,7 +5414,15 @@ export class AgentFramework {
3546
5414
  if (agent.state.status === 'inferring' || agent.state.status === 'streaming' || agent.state.status === 'waiting_for_tools') {
3547
5415
  // Re-queue requests, but warn if they've been pending too long
3548
5416
  const oldest = Math.min(...requests.map(r => r.timestamp));
3549
- if (now - oldest > STALE_REQUEST_MS) {
5417
+ if (
5418
+ now - oldest > STALE_REQUEST_MS &&
5419
+ (this.staleWarnAt.get(agentName) ?? 0) < now - 60_000
5420
+ ) {
5421
+ // Trace and stderr share the throttle. The old code throttled only
5422
+ // stderr, so a long-running tool call generated one trace per poll
5423
+ // (up to 100/sec after the scheduler backoff), adding avoidable work
5424
+ // precisely while the agent was already under pressure.
5425
+ this.staleWarnAt.set(agentName, now);
3550
5426
  this.emitTrace({
3551
5427
  type: 'inference:request_stale',
3552
5428
  agentName,
@@ -3554,19 +5430,28 @@ export class AgentFramework {
3554
5430
  requestCount: requests.length,
3555
5431
  oldestRequestAge: now - oldest,
3556
5432
  });
3557
- // Loud (but throttled) note that requests are waiting on a busy agent.
3558
- if ((this.staleWarnAt.get(agentName) ?? 0) < now - 60_000) {
3559
- this.staleWarnAt.set(agentName, now);
3560
- console.error(
3561
- `[inference-stale] agent=${agentName} busy (${agent.state.status}) — ` +
3562
- `${requests.length} request(s) waiting ${Math.round((now - oldest) / 1000)}s`,
3563
- );
3564
- }
5433
+ console.error(
5434
+ `[inference-stale] agent=${agentName} busy (${agent.state.status}) — ` +
5435
+ `${requests.length} request(s) waiting ${Math.round((now - oldest) / 1000)}s`,
5436
+ );
3565
5437
  }
3566
5438
  this.pendingRequests.push(...requests);
3567
5439
  continue;
3568
5440
  }
3569
5441
 
5442
+ if (this.discordAwarenessBarrier) {
5443
+ this.pendingRequests.push(...requests);
5444
+ continue;
5445
+ }
5446
+
5447
+ requests = requests.filter((request) =>
5448
+ request.reason !== 'context_budget_restart'
5449
+ || request.contextBudgetRestart?.dispatchKind !== 'primary_summary_fallback_retry'
5450
+ );
5451
+ if (requests.length === 0) {
5452
+ continue;
5453
+ }
5454
+
3570
5455
  // Check policy
3571
5456
  if (!this.inferencePolicy.shouldInfer(agentName, requests, state)) {
3572
5457
  // Loud drop: a queued request that dies here is otherwise invisible —
@@ -3580,7 +5465,12 @@ export class AgentFramework {
3580
5465
  }
3581
5466
 
3582
5467
  // Start streaming inference (non-blocking — driveStream runs in background)
3583
- const trigger = requests[0];
5468
+ const trigger = requests.find((request) => request.primarySummaryFallbackRetry) ?? requests[0]!;
5469
+ if (trigger.primarySummaryFallbackRetry) {
5470
+ for (const request of requests) {
5471
+ if (request !== trigger) this.pendingRequests.push(request);
5472
+ }
5473
+ }
3584
5474
  // Route this turn's auto-published speech to the channel that triggered
3585
5475
  // it (item-3 redux). A batched wake may carry several triggering channels
3586
5476
  // (messages arrived in >1 channel while the agent was busy/idle) — reply
@@ -3597,9 +5487,9 @@ export class AgentFramework {
3597
5487
  }
3598
5488
  }
3599
5489
 
3600
- private async startAgentStream(agent: Agent, trigger?: InferenceRequest, attempt = 0): Promise<void> {
5490
+ private async startAgentStream(agent: Agent, trigger?: PendingInferenceRequest, attempt = 0): Promise<void> {
3601
5491
  // Record turn checkpoint before inference (only on first attempt, not retries)
3602
- if (attempt === 0) {
5492
+ if (attempt === 0 && trigger?.reason !== 'primary-summary-fallback-retry') {
3603
5493
  this.recordTurnCheckpoint(agent.name);
3604
5494
  this.redoStacks.delete(agent.name); // new work invalidates redo
3605
5495
  }
@@ -3620,54 +5510,276 @@ export class AgentFramework {
3620
5510
  this.emitTrace({ type: 'inference:started', agentName: agent.name });
3621
5511
  this.eventGate?.onInferenceStarted(agent.name);
3622
5512
  this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
5513
+ const requestId = `${agent.name}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
3623
5514
 
3624
5515
  try {
3625
- const allTools = this.getAllTools();
5516
+ const requestSnapshot = this.captureInferenceToolSnapshot(agent);
5517
+ const allTools = this.getToolsForAgent(agent.name, requestSnapshot);
3626
5518
  const tools = allTools.filter((t) => agent.canUseTool(t.name));
5519
+ const fallbackConfig = this.primarySummaryFallbackConfig(agent);
5520
+ const retryDispatch = trigger?.primarySummaryFallbackRetry;
5521
+ let compiledRequest: NormalizedRequest;
5522
+ let artifacts: ActivationCompileArtifacts;
5523
+ let branch = agent.getCurrentBranchGeneration();
5524
+
5525
+ if (retryDispatch) {
5526
+ if (!fallbackConfig.enabled) {
5527
+ await this.holdPrimarySummaryRequest(
5528
+ agent,
5529
+ retryDispatch.originalRequestId,
5530
+ 'primary_summary_fallback_disabled_before_retry',
5531
+ );
5532
+ agent.reset();
5533
+ this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
5534
+ this.eventGate?.onInferenceEnded(agent.name);
5535
+ this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
5536
+ return;
5537
+ }
3627
5538
 
3628
- // Gather context from modules (pull-based) and MCPL hooks (push-based)
3629
- // Both produce ContextInjection[] that get merged before inference.
3630
- let injections: ContextInjection[] | undefined;
5539
+ const claim = await this.claimPrimarySummaryRetryIntent(
5540
+ retryDispatch.originalRequestId,
5541
+ retryDispatch.intentId,
5542
+ requestId,
5543
+ );
5544
+ if (claim !== 'claimed') {
5545
+ await this.holdPrimarySummaryRequest(
5546
+ agent,
5547
+ retryDispatch.originalRequestId,
5548
+ claim === 'duplicate'
5549
+ ? 'primary_summary_fallback_duplicate_retry_blocked'
5550
+ : 'primary_summary_fallback_retry_intent_missing',
5551
+ { intentId: retryDispatch.intentId, retryRequestId: requestId },
5552
+ );
5553
+ agent.reset();
5554
+ this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
5555
+ this.eventGate?.onInferenceEnded(agent.name);
5556
+ this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
5557
+ return;
5558
+ }
3631
5559
 
3632
- // Module gatherContext (fail-open, per-module timeout — the module's
3633
- // contextTimeoutMs, else the registry default). Injections are
3634
- // channel-scoped via scopeInjectionsForAgent, matching the MCPL hook
3635
- // injections below, so conversation forks don't see cross-channel
3636
- // content.
3637
- try {
3638
- const moduleInjections = this.scopeInjectionsForAgent(
3639
- agent.name,
3640
- await this.moduleRegistry.gatherContext(agent.name),
5560
+ if (
5561
+ !branch ||
5562
+ branch.id !== retryDispatch.branch.id ||
5563
+ branch.generation !== retryDispatch.branch.generation
5564
+ ) {
5565
+ await this.holdPrimarySummaryRequest(
5566
+ agent,
5567
+ retryDispatch.originalRequestId,
5568
+ 'primary_summary_fallback_branch_changed_before_retry',
5569
+ {
5570
+ expectedBranchId: retryDispatch.branch.id,
5571
+ expectedGeneration: retryDispatch.branch.generation,
5572
+ actualBranchId: branch?.id,
5573
+ actualGeneration: branch?.generation,
5574
+ },
5575
+ );
5576
+ agent.reset();
5577
+ this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
5578
+ this.eventGate?.onInferenceEnded(agent.name);
5579
+ this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
5580
+ return;
5581
+ }
5582
+
5583
+ const rebuilt = agent.buildPrimarySummaryRawExpansionRequest(
5584
+ retryDispatch.originalArtifacts,
5585
+ retryDispatch.candidateSummaries,
5586
+ tools,
3641
5587
  );
3642
- if (moduleInjections.length > 0) {
3643
- injections = moduleInjections;
5588
+ compiledRequest = rebuilt.request;
5589
+ artifacts = rebuilt.artifacts;
5590
+
5591
+ const rebuiltHash = this.hashJson(compiledRequest);
5592
+ if (
5593
+ rebuiltHash !== retryDispatch.expectedRetryRequestHash ||
5594
+ this.primarySummaryProjectionKeys(artifacts.compileResult.primarySummaryProjection ?? null).join('\u0000') !==
5595
+ retryDispatch.originalProjectionKeys.join('\u0000')
5596
+ ) {
5597
+ await this.holdPrimarySummaryRequest(
5598
+ agent,
5599
+ retryDispatch.originalRequestId,
5600
+ 'primary_summary_fallback_revalidation_failed_before_retry',
5601
+ {
5602
+ expectedRetryRequestHash: retryDispatch.expectedRetryRequestHash,
5603
+ actualRetryRequestHash: rebuiltHash,
5604
+ },
5605
+ );
5606
+ agent.reset();
5607
+ this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
5608
+ this.eventGate?.onInferenceEnded(agent.name);
5609
+ this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
5610
+ return;
3644
5611
  }
3645
- } catch (error) {
3646
- console.error('Module gatherContext error:', error);
3647
- }
3648
5612
 
3649
- // MCPL beforeInference hooks (fail-open)
3650
- if (this.hookOrchestrator) {
5613
+ const retryRecord = this.buildPrimarySummaryRequestRecord(
5614
+ agent,
5615
+ requestId,
5616
+ 'primary_summary_fallback_retry',
5617
+ compiledRequest,
5618
+ artifacts,
5619
+ branch,
5620
+ undefined,
5621
+ false,
5622
+ 0,
5623
+ undefined,
5624
+ 'pending',
5625
+ );
5626
+ await this.persistPrimarySummaryRequestRecord(retryRecord);
5627
+
5628
+ const admittedTokens = this.primarySummaryAdmissionBoundTokens(
5629
+ retryDispatch.originalRequestInputBoundTokens,
5630
+ compiledRequest,
5631
+ retryDispatch.originalProviderInputTokens,
5632
+ );
5633
+ if (admittedTokens > retryDispatch.requestBudgetTokens) {
5634
+ await this.holdPrimarySummaryRequest(
5635
+ agent,
5636
+ requestId,
5637
+ 'primary_summary_fallback_retry_over_budget',
5638
+ {
5639
+ admittedTokens,
5640
+ requestBudgetTokens: retryDispatch.requestBudgetTokens,
5641
+ retryRequestHash: rebuiltHash,
5642
+ },
5643
+ );
5644
+ await this.updatePrimarySummaryRequestRecord(retryDispatch.originalRequestId, (record) => ({
5645
+ ...record,
5646
+ fallbackStatus: 'held',
5647
+ fallbackHeldReason: 'primary_summary_fallback_retry_over_budget',
5648
+ }));
5649
+ agent.reset();
5650
+ this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
5651
+ this.eventGate?.onInferenceEnded(agent.name);
5652
+ this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
5653
+ return;
5654
+ }
5655
+ } else {
5656
+ // Gather context from modules (pull-based) and MCPL hooks (push-based)
5657
+ // Both produce ContextInjection[] that get merged before inference.
5658
+ let injections: ContextInjection[] | undefined;
5659
+
5660
+ // Module gatherContext (fail-open, per-module timeout — the module's
5661
+ // contextTimeoutMs, else the registry default). Injections are
5662
+ // channel-scoped via scopeInjectionsForAgent, matching the MCPL hook
5663
+ // injections below, so conversation forks don't see cross-channel
5664
+ // content.
3651
5665
  try {
3652
- const hookParams = this.buildBeforeInferenceParams(agent, trigger);
3653
- const hookInjections = this.scopeInjectionsForAgent(
5666
+ const moduleInjections = this.scopeInjectionsForAgent(
3654
5667
  agent.name,
3655
- await this.hookOrchestrator.beforeInference(hookParams),
5668
+ await this.moduleRegistry.gatherContext(agent.name),
3656
5669
  );
3657
- if (hookInjections.length > 0) {
3658
- injections = injections ? [...injections, ...hookInjections] : hookInjections;
5670
+ if (moduleInjections.length > 0) {
5671
+ injections = moduleInjections;
3659
5672
  }
3660
5673
  } catch (error) {
3661
- console.error('beforeInference hook error:', error);
5674
+ console.error('Module gatherContext error:', error);
5675
+ }
5676
+
5677
+ // MCPL beforeInference hooks (fail-open)
5678
+ if (this.hookOrchestrator) {
5679
+ try {
5680
+ const hookParams = this.buildBeforeInferenceParams(agent, trigger);
5681
+ const hookInjections = this.scopeInjectionsForAgent(
5682
+ agent.name,
5683
+ await this.hookOrchestrator.beforeInference(hookParams),
5684
+ );
5685
+ if (hookInjections.length > 0) {
5686
+ injections = injections ? [...injections, ...hookInjections] : hookInjections;
5687
+ }
5688
+ } catch (error) {
5689
+ console.error('beforeInference hook error:', error);
5690
+ }
3662
5691
  }
3663
- }
3664
5692
 
3665
- const { stream, request: compiledRequest } = await agent.startStreamWithInjections(tools, injections);
5693
+ const prepared = await agent.prepareActivationRequest(tools, injections);
5694
+ compiledRequest = prepared.request;
5695
+ artifacts = prepared.artifacts;
5696
+ branch = agent.getCurrentBranchGeneration();
5697
+
5698
+ if (fallbackConfig.enabled) {
5699
+ const record = this.buildPrimarySummaryRequestRecord(
5700
+ agent,
5701
+ requestId,
5702
+ 'primary',
5703
+ compiledRequest,
5704
+ artifacts,
5705
+ branch,
5706
+ undefined,
5707
+ false,
5708
+ 0,
5709
+ undefined,
5710
+ 'pending',
5711
+ );
5712
+ await this.persistPrimarySummaryRequestRecord(record);
5713
+ const expandedByQuarantine =
5714
+ artifacts.compileResult.primarySummaryProjection?.selectedSummaries.some(
5715
+ (selection) => selection.renderedAs === 'raw_expansion',
5716
+ ) ?? false;
5717
+ if (expandedByQuarantine) {
5718
+ const admittedTokens = this.estimateRequestCompleteBoundTokens(compiledRequest);
5719
+ if (admittedTokens > fallbackConfig.requestBudgetTokens) {
5720
+ await this.holdPrimarySummaryRequest(
5721
+ agent,
5722
+ requestId,
5723
+ 'primary_summary_quarantine_over_budget',
5724
+ {
5725
+ admittedTokens,
5726
+ requestBudgetTokens: fallbackConfig.requestBudgetTokens,
5727
+ },
5728
+ );
5729
+ agent.reset();
5730
+ this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
5731
+ this.eventGate?.onInferenceEnded(agent.name);
5732
+ this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
5733
+ return;
5734
+ }
5735
+ }
5736
+ }
5737
+ }
3666
5738
 
3667
- const handle = this.driveStream(agent, stream, trigger, attempt, compiledRequest);
5739
+ const { stream } = agent.startStreamFromRequest(compiledRequest, artifacts);
5740
+ this.activeStreamContexts.set(agent.name, {
5741
+ requestId,
5742
+ trigger,
5743
+ compiledRequest,
5744
+ artifacts,
5745
+ });
5746
+ const handle = this.driveStream(
5747
+ agent,
5748
+ stream,
5749
+ requestSnapshot,
5750
+ requestId,
5751
+ trigger,
5752
+ attempt,
5753
+ compiledRequest,
5754
+ artifacts,
5755
+ );
3668
5756
  this.activeStreams.set(agent.name, handle);
3669
5757
  } catch (error) {
3670
5758
  const err = error instanceof Error ? error : new Error(String(error));
5759
+ if (trigger?.primarySummaryFallbackRetry) {
5760
+ await this.updatePrimarySummaryRequestRecord(requestId, (record) => ({
5761
+ ...record,
5762
+ finalStatus: 'held',
5763
+ fallbackHeldReason: err.message,
5764
+ }));
5765
+ await this.updatePrimarySummaryRequestRecord(trigger.primarySummaryFallbackRetry.originalRequestId, (record) => ({
5766
+ ...record,
5767
+ finalStatus: 'held',
5768
+ fallbackStatus: 'held',
5769
+ fallbackHeldReason: err.message,
5770
+ }));
5771
+ this.opsAlert('refusal-held', agent.name, err.message, {
5772
+ data: { requestId, originalRequestId: trigger.primarySummaryFallbackRetry.originalRequestId },
5773
+ });
5774
+ agent.reset();
5775
+ this.settleAgent(agent.name, {
5776
+ stopReason: 'completed',
5777
+ speech: '',
5778
+ });
5779
+ this.eventGate?.onInferenceEnded(agent.name);
5780
+ this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
5781
+ return;
5782
+ }
3671
5783
  this.emitTrace({
3672
5784
  type: 'inference:failed',
3673
5785
  agentName: agent.name,
@@ -3710,27 +5822,35 @@ export class AgentFramework {
3710
5822
  private async driveStream(
3711
5823
  agent: Agent,
3712
5824
  stream: YieldingStream,
3713
- trigger?: InferenceRequest,
5825
+ requestSnapshot: InferenceToolSnapshot,
5826
+ requestId: string,
5827
+ trigger?: PendingInferenceRequest,
3714
5828
  attempt = 0,
3715
- compiledRequest?: NormalizedRequest
5829
+ compiledRequest?: NormalizedRequest,
5830
+ artifacts?: ActivationCompileArtifacts,
3716
5831
  ): Promise<void> {
3717
5832
  const startTime = Date.now();
3718
- const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
3719
5833
  const myStreamId = agent.streamId;
3720
5834
  let hadToolCalls = false;
5835
+ let executedToolCalls = 0;
5836
+ const primarySummaryConfig = this.primarySummaryFallbackConfig(agent);
5837
+ const primarySummaryRetry = trigger?.primarySummaryFallbackRetry;
5838
+ let primarySummaryQuarantinePersisted = false;
5839
+ const fallbackRetryRequestForLog =
5840
+ primarySummaryRetry && compiledRequest
5841
+ ? this.summarizePrimarySummaryRetryRequest(compiledRequest)
5842
+ : compiledRequest ?? { note: 'streaming request' };
3721
5843
 
3722
5844
  // ---- Present-while-acting turn state ---------------------------------
3723
- // Output locus for THIS turn: resolved lazily at the first routed prose
3724
- // segment and pinned for the rest of the turn (mid-turn rounds AND the
3725
- // trailing prose at 'complete'), so one turn's voice stays in one channel
3726
- // even when a queued inbound from another channel overwrites the
3727
- // per-agent triggering channel between segments (PR #32). The pin lives
3728
- // in `turnLocusPins` so it survives a context-budget stream restart. A
3729
- // null resolution is NOT pinned — a turn whose locus is unresolvable
3730
- // early (e.g. heartbeat-triggered) may still resolve a channel by the
3731
- // time later prose routes.
3732
- if (trigger?.reason !== 'context_budget_restart') {
5845
+ // Output locus for the current CONVERSATIONAL ROUND: resolved lazily and
5846
+ // kept stable across tool-only rounds. A channel message injected at a
5847
+ // tool boundary replaces the pin before the next model round, because a
5848
+ // single provider inference can now contain several conversations. The
5849
+ // pin lives in `turnLocusPins` so it survives a context-budget restart. A
5850
+ // null resolution is not pinned.
5851
+ if (trigger?.reason !== 'context_budget_restart' && trigger?.reason !== 'primary-summary-fallback-retry') {
3733
5852
  this.turnLocusPins.delete(agent.name);
5853
+ this.midTurnRoutingResets.delete(agent.name);
3734
5854
  }
3735
5855
  const resolveTurnLocus = (): string | null => {
3736
5856
  let locus = this.turnLocusPins.get(agent.name) ?? null;
@@ -3757,10 +5877,9 @@ export class AgentFramework {
3757
5877
  .catch((err) => console.error('mid-turn speech routing failed:', err));
3758
5878
  };
3759
5879
 
3760
- // Sticky silencing: once any round uses a SILENCING_TOOLS member,
3761
- // prose from that round ONWARD is suppressed — skip_reply means "stay
3762
- // silent", an explicit send means "I deliver my own words". Earlier
3763
- // rounds' prose was already delivered live and cannot be retracted.
5880
+ // Sticky explicit-send suppression: prose after send_message stays quiet
5881
+ // to prevent a redundant "sent it" postscript. Fresh injected input
5882
+ // clears it, because the following prose is a reply to a new message.
3764
5883
  let turnSilenced = false;
3765
5884
 
3766
5885
  // Live routing is only trusted when the membrane provides verbatim
@@ -3777,11 +5896,128 @@ export class AgentFramework {
3777
5896
  // responding to, for the whole duration of this turn. Started here (paired
3778
5897
  // with the finally below, so it can never leak) and refreshed on a 7s
3779
5898
  // interval by the ChannelRegistry until stopped on any exit path.
3780
- const typingChannel = this.channelRegistry
5899
+ let typingChannel = this.channelRegistry
3781
5900
  ? trigger?.channelId ?? this.channelRegistry.getDefaultPublishChannel()
3782
5901
  : null;
3783
5902
  if (typingChannel) this.channelRegistry!.startTyping(typingChannel);
3784
5903
 
5904
+ const adoptInjectedRound = (): void => {
5905
+ if (!this.midTurnRoutingResets.has(agent.name)) return;
5906
+ const nextLocus = this.midTurnRoutingResets.get(agent.name) ?? null;
5907
+ this.midTurnRoutingResets.delete(agent.name);
5908
+
5909
+ // A new human/agent message, not merely a tool result, means any earlier
5910
+ // explicit delivery has completed its conversational job.
5911
+ turnSilenced = false;
5912
+
5913
+ if (nextLocus) {
5914
+ this.turnLocusPins.set(agent.name, nextLocus);
5915
+ if (typingChannel !== nextLocus) {
5916
+ if (typingChannel) this.channelRegistry?.stopTyping(typingChannel);
5917
+ typingChannel = nextLocus;
5918
+ this.channelRegistry?.startTyping(nextLocus);
5919
+ }
5920
+ }
5921
+ };
5922
+
5923
+ const persistPrimarySummaryRetryQuarantine = async (): Promise<boolean> => {
5924
+ if (!primarySummaryRetry || primarySummaryQuarantinePersisted || !artifacts) return true;
5925
+ const validation = await this.validatePrimarySummaryRetryMutation(
5926
+ agent,
5927
+ requestId,
5928
+ primarySummaryRetry,
5929
+ compiledRequest!,
5930
+ artifacts,
5931
+ 'before_quarantine',
5932
+ );
5933
+ if (!validation.ok) {
5934
+ await this.holdPrimarySummaryRetryFamily(
5935
+ agent,
5936
+ requestId,
5937
+ primarySummaryRetry,
5938
+ validation.reason,
5939
+ validation.data,
5940
+ { emitChronicleMarker: false },
5941
+ );
5942
+ stream.cancel();
5943
+ agent.reset();
5944
+ this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
5945
+ return false;
5946
+ }
5947
+ try {
5948
+ const quarantinePrimarySummaryForPrimaryLane =
5949
+ agent.getContextManager().quarantinePrimarySummaryForPrimaryLane;
5950
+ if (typeof quarantinePrimarySummaryForPrimaryLane !== 'function') {
5951
+ throw new Error('Primary summary quarantine is unavailable in the active context strategy');
5952
+ }
5953
+ await quarantinePrimarySummaryForPrimaryLane.call(
5954
+ agent.getContextManager(),
5955
+ artifacts.contract,
5956
+ primarySummaryRetry.candidateSummaries,
5957
+ );
5958
+ primarySummaryQuarantinePersisted = true;
5959
+ await this.recordPrimarySummaryRetryQuarantine(
5960
+ primarySummaryRetry.originalRequestId,
5961
+ primarySummaryRetry.intentId,
5962
+ );
5963
+ return true;
5964
+ } catch (error) {
5965
+ const reason = 'primary_summary_fallback_quarantine_failed';
5966
+ await this.holdPrimarySummaryRetryFamily(agent, requestId, primarySummaryRetry, reason, {
5967
+ error: error instanceof Error ? error.message : String(error),
5968
+ });
5969
+ stream.cancel();
5970
+ agent.reset();
5971
+ this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
5972
+ return false;
5973
+ }
5974
+ };
5975
+
5976
+ const persistPrimarySummaryRetryOutput = async (
5977
+ phase: string,
5978
+ blocks: ContentBlock[],
5979
+ participant = agent.name,
5980
+ metadata?: MessageMetadata,
5981
+ ): Promise<boolean> => {
5982
+ if (!primarySummaryRetry || !artifacts) return true;
5983
+ if (blocks.length === 0) return true;
5984
+ const validation = await this.validatePrimarySummaryRetryMutation(
5985
+ agent,
5986
+ requestId,
5987
+ primarySummaryRetry,
5988
+ compiledRequest!,
5989
+ artifacts,
5990
+ `before_${phase}`,
5991
+ );
5992
+ if (!validation.ok) {
5993
+ await this.holdPrimarySummaryRetryFamily(
5994
+ agent,
5995
+ requestId,
5996
+ primarySummaryRetry,
5997
+ validation.reason,
5998
+ validation.data,
5999
+ { emitChronicleMarker: false },
6000
+ );
6001
+ stream.cancel();
6002
+ agent.reset();
6003
+ this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
6004
+ return false;
6005
+ }
6006
+ const messageId = agent.getContextManager().addMessage(participant, blocks, metadata);
6007
+ await this.recordPrimarySummaryRetryOutput(
6008
+ primarySummaryRetry.originalRequestId,
6009
+ primarySummaryRetry.intentId,
6010
+ {
6011
+ phase,
6012
+ contentHash: this.primarySummaryFallbackOutputPhaseHash(blocks),
6013
+ messageId,
6014
+ blockTypes: this.primarySummaryFallbackBlockTypes(blocks),
6015
+ persistedAt: Date.now(),
6016
+ },
6017
+ );
6018
+ return true;
6019
+ };
6020
+
3785
6021
  try {
3786
6022
  for await (const event of stream) {
3787
6023
  this.touchEphemeralRun(agent.name, true);
@@ -3809,8 +6045,8 @@ export class AgentFramework {
3809
6045
  }
3810
6046
 
3811
6047
  case 'tool-calls': {
6048
+ adoptInjectedRound();
3812
6049
  hadToolCalls = true;
3813
- this.recordEphemeralToolCalls(agent.name, event.calls.length);
3814
6050
  this.emitTrace({
3815
6051
  type: 'inference:tool_calls_yielded',
3816
6052
  agentName: agent.name,
@@ -3849,7 +6085,51 @@ export class AgentFramework {
3849
6085
 
3850
6086
  agent.enterWaitingForTools(event.calls, stream);
3851
6087
 
6088
+ if (!(await persistPrimarySummaryRetryQuarantine())) {
6089
+ return;
6090
+ }
6091
+
6092
+ if (primarySummaryRetry) {
6093
+ const phase = `tool_round_${event.context.depth ?? 0}`;
6094
+ if (!(await persistPrimarySummaryRetryOutput(phase, assistantBlocks))) {
6095
+ return;
6096
+ }
6097
+ this.pendingAssistantBlocks.delete(agent.name);
6098
+ }
6099
+
6100
+ executedToolCalls += event.calls.length;
6101
+ this.recordEphemeralToolCalls(agent.name, event.calls.length);
6102
+
3852
6103
  for (const call of event.calls) {
6104
+ if (primarySummaryRetry) {
6105
+ const validation = await this.validatePrimarySummaryRetryMutation(
6106
+ agent,
6107
+ requestId,
6108
+ primarySummaryRetry,
6109
+ compiledRequest!,
6110
+ artifacts!,
6111
+ `before_tool_dispatch_${call.id}`,
6112
+ );
6113
+ if (!validation.ok) {
6114
+ await this.holdPrimarySummaryRetryFamily(
6115
+ agent,
6116
+ requestId,
6117
+ primarySummaryRetry,
6118
+ validation.reason,
6119
+ validation.data,
6120
+ { emitChronicleMarker: false },
6121
+ );
6122
+ stream.cancel();
6123
+ agent.reset();
6124
+ this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
6125
+ return;
6126
+ }
6127
+ await this.recordPrimarySummaryRetryToolDispatch(
6128
+ primarySummaryRetry.originalRequestId,
6129
+ primarySummaryRetry.intentId,
6130
+ call,
6131
+ );
6132
+ }
3853
6133
  this.dispatchToolCall(agent.name, call);
3854
6134
  }
3855
6135
 
@@ -3866,6 +6146,9 @@ export class AgentFramework {
3866
6146
  // note above — the fallback preamble is cumulative in XML mode).
3867
6147
  if (this.channelRegistry) {
3868
6148
  const roundToolNames = event.calls.map((c) => c.name);
6149
+ const hasSameRoundPrivateThink =
6150
+ roundToolNames.includes('think') &&
6151
+ requestSnapshot.sameRoundThinkTextPolicy === 'private';
3869
6152
  if (roundToolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)))) {
3870
6153
  turnSilenced = true;
3871
6154
  }
@@ -3877,6 +6160,10 @@ export class AgentFramework {
3877
6160
  console.error(
3878
6161
  `[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (turn silenced)`,
3879
6162
  );
6163
+ } else if (hasSameRoundPrivateThink) {
6164
+ console.error(
6165
+ `[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (same_round_think_text_policy=private)`,
6166
+ );
3880
6167
  } else {
3881
6168
  const locus = resolveTurnLocus();
3882
6169
  console.error(
@@ -3894,6 +6181,7 @@ export class AgentFramework {
3894
6181
  }
3895
6182
 
3896
6183
  case 'complete': {
6184
+ adoptInjectedRound();
3897
6185
  const durationMs = Date.now() - startTime;
3898
6186
  const response = event.response;
3899
6187
 
@@ -3934,14 +6222,6 @@ export class AgentFramework {
3934
6222
  }
3935
6223
  }
3936
6224
 
3937
- // Add assistant response to context.
3938
- // If we had tool calls, each round's blocks (thinking + text +
3939
- // tool_use) were already stored as pendingAssistantBlocks and
3940
- // flushed when tool results arrived. Only store TRAILING content
3941
- // — blocks after the last tool block. A type-based filter would
3942
- // double-store earlier rounds' text/thinking AND detach signed
3943
- // thinking blocks from their tool_use turn (the API requires
3944
- // thinking to precede tool_use in the same assistant turn).
3945
6225
  const lastToolIdx = response.content.reduce(
3946
6226
  (last: number, b: ContentBlock, i: number) =>
3947
6227
  b.type === 'tool_use' || b.type === 'tool_result' ? i : last,
@@ -3950,12 +6230,259 @@ export class AgentFramework {
3950
6230
  const terminalContent = lastToolIdx >= 0
3951
6231
  ? response.content.slice(lastToolIdx + 1)
3952
6232
  : response.content;
6233
+ const heldAssistantContent = response.content;
6234
+ const visibleAssistantOutput = this.hasVisibleAssistantOutput(response.content);
6235
+ const providerInputTokens =
6236
+ response.usage?.inputTokens ??
6237
+ response.details?.usage?.inputTokens;
6238
+
6239
+ if (primarySummaryConfig.enabled && compiledRequest && artifacts && response.stopReason === 'refusal') {
6240
+ const stopDetails = (response.raw?.response as {
6241
+ stop_details?: { category?: string; explanation?: string };
6242
+ } | undefined)?.stop_details;
6243
+ const category = stopDetails?.category ?? 'unknown';
6244
+ console.error(
6245
+ `[inference-refusal] agent=${agent.name} category=${category}` +
6246
+ (stopDetails?.explanation ? ` explanation=${stopDetails.explanation}` : ''),
6247
+ );
6248
+ this.noteRefusal(agent.name, category, response.details?.usage
6249
+ ? {
6250
+ input: response.details.usage.inputTokens,
6251
+ output: response.details.usage.outputTokens,
6252
+ }
6253
+ : undefined);
6254
+
6255
+ const currentRecord = await this.updatePrimarySummaryRequestRecord(requestId, (record) => ({
6256
+ ...record,
6257
+ stopReason: response.stopReason,
6258
+ providerInputTokens,
6259
+ visibleAssistantOutput,
6260
+ executedToolCalls,
6261
+ finalStatus: 'refusal',
6262
+ }));
6263
+
6264
+ const holdReasonData = {
6265
+ category,
6266
+ visibleAssistantOutput,
6267
+ executedToolCalls,
6268
+ candidateLimit: primarySummaryConfig.maxNewSummaries,
6269
+ };
6270
+
6271
+ if (primarySummaryRetry) {
6272
+ const reason = visibleAssistantOutput
6273
+ ? 'primary_summary_fallback_retry_partial_output'
6274
+ : executedToolCalls > 0
6275
+ ? 'primary_summary_fallback_retry_tool_already_executed'
6276
+ : 'primary_summary_fallback_retry_refusal';
6277
+ if (visibleAssistantOutput || executedToolCalls > 0) {
6278
+ await this.persistPrimarySummaryHeldOutput(agent, requestId, reason, {
6279
+ stopReason: response.stopReason,
6280
+ providerInputTokens,
6281
+ visibleAssistantOutput,
6282
+ executedToolCalls,
6283
+ assistantBlocks: heldAssistantContent,
6284
+ retryDispatch: primarySummaryRetry,
6285
+ retryPhase: 'refusal_visible_output',
6286
+ holdData: holdReasonData,
6287
+ });
6288
+ } else {
6289
+ await this.holdPrimarySummaryRetryFamily(
6290
+ agent,
6291
+ requestId,
6292
+ primarySummaryRetry,
6293
+ reason,
6294
+ holdReasonData,
6295
+ );
6296
+ }
6297
+ } else if (visibleAssistantOutput || executedToolCalls > 0 || !currentRecord?.projection) {
6298
+ const reason = visibleAssistantOutput
6299
+ ? 'primary_summary_refusal_partial_output'
6300
+ : executedToolCalls > 0
6301
+ ? 'primary_summary_refusal_tool_already_executed'
6302
+ : 'primary_summary_refusal_no_projection';
6303
+ if (visibleAssistantOutput || executedToolCalls > 0) {
6304
+ await this.persistPrimarySummaryHeldOutput(agent, requestId, reason, {
6305
+ stopReason: response.stopReason,
6306
+ providerInputTokens,
6307
+ visibleAssistantOutput,
6308
+ executedToolCalls,
6309
+ assistantBlocks: heldAssistantContent,
6310
+ holdData: holdReasonData,
6311
+ });
6312
+ } else {
6313
+ await this.holdPrimarySummaryRequest(agent, requestId, reason, holdReasonData);
6314
+ }
6315
+ } else {
6316
+ const state = this.readPrimarySummaryFallbackState();
6317
+ const baseline = this.latestCompatibleHealthyPrimaryRequest(currentRecord, state);
6318
+ if (!baseline?.projection) {
6319
+ await this.holdPrimarySummaryRequest(
6320
+ agent,
6321
+ requestId,
6322
+ 'primary_summary_refusal_no_compatible_healthy_baseline',
6323
+ holdReasonData,
6324
+ );
6325
+ } else {
6326
+ const baselineKeys = new Set(this.primarySummaryProjectionKeys(baseline.projection));
6327
+ const candidates = currentRecord.projection.selectedSummaries
6328
+ .filter((selection) => !baselineKeys.has(this.primarySummaryIdentityKey(selection.identity)))
6329
+ .map((selection) => selection.identity);
6330
+ if (candidates.length === 0) {
6331
+ await this.holdPrimarySummaryRequest(
6332
+ agent,
6333
+ requestId,
6334
+ 'primary_summary_refusal_no_newly_admitted_candidates',
6335
+ holdReasonData,
6336
+ );
6337
+ } else if (candidates.length > primarySummaryConfig.maxNewSummaries) {
6338
+ await this.holdPrimarySummaryRequest(
6339
+ agent,
6340
+ requestId,
6341
+ 'primary_summary_refusal_too_many_candidates',
6342
+ { ...holdReasonData, candidateCount: candidates.length },
6343
+ );
6344
+ } else {
6345
+ try {
6346
+ const retry = agent.buildPrimarySummaryRawExpansionRequest(
6347
+ artifacts,
6348
+ candidates,
6349
+ compiledRequest.tools ?? [],
6350
+ );
6351
+ const retryRequestHash = this.hashJson(retry.request);
6352
+ const admittedTokens = this.primarySummaryAdmissionBoundTokens(
6353
+ currentRecord.requestInputBoundTokens,
6354
+ retry.request,
6355
+ providerInputTokens,
6356
+ );
6357
+ if (admittedTokens > primarySummaryConfig.requestBudgetTokens) {
6358
+ await this.holdPrimarySummaryRequest(
6359
+ agent,
6360
+ requestId,
6361
+ 'primary_summary_refusal_retry_over_budget',
6362
+ {
6363
+ ...holdReasonData,
6364
+ admittedTokens,
6365
+ requestBudgetTokens: primarySummaryConfig.requestBudgetTokens,
6366
+ },
6367
+ );
6368
+ } else {
6369
+ const intentId = `${requestId}:fallback:${Math.random().toString(36).slice(2, 8)}`;
6370
+ await this.updatePrimarySummaryRequestRecord(requestId, (record) => ({
6371
+ ...record,
6372
+ fallbackIntent: {
6373
+ intentId,
6374
+ createdAt: Date.now(),
6375
+ baselineRequestId: baseline.requestId,
6376
+ candidateSummaries: candidates,
6377
+ retryRequestHash,
6378
+ retryCompleteBoundTokens: admittedTokens,
6379
+ requestBudgetTokens: primarySummaryConfig.requestBudgetTokens,
6380
+ },
6381
+ fallbackStatus: 'pending',
6382
+ }));
6383
+ this.pendingRequests.push({
6384
+ agentName: agent.name,
6385
+ reason: 'primary-summary-fallback-retry',
6386
+ source: 'framework',
6387
+ timestamp: Date.now(),
6388
+ channelId: trigger?.channelId,
6389
+ primarySummaryFallbackRetry: {
6390
+ originalRequestId: requestId,
6391
+ baselineRequestId: baseline.requestId,
6392
+ intentId,
6393
+ branch: {
6394
+ id: currentRecord.branch.id,
6395
+ generation: currentRecord.branch.generation,
6396
+ },
6397
+ candidateSummaries: candidates,
6398
+ originalArtifacts: artifacts,
6399
+ originalProjectionKeys: this.primarySummaryProjectionKeys(currentRecord.projection),
6400
+ expectedRetryRequestHash: retryRequestHash,
6401
+ requestBudgetTokens: primarySummaryConfig.requestBudgetTokens,
6402
+ originalRequestInputBoundTokens: currentRecord.requestInputBoundTokens,
6403
+ originalProviderInputTokens: providerInputTokens,
6404
+ },
6405
+ });
6406
+ }
6407
+ } catch (error) {
6408
+ await this.holdPrimarySummaryRequest(
6409
+ agent,
6410
+ requestId,
6411
+ 'primary_summary_refusal_retry_build_failed',
6412
+ {
6413
+ ...holdReasonData,
6414
+ error: error instanceof Error ? error.message : String(error),
6415
+ },
6416
+ );
6417
+ }
6418
+ }
6419
+ }
6420
+ }
6421
+
6422
+ agent.reset();
6423
+ this.eventGate?.onInferenceEnded(agent.name);
6424
+ this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
6425
+ continue;
6426
+ }
6427
+
6428
+ if (!(await persistPrimarySummaryRetryQuarantine())) {
6429
+ return;
6430
+ }
6431
+
6432
+ // Add assistant response to context.
6433
+ // If we had tool calls, each round's blocks (thinking + text +
6434
+ // tool_use) were already stored as pendingAssistantBlocks and
6435
+ // flushed when tool results arrived. Only store TRAILING content
6436
+ // — blocks after the last tool block. A type-based filter would
6437
+ // double-store earlier rounds' text/thinking AND detach signed
6438
+ // thinking blocks from their tool_use turn (the API requires
6439
+ // thinking to precede tool_use in the same assistant turn).
3953
6440
  if (lastToolIdx >= 0) {
3954
6441
  if (terminalContent.length > 0) {
3955
- agent.addAssistantResponse(terminalContent);
6442
+ if (primarySummaryRetry) {
6443
+ const retryRecord = await this.getPrimarySummaryRequestRecord(requestId);
6444
+ const terminalMetadata = retryRecord
6445
+ ? this.buildPrimarySummarySettlementMetadata(retryRecord, {
6446
+ outcome: 'success',
6447
+ stopReason: response.stopReason,
6448
+ providerInputTokens,
6449
+ visibleAssistantOutput,
6450
+ executedToolCalls,
6451
+ entryIndex: 0,
6452
+ entryCount: 1,
6453
+ role: 'assistant',
6454
+ kind: 'assistant_output',
6455
+ })
6456
+ : undefined;
6457
+ if (!(await persistPrimarySummaryRetryOutput('terminal', terminalContent, agent.name, terminalMetadata))) {
6458
+ return;
6459
+ }
6460
+ } else {
6461
+ agent.addAssistantResponse(terminalContent);
6462
+ }
3956
6463
  }
3957
6464
  } else {
3958
- agent.addAssistantResponse(terminalContent);
6465
+ if (primarySummaryRetry) {
6466
+ const retryRecord = await this.getPrimarySummaryRequestRecord(requestId);
6467
+ const terminalMetadata = retryRecord && terminalContent.length > 0
6468
+ ? this.buildPrimarySummarySettlementMetadata(retryRecord, {
6469
+ outcome: 'success',
6470
+ stopReason: response.stopReason,
6471
+ providerInputTokens,
6472
+ visibleAssistantOutput,
6473
+ executedToolCalls,
6474
+ entryIndex: 0,
6475
+ entryCount: 1,
6476
+ role: 'assistant',
6477
+ kind: 'assistant_output',
6478
+ })
6479
+ : undefined;
6480
+ if (!(await persistPrimarySummaryRetryOutput('terminal', terminalContent, agent.name, terminalMetadata))) {
6481
+ return;
6482
+ }
6483
+ } else {
6484
+ agent.addAssistantResponse(terminalContent);
6485
+ }
3959
6486
  }
3960
6487
 
3961
6488
  // Run afterInference hooks (no-op if no MCPL servers)
@@ -4017,6 +6544,50 @@ export class AgentFramework {
4017
6544
  }
4018
6545
  : undefined;
4019
6546
 
6547
+ if (primarySummaryRetry && artifacts) {
6548
+ const validation = await this.validatePrimarySummaryRetryMutation(
6549
+ agent,
6550
+ requestId,
6551
+ primarySummaryRetry,
6552
+ compiledRequest!,
6553
+ artifacts,
6554
+ 'before_success_markers',
6555
+ );
6556
+ if (!validation.ok) {
6557
+ await this.holdPrimarySummaryRetryFamily(
6558
+ agent,
6559
+ requestId,
6560
+ primarySummaryRetry,
6561
+ validation.reason,
6562
+ validation.data,
6563
+ { emitChronicleMarker: false },
6564
+ );
6565
+ return;
6566
+ }
6567
+ }
6568
+
6569
+ if (primarySummaryConfig.enabled) {
6570
+ if (primarySummaryRetry) {
6571
+ await this.markPrimarySummaryRetrySuccess(
6572
+ requestId,
6573
+ primarySummaryRetry,
6574
+ response.stopReason,
6575
+ providerInputTokens,
6576
+ visibleAssistantOutput,
6577
+ executedToolCalls,
6578
+ );
6579
+ } else {
6580
+ await this.updatePrimarySummaryRequestRecord(requestId, (record) => ({
6581
+ ...record,
6582
+ stopReason: response.stopReason,
6583
+ providerInputTokens,
6584
+ visibleAssistantOutput,
6585
+ executedToolCalls,
6586
+ finalStatus: 'success',
6587
+ }));
6588
+ }
6589
+ }
6590
+
4020
6591
  // Reset agent state before emitting inference:completed. Traces are
4021
6592
  // observability-only, but external synchronous listeners should
4022
6593
  // still see the terminal state at the terminal trace boundary.
@@ -4055,8 +6626,10 @@ export class AgentFramework {
4055
6626
  agentName: agent.name,
4056
6627
  requestId,
4057
6628
  success: true,
4058
- request: compiledRequest ?? { note: 'streaming request' },
4059
- response: response.raw ?? { note: 'streaming response' },
6629
+ request: fallbackRetryRequestForLog,
6630
+ response: primarySummaryRetry
6631
+ ? this.summarizePrimarySummaryRetryResponse(response)
6632
+ : response.raw ?? { note: 'streaming response' },
4060
6633
  durationMs,
4061
6634
  tokenUsage,
4062
6635
  stopReason: response.stopReason,
@@ -4209,15 +6782,17 @@ export class AgentFramework {
4209
6782
  // speech/thoughts split is left untouched (module/TUI rendering
4210
6783
  // unaffected) — this only governs what reaches the channel.
4211
6784
  //
4212
- // Silencing here is the sticky flag ∪ turn-wide scan: the scan
4213
- // (over the whole turn's tool_use blocks) covers the fallback
4214
- // path and the "send_message then a 'sent it.' postscript"
4215
- // double-post guard; the sticky flag is its live-rounds twin.
6785
+ // Native round routing uses the live sticky flag, which a fresh
6786
+ // injected message can reset. The legacy/fallback path has no
6787
+ // reliable round boundaries, so retain its historical turn-wide
6788
+ // scan to avoid double-posting.
4216
6789
  const toolNames = response.content
4217
6790
  .filter((b) => b.type === 'tool_use')
4218
6791
  .map((b) => (b as unknown as { name?: string }).name)
4219
6792
  .filter((n): n is string => typeof n === 'string');
4220
- const silenced = turnSilenced || toolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)));
6793
+ const silenced = liveProseRouting
6794
+ ? turnSilenced
6795
+ : turnSilenced || toolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)));
4221
6796
 
4222
6797
  const segments = splitProseSegments(liveProseRouting ? terminalContent : response.content);
4223
6798
 
@@ -4276,13 +6851,30 @@ export class AgentFramework {
4276
6851
  requestId,
4277
6852
  success: false,
4278
6853
  error: err.message,
4279
- request: compiledRequest ?? { note: 'streaming request failed' },
6854
+ request: fallbackRetryRequestForLog,
4280
6855
  durationMs,
4281
6856
  });
4282
6857
 
4283
6858
  // Only reset + retry if this is still the active stream
4284
6859
  if (agent.streamId !== myStreamId) break;
4285
6860
 
6861
+ if (primarySummaryRetry) {
6862
+ await this.holdPrimarySummaryRetryFamily(
6863
+ agent,
6864
+ requestId,
6865
+ primarySummaryRetry,
6866
+ 'primary_summary_fallback_retry_stream_error',
6867
+ { error: err.message },
6868
+ );
6869
+ agent.reset();
6870
+ this.eventGate?.onInferenceEnded(agent.name);
6871
+ this.settleAgent(agent.name, {
6872
+ stopReason: 'completed',
6873
+ speech: '',
6874
+ });
6875
+ return;
6876
+ }
6877
+
4286
6878
  agent.reset();
4287
6879
 
4288
6880
  const action = this.errorPolicy.onInferenceError(err, agent.name, attempt);
@@ -4322,6 +6914,17 @@ export class AgentFramework {
4322
6914
  // reject an ephemeral's promise mid-run and bump the failure
4323
6915
  // streak). Gate release + stream teardown happen in `finally`.
4324
6916
  if (this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`)) {
6917
+ if (primarySummaryRetry) {
6918
+ if (!(await this.primarySummaryRetryFamilyAlreadySettled(requestId, primarySummaryRetry))) {
6919
+ await this.holdPrimarySummaryRetryFamily(
6920
+ agent,
6921
+ requestId,
6922
+ primarySummaryRetry,
6923
+ 'primary_summary_fallback_retry_cancelled_before_success',
6924
+ { frameworkCancelled: true },
6925
+ );
6926
+ }
6927
+ }
4325
6928
  this.eventGate?.onInferenceEnded(agent.name);
4326
6929
  return;
4327
6930
  }
@@ -4330,6 +6933,40 @@ export class AgentFramework {
4330
6933
  // may have already started a new stream, bumping streamId)
4331
6934
  if (agent.streamId === myStreamId) {
4332
6935
  const durationMs = Date.now() - startTime;
6936
+ if (primarySummaryRetry) {
6937
+ await this.holdPrimarySummaryRetryFamily(
6938
+ agent,
6939
+ requestId,
6940
+ primarySummaryRetry,
6941
+ `stream_aborted:${reason}`,
6942
+ { reason },
6943
+ { stopReason: 'abort' },
6944
+ );
6945
+ agent.reset();
6946
+ this.eventGate?.onInferenceEnded(agent.name);
6947
+ this.logInference({
6948
+ timestamp: startTime,
6949
+ agentName: agent.name,
6950
+ requestId,
6951
+ success: false,
6952
+ error: `Stream aborted: ${reason}`,
6953
+ request: fallbackRetryRequestForLog,
6954
+ durationMs,
6955
+ });
6956
+ this.settleAgent(agent.name, {
6957
+ stopReason: 'completed',
6958
+ speech: '',
6959
+ });
6960
+ return;
6961
+ }
6962
+ if (primarySummaryConfig.enabled) {
6963
+ await this.updatePrimarySummaryRequestRecord(requestId, (record) => ({
6964
+ ...record,
6965
+ stopReason: 'abort',
6966
+ finalStatus: 'error',
6967
+ fallbackHeldReason: `stream_aborted:${reason}`,
6968
+ }));
6969
+ }
4333
6970
  agent.reset();
4334
6971
  this.settleAgent(agent.name, {
4335
6972
  stopReason: 'exhausted',
@@ -4353,7 +6990,7 @@ export class AgentFramework {
4353
6990
  requestId,
4354
6991
  success: false,
4355
6992
  error: `Stream aborted: ${reason}`,
4356
- request: compiledRequest ?? { note: 'streaming request aborted' },
6993
+ request: fallbackRetryRequestForLog,
4357
6994
  durationMs,
4358
6995
  });
4359
6996
  this.eventGate?.onInferenceEnded(agent.name);
@@ -4405,12 +7042,63 @@ export class AgentFramework {
4405
7042
  // inference:exhausted so ephemeral agent promises can settle.
4406
7043
  const err = error instanceof Error ? error : new Error(String(error));
4407
7044
  const durationMs = Date.now() - startTime;
7045
+ if (primarySummaryRetry) {
7046
+ if (await this.primarySummaryRetryAlreadySucceeded(requestId, primarySummaryRetry)) {
7047
+ this.logInference({
7048
+ timestamp: startTime,
7049
+ agentName: agent.name,
7050
+ requestId,
7051
+ success: true,
7052
+ request: fallbackRetryRequestForLog,
7053
+ response: { kind: 'primary_summary_fallback_retry', postSuccessError: err.message },
7054
+ durationMs,
7055
+ });
7056
+ agent.reset();
7057
+ this.eventGate?.onInferenceEnded(agent.name);
7058
+ this.settleAgent(agent.name, {
7059
+ stopReason: 'completed',
7060
+ speech: '',
7061
+ });
7062
+ return;
7063
+ }
7064
+ await this.holdPrimarySummaryRetryFamily(
7065
+ agent,
7066
+ requestId,
7067
+ primarySummaryRetry,
7068
+ 'primary_summary_fallback_retry_stream_threw',
7069
+ { error: err.message },
7070
+ );
7071
+ } else if (primarySummaryConfig.enabled) {
7072
+ await this.updatePrimarySummaryRequestRecord(requestId, (record) => ({
7073
+ ...record,
7074
+ finalStatus: 'error',
7075
+ fallbackHeldReason: err.message,
7076
+ }));
7077
+ }
4408
7078
  this.emitTrace({
4409
7079
  type: 'inference:failed',
4410
7080
  agentName: agent.name,
4411
7081
  error: err.message,
4412
7082
  stack: err.stack,
4413
7083
  });
7084
+ if (primarySummaryRetry) {
7085
+ this.logInference({
7086
+ timestamp: startTime,
7087
+ agentName: agent.name,
7088
+ requestId,
7089
+ success: false,
7090
+ error: `Stream threw: ${err.message}`,
7091
+ request: fallbackRetryRequestForLog,
7092
+ durationMs,
7093
+ });
7094
+ agent.reset();
7095
+ this.eventGate?.onInferenceEnded(agent.name);
7096
+ this.settleAgent(agent.name, {
7097
+ stopReason: 'completed',
7098
+ speech: '',
7099
+ });
7100
+ return;
7101
+ }
4414
7102
  this.settleAgent(agent.name, {
4415
7103
  stopReason: 'exhausted',
4416
7104
  speech: '',
@@ -4431,7 +7119,7 @@ export class AgentFramework {
4431
7119
  requestId,
4432
7120
  success: false,
4433
7121
  error: `Stream threw: ${err.message}`,
4434
- request: compiledRequest ?? { note: 'streaming request threw' },
7122
+ request: fallbackRetryRequestForLog,
4435
7123
  durationMs,
4436
7124
  });
4437
7125
  agent.reset();
@@ -4452,6 +7140,7 @@ export class AgentFramework {
4452
7140
  this.channelRegistry?.stopTyping();
4453
7141
  this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`);
4454
7142
  this.activeStreams.delete(agent.name);
7143
+ this.activeStreamContexts.delete(agent.name);
4455
7144
  this.pendingAssistantBlocks.delete(agent.name);
4456
7145
 
4457
7146
  // A conversation fork whose TTL closure turn just finished is done for
@@ -4589,6 +7278,51 @@ export class AgentFramework {
4589
7278
  return undefined;
4590
7279
  }
4591
7280
 
7281
+ private approximateDecodedBase64Bytes(base64: string): number {
7282
+ const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
7283
+ return Math.max(0, Math.floor(base64.length * 3 / 4) - padding);
7284
+ }
7285
+
7286
+ private sanitizeProcessLogValue(value: unknown, seen = new WeakMap<object, unknown>()): unknown {
7287
+ if (value && typeof value === 'object') {
7288
+ const seenValue = seen.get(value);
7289
+ if (seenValue) return seenValue;
7290
+ }
7291
+ if (Array.isArray(value)) {
7292
+ const clone: unknown[] = [];
7293
+ seen.set(value, clone);
7294
+ for (const item of value) {
7295
+ clone.push(this.sanitizeProcessLogValue(item, seen));
7296
+ }
7297
+ return clone;
7298
+ }
7299
+ if (!value || typeof value !== 'object') {
7300
+ return value;
7301
+ }
7302
+
7303
+ const record = value as Record<string, unknown>;
7304
+ if (record.type === 'image' && typeof record.data === 'string' && typeof record.mimeType === 'string') {
7305
+ const clone: Record<string, unknown> = {};
7306
+ seen.set(value, clone);
7307
+ for (const [key, child] of Object.entries(record)) {
7308
+ if (key === 'data' || key === 'mimeType') continue;
7309
+ clone[key] = this.sanitizeProcessLogValue(child, seen);
7310
+ }
7311
+ clone.type = 'image';
7312
+ clone.mimeType = record.mimeType;
7313
+ clone.approxByteLength = this.approximateDecodedBase64Bytes(record.data);
7314
+ clone.redacted = true;
7315
+ return clone;
7316
+ }
7317
+
7318
+ const clone: Record<string, unknown> = {};
7319
+ seen.set(value, clone);
7320
+ for (const [key, child] of Object.entries(record)) {
7321
+ clone[key] = this.sanitizeProcessLogValue(child, seen);
7322
+ }
7323
+ return clone;
7324
+ }
7325
+
4592
7326
  private logInference(entry: InferenceLogEntry): void {
4593
7327
  // Store large request/response as blobs
4594
7328
  const entryToStore = { ...entry };
@@ -4622,15 +7356,15 @@ export class AgentFramework {
4622
7356
  private logProcessEvent(event: ProcessEvent, responses: ModuleProcessResponse[]): void {
4623
7357
  const entry: ProcessLogEntry = {
4624
7358
  timestamp: Date.now(),
4625
- processEvent: event,
4626
- responses,
7359
+ processEvent: this.sanitizeProcessLogValue(event) as ProcessEvent,
7360
+ responses: this.sanitizeProcessLogValue(responses) as ModuleProcessResponse[],
4627
7361
  };
4628
7362
 
4629
7363
  // Blob threshold: 10KB
4630
7364
  const BLOB_THRESHOLD = 10000;
4631
7365
 
4632
7366
  const entryToStore = { ...entry };
4633
- const responsesJson = JSON.stringify(responses);
7367
+ const responsesJson = JSON.stringify(entry.responses);
4634
7368
  if (responsesJson.length > BLOB_THRESHOLD) {
4635
7369
  const blobId = this.store.storeBlob(Buffer.from(responsesJson), 'application/json');
4636
7370
  entryToStore.responses = { blobId };
@@ -4676,6 +7410,11 @@ export class AgentFramework {
4676
7410
  return;
4677
7411
  }
4678
7412
 
7413
+ if (enrichedCall.name === 'save_recent_image') {
7414
+ this.dispatchSaveImageToolCall(agentName, enrichedCall);
7415
+ return;
7416
+ }
7417
+
4679
7418
  // Route gate_status tool
4680
7419
  if (enrichedCall.name === 'gate_status' && this.eventGate) {
4681
7420
  this.dispatchGateToolCall(agentName, enrichedCall);
@@ -4939,12 +7678,23 @@ export class AgentFramework {
4939
7678
  * work, per-agent status + last inference activity. Cheap and read-only.
4940
7679
  */
4941
7680
  healthSnapshot(): Record<string, unknown> {
7681
+ const primarySummary = this.readPrimarySummaryFallbackState();
4942
7682
  return {
4943
7683
  at: new Date().toISOString(),
4944
7684
  uptimeSec: Math.round(process.uptime()),
4945
7685
  gate: this.eventGate?.inferenceDiagnostics() ?? null,
4946
7686
  pendingRequests: this.pendingRequests.length,
4947
7687
  activeStreams: [...this.activeStreams.keys()],
7688
+ primarySummaryFallback: {
7689
+ requests: primarySummary.requests.length,
7690
+ pendingDispatches: primarySummary.requests.filter((record) => record.finalStatus === 'pending').length,
7691
+ unresolvedIntents: primarySummary.requests.filter((record) => record.fallbackStatus === 'pending').length,
7692
+ held: primarySummary.requests.filter((record) =>
7693
+ record.finalStatus === 'held' ||
7694
+ record.finalStatus === 'timeout_unknown' ||
7695
+ record.fallbackStatus === 'held' ||
7696
+ record.fallbackStatus === 'timeout_unknown').length,
7697
+ },
4948
7698
  agents: [...this.agents.entries()].map(([name, agent]) => ({
4949
7699
  name,
4950
7700
  status: agent.state.status,
@@ -4979,6 +7729,22 @@ export class AgentFramework {
4979
7729
  * every ~15 min instead of flooding the channel on every occurrence.
4980
7730
  * See connectome docs/observability.md.
4981
7731
  */
7732
+ /**
7733
+ * Public ops-alert entry point for host-wired alarms that originate
7734
+ * OUTSIDE the framework (e.g. context-manager's repeating
7735
+ * compression-quarantine klaxon, wired in the host at strategy
7736
+ * construction). Same sinks and cooldown as internal alerts:
7737
+ * failures.log + ops:alert trace + CONNECTOME_OPS_WEBHOOK.
7738
+ */
7739
+ notifyOps(
7740
+ kind: string,
7741
+ agentName: string,
7742
+ message: string,
7743
+ data?: Record<string, unknown>,
7744
+ ): void {
7745
+ this.opsAlert(kind, agentName, message, { data });
7746
+ }
7747
+
4982
7748
  private opsAlert(
4983
7749
  kind: string,
4984
7750
  agentName: string,
@@ -5074,6 +7840,17 @@ export class AgentFramework {
5074
7840
  return {};
5075
7841
  }
5076
7842
 
7843
+ /**
7844
+ * Public entry to the ops-alert pipeline (failures.log + ops:alert trace +
7845
+ * throttled webhook) for host-level detectors OUTSIDE the framework's own
7846
+ * inference driver — e.g. the host's logging adapter catching refusals on
7847
+ * off-path calls (compression/summarizer, maintenance) that noteRefusal
7848
+ * never sees. Same per-(agent,kind) cooldown as everything else.
7849
+ */
7850
+ notifyOpsAlert(kind: string, agentName: string, message: string, data?: Record<string, unknown>): void {
7851
+ this.opsAlert(kind, agentName, message, { data });
7852
+ }
7853
+
5077
7854
  private noteInferenceExhausted(
5078
7855
  agentName: string,
5079
7856
  reason: string,
@@ -5355,8 +8132,16 @@ export class AgentFramework {
5355
8132
 
5356
8133
  for (const config of serverConfigs) {
5357
8134
  try {
5358
- await this.connectMcplServerInternal(config);
8135
+ // Stage every startup connection with both planes closed. This removes
8136
+ // server-order dependence: no early heartbeat/shell push can become
8137
+ // model-visible before a later Discord connection has joined the same
8138
+ // awareness generation.
8139
+ await this.connectMcplServerInternal(config, true);
5359
8140
  } catch (error) {
8141
+ if (error instanceof DiscordAwarenessAccountingError) {
8142
+ await this.mcplServerRegistry.closeAll();
8143
+ throw error;
8144
+ }
5360
8145
  // Fail-open: log and continue with remaining servers
5361
8146
  const err = error instanceof Error ? error : new Error(String(error));
5362
8147
  console.error(`Failed to connect MCPL server "${config.id}":`, err.message);
@@ -5374,10 +8159,353 @@ export class AgentFramework {
5374
8159
  }
5375
8160
  }
5376
8161
 
8162
+ // Start the durable drain only after all startup connections are staged.
8163
+ // The awareness tools/call is written before control-plane registration is
8164
+ // flushed, preserving the server-side queue -> registration -> call order.
8165
+ const startupBarrier = this.installMcplDataPlaneGate();
8166
+ this.releaseMcplDataPlaneGate(startupBarrier);
8167
+ try {
8168
+ await startupBarrier.promise;
8169
+ this.completeMcplDataPlaneGate(startupBarrier);
8170
+ } catch (error) {
8171
+ await this.mcplServerRegistry.closeAll();
8172
+ throw error;
8173
+ }
8174
+
5377
8175
  // Discover tools from all connected servers
5378
8176
  await this.refreshMcplTools();
5379
8177
  }
5380
8178
 
8179
+ /** Reconcile the durable ledger with Chronicle, then deliver every server's work. */
8180
+ async syncDiscordAwarenessMarkers(_onlyServerId?: string): Promise<void> {
8181
+ if (!this.discordAwarenessOutbox) return;
8182
+ // A targeted retry may discover pending work for other Discord servers
8183
+ // during reconciliation. Safety is global, so one generation accounts for
8184
+ // all pending operations before releasing any MCPL data plane.
8185
+ const barrier = this.installMcplDataPlaneGate();
8186
+ this.releaseMcplDataPlaneGate(barrier);
8187
+ try {
8188
+ await barrier.promise;
8189
+ this.completeMcplDataPlaneGate(barrier);
8190
+ } catch (error) {
8191
+ await this.failMcplDataPlaneGate(barrier, 'explicit synchronization', error);
8192
+ throw error;
8193
+ }
8194
+ }
8195
+
8196
+ private async resumePreparedDiscordSuppressions(): Promise<void> {
8197
+ if (!this.discordAwarenessOutbox) return;
8198
+ const batches = this.discordAwarenessOutbox.preparedSuppressionsForBranch(
8199
+ this.store.currentBranch().name,
8200
+ this.store.listBranches(),
8201
+ );
8202
+ for (const batch of batches) {
8203
+ const agent = this.agents.get(batch.agentName);
8204
+ if (!agent) {
8205
+ throw new Error(
8206
+ `Cannot resume Discord suppression ${batch.id}: agent ${batch.agentName} is not configured`,
8207
+ );
8208
+ }
8209
+ const cm = agent.getContextManager();
8210
+ for (const interval of [...(batch.suppressionIntervals ?? [])].reverse()) {
8211
+ const from = cm.getMessage(interval.fromId as MessageId);
8212
+ const to = cm.getMessage(interval.toId as MessageId);
8213
+ if (!from && !to) continue; // This interval committed before the crash.
8214
+ if (!from || !to) {
8215
+ throw new Error(
8216
+ `Cannot safely resume Discord suppression ${batch.id}: interval ` +
8217
+ `${interval.fromId}..${interval.toId} is only partially present`,
8218
+ );
8219
+ }
8220
+ if (interval.fromId === interval.toId) cm.removeMessage(from.id);
8221
+ else cm.removeMessages(from.id, to.id);
8222
+ }
8223
+ this.discordAwarenessOutbox.activate(batch.id);
8224
+ console.error(`[discord-awareness] resumed suppression batch ${batch.id}`);
8225
+ }
8226
+ }
8227
+
8228
+ /**
8229
+ * Deliver every due ref independently. Permanent Discord failures are kept
8230
+ * in the ledger for audit but do not block later operations; retryable
8231
+ * failures remain pending for the next reconnect/list-change attempt.
8232
+ */
8233
+ private drainDiscordAwarenessOutbox(
8234
+ serverId: string,
8235
+ ): Promise<DiscordAwarenessDrainOutcome> {
8236
+ if (!this.discordAwarenessOutbox) {
8237
+ return Promise.resolve({ status: 'delivered', delivered: 0, failed: 0 });
8238
+ }
8239
+ const existing = this.discordAwarenessDrains.get(serverId);
8240
+ if (existing) return existing;
8241
+
8242
+ const drain = (async () => {
8243
+ const connection = this.mcplServerRegistry?.getServer(serverId);
8244
+ if (!connection?.isConnected) {
8245
+ const operations = this.readDiscordAwarenessPending(serverId);
8246
+ for (const operation of operations) {
8247
+ this.writeDiscordAwarenessFailure(
8248
+ operation.batchId,
8249
+ operation.ref,
8250
+ operation.action,
8251
+ 'Awareness delivery not attempted: MCPL connection unavailable',
8252
+ false,
8253
+ );
8254
+ }
8255
+ if (operations.length > 0) {
8256
+ console.error(
8257
+ `[discord-awareness] ${serverId}: connection unavailable; ` +
8258
+ `durably deferred=${operations.length}`,
8259
+ );
8260
+ }
8261
+ return { status: 'unavailable' as const, accounted: operations.length };
8262
+ }
8263
+
8264
+ let delivered = 0;
8265
+ let failed = 0;
8266
+ const attempted = new Set<string>();
8267
+ while (true) {
8268
+ const operations = this.readDiscordAwarenessPending(serverId).filter((operation) => {
8269
+ const key = `${operation.batchId}\0${operation.ref.channelId}\0${operation.ref.messageId}\0${operation.action}`;
8270
+ if (attempted.has(key)) return false;
8271
+ attempted.add(key);
8272
+ return true;
8273
+ });
8274
+ if (operations.length === 0) break;
8275
+ for (const operation of operations) {
8276
+ const ref = operation.ref;
8277
+ const channelId = ref.channelId.startsWith('discord:')
8278
+ ? ref.channelId.split(':').at(-1)!
8279
+ : ref.channelId;
8280
+ let deliveryError: string | undefined;
8281
+ try {
8282
+ const tool = operation.action === 'add' ? 'add_reaction' : 'remove_reaction';
8283
+ const result = await connection.sendToolsCallWithDeadline(tool, {
8284
+ channelId,
8285
+ messageId: ref.messageId,
8286
+ emoji: operation.emoji,
8287
+ }, this.discordAwarenessDeadlineMs);
8288
+ if (result.isError) {
8289
+ deliveryError = result.content
8290
+ .map((content) => content.text ?? '')
8291
+ .filter(Boolean)
8292
+ .join('; ') || `Discord ${tool} returned an error`;
8293
+ }
8294
+ } catch (error) {
8295
+ deliveryError = error instanceof Error ? error.message : String(error);
8296
+ }
8297
+
8298
+ if (deliveryError !== undefined) {
8299
+ const permanent = isPermanentDiscordReactionFailure(deliveryError);
8300
+ this.writeDiscordAwarenessFailure(
8301
+ operation.batchId,
8302
+ ref,
8303
+ operation.action,
8304
+ deliveryError,
8305
+ permanent,
8306
+ );
8307
+ failed++;
8308
+ console.error(
8309
+ `[discord-awareness] ${operation.action} failed for ${ref.channelId}/${ref.messageId}` +
8310
+ ` (${permanent ? 'permanent' : 'retryable'}): ${deliveryError}`,
8311
+ );
8312
+ } else {
8313
+ this.writeDiscordAwarenessSuccess(operation.batchId, ref, operation.action);
8314
+ delivered++;
8315
+ }
8316
+ }
8317
+ }
8318
+ if (delivered > 0 || failed > 0) {
8319
+ console.error(
8320
+ `[discord-awareness] ${serverId}: delivered=${delivered} failed=${failed}`,
8321
+ );
8322
+ }
8323
+ return { status: 'delivered' as const, delivered, failed };
8324
+ })().finally(() => {
8325
+ this.discordAwarenessDrains.delete(serverId);
8326
+ });
8327
+
8328
+ this.discordAwarenessDrains.set(serverId, drain);
8329
+ return drain;
8330
+ }
8331
+
8332
+ private readDiscordAwarenessPending(serverId?: string) {
8333
+ try {
8334
+ return this.discordAwarenessOutbox!.pending(serverId);
8335
+ } catch (error) {
8336
+ throw new DiscordAwarenessAccountingError(
8337
+ `ledger read${serverId ? ` for ${serverId}` : ''}`,
8338
+ error,
8339
+ );
8340
+ }
8341
+ }
8342
+
8343
+ private writeDiscordAwarenessSuccess(
8344
+ batchId: string,
8345
+ ref: import('./recovery/discord-awareness-outbox.js').DiscordAwarenessRef,
8346
+ action: import('./recovery/discord-awareness-outbox.js').DiscordAwarenessAction,
8347
+ ): void {
8348
+ try {
8349
+ this.discordAwarenessOutbox!.recordSuccess(batchId, ref, action);
8350
+ } catch (error) {
8351
+ throw new DiscordAwarenessAccountingError('recordSuccess ledger write', error);
8352
+ }
8353
+ }
8354
+
8355
+ private writeDiscordAwarenessFailure(
8356
+ batchId: string,
8357
+ ref: import('./recovery/discord-awareness-outbox.js').DiscordAwarenessRef,
8358
+ action: import('./recovery/discord-awareness-outbox.js').DiscordAwarenessAction,
8359
+ errorMessage: string,
8360
+ permanent: boolean,
8361
+ ): void {
8362
+ try {
8363
+ this.discordAwarenessOutbox!.recordFailure(
8364
+ batchId,
8365
+ ref,
8366
+ action,
8367
+ errorMessage,
8368
+ permanent,
8369
+ );
8370
+ } catch (error) {
8371
+ throw new DiscordAwarenessAccountingError('recordFailure ledger write', error);
8372
+ }
8373
+ }
8374
+
8375
+ private beginDiscordAwarenessBarrier(): {
8376
+ requiresBarrier: boolean;
8377
+ promise: Promise<DiscordAwarenessDrainOutcome>;
8378
+ } {
8379
+ if (!this.discordAwarenessOutbox) {
8380
+ return {
8381
+ requiresBarrier: false,
8382
+ promise: Promise.resolve({ status: 'delivered', delivered: 0, failed: 0 }),
8383
+ };
8384
+ }
8385
+ try {
8386
+ this.discordAwarenessOutbox.reconcileForBranch(
8387
+ this.store.currentBranch().name,
8388
+ this.store.listBranches(),
8389
+ );
8390
+ } catch (error) {
8391
+ throw new DiscordAwarenessAccountingError('branch reconciliation', error);
8392
+ }
8393
+ const pending = this.readDiscordAwarenessPending();
8394
+ if (pending.length === 0) {
8395
+ return {
8396
+ requiresBarrier: false,
8397
+ promise: Promise.resolve({ status: 'delivered', delivered: 0, failed: 0 }),
8398
+ };
8399
+ }
8400
+ const serverIds = [...new Set(pending.map((operation) => operation.ref.serverId))];
8401
+ const promise = Promise.all(
8402
+ serverIds.map((serverId) => this.drainDiscordAwarenessOutbox(serverId)),
8403
+ ).then((outcomes): DiscordAwarenessDrainOutcome => {
8404
+ if (outcomes.every((outcome) => outcome.status === 'unavailable')) {
8405
+ return {
8406
+ status: 'unavailable',
8407
+ accounted: outcomes.reduce(
8408
+ (count, outcome) => count + (outcome.status === 'unavailable' ? outcome.accounted : 0),
8409
+ 0,
8410
+ ),
8411
+ };
8412
+ }
8413
+ return {
8414
+ status: 'delivered',
8415
+ delivered: outcomes.reduce(
8416
+ (count, outcome) => count + (outcome.status === 'delivered' ? outcome.delivered : 0),
8417
+ 0,
8418
+ ),
8419
+ failed: outcomes.reduce(
8420
+ (count, outcome) => count + (outcome.status === 'delivered' ? outcome.failed : 0),
8421
+ 0,
8422
+ ),
8423
+ };
8424
+ });
8425
+ return { requiresBarrier: true, promise };
8426
+ }
8427
+
8428
+ /**
8429
+ * Install one framework-global Discord-awareness generation before releasing
8430
+ * any inference-bearing event. With pending work, every MCPL data plane is
8431
+ * paused while all control planes remain live. With no pending work, ready()
8432
+ * runs synchronously so request responders preserve their historical
8433
+ * same-stack behavior.
8434
+ */
8435
+ private installMcplDataPlaneGate(): DiscordAwarenessBarrier {
8436
+ for (const connection of this.mcplServerRegistry?.getAllServers() ?? []) {
8437
+ connection.pauseDataPlane();
8438
+ }
8439
+ const generation = ++this.discordAwarenessBarrierGeneration;
8440
+
8441
+ let begun: ReturnType<AgentFramework['beginDiscordAwarenessBarrier']>;
8442
+ try {
8443
+ begun = this.beginDiscordAwarenessBarrier();
8444
+ } catch (error) {
8445
+ begun = {
8446
+ requiresBarrier: true,
8447
+ promise: Promise.reject(error),
8448
+ };
8449
+ }
8450
+ const barrier: DiscordAwarenessBarrier = { generation, ...begun };
8451
+ this.discordAwarenessBarrier = barrier;
8452
+ return barrier;
8453
+ }
8454
+
8455
+ private releaseMcplDataPlaneGate(
8456
+ barrier: DiscordAwarenessBarrier,
8457
+ ): void {
8458
+ if (this.discordAwarenessBarrier !== barrier) return;
8459
+ if (barrier.requiresBarrier) {
8460
+ for (const connection of this.mcplServerRegistry?.getAllServers() ?? []) {
8461
+ connection.readyControlPlane();
8462
+ }
8463
+ return;
8464
+ }
8465
+ this.completeMcplDataPlaneGate(barrier);
8466
+ }
8467
+
8468
+ private completeMcplDataPlaneGate(
8469
+ barrier: DiscordAwarenessBarrier,
8470
+ ): boolean {
8471
+ if (this.discordAwarenessBarrier !== barrier) return false;
8472
+ this.discordAwarenessBarrier = null;
8473
+ for (const connection of this.mcplServerRegistry?.getAllServers() ?? []) {
8474
+ // ready() can synchronously flush a nested list-change notification that
8475
+ // installs a newer global generation. Never let this older completion
8476
+ // release any remaining server behind that newer gate.
8477
+ if (this.discordAwarenessBarrier !== null) return false;
8478
+ connection.ready();
8479
+ }
8480
+ return this.discordAwarenessBarrier === null;
8481
+ }
8482
+
8483
+ private async failMcplDataPlaneGate(
8484
+ barrier: DiscordAwarenessBarrier,
8485
+ context: string,
8486
+ error: unknown,
8487
+ ): Promise<void> {
8488
+ if (this.discordAwarenessBarrier !== barrier) return;
8489
+ const err = error instanceof Error ? error : new Error(String(error));
8490
+ console.error(`[discord-awareness] ${context} failed globally:`, err.message);
8491
+ const connections = this.mcplServerRegistry?.getAllServers() ?? [];
8492
+ for (const connection of connections) {
8493
+ this.emitTrace({
8494
+ type: 'mcpl:server-error',
8495
+ serverId: connection.id,
8496
+ error: `Discord awareness accounting unhealthy: ${err.message}`,
8497
+ });
8498
+ }
8499
+ await Promise.all(connections.map(async (connection) => {
8500
+ await connection.reconnectAfterFailure().catch((closeError) => {
8501
+ console.error(
8502
+ `[discord-awareness] could not recycle unhealthy connection ${connection.id}:`,
8503
+ closeError instanceof Error ? closeError.message : closeError,
8504
+ );
8505
+ });
8506
+ }));
8507
+ }
8508
+
5381
8509
  /**
5382
8510
  * Connect a single MCPL server: register routing entries, open the
5383
8511
  * connection, wire events, and initialize feature sets / scopes /
@@ -5386,6 +8514,7 @@ export class AgentFramework {
5386
8514
  */
5387
8515
  private async connectMcplServerInternal(
5388
8516
  config: import('./mcpl/types.js').McplServerConfig,
8517
+ deferAwareness = false,
5389
8518
  ): Promise<void> {
5390
8519
  if (!this.mcplServerRegistry || !this.mcplHostCapabilities) {
5391
8520
  throw new Error('MCPL subsystem is not initialized');
@@ -5408,14 +8537,31 @@ export class AgentFramework {
5408
8537
 
5409
8538
  const connection = await this.mcplServerRegistry.addServer(config, this.mcplHostCapabilities);
5410
8539
 
5411
- // Wire event listeners then flush any events that arrived during the
5412
- // handshake window (between setupMessageRouting and now).
8540
+ // Wire listeners before either startup staging or the runtime global gate
8541
+ // releases control traffic needed for registration and marker service.
5413
8542
  this.wireMcplEvents(connection);
5414
- connection.ready();
5415
8543
 
5416
8544
  // Initialize feature sets if server advertises MCPL capabilities
5417
8545
  this.registerMcplServerFeatures(config, connection);
5418
8546
 
8547
+ if (deferAwareness) {
8548
+ this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` });
8549
+ return;
8550
+ }
8551
+
8552
+ const awarenessBarrier = this.installMcplDataPlaneGate();
8553
+ this.releaseMcplDataPlaneGate(awarenessBarrier);
8554
+ try {
8555
+ await awarenessBarrier.promise;
8556
+ this.completeMcplDataPlaneGate(awarenessBarrier);
8557
+ } catch (error) {
8558
+ // Startup cannot fail open on a broken awareness ledger. Disable the
8559
+ // reconnecting stub/connection before propagating the distinct error to
8560
+ // initializeMcpl, which tears down any other servers and aborts create().
8561
+ await connection.close().catch(() => {});
8562
+ throw error;
8563
+ }
8564
+
5419
8565
  this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` });
5420
8566
  }
5421
8567
 
@@ -5629,10 +8775,12 @@ export class AgentFramework {
5629
8775
  });
5630
8776
 
5631
8777
  // Handle push events (Step 6)
5632
- connection.on('push-event', (
8778
+ connection.on('push-event', async (
5633
8779
  params: PushEventParams,
5634
8780
  responder?: { respond: (result: unknown) => void; respondError: (code: number, message: string) => void },
5635
8781
  ) => {
8782
+ const barrier = this.discordAwarenessBarrier;
8783
+ if (barrier) await barrier.promise;
5636
8784
  this.pushHandler?.handlePushEvent(connection.id, params, responder as never);
5637
8785
  });
5638
8786
 
@@ -5642,6 +8790,8 @@ export class AgentFramework {
5642
8790
  responder?: { id: string | number; respond: (result: unknown) => void; respondError: (code: number, message: string) => void },
5643
8791
  ) => {
5644
8792
  if (this.inferenceRouter && responder) {
8793
+ const barrier = this.discordAwarenessBarrier;
8794
+ if (barrier) await barrier.promise;
5645
8795
  await this.inferenceRouter.handleInferenceRequest(connection.id, params, {
5646
8796
  respond: responder.respond,
5647
8797
  respondError: responder.respondError,
@@ -5664,10 +8814,12 @@ export class AgentFramework {
5664
8814
  });
5665
8815
 
5666
8816
  // Handle incoming channel messages (Step 7)
5667
- connection.on('channels-incoming', (
8817
+ connection.on('channels-incoming', async (
5668
8818
  params: ChannelsIncomingParams,
5669
8819
  responder?: { respond: (result: unknown) => void },
5670
8820
  ) => {
8821
+ const barrier = this.discordAwarenessBarrier;
8822
+ if (barrier) await barrier.promise;
5671
8823
  this.channelRegistry?.handleIncoming(connection.id, params, responder as never);
5672
8824
  });
5673
8825
 
@@ -5678,6 +8830,8 @@ export class AgentFramework {
5678
8830
  ) => {
5679
8831
  if (!responder) return;
5680
8832
  try {
8833
+ const barrier = this.discordAwarenessBarrier;
8834
+ if (barrier) await barrier.promise;
5681
8835
  const result = await this.handleHostCommand(connection.id, params ?? {});
5682
8836
  responder.respond(result);
5683
8837
  } catch (error) {
@@ -5688,7 +8842,19 @@ export class AgentFramework {
5688
8842
 
5689
8843
  // Handle dynamic tool list changes (notifications/tools/list_changed)
5690
8844
  connection.on('tools-list-changed', () => {
8845
+ // Pause before starting any async refresh. This listener runs
8846
+ // synchronously in the transport line callback, so a following inbound
8847
+ // data event cannot pass before the new barrier exists.
8848
+ const awarenessBarrier = this.installMcplDataPlaneGate();
8849
+ this.releaseMcplDataPlaneGate(awarenessBarrier);
5691
8850
  this.handleToolsListChanged(connection.id);
8851
+ void awarenessBarrier.promise.then(() => {
8852
+ this.completeMcplDataPlaneGate(awarenessBarrier);
8853
+ }).catch((error) => this.failMcplDataPlaneGate(
8854
+ awarenessBarrier,
8855
+ 'tools-list reconciliation',
8856
+ error,
8857
+ ));
5692
8858
  });
5693
8859
 
5694
8860
  // Re-establish full server registration on reconnect. The 'close' handler
@@ -5699,6 +8865,10 @@ export class AgentFramework {
5699
8865
  // preserved across the transient close are resumed by idempotent
5700
8866
  // registration. Then refresh tools (server may have different tools).
5701
8867
  connection.on('reconnect', (info?: { attempts?: number }) => {
8868
+ // Install the barrier synchronously so any inbound event emitted after
8869
+ // reconnect observes it before doing work that could wake an agent. The
8870
+ // connection paused its data plane before wiring the fresh transport.
8871
+ const awarenessBarrier = this.installMcplDataPlaneGate();
5702
8872
  try {
5703
8873
  const config = this.mcplServerConfigs.get(connection.id);
5704
8874
  if (config) {
@@ -5710,15 +8880,26 @@ export class AgentFramework {
5710
8880
  error instanceof Error ? error.message : error,
5711
8881
  );
5712
8882
  }
8883
+ this.releaseMcplDataPlaneGate(awarenessBarrier);
5713
8884
  this.handleToolsListChanged(connection.id);
5714
- this.emitTrace({
5715
- type: 'mcpl:server-reconnected',
5716
- serverId: connection.id,
5717
- attempts: info?.attempts ?? 0,
5718
- });
5719
- // Mirror the module:removed emitted on 'close' so module-lifecycle
5720
- // consumers see the server come back, not just vanish.
5721
- this.emitTrace({ type: 'module:added', moduleName: `mcpl:${connection.id}` });
8885
+ void awarenessBarrier.promise.then(() => {
8886
+ if (
8887
+ awarenessBarrier.requiresBarrier
8888
+ && !this.completeMcplDataPlaneGate(awarenessBarrier)
8889
+ ) return;
8890
+ this.emitTrace({
8891
+ type: 'mcpl:server-reconnected',
8892
+ serverId: connection.id,
8893
+ attempts: info?.attempts ?? 0,
8894
+ });
8895
+ // Mirror the module:removed emitted on 'close' so module-lifecycle
8896
+ // consumers see the server come back, not just vanish.
8897
+ this.emitTrace({ type: 'module:added', moduleName: `mcpl:${connection.id}` });
8898
+ }).catch((error) => this.failMcplDataPlaneGate(
8899
+ awarenessBarrier,
8900
+ 'reconnect reconciliation',
8901
+ error,
8902
+ ));
5722
8903
  });
5723
8904
 
5724
8905
  // Surface connect/reconnect failures. Before these traces existed the
@@ -5760,6 +8941,7 @@ export class AgentFramework {
5760
8941
  // divergence greppable instead of a silent drift into stale state.
5761
8942
  connection.on('orphaned-response', (info: {
5762
8943
  id: string | number;
8944
+ method?: string;
5763
8945
  hadState: boolean;
5764
8946
  hadCheckpoint: boolean;
5765
8947
  }) => {
@@ -5767,6 +8949,7 @@ export class AgentFramework {
5767
8949
  type: 'mcpl:orphaned-response',
5768
8950
  serverId: connection.id,
5769
8951
  responseId: info.id,
8952
+ method: info.method,
5770
8953
  hadState: info.hadState,
5771
8954
  hadCheckpoint: info.hadCheckpoint,
5772
8955
  });
@@ -6228,6 +9411,153 @@ export class AgentFramework {
6228
9411
  });
6229
9412
  }
6230
9413
 
9414
+ /**
9415
+ * Handle the synthesized `save_recent_image` tool: locate the requested
9416
+ * image blocks in the calling agent's context (blobs re-inlined, counted
9417
+ * back from the most recent) and write their bytes to a workspace mount
9418
+ * via WorkspaceModule.writeBinary.
9419
+ */
9420
+ private dispatchSaveImageToolCall(agentName: string, call: ToolCall): void {
9421
+ this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
9422
+ const finish = (result: ToolResult): void => {
9423
+ this.emitTrace({
9424
+ type: result.isError ? 'tool:failed' : 'tool:completed',
9425
+ module: 'workspace',
9426
+ tool: call.name,
9427
+ callId: call.id,
9428
+ durationMs: 0,
9429
+ ...(result.isError ? { error: result.error } : {}),
9430
+ });
9431
+ this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'workspace', result });
9432
+ };
9433
+ void (async (): Promise<void> => {
9434
+ try {
9435
+ const workspace = this.getWorkspaceModule();
9436
+ if (!workspace) throw new Error('save_recent_image requires a workspace module');
9437
+ const agent = this.agents.get(agentName);
9438
+ if (!agent) throw new Error(`Unknown agent: ${agentName}`);
9439
+ const input = (call.input ?? {}) as { path?: unknown; index?: unknown; count?: unknown };
9440
+ if (typeof input.path !== 'string' || input.path.length === 0) {
9441
+ throw new Error('save_recent_image: `path` (mount-prefixed) is required');
9442
+ }
9443
+ const index = input.index === undefined ? 0 : Number(input.index);
9444
+ if (!Number.isInteger(index) || index < 0) {
9445
+ throw new Error('save_recent_image: `index` must be a non-negative integer');
9446
+ }
9447
+ const count = input.count === undefined ? 1 : Number(input.count);
9448
+ const MAX_COUNT = 20;
9449
+ if (!Number.isInteger(count) || count < 1 || count > MAX_COUNT) {
9450
+ throw new Error(`save_recent_image: \`count\` must be an integer in 1..${MAX_COUNT}`);
9451
+ }
9452
+ const lastWanted = index + count - 1;
9453
+
9454
+ // Walk the message store tail-first in bounded windows, re-inlining
9455
+ // blob media, until we've collected the requested range. Scanning is
9456
+ // capped so a pathological index can't drag the whole store through
9457
+ // blob resolution.
9458
+ const MAX_SCAN = 500;
9459
+ const WINDOW = 25;
9460
+ const cm = agent.getContextManager();
9461
+ const total = cm.getMessageCount();
9462
+ let seen = 0;
9463
+ let scanned = 0;
9464
+ const found: Array<{
9465
+ rangeOffset: number;
9466
+ data: string;
9467
+ mediaType: string;
9468
+ messagesBack: number;
9469
+ }> = [];
9470
+ for (let end = total; end > 0 && scanned < MAX_SCAN && found.length < count; end -= WINDOW) {
9471
+ const start = Math.max(0, end - WINDOW);
9472
+ const { messages } = cm.getMessageWindow(start, end - start, { resolveBlobs: true });
9473
+ scanned += end - start;
9474
+ for (let i = messages.length - 1; i >= 0 && found.length < count; i--) {
9475
+ const content = messages[i]?.content;
9476
+ if (!Array.isArray(content)) continue;
9477
+ for (let b = content.length - 1; b >= 0 && found.length < count; b--) {
9478
+ const block = content[b] as {
9479
+ type?: string;
9480
+ source?: { type?: string; data?: string; mediaType?: string };
9481
+ };
9482
+ if (block?.type !== 'image') continue;
9483
+ if (seen >= index && seen <= lastWanted) {
9484
+ if (block.source?.type !== 'base64' || typeof block.source.data !== 'string') {
9485
+ throw new Error(
9486
+ `save_recent_image: image at index ${seen} is not stored inline (base64) — cannot save it`,
9487
+ );
9488
+ }
9489
+ found.push({
9490
+ rangeOffset: seen - index,
9491
+ data: block.source.data,
9492
+ mediaType: block.source.mediaType ?? 'image/png',
9493
+ messagesBack: total - (start + i),
9494
+ });
9495
+ }
9496
+ seen++;
9497
+ }
9498
+ }
9499
+ }
9500
+ if (found.length === 0) {
9501
+ throw new Error(
9502
+ seen === 0
9503
+ ? `save_recent_image: no images found in the most recent ${Math.min(scanned, MAX_SCAN)} messages`
9504
+ : `save_recent_image: only ${seen} image(s) found in the most recent ${Math.min(scanned, MAX_SCAN)} messages (asked for index ${index})`,
9505
+ );
9506
+ }
9507
+
9508
+ // Single image → path as given. Range → numeric suffix before the
9509
+ // extension ("cat.png" → "cat-0.png"), 0 = newest of the range.
9510
+ const pathFor = (rangeOffset: number): string => {
9511
+ if (count === 1) return input.path as string;
9512
+ const p = input.path as string;
9513
+ const dot = p.lastIndexOf('.');
9514
+ const slash = p.lastIndexOf('/');
9515
+ return dot > slash
9516
+ ? `${p.slice(0, dot)}-${rangeOffset}${p.slice(dot)}`
9517
+ : `${p}-${rangeOffset}`;
9518
+ };
9519
+ const savedFiles: Array<Record<string, unknown>> = [];
9520
+ for (const image of found) {
9521
+ const destination = pathFor(image.rangeOffset);
9522
+ const bytes = Buffer.from(image.data, 'base64');
9523
+ const result = await workspace.writeBinary(destination, bytes, image.mediaType);
9524
+ if (!result.success) {
9525
+ finish({
9526
+ success: false,
9527
+ error:
9528
+ `save_recent_image: failed writing "${destination}": ${result.error}` +
9529
+ (savedFiles.length > 0
9530
+ ? ` (already saved: ${savedFiles.map((f) => f.path).join(', ')})`
9531
+ : ''),
9532
+ isError: true,
9533
+ });
9534
+ return;
9535
+ }
9536
+ savedFiles.push({
9537
+ ...(result.data as Record<string, unknown>),
9538
+ imageIndex: index + image.rangeOffset,
9539
+ messagesBack: image.messagesBack,
9540
+ });
9541
+ }
9542
+ finish({
9543
+ success: true,
9544
+ data: {
9545
+ saved: savedFiles,
9546
+ ...(found.length < count
9547
+ ? { note: `only ${found.length} of ${count} requested images exist in the scanned window` }
9548
+ : {}),
9549
+ },
9550
+ });
9551
+ } catch (error) {
9552
+ finish({
9553
+ success: false,
9554
+ error: error instanceof Error ? error.message : String(error),
9555
+ isError: true,
9556
+ });
9557
+ }
9558
+ })();
9559
+ }
9560
+
6231
9561
  private dispatchAgentSettingsToolCall(agentName: string, call: ToolCall): void {
6232
9562
  this.emitTrace({
6233
9563
  type: 'tool:started',
@@ -6243,11 +9573,22 @@ export class AgentFramework {
6243
9573
  context_budget_tokens?: unknown;
6244
9574
  tail_tokens?: unknown;
6245
9575
  transition_pace_tokens?: unknown;
9576
+ same_round_think_text_policy?: unknown;
6246
9577
  settings?: unknown;
9578
+ } & Record<string, unknown>;
9579
+ const extensions = this.collectAgentSettingsExtensions();
9580
+ /** Extension values merged flat alongside the core snapshot. */
9581
+ const extGet = (): Record<string, unknown> => {
9582
+ const out: Record<string, unknown> = {};
9583
+ for (const ext of extensions.values()) Object.assign(out, ext.get(agentName));
9584
+ return out;
6247
9585
  };
6248
9586
  switch (input.action) {
6249
9587
  case 'get':
6250
- result = { success: true, data: this.getAgentRuntimeSettings(agentName) };
9588
+ result = {
9589
+ success: true,
9590
+ data: { ...this.getAgentRuntimeSettings(agentName), ...extGet() },
9591
+ };
6251
9592
  break;
6252
9593
  case 'cancel':
6253
9594
  result = { success: true, data: this.cancelAgentRuntimeSettingsTransition(agentName) };
@@ -6261,26 +9602,101 @@ export class AgentFramework {
6261
9602
  if (input.transition_pace_tokens !== undefined) {
6262
9603
  patch.transitionPaceTokens = Number(input.transition_pace_tokens);
6263
9604
  }
6264
- result = { success: true, data: this.updateAgentRuntimeSettings(agentName, patch) };
9605
+ if (input.same_round_think_text_policy !== undefined) {
9606
+ patch.sameRoundThinkTextPolicy = input.same_round_think_text_policy as AgentRuntimeSettingsPatch['sameRoundThinkTextPolicy'];
9607
+ }
9608
+ // Route extension-owned keys to their modules; apply the core patch
9609
+ // only when it touches core keys (an extension-only update must not
9610
+ // disturb a converging budget transition).
9611
+ const extResults: Record<string, unknown> = {};
9612
+ for (const ext of extensions.values()) {
9613
+ const slice: Record<string, unknown> = {};
9614
+ for (const key of ext.keys) {
9615
+ if (input[key] !== undefined) slice[key] = input[key];
9616
+ }
9617
+ if (Object.keys(slice).length > 0) {
9618
+ Object.assign(extResults, ext.update(agentName, slice));
9619
+ }
9620
+ }
9621
+ const coreTouched = Object.keys(patch).length > 0;
9622
+ const core = coreTouched
9623
+ ? this.updateAgentRuntimeSettings(agentName, patch)
9624
+ : this.getAgentRuntimeSettings(agentName);
9625
+ result = {
9626
+ success: true,
9627
+ data: {
9628
+ ...core,
9629
+ ...extGet(),
9630
+ ...extResults,
9631
+ ...(patch.sameRoundThinkTextPolicy !== undefined
9632
+ ? {
9633
+ sameRoundThinkTextPolicyUpdateNote:
9634
+ 'Stored now; applies to provider think routing and description beginning with the next inference.',
9635
+ }
9636
+ : {}),
9637
+ },
9638
+ };
6265
9639
  break;
6266
9640
  }
6267
9641
  case 'reset': {
6268
9642
  let keys: Array<keyof AgentRuntimeSettingsPatch> | undefined;
9643
+ const extResetKeys = new Map<AgentSettingsExtension, string[]>();
9644
+ const resetsAllSettings = input.settings === undefined;
6269
9645
  if (input.settings !== undefined) {
6270
9646
  if (!Array.isArray(input.settings)) throw new Error('reset `settings` must be an array');
6271
9647
  const names: Record<string, keyof AgentRuntimeSettingsPatch> = {
6272
9648
  context_budget_tokens: 'contextBudgetTokens',
6273
9649
  tail_tokens: 'tailTokens',
6274
9650
  transition_pace_tokens: 'transitionPaceTokens',
9651
+ same_round_think_text_policy: 'sameRoundThinkTextPolicy',
6275
9652
  };
6276
- keys = input.settings.map((name) => {
6277
- if (typeof name !== 'string' || !names[name]) {
6278
- throw new Error(`Unknown reset setting: ${String(name)}`);
9653
+ keys = [];
9654
+ for (const name of input.settings) {
9655
+ if (typeof name === 'string' && names[name]) {
9656
+ keys.push(names[name]);
9657
+ continue;
6279
9658
  }
6280
- return names[name];
6281
- });
9659
+ const owner = [...extensions.values()].find(
9660
+ (ext) => typeof name === 'string' && ext.keys.includes(name),
9661
+ );
9662
+ if (!owner) throw new Error(`Unknown reset setting: ${String(name)}`);
9663
+ const list = extResetKeys.get(owner) ?? [];
9664
+ list.push(name as string);
9665
+ extResetKeys.set(owner, list);
9666
+ }
9667
+ if (keys.length === 0) keys = undefined;
9668
+ }
9669
+ const touchedSameRoundThinkTextPolicy =
9670
+ resetsAllSettings || keys?.includes('sameRoundThinkTextPolicy') === true;
9671
+ const extResults: Record<string, unknown> = {};
9672
+ if (input.settings === undefined) {
9673
+ // Reset-all covers extensions too.
9674
+ for (const ext of extensions.values()) {
9675
+ if (ext.reset) Object.assign(extResults, ext.reset(agentName));
9676
+ }
9677
+ } else {
9678
+ for (const [ext, list] of extResetKeys) {
9679
+ if (ext.reset) Object.assign(extResults, ext.reset(agentName, list));
9680
+ }
6282
9681
  }
6283
- result = { success: true, data: this.resetAgentRuntimeSettings(agentName, keys) };
9682
+ const coreTouched = input.settings === undefined || keys !== undefined;
9683
+ const core = coreTouched
9684
+ ? this.resetAgentRuntimeSettings(agentName, keys)
9685
+ : this.getAgentRuntimeSettings(agentName);
9686
+ result = {
9687
+ success: true,
9688
+ data: {
9689
+ ...core,
9690
+ ...extGet(),
9691
+ ...extResults,
9692
+ ...(touchedSameRoundThinkTextPolicy
9693
+ ? {
9694
+ sameRoundThinkTextPolicyUpdateNote:
9695
+ 'Reset now; restored provider think routing and description apply beginning with the next inference.',
9696
+ }
9697
+ : {}),
9698
+ },
9699
+ };
6284
9700
  break;
6285
9701
  }
6286
9702
  default: