Agents
An agent is a model with instructions and tools, run as a loop until the model answers. Use an agent when the model should decide which steps to take (look something up, then act, then answer). When the steps are known in advance, use a workflow instead, or put agents inside one. How the loop works is on Agents and the loop.
Build and run one#
from criogenai import Agent, FakeModel, FakeReply, ToolContext, ToolError, tool
@tool(destructive=True)
def issue_refund(order_id: str, amount: float, note: Optional[str] = None, ctx: Optional[ToolContext] = None) -> dict:
"""Refund an order."""
# Arguments arrive already checked against the schema built from the type hints.
if amount > 200:
raise ToolError("Refunds over 200 need a manager")
return {"refunded": amount}
class Resolution(BaseModel):
category: Literal["billing", "shipping", "other"]
refund: bool
agent = Agent(
"openai:gpt-5-mini",
instructions="You are a support agent.",
tools=[issue_refund],
output=Resolution, # result.output is a Resolution
limits={"model_calls": 10, "tool_calls": 20},
)
# In tests: scripted replies, no network, the same answer every time.
test_agent = Agent(
FakeModel([FakeReply.tool_call("issue_refund", {"order_id": "A-1001", "amount": 24}), "Refunded."]),
tools=[issue_refund],
)
result = test_agent.run_sync("Refund my order")
assert result.text == "Refunded."import { Agent, FakeModel, FakeReply, ToolError, tool } from "criogenai";
const issueRefund = tool<{ orderId: string; amount: number }>({
name: "issue_refund",
description: "Refund an order.",
destructive: true,
input: {
type: "object",
properties: {
orderId: { type: "string" },
amount: { type: "number", minimum: 0, maximum: 500 },
},
required: ["orderId", "amount"],
},
run: ({ amount }) => {
// Arguments arrive already checked against the schema.
if (amount > 200) throw new ToolError("Refunds over 200 need a manager");
return { refunded: amount };
},
});
const agent = new Agent<{ category: string; refund: boolean }>({
model: "openai:gpt-5-mini",
instructions: "You are a support agent.",
tools: [issueRefund],
output: {
type: "object",
properties: { category: { enum: ["billing", "shipping", "other"] }, refund: { type: "boolean" } },
required: ["category", "refund"],
},
limits: { modelCalls: 10, toolCalls: 20 },
});
// In tests: scripted replies, no network, the same answer every time.
const testAgent = new Agent({
model: new FakeModel([FakeReply.toolCall("issue_refund", { orderId: "A-1001", amount: 24 }), "Refunded."]),
tools: [issueRefund],
});
const result = await testAgent.run("Refund my order");use criogenai_agent::{Agent, Limits, ToolError, tool_fn};
use criogenai_providers::ToolSpec;
use serde_json::json;
let refund = tool_fn(
ToolSpec::new(
"issue_refund",
"Refund an order.",
json!({
"type": "object",
"properties": {
"order_id": {"type": "string"},
"amount": {"type": "number", "minimum": 0, "maximum": 500}
},
"required": ["order_id", "amount"]
}),
),
|args, _ctx| async move {
// Arguments arrive already checked against the schema.
if args["amount"].as_f64() > Some(200.0) {
// The model sees this and can explain or try another way.
return Err(ToolError::new("Refunds over 200 need a manager"));
}
Ok(json!({"refunded": args["amount"]}))
},
);
let agent = Agent::builder(model()?)
.instructions("You are a support agent.")
.tool(refund)
.limits(Limits::default().model_calls(10).tool_calls(20))
.build()?;run is one task with no history. For a conversation that continues, use a
thread: agent.thread(id).send(text). Python also
has run_sync and send_sync for scripts and notebooks (not inside a
running event loop).
Options#
Every option is a keyword argument in Python and a field of the options object in TypeScript. Only the model is required.
| Python | TypeScript | Default | What it does |
|---|---|---|---|
model (first argument) | model | required | "provider:model" string or a model object (Models) |
instructions | instructions | none | The system prompt |
tools | tools | none | Tools, agents as tools (as_tool), MCP servers, collection search tools |
approve | approve | none | Names of tools a person must approve (Human approval) |
output | output | none | A Pydantic model (Python) or JSON Schema: the run ends with JSON that matches it |
limits | limits | 25 model calls, 200 tool calls, 60 s per tool | Bounds on one run (limits) |
storage | storage | in memory, per agent | A Storage or a URL: where threads live (Storage) |
memory | memory | none | Long-term memory (Memory) |
on_tool_exception | onToolException | return to model | What an unexpected tool exception does (Tools) |
fallbacks, fallback_on | fallbacks, fallbackOn | none | Models tried when the first one fails (Errors and retries) |
model_retry, tool_retry | modelRetry, toolRetry | 3 attempts, from 0.5 s | Retries of transient failures |
max_retry_after | maxRetryAfterSecs | 60 s | The longest Retry-After the agent waits |
hooks | hooks | none | Your code around model and tool calls (Hooks) |
redact | redact | none | A function applied to every text before each model call (redaction) |
telemetry | telemetry | none | Export each run as a trace (Tracing) |
An agent is cheap to keep and safe to share: build it once (at start-up, or in a module) and use it from every request. Building one with a hosted model and no API key fails at once, naming the variable to set.
Writing instructions#
The instructions are the system prompt. They work best when they say:
- who the agent is and who it talks to ("the support assistant of an online shop, talking to customers");
- what to do before answering ("look an order up before you answer about it");
- the rules for risky tools ("refunds need a staff member's approval");
- the shape of the answer ("two sentences at most", "in the customer's language").
Put rules that must hold in code, not only in instructions: argument limits in
the tool's schema, approval in approve, checks in the tool or a
hook. A model can ignore an instruction; it cannot skip
an approval.
The result#
| Python | TypeScript | Meaning |
|---|---|---|
text | text | The final answer (None / null while waiting for approval) |
output | output | The typed output, if you asked for one |
messages | messages | The whole transcript, as JSON messages |
stop_reason | stopReason | Why the run ended: final_answer, output, hook_end, content_filter |
needs_approval, approvals | needsApproval, approvals | Paused for a person, and the waiting calls |
usage | usage | Tokens across all model calls of this run |
model_calls, tool_calls | modelCalls, toolCalls | How much the run did |
answered_by, models | answeredBy, models | Which model answered; calls and tokens per model |
duration_ms | durationMs | Wall time of the call |
run_id | runId | The run's id (also in traces and audit records) |
A thread call returns a Reply: the same fields plus version and
checkpoint_id (checkpointId) of the checkpoint it saved.
Typed (structured) output#
Give output a Pydantic model (Python) or a JSON Schema (both languages),
and the run ends with an object that matches it, in result.output:
- Models with a JSON mode answer in it; others get a reserved
final_outputtool to call. You do not need to know which. - The engine checks the answer against the schema. An invalid answer goes
back to the model with the problems; after 2 invalid answers the run fails
with
output_invalid, anddetailsholds theproblemand therawanswer. - In Python,
result.outputis an instance of your Pydantic model. In TypeScript, type it withnew Agent<YourType>({...}).
Typed output pairs well with tools: the agent can look things up first, then answer in the shape your code needs.
Run state#
Values you pass with a message become part of the run's state. Long-term memory uses them to pick the person's namespace, and hooks can read them:
await agent.run("What should I eat?", user_id="42")await agent.run("What should I eat?", { state: { user_id: "42" } });A value cannot use one of the agent's own state keys (messages, for
example): that fails with invalid_input.
Agents inside agents#
An agent can be a tool of another agent. The tool takes one argument,
input (text), runs the sub-agent, and returns its answer:
from criogenai import Agent
researcher = Agent("openai:gpt-5-mini", instructions="Find facts about orders. Be precise.")
support = Agent(
"openai:gpt-5-mini",
instructions="You are a support agent.",
# The researcher becomes a tool: it takes {"input": text} and answers.
tools=[researcher.as_tool("research", "Looks up facts about an order")],
)const researcher = new Agent({ model: "openai:gpt-5-mini", instructions: "Find facts about orders. Be precise." });
const support = new Agent({
model: "openai:gpt-5-mini",
instructions: "You are a support agent.",
// The researcher becomes a tool: it takes { input } and answers.
tools: [researcher.asTool("research", "Looks up facts about an order")],
});use criogenai_agent::Agent;
let researcher = Agent::builder(model()?)
.instructions("Find facts about orders. Be precise.")
.build()?;
let support = Agent::builder(model()?)
.instructions("You are a support agent.")
// The researcher becomes a tool: it takes {"input": text} and answers.
.tool(researcher.as_tool("research", "Looks up facts about an order"))
.build()?;- The sub-agent has its own instructions, tools and limits; the parent sees only its final answer.
- How deep agents may call agents is limited (8 levels); deeper calls fail
with
limit_exceeded. - For fixed hand-offs (always triage, then answer), a workflow with agents as nodes is clearer. For coordinators, swarms and review loops built from agents, see Patterns.
Cancelling and time limits#
- TypeScript: every call takes an
AbortSignal:agent.run(text, { signal: AbortSignal.timeout(30_000) }). When it fires, the model call stops at once and the call rejects withCancelledError. - Python: cancel the task, or use
asyncio.wait_for(agent.run(text), 30). The model call stops at once.
On a thread, a cancelled run is saved, and retry() continues it. Tools have
their own time limit (tool_timeout_secs / toolTimeoutSecs).
Errors#
| Code | When | What to do |
|---|---|---|
limit_exceeded | A limit was reached (details["limit"]) | Raise the limit or tighten the instructions |
model_failed | The model failed after retries and fallbacks | Check details["model_error"]["kind"]; retry later if transient |
output_invalid | Typed output still invalid after retries | Simplify the schema; describe the fields in it |
tool_failed | A tool crashed and on_tool_exception is fail | Fix the tool; the exception is the error's cause |
hook_failed | A hook raised | Fix the hook; the exception is the error's cause |
invalid_state | Memory by state, but the run has no value for the key | Pass the key with every message |
All codes: Error codes. How to catch them: Errors and retries.
Next#
- Tools: schemas, errors, retries and the tool context.
- Human approval and Hooks.
- Testing without a model.
- API: Python
Agent, TypeScriptAgent.