@animalabs/agent-framework 0.6.6 → 0.6.8

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 (117) hide show
  1. package/README.md +78 -0
  2. package/dist/src/agent.d.ts +5 -1
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +35 -1
  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/framework.d.ts +91 -3
  11. package/dist/src/framework.d.ts.map +1 -1
  12. package/dist/src/framework.js +988 -82
  13. package/dist/src/framework.js.map +1 -1
  14. package/dist/src/gate/event-gate.d.ts.map +1 -1
  15. package/dist/src/gate/event-gate.js +9 -2
  16. package/dist/src/gate/event-gate.js.map +1 -1
  17. package/dist/src/index.d.ts +4 -0
  18. package/dist/src/index.d.ts.map +1 -1
  19. package/dist/src/index.js +3 -0
  20. package/dist/src/index.js.map +1 -1
  21. package/dist/src/mcpl/channel-registry.d.ts +3 -1
  22. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  23. package/dist/src/mcpl/channel-registry.js +29 -15
  24. package/dist/src/mcpl/channel-registry.js.map +1 -1
  25. package/dist/src/mcpl/server-connection.d.ts +36 -3
  26. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  27. package/dist/src/mcpl/server-connection.js +142 -28
  28. package/dist/src/mcpl/server-connection.js.map +1 -1
  29. package/dist/src/modules/workspace/index.d.ts +12 -1
  30. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  31. package/dist/src/modules/workspace/index.js +661 -2
  32. package/dist/src/modules/workspace/index.js.map +1 -1
  33. package/dist/src/modules/workspace/types.d.ts +4 -0
  34. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  35. package/dist/src/modules/workspace/types.js.map +1 -1
  36. package/dist/src/recovery/discord-awareness-outbox.d.ts +99 -0
  37. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -0
  38. package/dist/src/recovery/discord-awareness-outbox.js +334 -0
  39. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -0
  40. package/dist/src/recovery/offline-branch.d.ts +45 -0
  41. package/dist/src/recovery/offline-branch.d.ts.map +1 -0
  42. package/dist/src/recovery/offline-branch.js +362 -0
  43. package/dist/src/recovery/offline-branch.js.map +1 -0
  44. package/dist/src/recovery/recover-cli.d.ts +3 -0
  45. package/dist/src/recovery/recover-cli.d.ts.map +1 -0
  46. package/dist/src/recovery/recover-cli.js +129 -0
  47. package/dist/src/recovery/recover-cli.js.map +1 -0
  48. package/dist/src/types/agent.d.ts +10 -0
  49. package/dist/src/types/agent.d.ts.map +1 -1
  50. package/dist/src/types/framework.d.ts +14 -0
  51. package/dist/src/types/framework.d.ts.map +1 -1
  52. package/dist/src/types/module.d.ts +31 -0
  53. package/dist/src/types/module.d.ts.map +1 -1
  54. package/dist/src/types/trace.d.ts +2 -0
  55. package/dist/src/types/trace.d.ts.map +1 -1
  56. package/dist/test/agent-runtime-settings.test.js +200 -93
  57. package/dist/test/agent-runtime-settings.test.js.map +1 -1
  58. package/dist/test/busy-agent-scheduler.test.d.ts +2 -0
  59. package/dist/test/busy-agent-scheduler.test.d.ts.map +1 -0
  60. package/dist/test/busy-agent-scheduler.test.js +62 -0
  61. package/dist/test/busy-agent-scheduler.test.js.map +1 -0
  62. package/dist/test/channel-lifecycle-provenance.test.d.ts +2 -0
  63. package/dist/test/channel-lifecycle-provenance.test.d.ts.map +1 -0
  64. package/dist/test/channel-lifecycle-provenance.test.js +47 -0
  65. package/dist/test/channel-lifecycle-provenance.test.js.map +1 -0
  66. package/dist/test/discord-awareness-outbox.test.d.ts +2 -0
  67. package/dist/test/discord-awareness-outbox.test.d.ts.map +1 -0
  68. package/dist/test/discord-awareness-outbox.test.js +248 -0
  69. package/dist/test/discord-awareness-outbox.test.js.map +1 -0
  70. package/dist/test/maintenance-timer.test.js +100 -0
  71. package/dist/test/maintenance-timer.test.js.map +1 -1
  72. package/dist/test/mcpl-awareness-barrier.test.d.ts +2 -0
  73. package/dist/test/mcpl-awareness-barrier.test.d.ts.map +1 -0
  74. package/dist/test/mcpl-awareness-barrier.test.js +618 -0
  75. package/dist/test/mcpl-awareness-barrier.test.js.map +1 -0
  76. package/dist/test/mcpl-reconnect-reregistration.test.js +22 -3
  77. package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -1
  78. package/dist/test/mcpl-request-timeout.test.js +15 -0
  79. package/dist/test/mcpl-request-timeout.test.js.map +1 -1
  80. package/dist/test/offline-recovery-branch.test.d.ts +2 -0
  81. package/dist/test/offline-recovery-branch.test.d.ts.map +1 -0
  82. package/dist/test/offline-recovery-branch.test.js +226 -0
  83. package/dist/test/offline-recovery-branch.test.js.map +1 -0
  84. package/dist/test/present-while-acting.test.d.ts +3 -2
  85. package/dist/test/present-while-acting.test.d.ts.map +1 -1
  86. package/dist/test/present-while-acting.test.js +31 -3
  87. package/dist/test/present-while-acting.test.js.map +1 -1
  88. package/dist/test/refusal-single-call.test.d.ts +2 -0
  89. package/dist/test/refusal-single-call.test.d.ts.map +1 -0
  90. package/dist/test/refusal-single-call.test.js +177 -0
  91. package/dist/test/refusal-single-call.test.js.map +1 -0
  92. package/dist/test/same-round-think-policy.test.d.ts +2 -0
  93. package/dist/test/same-round-think-policy.test.d.ts.map +1 -0
  94. package/dist/test/same-round-think-policy.test.js +853 -0
  95. package/dist/test/same-round-think-policy.test.js.map +1 -0
  96. package/dist/test/workspace-read-image.test.d.ts +2 -0
  97. package/dist/test/workspace-read-image.test.d.ts.map +1 -0
  98. package/dist/test/workspace-read-image.test.js +518 -0
  99. package/dist/test/workspace-read-image.test.js.map +1 -0
  100. package/dist/tsconfig.tsbuildinfo +1 -1
  101. package/package.json +5 -3
  102. package/src/agent.ts +39 -2
  103. package/src/api/server.ts +8 -2
  104. package/src/framework.ts +1180 -79
  105. package/src/gate/event-gate.ts +15 -3
  106. package/src/index.ts +20 -0
  107. package/src/mcpl/channel-registry.ts +31 -16
  108. package/src/mcpl/server-connection.ts +163 -28
  109. package/src/modules/workspace/index.ts +759 -2
  110. package/src/modules/workspace/types.ts +5 -0
  111. package/src/recovery/discord-awareness-outbox.ts +459 -0
  112. package/src/recovery/offline-branch.ts +510 -0
  113. package/src/recovery/recover-cli.ts +130 -0
  114. package/src/types/agent.ts +14 -0
  115. package/src/types/framework.ts +17 -0
  116. package/src/types/module.ts +33 -0
  117. package/src/types/trace.ts +2 -0
package/src/framework.ts CHANGED
@@ -47,6 +47,8 @@ import type {
47
47
  AgentRuntimeSettingsPatch,
48
48
  AgentRuntimeSettingsOverrides,
49
49
  AgentRuntimeSettingsSnapshot,
50
+ AgentSettingsExtension,
51
+ SameRoundThinkTextPolicy,
50
52
  } from './types/index.js';
51
53
  import { ProcessQueueImpl } from './queue.js';
52
54
  import { Agent } from './agent.js';
@@ -60,6 +62,7 @@ import { InferenceRouter } from './mcpl/inference-router.js';
60
62
  import { ChannelRegistry } from './mcpl/channel-registry.js';
61
63
  import { ConversationRouter } from './mcpl/conversation-router.js';
62
64
  import { safeSlice } from './safe-slice.js';
65
+ import type { WorkspaceModule } from './modules/workspace/index.js';
63
66
  import { toolResultDataToHistoryString } from './tool-result-history.js';
64
67
  import { splitProseSegments } from './prose-segments.js';
65
68
 
@@ -71,15 +74,14 @@ import { splitProseSegments } from './prose-segments.js';
71
74
  * - explicit delivery tools (channel_publish / *send_message /
72
75
  * *reply_message / *send_dm) — already sent the message, so routing the
73
76
  * prose again would double-post.
74
- * `think` is deliberately NOT here: it is silent *reasoning*, but prose
75
- * written around a think is the agent's actual voice and must be delivered.
77
+ * `think` is deliberately NOT here: it is silent *reasoning*, and same-round
78
+ * prose beside it is governed separately by same_round_think_text_policy.
76
79
  *
