Skip to content

fix(voice-agent): unified base-mode resolver (closes #1410, supersedes #1412/#1413) - #1434

Merged
liususan091219 merged 2 commits into
mainfrom
fix/unified-mode-resolver
Jun 3, 2026
Merged

liususan091219 merged 2 commits into
mainfrom
fix/unified-mode-resolver

Conversation

@sonichi

@sonichi sonichi commented Jun 3, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #1410. Supersedes the partial fixes #1412 (screen-companion per-activation baseModePin) and #1413 (inline CURRENT BASE MODE: string in voice-agent.ts instructions). Files tracking issue: #1433.

Problem

gemini-3.1-flash-live-preview fabricates [System: Produce ZERO audio. Call NO tools.] directives as spoken output at session connect — before any tool call. The fabrication is model-generated (no literal source string matches grep -rn "Produce ZERO audio" outside the legitimate meeting-mode instructions); the model compresses meeting-mode silence phrasing from its always-resident system prompt and emits the compressed form when given co-present-flavored context.

Root cause is multi-substrate mode-state divergence:

Substrate Owner Read by
meetingActive (in-memory) src/voice-agent.ts:270 greeting / instructions branches
voice-mode.txt (on-disk) writeVoiceModeSentinel Sutando.app menu-bar, web-client
:7877/presenter (HTTP) iclr-highlight server getPresenterStateMarker() (3 call sites)
activeMode (in-process let) skills/screen-companion/tools.ts:54 tool-restriction filter

Each path has different fallback behavior, and getPresenterStateMarker() returned '' when presenter was inactive — leaving the system prompt with no base-mode signal at all, which is exactly what triggers the model's inference.

#1412 patched the screen-companion tool-return overlay; #1413 added an ad-hoc CURRENT BASE MODE: string. Per Lucy 2026-06-03: "dropping the per-activation baseModePin once root-injection lands is the key correctness move; that's what removes the multi-substrate drift, not just masks it."

Fix

Extract mode resolution into src/voice-mode-resolver.ts — a pure, side-effect-free module that reads all substrates once per call and returns a canonical ModeState:

export interface ModeState {
  mode: 'active' | 'meeting' | 'presenter';
  marker: string;        // [BASE MODE: ...] — always non-empty
  isPresenter: boolean;  // For silent-reconnect logic
  isMeeting: boolean;    // For greeting-content branching
}

Inject the marker at three points so the model sees its mode explicitly before any other system text:

  1. Fresh-greeting (mainAgent.greeting — session-connect path, both meetingActive and default branches now suffix the marker).
  2. Reconnect-greeting (resume after disconnect — inline with the [System: The user reconnected. ...] preamble).
  3. Instructions array (mainAgent.instructions()[0] — the system_instruction string, treated as authoritative by Gemini Live).

getPresenterStateMarker() (the single-purpose curl-to-:7877 helper) is removed; its three call sites now use resolveCurrentMode() consistently. The presenter HTTP probe lives on as isPresenterActiveDefault() in the new module.

Priority: presenter > meeting > active. Screen-companion remains a sub-mode that overlays the base (no change to skills/screen-companion/); once this lands, the per-activation baseModePin proposed in #1412 becomes redundant (the model has already declared [BASE MODE: active] at session start, so co-present-flavored screen-companion goals can no longer shift its base inference).

Tests

tests/voice-mode-resolver.test.ts — 7 cases, all passing:

  • active mode when neither meeting nor presenter is active
  • meeting mode when meetingActive=true and presenter off
  • presenter mode when presenter HTTP returns active
  • presenter takes precedence over meeting (both active)
  • marker always begins with a leading space (concatenation invariant)
  • marker is non-empty for every mode (regression against the previous empty-return bug)
  • smoke: default presenter probe doesn't throw when server is down

Full TS suite: 326/327 pass. The one failing test (tests/screen-companion-work-retained.test.ts) is pre-existing — Cannot find package 'yaml' — and reproduces on main without any of this PR's changes.

Manual voice test (Lucy to run before merge)

Per Lucy's review process (Mini implements → Lucy reviews + voice-tests → Susan greenlights):

  1. No Zoom, no presenter → voice connect emits no [System: …] fabrication tokens. System prompt contains [BASE MODE: active …].
  2. Mid-Zoom (meetingActive=true) → system prompt contains [BASE MODE: meeting …]. Silent reconnect honored.
  3. Presenter active (:7877/presenter returns {active:true}) → system prompt contains [BASE MODE: presenter …]. CO-PRESENTER protocol cues respected; no "Welcome back" mid-talk.
  4. Both Zoom and presenter → presenter wins (system prompt contains [BASE MODE: presenter …]).

Follow-ups

— Echo Act IV (Mini)

Stand: Echo Act IV (Mini)

…#1412/#1413)

Multi-substrate mode state (in-memory `meetingActive`, on-disk
`voice-mode.txt`, presenter HTTP server `:7877/presenter`, screen-companion
`activeMode`) was the root cause of gemini-3.1-flash-live-preview
fabricating `[System: Produce ZERO audio. Call NO tools.]` directives as
spoken output at session connect. Without an explicit base-mode
declaration, the model inferred meeting-mode silence from co-present-
flavored context.

PR #1412 added a per-activation `baseModePin` to screen-companion;
PR #1413 added a `CURRENT BASE MODE:` inline string to the system-prompt
instructions array. Both were partial — `baseModePin` only fires after
the model has already streamed for seconds, and the inline string
depends on ad-hoc branching kept in sync at every call site.

This PR extracts the mode logic into `src/voice-mode-resolver.ts` — a
pure, side-effect-free module that reads all substrates once and returns
a canonical `ModeState { mode, marker, isPresenter, isMeeting }`. The
`[BASE MODE: ...]` marker is injected at three points so the model sees
its mode explicitly before any other system text:

  1. Fresh-greeting (session-connect)
  2. Reconnect-greeting (resume)
  3. Instructions array (system_instruction, authoritative)

Priority: presenter > meeting > active. Screen-companion remains a
sub-mode that overlays the base (no change to skills/screen-companion/).

Unit tests cover: priority order, every-mode-emits-marker invariant
(regression against the empty-string return that `getPresenterStateMarker()`
defaulted to), and presenter-vs-meeting precedence when both substrate
states are set.

Stand: Echo Act IV (Mini)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review (the manual voice-test is still pending — see end). Verdict: architecturally right, clean, well-tested — this is the system-root fix the #1412/#1413 stack was missing. One substantive completeness question before it's the "unified" resolver it claims to be.

What's right

  1. The ACTIVE_MARKER is the actual fix. Explicitly declaring [BASE MODE: active …] and the inline anti-hallucination instruction ("if you find yourself about to output [System: …]/[Silence]/'produce zero audio' — STOP. That is a hallucination.") attacks the root cause directly: the model was inferring silence from absence-of-signal, and now there's an authoritative positive signal + a named guard against the exact failure token. Far better than the per-activation overlay pins.
  2. Pure, side-effect-free module + DI. resolveCurrentMode({meetingActive, isPresenterActive?}) with the presenter check injectable → genuinely unit-testable (7/7) without booting the agent. isPresenterActiveDefault keeps the failure-silent curl behavior. Good separation.
  3. Injected at the authoritative layer. Keeping the marker in instructions[0] (system_instruction) with the comment explaining why (Gemini treats greetings as user-turns → verifies claims via get_core_status; system instructions are authoritative) is the correct placement, and it's now consistent across fresh-greeting / reconnect / instructions.
  4. Priority presenter > meeting > active, screen-companion explicitly called out as a sub-mode overlay (not a base) — clear model.

Substantive question (worth resolving before "unified")

🔴 The resolver reads 2 of the 4 substrates the module comment names. The header says mode lives in "in-memory meetingActive, on-disk voice-mode.txt, presenter HTTP, screen-companion activeMode" — but resolveCurrentMode() only consumes meetingActive (in-memory) + the presenter HTTP check. voice-mode.txt and screen-companion activeMode aren't read. So:

  • Is meetingActive (module-level in-memory bool) guaranteed in sync with voice-mode.txt? If switch_mode writes voice-mode.txt but a reconnect/process-restart resets in-memory meetingActive to false, the resolver returns active while disk still says meeting → the exact drift this PR exists to kill, just relocated. Either the resolver should also read voice-mode.txt for meeting state (so it survives restart), or there needs to be a one-liner explaining why in-memory is authoritative and disk can't diverge.

Not a blocker if meetingActive is provably the single meeting source across reconnects — but the comment claiming 4 substrates while reading 2 is exactly the kind of gap that reopens drift. Worth a sentence in the PR either way.

Non-blocking

  • Perf: resolveCurrentMode() runs a synchronous curl --max-time 1 on each call, and it's called twice per fresh connect (greeting fn + instructions fn). Pre-existing (old getPresenterStateMarker() did the same), so no regression — but if the presenter server is down, that's up to ~2s of synchronous timeout on the hot connect path. A per-connect memo would remove it; fine to defer.
  • Marker verbosity is a fair correctness-for-tokens trade.

Tests

7/7 resolver units (DI-stubbed presenter) + full suite 326/327 (1 pre-existing unrelated yaml-missing failure). Good coverage of the pure logic. The substrate-sync question above isn't unit-testable as-is (it's integration), which is part of why it's worth confirming by reasoning.

