Skip to content

fix(voice): stop the directive turning an announcement into the answer - #5550

Open
YellowSnnowmann wants to merge 1 commit into
tinyhumansai:mainfrom
YellowSnnowmann:fix/voice-directive-preface
Open

fix(voice): stop the directive turning an announcement into the answer#5550
YellowSnnowmann wants to merge 1 commit into
tinyhumansai:mainfrom
YellowSnnowmann:fix/voice-directive-preface

Conversation

@YellowSnnowmann

@YellowSnnowmann YellowSnnowmann commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Removes the clause telling the voice model to speak a sentence before using a tool. That instruction made the announcement the entire answer.
  • Replaces it with the opposite instruction: call the tool, answer from the result, do not narrate the intention.
  • Clears three comments that still described the preface as something that happens.

Problem

Asking for a summary produced no summary. The final answer delivered to chat was:

"Let me pull up your inbox now — if it takes a moment I'll drop the summary in your chat."

That is the directive's own example, echoed almost verbatim:

"Sure, let me pull up your inbox — if it takes a moment I'll drop the summary in your chat."

The mechanism: a reply carrying only text and no tool call is the end of a turn. The directive said "first say one short spoken sentence… then proceed", so a model that complied emitted the sentence, ended its turn, and the harness delivered that sentence as the final answer. The caller got a promise and never got the inbox. The more obediently the model followed the instruction, the more reliably it failed.

Solution

The clause existed for a latency reason that no longer holds. It was there to get audio to the caller early, back when the relay's spoken filler was inaudible until the turn closed (tinyhumansai/backend#1275 — an ellipsis-terminated filler is buffered by the provider, which segments on sentence boundaries). The relay now speaks a terminated sentence ~700ms in, and it does so without depending on the model choosing to speak first.

So the directive now says the opposite, and says why, because a bare prohibition invites the model to hedge instead:

Do NOT announce what you are about to do: a reply that only says what you are going to do ends your turn, so the caller is left with a promise and never gets the answer. The caller already hears a short acknowledgement while you work, so say nothing until you have something to tell them.

Submission Checklist

  • Tests added or updated (happy path + at least one failure / edge case) per Testing Strategy — one added, pinning both halves of the contract: the directive must forbid announcing, and must not contain the phrases that caused this ("first say one short spoken sentence", "then proceed").
  • Diff coverage ≥ 80% — the directive is a constant with a test asserting its contract; the rest of the diff is comments.
  • Coverage matrix updated — N/A: behaviour-only change
  • All affected feature IDs from the matrix are listed under ## RelatedN/A: no matrix rows affected
  • No new external network dependencies introduced
  • Manual smoke checklist updated if this touches release-cut surfaces — N/A: no release-cut surface change
  • Linked issue closed via Closes #NNN — see ## Related

Impact

Desktop only; prompt text on the voice path. No API, schema, or storage change.

Expect less speech early in a tool-backed turn, not more: the model no longer says anything until it has something to say, and the acknowledgement comes from the relay instead. That is the intended trade — an early sentence from the model costs the answer itself.

Not fixed here: the ~8s before a first token, which is the desktop rebuilding its orchestrator per turn. This PR is about the turn producing an answer at all.

Confidence: the mechanism is inferred from the delivered text matching the directive's example almost word for word, plus the fact that a text-only reply terminates a turn. I have not captured a transcript proving the model emitted zero tool calls on that specific turn — that would need the desktop log from the failing call.

Testing

  • GGML_NATIVE=OFF cargo test --features voice --lib openhuman::voice::realtime_harness — 15 passed
  • cargo clippy --features voice --lib, cargo fmt --check clean

Related

Closes #5552
Relates to #5399. Pairs with tinyhumansai/backend#1275 (the relay filler that makes the preface unnecessary) and #5549.

Summary by CodeRabbit

  • Bug Fixes

    • Improved voice interactions by removing unnecessary model-generated tool-use prefaces.
    • Relay acknowledgements now appear independently while the assistant waits for tool results before speaking.
  • Tests

    • Added regression coverage to ensure tools are called immediately and responses follow the expected handoff flow.

Asking for a spoken sentence before tool use made that sentence the whole
reply. A message carrying only text and no tool call is the end of a turn, so a
model that dutifully said "let me pull up your inbox - I'll drop the summary in
your chat" ended there, and the harness delivered it as the final answer. The
caller got a promise and no summary. Seen live, with the model echoing the
directive's own example almost verbatim.

The clause existed to get audio out early, back when the relay's filler was
inaudible until the turn closed. That is the relay's job now, and it does it
without depending on the model choosing to speak - so the directive says the
opposite: call the tool, answer from the result, and do not narrate the
intention.

Also clears three comments that still described the preface as a thing that
happens.
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 96bb99e1-1983-462b-8497-aec4168cd543

📥 Commits

Reviewing files that changed from the base of the PR and between fc33103 and b425eaa.

📒 Files selected for processing (1)
  • src/openhuman/voice/realtime_harness.rs

📝 Walkthrough

Walkthrough

The voice relay now provides acknowledgements independently. VOICE_DIRECTIVE requires immediate tool calls and delays model speech until results are available. Documentation and a regression test cover the updated behavior.

Changes

Voice directive behavior

Layer / File(s) Summary
Directive and acknowledgement contract
src/openhuman/voice/realtime_harness.rs
The directive removes spoken prefaces before tool use. It requires immediate tool calls and speech after results. Documentation describes relay acknowledgements for delayed responses and failures.
Directive regression coverage
src/openhuman/voice/realtime_harness.rs
A regression test verifies the updated directive and rejects the former preface instructions.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to b425e

This localized voice-directive change prevents a tool-backed turn from ending with only an announcement and preserves answering from the tool result; no actionable merge-blocking risk remains after normal checks and review.

Suggested reviewers: al629176, codeghost21, giri-aayush

Poem

I’m a rabbit in the relay lane,
Tools go first, then words explain.
The relay says, “Your answer’s near,”
While models wait for results clear.
No preface hops before the call—
Then speech arrives to help us all.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main voice directive fix that prevents an announcement from ending the turn as the answer.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@CodeGhost21 CodeGhost21 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving. This is the highest-value change of the four and the mechanism is convincing.

The core argument — a reply carrying only text and no tool call is the end of a turn, so an instruction to announce work before doing it makes the announcement the final answer — is correct, and the failure mode it produces is the nasty kind: the more faithfully the model follows the instruction, the more reliably the user gets nothing. The delivered text matching the directive's own worked example almost verbatim is strong evidence, and I appreciate that the description says plainly that a transcript proving zero tool calls on that turn was not captured, rather than overstating it. Given this is prompt text with no API or schema surface, that level of confidence is proportionate to the risk — it is trivially revertible if the read turns out to be wrong.

Two things done right that are easy to get wrong:

The replacement explains itself rather than just prohibiting. "Do NOT announce what you are about to do: a reply that only says what you are going to do ends your turn…" gives the model the reason, which is what stops it substituting a hedge for the banned behaviour. A bare prohibition here would likely have produced a different flavour of the same stall.

The removed clause's original justification was retired, not ignored. The preface existed to get audio out early while the relay's filler was inaudible; that is no longer true now the relay speaks a terminated sentence at ~700ms, and crucially the relay does it without depending on the model choosing to speak. Removing the clause without that having changed would have reintroduced the silence. The doc comment above VOICE_ACK_DEADLINE_SECS was updated to say so, so the next reader will not "restore" it.

Non-blocking notes:

The test is deliberately brittle, which I think is right here. the_directive_forbids_announcing_work_instead_of_doing_it asserts on the literal substring "Do NOT announce" plus two banned phrases. Any reword of the directive breaks it. That is the correct trade for a constant whose exact wording is the behaviour — but it is worth a one-line comment saying so, or the next person will "fix" the test by loosening the assertion, which would let the regression back in.

One stale phrase left behind. The doc block above VOICE_HANDOFF_LINE still reads "Both delivery paths honour the promise" while this PR removes promise language elsewhere. Cosmetic, and #5549 rewrites that exact line anyway, so it resolves itself on merge.

On the red check: Rust Feature-Gate Smoke (gates off) is unrelated to this PR. The Rust tests pass; the job fails afterwards at the dependency-graph ratchet with dep-sim: FAIL — expected 281 names, got 279. main is red with the identical failure, as are unrelated open PRs — .github/workflows/ci-lite.yml:518 pins --expect-names 281 after the graph lost two names, and needs bumping on main separately.

Merge ordering: this and #5549 both rewrite the doc block above VOICE_HANDOFF_LINE and insert a test at the same anchor in mod tests, so they conflict — whichever lands second needs a rebase.

@YellowSnnowmann

Copy link
Copy Markdown
Collaborator Author

The red check here is Rust Feature-Gate Smoke (gates off), and it's not from this PR — it's a pre-existing break on main.

Every test in that job passes; the step exits 1 later, on the dependency-count guard:

python3 scripts/dep-sim.py --cut-nothing --expect-names 281
dep-sim: FAIL — expected 281 names, got 279
profile:  no-default + flows
baseline: 297 packages / 279 names / 2 native

.github/workflows/ci-lite.yml:518 hardcodes --expect-names 281, but the actual no-default + flows baseline is now 279 — off by 2.

Evidence it's on main, not this branch:

  • main HEAD fc331038 fails the same job (ci-lite run at 2026-08-14T07:35:48Z); the prior main commit c5d5eaab passed it.
  • The --expect-names 281 line was last set in b9ee4750a6, part of chore(deps): move git2 ownership into tinycortex #5544 (move-git-to-tinycortex), which reorganized submodule deps and dropped the name count to 279 without lowering the guard.
  • This PR only edits src/openhuman/voice/realtime_harness.rs and touches no Cargo.toml / dependency, so it cannot move the dep-name count.

Fix belongs on main: set --expect-names 281279 in .github/workflows/ci-lite.yml. Once that lands, rebasing this branch clears the check.

@YellowSnnowmann

Copy link
Copy Markdown
Collaborator Author

Fix raised: #5556 — lowers the dep-sim --expect-names calibration 281 → 279 to match the git-cohort shed from #5544 (and the kernel-floor.limits value, which already reads 279). Once #5556 merges to main, rebasing this branch clears the Feature-Gate Smoke 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 announces the tool call instead of running it

2 participants