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.
Bug Description
In the pipeline reply loop,
max_tool_stepsis enforced one round late: withmax_tool_steps=N, the model is allowed to issue tool calls in N+1 consecutive generations beforetool_choiceis forced to"none"— one more consecutive tool round than the option documents.Root cause is the
+ 1in the check atlivekit-agents/livekit/agents/voice/agent_activity.py:3921(_pipeline_reply_task_impl):num_stepsstarts 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 seesnum_steps == k.tool_choice="none"is therefore forced (agent_activity.py:4010-4011) only whenk >= N+1, letting generations 1..N+1 all issue tool calls. Thelogger.warning("maximum number of function calls steps reached, ...")atagent_activity.py:3924-3928likewise fires only after the (N+1)-th round has already executed.Expected Behavior
max_tool_stepsis documented atlivekit-agents/livekit/agents/voice/agent_session.py:470-471as:With
max_tool_steps=N, at most N consecutive tool rounds should execute before the next generation is forced totool_choice="none". With the defaultN=3that 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.pycreate_session/run_sessionwithFakeLLM), at commit5f12c87f922012ebbec3ba2e34f4ed3ed6077580onmain. Place the script below at the repo root and run it with the repo venv:repro_maxsteps.py(a model that keeps requesting the same tool after every result):Observed output (three independent runs at the commit above, including mine):
An instrumented run (logging the
tool_choicesent to each generation) atmax_tool_steps=3shows generation 4 — the one whose input is the 3rd tool result — still being sent withtool_choice='auto', so the 4th tool round executes; only after that does the warning fire and the following generation gettool_choice='none':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
AgentSessionwith 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_stepstool rounds must be the forcedtool_choice="none"one. Since generation k seesnum_steps == kat the check, dropping the+ 1does exactly that:keeping the increment and the warning as-is. A regression test with a
FakeLLMthat always issues a tool call, asserting the tool executes exactlymax_tool_stepstimes, 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_stepswas reached) and its fix #4747, which introduced the forcedtool_choice="none"path. The+ 1comparison 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.