Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add builtin validators and docs #294

Merged
merged 1 commit into from
Sep 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 66 additions & 47 deletions docs/patterns/task-results.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ icon: square-check

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.

## String results
### 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.

Expand Down Expand Up @@ -41,11 +42,8 @@ In three languages, "Hello" can be expressed as follows:

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.

## Builtin types

You can cast task results to any of Python's built-in types.

### Basic types
### Numbers

If your result is a number, you can specify the `result_type` as `int` or `float`:

Expand All @@ -63,6 +61,7 @@ assert isinstance(result, int)
```
</CodeGroup>

### Booleans
You can use `bool` for tasks whose result is a simple true/false value:

<CodeGroup>
Expand All @@ -80,7 +79,8 @@ False
</CodeGroup>


### Compound types
### 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):
Expand Down Expand Up @@ -128,7 +128,7 @@ print(result)
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](#result-validators) to enforce the constraint.
</Tip>

## Classification
### Specific values

You can limit the result to one of a specific set of values, in order to label or classify a response. To do this, specify a list or tuple of allowed values for the result type. Here, we classify the media type of "Star Wars: Return of the Jedi":

Expand All @@ -154,7 +154,7 @@ movie
For classification tasks, ControlFlow asks agents to choose a value by index rather than writing out the entire response. This optimization significantly improves latency while also conserving output tokens.
</Tip>

## Structured results
### 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.

Expand Down Expand Up @@ -199,12 +199,36 @@ ResearchReport(
```
</CodeGroup>

### Advanced validation
### No result

Because Pydantic models are fully hydrated by ControlFlow, you can use any of Pydantic's built-in or custom validators to further constrain or modify the result after it has been produced.
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.

<CodeGroup>
```python
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.

<CodeGroup>
```python Code
import controlflow as cf
from pydantic import BaseModel, field_validator

class SentimentAnalysis(BaseModel):
Expand All @@ -231,60 +255,55 @@ SentimentAnalysis(text='I love ControlFlow!', sentiment=0.9)
```
</CodeGroup>

## No result
### Validation functions

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.
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.

```python
import controlflow as cf
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:

def status_tool(status: str) -> None:
"""Submit a status update to the workflow thread."""
print(f"Submitting status update: {status}")
- `between(min_value, max_value)`: Validates that the result is a float between `min_value` and `max_value`.
- `has_len(min_length, max_length)`: Validates that the result is a string, list, or tuple with a length between `min_length` and `max_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.

cf.run(
"Use your tool to submit a status update",
result_type=None,
tools=[status_tool],
)
```
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.

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.

<Tip>
Remember that result validators must either **return** the result or **raise** an exception. They are not true/false checks!
</Tip>

## Custom result validation
```python
import controlflow as cf
from controlflow.tasks.validators import chain, between

In addition to using Pydantic validation, you can also supply a custom validation function as the task's `result_validator`.
def is_even(value: int) -> int:
if value % 2 != 0:
raise ValueError("Value must be even")
return value

After the raw LLM result has been coerced into the `result_type`, it will be passed to your custom validator, which must either return the result or raise an exception. This gives you the opportunity to perform additional validation or modification of the result.
cf.run(
"Generate an even number between 1 and 100",
result_type=int,
result_validator=chain(between(1, 100), is_even),
)
```

### Modifying the result

<CodeGroup>
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.

```python Code
```python
import controlflow as cf

def constrain_sentiment(value: float) -> float:
if not 0 <= value <= 1:
raise ValueError("Sentiment must be between 0 and 1")
return value
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=constrain_sentiment,
result_validator=round_to_one_decimal_place,
)

print(sentiment)
```

```text Result
0.9
```
</CodeGroup>
Because the output of the result validator is used as the result, you can use it 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. Note, however, that result validation takes place *after* the raw LLM result has been coerced to the provided `result_type`.

<Tip>
Remember that result validators must either **return** the result or **raise** an exception. They are not true/false checks!
</Tip>
34 changes: 34 additions & 0 deletions src/controlflow/cli/dev.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import os
import subprocess
from pathlib import Path

import typer
Expand All @@ -14,6 +16,10 @@ def generate_ai_files(
help="The path where output files will be written. Defaults to current directory.",
),
):
"""
Generates two markdown files that contain all of ControlFlow's source code and documentation,
which can be used to provide context to an AI.
"""
try:
# Get the absolute path of the ControlFlow main repo
repo_root = Path(__file__).resolve().parents[3]
Expand Down Expand Up @@ -43,3 +49,31 @@ def generate_file_content(file_paths, output_file):
except Exception as e:
typer.echo(f"An error occurred: {str(e)}", err=True)
raise typer.Exit(code=1)


@dev_app.command()
def docs():
"""
This is equivalent to 'cd docs && mintlify dev' from the ControlFlow root.
"""
try:
# Get the absolute path of the ControlFlow main repo
repo_root = Path(__file__).resolve().parents[3]
docs_path = repo_root / "docs"

if not docs_path.exists():
typer.echo(f"Error: Docs directory not found at {docs_path}", err=True)
raise typer.Exit(code=1)

typer.echo(f"Changing directory to: {docs_path}")
os.chdir(docs_path)

typer.echo("Running 'mintlify dev'...")
subprocess.run(["mintlify", "dev"], check=True)

except subprocess.CalledProcessError as e:
typer.echo(f"Error running 'mintlify dev': {str(e)}", err=True)
raise typer.Exit(code=1)
except Exception as e:
typer.echo(f"An error occurred: {str(e)}", err=True)
raise typer.Exit(code=1)
Loading