Skip to content

Instantly share code, notes, and snippets.

@savarin
Last active July 1, 2026 04:03
Show Gist options
  • Select an option

  • Save savarin/c5fc3222473ab52d0328c9b9513d68a9 to your computer and use it in GitHub Desktop.

Select an option

Save savarin/c5fc3222473ab52d0328c9b9513d68a9 to your computer and use it in GitHub Desktop.
Agent frameworks for advanced beginners

Agent infrastructure for advanced beginners

You and your friend Jasper are building an AI agent. Jasper is the CTO of a small startup that sells marketing tools, and one Friday evening he decided the company needs a Slack bot that writes emails, analyzes experiments, and manages ad campaigns. You're the engineer. You've built chatbots before — you've wired up API keys and streamed responses and felt very clever about it. But you've never built something that runs in production, handles real users, and survives a server restart.

Jasper bounces into the co-working space on Monday. "OK, what have we got?"

You show him the chatbot. You send it a message, it replies. You send it another message, it replies. Jasper nods.

"Nice. Now let me show you everything that's going to go wrong."

He picks up a whiteboard marker.


What happens inside a turn

"First things first," Jasper says. "Your chatbot does request-response. Message in, message out. But an agent does more than that."

He's right. You send the agent "analyze my latest campaign data" and the agent can't just respond with text. It needs to call your analytics API, wait for the result, maybe call another API to cross-reference it, build a summary, and then reply. That's three or four steps, not one.

"This is called a turn," Jasper explains. "It's everything that happens between the user sending a message and the agent finishing its response."

Here's what a turn looks like:

User sends a message
      |
      v
+------------+
| Call the    |<---------------------------+
| model (LLM)|                            |
+-----+------+                            |
      |                                   |
      v                                   |
+------------+    +-----------+     +-----+------+
| Model says | -> | Run the   | --> | Feed       |
| "call this"|    | tool      |     | results    |
| tool"      |    |           |     | back to    |
+------------+    +-----------+     | model      |
                                    +------------+
      |
      v (when model has no more tool calls)
+------------+
| Reply to   |
| user       |
+------------+

The agent calls the model. The model says "I need to call the analytics API." You run the tool and give the result back to the model. The model says "now I need to call the comparison API." You run that tool too. Eventually the model has enough information and produces a text reply. That's one turn.

This loop — model, tool, result, repeat — is the engine of every agent framework. In some open-source agents, this loop alone is nearly four thousand lines of code. It handles parallel tool calls, iteration limits so the model doesn't loop forever, and various edge cases like the model calling a tool that doesn't exist.

"OK," you say. "So I need a loop. I can write a loop."

"Sure you can," Jasper says. "But what happens when the server crashes in the middle of it?"

What happens when the server crashes

You hadn't thought about this.

"Your agent is halfway through a turn," Jasper says. "It's already called two tools. It has results from both. It's about to call the model again to generate the final response. Then the server restarts. OOM kill, deployment, hardware failure — doesn't matter why. What happens to the work in progress?"

The answer, you realize, is: it's gone. The agent was holding the tool results in memory. Memory doesn't survive a restart. The user's message is still in the chat, but the work the agent did — the API calls, the results, the intermediate state — is all lost.

"Can't you just save the conversation to a database?" you ask.

"You can," Jasper says. "And you should. But think about what that actually saves you."

He draws it out. You save every message to a database — user messages, agent responses, tool calls, tool results. On restart, you reload the conversation history. The agent can see what was discussed. But it can't see where it was in the work. It knows it was asked to analyze campaign data. It might even know it called the analytics API. But it doesn't know whether it already got the result, whether it already called the second API, or whether it was about to send the reply.

"The conversation history tells the agent WHAT was discussed," Jasper says. "But not WHERE it was in the work."

This is the difference between saving conversations and saving execution state, and it matters more than you'd think. Imagine a turn where the agent sends an email to a customer, then logs the send in your database, then notifies the sales team. If the process crashes after sending the email but before logging it, and you "recover" by replaying the conversation, the agent might send the email again. The customer gets two emails. Not great.

What you actually want is checkpointing — writing down what you've done so you can pick up where you left off.

