@animalabs/agent-framework 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/dist/src/agent.d.ts +1 -1
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js.map +1 -1
  4. package/dist/src/framework.d.ts +72 -4
  5. package/dist/src/framework.d.ts.map +1 -1
  6. package/dist/src/framework.js +699 -48
  7. package/dist/src/framework.js.map +1 -1
  8. package/dist/src/gate/event-gate.d.ts +62 -7
  9. package/dist/src/gate/event-gate.d.ts.map +1 -1
  10. package/dist/src/gate/event-gate.js +333 -22
  11. package/dist/src/gate/event-gate.js.map +1 -1
  12. package/dist/src/gate/types.d.ts +56 -0
  13. package/dist/src/gate/types.d.ts.map +1 -1
  14. package/dist/src/mcpl/prose-grammar.d.ts +11 -0
  15. package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
  16. package/dist/src/mcpl/prose-grammar.js +21 -0
  17. package/dist/src/mcpl/prose-grammar.js.map +1 -1
  18. package/dist/src/mcpl/prose-stream-router.d.ts +2 -2
  19. package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
  20. package/dist/src/mcpl/prose-stream-router.js +57 -13
  21. package/dist/src/mcpl/prose-stream-router.js.map +1 -1
  22. package/dist/src/modules/discord/index.d.ts.map +1 -1
  23. package/dist/src/modules/discord/index.js +21 -5
  24. package/dist/src/modules/discord/index.js.map +1 -1
  25. package/dist/src/modules/workspace/index.d.ts +23 -0
  26. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  27. package/dist/src/modules/workspace/index.js +53 -3
  28. package/dist/src/modules/workspace/index.js.map +1 -1
  29. package/dist/src/modules/workspace/sync.d.ts +11 -2
  30. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  31. package/dist/src/modules/workspace/sync.js +27 -11
  32. package/dist/src/modules/workspace/sync.js.map +1 -1
  33. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  34. package/dist/src/modules/workspace/watcher.js +9 -6
  35. package/dist/src/modules/workspace/watcher.js.map +1 -1
  36. package/dist/src/queue.d.ts.map +1 -1
  37. package/dist/src/queue.js +9 -2
  38. package/dist/src/queue.js.map +1 -1
  39. package/dist/src/tool-result-history.d.ts +12 -2
  40. package/dist/src/tool-result-history.d.ts.map +1 -1
  41. package/dist/src/tool-result-history.js +12 -2
  42. package/dist/src/tool-result-history.js.map +1 -1
  43. package/dist/src/types/agent.d.ts +6 -1
  44. package/dist/src/types/agent.d.ts.map +1 -1
  45. package/dist/src/types/framework.d.ts +1 -1
  46. package/dist/src/types/trace.d.ts +7 -0
  47. package/dist/src/types/trace.d.ts.map +1 -1
  48. package/dist/test/busy-agent-scheduler.test.js +29 -0
  49. package/dist/test/busy-agent-scheduler.test.js.map +1 -1
  50. package/dist/test/discord-history-delete-window.test.d.ts +2 -0
  51. package/dist/test/discord-history-delete-window.test.d.ts.map +1 -0
  52. package/dist/test/discord-history-delete-window.test.js +81 -0
  53. package/dist/test/discord-history-delete-window.test.js.map +1 -0
  54. package/dist/test/discord-history-sync.test.d.ts +2 -0
  55. package/dist/test/discord-history-sync.test.d.ts.map +1 -0
  56. package/dist/test/discord-history-sync.test.js +56 -0
  57. package/dist/test/discord-history-sync.test.js.map +1 -0
  58. package/dist/test/event-gate-passthrough.test.d.ts +15 -0
  59. package/dist/test/event-gate-passthrough.test.d.ts.map +1 -0
  60. package/dist/test/event-gate-passthrough.test.js +289 -0
  61. package/dist/test/event-gate-passthrough.test.js.map +1 -0
  62. package/dist/test/explicit-prose-routing.test.js +179 -7
  63. package/dist/test/explicit-prose-routing.test.js.map +1 -1
  64. package/dist/test/framework.test.js +33 -0
  65. package/dist/test/framework.test.js.map +1 -1
  66. package/dist/test/prose-stream-router.test.js +43 -0
  67. package/dist/test/prose-stream-router.test.js.map +1 -1
  68. package/dist/test/provider-acceleration-cooldown.test.d.ts +2 -0
  69. package/dist/test/provider-acceleration-cooldown.test.d.ts.map +1 -0
  70. package/dist/test/provider-acceleration-cooldown.test.js +286 -0
  71. package/dist/test/provider-acceleration-cooldown.test.js.map +1 -0
  72. package/dist/test/puppet-tool-call.test.d.ts +2 -0
  73. package/dist/test/puppet-tool-call.test.d.ts.map +1 -0
  74. package/dist/test/puppet-tool-call.test.js +106 -0
  75. package/dist/test/puppet-tool-call.test.js.map +1 -0
  76. package/dist/test/release-changelog.test.d.ts +2 -0
  77. package/dist/test/release-changelog.test.d.ts.map +1 -0
  78. package/dist/test/release-changelog.test.js +159 -0
  79. package/dist/test/release-changelog.test.js.map +1 -0
  80. package/dist/test/tool-result-spill.test.d.ts +1 -1
  81. package/dist/test/tool-result-spill.test.js +18 -9
  82. package/dist/test/tool-result-spill.test.js.map +1 -1
  83. package/dist/test/workspace-path-containment.test.d.ts +19 -0
  84. package/dist/test/workspace-path-containment.test.d.ts.map +1 -0
  85. package/dist/test/workspace-path-containment.test.js +91 -0
  86. package/dist/test/workspace-path-containment.test.js.map +1 -0
  87. package/dist/test/workspace-watcher.test.js +15 -3
  88. package/dist/test/workspace-watcher.test.js.map +1 -1
  89. package/dist/test/xml-round-persist.test.d.ts +19 -0
  90. package/dist/test/xml-round-persist.test.d.ts.map +1 -0
  91. package/dist/test/xml-round-persist.test.js +235 -0
  92. package/dist/test/xml-round-persist.test.js.map +1 -0
  93. package/dist/tsconfig.tsbuildinfo +1 -1
  94. package/package.json +2 -1
  95. package/src/agent.ts +1 -1
  96. package/src/framework.ts +733 -48
  97. package/src/gate/event-gate.ts +355 -24
  98. package/src/gate/types.ts +58 -0
  99. package/src/mcpl/prose-grammar.ts +21 -0
  100. package/src/mcpl/prose-stream-router.ts +48 -14
  101. package/src/modules/discord/index.ts +24 -6
  102. package/src/modules/workspace/index.ts +53 -3
  103. package/src/modules/workspace/sync.ts +39 -10
  104. package/src/modules/workspace/watcher.ts +9 -6
  105. package/src/queue.ts +8 -2
  106. package/src/tool-result-history.ts +12 -2
  107. package/src/types/agent.ts +6 -1
  108. package/src/types/framework.ts +1 -1
  109. package/src/types/trace.ts +13 -0
@@ -13,7 +13,7 @@ import { computeGrant, CapabilityGrant, expandAdvertisementShorthand } from './m
13
13
  import { maskNegotiatedCapabilities } from './mcpl/capability-mask.js';
14
14
  import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
15
15
  import { PushHandler } from './mcpl/push-handler.js';
16
- import { parseProsePrefix } from './mcpl/prose-grammar.js';
16
+ import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js';
17
17
  import { ProseStreamRouter } from './mcpl/prose-stream-router.js';
18
18
  import { InferenceRouter } from './mcpl/inference-router.js';
19
19
  import { ChannelRegistry } from './mcpl/channel-registry.js';
