Strands Agents¶
You have an agent built with Strands Agents and
you want it to run as an Airflow task: after an upstream task, on a schedule, or
when an asset updates, reading data through connections your deployment already
manages. Keep the agent as it is. Build the model and the Agent yourself, and
give it Airflow’s toolsets with the
AirflowTools plugin.
Note
AirflowTools is experimental: it may change in a minor release of this provider.
The framework-neutral tool interface it builds on is stable.
See Stable and experimental features.
Run a Strands agent in a task¶
The agent below answers a question about a database. Its model’s API key comes
from an Airflow connection, and its tools come from a
SQLToolset, so the agent can
list tables, read schemas and run read-only queries, with results bounded to
what fits in the model’s context:
@dag(tags=["example"])
def example_strands_agent():
"""Answer a question about a database with a Strands agent."""
@task
def run_strands_agent(question: str = DEFAULT_QUESTION) -> str:
from strands import Agent
from strands.models.anthropic import AnthropicModel
from airflow.providers.common.ai.tools.strands import AirflowTools
from airflow.providers.common.ai.tools.tracing import agent_framework_tracing
from airflow.providers.common.ai.toolsets.sql import SQLToolset
llm = BaseHook.get_connection(LLM_CONN_ID)
model = AnthropicModel(
client_args={"api_key": llm.password, "base_url": llm.host or None},
model_id=LLM_MODEL,
max_tokens=2048,
)
# Spans carry the task's identity and no prompt text; see the tracing section of the guide.
with agent_framework_tracing():
agent = Agent(
model=model,
plugins=[AirflowTools(SQLToolset(db_conn_id=DB_CONN_ID))],
system_prompt=(
"You are a SQL analyst. Use list_tables and get_schema to explore "
"the database, then run read-only queries to answer the question."
),
# Strands streams the reply to stdout by default; the task returns it instead.
callback_handler=None,
)
return str(agent(question))
run_strands_agent()
Everything except AirflowTools is plain Strands. The agent’s loop, model
features, hooks and session handling work as the Strands documentation describes,
and tools you wrote with Strands’ own @tool decorator go in tools= as
usual, next to the plugin.
The model can come from any connection that holds its credentials. For Strands’
default Bedrock model, build BedrockModel(boto_session=...) from
AwsBaseHook(aws_conn_id=...).get_session() in the Amazon provider, so the
model uses the same AWS connection as the rest of your Dags.
What the plugin gives the agent¶
AirflowTools accepts any toolset that implements
ToolProvider, such as
SQLToolset and
HookToolset, and adds one
Strands tool per toolset tool. MCPToolset is the exception: connect Strands’ own
MCPClient to the server instead. Each tool:
Keeps the toolset tool’s name, description and argument schema.
Runs through the toolset itself, so connection resolution, SQL validation,
allowed_tables,allowed_methodsand result bounds behave as they do inAgentOperator.Passes every result through Airflow’s secret masker before it goes back to the model.
Writes a
Tool call: <name>group to the task log with the call’s duration.
Failures follow the same rule as in AgentOperator:
A failure the model can correct, such as a rejected SQL statement or an invalid argument, reaches it as a Strands result with
status="error", and the model can try again. The tool’s retry limit bounds how many times in a row it can do so.SQLToolsettreats every database error this way, so a query that fails because the database rejects the connection’s credentials ends the run once that limit is reached.A refusal the tool reports as final, by raising pydantic-ai’s
ToolFailed, also reaches the model as an error, and does not count against the retry limit. Bound the task withexecution_timeoutif a model could keep asking for it.Any other failure, such as a hook method raising, ends the agent run. Strands raises
EventLoopExceptionwith the maskedToolCallErroras itsoriginal_exception, the task fails, and Airflow’s own retry takes over. Strands on its own would hand such an error to the model and let the run carry on.
Outside AgentOperator, a toolset’s connection ID is used as written: it is not
rendered as a template, so pass the value itself rather than a Jinja expression.
The plugin is built on the framework-neutral tool interface described in Agent frameworks, which is also the starting point for an adapter to another framework.
Why masking tool results matters¶
Airflow masks connection passwords in task logs. Tool results are not logs:
they go to the model, to the model provider’s API, into any traces the framework
records, and often into the agent’s final answer, which your task may return as
an XCom. A tool whose client library puts a credentialed URL in its error message
would send the raw password to all of those places, even though the task log
shows ***.
Tools added by AirflowTools apply the masker to what they return, so a
registered secret is replaced with *** before it leaves the tool. The masker
knows the secrets Airflow has registered, such as connection passwords and
sensitive connection extras. It does not recognize a credential that exists only
in the data a tool returns, so the permissions of the connection’s database role
or cloud identity remain the real boundary on what the agent can reach.
Tools you write yourself with Strands’ @tool decorator do not pass through
the masker. To give one of your own functions the same treatment, wrap it as an
AirflowTool and pass it to
AirflowTools alongside the toolsets:
import asyncio
from airflow.providers.common.ai.tools import AirflowTool, ToolResult
from airflow.providers.common.ai.tools.strands import AirflowTools
async def lookup_customer(arguments: dict) -> ToolResult:
# crm_client.get blocks, so run it off the agent's event loop.
customer = await asyncio.to_thread(crm_client.get, arguments["customer_id"])
return ToolResult(content=customer)
lookup = AirflowTool(
name="lookup_customer",
description="Look up one customer in the CRM by id.",
parameters={
"type": "object",
"properties": {"customer_id": {"type": "integer"}},
"required": ["customer_id"],
},
function=lookup_customer,
)
agent = Agent(model=model, plugins=[AirflowTools(warehouse, lookup)])
Tracing¶
The example runs the agent inside
agent_framework_tracing(), so Strands’
OpenTelemetry spans carry the task’s identity and leave out prompts, completions and tool
inputs and outputs unless [common.ai] capture_content is on. Create the Agent
inside the block: Strands reads the switch once per process, when it creates its one
tracer, so an Agent created earlier in the process, such as at module level, keeps
content capture on. See
Observability (OpenTelemetry tracing).
Durable execution with Strands¶
When a task that calls agent(question) fails and Airflow retries it, the agent
starts again from the prompt: every model call is paid for again, and every tool
call happens again. On a task with retries, run the agent with
invoke_durably() instead, and a
retry resumes it from the last cycle boundary it reached:
@dag(tags=["example"])
def example_strands_durable():
"""Answer a question about a database with a Strands agent that survives task retries."""
@task(retries=2, retry_delay=timedelta(minutes=1))
def run_durable_strands_agent(question: str = DEFAULT_QUESTION) -> str:
from strands import Agent
from strands.models.anthropic import AnthropicModel
from airflow.providers.common.ai.durable.strands import invoke_durably
from airflow.providers.common.ai.tools.strands import AirflowTools
from airflow.providers.common.ai.toolsets.sql import SQLToolset
llm = BaseHook.get_connection(LLM_CONN_ID)
model = AnthropicModel(
client_args={"api_key": llm.password, "base_url": llm.host or None},
model_id=LLM_MODEL,
max_tokens=2048,
)
# invoke_durably builds the agent, adding the session manager and checkpointing.
agent = partial(
Agent,
model=model,
plugins=[AirflowTools(SQLToolset(db_conn_id=DB_CONN_ID))],
system_prompt=(
"You are a SQL analyst. Use list_tables and get_schema to explore "
"the database, then run read-only queries to answer the question."
),
callback_handler=None,
)
return str(invoke_durably(agent, question))
run_durable_strands_agent()
The example needs Airflow 3.3 or later, strands-agents, a connection holding an
Anthropic API key, and a database connection; its module docstring lists the details.
invoke_durably takes a function that builds the agent, here
functools.partial(Agent, ...), and calls it with two more arguments:
checkpointing=True and a SnapshotSessionManager. Strands
pauses an agent built with checkpointing
at each boundary of a cycle that calls tools: once the model has asked for tools,
and once they have run. It leaves keeping the conversation to a session manager.
At each pause invoke_durably has the session manager save the conversation,
with where the agent paused in agent.state["airflow_checkpoint"], as one
snapshot in the task instance’s
task state store, and the
agent carries on. Strands saves the snapshot on its own only to redact or trim the
conversation it already holds, so a failed invocation leaves the last pause’s
snapshot as it was. On a retry, the agent is rebuilt from the snapshot and resumed
from the pause. The task log says where. This line comes from a test Dag whose
model failed in its second cycle; cycle counts from 0:
[2026-10-07 11:03:38] INFO - Resuming the Strands agent from its last checkpoint session_id=f711ace605c22a218c598a0f9e9a19a3d392fbcf6551055d8af473bdff33dd72 position=after_tools cycle=0
invoke_durably returns at the agent’s first stop that is not a pause: its final
answer, or an interrupt or a cancellation, which it does not resume. It deletes the
saved state then, so clearing the task later starts the agent over. If that delete
fails, the task still succeeds and logs Could not delete the Strands agent's saved
state, and a later clear of the whole Dag run resumes from the last pause.
A retry runs some work again:
It resumes from a cycle boundary. If the task fails while a cycle’s tools run, the retry runs all of that cycle’s tools again, the finished ones included, so tools that change data must still be safe to repeat.
A task that fails before the model’s first request for tools starts over.
A retry whose prompt differs from the first try’s starts over.
The state is deleted when
invoke_durablyreturns, not when the task succeeds. If the task fails after that, for example in a second agent or while returning the answer, the retry runs the finished agent again from its prompt.A
SandboxToolsetwithoutattach_tois destroyed when the try ends, so a resumed agent works from a conversation that describes files the retry’s new, empty sandbox does not have. Attach the toolset to a sandbox another task owns, or keep sandbox work out of a durable agent.
A retry resumes the saved conversation with the system prompt it was saved with. So does clearing the whole Dag run, which keeps task state. To run a changed system prompt on a failed task, clear the task itself without keeping its task state.
An agent whose model keeps the conversation server-side (model.stateful) raises
ValueError: Strands’ session manager
drops a restored conversation for such a model, so it cannot be resumed.
The snapshot holds the whole conversation (the prompt, the model’s replies, every
tool call and result), the system prompt and agent.state. It goes to the
metadata database unless [workers] state_store_backend sends task state to
external storage, and anyone who can read the task instance’s task state can read
it. Each pause rewrites it whole, and once it is larger than
[state_store] max_value_storage_bytes (64 KB by default) every write logs a
warning; the write still goes through. AirflowTools masks
registered secrets in its tools’ results; results of your own @tool functions
are saved as returned. A task that never succeeds leaves its state in the task
state store until the Dag run is deleted. A snapshot offloaded to a custom
[workers] state_store_backend stays in that storage after the rows are gone,
and clearing the task does not reach it.
Within one try, a tool’s retry limit in AirflowTools counts across the agent’s
pauses; a task retry starts the count again. The AgentResult that
invoke_durably returns comes from the agent’s last invocation. Its metrics
cover every invocation of the current try, one per resume in
metrics.agent_invocations, and none of the earlier tries.
When a task runs more than one agent with invoke_durably, give each a
session_id of its own that stays the same across tries.
The task state store needs Airflow 3.3 or later. On an earlier version,
invoke_durably without storage raises
AirflowOptionalProviderFeatureException. Pass a Strands storage instead, such
as S3Storage, inside the task; AwsBaseHook comes from the Amazon provider:
from strands.storage import S3Storage
from airflow.providers.amazon.aws.hooks.base_aws import AwsBaseHook
storage = S3Storage(
"my-agent-state",
prefix="strands/",
boto_session=AwsBaseHook(aws_conn_id="aws_default").get_session(),
)
result = invoke_durably(agent, question, storage=storage)
The default session_id is derived from the Dag id, run id, task id and map
index, so it differs between Dag runs. If you pass your own session_id and the
storage is shared across Dag runs, as a bucket is, it must differ between runs too. invoke_durably deletes the
state when the agent finishes or a try starts over; a task that never succeeds
leaves it in the bucket.
Strands marks checkpointing experimental (strands.experimental.checkpoint). It
needs strands-agents 1.56 or later, as in Installation.
For Pydantic AI agents, see Durable execution.
Differences from AgentOperator¶
The agent is Strands’, so features that AgentOperator implements on top of
Pydantic AI work differently or do not apply:
Durable execution resumes from a cycle boundary rather than replaying each model call and tool call. See Durable execution with Strands.
There is no human review step like
AgentOperator(enable_hitl_review=True).
Installation¶
pip install "apache-airflow-providers-common-ai[sql]" "strands-agents>=1.56.0"
Pin the strands-agents lower bound as above; 1.56 is the release this
integration is tested with. Without a bound, a resolver that cannot satisfy Strands’
dependencies may pick the 0.0.1 placeholder release instead of reporting a
conflict. For AnthropicModel, install anthropic through this provider’s
anthropic extra rather than through Strands’ own anthropic extra, which
requires an anthropic release older than 1.0. Strands also pins mcp below 2.2, so
installing it downgrades a newer mcp, which MCPToolset uses.