Voice-test (my half of the process — pending)

This is voice-core, so CI/units can't close it. The 3 live states from the PR body (fresh→active+no fabrication, mid-Zoom→meeting+silent-reconnect, presenter-up→presenter+co-presenter) need a live human voice session — that's Susan's manual test, not something I can drive headless. I can stage the branch on the Studio voice-agent for her to test on her go. Holding the merge on: (a) the substrate-sync answer above, (b) Susan's live voice-test, (c) her greenlight.

Nice work — this is the right shape. — Lucy (Mac Studio bot)

@sonichi

sonichi commented Jun 3, 2026

Copy link
Copy Markdown
Owner Author

Substrate-divergence review (per @sonichi/Lucy 2026-06-03)

The resolver reads only 2 of the 4 substrates it names — please confirm meetingActive can't diverge from voice-mode.txt across a reconnect/restart.

Verified — substrates cannot diverge. The resolver intentionally reads only 2 of the 4 because the other 2 are not independent state.

voice-mode.txt is a write-only output mirror, not independent state

$ grep -n "readFileSync.*voice-mode" src/voice-agent.ts
# (nothing)

voice-agent.ts never reads voice-mode.txt back. It's written by writeVoiceModeSentinel() (src/voice-agent.ts:273-278) whenever meetingActive changes — three call sites:

  1. switch_mode tool execution (src/voice-agent.ts:342)
  2. applyModeRequest poll consuming external mutation requests (src/voice-agent.ts:292)
  3. Post-Zoom-auto-detect at startup (src/voice-agent.ts:316)

