feat(voice): add user turn limits#1535
Conversation
🦋 Changeset detectedLatest commit: f9a391e The changes in this PR will be included in the next version bump. This PR includes changesets to release 31 packages
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 |
| const waitInactiveTask = Task.from( | ||
| () => this.waitForInactive({ waitForAgent: true, waitForUser: false }, signal), | ||
| undefined, | ||
| 'AgentActivity.waitForInactiveForUserTurnExceeded', | ||
| ); |
There was a problem hiding this comment.
🟡 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
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
UserTurnExceededEventand invokesAgent.on_user_turn_exceeded. The default implementation callssession.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
Both thresholds default to
None(disabled). Set either or both.Coordination
AgentActivity._user_turn_exceeded_taskwaits for the normal EOU-triggered response to resolve before firing the hook — if the agent reachesspeakingon its own, the callback is skipped.