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:
- Send the conversation to the model, along with the tools it’s allowed to call.
- If the model asks to call a tool, run it and append the result.
- 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
streaminstead ofcalland you gettokensteps as the model produces text. Tools and pause/resume still work. -
Zod tools: the optional
yieldagent/zodentry 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
AbortSignalto 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.
답글 남기기