Sagents.Extract (Sagents v0.8.0-rc.13)
Copy MarkdownStructured data extraction through an Sagents.Agent.
This is LangChain.Chains.DataExtractionChain lifted to the Agent layer.
Use it when you want a single agent run to return a structured map, and
you want the call to flow through your agent's middleware stack — token
usage attribution, tenancy/APM context propagation, scope, fallback models,
filesystem scope, and so on.
The trick is the same one DataExtractionChain uses: the agent owns a single
"submit" tool whose parameters_schema is the desired result shape, the run
stops on that tool, and run/3 reads the result back out. run/3 returns the
tool's processed_content — the value the tool body produces — so the tool can
shape the raw LLM arguments into whatever data structure you actually want (a
struct, a persisted record, an id). When the tool sets no processed_content,
run/3 falls back to the LLM-supplied argument map. See "Shaping the result".
Agent-owned tool
The submit tool lives on the agent — defined alongside its system prompt and
middleware as one self-contained package — and you name it via
:until_tool_success (or :until_tool). Sagents.Agent owns its tools
(Agent.new!(%{tools: [t]}) merges caller tools with middleware tools) and
Sagents.Agent.execute/3 validates that the named tool is present. run/3
forwards the stop condition to execute and reads the result back out.
Basic usage
schema = %{
type: "object",
properties: %{
"title" => %{type: "string"},
"summary" => %{type: "string"}
},
required: ["title", "summary"]
}
submit =
LangChain.Function.new!(%{
name: "submit_result",
description: "Submit the structured result.",
parameters_schema: schema,
function: fn args, _ctx -> {:ok, Jason.encode!(args), args} end
})
{:ok, agent} = Sagents.Agent.new(%{model: model, tools: [submit]})
state = State.new!(%{messages: [Message.new_user!("Summarize: ...")]})
{:ok, result} = Sagents.Extract.run(agent, state, until_tool_success: "submit_result")
# result is the submit tool's processed_content. The tool above hands
# `args` back as the 3rd element, so result is the LLM-supplied map:
# %{"title" => "...", "summary" => "..."}Options
Exactly one of :until_tool_success / :until_tool is required (naming a tool
on the agent). The two are mutually exclusive; passing both is rejected by
Sagents.Agent.execute/3.
:until_tool_success— Name of the agent-owned submit tool. The run stops only when this tool returns a successful (non-error) result; an error result keeps the loop running so the LLM can correct its arguments, bounded by:max_runs. This is the loop most extraction callers want.:until_tool— Name of the agent-owned submit tool. The run stops as soon as this tool is called: the tool executes, then the loop stops.:max_runs(default5) — Maximum LLM calls. Enough for a single call plus a few retries if the tool body orparse_argsvalidation rejects malformed args. Increase for complex schemas.:callbacks— A list of callback handler maps forwarded toSagents.Agent.execute/3(e.g. a token-usage logger). These are merged with the agent's middleware callbacks, not substituted, so supplying them never disables middleware-provided callbacks. SeeSagents.Agent.execute/3for the accepted keys.
What run/3 returns
{:ok, term()}— The submit tool'sprocessed_content: the value the tool body returns as the 3rd element of{:ok, "text for LLM", term}. This can be any term — a map, a struct, a persisted record, an id. When the tool sets noprocessed_content(it returned a 2-tuple{:ok, "text"}), this falls back to the LLM-supplied argument map, with keys exactly as the provider returned them.{:error, term()}— ALangChainErrorif neither stop option is given (type: "extract_invalid_opts"), if the named tool is not on the agent (type: "extract_tool_not_found"), if the LLM never successfully called the submit tool withinmax_runs, or anything else thatSagents.Agent.execute/3would surface.
Shaping the result
run/3 returns whatever the submit tool produces, not just what the LLM
emitted. That lets the tool body turn the raw LLM arguments into the exact
data shape you want, in three escalating tiers:
Raw arguments. A tool body that returns
{:ok, Jason.encode!(args), args}(or a 2-tuple{:ok, text}) makesrun/3return the LLM-supplied argument map unchanged.Process inside the tool body. Transform, validate, or persist the args and return the shaped value as the 3rd element. That value is what
run/3returns; the LLM only ever sees the 2nd element (the string), never the 3rd.submit = LangChain.Function.new!(%{ name: "submit_result", description: "Submit the structured result.", parameters_schema: schema, function: fn args, _ctx -> # shape the raw LLM args into the data structure you want person = %MyApp.Person{name: args["name"], age: args["age"]} # (or persist here and return the inserted struct / id instead) {:ok, "Saved #{person.name}.", person} end }) {:ok, agent} = Sagents.Agent.new(%{model: model, tools: [submit]}) {:ok, %MyApp.Person{} = person} = Sagents.Extract.run(agent, state, until_tool_success: "submit_result"):parse_argsfor coercion/validation. ALangChain.Function:parse_argscallback (Zoi schemas work well) can coerce the args before the body runs; pair it with a body that returns the shaped value as the 3rd element. Returning{:error, "reason"}fromparse_argsor the body keeps the loop running so the LLM corrects the call — see "Validation and retry".
Provider-side tool_choice
Anthropic and OpenAI both let you require the model to call a specific tool
via tool_choice. Setting this on your ChatAnthropic / ChatOpenAI model
before passing it to Sagents.Agent.new/2 materially improves single-call
reliability. The name in tool_choice must match the submit tool's name
— the same name you attach to the agent and pass as :until_tool_success.
Mismatched names still work — until_tool_success catches the tool whenever
the LLM calls it — but you lose the provider-side guarantee.
Example:
model =
ChatAnthropic.new!(%{
model: "claude-sonnet-4-6",
tool_choice: %{"type" => "tool", "name" => "submit_result"}
})
{:ok, agent} = Sagents.Agent.new(%{model: model, tools: [submit]})
Sagents.Extract.run(agent, state, until_tool_success: "submit_result")Validation and retry
Two layers of validation are available, both caller-owned:
- JSON schema (provider-enforced) — defined by the submit tool's
parameters_schema. The provider rejects shape errors before the call reaches us. - Business rules (in your tool) — anything JSON Schema can't express.
Use
LangChain.Function's:parse_argscallback (Zoi schemas work well) or validate inside the tool body. Return{:error, "reason"}; the LLM sees the error and retries with corrected args until success or:max_runsis hit. Write error messages that are actionable — the LLM is the one reading them.
Nothing here needs to know about either layer. The retry behavior is driven
by Extract running with until_tool_success: <submit tool>: an error result
from the submit tool keeps the loop running, feeding the error back to the LLM
so it can correct its arguments. until_tool stops on the first call, whatever
the tool returns.
Middleware compatibility
Not every middleware is appropriate in a fire-and-wait run. run/3 calls
Sagents.Agent.execute/3 directly — Sagents.AgentServer is not involved.
Middleware that assumes a registered AgentServer will misbehave. In
particular:
- Middleware that broadcasts events over PubSub via
Sagents.AgentServer.publish_event_from/2. - Middleware that schedules or relies on an inactivity timer.
- Middleware that looks up an agent process by
agent_id.
Middleware that is purely state-shaping or context-propagating is fine: token-usage capture, tenancy/APM context propagation, request-scoped logging, scope-keyed persistence, etc. Composing a safe agent is the caller's responsibility.
Summary
Functions
Run a structured extraction with the given agent and state.
Types
@type opts() :: [ until_tool_success: String.t(), until_tool: String.t(), max_runs: pos_integer(), callbacks: [map()] ]
Options for run/3.
Functions
@spec run(Sagents.Agent.t(), Sagents.State.t(), opts()) :: {:ok, any()} | {:error, term()}
Run a structured extraction with the given agent and state.
The state carries the full conversation (system messages, user prompts,
any prior turns). The submit tool must be on the agent and named via
:until_tool_success or :until_tool. See the module doc for option details.