Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/list-syntax-toolcontext.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@livekit/agents": minor
---

**BREAKING**: `Agent({ tools })` and `agent.updateTools()` now accept a flat list `(FunctionTool | ProviderDefinedTool)[]` instead of a `Record<string, FunctionTool>` map, and `llm.tool({ ... })` requires a `name` field. `ToolContext` is now a Python-parity class with `functionTools` / `providerTools` / `toolsets` accessors, plus `flatten()`, `hasTool(name)`, `getFunctionTool(name)`, `updateTools()`, `copy()`, and `equals()`. To match the Python reference, registering two **different** function-tool instances under the same `name` now throws `duplicate function name: <name>` instead of silently overriding the earlier entry; passing the **same instance** twice is a no-op. `agent.toolCtx` returns a defensive copy so callers can no longer mutate the agent's internal state. `LLM.chat({ toolCtx })` accepts either a `ToolContext` instance or a raw `(FunctionTool | ProviderDefinedTool)[]` array (`ToolCtxInput`) and normalizes it internally, so callers don't have to construct a `ToolContext` themselves. Stateful `Toolset` containers are not part of this release — the `toolsets` accessor currently returns an empty list and `TODO`s in `tool_context.ts` mark every site where Python's Toolset support will plug in later.
6 changes: 2 additions & 4 deletions agents/src/beta/workflows/task_group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,7 @@ export class TaskGroup extends AgentTask<TaskGroupResult> {

const outOfScopeTool = this.buildOutOfScopeTool(taskId);
if (outOfScopeTool) {
await this._currentTask.updateTools({
...this._currentTask.toolCtx,
out_of_scope: outOfScopeTool,
});
await this._currentTask.updateTools([...this._currentTask.toolCtx.tools, outOfScopeTool]);
}

try {
Expand Down Expand Up @@ -190,6 +187,7 @@ export class TaskGroup extends AgentTask<TaskGroupResult> {
const visitedTasks = this._visitedTasks;

return tool({
name: 'out_of_scope',
description,
flags: ToolFlag.IGNORE_ON_ENTER,
parameters: z.object({
Expand Down
57 changes: 34 additions & 23 deletions agents/src/inference/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ export class LLM extends llm.LLM {

chat({
chatCtx,
toolCtx,
toolCtx: toolCtxInput,
connOptions = DEFAULT_API_CONNECT_OPTIONS,
parallelToolCalls,
toolChoice,
Expand All @@ -258,22 +258,27 @@ export class LLM extends llm.LLM {
extraKwargs,
}: {
chatCtx: llm.ChatContext;
toolCtx?: llm.ToolContext;
toolCtx?: llm.ToolCtxInput;
connOptions?: APIConnectOptions;
parallelToolCalls?: boolean;
toolChoice?: llm.ToolChoice;
inferenceClass?: InferenceClass;
// TODO(AJS-270): Add responseFormat parameter
extraKwargs?: Record<string, unknown>;
}): LLMStream {
const toolCtx = llm.toToolContext(toolCtxInput);
let modelOptions: Record<string, unknown> = { ...(extraKwargs || {}) };

parallelToolCalls =
parallelToolCalls !== undefined
? parallelToolCalls
: this.opts.modelOptions.parallel_tool_calls;

if (toolCtx && Object.keys(toolCtx).length > 0 && parallelToolCalls !== undefined) {
if (
toolCtx &&
Object.keys(toolCtx.functionTools).length > 0 &&
parallelToolCalls !== undefined
) {
modelOptions.parallel_tool_calls = parallelToolCalls;
}

Expand Down Expand Up @@ -379,26 +384,32 @@ export class LLMStream extends llm.LLMStream {
)) as OpenAI.ChatCompletionMessageParam[];

const tools = this.toolCtx
? Object.entries(this.toolCtx).map(([name, func]) => {
const oaiParams = {
type: 'function' as const,
function: {
name,
description: func.description,
parameters: llm.toJsonSchema(
func.parameters,
true,
this.strictToolSchema,
) as unknown as OpenAI.Chat.Completions.ChatCompletionFunctionTool['function']['parameters'],
} as OpenAI.Chat.Completions.ChatCompletionFunctionTool['function'],
};

if (this.strictToolSchema) {
oaiParams.function.strict = true;
}

return oaiParams;
})
? this.toolCtx
.flatten()
.map((t) => {
if (llm.isFunctionTool(t)) {
const oaiParams = {
type: 'function' as const,
function: {
name: t.name,
description: t.description,
parameters: llm.toJsonSchema(
t.parameters,
true,
this.strictToolSchema,
) as unknown as OpenAI.Chat.Completions.ChatCompletionFunctionTool['function']['parameters'],
} as OpenAI.Chat.Completions.ChatCompletionFunctionTool['function'],
};
if (this.strictToolSchema) {
oaiParams.function.strict = true;
}
return oaiParams;
}
// Provider-defined tools are not yet supported by the inference adapter; skip them
// here rather than emitting a malformed tool definition. See AJS-112.
return undefined;
})
.filter((t): t is NonNullable<typeof t> => t !== undefined)
: undefined;

const requestOptions: Record<string, unknown> = dropUnsupportedParams(
Expand Down
29 changes: 29 additions & 0 deletions agents/src/llm/chat_context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
isInstructions,
renderInstructions,
} from './chat_context.js';
import { ToolContext, tool } from './tool_context.js';

initializeLogger({ pretty: false, level: 'error' });

Expand Down Expand Up @@ -1479,3 +1480,31 @@ extra`;
expect((baseCtx.items[0]! as ChatMessage).content[0]).toBe(instr);
});
});

describe('ChatContext.copy with toolCtx filter', () => {
it('drops function calls / outputs whose tool is not in the supplied ToolContext', () => {
const known = tool({ name: 'known', description: 'k', execute: async () => 'ok' });
const ctx = new ChatContext([
ChatMessage.create({ role: 'user', content: ['hello'] }),
FunctionCall.create({ callId: 'c1', name: 'known', args: '{}' }),
FunctionCallOutput.create({ callId: 'c1', name: 'known', output: 'done', isError: false }),
FunctionCall.create({ callId: 'c2', name: 'removed', args: '{}' }),
FunctionCallOutput.create({ callId: 'c2', name: 'removed', output: 'x', isError: false }),
]);

const filtered = ctx.copy({ toolCtx: new ToolContext([known]) });
const types = filtered.items.map((i) => `${i.type}:${'name' in i ? i.name : ''}`);
expect(types).toEqual(['message:', 'function_call:known', 'function_call_output:known']);
});

it('keeps provider-tool calls when the ToolContext holds a matching provider tool id', () => {
const provider = tool({ id: 'code_runner', config: {} });
const ctx = new ChatContext([
FunctionCall.create({ callId: 'p1', name: 'code_runner', args: '{}' }),
FunctionCall.create({ callId: 'p2', name: 'other', args: '{}' }),
]);

const filtered = ctx.copy({ toolCtx: new ToolContext([provider]) });
expect(filtered.items.map((i) => ('name' in i ? i.name : ''))).toEqual(['code_runner']);
});
});
2 changes: 1 addition & 1 deletion agents/src/llm/chat_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -835,7 +835,7 @@ export class ChatContext {
continue;
}

if (toolCtx !== undefined && isToolCallOrOutput(item) && toolCtx[item.name] === undefined) {
if (toolCtx !== undefined && isToolCallOrOutput(item) && !toolCtx.hasTool(item.name)) {
continue;
}

Expand Down
6 changes: 3 additions & 3 deletions agents/src/llm/fallback_adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { delay } from '../utils.js';
import type { ChatContext } from './chat_context.js';
import { FallbackAdapter } from './fallback_adapter.js';
import { type ChatChunk, LLM, LLMStream } from './llm.js';
import type { ToolChoice, ToolContext } from './tool_context.js';
import type { ToolChoice, ToolCtxInput } from './tool_context.js';

class MockLLMStream extends LLMStream {
public myLLM: LLM;
Expand All @@ -18,7 +18,7 @@ class MockLLMStream extends LLMStream {
llm: LLM,
opts: {
chatCtx: ChatContext;
toolCtx?: ToolContext;
toolCtx?: ToolCtxInput;
connOptions: APIConnectOptions;
},
private shouldFail: boolean = false,
Expand Down Expand Up @@ -64,7 +64,7 @@ class MockLLM extends LLM {

chat(opts: {
chatCtx: ChatContext;
toolCtx?: ToolContext;
toolCtx?: ToolCtxInput;
connOptions?: APIConnectOptions;
parallelToolCalls?: boolean;
toolChoice?: ToolChoice;
Expand Down
6 changes: 3 additions & 3 deletions agents/src/llm/fallback_adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { type APIConnectOptions, DEFAULT_API_CONNECT_OPTIONS } from '../types.js
import type { ChatContext } from './chat_context.js';
import type { ChatChunk } from './llm.js';
import { LLM, LLMStream } from './llm.js';
import type { ToolChoice, ToolContext } from './tool_context.js';
import type { ToolChoice, ToolCtxInput } from './tool_context.js';

/**
* Default connection options for FallbackAdapter.
Expand Down Expand Up @@ -113,7 +113,7 @@ export class FallbackAdapter extends LLM {

chat(opts: {
chatCtx: ChatContext;
toolCtx?: ToolContext;
toolCtx?: ToolCtxInput;
connOptions?: APIConnectOptions;
parallelToolCalls?: boolean;
toolChoice?: ToolChoice;
Expand Down Expand Up @@ -159,7 +159,7 @@ class FallbackLLMStream extends LLMStream {
adapter: FallbackAdapter,
opts: {
chatCtx: ChatContext;
toolCtx?: ToolContext;
toolCtx?: ToolCtxInput;
connOptions: APIConnectOptions;
parallelToolCalls?: boolean;
toolChoice?: ToolChoice;
Expand Down
7 changes: 6 additions & 1 deletion agents/src/llm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,20 @@
export {
handoff,
isFunctionTool,
isProviderDefinedTool,
isTool,
tool,
ToolContext,
ToolError,
ToolFlag,
toToolContext,
type AgentHandoff,
type FunctionTool,
type ProviderDefinedTool,
type Tool,
type ToolChoice,
type ToolContext,
type ToolContextEntry,
type ToolCtxInput,
type ToolOptions,
type ToolType,
} from './tool_context.js';
Expand Down
18 changes: 14 additions & 4 deletions agents/src/llm/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ import { recordException, traceTypes, tracer } from '../telemetry/index.js';
import { type APIConnectOptions, intervalForRetry } from '../types.js';
import { AsyncIterableQueue, delay, startSoon, toError } from '../utils.js';
import { type ChatContext, type ChatRole, type FunctionCall } from './chat_context.js';
import type { ToolChoice, ToolContext } from './tool_context.js';
import {
type ToolChoice,
type ToolContext,
type ToolCtxInput,
toToolContext,
} from './tool_context.js';

export interface ChoiceDelta {
role: ChatRole;
Expand Down Expand Up @@ -91,7 +96,12 @@ export abstract class LLM extends (EventEmitter as new () => TypedEmitter<LLMCal
extraKwargs,
}: {
chatCtx: ChatContext;
toolCtx?: ToolContext;
/**
* Tools to advertise to the LLM. Accepts either a `ToolContext` instance or a raw
* `(FunctionTool | ProviderDefinedTool)[]` array — the array form is normalized into a
* `ToolContext` internally so callers don't have to construct one themselves.
*/
toolCtx?: ToolCtxInput;
connOptions?: APIConnectOptions;
parallelToolCalls?: boolean;
toolChoice?: ToolChoice;
Expand Down Expand Up @@ -134,13 +144,13 @@ export abstract class LLMStream implements AsyncIterableIterator<ChatChunk> {
connOptions,
}: {
chatCtx: ChatContext;
toolCtx?: ToolContext;
toolCtx?: ToolCtxInput;
connOptions: APIConnectOptions;
},
) {
this.#llm = llm;
this.#chatCtx = chatCtx;
this.#toolCtx = toolCtx;
this.#toolCtx = toToolContext(toolCtx);
this._connOptions = connOptions;
this.monitorMetrics();
this.abortController.signal.addEventListener('abort', () => {
Expand Down
Loading