Skip to content

Actions

Why Actions?

AI agents require tools to interact with the world, and so AI agents frameworks need to provide a way to expose tools to the LLM. Action is Conatus's way, but with some key features:

  • Function transparency: If you decorate a function with @action, it can still be called as a normal function. And, of course, you get type checking and validation for free.
  • Works with virtually ANY Python function: Traditional agent frameworks require functions to have parameters that are JSON-serializable, but Actions can work with any Python function. When paired with a Runtime, you can really turbocharge your agent's capabilities.
  • Schema extraction: Like other agent frameworks, Actions can extract a JSON schema from the function signature and docstring, which can be used to describe the action to an LLM.

All of this, in turns, enables you to build agentic systems that are:

  • Compatible with existing code: You can use @action on existing functions, or even on bound methods.
  • Modular: You can re-use actions in different parts of your codebase, and even in different projects. The actions are loosely coupled to the rest of the codebase, and can be used in different contexts.

Function transparency

Every action is fundamentally "just" a Python function, with its arguments, docstring, and return value. If you decorate a function with @action, it can still be called as a normal function:

from conatus import action
from conatus.actions.exceptions import ActionParamValidationError

@action
def double(x: int) -> int:
    """Return 2*x."""
    return 2 * x

assert double(4) == 8

try:
    double("not an int")
except ActionParamValidationError as e:
    print(e) # (1)!
  1. You should get something like this:

    Pydantic rejected the input arguments. The arguments are correctly passed, but the values are not compatible with the expected types.
     - Input arguments: OrderedDict({'x': 'not an int'})
     - Errors: [{'type': 'int_parsing', 'loc': ('x',), 'msg': 'Input should be a valid integer, unable to parse string as an integer', 'input': 'not an int', 'url': 'https://errors.pydantic.dev/2.11/v/int_parsing'}]
    

This means:

  • You can call an action instance directly. double(4) above is not magic.
  • Type-checking and validation happens automatically, so wrong inputs give clear errors.

Works with virtually ANY Python function

Most agent frameworks require functions to be JSON-serializable. In the real world, you often want to pass more than just JSON-serializable objects to a function, like a pandas DataFrame, a browser instance, or a database connection.

We solve this by allowing you to pass functions by reference, and by value.

  • Pass by reference: If the function takes a non-serializable object, the LLM will select from available variables with compatible types, using a variable reference syntax (e.g., "<<var:df1>>").
  • Pass by value: If the function takes a serializable object, the LLM will write out the object directly. Of course, if there are objects that can also be passed by reference, the LLM can select from those.
from conatus import action
import pandas as pd

@action
def print_df(df: pd.DataFrame):
    print(df)

print_df(pd.DataFrame({"a": [1, 2, 3]}))
print_df.json_schema.model_json_schema()

You should get something like this:

JSON Schema
{
  "$defs": {
    "df_possible_variables": {
      "description": "You can pass 'df' by reference with a formatted reference '<<var:{name}>>' to a variable compatible with type 'pandas.DataFrame' among ['test_var0']",
      "enum": ["<<var:test_var0>>"],
      "title": "df_possible_variables",
      "type": "string"
    },
    "possible_return_assignment": {
      "enum": ["test_var0"],
      "title": "possible_return_assignment",
      "type": "string"
    }
  },
  "properties": {
    "df": {
      "$ref": "#/$defs/df_possible_variables",
      "description": "(type: pandas.DataFrame) <No description>"
    },
    "return": {
      "anyOf": [
        { "$ref": "#/$defs/possible_return_assignment" },
        { "type": "null" }
      ],
      "description": "If you want this action to assign the return value to a variable, pass the name of the variable in this `return` parameter. If you pass a null value, we will create a new variable automatically.\nThis is OPTIONAL. Only use it if it makes sense."
    }
  },
  "required": ["df", "return"],
  "title": "print_dfJSONSchema",
  "type": "object"
}

As you can see by clicking on (+), the schema allows the LLM to pass a pandas DataFrame by reference.

Because we don't have a Runtime in this example, we use a test variable (e.g., <<var:test_var0>>). When connected to a Runtime, we will be able to expose the right variables to the LLM, based on their types. We will also make sure that the action is exposed only when the right variables are available.

from conatus import action
import pandas as pd
from conatus.runtime import Runtime

@action
def print_df(df: pd.DataFrame):
    print(df)

runtime = Runtime(actions=[print_df])

# No tool specifications yet, because no compatible variables are available
assert runtime.get_tool_specifications() == []

# Import a variable
runtime.import_variable(name="df1", value=pd.DataFrame({"a": [1, 2, 3]}))

# Now we have a tool specification
print(runtime.get_tool_specifications()[0].json_schema.model_json_schema())

You should get something like this:

JSON Schema
{
  "$defs": {
    "df_possible_variables": {
      "description": "You can pass 'df' by reference with a formatted reference '<<var:{name}>>' to a variable compatible with type 'pandas.DataFrame' among ['df1']",
      "enum": ["<<var:df1>>"],
      "title": "df_possible_variables",
      "type": "string"
    },
    "possible_return_assignment": {
      "enum": ["df1"],
      "title": "possible_return_assignment",
      "type": "string"
    }
  },
  "properties": {
    "df": {
      "$ref": "#/$defs/df_possible_variables",
      "description": "(type: pandas.DataFrame) <No description>"
    },
    "return": {
      "anyOf": [
        { "$ref": "#/$defs/possible_return_assignment" },
        { "type": "null" }
      ],
      "description": "If you want this action to assign the return value to a variable, pass the name of the variable in this `return` parameter. If you pass a null value, we will create a new variable automatically.\nThis is OPTIONAL. Only use it if it makes sense."
    }
  },
  "required": ["df", "return"],
  "title": "print_dfJSONSchema",
  "type": "object"
}

Schema extraction

Under the hood, Actions embed (and auto-generate):

These are built at definition time, and dynamically updated at runtime when relevant.

Strict mode vs Non-strict mode

When exposing actions to LLMs (e.g. via OpenAI function calling), the strict mode schema enforces that:

  • The agent must provide every required argument.
  • Output and input types are forcibly constrained; e.g., no extra fields are accepted.
  • The schema matches the runtime's expectations exactly.

In non-strict mode, some type flexibility is allowed, which can be more forgiving for loose function signatures or if types are partially specified. Because AI providers only support a subset of JSON schema features, there are some Actions that can only produce non-strict schemas. 1

To control this, you can set strict_mode=True or False. By default, we auto-detect whether the function is strict or not.

Controlling the behavior of actions

To customize the behavior of actions, you can use either the @action decorator or the with_config method. Here are a few possibilities.

Force variable updates

If you have an Action that mutates a variable, you can use the force_update_on argument to notify the Runtime that the variable has been mutated. This ensures that the LLM will see the change in the variable's value history.

For example, the following code will ensure that the Runtime will see the change in the browser variable's value history. 2

from conatus import action
from conatus.utils.browser import SimpleBrowser

@action(force_update_on="browser")
def browser_goto(browser: SimpleBrowser, url: str) -> None:
    """Go to a URL.

    Ultimately, if you provide a URL that is invalid, the browser will
    throw an error.

    Args:
        browser: The browser to use.
        url: The URL to go to.
    """
    _ = browser.goto(url)

The update works on the object passed

The force_update_on argument works by tracking the id of the object passed as an argument. In practice, it means that we only look at the object that was passed to the action as an argument. This means that you should there are a few caveats:

  • Simple objects (e.g., strings, numbers, booleans) are passed by value, and so their id is not tracked.
  • Re-assignments will not be reflected in the variable's value history. If you assign another object to the same variable name, we will not detect that new object as a modification of the variable.

Passive vs Active actions

WIP

Termination actions

WIP

Sometimes, you want an action that marks the end of the agent's run (like a return in a regular function). The terminate action is a special case:

  • It expects the run's outputs as arguments.
  • It sets a termination sentinel in the runtime so the agent stops.

Retrievability / With Config

WIP

You can tweak metadata, type hint handling, "strict mode" enforcement, etc. — either at definition time or dynamically:

## Override type hint for LLM schema (uses docstring not annotation)
# fetch_headlines2 = fetch_headlines.with_config(override_type_hint_for_llm=True)

## Add a custom description
# fetch_headlines3 = fetch_headlines.with_config(desc="Get news by topic")

Actions can optionally be retrievable — that is, they can be exported into a recipe and re-imported elsewhere. This is tracked in retrievability_info.

If you want to turn a run into a reproducible recipe, ensure your actions are retrievable.
(For more: [get_maybe_recipe_retrievability_warnings] and [get_maybe_recipe_retrievability_instructions]).

Action starter packs

Starter packs (ActionStarterPack) provide a curated, ergonomic way to bundle actions for agent use.

Why Starter packs?

  • Reduce boilerplate when specifying many standard actions (e.g., for browsers).
  • Easily re-use, extend, or combine common tool sets.
  • All methods (+, -, len(), etc.) behave as expected.
  • Starter packs can be passed anywhere a list of actions is expected.

One common starter pack is the browsing_actions starter pack, which includes actions for browsing the web.

from conatus import browsing_actions, Task, action

@action
def summarize(text: str) -> str:
    ...

task = Task(
    "Summarize a web page",
    name="summarize_web_page",
    actions=[browsing_actions, summarize],  # Packs work here!
)

Creating your own starter packs

If you want to group related actions, you can create your own starter pack:

from conatus import action, browsing_actions
from conatus.actions.starter_packs import ActionStarterPack

@action
def fetch_headlines(topic: str) -> list[str]:
    """Fetch the latest headlines for a given topic."""
    return ["Example: ..."]

@action
def summarize_headlines(headlines: list[str]) -> str:
    """Summarize a list of headlines."""
    return "Example: ..."

pack = ActionStarterPack(fetch_headlines, summarize_headlines)
all_actions = browsing_actions + pack

Advanced: Modifying the Runtime state

In general, you will want to use the @action decorator to create your action. If you want to modify the state of the Runtime, you will need to create a subclass of Action instead:

from conatus.actions.action import Action

class MyAction(Action):

    @Action.function # (1)!
    def my_action(self, a: int) -> int:
        if self.runtime_state is not None:
            self.runtime_state.add_result(a + 1, variable_name="a")
        return a + 1
  1. For now, we're requiring that you use the @Action.function decorator to declare which class method is the entrypoint of the action, but that might change in the future.

  1. For example, tuples are not properly supported by OpenAI function calling. 

  2. Note that the runtime variable does not have to be named browser in the Runtime. We automatically detect the variable name from the argument name.