bored-country-30093
05/26/2026, 8:28 PMtall-airline-16756
05/26/2026, 8:28 PMtall-airline-16756
05/26/2026, 8:28 PMself.complete(result)), or
2. a TaskGroup to run Tasks in a defined order and share context.
Reply with your preferred chaining style and language and I’ll provide a concise implementation example.
Sources: Tasks and task groups | LiveKit Documentation | How to Use the Supervisor Pattern for Multi-Agent Voice AI Systems | LiveKi..., with interactive elementsbored-country-30093
05/26/2026, 8:32 PMtall-airline-16756
05/26/2026, 8:32 PMcompose_tool that programmatically adds tasks and builds a task graph instead of relying on the agent/LLM to call tools sequentially. Short answer: yes — you can have a tool that composes tasks or a TaskGroup to orchestrate a graph of tasks, then trigger that orchestration from the agent. A few quick clarifying questions so I give the right pattern and sample code:
• Do you want the composition to be fully deterministic (agent/tool constructs a fixed graph and executes tasks automatically), or do you want the LLM to still choose which tasks to run at runtime?
• Do tasks need to run strictly in sequence, in parallel, or as a directed graph with dependencies and conditional branching?
• Which language do you want examples for: Python or Node.js?
• Should tasks share mutable state, or should each task return a typed result that the composer aggregates?
Reply with those details and I’ll provide a concise implementation pattern and sample code for a compose_tool that builds and runs a TaskGroup (or a custom graph executor) in LiveKit.
, with interactive elementsbored-country-30093
05/26/2026, 8:33 PMbored-country-30093
05/26/2026, 8:33 PMtall-airline-16756
05/26/2026, 8:34 PMcompose_tool that programmatically constructs and runs a task graph (no LLM-driven ordering). Below is a concise pattern and example showing how to:
• expose a tool on an agent that builds a TaskGroup (the deterministic graph),
• add tasks programmatically,
• run them in sequence, and
• collect typed results.
Key notes before code:
• Each Task should extend AgentTask[ResultType] and return a typed result via self.complete(result).
• The composer tool builds a TaskGroup, adds tasks (in the required order), awaits execution, and returns the aggregated result.
• This keeps orchestration deterministic — the agent/tool controls ordering and data flow.
Example (Python):
from livekit.agents.logic.tasks import AgentTask, TaskGroup, TaskFactory, task_tool
from typing import NamedTuple
# Define typed results
class TaskAResult(NamedTuple):
value: str
class TaskBResult(NamedTuple):
transformed: str
class TaskCResult(NamedTuple):
final: str
# Implement tasks
class TaskA(AgentTask[TaskAResult]):
async def on_enter(self):
# deterministic work
result = TaskAResult(value="hello")
self.complete(result)
class TaskB(AgentTask[TaskBResult]):
async def on_enter(self):
# expect input via kwargs (composer will pass)
input_value = self.task_args.get("from_a")
transformed = input_value.upper()
self.complete(TaskBResult(transformed=transformed))
class TaskC(AgentTask[TaskCResult]):
async def on_enter(self):
part1 = self.task_args.get("from_b")
final = f"{part1}-world"
self.complete(TaskCResult(final=final))
# Agent-exposed compose tool
class MyAgent:
# register as a tool so the agent (or LLM) can call it if desired
@task_tool() # or appropriate decorator for tools in your agent framework
async def compose_tool(self, **kwargs):
# Build deterministic graph as TaskGroup
tg = TaskGroup()
# Add tasks with factories; pass dependencies via task args when starting
tg.add(TaskFactory(TaskA))
tg.add(TaskFactory(TaskB))
tg.add(TaskFactory(TaskC))
# Run tasks sequentially and wire outputs deterministically
results = {}
# Start Task A and wait
a_res = await tg.start_task(TaskA) # pseudocode start API
results["a"] = a_res # TaskAResult
# Start Task B with data from A
b_res = await tg.start_task(TaskB, from_a=a_res.value)
results["b"] = b_res
# Start Task C with data from B
c_res = await tg.start_task(TaskC, from_b=b_res.transformed)
results["c"] = c_res
# Optionally await group completion if underlying framework requires
await tg.wait_complete()
# Aggregate and return
return {
"a": results["a"]._asdict(),
"b": results["b"]._asdict(),
"c": results["c"]._asdict(),
}
Practical details to adapt to your codebase:
• Replace tg.start_task(...) and tg.wait_complete() with the exact TaskGroup/task start APIs in your LiveKit SDK (the pattern is: create TaskGroup, add Task factories, start tasks programmatically with kwargs, then await results).
• Pass inputs to tasks via task_args or whatever mechanism AgentTask supports for constructor/runtime args.
• For branching or conditional deterministic graphs, have the composer decide which tasks to add/run before starting them.
If you’d like, I can:
• convert the above to exact LiveKit SDK calls from your codebase if you share the AgentTask/TaskGroup start APIs you’re using, or
• provide a version with parallel execution and joins for DAG-style graphs. Which would you prefer?, with interactive elementsbored-country-30093
05/26/2026, 8:35 PMtall-airline-16756
05/26/2026, 8:35 PMtask_args (or the Task constructor arguments your AgentTask supports).
• The composer (your compose_tool) is authoritative: it starts tasks in order, passes prior task outputs into the next task’s args, and awaits each result.
• Keep tasks pure: return structured results and avoid shared mutable state. The composer aggregates/transform results.
Compact example (Python-style, adapt to your LiveKit SDK method names):
from typing import NamedTuple
from livekit.agents.logic.tasks import AgentTask, TaskGroup, TaskFactory, task_tool
# 1) Define typed outputs
class TaskAResult(NamedTuple):
value: str
class TaskBResult(NamedTuple):
transformed: str
class TaskCResult(NamedTuple):
final: str
# 2) Tasks read inputs from task_args and call self.complete(result)
class TaskA(AgentTask[TaskAResult]):
async def on_enter(self):
# no input needed
self.complete(TaskAResult(value="hello"))
class TaskB(AgentTask[TaskBResult]):
async def on_enter(self):
# composer passed 'from_a'
from_a = self.task_args.get("from_a")
self.complete(TaskBResult(transformed=from_a.upper()))
class TaskC(AgentTask[TaskCResult]):
async def on_enter(self):
from_b = self.task_args.get("from_b")
self.complete(TaskCResult(final=f"{from_b}-world"))
# 3) Deterministic composer tool: start tasks, pass outputs into next
class MyAgent:
@task_tool()
async def compose_tool(self, **kwargs):
tg = TaskGroup()
# register factories (order here is informational; composer controls run)
tg.add(TaskFactory(TaskA))
tg.add(TaskFactory(TaskB))
tg.add(TaskFactory(TaskC))
# Run Task A
a_task = await tg.start_task(TaskA) # adapt to SDK start API
a_res: TaskAResult = await a_task.wait_result()
# Run Task B with input from A
b_task = await tg.start_task(TaskB, from_a=a_res.value)
b_res: TaskBResult = await b_task.wait_result()
# Run Task C with input from B
c_task = await tg.start_task(TaskC, from_b=b_res.transformed)
c_res: TaskCResult = await c_task.wait_result()
# Optionally await overall group completion
await tg.wait_complete()
return {
"a": a_res._asdict(),
"b": b_res._asdict(),
"c": c_res._asdict(),
}
Best practices
• Use explicit typed results (NamedTuple/dataclass) so the composer knows what fields to read.
• Use explicit task arg names (e.g., from_a, from_b) rather than positional or implicit context.
• Validate inputs at task start and raise/complete with errors so the composer can decide recovery/branching.
• For DAGs, build a dependency map in the composer and start tasks once their inputs are ready; use asyncio.gather for parallel runs where independent.
• For complex transformations, keep small focused tasks and let the composer handle aggregation.
If you want, I can convert the pseudocode to the exact LiveKit SDK call signatures you have (share the `TaskGroup`/`Task` start/wait API you’re using) or show a DAG implementation with parallel starts and joins. Which would you prefer?
, with interactive elementsbored-country-30093
05/26/2026, 8:41 PMtall-airline-16756
05/26/2026, 8:41 PMtask_args (or equivalent).
• A composer tool (deterministic) builds a sequence/DAG, starts tasks, and passes prior outputs into subsequent tasks.
Generic example
from typing import NamedTuple, Any, Dict, List, Callable
import asyncio
# --- Typed result alias for clarity ---
TaskResult = Any
# --- Task base placeholder (replace with actual AgentTask) ---
class BaseTask:
def __init__(self, task_args: Dict[str, Any] = None):
self.task_args = task_args or {}
async def run(self) -> TaskResult:
raise NotImplementedError
# --- Example typed results ---
class AResult(NamedTuple):
value: str
class BResult(NamedTuple):
transformed: str
class CResult(NamedTuple):
final: str
# --- Example tasks: read from self.task_args, return typed result ---
class TaskA(BaseTask):
async def run(self) -> AResult:
# deterministic work
return AResult(value="hello")
class TaskB(BaseTask):
async def run(self) -> BResult:
inp = self.task_args.get("from_a", "")
return BResult(transformed=inp.upper())
class TaskC(BaseTask):
async def run(self) -> CResult:
inp = self.task_args.get("from_b", "")
return CResult(final=f"{inp}-world")
# --- Composer: deterministic sequence or DAG executor ---
class Composer:
def __init__(self):
# map name -> task factory
self.task_factories: Dict[str, Callable[[Dict], BaseTask]] = {}
# DAG dependencies: name -> list of names it depends on
self.deps: Dict[str, List[str]] = {}
def register(self, name: str, factory: Callable[[Dict], BaseTask], deps: List[str] = None):
self.task_factories[name] = factory
self.deps[name] = deps or []
async def run_sequential(self, order: List[str]) -> Dict[str, TaskResult]:
results: Dict[str, TaskResult] = {}
for name in order:
# build task args by reading required inputs from results
# convention: a task reads inputs by keys like 'from_<dep>'
task_args = {}
for d in self.deps.get(name, []):
task_args[f"from_{d}"] = results[d]
task = self.task_factories[name](task_args)
res = await task.run()
results[name] = res
return results
async def run_dag(self) -> Dict[str, TaskResult]:
# simple DAG runner: start tasks when deps ready, allow parallelism
results: Dict[str, TaskResult] = {}
pending = set(self.task_factories.keys())
in_progress = set()
while pending:
ready = [n for n in pending if all(d in results for d in self.deps[n])]
if not ready and not in_progress:
raise RuntimeError("Cyclic or unsatisfiable dependencies")
# run ready tasks in parallel
tasks = []
for name in ready:
task_args = {f"from_{d}": results[d] for d in self.deps[name]}
task = self.task_factories[name](task_args)
tasks.append((name, asyncio.create_task(task.run())))
pending.remove(name)
in_progress.add(name)
for name, t in tasks:
res = await t
results[name] = res
in_progress.remove(name)
return results
# --- Usage example: register tasks and run sequentially ---
async def compose_tool_example():
composer = Composer()
composer.register("a", lambda args: TaskA(args), deps=[])
composer.register("b", lambda args: TaskB(args), deps=["a"])
composer.register("c", lambda args: TaskC(args), deps=["b"])
# Deterministic sequential run
seq_results = await composer.run_sequential(["a", "b", "c"])
# Or run as DAG (allows parallel where deps permit)
dag_results = await composer.run_dag()
# Convert NamedTuples to dicts for easy aggregation
def to_dict(x):
return x._asdict() if hasattr(x, "_asdict") else x
return {k: to_dict(v) for k, v in seq_results.items()}
How inputs/outputs flow here
• Composer registers tasks with a dependency list (deps).
• Composer constructs task_args for each task by taking results of its dependencies and putting them under keys like from_<dep>.
• Each Task reads inputs from self.task_args["from_<dep>"] and returns a typed result.
• Composer aggregates typed results and can map/transform them as needed before passing into next tasks.
Adaptation notes
• Replace BaseTask/Composer start/wait with LiveKit’s AgentTask, TaskFactory, and TaskGroup APIs; the wiring concept remains identical.
• For stricter typing, use dataclasses/NamedTuples for inputs and outputs and validate at task start.
• For error handling, have tasks return error objects or raise and let composer decide retry/compensation logic.
If you want, I can convert this generic runner to match exact LiveKit APIs (e.g., TaskGroup.add, start_task, wait_result) if you share those method names in your codebase. Which would you like next?
, with interactive elements