Can i use tasks to perform tool chaining of sort ?...
# ask-ai
b
Can i use tasks to perform tool chaining of sort ? How do i implement this on livekit ?
t
Thanks for reaching out, Suman. I'm an AI assistant and I'm here to help. Give me a moment while I research this topic.
Thanks — you're asking whether you can use LiveKit Tasks to implement tool chaining, and how to do it. Short answer: yes — Tasks support defining and invoking multiple tools and TaskGroups support sequencing multiple tasks. I can walk you through the exact pattern that fits your use case; a couple quick clarifying questions first: • By "tool chaining" do you mean: ◦ invoking multiple tools in sequence where each tool is explicitly called by the LLM (LLM-driven ordering), or ◦ a deterministic pipeline where outputs of Tool A are automatically passed into Tool B without LLM selection? • Are you implementing this inside a single Task (multiple tools within one Task) or across multiple Tasks (a sequence of Tasks) that must share state? If you want, tell me which language you’re using (Python or Node.js) and whether you need example code for: 1. multiple tools inside one Task (LLM can call tools, tools call
self.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 elements
b
Within a agent, instead of relying on agent to make sequential calls. Could we have a compose_tool that adds tasks and constructs the graph
t
Thanks — you're asking whether an agent can expose a
compose_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 elements
b
determinstic
and python
t
Thanks — you want a deterministic Python pattern where an agent exposes a
compose_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):
Copy code
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 elements
b
how do you define the inputs and outputs from each task to flow to the next
t
Great — I'll show a clear, deterministic pattern for defining task inputs/outputs and passing data between tasks in Python. Key ideas • Each Task exposes a typed result (e.g., NamedTuple or dataclass) and reads inputs from
task_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):
Copy code
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 elements
b
Generic version for doing this
t
Below is a compact, generic Python pattern you can drop into your agent to deterministically compose tasks, define task inputs/outputs, and wire results from one task to the next. It’s written to be SDK-agnostic—replace the placeholder start/wait APIs with your LiveKit TaskGroup/AgentTask methods if names differ. Key ideas (one line each) • Tasks return typed results (NamedTuple/dataclass). • Tasks read inputs from
task_args
(or equivalent). • A composer tool (deterministic) builds a sequence/DAG, starts tasks, and passes prior outputs into subsequent tasks. Generic example
Copy code
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