@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
@@ -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';
@@ -459,6 +468,8 @@ export class AgentFramework {
459
468
  /** Sticky per-TURN delivery target set by the turn's first `>>` prefix.
460
469
  * Cleared at every non-restart turn start; survives context restarts. */
461
470
  proseTargetPins = new Map();
471
+ /** Hybrid router is fail-closed after a malformed/unresolved envelope until a new valid target. */
472
+ proseHybridSuppressed = new Set();
462
473
  /** Agents whose current turn requested `!` continuation — re-woken when the
463
474
  * turn completes instead of pausing until the next external event. */
464
475
  proseContinuations = new Set();
@@ -1570,8 +1581,10 @@ export class AgentFramework {
1570
1581
  /**
1571
1582
  * Add or replace (upsert) a gate policy at runtime. Validated and persisted
1572
1583
  * 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).
1584
+ * policy is invalid. Placement: `'prepend'` puts a rule ahead of EVERYTHING
1585
+ * (including addressed-message wake rules — see EventGate.lintShadows);
1586
+ * `{before: name}` / `{after: name}` anchor it against a specific rule,
1587
+ * which is usually what a sampler or debounce actually wants.
1575
1588
  */
1576
1589
  addGatePolicy(rawPolicy, options) {
1577
1590
  if (!this.eventGate) {
@@ -1904,9 +1917,17 @@ export class AgentFramework {
1904
1917
  name: 'wake_add_rule',
1905
1918
  description: 'Add or replace a wake rule (a gate.json policy) at runtime — no need to ' +
1906
1919
  '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' +
1920
+ 'A rule with the same `name` replaces the existing one in place (add a ' +
1921
+ 'position to move it). ⚠️ ORDERING IS SEMANTICS: first match wins, and a ' +
1922
+ 'matched rule CONSUMES the event — a broad rule placed early makes every ' +
1923
+ 'later rule unreachable for its scope, including your addressed-message ' +
1924
+ '(DM/mention) wake rules. Prefer `insertBefore`/`insertAfter` to place a ' +
1925
+ 'rule relative to a specific existing rule; use `position: "prepend"` only ' +
1926
+ 'when the rule really must beat EVERYTHING. For "additionally wake me ' +
1927
+ 'every Nth event / at most N per hour" governors, set `passthrough: true` ' +
1928
+ 'so non-firing matches fall through to your normal rules instead of ' +
1929
+ 'swallowing them. The result includes a before/after probe table and any ' +
1930
+ 'shadow warnings — READ THEM before ending the turn. Two common shapes:\n' +
1910
1931
  '• Watch a FILE/workspace path: match on `mount` + `pathGlob`, e.g. ' +
1911
1932
  '`{ name: "watch-notes", match: { scope: ["workspace:modified"], mount: "project", pathGlob: "notes/*.md" }, behavior: "always" }`.\n' +
1912
1933
  '• Watch a CHANNEL: match on `source` + `channel` (and/or `tagsAny: ["chat:ambient"]`), ' +
@@ -1973,7 +1994,27 @@ export class AgentFramework {
1973
1994
  position: {
1974
1995
  type: 'string',
1975
1996
  enum: ['append', 'prepend'],
1976
- description: 'Where to insert a NEW rule (ignored when replacing by name). Default "append".',
1997
+ description: 'Coarse placement. "append" (default) adds at the end; "prepend" puts the rule ' +
1998
+ 'ahead of EVERYTHING — including addressed-message wake rules — so prefer ' +
1999
+ 'insertBefore/insertAfter unless the rule really must beat every other rule. ' +
2000
+ 'When replacing an existing name, giving any placement MOVES the rule there; ' +
2001
+ 'omit all placement fields to update it in place.',
2002
+ },
2003
+ insertBefore: {
2004
+ type: 'string',
2005
+ description: 'Insert the rule immediately BEFORE the named existing rule (error if absent). ' +
2006
+ 'The safe way to order against a specific catch-all, e.g. insertBefore: "discord-ambient".',
2007
+ },
2008
+ insertAfter: {
2009
+ type: 'string',
2010
+ description: 'Insert the rule immediately AFTER the named existing rule (error if absent).',
2011
+ },
2012
+ passthrough: {
2013
+ type: 'boolean',
2014
+ description: 'Observer semantics for rateLimit/passiveSample rules: when the rule matches but ' +
2015
+ 'does not fire (below N, bucket empty), the event FALLS THROUGH to later rules ' +
2016
+ 'instead of being consumed. Use for "additionally wake me every Nth event" ' +
2017
+ 'governors so they never swallow DM/mention wakes.',
1977
2018
  },
1978
2019
  resets: {
1979
2020
  type: 'array',
@@ -4307,6 +4348,98 @@ export class AgentFramework {
4307
4348
  await this.deliverProseEnvelope(agent, envelope.replace(/^\s+(?=>>)/, ''));
4308
4349
  }
4309
4350
  }
4351
+ /** Locus-preserving explicit publication. Source remains byte-identical in Chronicle. */
4352
+ async deliverHybridProse(agent, rawText, locus, allowLocus) {
4353
+ const lines = rawText.split('\n');
4354
+ const envelopes = [];
4355
+ let current = [];
4356
+ for (const line of lines) {
4357
+ if (line.trimStart().startsWith('>>>') && current.length > 0) {
4358
+ envelopes.push(current.join('\n'));
4359
+ current = [];
4360
+ }
4361
+ current.push(line);
4362
+ }
4363
+ if (current.length > 0)
4364
+ envelopes.push(current.join('\n'));
4365
+ for (const envelope of envelopes) {
4366
+ if (!envelope.trim())
4367
+ continue;
4368
+ const normalized = envelope.replace(/^\s+(?=>>>)/, '');
4369
+ const attempted = normalized.startsWith('>>>');
4370
+ const parsed = parseHybridProsePrefix(normalized);
4371
+ if (parsed.continueTurn)
4372
+ this.proseContinuations.add(agent.name);
4373
+ if (parsed.kind === 'private') {
4374
+ this.proseTargetPins.delete(agent.name);
4375
+ this.proseHybridSuppressed.add(agent.name);
4376
+ console.error(`[prose] ${agent.name}: >>>skip_reply — ${parsed.body.length} chars kept in context (not sent)`);
4377
+ continue;
4378
+ }
4379
+ if (parsed.kind === 'target') {
4380
+ const resolved = this.channelRegistry.resolveProseTarget(parsed.target);
4381
+ if ('error' in resolved) {
4382
+ this.proseTargetPins.delete(agent.name);
4383
+ this.proseHybridSuppressed.add(agent.name);
4384
+ this.bounceProse(agent, parsed.body, resolved.error, resolved.candidates, '>>>');
4385
+ continue;
4386
+ }
4387
+ let body = parsed.body;
4388
+ const usedClipboard = body.includes('{{unsent}}');
4389
+ if (usedClipboard)
4390
+ body = body.replaceAll('{{unsent}}', this.proseClipboards.get(agent.name) ?? '');
4391
+ this.proseTargetPins.set(agent.name, resolved.channelId);
4392
+ this.proseHybridSuppressed.delete(agent.name);
4393
+ if (!body.trim()) {
4394
+ console.error(`[prose] ${agent.name}: >>>${parsed.target} established target; empty body — nothing sent yet`);
4395
+ continue;
4396
+ }
4397
+ try {
4398
+ const outcome = await this.channelRegistry.routeSpeech(agent.name, body, resolved.channelId);
4399
+ this.recordProseDelivery(agent.name, outcome);
4400
+ if (outcome?.delivered) {
4401
+ this.proseBounceStreaks.delete(agent.name);
4402
+ if (usedClipboard)
4403
+ this.proseClipboards.delete(agent.name);
4404
+ }
4405
+ else {
4406
+ this.proseTargetPins.delete(agent.name);
4407
+ this.proseHybridSuppressed.add(agent.name);
4408
+ this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} was not confirmed`, undefined, '>>>');
4409
+ }
4410
+ }
4411
+ catch (err) {
4412
+ this.proseTargetPins.delete(agent.name);
4413
+ this.proseHybridSuppressed.add(agent.name);
4414
+ this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} failed: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>>');
4415
+ }
4416
+ continue;
4417
+ }
4418
+ if (attempted) {
4419
+ this.proseTargetPins.delete(agent.name);
4420
+ this.proseHybridSuppressed.add(agent.name);
4421
+ this.bounceProse(agent, normalized, 'malformed >>> routing envelope: destination is missing', undefined, '>>>');
4422
+ continue;
4423
+ }
4424
+ if (this.proseHybridSuppressed.has(agent.name)) {
4425
+ this.recordProseSuppression(agent.name, 1);
4426
+ continue;
4427
+ }
4428
+ const sticky = this.proseTargetPins.get(agent.name);
4429
+ if (!allowLocus && !sticky) {
4430
+ this.recordProseSuppression(agent.name, 1);
4431
+ continue;
4432
+ }
4433
+ const target = sticky ?? locus;
4434
+ try {
4435
+ const outcome = await this.channelRegistry.routeSpeech(agent.name, envelope, target);
4436
+ this.recordProseDelivery(agent.name, outcome);
4437
+ }
4438
+ catch (err) {
4439
+ console.error('hybrid locus delivery failed:', err);
4440
+ }
4441
+ }
4442
+ }
4310
4443
  async deliverProseEnvelope(agent, rawText) {
4311
4444
  const name = agent.name;
4312
4445
  const parsed = parseProsePrefix(rawText);
@@ -4365,7 +4498,7 @@ export class AgentFramework {
4365
4498
  }
4366
4499
  /** Cap on consecutive bounce-triggered wakes (notices still append after). */
4367
4500
  static PROSE_BOUNCE_WAKE_CAP = 2;
4368
- bounceProse(agent, text, reason, candidates) {
4501
+ bounceProse(agent, text, reason, candidates, prefix = '>>') {
4369
4502
  const name = agent.name;
4370
4503
  this.proseClipboards.set(name, text);
4371
4504
  const streak = (this.proseBounceStreaks.get(name) ?? 0) + 1;
@@ -4373,8 +4506,9 @@ export class AgentFramework {
4373
4506
  const cand = candidates?.length ? ` Known channels it could mean: ${candidates.join(', ')}.` : '';
4374
4507
  const notice = `[prose-routing] Your text (${text.length} chars) was not delivered — ${reason}.${cand} ` +
4375
4508
  '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.';
4509
+ `destination plus the token {{unsent}}, e.g. "${prefix}#channel {{unsent}}" or "${prefix}@person {{unsent}}". ` +
4510
+ `"${prefix}skip_reply {{unsent}}" keeps it in context only.` +
4511
+ (prefix === '>>' ? ' The prose_help tool shows the full syntax.' : '');
4378
4512
  try {
4379
4513
  // Through framework.addMessage, NOT the context manager directly: while
4380
4514
  // the turn is still streaming this defers the notice to the next tool
@@ -4563,6 +4697,9 @@ export class AgentFramework {
4563
4697
  this.turnEngagedChannels.delete(agent.name);
4564
4698
  this.turnProseDeliveries.delete(agent.name);
4565
4699
  this.turnProseSuppressed.delete(agent.name);
4700
+ this.proseHybridSuppressed.delete(agent.name);
4701
+ if (agent.proseRouting === 'hybrid')
4702
+ this.proseTargetPins.delete(agent.name);
4566
4703
  if (agent.proseRouting === 'explicit') {
4567
4704
  // Explicit prose routing: there is no locus. The model names every
4568
4705
  // destination in-band (`>>` prefixes); the only turn state is the
@@ -4801,7 +4938,7 @@ export class AgentFramework {
4801
4938
  });
4802
4939
  const proseStream = this.channelRegistry
4803
4940
  ? new ProseStreamRouter({
4804
- mode: agent.proseRouting === 'explicit' ? 'explicit' : 'locus',
4941
+ mode: agent.proseRouting === 'explicit' ? 'explicit' : agent.proseRouting === 'hybrid' ? 'hybrid' : 'locus',
4805
4942
  initialTarget: typingChannel,
4806
4943
  resolve: (spec) => {
4807
4944
  const r = this.channelRegistry.resolveProseTarget(spec);
@@ -4917,9 +5054,17 @@ export class AgentFramework {
4917
5054
  const rawXml = rawText && /^<(antml:)?function_calls>/.test(rawText)
4918
5055
  ? rawText
4919
5056
  : undefined;
5057
+ // Persist THIS round's text only. `roundPreamble` (membrane
5058
+ // ≥0.5.79) is the per-round delta; the legacy `preamble` is
5059
+ // CUMULATIVE in XML mode — persisting it per round stored each
5060
+ // round's text N times and re-persisted injected
5061
+ // <function_results> as the agent's own words (the Evander
5062
+ // 2026-08-08 scaffold-leak pyramid: r1 ×3, r2 ×2).
5063
+ const roundText = event.context.roundPreamble ??
5064
+ event.context.preamble;
4920
5065
  assistantBlocks = [];
4921
- if (event.context.preamble) {
4922
- assistantBlocks.push({ type: 'text', text: event.context.preamble });
5066
+ if (roundText) {
5067
+ assistantBlocks.push({ type: 'text', text: roundText });
4923
5068
  }
4924
5069
  for (const c of event.calls) {
4925
5070
  assistantBlocks.push({
@@ -4956,14 +5101,28 @@ export class AgentFramework {
4956
5101
  const roundToolNames = event.calls.map((c) => c.name);
4957
5102
  const hasSameRoundPrivateThink = roundToolNames.includes('think') &&
4958
5103
  requestSnapshot.sameRoundThinkTextPolicy === 'private';
4959
- if (roundToolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)))) {
5104
+ if (roundToolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)) ||
5105
+ (agent.proseRouting === 'hybrid' && HYBRID_PUBLICATION_TOOLS.has(bareToolName(n))))) {
4960
5106
  turnSilenced = true;
4961
5107
  }
4962
5108
  if (roundContent && roundContent.length > 0) {
4963
5109
  liveProseRouting = true;
4964
5110
  const roundSegments = splitProseSegments(assistantBlocks);
4965
5111
  if (roundSegments.length > 0) {
4966
- if (agent.proseRouting === 'explicit') {
5112
+ if (agent.proseRouting === 'hybrid') {
5113
+ if (turnSilenced) {
5114
+ this.recordProseSuppression(agent.name, roundSegments.length);
5115
+ }
5116
+ else if (!hasSameRoundPrivateThink) {
5117
+ const locus = resolveTurnLocus();
5118
+ for (const seg of roundSegments) {
5119
+ turnSpeechChain = turnSpeechChain
5120
+ .then(() => this.deliverHybridProse(agent, seg, locus, true))
5121
+ .catch((err) => console.error('mid-turn hybrid prose delivery failed:', err));
5122
+ }
5123
+ }
5124
+ }
5125
+ else if (agent.proseRouting === 'explicit') {
4967
5126
  // Explicit mode: every segment through the prose gateway.
4968
5127
  // Silencing does not apply — unprefixed prose bounces and
4969
5128
  // prefixed prose is deliberate; think-privacy still holds.
@@ -5243,7 +5402,17 @@ export class AgentFramework {
5243
5402
  .join('\n')
5244
5403
  .trim();
5245
5404
  if (speechText) {
5246
- if (agent.proseRouting === 'explicit') {
5405
+ if (agent.proseRouting === 'hybrid') {
5406
+ const locus = resolveTurnLocus();
5407
+ console.error(`[prose] ${agent.name}: text-only turn -> hybrid prose gateway`);
5408
+ try {
5409
+ await this.deliverHybridProse(agent, speechText, locus, true);
5410
+ }
5411
+ catch (err) {
5412
+ console.error('text-only hybrid prose delivery failed:', err);
5413
+ }
5414
+ }
5415
+ else if (agent.proseRouting === 'explicit') {
5247
5416
  console.error(`[prose] ${agent.name}: text-only turn -> prose gateway`);
5248
5417
  try {
5249
5418
  await this.deliverProse(agent, speechText);
@@ -5292,13 +5461,30 @@ export class AgentFramework {
5292
5461
  .filter((n) => typeof n === 'string');
5293
5462
  const silenced = liveProseRouting
5294
5463
  ? turnSilenced
5295
- : turnSilenced || toolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)));
5464
+ : turnSilenced || toolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)) ||
5465
+ (agent.proseRouting === 'hybrid' && HYBRID_PUBLICATION_TOOLS.has(bareToolName(n))));
5296
5466
  const segments = splitProseSegments(liveProseRouting ? terminalContent : response.content);
5297
5467
  // Preserve in-channel ordering: everything enqueued live must
5298
5468
  // land before the trailing prose. Awaited even when silenced —
5299
5469
  // the chain may still be flushing earlier rounds' posts.
5300
5470
  await turnSpeechChain;
5301
- if (agent.proseRouting === 'explicit') {
5471
+ if (agent.proseRouting === 'hybrid') {
5472
+ if (silenced && segments.length > 0) {
5473
+ this.recordProseSuppression(agent.name, segments.length);
5474
+ }
5475
+ else if (segments.length > 0) {
5476
+ const locus = resolveTurnLocus();
5477
+ for (const seg of segments) {
5478
+ try {
5479
+ await this.deliverHybridProse(agent, seg, locus, true);
5480
+ }
5481
+ catch (err) {
5482
+ console.error('trailing hybrid prose delivery failed:', err);
5483
+ }
5484
+ }
5485
+ }
5486
+ }
5487
+ else if (agent.proseRouting === 'explicit') {
5302
5488
  if (segments.length > 0) {
5303
5489
  console.error(`[prose] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> ${segments.length} trailing segment(s) via prose gateway`);
5304
5490
  for (const seg of segments) {
@@ -6082,6 +6268,9 @@ export class AgentFramework {
6082
6268
  const blocks = toolResults.map(tc => ({
6083
6269
  type: 'tool_result',
6084
6270
  toolUseId: tc.id,
6271
+ // Persisted so XML replay can reconstruct the legacy <tool_name>
6272
+ // element byte-identically to the live injection.
6273
+ toolName: tc.name,
6085
6274
  content: spilled.get(tc.id).text,
6086
6275
  isError: tc.result.isError,
6087
6276
  }));
@@ -6316,7 +6505,7 @@ export class AgentFramework {
6316
6505
  /**
6317
6506
  * Effective tool-result inline cap for an agent, with provenance. Desired
6318
6507
  * value: resident's durable agent_settings value → residence
6319
- * FrameworkConfig.toolResultInlineMaxChars → house default (5000).
6508
+ * FrameworkConfig.toolResultInlineMaxChars → house default (24000).
6320
6509
  * Effective value: min(desired, strategy bound) for EVERY source — the
6321
6510
  * strategy's per-message safety limit is a ceiling, not a suggestion
6322
6511
  * (Sol's #94 ruling: a durable preference must not be a durable path for
@@ -8520,17 +8709,70 @@ export class AgentFramework {
8520
8709
  : input.rateLimit !== undefined ? { rate_limit: input.rateLimit }
8521
8710
  : input.passiveSample !== undefined ? { passive_sample: input.passiveSample }
8522
8711
  : input.behavior;
8523
- const position = input.position === 'prepend' ? 'prepend' : 'append';
8712
+ // Placement: at most one of position / insertBefore / insertAfter.
8713
+ // With none given, a replaced rule stays where it was and a new rule
8714
+ // appends (addPolicy's contract).
8715
+ const placementSources = [
8716
+ input.position !== undefined ? 'position' : null,
8717
+ input.insertBefore !== undefined ? 'insertBefore' : null,
8718
+ input.insertAfter !== undefined ? 'insertAfter' : null,
8719
+ ].filter((s) => s !== null);
8720
+ if (placementSources.length > 1) {
8721
+ finish({
8722
+ success: false,
8723
+ error: `wake_add_rule: give at most one placement, got [${placementSources.join(', ')}].`,
8724
+ isError: true,
8725
+ });
8726
+ return;
8727
+ }
8728
+ let position;
8729
+ if (typeof input.insertBefore === 'string' && input.insertBefore) {
8730
+ position = { before: input.insertBefore };
8731
+ }
8732
+ else if (typeof input.insertAfter === 'string' && input.insertAfter) {
8733
+ position = { after: input.insertAfter };
8734
+ }
8735
+ else if (input.position === 'prepend' || input.position === 'append') {
8736
+ position = input.position;
8737
+ }
8524
8738
  const rawPolicy = {
8525
8739
  name: input.name,
8526
8740
  match: input.match ?? {},
8527
8741
  behavior,
8742
+ ...(input.passthrough !== undefined ? { passthrough: input.passthrough } : {}),
8528
8743
  ...(input.resets !== undefined ? { resets: input.resets } : {}),
8529
8744
  };
8530
- const policy = this.addGatePolicy(rawPolicy, { position });
8745
+ // Before/after probe table + shadow lint: the moment a rule changes the
8746
+ // evaluation order is the moment its real semantics must be legible
8747
+ // (af#105 — a prepended catch-all sampler silently disabled the
8748
+ // addressed-message wakes for 12h before anyone could see why).
8749
+ const before = new Map(this.eventGate.probeTable().map((r) => [r.probe, r]));
8750
+ const policy = this.addGatePolicy(rawPolicy, position ? { position } : undefined);
8751
+ const after = this.eventGate.probeTable();
8752
+ const probes = after.map((r) => {
8753
+ const prev = before.get(r.probe);
8754
+ const changed = !!prev && (prev.policy !== r.policy || prev.wouldWake !== r.wouldWake);
8755
+ return {
8756
+ probe: r.probe,
8757
+ rule: r.policy ?? '(default)',
8758
+ behavior: r.behavior,
8759
+ wouldWake: r.wouldWake,
8760
+ ...(changed ? { changed: true, was: `${prev.policy ?? '(default)'} (wouldWake=${prev.wouldWake})` } : {}),
8761
+ };
8762
+ });
8763
+ const shadowWarnings = this.eventGate.lintShadows()
8764
+ .map((w) => formatShadowWarning(w));
8531
8765
  finish({
8532
8766
  success: true,
8533
- data: { added: policy.name, policies: this.getGatePolicyNames() },
8767
+ data: {
8768
+ added: policy.name,
8769
+ policies: this.getGatePolicyNames(),
8770
+ ...(shadowWarnings.length > 0 ? { shadowWarnings } : {}),
8771
+ probes,
8772
+ ...(probes.some((p) => 'changed' in p)
8773
+ ? {}
8774
+ : { 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.' }),
8775
+ },
8534
8776
  });
8535
8777
  }
8536
8778
  catch (err) {