Skip to content

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
  1. Just define a task like a Python function, and add the tools that the AI will be able to use to perform the task.
  2. Your function can accept any arguments, and return any value. Underneath, the LLM can call a function by passing variables, not raw values.
  3. You can add a docstring to indicate more precisely what you want to do. This is not necessary, but it's a good practice.
  4. 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.
  5. 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 calling hn_stories_df.run(top_n = 3).
  6. Now you can retrieve the playbook, and re-run the task without soliciting the AIs at all.
  7. At this stage, this is also equivalent to calling hn_stories_df.run(top_n = 3), since Task.run defaults 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.

uv run https://docs.conatus-ai.com/hn_stories.py

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

  2. 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:
    ...
  1. 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:
    ...
  1. A task needs to define an agent class, because we create instances on the fly.

  2. A list will also work, but frozenset ensures that the attribute cannot be modified across different instances of the class.

  3. Similarly, we have implemented frozendict to ensure that the attribute cannot be modified across different instances of the class.

Do you want to ensure that you only use Anthropic models?

from conatus import task

@task(preferred_provider="anthropic")
def my_task():
    ...

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

  1. In general, traditional AI agent frameworks only accept functions with JSON-serializable inputs/outputs. 

  2. In general, for anything that more