@animalabs/agent-framework 0.9.0 → 0.10.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 (66) 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 +14 -4
  5. package/dist/src/framework.d.ts.map +1 -1
  6. package/dist/src/framework.js +265 -23
  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/workspace/index.d.ts +23 -0
  23. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  24. package/dist/src/modules/workspace/index.js +48 -0
  25. package/dist/src/modules/workspace/index.js.map +1 -1
  26. package/dist/src/modules/workspace/sync.d.ts +11 -2
  27. package/dist/src/modules/workspace/sync.d.ts.map +1 -1
  28. package/dist/src/modules/workspace/sync.js +18 -8
  29. package/dist/src/modules/workspace/sync.js.map +1 -1
  30. package/dist/src/tool-result-history.d.ts +12 -2
  31. package/dist/src/tool-result-history.d.ts.map +1 -1
  32. package/dist/src/tool-result-history.js +12 -2
  33. package/dist/src/tool-result-history.js.map +1 -1
  34. package/dist/src/types/agent.d.ts +4 -1
  35. package/dist/src/types/agent.d.ts.map +1 -1
  36. package/dist/src/types/framework.d.ts +1 -1
  37. package/dist/test/event-gate-passthrough.test.d.ts +15 -0
  38. package/dist/test/event-gate-passthrough.test.d.ts.map +1 -0
  39. package/dist/test/event-gate-passthrough.test.js +289 -0
  40. package/dist/test/event-gate-passthrough.test.js.map +1 -0
  41. package/dist/test/explicit-prose-routing.test.js +137 -7
  42. package/dist/test/explicit-prose-routing.test.js.map +1 -1
  43. package/dist/test/fixtures/awareness-barrier-mcpl-server.mjs +276 -0
  44. package/dist/test/fixtures/flaky-mcpl-server.mjs +41 -0
  45. package/dist/test/prose-stream-router.test.js +43 -0
  46. package/dist/test/prose-stream-router.test.js.map +1 -1
  47. package/dist/test/tool-result-spill.test.d.ts +1 -1
  48. package/dist/test/tool-result-spill.test.js +18 -9
  49. package/dist/test/tool-result-spill.test.js.map +1 -1
  50. package/dist/test/xml-round-persist.test.d.ts +19 -0
  51. package/dist/test/xml-round-persist.test.d.ts.map +1 -0
  52. package/dist/test/xml-round-persist.test.js +235 -0
  53. package/dist/test/xml-round-persist.test.js.map +1 -0
  54. package/dist/tsconfig.tsbuildinfo +1 -1
  55. package/package.json +2 -1
  56. package/src/agent.ts +1 -1
  57. package/src/framework.ts +270 -25
  58. package/src/gate/event-gate.ts +355 -24
  59. package/src/gate/types.ts +58 -0
  60. package/src/mcpl/prose-grammar.ts +21 -0
  61. package/src/mcpl/prose-stream-router.ts +48 -14
  62. package/src/modules/workspace/index.ts +48 -0
  63. package/src/modules/workspace/sync.ts +30 -7
  64. package/src/tool-result-history.ts +12 -2
  65. package/src/types/agent.ts +4 -1
  66. package/src/types/framework.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@animalabs/agent-framework",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Multi-agent framework with pluggable modules, persistent state, and concurrent tool execution",
5
5
  "repository": {
6
6
  "type": "git",
@@ -32,6 +32,7 @@
32
32
  "typecheck": "tsc --noEmit",
33
33
  "pretest": "mkdir -p dist/test/fixtures && cp test/fixtures/*.mjs dist/test/fixtures/",
34
34
  "test": "node --test --test-force-exit dist/test/*.test.js",
35
+ "version": "node scripts/release-changelog.mjs && git add CHANGELOG.md",
35
36
  "prepublishOnly": "npm run build"
36
37
  },
