Skip to content

voice: max_tool_steps allows N+1 consecutive tool rounds (off-by-one in num_steps check) #7431

Description

@BlueX888

Bug Description

In the pipeline reply loop, max_tool_steps is enforced one round late: with max_tool_steps=N, the model is allowed to issue tool calls in N+1 consecutive generations before tool_choice is forced to "none" — one more consecutive tool round than the option documents.

Root cause is the + 1 in the check at livekit-agents/livekit/agents/voice/agent_activity.py:3921 (_pipeline_reply_task_impl):

max_steps_reached = speech_handle.num_steps >= self._session.options.max_tool_steps + 1

num_steps starts at 1 (livekit-agents/livekit/agents/voice/speech_handle.py:60) and is incremented only after this check (agent_activity.py:3930), so at the end of generation k the check sees num_steps == k. tool_choice="none" is therefore forced (agent_activity.py:4010-4011) only when k >= N+1, letting generations 1..N+1 all issue tool calls. The logger.warning("maximum number of function calls steps reached, ...") at agent_activity.py:3924-3928 likewise fires only after the (N+1)-th round has already executed.

Expected Behavior

max_tool_steps is documented at livekit-agents/livekit/agents/voice/agent_session.py:470-471 as:

max_tool_steps (int): Maximum consecutive tool calls per LLM turn. Default ``3``.

With max_tool_steps=N, at most N consecutive tool rounds should execute before the next generation is forced to tool_choice="none". With the default N=3 that means 3 tool rounds per LLM turn, not 4. The "maximum number of function calls steps reached" warning should fire when the documented maximum is reached, not after an extra round beyond it has already run.

Reproduction Steps

Using the repo's own test fakes (tests/fake_session.py create_session/run_session with FakeLLM), at commit 5f12c87f922012ebbec3ba2e34f4ed3ed6077580 on main. Place the script below at the repo root and run it with the repo venv:

.venv/bin/python repro_maxsteps.py

repro_maxsteps.py (a model that keeps requesting the same tool after every result):

import asyncio

from livekit.agents import Agent, function_tool
from livekit.agents.llm import FunctionToolCall

from tests.fake_session import FakeActions, create_session, run_session

TOOL_CALL_IDS = iter(f"c{i}" for i in range(100))


class ToolAgent(Agent):
    def __init__(self) -> None:
        super().__init__(instructions="You are a helpful assistant.")
        self.executions: list[str] = []

    @function_tool
    async def do_the_thing(self) -> str:
        """Do the thing."""
        self.executions.append(f"result {len(self.executions) + 1}")
        return self.executions[-1]


async def run(max_tool_steps: int) -> int:
    agent = ToolAgent()
    actions = FakeActions()
    actions.add_user_speech(0.5, 2.5, "Do the thing.")
    actions.add_llm(
        content="Working on it.",
        tool_calls=[FunctionToolCall(name="do_the_thing", arguments="{}", call_id=next(TOOL_CALL_IDS))],
    )
    actions.add_tts(0.4)
    # a model that keeps asking for the tool after every result
    for i in range(1, 8):
        actions.add_llm(
            content=f"Round {i + 1}.",
            tool_calls=[
                FunctionToolCall(name="do_the_thing", arguments="{}", call_id=next(TOOL_CALL_IDS))
            ],
            input=f"result {i}",
        )
        actions.add_tts(0.4, input=f"Round {i + 1}.")
    actions.add_llm(content="All done.", input="result 8")
    actions.add_tts(0.4, input="All done.")

    session = create_session(actions, extra_kwargs={"max_tool_steps": max_tool_steps})
    await asyncio.wait_for(run_session(session, agent), timeout=90)
    return len(agent.executions)


async def main() -> None:
    for max_steps in (1, 2, 3):
        n = await run(max_steps)
        print(f"max_tool_steps={max_steps}: tool executed {n} times (expected at most {max_steps})")


asyncio.run(main())

Observed output (three independent runs at the commit above, including mine):

max_tool_steps=1: tool executed 2 times (expected at most 1)
max_tool_steps=2: tool executed 3 times (expected at most 2)
max_tool_steps=3: tool executed 4 times (expected at most 3)

An instrumented run (logging the tool_choice sent to each generation) at max_tool_steps=3 shows generation 4 — the one whose input is the 3rd tool result — still being sent with tool_choice='auto', so the 4th tool round executes; only after that does the warning fire and the following generation get tool_choice='none':

generation 4 (input='result 3'): tool_choice='auto'
tool executions: 4 (docstring cap: 3)

Similarly at max_tool_steps=1: gen#2 tool_choice='auto' → tool executed (2nd time) → warning fired → gen#3 tool_choice='none'.

Operating System

macOS 26.6.2 (Apple silicon)

Models Used

Pipeline AgentSession with the repo's own test fakes (tests/fake_session.py + FakeLLM/FakeSTT/FakeTTS); no provider credentials needed.

Package Versions

livekit-agents @ 5f12c87 (main, reports version 1.8.2)
Python 3.13.15
macOS 26.6.2

Proposed Solution

Count the completed tool rounds before allowing another one: the generation that follows a generation which has already produced max_tool_steps tool rounds must be the forced tool_choice="none" one. Since generation k sees num_steps == k at the check, dropping the + 1 does exactly that:

max_steps_reached = speech_handle.num_steps >= self._session.options.max_tool_steps

keeping the increment and the warning as-is. A regression test with a FakeLLM that always issues a tool call, asserting the tool executes exactly max_tool_steps times, would pin this down. Happy to open a PR with this change and the test.

Additional Context

Related: #5009 (no final response when max_tool_steps was reached) and its fix #4747, which introduced the forced tool_choice="none" path. The + 1 comparison predates that PR and was carried over, so the loop now stops one round later than the documented maximum. I found no existing issue reporting the off-by-one itself.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions