The agent that never finished
An AI agent is a loop in which a model chooses its own next step. Because nothing tells it when to quit, an agent needs a plan and a rule for stopping.
A support agent retries a failing lookup all night. Every retry costs money and none can succeed. The loop was correct. The stop rule was missing.
By the end of this lesson
Note
I can make an agent plan its steps and stop on a rule.
Key words
| Word | What it means | Example |
|---|---|---|
| Agent | a loop where a model picks the next step | |
| Plan | the steps an agent intends to take, as data | |
| Step budget | the most steps one run may take | 3 |
| Stop rule | a test that ends the loop | |
| Outcome | the reason a run ended |
The cells students fill in are left blank in this preview.
A plan is data, not a feeling
Ask the model for a plan as a JSON list, then treat it as data. Your program can count it, check each step and show it to a person before anything runs.
A plan is a proposal. The model may return a list with the wrong shape or a step no tool can do. Check the plan before you follow it.
Worked example: a plan and its run
| Step | Plan says | Tool result | Run state |
|---|---|---|---|
| 1 | look up apple | apple costs 40p | ok |
| 2 | look up pear | pear costs 45p | ok |
| 3 | look up kiwi | no tool fits | fail |
| 4 | report the prices | not reached | budget or stuck decides |
The cells students fill in are left blank in this preview.
Write the model helper, part 1
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const base = process.env.AI_URL || \"http://ai:4000\";' 'async function ask(messages, sample, max) {' ' try {' ' const res = await fetch(base + \"/v1/chat/completions\", {' ' method: \"POST\",' ' headers: { \"content-type\": \"application/json\", authorization: \"Bearer lab\" },' ' body: JSON.stringify({ model: \"lab\", messages: messages, max_tokens: max || 200 }),' ' signal: AbortSignal.timeout(30000),' ' });' ' if (res.ok === false) throw new Error(\"status \" + res.status);' ' return (await res.json()).choices[0].message.content;' > model.js"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Write the model helper, part 2
docker exec -w /home/node/project agent sh -c "printf '%s\n' ' } catch (error) {' ' console.log(\"Recorded sample: no model answered (\" + error.message + \").\");' ' const list = [].concat(sample);' ' const turn = messages.filter(function (m) { return m.role === \"assistant\"; }).length;' ' return list[Math.min(turn, list.length - 1)];' ' }' '}' 'module.exports = { ask };' >> model.js"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Write the planning agent, part 1
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const { ask } = require(\"./model\");' 'const PRICES = { apple: 40, pear: 45, bread: 120 };' 'const messages = [' ' { role: \"system\", content: \"You reply with JSON only.\" },' ' { role: \"user\", content: \"Plan how to find the price of an apple, a pear and a kiwi. Reply with a JSON array of 3 short strings, one item to look up each. Reply with only the JSON.\" },' '];' '// Recorded sample, used only when no model answers.' 'const sample = JSON.stringify([\"apple\", \"pear\", \"kiwi\"]);' > plan1.js"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Write the planning agent, part 2
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function step(text) {' ' const key = Object.keys(PRICES).find(function (k) { return text.toLowerCase().indexOf(k) >= 0; });' ' if (key === undefined) return { ok: false, note: \"no tool fits: \" + text };' ' return { ok: true, note: key + \" costs \" + PRICES[key] + \"p\" };' '}' >> plan1.js"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Write the planning agent, part 3
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'async function main() {' ' const reply = await ask(messages, sample, 100);' ' let plan = null;' ' try { plan = JSON.parse(reply.slice(reply.indexOf(\"[\"), reply.lastIndexOf(\"]\") + 1)); } catch (error) { }' ' if (Array.isArray(plan) === false) { console.log(\"Not a list, using a fixed plan.\"); plan = [\"apple\", \"pear\"]; }' ' const budget = 3, maxFails = 2;' ' let used = 0, fails = 0, outcome = \"done\";' ' for (const text of plan) {' ' if (used === budget) { outcome = \"budget\"; break; }' ' used += 1;' ' const r = step(String(text));' ' fails = r.ok ? 0 : fails + 1;' ' console.log(\"Step \" + used + \": \" + r.note);' ' if (fails === maxFails) { outcome = \"stuck\"; break; }' ' }' ' console.log(\"Outcome: \" + outcome + \" after \" + used + \" steps\");' '}' 'main();' >> plan1.js"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Run the agent and read the outcome
docker exec -w /home/node/project agent node plan1.jsShown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
One possible output
| Line | What it may show |
|---|---|
| Step 1 and 2 | The prices of the apple and the pear |
| Step 3 | no tool fits: kiwi |
| Outcome | done after 3 steps, because one failure is not two |
The cells students fill in are left blank in this preview.
Three ways a run ends
A run can end because the work is done, because the agent is stuck, or because the budget ran out. Each outcome is a different message to the user and a different line in the log.
Check them in that order. A run that finishes on its last allowed step is done, not over budget. Stuck means repeated failure with no progress.
A common mistake
Warning
Many people think a budget alone is a stop rule. A budget only caps the cost. It cannot tell a finished run from a stuck one. Keep a budget as the last resort and add rules for done and stuck.