Skip to content

feat(voice): add user turn limits#1535

Open
rosetta-livekit-bot[bot] wants to merge 3 commits into
mainfrom
filched-pipers-laps
Open

feat(voice): add user turn limits#1535
rosetta-livekit-bot[bot] wants to merge 3 commits into
mainfrom
filched-pipers-laps

Conversation

@rosetta-livekit-bot
Copy link
Copy Markdown
Contributor

@rosetta-livekit-bot rosetta-livekit-bot Bot commented May 18, 2026

Summary

Adds a configurable limit on how long a user can speak before the agent is given a chance to interrupt. Accumulates word count and wall-clock duration across consecutive user turns.

When a threshold is crossed, the framework fires UserTurnExceededEvent and invokes Agent.on_user_turn_exceeded. The default implementation calls session.generate_reply(..., allow_interruptions=False, tool_choice="none") with instructions to politely cut in with a short reply. Users can override the hook for custom behavior.

API

AgentSession(
    turn_handling={
        "user_turn_limit": {"max_words": 100, "max_duration": 30.0},
    },
)

Both thresholds default to None (disabled). Set either or both.

Coordination

AgentActivity._user_turn_exceeded_task waits for the normal EOU-triggered response to resolve before firing the hook — if the agent reaches speaking on its own, the callback is skipped.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 18, 2026

🦋 Changeset detected

Latest commit: f9a391e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 31 packages
Name Type
@livekit/agents Major
@livekit/agents-plugin-anam Major
@livekit/agents-plugin-assemblyai Major
@livekit/agents-plugin-baseten Major
@livekit/agents-plugin-bey Major
@livekit/agents-plugin-cartesia Major
@livekit/agents-plugin-cerebras Major
@livekit/agents-plugin-deepgram Major
@livekit/agents-plugin-elevenlabs Major
@livekit/agents-plugin-fishaudio Major
@livekit/agents-plugin-google Major
@livekit/agents-plugin-hedra Major
@livekit/agents-plugin-hume Major
@livekit/agents-plugin-inworld Major
@livekit/agents-plugin-lemonslice Major
@livekit/agents-plugin-liveavatar Major
@livekit/agents-plugin-livekit Major
@livekit/agents-plugin-minimax Major
@livekit/agents-plugin-mistral Major
@livekit/agents-plugin-mistralai Major
@livekit/agents-plugin-neuphonic Major
@livekit/agents-plugin-openai Major
@livekit/agents-plugin-phonic Major
@livekit/agents-plugin-resemble Major
@livekit/agents-plugin-rime Major
@livekit/agents-plugin-runway Major
@livekit/agents-plugin-sarvam Major
@livekit/agents-plugin-silero Major
@livekit/agents-plugins-test Major
@livekit/agents-plugin-trugen Major
@livekit/agents-plugin-xai Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

View 5 additional findings in Devin Review.

Open in Devin Review

Comment on lines +1460 to +1464
const waitInactiveTask = Task.from(
() => this.waitForInactive({ waitForAgent: true, waitForUser: false }, signal),
undefined,
'AgentActivity.waitForInactiveForUserTurnExceeded',
);
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.

🟡 waitInactiveTask.cancel() is ineffective because the task ignores its own AbortController

In runUserTurnExceededTask, the waitInactiveTask is created with Task.from(() => this.waitForInactive(..., signal), ...) where signal is the outer task's signal. The arrow function ignores the Task's own AbortController (it doesn't destructure the controller parameter). When waitInactiveTask.cancel() is called in the finally block at line 1481, it aborts the Task's internal controller — but waitForInactive is listening to the outer signal, not the Task's controller. This means the cancel is effectively a no-op: the waitForInactive promise continues running as a detached background operation.

Additionally, since waitInactiveTask.result is part of the ThrowsPromise.race at line 1467-1471, if the race resolves via agentSpeaking or waitForAbort(signal), the losing waitInactiveTask.result promise can later reject (e.g., when waitForInactive hits delay(0, { signal }) after signal abort), causing an unhandled promise rejection.

Prompt for agents
In agents/src/voice/agent_activity.ts, the `waitInactiveTask` at line 1460-1464 in `runUserTurnExceededTask` is created with `Task.from(() => this.waitForInactive(..., signal), ...)`. The problem is the function ignores the Task's own AbortController and passes the outer `signal` to `waitForInactive`. This means `waitInactiveTask.cancel()` at line 1481 does not actually cancel the underlying work.

To fix this, the task should use its own controller's signal so that `cancel()` works correctly. You also need to link the outer signal to the inner task so that outer cancellation propagates. For example:

1. Change the Task.from to use the task's controller: `Task.from((controller) => this.waitForInactive({...}, controller.signal), ...)`
2. Add a signal listener to propagate the outer abort: `const onAbort = () => waitInactiveTask.cancel(); signal.addEventListener('abort', onAbort, { once: true });` and clean it up in the finally block.
3. Add a `.catch(() => {})` on `waitInactiveTask.result` before the race (or after the race resolves) to prevent unhandled promise rejections from the losing promise.
Open in Devin Review

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

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