Memory, State and Planning
ជំពូកនេះមិនទាន់ត្រូវបានបកប្រែជាភាសាខ្មែរនៅឡើយទេ — សូមអានជាភាសាអង់គ្លេសខាងក្រោម។
Table of Contents
- Two Kinds of Memory
- Short-Term Memory Is the Message List
- Compacting the History Safely
- Testing the Rule You Must Not Break
- Long-Term Memory Lives Outside the Model
- A Scratchpad and a Plan Tool
- Plan-Then-Execute vs the ReAct Loop
- Reflection and Self-Checks
- Long Tasks: Checkpoint and Resume
- Tool Output Is the Biggest Cost
- Search as a Tool
- Multi-Agent Setups, Briefly
- Recipe: The history Module
- Try It Yourself
- Quick Review: Common Questions
1. Two Kinds of Memory
A model has no memory of its own. Every call starts blank. What feels like memory is text you send again. That gives an agent two kinds of memory, and it helps to name them:
- Short-term memory is the message list for the current task: the goal, every tool call, every result. It lives in a JavaScript array in your program and dies when the program ends.
- Long-term memory is anything stored outside the conversation (a file, a database, a notes tool) that the agent can read again in a later run.
Think of a cook in a kitchen. The order slips pinned above the stove are short-term memory: useful now, thrown away tonight. The recipe book on the shelf is long-term memory: it was there yesterday and will be there tomorrow.
Planning is the third idea in this chapter. A long task is easier when
the agent writes down its steps and ticks them off, instead of holding
everything in its head. In this chapter you will build src/history.js.
It has four pieces: compactHistory, checkHistory, a plan tool set,
and a resumable loop. The first two protect your context window, the
last two protect your time when a task is long.
You need the mock running, as in
Chapter 1,
and your agentlab folder from the earlier chapters. All demos below
read the mock address from LLM_BASE_URL (default
http://localhost:8787).
Honest note about the mock. The mock is rule-based. It splits the task at "then" or ";" and does one tool call per step. It cannot invent a plan, notice a mistake or decide to check its work. So in this chapter you write the steps in the task, and the demos prove that the plumbing works (the plan tool, the compaction, the checkpoint). A real model decides those things itself, and how well it does is a property of the model, not of this code.
2. Short-Term Memory Is the Message List
In The Agent Loop
you saw that runAgent keeps one array, messages, and sends all of
it on every step. Step 5 sends everything from steps 1 to 4 again.
That has two consequences:
- Cost and speed grow with every step. You pay for the same old text again and again, and the model takes longer to read it.
- The window is finite. Every model can read only so much text in one call. When the history is too long, the call fails or the oldest text is cut off. See Context Windows and Memory.
For a task of three or four steps this does not matter. For an agent that reads files, runs searches and loops twenty times, it matters a lot. Most of the text is tool results: a search returns a page, a file read returns a whole file. The model needed that text once, to decide the next step. After that, it is mostly dead weight.
The fix is to compact the history: keep what the model still needs and shorten the rest.
3. Compacting the History Safely
What must always stay?
- The system message (the rules).
- The first user message (the goal). Lose this and the agent forgets what it is doing.
- The last few messages, because the newest steps matter most.
What can shrink? The middle: old tool calls and old, long results.
compactHistory replaces the whole middle with one short summary
message that lists each old call and the first part of its result.
Notice that the summary is built by plain code, with no model call. It is cheap and predictable. The cost is that it is crude. A real system might ask a model to write the summary instead (better wording, but it costs a call and can lose details). Start with the crude version.
There is one rule you must not break. In a chat-completions-style
API, a tool message must follow the assistant message that asked for
it, and that assistant message must keep its tool_calls. Many
providers reject a conversation where this is broken. Real APIs differ,
so check your provider's docs, but treat the rule as universal in your
own code.
The tempting way to shorten history is messages.slice(-4). Let us see
what that does:
// demo-naive.js
import { runAgent } from "./src/agent.js";
import { makeDemoTools } from "./src/demo-tools.js";
import { compactHistory, checkHistory } from "./src/history.js";
const r = await runAgent({
task: "get weather in Phnom Penh; then add 2 and 3; then get weather in Takeo",
tools: makeDemoTools(),
});
console.log("full history:", checkHistory(r.messages));
// The tempting way to "keep only the last few messages":
const naive = [r.messages[0], ...r.messages.slice(-4)];
console.log("naive slice :", checkHistory(naive));
console.log("compactHistory:", checkHistory(compactHistory(r.messages, { keepLast: 4 })));
// output
// full history: []
// naive slice : [ 'message 1: tool result without a matching tool call' ]
// compactHistory: []
checkHistory returns a list of problems, and an empty list means the
conversation is valid. The naive slice cut the assistant message and
left its tool result alone. compactHistory avoids this by moving the
start of the kept tail backwards until it no longer begins with a
tool message. Here is the real compaction on a six-step task:
// demo-compact.js
import { runAgent } from "./src/agent.js";
import { makeDemoTools } from "./src/demo-tools.js";
import { compactHistory, checkHistory } from "./src/history.js";
const task = [
"get weather in Phnom Penh", "get weather in Siem Reap", "get weather in Takeo",
"add 2 and 3", "add 10 and 20", 'save note "trip" with "pack water"',
].join("; then ");
const r = await runAgent({ task, tools: makeDemoTools() });
const size = (msgs) => msgs.reduce((n, m) => n + JSON.stringify(m).length, 0);
console.log("steps:", r.steps, " messages:", r.messages.length, " chars:", size(r.messages));
const small = compactHistory(r.messages, { keepLast: 4 });
console.log("compacted messages:", small.length, " chars:", size(small));
console.log("problems before:", checkHistory(r.messages), " after:", checkHistory(small));
console.log(small.map((m) => m.role + (m.tool_calls ? "(tool_calls)" : "")).join(" > "));
console.log(small[2].content);
// output
// steps: 7 messages: 15 chars: 1834
// compacted messages: 8 chars: 1210
// problems before: [] after: []
// system > user > system > assistant(tool_calls) > tool > assistant(tool_calls) > tool > assistant
// Earlier steps (shortened):
// - get_weather({"city":"Phnom Penh"}) -> Phnom Penh: 33C, sunny
// - get_weather({"city":"Siem Reap"}) -> Siem Reap: 31C, light rain
// - get_weather({"city":"Takeo"}) -> Takeo: 32C, cloudy
// - add({"a":2,"b":3}) -> 5
The step and message counts are stable. The character counts can differ by a few characters between runs, because the mock puts a request counter inside each tool-call id. The shape is what matters: 15 messages became 8, and the old steps are now four short lines.
The summary is a system message because the mock (and most APIs) treat
the last user message as "the current request", and we do not want
to confuse that. If your provider does not accept a system message in
the middle of a conversation, send the summary as an assistant or user
message instead. Check your provider's docs.
4. Testing the Rule You Must Not Break
A rule that matters this much deserves a test. This is the same
node --test style as in
Designing Good Tools.
The key test tries every keepLast value on a conversation that
includes parallel tool calls (one assistant message with two
tool_calls), because boundaries inside such a group are the easiest to
get wrong.
// test/history.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { compactHistory, checkHistory } from "../src/history.js";
// A hand-made conversation: 5 rounds, round 3 has two PARALLEL tool calls.
function makeConversation() {
const messages = [
{ role: "system", content: "You are helpful." },
{ role: "user", content: "Do the task." },
];
for (let round = 1; round <= 5; round++) {
const ids = round === 3 ? [`c${round}a`, `c${round}b`] : [`c${round}a`];
messages.push({
role: "assistant",
content: null,
tool_calls: ids.map((id) => ({ id, type: "function", function: { name: "lookup", arguments: `{"n":${round}}` } })),
});
for (const id of ids) messages.push({ role: "tool", tool_call_id: id, content: `result ${id} ` + "x".repeat(500) });
}
messages.push({ role: "assistant", content: "All done." });
return messages;
}
test("the test conversation itself is valid", () => {
assert.deepEqual(checkHistory(makeConversation()), []);
});
test("every keepLast value leaves a valid conversation", () => {
const messages = makeConversation();
for (let keepLast = 0; keepLast <= messages.length + 2; keepLast++) {
const out = compactHistory(messages, { keepLast });
assert.deepEqual(checkHistory(out), [], `keepLast=${keepLast}`);
assert.equal(out[0].role, "system");
assert.equal(out.find((m) => m.role === "user").content, "Do the task.");
}
});
test("old tool results are shortened", () => {
const out = compactHistory(makeConversation(), { keepLast: 3, maxToolChars: 40 });
const summary = out.find((m) => m.content?.startsWith("Earlier steps"));
assert.ok(summary);
assert.ok(summary.content.length < 700);
assert.ok(out.length < makeConversation().length);
});
test("checkHistory catches an orphan tool message", () => {
const broken = makeConversation().slice(3); // starts with a tool result whose call was cut off
assert.ok(checkHistory(broken).length > 0);
});
node --test test/history.test.js
# output
# TAP version 13
# ok 1 - the test conversation itself is valid
# ok 2 - every keepLast value leaves a valid conversation
# ok 3 - old tool results are shortened
# ok 4 - checkHistory catches an orphan tool message
# tests 4
# pass 4
# fail 0
(Your output has a few more lines, such as # Subtest: and timings.
Only the pass and fail counts matter.)
While writing this test, the first version failed: with
keepLast: 0 the loop read messages[start].role past the end of the
array and crashed. The fix was one character, ?.. This is why you
test edge values such as 0, 1 and "bigger than the whole list".
5. Long-Term Memory Lives Outside the Model
Short-term memory vanishes when the run ends. Look at what happens to
the demo save_note tool across two runs:
// demo-memory.js
import { runAgent } from "./src/agent.js";
import { makeDemoTools } from "./src/demo-tools.js";
// Run 1: the agent saves a note. The notes live in a Map inside makeDemoTools().
const run1 = await runAgent({ task: 'save note "trip" with "pack water"', tools: makeDemoTools() });
console.log("run 1:", run1.answer);
// Run 2: a new session. We build fresh tools, so the Map is new and empty.
const run2 = await runAgent({ task: "read note trip", tools: makeDemoTools() });
console.log("run 2:", run2.answer);
// output
// run 1: Result: Saved note "trip".
// run 2: I could not finish: Error: no note titled "trip"
The first run saved a note. The second run built fresh tools, so the
notes Map was new and empty, and the tool returned a helpful error.
That is correct behaviour for an in-memory demo, and it shows the
design question: where does the agent's knowledge live between runs?
Long-term memory is just a tool that writes somewhere durable:
- a file (a notes file or a JSON file; simple and enough for one user on one machine),
- a database (when many users or many agents share the data),
- a search index (so the agent can find old notes by meaning; see section 11).
The pattern is always the same two tools: one to save something and one to read or search it. The model decides when to use them. Good instructions help: "Save anything the user asks you to remember. Check your notes before answering questions about the user's preferences."
Two cautions. First, memory is also an attack surface. If an agent saves text it got from a web page, a later run may trust that text. We return to this in Safety, Permissions and Sandboxing. Second, memory is personal data. Store only what you need, and let users see and delete it.
6. A Scratchpad and a Plan Tool
A scratchpad is a place for the model to write intermediate thoughts and facts. The cheapest scratchpad is the conversation itself, since the model can write reasoning in its replies. A plan tool is a scratchpad with structure: a to-do list the model fills in and ticks off. The benefit is that the plan stays correct and visible even after you compact the history, because it lives in the tool, not in old messages. You can also print it for the user.
makePlanTools() gives three tools: plan_add, plan_done and
plan_show. Here the mock "plans" only because we wrote the steps in
the task (remember, the mock cannot plan on its own):
// demo-plan.js
import { runAgent } from "./src/agent.js";
import { makePlanTools } from "./src/history.js";
const tools = makePlanTools();
const task = 'plan add "write slides"; plan add "test demos"; plan done 1; plan show';
const r = await runAgent({
task,
tools,
onEvent: (e) => console.log(e.type, e.name ?? "", e.args ?? e.content ?? e.text ?? ""),
});
console.log(r.stopReason, r.steps);
// output
// tool_call plan_add {"step":"write slides"}
// tool_result plan_add Added step 1.
// tool_call plan_add {"step":"test demos"}
// tool_result plan_add Added step 2.
// tool_call plan_done {"number":1}
// tool_result plan_done Step 1 done.
// tool_call plan_show {}
// tool_result plan_show 1. [x] write slides
// 2. [ ] test demos
// answer Here is what I found:
// 1. Added step 1.
// 2. Added step 2.
// 3. Step 1 done.
// 4. 1. [x] write slides
// 2. [ ] test demos
// done 5
Notice how plan_done handles a bad number: it throws an error that
includes the current plan, so a model that miscounts can correct
itself. That is the "helpful error message" idea from
Designing Good Tools.
With a real model you would add one line to the system prompt: "For any task with more than two steps, first add each step to the plan, then do them in order and mark each one done." Whether the model follows this reliably depends on the model, so measure it (Chapter 10).
7. Plan-Then-Execute vs the ReAct Loop
Two common shapes for an agent:
ReAct loop (what you built in Chapter 3). The model thinks one step, calls a tool, reads the result, thinks again. It plans a little at a time. This is flexible: if a result is surprising, the next step adapts. The risk is wandering, repeating, or forgetting the goal on long tasks.
Plan-then-execute. The model first writes the whole plan. Then your code (or the model) works through it step by step. This is easier to read, to show a human for approval and to resume. The risk is a rigid plan: if step 2 reveals a surprise, step 3 may no longer make sense, so you need a way to re-plan.
Most real systems mix them: write a short plan, run a ReAct loop on each
step, and update the plan when something changes. The plan_* tools
above are exactly that pattern. The honest summary: neither is "better".
Use a fixed plan when the task is predictable, and the loop when you
cannot know the steps in advance. And if you can write the steps in
code, a plain workflow
beats both.
The mock supports only the crude version of plan-then-execute: the "then" steps in your task. It will not revise a plan.
8. Reflection and Self-Checks
Reflection means asking the model to check its own work before it finishes: "Does your answer match what the user asked? Did every tool call succeed?" It can catch simple mistakes, such as forgetting one of three requested items.
Use it with care:
- It costs an extra model call (or more).
- A model can confidently approve a wrong answer. Reflection is not proof.
- The strongest checks are not model-based. If the task was "save a
note", call
read_notein your own code and compare. If the task was "write a function", run the tests. Code checks are cheap, exact and honest.
So the order of preference is: a check in code, then a check by a different model call, then trust. Chapter 10 builds code checks into an evaluation harness.
9. Long Tasks: Checkpoint and Resume
A task that takes twenty steps will, sooner or later, be interrupted: a network error, a closed laptop, a crash. If everything lives in memory, you start over and pay again. A checkpoint saves the message list to a file after every step, so a later run continues where the last one stopped.
runWithCheckpoint is the agent loop with two changes: it loads the
saved messages if the file exists, and it saves after each step. It
writes to a temporary file and then renames it, so a crash in the middle
of saving never leaves a half-written checkpoint. The stopAfter option
simulates a crash:
// demo-checkpoint.js
import { unlink } from "node:fs/promises";
import { runWithCheckpoint } from "./src/history.js";
import { makeDemoTools } from "./src/demo-tools.js";
const file = "checkpoint.json";
await unlink(file).catch(() => {});
const task = "get weather in Phnom Penh; then get weather in Siem Reap; then add 2 and 3";
const first = await runWithCheckpoint({ task, tools: makeDemoTools(), file, stopAfter: 2 });
console.log("first run:", first.stopReason, "messages saved:", first.messages.length);
const second = await runWithCheckpoint({ task, tools: makeDemoTools(), file });
console.log("second run:", second.stopReason, "messages:", second.messages.length);
console.log(second.answer);
// output
// first run: interrupted messages saved: 6
// second run: done messages: 9
// Here is what I found:
// 1. Phnom Penh: 33C, sunny
// 2. Siem Reap: 31C, light rain
// 3. 5
The first run stopped after two steps with six messages saved (system, user, and two tool rounds). The second run loaded those six messages and carried on. Tool calls already answered were not run again. The call ids inside the file vary from run to run, but the counts do not.
A few honest limits:
- Resuming works for reads. If a step had a side effect (sent an email) and the crash happened after the effect but before the save, resuming may repeat it. Make write tools idempotent, or record what was done. Chapter 10 returns to this.
maxStepscounts steps per run, not per task. For a hard limit across resumes, count the tool calls already in the file.- Delete the checkpoint when the task is done, and never write secrets into it. It is a plain text copy of the whole conversation.
10. Tool Output Is the Biggest Cost
Long tool outputs are where history grows fastest. Three habits:
- Return less. A tool should return what the model needs, not the whole page. See Designing Good Tools.
- Truncate.
runToolCallalready cuts results at 4000 characters by default and says how many were cut. - Compact old results, as in section 3.
Let us measure the effect. This demo gives the agent a noisy read_log
tool (about 1,500 characters per call), runs three calls, then sends the
next request twice: once with the full history, once compacted. The
mock reports token counts using a rough "4 characters per token"
estimate, so the numbers are a guide, not a bill. Real tokenizers
count differently, but the proportion is realistic.
// demo-cost.js
import { runAgent } from "./src/agent.js";
import { complete } from "./src/llm.js";
import { defineTool, toApiTools } from "./src/tools.js";
import { makeDemoTools } from "./src/demo-tools.js";
import { compactHistory, checkHistory } from "./src/history.js";
// A tool with a noisy, long output (about 1,500 characters each time).
const readLog = defineTool({
name: "read_log",
description: "Read the log of a service",
parameters: { type: "object", properties: { service: { type: "string" } }, required: ["service"] },
run: ({ service }) => `${service} log\n` + "2026-01-01 12:00:00 INFO request handled in 12 ms\n".repeat(30),
});
const tools = [readLog, ...makeDemoTools()];
const task = "read log of api; then read log of web; then read log of db; then get weather in Takeo";
const r = await runAgent({ task, tools });
console.log("answer:", r.answer.split("\n")[0], "| steps:", r.steps);
const next = { role: "user", content: "add 2 and 3" };
const compact = compactHistory(r.messages, { keepLast: 2, maxToolChars: 80 });
const a = await complete({ messages: [...r.messages, next], tools: toApiTools(tools) });
const b = await complete({ messages: [...compact, next], tools: toApiTools(tools) });
console.log("full history : messages", r.messages.length, "prompt tokens", a.usage.prompt_tokens);
console.log("compact history: messages", compact.length, "prompt tokens", b.usage.prompt_tokens, "problems", checkHistory(compact));
console.log("same next move :", a.message.tool_calls[0].function.name, "==", b.message.tool_calls[0].function.name);
console.log(compact[2].content);
// output
// answer: Here is what I found: | steps: 5
// full history : messages 11 prompt tokens 2324
// compact history: messages 6 prompt tokens 1286 problems []
// same next move : add == add
// Earlier steps (shortened):
// - read_log({"service":"api"}) -> api log 2026-01-01 12:00:00 INFO request handled in 12 ms 2026-01-01 12:00:00 IN...
// - read_log({"service":"web"}) -> web log 2026-01-01 12:00:00 INFO request handled in 12 ms 2026-01-01 12:00:00 IN...
// - read_log({"service":"db"}) -> db log 2026-01-01 12:00:00 INFO request handled in 12 ms 2026-01-01 12:00:00 INF...
Nearly half of the prompt tokens disappeared, and the next move
(add) was the same. With a real model, compaction can change
behaviour, because the model no longer sees the full old results. That
is a trade-off, not a free win. Compact only the parts the agent is
unlikely to need again, and test the result on your own tasks.
11. Search as a Tool
In the RAG series the program always searched first and then asked the model. As a tool, search becomes the model's choice: it searches when it needs facts, with its own query, and may search again with different words. That is sometimes called agentic RAG.
Below is a sketch. It uses a tiny keyword search over three
passages so that it runs anywhere; in a real system run would call the
VectorStore from the RAG series. The tool returns numbered passages
with their source, in the same format as RAG, so the model can cite
them.
// demo-search-docs.js
import { runAgent } from "./src/agent.js";
import { defineTool } from "./src/tools.js";
// A SKETCH of "RAG as a tool". Real code would call the vector store from
// the RAG series; here a tiny keyword search over three passages stands in.
const DOCS = [
{ source: "membership.md", text: "Membership costs 10 US dollars per term." },
{ source: "projects.md", text: "Each team has 7 minutes to present on Showcase Day." },
{ source: "faq.md", text: "The club meets every Friday in Room 204." },
];
const words = (s) => s.toLowerCase().match(/[a-z0-9]+/g) ?? [];
const searchDocs = defineTool({
name: "search_docs",
description: "Search the club documents for passages about a topic",
parameters: {
type: "object",
properties: { query: { type: "string", description: "What to look for" } },
required: ["query"],
},
run: ({ query }) => {
const q = new Set(words(query));
const hits = DOCS.map((d) => ({ d, score: words(d.text).filter((w) => q.has(w)).length }))
.filter((h) => h.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 2);
if (!hits.length) return "No matching passages.";
return hits.map((h, i) => `[${i + 1}] (${h.d.source}) ${h.d.text}`).join("\n");
},
});
const r = await runAgent({
task: 'search docs for "membership costs"',
tools: [searchDocs],
onEvent: (e) => console.log(e.type, e.name ?? "", e.args ?? e.content ?? e.text ?? ""),
});
console.log(r.usage);
// output
// tool_call search_docs {"query":"membership costs"}
// tool_result search_docs [1] (membership.md) Membership costs 10 US dollars per term.
// answer Result: [1] (membership.md) Membership costs 10 US dollars per term.
// { prompt_tokens: 68, completion_tokens: 47, total_tokens: 115 }
The trade-off compared with plain RAG: tool-based search is more flexible (several searches, different queries) and also less predictable and more expensive (extra model calls). If every question needs a search, plain RAG is simpler and cheaper. Use search as a tool when only some questions need documents, or when the agent must search several times.
12. Multi-Agent Setups, Briefly
You will hear about "multi-agent" systems: one agent plans, another researches, another writes code, another reviews. The idea is a team of specialists, each with its own prompt and its own small set of tools. It can help when tasks are truly separate and when one agent's context would become too crowded.
The honest costs: more model calls (more money and time), more places for errors to compound, harder debugging (whose fault was the wrong answer?), and agents that pass wrong information to each other confidently. A good rule is to start with one agent and good tools. Split into several only when you can point to a concrete problem that one agent cannot solve, such as a context that is too big or tools that should not be given to the same agent. Often a single loop calling a "sub-task" function is enough, and that function can itself call a model.
13. Recipe: The history Module
The whole file, ready to copy. It uses complete from
Tool Calling Basics
and defineTool and runToolCall from the same chapter.
// src/history.js
import { writeFile, readFile, rename } from "node:fs/promises";
import { complete } from "./llm.js";
import { toApiTools, runToolCall, defineTool } from "./tools.js";
const short = (text, max) => (text.length > max ? text.slice(0, max) + "..." : text);
// Shrink a long conversation without breaking it. Keeps the system
// message(s), the first user message (the task) and the last `keepLast`
// messages. Everything in between becomes ONE short summary message.
export function compactHistory(messages, { keepLast = 6, maxToolChars = 120 } = {}) {
let head = 0;
while (head < messages.length && messages[head].role === "system") head++;
if (messages[head]?.role === "user") head++; // the task
let start = Math.max(head, messages.length - keepLast);
// Never start the tail on a tool message: its assistant tool_call would be cut off.
while (start > head && messages[start]?.role === "tool") start--;
const middle = messages.slice(head, start);
if (middle.length === 0) return messages;
const results = new Map(middle.filter((m) => m.role === "tool").map((m) => [m.tool_call_id, m.content]));
const lines = [];
for (const m of middle) {
for (const call of m.tool_calls ?? []) {
const result = short(String(results.get(call.id) ?? "(no result)").replace(/\s+/g, " "), maxToolChars);
lines.push(`- ${call.function.name}(${call.function.arguments}) -> ${result}`);
}
if (m.role === "assistant" && !m.tool_calls?.length && m.content) lines.push(`- you said: ${short(m.content, maxToolChars)}`);
}
const summary = { role: "system", content: `Earlier steps (shortened):\n${lines.join("\n")}` };
return [...messages.slice(0, head), summary, ...messages.slice(start)];
}
// The rule compactHistory must never break. Returns a list of problems.
export function checkHistory(messages) {
const problems = [];
const open = new Set();
messages.forEach((m, i) => {
if (m.role === "assistant" && m.tool_calls?.length) {
if (open.size) problems.push(`message ${i}: earlier tool calls never got answers`);
m.tool_calls.forEach((c) => open.add(c.id));
} else if (m.role === "tool") {
if (!open.delete(m.tool_call_id)) problems.push(`message ${i}: tool result without a matching tool call`);
} else if (open.size) {
problems.push(`message ${i}: a ${m.role} message came before tool calls were answered`);
open.clear();
}
});
if (open.size) problems.push("the last tool calls never got answers");
return problems;
}
// A to-do list the model keeps for itself: the plan lives in a tool, not in its head.
export function makePlanTools() {
const items = [];
const show = () => (items.length ? items.map((it, i) => `${i + 1}. [${it.done ? "x" : " "}] ${it.text}`).join("\n") : "(plan is empty)");
return [
defineTool({
name: "plan_add",
description: "Add a step to the plan",
parameters: { type: "object", properties: { step: { type: "string", description: "What to do" } }, required: ["step"] },
run: ({ step }) => {
items.push({ text: step, done: false });
return `Added step ${items.length}.`;
},
}),
defineTool({
name: "plan_done",
description: "Mark a plan step as done by its number",
parameters: { type: "object", properties: { number: { type: "integer", description: "Step number, starting at 1" } }, required: ["number"] },
run: ({ number }) => {
if (!items[number - 1]) throw new Error(`there is no step ${number}. ${show()}`);
items[number - 1].done = true;
return `Step ${number} done.`;
},
}),
defineTool({ name: "plan_show", description: "Show the whole plan", run: show }),
];
}
// A resumable agent loop: saves the messages to a JSON file after every step.
// stopAfter simulates a crash. Call again with the same file to resume.
export async function runWithCheckpoint({ task, tools, file, system = "You are a helpful assistant. Use the tools when they help. Be brief.", model, maxSteps = 8, stopAfter = Infinity }) {
let messages;
try {
messages = JSON.parse(await readFile(file, "utf8"));
} catch {
messages = [{ role: "system", content: system }, { role: "user", content: task }];
}
const save = async () => {
await writeFile(file + ".tmp", JSON.stringify(messages, null, 2));
await rename(file + ".tmp", file); // never leave a half-written checkpoint
};
for (let step = 1; step <= maxSteps; step++) {
if (step > stopAfter) return { answer: null, stopReason: "interrupted", messages };
const { message } = await complete({ messages, tools: toApiTools(tools), model });
messages.push(message);
if (!message.tool_calls?.length) {
await save();
return { answer: message.content, stopReason: "done", messages };
}
for (const call of message.tool_calls) {
const { content } = await runToolCall(call, tools);
messages.push({ role: "tool", tool_call_id: call.id, content });
}
await save();
}
return { answer: null, stopReason: "max_steps", messages };
}
How the pieces fit:
compactHistorykeeps system messages, the first user message and the lastkeepLastmessages, backs up so the tail never begins with atoolmessage, and turns the middle into one summary.checkHistorywalks the list and reports any broken tool-call pairing. Use it in tests, and while debugging in production.makePlanToolsis a to-do list as three tools.runWithCheckpointis a resumable agent loop. It is the same idea asrunAgent, so if you prefer, you can add the load and save steps to your own copy ofrunAgentinstead.
14. Try It Yourself
- Run
demo-naive.jswithslice(-1),slice(-2)andslice(-3). Which ones are broken? Explain why using the pairing rule. - Add a
compactHistoryoptiondropResults: truethat removes the result text completely and keeps only the call lines. Extend the test file to cover it, then see how much further the token count falls indemo-cost.js. - Make
plan_addrefuse more than 10 steps with a helpful error. What would a model see, and would it know what to do next? - Write
makeFileNotes(path):save_noteandread_notetools that store notes in a JSON file, so thatdemo-memory.jsrun 2 finds the note. Use the same write-then-rename trick as the checkpoint. - Run
demo-checkpoint.jswithstopAfter: 1, then3. Print the number of saved messages each time. Delete the file between runs. - Change
demo-search-docs.jsso the query matches nothing. What does the model (the mock) say? What would you want a real model to do?
15. 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.
Does the model really remember things?
No. A model is stateless: each call starts blank. When it seems to remember, your program sent the earlier text again. If you do not send it, it does not know it. This is why memory is mostly a job for the application, not the model.
Is the context window the same as memory?
The context window is the limit on how much text one call can hold. It is a kind of short-term memory. Long-term memory is storage outside the model. A bigger window helps, but it costs more per call, and models can still miss details in very long inputs.
Why not just keep the whole history forever?
Cost, speed and limits. Each step resends everything, so a long task costs much more than the sum of its parts. Also, a history full of old tool output can distract the model. Compacting keeps what matters.
Is it safe to let the agent write its own memory?
Only with care. Whatever the agent saves, a later run may trust, even if it came from an untrusted web page. Limit what can be saved, let people review and delete memories, and never store secrets.
Should I use a plan tool or just let the model reason?
For short tasks, just let it reason. For long tasks, a plan tool keeps the steps visible, survives history compaction and lets you show progress. Try both on your own tasks and compare, because the answer depends on the model.
When do I need several agents?
Rarely at first. Try one agent with well-designed tools. Split only when you can name the problem: the context is too crowded, or the roles need different permissions. Every extra agent adds cost and failure modes.
Can an agent really fix its own mistakes?
Sometimes, especially with clear error messages and a check it can run. It can also approve its own wrong answer. Prefer checks written in code over asking the model "are you sure?".
Where to go next. You have now covered the inside of an agent: tools, the loop, good tool design, and memory. Next, the standard that lets one set of tools serve many agents: What Is MCP?
ទិញកាហ្វេជូនខ្ញុំ
ចូលចិត្តមេរៀននេះមែនទេ? សូមពិចារណាទិញកាហ្វេជូនខ្ញុំមួយកែវ ដើម្បីគាំទ្រការបង្កើតមាតិកាឥតគិតថ្លៃបន្ថែមទៀត!