Here's an analogy. You're following a recipe. If you write each step on a whiteboard, someone cleaning the kitchen will erase it. But if you write each step in a notebook, the notebook survives. And if you write "step 3: DONE" next to each completed step, you can pick up at step 4 tomorrow without redoing anything.

That's what a durable execution system does. Every step in a workflow writes its result to a database before the workflow advances to the next step. If the process crashes and restarts, the workflow runs again from the top — but each step checks the database first. If a result already exists, return it without re-executing. If not, run the step for real.

Normal execution:

Step 1 (call API)  -->  write result to DB  -->  Step 2 (send email)  -->  write result to DB  -->  Done

Crash after step 1:

Step 1 (call API)  -->  write result to DB  -->  💥 crash

Recovery:

Step 1 (call API)  -->  result exists in DB? YES --> skip, return stored result
Step 2 (send email) --> result exists in DB? NO  --> run for real --> write result to DB --> Done

The recovery is automatic. No special recovery code. The workflow function is the recovery function — it runs the same way every time, and the database tells it which steps to skip.

This is how DBOS Transact works — a Python library that provides durable workflows backed entirely by Postgres. Every step's result is stored in a table called operation_outputs, keyed by workflow ID and step number. On startup, a recovery thread finds any workflows that were still running when the process died and re-invokes them. Completed steps replay from the database. Uncompleted steps run fresh.

There's one important catch. For this to work, the workflow function has to be deterministic between steps. The steps themselves can do anything — call APIs, generate random numbers, check the weather. But the orchestration — which steps to call and in what order — must be the same every time the function runs. Because on recovery, you're literally running the same function again and expecting it to reach the same sequence of steps. If step 3 suddenly becomes step 4 because of a random branch, the stored results won't match and everything falls apart.

Jasper looks at you. "Make sense?"

You take a large sip of coffee. "Yeah. I think so. Checkpoint every step, replay on crash, deterministic orchestration."

"Good. Now here's the next problem. What happens when the agent needs to do something that takes five minutes?"

What happens when work takes too long

Your agent is good at answering quick questions. But then the product team asks you to add a feature: "Generate a weekly performance report for each customer." This involves pulling data from three APIs, running some calculations, building a summary, and formatting it as a PDF. It takes two to three minutes.

"You can't do that inside a turn," Jasper says. "The user sends 'generate my report' and then waits three minutes for a response? No."

He's right. The user should get an immediate acknowledgment ("Working on it!") and then a notification when the report is ready. The report generation should happen in the background.

"So I need background jobs," you say.

"You need background jobs," Jasper agrees. "But think about what that means. You need to start the job, run it separately from the conversation, and get the result back to the conversation when it's done."

Some frameworks solve the first two parts. They have a background daemon — a separate thread that does work after each turn. But the daemon is hardcoded. It does one specific thing, like updating the agent's memory or consolidating its skills. There's no general-purpose mechanism for "start this arbitrary work, run it in the background, and come back when it's done."

With durable workflows, the solution is simple. The background job is just another workflow. You start it, it runs independently, and because it's a workflow, it's automatically durable — it survives crashes, checkpoints its steps, and recovers on restart.

But that raises an interesting question. The background job finishes. It has the result. Now what? How does the result get back to the right conversation?

How do you route a message to the right place

Your agent has fifty active conversations with fifty different customers. Background job X finishes generating a report. The report needs to reach conversation Y, on Slack thread Z. Not conversation W. Not thread A. Conversation Y, thread Z.

"This is the routing problem," Jasper says. "And most agent frameworks punt on it."

He draws a mailroom on the whiteboard.

"Think of it like an office building. Every department has a mailbox. When a letter arrives, the mailroom reads the address and puts it in the right box. The sender doesn't need to know which floor the department is on. They just need to know the department's name."

Background job finishes
        |
        v
