A resumable, human-in-the-loop AI agent in ~200 lines with zero dependencies

작성자

카테고리:

← 피드로
DEV Community · Rahul Choudhary · 2026-07-22 개발(SW)

Rahul Choudhary

Most “AI agent” libraries fall into one of two buckets. Either they’re a big
framework you spend an afternoon configuring, or they’re a tiny toy that drops
the one feature you actually need in production: the ability to stop and ask a
human before the agent does something you can’t undo.

I wanted the middle. So I wrote yieldagent:
a small agent loop you can read end to end, with human-in-the-loop pause/resume
built in, and no runtime dependencies. This post walks through how it works and
why it’s built the way it is.

What an agent loop actually is

Strip away the branding and an “agent” is a loop:

  1. Send the conversation to the model, along with the tools it’s allowed to call.
  2. If the model asks to call a tool, run it and append the result.
  3. Repeat until the model answers without asking for a tool.

That’s it. The model decides the control flow at runtime; your job is to run the
tools and feed the results back. Here’s the core, lightly trimmed:

for (let step = 0; step < maxSteps; step++) {
  const reply = await call(messages, toolSpecs);
  messages.push(reply);

  if (!reply.tool_calls?.length) {
    yield { type: "final", text: reply.content, messages };
    return;
  }

  for (const tc of reply.tool_calls) {
    const args = JSON.parse(tc.function.arguments);
    const result = await tools[tc.function.name].run(args);
    messages.push({ role: "tool", tool_call_id: tc.id, content: JSON.stringify(result) });
  }
}

Enter fullscreen mode Exit fullscreen mode

Everything else in the library is in service of making this loop observable,
testable, and safe to run against the real world.

Why an async generator

Notice the yield. The loop is an async generator, so the caller drives it:

for await (const step of agent({ call, tools, messages })) {
  if (step.type === "tool-start") console.log("->", step.tool, step.args);
  if (step.type === "final") console.log(step.text);
}

Enter fullscreen mode Exit fullscreen mode

Every step (each tool call, each result, and the final answer) is handed back to
you as a plain object. Nothing is hidden inside the framework. You can log it,
render it, or assert on it in a test. Which brings us to the nicest side effect.

Testing without an LLM

The model call is just a function: (messages, tools) => Promise<Message>. In
tests, you pass one that returns canned replies. No API key, no network, and the
result is deterministic:

const replies = [
  { role: "assistant", content: null, tool_calls: [{ id: "1", function: { name: "getWeather", arguments: '{"city":"Delhi"}' } }] },
  { role: "assistant", content: "It's 31°C.", tool_calls: [] },
];
let i = 0;
const call = async () => replies[i++];

const steps = [];
for await (const s of agent({ call, tools, messages })) steps.push(s);

expect(steps.map(s => s.type)).toEqual(["tool-start", "tool-end", "final"]);

Enter fullscreen mode Exit fullscreen mode

The whole library is tested this way. Agent logic you can unit-test without an
LLM is worth a lot when you’re iterating.

The feature that’s actually hard to find: pause and resume

Real agents do risky things, send emails, spend money, delete files. You want a
human in the loop before that happens, and often you want to pause a run and
continue it later, in a different request or after a restart.

yieldagent does this with an approve callback. Return false for a tool and
the loop stops before running it, handing back a serializable resumeState:

const cfg = {
  call, tools,
  messages: [{ role: "user", content: "Email the Delhi weather to my boss" }],
  approve: (tool) => tool !== "sendEmail",
};

let paused;
for await (const step of agent(cfg)) {
  if (step.type === "paused") paused = step.resumeState; // a plain object
  if (step.type === "final") console.log(step.text);
}

Enter fullscreen mode Exit fullscreen mode

Because resumeState is just data, you can write it to a database or a job
queue, wait for a human to click “approve” somewhere else entirely, then pick up
where you left off:

import { resume } from "yieldagent";
for await (const step of resume(cfg, paused)) {
  if (step.type === "final") console.log(step.text);
}

Enter fullscreen mode Exit fullscreen mode

This is the part most minimal agents skip and most big frameworks turn into a
whole subsystem. Keeping it small and explicit was the main reason I wrote this.

Provider-agnostic by default

The core knows nothing about any specific provider. The included adapter talks
to anything that speaks the OpenAI /chat/completions shape, OpenAI, Anthropic’s
compatible endpoint, Groq, Together, or a local model via Ollama or vLLM:

const call = openaiCompatible({
  baseURL: "http://localhost:11434/v1", // Ollama
  apiKey: "ollama",
  model: "llama3.1",
});

Enter fullscreen mode Exit fullscreen mode

Or skip the adapter and write your own call, it’s about a dozen lines.

A couple of nice extras

  • Streaming: pass stream instead of call and you get token steps as the model produces text. Tools and pause/resume still work.
  • Zod tools: the optional yieldagent/zod entry derives the JSON Schema from a Zod schema and validates the model’s arguments, feeding errors back so the model can correct itself.
  • Cancellation: pass an AbortSignal to stop a run on a timeout or user cancel.

When not to use it

If you need streaming UI helpers, a big prebuilt tool ecosystem, or multi-agent
orchestration out of the box, reach for the Vercel AI SDK or LangGraph. yieldagent
is for when you’d rather own a loop you can read in a few minutes than adopt a
framework. If you outgrow it, you’ll know exactly what you’re replacing.

Try it

npm install yieldagent

Enter fullscreen mode Exit fullscreen mode

There’s a browser demo of the approval flow (no API key needed):
https://rahul1368.github.io/yieldagent/

Code and docs: https://github.com/rahul1368/yieldagent

If you build something with it, or the pause/resume API breaks down for your use
case, I’d like to hear about it.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다