Skip to content

Commit 41bf270

Browse files
committed
implement initial task group
1 parent 743b3d8 commit 41bf270

5 files changed

Lines changed: 330 additions & 3 deletions

File tree

agents/src/beta/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
export * as workflows from './workflows/index.js';

agents/src/beta/workflows/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
export {
5+
TaskGroup,
6+
type TaskCompletedEvent,
7+
type TaskGroupOptions,
8+
type TaskGroupResult,
9+
} from './task_group.js';
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
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+
}

agents/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
* @see {@link https://docs.livekit.io/agents/overview | LiveKit Agents documentation}
1010
* @packageDocumentation
1111
*/
12+
import * as beta from './beta/index.js';
1213
import * as cli from './cli.js';
1314
import * as inference from './inference/index.js';
1415
import * as ipc from './ipc/index.js';
@@ -37,4 +38,4 @@ export * from './version.js';
3738
export { createTimedString, isTimedString, type TimedString } from './voice/io.js';
3839
export * from './worker.js';
3940

40-
export { cli, inference, ipc, llm, metrics, stream, stt, telemetry, tokenize, tts, voice };
41+
export { beta, cli, inference, ipc, llm, metrics, stream, stt, telemetry, tokenize, tts, voice };

0 commit comments

Comments
 (0)