+----------------+                    +-----------+
| Job calls      |  write to table    | Database  |
| send(conv_Y,   |  --------------->  | (row:     |
|   "report done"|                    |  dest=Y,  |
|   topic="rpt") |                    |  topic=   |
+----------------+                    |  "rpt",   |
                                      |  msg=     |
                                      |  "done")  |
                                      +-----+-----+
                                            |
                    Postgres LISTEN/NOTIFY   |
                    wakes up the listener    |
                                            v
                                      +-----------+
                                      | Conv Y is |
                                      | waiting:  |
                                      | recv(     |
                                      |  "rpt")   |
                                      |           |
                                      | Gets the  |
                                      | message,  |
                                      | resumes   |
                                      +-----------+

This is pub/sub — publishing events and subscribing to them. The background job publishes a message ("the report is done"). The conversation subscribes to messages on a certain topic. When a message arrives, the conversation wakes up and processes it.

In some agent frameworks, there is no internal pub/sub at all. Each platform — Slack, Telegram, email — handles its own message routing. Slack uses SQS queues. Telegram uses long-polling. There's no way for one part of the system to send a message to another part. If a background job finishes on a worker thread, it has the result but no idea which Slack thread to post it to.

With DBOS, the pub/sub is backed by the same Postgres database that stores everything else. send(conversation_id, message, topic) writes a row to a notifications table. Inside the conversation's workflow, recv(topic) checks for matching rows. If none exist yet, it waits — using Postgres's built-in LISTEN/NOTIFY mechanism so it doesn't have to keep polling. When a matching row appears, recv returns the message and the conversation continues.

This has a few nice properties:

  • Any workflow can message any other workflow. A background job can notify a conversation. A conversation can notify another conversation. A cron job can notify everyone.
  • The messages survive crashes. They're rows in a database. If the process dies between the job completing and the notification being received, the notification is still there when the process comes back.
  • No separate message broker. No Redis. No RabbitMQ. Just Postgres.

"But what if the notification arrives and nobody's listening?" you ask. "What if conversation Y hasn't called recv yet?"

"That's fine," Jasper says. "The notification is a row in a table. It sits there until someone reads it. When conversation Y calls recv, it checks the table. If the row is there, it returns immediately. If not, it waits. Either way works."

"And what if the process crashes between the job completing and the notification being sent?"

"The send is itself a checkpointed step. On recovery, it replays. The notification gets sent exactly once."

You take a bite of a sandwich that appeared from somewhere and think about this.

"So the checkpoint system... and the pub/sub system... are both just... tables in the same database?"

Jasper nods. "Keep that thought. But first — one more problem."

How do you deploy without losing work

"You've got active conversations," Jasper says. "Mid-turn. Background jobs running. Notifications in flight. And you need to push a new version of the code."

This is the deployment problem. In a traditional web server, you can do a rolling restart — stop one instance, start a new one, repeat. Requests are stateless, so it doesn't matter which instance handles them. But your agent has stateful workflows. A conversation is mid-turn. A background job is on step 3 of 5. If you kill the process, that work is in flight.

Without durable execution, you have two choices. Wait for all active work to finish (which could take minutes), or kill it and accept the loss. Some frameworks offer graceful restart — a drain period where new messages are queued and active turns are allowed to finish. But if a turn is still running when the drain timeout expires, it's gone.

With durable execution, the deployment story is much simpler. You stop the old process. You start the new one. On startup, the new process queries the database for any workflows that were still running. It finds them and resumes them. Completed steps return their stored results. Uncompleted steps run the new code.

Old process:
  Conv A: mid-turn (step 2 of 4)
  Conv B: idle
  Job X: running (step 3 of 5)
       |
       v
  Drain signal --> stop accepting new messages
  Wait for active work... timeout
  Stop process.

New process (starts on same or different machine):
  Query workflow_status WHERE status = 'PENDING'
  Found: Conv A (step 2), Job X (step 3)
       |
       v
  Conv A: replay steps 1-2 from DB, run steps 3-4 fresh
  Job X:  replay steps 1-3 from DB, run steps 4-5 fresh
  Conv B: idle (nothing to recover)

The user never knows the server changed. The workflows pick up where they left off because every step was already checkpointed. You don't need a clever deployment strategy. You need a database.


Jasper puts down the marker. You look at the whiteboard. Six sections. Six problems. Turn loops, crashes, background jobs, message routing, deployments. You feel like you should be overwhelmed, but instead you notice something.

