crooked-car-42691
02/19/2026, 9:11 PMtall-airline-16756
02/19/2026, 9:11 PMtall-airline-16756
02/19/2026, 9:12 PMawait when calling a task. Awaiting a task guarantees the agent will pause execution until that task finishes and returns a typed result.
• Tasks temporarily take full control of the session while they run. Control only returns to the agent after the task calls self.complete(result), so awaiting a task enforces sequential execution and gives you the task’s result to branch on.
Example patterns:
1. Single task with branching
consent_given = await CollectConsent()
if not consent_given:
self.session.shutdown()
return
# continues only after CollectConsent finishes
1. Ordered multi-task workflow using TaskGroup
task_group = TaskGroup()
task_group.add(lambda: CollectConsent(), id="consent")
task_group.add(lambda: CollectNameTask(), id="name")
results = await task_group
# results contains all task outputs; execution pauses until group completes
Why this works:
• await provides synchronous-like sequencing so the next line runs only after completion.
• The task handoff ensures the task controls the session until it completes.
• Tasks return typed results and shared context merges back into the agent when finished, enabling safe decision-making based on completed work.
If you’d like, I can:
• Show how to handle retries or timeouts when awaiting a task.
• Give a concrete example for your specific workflow (share what your tasks do). Sources: Collect recording consent with tasks | LiveKit Documentation | Tasks and task groups | LiveKit Documentationcrooked-car-42691
02/21/2026, 1:45 AM_BaseTask)
python
class _BaseTask(AgentTask[ResultT], ABC):
GOODBYE_PATTERNS = frozenset([
"have a good", "take care", "looking forward", "you've got this",
"rooting for you", "good luck", "bye", "talk soon", "catch you later",
"i'll check in", "until next time"
])
async def on_agent_speech_committed(self, content: str) -> None:
"""Called when agent speech is committed to context."""
if self.done():
return
content_lower = content.lower()
if any(pattern in content_lower for pattern in self.GOODBYE_PATTERNS):
logger.warning(
"[PipelineV3] Goodbye detected in incomplete task",
extra={"task_id": self.TASK_ID, "content_preview": content[:100]}
)
# Inject correction
remaining = self._get_remaining_tasks()
await self.session.generate_reply(
instructions=f"""
STOP — you just said goodbye but this task isn't complete.
You haven't called mark_complete yet.
Current task: {self.TASK_NAME}
Still needed: {', '.join(remaining) if remaining else 'complete this task'}
Continue naturally without apologizing. Ask about: {self._get_missing_info_hint()}
"""
)
def _get_remaining_tasks(self) -> list[str]:
"""Get remaining task names after current."""
order = TASK_ORDER_BY_CALL_TYPE.get(self._session_data.call_type, [])
try:
idx = order.index(self.TASK_ID)
return order[idx + 1:]
except ValueError:
return []
def _get_missing_info_hint(self) -> str:
"""Get hint about what info is still needed."""
missing = [f for f in self.MIN_REQUIRED_FIELDS if not self._partial_data.get(f)]
return ", ".join(missing) if missing else "the required information"
2. Task Awareness Injection (in instructions)
python
def _build_persistent_instructions(self) -> str:
remaining = self._get_remaining_tasks()
remaining_block = ""
if remaining:
remaining_block = f"""
⚠️ WORKFLOW POSITION: You are on task {len(TASK_ORDER_BY_CALL_TYPE[self._session_data.call_type]) - len(remaining)} of {len(TASK_ORDER_BY_CALL_TYPE[self._session_data.call_type])}.
After this: {', '.join(remaining[:2])}{'...' if len(remaining) > 2 else ''}
DO NOT say goodbye or wrap up until ALL tasks complete.
"""
return render_task_instructions(
f"{GLOBAL_INSTRUCTION_PREFIX}\n\n{remaining_block}\n\n{self.TASK_INSTRUCTIONS}".strip(),
self._brief,
self._session_data,
)
3. Active Timeout (Reduce from 150s)
python
class ExploreGoalsTask(_BaseIntroTask[ExploreGoalsResult]):
TIMEOUT_SECONDS = 90.0 # Reduced from 150
IDLE_TIMEOUT_SECONDS = 30.0 # New: timeout after 30s of no user speech
4. Goodbye Prevention in GLOBAL_INSTRUCTION_PREFIX
python
GLOBAL_INSTRUCTION_PREFIX = """
=== CORE BEHAVIORAL RULES ===
CALL COMPLETION PROTOCOL:
- You MUST call mark_complete for each task before the call ends
- NEVER say goodbye phrases ("have a good one", "take care", etc.)
until mark_complete confirms "all objectives complete"
- If you feel ready to wrap up: STOP and check — did you call mark_complete?
TRANSITIONS: Never announce the next stage...
"""tall-airline-16756
02/21/2026, 1:45 AMself._injection_sent and skip sending multiple corrections for the same task.
• Replace raw substring match with a regex using word boundaries and lowercasing.
• Limit injected instruction length to one or two sentences and include a one-line developer log with task id.
• Add a confirmation step after timeout: “I’m pausing this task. Did we finish X?” before closing.
If you’d like, I can:
• Draft a minimal rewrite of the injected correction text to be concise and model-friendly.
• Propose a small regex-based goodbye detector to replace the substring check.
• Sketch a short fallback flow to run after two ignored injections.
Which of these would be most helpful next — the concise injection rewrite, a regex/intent detector example, or the fallback flow sketch?crooked-car-42691
02/21/2026, 1:53 AMfrom livekit.agents.beta.workflows import TaskGroup
class V3WorkflowAgent(Agent):
async def on_enter(self) -> None:
task_group = TaskGroup(chat_ctx=self.chat_ctx)
task_group.add(
lambda: OpenAndConnectTask(...),
id="open_and_connect",
description="Greet user, establish rapport, discover underlying why"
)
task_group.add(
lambda: ExploreGoalsTask(...),
id="explore_goals",
description="Sharpen goal into something specific and measurable"
)
# ... more tasks
results = await task_group
Benefits:
• Built-in regression support (user can say "go back to the goals part")
• Automatic context summarization
• Task descriptions passed to LLM for awareness
• on_task_completed callback for checkpoint persistence
Drawbacks:
• Still relies on complete() being called — no goodbye protectiontall-airline-16756
02/21/2026, 1:53 AMcomplete() and what happens if it doesn’t (for example, because the agent said goodbye early)?
Short answer
• Yes — TaskGroup still depends on each task invoking complete() (or otherwise returning a result) to mark that task finished. If a task fails to call complete() (for example, the LLM emits a goodbye and the task never triggers completion), the TaskGroup will not advance past that task and your workflow will stall unless you add guardrails or fallbacks.
Why (brief)
• TaskGroup coordinates task execution by awaiting each task’s completion and collecting typed results. The framework’s control handoff model requires the running task to explicitly hand control back (via self.complete(result) or an equivalent return). Without that signal, the TaskGroup will wait indefinitely (or until a timeout/abort occurs).
Practical implications and failure modes
• Premature wrap-ups: If the model issues goodbye language instead of completing the task, the task stays incomplete and the TaskGroup blocks subsequent tasks.
• Silent failures: Bugs that prevent calling complete() (logic errors, exceptions, or token-limited responses) prevent progress in the TaskGroup.
• User-initiated regression: If user asks to “go back,” TaskGroup can support it, but you still need a way to recover if a prior task didn’t complete cleanly.
• Observability: Without logging/metrics, it can be hard to detect tasks stuck waiting for complete().
Recommended mitigations (practical, ordered)
1. Keep the Goodbye Guardrail you outlined
◦ Use the Goodbye Detection Hook + brief injected correction to catch early wrap-ups and prompt the model to finish the task. This directly reduces the most common cause of missing complete().
1. Debounce & rate-limit injections
◦ Inject once per task (e.g., set self._injection_sent = True) to avoid spamming the model and confusing users.
1. Add task-level timeouts with deterministic behavior
◦ Use both a shorter idle timeout and an absolute task timeout.
◦ On timeout, take a deterministic fallback action:
▪︎ Option A: Auto-call complete() with a best-effort partial result plus a partial=true flag.
▪︎ Option B: Transition to a verification task that asks the user a single confirmatory question (e.g., “Did we complete X?”), then call complete() based on the reply.
◦ TaskGroup can then proceed based on the returned typed result or partial flag.
1. Add an explicit “force-complete” / recovery route inside TaskGroup
◦ Allow TaskGroup to detect stuck tasks (e.g., task not completed after N seconds or after M injections) and run a small recovery handler that:
▪︎ Persists the partial state (via on_task_completed or a checkpoint),
▪︎ Advances the group with a "timed_out" sentinel result, and
▪︎ Flags the session for later review.
1. Improve goodbye detection quality (reduce false positives)
◦ Replace raw substring matching with:
▪︎ A regex with word boundaries and punctuation normalization, or
▪︎ A lightweight intent classifier (few-shot prompt or small model) that returns “wrap-up intent” vs “incidental phrase.”
◦ This reduces unnecessary injections that could harm flow.
1. Short, prioritized correction messages for TaskGroup
◦ When injecting corrections during a TaskGroup run, keep them extremely concise and task-specific: one sentence that instructs the model to finish required fields and call mark_complete.
◦ Prefer adding a high-priority system instruction (if your runtime supports it) rather than a user-facing interrupt.
1. Instrumentation & monitoring
◦ Emit structured logs whenever goodbye detection fires, when a correction is injected, when timeouts occur, and when a task completes with partial=true.
◦ Track metrics: stuck-task rate, injection frequency, and how often timeouts force completion.
1. Make TaskGroup results expressive
◦ Return structured results with fields like status: completed|partial|timed_out, partial=true/false, and missing_fields.
◦ Downstream tasks can then adapt based on that status (e.g., re-run a short subtask to collect missing info).
Small code-level patterns you can add quickly
• Debounce injection:
if not getattr(self, "_injection_sent", False):
self._injection_sent = True
await self.session.generate_reply(instructions="Hold — this task is incomplete. Ask about X and call mark_complete.")
• Timeout fallback sketch:
try:
result = await asyncio.wait_for(self._run_task(), timeout=self.TIMEOUT_SECONDS)
except asyncio.TimeoutError:
# checkpoint partial state
partial_result = self._best_effort_result()
partial_result["status"] = "timed_out"
await self.complete(partial_result)
• TaskGroup-aware sentinel result:
# in on_task_completed callback
if result.get("status") == "timed_out":
# persist checkpoint and let TaskGroup continue with sentinel
When to prefer TaskGroup vs. per-task management
• Use TaskGroup if you want built-in regression, summaries, and a single coordinated flow — but still implement timeouts, guardrails, and recovery as above.
• If your workflows are fragile and you expect frequent LLM misbehaviors, start with per-task management and explicit state persistence, then move to TaskGroup once you have robust guardrails and monitoring.
If you want, I can:
• Draft a concise injected-correction message optimized for TaskGroup usage.
• Provide a regex-based goodbye detector example to replace substring matching.
• Sketch a complete timeout+fallback flow integrated into your TaskGroup and on_task_completed callback.
Which would be most helpful next?crooked-car-42691
02/21/2026, 1:58 AMtall-airline-16756
02/21/2026, 1:58 AMcomplete() or returns — this makes linear flows predictable.
• Shared context and summarization: Tasks inherit the same chat context and TaskGroup provides automatic summaries, which reduces drift and maintains continuity.
• Built-in regression and checkpoint hooks: TaskGroup supports going back to earlier tasks, persists checkpoints via on_task_completed, and collects typed results that downstream steps can rely on.
• Lower operational complexity: Everything lives inside one agent, so there’s less overhead coordinating multiple agents, fewer context-switching concerns, and simpler deployment.
When TaskGroup can fail (and how to mitigate)
• It still depends on complete() being called. If an LLM prematurely says goodbye or fails to call complete(), TaskGroup will stall on that task.
• Mitigations you should add:
◦ Goodbye guardrails (your detection + short correction injection).
◦ Debounce injections and improve detection quality (regex or a lightweight intent classifier).
◦ Task-level timeouts with deterministic fallbacks (auto-complete with partial=true or prompt a single confirmation).
◦ Recovery handler in TaskGroup that advances with a sentinel result after N failed attempts/timeouts.
◦ Instrumentation (logs, metrics) to surface stuck tasks.
When multi-agent workflows are more effective
• Role/permission separation: If different steps require different access, tools, or privacy boundaries (e.g., intake → billing → legal), separate agents help enforce those boundaries.
• Independent scaling and specialization: Teams can develop and tune agents independently; agents can run different prompts, models, or toolsets.
• Asynchronous or parallel flows: If parts of the workflow can run in parallel or need to wait for external processes, multiple agents can coordinate better.
• Clear handoffs: If you intentionally want an agent handoff (explicitly pass control to another agent), a multi-agent design is a better fit.
Trade-offs at a glance
• Reliability for linear flows: TaskGroup wins (less coordination overhead, consistent context).
• Flexibility & isolation: Multi-agent wins (separation of concerns, security/perms).
• Complexity: TaskGroup is simpler; multi-agent requires more orchestration and state passing.
• Failure modes: TaskGroup needs guardrails to avoid stalls from missing `complete()`; multi-agent needs careful state transfer and routing logic.
Practical recommendation
• Start with TaskGroup for structured, single-session, linear workflows (consent → goals → details). Add the guardrails you designed: goodbye detection, short injections, timeouts, and an auto-complete or verification fallback.
• Move to a multi-agent architecture when:
◦ Steps require distinct permissions or tool access, or
◦ You need independent scaling, or
◦ Workflow complexity grows (parallel tasks, long-running handoffs).
• Hybrid approach: use TaskGroup within each agent for internal sequencing, and use multi-agent handoffs between major stages (agent per role). This gives the best of both worlds.
If you want, I can:
• Draft a concise goodbye-correction message optimized for TaskGroup.
• Sketch a timeout+fallback pattern that auto-completes with partial=true and how TaskGroup should interpret that.
• Outline a hybrid architecture example (TaskGroup inside agents + agent handoffs between stages).
Which follow-up would you like first?
Sources: Tasks and task groups | LiveKit Documentation | Building multi-agent architectures with LiveKit agents | LiveKit | Collect recording consent with tasks | LiveKit Documentation