Task Termination in Langroid¶
Why Task Termination Matters¶
When building agent-based systems, one of the most critical yet challenging aspects is determining when a task should complete. Unlike traditional programs with clear exit points, agent conversations can meander, loop, or continue indefinitely. Getting termination wrong leads to two equally problematic scenarios:
Terminating too early means missing crucial information or cutting off an agent mid-process. Imagine an agent that searches for information, finds it, but terminates before it can process or summarize the results. The task completes "successfully" but fails to deliver value.
Terminating too late wastes computational resources, frustrates users, and can lead to repetitive loops where agents keep responding without making progress. We've all experienced chatbots that won't stop talking or systems that keep asking "Is there anything else?" long after the conversation should have ended. Even worse, agents can fall into infinite loops—repeatedly exchanging the same messages, calling the same tools, or cycling through states without making progress. These loops not only waste resources but can rack up significant costs when using paid LLM APIs.
The challenge is that the "right" termination point depends entirely on context. A customer service task might complete after resolving an issue and confirming satisfaction. A research task might need to gather multiple sources, synthesize them, and present findings. A calculation task should end after computing and presenting the result. Each scenario requires different termination logic.
Traditionally, developers would subclass Task
and override the done()
method with custom logic. While flexible, this approach scattered termination logic across multiple subclasses, making systems harder to understand and maintain. It also meant that common patterns—like "complete after tool use" or "stop when the user says goodbye"—had to be reimplemented repeatedly.
This guide introduces Langroid's declarative approach to task termination, culminating in the powerful done_sequences
feature. Instead of writing imperative code, you can now describe what patterns should trigger completion, and Langroid handles the how. This makes your agent systems more predictable, maintainable, and easier to reason about.
Table of Contents¶
- Overview
- Basic Termination Methods
- Done Sequences: Event-Based Termination
- Concept
- DSL Syntax (Recommended)
- Full Object Syntax
- Event Types
- Examples
- Implementation Details
- Best Practices
- Reference
Overview¶
In Langroid, a Task
wraps an Agent
and manages the conversation flow. Controlling when a task terminates is crucial for building reliable agent systems. Langroid provides several methods for task termination, from simple flags to sophisticated event sequence matching.
Basic Termination Methods¶
1. Turn Limits¶
2. Single Round Mode¶
# Task completes after one exchange
config = TaskConfig(single_round=True)
task = Task(agent, config=config)
3. Done If Tool¶
# Task completes when any tool is generated
config = TaskConfig(done_if_tool=True)
task = Task(agent, config=config)
4. Done If Response/No Response¶
# Task completes based on response from specific entities
config = TaskConfig(
done_if_response=[Entity.LLM], # Done if LLM responds
done_if_no_response=[Entity.USER] # Done if USER doesn't respond
)
5. String Signals¶
# Task completes when special strings like "DONE" are detected
# (enabled by default with recognize_string_signals=True)
6. Orchestration Tools¶
# Using DoneTool, FinalResultTool, etc.
from langroid.agent.tools.orchestration import DoneTool
agent.enable_message(DoneTool)
Done Sequences: Event-Based Termination¶
Concept¶
The done_sequences
feature allows you to specify sequences of events that trigger task completion. This provides fine-grained control over task termination based on conversation patterns.
Key Features: - Specify multiple termination sequences - Use convenient DSL syntax or full object syntax - Strict consecutive matching (no skipping events) - Efficient implementation using message parent pointers
DSL Syntax (Recommended)¶
The DSL (Domain Specific Language) provides a concise way to specify sequences:
from langroid.agent.task import Task, TaskConfig
config = TaskConfig(
done_sequences=[
"T, A", # Tool followed by agent response
"T[calculator], A", # Specific calculator tool
"L, T, A, L", # LLM, tool, agent, LLM sequence
"C[quit|exit|bye]", # Content matching regex
"U, L, A", # User, LLM, agent sequence
]
)
task = Task(agent, config=config)
DSL Pattern Reference¶
Pattern | Description | Event Type |
---|---|---|
T |
Any tool | TOOL |
T[name] |
Specific tool by name | SPECIFIC_TOOL |
A |
Agent response | AGENT_RESPONSE |
L |
LLM response | LLM_RESPONSE |
U |
User response | USER_RESPONSE |
N |
No response | NO_RESPONSE |
C[pattern] |
Content matching regex | CONTENT_MATCH |
Examples:
- "T, A"
- Any tool followed by agent handling
- "T[search], A, T[calculator], A"
- Search tool, then calculator tool
- "L, C[complete|done|finished]"
- LLM response containing completion words
- "TOOL, AGENT"
- Full words also supported
Full Object Syntax¶
For more control, use the full object syntax:
from langroid.agent.task import (
Task, TaskConfig, DoneSequence, AgentEvent, EventType
)
config = TaskConfig(
done_sequences=[
DoneSequence(
name="tool_handled",
events=[
AgentEvent(event_type=EventType.TOOL),
AgentEvent(event_type=EventType.AGENT_RESPONSE),
]
),
DoneSequence(
name="specific_tool_pattern",
events=[
AgentEvent(
event_type=EventType.SPECIFIC_TOOL,
tool_name="calculator"
),
AgentEvent(event_type=EventType.AGENT_RESPONSE),
]
),
]
)
Event Types¶
The following event types are available:
EventType | Description | Additional Parameters |
---|---|---|
TOOL |
Any tool message generated | - |
SPECIFIC_TOOL |
Specific tool by name | tool_name |
LLM_RESPONSE |
LLM generates a response | - |
AGENT_RESPONSE |
Agent responds (e.g., handles tool) | - |
USER_RESPONSE |
User provides input | - |
CONTENT_MATCH |
Response matches regex pattern | content_pattern |
NO_RESPONSE |
No valid response from entity | - |
Examples¶
Example 1: Tool Completion¶
Task completes after any tool is used and handled:
This is equivalent to done_if_tool=True
but happens after the agent handles the tool.
Example 2: Multi-Step Process¶
Task completes after a specific conversation pattern:
config = TaskConfig(
done_sequences=["L, T[calculator], A, L"]
)
# Completes after: LLM response → calculator tool → agent handles → LLM summary
Example 3: Multiple Exit Conditions¶
Different ways to complete the task:
config = TaskConfig(
done_sequences=[
"C[quit|exit|bye]", # User says quit
"T[calculator], A", # Calculator used
"T[search], A, T[search], A", # Two searches performed
]
)
Example 4: Mixed Syntax¶
Combine DSL strings and full objects:
config = TaskConfig(
done_sequences=[
"T, A", # Simple DSL
DoneSequence( # Full control
name="complex_check",
events=[
AgentEvent(
event_type=EventType.SPECIFIC_TOOL,
tool_name="database_query",
responder="DatabaseAgent" # Can specify responder
),
AgentEvent(event_type=EventType.AGENT_RESPONSE),
]
),
]
)
Implementation Details¶
Message Chain Traversal¶
Done sequences are checked by traversing the message history using parent pointers:
def _get_message_chain(self, msg: ChatDocument, max_depth: Optional[int] = None):
"""Efficiently traverse message history via parent pointers"""
chain = []
current = msg
while current and (max_depth is None or len(chain) < max_depth):
chain.append(current)
current = current.parent
return chain
Strict Matching¶
Events must occur consecutively without intervening messages:
# This sequence: [TOOL, AGENT_RESPONSE]
# Matches: USER → LLM(tool) → AGENT
# Does NOT match: USER → LLM(tool) → USER → AGENT
Performance¶
- Efficient O(n) traversal where n is sequence length
- No full history scan needed
- Early termination on first matching sequence
Best Practices¶
-
Use DSL for Simple Cases
-
Name Your Sequences
-
Order Matters
- Put more specific sequences first
-
General patterns at the end
-
Test Your Sequences
-
Combine with Other Methods
Reference¶
Code Examples¶
- Basic example:
examples/basic/done_sequences_example.py
- Test cases:
tests/main/test_done_sequences.py
- DSL tests:
tests/main/test_done_sequences_dsl.py
- Parser tests:
tests/main/test_done_sequence_parser.py
Core Classes¶
TaskConfig
- Configuration includingdone_sequences
DoneSequence
- Container for event sequencesAgentEvent
- Individual event in a sequenceEventType
- Enumeration of event types
Parser Module¶
langroid.agent.done_sequence_parser
- DSL parsing functionality
Task Methods¶
Task.done()
- Main method that checks sequencesTask._matches_sequence_with_current()
- Sequence matching logicTask._classify_event()
- Event classificationTask._get_message_chain()
- Message traversal
Migration Guide¶
If you're currently overriding Task.done()
:
# Before: Custom done() method
class MyTask(Task):
def done(self, result=None, r=None):
if some_complex_logic(result):
return (True, StatusCode.DONE)
return super().done(result, r)
# After: Use done_sequences
config = TaskConfig(
done_sequences=["T[my_tool], A, L"] # Express as sequence
)
task = Task(agent, config=config) # No subclassing needed
Troubleshooting¶
Sequence not matching? - Check that events are truly consecutive (no intervening messages) - Use logging to see the actual message chain - Verify tool names match exactly
Type errors with DSL?
- Ensure you're using strings for DSL patterns
- Check that tool names in T[name]
don't contain special characters
Performance concerns? - Sequences only traverse as deep as needed - Consider shorter sequences for better performance - Use specific tool names to avoid unnecessary checks
Summary¶
The done_sequences
feature provides a powerful, declarative way to control task termination based on conversation patterns. The DSL syntax makes common cases simple while the full object syntax provides complete control when needed. This approach eliminates the need to subclass Task
and override done()
for most use cases, leading to cleaner, more maintainable code.