It's all the same database

The checkpoint table that stores step results. The notifications table that routes messages between workflows. The workflow status table that tracks what's running and what's done. The recovery thread that re-invokes pending workflows on startup. All of it is backed by the same Postgres database.

No Redis for the pub/sub. No SQS for the job queue. No separate orchestration server for the workflows. No in-memory state that disappears on restart. Just Postgres.

"Why does this work?" you ask.

"Because Postgres already solves these problems," Jasper says. "Concurrent writes? Postgres handles it. Transactions? Postgres handles it. Crash recovery? Postgres handles it. Real-time notifications? LISTEN/NOTIFY — Postgres handles it. You don't need four systems. You need one system that does four things, and Postgres has been doing all four for decades."

He pauses.

"Look, this isn't the only way to do it. Plenty of companies run Redis for pub/sub and SQS for job queues and it works great. If you're operating at massive scale, you'll probably outgrow a single Postgres database. But when you're a small team building a production agent — when it's you and me above a bubble tea shop — using the database you already have for everything means fewer systems to operate and fewer things to go wrong at 3am."

You look at the whiteboard one more time. Six problems. One database. You can build this.

"Ready to write some code?" Jasper asks.

You close your laptop, which is still open to that tab you don't want him to see.

"Yeah. Let's do it."

Agent architecture for advanced beginners

Your agent works. It handles turns, survives crashes, runs background jobs, routes messages, deploys without losing work. You used an open-source framework that gave you most of this out of the box. You've been in production for three months. Life is good.

Then a customer asks for a different tone of voice.


You try to change the prompts

The customer — your biggest account — says the agent sounds "too robotic." Could it be friendlier? More casual? Maybe use their brand voice?

"Easy," you tell Jasper. "I'll just change the system prompt."

You open the codebase and search for where the prompt is defined. It's in a file called prompt_builder.py, which is part of the framework — not your code. The prompt is a constant called DEFAULT_AGENT_IDENTITY. Next to it are five other constants: TOOL_USE_ENFORCEMENT_GUIDANCE, MEMORY_GUIDANCE, SKILLS_GUIDANCE, PARALLEL_TOOL_CALL_GUIDANCE. All hardcoded. All in the framework's source files.

You change DEFAULT_AGENT_IDENTITY to something friendlier. It works. The customer is happy.

Two weeks later, the framework releases an update. You pull it in. Your prompt change is gone — overwritten by the new version. You could pin the framework version and never update, but then you miss bug fixes and model upgrades. You could maintain a patch, but the framework moves fast and your patch keeps breaking.

"You know what," you say. "I'll just fork the framework."

Week 1:  Customer request  -->  find prompt  -->  it's in the framework
Week 2:  Change the prompt  -->  framework update  -->  your change is gone
Week 3:  Fork the framework  -->  now you maintain 5,700 lines of someone else's code

Jasper watches this unfold without comment. You maintain the fork. It's fine. It's manageable. You only changed one file.

Then the product team wants a new feature.

You try to add a background job

Product wants the agent to generate weekly performance reports. That's a background job — run it on a schedule, pull data from three APIs, build a summary, notify the customer when it's done.

You know how background jobs work (you learned this in the infrastructure post). You need a way to start the job, run it independently, and notify the conversation when it's done.

You open the framework to see how it handles background work. There's a file called background_review.py. It's a daemon thread that runs after each turn. It does one specific thing: it forks the agent and tells it to update its memory and skills. That's it. The behavior is hardcoded. The daemon is wired directly into the turn loop — the same 3,900-line file that handles everything the agent does during a conversation.

To add your weekly report job, you'd need to modify two framework files: the background daemon (to add a second job type) and the turn loop (to trigger the new job and handle its completion notification). Both are deep inside the framework's core.

What you want:
  Schedule  -->  run report job  -->  notify customer

What actually has to happen:
  Schedule  -->  enter 3,900-line turn loop  -->  modify hardcoded daemon
       |
       v
  Framework core. You don't own this code.
  (Actually you do, because you forked it two weeks ago.)

