What you’ll build and where the risk actually is
This project builds a support agent that classifies inbound tickets, answers from a local policy set, calls order and refund tools, keeps session context, and escalates risky actions to a human. The no-framework baseline is the core of this ai agent project tutorial 2026.
The artifact is one Python package: an email/ticket intake function that runs guardrails, classifies the issue, retrieves an answer from a local policy set, optionally calls lookup_order or issue_refund, tracks conversation state, and writes an escalation row when the request is risky. The risk in support agents is almost never “the model can’t answer.” It’s the model inventing an order status, refunding money it shouldn’t, or leaking a credit card number into a prompt. So the stack front-loads control: Python 3.11+, the openai SDK for chat, SQLite for escalations, JSONL files for traces, and FastAPI for the HTTP surface. Everything after this section is a primitive you could delete and rebuild in an afternoon.
Environment setup and repository layout
Environment setup is a virtualenv plus five pip installs, with configuration read from .env through a Pydantic Settings class, per the Pydantic settings docs. The repository layout below keeps runtime state — the SQLite database and trace files — out of source control and inside writable volumes, while the policy file is versioned with the code.
python3.11 -m venv .venv && source .venv/bin/activate
pip install openai python-dotenv pydantic-settings fastapi uvicorn pydantic
pip freeze > requirements.txt
support-agent/
├── .env # OPENAI_API_KEY, MODEL_NAME, MAX_MEMORY_CHARS
├── app/
│ ├── config.py
│ ├── baseline.py # raw chat loop
│ ├── tools.py
│ ├── guardrails.py
│ ├── memory.py
│ ├── handoff.py
│ ├── tracing.py
│ ├── agent.py # full pipeline
│ └── api.py
├── policies/refunds.md
├── data/escalations.db
└── traces/tickets.jsonl
# app/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
openai_api_key: str
model_name: str = "gpt-4o-mini"
max_memory_chars: int = 6000
max_tool_rounds: int = 5
model_config = SettingsConfigDict(env_file=".env")
settings = Settings()
Set OPENAI_API_KEY, MODEL_NAME, and MAX_MEMORY_CHARS in .env; the settings object reads them at import time.
No-framework baseline: a raw chat loop
The baseline is a while loop around client.chat.completions.create with three policy lines in the system prompt, appended messages, and a quit command, using the OpenAI chat completions API. This is the whole agent before any tools exist, and it already answers policy questions correctly.
# app/baseline.py
from openai import OpenAI
from app.config import settings
client = OpenAI(api_key=settings.openai_api_key)
SYSTEM = """You are a customer support agent.
1. Never invent order data. If you do not have it, say so.
2. Always get an order ID (ORD-1000 format) before discussing a refund.
3. Escalate abuse or policy exceptions to a human; do not improvise.
Answer from the supplied policy text only."""
POLICY = open("policies/refunds.md").read()
messages = [{"role": "system", "content": SYSTEM + "\n\nPOLICY\n" + POLICY}]
def classify(text: str) -> str:
"""Single completion returning one of: refund, shipping, account, other."""
r = client.chat.completions.create(
model=settings.model_name,
messages=[*messages,
{"role": "user", "content": f"Classify as refund|shipping|account|other: {text}"}],
temperature=0,
)
return r.choices[0].message.content.strip().lower()
def chat(user_text: str) -> str:
messages.append({"role": "user", "content": user_text})
r = client.chat.completions.create(model=settings.model_name, messages=messages)
reply = r.choices[0].message.content
messages.append({"role": "assistant", "content": reply})
return reply
if __name__ == "__main__":
print("support agent (ctrl-d or 'quit' to exit)")
while True:
try:
text = input("you> ").strip()
except (EOFError, KeyboardInterrupt):
break
if not text or text.lower() in {"quit", "exit"}:
break
print("bot>", chat(text))
Classification and chat share one message list so ticket history survives a multi-turn exchange.
Adding tools with function calling
Tool calling here is two local functions, JSON schemas passed in the OpenAI tools parameter, and a loop that appends a tool message keyed by tool_call_id until the model stops asking, hard-capped at max_tool_rounds to prevent runaway loops. The function-tools pattern is documented in the OpenAI tools guide.
# app/tools.py + execution loop
import json, sqlite3
from openai import OpenAI
from app.config import settings
client = OpenAI(api_key=settings.openai_api_key)
DB = sqlite3.connect("data/escalations.db", check_same_thread=False)
DB.execute("CREATE TABLE IF NOT EXISTS orders (id TEXT PRIMARY KEY, status TEXT, total_cents INTEGER)")
DB.execute("CREATE TABLE IF NOT EXISTS refunds (order_id TEXT, amount_cents INTEGER, ts TEXT)")
def lookup_order(order_id: str) -> dict:
row = DB.execute("SELECT status, total_cents FROM orders WHERE id=?",
(order_id,)).fetchone()
if not row:
return {"found": False, "order_id": order_id}
return {"found": True, "order_id": order_id, "status": row[0], "total_cents": row[1]}
def issue_refund(order_id: str, amount_cents: int) -> dict:
total = DB.execute("SELECT total_cents FROM orders WHERE id=?",
(order_id,)).fetchone()
if not total or amount_cents > total[0]:
return {"ok": False, "reason": "amount exceeds order total"}
DB.execute("INSERT INTO refunds(order_id, amount_cents, ts) VALUES (?,?,datetime('now'))",
(order_id, amount_cents))
DB.commit()
return {"ok": True, "order_id": order_id, "amount_cents": amount_cents}
TOOLS = [
{"type": "function", "function": {
"name": "lookup_order",
"description": "Fetch order status and total by order ID.",
"parameters": {"type": "object", "properties": {
"order_id": {"type": "string", "description": "e.g. ORD-1042"}},
"required": ["order_id"]}}},
{"type": "function", "function": {
"name": "issue_refund",
"description": "Refund an order in cents. Requires a valid order ID.",
"parameters": {"type": "object", "properties": {
"order_id": {"type": "string"},
"amount_cents": {"type": "integer"}},
"required": ["order_id", "amount_cents"]}}},
]
DISPATCH = {"lookup_order": lookup_order, "issue_refund": issue_refund}
def run_with_tools(messages: list) -> str:
for _ in range(settings.max_tool_rounds):
resp = client.chat.completions.create(
model=settings.model_name, messages=messages, tools=TOOLS)
msg = resp.choices[0].message
if not msg.tool_calls:
messages.append({"role": "assistant", "content": msg.content})
return msg.content
messages.append({"role": "assistant", "content": msg.content or "",
"tool_calls": [tc.model_dump() for tc in msg.tool_calls]})
for tc in msg.tool_calls:
args = json.loads(tc.function.arguments or "{}")
try:
result = DISPATCH[tc.function.name](**args)
except Exception as exc:
result = {"error": type(exc).__name__, "detail": str(exc)}
messages.append({"role": "tool", "tool_call_id": tc.id,
"content": json.dumps(result)})
messages.append({"role": "assistant",
"content": "I could not resolve this in the tool budget; "
"escalating to a human."})
return "escalated"
Anthropic uses the same control flow with a different surface: client tools are declared in the top-level tools parameter, the model returns tool_use blocks, and execution happens client-side in a tool_result loop (Anthropic tool use docs). Keep the two invocation styles distinct — an OpenAI tool_calls message is not an Anthropic tool_use block.
Guardrails before the model acts
Three input guardrails run before any model call, and failure is fail-closed: return a blocked reason and never invoke the API. This mirrors the documented OpenAI Agents SDK guardrails design — configurable input validation, parallel execution, and the ability to block or raise.
# app/guardrails.py
import re
from dataclasses import dataclass
EMAIL = re.compile(r"^[\w.+-]+@[\w-]+\.[\w.-]+$")
ORDER = re.compile(r"\bORD-\d{4,8}\b")
CARD = re.compile(r"(?<!\d)(?:\d[ -]?){13,19}(?!\d)")
@dataclass
class GuardResult:
ok: bool
name: str
reason: str = ""
def email_guard(text: str) -> GuardResult:
m = re.search(r"[\w.+-]+@[\w-]+\.[\w-]+", text)
if not m or not EMAIL.match(m.group(0)):
return GuardResult(False, "EmailGuardrail", "no valid contact email")
return GuardResult(True, "EmailGuardrail")
def order_id_guard(text: str) -> GuardResult:
if not ORDER.search(text) and "refund" in text.lower():
return GuardResult(False, "OrderIDGuardrail", "refund request without ORD id")
return GuardResult(True, "OrderIDGuardrail")
def credit_card_block(text: str) -> GuardResult:
if CARD.search(text):
return GuardResult(False, "CreditCardBlockGuardrail", "payment instrument detected")
return GuardResult(True, "CreditCardBlockGuardrail")
GUARDRAILS = [credit_card_block, order_id_guard, email_guard]
def run_guardrails(text: str) -> GuardResult:
for g in GUARDRAILS: # parallelise with threads if inputs are long
res = g(text)
if not res.ok:
return res # fail closed: no model call
return GuardResult(True, "all")
CreditCardBlockGuardrail runs first because it is the highest-severity block; EmailGuardrail runs last since a missing address is recoverable inside the conversation.
Short-term session memory
SessionMemory is a deque of typed messages trimmed by MAX_MESSAGES or MAX_MEMORY_CHARS, whichever limit is hit first, dropping oldest-first while preserving the system prompt. This is explicitly sliding-window memory, not vector retrieval and not RAG; embeddings-based recall would be a separate subsystem entirely.
# app/memory.py
from collections import deque
from dataclasses import dataclass, field
from app.config import settings
@dataclass
class Msg:
role: str # system | user | assistant | tool
content: str
tool_call_id: str | None = None
@dataclass
class SessionMemory:
max_messages: int = 40
max_chars: int = settings.max_memory_chars
items: deque[Msg] = field(default_factory=deque)
def add(self, msg: Msg) -> None:
self.items.append(msg)
self._trim()
def _trim(self) -> None:
while len(self.items) > self.max_messages:
self._drop_oldest()
while sum(len(m.content) for m in self.items) > self.max_chars and len(self.items) > 2:
self._drop_oldest()
def _drop_oldest(self) -> None:
for m in list(self.items): # never drop the system prompt
if m.role != "system":
self.items.remove(m)
return
def as_api(self) -> list[dict]:
return [{"role": m.role, "content": m.content,
**({"tool_call_id": m.tool_call_id} if m.tool_call_id else {})}
for m in self.items]
Trimming drops oldest-first but never the system prompt or the current turn, so a long ticket thread degrades gracefully instead of hitting the context limit.
Human handoff and escalation
NeedsHumanPolicy is four deterministic rules: refunds above the configured threshold (refund_threshold_cents, default 10,000 cents), payment-instrument requests, abuse signals, and more than two failed tool attempts. On escalation the loop stops, writes a row to a SQLite escalations table with ticket_id, reason, and user_email, and returns a human-queue message to the customer.
# app/handoff.py
import re, sqlite3, uuid
conn = sqlite3.connect("data/escalations.db", check_same_thread=False)
conn.execute("""CREATE TABLE IF NOT EXISTS escalations (
ticket_id TEXT PRIMARY KEY, reason TEXT, user_email TEXT, created_at TEXT
DEFAULT CURRENT_TIMESTAMP)""")
ABUSE = re.compile(r"\b(idiot|lawsuit|chargeback|fraud|lawyer)\b", re.I)
class NeedsHumanPolicy:
def __init__(self, refund_threshold_cents: int = 10_000, max_tool_failures: int = 2):
self.refund_threshold_cents = refund_threshold_cents
self.max_tool_failures = max_tool_failures
def evaluate(self, text: str, refund_cents: int, tool_failures: int) -> str | None:
if refund_cents > self.refund_threshold_cents:
return "refund_above_100_usd"
if re.search(r"\b(credit card|card number|cvv|iban)\b", text, re.I):
return "payment_instrument_request"
if ABUSE.search(text):
return "abuse_signal"
if tool_failures > self.max_tool_failures:
return "tool_loop_exhausted"
return None
def escalate(reason: str, user_email: str) -> str:
ticket_id = "ESC-" + uuid.uuid4().hex[:8].upper()
conn.execute("INSERT INTO escalations(ticket_id, reason, user_email) VALUES (?,?,?)",
(ticket_id, reason, user_email))
conn.commit()
return (f"I've escalated this to a human specialist (ticket {ticket_id}). "
"They'll reply to your email within one business day.")
Escalation is a decision about action, not tone: the agent can still answer questions, but evaluate returning a reason is the signal for the calling pipeline to route the ticket to the human queue instead of executing further refunds.
Observability and tracing
TraceLogger emits one JSONL line per step, with one trace per ticket and one span per model call, tool call, guardrail decision, or escalation. The mental model is the OpenAI Agents SDK’s built-in tracing, whose default UI is the OpenAI Traces Dashboard.
# app/tracing.py
import json, time, re, uuid
from contextlib import contextmanager
from pathlib import Path
CARDISH = re.compile(r"(?<!\d)(?:\d[ -]?){13,19}(?!\d)")
def redact(text: str) -> str:
return CARDISH.sub("[REDACTED-PAN]", text)[:500]
class TraceLogger:
def __init__(self, path: str = "traces/tickets.jsonl"):
self.path = Path(path); self.path.parent.mkdir(exist_ok=True)
def start_ticket(self, ticket_id: str | None = None) -> str:
return ticket_id or "TR-" + uuid.uuid4().hex[:10]
def span(self, trace_id: str, ticket_id: str, step: str, inputs: dict):
t0 = time.perf_counter()
@contextmanager
def ctx():
exc_info = None
try:
yield
except Exception as e:
exc_info = e
raise
finally:
self.emit(trace_id, ticket_id, step, t0, inputs, exc_info)
return ctx()
def emit(self, trace_id, ticket_id, step, t0, inputs, exc):
rec = {"trace_id": trace_id, "ticket_id": ticket_id, "step": step,
"duration_ms": round((time.perf_counter() - t0) * 1000, 2),
"inputs": {k: redact(str(v)) for k, v in inputs.items()},
"error": None if exc is None else str(exc)}
with self.path.open("a") as fh:
fh.write(json.dumps(rec) + "\n")
trace = TraceLogger()
Every span carries the same trace_id, so one ticket’s guardrail block, model call, and escalation reconstruct as a single ordered timeline.
Deployment as a small HTTP service
FastAPI exposes POST /support/tickets with Pydantic request and response models returning status, needs_human, answer, and trace_id, plus a health check and a non-root Docker image on python:3.11-slim, following the FastAPI application pattern. The smoke-test commands below verify build, run, and end-to-end ticket flow.
# app/api.py
from fastapi import FastAPI
from pydantic import BaseModel, Field
from app.agent import handle_ticket
app = FastAPI(title="support-agent")
class TicketIn(BaseModel):
email: str = Field(pattern=r"^[\w.+-]+@[\w-]+\.[\w.-]+$")
subject: str
body: str
class TicketOut(BaseModel):
status: str
needs_human: bool
answer: str
trace_id: str
@app.post("/support/tickets", response_model=TicketOut)
def create_ticket(t: TicketIn) -> TicketOut:
return TicketOut(**handle_ticket(t.email, t.subject, t.body))
@app.get("/healthz")
def healthz() -> dict:
return {"ok": True}
FROM python:3.11-slim
WORKDIR /srv
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY policies ./policies
RUN useradd -m agent && mkdir -p data traces && chown -R agent data traces
USER agent
EXPOSE 8000
CMD ["uvicorn", "app.api:app", "--host", "0.0.0.0", "--port", "8000"]
docker build -t support-agent .
docker run --env-file .env -p 8000:8000 support-agent
curl -s localhost:8000/healthz
curl -s localhost:8000/support/tickets -H 'content-type: application/json' \
-d '{"email":"[email protected]","subject":"missing parcel","body":"order ORD-1042 never arrived"}'
Framework comparison: OpenAI Agents SDK, LangGraph, Google ADK, Pydantic AI, and raw API
The five paths differ in how much of the previous eight build sections they hand you: raw SDK gives none, the OpenAI Agents SDK gives agents, guardrails, handoffs and tracing, LangGraph gives typed state and checkpointing, Google ADK gives sessions and deployment targets, and Pydantic AI gives typed output.
Framework decision table
| Framework / path | GitHub stars (2026-10-09) | Core abstraction | Built-in tools, guardrails, tracing | Best fit |
|---|---|---|---|---|
| No-framework raw OpenAI SDK | N/A | chat completions loop you own | none — you build all three | tiny loops, minimal dependencies |
| OpenAI Agents SDK | 29,926 | Agent + Runner + handoffs | tools, parallel guardrails, tracing on OpenAI Traces Dashboard | first-party OpenAI with least glue |
| LangGraph | 42,931 | StateGraph runtime | tool edges, checkpointer persistence, state inspection | typed state and conditional graphs |
| Google ADK | 21,756 | LlmAgent + Session/state | function tools, sessions, sub_agents | Vertex AI Agent Engine, Cloud Run, GKE |
| Pydantic AI | 20,499 | type-safe Agent(model, system_prompt) | decorator tools, typed structured output | model-agnostic, schema-critical replies |
Three neighbours sit outside this five-way decision: openai/swarm at 22,045 stars is educational and superseded by the production-ready Agents SDK upgrade; the MCP python-sdk at 24,518 stars is a protocol layer — MCP is an open standard client-host-server architecture for connecting AI apps to external systems — not a framework; and crewAI at 59,481 stars sits outside this build-versus-orchestrate question.
Raw SDK quick start
from openai import OpenAI
client = OpenAI()
msgs = [{"role": "system", "content": "Answer only from policy."}]
msgs.append({"role": "user", "content": "Where is ORD-1042?"})
r = client.chat.completions.create(model="gpt-4o-mini", messages=msgs)
msgs.append({"role": "assistant", "content": r.choices[0].message.content})
OpenAI Agents SDK quick start (agents docs, repo)
from agents import Agent, Runner, function_tool
@function_tool
def lookup_order(order_id: str) -> dict:
return {"order_id": order_id, "status": "shipped"}
agent = Agent(
name="Support",
instructions="Never invent order data; escalate refunds over $100.",
tools=[lookup_order],
)
result = Runner.run_sync(agent, "Where is ORD-1042?")
print(result.final_output)
LangGraph quick start (low-level concepts, persistence, repo)
from typing import TypedDict, Annotated
from langgraph.graph import StateGraph, END
from langgraph.graph.message import add_messages
from langchain_core.messages import HumanMessage
class S(TypedDict):
messages: Annotated[list, add_messages]
def call_model(state: S) -> S:
return {"messages": [HumanMessage(content="policy answer")]}
def route(state: S) -> str:
return "tools" if "ORD-" in state["messages"][-1].content else END
g = StateGraph(S)
g.add_node("chatbot", call_model)
g.add_node("tools", lambda s: s)
g.set_entry_point("chatbot")
g.add_conditional_edges("chatbot", route, {"tools": "tools", END: END})
g.add_edge("tools", "chatbot")
app = g.compile() # add checkpointer=... for persistence
Google ADK quick start (LlmAgent, function tools, sessions/state, repo)
from google.adk.agents import LlmAgent
def lookup_order(order_id: str) -> dict:
"""Look up an order by ID."""
return {"order_id": order_id, "status": "shipped"}
support_agent = LlmAgent(
model="gemini-2.0-flash",
name="support_agent",
instruction="Never invent order data; escalate refunds over $100.",
tools=[lookup_order],
)
# Run with an InMemorySessionService via Runner; deploy to Vertex AI
# Agent Engine, Cloud Run, or GKE (see ai.google.dev/adk/docs/deploy).
Pydantic AI quick start (agents, tools, repo)
from pydantic import BaseModel
from pydantic_ai import Agent
class Reply(BaseModel):
needs_human: bool
answer: str
agent = Agent("openai:gpt-4o-mini",
system_prompt="Never invent order data; escalate refunds over $100.",
output_type=Reply)
@agent.tool_plain
def lookup_order(order_id: str) -> str:
return f"{order_id}: shipped"
result = agent.run_sync("Order ORD-1042 never arrived")
print(result.output.needs_human, result.output.answer)
Choosing the right stack for this project
Five decision rules follow, each paired with a team profile, covering the no-framework path and the four frameworks in the decision table. Pick with the failure mode you would rather debug, not with star counts, and note that every rule assumes the no-framework baseline from this tutorial already exists and runs.
- No-framework raw SDK — best for small loops and minimal dependencies. If your team is three engineers who want zero abstractions between them and the API, pick raw SDK.
- OpenAI Agents SDK — first-party handoffs, guardrails, and tracing. If your team is already on OpenAI and wants
Runner.runwith guardrails running in parallel, pick the Agents SDK. - LangGraph — a low-level orchestration framework and runtime with StateGraph, typed state, nodes, conditional edges, and a checkpointer; the get-started tutorial is the shortest path in, and Klarna, Uber, and J.P. Morgan are cited LangGraph users. If your team models workflows as explicit state machines with resumable checkpoints, pick LangGraph.
- Google ADK — a code-first Python toolkit with
LlmAgent, tools, Session/state management, andsub_agents, deployable to Vertex AI Agent Engine, Cloud Run, or GKE (deployment docs). If your team ships on Google Cloud infrastructure, pick ADK. - Pydantic AI — type-safe, model-agnostic
Agent(model, system_prompt)with decorator tools and typed structured output. If your team’s contracts are validated schemas the model must satisfy, pick Pydantic AI.
Troubleshooting common support-agent failures
Five failures cover the most common support-agent breakages, each with an exact fix pointing at a specific file and setting from this build. They are listed in the order they usually appear in a new deployment, from schema validation errors through memory tuning and trace-join failures in the logs.
- Invalid tool JSON schema — the model returns malformed arguments. Verify
parametersis a JSON Schema object withtype,properties, and arequiredarray; a missingrequiredarray lets the model omit arguments, and those missing keys surface asTypeErrorinside the dispatch try/except inrun_with_tools. - Guardrail false-positive on Unicode emails — an address with a non-ASCII domain is blocked. Fix the extraction:
re.UNICODEis already the default in Python 3, so the real problem is punctuation captured around the address — strip trailing punctuation from the extracted candidate and validate withre.fullmatch(EMAIL, candidate)instead of an anchoredmatchon the whole ticket. - Memory truncating too aggressively — early context vanishes after two turns. Raise
MAX_MEMORY_CHARSin.envfrom 6000 toward 12000; the build’s_trimalready sparessystemmessages, so if policy context still disappears, exempttoolmessages from trimming as well. - Tool loop hitting
max_tool_roundswithout resolution — the model keeps callinglookup_orderwith a bad ID. Log eachtool_callspayload, and after the second identical signature forceNeedsHumanPolicy.evaluate(..., tool_failures=3)so the ticket escalates instead of burning rounds. - Missing
trace_idin logs — spans exist but can’t be joined. InspectTraceLogger.emitevents:trace_idmust be captured fromstart_ticket()and passed into everyspan(...)call, including guardrail and escalation spans.
Extending the agent: next steps for production
Three extensions follow, in dependency order: move tools behind MCP, add an evaluation ticket set with pass/fail criteria, and add audit-log retention for guardrail blocks and escalations. Each extension builds directly on artifacts this project already creates, so none requires reworking the pipeline you just deployed.
The Bottom Line
The invariant sequence is raw baseline, tools, guardrails, memory, handoff, tracing, deployment, then framework choice — each layer assumes the previous one exists. Frameworks optimize primitives you can build yourself, and the decision table above marks the pick point once the baseline runs.
How This Guide Was Built
This guide is desk research on official documentation plus the GitHub REST API, fetched 2026-10-09. The project code follows documented APIs from the OpenAI, Anthropic, FastAPI, Pydantic, LangGraph, Google ADK, Pydantic AI, and MCP references linked inline throughout. No production deployment was performed, and no runtime claims are made about latency, cost, or accuracy.
FAQ
Do I need a framework to build a production support agent in 2026?
No. The baseline in this tutorial is a raw openai SDK loop with hand-written guardrails, a deque for memory, and a deterministic escalation policy. A framework buys you packaged versions of those primitives, not capability you cannot assemble yourself; add one only after the no-framework version is boring to maintain.
Which framework should I choose for guardrails and human handoffs?
The OpenAI Agents SDK, which is the production-ready upgrade of Swarm and provides Agents (LLM + instructions + tools), handoffs, guardrails that are configurable input validations running in parallel that can block or raise, and tracing whose default UI is the OpenAI Traces Dashboard, executed via Runner.run.
Why does the baseline use the OpenAI SDK instead of Anthropic?
Because the control flow is identical and the surface differs: Anthropic declares client tools in the top-level tools parameter, returns tool_use blocks, and runs a client-side tool_result loop. Swap the SDK and you keep the guardrails, memory, escalation, and tracing unchanged.
Is the session memory a vector database or RAG?
No. SessionMemory is sliding-window memory: a deque of typed messages trimmed by MAX_MESSAGES or MAX_MEMORY_CHARS. Retrieval over embeddings would be a different subsystem with its own index, latency, and failure modes, and none of them appear in this build.
When should I choose LangGraph over the OpenAI Agents SDK? Choose LangGraph when your agent is an explicit state machine — typed state, nodes, conditional edges, and a checkpointer for persistence, as described in its low-level concepts and persistence docs. Choose the Agents SDK when you want first-party OpenAI handoffs, parallel guardrails, and tracing with minimal glue.
← Back to all posts

