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.

PythonTypeScriptDefaultWhat it does
model (first argument)modelrequired"provider:model" string or a model object (Models)
instructionsinstructionsnoneThe system prompt
toolstoolsnoneTools, agents as tools (as_tool), MCP servers, collection search tools
approveapprovenoneNames of tools a person must approve (Human approval)
outputoutputnoneA Pydantic model (Python) or JSON Schema: the run ends with JSON that matches it
limitslimits25 model calls, 200 tool calls, 60 s per toolBounds on one run (limits)
storagestoragein memory, per agentA Storage or a URL: where threads live (Storage)
memorymemorynoneLong-term memory (Memory)
on_tool_exceptiononToolExceptionreturn to modelWhat an unexpected tool exception does (Tools)
fallbacks, fallback_onfallbacks, fallbackOnnoneModels tried when the first one fails (Errors and retries)
model_retry, tool_retrymodelRetry, toolRetry3 attempts, from 0.5 sRetries of transient failures
max_retry_aftermaxRetryAfterSecs60 sThe longest Retry-After the agent waits
hookshooksnoneYour code around model and tool calls (Hooks)
redactredactnoneA function applied to every text before each model call (redaction)
telemetrytelemetrynoneExport 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#

PythonTypeScriptMeaning
texttextThe final answer (None / null while waiting for approval)
outputoutputThe typed output, if you asked for one
messagesmessagesThe whole transcript, as JSON messages
stop_reasonstopReasonWhy the run ended: final_answer, output, hook_end, content_filter
needs_approval, approvalsneedsApproval, approvalsPaused for a person, and the waiting calls
usageusageTokens across all model calls of this run
model_calls, tool_callsmodelCalls, toolCallsHow much the run did
answered_by, modelsansweredBy, modelsWhich model answered; calls and tokens per model
duration_msdurationMsWall time of the call
run_idrunIdThe 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_output tool 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, and details holds the problem and the raw answer.
  • In Python, result.output is an instance of your Pydantic model. In TypeScript, type it with new 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 with CancelledError.
  • 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#

CodeWhenWhat to do
limit_exceededA limit was reached (details["limit"])Raise the limit or tighten the instructions
model_failedThe model failed after retries and fallbacksCheck details["model_error"]["kind"]; retry later if transient
output_invalidTyped output still invalid after retriesSimplify the schema; describe the fields in it
tool_failedA tool crashed and on_tool_exception is failFix the tool; the exception is the error's cause
hook_failedA hook raisedFix the hook; the exception is the error's cause
invalid_stateMemory by state, but the run has no value for the keyPass the key with every message

All codes: Error codes. How to catch them: Errors and retries.

Next#