You patch your fork again. Two changes, two framework patches. Your fork is now drifting further from upstream. You start to wonder: why does every change require touching the same giant file?

"I have a theory about that," Jasper says.

The welding problem

"Think about a restaurant," Jasper says. "You have a chef and an electrician. The chef makes the food. The electrician keeps the lights on. They do completely different jobs."

"OK."

"Now imagine a restaurant where the chef also manages the electricity. Want to add a new item to the menu? You'll need to talk to the electrician, because the chef IS the electrician. Want to upgrade the wiring? The chef has to stop cooking. Every change to the food requires changes to the electrical system, and every change to the electrical system disrupts the food."

"That sounds like a terrible restaurant."

"It's your agent framework."

He pulls up the codebase. "Look at what's inside the turn loop." He traces through the code. The turn loop handles tool dispatch — that's agent logic. It also handles crash recovery — that's infrastructure. The background daemon handles memory updates — agent logic. It also handles job scheduling — infrastructure. The prompt builder defines the agent's personality — agent logic. It also enforces tool-use formatting — infrastructure.

"There are two different things here," Jasper says. "And your framework mashes them into the same code."

User message arrives
       |
       v
+------+-------+-------+-------+-------+------+
|                                              |
|  prompt       tool        crash     session  |
|  assembly     dispatch    recovery  mgmt     |
|  (agent)      (agent)     (infra)   (infra)  |
|                                              |
|  memory       background  pub/sub   deploy   |
|  updates      daemon      routing   drain    |
|  (agent)      (infra)     (infra)   (infra)  |
|                                              |
|  ALL IN THE SAME FUNCTION CHAIN.             |
|  Change any piece --> patch the monolith.    |
|                                              |
+----------------------------------------------+

He labels the two concerns:

Agent concerns — the turn loop, prompt assembly, tool dispatch, memory, skills. These are specific to YOUR agent. They embody your product's personality, your customers' needs, your competitive advantage. You want to own these. You should own these.

Infrastructure concerns — durable execution, pub/sub, job queues, crash recovery, session management, deployment. These are solved problems. Engineers have been building durable job queues and message buses for decades. You don't want to own these. You want them to work reliably while you focus on the agent.

"When a framework welds these together," Jasper says, "changing one means cutting through the other. You wanted to change a prompt — an agent concern. You ended up forking the entire framework, including the infrastructure. You wanted to add a background job — an infrastructure concern. You ended up modifying the turn loop, which is agent logic."

You think about this. It's not that the framework is bad. The turn loop works. The tool dispatch is solid. The provider adapters handle half a dozen LLM vendors without breaking a sweat. But everything is in one place. There are no seams. No boundaries between agent and infrastructure. No way to change one without changing the other.

"So what do you do about it?" you ask.

"You unweld them."

What unwelding looks like

"Let's replay your two changes," Jasper says, "but with a clean boundary between agent and infrastructure."

Prompt change. The framework provides infrastructure — durable execution, pub/sub, crash recovery. The prompts are not part of the framework. They're in your code, in your repo, deployed with your application. When the framework updates, your prompts don't change. When you change your prompts, the framework doesn't care. The customer wants a friendlier tone? You edit your prompt file, push your code, and the framework has nothing to do with it.

Background job. Any function you mark as a workflow becomes a durable background job. No special daemon. No turn loop modification. You write a function called generate_weekly_report, mark it as a workflow, and schedule it. When it finishes, it sends a notification. The turn loop doesn't know or care that the job exists — it just receives the notification through the same pub/sub system it uses for everything else.

Message routing. The pub/sub system is generic — send() and recv() work between any two workflows. It's not wired to Slack or Telegram or any specific platform. The routing is infrastructure; where to post the result (which Slack thread, which email address) is agent logic. The infrastructure moves the message. The agent decides what to do with it.

"And the turn loop itself?" you ask.

"The turn loop is a thin orchestrator. Four hundred lines, not four thousand. It calls the model, dispatches tools, manages the conversation. Each tool call is a step in a workflow, so durability is automatic. The turn loop doesn't need to handle crash recovery — the infrastructure handles it. It doesn't need to manage background jobs — the infrastructure handles it. It doesn't need to route messages — the infrastructure handles it."

User message arrives
       |
       v
+----- AGENT LAYER (your code) -----+     +----- INFRA LAYER (framework) ----+
|                                    |     |                                  |
|  prompt assembly                   |     |  durable execution               |
|  tool dispatch                     | <-> |  crash recovery                  |
|  memory management                 |     |  pub/sub routing                 |
|  conversation logic                |     |  job scheduling                  |
|                                    |     |  deployment / drain              |
|  Changes here don't touch infra.   |     |  Changes here don't touch agent. |
+------------------------------------+     +----------------------------------+
                    |                                        |
                    v                                        v
            "What the agent says"                   "That it survives"

Jasper quotes something he read once: "An agent is just the history of everything that happened to it. If you've stored everything you need, you can load it into a stateless agent and drive it on a different process."

The agent is stateless. The infrastructure is stateful. And the boundary between them is where the interesting architectural decisions happen.

"This sounds great," you say. "But how far do you go? If you separate everything, don't you end up building an agent from scratch?"

The altitude

"That's the real question," Jasper says. "And there are two ways to get it wrong."

Build too high. The framework gives you an agent and infrastructure, welded together. You get up and running fast. Everything works out of the box. Then you need to change something and you discover there are no seams. You fork the framework. Your fork drifts. Every customer request turns into a framework patch. This is where you just were.

Build too low. The framework gives you raw infrastructure primitives — durable execution, pub/sub, job queues — but no agent. No turn loop. No tool dispatch. No prompt system. No memory. You build all of that yourself. It works, and it's perfectly tailored to your needs, but it took you three months. You built an agent framework to avoid building an agent framework.

Too high:

  User msg --> [========= FRAMEWORK (agent + infra welded) =========]
               You get everything. You can change nothing.

Too low:

  User msg --> [=== FRAMEWORK (infra only) ===]-->[??? you build the rest ???]
               You can change everything. It takes forever.

Right altitude:

  User msg --> [=== YOUR agent logic ===]-->[=== FRAMEWORK infra ===]
               You own what matters.       The rest just works.

The right altitude is infrastructure primitives you don't have to think about, and an agent layer thin enough that you could rewrite it in a week. The framework handles crashes, routing, and deployment. You handle what the agent says, which tools it uses, and how it remembers.


Jasper leans back. He's quiet for a moment.

"Here's the test," he says. "If the framework stopped releasing updates tomorrow — no more patches, no more features, the maintainers went on permanent vacation — would you be fine?"

You think about it. If the infrastructure primitives are solid — if the durability works, the pub/sub works, the recovery works — then yes. You're building on top of a stable foundation. The framework is a floor you stand on, not walls that contain you.

"If the answer is no," Jasper continues, "then you're not building on the framework. You're renting it. And when the landlord changes the locks, you're locked out."

He pauses again, then adds: "And here's the other thing. If you're using exactly what the framework gives you — the same turn loop, the same prompts, the same tool dispatch — then so is everybody else who uses that framework. Which means... what are you really winning on?"

The framework is the floor, not the ceiling. The infrastructure should be invisible. Durable execution, pub/sub, crash recovery, deployment — these should be things you never think about, the way you never think about TCP when you're building a web app. They should just work.

The agent — what it says, how it thinks, what it remembers, how it helps your customers — that should be yours. All of it. Every prompt, every tool, every decision. Because that's the part that matters. That's the part that's different. That's the part your customers are paying for.

"Most chatbots don't need any of this," Jasper admits. "They don't survive crashes because they don't need to — the user just retries. They don't have background jobs because they respond in two seconds. They don't need pub/sub because there's nothing to route."

He stands up.

"But when your agent runs hour-long workflows, or manages real money, or operates without a human watching — the infrastructure stops being optional. And where you draw the line between 'mine' and 'theirs' becomes the most important architectural decision you make."

You look at your fork of the framework. 5,700 lines. Two patches already drifting from upstream. A background daemon you bolted in sideways. A prompt you keep losing on updates.

Then you look at the whiteboard. Agent on the left. Infrastructure on the right. A clean line between them.

You know which version you want to maintain.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment