Skip to content

feat(voice): background session real-time state + silent context-only insertion#6518

Open
toubatbrian wants to merge 6 commits into
feature/background-sessionsfrom
feature/background-state
Open

feat(voice): background session real-time state + silent context-only insertion#6518
toubatbrian wants to merge 6 commits into
feature/background-sessionsfrom
feature/background-state

Conversation

@toubatbrian

@toubatbrian toubatbrian commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Overview

Stacked on #6517. Adds two capabilities to background sessions, both aimed at the "Hey, what's going on right now?" case and at keeping the voice agent informed without making it speak:

1. Real-time state — ctx.set_state() + lk_background_state

@background(name="deep_research")
async def deep_research(ctx: BackgroundContext) -> None:
    ctx.set_state({"phase": "searching the web", "topic": question, "angles": [...]})
  • BackgroundContext.set_state(state) stores the session's current state. The value follows the function-tool return-value rules (string, number, bool, None, or JSON-serializable list/dict/tuple/set — validated eagerly with the same _is_valid_function_output check used for tool outputs, since the state is ultimately returned through a tool).
  • A second generated session-level tool, lk_background_state(background_session_id), returns the reported state verbatim (serialized by the normal tool-output path). Its description deterministically lists every configured session; unknown IDs return a ToolError; unset state returns a clear "not reported yet" message.
  • Setting state is fire-and-forget: it never inserts context, never schedules a reply, and costs nothing until the voice LLM explicitly queries it.
  • Both generated tool names are now reserved (_RESERVED_BACKGROUND_TOOL_NAMES); conflicting session tools fail at construction, conflicting Agent tools fail during activity tool setup / update_tools() without mutating state.

2. Silent context-only insertion — ctx.send(..., silent=True)

await ctx.send(f"Claude Code modified {path}", silent=True)
  • The rendered update (same update_template) is eagerly inserted into the current Agent's chat context and session.history, but is not buffered for a scheduled reply — the session never speaks it. The voice agent can draw on it when the user asks.
  • Implemented by extracting the scheduler's eager-insertion step into _ReplyScheduler._insert_items_locked(), shared by enqueue() and the new insert_only(); close/race semantics are identical.
  • BackgroundMessageReceived events carry a new silent: bool flag (default False, backward compatible).

Examples updated

  • deep-research: reports phase/topic/angles/sources_read/claims_under_review at every pipeline stage, and last_report when idle — so mid-research "what are you researching right now?" gets a precise answer.
  • Claude Code: tracks status/task/files_changed via set_state, and silently inserts a context note for each file Claude Code edits. Both voice agents' instructions point the LLM at lk_background_state for status questions.

Testing

  • 7 new tests in tests/test_background_session.py: state tool round-trip (dict + raw text) and default message, unknown-ID ToolError, set_state never inserting context/scheduling replies, return-shape validation + post-close rejection, silent send inserting context exactly once with no reply and a silent=True event, silent send post-close rejection, and reserved-name conflict for lk_background_state.
  • Full hermetic unit suite: 1280 passed, 4 skipped. Strict mypy (625 files), ruff lint/format clean.

Status: under active live testing 🧪

This PR is currently being exercised in live voice sessions against both example agents; treat it as functionally complete but still stabilizing.

Testing experience so far

  • Live sessions drove several fixes already landed on this branch:
    • send_to_user is now always mediated through the voice agent — the background session never speaks directly (7c5dc75).
    • Live-test findings in the deep-research and claude-code examples fixed (7dd1c20), including interrupt-flow behavior.
    • Silent background inserts now retarget correctly across agent handoffs (7c30760), and insert_only/enqueue were unified on a shared _insert path (0ed9041) after handoff testing exposed divergence.
  • Current focus: the Claude Code example's interruption vs. queuing policy — verifying that mid-turn user messages interrupt exactly once, that bursts of split utterances queue behind the first correction, and that messages inside the 2s INTERRUPT_GRACE window queue instead of killing their own turn. The working tree carries temporary instrumentation around the interrupt guard, send_to_user, and state reporting while this is validated (not part of the PR).

Notes on PR state

  • Core framework changes (background_session.py, reply_scheduler.py, tool-executor refactor) are covered by the hermetic suite (1280 passed / 4 skipped, strict mypy + ruff clean) and considered stable.
  • Remaining risk is concentrated in the examples' live ergonomics (interrupt timing, spoken-update phrasing), not in the state/silent-insert APIs.
  • Stacked on feat(voice): Background Sessions — Async Background integrations for AgentSession #6517 — review that first; this PR should merge after it.

Meta-note: this status section was written and pushed to the PR by the very thing it describes — a LiveKit voice-driven Claude Code background session. The user talked, Claude Code typed, send_to_user relayed, and nobody got interrupted (outside the 2-second grace window). Dogfooding level: the dog wrote its own release notes. 🐕🎙️

@toubatbrian
toubatbrian requested a review from a team as a code owner July 22, 2026 21:04

@devin-ai-integration devin-ai-integration Bot 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.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

chatgpt-codex-connector[bot]

This comment was marked as resolved.

…sertion

Adds two capabilities to background sessions:

- BackgroundContext.set_state(): report JSON-serializable/raw-text
  real-time state (validated with the function-tool return-value rules).
  A generated lk_background_state tool lets the voice LLM query it on
  demand — e.g. when the user asks what is currently happening. Setting
  state never inserts context and never schedules a reply.
- ctx.send(..., silent=True): silent context-only insertion — the
  rendered update enters the current Agent context and session history
  without scheduling a spoken reply. BackgroundMessageReceived events
  carry a new `silent` flag.

Both generated tool names are reserved; conflicts fail at construction
or activity tool setup. Examples updated: deep-research reports its
phase/topic/stats per stage, and the Claude Code example tracks files
changed via set_state and silent insertions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@toubatbrian
toubatbrian force-pushed the feature/background-sessions branch from 814730d to b3161c9 Compare July 22, 2026 21:12
@toubatbrian
toubatbrian force-pushed the feature/background-state branch from 3179c58 to 6767cde Compare July 22, 2026 21:13

@devin-ai-integration devin-ai-integration Bot 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.

Devin Review found 1 new potential issue.

Open in Devin Review

Comment on lines +515 to +517
if state is None:
return "The background session has not reported any state yet."
return state

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.

🟡 Background status of zero or empty values shows up blank to the voice agent

When a background session reports a status value that happens to be zero, false, or empty (returned by the status tool at livekit-agents/livekit/agents/voice/background_session.py:517), the value is turned into an empty string before it reaches the voice model, so the agent sees nothing instead of the reported status.
Impact: A background session that reports a legitimate but "falsy" status (0, False, empty list/dict) leaves the voice agent with no information, even though the feature explicitly allows numbers and booleans.

Falsy tool-output collapse in make_function_call_output

The generated tool returns the stored state verbatim (background_session.py:517). The tool-output path serializes it with output=str(output or "") at livekit-agents/livekit/agents/llm/utils.py:768. For any valid-but-falsy state — 0, 0.0, False, "", [], {}output or "" evaluates to "", so the model receives an empty string rather than the reported value. set_state explicitly permits these via _is_valid_function_output (background_session.py:309), and the state is None guard (background_session.py:515) only special-cases None, so these falsy values are passed through and then lost. This is pre-existing behavior in the shared tool-output path but is newly reachable/impacted by user-controlled state values.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

toubatbrian and others added 3 commits July 22, 2026 14:18
Moves the Claude Code example to examples/voice_agents/coding_agents/
and restructures it around explicit harness channels:

- SPEAK: an in-process MCP send_to_user tool lets Claude Code push
  verbatim spoken messages (questions, confirmations, summaries)
- CONTEXT-ONLY: narration text is inserted silently so the voice LLM
  sees the working narrative without voicing it
- STATE: live status/task/recent-activity/files-changed via set_state,
  queried through lk_background_state
- INTERRUPT: voice messages arriving mid-turn call client.interrupt()
  and become the next user turn

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A handoff during the awaited update_chat_ctx() left a silent insert on
the previous Agent only — unlike enqueue(), insert_only() had no later
delivery pass to copy it over. Reuse _retarget_until_stable() so the
context-only note follows the current Agent, mirroring deferred-reply
handoff semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both methods performed the same lock + eager context/history insertion +
_PendingReply construction and diverged only at the end. Collapse into
one private _insert(schedule_reply=...): the deferred path buffers and
starts the delivery task; the silent path retargets inline. No behavior
change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@devin-ai-integration devin-ai-integration Bot 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.

Devin Review found 1 new potential issue.

Open in Devin Review

Comment on lines +306 to +314
def set_state(self, state: Any) -> None:
if self._closed:
raise RuntimeError(f"background session {self.definition.id!r} is closed")
if not _is_valid_function_output(state):
raise ValueError(
"background state must be a valid function-tool return value: a string, "
"number, bool, None, or a JSON-serializable list/dict/tuple/set of those"
)
self._state = state

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.

🟡 A background session that reports an empty status is shown as having reported nothing

When a background session reports a state of "none" (set_state(None) at livekit-agents/livekit/agents/voice/background_session.py:306-314), it is stored as the same value used to mean "nothing reported yet", so the status tool tells the voice agent no status has been reported even though one was.
Impact: A background session that deliberately reports an empty/none status is indistinguishable from one that never reported anything, so the voice agent gives the user a misleading "not reported yet" answer.

None sentinel collides with a valid reported value

The runtime initializes self._state = None and set_state accepts None because _is_valid_function_output(None) is True (the validation type set includes type(None)). The generated tool at livekit-agents/livekit/agents/voice/background_session.py:515-517 returns the default "The background session has not reported any state yet." whenever state is None, so an explicit set_state(None) is reported as unset. A distinct sentinel (e.g. an _unset object separate from the stored value) would disambiguate the two cases.

Prompt for agents
In _BackgroundRuntime (background_session.py), self._state is initialized to None and set_state(None) is accepted as a valid value (since _is_valid_function_output(None) is True). The generated lk_background_state tool treats state is None as "not reported yet". This makes an explicitly reported None state indistinguishable from an unset state. Consider introducing a distinct unset sentinel object (e.g. a module-level _UNSET = object()) used for the initial value and checked in _create_background_state_tool instead of `state is None`, so that a background that deliberately reports None is surfaced verbatim rather than as the default message.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

toubatbrian and others added 2 commits July 22, 2026 14:44
…-code

Findings from a live cue-driven integrity test with debug-mode probes:

deep-research:
- the "done, listening" state was dead — the loop-top set_state
  overwrote it in the same millisecond, so lk_background_state could
  never report the last report path. Idle state is now a variable the
  round outcome updates (done / no-sources / inconclusive) and persists
  while waiting for the next question.
- voice agent instructions now require forwarding the user's request
  with constraints intact ("just start, no clarifying questions" was
  being trimmed, costing an extra clarify round-trip).

claude-code:
- send_to_user now uses session.say() so the SPEAK channel is truly
  verbatim (ctx.send() schedules a generated reply that the voice LLM
  paraphrased in every live sample); docstrings updated to match.
- interrupt guard: a burst of split messages interrupts once — later
  pieces queue instead of killing the correction turn they belong to
  (inbox-empty check + 2s grace after turn start).
- voice agent instructions forbid claiming completion on dispatch (the
  LLM previously announced files created/deleted seconds before Claude
  Code did the work).
- files_changed now drops deleted files at report time; REPO_ROOT
  actually points at the repo root.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Revert the session.say() shortcut — background sessions are by design
unable to speak to the end user directly; everything flows through the
voice overlay. send_to_user goes back to ctx.send(), and the docstring,
comment, and Claude Code system prompt now describe the relay honestly
(the voice agent delivers the message and may lightly rephrase it)
instead of claiming verbatim delivery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant