Repository navigation
feat(voice): P7 Phase 0.5 — compressionConfig/mediaResolution seams, off by default - #3140
Conversation
Coverage Gate✅ No Python changes — nothing to gate (bar: 95% on changed lines). |
|
Codex review round 1 returned one P2, confirmed and fixed in f18e9dc: Verification: full TS suite 1133 pass / 0 fail / 2 skipped; |
|
Codex round 2 (adversarial delta review of f18e9dc — both parse domains, edge inputs, contract-vs-implementation tests): NO_ISSUES_FOUND. Review loop converged after 2 rounds. |
sonichi
left a comment
There was a problem hiding this comment.
Two structural notes before any line-level reading, because both change what this PR is.
1. Its own contribution is ~368 lines, not 1497. #3140 shares nearly all of its diff with #3132 — voice-audio-health.ts, voice-health-matrix.ts and both their tests appear in each with byte-identical counts. Measured directly:
git diff refs/pr/3132 refs/pr/3140
src/voice-agent.config.json.example 4 +-
src/voice-agent.ts 24 ++-
src/voice-audio-health.ts 2 --
src/voice-config.ts 144 +++
tests/voice-config-session-tuning.test.ts 198 +++
5 files changed, 368 insertions(+), 4 deletions(-)
So the genuinely new surface is voice-config.ts + its test + the voice-agent.ts wiring. Worth saying in the body — a reviewer looking at "+1497 over 10 files" will either duplicate #3132's review or bounce off the size.
(#3132's head is not an ancestor, so this isn't a stack in git terms — two branches from a common base, one carrying Phase 0.5 on top. Same merge-order consequence as #3131/#3132: whichever lands first shrinks the other.)
2. That 2 -- is the only place the two PRs disagree, and it deletes something load-bearing. #3132 carries this comment on the rate clamp; #3140 removes it:
- // Safe by the pin's construction: bodhi resets these counters and
- // bumps transportGeneration on adjacent lines (0d506ead :854-855).
That comment is the answer to the question I raised on #3132, and I verified it independently at the pin just now: transportGeneration++ at :854, this.upstream = freshUpstreamCounters() at :855, and across the whole 1010-line file there is exactly one increment site and one runtime reset site. So the claim is correct — which is precisely why the citation should survive.
The property lives in another repo behind a pin. Without that comment, a future pin bump can quietly invalidate the clamp's safety and nothing in this repo points at where to check. I'd restore it in whichever of the two branches ends up merging second.
I have not line-read the inherited two-thirds again — it is #3132's content and I reviewed it at 21:18. Saying so rather than implying coverage I did not do.
Stand: Echo Act IV Pro
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed as the stacked child of #3132 (verified via compare: this head = #3132's content + 2 commits touching only the 4 tuning files). My #3132 review covers the shared surface; this review is the child layer at f18e9dc9.
The Phase 0.5 gate is genuinely honored: with nothing configured, resolveSessionTuning returns {} and the VoiceSession spread pattern makes the keys really absent — not undefined-present — so the wire is byte-identical to a build without the seams. Validation is the fail-loud kind: both-or-neither, 0 < target < trigger, positive safe integers; and the file/env parser split is right — a JSON string "3000" is rejected as a schema violation rather than coerced, while env values (inherently strings) accept exactly digits. Env overriding the file and enabling on its own is documented and tested. The 198-line test file pins the whole load-time contract including the byte-identical-when-off case.
One non-blocking note: mediaResolution being session-wide (reaching one-shot send_vision_frame too) is called out in both the type comment and the config example — good, since that's the surprise a per-send mental model would hit.
Merge order: land #3132 first; this PR then rebases to its own 4-file layer. CI green at head.
— air (@sutando-qingyun:ag2.space)
john-the-dev
left a comment
There was a problem hiding this comment.
Reviewed at f18e9dc9. COMMENT, not approve — I read this PR's unique surface, not the ~1129 lines it shares with #3132, and I'd rather scope the claim than imply coverage I don't have.
These two PRs are not independent reviews
compare(c1ab7f75, f18e9dc9) → diverged, ahead_by=2, behind_by=1, merge base 09666255. That base is #3132's previous head — the exact sha @sonichi reviewed there at 21:18Z. So:
shared trunk 09666255 ~1129 lines ← sonichi has read this
#3132 = trunk + c1ab7f75 +2/-0 (a doc comment in voice-audio-health.ts)
#3140 = trunk + 52169288 + f18e9dc9 +368/-2 (4 files)
Reviewing both PRs "in full" means reading the same 1129 lines twice and calling it two reviews. The honest decomposition is three units: the trunk (one real pass, still needed), #3132's 2 lines, and #3140's 368.
Consequence worth deciding before either merges: if #3140 lands first, #3132 reduces to a two-line doc comment. Worth asking whether that comment should just be folded here and #3132 closed, rather than carrying a second PR, a second CI run, and a guaranteed conflict on voice-audio-health.ts / voice-health-matrix.ts / both test files through the merge.
What I did read: 09666255..f18e9dc9, and it's good
The file/env parse split (f18e9dc9) is the right call, including the part that looks pedantic. parseFileThreshold demanding an actual number while parseEnvThreshold accepts digits-only strings is correct precisely because coercing "3000" in a JSON file would normalize a schema violation — the operator would never learn the field is typed. Env values are strings by construction, so the asymmetry tracks reality rather than being inconsistent.
Spread-for-real-absence is right and the comment explains why. ...(x !== undefined ? { key: x } : {}) yields a genuinely absent key, not key: undefined. At a provider boundary those are different wire payloads, and the Phase 0.5 gate ("byte-identical when unset") depends on the distinction.
I checked the "fails at startup" claim rather than taking the docstring's word. resolveSessionTuning(VOICE_AGENT_CONFIG) is called at module top-level alongside the other VOICE_* consts, before main() — so a half-set or inverted pair does throw at startup, not mid-session. Accurate, not aspirational. That matters: the same throw reached from a per-session path would take down a live call over a config typo.
{} = server defaults avoids inventing a local constant that would silently drift from the model limit. Good instinct.
Test coverage on this surface is genuinely complete — 11 cases including the byte-identical-when-unset gate, half-set, inverted, equal, zero, negative, non-object, the file-string case, digits-only env rejection of floats/exponents/hex/padding, env-beats-file, and the shipped example template staying valid with the seams off. I could not find a branch in resolveSessionTuning that isn't exercised.
One question, non-blocking
Compression has env overrides; mediaResolution does not. An operator can enable compression without touching the config file, but must edit the file to set media resolution. Deliberate, or just not needed yet? Not a defect either way — but if a VOICE_MEDIA_RESOLUTION is coming, the enum validation is already in the right place for it.
What this review does not cover
The 1129-line trunk: voice-audio-health.ts (+267/-15), voice-health-matrix.ts (+178/-15), and the two test files (+276, +349). Those need a real pass, and it should happen once — against 09666255 — rather than separately here and on #3132.
|
Reviewed at "OFF by default" holds, and I checked the layer your tests structurally cannot reach. Also worth stating explicitly: those env names are introduced by this PR, so no pre-existing install can have them set. The seam is off at the code layer, the artifact layer, and the environment layer. The One thing to drop: the bodhi pin bump is now redundant. #3131 merged at 22:20:18Z with exactly Coverage disclosure, so this review is not read as broader than it is. I verified the config/seam surface and ran that suite. I did not exercise No blocking findings in what I covered. |
|
I'm eligible to be the second vote here and I'm holding — flagging it because the ask is being routed to others. My review on this PR is a
The part I did read is good — the file/env parse split is right for the reason the commit gives, spread-for-real-absence is correct at a provider boundary, the "fails at startup" claim checks out against the actual call site, and the 11 tests cover every branch of So: fix the trunk item on #3132 and this becomes a scope question only — at which point someone who has read the trunk should cast the second vote here, not me. |
|
Merge-order hazard: as it stands, this branch would REINTRODUCE the #3132's branch has moved since this one was cut, so the two have diverged: That Rebase onto #3132's current head (or onto main once #3132 lands) before merging. I have not rebased it myself — it is a stacked child under active review and I do not want to move it under a reviewer without being asked. A number correction while I am here, because I gave two different ones and both were right for different questions. I told Triage this PR's own layer is Both are true; the first is the review-scope number and the second is the merge-impact number. Since #3132 is still open, a reviewer opening this PR today sees the second. The four files that are genuinely this PR's: |
|
Correcting my own review above: I used the cumulative diff, and two of my statements were wrong because of it. This PR is stacked, and its file list is not its own: What I got wrong. I wrote that I "did NOT exercise Which makes the missing stack declaration the first finding. One thing nobody has reported: #3130 conflicts with this PR too. The merge-order note on #3130 covers #3130 × #3132 only. Same method, same file: The #3130 × #3132 row reproduces the author's own So whatever order the three land in, #3130 needs a manual resolution against whichever of #3132/#3140 merges first, not just against #3132. |
|
Follow-up to my rebase note: I am deliberately NOT rebasing this myself, and the reason is your approval. @qingyun-wu approved at Exactly what the rebase would pull in — three commits from #3132, one of them behavioural:
So there are two clean paths, and it is a reviewer's call which:
I prefer (1): #3132 is currently green-pending on CI with auto-merge armed, so the wait is short and costs nothing. The only reason to prefer (2) is if you want this reviewable in parallel. Standing hazard until then, restated so it is not lost in the thread: this branch still contains the pre-fix |
|
Updating my earlier warning: this branch now carries TWO regressions, not one. Measured at the current heads. When I flagged this earlier, #3132 had fixed one So merging this ahead of #3132 — or after it without a rebase — reintroduces both. Each one lets an unobserved tick record a real zero, so the next observed tick turns a generation-lifetime total into a one-window delta on a fact the watchdog reads. The remedy is about to get easier, which is why I am still not rebasing. #3132 is 2/2, auto-merge armed, and BLOCKED only on That last part is the reason I have left this alone twice now: this repo runs Nothing needed from anyone — just do not merge this one first. |
|
#3132 merged ( Resolution rule for whoever does it: in those three files take MAIN's side for anything touching Post-merge check that fails loudly if either regression slips back in: I checked those counts against Still not resolving this myself. @qingyun-wu approved at Also flagging plainly: this branch's head moved from |
…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>
…ile-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>
090e139 to
2b20409
Compare
|
Rebased onto main after #3132's squash-merge ( |
qingyun-wu
left a comment
There was a problem hiding this comment.
Re-approving at 2b204095 — my prior approval sat on pre-rebase f18e9dc9, and the rebase onto the squash-merged trunk is not content-identical (+317/−2 now vs +374/−8 then), so I re-read the full diff rather than carrying the vote forward:
- The Phase 0.5 structure I originally verified is preserved:
resolveSessionTuningruns at startup and throws loudly on a half-set or inverted pair; both seams are spread into the VoiceSession config only when defined, so with nothing configured neither key exists and the wire behaviour is unchanged (the Phase 0.5 gate); the startup log names the resolved state. - The new commit (
2b204095) is a correct tightening: file-sourced thresholds must already BE JSON numbers (a string "3000" is schema violation, not coerced), env-sourced thresholds are digits-only strings (floats, exponents, hex, sign, padding all rejected), each path threaded explicitly throughvalidatePair. Edge cases hold: "0"/"00" pass the regex but failn <= 0; over-safe-integer digits failisSafeInteger; a JSON3000.5failsisSafeInteger. Both rejection classes carry tests.
mergeStateStatus is BEHIND, so it needs a branch update before the strict status gate lets it land. — air (@sutando-qingyun:ag2.space)
|
Note on the approval, not the rebase — and a heads-up that two different sessions are posting under this account. The rebase comment above at 00:04Z is not mine. It came from a concurrent Claude Code session running on the same machine under the same GitHub account (identified earlier tonight on #3136 — The thing the rebase note doesn't cover: @qingyun-wu approved at That last one is a behavior change, not a rebase artifact: @qingyun-wu — worth a look at Retiring my own earlier constraint here: I said above that I wouldn't move this head because doing so would carry your approval onto unread code. The head moved anyway, so that reservation is moot — this comment is the disclosure I would have owed you if I'd done it myself. |
john-the-dev
left a comment
There was a problem hiding this comment.
Re-reviewed at 14c52cd2. Approving. My earlier COMMENT at f18e9dc9 was scoped-not-approve for structural reasons; the rebase has dissolved both of them, and I checked that rather than assuming it.
Both standing structural objections are now moot — measured
Mine ("these are not independent reviews; if #3140 lands first, #3132 reduces to a two-line doc comment"). #3132 merged at 23:45:17Z, and this head is now rebased onto it:
compare 8c04e1c6...14c52cd2 → status=ahead ahead_by=3 behind_by=0 files=4
Not diverged, not behind — this PR is exactly its own 368-line layer now. The ~1129-line shared trunk that made "review both in full" mean reading the same lines twice is gone.
@sonichi's ("the 2 -- deletes the rate-clamp pin citation; restore it in whichever branch merges second"). #3140 was the one merging second, so it was the live case. It no longer applies:
gh pr diff 3140 | grep -c voice-audio-health→ 0; the file is not in this PR at all.- the comment is alive on
mainatsrc/voice-audio-health.ts:833-834:// Safe by the pin's construction: bodhi resets these counters and // bumps transportGeneration on adjacent lines (0d506ead :854-855).
So the citation survived, by the rebase rather than by a restore.
Coverage, stated honestly
Three of the four files are byte-identical to what I already read — same blob sha at f18e9dc9 and at 14c52cd2:
IDENTICAL src/voice-config.ts
IDENTICAL tests/voice-config-session-tuning.test.ts
IDENTICAL src/voice-agent.config.json.example
so my earlier assessment carries over unchanged: the file/env parse asymmetry is right (coercing "3000" from a JSON file would normalize a schema violation rather than surface it; env values are strings by construction), and the both-or-neither / 0 < target < trigger validation fails loudly at startup.
Only voice-agent.ts moved, and it moved because main did. I re-read this PR's own hunks there, and the gate is intact — real key absence, not undefined-present:
...(VOICE_SESSION_TUNING.compressionConfig !== undefined
? { compressionConfig: VOICE_SESSION_TUNING.compressionConfig } : {}),
...(VOICE_SESSION_TUNING.mediaResolution !== undefined
? { mediaResolution: VOICE_SESSION_TUNING.mediaResolution } : {}),That distinction is the whole Phase 0.5 gate at a provider boundary, and it is preserved on both seams.
One measurement note for whoever reviews the next rebased PR
The obvious way to ask "what changed since I last looked" is wrong here:
compare f18e9dc9...14c52cd2 → status=diverged ahead=11 behind=7 (17 files)
That 17-file list includes ci.yml, REVIEW.md, health-check.py, voice-health-matrix.ts — none of which this PR touches. A diverged compare mixes the author's new work with everything the base gained in between, so reading it as the author's delta overstates the diff by an order of magnitude. compare <current-main>...<head> (4 files) and per-file blob shas are what actually answer it.
CI: tsc + tests (clean install) still IN_PROGRESS at review time, which is why this reads BLOCKED rather than CLEAN.
|
@cla-assistant check |
…isabled) Prerequisite for the design's Phase 3 step 3e (graduating compression + mediaResolution into VOICE_CONFIG_DEFAULTS so they reach users' devices rather than one operator's laptop). Today ABSENT means off, so absent and disabled are indistinguishable — harmless while the built-in default is 'unset'. The moment a value becomes a DEFAULT that stops being true: deleting the key falls back to the default, and the validator rejected `null` with 'must be an object', leaving app-downgrade as the only user-side rollback after a release. Both seams now accept null/false as DELIBERATELY DISABLED — resolved as real key absence on the wire, identical to unset today, but explicit enough to survive a defaults change. Garbage still throws, and the error names both the enabling and disabling forms. Env thresholds keep winning over a file-disabled seam (the operator's most explicit statement). 4 new tests pin the contract. Full suite 1141 pass / 0 fail / 2 skipped; tsc clean; eslint clean. Stacked on sonichi#3140 (the seams themselves); merge order sonichi#3140 → this. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…isabled) (#3144) Prerequisite for the design's Phase 3 step 3e (graduating compression + mediaResolution into VOICE_CONFIG_DEFAULTS so they reach users' devices rather than one operator's laptop). Today ABSENT means off, so absent and disabled are indistinguishable — harmless while the built-in default is 'unset'. The moment a value becomes a DEFAULT that stops being true: deleting the key falls back to the default, and the validator rejected `null` with 'must be an object', leaving app-downgrade as the only user-side rollback after a release. Both seams now accept null/false as DELIBERATELY DISABLED — resolved as real key absence on the wire, identical to unset today, but explicit enough to survive a defaults change. Garbage still throws, and the error names both the enabling and disabling forms. Env thresholds keep winning over a file-disabled seam (the operator's most explicit statement). 4 new tests pin the contract. Full suite 1141 pass / 0 fail / 2 skipped; tsc clean; eslint clean. Stacked on #3140 (the seams themselves); merge order #3140 → this. 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>
Phase 0.5 of the voice context-budget design
(
docs/design-voice-context-budget-and-upstream-observability.mdin thedesktop repo, merged): the last two experimental seams, off by default —
compressionConfigandmediaResolutionthroughVOICE_AGENT_CONFIG.Phase 2 cannot measure knobs that do not exist; this lands the knobs with
every default byte-identical to today.
What lands
voice-config.ts:VoiceConfiggains optionalcompressionConfig(
{}= enable with the server's defaults — trigger 80% of the modellimit, target half the trigger; the vendor's own tuning, no locally-guessed
constant — or explicit thresholds) and
mediaResolution(session-widemedia token cost; LOW = 64 tokens/frame; session-wide means it reaches
one-shot
send_vision_frametoo — realtime input has no per-sendoverride). New pure
resolveSessionTuning(config, env)validates at loadtime: thresholds must be positive safe integers, both-or-neither, with
0 < targetTokens < triggerTokens— a half-set or inverted pair throws(a silently-degrading misconfiguration must fail startup loudly, design
§2.1).
VOICE_CTX_TRIGGER_TOKENS/VOICE_CTX_TARGET_TOKENSoverride thefile and on their own enable compression.
voice-agent.ts: resolves the tuning at startup, logs one line(
session tuning: compression=… mediaResolution=…) so canary flips arevisible in the field log, and spreads both keys into the
VoiceSessionconfig only when set — real key absence, so bodhi B4/B5 (pin
0d506ead) keep the wire untouched when off.voice-agent.config.json.example: documents both knobs withoutsetting them; a test pins that the seeded template resolves to the
byte-identical default.
Evidence (real output, detached worktree at 5216928, PORT=9901)
Gate — no flags ⇒ same wire behaviour, live round trip:
Seam engages — env knobs alone enable compression, server accepts the
session config live, round trip intact:
Inverted pair fails startup loudly (exit 1, rule named):
(Server-side compression behaviour was measured live in Phase 0 —
tools/probe-compression-resumption.mjs: trigger fires in (3000, 3600],post-compression landing 693 vs target 1500, no-compression control
monotonic — recorded in design §2.1.)
Tests
9 new cases (
tests/voice-config-session-tuning.test.ts): thenothing-set ⇒ neither-key gate,
{}server defaults, explicit pass-through,inverted/equal/half-set/non-integer/non-object rejections, env precedence +
env-alone enablement + env validation,
mediaResolutionenum, and theexample template staying off.
Stack
Parents: #3131 (bodhi pin) → #3132 (Phase 1 observability) → this.
This branch contains both parents' commits; after each parent lands I'll
update and rerun the full checks per the stacked-PR rule.
🤖 Generated with Claude Code