What Is an Agent?
ជំពូកនេះមិនទាន់ត្រូវបានបកប្រែជាភាសាខ្មែរនៅឡើយទេ — សូមអានជាភាសាអង់គ្លេសខាងក្រោម។
Table of Contents
- A Chatbot Talks, an Agent Acts
- The Four Parts of an Agent
- Chatbot, Workflow, Agent
- When Not to Use an Agent
- The Model Never Runs Code
- Set Up agentlab
- Start the Mock
- One Tool Call by Hand
- The Same Round Trip in JavaScript
- When the Request Does Not Fit
- Honest Limits of the Mock
- Recipe: The Mock Server
- Try It Yourself
- Quick Review: Common Questions
1. A Chatbot Talks, an Agent Acts
In Build a Chatbot with an LLM API you sent text to a model and got text back. That is a chatbot. It can explain, summarise and write. But it can only talk. Ask it "what is the weather in Phnom Penh right now?" and it has no way to look. At best it guesses. At worst it invents an answer that sounds right (see Why Models Hallucinate).
Now imagine you give the model a list of things it may ask your program to do: look up the weather, read a file, add two numbers, save a note. When it needs one of them, it says so. Your program does it and hands back the result. The model reads the result and decides what to do next: ask for another action, or give the final answer.
That is an agent. This series builds one from nothing, with no framework, in a few hundred lines of JavaScript. Then it adds the parts that make an agent safe enough to trust with real work.
A good picture is a new office assistant with a phone and a list of people they may call. The assistant (the model) is smart but sits in a closed room. They cannot walk to the archive. So they ask: "please read me page 3 of file X". You (your code) go, read it, and tell them. They think, then ask for the next thing, or write the report. The assistant never touches the archive. You do.
Honest note about the mock. All demos here run against a mock model, a small rule-based program, not a real language model. It picks tools by matching words and does not understand anything. It is good enough to test your code (messages, loop, errors), but a real model is far better at choosing tools. Section 11 lists exactly what the mock simplifies.
2. The Four Parts of an Agent
Every agent, from a ten-line demo to a large product, has the same four parts:
- A model that reads the conversation and decides what to do next.
- Tools: functions you wrote, each with a name, a description and a list of arguments. The model sees the descriptions, not the code.
- A loop: your code calls the model, runs any tool it asks for, adds the result to the conversation, and calls the model again. It stops when the model gives a final answer or a limit is reached.
- A goal: the task you give it ("add 2 and 40, then check the weather in Siem Reap").
goal
|
v
+-----------+ "call get_weather" +-----------+
| MODEL | ---------------------> | YOUR CODE |
| (decides) | <--------------------- | (acts) |
+-----------+ "33C, sunny" +-----------+
| (repeat)
v
final answer
Notice who does what. The model decides. Your code acts. The model has no hands. Everything it learns about the world arrives as text that your code put into the conversation. This split is the most important idea in the whole series, and chapter 9 (safety) is built on it: because your code runs the tool, your code can check, limit, refuse, or ask a human first.
3. Chatbot, Workflow, Agent
People use "agent" loosely, so it helps to separate three things.
| Who chooses the steps? | Example | |
|---|---|---|
| Chatbot | Nobody. One question, one answer. | "Explain what an API is." |
| Workflow | You, in code, ahead of time. A model may do one step. | "Every morning: fetch 3 cities' weather, ask a model to summarise, send it." |
| Agent | The model, at run time, step by step. | "Find out why my build is failing and fix it." |
A workflow is a recipe: step 1, then step 2, then step 3, always in that order. A model may be used inside a step (for example, to summarise), but the path is fixed. An agent is more like handing a goal to a colleague: you do not list the steps, because you do not know them in advance. The model picks the next action based on what it has seen so far.
Here is a workflow in code. The path never changes, so no model is needed at all:
// workflow.js: the steps are fixed, so plain code is enough. No model needed.
const WEATHER = { "Phnom Penh": "33C, sunny", "Siem Reap": "31C, light rain" };
for (const city of ["Phnom Penh", "Siem Reap"]) {
console.log(`${city}: ${WEATHER[city]}`);
}
node workflow.js
# output
# Phnom Penh: 33C, sunny
# Siem Reap: 31C, light rain
Real systems sit on a spectrum, not in three boxes. Many useful products are mostly workflow with one small "let the model decide" step. That is usually a good sign, not a weakness.
4. When Not to Use an Agent
Agents are exciting, so people reach for them too early. They have real costs:
- They cost more. Every step is another model call, and each call resends the whole conversation so far (chapter 3 shows why).
- They are slower. Five steps means five round trips to the model.
- They are less predictable. The model may choose a different path tomorrow. It may call the wrong tool, repeat itself, or stop early.
- They are harder to test and to secure. More freedom means more that can go wrong, and an agent can do things (chapter 9).
So the first question is always: do I know the steps? If yes, write a workflow, with plain code, and use a model only where you need language understanding. If the steps depend on what you discover along the way (debugging, research, "do whatever it takes to answer this support ticket"), an agent may be worth it.
A rule of thumb many teams use: start with the simplest thing that works. A single model call. If that is not enough, a fixed workflow. Only if the path truly cannot be known in advance, an agent, with limits.
5. The Model Never Runs Code
A very common misunderstanding, worth fixing early: the model does not run your function. It cannot. A language model only produces text. When people say "the model called a tool", what really happened is:
- You sent the model a description of your tools.
- The model produced a special piece of text that means "please
call
get_weatherwith{"city": "Phnom Penh"}". - Your program read that text, decided whether to obey, ran the function, and sent the result back as another message.
The model's output is a request, like a customer filling in an order form. The form does nothing by itself. The waiter (your code) decides whether to take it to the kitchen. If your code has a bug, or does not check the request, a bad request becomes a bad action. If your code checks carefully, it does not. Keep this picture in mind, because it returns in every chapter.
6. Set Up agentlab
You will build a project called agentlab. It has no dependencies, only
Node.js (an LTS version; the examples were tested on Node 22). Create the
folder:
mkdir agentlab
cd agentlab
npm init -y
npm pkg set type=module
npm pkg set type=module adds "type": "module" to package.json, so
import and top-level await work in every file. Check it:
grep '"type"' package.json
# output
# "type": "module"
From the next chapter on you will add a src/ folder. For now you need
one file, mock-llm.js from the recipe in section 12. If you built the
chatbot series you already have an earlier version of it. Replace it
with this one, which adds tool calling. Everything the old one did
still works.
7. Start the Mock
In one terminal, from the agentlab folder:
node mock-llm.js
# output
# mock LLM listening on http://localhost:8787 (api key: test-key)
Leave it running. Open a second terminal for everything else. (The
mock reads a PORT environment variable if 8787 is busy; the demos read
LLM_BASE_URL, for example LLM_BASE_URL=http://localhost:8880.) Check
it is alive:
curl -s localhost:8787/health
# output
# {"ok":true}
8. One Tool Call by Hand
Before any JavaScript, do the whole thing with curl, so you see
every byte. Your first request is a normal chat request plus a new field,
tools: a list of tool descriptions.
curl -s localhost:8787/v1/chat/completions \
-H 'authorization: Bearer test-key' \
-H 'content-type: application/json' \
-d '{
"model": "mock-1",
"messages": [{"role": "user", "content": "What is the weather in Phnom Penh?"}],
"tools": [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a city",
"parameters": {
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"]
}
}
}]
}'
# output
# {"id":"mock-1","model":"mock-1","choices":[{"index":0,"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1_0","type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"Phnom Penh\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":9,"completion_tokens":28,"total_tokens":37}}
The id fields and token counts depend on how many requests the mock has
already served. The shape is stable. Read the reply carefully:
finish_reasonis"tool_calls", not"stop". This is the signal "I am not done. I want something from you."message.contentisnull: there is no text for the user.message.tool_callsis a list. Each item has anid(you must quote it back), the toolname, andarguments.argumentsis a string containing JSON, not an object. You mustJSON.parseit, and it can be invalid. Chapter 2 deals with that.
Now your side of the deal. You ran get_weather (pretend it returned
Phnom Penh: 33C, sunny). Send the conversation back with two new
messages appended: the assistant's tool-call message exactly as it
came, then a role: "tool" message with the result and the matching
tool_call_id:
curl -s localhost:8787/v1/chat/completions \
-H 'authorization: Bearer test-key' \
-H 'content-type: application/json' \
-d '{
"model": "mock-1",
"messages": [
{"role": "user", "content": "What is the weather in Phnom Penh?"},
{"role": "assistant", "content": null, "tool_calls": [
{"id": "call_1_0", "type": "function",
"function": {"name": "get_weather", "arguments": "{\"city\":\"Phnom Penh\"}"}}
]},
{"role": "tool", "tool_call_id": "call_1_0", "content": "Phnom Penh: 33C, sunny"}
],
"tools": [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a city",
"parameters": {
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"]
}
}
}]
}'
# output
# {"id":"mock-2","model":"mock-1","choices":[{"index":0,"message":{"role":"assistant","content":"Result: Phnom Penh: 33C, sunny"},"finish_reason":"stop"}],"usage":{"prompt_tokens":15,"completion_tokens":8,"total_tokens":23}}
This time finish_reason is "stop" and content holds the final
answer. The mock phrases it as Result: .... A real model would write a
natural sentence such as "It is sunny and 33 degrees in Phnom Penh."
Two details to notice. First, the tools list is sent on every
request. The API is stateless: the model remembers nothing between
calls, so you resend the whole conversation and the tool list each time.
Second, the model's answer depends only on what is in messages. The
tool result is simply another message it reads.
That is one full round trip. An agent loop is nothing more than
doing it repeatedly until finish_reason is "stop".
9. The Same Round Trip in JavaScript
Here is the same thing in a script with only fetch. Save it as
round-trip.js:
// round-trip.js: one tool call by hand, using only fetch.
const URL = `${process.env.LLM_BASE_URL ?? "http://localhost:8787"}/v1/chat/completions`;
async function post(body) {
const res = await fetch(URL, {
method: "POST",
headers: { "content-type": "application/json", authorization: "Bearer test-key" },
body: JSON.stringify(body),
});
return (await res.json()).choices[0];
}
// 1. Describe the tool. This is only a description, not code.
const tools = [{
type: "function",
function: {
name: "get_weather",
description: "Get the current weather for a city",
parameters: {
type: "object",
properties: { city: { type: "string" } },
required: ["city"],
},
},
}];
// 2. The real function lives in OUR program. The model cannot see it.
function getWeather({ city }) {
console.log(" [our code] running getWeather for", city);
return `${city}: 33C, sunny`;
}
const messages = [{ role: "user", content: "What is the weather in Phnom Penh?" }];
// 3. First call: the model answers with a request, not with text.
const first = await post({ model: "mock-1", messages, tools });
console.log("finish_reason:", first.finish_reason);
console.log("message:", JSON.stringify(first.message));
// 4. We run the tool ourselves. `arguments` is a JSON string.
const call = first.message.tool_calls[0];
const args = JSON.parse(call.function.arguments);
const result = getWeather(args);
// 5. Send everything back: the assistant message, then the tool result.
messages.push(first.message);
messages.push({ role: "tool", tool_call_id: call.id, content: result });
const second = await post({ model: "mock-1", messages, tools });
console.log("finish_reason:", second.finish_reason);
console.log("answer:", second.message.content);
node round-trip.js
# output
# finish_reason: tool_calls
# message: {"role":"assistant","content":null,"tool_calls":[{"id":"call_1_0","type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"Phnom Penh\"}"}}]}
# [our code] running getWeather for Phnom Penh
# finish_reason: stop
# answer: Result: Phnom Penh: 33C, sunny
(The call_1_0 id depends on how many requests the mock has seen.)
Find the line [our code] running getWeather. It is printed between
the two model calls, by our process. The model was not running at
that moment. It was a different computer, or in the mock's case, just
waiting for the next request. This is the "model never runs code" idea
in action.
After step 5, messages holds three items. Printing it shows the whole
conversation, the thing you resend on every call:
[0] user "What is the weather in Phnom Penh?"
[1] assistant content: null, tool_calls: [get_weather {"city":"Phnom Penh"}]
[2] tool "Phnom Penh: 33C, sunny" (tool_call_id matches [1])
Push the final answer too and you have four. Every agent you build in this series is a loop around exactly these message shapes.
10. When the Request Does Not Fit
The mock does not always call a tool. Three other outcomes matter, and
real models show the same behaviours in their own way. Save edge.js:
// edge.js: what the mock does when the request does not fit a tool.
const URL = `${process.env.LLM_BASE_URL ?? "http://localhost:8787"}/v1/chat/completions`;
const tools = [{
type: "function",
function: {
name: "get_weather",
description: "Get the current weather for a city",
parameters: {
type: "object",
properties: { city: { type: "string" } },
required: ["city"],
},
},
}];
async function ask(content, model = "mock-1") {
const res = await fetch(URL, {
method: "POST",
headers: { "content-type": "application/json", authorization: "Bearer test-key" },
body: JSON.stringify({ model, messages: [{ role: "user", content }], tools }),
});
const choice = (await res.json()).choices[0];
console.log(JSON.stringify(content), "->", choice.finish_reason, JSON.stringify(choice.message.content ?? choice.message.tool_calls[0].function));
}
await ask("Tell me a joke");
await ask("What is the weather?");
await ask("What is the weather in Siem Reap?");
await ask("Weather in Takeo please");
node edge.js
# output
# "Tell me a joke" -> stop "I don't have a tool that can do that."
# "What is the weather?" -> stop "I need more information: what is the city?"
# "What is the weather in Siem Reap?" -> tool_calls {"name":"get_weather","arguments":"{\"city\":\"Siem Reap\"}"}
# "Weather in Takeo please" -> tool_calls {"name":"get_weather","arguments":"{\"city\":\"Takeo\"}"}
So after every model call there are three possible outcomes your code must handle:
finish_reason: "tool_calls": run the tools, send the results back.finish_reason: "stop"with text: this is the final answer.finish_reason: "stop"with a question or refusal, such as "I need more information". It is also a final answer for this turn. A user (or another part of your program) must reply.
Real models also have other finish reasons, such as being cut off by a length limit. Check your provider's documentation for the exact names.
The same request without tools is just a chatbot turn:
curl -s localhost:8787/v1/chat/completions \
-H 'authorization: Bearer test-key' \
-H 'content-type: application/json' \
-d '{"model":"mock-1","messages":[{"role":"user","content":"What is the weather in Phnom Penh?"}]}'
# output
# {"id":"mock-7","model":"mock-1","choices":[{"index":0,"message":{"role":"assistant","content":"Phnom Penh is the capital of Cambodia."},"finish_reason":"stop"}],"usage":{"prompt_tokens":9,"completion_tokens":10,"total_tokens":19}}
No tools, no tool call: the mock falls back to its canned chat reply. That is what a chatbot does, and the difference between this reply and the earlier one is the whole point of the chapter.
11. Honest Limits of the Mock
The mock lets you learn the plumbing for free, but it is not a model. Know exactly what it simplifies, so you do not draw wrong conclusions:
- It is rule-based, not intelligent. It splits your message at
"then" or
;into steps. For each step it picks the tool whose name and description share the most words with that step. It does not understand meaning. - Argument extraction is by simple patterns: numbers in order, quoted text, or a word after "in", "for", "of", "about", "named", "called" or "file". A real model fills arguments by understanding the request and your schema.
- One tool call per reply. A real model may return several
tool_callsat once (chapter 2). - No streaming together with tools. Sending
"stream": truewithtoolsreturns HTTP 400 from the mock. Real APIs can stream tool calls; the mock just does not. - No real reasoning, no planning, no recovery (except the scripted
mock-sloppy). If your wording does not match a tool's words, it says "I don't have a tool that can do that." - Two special models for later chapters.
mock-sloppyfirst sends broken JSON arguments, then retries correctly after it sees an error (chapters 2 and 4).mock-gullibleobeys the textCALL <tool> <arg>if it appears inside a tool result, which simulates a model vulnerable to prompt injection (chapter 9). Real models are often more resistant than this, but not immune. - Token counts are estimates (characters divided by 4).
Use the mock to test your code. Do not use it to judge how well an agent would behave with a real model. That needs a real model and real evaluation (chapter 10).
12. Recipe: The Mock Server
Here is the full mock-llm.js. You can type it, or copy it from the
repository files that go with this series.
// mock-llm.js — a tiny fake LLM server with a chat-completions-style API.
// Zero dependencies. Run: node mock-llm.js (default port 8787)
import http from "node:http";
const KEY = "test-key";
let requestCount = 0;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const estimateTokens = (text) => Math.max(1, Math.ceil(text.length / 4));
// ---- Tool calling (a rule-based stand-in for a model that can call tools) ----
const STOP = new Set("a an the is are was were be to of in on at for and or but with by from as it its this that these those i you we they he she what when where who which how do does did can could should would will my your our their me us about into than then there here please tool".split(" "));
const wordsOf = (t) => t.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, " ").split(/\s+/).filter((w) => w && !STOP.has(w));
function pickTool(step, tools) {
const sw = new Set(wordsOf(step));
let best = null;
for (const t of tools) {
const f = t.function;
const tw = new Set(wordsOf(`${f.name.replace(/_/g, " ")} ${f.description ?? ""}`));
const score = [...sw].filter((w) => tw.has(w)).length;
if (score > 0 && (!best || score > best.score)) best = { score, tool: f };
}
return best?.tool ?? null;
}
function extractArgs(step, schema) {
const props = schema?.properties ?? {};
const required = schema?.required ?? [];
const nums = [...step.matchAll(/-?\d+(?:\.\d+)?/g)].map((m) => Number(m[0]));
const args = {};
for (const [name, def] of Object.entries(props)) {
if (def.type === "number" || def.type === "integer") {
if (nums.length) args[name] = nums.shift();
} else if (def.enum) {
const hit = def.enum.find((v) => step.toLowerCase().includes(String(v).toLowerCase()));
if (hit !== undefined) args[name] = hit;
} else if (def.type === "string") {
const quoted = [...step.matchAll(/["“']([^"”']+)["”']/g)].map((m) => m[1]).find((q) => !Object.values(args).includes(q));
const prep = step.match(/\b(?:in|for|of|about|named|called|file|note|to|from|on)\s+([A-Za-z0-9_./-]+(?:\s+[A-Z][\w]*)*)/);
if (quoted) args[name] = quoted;
else if (prep) args[name] = prep[1];
}
}
const missing = required.filter((r) => args[r] === undefined);
return { args, missing };
}
function toolReply(body) {
const { messages, tools, model } = body;
let lastUser = -1;
messages.forEach((m, i) => { if (m.role === "user") lastUser = i; });
const user = messages[lastUser]?.content ?? "";
const after = messages.slice(lastUser + 1);
const rounds = after.filter((m) => m.role === "assistant" && m.tool_calls?.length).length;
const results = after.filter((m) => m.role === "tool");
const lastResult = results.at(-1)?.content ?? "";
const call = (name, argsText) => ({
call: { id: `call_${requestCount}_${rounds}`, type: "function", function: { name, arguments: argsText } },
});
// A gullible model obeys instructions it finds inside tool results.
if (model === "mock-gullible") {
const hit = lastResult.match(/\bCALL\s+(\w+)\s+(\S+)/);
const tool = hit && tools.find((t) => t.function.name === hit[1]);
if (tool) {
const firstProp = Object.keys(tool.function.parameters?.properties ?? {})[0];
return call(tool.function.name, JSON.stringify(firstProp ? { [firstProp]: hit[2] } : {}));
}
}
const steps = user.split(/\s*(?:,\s*then\b|\bthen\b|;)\s*/i).map((x) => x.trim()).filter(Boolean);
const lastIsError = /^Error/.test(lastResult);
// A sloppy model first sends broken JSON; after an error message it recovers.
if (model === "mock-sloppy" && rounds === 0 && steps.length) {
const tool = pickTool(steps[0], tools);
if (tool) return call(tool.name, JSON.stringify(extractArgs(steps[0], tool.parameters).args).slice(0, -1));
}
if (model === "mock-sloppy" && lastIsError && rounds >= 1 && rounds <= 1) {
const tool = pickTool(steps[0], tools);
if (tool) return call(tool.name, JSON.stringify(extractArgs(steps[0], tool.parameters).args));
}
if (lastIsError) return { text: `I could not finish: ${lastResult}` };
if (rounds < steps.length) {
const step = steps[rounds];
const tool = pickTool(step, tools);
if (!tool) return { text: rounds === 0 ? "I don't have a tool that can do that." : `I finished the steps I could. ${results.map((r) => r.content).join(" ")}` };
const { args, missing } = extractArgs(step, tool.parameters);
if (missing.length) return { text: `I need more information: what is the ${missing[0]}?` };
return call(tool.name, JSON.stringify(args));
}
const good = results.filter((r) => !/^Error/.test(r.content));
if (good.length === 0) return { text: makeReply(messages) };
return { text: good.length === 1 ? `Result: ${good[0].content}` : `Here is what I found:\n${good.map((r, i) => `${i + 1}. ${r.content}`).join("\n")}` };
}
function makeReply(messages) {
const users = messages.filter((m) => m.role === "user");
const last = (users.at(-1)?.content ?? "").toLowerCase();
const system = messages.find((m) => m.role === "system")?.content ?? "";
const pirate = system.toLowerCase().includes("pirate");
const say = (t) => (pirate ? `Arr! ${t}` : t);
if (/\bjson\b/.test(last)) return '{"answer": "mock json", "confidence": 0.9}';
if (/what('| i)s my name|who am i/.test(last)) {
for (const m of [...users].reverse()) {
const hit = m.content.match(/my name is (\w+)/i);
if (hit) return say(`Your name is ${hit[1]}.`);
}
return say("I do not know your name yet. You have not told me.");
}
if (/\b(hello|hi|hey)\b/.test(last)) return say("Hello! How can I help you today?");
if (/phnom penh/.test(last)) return say("Phnom Penh is the capital of Cambodia.");
return say(`You said: ${users.at(-1)?.content ?? ""}`);
}
export function startMock(port = 8787) {
const server = http.createServer(async (req, res) => {
const send = (status, obj, headers = {}) => {
res.writeHead(status, { "content-type": "application/json", ...headers });
res.end(JSON.stringify(obj));
};
if (req.method === "GET" && req.url === "/health") return send(200, { ok: true });
if (req.method !== "POST" || req.url !== "/v1/chat/completions")
return send(404, { error: { message: "not found" } });
if (req.headers.authorization !== `Bearer ${KEY}`)
return send(401, { error: { message: "invalid api key" } });
let raw = "";
for await (const chunk of req) raw += chunk;
let body;
try { body = JSON.parse(raw); } catch {
return send(400, { error: { message: "body must be valid JSON" } });
}
const { model, messages, stream } = body;
if (!model || !Array.isArray(messages) || messages.length === 0)
return send(400, { error: { message: "model and messages are required" } });
requestCount += 1;
if (model === "mock-flaky" && requestCount % 3 !== 0)
return send(429, { error: { message: "rate limit, slow down" } }, { "retry-after": "0" });
if (model === "mock-error") return send(500, { error: { message: "server exploded" } });
if (model === "mock-slow") await sleep(3000);
if (body.tools?.length) {
if (stream) return send(400, { error: { message: "mock: streaming together with tools is not supported" } });
const r = toolReply(body);
const pt = estimateTokens(messages.map((m) => m.content ?? "").join(" "));
const content = r.text ?? null;
const ct = estimateTokens(content ?? JSON.stringify(r.call));
return send(200, {
id: `mock-${requestCount}`,
model,
choices: [{
index: 0,
message: r.call ? { role: "assistant", content: null, tool_calls: [r.call] } : { role: "assistant", content },
finish_reason: r.call ? "tool_calls" : "stop",
}],
usage: { prompt_tokens: pt, completion_tokens: ct, total_tokens: pt + ct },
});
}
const text = makeReply(messages);
const promptTokens = estimateTokens(messages.map((m) => m.content ?? "").join(" "));
const completionTokens = estimateTokens(text);
const usage = {
prompt_tokens: promptTokens,
completion_tokens: completionTokens,
total_tokens: promptTokens + completionTokens,
};
if (!stream) {
return send(200, {
id: `mock-${requestCount}`,
model,
choices: [{ index: 0, message: { role: "assistant", content: text }, finish_reason: "stop" }],
usage,
});
}
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
const event = (obj) => res.write(`data: ${JSON.stringify(obj)}\n\n`);
event({ id: `mock-${requestCount}`, choices: [{ index: 0, delta: { role: "assistant" } }] });
for (const piece of text.match(/\S+\s*/g) ?? []) {
await sleep(40);
event({ id: `mock-${requestCount}`, choices: [{ index: 0, delta: { content: piece } }] });
}
event({ id: `mock-${requestCount}`, choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage });
res.write("data: [DONE]\n\n");
res.end();
});
return new Promise((resolve) => server.listen(port, () => resolve(server)));
}
if (import.meta.url === `file://${process.argv[1]}`) {
const port = Number(process.env.PORT ?? 8787);
await startMock(port);
console.log(`mock LLM listening on http://localhost:${port} (api key: ${KEY})`);
}
The chat, error and streaming parts are the same as in the chatbot series (see What an LLM API Is and Streaming Responses). Only the tool-calling part is new. Read it in these pieces:
pickTool(step, tools)removes common words ("the", "is", "what"...), then scores each tool by how many of the remaining words appear in the tool's name and description. The best score wins. No match means no tool.extractArgs(step, schema)walks the schema'sproperties. Numbers get the numbers found in the text, in order. A property with anenumgets the allowed value mentioned in the text. A string gets a quoted phrase or the word after a preposition. Required properties that stay empty are reported asmissing.toolReply(body)is the brain. It finds the last user message, counts how many assistant tool-call rounds have happened since, and uses that count as the index of the next step. If there is a step left, it returns one tool call. If not, it summarises the tool results asResult: ...or a numberedHere is what I found:list. Errors becomeI could not finish: ....- The
if (body.tools?.length)branch in the server builds the response in the real chat-completions shape:tool_callswithfinish_reason: "tool_calls", or plain text with"stop".
Because the mock decides everything from the message history alone, it
shows an important property of real APIs too: the server keeps no
memory between calls. The whole state of an agent is the messages
array in your program.
13. Try It Yourself
- Add a second tool. Add an
addtool (two numbersaandb, both required) to thetoolslist inround-trip.js, and change the user message to "add 2 and 40". What does the mock return? Run the tool in your code and send back the result. - Break the round trip. Change
tool_call_idin the tool message to"wrong". Does the mock complain? (It does not look at ids.) What do you think a real API would do, and why does the id exist? Think about what happens when the model asks for two calls at once. - Skip the assistant message. Remove
messages.push(first.message)so the tool message arrives without the assistant request before it. Look at the reply (the mock simply asks for the call again, because it counts the assistant tool-call messages it can see), then explain why real APIs usually reject this. - Two steps. Use the user message "add 2 and 40, then get the weather in Siem Reap" with both tools. The mock returns one call at a time. Run the first, send the result, and write down what you get next. Count how many requests the whole task needed.
- Workflow or agent? For each task decide which one fits, and say why: (a) resize every photo in a folder to 800px; (b) find out why a user cannot log in, using logs and the database; (c) send a weekly report email built from a fixed query.
- Spot the guess. Ask the plain chatbot (no
tools) "what is the weather in Siem Reap?". Does the answer come from real data? Why is that the problem tools solve?
14. Quick Review: Common Questions
Questions beginners often ask about this chapter. Try answering each one yourself first, then click the question to open the answer.
What is an AI agent, in one sentence?
A language model that can ask your program to run tools, reads the results, and keeps going in a loop until a goal is done or a limit is reached. The model decides the next step and your code carries it out.
Does the model actually run my function?
No. It only writes text that says "please call this tool with these arguments". Your program reads that text, runs the function (or refuses), and sends the result back as a message. That gap is also where you add safety checks.
Is an agent the same as a chatbot with plugins?
Close in spirit. A chatbot answers once per question. An agent can take several steps, using tool results to decide the next one. A chatbot with tools that always runs one tool and answers is a simple workflow. The loop is what makes it an agent.
Why not make everything an agent?
Agents cost more, take longer, and behave less predictably than fixed code. If you already know the steps, a normal program (maybe with a model call inside one step) is cheaper, faster and easier to test. Use an agent when the path cannot be known in advance.
Do I need a paid model or the internet for this series?
No. Everything here runs on a free local mock that follows simple rules. The code and messages are the same shape a hosted model uses, so the skills transfer. For real quality you will later point the same code at a real provider, or a model you run yourself.
Why does the mock call the wrong tool sometimes?
Because it matches words, not meaning. If your sentence shares no words with a tool's name or description, it will not find the tool. It is a teaching stand-in. Real models understand meaning, but they still make mistakes, which is why the later chapters add validation, limits and tests.
Is it safe to give an agent tools?
It is safe only as far as you limit it. A tool is a real action done with your permissions. Start with read-only tools, check every argument, and ask a human before anything risky. Chapter 9 covers this in detail.
Where to go next. You have seen the whole idea once, by hand: a description of tools goes in, a request for a call comes out, you run it, and you send the result back. Next, in Tool Calling Basics you turn that into reusable code: a schema validator, a tool registry that never crashes, and a small set of demo tools.
ទិញកាហ្វេជូនខ្ញុំ
ចូលចិត្តមេរៀននេះមែនទេ? សូមពិចារណាទិញកាហ្វេជូនខ្ញុំមួយកែវ ដើម្បីគាំទ្រការបង្កើតមាតិកាឥតគិតថ្លៃបន្ថែមទៀត!