Custom Agent Runtimes Basics
8 examples to get you started with a framework-free agent runtime - 5 basic and 3 intermediate.
You will define tools as pure functions, call a chat/completions-style API, dispatch tool calls, enforce max_turns, and return a structured stop reason.
APIs move quickly; verify model ids, request shapes, and SDK imports at build against your provider or gateway docs.
Prerequisites
- Python 3.10+ recommended
- An API key for a chat model that supports tool/function calling (provider direct or OpenRouter-style gateway)
- Comfort with JSON, HTTP clients, and simple async optional
python -m venv .venv && source .venv/bin/activate
pip install openai # OpenAI-compatible client works for many gateways
export OPENAI_API_KEY="sk-..." # or gateway key
export OPENAI_BASE_URL="${OPENAI_BASE_URL:-}" # optional gatewayBasic Examples
1. Pure Tool Functions
Keep tools free of model SDK imports so tests do not need network.
from typing import Any
def get_order_status(order_id: str) -> dict[str, Any]:
"""Return a demo order status payload."""
demo = {
"A-100": {"status": "shipped", "eta_days": 2},
"A-200": {"status": "processing", "eta_days": 5},
}
return demo.get(order_id, {"status": "not_found", "order_id": order_id})
def add(a: float, b: float) -> float:
"""Add two numbers for deterministic demos."""
return a + b- Tools return JSON-serializable values the model can read as observations.
- Docstrings become tool descriptions when you build schemas.
- Side effects (HTTP, DB) belong here later; keep demos pure first.
2. Tool Schema + Dispatcher
Map JSON-schema tool defs to callables by name.
from typing import Any, Callable
TOOLS_SPEC: list[dict[str, Any]] = [
{
"type": "function",
"function": {
"name": "get_order_status",
"description": "Look up order status by id.",
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string"},
},
"required": ["order_id"],
},
},
},
{
"type": "function",
"function": {
"name": "add",
"description": "Add two numbers.",
"parameters": {
"type": "object",
"properties": {
"a": {"type": "number"},
"b": {"type": "number"},
},
"required": ["a", "b"],
},
},
},
]
DISPATCH: dict[str, Callable[..., Any]] = {
"get_order_status": get_order_status,
"add": add,
}
def run_tool(name: str, arguments: dict[str, Any]) -> Any:
fn = DISPATCH.get(name)
if fn is None:
raise KeyError(f"unknown_tool:{name}")
return fn(**arguments)- Specs are what you send to the model; dispatch is what you execute.
- Unknown tools should fail loudly, not silently invent success.
- Keep one registry so allowlists and plugins stay consistent.
Related: Why Some Teams Build a Custom Agent Runtime Instead of Adopting a Framework
3. One Model Call With Tools Attached
Use an OpenAI-compatible client (works with many gateways if base_url is set).
import json
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ["OPENAI_API_KEY"],
base_url=os.environ.get("OPENAI_BASE_URL") or None,
)
MODEL = os.environ.get("AGENT_MODEL", "gpt-4.1-mini") # verify at build
def chat_once(messages: list[dict]) -> object:
return client.chat.completions.create(
model=MODEL,
messages=messages,
tools=TOOLS_SPEC,
tool_choice="auto",
)toolsadvertises capabilities; the model may still answer without calling any.- Pin
MODELvia env/config rather than hardcoding forever. - Gateway users often only change
base_url+ model slug.
Related: Designing a ReAct Loop from Scratch in FastAPI or Express
4. Bounded ReAct Loop (Core Runtime)
Call model → execute tools → append observations → stop on final text or budget.
import json
from typing import Any
def run_agent(user_text: str, max_turns: int = 5) -> dict[str, Any]:
messages: list[dict[str, Any]] = [
{
"role": "system",
"content": (
"You are a careful assistant. "
"Use tools when needed. Prefer short final answers."
),
},
{"role": "user", "content": user_text},
]
for turn in range(1, max_turns + 1):
resp = chat_once(messages)
choice = resp.choices[0]
msg = choice.message
# Record assistant message (including tool_calls if any)
assistant_msg: dict[str, Any] = {
"role": "assistant",
"content": msg.content or "",
}
if msg.tool_calls:
assistant_msg["tool_calls"] = [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
},
}
for tc in msg.tool_calls
]
messages.append(assistant_msg)
if not msg.tool_calls:
return {
"ok": True,
"stop_reason": "final_answer",
"turns": turn,
"output": msg.content or "",
"messages": messages,
}
for tc in msg.tool_calls:
name = tc.function.name
try:
args = json.loads(tc.function.arguments or "{}")
result = run_tool(name, args)
content = json.dumps(result)
except Exception as exc: # production: narrower + metrics
content = json.dumps({"error": str(exc), "tool": name})
messages.append(
{
"role": "tool",
"tool_call_id": tc.id,
"content": content,
}
)
return {
"ok": False,
"stop_reason": "max_turns",
"turns": max_turns,
"output": None,
"messages": messages,
}
if __name__ == "__main__":
print(run_agent("What is the status of order A-100?"))- Always cap turns; unbounded tool loops burn budget.
- Tool errors become observations so the model can recover or stop.
stop_reasonis product data for logs, metrics, and UX.
5. Structured Stop Reasons for Ops
Normalize how runs end so dashboards stay stable.
from enum import Enum
class StopReason(str, Enum):
FINAL_ANSWER = "final_answer"
MAX_TURNS = "max_turns"
TIMEOUT = "timeout"
CANCELLED = "cancelled"
TOOL_POLICY = "tool_policy"
MODEL_ERROR = "model_error"
def summarize_run(result: dict) -> dict:
return {
"stop_reason": result["stop_reason"],
"turns": result["turns"],
"ok": result["ok"],
"output_preview": (result.get("output") or "")[:200],
}- Use a fixed enum across services; avoid free-text stop strings.
- Map HTTP 504 / client cancel into
timeout/cancelled. - Evals and SLOs should filter by
stop_reason.
Related: Circuit Breakers and Observability Without a Third-Party Framework
Intermediate Examples
6. Wall-Clock Budget Around the Loop
Turns alone are not enough if a single tool hangs.
import time
from typing import Any, Callable
def run_agent_with_deadline(
user_text: str,
max_turns: int = 5,
deadline_s: float = 30.0,
runner: Callable[..., dict[str, Any]] = run_agent,
) -> dict[str, Any]:
started = time.monotonic()
# Thin wrapper pattern: production often checks deadline inside each turn.
result = runner(user_text, max_turns=max_turns)
if time.monotonic() - started > deadline_s and result.get("ok"):
result = {
**result,
"ok": False,
"stop_reason": "timeout",
}
return result- Prefer checking the deadline before each model/tool call for real cancellation.
- Pair with per-tool timeouts; one hung HTTP call should not freeze the agent.
- Surface timeout as a first-class stop reason to clients.
7. Unit-Test the Dispatcher Without Tokens
Prove tool wiring without calling a model.
import json
def test_get_order_status_dispatches():
out = run_tool("get_order_status", {"order_id": "A-100"})
assert out["status"] == "shipped"
def test_unknown_tool_raises():
try:
run_tool("drop_database", {})
assert False, "expected KeyError"
except KeyError as exc:
assert "unknown_tool" in str(exc)
def test_tool_error_becomes_json_observation():
# Simulate the loop's error packaging
try:
run_tool("add", {"a": 1}) # missing b
except TypeError as exc:
payload = json.dumps({"error": str(exc), "tool": "add"})
assert "error" in json.loads(payload)- Dispatcher tests are free, fast, and catch schema drift.
- Save live-model golden paths for a smaller integration suite.
- Treat tool policy denials as explicit observations in tests too.
8. Minimal Run Record for Tracing
Log enough to debug a production turn without dumping secrets.
import time
from typing import Any
def run_agent_traced(user_text: str, max_turns: int = 5) -> dict[str, Any]:
t0 = time.monotonic()
events: list[dict[str, Any]] = []
result = run_agent(user_text, max_turns=max_turns)
for m in result.get("messages") or []:
if m.get("role") == "assistant" and m.get("tool_calls"):
for tc in m["tool_calls"]:
events.append(
{
"type": "tool_call",
"name": tc["function"]["name"],
# redacted: do not log raw args in prod by default
}
)
if m.get("role") == "tool":
events.append({"type": "tool_result", "tool_call_id": m.get("tool_call_id")})
return {
**summarize_run(result),
"latency_ms": int((time.monotonic() - t0) * 1000),
"events": events,
"output": result.get("output"),
}- Correlate with a
request_idfrom your API gateway. - Redact tool args that may contain PII or secrets.
- This is enough to start; expand toward OpenTelemetry spans as you harden.
Related: Custom Agent Runtimes Best Practices
What to Practice Next
- Lift the loop behind HTTP with Designing a ReAct Loop from Scratch in FastAPI or Express.
- Register tools as plugins in Plugin Architecture for Tools in a Custom Runtime.
- Add breakers and metrics in Circuit Breakers and Observability Without a Third-Party Framework.
- Score quality with Building an Eval Harness for a Custom Runtime.
- Know your exit ramps via When a Custom Runtime Stops Being Worth Maintaining.
Stack versions: Pins from the category manifest (verify at build): OpenRouter (~315+ models, July 2026 pricing/fees); LangGraph 1.0+; CrewAI 1.14+; Microsoft Agent Framework 1.0; Vercel AI SDK 6; Pydantic AI (latest); LlamaIndex (latest); OpenAI Agents SDK (latest + MCP); MCP (Linux Foundation governance); A2A (HTTP+SSE+JSON-RPC 2.0); Solana
@solana/web3.js+@solana/spl-token.