37
38
  "keywords": [
package/src/agent.ts CHANGED
@@ -67,7 +67,7 @@ export class Agent {
67
67
  /** Refusal auto-rewind policy (see AgentConfig.refusalHandling). */
68
68
  readonly refusalHandling: AgentConfig['refusalHandling'];
69
69
  /** Prose delivery mode (see AgentConfig.proseRouting). Default 'locus'. */
70
- readonly proseRouting: 'locus' | 'explicit';
70
+ readonly proseRouting: 'locus' | 'explicit' | 'hybrid';
71
71
  /** Prompt-cache TTL forwarded to the provider (see AgentConfig.cacheTtl). */
72
72
  readonly cacheTtl: NonNullable<AgentConfig['cacheTtl']>;
73
73
  /** Emit prompt-cache markers on requests (see AgentConfig.promptCaching). */
package/src/framework.ts CHANGED
@@ -62,7 +62,7 @@ import { computeGrant, CapabilityGrant, expandAdvertisementShorthand } from './m
62
62
  import { maskNegotiatedCapabilities } from './mcpl/capability-mask.js';
63
63
  import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
64
64
  import { PushHandler, type McplPushEvent } from './mcpl/push-handler.js';
65
- import { parseProsePrefix } from './mcpl/prose-grammar.js';
65
+ import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js';
66
66
  import { ProseStreamRouter } from './mcpl/prose-stream-router.js';
67
67
  import { InferenceRouter } from './mcpl/inference-router.js';
68
68
  import { ChannelRegistry, type ChannelToolOrigin } from './mcpl/channel-registry.js';
@@ -126,6 +126,9 @@ const SILENCING_TOOLS = new Set([
126
126
  'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
127
127
  ]);
128
128
 
129
+ /** World-surface publication names that outrank hybrid prose envelopes. */
130
+ const HYBRID_PUBLICATION_TOOLS = new Set(['say', 'whisper']);
131
+
129
132
  /**
130
133
  * True when an injected message is real conversational input — something the
131
134
  * agent might actually be replying to — rather than ambient machinery.
@@ -225,11 +228,18 @@ const isAddressedMessage = (
225
228
  // envelopes can never disagree on what is a prefix.
226
229
 
227
230
  /** One-time primer appended when an agent's proseRouting mode changes. */
228
- function proseModePrimer(mode: 'explicit' | 'locus'): string {
231
+ function proseModePrimer(mode: 'explicit' | 'hybrid' | 'locus'): string {
229
232
  if (mode === 'locus') {
230
233
  return '[prose-routing] Mode change: your plain text auto-routes to the ' +
231
234
  'conversational locus again. `>>` destination prefixes are no longer needed.';
232
235
  }
236
+ if (mode === 'hybrid') {
237
+ return '[prose-routing] Hybrid routing enabled: unprefixed text still lands in the ' +
238
+ 'current conversational locus. A leading `>>>destination` envelope instead routes ' +
239
+ 'that prose to one uniquely resolved authorized Discord or Eidoverse channel. The ' +
240
+ 'envelope remains in your memory but recipients see only its body; delivery or failure ' +
241
+ 'is reported back to you.';
242
+ }
233
243
  // Deliberately terse: a short event-style notice is classifier-safe from
234
244
  // the user role (ablation D, 2026-07-24), while the full grammar as a user
235
245
  // message drew a deterministic reasoning_extraction refusal (ablation A/B).
@@ -329,7 +339,7 @@ function validateUtilityArgs(
329
339
  }
330
340
  import { CheckpointManager } from './mcpl/checkpoint-manager.js';
331
341
  import { isToolAllowed } from './mcpl/tool-policy.js';
332
- import { EventGate } from './gate/event-gate.js';
342
+ import { EventGate, formatShadowWarning } from './gate/event-gate.js';
333
343
  import { UsageTracker, type PersistedUsageState } from './usage/usage-tracker.js';
334
344
  import type { SessionUsageSnapshot, UsageUpdatedEvent } from './usage/types.js';
335
345
  import type { McplServerConnection } from './mcpl/server-connection.js';
@@ -684,6 +694,8 @@ export class AgentFramework {
684
694
  /** Sticky per-TURN delivery target set by the turn's first `>>` prefix.
685
695
  * Cleared at every non-restart turn start; survives context restarts. */
686
696
  private proseTargetPins: Map<string, string> = new Map();
697
+ /** Hybrid router is fail-closed after a malformed/unresolved envelope until a new valid target. */
698
+ private proseHybridSuppressed: Set<string> = new Set();
687
699
  /** Agents whose current turn requested `!` continuation — re-woken when the
688
700
  * turn completes instead of pausing until the next external event. */
689
701
  private proseContinuations: Set<string> = new Set();
@@ -1968,12 +1980,14 @@ export class AgentFramework {
1968
1980
  /**
1969
1981
  * Add or replace (upsert) a gate policy at runtime. Validated and persisted
1970
1982
  * to gate.json; hot-applied in memory. Throws if no gate is configured or the
1971
- * policy is invalid. `position: 'prepend'` puts a wake rule ahead of broad
1972
- * defer/debounce rules (first match wins).
1983
+ * policy is invalid. Placement: `'prepend'` puts a rule ahead of EVERYTHING
1984
+ * (including addressed-message wake rules — see EventGate.lintShadows);
1985
+ * `{before: name}` / `{after: name}` anchor it against a specific rule,
1986
+ * which is usually what a sampler or debounce actually wants.
1973
1987
  */
1974
1988
  addGatePolicy(
1975
1989
  rawPolicy: unknown,
1976
- options?: { position?: 'append' | 'prepend' },
1990
+ options?: { position?: 'append' | 'prepend' | { before: string } | { after: string } },
1977
1991
  ): import('./gate/types.js').GatePolicy {
1978
1992
  if (!this.eventGate) {
1979
1993
  throw new Error('No EventGate configured (FrameworkConfig.gate is unset).');
@@ -2350,9 +2364,17 @@ export class AgentFramework {
2350
2364
  description:
2351
2365
  'Add or replace a wake rule (a gate.json policy) at runtime — no need to ' +
2352
2366
  'hand-edit the file. The rule is validated and hot-applied immediately. ' +
2353
- 'A rule with the same `name` replaces the existing one in place. Use ' +
2354
- '`position: "prepend"` to put a wake rule ahead of broad defer/debounce ' +
2355
- 'rules (first match wins). Two common shapes:\n' +
2367
+ 'A rule with the same `name` replaces the existing one in place (add a ' +
2368
+ 'position to move it). ⚠️ ORDERING IS SEMANTICS: first match wins, and a ' +
2369
+ 'matched rule CONSUMES the event — a broad rule placed early makes every ' +
2370
+ 'later rule unreachable for its scope, including your addressed-message ' +
2371
+ '(DM/mention) wake rules. Prefer `insertBefore`/`insertAfter` to place a ' +
2372
+ 'rule relative to a specific existing rule; use `position: "prepend"` only ' +
2373
+ 'when the rule really must beat EVERYTHING. For "additionally wake me ' +
2374
+ 'every Nth event / at most N per hour" governors, set `passthrough: true` ' +
2375
+ 'so non-firing matches fall through to your normal rules instead of ' +
2376
+ 'swallowing them. The result includes a before/after probe table and any ' +
2377
+ 'shadow warnings — READ THEM before ending the turn. Two common shapes:\n' +
2356
2378
  '• Watch a FILE/workspace path: match on `mount` + `pathGlob`, e.g. ' +
2357
2379
  '`{ name: "watch-notes", match: { scope: ["workspace:modified"], mount: "project", pathGlob: "notes/*.md" }, behavior: "always" }`.\n' +
2358
2380
  '• Watch a CHANNEL: match on `source` + `channel` (and/or `tagsAny: ["chat:ambient"]`), ' +
@@ -2420,7 +2442,30 @@ export class AgentFramework {
2420
2442
  position: {
2421
2443
  type: 'string',
2422
2444
  enum: ['append', 'prepend'],
2423
- description: 'Where to insert a NEW rule (ignored when replacing by name). Default "append".',
2445
+ description:
2446
+ 'Coarse placement. "append" (default) adds at the end; "prepend" puts the rule ' +
2447
+ 'ahead of EVERYTHING — including addressed-message wake rules — so prefer ' +
2448
+ 'insertBefore/insertAfter unless the rule really must beat every other rule. ' +
2449
+ 'When replacing an existing name, giving any placement MOVES the rule there; ' +
2450
+ 'omit all placement fields to update it in place.',
2451
+ },
2452
+ insertBefore: {
2453
+ type: 'string',
2454
+ description:
2455
+ 'Insert the rule immediately BEFORE the named existing rule (error if absent). ' +
2456
+ 'The safe way to order against a specific catch-all, e.g. insertBefore: "discord-ambient".',
2457
+ },
2458
+ insertAfter: {
2459
+ type: 'string',
2460
+ description: 'Insert the rule immediately AFTER the named existing rule (error if absent).',
2461
+ },
2462
+ passthrough: {
2463
+ type: 'boolean',
2464
+ description:
2465
+ 'Observer semantics for rateLimit/passiveSample rules: when the rule matches but ' +
2466
+ 'does not fire (below N, bucket empty), the event FALLS THROUGH to later rules ' +
2467
+ 'instead of being consumed. Use for "additionally wake me every Nth event" ' +
2468
+ 'governors so they never swallow DM/mention wakes.',
2424
2469
  },
2425
2470
  resets: {
2426
2471
  type: 'array',
@@ -5110,6 +5155,97 @@ export class AgentFramework {
5110
5155
  }
5111
5156
  }
5112
5157
 
5158
+ /** Locus-preserving explicit publication. Source remains byte-identical in Chronicle. */
5159
+ private async deliverHybridProse(
5160
+ agent: Agent,
5161
+ rawText: string,
5162
+ locus: string | null,
5163
+ allowLocus: boolean,
5164
+ ): Promise<void> {
5165
+ const lines = rawText.split('\n');
5166
+ const envelopes: string[] = [];
5167
+ let current: string[] = [];
5168
+ for (const line of lines) {
5169
+ if (line.trimStart().startsWith('>>>') && current.length > 0) {
5170
+ envelopes.push(current.join('\n'));
5171
+ current = [];
5172
+ }
5173
+ current.push(line);
5174
+ }
5175
+ if (current.length > 0) envelopes.push(current.join('\n'));
5176
+
5177
+ for (const envelope of envelopes) {
5178
+ if (!envelope.trim()) continue;
5179
+ const normalized = envelope.replace(/^\s+(?=>>>)/, '');
5180
+ const attempted = normalized.startsWith('>>>');
5181
+ const parsed = parseHybridProsePrefix(normalized);
5182
+ if (parsed.continueTurn) this.proseContinuations.add(agent.name);
5183
+ if (parsed.kind === 'private') {
5184
+ this.proseTargetPins.delete(agent.name);
5185
+ this.proseHybridSuppressed.add(agent.name);
5186
+ console.error(`[prose] ${agent.name}: >>>skip_reply — ${parsed.body.length} chars kept in context (not sent)`);
5187
+ continue;
5188
+ }
5189
+ if (parsed.kind === 'target') {
5190
+ const resolved = this.channelRegistry!.resolveProseTarget(parsed.target!);
5191
+ if ('error' in resolved) {
5192
+ this.proseTargetPins.delete(agent.name);
5193
+ this.proseHybridSuppressed.add(agent.name);
5194
+ this.bounceProse(agent, parsed.body, resolved.error, resolved.candidates, '>>>');
5195
+ continue;
5196
+ }
5197
+ let body = parsed.body;
5198
+ const usedClipboard = body.includes('{{unsent}}');
5199
+ if (usedClipboard) body = body.replaceAll('{{unsent}}', this.proseClipboards.get(agent.name) ?? '');
5200
+ this.proseTargetPins.set(agent.name, resolved.channelId);
5201
+ this.proseHybridSuppressed.delete(agent.name);
5202
+ if (!body.trim()) {
5203
+ console.error(`[prose] ${agent.name}: >>>${parsed.target} established target; empty body — nothing sent yet`);
5204
+ continue;
5205
+ }
5206
+ try {
5207
+ const outcome = await this.channelRegistry!.routeSpeech(agent.name, body, resolved.channelId);
5208
+ this.recordProseDelivery(agent.name, outcome);
5209
+ if (outcome?.delivered) {
5210
+ this.proseBounceStreaks.delete(agent.name);
5211
+ if (usedClipboard) this.proseClipboards.delete(agent.name);
5212
+ } else {
5213
+ this.proseTargetPins.delete(agent.name);
5214
+ this.proseHybridSuppressed.add(agent.name);
5215
+ this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} was not confirmed`, undefined, '>>>');
5216
+ }
5217
+ } catch (err) {
5218
+ this.proseTargetPins.delete(agent.name);
5219
+ this.proseHybridSuppressed.add(agent.name);
5220
+ this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} failed: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>>');
5221
+ }
5222
+ continue;
5223
+ }
5224
+ if (attempted) {
5225
+ this.proseTargetPins.delete(agent.name);
5226
+ this.proseHybridSuppressed.add(agent.name);
5227
+ this.bounceProse(agent, normalized, 'malformed >>> routing envelope: destination is missing', undefined, '>>>');
5228
+ continue;
5229
+ }
5230
+ if (this.proseHybridSuppressed.has(agent.name)) {
5231
+ this.recordProseSuppression(agent.name, 1);
5232
+ continue;
5233
+ }
5234
+ const sticky = this.proseTargetPins.get(agent.name);
5235
+ if (!allowLocus && !sticky) {
5236
+ this.recordProseSuppression(agent.name, 1);
5237
+ continue;
5238
+ }
5239
+ const target = sticky ?? locus;
5240
+ try {
5241
+ const outcome = await this.channelRegistry!.routeSpeech(agent.name, envelope, target);
5242
+ this.recordProseDelivery(agent.name, outcome);
5243
+ } catch (err) {
5244
+ console.error('hybrid locus delivery failed:', err);
5245
+ }
5246
+ }
5247
+ }
5248
+
5113
5249
  private async deliverProseEnvelope(agent: Agent, rawText: string): Promise<void> {
5114
5250
  const name = agent.name;
5115
5251
  const parsed = parseProsePrefix(rawText);
@@ -5169,7 +5305,7 @@ export class AgentFramework {
5169
5305
  /** Cap on consecutive bounce-triggered wakes (notices still append after). */
5170
5306
  private static readonly PROSE_BOUNCE_WAKE_CAP = 2;
5171
5307
 
5172
- private bounceProse(agent: Agent, text: string, reason: string, candidates?: string[]): void {
5308
+ private bounceProse(agent: Agent, text: string, reason: string, candidates?: string[], prefix: '>>' | '>>>' = '>>'): void {
5173
5309
  const name = agent.name;
5174
5310
  this.proseClipboards.set(name, text);
5175
5311
  const streak = (this.proseBounceStreaks.get(name) ?? 0) + 1;
@@ -5178,8 +5314,9 @@ export class AgentFramework {
5178
5314
  const notice =
5179
5315
  `[prose-routing] Your text (${text.length} chars) was not delivered — ${reason}.${cand} ` +
5180
5316
  'The text is retained; nothing is lost. To deliver it unchanged, reply with a ' +
5181
- 'destination plus the token {{unsent}}, e.g. ">>#channel {{unsent}}" or ">>@person {{unsent}}". ' +
5182
- '">>skip_reply {{unsent}}" keeps it in context only. The prose_help tool shows the full syntax.';
5317
+ `destination plus the token {{unsent}}, e.g. "${prefix}#channel {{unsent}}" or "${prefix}@person {{unsent}}". ` +
5318
+ `"${prefix}skip_reply {{unsent}}" keeps it in context only.` +
5319
+ (prefix === '>>' ? ' The prose_help tool shows the full syntax.' : '');
5183
5320
  try {
5184
5321
  // Through framework.addMessage, NOT the context manager directly: while
5185
5322
  // the turn is still streaming this defers the notice to the next tool
@@ -5381,6 +5518,8 @@ export class AgentFramework {
5381
5518
  this.turnEngagedChannels.delete(agent.name);
5382
5519
  this.turnProseDeliveries.delete(agent.name);
5383
5520
  this.turnProseSuppressed.delete(agent.name);
5521
+ this.proseHybridSuppressed.delete(agent.name);
5522
+ if (agent.proseRouting === 'hybrid') this.proseTargetPins.delete(agent.name);
5384
5523
  if (agent.proseRouting === 'explicit') {
5385
5524
  // Explicit prose routing: there is no locus. The model names every
5386
5525
  // destination in-band (`>>` prefixes); the only turn state is the
@@ -5649,7 +5788,7 @@ export class AgentFramework {
5649
5788
  });
5650
5789
  const proseStream = this.channelRegistry
5651
5790
  ? new ProseStreamRouter({
5652
- mode: agent.proseRouting === 'explicit' ? 'explicit' : 'locus',
5791
+ mode: agent.proseRouting === 'explicit' ? 'explicit' : agent.proseRouting === 'hybrid' ? 'hybrid' : 'locus',
5653
5792
  initialTarget: typingChannel,
5654
5793
  resolve: (spec) => {
5655
5794
  const r = this.channelRegistry!.resolveProseTarget(spec);
@@ -5776,9 +5915,18 @@ export class AgentFramework {
5776
5915
  const rawXml = rawText && /^<(antml:)?function_calls>/.test(rawText)
5777
5916
  ? rawText
5778
5917
  : undefined;
5918
+ // Persist THIS round's text only. `roundPreamble` (membrane
5919
+ // ≥0.5.79) is the per-round delta; the legacy `preamble` is
5920
+ // CUMULATIVE in XML mode — persisting it per round stored each
5921
+ // round's text N times and re-persisted injected
5922
+ // <function_results> as the agent's own words (the Evander
5923
+ // 2026-08-08 scaffold-leak pyramid: r1 ×3, r2 ×2).
5924
+ const roundText =
5925
+ (event.context as { roundPreamble?: string }).roundPreamble ??
5926
+ event.context.preamble;
5779
5927
  assistantBlocks = [];
5780
- if (event.context.preamble) {
5781
- assistantBlocks.push({ type: 'text', text: event.context.preamble });
5928
+ if (roundText) {
5929
+ assistantBlocks.push({ type: 'text', text: roundText });
5782
5930
  }
5783
5931
  for (const c of event.calls) {
5784
5932
  assistantBlocks.push({
@@ -5820,14 +5968,28 @@ export class AgentFramework {
5820
5968
  const hasSameRoundPrivateThink =
5821
5969
  roundToolNames.includes('think') &&
5822
5970
  requestSnapshot.sameRoundThinkTextPolicy === 'private';
5823
- if (roundToolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)))) {
5971
+ if (roundToolNames.some((n) =>
5972
+ SILENCING_TOOLS.has(bareToolName(n)) ||
5973
+ (agent.proseRouting === 'hybrid' && HYBRID_PUBLICATION_TOOLS.has(bareToolName(n)))
5974
+ )) {
5824
5975
  turnSilenced = true;
5825
5976
  }
5826
5977
  if (roundContent && roundContent.length > 0) {
5827
5978
  liveProseRouting = true;
5828
5979
  const roundSegments = splitProseSegments(assistantBlocks);
5829
5980
  if (roundSegments.length > 0) {
5830
- if (agent.proseRouting === 'explicit') {
5981
+ if (agent.proseRouting === 'hybrid') {
5982
+ if (turnSilenced) {
5983
+ this.recordProseSuppression(agent.name, roundSegments.length);
5984
+ } else if (!hasSameRoundPrivateThink) {
5985
+ const locus = resolveTurnLocus();
5986
+ for (const seg of roundSegments) {
5987
+ turnSpeechChain = turnSpeechChain
5988
+ .then(() => this.deliverHybridProse(agent, seg, locus, true))
5989
+ .catch((err) => console.error('mid-turn hybrid prose delivery failed:', err));
5990
+ }
5991
+ }
5992
+ } else if (agent.proseRouting === 'explicit') {
5831
5993
  // Explicit mode: every segment through the prose gateway.
5832
5994
  // Silencing does not apply — unprefixed prose bounces and
5833
5995
  // prefixed prose is deliberate; think-privacy still holds.
@@ -6157,7 +6319,15 @@ export class AgentFramework {
6157
6319
  .join('\n')
6158
6320
  .trim();
6159
6321
  if (speechText) {
6160
- if (agent.proseRouting === 'explicit') {
6322
+ if (agent.proseRouting === 'hybrid') {
6323
+ const locus = resolveTurnLocus();
6324
+ console.error(`[prose] ${agent.name}: text-only turn -> hybrid prose gateway`);
6325
+ try {
6326
+ await this.deliverHybridProse(agent, speechText, locus, true);
6327
+ } catch (err) {
6328
+ console.error('text-only hybrid prose delivery failed:', err);
6329
+ }
6330
+ } else if (agent.proseRouting === 'explicit') {
6161
6331
  console.error(`[prose] ${agent.name}: text-only turn -> prose gateway`);
6162
6332
  try {
6163
6333
  await this.deliverProse(agent, speechText);
@@ -6204,7 +6374,10 @@ export class AgentFramework {
6204
6374
  .filter((n): n is string => typeof n === 'string');
6205
6375
  const silenced = liveProseRouting
6206
6376
  ? turnSilenced
6207
- : turnSilenced || toolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)));
6377
+ : turnSilenced || toolNames.some((n) =>
6378
+ SILENCING_TOOLS.has(bareToolName(n)) ||
6379
+ (agent.proseRouting === 'hybrid' && HYBRID_PUBLICATION_TOOLS.has(bareToolName(n)))
6380
+ );
6208
6381
 
6209
6382
  const segments = splitProseSegments(liveProseRouting ? terminalContent : response.content);
6210
6383
 
@@ -6213,7 +6386,20 @@ export class AgentFramework {
6213
6386
  // the chain may still be flushing earlier rounds' posts.
6214
6387
  await turnSpeechChain;
6215
6388
 
6216
- if (agent.proseRouting === 'explicit') {
6389
+ if (agent.proseRouting === 'hybrid') {
6390
+ if (silenced && segments.length > 0) {
6391
+ this.recordProseSuppression(agent.name, segments.length);
6392
+ } else if (segments.length > 0) {
6393
+ const locus = resolveTurnLocus();
6394
+ for (const seg of segments) {
6395
+ try {
6396
+ await this.deliverHybridProse(agent, seg, locus, true);
6397
+ } catch (err) {
6398
+ console.error('trailing hybrid prose delivery failed:', err);
6399
+ }
6400
+ }
6401
+ }
6402
+ } else if (agent.proseRouting === 'explicit') {
6217
6403
  if (segments.length > 0) {
6218
6404
  console.error(
6219
6405
  `[prose] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> ${segments.length} trailing segment(s) via prose gateway`,
@@ -7075,6 +7261,9 @@ export class AgentFramework {
7075
7261
  const blocks: ContentBlock[] = toolResults.map(tc => ({
7076
7262
  type: 'tool_result' as const,
7077
7263
  toolUseId: tc.id,
7264
+ // Persisted so XML replay can reconstruct the legacy <tool_name>
7265
+ // element byte-identically to the live injection.
7266
+ toolName: tc.name,
7078
7267
  content: spilled.get(tc.id)!.text,
7079
7268
  isError: tc.result.isError,
7080
7269
  }));
@@ -7334,7 +7523,7 @@ export class AgentFramework {
7334
7523
  /**
7335
7524
  * Effective tool-result inline cap for an agent, with provenance. Desired
7336
7525
  * value: resident's durable agent_settings value → residence
7337
- * FrameworkConfig.toolResultInlineMaxChars → house default (5000).
7526
+ * FrameworkConfig.toolResultInlineMaxChars → house default (24000).
7338
7527
  * Effective value: min(desired, strategy bound) for EVERY source — the
7339
7528
  * strategy's per-message safety limit is a ceiling, not a suggestion
7340
7529
  * (Sol's #94 ruling: a durable preference must not be a durable path for
@@ -9960,6 +10149,7 @@ export class AgentFramework {
9960
10149
  // range/shape checks.
9961
10150
  const input = (call.input ?? {}) as {
9962
10151
  name?: unknown; match?: unknown; resets?: unknown; position?: unknown;
10152
+ insertBefore?: unknown; insertAfter?: unknown; passthrough?: unknown;
9963
10153
  behavior?: unknown; debounceMs?: unknown; rateLimit?: unknown; passiveSample?: unknown;
9964
10154
  };
9965
10155
  const behaviorSources = [
@@ -9990,17 +10180,72 @@ export class AgentFramework {
9990
10180
  : input.passiveSample !== undefined ? { passive_sample: input.passiveSample }
9991
10181
  : input.behavior;
9992
10182
 
9993
- const position = input.position === 'prepend' ? 'prepend' : 'append';
10183
+ // Placement: at most one of position / insertBefore / insertAfter.
10184
+ // With none given, a replaced rule stays where it was and a new rule
10185
+ // appends (addPolicy's contract).
10186
+ const placementSources = [
10187
+ input.position !== undefined ? 'position' : null,
10188
+ input.insertBefore !== undefined ? 'insertBefore' : null,
10189
+ input.insertAfter !== undefined ? 'insertAfter' : null,
10190
+ ].filter((s): s is string => s !== null);
10191
+ if (placementSources.length > 1) {
10192
+ finish({
10193
+ success: false,
10194
+ error: `wake_add_rule: give at most one placement, got [${placementSources.join(', ')}].`,
10195
+ isError: true,
10196
+ });
10197
+ return;
10198
+ }
10199
+ let position: 'append' | 'prepend' | { before: string } | { after: string } | undefined;
10200
+ if (typeof input.insertBefore === 'string' && input.insertBefore) {
10201
+ position = { before: input.insertBefore };
10202
+ } else if (typeof input.insertAfter === 'string' && input.insertAfter) {
10203
+ position = { after: input.insertAfter };
10204
+ } else if (input.position === 'prepend' || input.position === 'append') {
10205
+ position = input.position;
10206
+ }
10207
+
9994
10208
  const rawPolicy = {
9995
10209
  name: input.name,
9996
10210
  match: input.match ?? {},
9997
10211
  behavior,
10212
+ ...(input.passthrough !== undefined ? { passthrough: input.passthrough } : {}),
9998
10213
  ...(input.resets !== undefined ? { resets: input.resets } : {}),
9999
10214
  };
10000
- const policy = this.addGatePolicy(rawPolicy, { position });
10215
+
10216
+ // Before/after probe table + shadow lint: the moment a rule changes the
10217
+ // evaluation order is the moment its real semantics must be legible
10218
+ // (af#105 — a prepended catch-all sampler silently disabled the
10219
+ // addressed-message wakes for 12h before anyone could see why).
10220
+ const before = new Map(
10221
+ this.eventGate!.probeTable().map((r) => [r.probe, r]),
10222
+ );
10223
+ const policy = this.addGatePolicy(rawPolicy, position ? { position } : undefined);
10224
+ const after = this.eventGate!.probeTable();
10225
+ const probes = after.map((r) => {
10226
+ const prev = before.get(r.probe);
10227
+ const changed = !!prev && (prev.policy !== r.policy || prev.wouldWake !== r.wouldWake);
10228
+ return {
10229
+ probe: r.probe,
10230
+ rule: r.policy ?? '(default)',
10231
+ behavior: r.behavior,
10232
+ wouldWake: r.wouldWake,
10233
+ ...(changed ? { changed: true, was: `${prev!.policy ?? '(default)'} (wouldWake=${prev!.wouldWake})` } : {}),
10234
+ };
10235
+ });
10236
+ const shadowWarnings = this.eventGate!.lintShadows()
10237
+ .map((w) => formatShadowWarning(w));
10001
10238
  finish({
10002
10239
  success: true,
10003
- data: { added: policy.name, policies: this.getGatePolicyNames() },
10240
+ data: {
10241
+ added: policy.name,
10242
+ policies: this.getGatePolicyNames(),
10243
+ ...(shadowWarnings.length > 0 ? { shadowWarnings } : {}),
10244
+ probes,
10245
+ ...(probes.some((p) => 'changed' in p)
10246
+ ? {}
10247
+ : { 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.' }),
10248
+ },
10004
10249
  });
10005
10250
  } catch (err) {
10006
10251
  const msg = err instanceof Error ? err.message : String(err);