77
- * Scope: mid-turn rounds apply this PER ROUND (a round's prose is suppressed
78
- * only by a silencing tool in that same round — live delivery can't be
79
- * retracted by a later round's skip_reply); the turn's TRAILING prose keeps
80
- * the historical turn-wide rule (any silencing tool anywhere in the turn
81
- * suppresses it), preserving the "send_message then 'sent it.' postscript"
82
- * double-post guard.
80
+ * Scope: an explicit delivery suppresses prose from that round onward until
81
+ * another external message is injected. The suppression prevents a
82
+ * `send_message` followed by "sent it" from double-posting, but a new message
83
+ * starts a new conversational round and must be answerable with plain prose.
84
+ * `skip_reply` ends the turn at the tool-result boundary.
83
85
  */
84
86
  const SILENCING_TOOLS = new Set([
85
87
  'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
@@ -108,6 +110,12 @@ import type {
108
110
  } from './mcpl/types.js';
109
111
  import type { ContextInjection } from '@animalabs/context-manager';
110
112
  import { formatZonedDateTime, resolveTimeZone } from './timezone.js';
113
+ import {
114
+ DEFAULT_DISCORD_AWARENESS_EMOJI,
115
+ DiscordAwarenessOutbox,
116
+ defaultDiscordAwarenessOutboxPath,
117
+ extractDiscordAwarenessRefs,
118
+ } from './recovery/discord-awareness-outbox.js';
111
119
 
112
120
  const FRAMEWORK_STATE_ID = 'framework/state';
113
121
  const CONVERSATION_ROUTER_STATE_ID = 'framework/conversation-router';
@@ -118,6 +126,9 @@ const TURN_CHECKPOINTS_TREE_ID = 'framework/turn-checkpoints/tree';
118
126
 
119
127
  /** Maximum number of turn checkpoints to keep per agent. */
120
128
  const MAX_TURN_CHECKPOINTS = 20;
129
+ const DEFAULT_DISCORD_AWARENESS_DEADLINE_MS = 10_000;
130
+ const MIN_DISCORD_AWARENESS_DEADLINE_MS = 50;
131
+ const MAX_DISCORD_AWARENESS_DEADLINE_MS = 60_000;
121
132
 
122
133
  interface TurnCheckpoint {
123
134
  agentName: string;
@@ -127,11 +138,43 @@ interface TurnCheckpoint {
127
138
  timestamp: number;
128
139
  }
129
140
 
141
+ type DiscordAwarenessDrainOutcome =
142
+ | { status: 'delivered'; delivered: number; failed: number }
143
+ | { status: 'unavailable'; accounted: number };
144
+
145
+ interface DiscordAwarenessBarrier {
146
+ generation: number;
147
+ requiresBarrier: boolean;
148
+ promise: Promise<DiscordAwarenessDrainOutcome>;
149
+ }
150
+
151
+ function normalizeDiscordAwarenessDeadline(value: number | undefined): number {
152
+ if (value === undefined || !Number.isFinite(value) || value <= 0) {
153
+ return DEFAULT_DISCORD_AWARENESS_DEADLINE_MS;
154
+ }
155
+ return Math.max(
156
+ MIN_DISCORD_AWARENESS_DEADLINE_MS,
157
+ Math.min(MAX_DISCORD_AWARENESS_DEADLINE_MS, Math.floor(value)),
158
+ );
159
+ }
160
+
161
+ class DiscordAwarenessAccountingError extends Error {
162
+ constructor(operation: string, cause: unknown) {
163
+ const detail = cause instanceof Error ? cause.message : String(cause);
164
+ super(`Discord awareness accounting failed during ${operation}: ${detail}`, { cause });
165
+ this.name = 'DiscordAwarenessAccountingError';
166
+ }
167
+ }
168
+
130
169
  interface RedoEntry {
131
170
  branchName: string;
132
171
  checkpoint: TurnCheckpoint;
133
172
  }
134
173
 
174
+ interface InferenceToolSnapshot {
175
+ sameRoundThinkTextPolicy: SameRoundThinkTextPolicy;
176
+ }
177
+
135
178
  /**
136
179
  * Default inference policy - infer if any request exists for the agent.
137
180
  */
@@ -145,6 +188,11 @@ class DefaultInferencePolicy implements InferencePolicy {
145
188
  }
146
189
  }
147
190
 
191
+ function isPermanentDiscordReactionFailure(message: string): boolean {
192
+ return /unknown message|unknown channel|missing access|missing permissions|missing permission|cannot access|channel .* not found|message .* not found/i
193
+ .test(message);
194
+ }
195
+
148
196
  /**
149
197
  * Default error policy - retry with exponential backoff.
150
198
  *
@@ -312,7 +360,7 @@ export class AgentFramework {
312
360
  private maintenanceRunId = 0;
313
361
  private currentMaintenanceRun: ContextMaintenanceRun | null = null;
314
362
  private maintenanceHistory: ContextMaintenanceRun[] = [];
315
- /** Last time we console-warned about stale (busy-requeued) inference requests, per agent. */
363
+ /** Last time we reported stale (busy-requeued) inference requests, per agent. */
316
364
  private staleWarnAt = new Map<string, number>();
317
365
  /** Per-agent last inference activity (epoch ms), for /healthz + doctor tooling. */
318
366
  private lastInferenceAt = new Map<string, { startedAt?: number; endedAt?: number; failedAt?: number; lastError?: string }>();
@@ -323,9 +371,17 @@ export class AgentFramework {
323
371
  /** Per-agent output locus pinned for the CURRENT logical turn (see
324
372
  * resolveTurnLocus in driveStream). Lives here — not in driveStream
325
373
  * locals — so the pin survives a context-budget stream restart, which
326
- * continues the same logical turn in a fresh driveStream. Cleared at the
327
- * start of every non-restart turn. */
374
+ * continues the same logical turn in a fresh driveStream. A mid-turn
375
+ * injected channel message replaces the pin for the next conversational
376
+ * round; otherwise it remains stable. Cleared at the start of every
377
+ * non-restart turn. */
328
378
  private turnLocusPins: Map<string, string> = new Map();
379
+ /** A tool boundary injected fresh external input into the live stream.
380
+ * Presence (including a null value) tells driveStream to clear sticky
381
+ * explicit-send suppression before handling the next model round. A
382
+ * string also moves that round's reply locus to the newest injected
383
+ * channel. */
384
+ private midTurnRoutingResets: Map<string, string | null> = new Map();
329
385
  private pendingAssistantBlocks: Map<string, ContentBlock[]> = new Map();
330
386
  /** Streams the FRAMEWORK cancelled for non-terminal reasons, keyed
331
387
  * `${agentName}:${streamId}`: an endTurn tool result or a context-budget
@@ -439,6 +495,15 @@ export class AgentFramework {
439
495
  * subsystem can be lazily initialized by connectMcplServer when the
440
496
  * framework started with zero configured servers. */
441
497
  private mcplInferenceRoutingConfig: import('./mcpl/types.js').InferenceRoutingPolicy | null = null;
498
+ /** Durable, non-Chronicle projection queue for messages removed by a branch. */
499
+ private discordAwarenessOutbox: DiscordAwarenessOutbox | null = null;
500
+ private discordAwarenessEmoji = DEFAULT_DISCORD_AWARENESS_EMOJI;
501
+ private discordAwarenessDeadlineMs = DEFAULT_DISCORD_AWARENESS_DEADLINE_MS;
502
+ /** Serialize per-server drains so reconnect and an online undo cannot race. */
503
+ private discordAwarenessDrains: Map<string, Promise<DiscordAwarenessDrainOutcome>> = new Map();
504
+ /** Framework-global inference gate; older generations cannot release it. */
505
+ private discordAwarenessBarrier: DiscordAwarenessBarrier | null = null;
506
+ private discordAwarenessBarrierGeneration = 0;
442
507
 
443
508
  // EventGate (null when FrameworkConfig.gate is omitted)
444
509
  private eventGate: EventGate | null = null;
@@ -459,6 +524,9 @@ export class AgentFramework {
459
524
  processLoggingPersist: boolean,
460
525
  processLoggingBroadcast: boolean,
461
526
  timeZone: string,
527
+ discordAwarenessOutbox: DiscordAwarenessOutbox | null,
528
+ discordAwarenessEmoji: string,
529
+ discordAwarenessDeadlineMs: number,
462
530
  ) {
463
531
  this.store = store;
464
532
  this.ownsStore = ownsStore;
@@ -470,6 +538,9 @@ export class AgentFramework {
470
538
  this.processLoggingPersist = processLoggingPersist;
471
539
  this.processLoggingBroadcast = processLoggingBroadcast;
472
540
  this.timeZone = timeZone;
541
+ this.discordAwarenessOutbox = discordAwarenessOutbox;
542
+ this.discordAwarenessEmoji = discordAwarenessEmoji;
543
+ this.discordAwarenessDeadlineMs = discordAwarenessDeadlineMs;
473
544
  this.queue = new ProcessQueueImpl();
474
545
  this.usageTracker = new UsageTracker({
475
546
  emitTrace: (e: UsageUpdatedEvent) => this.emitTrace({ ...e }),
@@ -551,6 +622,12 @@ export class AgentFramework {
551
622
  }
552
623
  }
553
624
 
625
+ const discordAwarenessOutboxPath = config.discordAwarenessOutboxPath
626
+ ?? (config.storePath ? defaultDiscordAwarenessOutboxPath(config.storePath) : undefined);
627
+ const discordAwarenessOutbox = discordAwarenessOutboxPath
628
+ ? new DiscordAwarenessOutbox(discordAwarenessOutboxPath)
629
+ : null;
630
+
554
631
  const framework = new AgentFramework(
555
632
  store,
556
633
  ownsStore,
@@ -562,8 +639,33 @@ export class AgentFramework {
562
639
  processLoggingPersist,
563
640
  processLoggingBroadcast,
564
641
  resolveTimeZone(config.timeZone),
642
+ discordAwarenessOutbox,
643
+ config.discordAwarenessEmoji ?? DEFAULT_DISCORD_AWARENESS_EMOJI,
644
+ normalizeDiscordAwarenessDeadline(config.discordAwarenessDeadlineMs),
565
645
  );
566
646
 
647
+ // If an offline recovery process crashed after switching Chronicle but
648
+ // before committing its prepared marker batch, the active branch is the
649
+ // commit record. Promote it now; no quarantined content is read.
650
+ if (discordAwarenessOutbox) {
651
+ try {
652
+ const activated = discordAwarenessOutbox.activatePreparedForBranch(
653
+ store.currentBranch().name,
654
+ store.listBranches(),
655
+ );
656
+ if (activated > 0) {
657
+ console.error(
658
+ `[discord-awareness] recovered ${activated} prepared batch(es) for active branch ${store.currentBranch().name}`,
659
+ );
660
+ }
661
+ } catch (error) {
662
+ // The branch may be safe, but reporting the framework ready while its
663
+ // durable awareness projection is unreadable creates a half-ready
664
+ // host whose data plane can never be released safely.
665
+ throw new DiscordAwarenessAccountingError('startup reconciliation', error);
666
+ }
667
+ }
668
+
567
669
  // Restore persisted usage data (if any) from prior session
568
670
  framework.restoreUsageState();
569
671
 
@@ -572,6 +674,10 @@ export class AgentFramework {
572
674
  await framework.createAgent(agentConfig);
573
675
  }
574
676
 
677
+ // Finish any branch-local suppression interrupted after Chronicle switched
678
+ // branches. This runs before modules, MCPL connections, or inbound traffic.
679
+ await framework.resumePreparedDiscordSuppressions();
680
+
575
681
  // Add modules
576
682
  for (const module of config.modules) {
577
683
  await framework.addModule(module);
@@ -821,10 +927,9 @@ export class AgentFramework {
821
927
  }
822
928
 
823
929
  private async runQueuedMaintenance(): Promise<void> {
824
- const allTools = this.getAllTools();
825
930
  const queued = [...this.agents.values()].flatMap((agent) => {
826
931
  const cm = agent.getContextManager();
827
- const tools = allTools.filter((tool) => agent.canUseTool(tool.name));
932
+ const tools = this.getToolsForAgent(agent.name).filter((tool) => agent.canUseTool(tool.name));
828
933
  cm.setToolDefinitions(tools);
829
934
  if (cm.isReady()) return [];
830
935
  const pending = cm.getPendingWork()?.description;
@@ -1028,10 +1133,138 @@ export class AgentFramework {
1028
1133
  ...this.mcplTools,
1029
1134
  ...channelTools,
1030
1135
  ...gateTools,
1031
- AgentFramework.AGENT_SETTINGS_TOOL,
1136
+ this.buildAgentSettingsTool(),
1137
+ ...(this.getWorkspaceModule() ? [AgentFramework.SAVE_IMAGE_TOOL] : []),
1032
1138
  ];
1033
1139
  }
1034
1140
 
1141
+ /** The registered workspace module, if any (used by save_image). */
1142
+ private getWorkspaceModule(): WorkspaceModule | undefined {
1143
+ const module = this.moduleRegistry.getAllModules().find((m) => m.name === 'workspace');
1144
+ return module && typeof (module as WorkspaceModule).writeBinary === 'function'
1145
+ ? (module as WorkspaceModule)
1146
+ : undefined;
1147
+ }
1148
+
1149
+ /** Core agent_settings keys — extension keys must not collide with these. */
1150
+ private static readonly AGENT_SETTINGS_CORE_KEYS = [
1151
+ 'context_budget_tokens',
1152
+ 'tail_tokens',
1153
+ 'transition_pace_tokens',
1154
+ 'same_round_think_text_policy',
1155
+ ];
1156
+
1157
+ /**
1158
+ * Collect module-declared agent_settings extensions (Module.getAgentSettingsExtension),
1159
+ * keyed by owning module name. Extensions whose keys collide with the core
1160
+ * settings or an earlier extension are skipped loudly — silent shadowing
1161
+ * would make updates route to the wrong owner.
1162
+ */
1163
+ private collectAgentSettingsExtensions(): Map<string, AgentSettingsExtension> {
1164
+ const result = new Map<string, AgentSettingsExtension>();
1165
+ const taken = new Set<string>(AgentFramework.AGENT_SETTINGS_CORE_KEYS);
1166
+ for (const module of this.moduleRegistry.getAllModules()) {
1167
+ const ext = module.getAgentSettingsExtension?.();
1168
+ if (!ext) continue;
1169
+ const collision = ext.keys.find((k) => taken.has(k));
1170
+ if (collision) {
1171
+ console.error(
1172
+ `[agent-settings] extension from module '${module.name}' skipped: key '${collision}' already taken`,
1173
+ );
1174
+ continue;
1175
+ }
1176
+ ext.keys.forEach((k) => taken.add(k));
1177
+ result.set(module.name, ext);
1178
+ }
1179
+ return result;
1180
+ }
1181
+
1182
+ private captureInferenceToolSnapshot(agent: Agent): InferenceToolSnapshot {
1183
+ return {
1184
+ sameRoundThinkTextPolicy: agent.getEffectiveSameRoundThinkTextPolicy(),
1185
+ };
1186
+ }
1187
+
1188
+ private buildThinkTool(
1189
+ policy: SameRoundThinkTextPolicy,
1190
+ ): import('./types/index.js').ToolDefinition {
1191
+ return {
1192
+ name: 'think',
1193
+ description:
1194
+ 'Reason privately. The think({content}) argument stays in your own context and is NOT sent ' +
1195
+ 'to channels or other surfaces. ' +
1196
+ (policy === 'private'
1197
+ ? '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.'
1198
+ : '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.'
1199
+ ) +
1200
+ ' 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.',
1201
+ inputSchema: {
1202
+ type: 'object' as const,
1203
+ properties: {
1204
+ content: {
1205
+ type: 'string',
1206
+ description: 'Your private thought / reasoning (optional; not sent anywhere).',
1207
+ },
1208
+ },
1209
+ required: [],
1210
+ },
1211
+ };
1212
+ }
1213
+
1214
+ /** agent_settings tool definition with module extension fields merged in. */
1215
+ private buildAgentSettingsTool(): import('./types/index.js').ToolDefinition {
1216
+ const base = AgentFramework.AGENT_SETTINGS_TOOL;
1217
+ const extensions = this.collectAgentSettingsExtensions();
1218
+ if (extensions.size === 0) return base;
1219
+ const baseSchema = base.inputSchema as {
1220
+ type: string;
1221
+ properties: Record<string, unknown>;
1222
+ required?: string[];
1223
+ };
1224
+ const extProperties: Record<string, unknown> = {};
1225
+ const extKeys: string[] = [];
1226
+ for (const ext of extensions.values()) {
1227
+ Object.assign(extProperties, ext.properties);
1228
+ extKeys.push(...ext.keys);
1229
+ }
1230
+ const settingsProp = baseSchema.properties.settings as { items?: { enum?: string[] } };
1231
+ return {
1232
+ ...base,
1233
+ description:
1234
+ base.description +
1235
+ ` Additional host-managed settings also live here: ${extKeys.join(', ')}.`,
1236
+ inputSchema: {
1237
+ ...baseSchema,
1238
+ properties: {
1239
+ ...baseSchema.properties,
1240
+ ...extProperties,
1241
+ settings: {
1242
+ ...(baseSchema.properties.settings as Record<string, unknown>),
1243
+ items: {
1244
+ ...(settingsProp.items as Record<string, unknown>),
1245
+ enum: [...(settingsProp.items?.enum ?? []), ...extKeys],
1246
+ },
1247
+ },
1248
+ },
1249
+ } as unknown as import('./types/index.js').ToolDefinition['inputSchema'],
1250
+ };
1251
+ }
1252
+
1253
+ private getToolsForAgent(
1254
+ agentName: string,
1255
+ snapshot?: InferenceToolSnapshot,
1256
+ ): import('./types/index.js').ToolDefinition[] {
1257
+ return this.getAllTools().map((tool) => {
1258
+ if (tool.name === 'think') {
1259
+ return this.buildThinkTool(
1260
+ snapshot?.sameRoundThinkTextPolicy
1261
+ ?? this.getAgentRuntimeSettings(agentName).sameRoundThinkTextPolicy,
1262
+ );
1263
+ }
1264
+ return tool;
1265
+ });
1266
+ }
1267
+
1035
1268
  getAgentRuntimeSettings(agentName: string): AgentRuntimeSettingsSnapshot {
1036
1269
  const agent = this.agents.get(agentName);
1037
1270
  if (!agent) throw new Error(`Unknown agent: ${agentName}`);
@@ -1124,7 +1357,7 @@ export class AgentFramework {
1124
1357
  throw new Error(`Agent not found: ${agentName}`);
1125
1358
  }
1126
1359
 
1127
- const tools = this.getAllTools().filter((t) => agent.canUseTool(t.name));
1360
+ const tools = this.getToolsForAgent(agentName).filter((t) => agent.canUseTool(t.name));
1128
1361
 
1129
1362
  // Default: no dynamic injection gathering → fully transparent (no
1130
1363
  // inference, no Chronicle writes, no external RPC). Opt in explicitly.
@@ -1279,6 +1512,22 @@ export class AgentFramework {
1279
1512
  }
1280
1513
  }
1281
1514
 
1515
+ private validatePersistedAgentRuntimeSettings(
1516
+ agentName: string,
1517
+ overrides: AgentRuntimeSettingsOverrides,
1518
+ ): AgentRuntimeSettingsOverrides {
1519
+ if (
1520
+ overrides.sameRoundThinkTextPolicy !== undefined &&
1521
+ overrides.sameRoundThinkTextPolicy !== 'public' &&
1522
+ overrides.sameRoundThinkTextPolicy !== 'private'
1523
+ ) {
1524
+ throw new Error(
1525
+ `Invalid persisted sameRoundThinkTextPolicy for agent "${agentName}": ${JSON.stringify(overrides.sameRoundThinkTextPolicy)}`,
1526
+ );
1527
+ }
1528
+ return overrides;
1529
+ }
1530
+
1282
1531
  private persistAgentRuntimeSettings(
1283
1532
  agentName: string,
1284
1533
  overrides: AgentRuntimeSettingsOverrides,
@@ -1633,11 +1882,44 @@ export class AgentFramework {
1633
1882
  inputSchema: { type: 'object' },
1634
1883
  };
1635
1884
 
1885
+ /** Synthesized save_image tool — present when a workspace module is
1886
+ * registered. Lets the agent persist an image it has already seen in its
1887
+ * own context (Discord attachments arrive inlined as base64 and are
1888
+ * otherwise unreachable as files). */
1889
+ private static readonly SAVE_IMAGE_TOOL: import('./types/index.js').ToolDefinition = {
1890
+ name: 'save_recent_image',
1891
+ description:
1892
+ 'Save one or more recent images from your own context to workspace files. ' +
1893
+ 'Images are counted back from the most recent (index 0). A single image is ' +
1894
+ 'written to `path` as given (e.g. "project/photos/cat.png"); when `count` > 1 ' +
1895
+ 'the range index..index+count-1 is saved with numeric suffixes ' +
1896
+ '("cat-0.png", "cat-1.png", …; 0 = the newest of the range). Saved files are ' +
1897
+ 'visible via workspace tools and the /files/ endpoint.',
1898
+ inputSchema: {
1899
+ type: 'object',
1900
+ properties: {
1901
+ path: {
1902
+ type: 'string',
1903
+ description: 'Mount-prefixed destination path, e.g. "project/photos/name.png".',
1904
+ },
1905
+ index: {
1906
+ type: 'number',
1907
+ description: 'Which image, counting back from the most recent (0 = most recent). Default 0.',
1908
+ },
1909
+ count: {
1910
+ type: 'number',
1911
+ description: 'How many images to save, starting at `index` and going further back. Default 1.',
1912
+ },
1913
+ },
1914
+ required: ['path'],
1915
+ },
1916
+ };
1917
+
1636
1918
  private static readonly AGENT_SETTINGS_TOOL: import('./types/index.js').ToolDefinition = {
1637
1919
  name: 'agent_settings',
1638
1920
  description:
1639
1921
  'Read or change your hot runtime settings. This intentionally exposes only ' +
1640
- 'context budget, recent raw tail size, and transition pace; model, prompts, ' +
1922
+ 'context budget, recent raw tail size, transition pace, and same-round think text routing; model, prompts, ' +
1641
1923
  'folding strategy, and other restart-bound configuration are not mutable here. ' +
1642
1924
  'Lower context budgets converge gradually under the transition pace before ' +
1643
1925
  'becoming the hard live limit; increases take effect immediately.',
@@ -1648,9 +1930,16 @@ export class AgentFramework {
1648
1930
  context_budget_tokens: { type: 'number', description: 'Total input context budget, including the reserved response allowance.' },
1649
1931
  tail_tokens: { type: 'number', description: 'Recent raw context retained verbatim.' },
1650
1932
  transition_pace_tokens: { type: 'number', description: 'Maximum ordinary KV re-read/perturbation per compile while converging.' },
1933
+ same_round_think_text_policy: {
1934
+ type: 'string',
1935
+ enum: ['public', 'private'],
1936
+ description:
1937
+ 'Routing policy for ordinary text emitted in the same native assistant round as think(). ' +
1938
+ "Omitted in the recipe preserves the compatibility carry-forward: public.",
1939
+ },
1651
1940
  settings: {
1652
1941
  type: 'array',
1653
- items: { type: 'string', enum: ['context_budget_tokens', 'tail_tokens', 'transition_pace_tokens'] },
1942
+ items: { type: 'string', enum: ['context_budget_tokens', 'tail_tokens', 'transition_pace_tokens', 'same_round_think_text_policy'] },
1654
1943
  description: 'For reset: settings to restore to recipe values. Omit to reset all.',
1655
1944
  },
1656
1945
  },
@@ -1943,6 +2232,9 @@ export class AgentFramework {
1943
2232
  undone?: number;
1944
2233
  requested?: number;
1945
2234
  messagesRemoved?: number;
2235
+ /** Discord addresses removed by message-granular undo. The durable outbox
2236
+ * owns eventual delivery; this is also returned for immediate surfaces. */
2237
+ removedRefs?: Array<{ serverId: string; channelId: string; messageId: string }>;
1946
2238
  hidden?: number;
1947
2239
  /** For `hide`: the Discord (channelId, messageId) of each removed message
1948
2240
  * that carried one — so the surface can mark them with a reaction. */
@@ -2089,8 +2381,26 @@ export class AgentFramework {
2089
2381
  };
2090
2382
  }
2091
2383
  const target = allMessages[allMessages.length - 1 - n];
2092
- const branchName = cm.branchAt(target.id, `undo-msgs/${agentName}/${Date.now()}`);
2384
+ const discarded = allMessages.slice(allMessages.length - n);
2385
+ const removedRefs = extractDiscordAwarenessRefs(discarded);
2386
+ const sourceBranch = this.store.currentBranch().name;
2387
+ const targetBranch = `undo-msgs/${agentName}/${Date.now()}`;
2388
+
2389
+ // Prepare the external side effect before switching Chronicle. If the
2390
+ // process dies after the switch but before activate(), startup promotes
2391
+ // this batch by matching targetBranch to the active branch.
2392
+ const markerBatch = this.discordAwarenessOutbox?.prepare({
2393
+ agentName,
2394
+ sourceBranch,
2395
+ targetBranch,
2396
+ refs: removedRefs,
2397
+ emoji: this.discordAwarenessEmoji,
2398
+ }) ?? null;
2399
+
2400
+ const branchName = cm.branchAt(target.id, targetBranch);
2093
2401
  await cm.switchBranch(branchName);
2402
+ if (markerBatch) this.discordAwarenessOutbox!.activate(markerBatch.id);
2403
+ await this.syncDiscordAwarenessMarkers();
2094
2404
 
2095
2405
  // Materialize config files from the new branch (fire-and-forget; gate
2096
2406
  // picks up via mtime) — mirrors undoLastTurn.
@@ -2109,6 +2419,7 @@ export class AgentFramework {
2109
2419
  return {
2110
2420
  ok: true,
2111
2421
  messagesRemoved: n,
2422
+ removedRefs,
2112
2423
  lastVisible: await this.lastVisiblePreview(agentName),
2113
2424
  };
2114
2425
  }
@@ -2138,6 +2449,8 @@ export class AgentFramework {
2138
2449
  return { ok: true, undone: 0, requested, lastVisible: null };
2139
2450
  }
2140
2451
 
2452
+ await this.syncDiscordAwarenessMarkers();
2453
+
2141
2454
  return { ok: true, undone, requested, lastVisible: await this.lastVisiblePreview(agentName) };
2142
2455
  }
2143
2456
 
@@ -2727,7 +3040,11 @@ export class AgentFramework {
2727
3040
 
2728
3041
  const agent = new Agent(config, contextManager, this.membrane);
2729
3042
  const restoredSettings = this.readAgentRuntimeSettings(config.name);
2730
- if (restoredSettings) agent.restoreRuntimeSettings(restoredSettings);
3043
+ if (restoredSettings) {
3044
+ agent.restoreRuntimeSettings(
3045
+ this.validatePersistedAgentRuntimeSettings(config.name, restoredSettings),
3046
+ );
3047
+ }
2731
3048
  this.agents.set(config.name, agent);
2732
3049
  this.agentConfigs.set(config.name, config);
2733
3050
 
@@ -2763,12 +3080,21 @@ export class AgentFramework {
2763
3080
  // Check for inference requests
2764
3081
  await this.processInferenceRequests();
2765
3082
 
2766
- // Yield to the event loop between iterations.
2767
- // Full 10ms sleep when truly idle; minimal yield when streams are active
2768
- // (needed to let stream microtasks and tool-call callbacks execute).
2769
- if (!event && this.pendingRequests.length === 0) {
3083
+ // Yield to the event loop between iterations. A pending inference request
3084
+ // is not necessarily runnable: while its agent is streaming or waiting for
3085
+ // tools, processInferenceRequests() deliberately requeues it. Treating that
3086
+ // requeued request as "work made progress" creates a microtask-only polling
3087
+ // loop. If activeStreams bookkeeping is absent/stale at the same time, the
3088
+ // old code had no await at all and starved the tool-result and HTTP I/O that
3089
+ // could make the agent runnable again (the Sol outage, 2026-07-15).
3090
+ //
3091
+ // No queue event means no foreground progress, so always take the normal
3092
+ // polling backoff. After an event, retain the low-latency macrotask yield
3093
+ // while background work remains. Both paths give Bun/Node a real event-loop
3094
+ // turn; neither can recurse forever through already-resolved promises.
3095
+ if (!event) {
2770
3096
  await new Promise((resolve) => setTimeout(resolve, 10));
2771
- } else if (this.activeStreams.size > 0) {
3097
+ } else if (this.pendingRequests.length > 0 || this.activeStreams.size > 0) {
2772
3098
  await new Promise((resolve) => setTimeout(resolve, 0));
2773
3099
  }
2774
3100
  }
@@ -2883,6 +3209,25 @@ export class AgentFramework {
2883
3209
  }
2884
3210
  }
2885
3211
 
3212
+ // A newly injected message begins a new conversational round inside
3213
+ // the same provider inference. Remember that boundary for
3214
+ // driveStream: an explicit send in the preceding round must not
3215
+ // silence the reply, and the newest channel-bearing injection is
3216
+ // now the reply locus. Map presence matters even without a channel
3217
+ // (CLI/module input): it still clears the prior send suppression.
3218
+ if (midTurnInjections.length > 0) {
3219
+ const injectedChannelId = midTurnInjections.reduce<string | null>(
3220
+ (latest, injection) => {
3221
+ const candidate = injection.metadata?.channelId;
3222
+ return typeof candidate === 'string' && candidate.length > 0
3223
+ ? candidate
3224
+ : latest;
3225
+ },
3226
+ null,
3227
+ );
3228
+ this.midTurnRoutingResets.set(agent.name, injectedChannelId);
3229
+ }
3230
+
2886
3231
  // Check if any tool result requested endTurn
2887
3232
  const shouldEndTurn = currentState.toolResults.some(tc => tc.result.endTurn);
2888
3233
 
@@ -3546,7 +3891,15 @@ export class AgentFramework {
3546
3891
  if (agent.state.status === 'inferring' || agent.state.status === 'streaming' || agent.state.status === 'waiting_for_tools') {
3547
3892
  // Re-queue requests, but warn if they've been pending too long
3548
3893
  const oldest = Math.min(...requests.map(r => r.timestamp));
3549
- if (now - oldest > STALE_REQUEST_MS) {
3894
+ if (
3895
+ now - oldest > STALE_REQUEST_MS &&
3896
+ (this.staleWarnAt.get(agentName) ?? 0) < now - 60_000
3897
+ ) {
3898
+ // Trace and stderr share the throttle. The old code throttled only
3899
+ // stderr, so a long-running tool call generated one trace per poll
3900
+ // (up to 100/sec after the scheduler backoff), adding avoidable work
3901
+ // precisely while the agent was already under pressure.
3902
+ this.staleWarnAt.set(agentName, now);
3550
3903
  this.emitTrace({
3551
3904
  type: 'inference:request_stale',
3552
3905
  agentName,
@@ -3554,14 +3907,10 @@ export class AgentFramework {
3554
3907
  requestCount: requests.length,
3555
3908
  oldestRequestAge: now - oldest,
3556
3909
  });
3557
- // Loud (but throttled) note that requests are waiting on a busy agent.
3558
- if ((this.staleWarnAt.get(agentName) ?? 0) < now - 60_000) {
3559
- this.staleWarnAt.set(agentName, now);
3560
- console.error(
3561
- `[inference-stale] agent=${agentName} busy (${agent.state.status}) — ` +
3562
- `${requests.length} request(s) waiting ${Math.round((now - oldest) / 1000)}s`,
3563
- );
3564
- }
3910
+ console.error(
3911
+ `[inference-stale] agent=${agentName} busy (${agent.state.status}) — ` +
3912
+ `${requests.length} request(s) waiting ${Math.round((now - oldest) / 1000)}s`,
3913
+ );
3565
3914
  }
3566
3915
  this.pendingRequests.push(...requests);
3567
3916
  continue;
@@ -3622,7 +3971,8 @@ export class AgentFramework {
3622
3971
  this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
3623
3972
 
3624
3973
  try {
3625
- const allTools = this.getAllTools();
3974
+ const requestSnapshot = this.captureInferenceToolSnapshot(agent);
3975
+ const allTools = this.getToolsForAgent(agent.name, requestSnapshot);
3626
3976
  const tools = allTools.filter((t) => agent.canUseTool(t.name));
3627
3977
 
3628
3978
  // Gather context from modules (pull-based) and MCPL hooks (push-based)
@@ -3664,7 +4014,14 @@ export class AgentFramework {
3664
4014
 
3665
4015
  const { stream, request: compiledRequest } = await agent.startStreamWithInjections(tools, injections);
3666
4016
 
3667
- const handle = this.driveStream(agent, stream, trigger, attempt, compiledRequest);
4017
+ const handle = this.driveStream(
4018
+ agent,
4019
+ stream,
4020
+ requestSnapshot,
4021
+ trigger,
4022
+ attempt,
4023
+ compiledRequest,
4024
+ );
3668
4025
  this.activeStreams.set(agent.name, handle);
3669
4026
  } catch (error) {
3670
4027
  const err = error instanceof Error ? error : new Error(String(error));
@@ -3710,6 +4067,7 @@ export class AgentFramework {
3710
4067
  private async driveStream(
3711
4068
  agent: Agent,
3712
4069
  stream: YieldingStream,
4070
+ requestSnapshot: InferenceToolSnapshot,
3713
4071
  trigger?: InferenceRequest,
3714
4072
  attempt = 0,
3715
4073
  compiledRequest?: NormalizedRequest
@@ -3720,17 +4078,15 @@ export class AgentFramework {
3720
4078
  let hadToolCalls = false;
3721
4079
 
3722
4080
  // ---- Present-while-acting turn state ---------------------------------
3723
- // Output locus for THIS turn: resolved lazily at the first routed prose
3724
- // segment and pinned for the rest of the turn (mid-turn rounds AND the
3725
- // trailing prose at 'complete'), so one turn's voice stays in one channel
3726
- // even when a queued inbound from another channel overwrites the
3727
- // per-agent triggering channel between segments (PR #32). The pin lives
3728
- // in `turnLocusPins` so it survives a context-budget stream restart. A
3729
- // null resolution is NOT pinned — a turn whose locus is unresolvable
3730
- // early (e.g. heartbeat-triggered) may still resolve a channel by the
3731
- // time later prose routes.
4081
+ // Output locus for the current CONVERSATIONAL ROUND: resolved lazily and
4082
+ // kept stable across tool-only rounds. A channel message injected at a
4083
+ // tool boundary replaces the pin before the next model round, because a
4084
+ // single provider inference can now contain several conversations. The
4085
+ // pin lives in `turnLocusPins` so it survives a context-budget restart. A
4086
+ // null resolution is not pinned.
3732
4087
  if (trigger?.reason !== 'context_budget_restart') {
3733
4088
  this.turnLocusPins.delete(agent.name);
4089
+ this.midTurnRoutingResets.delete(agent.name);
3734
4090
  }
3735
4091
  const resolveTurnLocus = (): string | null => {
3736
4092
  let locus = this.turnLocusPins.get(agent.name) ?? null;
@@ -3757,10 +4113,9 @@ export class AgentFramework {
3757
4113
  .catch((err) => console.error('mid-turn speech routing failed:', err));
3758
4114
  };
3759
4115
 
3760
- // Sticky silencing: once any round uses a SILENCING_TOOLS member,
3761
- // prose from that round ONWARD is suppressed — skip_reply means "stay
3762
- // silent", an explicit send means "I deliver my own words". Earlier
3763
- // rounds' prose was already delivered live and cannot be retracted.
4116
+ // Sticky explicit-send suppression: prose after send_message stays quiet
4117
+ // to prevent a redundant "sent it" postscript. Fresh injected input
4118
+ // clears it, because the following prose is a reply to a new message.
3764
4119
  let turnSilenced = false;
3765
4120
 
3766
4121
  // Live routing is only trusted when the membrane provides verbatim
@@ -3777,11 +4132,30 @@ export class AgentFramework {
3777
4132
  // responding to, for the whole duration of this turn. Started here (paired
3778
4133
  // with the finally below, so it can never leak) and refreshed on a 7s
3779
4134
  // interval by the ChannelRegistry until stopped on any exit path.
3780
- const typingChannel = this.channelRegistry
4135
+ let typingChannel = this.channelRegistry
3781
4136
  ? trigger?.channelId ?? this.channelRegistry.getDefaultPublishChannel()
3782
4137
  : null;
3783
4138
  if (typingChannel) this.channelRegistry!.startTyping(typingChannel);
3784
4139
 
4140
+ const adoptInjectedRound = (): void => {
4141
+ if (!this.midTurnRoutingResets.has(agent.name)) return;
4142
+ const nextLocus = this.midTurnRoutingResets.get(agent.name) ?? null;
4143
+ this.midTurnRoutingResets.delete(agent.name);
4144
+
4145
+ // A new human/agent message, not merely a tool result, means any earlier
4146
+ // explicit delivery has completed its conversational job.
4147
+ turnSilenced = false;
4148
+
4149
+ if (nextLocus) {
4150
+ this.turnLocusPins.set(agent.name, nextLocus);
4151
+ if (typingChannel !== nextLocus) {
4152
+ if (typingChannel) this.channelRegistry?.stopTyping(typingChannel);
4153
+ typingChannel = nextLocus;
4154
+ this.channelRegistry?.startTyping(nextLocus);
4155
+ }
4156
+ }
4157
+ };
4158
+
3785
4159
  try {
3786
4160
  for await (const event of stream) {
3787
4161
  this.touchEphemeralRun(agent.name, true);
@@ -3809,6 +4183,7 @@ export class AgentFramework {
3809
4183
  }
3810
4184
 
3811
4185
  case 'tool-calls': {
4186
+ adoptInjectedRound();
3812
4187
  hadToolCalls = true;
3813
4188
  this.recordEphemeralToolCalls(agent.name, event.calls.length);
3814
4189
  this.emitTrace({
@@ -3866,6 +4241,9 @@ export class AgentFramework {
3866
4241
  // note above — the fallback preamble is cumulative in XML mode).
3867
4242
  if (this.channelRegistry) {
3868
4243
  const roundToolNames = event.calls.map((c) => c.name);
4244
+ const hasSameRoundPrivateThink =
4245
+ roundToolNames.includes('think') &&
4246
+ requestSnapshot.sameRoundThinkTextPolicy === 'private';
3869
4247
  if (roundToolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)))) {
3870
4248
  turnSilenced = true;
3871
4249
  }
@@ -3877,6 +4255,10 @@ export class AgentFramework {
3877
4255
  console.error(
3878
4256
  `[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (turn silenced)`,
3879
4257
  );
4258
+ } else if (hasSameRoundPrivateThink) {
4259
+ console.error(
4260
+ `[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (same_round_think_text_policy=private)`,
4261
+ );
3880
4262
  } else {
3881
4263
  const locus = resolveTurnLocus();
3882
4264
  console.error(
@@ -3894,6 +4276,7 @@ export class AgentFramework {
3894
4276
  }
3895
4277
 
3896
4278
  case 'complete': {
4279
+ adoptInjectedRound();
3897
4280
  const durationMs = Date.now() - startTime;
3898
4281
  const response = event.response;
3899
4282
 
@@ -4209,15 +4592,17 @@ export class AgentFramework {
4209
4592
  // speech/thoughts split is left untouched (module/TUI rendering
4210
4593
  // unaffected) — this only governs what reaches the channel.
4211
4594
  //
4212
- // Silencing here is the sticky flag ∪ turn-wide scan: the scan
4213
- // (over the whole turn's tool_use blocks) covers the fallback
4214
- // path and the "send_message then a 'sent it.' postscript"
4215
- // double-post guard; the sticky flag is its live-rounds twin.
4595
+ // Native round routing uses the live sticky flag, which a fresh
4596
+ // injected message can reset. The legacy/fallback path has no
4597
+ // reliable round boundaries, so retain its historical turn-wide
4598
+ // scan to avoid double-posting.
4216
4599
  const toolNames = response.content
4217
4600
  .filter((b) => b.type === 'tool_use')
4218
4601
  .map((b) => (b as unknown as { name?: string }).name)
4219
4602
  .filter((n): n is string => typeof n === 'string');
4220
- const silenced = turnSilenced || toolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)));
4603
+ const silenced = liveProseRouting
4604
+ ? turnSilenced
4605
+ : turnSilenced || toolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)));
4221
4606
 
4222
4607
  const segments = splitProseSegments(liveProseRouting ? terminalContent : response.content);
4223
4608
 
@@ -4589,6 +4974,51 @@ export class AgentFramework {
4589
4974
  return undefined;
4590
4975
  }
4591
4976
 
4977
+ private approximateDecodedBase64Bytes(base64: string): number {
4978
+ const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
4979
+ return Math.max(0, Math.floor(base64.length * 3 / 4) - padding);
4980
+ }
4981
+
4982
+ private sanitizeProcessLogValue(value: unknown, seen = new WeakMap<object, unknown>()): unknown {
4983
+ if (value && typeof value === 'object') {
4984
+ const seenValue = seen.get(value);
4985
+ if (seenValue) return seenValue;
4986
+ }
4987
+ if (Array.isArray(value)) {
4988
+ const clone: unknown[] = [];
4989
+ seen.set(value, clone);
4990
+ for (const item of value) {
4991
+ clone.push(this.sanitizeProcessLogValue(item, seen));
4992
+ }
4993
+ return clone;
4994
+ }
4995
+ if (!value || typeof value !== 'object') {
4996
+ return value;
4997
+ }
4998
+
4999
+ const record = value as Record<string, unknown>;
5000
+ if (record.type === 'image' && typeof record.data === 'string' && typeof record.mimeType === 'string') {
5001
+ const clone: Record<string, unknown> = {};
5002
+ seen.set(value, clone);
5003
+ for (const [key, child] of Object.entries(record)) {
5004
+ if (key === 'data' || key === 'mimeType') continue;
5005
+ clone[key] = this.sanitizeProcessLogValue(child, seen);
5006
+ }
5007
+ clone.type = 'image';
5008
+ clone.mimeType = record.mimeType;
5009
+ clone.approxByteLength = this.approximateDecodedBase64Bytes(record.data);
5010
+ clone.redacted = true;
5011
+ return clone;
5012
+ }
5013
+
5014
+ const clone: Record<string, unknown> = {};
5015
+ seen.set(value, clone);
5016
+ for (const [key, child] of Object.entries(record)) {
5017
+ clone[key] = this.sanitizeProcessLogValue(child, seen);
5018
+ }
5019
+ return clone;
5020
+ }
5021
+
4592
5022
  private logInference(entry: InferenceLogEntry): void {
4593
5023
  // Store large request/response as blobs
4594
5024
  const entryToStore = { ...entry };
@@ -4622,15 +5052,15 @@ export class AgentFramework {
4622
5052
  private logProcessEvent(event: ProcessEvent, responses: ModuleProcessResponse[]): void {
4623
5053
  const entry: ProcessLogEntry = {
4624
5054
  timestamp: Date.now(),
4625
- processEvent: event,
4626
- responses,
5055
+ processEvent: this.sanitizeProcessLogValue(event) as ProcessEvent,
5056
+ responses: this.sanitizeProcessLogValue(responses) as ModuleProcessResponse[],
4627
5057
  };
4628
5058
 
4629
5059
  // Blob threshold: 10KB
4630
5060
  const BLOB_THRESHOLD = 10000;
4631
5061
 
4632
5062
  const entryToStore = { ...entry };
4633
- const responsesJson = JSON.stringify(responses);
5063
+ const responsesJson = JSON.stringify(entry.responses);
4634
5064
  if (responsesJson.length > BLOB_THRESHOLD) {
4635
5065
  const blobId = this.store.storeBlob(Buffer.from(responsesJson), 'application/json');
4636
5066
  entryToStore.responses = { blobId };
@@ -4676,6 +5106,11 @@ export class AgentFramework {
4676
5106
  return;
4677
5107
  }
4678
5108
 
5109
+ if (enrichedCall.name === 'save_recent_image') {
5110
+ this.dispatchSaveImageToolCall(agentName, enrichedCall);
5111
+ return;
5112
+ }
5113
+
4679
5114
  // Route gate_status tool
4680
5115
  if (enrichedCall.name === 'gate_status' && this.eventGate) {
4681
5116
  this.dispatchGateToolCall(agentName, enrichedCall);
@@ -4979,6 +5414,22 @@ export class AgentFramework {
4979
5414
  * every ~15 min instead of flooding the channel on every occurrence.
4980
5415
  * See connectome docs/observability.md.
4981
5416
  */
5417
+ /**
5418
+ * Public ops-alert entry point for host-wired alarms that originate
5419
+ * OUTSIDE the framework (e.g. context-manager's repeating
5420
+ * compression-quarantine klaxon, wired in the host at strategy
5421
+ * construction). Same sinks and cooldown as internal alerts:
5422
+ * failures.log + ops:alert trace + CONNECTOME_OPS_WEBHOOK.
5423
+ */
5424
+ notifyOps(
5425
+ kind: string,
5426
+ agentName: string,
5427
+ message: string,
5428
+ data?: Record<string, unknown>,
5429
+ ): void {
5430
+ this.opsAlert(kind, agentName, message, { data });
5431
+ }
5432
+
4982
5433
  private opsAlert(
4983
5434
  kind: string,
4984
5435
  agentName: string,
@@ -5074,6 +5525,17 @@ export class AgentFramework {
5074
5525
  return {};
5075
5526
  }
5076
5527
 
5528
+ /**
5529
+ * Public entry to the ops-alert pipeline (failures.log + ops:alert trace +
5530
+ * throttled webhook) for host-level detectors OUTSIDE the framework's own
5531
+ * inference driver — e.g. the host's logging adapter catching refusals on
5532
+ * off-path calls (compression/summarizer, maintenance) that noteRefusal
5533
+ * never sees. Same per-(agent,kind) cooldown as everything else.
5534
+ */
5535
+ notifyOpsAlert(kind: string, agentName: string, message: string, data?: Record<string, unknown>): void {
5536
+ this.opsAlert(kind, agentName, message, { data });
5537
+ }
5538
+
5077
5539
  private noteInferenceExhausted(
5078
5540
  agentName: string,
5079
5541
  reason: string,
@@ -5355,8 +5817,16 @@ export class AgentFramework {
5355
5817
 
5356
5818
  for (const config of serverConfigs) {
5357
5819
  try {
5358
- await this.connectMcplServerInternal(config);
5820
+ // Stage every startup connection with both planes closed. This removes
5821
+ // server-order dependence: no early heartbeat/shell push can become
5822
+ // model-visible before a later Discord connection has joined the same
5823
+ // awareness generation.
5824
+ await this.connectMcplServerInternal(config, true);
5359
5825
  } catch (error) {
5826
+ if (error instanceof DiscordAwarenessAccountingError) {
5827
+ await this.mcplServerRegistry.closeAll();
5828
+ throw error;
5829
+ }
5360
5830
  // Fail-open: log and continue with remaining servers
5361
5831
  const err = error instanceof Error ? error : new Error(String(error));
5362
5832
  console.error(`Failed to connect MCPL server "${config.id}":`, err.message);
@@ -5374,10 +5844,353 @@ export class AgentFramework {
5374
5844
  }
5375
5845
  }
5376
5846
 
5847
+ // Start the durable drain only after all startup connections are staged.
5848
+ // The awareness tools/call is written before control-plane registration is
5849
+ // flushed, preserving the server-side queue -> registration -> call order.
5850
+ const startupBarrier = this.installMcplDataPlaneGate();
5851
+ this.releaseMcplDataPlaneGate(startupBarrier);
5852
+ try {
5853
+ await startupBarrier.promise;
5854
+ this.completeMcplDataPlaneGate(startupBarrier);
5855
+ } catch (error) {
5856
+ await this.mcplServerRegistry.closeAll();
5857
+ throw error;
5858
+ }
5859
+
5377
5860
  // Discover tools from all connected servers
5378
5861
  await this.refreshMcplTools();
5379
5862
  }
5380
5863
 
5864
+ /** Reconcile the durable ledger with Chronicle, then deliver every server's work. */
5865
+ async syncDiscordAwarenessMarkers(_onlyServerId?: string): Promise<void> {
5866
+ if (!this.discordAwarenessOutbox) return;
5867
+ // A targeted retry may discover pending work for other Discord servers
5868
+ // during reconciliation. Safety is global, so one generation accounts for
5869
+ // all pending operations before releasing any MCPL data plane.
5870
+ const barrier = this.installMcplDataPlaneGate();
5871
+ this.releaseMcplDataPlaneGate(barrier);
5872
+ try {
5873
+ await barrier.promise;
5874
+ this.completeMcplDataPlaneGate(barrier);
5875
+ } catch (error) {
5876
+ await this.failMcplDataPlaneGate(barrier, 'explicit synchronization', error);
5877
+ throw error;
5878
+ }
5879
+ }
5880
+
5881
+ private async resumePreparedDiscordSuppressions(): Promise<void> {
5882
+ if (!this.discordAwarenessOutbox) return;
5883
+ const batches = this.discordAwarenessOutbox.preparedSuppressionsForBranch(
5884
+ this.store.currentBranch().name,
5885
+ this.store.listBranches(),
5886
+ );
5887
+ for (const batch of batches) {
5888
+ const agent = this.agents.get(batch.agentName);
5889
+ if (!agent) {
5890
+ throw new Error(
5891
+ `Cannot resume Discord suppression ${batch.id}: agent ${batch.agentName} is not configured`,
5892
+ );
5893
+ }
5894
+ const cm = agent.getContextManager();
5895
+ for (const interval of [...(batch.suppressionIntervals ?? [])].reverse()) {
5896
+ const from = cm.getMessage(interval.fromId as MessageId);
5897
+ const to = cm.getMessage(interval.toId as MessageId);
5898
+ if (!from && !to) continue; // This interval committed before the crash.
5899
+ if (!from || !to) {
5900
+ throw new Error(
5901
+ `Cannot safely resume Discord suppression ${batch.id}: interval ` +
5902
+ `${interval.fromId}..${interval.toId} is only partially present`,
5903
+ );
5904
+ }
5905
+ if (interval.fromId === interval.toId) cm.removeMessage(from.id);
5906
+ else cm.removeMessages(from.id, to.id);
5907
+ }
5908
+ this.discordAwarenessOutbox.activate(batch.id);
5909
+ console.error(`[discord-awareness] resumed suppression batch ${batch.id}`);
5910
+ }
5911
+ }
5912
+
5913
+ /**
5914
+ * Deliver every due ref independently. Permanent Discord failures are kept
5915
+ * in the ledger for audit but do not block later operations; retryable
5916
+ * failures remain pending for the next reconnect/list-change attempt.
5917
+ */
5918
+ private drainDiscordAwarenessOutbox(
5919
+ serverId: string,
5920
+ ): Promise<DiscordAwarenessDrainOutcome> {
5921
+ if (!this.discordAwarenessOutbox) {
5922
+ return Promise.resolve({ status: 'delivered', delivered: 0, failed: 0 });
5923
+ }
5924
+ const existing = this.discordAwarenessDrains.get(serverId);
5925
+ if (existing) return existing;
5926
+
5927
+ const drain = (async () => {
5928
+ const connection = this.mcplServerRegistry?.getServer(serverId);
5929
+ if (!connection?.isConnected) {
5930
+ const operations = this.readDiscordAwarenessPending(serverId);
5931
+ for (const operation of operations) {
5932
+ this.writeDiscordAwarenessFailure(
5933
+ operation.batchId,
5934
+ operation.ref,
5935
+ operation.action,
5936
+ 'Awareness delivery not attempted: MCPL connection unavailable',
5937
+ false,
5938
+ );
5939
+ }
5940
+ if (operations.length > 0) {
5941
+ console.error(
5942
+ `[discord-awareness] ${serverId}: connection unavailable; ` +
5943
+ `durably deferred=${operations.length}`,
5944
+ );
5945
+ }
5946
+ return { status: 'unavailable' as const, accounted: operations.length };
5947
+ }
5948
+
5949
+ let delivered = 0;
5950
+ let failed = 0;
5951
+ const attempted = new Set<string>();
5952
+ while (true) {
5953
+ const operations = this.readDiscordAwarenessPending(serverId).filter((operation) => {
5954
+ const key = `${operation.batchId}\0${operation.ref.channelId}\0${operation.ref.messageId}\0${operation.action}`;
5955
+ if (attempted.has(key)) return false;
5956
+ attempted.add(key);
5957
+ return true;
5958
+ });
5959
+ if (operations.length === 0) break;
5960
+ for (const operation of operations) {
5961
+ const ref = operation.ref;
5962
+ const channelId = ref.channelId.startsWith('discord:')
5963
+ ? ref.channelId.split(':').at(-1)!
5964
+ : ref.channelId;
5965
+ let deliveryError: string | undefined;
5966
+ try {
5967
+ const tool = operation.action === 'add' ? 'add_reaction' : 'remove_reaction';
5968
+ const result = await connection.sendToolsCallWithDeadline(tool, {
5969
+ channelId,
5970
+ messageId: ref.messageId,
5971
+ emoji: operation.emoji,
5972
+ }, this.discordAwarenessDeadlineMs);
5973
+ if (result.isError) {
5974
+ deliveryError = result.content
5975
+ .map((content) => content.text ?? '')
5976
+ .filter(Boolean)
5977
+ .join('; ') || `Discord ${tool} returned an error`;
5978
+ }
5979
+ } catch (error) {
5980
+ deliveryError = error instanceof Error ? error.message : String(error);
5981
+ }
5982
+
5983
+ if (deliveryError !== undefined) {
5984
+ const permanent = isPermanentDiscordReactionFailure(deliveryError);
5985
+ this.writeDiscordAwarenessFailure(
5986
+ operation.batchId,
5987
+ ref,
5988
+ operation.action,
5989
+ deliveryError,
5990
+ permanent,
5991
+ );
5992
+ failed++;
5993
+ console.error(
5994
+ `[discord-awareness] ${operation.action} failed for ${ref.channelId}/${ref.messageId}` +
5995
+ ` (${permanent ? 'permanent' : 'retryable'}): ${deliveryError}`,
5996
+ );
5997
+ } else {
5998
+ this.writeDiscordAwarenessSuccess(operation.batchId, ref, operation.action);
5999
+ delivered++;
6000
+ }
6001
+ }
6002
+ }
6003
+ if (delivered > 0 || failed > 0) {
6004
+ console.error(
6005
+ `[discord-awareness] ${serverId}: delivered=${delivered} failed=${failed}`,
6006
+ );
6007
+ }
6008
+ return { status: 'delivered' as const, delivered, failed };
6009
+ })().finally(() => {
6010
+ this.discordAwarenessDrains.delete(serverId);
6011
+ });
6012
+
6013
+ this.discordAwarenessDrains.set(serverId, drain);
6014
+ return drain;
6015
+ }
6016
+
6017
+ private readDiscordAwarenessPending(serverId?: string) {
6018
+ try {
6019
+ return this.discordAwarenessOutbox!.pending(serverId);
6020
+ } catch (error) {
6021
+ throw new DiscordAwarenessAccountingError(
6022
+ `ledger read${serverId ? ` for ${serverId}` : ''}`,
6023
+ error,
6024
+ );
6025
+ }
6026
+ }
6027
+
6028
+ private writeDiscordAwarenessSuccess(
6029
+ batchId: string,
6030
+ ref: import('./recovery/discord-awareness-outbox.js').DiscordAwarenessRef,
6031
+ action: import('./recovery/discord-awareness-outbox.js').DiscordAwarenessAction,
6032
+ ): void {
6033
+ try {
6034
+ this.discordAwarenessOutbox!.recordSuccess(batchId, ref, action);
6035
+ } catch (error) {
6036
+ throw new DiscordAwarenessAccountingError('recordSuccess ledger write', error);
6037
+ }
6038
+ }
6039
+
6040
+ private writeDiscordAwarenessFailure(
6041
+ batchId: string,
6042
+ ref: import('./recovery/discord-awareness-outbox.js').DiscordAwarenessRef,
6043
+ action: import('./recovery/discord-awareness-outbox.js').DiscordAwarenessAction,
6044
+ errorMessage: string,
6045
+ permanent: boolean,
6046
+ ): void {
6047
+ try {
6048
+ this.discordAwarenessOutbox!.recordFailure(
6049
+ batchId,
6050
+ ref,
6051
+ action,
6052
+ errorMessage,
6053
+ permanent,
6054
+ );
6055
+ } catch (error) {
6056
+ throw new DiscordAwarenessAccountingError('recordFailure ledger write', error);
6057
+ }
6058
+ }
6059
+
6060
+ private beginDiscordAwarenessBarrier(): {
6061
+ requiresBarrier: boolean;
6062
+ promise: Promise<DiscordAwarenessDrainOutcome>;
6063
+ } {
6064
+ if (!this.discordAwarenessOutbox) {
6065
+ return {
6066
+ requiresBarrier: false,
6067
+ promise: Promise.resolve({ status: 'delivered', delivered: 0, failed: 0 }),
6068
+ };
6069
+ }
6070
+ try {
6071
+ this.discordAwarenessOutbox.reconcileForBranch(
6072
+ this.store.currentBranch().name,
6073
+ this.store.listBranches(),
6074
+ );
6075
+ } catch (error) {
6076
+ throw new DiscordAwarenessAccountingError('branch reconciliation', error);
6077
+ }
6078
+ const pending = this.readDiscordAwarenessPending();
6079
+ if (pending.length === 0) {
6080
+ return {
6081
+ requiresBarrier: false,
6082
+ promise: Promise.resolve({ status: 'delivered', delivered: 0, failed: 0 }),
6083
+ };
6084
+ }
6085
+ const serverIds = [...new Set(pending.map((operation) => operation.ref.serverId))];
6086
+ const promise = Promise.all(
6087
+ serverIds.map((serverId) => this.drainDiscordAwarenessOutbox(serverId)),
6088
+ ).then((outcomes): DiscordAwarenessDrainOutcome => {
6089
+ if (outcomes.every((outcome) => outcome.status === 'unavailable')) {
6090
+ return {
6091
+ status: 'unavailable',
6092
+ accounted: outcomes.reduce(
6093
+ (count, outcome) => count + (outcome.status === 'unavailable' ? outcome.accounted : 0),
6094
+ 0,
6095
+ ),
6096
+ };
6097
+ }
6098
+ return {
6099
+ status: 'delivered',
6100
+ delivered: outcomes.reduce(
6101
+ (count, outcome) => count + (outcome.status === 'delivered' ? outcome.delivered : 0),
6102
+ 0,
6103
+ ),
6104
+ failed: outcomes.reduce(
6105
+ (count, outcome) => count + (outcome.status === 'delivered' ? outcome.failed : 0),
6106
+ 0,
6107
+ ),
6108
+ };
6109
+ });
6110
+ return { requiresBarrier: true, promise };
6111
+ }
6112
+
6113
+ /**
6114
+ * Install one framework-global Discord-awareness generation before releasing
6115
+ * any inference-bearing event. With pending work, every MCPL data plane is
6116
+ * paused while all control planes remain live. With no pending work, ready()
6117
+ * runs synchronously so request responders preserve their historical
6118
+ * same-stack behavior.
6119
+ */
6120
+ private installMcplDataPlaneGate(): DiscordAwarenessBarrier {
6121
+ for (const connection of this.mcplServerRegistry?.getAllServers() ?? []) {
6122
+ connection.pauseDataPlane();
6123
+ }
6124
+ const generation = ++this.discordAwarenessBarrierGeneration;
6125
+
6126
+ let begun: ReturnType<AgentFramework['beginDiscordAwarenessBarrier']>;
6127
+ try {
6128
+ begun = this.beginDiscordAwarenessBarrier();
6129
+ } catch (error) {
6130
+ begun = {
6131
+ requiresBarrier: true,
6132
+ promise: Promise.reject(error),
6133
+ };
6134
+ }
6135
+ const barrier: DiscordAwarenessBarrier = { generation, ...begun };
6136
+ this.discordAwarenessBarrier = barrier;
6137
+ return barrier;
6138
+ }
6139
+
6140
+ private releaseMcplDataPlaneGate(
6141
+ barrier: DiscordAwarenessBarrier,
6142
+ ): void {
6143
+ if (this.discordAwarenessBarrier !== barrier) return;
6144
+ if (barrier.requiresBarrier) {
6145
+ for (const connection of this.mcplServerRegistry?.getAllServers() ?? []) {
6146
+ connection.readyControlPlane();
6147
+ }
6148
+ return;
6149
+ }
6150
+ this.completeMcplDataPlaneGate(barrier);
6151
+ }
6152
+
6153
+ private completeMcplDataPlaneGate(
6154
+ barrier: DiscordAwarenessBarrier,
6155
+ ): boolean {
6156
+ if (this.discordAwarenessBarrier !== barrier) return false;
6157
+ this.discordAwarenessBarrier = null;
6158
+ for (const connection of this.mcplServerRegistry?.getAllServers() ?? []) {
6159
+ // ready() can synchronously flush a nested list-change notification that
6160
+ // installs a newer global generation. Never let this older completion
6161
+ // release any remaining server behind that newer gate.
6162
+ if (this.discordAwarenessBarrier !== null) return false;
6163
+ connection.ready();
6164
+ }
6165
+ return this.discordAwarenessBarrier === null;
6166
+ }
6167
+
6168
+ private async failMcplDataPlaneGate(
6169
+ barrier: DiscordAwarenessBarrier,
6170
+ context: string,
6171
+ error: unknown,
6172
+ ): Promise<void> {
6173
+ if (this.discordAwarenessBarrier !== barrier) return;
6174
+ const err = error instanceof Error ? error : new Error(String(error));
6175
+ console.error(`[discord-awareness] ${context} failed globally:`, err.message);
6176
+ const connections = this.mcplServerRegistry?.getAllServers() ?? [];
6177
+ for (const connection of connections) {
6178
+ this.emitTrace({
6179
+ type: 'mcpl:server-error',
6180
+ serverId: connection.id,
6181
+ error: `Discord awareness accounting unhealthy: ${err.message}`,
6182
+ });
6183
+ }
6184
+ await Promise.all(connections.map(async (connection) => {
6185
+ await connection.reconnectAfterFailure().catch((closeError) => {
6186
+ console.error(
6187
+ `[discord-awareness] could not recycle unhealthy connection ${connection.id}:`,
6188
+ closeError instanceof Error ? closeError.message : closeError,
6189
+ );
6190
+ });
6191
+ }));
6192
+ }
6193
+
5381
6194
  /**
5382
6195
  * Connect a single MCPL server: register routing entries, open the
5383
6196
  * connection, wire events, and initialize feature sets / scopes /
@@ -5386,6 +6199,7 @@ export class AgentFramework {
5386
6199
  */
5387
6200
  private async connectMcplServerInternal(
5388
6201
  config: import('./mcpl/types.js').McplServerConfig,
6202
+ deferAwareness = false,
5389
6203
  ): Promise<void> {
5390
6204
  if (!this.mcplServerRegistry || !this.mcplHostCapabilities) {
5391
6205
  throw new Error('MCPL subsystem is not initialized');
@@ -5408,14 +6222,31 @@ export class AgentFramework {
5408
6222
 
5409
6223
  const connection = await this.mcplServerRegistry.addServer(config, this.mcplHostCapabilities);
5410
6224
 
5411
- // Wire event listeners then flush any events that arrived during the
5412
- // handshake window (between setupMessageRouting and now).
6225
+ // Wire listeners before either startup staging or the runtime global gate
6226
+ // releases control traffic needed for registration and marker service.
5413
6227
  this.wireMcplEvents(connection);
5414
- connection.ready();
5415
6228
 
5416
6229
  // Initialize feature sets if server advertises MCPL capabilities
5417
6230
  this.registerMcplServerFeatures(config, connection);
5418
6231
 
6232
+ if (deferAwareness) {
6233
+ this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` });
6234
+ return;
6235
+ }
6236
+
6237
+ const awarenessBarrier = this.installMcplDataPlaneGate();
6238
+ this.releaseMcplDataPlaneGate(awarenessBarrier);
6239
+ try {
6240
+ await awarenessBarrier.promise;
6241
+ this.completeMcplDataPlaneGate(awarenessBarrier);
6242
+ } catch (error) {
6243
+ // Startup cannot fail open on a broken awareness ledger. Disable the
6244
+ // reconnecting stub/connection before propagating the distinct error to
6245
+ // initializeMcpl, which tears down any other servers and aborts create().
6246
+ await connection.close().catch(() => {});
6247
+ throw error;
6248
+ }
6249
+
5419
6250
  this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` });
5420
6251
  }
5421
6252
 
@@ -5629,10 +6460,12 @@ export class AgentFramework {
5629
6460
  });
5630
6461
 
5631
6462
  // Handle push events (Step 6)
5632
- connection.on('push-event', (
6463
+ connection.on('push-event', async (
5633
6464
  params: PushEventParams,
5634
6465
  responder?: { respond: (result: unknown) => void; respondError: (code: number, message: string) => void },
5635
6466
  ) => {
6467
+ const barrier = this.discordAwarenessBarrier;
6468
+ if (barrier) await barrier.promise;
5636
6469
  this.pushHandler?.handlePushEvent(connection.id, params, responder as never);
5637
6470
  });
5638
6471
 
@@ -5642,6 +6475,8 @@ export class AgentFramework {
5642
6475
  responder?: { id: string | number; respond: (result: unknown) => void; respondError: (code: number, message: string) => void },
5643
6476
  ) => {
5644
6477
  if (this.inferenceRouter && responder) {
6478
+ const barrier = this.discordAwarenessBarrier;
6479
+ if (barrier) await barrier.promise;
5645
6480
  await this.inferenceRouter.handleInferenceRequest(connection.id, params, {
5646
6481
  respond: responder.respond,
5647
6482
  respondError: responder.respondError,
@@ -5664,10 +6499,12 @@ export class AgentFramework {
5664
6499
  });
5665
6500
 
5666
6501
  // Handle incoming channel messages (Step 7)
5667
- connection.on('channels-incoming', (
6502
+ connection.on('channels-incoming', async (
5668
6503
  params: ChannelsIncomingParams,
5669
6504
  responder?: { respond: (result: unknown) => void },
5670
6505
  ) => {
6506
+ const barrier = this.discordAwarenessBarrier;
6507
+ if (barrier) await barrier.promise;
5671
6508
  this.channelRegistry?.handleIncoming(connection.id, params, responder as never);
5672
6509
  });
5673
6510
 
@@ -5678,6 +6515,8 @@ export class AgentFramework {
5678
6515
  ) => {
5679
6516
  if (!responder) return;
5680
6517
  try {
6518
+ const barrier = this.discordAwarenessBarrier;
6519
+ if (barrier) await barrier.promise;
5681
6520
  const result = await this.handleHostCommand(connection.id, params ?? {});
5682
6521
  responder.respond(result);
5683
6522
  } catch (error) {
@@ -5688,7 +6527,19 @@ export class AgentFramework {
5688
6527
 
5689
6528
  // Handle dynamic tool list changes (notifications/tools/list_changed)
5690
6529
  connection.on('tools-list-changed', () => {
6530
+ // Pause before starting any async refresh. This listener runs
6531
+ // synchronously in the transport line callback, so a following inbound
6532
+ // data event cannot pass before the new barrier exists.
6533
+ const awarenessBarrier = this.installMcplDataPlaneGate();
6534
+ this.releaseMcplDataPlaneGate(awarenessBarrier);
5691
6535
  this.handleToolsListChanged(connection.id);
6536
+ void awarenessBarrier.promise.then(() => {
6537
+ this.completeMcplDataPlaneGate(awarenessBarrier);
6538
+ }).catch((error) => this.failMcplDataPlaneGate(
6539
+ awarenessBarrier,
6540
+ 'tools-list reconciliation',
6541
+ error,
6542
+ ));
5692
6543
  });
5693
6544
 
5694
6545
  // Re-establish full server registration on reconnect. The 'close' handler
@@ -5699,6 +6550,10 @@ export class AgentFramework {
5699
6550
  // preserved across the transient close are resumed by idempotent
5700
6551
  // registration. Then refresh tools (server may have different tools).
5701
6552
  connection.on('reconnect', (info?: { attempts?: number }) => {
6553
+ // Install the barrier synchronously so any inbound event emitted after
6554
+ // reconnect observes it before doing work that could wake an agent. The
6555
+ // connection paused its data plane before wiring the fresh transport.
6556
+ const awarenessBarrier = this.installMcplDataPlaneGate();
5702
6557
  try {
5703
6558
  const config = this.mcplServerConfigs.get(connection.id);
5704
6559
  if (config) {
@@ -5710,15 +6565,26 @@ export class AgentFramework {
5710
6565
  error instanceof Error ? error.message : error,
5711
6566
  );
5712
6567
  }
6568
+ this.releaseMcplDataPlaneGate(awarenessBarrier);
5713
6569
  this.handleToolsListChanged(connection.id);
5714
- this.emitTrace({
5715
- type: 'mcpl:server-reconnected',
5716
- serverId: connection.id,
5717
- attempts: info?.attempts ?? 0,
5718
- });
5719
- // Mirror the module:removed emitted on 'close' so module-lifecycle
5720
- // consumers see the server come back, not just vanish.
5721
- this.emitTrace({ type: 'module:added', moduleName: `mcpl:${connection.id}` });
6570
+ void awarenessBarrier.promise.then(() => {
6571
+ if (
6572
+ awarenessBarrier.requiresBarrier
6573
+ && !this.completeMcplDataPlaneGate(awarenessBarrier)
6574
+ ) return;
6575
+ this.emitTrace({
6576
+ type: 'mcpl:server-reconnected',
6577
+ serverId: connection.id,
6578
+ attempts: info?.attempts ?? 0,
6579
+ });
6580
+ // Mirror the module:removed emitted on 'close' so module-lifecycle
6581
+ // consumers see the server come back, not just vanish.
6582
+ this.emitTrace({ type: 'module:added', moduleName: `mcpl:${connection.id}` });
6583
+ }).catch((error) => this.failMcplDataPlaneGate(
6584
+ awarenessBarrier,
6585
+ 'reconnect reconciliation',
6586
+ error,
6587
+ ));
5722
6588
  });
5723
6589
 
5724
6590
  // Surface connect/reconnect failures. Before these traces existed the
@@ -5760,6 +6626,7 @@ export class AgentFramework {
5760
6626
  // divergence greppable instead of a silent drift into stale state.
5761
6627
  connection.on('orphaned-response', (info: {
5762
6628
  id: string | number;
6629
+ method?: string;
5763
6630
  hadState: boolean;
5764
6631
  hadCheckpoint: boolean;
5765
6632
  }) => {
@@ -5767,6 +6634,7 @@ export class AgentFramework {
5767
6634
  type: 'mcpl:orphaned-response',
5768
6635
  serverId: connection.id,
5769
6636
  responseId: info.id,
6637
+ method: info.method,
5770
6638
  hadState: info.hadState,
5771
6639
  hadCheckpoint: info.hadCheckpoint,
5772
6640
  });
@@ -6228,6 +7096,153 @@ export class AgentFramework {
6228
7096
  });
6229
7097
  }
6230
7098
 
7099
+ /**
7100
+ * Handle the synthesized `save_recent_image` tool: locate the requested
7101
+ * image blocks in the calling agent's context (blobs re-inlined, counted
7102
+ * back from the most recent) and write their bytes to a workspace mount
7103
+ * via WorkspaceModule.writeBinary.
7104
+ */
7105
+ private dispatchSaveImageToolCall(agentName: string, call: ToolCall): void {
7106
+ this.emitTrace({ type: 'tool:started', module: 'workspace', tool: call.name, callId: call.id, input: call.input });
7107
+ const finish = (result: ToolResult): void => {
7108
+ this.emitTrace({
7109
+ type: result.isError ? 'tool:failed' : 'tool:completed',
7110
+ module: 'workspace',
7111
+ tool: call.name,
7112
+ callId: call.id,
7113
+ durationMs: 0,
7114
+ ...(result.isError ? { error: result.error } : {}),
7115
+ });
7116
+ this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'workspace', result });
7117
+ };
7118
+ void (async (): Promise<void> => {
7119
+ try {
7120
+ const workspace = this.getWorkspaceModule();
7121
+ if (!workspace) throw new Error('save_recent_image requires a workspace module');
7122
+ const agent = this.agents.get(agentName);
7123
+ if (!agent) throw new Error(`Unknown agent: ${agentName}`);
7124
+ const input = (call.input ?? {}) as { path?: unknown; index?: unknown; count?: unknown };
7125
+ if (typeof input.path !== 'string' || input.path.length === 0) {
7126
+ throw new Error('save_recent_image: `path` (mount-prefixed) is required');
7127
+ }
7128
+ const index = input.index === undefined ? 0 : Number(input.index);
7129
+ if (!Number.isInteger(index) || index < 0) {
7130
+ throw new Error('save_recent_image: `index` must be a non-negative integer');
7131
+ }
7132
+ const count = input.count === undefined ? 1 : Number(input.count);
7133
+ const MAX_COUNT = 20;
7134
+ if (!Number.isInteger(count) || count < 1 || count > MAX_COUNT) {
7135
+ throw new Error(`save_recent_image: \`count\` must be an integer in 1..${MAX_COUNT}`);
7136
+ }
7137
+ const lastWanted = index + count - 1;
7138
+
7139
+ // Walk the message store tail-first in bounded windows, re-inlining
7140
+ // blob media, until we've collected the requested range. Scanning is
7141
+ // capped so a pathological index can't drag the whole store through
7142
+ // blob resolution.
7143
+ const MAX_SCAN = 500;
7144
+ const WINDOW = 25;
7145
+ const cm = agent.getContextManager();
7146
+ const total = cm.getMessageCount();
7147
+ let seen = 0;
7148
+ let scanned = 0;
7149
+ const found: Array<{
7150
+ rangeOffset: number;
7151
+ data: string;
7152
+ mediaType: string;
7153
+ messagesBack: number;
7154
+ }> = [];
7155
+ for (let end = total; end > 0 && scanned < MAX_SCAN && found.length < count; end -= WINDOW) {
7156
+ const start = Math.max(0, end - WINDOW);
7157
+ const { messages } = cm.getMessageWindow(start, end - start, { resolveBlobs: true });
7158
+ scanned += end - start;
7159
+ for (let i = messages.length - 1; i >= 0 && found.length < count; i--) {
7160
+ const content = messages[i]?.content;
7161
+ if (!Array.isArray(content)) continue;
7162
+ for (let b = content.length - 1; b >= 0 && found.length < count; b--) {
7163
+ const block = content[b] as {
7164
+ type?: string;
7165
+ source?: { type?: string; data?: string; mediaType?: string };
7166
+ };
7167
+ if (block?.type !== 'image') continue;
7168
+ if (seen >= index && seen <= lastWanted) {
7169
+ if (block.source?.type !== 'base64' || typeof block.source.data !== 'string') {
7170
+ throw new Error(
7171
+ `save_recent_image: image at index ${seen} is not stored inline (base64) — cannot save it`,
7172
+ );
7173
+ }
7174
+ found.push({
7175
+ rangeOffset: seen - index,
7176
+ data: block.source.data,
7177
+ mediaType: block.source.mediaType ?? 'image/png',
7178
+ messagesBack: total - (start + i),
7179
+ });
7180
+ }
7181
+ seen++;
7182
+ }
7183
+ }
7184
+ }
7185
+ if (found.length === 0) {
7186
+ throw new Error(
7187
+ seen === 0
7188
+ ? `save_recent_image: no images found in the most recent ${Math.min(scanned, MAX_SCAN)} messages`
7189
+ : `save_recent_image: only ${seen} image(s) found in the most recent ${Math.min(scanned, MAX_SCAN)} messages (asked for index ${index})`,
7190
+ );
7191
+ }
7192
+
7193
+ // Single image → path as given. Range → numeric suffix before the
7194
+ // extension ("cat.png" → "cat-0.png"), 0 = newest of the range.
7195
+ const pathFor = (rangeOffset: number): string => {
7196
+ if (count === 1) return input.path as string;
7197
+ const p = input.path as string;
7198
+ const dot = p.lastIndexOf('.');
7199
+ const slash = p.lastIndexOf('/');
7200
+ return dot > slash
7201
+ ? `${p.slice(0, dot)}-${rangeOffset}${p.slice(dot)}`
7202
+ : `${p}-${rangeOffset}`;
7203
+ };
7204
+ const savedFiles: Array<Record<string, unknown>> = [];
7205
+ for (const image of found) {
7206
+ const destination = pathFor(image.rangeOffset);
7207
+ const bytes = Buffer.from(image.data, 'base64');
7208
+ const result = await workspace.writeBinary(destination, bytes, image.mediaType);
7209
+ if (!result.success) {
7210
+ finish({
7211
+ success: false,
7212
+ error:
7213
+ `save_recent_image: failed writing "${destination}": ${result.error}` +
7214
+ (savedFiles.length > 0
7215
+ ? ` (already saved: ${savedFiles.map((f) => f.path).join(', ')})`
7216
+ : ''),
7217
+ isError: true,
7218
+ });
7219
+ return;
7220
+ }
7221
+ savedFiles.push({
7222
+ ...(result.data as Record<string, unknown>),
7223
+ imageIndex: index + image.rangeOffset,
7224
+ messagesBack: image.messagesBack,
7225
+ });
7226
+ }
7227
+ finish({
7228
+ success: true,
7229
+ data: {
7230
+ saved: savedFiles,
7231
+ ...(found.length < count
7232
+ ? { note: `only ${found.length} of ${count} requested images exist in the scanned window` }
7233
+ : {}),
7234
+ },
7235
+ });
7236
+ } catch (error) {
7237
+ finish({
7238
+ success: false,
7239
+ error: error instanceof Error ? error.message : String(error),
7240
+ isError: true,
7241
+ });
7242
+ }
7243
+ })();
7244
+ }
7245
+
6231
7246
  private dispatchAgentSettingsToolCall(agentName: string, call: ToolCall): void {
6232
7247
  this.emitTrace({
6233
7248
  type: 'tool:started',
@@ -6243,11 +7258,22 @@ export class AgentFramework {
6243
7258
  context_budget_tokens?: unknown;
6244
7259
  tail_tokens?: unknown;
6245
7260
  transition_pace_tokens?: unknown;
7261
+ same_round_think_text_policy?: unknown;
6246
7262
  settings?: unknown;
7263
+ } & Record<string, unknown>;
7264
+ const extensions = this.collectAgentSettingsExtensions();
7265
+ /** Extension values merged flat alongside the core snapshot. */
7266
+ const extGet = (): Record<string, unknown> => {
7267
+ const out: Record<string, unknown> = {};
7268
+ for (const ext of extensions.values()) Object.assign(out, ext.get(agentName));
7269
+ return out;
6247
7270
  };
6248
7271
  switch (input.action) {
6249
7272
  case 'get':
6250
- result = { success: true, data: this.getAgentRuntimeSettings(agentName) };
7273
+ result = {
7274
+ success: true,
7275
+ data: { ...this.getAgentRuntimeSettings(agentName), ...extGet() },
7276
+ };
6251
7277
  break;
6252
7278
  case 'cancel':
6253
7279
  result = { success: true, data: this.cancelAgentRuntimeSettingsTransition(agentName) };
@@ -6261,26 +7287,101 @@ export class AgentFramework {
6261
7287
  if (input.transition_pace_tokens !== undefined) {
6262
7288
  patch.transitionPaceTokens = Number(input.transition_pace_tokens);
6263
7289
  }
6264
- result = { success: true, data: this.updateAgentRuntimeSettings(agentName, patch) };
7290
+ if (input.same_round_think_text_policy !== undefined) {
7291
+ patch.sameRoundThinkTextPolicy = input.same_round_think_text_policy as AgentRuntimeSettingsPatch['sameRoundThinkTextPolicy'];
7292
+ }
7293
+ // Route extension-owned keys to their modules; apply the core patch
7294
+ // only when it touches core keys (an extension-only update must not
7295
+ // disturb a converging budget transition).
7296
+ const extResults: Record<string, unknown> = {};
7297
+ for (const ext of extensions.values()) {
7298
+ const slice: Record<string, unknown> = {};
7299
+ for (const key of ext.keys) {
7300
+ if (input[key] !== undefined) slice[key] = input[key];
7301
+ }
7302
+ if (Object.keys(slice).length > 0) {
7303
+ Object.assign(extResults, ext.update(agentName, slice));
7304
+ }
7305
+ }
7306
+ const coreTouched = Object.keys(patch).length > 0;
7307
+ const core = coreTouched
7308
+ ? this.updateAgentRuntimeSettings(agentName, patch)
7309
+ : this.getAgentRuntimeSettings(agentName);
7310
+ result = {
7311
+ success: true,
7312
+ data: {
7313
+ ...core,
7314
+ ...extGet(),
7315
+ ...extResults,
7316
+ ...(patch.sameRoundThinkTextPolicy !== undefined
7317
+ ? {
7318
+ sameRoundThinkTextPolicyUpdateNote:
7319
+ 'Stored now; applies to provider think routing and description beginning with the next inference.',
7320
+ }
7321
+ : {}),
7322
+ },
7323
+ };
6265
7324
  break;
6266
7325
  }
6267
7326
  case 'reset': {
6268
7327
  let keys: Array<keyof AgentRuntimeSettingsPatch> | undefined;
7328
+ const extResetKeys = new Map<AgentSettingsExtension, string[]>();
7329
+ const resetsAllSettings = input.settings === undefined;
6269
7330
  if (input.settings !== undefined) {
6270
7331
  if (!Array.isArray(input.settings)) throw new Error('reset `settings` must be an array');
6271
7332
  const names: Record<string, keyof AgentRuntimeSettingsPatch> = {
6272
7333
  context_budget_tokens: 'contextBudgetTokens',
6273
7334
  tail_tokens: 'tailTokens',
6274
7335
  transition_pace_tokens: 'transitionPaceTokens',
7336
+ same_round_think_text_policy: 'sameRoundThinkTextPolicy',
6275
7337
  };
6276
- keys = input.settings.map((name) => {
6277
- if (typeof name !== 'string' || !names[name]) {
6278
- throw new Error(`Unknown reset setting: ${String(name)}`);
7338
+ keys = [];
7339
+ for (const name of input.settings) {
7340
+ if (typeof name === 'string' && names[name]) {
7341
+ keys.push(names[name]);
7342
+ continue;
6279
7343
  }
6280
- return names[name];
6281
- });
7344
+ const owner = [...extensions.values()].find(
7345
+ (ext) => typeof name === 'string' && ext.keys.includes(name),
7346
+ );
7347
+ if (!owner) throw new Error(`Unknown reset setting: ${String(name)}`);
7348
+ const list = extResetKeys.get(owner) ?? [];
7349
+ list.push(name as string);
7350
+ extResetKeys.set(owner, list);
7351
+ }
7352
+ if (keys.length === 0) keys = undefined;
7353
+ }
7354
+ const touchedSameRoundThinkTextPolicy =
7355
+ resetsAllSettings || keys?.includes('sameRoundThinkTextPolicy') === true;
7356
+ const extResults: Record<string, unknown> = {};
7357
+ if (input.settings === undefined) {
7358
+ // Reset-all covers extensions too.
7359
+ for (const ext of extensions.values()) {
7360
+ if (ext.reset) Object.assign(extResults, ext.reset(agentName));
7361
+ }
7362
+ } else {
7363
+ for (const [ext, list] of extResetKeys) {
7364
+ if (ext.reset) Object.assign(extResults, ext.reset(agentName, list));
7365
+ }
6282
7366
  }
6283
- result = { success: true, data: this.resetAgentRuntimeSettings(agentName, keys) };
7367
+ const coreTouched = input.settings === undefined || keys !== undefined;
7368
+ const core = coreTouched
7369
+ ? this.resetAgentRuntimeSettings(agentName, keys)
7370
+ : this.getAgentRuntimeSettings(agentName);
7371
+ result = {
7372
+ success: true,
7373
+ data: {
7374
+ ...core,
7375
+ ...extGet(),
7376
+ ...extResults,
7377
+ ...(touchedSameRoundThinkTextPolicy
7378
+ ? {
7379
+ sameRoundThinkTextPolicyUpdateNote:
7380
+ 'Reset now; restored provider think routing and description apply beginning with the next inference.',
7381
+ }
7382
+ : {}),
7383
+ },
7384
+ };
6284
7385
  break;
6285
7386
  }
6286
7387
  default: