Two agents that kept passing a ticket
A multi-agent system splits work between agents that each have one role. They pass work along as hand-off messages. It matters because every hand-off costs a call and can lose context.
A billing agent sends a ticket to a stock agent. The stock agent sends it straight back. The ticket bounced all night.
By the end of this lesson
Note
I can split work between agents and say when one agent is enough.
Key words
| Word | What it means | Example |
|---|---|---|
| Role | the one job an agent has | pricer |
| Hand-off | a message passing work to another agent | |
| Router | the part that picks which role gets a task | |
| Shared record | a log every agent can read and add to | |
| Loop guard | a check that stops work bouncing for ever |
The cells students fill in are left blank in this preview.
When one agent is enough
Start with one agent and one prompt. Add a second only when you can name the gain. It may be a shorter prompt, fewer tools in view, a different model, or a step a person must approve.
Each extra agent adds calls, delay and a place where context leaks. If one agent with three tools does the job, stop there.
Worked example: one agent or several
| Situation | Choice | Reason |
|---|---|---|
| Look up a price and say it | One agent | One tool, one prompt |
| Price, stock and delivery rules, 20 tools | Roles | Each role sees only its own tools |
| A refund over 100 pounds | A person step | Approval is not an agent job |
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 hand-off demo, part 1
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const { ask } = require(\"./model\");' 'const PRICES = { pear: 45, bread: 120 };' 'const STOCK = { pear: 12, bread: 0 };' 'function look(table, q, unit) {' ' const key = Object.keys(table).find(function (w) { return q.indexOf(w) >= 0; });' ' return key === undefined ? \"nothing found\" : key + \": \" + table[key] + unit;' '}' 'const roles = {' ' pricer: function (q) { return look(PRICES, q, \"p\"); },' ' stocker: function (q) { return look(STOCK, q, \" in stock\"); },' '};' 'function route(q) {' ' if (q.indexOf(\"how much\") >= 0) return \"pricer\";' ' if (q.indexOf(\"in stock\") >= 0) return \"stocker\";' ' return null;' '}' > handoff1.js"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Write the hand-off demo, part 2
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'async function pick(q) {' ' const rule = route(q);' ' if (rule) return rule;' ' const text = \"Which role fits this task: pricer, stocker or none? Reply with one word. Task: \" + q;' ' const word = (await ask([{ role: \"user\", content: text }], \"none\", 10)).trim().toLowerCase();' ' return word === \"pricer\" || word === \"stocker\" ? word : \"none\";' '}' 'const trail = [];' 'function allowed(from, to) {' ' trail.push(from + \">\" + to);' ' return trail.filter(function (t) { return t === from + \">\" + to; }).length < 3;' '}' >> handoff1.js"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Write the hand-off demo, part 3
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'async function main() {' ' const record = [];' ' for (const q of [\"how much is a pear\", \"is bread in stock\", \"what is the weather\"]) {' ' const role = await pick(q);' ' const result = role === \"none\" ? \"no role fits, ask a person\" : roles[role](q);' ' record.push({ to: role, task: q, result: result });' ' }' ' record.forEach(function (r) { console.log(r.to + \" | \" + r.task + \" | \" + r.result); });' ' let who = \"pricer\";' ' for (let i = 0; i < 6; i++) {' ' const next = who === \"pricer\" ? \"stocker\" : \"pricer\";' ' if (allowed(who, next) === false) { console.log(\"Loop: \" + who + \" to \" + next + \" a third time, stopping\"); break; }' ' who = next;' ' }' '}' 'main();' >> handoff1.js"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Run it and read the shared record
docker exec -w /home/node/project agent node handoff1.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 |
|---|---|
| pricer | how much is a pear | pear: 45p |
| stocker | is bread in stock | bread: 0 in stock |
| The weather task | none, or a role the model chose, with a weak result |
| Loop | a line saying a pair was seen a third time |
The cells students fill in are left blank in this preview.
A hand-off is a message with a shape
A hand-off message names who sends it, who gets it, the task, and the context the receiver needs. The receiver should not have to read the sender's whole history.
Agents write what they did into a shared record. Read it as the one place where the truth about the job lives.
Stop work bouncing between agents
Two agents can each decide the other should handle a task. Nothing is wrong inside either one, yet the work never ends.
Count every from-to pair. When one pair reaches a limit, stop and hand the job to a person. Keep the hand-off trail in the record so the loop is easy to see.
A common mistake
Warning
Many people think a router should always ask a model. A model call is slower, costs money and can name a role that does not exist. Use rules where they are exact, ask the model only for the rest, and check what it names.