Task Results
Validate task outputs with structured result types.
ControlFlow tasks are designed to translate between the unstructured, conversational world of your AI agents and the structured, programmatic world of your application. The primary mechanism for this translation is the task’s result, which should be a well-defined, validated output that can be used by other tasks or components in your workflow.
Structured results
ControlFlow allows you to specify the expected structure of a task’s result using the result_type
parameter. This ensures that the result conforms to a specific data schema, making it easier to work with and reducing the risk of errors in downstream tasks.
Strings
By default, the result_type
of a task is a string, which essentially means the agent can return any value that satisfies the task’s objective.
For example, if you ask an agent to “Say hello in three languages”, it might return a simple string like "Hello; Hola; Bonjour"
or a more complex, conversational response instead:
Sometimes this flexibility is useful, especially if your task’s result will only be consumed as the input to another ControlFlow task. However, it can also lead to ambiguity and errors if the agent produces unexpected output, and is difficult to work with in an automated or programmatic way.
Numbers
If your result is a number, you can specify the result_type
as int
or float
:
Booleans
You can use bool
for tasks whose result is a simple true/false value:
Collections
You can also use typed collections like lists and dicts to specify the structure of your task’s result.
Let’s revisit the example of asking an agent to say hello in three languages, but this time specifying that the result should be a list of strings, or list[str]
. This forces the agent to produce the result you probably expected (three separate strings, each representing a greeting in a different language):
Annotated types
Sometimes, data types are not precise enough to guide the agent to the desired result. In these cases, you can use an annotated type to provide more specific instructions.
For example, if we want to ensure that the agent returns a string that is only a zip code, we can specify the result_type
as Annotated[str, "a 5 digit zip code"]
.
Note that annotated types are not validated; the annotation is provided as part of the agent’s natural language instructions. You could additionaly provide a custom result validator to enforce the constraint.
Labeling / classification
Often, you may want an agent to choose a value from a specific set of options, in order to label or classify a response as one of potentially many choices.
To do this, specify a list, tuple, Literal
, or enum of allowed values for the result type. Here, we classify the media type of “Star Wars: Return of the Jedi” from a list of options:
ControlFlow optimizes single-choice constrained selection by asking agents to choose a value by index rather than writing out the entire response. This optimization significantly improves latency while also conserving output tokens.
You can provide almost any Python object as a constrained choice, and ControlFlow will return that object as the result. Note that objects must be serialized in order to be shown to the agent.
A list of labels
When you provide a set of constrained choices, the agent will choose one and only one as the task’s result. Often, you will want to produce a list of labels, either because you want to classify multiple items at once OR because you want to allow the agent to choose multiple values for a single input. To do so, you must indicate that your expected result type is a list of either Literal
values or enum members.
In the following example, two media types are provided as context, and because the result type is a list, the agent is able to produce two responses:
In this example, the agent is able to choose multiple values for a single input, and the result is a list of strings:
Labeling multiple inputs at once relies on Python’s built-in type annotations and does not provide the same list- and tuple-aware optimizations and sugar that ControlFlow provides for single-choice constrained selection. Therefore the following syntax, which is not considered proper Python, will error:
cf.run(
...,
result_type=list[["A", "B"]]
)
but using a Literal
or enum will work:
cf.run(
...,
result_type=list[Literal["A", "B"]]
)
Pydantic models
For complex, structured results, you can use a Pydantic model as the result_type
. Pydantic models provide a powerful way to define data schemas and validate input data.
No result
Sometimes, you may want to ask an agent to perform an action without expecting or requiring a result. In this case, you can specify result_type=None
. For example, you might want to ask an agent to use a tool or post a message to the workflow thread, without requiring any task output.
import controlflow as cf
def status_tool(status: str) -> None:
"""Submit a status update to the workflow thread."""
print(f"Submitting status update: {status}")
cf.run(
"Use your tool to submit a status update",
result_type=None,
tools=[status_tool],
)
Note that it is generally recommended to ask agents to produce a result, even if its just a quick status update. This is because other agents in the workflow can usually see the result of a task, but they may not be able to see any tool calls, messages, or side effects that the agent used to produce the result. Therefore, results can be helpful even if the assigned agent doesn’t need them.
Validation
Pydantic
When using a Pydantic model as the result_type
, you can use any of Pydantic’s built-in or custom validators to further constrain or modify the result after it has been produced.
Validation functions
If you supply a function as your task’s result_validator
, it can be used to further validate or even modify the result after it has been produced by an agent.
The result validator will be called with the LLM result after it has been coerced into the result_type
, and must either return a validated result or raise an exception. ControlFlow supplies a few common validators to get you started:
between(min_value, max_value)
: Validates that the result is a float betweenmin_value
andmax_value
.has_len(min_length, max_length)
: Validates that the result is a string, list, or tuple with a length betweenmin_length
andmax_length
.has_keys(required_keys)
: Validates that the result is a dictionary with all of the specified keys.is_url()
: Validates that the result is a string that is a URL.is_email()
: Validates that the result is a string that is an email address.
These are available in the controlflow.tasks.validators
module, along with a convenient chain
function that allows you to combine multiple validators into a single function.
Remember that result validators must either return the result or raise an exception. They are not true/false checks!
import controlflow as cf
from controlflow.tasks.validators import chain, between
def is_even(value: int) -> int:
if value % 2 != 0:
raise ValueError("Value must be even")
return value
cf.run(
"Generate an even number between 1 and 100",
result_type=int,
result_validator=chain(between(1, 100), is_even),
)
Modifying the result
You can also use a result validator to modify the result after it has been produced by an agent. For example, you might want to round a floating point number or convert a string to a specific format.
import controlflow as cf
def round_to_one_decimal_place(value: float) -> float:
return round(value, 1)
sentiment = cf.run(
"Analyze sentiment of given text",
result_type=float,
context=dict(text="I love ControlFlow!"),
result_validator=round_to_one_decimal_place,
)