@@ -64,6 +64,8 @@ function sniffImageMediaType(data) {
64
64
  const SILENCING_TOOLS = new Set([
65
65
  'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
66
66
  ]);
67
+ /** World-surface publication names that outrank hybrid prose envelopes. */
68
+ const HYBRID_PUBLICATION_TOOLS = new Set(['say', 'whisper']);
67
69
  /**
68
70
  * True when an injected message is real conversational input — something the
69
71
  * agent might actually be replying to — rather than ambient machinery.
@@ -159,6 +161,13 @@ function proseModePrimer(mode) {
159
161
  return '[prose-routing] Mode change: your plain text auto-routes to the ' +
160
162
  'conversational locus again. `>>` destination prefixes are no longer needed.';
161
163
  }
164
+ if (mode === 'hybrid') {
165
+ return '[prose-routing] Hybrid routing enabled: unprefixed text still lands in the ' +
166
+ 'current conversational locus. A leading `>>>destination` envelope instead routes ' +
167
+ 'that prose to one uniquely resolved authorized Discord or Eidoverse channel. The ' +
168
+ 'envelope remains in your memory but recipients see only its body; delivery or failure ' +
169
+ 'is reported back to you.';
170
+ }
162
171
  // Deliberately terse: a short event-style notice is classifier-safe from
163
172
  // the user role (ablation D, 2026-07-24), while the full grammar as a user
164
173
  // message drew a deterministic reasoning_extraction refusal (ablation A/B).
@@ -249,7 +258,7 @@ function validateUtilityArgs(input, schema) {
249
258
  }
250
259
  import { CheckpointManager } from './mcpl/checkpoint-manager.js';
251
260
  import { isToolAllowed } from './mcpl/tool-policy.js';
252
- import { EventGate } from './gate/event-gate.js';
261
+ import { EventGate, formatShadowWarning } from './gate/event-gate.js';
253
262
  import { UsageTracker } from './usage/usage-tracker.js';
254
263
  import { formatZonedDateTime, resolveTimeZone } from './timezone.js';
255
264
  import { DEFAULT_DISCORD_AWARENESS_EMOJI, DiscordAwarenessOutbox, defaultDiscordAwarenessOutboxPath, extractDiscordAwarenessRefs, } from './recovery/discord-awareness-outbox.js';
@@ -322,6 +331,15 @@ const DEFAULT_SYNC_INTERVAL_MS = 1000;
322
331
  const DEFAULT_MAINTENANCE_INTERVAL_MS = 5000;
323
332
  /** Bound one pass so a large backlog yields to inference and other agents. */
324
333
  const MAINTENANCE_TICKS_PER_PASS = 8;
334
+ const PROVIDER_ACCELERATION_DEFAULT_COOLDOWN_MS = 65_000;
335
+ const PROVIDER_ACCELERATION_MAX_COOLDOWN_MS = 10 * 60_000;
336
+ const PROVIDER_ACCELERATION_JITTER_MS = 5_000;
337
+ function isOrganizationAccelerationRateLimit(error) {
338
+ if (!(error instanceof MembraneError) || error.type !== 'rate_limit')
339
+ return false;
340
+ return (/organization(?:'s)?[^\n]{0,120}maximum(?:\s+usage)?\s+increase\s+rate/i.test(error.message) ||
341
+ /organization(?:'s)?[^\n]{0,120}acceleration(?:\s+limit)?/i.test(error.message));
342
+ }
325
343
  /**
326
344
  * Extract fields the EventGate cares about from a ProcessEvent. The set of
327
345
  * event variants that carry `content`/`mount`/`paths`/`metadata` is open-ended
@@ -390,6 +408,13 @@ export class AgentFramework {
390
408
  maintenanceRunId = 0;
391
409
  currentMaintenanceRun = null;
392
410
  maintenanceHistory = [];
411
+ providerGates = new Map();
412
+ providerAccelerationCooldowns = new Map();
413
+ providerAccelerationRecoveries = new Map();
414
+ providerAccelerationLastRecovery = new Map();
415
+ providerAccelerationDefaultCooldownMs = PROVIDER_ACCELERATION_DEFAULT_COOLDOWN_MS;
416
+ providerAccelerationJitterMs = PROVIDER_ACCELERATION_JITTER_MS;
417
+ providerAdmissionClosed = false;
393
418
  /** Last time we reported stale (busy-requeued) inference requests, per agent. */
394
419
  staleWarnAt = new Map();
395
420
  /** Per-agent last inference activity (epoch ms), for /healthz + doctor tooling. */
@@ -459,6 +484,8 @@ export class AgentFramework {
459
484
  /** Sticky per-TURN delivery target set by the turn's first `>>` prefix.
460
485
  * Cleared at every non-restart turn start; survives context restarts. */
461
486
  proseTargetPins = new Map();
487
+ /** Hybrid router is fail-closed after a malformed/unresolved envelope until a new valid target. */
488
+ proseHybridSuppressed = new Set();
462
489
  /** Agents whose current turn requested `!` continuation — re-woken when the
463
490
  * turn completes instead of pausing until the next external event. */
464
491
  proseContinuations = new Set();
@@ -886,6 +913,7 @@ export class AgentFramework {
886
913
  return;
887
914
  }
888
915
  this.running = true;
916
+ this.providerAdmissionClosed = false;
889
917
  this.loopPromise = this.runLoop();
890
918
  // Start periodic sync timer (if enabled)
891
919
  if (this.syncIntervalMs > 0) {
@@ -913,6 +941,7 @@ export class AgentFramework {
913
941
  */
914
942
  async stop() {
915
943
  this.running = false;
944
+ this.providerAdmissionClosed = true;
916
945
  this.queue.close();
917
946
  // Kill running code_execution scripts before cancelling streams: a
918
947
  // zombie script must not keep firing side-effectful tool calls into a
@@ -932,6 +961,8 @@ export class AgentFramework {
932
961
  record.runner.dispose();
933
962
  }
934
963
  this.backgroundScripts.clear();
964
+ // A stopped host must never hang behind its own cooldown.
965
+ this.cancelProviderAdmission();
935
966
  // Cancel all active streams
936
967
  for (const agent of this.agents.values()) {
937
968
  if (agent.state.status === 'streaming' ||
@@ -988,6 +1019,168 @@ export class AgentFramework {
988
1019
  * schemas are absent. Passes never overlap; each agent gets a bounded drain
989
1020
  * so maintenance cannot monopolize the framework event loop.
990
1021
  */
1022
+ providerGate(agentName) {
1023
+ let gate = this.providerGates.get(agentName);
1024
+ if (!gate) {
1025
+ gate = { primaryDepth: 0, primaryPending: false, auxiliaryInFlight: 0,
1026
+ auxiliaryWaiters: [], idleWaiters: [], deferredAuxiliary: 0 };
1027
+ this.providerGates.set(agentName, gate);
1028
+ }
1029
+ return gate;
1030
+ }
1031
+ providerGateBlocked(agentName) {
1032
+ const gate = this.providerGates.get(agentName);
1033
+ return (gate?.primaryDepth ?? 0) > 0 || (gate?.primaryPending ?? false) ||
1034
+ this.providerAccelerationCooldowns.has(agentName);
1035
+ }
1036
+ acquirePrimaryProviderGate(agentName) {
1037
+ const gate = this.providerGate(agentName);
1038
+ gate.primaryPending = false;
1039
+ gate.primaryDepth++;
1040
+ }
1041
+ releasePrimaryProviderGate(agentName) {
1042
+ const gate = this.providerGate(agentName);
1043
+ gate.primaryDepth = Math.max(0, gate.primaryDepth - 1);
1044
+ this.flushAuxiliaryAdmission(agentName);
1045
+ }
1046
+ flushAuxiliaryAdmission(agentName) {
1047
+ const gate = this.providerGate(agentName);
1048
+ if (gate.primaryDepth > 0 || gate.primaryPending || this.providerAccelerationCooldowns.has(agentName))
1049
+ return;
1050
+ for (const resolve of gate.auxiliaryWaiters.splice(0))
1051
+ resolve();
1052
+ }
1053
+ async waitForAuxiliaryIdle(agentName) {
1054
+ const gate = this.providerGate(agentName);
1055
+ if (gate.auxiliaryInFlight === 0)
1056
+ return;
1057
+ await new Promise((resolve) => gate.idleWaiters.push(resolve));
1058
+ }
1059
+ async withAuxiliaryAdmission(agentName, run) {
1060
+ const gate = this.providerGate(agentName);
1061
+ if (this.providerGateBlocked(agentName)) {
1062
+ gate.deferredAuxiliary++;
1063
+ try {
1064
+ await new Promise((resolve) => gate.auxiliaryWaiters.push(resolve));
1065
+ }
1066
+ finally {
1067
+ gate.deferredAuxiliary = Math.max(0, gate.deferredAuxiliary - 1);
1068
+ }
1069
+ }
1070
+ if (this.providerAdmissionClosed) {
1071
+ throw new Error(`Provider admission closed while stopping (${agentName})`);
1072
+ }
1073
+ gate.auxiliaryInFlight++;
1074
+ try {
1075
+ return await run();
1076
+ }
1077
+ finally {
1078
+ gate.auxiliaryInFlight = Math.max(0, gate.auxiliaryInFlight - 1);
1079
+ if (gate.auxiliaryInFlight === 0)
1080
+ for (const resolve of gate.idleWaiters.splice(0))
1081
+ resolve();
1082
+ }
1083
+ }
1084
+ auxiliaryMembraneFor(agentName) {
1085
+ const target = this.membrane;
1086
+ return new Proxy(target, { get: (obj, prop, receiver) => {
1087
+ const value = Reflect.get(obj, prop, receiver);
1088
+ if (prop === 'complete' && typeof value === 'function') {
1089
+ return (...args) => this.withAuxiliaryAdmission(agentName, () => Reflect.apply(value, this.membrane, args));
1090
+ }
1091
+ return typeof value === 'function' ? value.bind(this.membrane) : value;
1092
+ } });
1093
+ }
1094
+ accelerationCooldownMs(agentName, error) {
1095
+ const base = error.retryAfterMs ?? this.providerAccelerationDefaultCooldownMs;
1096
+ let hash = 0;
1097
+ for (const ch of agentName)
1098
+ hash = ((hash * 31) + ch.charCodeAt(0)) >>> 0;
1099
+ const jitter = this.providerAccelerationJitterMs > 0 ? hash % (this.providerAccelerationJitterMs + 1) : 0;
1100
+ return Math.min(PROVIDER_ACCELERATION_MAX_COOLDOWN_MS, Math.max(1_000, base) + jitter);
1101
+ }
1102
+ sameInferenceRequest(a, b) {
1103
+ return a.agentName === b.agentName && a.reason === b.reason && a.source === b.source &&
1104
+ a.timestamp === b.timestamp && a.channelId === b.channelId;
1105
+ }
1106
+ holdProviderAcceleration(agent, error, trigger) {
1107
+ if (this.ephemeralRuns.has(agent.name) || this.conversationAgentHomes.has(agent.name) || !isOrganizationAccelerationRateLimit(error))
1108
+ return false;
1109
+ const now = Date.now();
1110
+ const delayMs = this.accelerationCooldownMs(agent.name, error);
1111
+ const existing = this.providerAccelerationCooldowns.get(agent.name);
1112
+ const held = existing?.heldRequests ?? [];
1113
+ if (trigger && !held.some((r) => this.sameInferenceRequest(r, trigger)))
1114
+ held.push(trigger);
1115
+ if (existing)
1116
+ clearTimeout(existing.timer);
1117
+ const timer = setTimeout(() => this.releaseProviderAccelerationCooldown(agent.name), delayMs);
1118
+ timer.unref?.();
1119
+ this.providerAccelerationCooldowns.set(agent.name, { startedAt: existing?.startedAt ?? now,
1120
+ until: now + delayMs, timer, heldRequests: held, reason: error.message,
1121
+ failures: (existing?.failures ?? 0) + 1 });
1122
+ const gate = this.providerGate(agent.name);
1123
+ gate.primaryPending = true;
1124
+ this.providerAccelerationRecoveries.set(agent.name, { startedAt: existing?.startedAt ?? now,
1125
+ failures: (existing?.failures ?? 0) + 1, heldRequests: held.length, reason: error.message });
1126
+ console.error(`[provider-cooldown] agent=${agent.name} organization acceleration 429 — ` +
1127
+ `holding primary/auxiliary for ${delayMs}ms; ${held.length} request(s) retained`);
1128
+ return true;
1129
+ }
1130
+ releaseProviderAccelerationCooldown(agentName) {
1131
+ const cooldown = this.providerAccelerationCooldowns.get(agentName);
1132
+ if (!cooldown)
1133
+ return;
1134
+ clearTimeout(cooldown.timer);
1135
+ this.providerAccelerationCooldowns.delete(agentName);
1136
+ const recovery = this.providerAccelerationRecoveries.get(agentName);
1137
+ if (recovery) {
1138
+ recovery.releasedAt = Date.now();
1139
+ recovery.heldRequests = cooldown.heldRequests.length;
1140
+ }
1141
+ const requests = cooldown.heldRequests.length > 0 ? cooldown.heldRequests : [{ agentName,
1142
+ reason: 'provider-acceleration-retry', source: 'framework', timestamp: Date.now() }];
1143
+ this.pendingRequests.push(...requests);
1144
+ console.error(`[provider-cooldown] agent=${agentName} released after ${Date.now() - cooldown.startedAt}ms — ` +
1145
+ `fresh compile queued with ${requests.length} retained request(s)`);
1146
+ }
1147
+ recordProviderAccelerationRecovery(agent, request, stopReason) {
1148
+ const recovery = this.providerAccelerationRecoveries.get(agent.name);
1149
+ if (!recovery?.releasedAt)
1150
+ return;
1151
+ this.providerAccelerationRecoveries.delete(agent.name);
1152
+ const completedAt = Date.now();
1153
+ const receipt = {
1154
+ startedAt: recovery.startedAt,
1155
+ releasedAt: recovery.releasedAt,
1156
+ completedAt,
1157
+ cooldownMs: recovery.releasedAt - recovery.startedAt,
1158
+ waitedMs: completedAt - recovery.startedAt,
1159
+ failures: recovery.failures,
1160
+ heldRequests: recovery.heldRequests,
1161
+ messageCount: request?.messages.length ?? 0,
1162
+ toolCount: request?.tools?.length ?? 0,
1163
+ stopReason,
1164
+ };
1165
+ this.providerAccelerationLastRecovery.set(agent.name, receipt);
1166
+ // Operational receipt only: provider admission must never author resident memory.
1167
+ console.error(`[provider-cooldown] recovered ${JSON.stringify({ agent: agent.name, ...receipt })}`);
1168
+ }
1169
+ cancelProviderAdmission() {
1170
+ for (const cooldown of this.providerAccelerationCooldowns.values())
1171
+ clearTimeout(cooldown.timer);
1172
+ this.providerAccelerationCooldowns.clear();
1173
+ this.providerAccelerationRecoveries.clear();
1174
+ for (const gate of this.providerGates.values()) {
1175
+ gate.primaryDepth = 0;
1176
+ gate.primaryPending = false;
1177
+ for (const resolve of gate.auxiliaryWaiters.splice(0))
1178
+ resolve();
1179
+ for (const resolve of gate.idleWaiters.splice(0))
1180
+ resolve();
1181
+ }
1182
+ this.providerGates.clear();
1183
+ }
991
1184
  startQueuedMaintenance() {
992
1185
  if (this.maintenancePass || !this.running)
993
1186
  return;
@@ -1007,6 +1200,8 @@ export class AgentFramework {
1007
1200
  const cm = agent.getContextManager();
1008
1201
  const tools = this.getToolsForAgent(agent.name).filter((tool) => agent.canUseTool(tool.name));
1009
1202
  cm.setToolDefinitions(tools);
1203
+ if (this.providerGateBlocked(agent.name))
1204
+ return [];
1010
1205
  if (cm.isReady())
1011
1206
  return [];
1012
1207
  const pending = cm.getPendingWork()?.description;
@@ -1570,8 +1765,10 @@ export class AgentFramework {
1570
1765
  /**
1571
1766
  * Add or replace (upsert) a gate policy at runtime. Validated and persisted
1572
1767
  * to gate.json; hot-applied in memory. Throws if no gate is configured or the
1573
- * policy is invalid. `position: 'prepend'` puts a wake rule ahead of broad
1574
- * defer/debounce rules (first match wins).
1768
+ * policy is invalid. Placement: `'prepend'` puts a rule ahead of EVERYTHING
1769
+ * (including addressed-message wake rules — see EventGate.lintShadows);
1770
+ * `{before: name}` / `{after: name}` anchor it against a specific rule,
1771
+ * which is usually what a sampler or debounce actually wants.
1575
1772
  */
1576
1773
  addGatePolicy(rawPolicy, options) {
1577
1774
  if (!this.eventGate) {
@@ -1904,9 +2101,17 @@ export class AgentFramework {
1904
2101
  name: 'wake_add_rule',
1905
2102
  description: 'Add or replace a wake rule (a gate.json policy) at runtime — no need to ' +
1906
2103
  'hand-edit the file. The rule is validated and hot-applied immediately. ' +
1907
- 'A rule with the same `name` replaces the existing one in place. Use ' +
1908
- '`position: "prepend"` to put a wake rule ahead of broad defer/debounce ' +
1909
- 'rules (first match wins). Two common shapes:\n' +
2104
+ 'A rule with the same `name` replaces the existing one in place (add a ' +
2105
+ 'position to move it). ⚠️ ORDERING IS SEMANTICS: first match wins, and a ' +
2106
+ 'matched rule CONSUMES the event — a broad rule placed early makes every ' +
2107
+ 'later rule unreachable for its scope, including your addressed-message ' +
2108
+ '(DM/mention) wake rules. Prefer `insertBefore`/`insertAfter` to place a ' +
2109
+ 'rule relative to a specific existing rule; use `position: "prepend"` only ' +
2110
+ 'when the rule really must beat EVERYTHING. For "additionally wake me ' +
2111
+ 'every Nth event / at most N per hour" governors, set `passthrough: true` ' +
2112
+ 'so non-firing matches fall through to your normal rules instead of ' +
2113
+ 'swallowing them. The result includes a before/after probe table and any ' +
2114
+ 'shadow warnings — READ THEM before ending the turn. Two common shapes:\n' +
1910
2115
  '• Watch a FILE/workspace path: match on `mount` + `pathGlob`, e.g. ' +
1911
2116
  '`{ name: "watch-notes", match: { scope: ["workspace:modified"], mount: "project", pathGlob: "notes/*.md" }, behavior: "always" }`.\n' +
1912
2117
  '• Watch a CHANNEL: match on `source` + `channel` (and/or `tagsAny: ["chat:ambient"]`), ' +
@@ -1973,7 +2178,27 @@ export class AgentFramework {
1973
2178
  position: {
1974
2179
  type: 'string',
1975
2180
  enum: ['append', 'prepend'],
1976
- description: 'Where to insert a NEW rule (ignored when replacing by name). Default "append".',
2181
+ description: 'Coarse placement. "append" (default) adds at the end; "prepend" puts the rule ' +
2182
+ 'ahead of EVERYTHING — including addressed-message wake rules — so prefer ' +
2183
+ 'insertBefore/insertAfter unless the rule really must beat every other rule. ' +
2184
+ 'When replacing an existing name, giving any placement MOVES the rule there; ' +
2185
+ 'omit all placement fields to update it in place.',
2186
+ },
2187
+ insertBefore: {
2188
+ type: 'string',
2189
+ description: 'Insert the rule immediately BEFORE the named existing rule (error if absent). ' +
2190
+ 'The safe way to order against a specific catch-all, e.g. insertBefore: "discord-ambient".',
2191
+ },
2192
+ insertAfter: {
2193
+ type: 'string',
2194
+ description: 'Insert the rule immediately AFTER the named existing rule (error if absent).',
2195
+ },
2196
+ passthrough: {
2197
+ type: 'boolean',
2198
+ description: 'Observer semantics for rateLimit/passiveSample rules: when the rule matches but ' +
2199
+ 'does not fire (below N, bucket empty), the event FALLS THROUGH to later rules ' +
2200
+ 'instead of being consumed. Use for "additionally wake me every Nth event" ' +
2201
+ 'governors so they never swallow DM/mention wakes.',
1977
2202
  },
1978
2203
  resets: {
1979
2204
  type: 'array',
@@ -3063,7 +3288,9 @@ export class AgentFramework {
3063
3288
  store: this.store,
3064
3289
  namespace: `agents/${config.name}`,
3065
3290
  strategy: config.strategy ?? new PassthroughStrategy(),
3066
- membrane: this.membrane,
3291
+ // Context Manager complete() calls are auxiliary work; the primary
3292
+ // streaming agent keeps the original Membrane.
3293
+ membrane: this.auxiliaryMembraneFor(config.name),
3067
3294
  debugLogContext: !!process.env.DEBUG_CONTEXT,
3068
3295
  });
3069
3296
  const agent = new Agent(config, contextManager, this.membrane);
@@ -3290,7 +3517,7 @@ export class AgentFramework {
3290
3517
  // classifier-safe D-class from the 2026-07-24 ablation). It also
3291
3518
  // updates lastAnnouncedLocus so the next turn's announce-on-change
3292
3519
  // diffs against what the agent was actually last told.
3293
- if (agent.proseRouting !== 'explicit' &&
3520
+ if ((agent.proseRouting === 'locus' || agent.proseRouting === 'hybrid') &&
3294
3521
  !shouldEndTurn && !overBudget && currentState.stream) {
3295
3522
  // Two signals qualify an injection to move the pin (n=6 + n=7):
3296
3523
  // - chat:addressed — someone explicitly spoke TO the agent
@@ -3693,6 +3920,9 @@ export class AgentFramework {
3693
3920
  isolate: true,
3694
3921
  // Strategy instances are stateful — never share the template's.
3695
3922
  strategy: router.strategyFactory?.() ?? new PassthroughStrategy(),
3923
+ // Dynamic conversation forks retain the established provider policy in
3924
+ // this bounded first slice. Provider cooldown ownership belongs to the
3925
+ // persistent resident only; generation-unique forks must not leak gates.
3696
3926
  membrane: this.membrane,
3697
3927
  debugLogContext: !!process.env.DEBUG_CONTEXT,
3698
3928
  });
@@ -4026,7 +4256,8 @@ export class AgentFramework {
4026
4256
  // Clear pending (we'll re-add if inference doesn't run)
4027
4257
  this.pendingRequests = [];
4028
4258
  // Check each agent
4029
- for (const [agentName, requests] of requestsByAgent) {
4259
+ for (const [agentName, groupedRequests] of requestsByAgent) {
4260
+ let requests = groupedRequests;
4030
4261
  const agent = this.agents.get(agentName);
4031
4262
  if (!agent) {
4032
4263
  // Agent not found — request is orphaned. Emit warning and drop.
@@ -4041,6 +4272,29 @@ export class AgentFramework {
4041
4272
  console.error(`[inference-dropped] agent=${agentName} reason=agent_not_found requests=${requests.length}`);
4042
4273
  continue;
4043
4274
  }
4275
+ const providerCooldown = this.providerAccelerationCooldowns?.get(agentName);
4276
+ if (providerCooldown) {
4277
+ if (now < providerCooldown.until) {
4278
+ for (const req of requests) {
4279
+ if (!providerCooldown.heldRequests.some((r) => this.sameInferenceRequest(r, req))) {
4280
+ providerCooldown.heldRequests.push(req);
4281
+ }
4282
+ }
4283
+ const recovery = this.providerAccelerationRecoveries.get(agentName);
4284
+ if (recovery)
4285
+ recovery.heldRequests = providerCooldown.heldRequests.length;
4286
+ // The timer owns release. Do not hot-poll these requests.
4287
+ continue;
4288
+ }
4289
+ // Timers can deliver late under load. Release synchronously and merge
4290
+ // the held causes into this one current compile.
4291
+ clearTimeout(providerCooldown.timer);
4292
+ this.providerAccelerationCooldowns.delete(agentName);
4293
+ const recovery = this.providerAccelerationRecoveries.get(agentName);
4294
+ if (recovery)
4295
+ recovery.releasedAt = now;
4296
+ requests = [...providerCooldown.heldRequests, ...requests];
4297
+ }
4044
4298
  // Skip if agent is busy (inferring, streaming, or waiting for tools) —
4045
4299
  // or if a turn is alive at all (activeTurnTokens): the state machine
4046
4300
  // goes idle at endTurn's reset while driveStream teardown is still
@@ -4056,9 +4310,14 @@ export class AgentFramework {
4056
4310
  // token; the predecessor frame's late finally no-ops via token-match.
4057
4311
  // This mirrors how the restart has always overwritten activeStreams
4058
4312
  // rather than waiting for the old stream's teardown.
4059
- const isBudgetRestart = requests.some((r) => r.reason === 'context_budget_restart');
4060
- const turnAlive = !isBudgetRestart && this.activeTurnTokens.has(agentName);
4061
- if (turnAlive || agent.state.status === 'inferring' || agent.state.status === 'streaming' || agent.state.status === 'waiting_for_tools') {
4313
+ const budgetRestart = requests.find((r) => r.reason === 'context_budget_restart');
4314
+ const turnAlive = !budgetRestart && this.activeTurnTokens.has(agentName);
4315
+ const providerGate = this.providerGates?.get(agentName);
4316
+ // A primary can own provider admission while yielding to an already in-flight
4317
+ // auxiliary call. Keep this agent's later wakes queued, but do not block the
4318
+ // framework event loop or other residents while that auxiliary call settles.
4319
+ const providerPrimaryWaiting = (providerGate?.primaryDepth ?? 0) > 0 && !this.activeTurnTokens.has(agentName);
4320
+ if (providerPrimaryWaiting || turnAlive || agent.state.status === 'inferring' || agent.state.status === 'streaming' || agent.state.status === 'waiting_for_tools') {
4062
4321
  // Re-queue requests, but warn if they've been pending too long
4063
4322
  const oldest = Math.min(...requests.map(r => r.timestamp));
4064
4323
  if (now - oldest > STALE_REQUEST_MS &&
@@ -4095,10 +4354,21 @@ export class AgentFramework {
4095
4354
  // exactly this class of silent drop. One stderr line per drop.
4096
4355
  console.error(`[inference-dropped] agent=${agentName} reason=policy-skip ` +
4097
4356
  `requests=${requests.length} triggers=${requests.map((r) => r.reason).join(',')}`);
4357
+ const gate = this.providerGates.get(agentName);
4358
+ if (gate?.primaryPending && this.providerAccelerationRecoveries.has(agentName)) {
4359
+ gate.primaryPending = false;
4360
+ this.providerAccelerationRecoveries.delete(agentName);
4361
+ this.flushAuxiliaryAdmission(agentName);
4362
+ }
4098
4363
  continue;
4099
4364
  }
4100
4365
  // Start streaming inference (non-blocking — driveStream runs in background)
4101
- const trigger = requests[0];
4366
+ // The request that grants the turn-alive exception must also define the
4367
+ // downstream turn semantics. An ordinary wake can already be pending
4368
+ // when the tool boundary appends a context-budget restart; selecting
4369
+ // requests[0] in that mixed batch bypassed the turn lock because a
4370
+ // restart existed, then treated the continuation as a fresh turn.
4371
+ const trigger = budgetRestart ?? requests[0];
4102
4372
  // Route this turn's auto-published speech to the channel that triggered
4103
4373
  // it (item-3 redux). A batched wake may carry several triggering channels
4104
4374
  // (messages arrived in >1 channel while the agent was busy/idle):
@@ -4177,7 +4447,9 @@ export class AgentFramework {
4177
4447
  : id);
4178
4448
  }
4179
4449
  const suppressedNote = suppressed > 0
4180
- ? `${suppressed} plain-speech segment(s) suppressed (explicit send in the same round — resend with a send tool if it was meant to be heard)`
4450
+ ? agent.proseRouting === 'disabled'
4451
+ ? `${suppressed} plain-speech segment(s) suppressed (proseRouting=disabled — publish only with an explicit send tool)`
4452
+ : `${suppressed} plain-speech segment(s) suppressed (explicit send in the same round — resend with a send tool if it was meant to be heard)`
4181
4453
  : '';
4182
4454
  const text = shown.length > 0
4183
4455
  ? `[delivered] plain speech → ${shown.join(' · ')}${suppressedNote ? ` · ${suppressedNote}` : ''}`
@@ -4307,6 +4579,98 @@ export class AgentFramework {
4307
4579
  await this.deliverProseEnvelope(agent, envelope.replace(/^\s+(?=>>)/, ''));
4308
4580
  }
4309
4581
  }
4582
+ /** Locus-preserving explicit publication. Source remains byte-identical in Chronicle. */
4583
+ async deliverHybridProse(agent, rawText, locus, allowLocus) {
4584
+ const lines = rawText.split('\n');
4585
+ const envelopes = [];
4586
+ let current = [];
4587
+ for (const line of lines) {
4588
+ if (line.trimStart().startsWith('>>>') && current.length > 0) {
4589
+ envelopes.push(current.join('\n'));
4590
+ current = [];
4591
+ }
4592
+ current.push(line);
4593
+ }
4594
+ if (current.length > 0)
4595
+ envelopes.push(current.join('\n'));
4596
+ for (const envelope of envelopes) {
4597
+ if (!envelope.trim())
4598
+ continue;
4599
+ const normalized = envelope.replace(/^\s+(?=>>>)/, '');
4600
+ const attempted = normalized.startsWith('>>>');
4601
+ const parsed = parseHybridProsePrefix(normalized);
4602
+ if (parsed.continueTurn)
4603
+ this.proseContinuations.add(agent.name);
4604
+ if (parsed.kind === 'private') {
4605
+ this.proseTargetPins.delete(agent.name);
4606
+ this.proseHybridSuppressed.add(agent.name);
4607
+ console.error(`[prose] ${agent.name}: >>>skip_reply — ${parsed.body.length} chars kept in context (not sent)`);
4608
+ continue;
4609
+ }
4610
+ if (parsed.kind === 'target') {
4611
+ const resolved = this.channelRegistry.resolveProseTarget(parsed.target);
4612
+ if ('error' in resolved) {
4613
+ this.proseTargetPins.delete(agent.name);
4614
+ this.proseHybridSuppressed.add(agent.name);
4615
+ this.bounceProse(agent, parsed.body, resolved.error, resolved.candidates, '>>>');
4616
+ continue;
4617
+ }
4618
+ let body = parsed.body;
4619
+ const usedClipboard = body.includes('{{unsent}}');
4620
+ if (usedClipboard)
4621
+ body = body.replaceAll('{{unsent}}', this.proseClipboards.get(agent.name) ?? '');
4622
+ this.proseTargetPins.set(agent.name, resolved.channelId);
4623
+ this.proseHybridSuppressed.delete(agent.name);
4624
+ if (!body.trim()) {
4625
+ console.error(`[prose] ${agent.name}: >>>${parsed.target} established target; empty body — nothing sent yet`);
4626
+ continue;
4627
+ }
4628
+ try {
4629
+ const outcome = await this.channelRegistry.routeSpeech(agent.name, body, resolved.channelId);
4630
+ this.recordProseDelivery(agent.name, outcome);
4631
+ if (outcome?.delivered) {
4632
+ this.proseBounceStreaks.delete(agent.name);
4633
+ if (usedClipboard)
4634
+ this.proseClipboards.delete(agent.name);
4635
+ }
4636
+ else {
4637
+ this.proseTargetPins.delete(agent.name);
4638
+ this.proseHybridSuppressed.add(agent.name);
4639
+ this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} was not confirmed`, undefined, '>>>');
4640
+ }
4641
+ }
4642
+ catch (err) {
4643
+ this.proseTargetPins.delete(agent.name);
4644
+ this.proseHybridSuppressed.add(agent.name);
4645
+ this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} failed: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>>');
4646
+ }
4647
+ continue;
4648
+ }
4649
+ if (attempted) {
4650
+ this.proseTargetPins.delete(agent.name);
4651
+ this.proseHybridSuppressed.add(agent.name);
4652
+ this.bounceProse(agent, normalized, 'malformed >>> routing envelope: destination is missing', undefined, '>>>');
4653
+ continue;
4654
+ }
4655
+ if (this.proseHybridSuppressed.has(agent.name)) {
4656
+ this.recordProseSuppression(agent.name, 1);
4657
+ continue;
4658
+ }
4659
+ const sticky = this.proseTargetPins.get(agent.name);
4660
+ if (!allowLocus && !sticky) {
4661
+ this.recordProseSuppression(agent.name, 1);
4662
+ continue;
4663
+ }
4664
+ const target = sticky ?? locus;
4665
+ try {
4666
+ const outcome = await this.channelRegistry.routeSpeech(agent.name, envelope, target);
4667
+ this.recordProseDelivery(agent.name, outcome);
4668
+ }
4669
+ catch (err) {
4670
+ console.error('hybrid locus delivery failed:', err);
4671
+ }
4672
+ }
4673
+ }
4310
4674
  async deliverProseEnvelope(agent, rawText) {
4311
4675
  const name = agent.name;
4312
4676
  const parsed = parseProsePrefix(rawText);
@@ -4365,7 +4729,7 @@ export class AgentFramework {
4365
4729
  }
4366
4730
  /** Cap on consecutive bounce-triggered wakes (notices still append after). */
4367
4731
  static PROSE_BOUNCE_WAKE_CAP = 2;
4368
- bounceProse(agent, text, reason, candidates) {
4732
+ bounceProse(agent, text, reason, candidates, prefix = '>>') {
4369
4733
  const name = agent.name;
4370
4734
  this.proseClipboards.set(name, text);
4371
4735
  const streak = (this.proseBounceStreaks.get(name) ?? 0) + 1;
@@ -4373,8 +4737,9 @@ export class AgentFramework {
4373
4737
  const cand = candidates?.length ? ` Known channels it could mean: ${candidates.join(', ')}.` : '';
4374
4738
  const notice = `[prose-routing] Your text (${text.length} chars) was not delivered — ${reason}.${cand} ` +
4375
4739
  'The text is retained; nothing is lost. To deliver it unchanged, reply with a ' +
4376
- 'destination plus the token {{unsent}}, e.g. ">>#channel {{unsent}}" or ">>@person {{unsent}}". ' +
4377
- '">>skip_reply {{unsent}}" keeps it in context only. The prose_help tool shows the full syntax.';
4740
+ `destination plus the token {{unsent}}, e.g. "${prefix}#channel {{unsent}}" or "${prefix}@person {{unsent}}". ` +
4741
+ `"${prefix}skip_reply {{unsent}}" keeps it in context only.` +
4742
+ (prefix === '>>' ? ' The prose_help tool shows the full syntax.' : '');
4378
4743
  try {
4379
4744
  // Through framework.addMessage, NOT the context manager directly: while
4380
4745
  // the turn is still streaming this defers the notice to the next tool
@@ -4437,6 +4802,13 @@ export class AgentFramework {
4437
4802
  console.error('maybePrimeProseMode: state read/write failed:', err);
4438
4803
  return;
4439
4804
  }
4805
+ // Disabled mode is intentionally silent: no model-visible routing prose.
4806
+ // The resident publishes only through explicit tools. This contains
4807
+ // continuity-summary locus leaks (Host #96).
4808
+ if (mode === 'disabled') {
4809
+ console.error(`[prose] ${agent.name}: disabled mode recorded (no primer injected)`);
4810
+ return;
4811
+ }
4440
4812
  try {
4441
4813
  const id = agent.getContextManager().addMessage('user', [{ type: 'text', text: proseModePrimer(mode) }], { system: true, kind: 'prose-routing-primer' });
4442
4814
  this.emitTrace({ type: 'message:added', messageId: id, source: 'prose-routing-primer' });
@@ -4446,7 +4818,32 @@ export class AgentFramework {
4446
4818
  console.error('maybePrimeProseMode: failed to append primer:', err);
4447
4819
  }
4448
4820
  }
4449
- async startAgentStream(agent, trigger, attempt = 0) {
4821
+ async startAgentStream(agent, trigger, attempt = 0, providerGateAlreadyHeld = false) {
4822
+ const ownsProviderGate = !this.ephemeralRuns.has(agent.name) && !this.conversationAgentHomes.has(agent.name);
4823
+ if (ownsProviderGate && !providerGateAlreadyHeld) {
4824
+ this.acquirePrimaryProviderGate(agent.name);
4825
+ const gate = this.providerGate(agent.name);
4826
+ if (gate.auxiliaryInFlight > 0) {
4827
+ // Do not await inside processInferenceRequests: one slow auxiliary call for
4828
+ // this resident must not block events or other residents. Admission stays
4829
+ // owned and the continuation receives it after the in-flight call settles.
4830
+ void this.waitForAuxiliaryIdle(agent.name).then(async () => {
4831
+ if (this.providerAdmissionClosed) {
4832
+ this.releasePrimaryProviderGate(agent.name);
4833
+ return;
4834
+ }
4835
+ await this.startAgentStream(agent, trigger, attempt, true);
4836
+ }).catch((error) => {
4837
+ this.releasePrimaryProviderGate(agent.name);
4838
+ console.error(`[provider-cooldown] failed to resume primary for ${agent.name}:`, error);
4839
+ });
4840
+ return;
4841
+ }
4842
+ if (this.providerAdmissionClosed) {
4843
+ this.releasePrimaryProviderGate(agent.name);
4844
+ return;
4845
+ }
4846
+ }
4450
4847
  // Mark the turn alive before ANYTHING awaits (hooks, compile, stream
4451
4848
  // setup): from here until this turn's teardown, cross-turn writers defer
4452
4849
  // (addMessage guard) instead of appending — nothing may enter the window
@@ -4467,12 +4864,14 @@ export class AgentFramework {
4467
4864
  this.activeTurnTokens.set(agent.name, turnToken);
4468
4865
  let tokenHandedOff = false;
4469
4866
  try {
4470
- tokenHandedOff = await this.beginAgentTurn(agent, trigger, attempt, turnToken);
4867
+ tokenHandedOff = await this.beginAgentTurn(agent, trigger, attempt, turnToken, ownsProviderGate);
4471
4868
  }
4472
4869
  finally {
4473
4870
  if (!tokenHandedOff && this.activeTurnTokens.get(agent.name) === turnToken) {
4474
4871
  this.activeTurnTokens.delete(agent.name);
4475
4872
  }
4873
+ if (!tokenHandedOff && ownsProviderGate)
4874
+ this.releasePrimaryProviderGate(agent.name);
4476
4875
  }
4477
4876
  }
4478
4877
  /**
@@ -4480,7 +4879,7 @@ export class AgentFramework {
4480
4879
  * turn token unconditionally. Returns true iff the token was handed off to
4481
4880
  * driveStream (whose finally then owns clearing it).
4482
4881
  */
4483
- async beginAgentTurn(agent, trigger, attempt, turnToken) {
4882
+ async beginAgentTurn(agent, trigger, attempt, turnToken, ownsProviderGate) {
4484
4883
  // Flush messages deferred during the PREVIOUS turn — before the
4485
4884
  // checkpoint, the locus announcement, and the compile — so a turn started
4486
4885
  // by a queued wake actually CONTAINS the message that woke it. (2026-07-31
@@ -4563,10 +4962,13 @@ export class AgentFramework {
4563
4962
  this.turnEngagedChannels.delete(agent.name);
4564
4963
  this.turnProseDeliveries.delete(agent.name);
4565
4964
  this.turnProseSuppressed.delete(agent.name);
4566
- if (agent.proseRouting === 'explicit') {
4567
- // Explicit prose routing: there is no locus. The model names every
4568
- // destination in-band (`>>` prefixes); the only turn state is the
4569
- // sticky target, reset for each fresh turn. No freeze, no announce.
4965
+ this.proseHybridSuppressed.delete(agent.name);
4966
+ if (agent.proseRouting === 'hybrid')
4967
+ this.proseTargetPins.delete(agent.name);
4968
+ if (agent.proseRouting === 'explicit' || agent.proseRouting === 'disabled') {
4969
+ // Explicit and disabled prose routing have no inferred locus.
4970
+ // Explicit mode uses a turn-scoped `>>` target; disabled mode has no
4971
+ // prose target at all. Neither mode freezes or announces a locus.
4570
4972
  this.proseTargetPins.delete(agent.name);
4571
4973
  this.proseContinuations.delete(agent.name);
4572
4974
  this.midTurnInputSignals.delete(agent.name);
@@ -4646,7 +5048,7 @@ export class AgentFramework {
4646
5048
  }
4647
5049
  }
4648
5050
  const { stream, request: compiledRequest } = await agent.startStreamWithInjections(tools, injections);
4649
- const handle = this.driveStream(agent, stream, requestSnapshot, trigger, attempt, compiledRequest);
5051
+ const handle = this.driveStream(agent, stream, requestSnapshot, trigger, attempt, compiledRequest, ownsProviderGate);
4650
5052
  this.activeStreams.set(agent.name, handle);
4651
5053
  // Handoff: driveStream captured the token in its synchronous prefix;
4652
5054
  // its finally now owns clearing it. The caller's finally must not.
@@ -4665,6 +5067,12 @@ export class AgentFramework {
4665
5067
  stack: err.stack,
4666
5068
  });
4667
5069
  agent.reset();
5070
+ if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
5071
+ // Capacity scheduling is not poisoned history. The cooldown timer owns
5072
+ // one later fresh compile; no immediate retry or exhaustion marker.
5073
+ this.eventGate?.onInferenceEnded(agent.name);
5074
+ return false;
5075
+ }
4668
5076
  const action = this.errorPolicy.onInferenceError(err, agent.name, attempt);
4669
5077
  if (action.retry) {
4670
5078
  await new Promise((resolve) => setTimeout(resolve, action.delayMs));
@@ -4712,7 +5120,7 @@ export class AgentFramework {
4712
5120
  return false;
4713
5121
  }
4714
5122
  }
4715
- async driveStream(agent, stream, requestSnapshot, trigger, attempt = 0, compiledRequest) {
5123
+ async driveStream(agent, stream, requestSnapshot, trigger, attempt = 0, compiledRequest, ownsProviderGate = false) {
4716
5124
  const startTime = Date.now();
4717
5125
  const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
4718
5126
  const myStreamId = agent.streamId;
@@ -4769,9 +5177,11 @@ export class AgentFramework {
4769
5177
  // this doesn't violate never-guess: it says "attending to what you
4770
5178
  // sent here", which is true regardless of where the reply goes.
4771
5179
  // Heartbeat/no-trigger explicit turns show no indicator.
4772
- const typingChannel = agent.proseRouting === 'explicit'
4773
- ? trigger?.channelId ?? null
4774
- : resolveTurnLocus();
5180
+ const typingChannel = agent.proseRouting === 'disabled'
5181
+ ? null
5182
+ : agent.proseRouting === 'explicit'
5183
+ ? trigger?.channelId ?? null
5184
+ : resolveTurnLocus();
4775
5185
  if (typingChannel)
4776
5186
  this.channelRegistry.startTyping(typingChannel);
4777
5187
  // MCPL Spec 14.3 outgoing streaming: route text deltas to their
@@ -4799,9 +5209,9 @@ export class AgentFramework {
4799
5209
  turnIndex: 0,
4800
5210
  phase: 'started',
4801
5211
  });
4802
- const proseStream = this.channelRegistry
5212
+ const proseStream = this.channelRegistry && agent.proseRouting !== 'disabled'
4803
5213
  ? new ProseStreamRouter({
4804
- mode: agent.proseRouting === 'explicit' ? 'explicit' : 'locus',
5214
+ mode: agent.proseRouting === 'explicit' ? 'explicit' : agent.proseRouting === 'hybrid' ? 'hybrid' : 'locus',
4805
5215
  initialTarget: typingChannel,
4806
5216
  resolve: (spec) => {
4807
5217
  const r = this.channelRegistry.resolveProseTarget(spec);
@@ -4917,9 +5327,17 @@ export class AgentFramework {
4917
5327
  const rawXml = rawText && /^<(antml:)?function_calls>/.test(rawText)
4918
5328
  ? rawText
4919
5329
  : undefined;
5330
+ // Persist THIS round's text only. `roundPreamble` (membrane
5331
+ // ≥0.5.79) is the per-round delta; the legacy `preamble` is
5332
+ // CUMULATIVE in XML mode — persisting it per round stored each
5333
+ // round's text N times and re-persisted injected
5334
+ // <function_results> as the agent's own words (the Evander
5335
+ // 2026-08-08 scaffold-leak pyramid: r1 ×3, r2 ×2).
5336
+ const roundText = event.context.roundPreamble ??
5337
+ event.context.preamble;
4920
5338
  assistantBlocks = [];
4921
- if (event.context.preamble) {
4922
- assistantBlocks.push({ type: 'text', text: event.context.preamble });
5339
+ if (roundText) {
5340
+ assistantBlocks.push({ type: 'text', text: roundText });
4923
5341
  }
4924
5342
  for (const c of event.calls) {
4925
5343
  assistantBlocks.push({
@@ -4956,14 +5374,32 @@ export class AgentFramework {
4956
5374
  const roundToolNames = event.calls.map((c) => c.name);
4957
5375
  const hasSameRoundPrivateThink = roundToolNames.includes('think') &&
4958
5376
  requestSnapshot.sameRoundThinkTextPolicy === 'private';
4959
- if (roundToolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)))) {
5377
+ if (roundToolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)) ||
5378
+ (agent.proseRouting === 'hybrid' && HYBRID_PUBLICATION_TOOLS.has(bareToolName(n))))) {
4960
5379
  turnSilenced = true;
4961
5380
  }
4962
5381
  if (roundContent && roundContent.length > 0) {
4963
5382
  liveProseRouting = true;
4964
5383
  const roundSegments = splitProseSegments(assistantBlocks);
4965
5384
  if (roundSegments.length > 0) {
4966
- if (agent.proseRouting === 'explicit') {
5385
+ if (agent.proseRouting === 'disabled') {
5386
+ console.error(`[routing] ${agent.name}: mid-turn prose NOT routed (proseRouting=disabled)`);
5387
+ this.recordProseSuppression(agent.name, roundSegments.length);
5388
+ }
5389
+ else if (agent.proseRouting === 'hybrid') {
5390
+ if (turnSilenced) {
5391
+ this.recordProseSuppression(agent.name, roundSegments.length);
5392
+ }
5393
+ else if (!hasSameRoundPrivateThink) {
5394
+ const locus = resolveTurnLocus();
5395
+ for (const seg of roundSegments) {
5396
+ turnSpeechChain = turnSpeechChain
5397
+ .then(() => this.deliverHybridProse(agent, seg, locus, true))
5398
+ .catch((err) => console.error('mid-turn hybrid prose delivery failed:', err));
5399
+ }
5400
+ }
5401
+ }
5402
+ else if (agent.proseRouting === 'explicit') {
4967
5403
  // Explicit mode: every segment through the prose gateway.
4968
5404
  // Silencing does not apply — unprefixed prose bounces and
4969
5405
  // prefixed prose is deliberate; think-privacy still holds.
@@ -5051,6 +5487,9 @@ export class AgentFramework {
5051
5487
  else {
5052
5488
  agent.addAssistantResponse(terminalContent);
5053
5489
  }
5490
+ // Bind the cooldown receipt to this fresh, successful request —
5491
+ // never to the frozen request that received the 429.
5492
+ this.recordProviderAccelerationRecovery(agent, compiledRequest, response.stopReason);
5054
5493
  // §10.5: context/afterInference is removed in 0.5.0 — no
5055
5494
  // content leaves the host here. Turn boundaries are announced
5056
5495
  // by the metadata-only inference/lifecycle notifications
@@ -5243,7 +5682,21 @@ export class AgentFramework {
5243
5682
  .join('\n')
5244
5683
  .trim();
5245
5684
  if (speechText) {
5246
- if (agent.proseRouting === 'explicit') {
5685
+ if (agent.proseRouting === 'disabled') {
5686
+ console.error(`[routing] ${agent.name}: text-only prose NOT routed (proseRouting=disabled)`);
5687
+ this.recordProseSuppression(agent.name, 1);
5688
+ }
5689
+ else if (agent.proseRouting === 'hybrid') {
5690
+ const locus = resolveTurnLocus();
5691
+ console.error(`[prose] ${agent.name}: text-only turn -> hybrid prose gateway`);
5692
+ try {
5693
+ await this.deliverHybridProse(agent, speechText, locus, true);
5694
+ }
5695
+ catch (err) {
5696
+ console.error('text-only hybrid prose delivery failed:', err);
5697
+ }
5698
+ }
5699
+ else if (agent.proseRouting === 'explicit') {
5247
5700
  console.error(`[prose] ${agent.name}: text-only turn -> prose gateway`);
5248
5701
  try {
5249
5702
  await this.deliverProse(agent, speechText);
@@ -5292,13 +5745,36 @@ export class AgentFramework {
5292
5745
  .filter((n) => typeof n === 'string');
5293
5746
  const silenced = liveProseRouting
5294
5747
  ? turnSilenced
5295
- : turnSilenced || toolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)));
5748
+ : turnSilenced || toolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)) ||
5749
+ (agent.proseRouting === 'hybrid' && HYBRID_PUBLICATION_TOOLS.has(bareToolName(n))));
5296
5750
  const segments = splitProseSegments(liveProseRouting ? terminalContent : response.content);
5297
5751
  // Preserve in-channel ordering: everything enqueued live must
5298
5752
  // land before the trailing prose. Awaited even when silenced —
5299
5753
  // the chain may still be flushing earlier rounds' posts.
5300
5754
  await turnSpeechChain;
5301
- if (agent.proseRouting === 'explicit') {
5755
+ if (agent.proseRouting === 'disabled') {
5756
+ if (segments.length > 0) {
5757
+ console.error(`[routing] ${agent.name}: tool-call trailing prose NOT routed (proseRouting=disabled)`);
5758
+ this.recordProseSuppression(agent.name, segments.length);
5759
+ }
5760
+ }
5761
+ else if (agent.proseRouting === 'hybrid') {
5762
+ if (silenced && segments.length > 0) {
5763
+ this.recordProseSuppression(agent.name, segments.length);
5764
+ }
5765
+ else if (segments.length > 0) {
5766
+ const locus = resolveTurnLocus();
5767
+ for (const seg of segments) {
5768
+ try {
5769
+ await this.deliverHybridProse(agent, seg, locus, true);
5770
+ }
5771
+ catch (err) {
5772
+ console.error('trailing hybrid prose delivery failed:', err);
5773
+ }
5774
+ }
5775
+ }
5776
+ }
5777
+ else if (agent.proseRouting === 'explicit') {
5302
5778
  if (segments.length > 0) {
5303
5779
  console.error(`[prose] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> ${segments.length} trailing segment(s) via prose gateway`);
5304
5780
  for (const seg of segments) {
@@ -5390,6 +5866,11 @@ export class AgentFramework {
5390
5866
  break;
5391
5867
  this.abortAgentScript(agent.name, 'stream error');
5392
5868
  agent.reset();
5869
+ if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
5870
+ lifecyclePhase = 'failed';
5871
+ this.eventGate?.onInferenceEnded(agent.name);
5872
+ break;
5873
+ }
5393
5874
  const action = this.errorPolicy.onInferenceError(err, agent.name, attempt);
5394
5875
  if (action.retry) {
5395
5876
  await new Promise((resolve) => setTimeout(resolve, action.delayMs));
@@ -5523,10 +6004,21 @@ export class AgentFramework {
5523
6004
  }
5524
6005
  }
5525
6006
  catch (error) {
5526
- // Stream itself threw (unexpected) — no retry path here, so also emit
5527
- // inference:exhausted so ephemeral agent promises can settle.
6007
+ // Stream itself threw. Organization acceleration is deferred from a
6008
+ // fresh compile; every other throw keeps the ordinary exhausted path.
5528
6009
  const err = error instanceof Error ? error : new Error(String(error));
5529
6010
  const durationMs = Date.now() - startTime;
6011
+ if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
6012
+ this.emitTrace({ type: 'inference:failed', agentName: agent.name, error: err.message, stack: err.stack });
6013
+ this.logInference({ timestamp: startTime, agentName: agent.name, requestId, success: false,
6014
+ error: `Provider acceleration cooldown: ${err.message}`,
6015
+ request: compiledRequest ?? { note: 'streaming request rate-limited' }, durationMs });
6016
+ this.abortAgentScript(agent.name, 'provider acceleration cooldown');
6017
+ lifecyclePhase = 'failed';
6018
+ agent.reset();
6019
+ this.eventGate?.onInferenceEnded(agent.name);
6020
+ return;
6021
+ }
5530
6022
  this.emitTrace({
5531
6023
  type: 'inference:failed',
5532
6024
  agentName: agent.name,
@@ -5582,6 +6074,8 @@ export class AgentFramework {
5582
6074
  // wedge). onInferenceEnded is idempotent, so a redundant call is safe.
5583
6075
  this.eventGate?.onInferenceEnded(agent.name);
5584
6076
  this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
6077
+ if (ownsProviderGate)
6078
+ this.releasePrimaryProviderGate(agent.name);
5585
6079
  // Stop the typing indicator on every exit path (complete, error,
5586
6080
  // exhausted, abort) so it never sticks after the turn ends.
5587
6081
  this.channelRegistry?.stopTyping();
@@ -5640,6 +6134,91 @@ export class AgentFramework {
5640
6134
  agentName: call.callerAgentName ?? '__ephemeral__',
5641
6135
  });
5642
6136
  }
6137
+ /**
6138
+ * Admin puppet: execute ONE tool call AS an agent and persist the
6139
+ * tool_use + tool_result pair in that agent's window exactly as a
6140
+ * model-initiated call is stored.
6141
+ *
6142
+ * Born from the princess exemplar surgery (2026-08-23). Her folded
6143
+ * narrative said "I have no tools"; every past tool_use/tool_result pair
6144
+ * had folded out of her context, so the act of calling had no in-context
6145
+ * exemplar and she could not emit her first call despite 30 eidoverse
6146
+ * tools riding every request. Placing one first-person pair restored the
6147
+ * capacity. That repair required stop → store surgery → restart; this
6148
+ * method is the live path — and the general lever for demonstrating an
6149
+ * affordance to a model that cannot find it (older models especially).
6150
+ *
6151
+ * Semantics:
6152
+ * - Refused unless the agent is idle: puppeting mid-turn would corrupt
6153
+ * the turn state machine and the live stream's wire ordering.
6154
+ * - Refused for tools outside the agent's own surface (canUseTool): the
6155
+ * stored pair must be an act the agent could genuinely have taken.
6156
+ * - The call executes FOR REAL through the shared dispatch (MCPL,
6157
+ * channel tools, utils, modules) with the agent's provenance — a
6158
+ * puppeted walk_to actually moves the avatar.
6159
+ * - Storage byte-follows the ordinary path: a bare tool_use assistant
6160
+ * message (no fabricated thinking or text — a stored assistant turn
6161
+ * with unverifiable thinking is the labclaude 400 class) plus a
6162
+ * tool_result under the same bounded spill policy (issue #89).
6163
+ * - Does NOT request inference; the agent sees the pair on its next
6164
+ * wake. Speak to the agent afterward if a wake is wanted.
6165
+ * - Provenance is loud in traces (puppet:tool-call) and the host log,
6166
+ * deliberately NOT in the stored messages — metadata there would break
6167
+ * byte-parity with real turns. Whether to disclose to the resident is
6168
+ * the operator's call; the princess precedent was disclosed first.
6169
+ */
6170
+ async puppetToolCall(agentName, toolName, input) {
6171
+ const agent = this.agents.get(agentName);
6172
+ if (!agent)
6173
+ throw new Error(`Unknown agent: ${agentName}`);
6174
+ if (agent.state.status !== 'idle') {
6175
+ throw new Error(`puppet refused: agent ${agentName} is ${agent.state.status} (requires idle — ` +
6176
+ `injecting a turn under an active stream corrupts wire ordering)`);
6177
+ }
6178
+ const onSurface = this.getToolsForAgent(agentName)
6179
+ .some((t) => t.name === toolName && agent.canUseTool(t.name));
6180
+ if (!onSurface) {
6181
+ throw new Error(`puppet refused: tool ${toolName} is not on ${agentName}'s surface — ` +
6182
+ `the stored pair must be an act the agent could genuinely have taken`);
6183
+ }
6184
+ // Anthropic-shaped id so the stored pair is indistinguishable from a
6185
+ // provider-issued call.
6186
+ const alphabet = 'ABCDEFGHJKMNPQRSTVWXYZabcdefghjkmnpqrstvwxyz0123456789';
6187
+ let suffix = '';
6188
+ for (let i = 0; i < 22; i++)
6189
+ suffix += alphabet[Math.floor(Math.random() * alphabet.length)];
6190
+ const toolUseId = `toolu_01${suffix}`;
6191
+ const started = Date.now();
6192
+ const result = await this.executeToolCall({
6193
+ id: toolUseId,
6194
+ name: toolName,
6195
+ input,
6196
+ callerAgentName: agentName,
6197
+ });
6198
+ const durationMs = Date.now() - started;
6199
+ // Store the pair through the same shapes the ordinary path uses. Build
6200
+ // the result blocks BEFORE storing the tool_use: the spill path awaits,
6201
+ // and a message arriving during that await must land before the pair,
6202
+ // never between tool_use and its tool_result. The two addMessage calls
6203
+ // below are synchronous and adjacent — nothing can interleave.
6204
+ const { blocks } = await this.buildStoredToolResultContent([{ id: toolUseId, name: toolName, input, result, durationMs }], this.resolveToolResultInlineCap(agent).cap);
6205
+ const cm = agent.getContextManager();
6206
+ cm.addMessage(agentName, [
6207
+ { type: 'tool_use', id: toolUseId, name: toolName, input },
6208
+ ]);
6209
+ cm.addMessage('user', blocks);
6210
+ this.emitTrace({
6211
+ type: 'puppet:tool-call',
6212
+ agentName,
6213
+ toolName,
6214
+ toolUseId,
6215
+ isError: !!result.isError,
6216
+ durationMs,
6217
+ });
6218
+ console.log(`[puppet] ${agentName}: ${toolName} → ${result.isError ? 'ERROR' : 'ok'} ` +
6219
+ `in ${durationMs}ms (${toolUseId})`);
6220
+ return { toolUseId, result };
6221
+ }
5643
6222
  async executeToolCallFrom(call, origin) {
5644
6223
  // Client-side programmatic tool calling for promise-based callers
5645
6224
  // (SubagentModule ephemerals). Keyed by callerAgentName so each ephemeral
@@ -6082,6 +6661,9 @@ export class AgentFramework {
6082
6661
  const blocks = toolResults.map(tc => ({
6083
6662
  type: 'tool_result',
6084
6663
  toolUseId: tc.id,
6664
+ // Persisted so XML replay can reconstruct the legacy <tool_name>
6665
+ // element byte-identically to the live injection.
6666
+ toolName: tc.name,
6085
6667
  content: spilled.get(tc.id).text,
6086
6668
  isError: tc.result.isError,
6087
6669
  }));
@@ -6316,7 +6898,7 @@ export class AgentFramework {
6316
6898
  /**
6317
6899
  * Effective tool-result inline cap for an agent, with provenance. Desired
6318
6900
  * value: resident's durable agent_settings value → residence
6319
- * FrameworkConfig.toolResultInlineMaxChars → house default (5000).
6901
+ * FrameworkConfig.toolResultInlineMaxChars → house default (24000).
6320
6902
  * Effective value: min(desired, strategy bound) for EVERY source — the
6321
6903
  * strategy's per-message safety limit is a ceiling, not a suggestion
6322
6904
  * (Sol's #94 ruling: a durable preference must not be a durable path for
@@ -6759,6 +7341,19 @@ export class AgentFramework {
6759
7341
  name,
6760
7342
  status: agent.state.status,
6761
7343
  consecutiveInferenceFailures: this.consecutiveInferenceFailures.get(name) ?? 0,
7344
+ providerAdmission: (() => {
7345
+ const gate = this.providerGates?.get(name);
7346
+ const cooldown = this.providerAccelerationCooldowns?.get(name);
7347
+ return {
7348
+ primaryActive: (gate?.primaryDepth ?? 0) > 0,
7349
+ primaryPending: gate?.primaryPending ?? false,
7350
+ auxiliaryInFlight: gate?.auxiliaryInFlight ?? 0,
7351
+ auxiliaryDeferred: gate?.deferredAuxiliary ?? 0,
7352
+ cooldownUntil: cooldown?.until ?? null,
7353
+ heldRequests: cooldown?.heldRequests.length ?? 0,
7354
+ lastRecovery: this.providerAccelerationLastRecovery?.get(name) ?? null,
7355
+ };
7356
+ })(),
6762
7357
  lastInference: this.lastInferenceAt.get(name) ?? null,
6763
7358
  refusalStats: this.refusalStats.get(name) ?? null,
6764
7359
  })),
@@ -8426,7 +9021,7 @@ export class AgentFramework {
8426
9021
  if (opened) {
8427
9022
  this.activeTriggerChannels.set(agentName, opened);
8428
9023
  const openerAgent = this.agents.get(agentName);
8429
- if (openerAgent && openerAgent.proseRouting !== 'explicit') {
9024
+ if (openerAgent && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')) {
8430
9025
  this.turnLocusPins.set(agentName, opened);
8431
9026
  this.lastAnnouncedLocus.set(agentName, opened);
8432
9027
  result = {
@@ -8520,17 +9115,70 @@ export class AgentFramework {
8520
9115
  : input.rateLimit !== undefined ? { rate_limit: input.rateLimit }
8521
9116
  : input.passiveSample !== undefined ? { passive_sample: input.passiveSample }
8522
9117
  : input.behavior;
8523
- const position = input.position === 'prepend' ? 'prepend' : 'append';
9118
+ // Placement: at most one of position / insertBefore / insertAfter.
9119
+ // With none given, a replaced rule stays where it was and a new rule
9120
+ // appends (addPolicy's contract).
9121
+ const placementSources = [
9122
+ input.position !== undefined ? 'position' : null,
9123
+ input.insertBefore !== undefined ? 'insertBefore' : null,
9124
+ input.insertAfter !== undefined ? 'insertAfter' : null,
9125
+ ].filter((s) => s !== null);
9126
+ if (placementSources.length > 1) {
9127
+ finish({
9128
+ success: false,
9129
+ error: `wake_add_rule: give at most one placement, got [${placementSources.join(', ')}].`,
9130
+ isError: true,
9131
+ });
9132
+ return;
9133
+ }
9134
+ let position;
9135
+ if (typeof input.insertBefore === 'string' && input.insertBefore) {
9136
+ position = { before: input.insertBefore };
9137
+ }
9138
+ else if (typeof input.insertAfter === 'string' && input.insertAfter) {
9139
+ position = { after: input.insertAfter };
9140
+ }
9141
+ else if (input.position === 'prepend' || input.position === 'append') {
9142
+ position = input.position;
9143
+ }
8524
9144
  const rawPolicy = {
8525
9145
  name: input.name,
8526
9146
  match: input.match ?? {},
8527
9147
  behavior,
9148
+ ...(input.passthrough !== undefined ? { passthrough: input.passthrough } : {}),
8528
9149
  ...(input.resets !== undefined ? { resets: input.resets } : {}),
8529
9150
  };
8530
- const policy = this.addGatePolicy(rawPolicy, { position });
9151
+ // Before/after probe table + shadow lint: the moment a rule changes the
9152
+ // evaluation order is the moment its real semantics must be legible
9153
+ // (af#105 — a prepended catch-all sampler silently disabled the
9154
+ // addressed-message wakes for 12h before anyone could see why).
9155
+ const before = new Map(this.eventGate.probeTable().map((r) => [r.probe, r]));
9156
+ const policy = this.addGatePolicy(rawPolicy, position ? { position } : undefined);
9157
+ const after = this.eventGate.probeTable();
9158
+ const probes = after.map((r) => {
9159
+ const prev = before.get(r.probe);
9160
+ const changed = !!prev && (prev.policy !== r.policy || prev.wouldWake !== r.wouldWake);
9161
+ return {
9162
+ probe: r.probe,
9163
+ rule: r.policy ?? '(default)',
9164
+ behavior: r.behavior,
9165
+ wouldWake: r.wouldWake,
9166
+ ...(changed ? { changed: true, was: `${prev.policy ?? '(default)'} (wouldWake=${prev.wouldWake})` } : {}),
9167
+ };
9168
+ });
9169
+ const shadowWarnings = this.eventGate.lintShadows()
9170
+ .map((w) => formatShadowWarning(w));
8531
9171
  finish({
8532
9172
  success: true,
8533
- data: { added: policy.name, policies: this.getGatePolicyNames() },
9173
+ data: {
9174
+ added: policy.name,
9175
+ policies: this.getGatePolicyNames(),
9176
+ ...(shadowWarnings.length > 0 ? { shadowWarnings } : {}),
9177
+ probes,
9178
+ ...(probes.some((p) => 'changed' in p)
9179
+ ? {}
9180
+ : { note: 'No canonical probe changed its winning rule — the new rule affects none of the common chat/heartbeat shapes, or only channel/path-specific events the probes do not model.' }),
9181
+ },
8534
9182
  });
8535
9183
  }
8536
9184
  catch (err) {
@@ -8572,7 +9220,10 @@ export class AgentFramework {
8572
9220
  if (announce && this.channelRegistry) {
8573
9221
  const text = input.message ?? `💤 Going quiet for ${human}. I'll still see messages, but won't respond until I wake.`;
8574
9222
  const agent = this.agents.get(agentName);
8575
- if (agent?.proseRouting === 'explicit') {
9223
+ if (agent?.proseRouting === 'disabled') {
9224
+ console.error(`[sleep] ${agentName}: proseRouting=disabled — sleep announcement not posted`);
9225
+ }
9226
+ else if (agent?.proseRouting === 'explicit') {
8576
9227
  // Explicit mode: announce to the turn's sticky prose target if the
8577
9228
  // model has set one; otherwise stay quiet — never guess a channel.
8578
9229
  const target = this.proseTargetPins.get(agentName);