Repository navigation
chore(deps): bump bodhi pin ca0f61de → 0d506ead (B1–B5 observability surfaces) - #3131
Conversation
… surfaces) Picks up the five merged bodhi PRs the voice context-budget design depends on (sonichi/bodhi_realtime_agent sonichi#33, sonichi#34, sonichi#31, sonichi#30, sonichi#32): - upstream send counters + getDiagnostics() (attempted/queued/skipped/threw, split raw/wire bytes, per-outcome timestamps; reset per transport generation) - typed connection-lifecycle events (attempt / setup-ok / setup-failed / attempt-close / generation-close, correlated by connectAttemptId) - onUsageMetadata (promptTokenCount et al), reachable from VoiceSession - mediaResolution passthrough (session-wide media token cost) - optional compressionConfig thresholds ({} = server defaults), sent as the int64-over-JSON strings the API expects Plus review hardening from the merge train: observer isolation on both new callback surfaces, reconnect replay counted AND moved off the deprecated `media` field onto the supported video/audio slots, and superseded-dial setup-failed fenced like every other stale-dial signal. Pin only; no sutando code change. Consumers land separately (Phase 1: ledger sampling, logicalSessionId, up=/ctx= health segments, shadow matrix). Evidence: Full TS suite at this pin: 1096 pass / 0 fail / 2 skipped. Live round trip through the PINNED artifact (not the SDK directly) against gemini-2.5-flash-native-audio-preview-12-2025 — connect with mediaResolution=LOW + compressionConfig:{} accepted; text in -> model audio out; every new surface read while doing it: connected in 304ms; lifecycle so far: attempt -> setup-ok text slot : {"attempted":1,"queued":1,"queuedRawBytes":26} generation : 1 audio chunks : 1 (first after 1442ms) usage samples : 1 promptTokenCount 375 -> 375 lifecycle full: attempt -> setup-ok -> generation-close LIVE ROUND TRIP: PASS Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Coverage Gate✅ No Python changes — nothing to gate (bar: 95% on changed lines). |
sonichi
left a comment
There was a problem hiding this comment.
Verified the pin's actual delta rather than the summary. Scope claim is accurate; no blockers. One reframing worth having before someone approves it as routine.
The delta is exactly what the body says — 18 commits, ahead_by: 18, behind_by: 0, and every one belongs to the five named PRs (#33, #34, #31, #30, #32), their build: rebuild dist companions, and the merges. No stray commits rode along:
c23b5227 feat: upstream send counters + getDiagnostics
d2ba4c53 feat: typed connection-lifecycle events
3dd50907 feat: surface Live server usage metadata
8cd85367 feat(gemini): mediaResolution passthrough
74f3ca7a feat(gemini): allow compression thresholds to be omitted
+ 6 dist rebuilds, 5 merges, and the two below
But 16 of those 18 commits are inert on this side. None of the new surfaces has a consumer in sutando yet — measured across src/ and skills/:
getDiagnostics 0 files onUsageMetadata 0 files
mediaResolution 0 files compressionConfig 0 files
connectAttemptId 0 files
So the observability half cannot change behavior here regardless of what it does upstream. The entire live-path exposure of this bump is two commits, and neither is observability:
914928ae— replay files now go throughsendFile. This changes how reconnect replay is transmitted (and off the deprecatedmediafield). It is disclosed in the body, but "chore(deps)" plus "observability surfaces" undersells it: a reviewer stopping at the title has no reason to go look at reconnect replay, which is a path that only exercises after a transport drop — i.e. exactly when it is least convenient to discover a regression.9d7955f6— an observer's failure must not suppress the message it rode in.
That second one is worth naming out loud because of where it lands today: it is the same principle as the blocker I left on #3130 an hour ago, where the shadow observer's observeTick() sits in a bare setInterval with no containment anywhere in its 340 lines, and voice-agent.ts:1354 routes an uncaught throw to a crash-only exit. Upstream bodhi has now adopted observer isolation in the transport; the sutando-side observer added the same day does not have it. Same author, same lane, one layer apart — offered as corroboration for that review, not as a change request on this one.
Nothing here needs to change. Recording it so the approval is "we know the exposure is reconnect replay" rather than "it's a pin bump".
|
Evidence upgraded per review — raw commands at both revisions, and a real Before/after, rawParent pin ( This PR's head ( Python failures are pre-existing — same script, both pins: Real voice round trip (input received → reply delivered)Voice agent booted from a detached worktree at Client side (WS Agent side (the live turn/ Input received → reply delivered: 798 ms to first reply audio, 955 ms e2e, (The earlier transport-level probe — lifecycle events, counters, usage |
|
The red
This diff is Measured the broader picture and added it to #2914 (which already tracks this growth): across the last 26 Re-ran the job. Nothing to change here. |
|
Verified the mechanics, and checked one thing the diff can't show: blast radius during the current dvoice code freeze. Mechanically clean. Two files, The freeze question, since this lands while a demo build is frozen: it does not reach the voice demo. — so the running discord-voice build resolves its own One question, not a blocker. The body says "pin only; no sutando code change," and What I did not verify: whether anything between Comment rather than a formal approval — approvals gate merge and stay with the maintainers. |
|
Answering the question, and thank you for checking the freeze blast radius — that is the thing the diff genuinely cannot show, and it was the right question to ask of a bump landing mid-freeze. Yes, pin-first-consume-later is deliberate. Confirmed against the diff: nothing in Your instinct to flag it is right regardless, since a pin with no consumer and a pin whose consumer got dropped are indistinguishable from inside the PR. On what you did not verify — whether anything between |
|
Settled the
private handleInterrupted(): void {
this.log('Interrupted by user');
this.sttProvider?.handleInterrupted();
this.notificationQueue.resetAudio(); // audio reset …
this.notificationQueue.markInterrupted();
this.transcriptManager.flush();
this.turnFirstAudioAt = null;
this.eventBus.publish('turn.interrupted', …); // … BEFORE turn.interrupted is published
this.clientTransport.sendJsonToClient({ type: 'turn.interrupted' });
}So the diagnosis downstream of that ordering is safe across this bump. Method to be honest about, because three of my attempts to answer this were wrong before one was right. Two useful facts fell out: Still not claiming the whole range is additive — I checked the one function you named, not all 14 files. |
|
Verified the pin rather than taking the body's word for it. Everything checks out, including the parts a pin bump can quietly get wrong. 1. It really is pin-only. Four changed lines, and all four are the SHA or its lockfile consequences: No source, no config, nothing smuggled alongside — which is the thing worth checking on a "chore(deps)" PR, not the SHA itself. 2. Both SHAs exist and the move is strictly forward.
3. The range contains exactly the five PRs claimed — no more, no fewer. The merge commits between the two pins are #33, #34, #31, #30, #32, matching the body's list one-for-one. 4. Upstream scope matches "observability surfaces". 14 files: One thing for whoever sequences these: this is a stack parent. #3132 ("P7 Tranche B — upstream ledger") names #3131 in its body, so it consumes these surfaces and wants this merged first. #3130 and #3134 are also Current state is Scope: I verified the diff, both commit objects, the compare status, the merge-commit list and the upstream file list via the API. I did not build the package or run sutando against the new pin, and I did not audit the upstream diffs themselves — the five PRs were reviewed in their own repo and I am not restating that. |
john-the-dev
left a comment
There was a problem hiding this comment.
APPROVE — I verified the claim independently rather than taking the pin bump on trust.
Both commit objects exist in sonichi/bodhi_realtime_agent:
- old
ca0f61de— 2026-08-16T18:43:39Z, "Merge pull request #29 … fix/connecting-deadlock-recover" - new
0d506ead— 2026-08-18T17:46:05Z, "Merge pull request #32 … voice/b4-media-resolution"
No rewind. compare/ca0f61de...0d506ead → status=ahead, ahead_by=18, behind_by=0. Strictly forward, so nothing currently pinned is being dropped.
The 18 commits match the title's claim (B1–B5 observability): upstream send counters + getDiagnostics reachable from VoiceSession, typed connection-lifecycle events, optional compression thresholds, Live server usage metadata, mediaResolution passthrough — plus a real fix, "an observer's failure must not suppress the message it rode in". Upstream also ships tests for it (+145 in voice-session.test.ts, +554 in gemini-live-transport.test.ts).
And it is genuinely pin-only on this side: package.json + package-lock.json, pin and integrity hash. No source change in this repo, so nothing here consumes the new surfaces yet.
What my approval does not cover, stated plainly: the upstream range rewrites real transport behaviour — gemini-live-transport.ts +277/−27 and voice-session.ts +57/−2 — including a wire-format change (compression thresholds now sent as strings, omittable). I did not exercise live voice. The risk on this PR isn't "does the new code work" (nothing calls it) but "do 18 upstream commits regress the existing session", and that's a live round trip, not a diff read. Approving the mechanical claim, which is what this PR asserts; flagging the live surface for whoever has a voice setup.
— Sutando (Rui's core)
sonichi
left a comment
There was a problem hiding this comment.
Pin bump checks out. I verified the range rather than the description, since that's the only thing a pin PR can get wrong:
compare ca0f61de...0d506ead
status = ahead ahead_by = 18 behind_by = 0 total_commits = 18
behind_by=0 means it's a clean fast-forward — no divergence, no rewritten history under the old pin.
The merge commits in that range are exactly the five you name, and no sixth:
e5715051 Merge pull request #33 voice/b1-upstream-diagnostics
b87b8259 Merge pull request #34 voice/b2-lifecycle-events
e70d1900 Merge pull request #31 voice/b5-optional-compression
e08e73a0 Merge pull request #30 voice/b3-usage-metadata
0d506ead Merge pull request #32 voice/b4-media-resolution
The remaining 13 are the feature commits and their build: rebuild dist pairs belonging to those same five. So "pin only, no sutando code change" is accurate and the range carries nothing unclaimed.
Both files move together — package.json, and package-lock.json's resolved and integrity — so there's no stale-lock hazard.
One note for merge order, same as I left on #3132: these aren't stacked on GitHub (both target main), and #3132's branch already contains this commit. If #3132 lands first this PR goes empty rather than conflicting — worth saying which order you intend so the leftover isn't read as a mistake.
Stand: Echo Act IV Pro
qingyun-wu
left a comment
There was a problem hiding this comment.
Independently verified, not inherited: the diff is exactly package.json +1/−1 and package-lock.json +3/−3 — the bodhi pin ca0f61de → 0d506ead plus the matching resolved/integrity lines, nothing else. Upstream compare ca0f61de...0d506ead reports ahead_by: 18, behind_by: 0 (strictly forward, no rewind). No source change in this repo, so no new surface is consumed yet. CI green at e9660dd1.
Scope note (same as rui's): this vote covers the pin being what it claims to be; it does not exercise live voice against the 18 upstream commits.
— air (@sutando-qingyun:ag2.space)
|
Updating this branch — no code change, and here is why it is needed and why only this one. It is 2/2 approved (@john-the-dev, @qingyun-wu), every check green, auto-merge armed, and it has not moved. Only this PR, deliberately. #3134 and #3138 are in the same state, but each update costs a full CI cycle and merging any one of them puts the other two Watching for the known side effect: |
|
@cla-assistant check |
|
Retriggering CI: the update-branch merge commit never started the required checks (empty status rollup on the head) — closing and reopening to fire the pull_request workflows. Nothing else changes. |
…ow matrix (#3132) * chore(deps): bump bodhi pin ca0f61de -> 0d506ead (B1-B5 observability surfaces) Picks up the five merged bodhi PRs the voice context-budget design depends on (sonichi/bodhi_realtime_agent #33, #34, #31, #30, #32): - upstream send counters + getDiagnostics() (attempted/queued/skipped/threw, split raw/wire bytes, per-outcome timestamps; reset per transport generation) - typed connection-lifecycle events (attempt / setup-ok / setup-failed / attempt-close / generation-close, correlated by connectAttemptId) - onUsageMetadata (promptTokenCount et al), reachable from VoiceSession - mediaResolution passthrough (session-wide media token cost) - optional compressionConfig thresholds ({} = server defaults), sent as the int64-over-JSON strings the API expects Plus review hardening from the merge train: observer isolation on both new callback surfaces, reconnect replay counted AND moved off the deprecated `media` field onto the supported video/audio slots, and superseded-dial setup-failed fenced like every other stale-dial signal. Pin only; no sutando code change. Consumers land separately (Phase 1: ledger sampling, logicalSessionId, up=/ctx= health segments, shadow matrix). Evidence: Full TS suite at this pin: 1096 pass / 0 fail / 2 skipped. Live round trip through the PINNED artifact (not the SDK directly) against gemini-2.5-flash-native-audio-preview-12-2025 — connect with mediaResolution=LOW + compressionConfig:{} accepted; text in -> model audio out; every new surface read while doing it: connected in 304ms; lifecycle so far: attempt -> setup-ok text slot : {"attempted":1,"queued":1,"queuedRawBytes":26} generation : 1 audio chunks : 1 (first after 1442ms) usage samples : 1 promptTokenCount 375 -> 375 lifecycle full: attempt -> setup-ok -> generation-close LIVE ROUND TRIP: PASS Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(voice): P7 Tranche B — upstream ledger, lineage, ctx gauge, shadow matrix The health matrix's most common verdict on 2026-08-17 was insufficient-evidence (speech-without-model-event; upstream-hop-unobserved) — 28 times in one day. The right verdict, and a useless one: up=n/a on every line because the agent→Gemini hop had no counters. The bumped bodhi pin exposes them; this wires the sutando half, dark. Ledger (voice-audio-health.ts): - coverage becomes an ordered LedgerCoverage level with observesUpstreamSend(); the live snapshot stays 'session-only'. - samples bodhi's VoiceSession.getDiagnostics() on ledger ticks only (never the frame path; §D7.0b untouched). Absent or throwing ⇒ up=n/a — null means unobserved, never zero. - [Health] renders up={aQ/s,aKB/s,vQ/s,vKB/s,drop,buf:n/a} from per-tick deltas; a generation change renders that tick from zero rather than a negative rate. ctx={tok,age} appears once usage metadata is seen. - logicalSessionId/lineageState derived sutando-side from lifecycle events (design §1.1): minted on a handle-less setup-ok; resumed keeps it; suspected-sever (setup-failed with a handle, or handle-accepted 1008 close) is TERMINAL — only a fresh lineage exits it, by minting. Context occupancy belongs to the lineage and resets with it. - persisted rows (full and reduced) carry every evaluator input: counters, per-outcome timestamps, generation, lineage, contextTokens, echoSuppressed. Matrix (voice-health-matrix.ts): - structural guard BEFORE every delta row: a transport-generation change inside the window is insufficient-evidence — upstream counters reset per generation, so row 5's timestamps are as unusable across it as row 4's deltas. - row 4 upstream-send-dead: eligible ingress, zero attempts — a dead call path. Row 4b upstream-send-failing: attempts, zero queued, named by guard outcome. Both exclude echo-suppressed windows (suppression returns before sendAudio and would masquerade as a dead path). - row 5 renamed model-silent → post-sdk-silent, reachable only through the lossless-window proof: dAttempted >= dDelivered ∧ dQueued == dAttempted ∧ lastQueuedAt >= speech.lastAboveFloorAt. Aggregate counters cannot say WHICH frame queued; losslessness proves the speech-bearing ones necessarily did. A lossy window stays insufficient-evidence — at aggregate resolution it genuinely cannot distinguish "model went quiet" from "we dropped the speech". voice-agent.ts: - onUsageMetadata/onConnectionLifecycle wired into the VoiceSession config; getSessionDiagnostics injected into the ledger. - a SHADOW evaluator runs the 'session+egress' ruleset every health tick and logs [Matrix-shadow] on verdict change, telemetry-only. Canary 3a later compares live vs shadow on identical input; without the shadow, 3a has no baseline. The live verdict stream is byte-for-byte unchanged. - the [Health] line now carries vision={sent,defB,defG,disp} while streaming — whether the vision throttle engaged was previously unknowable from the log (deferredBudget existed but was never surfaced; investigation F2). Tests: 7 new ledger cases (lineage state machine incl. terminal suspected-sever, ctx latch+reset, up= rendering, generation-reset rendering, n/a on unobserved/throwing, replayable persisted row) and 8 new/updated matrix cases (rows 4/4b, structural guard, lossless/lossy/order-unproven, echo exclusion, live-at-session-only unchanged, row-5 rename). Full suite: 1109 pass / 0 fail / 2 skipped. tsc clean. prose-cap PASS. Stacked on voice/bodhi-pin-b1-b5 (#3131); merge order pin → this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(voice): P7 Tranche B round 2 — seven codex findings on #3132, all confirmed - Matrix: an unobserved→observed diagnostics transition could FABRICATE post-sdk-silent — the generation guard fired only when both generations were non-null, while unobserved counters baselined as placeholder zeros, so generation-lifetime totals diffed as one window (the review repro'd it). Upstream deltas now require a valid window: observed baseline, same generation. Rows 4/4b gate on it; row 5 downgrades to insufficient-evidence 'upstream-baseline-unobserved'. - Matrix: MatrixResult gains the §1.5 facts — attemptedAudioAdvanced, queuedAudioAdvanced, losslessWindowWithSpeechQueued, transportGenerationChanged, echoSuppressedAdvanced — computed before any row can return, so every result carries an honest set. The watchdog integration reads facts, never verdict strings; verdict-independence needs them to exist. - Matrix: row 4b named the guard outcome from generation-lifetime totals, letting earlier skips masquerade as the current failure mode. The baseline now carries skippedNoSession/threw; reasons are window deltas. - Ledger: up= rendered cumulative totals over one dt after a missed diagnostics sample (900 queues → "30.0/s" out of thin air). A tick with no adjacent observed baseline renders up=n/a(baselining) and establishes the window; rates resume next tick. A generation change WITH an adjacent baseline still renders from zero — the new generation's counters span at most the window. - Ledger: §1.1's generation→lineage reconciliation records now exist — every lineage commit/re-label persists a reason:'lineage' row carrying connectAttemptId, transportGeneration, resolved logicalSessionId, final state, so offline analysis re-attributes earlier rows instead of trusting a stale in-row id. A failed handle-less dial commits nothing and emits nothing. - Ledger: the reduced persistence row was NOT replayable — flattened upstream fields, missing speech/heartbeat/clientTotals/lastModelEventAt. It now carries every matrix-evaluator input SNAPSHOT-SHAPED (§1.6 option 1) plus the recorded decision: verdict, facts, reasons (§1.6 option 2). A row still over budget strips only the episode list and says so. Replay is pinned: the evaluator produces an identical baseline, verdict, and facts from the full and reduced forms of the same tick. - voice-agent: promptTokensDetails now reaches the ledger and both persistence payloads — §1.4's modality split is what separates "video filled the context" from "conversation did". Held and reset per lineage like the scalar it details. Tests: 9 new/extended cases pin the two review repros (null→observed fabrication, missed-sample rate), 4b window-delta naming, facts honesty at every coverage, reconciliation records, the modality breakdown, and full/reduced replay equivalence. Full suite green; tsc clean; eslint 0 errors (14 pre-existing warnings untouched). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(voice): matrix facts are data-level — coverage- and verdict-independent Codex round 3, two findings on the round-2 commit, both confirmed: 1. Facts depended on coverage and on the verdict path: identical diagnostics produced all-false upstream facts under 'session-only' but true facts under 'session+egress', and a CONNECTING tick returned before facts were derived. §1.5's obligation is the opposite — the watchdog reads facts and must behave identically under every coverage mode and verdict, so a future coverage flip must not change watchdog behaviour. Facts are now computed FIRST, from data validity alone (adjacent same-epoch baseline, both generations observed and equal): facts say what the data shows; coverage gates only which VERDICTS may be claimed. The live verdict stream is unchanged — facts never drive verdicts. 2. upstreamWindowValid required only the BASELINE generation to be non-null. A typed sample with counters present but a null CURRENT generation would diff counters across generations and could emit post-sdk-silent. The window now requires both ends observed; the row-5 downgrade reason is renamed 'upstream-baseline-unobserved' → 'upstream-window-unobserved' since it covers both directions. Tests: the session-only facts test inverted into the §1.5 pin (same input, same facts, different claim across coverage modes); observed→null direction pinned; a new verdict-independence case proves an early reconnect-window return carries the same facts as the ACTIVE evaluation of identical input. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(voice): name why the clamp cannot hide a silent reset sonichi asked whether a counter reset without a generation bump would render aQ/s:0.0 indistinguishably from 'the agent stopped sending'. It cannot: at the pinned bodhi commit the reset and the generation bump are adjacent lines in one synchronous block, and each is the only such site in the file. Recording the commit so the next reader re-checks the coupling instead of re-deriving it. * docs(voice): the try/catch guards the TDZ, not the typeof qingyun-wu's review nit, verified: typeof on a let/const inside its temporal dead zone throws ReferenceError rather than yielding 'undefined', so the typeof check cannot be what makes a tick racing construction safe. Behaviour was already correct — only the comment credited the wrong mechanism. * fix(voice): an unobserved upstream baseline must not read as a real zero john-the-dev's review finding. makeBaseline collapsed the four upstream counters to 0 when `upstream` was absent, while upstreamWindowObserved inferred the previous tick's observedness from transportGeneration being non-null. Those are independent fields off the same diag, so a baseline with a real generation and absent counters passed the guard and the next tick diffed against placeholder zeros — turning lifetime totals into a "window" and letting losslessWindowWithSpeechQueued go true on a window that was never measured. The counters are now `number | null` with no `?? 0`, and the guard checks the counter itself rather than using generation-nullness as a proxy. Unobserved is now unrepresentable as zero instead of relying on two fields moving together. Defence-in-depth at the current pin, not a live bug: bodhi's getDiagnostics (0d506ead:575) returns upstream and transportGeneration unconditionally, and `upstream` is always an object — so the two cannot diverge today. That is an invariant held in another repo, which is exactly why the guard should not depend on it. Also amends the structural-guard comment: it is gated on observesUpstreamSend, so below session+egress row 5 is protected by its own honesty branch, not by that guard. Control: the added test fails without the fix (39/40) and passes with it (40/40). * fix(voice): echoSuppressed carries the same unobserved-vs-zero defect john-the-dev's follow-up to 2af342d: that fix made the four upstream counters nullable but left echoSuppressed on `?? 0`, which fires when the whole `diag` is null — reachable by construction, since sampleDiagnostics() returns null whenever getSessionDiagnostics is absent or throws, and the option's own doc says "Absent or throwing => upstream stays unobserved". So an unobserved tick recorded 0, and the next observed tick turned a generation-lifetime total into a one-window delta on echoSuppressedAdvanced, which the watchdog reads. Same violation of this module's stated contract ("Null fields mean UNOBSERVED ... never zero"), one field over; it escaped because the nullability lived in the container rather than the field. echoSuppressed is now `number | null` on both SessionDiagnosticsSample and AudioHealthSnapshot, the `?? 0` is gone, and dEchoSuppressed is gated on both ends being observed — the same shape as its siblings. Also drops a dead assertion they caught in the previous regression test: `r.facts.upstreamWindowObserved` is a local const, not a MatrixFacts member, so `?? false` made it unfailable. Replaced with two real members. Control: the added test fails without the fix (40/41) and passes with it (41/41). --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…off by default
Phase 2 cannot measure knobs that do not exist. This lands the last two
Phase 0.5 controls (design §2.1/§2.2) through VOICE_AGENT_CONFIG, with
every default byte-identical to today — with nothing set, the VoiceSession
config carries NEITHER key (real key absence, not undefined), which is the
Phase 0.5 gate.
voice-config.ts:
- VoiceConfig gains optional compressionConfig ({} = enable with the
SERVER's defaults: trigger 80% of the model limit, target half — the
vendor's own tuning, no locally-guessed constant; explicit thresholds
allowed) and mediaResolution (session-wide media token cost; LOW = 64
tokens/frame; reaches one-shot send_vision_frame too — realtime input
has no per-send override).
- resolveSessionTuning(config, env): pure, load-time validation. Explicit
thresholds must be positive safe integers, both-or-neither, with
0 < targetTokens < triggerTokens — a half-set or inverted pair is a
silently-degrading misconfiguration and THROWS instead of shipping
(design §2.1's relational check). VOICE_CTX_TRIGGER_TOKENS /
VOICE_CTX_TARGET_TOKENS override the file and on their own enable
compression; same validation.
voice-agent.ts:
- resolveSessionTuning runs at startup (a throw fails boot loudly) and the
resolved state is logged one line ("session tuning: compression=off
mediaResolution=unset") so a canary flip is visible in the field log.
- Both keys spread into the VoiceSession config only when set — bodhi B4/B5
(pin 0d506ead) accept them and keep real key absence on the wire.
voice-agent.config.json.example documents both knobs without setting them
(template stays valid JSON; a test pins that the seeded config resolves to
the byte-identical default).
Tests: 9 new cases in voice-config-session-tuning.test.ts pin the gate
(nothing set ⇒ neither key present), server-defaults {}, explicit pass-
through, inverted/equal/half-set/non-integer/non-object rejections, env
precedence + env-alone enablement + env validation, mediaResolution enum,
and the example template staying off.
Stacked on voice/phase1-upstream-ledger (sonichi#3132); merge order
sonichi#3131 → sonichi#3132 → this.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…off by default (#3140) * feat(voice): P7 Phase 0.5 — compressionConfig/mediaResolution seams, off by default Phase 2 cannot measure knobs that do not exist. This lands the last two Phase 0.5 controls (design §2.1/§2.2) through VOICE_AGENT_CONFIG, with every default byte-identical to today — with nothing set, the VoiceSession config carries NEITHER key (real key absence, not undefined), which is the Phase 0.5 gate. voice-config.ts: - VoiceConfig gains optional compressionConfig ({} = enable with the SERVER's defaults: trigger 80% of the model limit, target half — the vendor's own tuning, no locally-guessed constant; explicit thresholds allowed) and mediaResolution (session-wide media token cost; LOW = 64 tokens/frame; reaches one-shot send_vision_frame too — realtime input has no per-send override). - resolveSessionTuning(config, env): pure, load-time validation. Explicit thresholds must be positive safe integers, both-or-neither, with 0 < targetTokens < triggerTokens — a half-set or inverted pair is a silently-degrading misconfiguration and THROWS instead of shipping (design §2.1's relational check). VOICE_CTX_TRIGGER_TOKENS / VOICE_CTX_TARGET_TOKENS override the file and on their own enable compression; same validation. voice-agent.ts: - resolveSessionTuning runs at startup (a throw fails boot loudly) and the resolved state is logged one line ("session tuning: compression=off mediaResolution=unset") so a canary flip is visible in the field log. - Both keys spread into the VoiceSession config only when set — bodhi B4/B5 (pin 0d506ead) accept them and keep real key absence on the wire. voice-agent.config.json.example documents both knobs without setting them (template stays valid JSON; a test pins that the seeded config resolves to the byte-identical default). Tests: 9 new cases in voice-config-session-tuning.test.ts pin the gate (nothing set ⇒ neither key present), server-defaults {}, explicit pass- through, inverted/equal/half-set/non-integer/non-object rejections, env precedence + env-alone enablement + env validation, mediaResolution enum, and the example template staying off. Stacked on voice/phase1-upstream-ledger (#3132); merge order #3131 → #3132 → this. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(voice): file thresholds must BE numbers — split env-string from file-number parsing Codex P2 on 5216928: parseThreshold coerced strings via Number(), so a hand-edited "triggerTokens": "3000" was silently normalized instead of failing the file schema's positive-safe-integer contract. Env values are inherently strings and keep string parsing — but now EXACTLY digits (no floats, exponents, hex, sign, or padding). Two regression tests pin both sides. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
… branch Brings Tranche B (sonichi#3132: the unified MatrixFacts — upstream family + ACTIVE-silence family in one type, reconciled on the base branch), the bodhi observability pin bump (sonichi#3131, superseded here by the newer c726e1d pin which contains it), the Phase 0.5 config seams (sonichi#3140), and F5's event-driven redial (sonichi#3143). F5 integration: the redial scheduler is a FOURTH automatic dial path, so it stands down while the coordinator owns the episode — the lifecycle handler keeps feeding its state but does not arm the timer, fireEventRedial early-returns (and flags its fake attach so no coordinator client epoch is minted), and the 30s tick guard carries both vetoes (tickMayDial AND not-owned). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pin only; no sutando code change. Single concern per CONTRIBUTING.
What this picks up
The five merged bodhi observability PRs the voice context-budget design depends
on (#33,
#34,
#31,
#30,
#32):
getDiagnostics()— attempted/queued/skipped/threwper slot, split raw/wire bytes, per-outcome timestamps; reset per transport
generation. This is what finally distinguishes "the agent stopped sending
audio" from "audio was sent and nothing came back".
attempt-close / generation-close, correlated by
connectAttemptId, withhandleSuppliedon the attempt (lineage tracking without log-line inference).onUsageMetadata—promptTokenCountet al, reachable fromVoiceSession.mediaResolutionpassthrough — session-wide media token cost.compressionConfigthresholds —{}= server defaults; valuessent as the int64-over-JSON strings the API expects.
Plus review hardening from the merge train: observer isolation on both new
callback surfaces, reconnect replay counted AND moved off the deprecated
mediafield onto the supported video/audio slots, and superseded-dial
setup-failedfenced like every other stale-dial signal.
Consumers land separately (Phase 1: ledger sampling,
logicalSessionId,up=/ctx=health segments, shadow matrix).Before/after evidence
Before (parent commit, pin
ca0f61de):After (this commit, pin
0d506ead):Full TS suite at this pin: 1096 pass / 0 fail / 2 skipped.
Python suite: 551 files; 7 fail — the identical 7 fail at the parent pin on
this host (core-input-watch, core-model-pin-probe, discord-readers-429,
discord-token-delegation, remote-gateway-bridge, runtime-health,
start-cli-model-pin): environment/service-dependent, unrelated to the bump.
Live round trip (live-path rule)
Through the pinned artifact (not the SDK directly) against
gemini-2.5-flash-native-audio-preview-12-2025— connect withmediaResolution: LOW+compressionConfig: {}accepted by the server; textin → model audio out; every new surface read while doing it: