feat(voice-relay): framework agents join the external TTS relay as bot clients - #68
nickmahdavi wants to merge 4 commits into
Conversation
Two framework-core changes that let a channel-scoped consumer (e.g. a TTS
relay bridge) follow an agent's turn without reaching into private
framework state.
1. Optional channelId on the inference:* trace family, stamped from the
turn's pinned locus by a read-only helper at the existing trace-emit
sites, so a consumer can key per-channel streams. With a channelRegistry
the pin is the only source read: a mid-turn channel_open moves the
trigger bookkeeping without moving actual routing, so falling back to it
would stamp a channel the turn's speech never lands in. Registry-less
hosts fall back to the triggering channel. Turns with no channel
(heartbeats, timers) leave the field unset.
2. abortInference(name, { reason, keepText }). When a user-initiated abort
supplies keepText — the text a voice client delivered aloud before the
user interrupted — its not-yet-committed part is saved as the assistant's turn, and the
channel receives only the part the turn's live speak-while-acting posts
have not already delivered (undeliveredSuffix matches the spoken text
against live-routed prose; re-routing it verbatim would double-post).
Silencing is honored as at completion, and a keepText racing a
framework-internal cancel is discarded with its stream. With no keepText
the partial turn is discarded as before, and framework-internal cancels
are unchanged. The existing string form, abortInference(name, reason),
still works.
prose-segments also gains isWhitespaceInsensitivePrefix, the same
whitespace-tolerant walk answering only yes/no. The relay client uses it to
judge whether a voice client's reported spoken text belongs to the utterance
currently streaming in a channel — a report that does not match is stale and
must not cut off a newer turn.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…y test helpers Adds the pieces shared by the relay-client work: - The v2 TTS-relay wire-protocol types, ported from melodeus-tts-relay to match it exactly on the wire. - InferenceTraceBridge, which translates the framework's inference:* traces into the relay's streaming messages (activation_start, block_start, chunk, block_complete, activation_end), keyed by the new channelId. - sha256 token helpers. - Test helpers: a recording socket, a simulated v2 voice client, and a helper that spawns the reference melodeus-tts-relay used by the end-to-end tests. The reference-repo lookup no longer false-positives on the framework repo itself when HOME is unset. Tests cover the bridge's translation table: identity resolution, the visible flag (text chunks are voiced, thinking is not), block-content accumulation, terminal-trace mapping to activation_end reasons, channel-less drops, and unsubscribe on stop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nal TTS relay
Connects framework agents to an external melodeus TTS relay the same way the
relay's own bots do: the module opens one WebSocket to the relay's /bot
endpoint, authenticates as a single bot identity, and streams the agent's
turn up the socket as it is produced. The stream is the relay's own
messages (activation_start, block_start, chunk, block_complete,
activation_end), translated from the agent's inference:* traces and scoped
to a channel by the trace channelId.
Interruptions coming back from the relay are mapped to
abortInference(agentName, { keepText }), so an interrupted agent's context
keeps the words the client verifiably reported spoken. Addressing is strict: an
interruption naming a channel the module never streamed to is dropped
(guessing could abort an unrelated agent), and only a channel-less
interruption may fall back to the single tracked agent. Before aborting,
the reported spoken text must prefix-match the current utterance's streamed text
(per-block or whole-activation, per client) — voice lags text, so a report that does not
match the current utterance describes an earlier one and must not cut off
the new turn. Channel tracking is bounded (256, oldest evicted) so a
long-lived process cannot grow it without limit.
Connection handling: the client treats relay heartbeats as liveness only,
reconnects with exponential backoff and re-authenticates, and — matching the
relay — does not queue while disconnected, so messages produced while the
socket is down are dropped. A close carrying the relay's "Replaced by new
connection" is fatal rather than retried: two clients sharing a bot
identity would otherwise evict each other forever. The backoff resets only
after a connection has stayed authenticated for a stability window, so an
auth-then-drop loop cannot hammer the relay from the floor delay.
Tested against an in-process mock /bot server (auth, bot-identity stamping,
reconnect and the replaced-connection stop, backoff stability, interruption
addressing and staleness, channel-tracking bounds) and end to end against
the real melodeus-tts-relay: a simulated voice client hears a framework
agent's streamed turn and interrupts it mid-sentence.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ing, keepText hygiene, connection hardening
Fixes from two review rounds: a six-dimension adversarially-verified
workflow, then a ten-agent sweep of the full PR.
Block emission (the load-bearing fix): the agent's production stream
requested emitBlocks: false, so inference:content_block never fired
outside tests and block_start/block_complete never reached the wire —
melodeus keys its utterance state machine (and its ability to interrupt)
on block_start, and iOS flushes the trailing sentence on block_complete.
Masked because the mock membrane ignored the flag; it now honors it, so
every block-dependent test doubles as a regression pin on the agent
actually requesting blocks.
Interruption correctness:
- Interruptions are dropped when the target agent is mid-turn on a
DIFFERENT channel (activeChannelByAgent, set on inference:started,
cleared on terminals): a late report for a finished turn must not abort
the unrelated turn the agent is running now.
- The staleness guard matches spokenText against two windows — since the
channel's last block_start AND since its activation_start — because the
reference clients differ (melodeus resets per block, iOS accumulates the
whole activation). Previously a legitimate iOS report on a multi-block
turn was dropped as stale.
- Both comparison walks ignore characters clients normalize away:
whitespace and narrator-markup asterisks. iOS voices *action* spans via
a narrator voice and strips the asterisks from its report, so any
narrated turn's interruption used to fail verification and the agent
kept talking.
- A non-empty report while the current utterance has voiced nothing yet
(activation_start seen, no visible text) is dropped as stale: real
clients never report speech for an utterance that voiced nothing, so it
can only describe the previous turn — previously it aborted the fresh
turn. Only a report with NO accumulator entry at all (connected
mid-turn) is treated as unverifiable: the abort goes through, but
unverifiable text is never forwarded as keepText — text we cannot match
against what we streamed must not be committed as words the agent said.
keepText lifecycle (framework core):
- keepText is stored only after a SUCCESSFUL abort. Storing before and
deleting on failure let a duplicate interruption report (same streamId —
cancelStream does not bump it) wipe the first abort's still-pending
keepText, discarding the spoken words entirely.
- abortKeepTexts is keyed `${agentName}:${streamId}` with a driveStream
finally backstop, so an abort racing turn completion goes inert instead
of attaching an old turn's spoken text to a later abort of the agent.
- The keepText branch snapshots the turn's locus BEFORE awaiting the
speech chain (a successor turn re-pins it) and re-checks agent.streamId
after its awaits: a stream started during that window is not
reset/settled against the wrong turn, and driveStream's finally no
longer deletes the successor's name-keyed per-turn state (which would
strand its tool round).
- A user abort is booked as a deliberate cancel (inference:exhausted with
errorType 'abort'): no consecutive-failure streak, no failures.log
entry, no false "[inference-failed] nothing was sent" chronicle marker —
three routine voice barge-ins must not page an operator as hard-down.
- The context commit dedupes keepText against turnCommittedProse (the
rounds already flushed with their tool calls), so a whole-activation
client's replayed prose is not committed twice.
Connection hardening:
- Heartbeat watchdog: the relay heartbeats every ~2s; when nothing arrives
for heartbeatTimeoutMs (default 8s) the link is presumed half-open and
terminated so the normal reconnect path recovers it. handshakeTimeout
covers the dial phase the watchdog cannot (a host that accepts TCP but
stalls the upgrade).
- Inbound frames are shape-checked (a bare JSON null previously crashed
the process), capped at 1 MiB, and handled inside a catch so a throw in
the abort path cannot escape the socket listener; the interruption
reason is coerced to the protocol enum.
- Outbound sends drop when the socket buffer exceeds 4 MiB (delivery is
best-effort by design) and reconnects carry ±20% jitter.
- The fatal replaced-connection stop tears down the trace subscriptions
with the connection — a permanently-down module must not keep
translating every turn into sends that go nowhere. auth_error drops the
authed flag, duplicate auth_ok cannot re-arm the stability timer, and a
restarted module begins at the backoff floor.
- The default logger is console-backed for info and above (matching
RelayLogger's documented contract) so auth rejection and the fatal
replaced-connection stop are visible without configuration.
Scope and typing cleanup:
- \`agents\` config filter scopes the bridge and interruption addressing to
the listed agents, making the multi-instance recipe in the header true;
the header documents the required construct → bind(framework) →
addModule wiring, and the iOS deployment constraint (unconfigured-bot
announcements prefix "<name> says:" to reports, which fails
verification — give the bot a voice entry).
- Outbound messages are typed BotStreamMessage (the bot-side union) so the
compiler rejects anything a /bot client must never send. NoopTraceBridge
(no consumers) and tokens.ts (relay-server account machinery, no
callers) are removed.
- inference:stream_restarted carries channelId and the bridge closes the
abandoned activation with activation_end('abort') — verified against
both clients ('abort' cancels iOS's queued audio for the abandoned
stream; the re-stream then delivers fresh), so a budget restart no
longer leaves an unpaired activation_start on the wire.
- trace.ts documents exactly which inference:* members carry channelId;
types.ts records the relay commit it mirrors (ec8f0f1) and glosses "v2"
and "ChapterX".
Tests: 438 → 454. New coverage: block emission (mock honors emitBlocks),
watchdog (dead link + kept-alive), malformed frames, the
unverifiable-vs-stale report split, narrator-asterisk reports, iOS-style
whole-activation reports, cross-turn late interruptions, duplicate-abort
keepText survival, multi-round keepText dedupe, stream_restarted closure,
the agents filter, deliberate-cancel booking, fatal-stop teardown, and a
CI-runnable closed loop (real framework + mock /bot server: interruption
→ abort → activation_end('abort') on the wire, spoken prefix in context)
so the round trip no longer rides only on the locally-run e2e. All unit
tests tear down via t.after so a failed assertion cannot leak
sockets/timers and hang the runner; the e2e cleans up its spawned relay
on any failure; the backoff-stability window is widened against
CPU-starved CI runners.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Anarchid
left a comment
There was a problem hiding this comment.
Read against head 02381b2 (4 commits, +4083/−24), rebuilt and tested here against its own base, and checked against upstream main at fa95817 (0.13.0), connectome-host bf41338, discord-mcpl cda7b27, and melodeus-tts-relay ec8f0f1.
Changes requested — not mergeable, and the design it implements has been overtaken. Since this PR's base, main grew its own answer to the same problem three times over: channelId on inference traces plus mcpl:speech-routed{messageId,text} (a2f0d85, Jul 22), a shipped connectome-host TtsRelayModule for the same relay /bot endpoint (edit-the-posted-message design, Jul 22), and the MCPL Spec 14.3 channels/outgoing/chunk prose stream (24c559a, Jul 26) that discord-mcpl #28 (merged Sep 7) now voices directly, reporting a [voice] interrupted receipt instead of aborting inference. The PR mentions none of them. Independently of that, the module cannot work against the only production host: it puts the MCPL composite channel id (discord:<guild>:<id>) on the relay wire, where voice clients subscribe by raw snowflake, so nothing is voiced and every interruption is dropped as "unknown channel". Two pieces are worth salvaging as small separate PRs: emitBlocks: true (main's own block-trace consumers are dead without it) and the deliberate-cancel failure-streak bypass (with provenance).
Blockers
-
Superseded design + nine-hunk conflict in the abort path. (both reviewers) main's driveStream now has
lifecyclePhasewith a terminalhookOrchestrator.emitLifecycleinfinally,abortAgentScript,turnProseDeliveries+[delivered]receipts,ownsPhysicalStream, proseRoutingexplicit/hybrid/disabled, and aProseStreamRouterthat emits routed,>>-stripped, fail-closed-suppressed deltas (sendOutgoingChunk) precisely for voice. The PR's bridge forwards rawinference:tokens, and its keepText path commits that raw text and callsrouteSpeecharound main's delivery policy. Astra's in-memory comparison on a hybrid-mode turn: main routes{"c2":"Hello\n"}; the bridge voices">>>other Hello\n>>>skip_reply Hidden"at the frozen locus. My probe:undeliveredSuffix('>> #general hello there','>> #general hello')→'there', i.e. routing syntax survives into keepText. Per-hunk disposition (Astra, verified against the trial merge inmerge/): hunks 1, 2, 7 mechanical; 3, 5, 6 drop the PR side (main already stamps channelId fromtypingChanneland keeps restart gate ownership); 4, 8, 9 need redesign (liveRoutedProse bookkeeping, keepText commit vs receipts, streamId-only ownership vsownsPhysicalStream). Resolving the text conflicts does not preserve main's behaviour.- Evidence: main
src/framework.ts:6600-6620,:7413-7470,:7642-7697;src/mcpl/prose-stream-router.ts:1-25; discord-mcplsrc/voice.ts:1-42,src/server.ts:418-468. - Fix: decide first whether a second melodeus client is wanted at all (see Minor 13). If yes, rebuild on main's routed deltas (or the stamped traces) and adopt the receipt-style interruption, not keepText.
- Evidence: main
-
Composite MCPL channel id on the relay wire → module is inert on every registry-backed Discord deployment. (both) Locus =
event.channelIdfrommcpl:channel-incoming(mainframework.ts:4954) →activeTriggerChannels→resolveLocus(channel-registry.ts:2285); discord-mcpl builds ids asdiscord:<guildId>:<channelId>(src/channels.ts:17-19). The bridge sendsevent.channelIdverbatim (trace-bridge.ts:136,153); the relay fans out by exact match on that field (handlers/bot.ts:152,state.ts:180-183) and interruptions come back with raw snowflakes (PROTOCOL.md:99-108). Module then hits "Interruption for unknown channel dropped" (relay-client-module.ts:531-535). connectome-host's module strips to the last segment for exactly this (tts-relay-module.ts:230-232). Astra probe:{"wireChannels":["discord:123:456"],"aborts":[],"warnings":["Interruption for unknown channel dropped"]}. Every PR test uses raw ids ('chan-loop','chan-e2e', stub registry resolving'chan-live'), so the suite cannot see it.- Fix: explicit surface↔relay id translation at the module boundary, both directions; one test with a registered
discord:g:cchannel.
- Fix: explicit surface↔relay id translation at the module boundary, both directions; one test with a registered
Major
-
Provider-timeout aborts are booked as deliberate cancels and never close the activation. (Astra) membrane emits
abortedwithreason: 'user' | 'timeout' | 'error'(origin/main src/membrane.ts:2932-2937, 3943-3950); the PR'sabortedcase stampserrorType: 'abort'unconditionally (framework.ts:5168-5174), bypassing the streak/failures.log/marker for adapter deadlines too. The bridge does not handleinference:exhausted, andinference:abortedonly comes fromabortInference, so a timed-out stream leavesactivation_startunpaired on the wire. Probe:healthFailures:0, wire:["activation_start"].framework.stop()also callscancelStreamdirectly (:955) with no aborted trace.- Fix: gate the bypass on
event.reason === 'user'(or on an abortInference-originated flag); close activations idempotently on every terminal, using saved activation identity.
- Fix: gate the bypass on
-
Interruptions after completion or after a tool-round post do nothing. (Astra; conhost contrast Claude) The module only calls
abortInference, which returnsfalsefor an idle agent (agent.ts:783-797). Voice lags text, and connectome posts prose at round boundaries, so the common case is "the message is already on Discord and the stream has moved on": the full unspoken reply stands, context is untouched, and the header's claim that "context and posted message keep the words … spoken" (relay-client-module.ts:11-15) is false.test/inference-trace-channel.test.ts:287-315enshrines keeping the full live post. The e2e sends its interruption afterrunUntilIdleand asserts only the spy call, not an effect. conhost's module edits the posted message and drops a context note (tts-relay-module.ts:441-474); discord-mcpl #28 sends a voiced/unvoiced receipt.- Fix: reconcile against
mcpl:speech-routedmessage ids regardless of stream state, or adopt the receipt design.
- Fix: reconcile against
-
Abort-drain window corrupts the successor stream's bookkeeping. (Astra) The new
await turnSpeechChain+routeSpeechin the aborted case (framework.ts:5123-5129) runs aftercancelStreamset the agent idle, so a successor can start. ThestreamId !== myStreamIdbranch callsonInferenceEndedandbreaks without settling;finallythen callsstopTyping()unconditionally (:5285) and leaves the predecessor'spendingAssistantBlocks(oldtool_use) under the agent name for the successor to flush. Probe:pending:[["a",[{"type":"tool_use","id":"old-tool"}]]], typingStops:1, gateEnds:2, settles:[]. Orphanedtool_usewithouttool_resultis a 400 on the next request.- Fix: generation-owned state (main's
ownsPhysicalStream/turn token pattern); settle before releasing ownership.
- Fix: generation-owned state (main's
-
Divergence ≠ "all new": the dedupe heuristic re-commits already-committed rounds. (Astra; Claude probe agrees)
undeliveredSuffixreturns the whole report on the first mismatch (prose-segments.ts:75-79) and both the context commit and the channel post use it (framework.ts:5103-5113). A module that (re)connected mid-activation starts its window at the first chunk it saw; a whole-activation client then legitimately reports"Beta Gamma"against committed"Alpha\nBeta"→ diverges →Betacommitted and posted twice. Probe:committed:"Alpha\nBeta", toCommit:"Beta Gamma".test/prose-segments.test.ts:83-89andinference-trace-channel.test.ts:347-357encode the assumption.- Fix: correlate reports to block/activation offsets, not first-differing-character.
-
Default
agents: all+ one channel → the wrong agent gets aborted. (Astra) AF wakes every agent on a channel message by default;activeAgentByChannelkeeps only the lastinference:started(relay-client-module.ts:222-224) whilestreamedTextaccumulates per channel across agents under one botId (:653-666). Probe: Alice speaks, her interruption prefix-matches,abortInference('bob', {keepText:'Alice'}). Alice's words land in Bob's context.- Fix: one active agent per relay identity, or activation-aware addressing.
-
Two-window staleness guard drops legitimate lagging barge-ins and accepts repeated-prefix stale ones. (both; opposite halves) Each
block_starterases the block window (:656-659); the activation window has no separators ('Round oneRound two'). A client still voicing block B when generation is in block C reports"Beta…"→ no prefix match → "Stale interruption dropped" (probe:aborts:[]); the barge-in is silently ignored exactly when speak-while-acting makes it likely. Conversely an old"Hello"report aborts a new"Hello, again"turn on the same channel (probe:keepText:"Hello"). The incomingtimestampis discarded (:503-509).- Fix: bounded per-block records + timestamp correlation; treat ambiguous legacy reports conservatively.
-
Identity defaults defeat the relay's duplicate-speech suppression. (both)
userId/usernamedefault tobotId(:241-245). The relay turns a Discord message intobot_message_posted(not voiced) only when author id/username matches a connected bot (relay src/index.ts:104-119,143-146); a connectome agent posts through discord-mcpl's bot user, so with the defaults voice clients receive the streamed chunks and then the ordinarychannel_messagefor the same reply. Probe:suppressedAsConnected:false. The config comment callsuserIda mention-resolution concern. Inherited: conhost's module has the same weakness (tts-relay-module.ts:305-309).- Fix: require/resolve the posting bot's Discord identity or a
discordNamemapping; document the suppression role.
- Fix: require/resolve the posting bot's Discord identity or a
Minor
emitBlocks: trueis a global stream-contract change, undisclosed as one. (Claude; Astra concurs)agent.ts:676flips it for every agent, module or not; the PR body says hosts without the module are unaffected. It is nevertheless the most valuable line here: mainagent.ts:771is stillfalse, soinference:content_blocknever fires on 0.13.0 and conhost'stts-relay-module.ts:398-418,tui.ts:1694, andweb-ui-observers.ts:269block lanes are dead today. Ship it alone, with a changelog fragment.- Backpressure drops terminal frames on a healthy socket. (Astra)
:637-643drops any message overMAX_BUFFERED_BYTES, includingactivation_end; heartbeats keep the watchdog happy, so the client holds a half-open activation indefinitely. Probe:wire:["activation_start","chunk"]. - Asterisk-only suffix is committed/posted. (Astra) Comparison skips
*but the tail usestrim():undeliveredSuffix('*grins*','*grins*')→'*','**Hello**'→'**'(my re-probe). Normalise the remainder with the same predicate. - Two clients for one relay identity, and no decision recorded. (Claude) conhost main still constructs its own
TtsRelayModule(src/index.ts:400-401); running both against onebotIdis fatal by design (relaystate.ts:54+ module:399-410). The PR needs to say whether it replaces the host module. Relay-side inherited bug found by Astra while probing:handlers/bot.ts:91-95callsunregisterBot(botId)when the old socket closes, deleting the replacement thatregisterBotjust stored (probe:newRegisteredAfterOldClose:false) — worth an issue on melodeus-tts-relay. - Channel-less interruption fallback is unreachable (both): the relay requires a string
channelId(handlers/tts.ts:356-359);test/voice-relay-client.test.ts:387enshrines dead code. - Doc slips (Astra): trace.ts JSDoc says ten channel-bearing traces; there are nine. "Pinned locus, else triggering channel" is wrong when a registry exists without a pin (
inferenceTraceChanneldeliberately returns undefined,framework.ts:5784-5786).
Verified fine
- PR head builds clean and passes 454/454 on its own base with the real relay pinned (PR body: 453 + 1 skip); e2e runs against melodeus-tts-relay@ec8f0f1.
types.tsis a faithful mirror of relaytypes.ts@ec8f0f1(renames,HeartbeatMessage,GuildMemberInfoonly).ws ^8.18.0already an AF dependency at base and main.- Reconnect/backoff/watchdog: timers cleaned on stop and on replaced-connection (Astra timer probe:
remaining:0);handshakeTimeout:10000; ±20 % jitter; stale-socket guards; no token in logs. - Duplicate-abort keying:
cancelStreamdoes not bumpstreamId; store-after-success keeps the first keepText (probekept:[["a:7","Hello"]]). visible = blockType === 'text'matches membrane's native and XML emitters. Ordinary error retries do pairfailedwith a freshstarted.- Relay facts: heartbeat 2 s; replacement close
1000 "Replaced by new connection"; botId mismatch dropped; interruption requires connected bot + string channelId. - Fact-block audit: Astra confirmed 7/7 and corrected one prompt claim — the trace test does install a registry stub (
inference-trace-channel.test.ts:107-124), it just resolves to a raw id.
What to salvage
emitBlocks: true+ changelog fragment, own PR (fixes conhost/TUI/webui block lanes on main).- Deliberate-cancel bypass keyed on
reason === 'user'(+[inference-failed]marker suppression), own PR. - Decide the voice architecture explicitly (host TtsRelayModule vs discord-mcpl #28 vs a framework relay client) before any more relay-client code; if a framework client is wanted, it consumes routed deltas, translates ids, and reports receipts.
🔴 CHANGES REQUESTED
Reviewers: Claude (Fable 5.1, Claude Code) + Codex (GPT-6 Astra), independent passes merged; every Astra citation re-verified against source or by probe.
Method
Codex GPT-6 Astra (gpt-6-astra, xhigh, codex-cli 0.153.4, read-only sandbox, no network): 14 min wall, 84 commands, 3.38 M input (3.19 M cached), 23.1 k output (8.0 k reasoning); 11 findings, 11/11 citations re-verified, severities kept except #2 raised Major→Blocker. Probes ran in-memory against the built dist/ (no Chronicle stores, no sockets). Found by both: 1, 2, 8, 9, 14. Astra only: 3, 4, 5, 6, 7, 11, 12, 15, relay unregister race. Claude only: 10, 13, the discord-mcpl #28 / Spec 14.3 supersession context, and the build+e2e run. Astra could not reach GitHub (PR body supplied) or run the test suite (read-only sandbox).
— Reviewed by Claude Fable 5.1 via Claude Code and GPT-6 Astra via OpenAI Codex, merged and re-verified.
|
Second reviewer's note, at head
Recommendation to the maintainers: close this PR as superseded, with thanks — the diagnosis in the body (zero-retry default, the relay's need for block boundaries, barge-ins must not page as hard-down) was right and each of those has since landed or is landing by another route. If nickmahdavi wants to open the — Weft (Claude, via Ra's account, disclosed per convention) |
|
(closing as superceded per review above) |
What
Framework agents join the external melodeus TTS relay as first-class bot clients — the same
/botconnection the relay's existing (ChapterX) bots hold. The relay itself is untouched and stays external: one module instance dials{url}/bot, authenticates as one bot identity, streams the agent's turns as the relay's own wire messages, and maps interruptions coming back intoabortInference. Only the client connection each agent-framework process needs; nothing relay-side moves into connectome.Commits / how to review
channelIdon the turn-scopedinference:*traces (stamped read-only from the turn's pinned locus), andabortInference(name, { reason, keepText })so an interrupted agent's context keeps what was actually spoken. ~200 src lines; the only commit touching shared code — review this one hardest.types.tsis a near-verbatim mirror ofmelodeus-tts-relay/src/types.ts@ ec8f0f1 (fastest review: diff it against the relay). The bridge translatesinference:*traces into the five streaming messages.emitBlocks: true— block boundaries never reached the wire before (melodeus can't track or interrupt an utterance without them), masked by mocks that ignored the flag.Behavior notes for existing deployments
TraceEventadditions are optional fields;abortInferencestill accepts the plain string form; hosts without the module are unaffected.inference:exhaustedwitherrorType: 'abort') — no failure streak, nofailures.logentry, no[inference-failed]chronicle marker. Voice barge-ins are routine; three in a row must not page an operator as hard-down. Monitoring that counted aborts as failures will see fewer entries.bind(framework)→addModule. Withoutbind, outbound streaming works but interruptions are dropped (warned). One instance = one bot identity; scope withagents: [...]when running several instances in one process.Testing
/botserver — a turn streams to the wire, an interruption comes back,activation_end(reason: "abort")goes out, and the agent's context keeps the spoken prefix./ttsvoice client hears a framework agent through the real relay and interrupts it mid-sentence.