feat(agents): add Toolset support to ToolContext and AgentActivity#1525
Conversation
… Toolset
`Agent({ tools })` now accepts a list of `FunctionTool | ProviderDefinedTool
| Toolset` entries instead of a `Record<string, FunctionTool>` map, matching
the shape of the Python `ToolContext`. `llm.tool({ ... })` now requires an
explicit `name`. `ToolContext` is exported as a class with the same surface
as the Python one (`functionTools`, `providerTools`, `toolsets`, `flatten()`,
`getFunctionTool()`, `updateTools()`, `copy()`, `equals()`), backed by a
flat `_tools` list plus denormalized name map / provider list / toolset list.
A `Toolset` base class is included as the unit of stateful tool composition
with `setup()` / `aclose()` lifecycle hooks.
This is the foundational refactor; the standalone Toolset PR will rebase on
top of this once it lands.
- Removed `Toolset` from this PR — it lives in the stacked Toolset PR on top instead. `ToolContext` only handles `FunctionTool` and `ProviderDefinedTool` here. - `updateTools()` now uses later-wins semantics for duplicate function names instead of throwing. - Fixed every remaining call site that iterated `ToolContext` like a record (`Object.keys/entries(toolCtx)`): inference LLM, OpenAI chat / WS / responses, MistralAI, Baseten, and AgentActivity's initial-tools snapshot all now go through `.functionTools`.
…rray UX
- `updateTools()`: dropped redundant name-empty check (the `tool()` factory
already enforces a non-empty name at construction).
- `ToolContext.hasTool(name)`: new helper that matches function tools by
`name` and provider tools by `id`, so chat-context filtering remains
correct when provider tool call items are added later.
- `ChatContext.copy({ toolCtx })`: switched the filter predicate from
`getFunctionTool(name)` to `hasTool(name)` for that future-proofing.
- `AgentActivity` initial-tools snapshot: include both function tool names
and provider tool ids in the `AgentConfigUpdate.toolsAdded` payload.
- Inference LLM tool builder: iterates `toolCtx.flatten()` and discriminates
on tool type instead of only `functionTools`; provider tools are skipped
until AJS-112 wires them up properly.
- `ToolCtxInput` + `toToolContext()` helpers: LLM `chat({ toolCtx })` and
`LLMStream` now accept either a `ToolContext` or a raw
`(FunctionTool | ProviderDefinedTool)[]` array. Callers can write
`toolCtx: [tool1, tool2]` directly; `run_result.ts` updated to use it.
…n amd typing
- Removed `toolListFromRecord` — `tool()` requires `name` so the legacy
`Record<string, FunctionTool>` shape never gets produced in practice.
- `Agent.toolCtx` now returns `this._toolCtx.copy()` so external callers
can't mutate the agent's internal state.
- `amd.ts` imports `ToolContextEntry` properly at the top instead of
using the inline `import('../llm/index.js').ToolContextEntry` syntax.
🦋 Changeset detectedLatest commit: 4c3794f 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 |
Co-authored-by: rosetta-livekit-bot[bot] <282703043+rosetta-livekit-bot[bot]@users.noreply.github.com> Co-authored-by: u9g <jason.lernerman@livekit.io>
|
|
Co-authored-by: rosetta-livekit-bot[bot] <282703043+rosetta-livekit-bot[bot]@users.noreply.github.com> Co-authored-by: u9g <jason.lernerman@livekit.io>
| if (tools.length > 0 && providerTools.length > 0) { | ||
| throw new Error('Gemini does not support mixing function tools and provider tools'); | ||
| } |
There was a problem hiding this comment.
🔴 Google toToolsConfig incorrectly throws when function tools coexist with provider tools or geminiTools
The new toToolsConfig helper throws "Gemini does not support mixing function tools and provider tools" whenever function declarations and provider-side tools exist simultaneously (plugins/google/src/utils.ts:204-205). This is a regression: the old realtime code (buildConnectConfig) merged functionDeclarations and geminiTools (e.g. { codeExecution: {} }) into a single types.Tool entry, which worked. The Gemini API accepts multiple Tool entries in the array — one for function declarations and separate ones for googleSearch, codeExecution, etc. — so rejecting the combination outright is incorrect.
This breaks two paths:
- Realtime sessions — any user passing
geminiTools(e.g.{ codeExecution: {} }) alongside agents that have function tools will now get a runtime error instead of the previously-working merged behavior. - New
GeminiToolprovider tools — the newly-introducedGoogleSearch,ToolCodeExecution, etc. cannot be used together with function tools, which is a core use case for provider tools.
The onlySingleType flag used by the non-realtime LLM path (plugins/google/src/llm.ts:361) also cannot help since the throw fires before that check.
Prompt for agents
The error at plugins/google/src/utils.ts:204-206 incorrectly prevents valid Gemini API configurations where function declarations coexist with other tool types (googleSearch, codeExecution, etc.).
The Gemini API accepts a Tool[] array where function declarations and other tool types are separate entries. The fix should remove the throw and instead allow both types to coexist as separate entries in the returned array.
However, care is needed for the onlySingleType=true path (used by the non-realtime LLM at plugins/google/src/llm.ts:361). When onlySingleType is true and both function tools and provider tools exist, the current early-return at line 208 would silently drop provider tools. The fix should either:
1. Remove onlySingleType entirely and always merge both types into the array (function declarations in one entry, provider tools in separate entries), OR
2. When onlySingleType is true and both types exist, merge them into a single types.Tool object (like the old realtime code did with spread), OR
3. Keep onlySingleType but ensure provider tools are not silently dropped — perhaps by merging them into the same Tool entry that holds functionDeclarations.
The old realtime code used spread to merge: { functionDeclarations: [...], ...geminiTools } which put everything in one Tool. The new architecture with separate GeminiTool subclasses (each producing their own toToolConfig()) naturally maps to separate Tool[] entries. Both approaches are valid per the Gemini API.
Affected call sites: plugins/google/src/llm.ts:358-362 (LLM path) and plugins/google/src/beta/realtime/realtime_api.ts:1416-1420 (realtime path).
Was this helpful? React with 👍 or 👎 to provide feedback.
Summary
Adds Python-parity
Toolsetsupport: a stateful container that bundles a group of tools behind sharedsetup()/aclose()lifecycle hooks.Toolsetinstances can be passed directly intoAgent({ tools: [...] })alongside individual function tools; their tools are flattened into the agent'sToolContextand the runtime drives setup on activity start, close on activity teardown, and a diff onupdateTools().Closes the placeholder TODOs left in
tool_context.tsfrom the previous list-syntax refactor.Changes
Core (
agents/src/llm/tool_context.ts)Toolsetclass withid,tools,setup(),aclose()(default no-ops, override in subclass).ToolCalledEvent/ToolCompletedEventpayload types (exported for plugin authors).ToolContextEntrywidened toFunctionTool | ProviderDefinedTool | Toolset.updateTools()recurses into Toolsets and flattens their tools into_functionToolsMap/_providerToolswhile tracking the Toolset itself in_toolsets.equals()compares_toolsetsas an identity set (matches Python's{id(ts) for ts in self._tool_sets}semantics — order-insensitive).duplicate function name: <name>, including when the duplicate is contributed by a Toolset (Python parity).Activity (
agents/src/voice/agent_activity.ts)setupToolsets()/closeToolsets()driven by_startSessionand_closeSessionResources.setup()failures propagate to the caller after rolling back any toolsets that already initialized — the agent fails explicitly rather than running with half-set-up resources.updateTools()now diffs added/removed Toolsets and runs lifecycle insetup → swap toolCtx → closeorder so a setup failure leaves the activity pointing at the previous (still-valid)ToolContext.IGNORE_ON_ENTERfilter recurses through Toolsets so nested function tools also honor the flag.Plugin migrations to
ToolContext.flatten()+isFunctionToolfilterToolset-contributed tools are included in
_functionToolsMap, but several plugins were also rebuilding tool lists fromObject.entries(toolCtx.functionTools), which would silently drop theToolsetreferences and confuse subsequentupdateTools()diffs. Migrated:plugins/google/src/utils.tsplugins/mistralai/src/llm.tsplugins/openai/src/realtime/realtime_model.ts— also fixes a real bug in_handleSessionUpdated()where the post-updateretainedToolsrebuild dropped Toolsets, causing them to be diffed-as-removed and prematurelyaclose()d.plugins/openai/src/responses/llm.tsplugins/openai/src/ws/llm.tsplugins/phonic/src/realtime/realtime_model.tsplugins/openai/src/llm.ts(chat) andplugins/baseten/src/llm.tsleft unchanged: they iterateObject.keys(toolCtx.functionTools)which already picks up flattened Toolset tools, mirroring Python's chat plugin.Tests
agents/src/llm/tool_context.test.ts: newToolsetdescribe block (id/tools accessors, default no-op lifecycle, subclass override, flatten-into-ToolContext, duplicate-throw parity, identity-setequals()parity).plugins/test/src/llm.ts: newtoolsetdescribe block runs against every LLM plugin's shared test suite — verifies the model can call a function tool nested inside aToolsetand that direct tools continue to work alongside.agent_activity.test.ts/agent_activity_handoff.test.ts: existing tests pass (verified the toolset wiring doesn't disturb the fake activity scaffolding).Example
examples/src/basic_toolsets.tsshowing alocation_toolsToolset bundlinggetWeather+lookupTimezoneand passed alongside anAgent.Test plan
pnpm test --run agents/src/llm/tool_context.test.ts— 46/46 pass (40 prior + 6 new)pnpm test --run agents/src/voice/agent_activity.test.ts agents/src/voice/agent_activity_handoff.test.ts— 24/24 passpnpm test --run plugins/openai/src/llm.test.ts plugins/google/src/llm.test.ts plugins/mistralai/src/llm.test.ts— 29/30 pass (1 pre-existing skip), including 2 new toolset cases per pluginpnpm build— all 32 packages greenpnpm lint— clean (only pre-existinganywarnings in unrelated files)aclose()runs on session close and thatsetup()rejection rolls back already-set-up toolsets