@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
@@ -1,5 +1,6 @@
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
5
  import { MembraneError } from '@animalabs/membrane';
5
6
  import { ContextManager, PassthroughStrategy } from '@animalabs/context-manager';
@@ -17,6 +18,7 @@ import { ConversationRouter } from './mcpl/conversation-router.js';
17
18
  import { safeSlice } from './safe-slice.js';
18
19
  import { toolResultDataToHistoryString } from './tool-result-history.js';
19
20
  import { splitProseSegments } from './prose-segments.js';
21
+ import { canonicalJsonStringify } from './canonical-json.js';
20
22
  /**
21
23
  * Tools whose presence suppresses auto-routing of the surrounding prose,
22
24
  * for two distinct reasons:
@@ -25,15 +27,14 @@ import { splitProseSegments } from './prose-segments.js';
25
27
  * - explicit delivery tools (channel_publish / *send_message /
26
28
  * *reply_message / *send_dm) — already sent the message, so routing the
27
29
  * prose again would double-post.
28
- * `think` is deliberately NOT here: it is silent *reasoning*, but prose
29
- * written around a think is the agent's actual voice and must be delivered.
30
+ * `think` is deliberately NOT here: it is silent *reasoning*, and same-round
31
+ * prose beside it is governed separately by same_round_think_text_policy.
30
32
  *
31
- * Scope: mid-turn rounds apply this PER ROUND (a round's prose is suppressed
32
- * only by a silencing tool in that same round — live delivery can't be
33
- * retracted by a later round's skip_reply); the turn's TRAILING prose keeps
34
- * the historical turn-wide rule (any silencing tool anywhere in the turn
35
- * suppresses it), preserving the "send_message then 'sent it.' postscript"
36
- * double-post guard.
33
+ * Scope: an explicit delivery suppresses prose from that round onward until
34
+ * another external message is injected. The suppression prevents a
35
+ * `send_message` followed by "sent it" from double-posting, but a new message
36
+ * starts a new conversational round and must be answerable with plain prose.
37
+ * `skip_reply` ends the turn at the tool-result boundary.
37
38
  */
38
39
  const SILENCING_TOOLS = new Set([
39
40
  'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
@@ -45,14 +46,37 @@ import { isToolAllowed } from './mcpl/tool-policy.js';
45
46
  import { EventGate } from './gate/event-gate.js';
46
47
  import { UsageTracker } from './usage/usage-tracker.js';
47
48
  import { formatZonedDateTime, resolveTimeZone } from './timezone.js';
49
+ import { DEFAULT_DISCORD_AWARENESS_EMOJI, DiscordAwarenessOutbox, defaultDiscordAwarenessOutboxPath, extractDiscordAwarenessRefs, } from './recovery/discord-awareness-outbox.js';
48
50
  const FRAMEWORK_STATE_ID = 'framework/state';
49
51
  const CONVERSATION_ROUTER_STATE_ID = 'framework/conversation-router';
50
52
  const INFERENCE_LOG_ID = 'framework/inference-log';
51
53
  const PROCESS_LOG_ID = 'framework/process-log';
54
+ const PRIMARY_SUMMARY_FALLBACK_STATE_ID = 'framework/primary-summary-fallback';
55
+ const PRIMARY_SUMMARY_SETTLEMENT_METADATA_KEY = 'primarySummarySettlement';
56
+ const PRIMARY_SUMMARY_SETTLEMENT_VERSION = 1;
57
+ const PRIMARY_SUMMARY_SETTLEMENT_MAX_ID_LENGTH = 1024;
58
+ const PRIMARY_SUMMARY_SETTLEMENT_MAX_NAME_LENGTH = 256;
59
+ const PRIMARY_SUMMARY_SETTLEMENT_MAX_REASON_LENGTH = 256;
52
60
  const TURN_CHECKPOINTS_ID = 'framework/turn-checkpoints'; // legacy single-map layout, read-only fallback
53
61
  const TURN_CHECKPOINTS_TREE_ID = 'framework/turn-checkpoints/tree';
54
62
  /** Maximum number of turn checkpoints to keep per agent. */
55
63
  const MAX_TURN_CHECKPOINTS = 20;
64
+ const DEFAULT_DISCORD_AWARENESS_DEADLINE_MS = 10_000;
65
+ const MIN_DISCORD_AWARENESS_DEADLINE_MS = 50;
66
+ const MAX_DISCORD_AWARENESS_DEADLINE_MS = 60_000;
67
+ function normalizeDiscordAwarenessDeadline(value) {
68
+ if (value === undefined || !Number.isFinite(value) || value <= 0) {
69
+ return DEFAULT_DISCORD_AWARENESS_DEADLINE_MS;
70
+ }
71
+ return Math.max(MIN_DISCORD_AWARENESS_DEADLINE_MS, Math.min(MAX_DISCORD_AWARENESS_DEADLINE_MS, Math.floor(value)));
72
+ }
73
+ class DiscordAwarenessAccountingError extends Error {
74
+ constructor(operation, cause) {
75
+ const detail = cause instanceof Error ? cause.message : String(cause);
76
+ super(`Discord awareness accounting failed during ${operation}: ${detail}`, { cause });
77
+ this.name = 'DiscordAwarenessAccountingError';
78
+ }
79
+ }
56
80
  /**
57
81
  * Default inference policy - infer if any request exists for the agent.
58
82
  */
@@ -61,6 +85,10 @@ class DefaultInferencePolicy {
61
85
  return requests.some((r) => r.agentName === agentName);
62
86
  }
63
87
  }
88
+ function isPermanentDiscordReactionFailure(message) {
89
+ return /unknown message|unknown channel|missing access|missing permissions|missing permission|cannot access|channel .* not found|message .* not found/i
90
+ .test(message);
91
+ }
64
92
  /**
65
93
  * Default error policy - retry with exponential backoff.
66
94
  *
@@ -162,19 +190,29 @@ export class AgentFramework {
162
190
  maintenanceRunId = 0;
163
191
  currentMaintenanceRun = null;
164
192
  maintenanceHistory = [];
165
- /** Last time we console-warned about stale (busy-requeued) inference requests, per agent. */
193
+ /** Last time we reported stale (busy-requeued) inference requests, per agent. */
166
194
  staleWarnAt = new Map();
167
195
  /** Per-agent last inference activity (epoch ms), for /healthz + doctor tooling. */
168
196
  lastInferenceAt = new Map();
169
197
  processLoggingPersist;
170
198
  processLoggingBroadcast;
171
199
  activeStreams = new Map();
200
+ activeStreamContexts = new Map();
201
+ primarySummaryStateLock = Promise.resolve();
172
202
  /** Per-agent output locus pinned for the CURRENT logical turn (see
173
203
  * resolveTurnLocus in driveStream). Lives here — not in driveStream
174
204
  * locals — so the pin survives a context-budget stream restart, which
175
- * continues the same logical turn in a fresh driveStream. Cleared at the
176
- * start of every non-restart turn. */
205
+ * continues the same logical turn in a fresh driveStream. A mid-turn
206
+ * injected channel message replaces the pin for the next conversational
207
+ * round; otherwise it remains stable. Cleared at the start of every
208
+ * non-restart turn. */
177
209
  turnLocusPins = new Map();
210
+ /** A tool boundary injected fresh external input into the live stream.
211
+ * Presence (including a null value) tells driveStream to clear sticky
212
+ * explicit-send suppression before handling the next model round. A
213
+ * string also moves that round's reply locus to the newest injected
214
+ * channel. */
215
+ midTurnRoutingResets = new Map();
178
216
  pendingAssistantBlocks = new Map();
179
217
  /** Streams the FRAMEWORK cancelled for non-terminal reasons, keyed
180
218
  * `${agentName}:${streamId}`: an endTurn tool result or a context-budget
@@ -278,13 +316,22 @@ export class AgentFramework {
278
316
  * subsystem can be lazily initialized by connectMcplServer when the
279
317
  * framework started with zero configured servers. */
280
318
  mcplInferenceRoutingConfig = null;
319
+ /** Durable, non-Chronicle projection queue for messages removed by a branch. */
320
+ discordAwarenessOutbox = null;
321
+ discordAwarenessEmoji = DEFAULT_DISCORD_AWARENESS_EMOJI;
322
+ discordAwarenessDeadlineMs = DEFAULT_DISCORD_AWARENESS_DEADLINE_MS;
323
+ /** Serialize per-server drains so reconnect and an online undo cannot race. */
324
+ discordAwarenessDrains = new Map();
325
+ /** Framework-global inference gate; older generations cannot release it. */
326
+ discordAwarenessBarrier = null;
327
+ discordAwarenessBarrierGeneration = 0;
281
328
  // EventGate (null when FrameworkConfig.gate is omitted)
282
329
  eventGate = null;
283
330
  // Session-level token usage tracking (always-on)
284
331
  usageTracker;
285
332
  /** Presentation-only wall-clock zone; persistence remains UTC/epoch. */
286
333
  timeZone;
287
- constructor(store, ownsStore, membrane, inferencePolicy, errorPolicy, syncIntervalMs, maintenanceIntervalMs, processLoggingPersist, processLoggingBroadcast, timeZone) {
334
+ constructor(store, ownsStore, membrane, inferencePolicy, errorPolicy, syncIntervalMs, maintenanceIntervalMs, processLoggingPersist, processLoggingBroadcast, timeZone, discordAwarenessOutbox, discordAwarenessEmoji, discordAwarenessDeadlineMs) {
288
335
  this.store = store;
289
336
  this.ownsStore = ownsStore;
290
337
  this.membrane = membrane;
@@ -295,6 +342,9 @@ export class AgentFramework {
295
342
  this.processLoggingPersist = processLoggingPersist;
296
343
  this.processLoggingBroadcast = processLoggingBroadcast;
297
344
  this.timeZone = timeZone;
345
+ this.discordAwarenessOutbox = discordAwarenessOutbox;
346
+ this.discordAwarenessEmoji = discordAwarenessEmoji;
347
+ this.discordAwarenessDeadlineMs = discordAwarenessDeadlineMs;
298
348
  this.queue = new ProcessQueueImpl();
299
349
  this.usageTracker = new UsageTracker({
300
350
  emitTrace: (e) => this.emitTrace({ ...e }),
@@ -374,13 +424,45 @@ export class AgentFramework {
374
424
  // Already registered
375
425
  }
376
426
  }
377
- const framework = new AgentFramework(store, ownsStore, config.membrane, config.inferencePolicy ?? new DefaultInferencePolicy(), config.errorPolicy ?? new DefaultErrorPolicy(), config.syncIntervalMs ?? DEFAULT_SYNC_INTERVAL_MS, config.maintenanceIntervalMs ?? DEFAULT_MAINTENANCE_INTERVAL_MS, processLoggingPersist, processLoggingBroadcast, resolveTimeZone(config.timeZone));
378
- // Restore persisted usage data (if any) from prior session
379
- framework.restoreUsageState();
427
+ try {
428
+ store.registerState({ id: PRIMARY_SUMMARY_FALLBACK_STATE_ID, strategy: 'snapshot' });
429
+ }
430
+ catch {
431
+ // Already registered
432
+ }
433
+ const discordAwarenessOutboxPath = config.discordAwarenessOutboxPath
434
+ ?? (config.storePath ? defaultDiscordAwarenessOutboxPath(config.storePath) : undefined);
435
+ const discordAwarenessOutbox = discordAwarenessOutboxPath
436
+ ? new DiscordAwarenessOutbox(discordAwarenessOutboxPath)
437
+ : null;
438
+ const framework = new AgentFramework(store, ownsStore, config.membrane, config.inferencePolicy ?? new DefaultInferencePolicy(), config.errorPolicy ?? new DefaultErrorPolicy(), config.syncIntervalMs ?? DEFAULT_SYNC_INTERVAL_MS, config.maintenanceIntervalMs ?? DEFAULT_MAINTENANCE_INTERVAL_MS, processLoggingPersist, processLoggingBroadcast, resolveTimeZone(config.timeZone), discordAwarenessOutbox, config.discordAwarenessEmoji ?? DEFAULT_DISCORD_AWARENESS_EMOJI, normalizeDiscordAwarenessDeadline(config.discordAwarenessDeadlineMs));
439
+ // If an offline recovery process crashed after switching Chronicle but
440
+ // before committing its prepared marker batch, the active branch is the
441
+ // commit record. Promote it now; no quarantined content is read.
442
+ if (discordAwarenessOutbox) {
443
+ try {
444
+ const activated = discordAwarenessOutbox.activatePreparedForBranch(store.currentBranch().name, store.listBranches());
445
+ if (activated > 0) {
446
+ console.error(`[discord-awareness] recovered ${activated} prepared batch(es) for active branch ${store.currentBranch().name}`);
447
+ }
448
+ }
449
+ catch (error) {
450
+ // The branch may be safe, but reporting the framework ready while its
451
+ // durable awareness projection is unreadable creates a half-ready
452
+ // host whose data plane can never be released safely.
453
+ throw new DiscordAwarenessAccountingError('startup reconciliation', error);
454
+ }
455
+ }
380
456
  // Create agents
381
457
  for (const agentConfig of config.agents) {
382
458
  await framework.createAgent(agentConfig);
383
459
  }
460
+ // Restore persisted usage data (if any) from prior session
461
+ framework.restoreUsageState();
462
+ await framework.reconcilePrimarySummaryFallbackStateOnStartup();
463
+ // Finish any branch-local suppression interrupted after Chronicle switched
464
+ // branches. This runs before modules, MCPL connections, or inbound traffic.
465
+ await framework.resumePreparedDiscordSuppressions();
384
466
  // Add modules
385
467
  for (const module of config.modules) {
386
468
  await framework.addModule(module);
@@ -595,11 +677,10 @@ export class AgentFramework {
595
677
  });
596
678
  }
597
679
  async runQueuedMaintenance() {
598
- const allTools = this.getAllTools();
599
680
  const queued = [...this.agents.values()].flatMap((agent) => {
600
681
  const cm = agent.getContextManager();
601
- const tools = allTools.filter((tool) => agent.canUseTool(tool.name));
602
- cm.setToolDefinitions(tools);
682
+ const tools = this.getToolsForAgent(agent.name).filter((tool) => agent.canUseTool(tool.name));
683
+ cm.setToolDefinitions?.(tools);
603
684
  if (cm.isReady())
604
685
  return [];
605
686
  const pending = cm.getPendingWork()?.description;
@@ -786,9 +867,116 @@ export class AgentFramework {
786
867
  ...this.mcplTools,
787
868
  ...channelTools,
788
869
  ...gateTools,
789
- AgentFramework.AGENT_SETTINGS_TOOL,
870
+ this.buildAgentSettingsTool(),
871
+ ...(this.getWorkspaceModule() ? [AgentFramework.SAVE_IMAGE_TOOL] : []),
790
872
  ];
791
873
  }
874
+ /** The registered workspace module, if any (used by save_image). */
875
+ getWorkspaceModule() {
876
+ const module = this.moduleRegistry.getAllModules().find((m) => m.name === 'workspace');
877
+ return module && typeof module.writeBinary === 'function'
878
+ ? module
879
+ : undefined;
880
+ }
881
+ /** Core agent_settings keys — extension keys must not collide with these. */
882
+ static AGENT_SETTINGS_CORE_KEYS = [
883
+ 'context_budget_tokens',
884
+ 'tail_tokens',
885
+ 'transition_pace_tokens',
886
+ 'same_round_think_text_policy',
887
+ ];
888
+ /**
889
+ * Collect module-declared agent_settings extensions (Module.getAgentSettingsExtension),
890
+ * keyed by owning module name. Extensions whose keys collide with the core
891
+ * settings or an earlier extension are skipped loudly — silent shadowing
892
+ * would make updates route to the wrong owner.
893
+ */
894
+ collectAgentSettingsExtensions() {
895
+ const result = new Map();
896
+ const taken = new Set(AgentFramework.AGENT_SETTINGS_CORE_KEYS);
897
+ for (const module of this.moduleRegistry.getAllModules()) {
898
+ const ext = module.getAgentSettingsExtension?.();
899
+ if (!ext)
900
+ continue;
901
+ const collision = ext.keys.find((k) => taken.has(k));
902
+ if (collision) {
903
+ console.error(`[agent-settings] extension from module '${module.name}' skipped: key '${collision}' already taken`);
904
+ continue;
905
+ }
906
+ ext.keys.forEach((k) => taken.add(k));
907
+ result.set(module.name, ext);
908
+ }
909
+ return result;
910
+ }
911
+ captureInferenceToolSnapshot(agent) {
912
+ return {
913
+ sameRoundThinkTextPolicy: agent.getEffectiveSameRoundThinkTextPolicy(),
914
+ };
915
+ }
916
+ buildThinkTool(policy) {
917
+ return {
918
+ name: 'think',
919
+ description: 'Reason privately. The think({content}) argument stays in your own context and is NOT sent ' +
920
+ 'to channels or other surfaces. ' +
921
+ (policy === 'private'
922
+ ? '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.'
923
+ : '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.') +
924
+ ' 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.',
925
+ inputSchema: {
926
+ type: 'object',
927
+ properties: {
928
+ content: {
929
+ type: 'string',
930
+ description: 'Your private thought / reasoning (optional; not sent anywhere).',
931
+ },
932
+ },
933
+ required: [],
934
+ },
935
+ };
936
+ }
937
+ /** agent_settings tool definition with module extension fields merged in. */
938
+ buildAgentSettingsTool() {
939
+ const base = AgentFramework.AGENT_SETTINGS_TOOL;
940
+ const extensions = this.collectAgentSettingsExtensions();
941
+ if (extensions.size === 0)
942
+ return base;
943
+ const baseSchema = base.inputSchema;
944
+ const extProperties = {};
945
+ const extKeys = [];
946
+ for (const ext of extensions.values()) {
947
+ Object.assign(extProperties, ext.properties);
948
+ extKeys.push(...ext.keys);
949
+ }
950
+ const settingsProp = baseSchema.properties.settings;
951
+ return {
952
+ ...base,
953
+ description: base.description +
954
+ ` Additional host-managed settings also live here: ${extKeys.join(', ')}.`,
955
+ inputSchema: {
956
+ ...baseSchema,
957
+ properties: {
958
+ ...baseSchema.properties,
959
+ ...extProperties,
960
+ settings: {
961
+ ...baseSchema.properties.settings,
962
+ items: {
963
+ ...settingsProp.items,
964
+ enum: [...(settingsProp.items?.enum ?? []), ...extKeys],
965
+ },
966
+ },
967
+ },
968
+ },
969
+ };
970
+ }
971
+ getToolsForAgent(agentName, snapshot) {
972
+ return this.getAllTools().map((tool) => {
973
+ if (tool.name === 'think') {
974
+ return this.buildThinkTool(snapshot?.sameRoundThinkTextPolicy
975
+ ?? this.getAgentRuntimeSettings(agentName).sameRoundThinkTextPolicy);
976
+ }
977
+ return tool;
978
+ });
979
+ }
792
980
  getAgentRuntimeSettings(agentName) {
793
981
  const agent = this.agents.get(agentName);
794
982
  if (!agent)
@@ -870,7 +1058,7 @@ export class AgentFramework {
870
1058
  if (!agent) {
871
1059
  throw new Error(`Agent not found: ${agentName}`);
872
1060
  }
873
- const tools = this.getAllTools().filter((t) => agent.canUseTool(t.name));
1061
+ const tools = this.getToolsForAgent(agentName).filter((t) => agent.canUseTool(t.name));
874
1062
  // Default: no dynamic injection gathering → fully transparent (no
875
1063
  // inference, no Chronicle writes, no external RPC). Opt in explicitly.
876
1064
  if (!opts?.injections) {
@@ -1004,6 +1192,14 @@ export class AgentFramework {
1004
1192
  return null;
1005
1193
  }
1006
1194
  }
1195
+ validatePersistedAgentRuntimeSettings(agentName, overrides) {
1196
+ if (overrides.sameRoundThinkTextPolicy !== undefined &&
1197
+ overrides.sameRoundThinkTextPolicy !== 'public' &&
1198
+ overrides.sameRoundThinkTextPolicy !== 'private') {
1199
+ throw new Error(`Invalid persisted sameRoundThinkTextPolicy for agent "${agentName}": ${JSON.stringify(overrides.sameRoundThinkTextPolicy)}`);
1200
+ }
1201
+ return overrides;
1202
+ }
1007
1203
  persistAgentRuntimeSettings(agentName, overrides) {
1008
1204
  const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
1009
1205
  const state = (data && typeof data === 'object' ? data : {});
@@ -1055,6 +1251,1091 @@ export class AgentFramework {
1055
1251
  });
1056
1252
  return { promise, resolve, reject };
1057
1253
  }
1254
+ hashJson(value) {
1255
+ return createHash('sha256').update(canonicalJsonStringify(value), 'utf8').digest('hex');
1256
+ }
1257
+ estimateStringTokens(text, overhead = 0) {
1258
+ return Math.ceil(Buffer.byteLength(text, 'utf8') / 4) + overhead;
1259
+ }
1260
+ estimateUnknownTokens(value, overhead = 0) {
1261
+ return this.estimateStringTokens(JSON.stringify(value) ?? 'null', overhead);
1262
+ }
1263
+ estimateRequestInputBoundTokens(request) {
1264
+ let total = 96;
1265
+ total += this.estimateStringTokens(request.system ?? '', 48);
1266
+ for (const message of request.messages) {
1267
+ total += 24;
1268
+ total += this.estimateStringTokens(message.participant, 8);
1269
+ if (message.cacheBreakpoint)
1270
+ total += 8;
1271
+ for (const block of message.content) {
1272
+ switch (block.type) {
1273
+ case 'text':
1274
+ total += this.estimateStringTokens(block.text, 24);
1275
+ break;
1276
+ case 'thinking':
1277
+ total += this.estimateStringTokens(block.thinking, 32);
1278
+ total += block.signature ? this.estimateStringTokens(block.signature, 8) : 0;
1279
+ break;
1280
+ case 'redacted_thinking':
1281
+ total += this.estimateUnknownTokens(block, 32);
1282
+ break;
1283
+ default:
1284
+ total += this.estimateUnknownTokens(block, 32);
1285
+ break;
1286
+ }
1287
+ }
1288
+ }
1289
+ if (request.tools && request.tools.length > 0) {
1290
+ total += 32;
1291
+ for (const tool of request.tools) {
1292
+ total += this.estimateUnknownTokens({
1293
+ name: tool.name,
1294
+ description: tool.description,
1295
+ inputSchema: tool.inputSchema,
1296
+ }, 48);
1297
+ }
1298
+ }
1299
+ total += this.estimateUnknownTokens(request.config, 48);
1300
+ if (request.providerParams !== undefined) {
1301
+ total += this.estimateUnknownTokens(request.providerParams, 32);
1302
+ }
1303
+ total += request.promptCaching ? 16 : 0;
1304
+ total += request.cacheTtl ? this.estimateStringTokens(request.cacheTtl, 8) : 0;
1305
+ total += request.assistantParticipant
1306
+ ? this.estimateStringTokens(request.assistantParticipant, 8)
1307
+ : 0;
1308
+ return total;
1309
+ }
1310
+ estimateRequestCompleteBoundTokens(request) {
1311
+ const reserve = typeof request.config.maxTokens === 'number' && Number.isFinite(request.config.maxTokens)
1312
+ ? request.config.maxTokens
1313
+ : 0;
1314
+ return this.estimateRequestInputBoundTokens(request) + reserve + 96;
1315
+ }
1316
+ primarySummaryAdmissionBoundTokens(originalRequestInputTokens, expandedRequest, originalProviderInputTokens) {
1317
+ const expandedInput = this.estimateRequestInputBoundTokens(expandedRequest);
1318
+ const expandedComplete = this.estimateRequestCompleteBoundTokens(expandedRequest);
1319
+ if (originalProviderInputTokens === undefined)
1320
+ return expandedComplete;
1321
+ const reserve = typeof expandedRequest.config.maxTokens === 'number' && Number.isFinite(expandedRequest.config.maxTokens)
1322
+ ? expandedRequest.config.maxTokens
1323
+ : 0;
1324
+ const positiveDelta = Math.max(0, expandedInput - originalRequestInputTokens);
1325
+ return Math.max(expandedComplete, originalProviderInputTokens + positiveDelta + reserve + 96);
1326
+ }
1327
+ readPrimarySummaryFallbackState() {
1328
+ const raw = this.store
1329
+ ?.getStateJson?.(PRIMARY_SUMMARY_FALLBACK_STATE_ID);
1330
+ if (!raw || typeof raw !== 'object')
1331
+ return { requests: [] };
1332
+ const requests = Array.isArray(raw.requests)
1333
+ ? (raw.requests)
1334
+ : [];
1335
+ return { requests };
1336
+ }
1337
+ writePrimarySummaryFallbackState(state) {
1338
+ this.store.setStateJson(PRIMARY_SUMMARY_FALLBACK_STATE_ID, state);
1339
+ }
1340
+ async withPrimarySummaryStateLock(work) {
1341
+ const previous = this.primarySummaryStateLock;
1342
+ let release;
1343
+ this.primarySummaryStateLock = new Promise((resolve) => { release = resolve; });
1344
+ await previous;
1345
+ try {
1346
+ return await work();
1347
+ }
1348
+ finally {
1349
+ release();
1350
+ }
1351
+ }
1352
+ async reconcilePrimarySummaryFallbackStateOnStartup() {
1353
+ const alerts = [];
1354
+ const unsettledRequests = (await this.withPrimarySummaryStateLock(() => this.readPrimarySummaryFallbackState().requests
1355
+ .filter((record) => record.finalStatus === 'pending'
1356
+ || record.finalStatus === 'refusal'
1357
+ || record.fallbackStatus === 'pending')
1358
+ .map((record) => structuredClone(record))));
1359
+ const resolvedRequestIds = new Set();
1360
+ for (const record of unsettledRequests) {
1361
+ const result = await this.finalizePrimarySummarySettlementOnStartup(record);
1362
+ if (result === 'finalized') {
1363
+ resolvedRequestIds.add(record.requestId);
1364
+ continue;
1365
+ }
1366
+ if (result && typeof result === 'object' && 'invalidReason' in result) {
1367
+ resolvedRequestIds.add(record.requestId);
1368
+ alerts.push({ agentName: record.agentName, requestId: record.requestId, reason: result.invalidReason });
1369
+ await this.markPrimarySummaryRequestRestartFailure(record, result.invalidReason);
1370
+ }
1371
+ }
1372
+ await this.withPrimarySummaryStateLock(() => {
1373
+ const state = this.readPrimarySummaryFallbackState();
1374
+ let changed = false;
1375
+ state.requests = state.requests.map((record) => {
1376
+ if (resolvedRequestIds.has(record.requestId))
1377
+ return record;
1378
+ if (record.dispatchKind === 'primary_summary_fallback_retry' && (record.finalStatus === 'pending' || record.finalStatus === 'refusal')) {
1379
+ changed = true;
1380
+ const reason = 'primary_summary_fallback_unresolved_on_restart';
1381
+ alerts.push({ agentName: record.agentName, requestId: record.requestId, reason });
1382
+ return {
1383
+ ...record,
1384
+ finalStatus: 'held',
1385
+ fallbackHeldReason: reason,
1386
+ };
1387
+ }
1388
+ if (record.fallbackIntent && record.fallbackStatus === 'pending') {
1389
+ changed = true;
1390
+ const reason = 'primary_summary_fallback_unresolved_on_restart';
1391
+ alerts.push({ agentName: record.agentName, requestId: record.requestId, reason });
1392
+ return {
1393
+ ...record,
1394
+ fallbackStatus: 'held',
1395
+ fallbackHeldReason: reason,
1396
+ };
1397
+ }
1398
+ if (record.finalStatus === 'refusal') {
1399
+ changed = true;
1400
+ const reason = 'primary_summary_request_unresolved_on_restart';
1401
+ alerts.push({ agentName: record.agentName, requestId: record.requestId, reason });
1402
+ return {
1403
+ ...record,
1404
+ finalStatus: 'held',
1405
+ fallbackStatus: 'held',
1406
+ fallbackHeldReason: reason,
1407
+ };
1408
+ }
1409
+ if (record.finalStatus === 'pending') {
1410
+ changed = true;
1411
+ const reason = 'primary_summary_request_unresolved_on_restart';
1412
+ alerts.push({ agentName: record.agentName, requestId: record.requestId, reason });
1413
+ return {
1414
+ ...record,
1415
+ finalStatus: 'timeout_unknown',
1416
+ fallbackHeldReason: reason,
1417
+ };
1418
+ }
1419
+ return record;
1420
+ });
1421
+ if (changed)
1422
+ this.writePrimarySummaryFallbackState({ requests: state.requests.slice(-256) });
1423
+ });
1424
+ for (const alert of alerts) {
1425
+ this.opsAlert('refusal-held', alert.agentName, alert.reason, {
1426
+ data: { requestId: alert.requestId },
1427
+ });
1428
+ }
1429
+ }
1430
+ primarySummaryIdentityKey(identity) {
1431
+ return [
1432
+ identity.id,
1433
+ identity.contentHash,
1434
+ identity.carrierHash,
1435
+ identity.sourceLeafHash,
1436
+ ].join('\u0000');
1437
+ }
1438
+ primarySummaryProjectionKeys(projection) {
1439
+ return projection?.selectedSummaries.map((selection) => this.primarySummaryIdentityKey(selection.identity)) ?? [];
1440
+ }
1441
+ primarySummaryFallbackConfig(agent) {
1442
+ const raw = agent.refusalHandling?.primarySummaryFallback;
1443
+ const fallbackBudgetBase = agent.contextBudgetTokens ?? 100_000;
1444
+ return {
1445
+ enabled: raw?.enabled === true,
1446
+ maxNewSummaries: typeof raw?.maxNewSummaries === 'number' && Number.isSafeInteger(raw.maxNewSummaries)
1447
+ ? Math.max(0, raw.maxNewSummaries)
1448
+ : 4,
1449
+ requestBudgetTokens: typeof raw?.requestBudgetTokens === 'number' && Number.isFinite(raw.requestBudgetTokens) && raw.requestBudgetTokens > 0
1450
+ ? raw.requestBudgetTokens
1451
+ : fallbackBudgetBase + agent.maxTokens,
1452
+ };
1453
+ }
1454
+ latestCompatibleHealthyPrimaryRequest(current, state) {
1455
+ for (let i = state.requests.length - 1; i >= 0; i--) {
1456
+ const candidate = state.requests[i];
1457
+ if (candidate.requestId === current.requestId)
1458
+ continue;
1459
+ if (candidate.agentName !== current.agentName)
1460
+ continue;
1461
+ if (candidate.namespace !== current.namespace)
1462
+ continue;
1463
+ if (candidate.branch.id !== current.branch.id)
1464
+ continue;
1465
+ if (candidate.branch.generation !== current.branch.generation)
1466
+ continue;
1467
+ if (candidate.systemHash !== current.systemHash)
1468
+ continue;
1469
+ if (candidate.modelConfigHash !== current.modelConfigHash)
1470
+ continue;
1471
+ if (candidate.toolContractHash !== current.toolContractHash)
1472
+ continue;
1473
+ if (candidate.dispatchKind !== 'primary')
1474
+ continue;
1475
+ if (candidate.fallbackIntent || candidate.fallbackStatus)
1476
+ continue;
1477
+ if (candidate.finalStatus !== 'success')
1478
+ continue;
1479
+ return candidate;
1480
+ }
1481
+ return null;
1482
+ }
1483
+ async persistPrimarySummaryRequestRecord(record) {
1484
+ await this.withPrimarySummaryStateLock(() => {
1485
+ const state = this.readPrimarySummaryFallbackState();
1486
+ const requests = state.requests.filter((entry) => entry.requestId !== record.requestId);
1487
+ requests.push(record);
1488
+ this.writePrimarySummaryFallbackState({ requests: requests.slice(-256) });
1489
+ });
1490
+ }
1491
+ async updatePrimarySummaryRequestRecord(requestId, update) {
1492
+ return this.withPrimarySummaryStateLock(() => {
1493
+ const state = this.readPrimarySummaryFallbackState();
1494
+ const index = state.requests.findIndex((entry) => entry.requestId === requestId);
1495
+ if (index < 0)
1496
+ return null;
1497
+ state.requests[index] = update(state.requests[index]);
1498
+ this.writePrimarySummaryFallbackState({ requests: state.requests.slice(-256) });
1499
+ return state.requests[index];
1500
+ });
1501
+ }
1502
+ async getPrimarySummaryRequestRecord(requestId) {
1503
+ return this.withPrimarySummaryStateLock(() => {
1504
+ const state = this.readPrimarySummaryFallbackState();
1505
+ return state.requests.find((entry) => entry.requestId === requestId) ?? null;
1506
+ });
1507
+ }
1508
+ primarySummaryContractsMatch(left, right) {
1509
+ return left.systemHash === right.systemHash
1510
+ && left.modelConfigHash === right.modelConfigHash
1511
+ && left.toolContractHash === right.toolContractHash;
1512
+ }
1513
+ primarySummaryFallbackOutputPhaseHash(blocks) {
1514
+ return this.hashJson(blocks);
1515
+ }
1516
+ primarySummaryFallbackBlockTypes(blocks) {
1517
+ return blocks.map((block) => block.type);
1518
+ }
1519
+ static NON_EXECUTED_TOOL_RESULT_TEXT = '[tool not executed]';
1520
+ buildNonExecutedToolResults(blocks) {
1521
+ const seenToolUseIds = new Set();
1522
+ return blocks
1523
+ .filter((block) => block.type === 'tool_use')
1524
+ .filter((block) => {
1525
+ if (seenToolUseIds.has(block.id))
1526
+ return false;
1527
+ seenToolUseIds.add(block.id);
1528
+ return true;
1529
+ })
1530
+ .map((block) => ({
1531
+ type: 'tool_result',
1532
+ toolUseId: block.id,
1533
+ content: AgentFramework.NON_EXECUTED_TOOL_RESULT_TEXT,
1534
+ isError: true,
1535
+ }));
1536
+ }
1537
+ buildPrimarySummarySettlementId(record, outcome) {
1538
+ return `${record.dispatchKind}:${outcome}:terminal:v${PRIMARY_SUMMARY_SETTLEMENT_VERSION}`;
1539
+ }
1540
+ buildPrimarySummarySettlementMetadata(record, options) {
1541
+ const settlementId = options.settlementId ?? this.buildPrimarySummarySettlementId(record, options.outcome);
1542
+ return {
1543
+ [PRIMARY_SUMMARY_SETTLEMENT_METADATA_KEY]: {
1544
+ version: PRIMARY_SUMMARY_SETTLEMENT_VERSION,
1545
+ requestId: record.requestId,
1546
+ settlementId,
1547
+ agentName: record.agentName,
1548
+ dispatchKind: record.dispatchKind,
1549
+ outcome: options.outcome,
1550
+ ...(options.stopReason ? { stopReason: options.stopReason } : {}),
1551
+ ...(options.holdReason ? { holdReason: options.holdReason } : {}),
1552
+ ...(options.providerInputTokens !== undefined
1553
+ ? { providerInputTokens: options.providerInputTokens }
1554
+ : {}),
1555
+ visibleAssistantOutput: options.visibleAssistantOutput,
1556
+ executedToolCalls: options.executedToolCalls,
1557
+ branch: {
1558
+ id: record.branch.id,
1559
+ name: record.branch.name,
1560
+ generation: record.branch.generation,
1561
+ },
1562
+ role: options.role,
1563
+ kind: options.kind,
1564
+ entryIndex: options.entryIndex,
1565
+ entryCount: options.entryCount,
1566
+ },
1567
+ };
1568
+ }
1569
+ isBoundedNonEmptyString(value, maxLength) {
1570
+ return typeof value === 'string' && value.length > 0 && value.length <= maxLength;
1571
+ }
1572
+ isSafeNonNegativeInteger(value) {
1573
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
1574
+ }
1575
+ primarySummarySettlementRequestId(message) {
1576
+ const raw = message.metadata?.[PRIMARY_SUMMARY_SETTLEMENT_METADATA_KEY];
1577
+ if (!raw || typeof raw !== 'object')
1578
+ return null;
1579
+ const requestId = raw.requestId;
1580
+ return typeof requestId === 'string' ? requestId : null;
1581
+ }
1582
+ parsePrimarySummarySettlementMetadata(message) {
1583
+ const raw = message.metadata?.[PRIMARY_SUMMARY_SETTLEMENT_METADATA_KEY];
1584
+ if (!raw || typeof raw !== 'object')
1585
+ return null;
1586
+ const candidate = raw;
1587
+ if (candidate.version !== PRIMARY_SUMMARY_SETTLEMENT_VERSION ||
1588
+ !this.isBoundedNonEmptyString(candidate.requestId, PRIMARY_SUMMARY_SETTLEMENT_MAX_ID_LENGTH) ||
1589
+ !this.isBoundedNonEmptyString(candidate.settlementId, PRIMARY_SUMMARY_SETTLEMENT_MAX_ID_LENGTH) ||
1590
+ !this.isBoundedNonEmptyString(candidate.agentName, PRIMARY_SUMMARY_SETTLEMENT_MAX_NAME_LENGTH) ||
1591
+ (candidate.dispatchKind !== 'primary' && candidate.dispatchKind !== 'primary_summary_fallback_retry') ||
1592
+ (candidate.outcome !== 'success' && candidate.outcome !== 'held') ||
1593
+ typeof candidate.visibleAssistantOutput !== 'boolean' ||
1594
+ !this.isSafeNonNegativeInteger(candidate.executedToolCalls) ||
1595
+ !this.isSafeNonNegativeInteger(candidate.entryIndex) ||
1596
+ !this.isSafeNonNegativeInteger(candidate.entryCount) ||
1597
+ (candidate.role !== 'assistant' && candidate.role !== 'user') ||
1598
+ (candidate.kind !== 'assistant_output' && candidate.kind !== 'generated_tool_result') ||
1599
+ !candidate.branch ||
1600
+ !this.isBoundedNonEmptyString(candidate.branch.id, PRIMARY_SUMMARY_SETTLEMENT_MAX_ID_LENGTH) ||
1601
+ !this.isBoundedNonEmptyString(candidate.branch.name, PRIMARY_SUMMARY_SETTLEMENT_MAX_NAME_LENGTH) ||
1602
+ !this.isSafeNonNegativeInteger(candidate.branch.generation) ||
1603
+ candidate.entryCount < 1 ||
1604
+ candidate.entryIndex >= candidate.entryCount ||
1605
+ (candidate.stopReason !== undefined
1606
+ && !this.isBoundedNonEmptyString(candidate.stopReason, PRIMARY_SUMMARY_SETTLEMENT_MAX_REASON_LENGTH)) ||
1607
+ (candidate.holdReason !== undefined
1608
+ && !this.isBoundedNonEmptyString(candidate.holdReason, PRIMARY_SUMMARY_SETTLEMENT_MAX_REASON_LENGTH)) ||
1609
+ (candidate.providerInputTokens !== undefined
1610
+ && !this.isSafeNonNegativeInteger(candidate.providerInputTokens)) ||
1611
+ (candidate.outcome === 'held' && candidate.holdReason === undefined) ||
1612
+ (candidate.outcome === 'success' && candidate.holdReason !== undefined)) {
1613
+ return { invalidReason: 'primary_summary_settlement_metadata_invalid_on_restart' };
1614
+ }
1615
+ return candidate;
1616
+ }
1617
+ primarySummarySettlementMetadataConsistent(left, right) {
1618
+ return left.version === right.version
1619
+ && left.requestId === right.requestId
1620
+ && left.settlementId === right.settlementId
1621
+ && left.agentName === right.agentName
1622
+ && left.dispatchKind === right.dispatchKind
1623
+ && left.outcome === right.outcome
1624
+ && left.stopReason === right.stopReason
1625
+ && left.holdReason === right.holdReason
1626
+ && left.providerInputTokens === right.providerInputTokens
1627
+ && left.visibleAssistantOutput === right.visibleAssistantOutput
1628
+ && left.executedToolCalls === right.executedToolCalls
1629
+ && left.branch.id === right.branch.id
1630
+ && left.branch.name === right.branch.name
1631
+ && left.branch.generation === right.branch.generation
1632
+ && left.entryCount === right.entryCount;
1633
+ }
1634
+ primarySummaryShardEnvelopeConsistent(participant, left, candidateParticipant, right) {
1635
+ return participant === candidateParticipant
1636
+ && this.primarySummarySettlementMetadataConsistent(left, right)
1637
+ && left.role === right.role
1638
+ && left.kind === right.kind
1639
+ && left.entryIndex === right.entryIndex;
1640
+ }
1641
+ primarySummaryLogicalSettlementEntries(agent, requestId) {
1642
+ const allMessages = agent.getContextManager().getAllMessages()
1643
+ .slice()
1644
+ .sort((left, right) => left.sequence - right.sequence);
1645
+ const entries = [];
1646
+ for (let index = 0; index < allMessages.length;) {
1647
+ const message = allMessages[index];
1648
+ if (this.primarySummarySettlementRequestId(message) !== requestId) {
1649
+ index++;
1650
+ continue;
1651
+ }
1652
+ const parsed = this.parsePrimarySummarySettlementMetadata(message);
1653
+ if (!parsed) {
1654
+ index++;
1655
+ continue;
1656
+ }
1657
+ if ('invalidReason' in parsed)
1658
+ return parsed;
1659
+ if (message.bodyGroupId === undefined) {
1660
+ if (message.shardIndex !== undefined) {
1661
+ return { invalidReason: 'primary_summary_settlement_shard_group_invalid_on_restart' };
1662
+ }
1663
+ entries.push({
1664
+ messages: [message],
1665
+ settlement: parsed,
1666
+ participant: message.participant,
1667
+ content: message.content,
1668
+ });
1669
+ index++;
1670
+ continue;
1671
+ }
1672
+ const groupId = message.bodyGroupId;
1673
+ if (!this.isBoundedNonEmptyString(groupId, PRIMARY_SUMMARY_SETTLEMENT_MAX_ID_LENGTH)) {
1674
+ return { invalidReason: 'primary_summary_settlement_shard_group_invalid_on_restart' };
1675
+ }
1676
+ if (index > 0 && allMessages[index - 1]?.bodyGroupId === groupId) {
1677
+ return { invalidReason: 'primary_summary_settlement_shard_group_invalid_on_restart' };
1678
+ }
1679
+ const groupMessages = [];
1680
+ while (index < allMessages.length && allMessages[index].bodyGroupId === groupId) {
1681
+ groupMessages.push(allMessages[index]);
1682
+ index++;
1683
+ }
1684
+ if (allMessages.slice(index).some((candidate) => candidate.bodyGroupId === groupId)) {
1685
+ return { invalidReason: 'primary_summary_settlement_shard_group_invalid_on_restart' };
1686
+ }
1687
+ const orderedGroupMessages = [];
1688
+ let expectedShardIndex = 0;
1689
+ for (const groupMessage of groupMessages) {
1690
+ if (this.primarySummarySettlementRequestId(groupMessage) !== requestId) {
1691
+ return { invalidReason: 'primary_summary_settlement_shard_group_invalid_on_restart' };
1692
+ }
1693
+ const groupParsed = this.parsePrimarySummarySettlementMetadata(groupMessage);
1694
+ if (!groupParsed) {
1695
+ return { invalidReason: 'primary_summary_settlement_shard_group_invalid_on_restart' };
1696
+ }
1697
+ if ('invalidReason' in groupParsed)
1698
+ return groupParsed;
1699
+ if (!this.primarySummaryShardEnvelopeConsistent(message.participant, parsed, groupMessage.participant, groupParsed)) {
1700
+ return { invalidReason: 'primary_summary_settlement_shard_group_invalid_on_restart' };
1701
+ }
1702
+ if (groupMessage.shardIndex !== expectedShardIndex) {
1703
+ return { invalidReason: 'primary_summary_settlement_shard_group_invalid_on_restart' };
1704
+ }
1705
+ orderedGroupMessages.push(groupMessage);
1706
+ expectedShardIndex++;
1707
+ }
1708
+ entries.push({
1709
+ messages: orderedGroupMessages,
1710
+ settlement: parsed,
1711
+ participant: message.participant,
1712
+ content: orderedGroupMessages.flatMap((groupMessage) => groupMessage.content),
1713
+ bodyGroupId: groupId,
1714
+ });
1715
+ }
1716
+ return entries;
1717
+ }
1718
+ primarySummaryContentMatches(left, right) {
1719
+ return this.primarySummaryFallbackOutputPhaseHash(left) === this.primarySummaryFallbackOutputPhaseHash(right);
1720
+ }
1721
+ async persistPrimarySummaryChronicleEntries(agent, entries) {
1722
+ const messageIds = [];
1723
+ for (const entry of entries) {
1724
+ if (entry.content.length === 0)
1725
+ continue;
1726
+ messageIds.push(agent.getContextManager().addMessage(entry.participant, entry.content, entry.metadata));
1727
+ }
1728
+ return messageIds;
1729
+ }
1730
+ async markPrimarySummaryRequestRestartFailure(record, reason) {
1731
+ if (record.dispatchKind === 'primary_summary_fallback_retry') {
1732
+ const parent = await this.findPrimarySummaryRetryParent(record.requestId);
1733
+ await this.updatePrimarySummaryRequestRecord(record.requestId, (entry) => ({
1734
+ ...entry,
1735
+ finalStatus: 'held',
1736
+ fallbackHeldReason: reason,
1737
+ }));
1738
+ if (parent) {
1739
+ await this.updatePrimarySummaryRequestRecord(parent.originalRequestId, (entry) => ({
1740
+ ...entry,
1741
+ finalStatus: 'held',
1742
+ fallbackStatus: 'held',
1743
+ fallbackHeldReason: reason,
1744
+ }));
1745
+ }
1746
+ return;
1747
+ }
1748
+ await this.updatePrimarySummaryRequestRecord(record.requestId, (current) => ({
1749
+ ...current,
1750
+ finalStatus: 'held',
1751
+ fallbackStatus: 'held',
1752
+ fallbackHeldReason: reason,
1753
+ }));
1754
+ }
1755
+ async persistPrimarySummaryHeldOutput(agent, requestId, holdReason, options) {
1756
+ const record = await this.getPrimarySummaryRequestRecord(requestId);
1757
+ if (!record) {
1758
+ throw new Error(`Primary summary request '${requestId}' disappeared before held-output persistence`);
1759
+ }
1760
+ const placeholderBlocks = this.buildNonExecutedToolResults(options.assistantBlocks);
1761
+ const entryCount = placeholderBlocks.length > 0 ? 2 : 1;
1762
+ const assistantMetadata = this.buildPrimarySummarySettlementMetadata(record, {
1763
+ outcome: 'held',
1764
+ stopReason: options.stopReason,
1765
+ holdReason,
1766
+ providerInputTokens: options.providerInputTokens,
1767
+ visibleAssistantOutput: options.visibleAssistantOutput,
1768
+ executedToolCalls: options.executedToolCalls,
1769
+ entryIndex: 0,
1770
+ entryCount,
1771
+ role: 'assistant',
1772
+ kind: 'assistant_output',
1773
+ });
1774
+ const settlementId = assistantMetadata[PRIMARY_SUMMARY_SETTLEMENT_METADATA_KEY].settlementId;
1775
+ const placeholderMetadata = placeholderBlocks.length > 0
1776
+ ? this.buildPrimarySummarySettlementMetadata(record, {
1777
+ outcome: 'held',
1778
+ settlementId,
1779
+ stopReason: options.stopReason,
1780
+ holdReason,
1781
+ providerInputTokens: options.providerInputTokens,
1782
+ visibleAssistantOutput: options.visibleAssistantOutput,
1783
+ executedToolCalls: options.executedToolCalls,
1784
+ entryIndex: 1,
1785
+ entryCount,
1786
+ role: 'user',
1787
+ kind: 'generated_tool_result',
1788
+ })
1789
+ : undefined;
1790
+ const messageIds = await this.persistPrimarySummaryChronicleEntries(agent, [
1791
+ { participant: agent.name, content: options.assistantBlocks, metadata: assistantMetadata },
1792
+ ...(placeholderBlocks.length > 0 && placeholderMetadata
1793
+ ? [{ participant: 'user', content: placeholderBlocks, metadata: placeholderMetadata }]
1794
+ : []),
1795
+ ]);
1796
+ if (options.retryDispatch && options.retryPhase && options.assistantBlocks.length > 0) {
1797
+ await this.recordPrimarySummaryRetryOutput(options.retryDispatch.originalRequestId, options.retryDispatch.intentId, {
1798
+ phase: options.retryPhase,
1799
+ contentHash: this.primarySummaryFallbackOutputPhaseHash(options.assistantBlocks),
1800
+ messageId: messageIds[0] ?? '',
1801
+ blockTypes: this.primarySummaryFallbackBlockTypes(options.assistantBlocks),
1802
+ persistedAt: Date.now(),
1803
+ });
1804
+ }
1805
+ if (options.retryDispatch) {
1806
+ await this.holdPrimarySummaryRetryFamily(agent, requestId, options.retryDispatch, holdReason, options.holdData);
1807
+ return;
1808
+ }
1809
+ await this.holdPrimarySummaryRequest(agent, requestId, holdReason, options.holdData);
1810
+ }
1811
+ async findPrimarySummaryRetryParent(retryRequestId) {
1812
+ return this.withPrimarySummaryStateLock(() => {
1813
+ const state = this.readPrimarySummaryFallbackState();
1814
+ for (const record of state.requests) {
1815
+ const intent = record.fallbackIntent;
1816
+ if (intent?.retryRequestId === retryRequestId) {
1817
+ return { originalRequestId: record.requestId, intentId: intent.intentId };
1818
+ }
1819
+ }
1820
+ return null;
1821
+ });
1822
+ }
1823
+ async finalizePrimarySummarySettlementOnStartup(record) {
1824
+ const agent = this.agents.get(record.agentName);
1825
+ if (!agent)
1826
+ return false;
1827
+ const settlementEntries = this.primarySummaryLogicalSettlementEntries(agent, record.requestId);
1828
+ if ('invalidReason' in settlementEntries)
1829
+ return settlementEntries;
1830
+ if (settlementEntries.length === 0) {
1831
+ return false;
1832
+ }
1833
+ const currentBranch = agent.getCurrentBranchGeneration();
1834
+ const assistantEntry = settlementEntries[0];
1835
+ const settlement = assistantEntry.settlement;
1836
+ if (assistantEntry.participant !== record.agentName ||
1837
+ settlement.agentName !== record.agentName ||
1838
+ settlement.dispatchKind !== record.dispatchKind ||
1839
+ settlement.entryIndex !== 0 ||
1840
+ settlement.role !== 'assistant' ||
1841
+ settlement.kind !== 'assistant_output') {
1842
+ return { invalidReason: 'primary_summary_settlement_invalid_assistant_marker_on_restart' };
1843
+ }
1844
+ if (!currentBranch) {
1845
+ return { invalidReason: 'primary_summary_settlement_branch_unknown_on_restart' };
1846
+ }
1847
+ if (settlement.branch.id !== record.branch.id ||
1848
+ settlement.branch.name !== record.branch.name ||
1849
+ settlement.branch.generation !== record.branch.generation ||
1850
+ settlement.branch.id !== currentBranch.id ||
1851
+ settlement.branch.name !== currentBranch.name ||
1852
+ settlement.branch.generation !== currentBranch.generation) {
1853
+ return { invalidReason: 'primary_summary_settlement_branch_mismatch_on_restart' };
1854
+ }
1855
+ if (settlement.entryCount < 1 || settlement.entryCount > 2) {
1856
+ return { invalidReason: 'primary_summary_settlement_entry_count_invalid_on_restart' };
1857
+ }
1858
+ const entriesByIndex = new Map();
1859
+ for (const entry of settlementEntries) {
1860
+ if (!this.primarySummarySettlementMetadataConsistent(settlement, entry.settlement)) {
1861
+ return { invalidReason: 'primary_summary_settlement_marker_conflict_on_restart' };
1862
+ }
1863
+ if (entriesByIndex.has(entry.settlement.entryIndex)) {
1864
+ return { invalidReason: 'primary_summary_settlement_duplicate_entry_on_restart' };
1865
+ }
1866
+ entriesByIndex.set(entry.settlement.entryIndex, entry);
1867
+ }
1868
+ const placeholderEntry = entriesByIndex.get(1);
1869
+ if (placeholderEntry
1870
+ && (placeholderEntry.participant !== 'user'
1871
+ || placeholderEntry.settlement.role !== 'user'
1872
+ || placeholderEntry.settlement.kind !== 'generated_tool_result')) {
1873
+ return { invalidReason: 'primary_summary_settlement_placeholder_marker_invalid_on_restart' };
1874
+ }
1875
+ if ([...entriesByIndex.keys()].some((index) => index !== 0 && index !== 1)) {
1876
+ return { invalidReason: 'primary_summary_settlement_out_of_order_entries_on_restart' };
1877
+ }
1878
+ const expectedPlaceholder = settlement.outcome === 'held'
1879
+ ? this.buildNonExecutedToolResults(assistantEntry.content)
1880
+ : [];
1881
+ if (settlement.outcome === 'success') {
1882
+ if (settlement.entryCount !== 1 || placeholderEntry) {
1883
+ return { invalidReason: 'primary_summary_success_settlement_shape_invalid_on_restart' };
1884
+ }
1885
+ }
1886
+ else {
1887
+ const expectedEntryCount = expectedPlaceholder.length > 0 ? 2 : 1;
1888
+ if (settlement.entryCount !== expectedEntryCount) {
1889
+ return { invalidReason: 'primary_summary_held_settlement_shape_invalid_on_restart' };
1890
+ }
1891
+ if (placeholderEntry) {
1892
+ if (!this.primarySummaryContentMatches(placeholderEntry.content, expectedPlaceholder)) {
1893
+ return { invalidReason: 'primary_summary_settlement_placeholder_content_mismatch_on_restart' };
1894
+ }
1895
+ }
1896
+ else if (expectedPlaceholder.length > 0) {
1897
+ const placeholderMetadata = this.buildPrimarySummarySettlementMetadata(record, {
1898
+ outcome: 'held',
1899
+ settlementId: settlement.settlementId,
1900
+ stopReason: settlement.stopReason,
1901
+ holdReason: settlement.holdReason,
1902
+ providerInputTokens: settlement.providerInputTokens,
1903
+ visibleAssistantOutput: settlement.visibleAssistantOutput,
1904
+ executedToolCalls: settlement.executedToolCalls,
1905
+ entryIndex: 1,
1906
+ entryCount: settlement.entryCount,
1907
+ role: 'user',
1908
+ kind: 'generated_tool_result',
1909
+ });
1910
+ await this.persistPrimarySummaryChronicleEntries(agent, [
1911
+ { participant: 'user', content: expectedPlaceholder, metadata: placeholderMetadata },
1912
+ ]);
1913
+ }
1914
+ }
1915
+ if (record.dispatchKind === 'primary_summary_fallback_retry') {
1916
+ const parent = await this.findPrimarySummaryRetryParent(record.requestId);
1917
+ if (!parent)
1918
+ return { invalidReason: 'primary_summary_fallback_parent_missing_on_restart' };
1919
+ if (settlement.outcome === 'success') {
1920
+ await this.updatePrimarySummaryRequestRecord(record.requestId, (entry) => ({
1921
+ ...entry,
1922
+ stopReason: settlement.stopReason ?? entry.stopReason,
1923
+ providerInputTokens: settlement.providerInputTokens ?? entry.providerInputTokens,
1924
+ visibleAssistantOutput: settlement.visibleAssistantOutput,
1925
+ executedToolCalls: settlement.executedToolCalls,
1926
+ finalStatus: 'success',
1927
+ fallbackHeldReason: undefined,
1928
+ }));
1929
+ await this.updatePrimarySummaryRequestRecord(parent.originalRequestId, (entry) => {
1930
+ const intent = entry.fallbackIntent;
1931
+ return {
1932
+ ...entry,
1933
+ finalStatus: 'success',
1934
+ fallbackStatus: 'success',
1935
+ fallbackHeldReason: undefined,
1936
+ fallbackIntent: intent && intent.intentId === parent.intentId
1937
+ ? { ...intent, finalizedAt: intent.finalizedAt ?? Date.now() }
1938
+ : intent,
1939
+ };
1940
+ });
1941
+ return 'finalized';
1942
+ }
1943
+ await this.updatePrimarySummaryRequestRecord(record.requestId, (entry) => ({
1944
+ ...entry,
1945
+ stopReason: settlement.stopReason ?? entry.stopReason,
1946
+ providerInputTokens: settlement.providerInputTokens ?? entry.providerInputTokens,
1947
+ visibleAssistantOutput: settlement.visibleAssistantOutput,
1948
+ executedToolCalls: settlement.executedToolCalls,
1949
+ finalStatus: 'held',
1950
+ fallbackHeldReason: settlement.holdReason,
1951
+ }));
1952
+ await this.updatePrimarySummaryRequestRecord(parent.originalRequestId, (entry) => ({
1953
+ ...entry,
1954
+ finalStatus: 'held',
1955
+ fallbackStatus: 'held',
1956
+ fallbackHeldReason: settlement.holdReason,
1957
+ }));
1958
+ return 'finalized';
1959
+ }
1960
+ await this.updatePrimarySummaryRequestRecord(record.requestId, (entry) => ({
1961
+ ...entry,
1962
+ stopReason: settlement.stopReason ?? entry.stopReason,
1963
+ providerInputTokens: settlement.providerInputTokens ?? entry.providerInputTokens,
1964
+ visibleAssistantOutput: settlement.visibleAssistantOutput,
1965
+ executedToolCalls: settlement.executedToolCalls,
1966
+ finalStatus: settlement.outcome === 'success' ? 'success' : 'held',
1967
+ fallbackStatus: settlement.outcome === 'success' ? entry.fallbackStatus : 'held',
1968
+ fallbackHeldReason: settlement.outcome === 'held' ? settlement.holdReason : undefined,
1969
+ }));
1970
+ return 'finalized';
1971
+ }
1972
+ summarizePrimarySummaryRetryRequest(request) {
1973
+ const blockCounts = new Map();
1974
+ for (const message of request.messages) {
1975
+ for (const block of message.content) {
1976
+ blockCounts.set(block.type, (blockCounts.get(block.type) ?? 0) + 1);
1977
+ }
1978
+ }
1979
+ return {
1980
+ kind: 'primary_summary_fallback_retry',
1981
+ requestHash: this.hashJson(request),
1982
+ systemHash: this.hashJson(request.system ?? ''),
1983
+ messageCount: request.messages.length,
1984
+ toolCount: request.tools?.length ?? 0,
1985
+ participantCount: [...new Set(request.messages.map((message) => message.participant))].length,
1986
+ blockCounts: Object.fromEntries([...blockCounts.entries()].sort(([left], [right]) => left.localeCompare(right))),
1987
+ promptCaching: request.promptCaching === true,
1988
+ cacheTtl: request.cacheTtl,
1989
+ assistantParticipant: request.assistantParticipant,
1990
+ requestCompleteBoundTokens: this.estimateRequestCompleteBoundTokens(request),
1991
+ };
1992
+ }
1993
+ summarizePrimarySummaryRetryResponse(response) {
1994
+ const blockCounts = new Map();
1995
+ for (const block of response.content) {
1996
+ blockCounts.set(block.type, (blockCounts.get(block.type) ?? 0) + 1);
1997
+ }
1998
+ return {
1999
+ kind: 'primary_summary_fallback_retry',
2000
+ stopReason: response.stopReason,
2001
+ rawAssistantTextHash: response.rawAssistantText
2002
+ ? this.hashJson(response.rawAssistantText)
2003
+ : undefined,
2004
+ blockCounts: Object.fromEntries([...blockCounts.entries()].sort(([left], [right]) => left.localeCompare(right))),
2005
+ toolCallIds: response.toolCalls.map((call) => call.id),
2006
+ usage: response.usage
2007
+ ? {
2008
+ inputTokens: response.usage.inputTokens,
2009
+ outputTokens: response.usage.outputTokens,
2010
+ }
2011
+ : undefined,
2012
+ };
2013
+ }
2014
+ async claimPrimarySummaryRetryIntent(originalRequestId, intentId, retryRequestId) {
2015
+ let outcome = 'missing';
2016
+ await this.updatePrimarySummaryRequestRecord(originalRequestId, (record) => {
2017
+ const intent = record.fallbackIntent;
2018
+ if (!intent || intent.intentId !== intentId || record.fallbackStatus !== 'pending') {
2019
+ return record;
2020
+ }
2021
+ if (intent.retryRequestId && intent.retryRequestId !== retryRequestId) {
2022
+ outcome = 'duplicate';
2023
+ return record;
2024
+ }
2025
+ outcome = 'claimed';
2026
+ return {
2027
+ ...record,
2028
+ fallbackIntent: {
2029
+ ...intent,
2030
+ retryRequestId,
2031
+ dispatchedAt: intent.dispatchedAt ?? Date.now(),
2032
+ },
2033
+ };
2034
+ });
2035
+ return outcome;
2036
+ }
2037
+ async validatePrimarySummaryRetryMutation(agent, requestId, retryDispatch, compiledRequest, artifacts, phase) {
2038
+ const branch = agent.getCurrentBranchGeneration();
2039
+ if (!branch || branch.id !== retryDispatch.branch.id || branch.generation !== retryDispatch.branch.generation) {
2040
+ return {
2041
+ ok: false,
2042
+ reason: `primary_summary_fallback_branch_changed_${phase}`,
2043
+ data: {
2044
+ expectedBranchId: retryDispatch.branch.id,
2045
+ expectedGeneration: retryDispatch.branch.generation,
2046
+ actualBranchId: branch?.id,
2047
+ actualGeneration: branch?.generation,
2048
+ },
2049
+ };
2050
+ }
2051
+ const requestHash = this.hashJson(compiledRequest);
2052
+ if (requestHash !== retryDispatch.expectedRetryRequestHash) {
2053
+ return {
2054
+ ok: false,
2055
+ reason: `primary_summary_fallback_request_changed_${phase}`,
2056
+ data: {
2057
+ expectedRetryRequestHash: retryDispatch.expectedRetryRequestHash,
2058
+ actualRetryRequestHash: requestHash,
2059
+ },
2060
+ };
2061
+ }
2062
+ if (this.primarySummaryProjectionKeys(artifacts.compileResult.primarySummaryProjection ?? null).join('\u0000') !==
2063
+ retryDispatch.originalProjectionKeys.join('\u0000')) {
2064
+ return {
2065
+ ok: false,
2066
+ reason: `primary_summary_fallback_projection_changed_${phase}`,
2067
+ data: {
2068
+ expectedProjectionKeys: retryDispatch.originalProjectionKeys,
2069
+ actualProjectionKeys: this.primarySummaryProjectionKeys(artifacts.compileResult.primarySummaryProjection ?? null),
2070
+ },
2071
+ };
2072
+ }
2073
+ if (!this.primarySummaryContractsMatch(artifacts.contract, retryDispatch.originalArtifacts.contract)) {
2074
+ return {
2075
+ ok: false,
2076
+ reason: `primary_summary_fallback_contract_changed_${phase}`,
2077
+ data: {
2078
+ expectedContract: retryDispatch.originalArtifacts.contract,
2079
+ actualContract: artifacts.contract,
2080
+ },
2081
+ };
2082
+ }
2083
+ const original = await this.getPrimarySummaryRequestRecord(retryDispatch.originalRequestId);
2084
+ if (!original?.fallbackIntent ||
2085
+ original.fallbackIntent.intentId !== retryDispatch.intentId ||
2086
+ original.fallbackIntent.retryRequestId !== requestId ||
2087
+ original.fallbackStatus !== 'pending') {
2088
+ return {
2089
+ ok: false,
2090
+ reason: `primary_summary_fallback_intent_changed_${phase}`,
2091
+ data: {
2092
+ expectedIntentId: retryDispatch.intentId,
2093
+ actualIntentId: original?.fallbackIntent?.intentId,
2094
+ retryRequestId: original?.fallbackIntent?.retryRequestId,
2095
+ fallbackStatus: original?.fallbackStatus,
2096
+ },
2097
+ };
2098
+ }
2099
+ return { ok: true };
2100
+ }
2101
+ async recordPrimarySummaryRetryQuarantine(originalRequestId, intentId) {
2102
+ await this.updatePrimarySummaryRequestRecord(originalRequestId, (record) => {
2103
+ const intent = record.fallbackIntent;
2104
+ if (!intent || intent.intentId !== intentId || intent.quarantinePersistedAt)
2105
+ return record;
2106
+ return {
2107
+ ...record,
2108
+ fallbackIntent: {
2109
+ ...intent,
2110
+ quarantinePersistedAt: Date.now(),
2111
+ },
2112
+ };
2113
+ });
2114
+ }
2115
+ async recordPrimarySummaryRetryOutput(originalRequestId, intentId, output) {
2116
+ await this.updatePrimarySummaryRequestRecord(originalRequestId, (record) => {
2117
+ const intent = record.fallbackIntent;
2118
+ if (!intent || intent.intentId !== intentId)
2119
+ return record;
2120
+ const existing = intent.outputs?.find((entry) => entry.phase === output.phase);
2121
+ if (existing &&
2122
+ existing.contentHash === output.contentHash &&
2123
+ existing.messageId === output.messageId) {
2124
+ return record;
2125
+ }
2126
+ if (existing) {
2127
+ throw new Error(`Primary summary fallback output phase '${output.phase}' changed after persistence`);
2128
+ }
2129
+ return {
2130
+ ...record,
2131
+ fallbackIntent: {
2132
+ ...intent,
2133
+ outputs: [...(intent.outputs ?? []), output],
2134
+ },
2135
+ };
2136
+ });
2137
+ }
2138
+ async recordPrimarySummaryRetryToolDispatch(originalRequestId, intentId, call) {
2139
+ await this.updatePrimarySummaryRequestRecord(originalRequestId, (record) => {
2140
+ const intent = record.fallbackIntent;
2141
+ if (!intent || intent.intentId !== intentId)
2142
+ return record;
2143
+ const inputHash = this.hashJson(call.input);
2144
+ const existing = intent.toolDispatches?.find((entry) => entry.callId === call.id);
2145
+ if (existing?.inputHash === inputHash)
2146
+ return record;
2147
+ if (existing) {
2148
+ throw new Error(`Primary summary fallback tool dispatch '${call.id}' changed after persistence`);
2149
+ }
2150
+ return {
2151
+ ...record,
2152
+ fallbackIntent: {
2153
+ ...intent,
2154
+ toolDispatches: [
2155
+ ...(intent.toolDispatches ?? []),
2156
+ {
2157
+ callId: call.id,
2158
+ inputHash,
2159
+ recordedAt: Date.now(),
2160
+ },
2161
+ ],
2162
+ },
2163
+ };
2164
+ });
2165
+ }
2166
+ async resolveContextBudgetRestartDispatch(requestId, retryDispatch) {
2167
+ const retryIdentity = retryDispatch
2168
+ ? {
2169
+ requestId,
2170
+ dispatchKind: 'primary_summary_fallback_retry',
2171
+ originalRequestId: retryDispatch.originalRequestId,
2172
+ intentId: retryDispatch.intentId,
2173
+ }
2174
+ : null;
2175
+ const record = await this.getPrimarySummaryRequestRecord(requestId);
2176
+ if (record?.dispatchKind === 'primary_summary_fallback_retry' && retryIdentity) {
2177
+ return retryIdentity;
2178
+ }
2179
+ if (retryIdentity) {
2180
+ const original = await this.getPrimarySummaryRequestRecord(retryIdentity.originalRequestId);
2181
+ if (original?.fallbackIntent?.intentId === retryIdentity.intentId
2182
+ && original.fallbackIntent.retryRequestId === requestId) {
2183
+ return retryIdentity;
2184
+ }
2185
+ }
2186
+ return {
2187
+ requestId,
2188
+ dispatchKind: 'primary',
2189
+ };
2190
+ }
2191
+ contextBudgetRestartMatchesIdentity(request, identity) {
2192
+ if (request.reason !== 'context_budget_restart')
2193
+ return false;
2194
+ const restart = request.contextBudgetRestart;
2195
+ if (!restart)
2196
+ return false;
2197
+ if (restart.dispatchKind !== identity.dispatchKind)
2198
+ return false;
2199
+ if (restart.requestId !== identity.requestId)
2200
+ return false;
2201
+ if (identity.dispatchKind !== 'primary_summary_fallback_retry')
2202
+ return true;
2203
+ return restart.originalRequestId === identity.originalRequestId
2204
+ && restart.intentId === identity.intentId;
2205
+ }
2206
+ clearQueuedContextBudgetRestart(identity) {
2207
+ const before = this.pendingRequests.length;
2208
+ this.pendingRequests = this.pendingRequests.filter((request) => !this.contextBudgetRestartMatchesIdentity(request, identity));
2209
+ return before - this.pendingRequests.length;
2210
+ }
2211
+ async markPrimarySummaryRetrySuccess(requestId, retryDispatch, stopReason, providerInputTokens, visibleAssistantOutput, executedToolCalls) {
2212
+ await this.updatePrimarySummaryRequestRecord(requestId, (record) => ({
2213
+ ...record,
2214
+ stopReason,
2215
+ providerInputTokens,
2216
+ visibleAssistantOutput,
2217
+ executedToolCalls,
2218
+ finalStatus: 'success',
2219
+ fallbackHeldReason: undefined,
2220
+ }));
2221
+ await this.updatePrimarySummaryRequestRecord(retryDispatch.originalRequestId, (record) => {
2222
+ const intent = record.fallbackIntent;
2223
+ return {
2224
+ ...record,
2225
+ finalStatus: 'success',
2226
+ fallbackStatus: 'success',
2227
+ fallbackHeldReason: undefined,
2228
+ fallbackIntent: intent && intent.intentId === retryDispatch.intentId
2229
+ ? { ...intent, finalizedAt: Date.now() }
2230
+ : intent,
2231
+ };
2232
+ });
2233
+ }
2234
+ async holdPrimarySummaryRetryFamily(agent, requestId, retryDispatch, reason, data, options) {
2235
+ const clearedQueuedContextBudgetRestarts = this.clearQueuedContextBudgetRestart({
2236
+ requestId,
2237
+ dispatchKind: 'primary_summary_fallback_retry',
2238
+ originalRequestId: retryDispatch.originalRequestId,
2239
+ intentId: retryDispatch.intentId,
2240
+ });
2241
+ const holdData = clearedQueuedContextBudgetRestarts > 0
2242
+ ? { ...(data ?? {}), clearedQueuedContextBudgetRestarts }
2243
+ : data;
2244
+ await this.updatePrimarySummaryRequestRecord(requestId, (record) => ({
2245
+ ...record,
2246
+ ...(options?.stopReason ? { stopReason: options.stopReason } : {}),
2247
+ finalStatus: 'held',
2248
+ fallbackHeldReason: reason,
2249
+ }));
2250
+ await this.updatePrimarySummaryRequestRecord(retryDispatch.originalRequestId, (record) => ({
2251
+ ...record,
2252
+ finalStatus: 'held',
2253
+ fallbackStatus: 'held',
2254
+ fallbackHeldReason: reason,
2255
+ }));
2256
+ this.opsAlert('refusal-held', agent.name, reason, { data: holdData });
2257
+ if (options?.emitChronicleMarker === false)
2258
+ return;
2259
+ try {
2260
+ agent.getContextManager().addMessage('user', [{
2261
+ type: 'text',
2262
+ text: `[refusal-held] Your previous turn was refused by the provider and ` +
2263
+ `is being held for operator review rather than silently retried. ` +
2264
+ `Automatic raw-expansion recovery was either unsafe or unsuccessful.`,
2265
+ }], { system: true, kind: 'refusal-held', reason });
2266
+ }
2267
+ catch (error) {
2268
+ console.error('[refusal-held] could not record chronicle marker:', error);
2269
+ }
2270
+ }
2271
+ async primarySummaryRetryAlreadySucceeded(requestId, retryDispatch) {
2272
+ const [retryRecord, originalRecord] = await Promise.all([
2273
+ this.getPrimarySummaryRequestRecord(requestId),
2274
+ this.getPrimarySummaryRequestRecord(retryDispatch.originalRequestId),
2275
+ ]);
2276
+ return retryRecord?.finalStatus === 'success'
2277
+ || originalRecord?.fallbackStatus === 'success'
2278
+ || originalRecord?.finalStatus === 'success';
2279
+ }
2280
+ async primarySummaryRetryFamilyAlreadySettled(requestId, retryDispatch) {
2281
+ const [retryRecord, originalRecord] = await Promise.all([
2282
+ this.getPrimarySummaryRequestRecord(requestId),
2283
+ this.getPrimarySummaryRequestRecord(retryDispatch.originalRequestId),
2284
+ ]);
2285
+ return retryRecord?.finalStatus === 'held'
2286
+ || retryRecord?.finalStatus === 'success'
2287
+ || originalRecord?.fallbackStatus === 'held'
2288
+ || originalRecord?.fallbackStatus === 'success'
2289
+ || originalRecord?.finalStatus === 'held'
2290
+ || originalRecord?.finalStatus === 'success';
2291
+ }
2292
+ async holdPrimarySummaryRequest(agent, requestId, reason, data) {
2293
+ await this.updatePrimarySummaryRequestRecord(requestId, (record) => ({
2294
+ ...record,
2295
+ finalStatus: 'held',
2296
+ fallbackStatus: 'held',
2297
+ fallbackHeldReason: reason,
2298
+ }));
2299
+ this.opsAlert('refusal-held', agent.name, reason, { data });
2300
+ try {
2301
+ agent.getContextManager().addMessage('user', [{
2302
+ type: 'text',
2303
+ text: `[refusal-held] Your previous turn was refused by the provider and ` +
2304
+ `is being held for operator review rather than silently retried. ` +
2305
+ `Automatic raw-expansion recovery was either unsafe or unsuccessful.`,
2306
+ }], { system: true, kind: 'refusal-held', reason });
2307
+ }
2308
+ catch (error) {
2309
+ console.error('[refusal-held] could not record chronicle marker:', error);
2310
+ }
2311
+ }
2312
+ hasVisibleAssistantOutput(blocks) {
2313
+ return blocks.some((block) => block.type !== 'thinking' && block.type !== 'redacted_thinking');
2314
+ }
2315
+ buildPrimarySummaryRequestRecord(agent, requestId, dispatchKind, request, artifacts, branch, stopReason, visibleAssistantOutput, executedToolCalls, providerInputTokens, finalStatus) {
2316
+ const projection = artifacts?.compileResult.primarySummaryProjection ?? null;
2317
+ return {
2318
+ requestId,
2319
+ agentName: agent.name,
2320
+ namespace: projection?.namespace ?? `agents/${agent.name}`,
2321
+ timestamp: Date.now(),
2322
+ dispatchKind,
2323
+ branch: branch
2324
+ ? { id: branch.id, name: branch.name, generation: branch.generation }
2325
+ : { id: 'unknown', name: 'unknown', generation: -1 },
2326
+ projection,
2327
+ requestInputBoundTokens: this.estimateRequestInputBoundTokens(request),
2328
+ requestCompleteBoundTokens: this.estimateRequestCompleteBoundTokens(request),
2329
+ providerInputTokens,
2330
+ systemHash: artifacts?.contract.systemHash ?? this.hashJson(request.system ?? ''),
2331
+ modelConfigHash: artifacts?.contract.modelConfigHash ?? this.hashJson({ request: request.config }),
2332
+ toolContractHash: artifacts?.contract.toolContractHash ?? this.hashJson(request.tools ?? []),
2333
+ stopReason,
2334
+ visibleAssistantOutput,
2335
+ executedToolCalls,
2336
+ finalStatus,
2337
+ };
2338
+ }
1058
2339
  /** Liveness ping for an active ephemeral run (no-op otherwise). */
1059
2340
  touchEphemeralRun(agentName, inferenceStarted = false) {
1060
2341
  const run = this.ephemeralRuns.get(agentName);
@@ -1323,10 +2604,41 @@ export class AgentFramework {
1323
2604
  'tagsAll / tagsNone) or in gate.js.',
1324
2605
  inputSchema: { type: 'object' },
1325
2606
  };
2607
+ /** Synthesized save_image tool — present when a workspace module is
2608
+ * registered. Lets the agent persist an image it has already seen in its
2609
+ * own context (Discord attachments arrive inlined as base64 and are
2610
+ * otherwise unreachable as files). */
2611
+ static SAVE_IMAGE_TOOL = {
2612
+ name: 'save_recent_image',
2613
+ description: 'Save one or more recent images from your own context to workspace files. ' +
2614
+ 'Images are counted back from the most recent (index 0). A single image is ' +
2615
+ 'written to `path` as given (e.g. "project/photos/cat.png"); when `count` > 1 ' +
2616
+ 'the range index..index+count-1 is saved with numeric suffixes ' +
2617
+ '("cat-0.png", "cat-1.png", …; 0 = the newest of the range). Saved files are ' +
2618
+ 'visible via workspace tools and the /files/ endpoint.',
2619
+ inputSchema: {
2620
+ type: 'object',
2621
+ properties: {
2622
+ path: {
2623
+ type: 'string',
2624
+ description: 'Mount-prefixed destination path, e.g. "project/photos/name.png".',
2625
+ },
2626
+ index: {
2627
+ type: 'number',
2628
+ description: 'Which image, counting back from the most recent (0 = most recent). Default 0.',
2629
+ },
2630
+ count: {
2631
+ type: 'number',
2632
+ description: 'How many images to save, starting at `index` and going further back. Default 1.',
2633
+ },
2634
+ },
2635
+ required: ['path'],
2636
+ },
2637
+ };
1326
2638
  static AGENT_SETTINGS_TOOL = {
1327
2639
  name: 'agent_settings',
1328
2640
  description: 'Read or change your hot runtime settings. This intentionally exposes only ' +
1329
- 'context budget, recent raw tail size, and transition pace; model, prompts, ' +
2641
+ 'context budget, recent raw tail size, transition pace, and same-round think text routing; model, prompts, ' +
1330
2642
  'folding strategy, and other restart-bound configuration are not mutable here. ' +
1331
2643
  'Lower context budgets converge gradually under the transition pace before ' +
1332
2644
  'becoming the hard live limit; increases take effect immediately.',
@@ -1337,9 +2649,15 @@ export class AgentFramework {
1337
2649
  context_budget_tokens: { type: 'number', description: 'Total input context budget, including the reserved response allowance.' },
1338
2650
  tail_tokens: { type: 'number', description: 'Recent raw context retained verbatim.' },
1339
2651
  transition_pace_tokens: { type: 'number', description: 'Maximum ordinary KV re-read/perturbation per compile while converging.' },
2652
+ same_round_think_text_policy: {
2653
+ type: 'string',
2654
+ enum: ['public', 'private'],
2655
+ description: 'Routing policy for ordinary text emitted in the same native assistant round as think(). ' +
2656
+ "Omitted in the recipe preserves the compatibility carry-forward: public.",
2657
+ },
1340
2658
  settings: {
1341
2659
  type: 'array',
1342
- items: { type: 'string', enum: ['context_budget_tokens', 'tail_tokens', 'transition_pace_tokens'] },
2660
+ items: { type: 'string', enum: ['context_budget_tokens', 'tail_tokens', 'transition_pace_tokens', 'same_round_think_text_policy'] },
1343
2661
  description: 'For reset: settings to restore to recipe values. Omit to reset all.',
1344
2662
  },
1345
2663
  },
@@ -1727,8 +3045,25 @@ export class AgentFramework {
1727
3045
  };
1728
3046
  }
1729
3047
  const target = allMessages[allMessages.length - 1 - n];
1730
- const branchName = cm.branchAt(target.id, `undo-msgs/${agentName}/${Date.now()}`);
3048
+ const discarded = allMessages.slice(allMessages.length - n);
3049
+ const removedRefs = extractDiscordAwarenessRefs(discarded);
3050
+ const sourceBranch = this.store.currentBranch().name;
3051
+ const targetBranch = `undo-msgs/${agentName}/${Date.now()}`;
3052
+ // Prepare the external side effect before switching Chronicle. If the
3053
+ // process dies after the switch but before activate(), startup promotes
3054
+ // this batch by matching targetBranch to the active branch.
3055
+ const markerBatch = this.discordAwarenessOutbox?.prepare({
3056
+ agentName,
3057
+ sourceBranch,
3058
+ targetBranch,
3059
+ refs: removedRefs,
3060
+ emoji: this.discordAwarenessEmoji,
3061
+ }) ?? null;
3062
+ const branchName = cm.branchAt(target.id, targetBranch);
1731
3063
  await cm.switchBranch(branchName);
3064
+ if (markerBatch)
3065
+ this.discordAwarenessOutbox.activate(markerBatch.id);
3066
+ await this.syncDiscordAwarenessMarkers();
1732
3067
  // Materialize config files from the new branch (fire-and-forget; gate
1733
3068
  // picks up via mtime) — mirrors undoLastTurn.
1734
3069
  const wsUndo = this.moduleRegistry.getModule('workspace');
@@ -1742,6 +3077,7 @@ export class AgentFramework {
1742
3077
  return {
1743
3078
  ok: true,
1744
3079
  messagesRemoved: n,
3080
+ removedRefs,
1745
3081
  lastVisible: await this.lastVisiblePreview(agentName),
1746
3082
  };
1747
3083
  }
@@ -1768,6 +3104,7 @@ export class AgentFramework {
1768
3104
  if (undone === 0) {
1769
3105
  return { ok: true, undone: 0, requested, lastVisible: null };
1770
3106
  }
3107
+ await this.syncDiscordAwarenessMarkers();
1771
3108
  return { ok: true, undone, requested, lastVisible: await this.lastVisiblePreview(agentName) };
1772
3109
  }
1773
3110
  /**
@@ -2264,8 +3601,9 @@ export class AgentFramework {
2264
3601
  });
2265
3602
  const agent = new Agent(config, contextManager, this.membrane);
2266
3603
  const restoredSettings = this.readAgentRuntimeSettings(config.name);
2267
- if (restoredSettings)
2268
- agent.restoreRuntimeSettings(restoredSettings);
3604
+ if (restoredSettings) {
3605
+ agent.restoreRuntimeSettings(this.validatePersistedAgentRuntimeSettings(config.name, restoredSettings));
3606
+ }
2269
3607
  this.agents.set(config.name, agent);
2270
3608
  this.agentConfigs.set(config.name, config);
2271
3609
  // First non-ephemeral agent becomes the primary for message routing
@@ -2294,13 +3632,22 @@ export class AgentFramework {
2294
3632
  this.sweepExpiredConversations();
2295
3633
  // Check for inference requests
2296
3634
  await this.processInferenceRequests();
2297
- // Yield to the event loop between iterations.
2298
- // Full 10ms sleep when truly idle; minimal yield when streams are active
2299
- // (needed to let stream microtasks and tool-call callbacks execute).
2300
- if (!event && this.pendingRequests.length === 0) {
3635
+ // Yield to the event loop between iterations. A pending inference request
3636
+ // is not necessarily runnable: while its agent is streaming or waiting for
3637
+ // tools, processInferenceRequests() deliberately requeues it. Treating that
3638
+ // requeued request as "work made progress" creates a microtask-only polling
3639
+ // loop. If activeStreams bookkeeping is absent/stale at the same time, the
3640
+ // old code had no await at all and starved the tool-result and HTTP I/O that
3641
+ // could make the agent runnable again (the Sol outage, 2026-07-15).
3642
+ //
3643
+ // No queue event means no foreground progress, so always take the normal
3644
+ // polling backoff. After an event, retain the low-latency macrotask yield
3645
+ // while background work remains. Both paths give Bun/Node a real event-loop
3646
+ // turn; neither can recurse forever through already-resolved promises.
3647
+ if (!event) {
2301
3648
  await new Promise((resolve) => setTimeout(resolve, 10));
2302
3649
  }
2303
- else if (this.activeStreams.size > 0) {
3650
+ else if (this.pendingRequests.length > 0 || this.activeStreams.size > 0) {
2304
3651
  await new Promise((resolve) => setTimeout(resolve, 0));
2305
3652
  }
2306
3653
  }
@@ -2337,6 +3684,7 @@ export class AgentFramework {
2337
3684
  // Cast to AgentState to bypass TypeScript's control flow narrowing
2338
3685
  const currentState = agent.state;
2339
3686
  if (currentState.status === 'ready') {
3687
+ const activeStreamContext = this.activeStreamContexts.get(agent.name);
2340
3688
  // Flush pending assistant blocks (tool_use + preamble text) to context
2341
3689
  const pendingBlocks = this.pendingAssistantBlocks.get(agent.name);
2342
3690
  if (pendingBlocks) {
@@ -2403,6 +3751,21 @@ export class AgentFramework {
2403
3751
  }
2404
3752
  }
2405
3753
  }
3754
+ // A newly injected message begins a new conversational round inside
3755
+ // the same provider inference. Remember that boundary for
3756
+ // driveStream: an explicit send in the preceding round must not
3757
+ // silence the reply, and the newest channel-bearing injection is
3758
+ // now the reply locus. Map presence matters even without a channel
3759
+ // (CLI/module input): it still clears the prior send suppression.
3760
+ if (midTurnInjections.length > 0) {
3761
+ const injectedChannelId = midTurnInjections.reduce((latest, injection) => {
3762
+ const candidate = injection.metadata?.channelId;
3763
+ return typeof candidate === 'string' && candidate.length > 0
3764
+ ? candidate
3765
+ : latest;
3766
+ }, null);
3767
+ this.midTurnRoutingResets.set(agent.name, injectedChannelId);
3768
+ }
2406
3769
  // Check if any tool result requested endTurn
2407
3770
  const shouldEndTurn = currentState.toolResults.some(tc => tc.result.endTurn);
2408
3771
  // Check if accumulated input tokens exceed the agent's budget
@@ -2432,6 +3795,34 @@ export class AgentFramework {
2432
3795
  // a terminal failure — rejecting an ephemeral's promise mid-run
2433
3796
  // and emitting a spurious inference:exhausted (same race shape as
2434
3797
  // endTurn above).
3798
+ const requestId = activeStreamContext?.requestId;
3799
+ const primarySummaryRetry = activeStreamContext?.trigger?.primarySummaryFallbackRetry;
3800
+ const compiledRequest = activeStreamContext?.compiledRequest;
3801
+ const artifacts = activeStreamContext?.artifacts;
3802
+ const restartDispatch = await this.resolveContextBudgetRestartDispatch(requestId ?? `${agent.name}:context_budget_restart`, primarySummaryRetry);
3803
+ if (restartDispatch.dispatchKind === 'primary_summary_fallback_retry'
3804
+ && requestId
3805
+ && primarySummaryRetry
3806
+ && compiledRequest
3807
+ && artifacts) {
3808
+ const validation = await this.validatePrimarySummaryRetryMutation(agent, requestId, primarySummaryRetry, compiledRequest, artifacts, 'before_context_budget_restart_hold');
3809
+ if (!validation.ok) {
3810
+ await this.holdPrimarySummaryRetryFamily(agent, requestId, primarySummaryRetry, validation.reason, validation.data, { emitChronicleMarker: false, stopReason: 'abort' });
3811
+ }
3812
+ else {
3813
+ await this.holdPrimarySummaryRetryFamily(agent, requestId, primarySummaryRetry, 'primary_summary_fallback_context_budget_restart_required', {
3814
+ inputTokens: agent.lastStreamInputTokens,
3815
+ budget: agent.maxStreamTokens,
3816
+ originalRequestId: primarySummaryRetry.originalRequestId,
3817
+ intentId: primarySummaryRetry.intentId,
3818
+ }, { emitChronicleMarker: false, stopReason: 'abort' });
3819
+ }
3820
+ this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'budget_restart');
3821
+ currentState.stream?.cancel();
3822
+ agent.reset();
3823
+ this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
3824
+ return;
3825
+ }
2435
3826
  if (currentState.stream) {
2436
3827
  this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'budget_restart');
2437
3828
  }
@@ -2448,6 +3839,8 @@ export class AgentFramework {
2448
3839
  reason: 'context_budget_restart',
2449
3840
  source: 'framework',
2450
3841
  timestamp: Date.now(),
3842
+ channelId: activeStreamContext?.trigger?.channelId,
3843
+ contextBudgetRestart: restartDispatch,
2451
3844
  });
2452
3845
  }
2453
3846
  else if (currentState.stream) {
@@ -2945,7 +4338,8 @@ export class AgentFramework {
2945
4338
  // Clear pending (we'll re-add if inference doesn't run)
2946
4339
  this.pendingRequests = [];
2947
4340
  // Check each agent
2948
- for (const [agentName, requests] of requestsByAgent) {
4341
+ for (const [agentName, queuedRequests] of requestsByAgent) {
4342
+ let requests = queuedRequests;
2949
4343
  const agent = this.agents.get(agentName);
2950
4344
  if (!agent) {
2951
4345
  // Agent not found — request is orphaned. Emit warning and drop.
@@ -2964,7 +4358,13 @@ export class AgentFramework {
2964
4358
  if (agent.state.status === 'inferring' || agent.state.status === 'streaming' || agent.state.status === 'waiting_for_tools') {
2965
4359
  // Re-queue requests, but warn if they've been pending too long
2966
4360
  const oldest = Math.min(...requests.map(r => r.timestamp));
2967
- if (now - oldest > STALE_REQUEST_MS) {
4361
+ if (now - oldest > STALE_REQUEST_MS &&
4362
+ (this.staleWarnAt.get(agentName) ?? 0) < now - 60_000) {
4363
+ // Trace and stderr share the throttle. The old code throttled only
4364
+ // stderr, so a long-running tool call generated one trace per poll
4365
+ // (up to 100/sec after the scheduler backoff), adding avoidable work
4366
+ // precisely while the agent was already under pressure.
4367
+ this.staleWarnAt.set(agentName, now);
2968
4368
  this.emitTrace({
2969
4369
  type: 'inference:request_stale',
2970
4370
  agentName,
@@ -2972,16 +4372,21 @@ export class AgentFramework {
2972
4372
  requestCount: requests.length,
2973
4373
  oldestRequestAge: now - oldest,
2974
4374
  });
2975
- // Loud (but throttled) note that requests are waiting on a busy agent.
2976
- if ((this.staleWarnAt.get(agentName) ?? 0) < now - 60_000) {
2977
- this.staleWarnAt.set(agentName, now);
2978
- console.error(`[inference-stale] agent=${agentName} busy (${agent.state.status}) — ` +
2979
- `${requests.length} request(s) waiting ${Math.round((now - oldest) / 1000)}s`);
2980
- }
4375
+ console.error(`[inference-stale] agent=${agentName} busy (${agent.state.status}) — ` +
4376
+ `${requests.length} request(s) waiting ${Math.round((now - oldest) / 1000)}s`);
2981
4377
  }
2982
4378
  this.pendingRequests.push(...requests);
2983
4379
  continue;
2984
4380
  }
4381
+ if (this.discordAwarenessBarrier) {
4382
+ this.pendingRequests.push(...requests);
4383
+ continue;
4384
+ }
4385
+ requests = requests.filter((request) => request.reason !== 'context_budget_restart'
4386
+ || request.contextBudgetRestart?.dispatchKind !== 'primary_summary_fallback_retry');
4387
+ if (requests.length === 0) {
4388
+ continue;
4389
+ }
2985
4390
  // Check policy
2986
4391
  if (!this.inferencePolicy.shouldInfer(agentName, requests, state)) {
2987
4392
  // Loud drop: a queued request that dies here is otherwise invisible —
@@ -2992,7 +4397,13 @@ export class AgentFramework {
2992
4397
  continue;
2993
4398
  }
2994
4399
  // Start streaming inference (non-blocking — driveStream runs in background)
2995
- const trigger = requests[0];
4400
+ const trigger = requests.find((request) => request.primarySummaryFallbackRetry) ?? requests[0];
4401
+ if (trigger.primarySummaryFallbackRetry) {
4402
+ for (const request of requests) {
4403
+ if (request !== trigger)
4404
+ this.pendingRequests.push(request);
4405
+ }
4406
+ }
2996
4407
  // Route this turn's auto-published speech to the channel that triggered
2997
4408
  // it (item-3 redux). A batched wake may carry several triggering channels
2998
4409
  // (messages arrived in >1 channel while the agent was busy/idle) — reply
@@ -3007,7 +4418,7 @@ export class AgentFramework {
3007
4418
  }
3008
4419
  async startAgentStream(agent, trigger, attempt = 0) {
3009
4420
  // Record turn checkpoint before inference (only on first attempt, not retries)
3010
- if (attempt === 0) {
4421
+ if (attempt === 0 && trigger?.reason !== 'primary-summary-fallback-retry') {
3011
4422
  this.recordTurnCheckpoint(agent.name);
3012
4423
  this.redoStacks.delete(agent.name); // new work invalidates redo
3013
4424
  }
@@ -3027,45 +4438,180 @@ export class AgentFramework {
3027
4438
  this.emitTrace({ type: 'inference:started', agentName: agent.name });
3028
4439
  this.eventGate?.onInferenceStarted(agent.name);
3029
4440
  this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
4441
+ const requestId = `${agent.name}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
3030
4442
  try {
3031
- const allTools = this.getAllTools();
4443
+ const requestSnapshot = this.captureInferenceToolSnapshot(agent);
4444
+ const allTools = this.getToolsForAgent(agent.name, requestSnapshot);
3032
4445
  const tools = allTools.filter((t) => agent.canUseTool(t.name));
3033
- // Gather context from modules (pull-based) and MCPL hooks (push-based)
3034
- // Both produce ContextInjection[] that get merged before inference.
3035
- let injections;
3036
- // Module gatherContext (fail-open, per-module timeout — the module's
3037
- // contextTimeoutMs, else the registry default). Injections are
3038
- // channel-scoped via scopeInjectionsForAgent, matching the MCPL hook
3039
- // injections below, so conversation forks don't see cross-channel
3040
- // content.
3041
- try {
3042
- const moduleInjections = this.scopeInjectionsForAgent(agent.name, await this.moduleRegistry.gatherContext(agent.name));
3043
- if (moduleInjections.length > 0) {
3044
- injections = moduleInjections;
4446
+ const fallbackConfig = this.primarySummaryFallbackConfig(agent);
4447
+ const retryDispatch = trigger?.primarySummaryFallbackRetry;
4448
+ let compiledRequest;
4449
+ let artifacts;
4450
+ let branch = agent.getCurrentBranchGeneration();
4451
+ if (retryDispatch) {
4452
+ if (!fallbackConfig.enabled) {
4453
+ await this.holdPrimarySummaryRequest(agent, retryDispatch.originalRequestId, 'primary_summary_fallback_disabled_before_retry');
4454
+ agent.reset();
4455
+ this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
4456
+ this.eventGate?.onInferenceEnded(agent.name);
4457
+ this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
4458
+ return;
4459
+ }
4460
+ const claim = await this.claimPrimarySummaryRetryIntent(retryDispatch.originalRequestId, retryDispatch.intentId, requestId);
4461
+ if (claim !== 'claimed') {
4462
+ await this.holdPrimarySummaryRequest(agent, retryDispatch.originalRequestId, claim === 'duplicate'
4463
+ ? 'primary_summary_fallback_duplicate_retry_blocked'
4464
+ : 'primary_summary_fallback_retry_intent_missing', { intentId: retryDispatch.intentId, retryRequestId: requestId });
4465
+ agent.reset();
4466
+ this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
4467
+ this.eventGate?.onInferenceEnded(agent.name);
4468
+ this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
4469
+ return;
4470
+ }
4471
+ if (!branch ||
4472
+ branch.id !== retryDispatch.branch.id ||
4473
+ branch.generation !== retryDispatch.branch.generation) {
4474
+ await this.holdPrimarySummaryRequest(agent, retryDispatch.originalRequestId, 'primary_summary_fallback_branch_changed_before_retry', {
4475
+ expectedBranchId: retryDispatch.branch.id,
4476
+ expectedGeneration: retryDispatch.branch.generation,
4477
+ actualBranchId: branch?.id,
4478
+ actualGeneration: branch?.generation,
4479
+ });
4480
+ agent.reset();
4481
+ this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
4482
+ this.eventGate?.onInferenceEnded(agent.name);
4483
+ this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
4484
+ return;
4485
+ }
4486
+ const rebuilt = agent.buildPrimarySummaryRawExpansionRequest(retryDispatch.originalArtifacts, retryDispatch.candidateSummaries, tools);
4487
+ compiledRequest = rebuilt.request;
4488
+ artifacts = rebuilt.artifacts;
4489
+ const rebuiltHash = this.hashJson(compiledRequest);
4490
+ if (rebuiltHash !== retryDispatch.expectedRetryRequestHash ||
4491
+ this.primarySummaryProjectionKeys(artifacts.compileResult.primarySummaryProjection ?? null).join('\u0000') !==
4492
+ retryDispatch.originalProjectionKeys.join('\u0000')) {
4493
+ await this.holdPrimarySummaryRequest(agent, retryDispatch.originalRequestId, 'primary_summary_fallback_revalidation_failed_before_retry', {
4494
+ expectedRetryRequestHash: retryDispatch.expectedRetryRequestHash,
4495
+ actualRetryRequestHash: rebuiltHash,
4496
+ });
4497
+ agent.reset();
4498
+ this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
4499
+ this.eventGate?.onInferenceEnded(agent.name);
4500
+ this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
4501
+ return;
4502
+ }
4503
+ const retryRecord = this.buildPrimarySummaryRequestRecord(agent, requestId, 'primary_summary_fallback_retry', compiledRequest, artifacts, branch, undefined, false, 0, undefined, 'pending');
4504
+ await this.persistPrimarySummaryRequestRecord(retryRecord);
4505
+ const admittedTokens = this.primarySummaryAdmissionBoundTokens(retryDispatch.originalRequestInputBoundTokens, compiledRequest, retryDispatch.originalProviderInputTokens);
4506
+ if (admittedTokens > retryDispatch.requestBudgetTokens) {
4507
+ await this.holdPrimarySummaryRequest(agent, requestId, 'primary_summary_fallback_retry_over_budget', {
4508
+ admittedTokens,
4509
+ requestBudgetTokens: retryDispatch.requestBudgetTokens,
4510
+ retryRequestHash: rebuiltHash,
4511
+ });
4512
+ await this.updatePrimarySummaryRequestRecord(retryDispatch.originalRequestId, (record) => ({
4513
+ ...record,
4514
+ fallbackStatus: 'held',
4515
+ fallbackHeldReason: 'primary_summary_fallback_retry_over_budget',
4516
+ }));
4517
+ agent.reset();
4518
+ this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
4519
+ this.eventGate?.onInferenceEnded(agent.name);
4520
+ this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
4521
+ return;
3045
4522
  }
3046
4523
  }
3047
- catch (error) {
3048
- console.error('Module gatherContext error:', error);
3049
- }
3050
- // MCPL beforeInference hooks (fail-open)
3051
- if (this.hookOrchestrator) {
4524
+ else {
4525
+ // Gather context from modules (pull-based) and MCPL hooks (push-based)
4526
+ // Both produce ContextInjection[] that get merged before inference.
4527
+ let injections;
4528
+ // Module gatherContext (fail-open, per-module timeout — the module's
4529
+ // contextTimeoutMs, else the registry default). Injections are
4530
+ // channel-scoped via scopeInjectionsForAgent, matching the MCPL hook
4531
+ // injections below, so conversation forks don't see cross-channel
4532
+ // content.
3052
4533
  try {
3053
- const hookParams = this.buildBeforeInferenceParams(agent, trigger);
3054
- const hookInjections = this.scopeInjectionsForAgent(agent.name, await this.hookOrchestrator.beforeInference(hookParams));
3055
- if (hookInjections.length > 0) {
3056
- injections = injections ? [...injections, ...hookInjections] : hookInjections;
4534
+ const moduleInjections = this.scopeInjectionsForAgent(agent.name, await this.moduleRegistry.gatherContext(agent.name));
4535
+ if (moduleInjections.length > 0) {
4536
+ injections = moduleInjections;
3057
4537
  }
3058
4538
  }
3059
4539
  catch (error) {
3060
- console.error('beforeInference hook error:', error);
4540
+ console.error('Module gatherContext error:', error);
4541
+ }
4542
+ // MCPL beforeInference hooks (fail-open)
4543
+ if (this.hookOrchestrator) {
4544
+ try {
4545
+ const hookParams = this.buildBeforeInferenceParams(agent, trigger);
4546
+ const hookInjections = this.scopeInjectionsForAgent(agent.name, await this.hookOrchestrator.beforeInference(hookParams));
4547
+ if (hookInjections.length > 0) {
4548
+ injections = injections ? [...injections, ...hookInjections] : hookInjections;
4549
+ }
4550
+ }
4551
+ catch (error) {
4552
+ console.error('beforeInference hook error:', error);
4553
+ }
4554
+ }
4555
+ const prepared = await agent.prepareActivationRequest(tools, injections);
4556
+ compiledRequest = prepared.request;
4557
+ artifacts = prepared.artifacts;
4558
+ branch = agent.getCurrentBranchGeneration();
4559
+ if (fallbackConfig.enabled) {
4560
+ const record = this.buildPrimarySummaryRequestRecord(agent, requestId, 'primary', compiledRequest, artifacts, branch, undefined, false, 0, undefined, 'pending');
4561
+ await this.persistPrimarySummaryRequestRecord(record);
4562
+ const expandedByQuarantine = artifacts.compileResult.primarySummaryProjection?.selectedSummaries.some((selection) => selection.renderedAs === 'raw_expansion') ?? false;
4563
+ if (expandedByQuarantine) {
4564
+ const admittedTokens = this.estimateRequestCompleteBoundTokens(compiledRequest);
4565
+ if (admittedTokens > fallbackConfig.requestBudgetTokens) {
4566
+ await this.holdPrimarySummaryRequest(agent, requestId, 'primary_summary_quarantine_over_budget', {
4567
+ admittedTokens,
4568
+ requestBudgetTokens: fallbackConfig.requestBudgetTokens,
4569
+ });
4570
+ agent.reset();
4571
+ this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
4572
+ this.eventGate?.onInferenceEnded(agent.name);
4573
+ this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
4574
+ return;
4575
+ }
4576
+ }
3061
4577
  }
3062
4578
  }
3063
- const { stream, request: compiledRequest } = await agent.startStreamWithInjections(tools, injections);
3064
- const handle = this.driveStream(agent, stream, trigger, attempt, compiledRequest);
4579
+ const { stream } = agent.startStreamFromRequest(compiledRequest, artifacts);
4580
+ this.activeStreamContexts.set(agent.name, {
4581
+ requestId,
4582
+ trigger,
4583
+ compiledRequest,
4584
+ artifacts,
4585
+ });
4586
+ const handle = this.driveStream(agent, stream, requestSnapshot, requestId, trigger, attempt, compiledRequest, artifacts);
3065
4587
  this.activeStreams.set(agent.name, handle);
3066
4588
  }
3067
4589
  catch (error) {
3068
4590
  const err = error instanceof Error ? error : new Error(String(error));
4591
+ if (trigger?.primarySummaryFallbackRetry) {
4592
+ await this.updatePrimarySummaryRequestRecord(requestId, (record) => ({
4593
+ ...record,
4594
+ finalStatus: 'held',
4595
+ fallbackHeldReason: err.message,
4596
+ }));
4597
+ await this.updatePrimarySummaryRequestRecord(trigger.primarySummaryFallbackRetry.originalRequestId, (record) => ({
4598
+ ...record,
4599
+ finalStatus: 'held',
4600
+ fallbackStatus: 'held',
4601
+ fallbackHeldReason: err.message,
4602
+ }));
4603
+ this.opsAlert('refusal-held', agent.name, err.message, {
4604
+ data: { requestId, originalRequestId: trigger.primarySummaryFallbackRetry.originalRequestId },
4605
+ });
4606
+ agent.reset();
4607
+ this.settleAgent(agent.name, {
4608
+ stopReason: 'completed',
4609
+ speech: '',
4610
+ });
4611
+ this.eventGate?.onInferenceEnded(agent.name);
4612
+ this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
4613
+ return;
4614
+ }
3069
4615
  this.emitTrace({
3070
4616
  type: 'inference:failed',
3071
4617
  agentName: agent.name,
@@ -3104,23 +4650,27 @@ export class AgentFramework {
3104
4650
  }
3105
4651
  }
3106
4652
  }
3107
- async driveStream(agent, stream, trigger, attempt = 0, compiledRequest) {
4653
+ async driveStream(agent, stream, requestSnapshot, requestId, trigger, attempt = 0, compiledRequest, artifacts) {
3108
4654
  const startTime = Date.now();
3109
- const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
3110
4655
  const myStreamId = agent.streamId;
3111
4656
  let hadToolCalls = false;
4657
+ let executedToolCalls = 0;
4658
+ const primarySummaryConfig = this.primarySummaryFallbackConfig(agent);
4659
+ const primarySummaryRetry = trigger?.primarySummaryFallbackRetry;
4660
+ let primarySummaryQuarantinePersisted = false;
4661
+ const fallbackRetryRequestForLog = primarySummaryRetry && compiledRequest
4662
+ ? this.summarizePrimarySummaryRetryRequest(compiledRequest)
4663
+ : compiledRequest ?? { note: 'streaming request' };
3112
4664
  // ---- Present-while-acting turn state ---------------------------------
3113
- // Output locus for THIS turn: resolved lazily at the first routed prose
3114
- // segment and pinned for the rest of the turn (mid-turn rounds AND the
3115
- // trailing prose at 'complete'), so one turn's voice stays in one channel
3116
- // even when a queued inbound from another channel overwrites the
3117
- // per-agent triggering channel between segments (PR #32). The pin lives
3118
- // in `turnLocusPins` so it survives a context-budget stream restart. A
3119
- // null resolution is NOT pinned — a turn whose locus is unresolvable
3120
- // early (e.g. heartbeat-triggered) may still resolve a channel by the
3121
- // time later prose routes.
3122
- if (trigger?.reason !== 'context_budget_restart') {
4665
+ // Output locus for the current CONVERSATIONAL ROUND: resolved lazily and
4666
+ // kept stable across tool-only rounds. A channel message injected at a
4667
+ // tool boundary replaces the pin before the next model round, because a
4668
+ // single provider inference can now contain several conversations. The
4669
+ // pin lives in `turnLocusPins` so it survives a context-budget restart. A
4670
+ // null resolution is not pinned.
4671
+ if (trigger?.reason !== 'context_budget_restart' && trigger?.reason !== 'primary-summary-fallback-retry') {
3123
4672
  this.turnLocusPins.delete(agent.name);
4673
+ this.midTurnRoutingResets.delete(agent.name);
3124
4674
  }
3125
4675
  const resolveTurnLocus = () => {
3126
4676
  let locus = this.turnLocusPins.get(agent.name) ?? null;
@@ -3146,10 +4696,9 @@ export class AgentFramework {
3146
4696
  })
3147
4697
  .catch((err) => console.error('mid-turn speech routing failed:', err));
3148
4698
  };
3149
- // Sticky silencing: once any round uses a SILENCING_TOOLS member,
3150
- // prose from that round ONWARD is suppressed — skip_reply means "stay
3151
- // silent", an explicit send means "I deliver my own words". Earlier
3152
- // rounds' prose was already delivered live and cannot be retracted.
4699
+ // Sticky explicit-send suppression: prose after send_message stays quiet
4700
+ // to prevent a redundant "sent it" postscript. Fresh injected input
4701
+ // clears it, because the following prose is a reply to a new message.
3153
4702
  let turnSilenced = false;
3154
4703
  // Live routing is only trusted when the membrane provides verbatim
3155
4704
  // round-scoped blocks (roundContent, native tool mode, membrane ≥0.5.64).
@@ -3164,11 +4713,84 @@ export class AgentFramework {
3164
4713
  // responding to, for the whole duration of this turn. Started here (paired
3165
4714
  // with the finally below, so it can never leak) and refreshed on a 7s
3166
4715
  // interval by the ChannelRegistry until stopped on any exit path.
3167
- const typingChannel = this.channelRegistry
4716
+ let typingChannel = this.channelRegistry
3168
4717
  ? trigger?.channelId ?? this.channelRegistry.getDefaultPublishChannel()
3169
4718
  : null;
3170
4719
  if (typingChannel)
3171
4720
  this.channelRegistry.startTyping(typingChannel);
4721
+ const adoptInjectedRound = () => {
4722
+ if (!this.midTurnRoutingResets.has(agent.name))
4723
+ return;
4724
+ const nextLocus = this.midTurnRoutingResets.get(agent.name) ?? null;
4725
+ this.midTurnRoutingResets.delete(agent.name);
4726
+ // A new human/agent message, not merely a tool result, means any earlier
4727
+ // explicit delivery has completed its conversational job.
4728
+ turnSilenced = false;
4729
+ if (nextLocus) {
4730
+ this.turnLocusPins.set(agent.name, nextLocus);
4731
+ if (typingChannel !== nextLocus) {
4732
+ if (typingChannel)
4733
+ this.channelRegistry?.stopTyping(typingChannel);
4734
+ typingChannel = nextLocus;
4735
+ this.channelRegistry?.startTyping(nextLocus);
4736
+ }
4737
+ }
4738
+ };
4739
+ const persistPrimarySummaryRetryQuarantine = async () => {
4740
+ if (!primarySummaryRetry || primarySummaryQuarantinePersisted || !artifacts)
4741
+ return true;
4742
+ const validation = await this.validatePrimarySummaryRetryMutation(agent, requestId, primarySummaryRetry, compiledRequest, artifacts, 'before_quarantine');
4743
+ if (!validation.ok) {
4744
+ await this.holdPrimarySummaryRetryFamily(agent, requestId, primarySummaryRetry, validation.reason, validation.data, { emitChronicleMarker: false });
4745
+ stream.cancel();
4746
+ agent.reset();
4747
+ this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
4748
+ return false;
4749
+ }
4750
+ try {
4751
+ const quarantinePrimarySummaryForPrimaryLane = agent.getContextManager().quarantinePrimarySummaryForPrimaryLane;
4752
+ if (typeof quarantinePrimarySummaryForPrimaryLane !== 'function') {
4753
+ throw new Error('Primary summary quarantine is unavailable in the active context strategy');
4754
+ }
4755
+ await quarantinePrimarySummaryForPrimaryLane.call(agent.getContextManager(), artifacts.contract, primarySummaryRetry.candidateSummaries);
4756
+ primarySummaryQuarantinePersisted = true;
4757
+ await this.recordPrimarySummaryRetryQuarantine(primarySummaryRetry.originalRequestId, primarySummaryRetry.intentId);
4758
+ return true;
4759
+ }
4760
+ catch (error) {
4761
+ const reason = 'primary_summary_fallback_quarantine_failed';
4762
+ await this.holdPrimarySummaryRetryFamily(agent, requestId, primarySummaryRetry, reason, {
4763
+ error: error instanceof Error ? error.message : String(error),
4764
+ });
4765
+ stream.cancel();
4766
+ agent.reset();
4767
+ this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
4768
+ return false;
4769
+ }
4770
+ };
4771
+ const persistPrimarySummaryRetryOutput = async (phase, blocks, participant = agent.name, metadata) => {
4772
+ if (!primarySummaryRetry || !artifacts)
4773
+ return true;
4774
+ if (blocks.length === 0)
4775
+ return true;
4776
+ const validation = await this.validatePrimarySummaryRetryMutation(agent, requestId, primarySummaryRetry, compiledRequest, artifacts, `before_${phase}`);
4777
+ if (!validation.ok) {
4778
+ await this.holdPrimarySummaryRetryFamily(agent, requestId, primarySummaryRetry, validation.reason, validation.data, { emitChronicleMarker: false });
4779
+ stream.cancel();
4780
+ agent.reset();
4781
+ this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
4782
+ return false;
4783
+ }
4784
+ const messageId = agent.getContextManager().addMessage(participant, blocks, metadata);
4785
+ await this.recordPrimarySummaryRetryOutput(primarySummaryRetry.originalRequestId, primarySummaryRetry.intentId, {
4786
+ phase,
4787
+ contentHash: this.primarySummaryFallbackOutputPhaseHash(blocks),
4788
+ messageId,
4789
+ blockTypes: this.primarySummaryFallbackBlockTypes(blocks),
4790
+ persistedAt: Date.now(),
4791
+ });
4792
+ return true;
4793
+ };
3172
4794
  try {
3173
4795
  for await (const event of stream) {
3174
4796
  this.touchEphemeralRun(agent.name, true);
@@ -3194,8 +4816,8 @@ export class AgentFramework {
3194
4816
  break;
3195
4817
  }
3196
4818
  case 'tool-calls': {
4819
+ adoptInjectedRound();
3197
4820
  hadToolCalls = true;
3198
- this.recordEphemeralToolCalls(agent.name, event.calls.length);
3199
4821
  this.emitTrace({
3200
4822
  type: 'inference:tool_calls_yielded',
3201
4823
  agentName: agent.name,
@@ -3229,7 +4851,30 @@ export class AgentFramework {
3229
4851
  // Truncated responses yield 'complete' with stop_reason 'max_tokens' directly.
3230
4852
  // So all calls here have valid input — {} is legitimate for parameterless tools.
3231
4853
  agent.enterWaitingForTools(event.calls, stream);
4854
+ if (!(await persistPrimarySummaryRetryQuarantine())) {
4855
+ return;
4856
+ }
4857
+ if (primarySummaryRetry) {
4858
+ const phase = `tool_round_${event.context.depth ?? 0}`;
4859
+ if (!(await persistPrimarySummaryRetryOutput(phase, assistantBlocks))) {
4860
+ return;
4861
+ }
4862
+ this.pendingAssistantBlocks.delete(agent.name);
4863
+ }
4864
+ executedToolCalls += event.calls.length;
4865
+ this.recordEphemeralToolCalls(agent.name, event.calls.length);
3232
4866
  for (const call of event.calls) {
4867
+ if (primarySummaryRetry) {
4868
+ const validation = await this.validatePrimarySummaryRetryMutation(agent, requestId, primarySummaryRetry, compiledRequest, artifacts, `before_tool_dispatch_${call.id}`);
4869
+ if (!validation.ok) {
4870
+ await this.holdPrimarySummaryRetryFamily(agent, requestId, primarySummaryRetry, validation.reason, validation.data, { emitChronicleMarker: false });
4871
+ stream.cancel();
4872
+ agent.reset();
4873
+ this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
4874
+ return;
4875
+ }
4876
+ await this.recordPrimarySummaryRetryToolDispatch(primarySummaryRetry.originalRequestId, primarySummaryRetry.intentId, call);
4877
+ }
3233
4878
  this.dispatchToolCall(agent.name, call);
3234
4879
  }
3235
4880
  // Speak-while-acting: route THIS round's prose to the locus NOW
@@ -3245,6 +4890,8 @@ export class AgentFramework {
3245
4890
  // note above — the fallback preamble is cumulative in XML mode).
3246
4891
  if (this.channelRegistry) {
3247
4892
  const roundToolNames = event.calls.map((c) => c.name);
4893
+ const hasSameRoundPrivateThink = roundToolNames.includes('think') &&
4894
+ requestSnapshot.sameRoundThinkTextPolicy === 'private';
3248
4895
  if (roundToolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)))) {
3249
4896
  turnSilenced = true;
3250
4897
  }
@@ -3255,6 +4902,9 @@ export class AgentFramework {
3255
4902
  if (turnSilenced) {
3256
4903
  console.error(`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (turn silenced)`);
3257
4904
  }
4905
+ else if (hasSameRoundPrivateThink) {
4906
+ console.error(`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (same_round_think_text_policy=private)`);
4907
+ }
3258
4908
  else {
3259
4909
  const locus = resolveTurnLocus();
3260
4910
  console.error(`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> routing ${roundSegments.length} prose segment(s) live -> ${locus ?? '(default)'}`);
@@ -3269,6 +4919,7 @@ export class AgentFramework {
3269
4919
  break;
3270
4920
  }
3271
4921
  case 'complete': {
4922
+ adoptInjectedRound();
3272
4923
  const durationMs = Date.now() - startTime;
3273
4924
  const response = event.response;
3274
4925
  // If the agent is still waiting_for_tools when 'complete' fires
@@ -3305,6 +4956,167 @@ export class AgentFramework {
3305
4956
  agent.getContextManager().addMessage('user', toolResultContent);
3306
4957
  }
3307
4958
  }
4959
+ const lastToolIdx = response.content.reduce((last, b, i) => b.type === 'tool_use' || b.type === 'tool_result' ? i : last, -1);
4960
+ const terminalContent = lastToolIdx >= 0
4961
+ ? response.content.slice(lastToolIdx + 1)
4962
+ : response.content;
4963
+ const heldAssistantContent = response.content;
4964
+ const visibleAssistantOutput = this.hasVisibleAssistantOutput(response.content);
4965
+ const providerInputTokens = response.usage?.inputTokens ??
4966
+ response.details?.usage?.inputTokens;
4967
+ if (primarySummaryConfig.enabled && compiledRequest && artifacts && response.stopReason === 'refusal') {
4968
+ const stopDetails = response.raw?.response?.stop_details;
4969
+ const category = stopDetails?.category ?? 'unknown';
4970
+ console.error(`[inference-refusal] agent=${agent.name} category=${category}` +
4971
+ (stopDetails?.explanation ? ` explanation=${stopDetails.explanation}` : ''));
4972
+ this.noteRefusal(agent.name, category, response.details?.usage
4973
+ ? {
4974
+ input: response.details.usage.inputTokens,
4975
+ output: response.details.usage.outputTokens,
4976
+ }
4977
+ : undefined);
4978
+ const currentRecord = await this.updatePrimarySummaryRequestRecord(requestId, (record) => ({
4979
+ ...record,
4980
+ stopReason: response.stopReason,
4981
+ providerInputTokens,
4982
+ visibleAssistantOutput,
4983
+ executedToolCalls,
4984
+ finalStatus: 'refusal',
4985
+ }));
4986
+ const holdReasonData = {
4987
+ category,
4988
+ visibleAssistantOutput,
4989
+ executedToolCalls,
4990
+ candidateLimit: primarySummaryConfig.maxNewSummaries,
4991
+ };
4992
+ if (primarySummaryRetry) {
4993
+ const reason = visibleAssistantOutput
4994
+ ? 'primary_summary_fallback_retry_partial_output'
4995
+ : executedToolCalls > 0
4996
+ ? 'primary_summary_fallback_retry_tool_already_executed'
4997
+ : 'primary_summary_fallback_retry_refusal';
4998
+ if (visibleAssistantOutput || executedToolCalls > 0) {
4999
+ await this.persistPrimarySummaryHeldOutput(agent, requestId, reason, {
5000
+ stopReason: response.stopReason,
5001
+ providerInputTokens,
5002
+ visibleAssistantOutput,
5003
+ executedToolCalls,
5004
+ assistantBlocks: heldAssistantContent,
5005
+ retryDispatch: primarySummaryRetry,
5006
+ retryPhase: 'refusal_visible_output',
5007
+ holdData: holdReasonData,
5008
+ });
5009
+ }
5010
+ else {
5011
+ await this.holdPrimarySummaryRetryFamily(agent, requestId, primarySummaryRetry, reason, holdReasonData);
5012
+ }
5013
+ }
5014
+ else if (visibleAssistantOutput || executedToolCalls > 0 || !currentRecord?.projection) {
5015
+ const reason = visibleAssistantOutput
5016
+ ? 'primary_summary_refusal_partial_output'
5017
+ : executedToolCalls > 0
5018
+ ? 'primary_summary_refusal_tool_already_executed'
5019
+ : 'primary_summary_refusal_no_projection';
5020
+ if (visibleAssistantOutput || executedToolCalls > 0) {
5021
+ await this.persistPrimarySummaryHeldOutput(agent, requestId, reason, {
5022
+ stopReason: response.stopReason,
5023
+ providerInputTokens,
5024
+ visibleAssistantOutput,
5025
+ executedToolCalls,
5026
+ assistantBlocks: heldAssistantContent,
5027
+ holdData: holdReasonData,
5028
+ });
5029
+ }
5030
+ else {
5031
+ await this.holdPrimarySummaryRequest(agent, requestId, reason, holdReasonData);
5032
+ }
5033
+ }
5034
+ else {
5035
+ const state = this.readPrimarySummaryFallbackState();
5036
+ const baseline = this.latestCompatibleHealthyPrimaryRequest(currentRecord, state);
5037
+ if (!baseline?.projection) {
5038
+ await this.holdPrimarySummaryRequest(agent, requestId, 'primary_summary_refusal_no_compatible_healthy_baseline', holdReasonData);
5039
+ }
5040
+ else {
5041
+ const baselineKeys = new Set(this.primarySummaryProjectionKeys(baseline.projection));
5042
+ const candidates = currentRecord.projection.selectedSummaries
5043
+ .filter((selection) => !baselineKeys.has(this.primarySummaryIdentityKey(selection.identity)))
5044
+ .map((selection) => selection.identity);
5045
+ if (candidates.length === 0) {
5046
+ await this.holdPrimarySummaryRequest(agent, requestId, 'primary_summary_refusal_no_newly_admitted_candidates', holdReasonData);
5047
+ }
5048
+ else if (candidates.length > primarySummaryConfig.maxNewSummaries) {
5049
+ await this.holdPrimarySummaryRequest(agent, requestId, 'primary_summary_refusal_too_many_candidates', { ...holdReasonData, candidateCount: candidates.length });
5050
+ }
5051
+ else {
5052
+ try {
5053
+ const retry = agent.buildPrimarySummaryRawExpansionRequest(artifacts, candidates, compiledRequest.tools ?? []);
5054
+ const retryRequestHash = this.hashJson(retry.request);
5055
+ const admittedTokens = this.primarySummaryAdmissionBoundTokens(currentRecord.requestInputBoundTokens, retry.request, providerInputTokens);
5056
+ if (admittedTokens > primarySummaryConfig.requestBudgetTokens) {
5057
+ await this.holdPrimarySummaryRequest(agent, requestId, 'primary_summary_refusal_retry_over_budget', {
5058
+ ...holdReasonData,
5059
+ admittedTokens,
5060
+ requestBudgetTokens: primarySummaryConfig.requestBudgetTokens,
5061
+ });
5062
+ }
5063
+ else {
5064
+ const intentId = `${requestId}:fallback:${Math.random().toString(36).slice(2, 8)}`;
5065
+ await this.updatePrimarySummaryRequestRecord(requestId, (record) => ({
5066
+ ...record,
5067
+ fallbackIntent: {
5068
+ intentId,
5069
+ createdAt: Date.now(),
5070
+ baselineRequestId: baseline.requestId,
5071
+ candidateSummaries: candidates,
5072
+ retryRequestHash,
5073
+ retryCompleteBoundTokens: admittedTokens,
5074
+ requestBudgetTokens: primarySummaryConfig.requestBudgetTokens,
5075
+ },
5076
+ fallbackStatus: 'pending',
5077
+ }));
5078
+ this.pendingRequests.push({
5079
+ agentName: agent.name,
5080
+ reason: 'primary-summary-fallback-retry',
5081
+ source: 'framework',
5082
+ timestamp: Date.now(),
5083
+ channelId: trigger?.channelId,
5084
+ primarySummaryFallbackRetry: {
5085
+ originalRequestId: requestId,
5086
+ baselineRequestId: baseline.requestId,
5087
+ intentId,
5088
+ branch: {
5089
+ id: currentRecord.branch.id,
5090
+ generation: currentRecord.branch.generation,
5091
+ },
5092
+ candidateSummaries: candidates,
5093
+ originalArtifacts: artifacts,
5094
+ originalProjectionKeys: this.primarySummaryProjectionKeys(currentRecord.projection),
5095
+ expectedRetryRequestHash: retryRequestHash,
5096
+ requestBudgetTokens: primarySummaryConfig.requestBudgetTokens,
5097
+ originalRequestInputBoundTokens: currentRecord.requestInputBoundTokens,
5098
+ originalProviderInputTokens: providerInputTokens,
5099
+ },
5100
+ });
5101
+ }
5102
+ }
5103
+ catch (error) {
5104
+ await this.holdPrimarySummaryRequest(agent, requestId, 'primary_summary_refusal_retry_build_failed', {
5105
+ ...holdReasonData,
5106
+ error: error instanceof Error ? error.message : String(error),
5107
+ });
5108
+ }
5109
+ }
5110
+ }
5111
+ }
5112
+ agent.reset();
5113
+ this.eventGate?.onInferenceEnded(agent.name);
5114
+ this.settleAgent(agent.name, { stopReason: 'completed', speech: '' });
5115
+ continue;
5116
+ }
5117
+ if (!(await persistPrimarySummaryRetryQuarantine())) {
5118
+ return;
5119
+ }
3308
5120
  // Add assistant response to context.
3309
5121
  // If we had tool calls, each round's blocks (thinking + text +
3310
5122
  // tool_use) were already stored as pendingAssistantBlocks and
@@ -3313,17 +5125,55 @@ export class AgentFramework {
3313
5125
  // double-store earlier rounds' text/thinking AND detach signed
3314
5126
  // thinking blocks from their tool_use turn (the API requires
3315
5127
  // thinking to precede tool_use in the same assistant turn).
3316
- const lastToolIdx = response.content.reduce((last, b, i) => b.type === 'tool_use' || b.type === 'tool_result' ? i : last, -1);
3317
- const terminalContent = lastToolIdx >= 0
3318
- ? response.content.slice(lastToolIdx + 1)
3319
- : response.content;
3320
5128
  if (lastToolIdx >= 0) {
3321
5129
  if (terminalContent.length > 0) {
3322
- agent.addAssistantResponse(terminalContent);
5130
+ if (primarySummaryRetry) {
5131
+ const retryRecord = await this.getPrimarySummaryRequestRecord(requestId);
5132
+ const terminalMetadata = retryRecord
5133
+ ? this.buildPrimarySummarySettlementMetadata(retryRecord, {
5134
+ outcome: 'success',
5135
+ stopReason: response.stopReason,
5136
+ providerInputTokens,
5137
+ visibleAssistantOutput,
5138
+ executedToolCalls,
5139
+ entryIndex: 0,
5140
+ entryCount: 1,
5141
+ role: 'assistant',
5142
+ kind: 'assistant_output',
5143
+ })
5144
+ : undefined;
5145
+ if (!(await persistPrimarySummaryRetryOutput('terminal', terminalContent, agent.name, terminalMetadata))) {
5146
+ return;
5147
+ }
5148
+ }
5149
+ else {
5150
+ agent.addAssistantResponse(terminalContent);
5151
+ }
3323
5152
  }
3324
5153
  }
3325
5154
  else {
3326
- agent.addAssistantResponse(terminalContent);
5155
+ if (primarySummaryRetry) {
5156
+ const retryRecord = await this.getPrimarySummaryRequestRecord(requestId);
5157
+ const terminalMetadata = retryRecord && terminalContent.length > 0
5158
+ ? this.buildPrimarySummarySettlementMetadata(retryRecord, {
5159
+ outcome: 'success',
5160
+ stopReason: response.stopReason,
5161
+ providerInputTokens,
5162
+ visibleAssistantOutput,
5163
+ executedToolCalls,
5164
+ entryIndex: 0,
5165
+ entryCount: 1,
5166
+ role: 'assistant',
5167
+ kind: 'assistant_output',
5168
+ })
5169
+ : undefined;
5170
+ if (!(await persistPrimarySummaryRetryOutput('terminal', terminalContent, agent.name, terminalMetadata))) {
5171
+ return;
5172
+ }
5173
+ }
5174
+ else {
5175
+ agent.addAssistantResponse(terminalContent);
5176
+ }
3327
5177
  }
3328
5178
  // Run afterInference hooks (no-op if no MCPL servers)
3329
5179
  if (this.hookOrchestrator) {
@@ -3378,6 +5228,28 @@ export class AgentFramework {
3378
5228
  cacheRead: du.cacheReadTokens,
3379
5229
  }
3380
5230
  : undefined;
5231
+ if (primarySummaryRetry && artifacts) {
5232
+ const validation = await this.validatePrimarySummaryRetryMutation(agent, requestId, primarySummaryRetry, compiledRequest, artifacts, 'before_success_markers');
5233
+ if (!validation.ok) {
5234
+ await this.holdPrimarySummaryRetryFamily(agent, requestId, primarySummaryRetry, validation.reason, validation.data, { emitChronicleMarker: false });
5235
+ return;
5236
+ }
5237
+ }
5238
+ if (primarySummaryConfig.enabled) {
5239
+ if (primarySummaryRetry) {
5240
+ await this.markPrimarySummaryRetrySuccess(requestId, primarySummaryRetry, response.stopReason, providerInputTokens, visibleAssistantOutput, executedToolCalls);
5241
+ }
5242
+ else {
5243
+ await this.updatePrimarySummaryRequestRecord(requestId, (record) => ({
5244
+ ...record,
5245
+ stopReason: response.stopReason,
5246
+ providerInputTokens,
5247
+ visibleAssistantOutput,
5248
+ executedToolCalls,
5249
+ finalStatus: 'success',
5250
+ }));
5251
+ }
5252
+ }
3381
5253
  // Reset agent state before emitting inference:completed. Traces are
3382
5254
  // observability-only, but external synchronous listeners should
3383
5255
  // still see the terminal state at the terminal trace boundary.
@@ -3413,8 +5285,10 @@ export class AgentFramework {
3413
5285
  agentName: agent.name,
3414
5286
  requestId,
3415
5287
  success: true,
3416
- request: compiledRequest ?? { note: 'streaming request' },
3417
- response: response.raw ?? { note: 'streaming response' },
5288
+ request: fallbackRetryRequestForLog,
5289
+ response: primarySummaryRetry
5290
+ ? this.summarizePrimarySummaryRetryResponse(response)
5291
+ : response.raw ?? { note: 'streaming response' },
3418
5292
  durationMs,
3419
5293
  tokenUsage,
3420
5294
  stopReason: response.stopReason,
@@ -3546,15 +5420,17 @@ export class AgentFramework {
3546
5420
  // speech/thoughts split is left untouched (module/TUI rendering
3547
5421
  // unaffected) — this only governs what reaches the channel.
3548
5422
  //
3549
- // Silencing here is the sticky flag ∪ turn-wide scan: the scan
3550
- // (over the whole turn's tool_use blocks) covers the fallback
3551
- // path and the "send_message then a 'sent it.' postscript"
3552
- // double-post guard; the sticky flag is its live-rounds twin.
5423
+ // Native round routing uses the live sticky flag, which a fresh
5424
+ // injected message can reset. The legacy/fallback path has no
5425
+ // reliable round boundaries, so retain its historical turn-wide
5426
+ // scan to avoid double-posting.
3553
5427
  const toolNames = response.content
3554
5428
  .filter((b) => b.type === 'tool_use')
3555
5429
  .map((b) => b.name)
3556
5430
  .filter((n) => typeof n === 'string');
3557
- const silenced = turnSilenced || toolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)));
5431
+ const silenced = liveProseRouting
5432
+ ? turnSilenced
5433
+ : turnSilenced || toolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)));
3558
5434
  const segments = splitProseSegments(liveProseRouting ? terminalContent : response.content);
3559
5435
  // Preserve in-channel ordering: everything enqueued live must
3560
5436
  // land before the trailing prose. Awaited even when silenced —
@@ -3605,12 +5481,22 @@ export class AgentFramework {
3605
5481
  requestId,
3606
5482
  success: false,
3607
5483
  error: err.message,
3608
- request: compiledRequest ?? { note: 'streaming request failed' },
5484
+ request: fallbackRetryRequestForLog,
3609
5485
  durationMs,
3610
5486
  });
3611
5487
  // Only reset + retry if this is still the active stream
3612
5488
  if (agent.streamId !== myStreamId)
3613
5489
  break;
5490
+ if (primarySummaryRetry) {
5491
+ await this.holdPrimarySummaryRetryFamily(agent, requestId, primarySummaryRetry, 'primary_summary_fallback_retry_stream_error', { error: err.message });
5492
+ agent.reset();
5493
+ this.eventGate?.onInferenceEnded(agent.name);
5494
+ this.settleAgent(agent.name, {
5495
+ stopReason: 'completed',
5496
+ speech: '',
5497
+ });
5498
+ return;
5499
+ }
3614
5500
  agent.reset();
3615
5501
  const action = this.errorPolicy.onInferenceError(err, agent.name, attempt);
3616
5502
  if (action.retry) {
@@ -3649,6 +5535,11 @@ export class AgentFramework {
3649
5535
  // reject an ephemeral's promise mid-run and bump the failure
3650
5536
  // streak). Gate release + stream teardown happen in `finally`.
3651
5537
  if (this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`)) {
5538
+ if (primarySummaryRetry) {
5539
+ if (!(await this.primarySummaryRetryFamilyAlreadySettled(requestId, primarySummaryRetry))) {
5540
+ await this.holdPrimarySummaryRetryFamily(agent, requestId, primarySummaryRetry, 'primary_summary_fallback_retry_cancelled_before_success', { frameworkCancelled: true });
5541
+ }
5542
+ }
3652
5543
  this.eventGate?.onInferenceEnded(agent.name);
3653
5544
  return;
3654
5545
  }
@@ -3657,6 +5548,33 @@ export class AgentFramework {
3657
5548
  // may have already started a new stream, bumping streamId)
3658
5549
  if (agent.streamId === myStreamId) {
3659
5550
  const durationMs = Date.now() - startTime;
5551
+ if (primarySummaryRetry) {
5552
+ await this.holdPrimarySummaryRetryFamily(agent, requestId, primarySummaryRetry, `stream_aborted:${reason}`, { reason }, { stopReason: 'abort' });
5553
+ agent.reset();
5554
+ this.eventGate?.onInferenceEnded(agent.name);
5555
+ this.logInference({
5556
+ timestamp: startTime,
5557
+ agentName: agent.name,
5558
+ requestId,
5559
+ success: false,
5560
+ error: `Stream aborted: ${reason}`,
5561
+ request: fallbackRetryRequestForLog,
5562
+ durationMs,
5563
+ });
5564
+ this.settleAgent(agent.name, {
5565
+ stopReason: 'completed',
5566
+ speech: '',
5567
+ });
5568
+ return;
5569
+ }
5570
+ if (primarySummaryConfig.enabled) {
5571
+ await this.updatePrimarySummaryRequestRecord(requestId, (record) => ({
5572
+ ...record,
5573
+ stopReason: 'abort',
5574
+ finalStatus: 'error',
5575
+ fallbackHeldReason: `stream_aborted:${reason}`,
5576
+ }));
5577
+ }
3660
5578
  agent.reset();
3661
5579
  this.settleAgent(agent.name, {
3662
5580
  stopReason: 'exhausted',
@@ -3680,7 +5598,7 @@ export class AgentFramework {
3680
5598
  requestId,
3681
5599
  success: false,
3682
5600
  error: `Stream aborted: ${reason}`,
3683
- request: compiledRequest ?? { note: 'streaming request aborted' },
5601
+ request: fallbackRetryRequestForLog,
3684
5602
  durationMs,
3685
5603
  });
3686
5604
  this.eventGate?.onInferenceEnded(agent.name);
@@ -3726,12 +5644,58 @@ export class AgentFramework {
3726
5644
  // inference:exhausted so ephemeral agent promises can settle.
3727
5645
  const err = error instanceof Error ? error : new Error(String(error));
3728
5646
  const durationMs = Date.now() - startTime;
5647
+ if (primarySummaryRetry) {
5648
+ if (await this.primarySummaryRetryAlreadySucceeded(requestId, primarySummaryRetry)) {
5649
+ this.logInference({
5650
+ timestamp: startTime,
5651
+ agentName: agent.name,
5652
+ requestId,
5653
+ success: true,
5654
+ request: fallbackRetryRequestForLog,
5655
+ response: { kind: 'primary_summary_fallback_retry', postSuccessError: err.message },
5656
+ durationMs,
5657
+ });
5658
+ agent.reset();
5659
+ this.eventGate?.onInferenceEnded(agent.name);
5660
+ this.settleAgent(agent.name, {
5661
+ stopReason: 'completed',
5662
+ speech: '',
5663
+ });
5664
+ return;
5665
+ }
5666
+ await this.holdPrimarySummaryRetryFamily(agent, requestId, primarySummaryRetry, 'primary_summary_fallback_retry_stream_threw', { error: err.message });
5667
+ }
5668
+ else if (primarySummaryConfig.enabled) {
5669
+ await this.updatePrimarySummaryRequestRecord(requestId, (record) => ({
5670
+ ...record,
5671
+ finalStatus: 'error',
5672
+ fallbackHeldReason: err.message,
5673
+ }));
5674
+ }
3729
5675
  this.emitTrace({
3730
5676
  type: 'inference:failed',
3731
5677
  agentName: agent.name,
3732
5678
  error: err.message,
3733
5679
  stack: err.stack,
3734
5680
  });
5681
+ if (primarySummaryRetry) {
5682
+ this.logInference({
5683
+ timestamp: startTime,
5684
+ agentName: agent.name,
5685
+ requestId,
5686
+ success: false,
5687
+ error: `Stream threw: ${err.message}`,
5688
+ request: fallbackRetryRequestForLog,
5689
+ durationMs,
5690
+ });
5691
+ agent.reset();
5692
+ this.eventGate?.onInferenceEnded(agent.name);
5693
+ this.settleAgent(agent.name, {
5694
+ stopReason: 'completed',
5695
+ speech: '',
5696
+ });
5697
+ return;
5698
+ }
3735
5699
  this.settleAgent(agent.name, {
3736
5700
  stopReason: 'exhausted',
3737
5701
  speech: '',
@@ -3752,7 +5716,7 @@ export class AgentFramework {
3752
5716
  requestId,
3753
5717
  success: false,
3754
5718
  error: `Stream threw: ${err.message}`,
3755
- request: compiledRequest ?? { note: 'streaming request threw' },
5719
+ request: fallbackRetryRequestForLog,
3756
5720
  durationMs,
3757
5721
  });
3758
5722
  agent.reset();
@@ -3773,6 +5737,7 @@ export class AgentFramework {
3773
5737
  this.channelRegistry?.stopTyping();
3774
5738
  this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`);
3775
5739
  this.activeStreams.delete(agent.name);
5740
+ this.activeStreamContexts.delete(agent.name);
3776
5741
  this.pendingAssistantBlocks.delete(agent.name);
3777
5742
  // A conversation fork whose TTL closure turn just finished is done for
3778
5743
  // good — dispose it so the agent map doesn't grow monotonically.
@@ -3907,6 +5872,49 @@ export class AgentFramework {
3907
5872
  return maxTokens * 4;
3908
5873
  return undefined;
3909
5874
  }
5875
+ approximateDecodedBase64Bytes(base64) {
5876
+ const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
5877
+ return Math.max(0, Math.floor(base64.length * 3 / 4) - padding);
5878
+ }
5879
+ sanitizeProcessLogValue(value, seen = new WeakMap()) {
5880
+ if (value && typeof value === 'object') {
5881
+ const seenValue = seen.get(value);
5882
+ if (seenValue)
5883
+ return seenValue;
5884
+ }
5885
+ if (Array.isArray(value)) {
5886
+ const clone = [];
5887
+ seen.set(value, clone);
5888
+ for (const item of value) {
5889
+ clone.push(this.sanitizeProcessLogValue(item, seen));
5890
+ }
5891
+ return clone;
5892
+ }
5893
+ if (!value || typeof value !== 'object') {
5894
+ return value;
5895
+ }
5896
+ const record = value;
5897
+ if (record.type === 'image' && typeof record.data === 'string' && typeof record.mimeType === 'string') {
5898
+ const clone = {};
5899
+ seen.set(value, clone);
5900
+ for (const [key, child] of Object.entries(record)) {
5901
+ if (key === 'data' || key === 'mimeType')
5902
+ continue;
5903
+ clone[key] = this.sanitizeProcessLogValue(child, seen);
5904
+ }
5905
+ clone.type = 'image';
5906
+ clone.mimeType = record.mimeType;
5907
+ clone.approxByteLength = this.approximateDecodedBase64Bytes(record.data);
5908
+ clone.redacted = true;
5909
+ return clone;
5910
+ }
5911
+ const clone = {};
5912
+ seen.set(value, clone);
5913
+ for (const [key, child] of Object.entries(record)) {
5914
+ clone[key] = this.sanitizeProcessLogValue(child, seen);
5915
+ }
5916
+ return clone;
5917
+ }
3910
5918
  logInference(entry) {
3911
5919
  // Store large request/response as blobs
3912
5920
  const entryToStore = { ...entry };
@@ -3935,13 +5943,13 @@ export class AgentFramework {
3935
5943
  logProcessEvent(event, responses) {
3936
5944
  const entry = {
3937
5945
  timestamp: Date.now(),
3938
- processEvent: event,
3939
- responses,
5946
+ processEvent: this.sanitizeProcessLogValue(event),
5947
+ responses: this.sanitizeProcessLogValue(responses),
3940
5948
  };
3941
5949
  // Blob threshold: 10KB
3942
5950
  const BLOB_THRESHOLD = 10000;
3943
5951
  const entryToStore = { ...entry };
3944
- const responsesJson = JSON.stringify(responses);
5952
+ const responsesJson = JSON.stringify(entry.responses);
3945
5953
  if (responsesJson.length > BLOB_THRESHOLD) {
3946
5954
  const blobId = this.store.storeBlob(Buffer.from(responsesJson), 'application/json');
3947
5955
  entryToStore.responses = { blobId };
@@ -3980,6 +5988,10 @@ export class AgentFramework {
3980
5988
  this.dispatchAgentSettingsToolCall(agentName, enrichedCall);
3981
5989
  return;
3982
5990
  }
5991
+ if (enrichedCall.name === 'save_recent_image') {
5992
+ this.dispatchSaveImageToolCall(agentName, enrichedCall);
5993
+ return;
5994
+ }
3983
5995
  // Route gate_status tool
3984
5996
  if (enrichedCall.name === 'gate_status' && this.eventGate) {
3985
5997
  this.dispatchGateToolCall(agentName, enrichedCall);
@@ -4212,12 +6224,22 @@ export class AgentFramework {
4212
6224
  * work, per-agent status + last inference activity. Cheap and read-only.
4213
6225
  */
4214
6226
  healthSnapshot() {
6227
+ const primarySummary = this.readPrimarySummaryFallbackState();
4215
6228
  return {
4216
6229
  at: new Date().toISOString(),
4217
6230
  uptimeSec: Math.round(process.uptime()),
4218
6231
  gate: this.eventGate?.inferenceDiagnostics() ?? null,
4219
6232
  pendingRequests: this.pendingRequests.length,
4220
6233
  activeStreams: [...this.activeStreams.keys()],
6234
+ primarySummaryFallback: {
6235
+ requests: primarySummary.requests.length,
6236
+ pendingDispatches: primarySummary.requests.filter((record) => record.finalStatus === 'pending').length,
6237
+ unresolvedIntents: primarySummary.requests.filter((record) => record.fallbackStatus === 'pending').length,
6238
+ held: primarySummary.requests.filter((record) => record.finalStatus === 'held' ||
6239
+ record.finalStatus === 'timeout_unknown' ||
6240
+ record.fallbackStatus === 'held' ||
6241
+ record.fallbackStatus === 'timeout_unknown').length,
6242
+ },
4221
6243
  agents: [...this.agents.entries()].map(([name, agent]) => ({
4222
6244
  name,
4223
6245
  status: agent.state.status,
@@ -4248,6 +6270,16 @@ export class AgentFramework {
4248
6270
  * every ~15 min instead of flooding the channel on every occurrence.
4249
6271
  * See connectome docs/observability.md.
4250
6272
  */
6273
+ /**
6274
+ * Public ops-alert entry point for host-wired alarms that originate
6275
+ * OUTSIDE the framework (e.g. context-manager's repeating
6276
+ * compression-quarantine klaxon, wired in the host at strategy
6277
+ * construction). Same sinks and cooldown as internal alerts:
6278
+ * failures.log + ops:alert trace + CONNECTOME_OPS_WEBHOOK.
6279
+ */
6280
+ notifyOps(kind, agentName, message, data) {
6281
+ this.opsAlert(kind, agentName, message, { data });
6282
+ }
4251
6283
  opsAlert(kind, agentName, message, opts) {
4252
6284
  if (!opts?.skipLog) {
4253
6285
  this.logFailure({ agent: agentName, kind, reason: message, ...opts?.data });
@@ -4322,6 +6354,16 @@ export class AgentFramework {
4322
6354
  }
4323
6355
  return {};
4324
6356
  }
6357
+ /**
6358
+ * Public entry to the ops-alert pipeline (failures.log + ops:alert trace +
6359
+ * throttled webhook) for host-level detectors OUTSIDE the framework's own
6360
+ * inference driver — e.g. the host's logging adapter catching refusals on
6361
+ * off-path calls (compression/summarizer, maintenance) that noteRefusal
6362
+ * never sees. Same per-(agent,kind) cooldown as everything else.
6363
+ */
6364
+ notifyOpsAlert(kind, agentName, message, data) {
6365
+ this.opsAlert(kind, agentName, message, { data });
6366
+ }
4325
6367
  noteInferenceExhausted(agentName, reason, retryable, errorType) {
4326
6368
  const streak = (this.consecutiveInferenceFailures.get(agentName) ?? 0) + 1;
4327
6369
  this.consecutiveInferenceFailures.set(agentName, streak);
@@ -4534,9 +6576,17 @@ export class AgentFramework {
4534
6576
  };
4535
6577
  for (const config of serverConfigs) {
4536
6578
  try {
4537
- await this.connectMcplServerInternal(config);
6579
+ // Stage every startup connection with both planes closed. This removes
6580
+ // server-order dependence: no early heartbeat/shell push can become
6581
+ // model-visible before a later Discord connection has joined the same
6582
+ // awareness generation.
6583
+ await this.connectMcplServerInternal(config, true);
4538
6584
  }
4539
6585
  catch (error) {
6586
+ if (error instanceof DiscordAwarenessAccountingError) {
6587
+ await this.mcplServerRegistry.closeAll();
6588
+ throw error;
6589
+ }
4540
6590
  // Fail-open: log and continue with remaining servers
4541
6591
  const err = error instanceof Error ? error : new Error(String(error));
4542
6592
  console.error(`Failed to connect MCPL server "${config.id}":`, err.message);
@@ -4553,16 +6603,289 @@ export class AgentFramework {
4553
6603
  });
4554
6604
  }
4555
6605
  }
6606
+ // Start the durable drain only after all startup connections are staged.
6607
+ // The awareness tools/call is written before control-plane registration is
6608
+ // flushed, preserving the server-side queue -> registration -> call order.
6609
+ const startupBarrier = this.installMcplDataPlaneGate();
6610
+ this.releaseMcplDataPlaneGate(startupBarrier);
6611
+ try {
6612
+ await startupBarrier.promise;
6613
+ this.completeMcplDataPlaneGate(startupBarrier);
6614
+ }
6615
+ catch (error) {
6616
+ await this.mcplServerRegistry.closeAll();
6617
+ throw error;
6618
+ }
4556
6619
  // Discover tools from all connected servers
4557
6620
  await this.refreshMcplTools();
4558
6621
  }
6622
+ /** Reconcile the durable ledger with Chronicle, then deliver every server's work. */
6623
+ async syncDiscordAwarenessMarkers(_onlyServerId) {
6624
+ if (!this.discordAwarenessOutbox)
6625
+ return;
6626
+ // A targeted retry may discover pending work for other Discord servers
6627
+ // during reconciliation. Safety is global, so one generation accounts for
6628
+ // all pending operations before releasing any MCPL data plane.
6629
+ const barrier = this.installMcplDataPlaneGate();
6630
+ this.releaseMcplDataPlaneGate(barrier);
6631
+ try {
6632
+ await barrier.promise;
6633
+ this.completeMcplDataPlaneGate(barrier);
6634
+ }
6635
+ catch (error) {
6636
+ await this.failMcplDataPlaneGate(barrier, 'explicit synchronization', error);
6637
+ throw error;
6638
+ }
6639
+ }
6640
+ async resumePreparedDiscordSuppressions() {
6641
+ if (!this.discordAwarenessOutbox)
6642
+ return;
6643
+ const batches = this.discordAwarenessOutbox.preparedSuppressionsForBranch(this.store.currentBranch().name, this.store.listBranches());
6644
+ for (const batch of batches) {
6645
+ const agent = this.agents.get(batch.agentName);
6646
+ if (!agent) {
6647
+ throw new Error(`Cannot resume Discord suppression ${batch.id}: agent ${batch.agentName} is not configured`);
6648
+ }
6649
+ const cm = agent.getContextManager();
6650
+ for (const interval of [...(batch.suppressionIntervals ?? [])].reverse()) {
6651
+ const from = cm.getMessage(interval.fromId);
6652
+ const to = cm.getMessage(interval.toId);
6653
+ if (!from && !to)
6654
+ continue; // This interval committed before the crash.
6655
+ if (!from || !to) {
6656
+ throw new Error(`Cannot safely resume Discord suppression ${batch.id}: interval ` +
6657
+ `${interval.fromId}..${interval.toId} is only partially present`);
6658
+ }
6659
+ if (interval.fromId === interval.toId)
6660
+ cm.removeMessage(from.id);
6661
+ else
6662
+ cm.removeMessages(from.id, to.id);
6663
+ }
6664
+ this.discordAwarenessOutbox.activate(batch.id);
6665
+ console.error(`[discord-awareness] resumed suppression batch ${batch.id}`);
6666
+ }
6667
+ }
6668
+ /**
6669
+ * Deliver every due ref independently. Permanent Discord failures are kept
6670
+ * in the ledger for audit but do not block later operations; retryable
6671
+ * failures remain pending for the next reconnect/list-change attempt.
6672
+ */
6673
+ drainDiscordAwarenessOutbox(serverId) {
6674
+ if (!this.discordAwarenessOutbox) {
6675
+ return Promise.resolve({ status: 'delivered', delivered: 0, failed: 0 });
6676
+ }
6677
+ const existing = this.discordAwarenessDrains.get(serverId);
6678
+ if (existing)
6679
+ return existing;
6680
+ const drain = (async () => {
6681
+ const connection = this.mcplServerRegistry?.getServer(serverId);
6682
+ if (!connection?.isConnected) {
6683
+ const operations = this.readDiscordAwarenessPending(serverId);
6684
+ for (const operation of operations) {
6685
+ this.writeDiscordAwarenessFailure(operation.batchId, operation.ref, operation.action, 'Awareness delivery not attempted: MCPL connection unavailable', false);
6686
+ }
6687
+ if (operations.length > 0) {
6688
+ console.error(`[discord-awareness] ${serverId}: connection unavailable; ` +
6689
+ `durably deferred=${operations.length}`);
6690
+ }
6691
+ return { status: 'unavailable', accounted: operations.length };
6692
+ }
6693
+ let delivered = 0;
6694
+ let failed = 0;
6695
+ const attempted = new Set();
6696
+ while (true) {
6697
+ const operations = this.readDiscordAwarenessPending(serverId).filter((operation) => {
6698
+ const key = `${operation.batchId}\0${operation.ref.channelId}\0${operation.ref.messageId}\0${operation.action}`;
6699
+ if (attempted.has(key))
6700
+ return false;
6701
+ attempted.add(key);
6702
+ return true;
6703
+ });
6704
+ if (operations.length === 0)
6705
+ break;
6706
+ for (const operation of operations) {
6707
+ const ref = operation.ref;
6708
+ const channelId = ref.channelId.startsWith('discord:')
6709
+ ? ref.channelId.split(':').at(-1)
6710
+ : ref.channelId;
6711
+ let deliveryError;
6712
+ try {
6713
+ const tool = operation.action === 'add' ? 'add_reaction' : 'remove_reaction';
6714
+ const result = await connection.sendToolsCallWithDeadline(tool, {
6715
+ channelId,
6716
+ messageId: ref.messageId,
6717
+ emoji: operation.emoji,
6718
+ }, this.discordAwarenessDeadlineMs);
6719
+ if (result.isError) {
6720
+ deliveryError = result.content
6721
+ .map((content) => content.text ?? '')
6722
+ .filter(Boolean)
6723
+ .join('; ') || `Discord ${tool} returned an error`;
6724
+ }
6725
+ }
6726
+ catch (error) {
6727
+ deliveryError = error instanceof Error ? error.message : String(error);
6728
+ }
6729
+ if (deliveryError !== undefined) {
6730
+ const permanent = isPermanentDiscordReactionFailure(deliveryError);
6731
+ this.writeDiscordAwarenessFailure(operation.batchId, ref, operation.action, deliveryError, permanent);
6732
+ failed++;
6733
+ console.error(`[discord-awareness] ${operation.action} failed for ${ref.channelId}/${ref.messageId}` +
6734
+ ` (${permanent ? 'permanent' : 'retryable'}): ${deliveryError}`);
6735
+ }
6736
+ else {
6737
+ this.writeDiscordAwarenessSuccess(operation.batchId, ref, operation.action);
6738
+ delivered++;
6739
+ }
6740
+ }
6741
+ }
6742
+ if (delivered > 0 || failed > 0) {
6743
+ console.error(`[discord-awareness] ${serverId}: delivered=${delivered} failed=${failed}`);
6744
+ }
6745
+ return { status: 'delivered', delivered, failed };
6746
+ })().finally(() => {
6747
+ this.discordAwarenessDrains.delete(serverId);
6748
+ });
6749
+ this.discordAwarenessDrains.set(serverId, drain);
6750
+ return drain;
6751
+ }
6752
+ readDiscordAwarenessPending(serverId) {
6753
+ try {
6754
+ return this.discordAwarenessOutbox.pending(serverId);
6755
+ }
6756
+ catch (error) {
6757
+ throw new DiscordAwarenessAccountingError(`ledger read${serverId ? ` for ${serverId}` : ''}`, error);
6758
+ }
6759
+ }
6760
+ writeDiscordAwarenessSuccess(batchId, ref, action) {
6761
+ try {
6762
+ this.discordAwarenessOutbox.recordSuccess(batchId, ref, action);
6763
+ }
6764
+ catch (error) {
6765
+ throw new DiscordAwarenessAccountingError('recordSuccess ledger write', error);
6766
+ }
6767
+ }
6768
+ writeDiscordAwarenessFailure(batchId, ref, action, errorMessage, permanent) {
6769
+ try {
6770
+ this.discordAwarenessOutbox.recordFailure(batchId, ref, action, errorMessage, permanent);
6771
+ }
6772
+ catch (error) {
6773
+ throw new DiscordAwarenessAccountingError('recordFailure ledger write', error);
6774
+ }
6775
+ }
6776
+ beginDiscordAwarenessBarrier() {
6777
+ if (!this.discordAwarenessOutbox) {
6778
+ return {
6779
+ requiresBarrier: false,
6780
+ promise: Promise.resolve({ status: 'delivered', delivered: 0, failed: 0 }),
6781
+ };
6782
+ }
6783
+ try {
6784
+ this.discordAwarenessOutbox.reconcileForBranch(this.store.currentBranch().name, this.store.listBranches());
6785
+ }
6786
+ catch (error) {
6787
+ throw new DiscordAwarenessAccountingError('branch reconciliation', error);
6788
+ }
6789
+ const pending = this.readDiscordAwarenessPending();
6790
+ if (pending.length === 0) {
6791
+ return {
6792
+ requiresBarrier: false,
6793
+ promise: Promise.resolve({ status: 'delivered', delivered: 0, failed: 0 }),
6794
+ };
6795
+ }
6796
+ const serverIds = [...new Set(pending.map((operation) => operation.ref.serverId))];
6797
+ const promise = Promise.all(serverIds.map((serverId) => this.drainDiscordAwarenessOutbox(serverId))).then((outcomes) => {
6798
+ if (outcomes.every((outcome) => outcome.status === 'unavailable')) {
6799
+ return {
6800
+ status: 'unavailable',
6801
+ accounted: outcomes.reduce((count, outcome) => count + (outcome.status === 'unavailable' ? outcome.accounted : 0), 0),
6802
+ };
6803
+ }
6804
+ return {
6805
+ status: 'delivered',
6806
+ delivered: outcomes.reduce((count, outcome) => count + (outcome.status === 'delivered' ? outcome.delivered : 0), 0),
6807
+ failed: outcomes.reduce((count, outcome) => count + (outcome.status === 'delivered' ? outcome.failed : 0), 0),
6808
+ };
6809
+ });
6810
+ return { requiresBarrier: true, promise };
6811
+ }
6812
+ /**
6813
+ * Install one framework-global Discord-awareness generation before releasing
6814
+ * any inference-bearing event. With pending work, every MCPL data plane is
6815
+ * paused while all control planes remain live. With no pending work, ready()
6816
+ * runs synchronously so request responders preserve their historical
6817
+ * same-stack behavior.
6818
+ */
6819
+ installMcplDataPlaneGate() {
6820
+ for (const connection of this.mcplServerRegistry?.getAllServers() ?? []) {
6821
+ connection.pauseDataPlane();
6822
+ }
6823
+ const generation = ++this.discordAwarenessBarrierGeneration;
6824
+ let begun;
6825
+ try {
6826
+ begun = this.beginDiscordAwarenessBarrier();
6827
+ }
6828
+ catch (error) {
6829
+ begun = {
6830
+ requiresBarrier: true,
6831
+ promise: Promise.reject(error),
6832
+ };
6833
+ }
6834
+ const barrier = { generation, ...begun };
6835
+ this.discordAwarenessBarrier = barrier;
6836
+ return barrier;
6837
+ }
6838
+ releaseMcplDataPlaneGate(barrier) {
6839
+ if (this.discordAwarenessBarrier !== barrier)
6840
+ return;
6841
+ if (barrier.requiresBarrier) {
6842
+ for (const connection of this.mcplServerRegistry?.getAllServers() ?? []) {
6843
+ connection.readyControlPlane();
6844
+ }
6845
+ return;
6846
+ }
6847
+ this.completeMcplDataPlaneGate(barrier);
6848
+ }
6849
+ completeMcplDataPlaneGate(barrier) {
6850
+ if (this.discordAwarenessBarrier !== barrier)
6851
+ return false;
6852
+ this.discordAwarenessBarrier = null;
6853
+ for (const connection of this.mcplServerRegistry?.getAllServers() ?? []) {
6854
+ // ready() can synchronously flush a nested list-change notification that
6855
+ // installs a newer global generation. Never let this older completion
6856
+ // release any remaining server behind that newer gate.
6857
+ if (this.discordAwarenessBarrier !== null)
6858
+ return false;
6859
+ connection.ready();
6860
+ }
6861
+ return this.discordAwarenessBarrier === null;
6862
+ }
6863
+ async failMcplDataPlaneGate(barrier, context, error) {
6864
+ if (this.discordAwarenessBarrier !== barrier)
6865
+ return;
6866
+ const err = error instanceof Error ? error : new Error(String(error));
6867
+ console.error(`[discord-awareness] ${context} failed globally:`, err.message);
6868
+ const connections = this.mcplServerRegistry?.getAllServers() ?? [];
6869
+ for (const connection of connections) {
6870
+ this.emitTrace({
6871
+ type: 'mcpl:server-error',
6872
+ serverId: connection.id,
6873
+ error: `Discord awareness accounting unhealthy: ${err.message}`,
6874
+ });
6875
+ }
6876
+ await Promise.all(connections.map(async (connection) => {
6877
+ await connection.reconnectAfterFailure().catch((closeError) => {
6878
+ console.error(`[discord-awareness] could not recycle unhealthy connection ${connection.id}:`, closeError instanceof Error ? closeError.message : closeError);
6879
+ });
6880
+ }));
6881
+ }
4559
6882
  /**
4560
6883
  * Connect a single MCPL server: register routing entries, open the
4561
6884
  * connection, wire events, and initialize feature sets / scopes /
4562
6885
  * checkpoints. Shared by startup (initializeMcpl) and the runtime
4563
6886
  * lifecycle API (connectMcplServer). Throws on connection failure.
4564
6887
  */
4565
- async connectMcplServerInternal(config) {
6888
+ async connectMcplServerInternal(config, deferAwareness = false) {
4566
6889
  if (!this.mcplServerRegistry || !this.mcplHostCapabilities) {
4567
6890
  throw new Error('MCPL subsystem is not initialized');
4568
6891
  }
@@ -4577,12 +6900,28 @@ export class AgentFramework {
4577
6900
  this.channelRegistry.setSubscriptionPolicy(config.id, config.channelSubscription ?? 'manual');
4578
6901
  }
4579
6902
  const connection = await this.mcplServerRegistry.addServer(config, this.mcplHostCapabilities);
4580
- // Wire event listeners then flush any events that arrived during the
4581
- // handshake window (between setupMessageRouting and now).
6903
+ // Wire listeners before either startup staging or the runtime global gate
6904
+ // releases control traffic needed for registration and marker service.
4582
6905
  this.wireMcplEvents(connection);
4583
- connection.ready();
4584
6906
  // Initialize feature sets if server advertises MCPL capabilities
4585
6907
  this.registerMcplServerFeatures(config, connection);
6908
+ if (deferAwareness) {
6909
+ this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` });
6910
+ return;
6911
+ }
6912
+ const awarenessBarrier = this.installMcplDataPlaneGate();
6913
+ this.releaseMcplDataPlaneGate(awarenessBarrier);
6914
+ try {
6915
+ await awarenessBarrier.promise;
6916
+ this.completeMcplDataPlaneGate(awarenessBarrier);
6917
+ }
6918
+ catch (error) {
6919
+ // Startup cannot fail open on a broken awareness ledger. Disable the
6920
+ // reconnecting stub/connection before propagating the distinct error to
6921
+ // initializeMcpl, which tears down any other servers and aborts create().
6922
+ await connection.close().catch(() => { });
6923
+ throw error;
6924
+ }
4586
6925
  this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` });
4587
6926
  }
4588
6927
  /**
@@ -4749,12 +7088,18 @@ export class AgentFramework {
4749
7088
  }
4750
7089
  });
4751
7090
  // Handle push events (Step 6)
4752
- connection.on('push-event', (params, responder) => {
7091
+ connection.on('push-event', async (params, responder) => {
7092
+ const barrier = this.discordAwarenessBarrier;
7093
+ if (barrier)
7094
+ await barrier.promise;
4753
7095
  this.pushHandler?.handlePushEvent(connection.id, params, responder);
4754
7096
  });
4755
7097
  // Handle server-initiated inference requests (Step 6)
4756
7098
  connection.on('inference-request', async (params, responder) => {
4757
7099
  if (this.inferenceRouter && responder) {
7100
+ const barrier = this.discordAwarenessBarrier;
7101
+ if (barrier)
7102
+ await barrier.promise;
4758
7103
  await this.inferenceRouter.handleInferenceRequest(connection.id, params, {
4759
7104
  respond: responder.respond,
4760
7105
  respondError: responder.respondError,
@@ -4771,7 +7116,10 @@ export class AgentFramework {
4771
7116
  await this.channelRegistry?.handleChanged(connection.id, params);
4772
7117
  });
4773
7118
  // Handle incoming channel messages (Step 7)
4774
- connection.on('channels-incoming', (params, responder) => {
7119
+ connection.on('channels-incoming', async (params, responder) => {
7120
+ const barrier = this.discordAwarenessBarrier;
7121
+ if (barrier)
7122
+ await barrier.promise;
4775
7123
  this.channelRegistry?.handleIncoming(connection.id, params, responder);
4776
7124
  });
4777
7125
  // Handle host-level admin commands from a surface (e.g. Discord /undo)
@@ -4779,6 +7127,9 @@ export class AgentFramework {
4779
7127
  if (!responder)
4780
7128
  return;
4781
7129
  try {
7130
+ const barrier = this.discordAwarenessBarrier;
7131
+ if (barrier)
7132
+ await barrier.promise;
4782
7133
  const result = await this.handleHostCommand(connection.id, params ?? {});
4783
7134
  responder.respond(result);
4784
7135
  }
@@ -4789,7 +7140,15 @@ export class AgentFramework {
4789
7140
  });
4790
7141
  // Handle dynamic tool list changes (notifications/tools/list_changed)
4791
7142
  connection.on('tools-list-changed', () => {
7143
+ // Pause before starting any async refresh. This listener runs
7144
+ // synchronously in the transport line callback, so a following inbound
7145
+ // data event cannot pass before the new barrier exists.
7146
+ const awarenessBarrier = this.installMcplDataPlaneGate();
7147
+ this.releaseMcplDataPlaneGate(awarenessBarrier);
4792
7148
  this.handleToolsListChanged(connection.id);
7149
+ void awarenessBarrier.promise.then(() => {
7150
+ this.completeMcplDataPlaneGate(awarenessBarrier);
7151
+ }).catch((error) => this.failMcplDataPlaneGate(awarenessBarrier, 'tools-list reconciliation', error));
4793
7152
  });
4794
7153
  // Re-establish full server registration on reconnect. The 'close' handler
4795
7154
  // removed the feature-set state, so pushes / inference requests from the
@@ -4799,6 +7158,10 @@ export class AgentFramework {
4799
7158
  // preserved across the transient close are resumed by idempotent
4800
7159
  // registration. Then refresh tools (server may have different tools).
4801
7160
  connection.on('reconnect', (info) => {
7161
+ // Install the barrier synchronously so any inbound event emitted after
7162
+ // reconnect observes it before doing work that could wake an agent. The
7163
+ // connection paused its data plane before wiring the fresh transport.
7164
+ const awarenessBarrier = this.installMcplDataPlaneGate();
4802
7165
  try {
4803
7166
  const config = this.mcplServerConfigs.get(connection.id);
4804
7167
  if (config) {
@@ -4808,15 +7171,21 @@ export class AgentFramework {
4808
7171
  catch (error) {
4809
7172
  console.error(`MCPL server "${connection.id}" re-registration after reconnect failed:`, error instanceof Error ? error.message : error);
4810
7173
  }
7174
+ this.releaseMcplDataPlaneGate(awarenessBarrier);
4811
7175
  this.handleToolsListChanged(connection.id);
4812
- this.emitTrace({
4813
- type: 'mcpl:server-reconnected',
4814
- serverId: connection.id,
4815
- attempts: info?.attempts ?? 0,
4816
- });
4817
- // Mirror the module:removed emitted on 'close' so module-lifecycle
4818
- // consumers see the server come back, not just vanish.
4819
- this.emitTrace({ type: 'module:added', moduleName: `mcpl:${connection.id}` });
7176
+ void awarenessBarrier.promise.then(() => {
7177
+ if (awarenessBarrier.requiresBarrier
7178
+ && !this.completeMcplDataPlaneGate(awarenessBarrier))
7179
+ return;
7180
+ this.emitTrace({
7181
+ type: 'mcpl:server-reconnected',
7182
+ serverId: connection.id,
7183
+ attempts: info?.attempts ?? 0,
7184
+ });
7185
+ // Mirror the module:removed emitted on 'close' so module-lifecycle
7186
+ // consumers see the server come back, not just vanish.
7187
+ this.emitTrace({ type: 'module:added', moduleName: `mcpl:${connection.id}` });
7188
+ }).catch((error) => this.failMcplDataPlaneGate(awarenessBarrier, 'reconnect reconciliation', error));
4820
7189
  });
4821
7190
  // Surface connect/reconnect failures. Before these traces existed the
4822
7191
  // only receipt was a console.error on the host's own stderr — invisible
@@ -4855,6 +7224,7 @@ export class AgentFramework {
4855
7224
  type: 'mcpl:orphaned-response',
4856
7225
  serverId: connection.id,
4857
7226
  responseId: info.id,
7227
+ method: info.method,
4858
7228
  hadState: info.hadState,
4859
7229
  hadCheckpoint: info.hadCheckpoint,
4860
7230
  });
@@ -5280,6 +7650,143 @@ export class AgentFramework {
5280
7650
  endTurn: true,
5281
7651
  });
5282
7652
  }
7653
+ /**
7654
+ * Handle the synthesized `save_recent_image` tool: locate the requested
7655
+ * image blocks in the calling agent's context (blobs re-inlined, counted
7656
+ * back from the most recent) and write their bytes to a workspace mount
7657
+ * via WorkspaceModule.writeBinary.
7658
+ */
7659
+ dispatchSaveImageToolCall(agentName, call) {
7660
+ this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
7661
+ const finish = (result) => {
7662
+ this.emitTrace({
7663
+ type: result.isError ? 'tool:failed' : 'tool:completed',
7664
+ module: 'workspace',
7665
+ tool: call.name,
7666
+ callId: call.id,
7667
+ durationMs: 0,
7668
+ ...(result.isError ? { error: result.error } : {}),
7669
+ });
7670
+ this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'workspace', result });
7671
+ };
7672
+ void (async () => {
7673
+ try {
7674
+ const workspace = this.getWorkspaceModule();
7675
+ if (!workspace)
7676
+ throw new Error('save_recent_image requires a workspace module');
7677
+ const agent = this.agents.get(agentName);
7678
+ if (!agent)
7679
+ throw new Error(`Unknown agent: ${agentName}`);
7680
+ const input = (call.input ?? {});
7681
+ if (typeof input.path !== 'string' || input.path.length === 0) {
7682
+ throw new Error('save_recent_image: `path` (mount-prefixed) is required');
7683
+ }
7684
+ const index = input.index === undefined ? 0 : Number(input.index);
7685
+ if (!Number.isInteger(index) || index < 0) {
7686
+ throw new Error('save_recent_image: `index` must be a non-negative integer');
7687
+ }
7688
+ const count = input.count === undefined ? 1 : Number(input.count);
7689
+ const MAX_COUNT = 20;
7690
+ if (!Number.isInteger(count) || count < 1 || count > MAX_COUNT) {
7691
+ throw new Error(`save_recent_image: \`count\` must be an integer in 1..${MAX_COUNT}`);
7692
+ }
7693
+ const lastWanted = index + count - 1;
7694
+ // Walk the message store tail-first in bounded windows, re-inlining
7695
+ // blob media, until we've collected the requested range. Scanning is
7696
+ // capped so a pathological index can't drag the whole store through
7697
+ // blob resolution.
7698
+ const MAX_SCAN = 500;
7699
+ const WINDOW = 25;
7700
+ const cm = agent.getContextManager();
7701
+ const total = cm.getMessageCount();
7702
+ let seen = 0;
7703
+ let scanned = 0;
7704
+ const found = [];
7705
+ for (let end = total; end > 0 && scanned < MAX_SCAN && found.length < count; end -= WINDOW) {
7706
+ const start = Math.max(0, end - WINDOW);
7707
+ const { messages } = cm.getMessageWindow(start, end - start, { resolveBlobs: true });
7708
+ scanned += end - start;
7709
+ for (let i = messages.length - 1; i >= 0 && found.length < count; i--) {
7710
+ const content = messages[i]?.content;
7711
+ if (!Array.isArray(content))
7712
+ continue;
7713
+ for (let b = content.length - 1; b >= 0 && found.length < count; b--) {
7714
+ const block = content[b];
7715
+ if (block?.type !== 'image')
7716
+ continue;
7717
+ if (seen >= index && seen <= lastWanted) {
7718
+ if (block.source?.type !== 'base64' || typeof block.source.data !== 'string') {
7719
+ throw new Error(`save_recent_image: image at index ${seen} is not stored inline (base64) — cannot save it`);
7720
+ }
7721
+ found.push({
7722
+ rangeOffset: seen - index,
7723
+ data: block.source.data,
7724
+ mediaType: block.source.mediaType ?? 'image/png',
7725
+ messagesBack: total - (start + i),
7726
+ });
7727
+ }
7728
+ seen++;
7729
+ }
7730
+ }
7731
+ }
7732
+ if (found.length === 0) {
7733
+ throw new Error(seen === 0
7734
+ ? `save_recent_image: no images found in the most recent ${Math.min(scanned, MAX_SCAN)} messages`
7735
+ : `save_recent_image: only ${seen} image(s) found in the most recent ${Math.min(scanned, MAX_SCAN)} messages (asked for index ${index})`);
7736
+ }
7737
+ // Single image → path as given. Range → numeric suffix before the
7738
+ // extension ("cat.png" → "cat-0.png"), 0 = newest of the range.
7739
+ const pathFor = (rangeOffset) => {
7740
+ if (count === 1)
7741
+ return input.path;
7742
+ const p = input.path;
7743
+ const dot = p.lastIndexOf('.');
7744
+ const slash = p.lastIndexOf('/');
7745
+ return dot > slash
7746
+ ? `${p.slice(0, dot)}-${rangeOffset}${p.slice(dot)}`
7747
+ : `${p}-${rangeOffset}`;
7748
+ };
7749
+ const savedFiles = [];
7750
+ for (const image of found) {
7751
+ const destination = pathFor(image.rangeOffset);
7752
+ const bytes = Buffer.from(image.data, 'base64');
7753
+ const result = await workspace.writeBinary(destination, bytes, image.mediaType);
7754
+ if (!result.success) {
7755
+ finish({
7756
+ success: false,
7757
+ error: `save_recent_image: failed writing "${destination}": ${result.error}` +
7758
+ (savedFiles.length > 0
7759
+ ? ` (already saved: ${savedFiles.map((f) => f.path).join(', ')})`
7760
+ : ''),
7761
+ isError: true,
7762
+ });
7763
+ return;
7764
+ }
7765
+ savedFiles.push({
7766
+ ...result.data,
7767
+ imageIndex: index + image.rangeOffset,
7768
+ messagesBack: image.messagesBack,
7769
+ });
7770
+ }
7771
+ finish({
7772
+ success: true,
7773
+ data: {
7774
+ saved: savedFiles,
7775
+ ...(found.length < count
7776
+ ? { note: `only ${found.length} of ${count} requested images exist in the scanned window` }
7777
+ : {}),
7778
+ },
7779
+ });
7780
+ }
7781
+ catch (error) {
7782
+ finish({
7783
+ success: false,
7784
+ error: error instanceof Error ? error.message : String(error),
7785
+ isError: true,
7786
+ });
7787
+ }
7788
+ })();
7789
+ }
5283
7790
  dispatchAgentSettingsToolCall(agentName, call) {
5284
7791
  this.emitTrace({
5285
7792
  type: 'tool:started',
@@ -5291,9 +7798,20 @@ export class AgentFramework {
5291
7798
  let result;
5292
7799
  try {
5293
7800
  const input = (call.input ?? {});
7801
+ const extensions = this.collectAgentSettingsExtensions();
7802
+ /** Extension values merged flat alongside the core snapshot. */
7803
+ const extGet = () => {
7804
+ const out = {};
7805
+ for (const ext of extensions.values())
7806
+ Object.assign(out, ext.get(agentName));
7807
+ return out;
7808
+ };
5294
7809
  switch (input.action) {
5295
7810
  case 'get':
5296
- result = { success: true, data: this.getAgentRuntimeSettings(agentName) };
7811
+ result = {
7812
+ success: true,
7813
+ data: { ...this.getAgentRuntimeSettings(agentName), ...extGet() },
7814
+ };
5297
7815
  break;
5298
7816
  case 'cancel':
5299
7817
  result = { success: true, data: this.cancelAgentRuntimeSettingsTransition(agentName) };
@@ -5308,11 +7826,46 @@ export class AgentFramework {
5308
7826
  if (input.transition_pace_tokens !== undefined) {
5309
7827
  patch.transitionPaceTokens = Number(input.transition_pace_tokens);
5310
7828
  }
5311
- result = { success: true, data: this.updateAgentRuntimeSettings(agentName, patch) };
7829
+ if (input.same_round_think_text_policy !== undefined) {
7830
+ patch.sameRoundThinkTextPolicy = input.same_round_think_text_policy;
7831
+ }
7832
+ // Route extension-owned keys to their modules; apply the core patch
7833
+ // only when it touches core keys (an extension-only update must not
7834
+ // disturb a converging budget transition).
7835
+ const extResults = {};
7836
+ for (const ext of extensions.values()) {
7837
+ const slice = {};
7838
+ for (const key of ext.keys) {
7839
+ if (input[key] !== undefined)
7840
+ slice[key] = input[key];
7841
+ }
7842
+ if (Object.keys(slice).length > 0) {
7843
+ Object.assign(extResults, ext.update(agentName, slice));
7844
+ }
7845
+ }
7846
+ const coreTouched = Object.keys(patch).length > 0;
7847
+ const core = coreTouched
7848
+ ? this.updateAgentRuntimeSettings(agentName, patch)
7849
+ : this.getAgentRuntimeSettings(agentName);
7850
+ result = {
7851
+ success: true,
7852
+ data: {
7853
+ ...core,
7854
+ ...extGet(),
7855
+ ...extResults,
7856
+ ...(patch.sameRoundThinkTextPolicy !== undefined
7857
+ ? {
7858
+ sameRoundThinkTextPolicyUpdateNote: 'Stored now; applies to provider think routing and description beginning with the next inference.',
7859
+ }
7860
+ : {}),
7861
+ },
7862
+ };
5312
7863
  break;
5313
7864
  }
5314
7865
  case 'reset': {
5315
7866
  let keys;
7867
+ const extResetKeys = new Map();
7868
+ const resetsAllSettings = input.settings === undefined;
5316
7869
  if (input.settings !== undefined) {
5317
7870
  if (!Array.isArray(input.settings))
5318
7871
  throw new Error('reset `settings` must be an array');
@@ -5320,15 +7873,56 @@ export class AgentFramework {
5320
7873
  context_budget_tokens: 'contextBudgetTokens',
5321
7874
  tail_tokens: 'tailTokens',
5322
7875
  transition_pace_tokens: 'transitionPaceTokens',
7876
+ same_round_think_text_policy: 'sameRoundThinkTextPolicy',
5323
7877
  };
5324
- keys = input.settings.map((name) => {
5325
- if (typeof name !== 'string' || !names[name]) {
5326
- throw new Error(`Unknown reset setting: ${String(name)}`);
7878
+ keys = [];
7879
+ for (const name of input.settings) {
7880
+ if (typeof name === 'string' && names[name]) {
7881
+ keys.push(names[name]);
7882
+ continue;
5327
7883
  }
5328
- return names[name];
5329
- });
7884
+ const owner = [...extensions.values()].find((ext) => typeof name === 'string' && ext.keys.includes(name));
7885
+ if (!owner)
7886
+ throw new Error(`Unknown reset setting: ${String(name)}`);
7887
+ const list = extResetKeys.get(owner) ?? [];
7888
+ list.push(name);
7889
+ extResetKeys.set(owner, list);
7890
+ }
7891
+ if (keys.length === 0)
7892
+ keys = undefined;
7893
+ }
7894
+ const touchedSameRoundThinkTextPolicy = resetsAllSettings || keys?.includes('sameRoundThinkTextPolicy') === true;
7895
+ const extResults = {};
7896
+ if (input.settings === undefined) {
7897
+ // Reset-all covers extensions too.
7898
+ for (const ext of extensions.values()) {
7899
+ if (ext.reset)
7900
+ Object.assign(extResults, ext.reset(agentName));
7901
+ }
7902
+ }
7903
+ else {
7904
+ for (const [ext, list] of extResetKeys) {
7905
+ if (ext.reset)
7906
+ Object.assign(extResults, ext.reset(agentName, list));
7907
+ }
5330
7908
  }
5331
- result = { success: true, data: this.resetAgentRuntimeSettings(agentName, keys) };
7909
+ const coreTouched = input.settings === undefined || keys !== undefined;
7910
+ const core = coreTouched
7911
+ ? this.resetAgentRuntimeSettings(agentName, keys)
7912
+ : this.getAgentRuntimeSettings(agentName);
7913
+ result = {
7914
+ success: true,
7915
+ data: {
7916
+ ...core,
7917
+ ...extGet(),
7918
+ ...extResults,
7919
+ ...(touchedSameRoundThinkTextPolicy
7920
+ ? {
7921
+ sameRoundThinkTextPolicyUpdateNote: 'Reset now; restored provider think routing and description apply beginning with the next inference.',
7922
+ }
7923
+ : {}),
7924
+ },
7925
+ };
5332
7926
  break;
5333
7927
  }
5334
7928
  default: