Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
972a8bd
voice: retry a run that ends without its output_type, raise RunOutput…
theomonnom Jun 12, 2026
51fe116
voice: overridable output retry instructions
theomonnom Jun 12, 2026
ef284b8
voice: fold retry config into output_retries
theomonnom Jun 12, 2026
b71f13f
voice: the task owns its output retry instructions
theomonnom Jun 12, 2026
f006d67
voice: output retry prompt configured on the session
theomonnom Jun 12, 2026
b7389bf
voice: group structured-output behavior into output_options
theomonnom Jun 12, 2026
9238266
voice: rename RunOutputError to UnexpectedModelBehavior
theomonnom Jun 12, 2026
50561cc
voice: output_options moves to run()
theomonnom Jun 12, 2026
e712c0d
voice: retry only the no-output case, fold retry tests
theomonnom Jun 12, 2026
5038667
fix formatting
theomonnom Jun 12, 2026
3a9cf15
voice: retry via per-turn system instructions, broaden retry guard
theomonnom Jun 12, 2026
4538b33
voice: rename retries to max_retries
theomonnom Jun 12, 2026
d9d91c9
voice: default output max_retries to 2
theomonnom Jun 12, 2026
0d4fd62
drop the retry test file
theomonnom Jun 12, 2026
86acdca
remove unrelated local changes
theomonnom Jun 12, 2026
a6b7fa5
voice: UnexpectedModelBehavior extends RuntimeError
theomonnom Jun 12, 2026
85b21fd
voice: rename RunOutputOptions (class only)
theomonnom Jun 12, 2026
6fce0bf
sort imports
theomonnom Jun 12, 2026
d162cdc
voice: run() output_options accepts None to disable retries
theomonnom Jul 2, 2026
758de45
voice: resolve output_options defaults in one place (RunResult)
theomonnom Jul 7, 2026
0a1ab63
Merge remote-tracking branch 'origin/main' into theo/output-retries
theomonnom Jul 7, 2026
00c1c7d
voice: soften default output retry prompt
theomonnom Jul 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions livekit-agents/livekit/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
APIStatusError,
APITimeoutError,
AssignmentTimeoutError,
UnexpectedModelBehavior,
create_api_error_from_http,
)
from .job import (
Expand Down Expand Up @@ -93,6 +94,7 @@
ModelSettings,
RecordingOptions,
RunContext,
RunOutputOptions,
SessionUsageUpdatedEvent,
SpeechCreatedEvent,
ToolCallEnded,
Expand Down Expand Up @@ -215,12 +217,14 @@ def __getattr__(name: str) -> typing.Any:
"AgentSession",
"AudioRecognition",
"RecordingOptions",
"RunOutputOptions",
"text_transforms",
"AgentEvent",
"ModelSettings",
"Agent",
"AgentTask",
"AssignmentTimeoutError",
"UnexpectedModelBehavior",
"APIConnectionError",
"APIError",
"APIStatusError",
Expand Down
6 changes: 6 additions & 0 deletions livekit-agents/livekit/agents/_exceptions.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
from __future__ import annotations


class UnexpectedModelBehavior(RuntimeError):
"""Raised when the model behaves in a way the run cannot recover from,
e.g. a run with an output_type ends without the expected output after
exhausting its retries."""


class AssignmentTimeoutError(Exception):
"""Raised when accepting a job but not receiving an assignment within the specified timeout.
The server may have chosen another worker to handle this job."""
Expand Down
2 changes: 2 additions & 0 deletions livekit-agents/livekit/agents/voice/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,14 @@
_ParticipantStreamTranscriptionOutput,
_ParticipantTranscriptionOutput,
)
from .run_result import RunOutputOptions
from .speech_handle import SpeechHandle
from .transcription import TranscriptSynchronizer, text_transforms

__all__ = [
"AgentSession",
"RecordingOptions",
"RunOutputOptions",
"VoiceActivityVideoSampler",
"Agent",
"ModelSettings",
Expand Down
10 changes: 8 additions & 2 deletions livekit-agents/livekit/agents/voice/agent_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
from .keyterm_detection import KeytermDetector, KeytermsOptions, _resolve_keyterms_options
from .recorder_io import RecorderIO
from .remote_session import RoomSessionTransport, SessionHost, SessionTransport
from .run_result import RunResult
from .run_result import RunOutputOptions, RunResult
from .speech_handle import InputDetails, SpeechHandle
from .tool_executor import ToolHandlingOptions, _resolve_async_tool_options
from .turn import (
Expand Down Expand Up @@ -651,11 +651,17 @@ def run(
user_input: str,
input_modality: Literal["text", "audio"] = "text",
output_type: type[Run_T] | None = None,
output_options: NotGivenOr[RunOutputOptions | None] = NOT_GIVEN,
) -> RunResult[Run_T]:
if self._global_run_state is not None and not self._global_run_state.done():
raise RuntimeError("nested runs are not supported")

run_state = RunResult(user_input=user_input, output_type=output_type)
run_state = RunResult(
user_input=user_input,
output_type=output_type,
output_options=output_options,
session=self,
)
self._global_run_state = run_state
self.generate_reply(user_input=user_input, input_modality=input_modality)
return run_state
Expand Down
129 changes: 121 additions & 8 deletions livekit-agents/livekit/agents/voice/run_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import functools
import json
import os
import weakref
from collections.abc import Callable, Generator
from contextlib import contextmanager
from dataclasses import dataclass
Expand All @@ -19,6 +20,7 @@
)

from opentelemetry import trace
from typing_extensions import TypedDict

from .. import llm
from ..llm import function_tool, utils as llm_utils
Expand All @@ -29,10 +31,38 @@

if TYPE_CHECKING:
from .agent import Agent
from .agent_session import AgentSession


lk_evals_verbose = int(os.getenv("LIVEKIT_EVALS_VERBOSE", 0))

_OUTPUT_RETRY_PROMPT = (
"You have not provided the final output yet. Call the appropriate function "
"to do so; a plain text response alone is not enough."
)


class RunOutputOptions(TypedDict, total=False):
"""Structured-output behavior for :meth:`AgentSession.run`.

Can be passed as a plain dict::

sess.run(
user_input=...,
output_type=MyOutput,
output_options={"max_retries": 2, "retry_instructions": "Call submit_result."},
)

Pass ``output_options=None`` to disable the retry behavior.
"""

max_retries: int
"""Re-prompts when a run ends without its ``output_type``, before raising
UnexpectedModelBehavior. Defaults to ``2``."""
retry_instructions: str
"""Override the built-in retry prompt."""


Run_T = TypeVar("Run_T")


Expand Down Expand Up @@ -66,12 +96,29 @@ class AgentHandoffEvent:


class RunResult(Generic[Run_T]):
def __init__(self, *, user_input: str | None = None, output_type: type[Run_T] | None) -> None:
def __init__(
self,
*,
user_input: str | None = None,
output_type: type[Run_T] | None,
output_options: NotGivenOr[RunOutputOptions | None] = NOT_GIVEN,
session: AgentSession | None = None,
) -> None:
self._handles: set[SpeechHandle | asyncio.Task] = set()

if not is_given(output_options):
output_options = RunOutputOptions()
elif output_options is None:
output_options = RunOutputOptions(max_retries=0)

self._done_fut = asyncio.Future[None]()
self._user_input = user_input
self._output_type = output_type
self._output_retries = output_options.get("max_retries", 2)
self._output_retry_instructions = output_options.get(
"retry_instructions", _OUTPUT_RETRY_PROMPT
)
self._session = session
self._recorded_items: list[RunEvent] = []
self._final_output: Run_T | None = None

Expand Down Expand Up @@ -211,8 +258,14 @@ def _mark_done(self) -> None:
final_output = self.__last_speech_handle._maybe_run_final_output
if not isinstance(final_output, BaseException):
if self._output_type and not isinstance(final_output, self._output_type):
# only the no-output case is retryable: a completed task is
# one-shot, so a wrong type cannot change on a retry
if final_output is None and self._maybe_retry_output():
return
from .._exceptions import UnexpectedModelBehavior

self._done_fut.set_exception(
RuntimeError(
UnexpectedModelBehavior(
f"Expected output of type {self._output_type.__name__}, "
f"got {type(final_output).__name__}"
)
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Expand All @@ -223,6 +276,30 @@ def _mark_done(self) -> None:
else:
self._done_fut.set_exception(final_output)

def _maybe_retry_output(self) -> bool:
"""Re-prompt the model when the run ended without the expected output
type. Returns True when a retry was scheduled."""
if self._output_retries <= 0 or self._session is None:
return False
self._output_retries -= 1

from ..log import logger

try:
# generate_reply attaches the new handle to this run state (it is
# still the session's active run); instructions inject as a
# per-turn system message instead of a fake user message.
self._session.generate_reply(instructions=self._output_retry_instructions)
except Exception:
# an unhandled exception here would leave the run future
# unresolved; fall through to UnexpectedModelBehavior instead
return False
Comment on lines +293 to +296

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.

πŸ” Exception swallowing in _maybe_retry_output could hide root causes

The broad except Exception at run_result.py:293 catches all errors from generate_reply and returns False, causing the caller to fall through to UnexpectedModelBehavior. While the comment explains the rationale (preventing an unresolved future), this means errors like RuntimeError('AgentSession isn't running') or RuntimeError('AgentSession is closing') from agent_session.py:1297-1310 are silently swallowed. The user sees UnexpectedModelBehavior with no indication that a retry was attempted and failed. Consider logging the caught exception at warning/debug level before returning False.

Open in Devin Review

Was this helpful? React with πŸ‘ or πŸ‘Ž to provide feedback.

logger.warning(
"run ended without the expected output type, retrying",
extra={"output_type": self._output_type.__name__ if self._output_type else None},
)
return True

def _find_insertion_index(self, *, created_at: float) -> int:
"""
Returns the index to insert an item by creation time.
Expand Down Expand Up @@ -1034,17 +1111,53 @@ def event(self) -> AgentHandoffEvent:
if TYPE_CHECKING:
MockTools = dict[type[Agent], dict[str, Callable]]
_MockToolsContextVar = contextvars.ContextVar["MockTools"]("agents_mock_tools")
_SessionMockTools: weakref.WeakKeyDictionary[AgentSession, MockTools] = weakref.WeakKeyDictionary()


@contextmanager
def mock_tools(agent: type[Agent], mocks: dict[str, Callable]) -> Generator[None, None, None]:
"""
Temporarily assign a set of mock tool callables to a specific Agent type within the current context.
@overload
def mock_tools(
agent: type[Agent], mocks: dict[str, Callable]
) -> contextlib.AbstractContextManager[None]: ...


@overload
def mock_tools(
agent: type[Agent], mocks: dict[str, Callable], *, session: AgentSession
) -> None: ...


def mock_tools(
agent: type[Agent], mocks: dict[str, Callable], *, session: AgentSession | None = None
) -> contextlib.AbstractContextManager[None] | None:
"""Assign a set of mock tool callables to a specific Agent type.

Mocks intercept tool *execution* only; the LLM keeps seeing the real tool
schemas. A mock may declare any subset of the real tool's parameters
(extra arguments are dropped when it is invoked).

Without ``session``, returns a context manager scoping the mocks to the
current context (intended for tests):

Usage:
with mock_tools(MyAgentClass, {"tool_name": mock_fn}):
# inside this block, MyAgentClass will see the given mocks
""" # noqa: E501

With ``session``, ``mocks`` becomes the mock set for the Agent type on that
session, effective immediately and for the session's lifetime:

mock_tools(MyAgentClass, {"tool_name": mock_fn}, session=session)

Call it again to replace the mock set, or pass ``{}`` to remove all mocks
for the Agent type. When both forms are active, the context-manager mocks
take precedence over the session ones.
"""
if session is not None:
_SessionMockTools.setdefault(session, {})[agent] = dict(mocks)
return None
Comment on lines +1153 to +1155

@devin-ai-integration devin-ai-integration Bot Jul 7, 2026

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.

πŸ”΄ Session-scoped mock tools are silently ignored during tool execution

Mock tools registered via the session-scoped path are stored (_SessionMockTools.setdefault(session, {})[agent] = dict(mocks) at livekit-agents/livekit/agents/voice/run_result.py:1154) but never consulted when tools are actually executed, so they have no effect.

Impact: Users calling mock_tools(MyAgent, {...}, session=session) will see no error but their mocks will never intercept tool calls.

Tool execution only reads from the context-variable store, not the session store

The tool execution code in livekit-agents/livekit/agents/voice/generation.py:856 only reads from _MockToolsContextVar:

mock_tools: dict[str, Callable] = _MockToolsContextVar.get({}).get(
    type(session.current_agent), {}
)

It never reads from _SessionMockTools. The _SessionMockTools dictionary is only written to (at run_result.py:1154) and declared (at run_result.py:1114), but has zero read sites anywhere in the codebase. The docstring at run_result.py:1150-1151 even describes the intended precedence ("the context-manager mocks take precedence over the session ones"), but this fallback lookup was never implemented.

To fix this, generation.py:856 needs to also check _SessionMockTools.get(session, {}).get(type(session.current_agent), {}) as a fallback when _MockToolsContextVar has no mocks for the agent type.

Open in Devin Review

Was this helpful? React with πŸ‘ or πŸ‘Ž to provide feedback.

return _mock_tools_ctx(agent, mocks)


@contextmanager
def _mock_tools_ctx(agent: type[Agent], mocks: dict[str, Callable]) -> Generator[None, None, None]:
current = _MockToolsContextVar.get({})
updated = {**current, agent: mocks} # create a new dict
token = _MockToolsContextVar.set(updated)
Expand Down
Loading