Agents in ETL steps (WithAgent)¶
A step that needs a model to answer something about each row (classify a
message, extract a field, label a reply) declares the agent with WithAgent,
the way it declares a config value with FromConfig. The agent itself is an
agent artifact in the repository; the model, its region
and its price are deployment config under ai:. The step stays synchronous
and Polars: it receives an AgentMapper and calls map on a frame.
pip install "loom-kernel[etl-polars,ai-bedrock]"
A complete example¶
The output type, next to the step that uses it. It must reject unknown fields,
as every type_ref output does:
# src/pipelines/respondio/agents/seller_reply.py
from typing import Annotated, Literal
import msgspec
class SellerReply(msgspec.Struct, frozen=True, forbid_unknown_fields=True):
respuesta: Literal["acepta", "contraoferta", "evalua", "rechaza", "otro"]
motivo: Literal["ya_vendida", "precio", "competencia", "sin_motivo"] | None
confianza: Annotated[float, msgspec.Meta(ge=0, le=1)]
The artifact, reviewed in pull requests like any other code:
# src/pipelines/respondio/agents/seller_reply.agent.yaml
spec_version: 1
name: seller_reply
description: Classifies the seller's answer to our appraisal.
model_role: classifier
instructions:
- name: guide
text: |
Classify the seller's message as one of acepta, contraoferta, evalua,
rechaza or otro. Give a reason only when the answer is rechaza.
output: {kind: type_ref, ref: "pipelines.respondio.agents.seller_reply:SellerReply"}
policies: {retries: 2, run_timeout_ms: 60000, max_requests: 3, max_usd: 0.01, on_unpriced_spend: refuse}
The pipeline config, next to storage:. specs resolve against the
directory holding the YAML and may not leave it (see below for a config kept
in a subfolder):
# etl.yaml, at the repository root
storage:
engine: polars
# ...
ai:
engine: pydantic-ai
specs: ["src/pipelines/respondio/agents/*.agent.yaml"]
max_concurrent_runs: 8
models:
classifier:
provider: bedrock
model: eu.anthropic.claude-haiku-5-5
region: eu-west-1
output_mode: tool
options: {max_tokens: 3000}
prices: # USD per million tokens
eu.anthropic.claude-haiku-5-5:
input: 0.11
output: 0.55
cache_read: 0.011
cache_write: 0.1375
source: AWS Pricing API eu-west-1
as_of: 2026-10-09
A config in a subfolder¶
specs resolve against ai.root, a path relative to the directory holding
the YAML; without ai.root, against that directory itself. A glob may not
leave the directory it resolves against, so a config kept under
config/pipelines/ names the repository root with ai.root and its globs
from there:
# config/pipelines/respondio.yaml
ai:
engine: pydantic-ai
root: ../.. # the repository root
specs: ["src/pipelines/respondio/agents/*.agent.yaml"]
# models, prices, ...
ai.root is trusted config and may point anywhere; .. in a specs glob is
still refused (AGENT_COMPILATION_FAILED carrying AGENT_SPECS_ESCAPE_ROOT).
The step reads the messages and a bounded window of what is already labelled, keeps the pending ones with an anti-join, and maps them:
from decimal import Decimal
import polars as pl
from loom.etl import AgentMapper, ETLStep, FromTable, IntoTable, WithAgent, col, params
from pipelines.respondio.agents.seller_reply import SellerReply
KEYS = ("workspace", "message_id")
TARGET = "fact.seller_reply"
class SellerReplyStep(ETLStep[DailyParams]):
messages = FromTable("prep.seller_reply").where(col("message_date") == params.run_date)
stored = FromTable(TARGET).where(col("message_date") == params.run_date)
agent = WithAgent("seller_reply", output=SellerReply, max_usd=Decimal("2.00"))
target = IntoTable(TARGET).upsert(keys=(*KEYS, "agent_version"))
def execute(
self,
params: DailyParams,
*,
messages: pl.LazyFrame,
stored: pl.LazyFrame,
agent: AgentMapper,
) -> pl.LazyFrame:
done = stored.filter(pl.col("agent_version") == agent.version).select(KEYS)
pending = messages.join(done, on=list(KEYS), how="anti")
prompt = pl.format(
"<our_message>\n{}\n</our_message>\n<seller_message>\n{}\n</seller_message>",
pl.col("our_text").fill_null("(none)"),
pl.col("seller_text"),
)
return agent.map(pending, keys=KEYS, prompt=prompt).lazy()
runner = ETLRunner.from_yaml("etl.yaml")
runner.run(RespondioPipeline, DailyParams(run_date=date(2026, 10, 9)))
What map returns¶
One row per input row, in the input order, failures included:
Column |
Content |
|---|---|
the |
copied from the input row |
one per field of |
the answer, null on an error row |
|
the agent’s fingerprint (see below) |
|
|
|
null, a run error code ( |
|
what the run consumed, |
|
what the run cost in USD, as |
prompt is a Polars expression or a column name; a null prompt is not sent.
A key named like a column map writes is refused with a ValueError before
anything runs; an output field named like an agent_* column fails
compilation (AGENT_COLUMN_COLLISION).
The answer columns take their Polars type from the field, never from the rows, so a batch without rows or with errors only has the same schema as any other and upserts into the same table:
Field type |
Polars type |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
a nested struct or pydantic model |
|
a struct or pydantic model nested in itself, directly or through another type |
|
|
the type of |
anything else ( |
|
An answer holding a value its column cannot (an int past Int64, a
Decimal past Decimal(38, 9), …) becomes an error row with
agent_error = OUTPUT_UNREPRESENTABLE, its answer columns null and its tokens
and cost kept; the other rows of the batch are unaffected.
A failed row never aborts the batch: an Exception that is not a run error
becomes UNEXPECTED_ERROR, keeps its reservation as spent and is logged by
its type. What is not an Exception aborts map: KeyboardInterrupt and
SystemExit reach the caller as themselves, any other inside a
BaseExceptionGroup, and a run cancelled while the batch was not raises a
RuntimeError naming the rows left without an outcome. Whether to write error rows, drop them or
count them against a threshold is the step’s decision. Writing them with
agent_version in the upsert keys and filtering agent_status == "ok" in the
anti-join retries them on the next execution; leaving them in the done side
does not.
How it runs¶
One runtime per call. Each
mapbuilds, enters and closes a runtime of its own on an event loop of its own (a dedicated thread when the calling thread already runs one, as under an async orchestrator; that thread sees the caller’s context variables). Two steps mapping at once share nothing. A frame without rows compiles nothing and opens no runtime.versionand the compile-time checks compile each agent once per runner; eachmaprecompiles it andversionthen answers that compilation, so it always equals theagent_versionthe lastmapwrote.Concurrency waits. At most
ai.max_concurrent_runsruns are in flight; the others wait for a slot instead of being refused withTOO_MANY_RUNS.Budget per execution.
WithAgent(..., max_usd=...)bounds what onemapcall may spend, whatevermax_concurrent_runs. The budget is aDecimalor anint(converted exactly) above zero. Afloatis deprecated until loom 3.0: it emits aDeprecationWarningand is converted through itsstr(), so0.1becomesDecimal("0.1"). Any other type,boolincluded, raisesTypeError; zero, a negative amount,NaNorInfinityraiseValueErrorwhen theWithAgentis constructed. Before each run itspolicies.max_usdis set aside, and replaced by the real cost when the run ends (the reservation itself when the cost is unknown or the run failed unexpectedly). The first run that does not fit stops the batch: it and every row after it are returned withagent_error = BUDGET_EXHAUSTEDand nothing spent, and the anti-join picks them up on the next execution. A step budget therefore needspolicies.max_usdon the artifact, and one run’spolicies.max_usdmust fit in it; otherwise the step does not compile (AGENT_BUDGET_UNENFORCEABLE), andmapcalled directly with a budget raisesValueError.What the step spends never exceeds its budget except by the elastic excess of the last response of each run in flight, so by at most
max_concurrent_runsresponses.policies.max_usdis elastic: the engine checks it against the run’s spend, summed over all its attempts, after each response, so a run stops withUSAGE_LIMIT_EXCEEDEDonce a response takes it past the cap, and that response is billed and counted. Retries do not multiply the reservation: the attempts of one run share the same cap.Writes happen once, after
executereturns. A process killed mid-batch loses what that batch paid for, so a large backfill goes in bounded chunks (one day per execution, for instance).Polars only.
maptakes a PolarsDataFrameorLazyFrameand returns aDataFrame. On a Spark runner everyWithAgentfails compilation (AGENT_UNSUPPORTED_ENGINE) andloom.ai.etlis never loaded.
Versions¶
agent.version, and the agent_version column, is the agent’s
fingerprint: it
changes when the instructions, the output schema, the output check, the
declared tools (the kind and declared facts of each capability, including the
include/exclude filters of MCP and A2A) or the model binding change, and
stays when any policy (retries, the timeouts, max_iterations,
max_tool_calls, max_requests, the token caps, max_usd,
on_unpriced_spend, max_history_bytes), the step budget
(WithAgent(max_usd=...)), the region, the credentials, the price or where a
tool is served from change. Editing a skill’s text or a tool’s code does not
change it either. Keeping agent_version in the upsert keys and in the
anti-join re-runs, and pays for, every row of the anti-join window the day the
agent changes, and leaves the old answers in place for comparison.
What is checked before anything is spent¶
ETLRunner.run compiles the pipeline and validates every WithAgent before
any step runs:
Code |
Cause |
|---|---|
|
the config has no |
|
no artifact under |
|
the artifact does not compile; the message carries every compiler issue with its code |
|
|
|
a budget is declared ( |
|
|
|
a field of |
|
the runner executes on Spark; agents map Polars frames only |
ETLCompiler() alone checks the shape, with the codes FromConfig uses: a
keyword-only parameter named like the attribute (MISSING_CONFIG_PARAMS), no
name shared with a source or client (CONFIG_ALIAS_CONFLICT), and no
WithAgent on a StepSQL (UNSUPPORTED_CONFIG_VALUE).
Without from_yaml¶
ETLRunner, ETLRunner.from_config, ETLCompiler and ETLExecutor take
agents=, any implementation of loom.etl.runtime.AgentBatchRunner.
loom.ai.etl.PolarsAgentRunner(ai_config, root=...) is the one loom ships;
its engine_provider= lets a test serve the agent from a pydantic-ai
TestModel or FunctionModel:
from pydantic_ai.models.test import TestModel
from loom.ai.engines.pydantic_ai import PydanticAIEngineProvider
from loom.ai.etl import PolarsAgentRunner
agents = PolarsAgentRunner(
ai_config,
root=repo_root,
engine_provider=lambda: PydanticAIEngineProvider(model_resolver=lambda _: TestModel()),
)
runner = ETLRunner.from_config(storage_config, agents=agents)
loom.etl never imports loom.ai: from_yaml loads loom.ai.etl by module
path only when the config has an ai: section.