|
| 1 | +// SPDX-FileCopyrightText: 2026 LiveKit, Inc. |
| 2 | +// |
| 3 | +// SPDX-License-Identifier: Apache-2.0 |
| 4 | +import { z } from 'zod'; |
| 5 | +import { ChatContext } from '../../llm/chat_context.js'; |
| 6 | +import { LLM, ToolError, ToolFlag, tool } from '../../llm/index.js'; |
| 7 | +import { AgentTask } from '../../voice/agent.js'; |
| 8 | + |
| 9 | +interface FactoryInfo { |
| 10 | + taskFactory: () => AgentTask; |
| 11 | + id: string; |
| 12 | + description: string; |
| 13 | +} |
| 14 | + |
| 15 | +export interface TaskGroupResult { |
| 16 | + taskResults: Record<string, unknown>; |
| 17 | +} |
| 18 | + |
| 19 | +export interface TaskCompletedEvent { |
| 20 | + agentTask: AgentTask; |
| 21 | + taskId: string; |
| 22 | + result: unknown; |
| 23 | +} |
| 24 | + |
| 25 | +class OutOfScopeError extends ToolError { |
| 26 | + readonly targetTaskIds: string[]; |
| 27 | + |
| 28 | + constructor(targetTaskIds: string[]) { |
| 29 | + super('out_of_scope'); |
| 30 | + this.targetTaskIds = targetTaskIds; |
| 31 | + } |
| 32 | +} |
| 33 | + |
| 34 | +export interface TaskGroupOptions { |
| 35 | + summarizeChatCtx?: boolean; |
| 36 | + returnExceptions?: boolean; |
| 37 | + chatCtx?: ChatContext; |
| 38 | + onTaskCompleted?: (event: TaskCompletedEvent) => Promise<void>; |
| 39 | +} |
| 40 | + |
| 41 | +export class TaskGroup extends AgentTask<TaskGroupResult> { |
| 42 | + private _summarizeChatCtx: boolean; |
| 43 | + private _returnExceptions: boolean; |
| 44 | + private _visitedTasks = new Set<string>(); |
| 45 | + private _registeredFactories = new Map<string, FactoryInfo>(); |
| 46 | + private _taskCompletedCallback?: (event: TaskCompletedEvent) => Promise<void>; |
| 47 | + private _currentTask?: AgentTask; |
| 48 | + |
| 49 | + constructor(options: TaskGroupOptions = {}) { |
| 50 | + const { summarizeChatCtx = true, returnExceptions = false, chatCtx, onTaskCompleted } = options; |
| 51 | + |
| 52 | + super({ instructions: '*empty*', chatCtx }); |
| 53 | + |
| 54 | + this._summarizeChatCtx = summarizeChatCtx; |
| 55 | + this._returnExceptions = returnExceptions; |
| 56 | + this._taskCompletedCallback = onTaskCompleted; |
| 57 | + } |
| 58 | + |
| 59 | + add(task: () => AgentTask, { id, description }: { id: string; description: string }): this { |
| 60 | + this._registeredFactories.set(id, { taskFactory: task, id, description }); |
| 61 | + return this; |
| 62 | + } |
| 63 | + |
| 64 | + async onEnter(): Promise<void> { |
| 65 | + const taskStack = [...this._registeredFactories.keys()]; |
| 66 | + const taskResults: Record<string, unknown> = {}; |
| 67 | + |
| 68 | + while (taskStack.length > 0) { |
| 69 | + const taskId = taskStack.shift()!; |
| 70 | + const factoryInfo = this._registeredFactories.get(taskId)!; |
| 71 | + |
| 72 | + this._currentTask = factoryInfo.taskFactory(); |
| 73 | + |
| 74 | + const sharedChatCtx = this._chatCtx.copy(); |
| 75 | + await this._currentTask.updateChatCtx(sharedChatCtx); |
| 76 | + |
| 77 | + const outOfScopeTool = this.buildOutOfScopeTool(taskId); |
| 78 | + if (outOfScopeTool) { |
| 79 | + await this._currentTask.updateTools({ |
| 80 | + ...this._currentTask.toolCtx, |
| 81 | + out_of_scope: outOfScopeTool, |
| 82 | + }); |
| 83 | + } |
| 84 | + |
| 85 | + try { |
| 86 | + this._visitedTasks.add(taskId); |
| 87 | + const res = await this._currentTask.run(); |
| 88 | + taskResults[taskId] = res; |
| 89 | + |
| 90 | + if (this._taskCompletedCallback) { |
| 91 | + await this._taskCompletedCallback({ |
| 92 | + agentTask: this._currentTask, |
| 93 | + taskId, |
| 94 | + result: res, |
| 95 | + }); |
| 96 | + } |
| 97 | + } catch (e) { |
| 98 | + if (e instanceof OutOfScopeError) { |
| 99 | + taskStack.unshift(taskId); |
| 100 | + for (let i = e.targetTaskIds.length - 1; i >= 0; i--) { |
| 101 | + taskStack.unshift(e.targetTaskIds[i]!); |
| 102 | + } |
| 103 | + continue; |
| 104 | + } |
| 105 | + |
| 106 | + if (this._returnExceptions) { |
| 107 | + taskResults[taskId] = e; |
| 108 | + continue; |
| 109 | + } else { |
| 110 | + this.complete(e instanceof Error ? e : new Error(String(e))); |
| 111 | + return; |
| 112 | + } |
| 113 | + } |
| 114 | + } |
| 115 | + |
| 116 | + try { |
| 117 | + if (this._summarizeChatCtx) { |
| 118 | + const sessionLlm = this.session.llm; |
| 119 | + if (!(sessionLlm instanceof LLM)) { |
| 120 | + throw new Error('summarizeChatCtx requires a standard LLM on the session'); |
| 121 | + } |
| 122 | + |
| 123 | + // TODO(parity): Add excludeConfigUpdate when AgentConfigUpdate is ported |
| 124 | + const ctxToSummarize = this._chatCtx.copy({ |
| 125 | + excludeInstructions: true, |
| 126 | + excludeHandoff: true, |
| 127 | + excludeEmptyMessage: true, |
| 128 | + excludeFunctionCall: true, |
| 129 | + }); |
| 130 | + |
| 131 | + const summarizedChatCtx = await ctxToSummarize._summarize(sessionLlm, { |
| 132 | + keepLastTurns: 0, |
| 133 | + }); |
| 134 | + await this.updateChatCtx(summarizedChatCtx); |
| 135 | + } |
| 136 | + } catch (e) { |
| 137 | + this.complete(new Error(`failed to summarize the chat_ctx: ${e}`)); |
| 138 | + return; |
| 139 | + } |
| 140 | + |
| 141 | + this.complete({ taskResults }); |
| 142 | + } |
| 143 | + |
| 144 | + private buildOutOfScopeTool(activeTaskId: string) { |
| 145 | + if (this._visitedTasks.size === 0) { |
| 146 | + return undefined; |
| 147 | + } |
| 148 | + |
| 149 | + const regressionTaskIds = new Set(this._visitedTasks); |
| 150 | + regressionTaskIds.delete(activeTaskId); |
| 151 | + |
| 152 | + if (regressionTaskIds.size === 0) { |
| 153 | + return undefined; |
| 154 | + } |
| 155 | + |
| 156 | + const taskRepr: Record<string, string> = {}; |
| 157 | + for (const [id, info] of this._registeredFactories) { |
| 158 | + if (regressionTaskIds.has(id)) { |
| 159 | + taskRepr[id] = info.description; |
| 160 | + } |
| 161 | + } |
| 162 | + |
| 163 | + const taskIdValues = [...regressionTaskIds] as [string, ...string[]]; |
| 164 | + |
| 165 | + const description = |
| 166 | + 'Call to regress to other tasks according to what the user requested to modify, return the corresponding task ids. ' + |
| 167 | + 'For example, if the user wants to change their email and there is a task with id "email_task" with a description of "Collect the user\'s email", return the id ("get_email_task"). ' + |
| 168 | + 'If the user requests to regress to multiple tasks, such as changing their phone number and email, return both task ids in the order they were requested. ' + |
| 169 | + `The following are the IDs and their corresponding task description. ${JSON.stringify(taskRepr)}`; |
| 170 | + |
| 171 | + const currentTask = this._currentTask; |
| 172 | + const registeredFactories = this._registeredFactories; |
| 173 | + const visitedTasks = this._visitedTasks; |
| 174 | + |
| 175 | + return tool({ |
| 176 | + description, |
| 177 | + flags: ToolFlag.IGNORE_ON_ENTER, |
| 178 | + parameters: z.object({ |
| 179 | + task_ids: z.array(z.enum(taskIdValues)).describe('The IDs of the tasks requested'), |
| 180 | + }), |
| 181 | + execute: async ({ task_ids }: { task_ids: string[] }) => { |
| 182 | + for (const tid of task_ids) { |
| 183 | + if (!registeredFactories.has(tid) || !visitedTasks.has(tid)) { |
| 184 | + throw new ToolError(`Unable to regress, invalid task id ${tid}`); |
| 185 | + } |
| 186 | + } |
| 187 | + |
| 188 | + if (currentTask && !currentTask.done) { |
| 189 | + currentTask.complete(new OutOfScopeError(task_ids)); |
| 190 | + } |
| 191 | + }, |
| 192 | + }); |
| 193 | + } |
| 194 | +} |
0 commit comments