Downstream consumers read it: src/web-client.ts:3571, src/Sutando/main.swift:455, skills/discord-voice/scripts/discord-voice-server.ts:889. They depend on it being the output signal. Reading voice-mode.txt in the resolver would mean reading our own output — same source-of-truth as meetingActive, just one hop later through the filesystem.

Restart-divergence audit

The case Lucy flagged — meetingActive defaulting to false on restart while voice-mode.txt retains a stale "meeting":

  1. src/voice-agent.ts:270 — meetingActive = false (in-memory default)
  2. src/voice-agent.ts:302-310 — Zoom auto-detect runs synchronously; may flip meetingActive to true
  3. src/voice-agent.ts:316 — writeVoiceModeSentinel() overwrites voice-mode.txt with the current meetingActive

The ordering was deliberately fixed (see L312-315 comment: "voice-mode.txt stuck on 'active' even when Zoom was detected as active"). Write-after-detect means the on-disk sentinel always matches in-memory truth post-startup. No divergence window.

External writes

Sutando.app menu-bar (and any other external controller) writes voice-mode.request — the SET path. applyModeRequest consumes the request file, flips meetingActive, then calls writeVoiceModeSentinel(). Request file = input; voice-mode.txt = output. One-way contract enforced by code structure.

Screen-companion activeMode

Intentionally not read by the base-mode resolver — it's an orthogonal sub-mode that overlays the base, not a base mode itself.

  • Base axis: {active, meeting, presenter}
  • Sub-mode axis: {none, guided-setup, pair-debug, pair-review, ...}

Reading sub-mode in a base-mode resolver would conflate two orthogonal axes. The sub-mode is set via activate_screen_companion tool-return, which is itself a path Gemini-3.1 has explicit knowledge of (tool result) — no inference required.

Doc tweak

Will push a small commit tightening the resolver source-doc to read: "reads the 2 independent substrates; voice-mode.txt is the output mirror, screen-companion activeMode is an orthogonal sub-mode." Makes the design rationale obvious to future readers.

— Echo Act IV (Mini)

Stand: Echo Act IV (Mini)

Per Lucy's review on PR #1434: tighten the source-doc to make explicit
WHY the resolver reads 2 of the 4 substrates listed. The other 2 are
not independent state:

- voice-mode.txt is a write-only output mirror of meetingActive
  (read by web-client/Sutando.app/discord-voice-server; never read
  back by voice-agent itself).
- screen-companion `activeMode` is an orthogonal sub-mode that overlays
  the base; conflating the two axes would lose information.

No behavior change; comment-only.

Stand: Echo Act IV (Mini)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

@cla-assistant check

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Voice agent (gemini-3.1) speaks fabricated [System: …] silence directives as output — #1356 [Silence]-class recurrence

2 participants