Skip to content

first-class async tools via ctx.update()#5841

Open
longcw wants to merge 32 commits into
mainfrom
longc/unify-run-context
Open

first-class async tools via ctx.update()#5841
longcw wants to merge 32 commits into
mainfrom
longc/unify-run-context

Conversation

@longcw
Copy link
Copy Markdown
Contributor

@longcw longcw commented May 25, 2026

Overview

Async tools are now first-class. Any @function_tool can call ctx.update() to stream progress and run in the background β€” no special type hint needed. AsyncRunContext is now an alias of RunContext, and per-tool duplicate policy / cancellation are explicit flags on @function_tool.

Summary

  • AsyncRunContext is now a module-level alias of RunContext. Tools typed ctx: AsyncRunContext keep working unchanged.
  • Per-tool flags replace type-hint-driven dispatch: @function_tool(on_duplicate="allow|reject|replace|confirm", allow_cancellation=False).
  • Every tool routes through _ToolExecutor (voice/tool_executor.py). The default executor is owned by AgentActivity. Each AsyncToolset owns its own executor scoped to that toolset β€” when placed under AgentSession(tools=[...]) its in-flight tools and deferred replies survive agent handoff; under Agent(tools=[...]) they're bound to that agent.
  • cancel_task / get_running_tasks companion tools are auto-exposed by AgentActivity.tools whenever any registered tool declares allow_cancellation=True (always-on, so the LLM-visible schema stays stable across turns and the prompt cache stays warm).
  • AsyncToolPrompts template fields accept either a str.format() template or a callable receiving typed UpdatePromptArgs / DuplicatePromptArgs / ReplyPromptArgs. Resolved via AgentSession β†’ Agent β†’ AsyncToolset (most specific wins).

Deprecations

  • AsyncToolset(on_duplicate_call=...) logs a warning at construction and is applied as a fallback override onto contained tools' on_duplicate. Prefer the per-tool @function_tool(on_duplicate=...) flag.

@chenghao-mou chenghao-mou requested a review from a team May 25, 2026 06:39
@longcw longcw changed the title voice: unify RunContext / AsyncRunContext first-class async tools via ctx.update() May 25, 2026
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: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

items: list[ChatItem]
# AsyncRunContext is a backwards-compat alias β€” the unified RunContext now carries
# the same surface, so user tools typing `ctx: AsyncRunContext` keep working
__all__ = ["AsyncRunContext", "AsyncToolset"]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Since AsyncToolset was in beta, I think we can safely remove AsyncRunContext?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

maybe log a deprecation warning and notify them to use RunContext directly when it's used

raise ToolError(
f"Tool call {call_id} is not cancellable because interruptions are disallowed"
)
await utils.aio.cancel_and_wait(task.exe_task)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should we cancel the asyncio.Task? Or just mark the SpeechHandle as cancelled/interrupted?

My main concern is that most users don’t really understand asyncio cancellation logic. If we can hide that complexity from users, that would be great. (e.g not having to worry about all your method to be cancellation-safe)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

we have allow_cancellation default with False, I think user should be aware of it if they enable it specifically. it could be useful to stop or revert some write operations like updating the db or ordering something, etc...

Copy link
Copy Markdown
Member

@theomonnom theomonnom May 27, 2026

Choose a reason for hiding this comment

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

But should the user catch asyncio.CancelledErrorvs having something like if speech_handle.cancelled: ...?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

perhaps something like ctx.wait_if_not_cancelled and ctx.cancelled since the speech handle is done after the first update. we have something similar using speech_handle.wait_if_not_interrupted in

wait_for_result = asyncio.ensure_future(self._a_long_running_task(query))
await run_ctx.speech_handle.wait_if_not_interrupted([wait_for_result])
if run_ctx.speech_handle.interrupted:
logger.info(f"Interrupted searching the web for {query}")
# return None to skip the tool reply
wait_for_result.cancel()
return None

Comment on lines -148 to +27
"""A toolset for running long-running functions in the background.
"""Session-scoped toolset whose tools survive agent handoff.

Tools with an :class:`AsyncRunContext` parameter are wrapped to run in the background.
Each ``ctx.update()`` and the final ``return`` inject a tool output into the conversation;
the agent then generates a natural-language reply to the user based on that output.
Background updates from tools in this toolset are delivered to whichever agent
is current at delivery time, so a ``ctx.update()`` started under agent A still
completes after a handoff to agent B. Tools placed on ``Agent(tools=...)``
instead use the activity-scoped executor and are cancelled/awaited on handoff.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

πŸ™Œ

Comment thread livekit-agents/livekit/agents/llm/tool_context.py Outdated
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@longcw longcw force-pushed the longc/unify-run-context branch from 3a10d51 to 381b113 Compare May 26, 2026 09:26
devin-ai-integration[bot]

This comment was marked as resolved.

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 new potential issue.

View 23 additional findings in Devin Review.

Open in Devin Review

Comment on lines +280 to +284
except Exception as e:
output = e
logger.exception(
"error in tool execution",
extra={"call_id": call_id, "function": fnc_name},
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.

🟑 _execute_tool logs ToolError and StopResponse with full traceback via logger.exception

In the new _ToolExecutor._execute_tool, all exceptions (including expected ToolError and signal-like StopResponse) are caught by the generic except Exception handler and logged with logger.exception, which emits a full traceback. These exceptions then propagate through first_update_fut to the caller in generation.py:658-674, where ToolError is properly handled as a warning and StopResponse is silently consumed. This results in expected errors being double-logged β€” once with a noisy full traceback in the executor and once properly in the caller.

In the old code, ToolError from prepare_function_arguments was caught directly in _execute_tools_task and handled at the appropriate log level. Now it flows through the executor's generic handler first. This is particularly noisy in production since ToolError is common (LLM sends bad arguments) and StopResponse is a deliberate signal, not an error at all.

Suggested change
except Exception as e:
output = e
logger.exception(
"error in tool execution",
extra={"call_id": call_id, "function": fnc_name},
except Exception as e:
output = e
if not isinstance(e, (ToolError, StopResponse)):
logger.exception(
"error in tool execution",
extra={"call_id": call_id, "function": fnc_name},
)
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.

5 participants