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 aRuntime, 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
@actionon 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)!
-
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):
FunctionInfo: a structured object capturing the function's name, description, argument types/descriptions, and return type. SeeAction.function_info.- Pydantic models: for validating inputs/outputs, extracted from the
function signature and docstring. See
Action.pydantic_models. - JSON schema: an LLM-friendly schema (as a
BaseModel) used to describe the action to an agent/API (viaAction.json_schema). That schema can then be passed togenerate_openai_json_schemato get a JSON schema that is compatible with the AI provider's APIs.
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
idis 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
- For now, we're requiring that you use the
@Action.functiondecorator to declare which class method is the entrypoint of the action, but that might change in the future.