Conatus ¶
AI agents for grown-ups.
Developing AI agents is frustrating:
- Why do you have to run the agent every time you want to accomplish the same task?
- Why do you have to customize your tools / functions for each agent framework, and sometimes even for each agent?
- Why does it just feel so unnecessarily complicated?
Conatus is an attempt to fix all of that.
Features ¶
Tasks, not agents — Users want to get stuff done. We make it easy
to define and customize agents, but you shouldn't need to do that most of the
time.
Optimized for web browsing tasks — We leverage Playwright and
plenty of custom logic to make web browsing tasks easier.
Replay mode — Whenever an agent runs, it creates a
playbook that can be re-run later as valid Python code, without re-soliciting
the AIs.
Very Pythonic — Write your tasks as Python functions. Write your
tools as Python functions. It will just work.
Modular, customizable architecture — You can easily add your own
tools, extend your own agent or task, and customize the behavior of the framework.
Say goodbye to monolithic AI agents.
BYOF (Bring Your Own Functions) — Unlike traditional AI agent frameworks,
Conatus allows you to work with almost any existing Python function.
No arbitrary code by default — This is not Devin. The AI will only
use the functions given to it, unless you explicitly enable it.
Example ¶
This code will work as long as you have a valid OPENAI_API_KEY in your
environment:
# /// script
# dependencies = ["conatus[pandas]"]
# ///
from conatus import task, web_actions, pandas_actions
import pandas as pd
@task(actions=[web_actions, pandas_actions]) # (1)!
def hn_stories_df(top_n: int = 10) -> pd.DataFrame: # (2)!
"""Get the top (n) stories from Hacker News.
The result should be a Pandas DataFrame with the following columns:
'url', 'title', 'points'.
""" # (3)!
hn_homepage = "https://news.ycombinator.com" # (4)!
...
df = hn_stories_df(top_n = 3) # (5)!
print(df)
""" url title points
0 https://emoji.build/deal-w... Show HN: I built the most ... 832
1 https://github.com/Snowiii... Show HN: Pumpkin – A Mode... 419
2 https://sqlite.org/wasm/do... Sqlite3 WebAssembly 647
"""
# From now on, no AI required
hn_stories_playbook = hn_stories_df.playbook # (6)!
first_df = hn_stories_playbook(top_n = 3) # (7)!
second_df = hn_stories_playbook(top_n = 3) # (8)!
assert first_df == second_df
- Just define a task like a Python function, and add the tools that the AI will be able to use to perform the task.
- Your function can accept any arguments, and return any value. Underneath, the LLM can call a function by passing variables, not raw values.
- You can add a docstring to indicate more precisely what you want to do. This is not necessary, but it's a good practice.
- Since this task is going to be executed by an agent, the body will not be executed. Nevertheless, you can use the body to define variables that will be used in the task.
- Run the task. This will create API calls to the AIs. The result of the
run is the Pandas DataFrame that you specified in your signature.
This is equivalent to callinghn_stories_df.run(top_n = 3). - Now you can retrieve the playbook, and re-run the task without soliciting the AIs at all.
- At this stage, this is also equivalent to calling
hn_stories_df.run(top_n = 3), sinceTask.rundefaults to executing the playbook.
Run this example in one line ¶
You can run a modified version of this code with uv.
It's basically the same code, but it will require your consent before calling
an AI or writing anything to disk.
Re-running tasks later ¶
By default, this code will also write the playbook to a folder in the current working directory, so you can re-run this later, no LLM required:
from conatus import Playbook
import pandas as pd
hn_stories_df = Playbook[[int], pd.DataFrame]("hn_stories_df") # (1)!
try:
df = hn_stories_df.run(top_n = 3)
except Exception as e: # (2)!
print(e)
hn_stories_df.rewrite()
df = hn_stories_df.run(top_n = 3)
-
You do not have to specify the type of the playbook, but it's a good idea since type checkers will not be able to infer it from the playbook's name.
-
If you encounter an error, you can just ask the LLM to rewrite the playbook.
Extremely extensible ¶
Do you want to create a custom task that guarantees that some specific actions are always available, and uses an multi-agent system that periodically performs a critique on how the task is going? It's just a few lines of code:
from conatus import Task
WebTask = Task.customize(
agent_cls=CritiqueMultiAgent,
additional_actions=web_actions,
additional_constants={
"hn_homepage": "https://news.ycombinator.com"
}
)
WebPandasTask = WebTask.customize(additional_actions=pandas_actions)
# How to use it
@task(using=WebPandasTask)
def hn_stories_df_with_critique(top_n: int = 10) -> pd.DataFrame:
...
from conatus import CritiqueMultiAgent, Task, pandas_actions, web_actions
# Beware, the attributes of this class can be modified across different
# instances of the class!
class WebPandasTask(Task):
agent_cls = CritiqueMultiAgent # (1)!
additional_actions = [web_actions, pandas_actions]
additional_constants = {
"hn_homepage": "https://news.ycombinator.com"
}
# How to use it
@task(using=WebPandasTask)
def hn_stories_df_with_critique(top_n: int = 10) -> pd.DataFrame:
...
- A task needs to define an agent class, because we create instances on the fly.
from typing import TypeVar, ParamSpec
from conatus import CritiqueMultiAgent, Task, pandas_actions, web_actions
from conatus.utils import frozendict
R, Ps = TypeVar("R"), ParamSpec("Ps")
class WebPandasTask(Task[R, Ps]):
agent_cls = CritiqueMultiAgent # (1)!
additional_actions = frozenset([web_actions, pandas_actions]) # (2)!
additional_constants = frozendict({ # (3)!
"hn_homepage": "https://news.ycombinator.com"
})
# How to use it
@task(using=WebPandasTask)
def hn_stories_df_with_critique(top_n: int = 10) -> pd.DataFrame:
...
-
A task needs to define an agent class, because we create instances on the fly.
-
A list will also work, but
frozensetensures that the attribute cannot be modified across different instances of the class. -
Similarly, we have implemented
frozendictto ensure that the attribute cannot be modified across different instances of the class.
Do you want to ensure that you only use Anthropic models?
Do you want to write an action that has a signature for when it's called by an AI, and a different signature for when it's called by a playbook?
from conatus import Action
from conatus.browser import Browser, NodeSpec
from typing import overload
class BrowserClick(Action):
@overload
@Action.ai_signature
def click(browser: Browser, node: int) -> Browser:
"""Click on a node with the given ID.
Args:
browser: The browser instance.
node: The ID of the node to click on.
Returns:
The browser instance.
"""
@overload
@Action.playbook_signature
def click(browser: Browser, node: NodeSpec) -> Browser: ...
def click(browser: Browser, node: int | NodeSpec) -> Browser:
if self.called_by_ai:
node_spec = browser.create_node_spec(node=node)
self.make_argument_constant("node", node_spec)
return browser.click(node=node)
else:
node = browser.get_node_by_spec(node_spec=node)
return browser.click(node=node)
Comparison table ¶
| Feature | Conatus | Traditional AI agent frameworks |
|---|---|---|
| Replay mode | ✅ | You have to run the agent every time |
| BYOF | ✅ | Only functions with simple inputs/outputs 1 |
| Modular architecture | ✅ | You might have to rewrite your functions for each agent 2 |