Designing Good Tools

ជំពូកនេះមិនទាន់ត្រូវបានបកប្រែជាភាសាខ្មែរនៅឡើយទេ — សូមអានជាភាសាអង់គ្លេសខាងក្រោម។

Table of Contents

  1. Tools Are an Interface for a Reader That Cannot Ask Questions
  2. Names
  3. Descriptions Are Instructions
  4. Small Tools or One Big Tool
  5. Schemas: Types, Enums and Argument Descriptions
  6. Errors the Model Can Act On
  7. Return Short, Useful Results
  8. Reads, Writes and Idempotency
  9. Preview Before You Write
  10. How Many Tools?
  11. Naming Collisions and Overlap
  12. Changing Tools Over Time
  13. Testing a Tool Without the Model
  14. Refactor: A Bad Tool Becomes a Good Set
  15. Documenting Your Tools
  16. Recipe: The Example Tools and Their Tests
  17. Try It Yourself
  18. Quick Review: Common Questions

1. Tools Are an Interface for a Reader That Cannot Ask Questions

When an agent misbehaves, the first suspect is usually the model. Very often the real cause is the tools. The model never sees your code. It sees exactly three things about each tool: a name, a description and an argument schema. Then it sees whatever your tool returns. That is the whole interface.

Imagine you start a new job and your manager hands you a drawer of unlabelled switches, with a sticky note on one that says "does stuff". You could not use them well, and you could not ask anyone. A model is in that position every time it reads your tool list.

Good tool design is therefore mostly writing: clear names, clear descriptions, short results, and error messages that say what to do next. It is also ordinary API design: small, predictable, safe to repeat.

This chapter adds no new module to agentlab. It teaches the habits and shows each one with a runnable demo. At the end you refactor a bad tool into a good set and write node --test tests that need no model at all.

Honest note about the mock. The mock picks a tool by counting words that its name and description share with your request, and fills arguments with simple patterns. It is far cruder than a real model, which understands meaning and can often work with weak descriptions. But the direction is the same: clear names and descriptions help any model, and the mock makes the effect easy to see. Where the mock cannot show something (for example, a model reading an error message and fixing its next call), we say so and show you what the model would read.

Start the mock from your agentlab folder, as before:

node mock-llm.js
# output
# mock LLM listening on http://localhost:8787  (api key: test-key)

2. Names

A tool name is the model's first clue. Rules that work well:

  • Verb first, then the thing: get_weather, list_students, create_invoice, search_docs. The verb tells the model what kind of action it is (read, write, search).
  • Specific over generic: get_student beats get, and both beat data, tool1 or run.
  • One style: pick snake_case and keep it. Many providers restrict names to letters, digits, underscores and hyphens, so avoid spaces and punctuation. Check your provider's rules.
  • Use words from your users' vocabulary. If people say "class" and "score", do not name things grp and val.
  • Say what is risky. delete_note and send_message tell the model, and the human reading a trace, that these have effects.

3. Descriptions Are Instructions

The description is not documentation for humans. It is instructions to the model: what the tool does, when to use it, and when not to. Compare three tools that behave identically:

// a1.js
import { runAgent } from "./src/agent.js";
import { defineTool } from "./src/tools.js";
import { printTrace } from "./src/trace.js";

// Same behaviour, three different names and descriptions.
const make = (name, description) =>
  defineTool({
    name,
    description,
    parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
    run: ({ city }) => `${city}: 33C, sunny`,
  });

const task = "what is the weather in Phnom Penh";
for (const tool of [
  make("tool1", "Handles requests"),
  make("lookup", "Looks things up"),
  make("get_weather", "Get the current weather for a city. Use for temperature and conditions."),
]) {
  console.log(`\n== ${tool.name}: "${tool.description}"`);
  const r = await runAgent({ task, tools: [tool], onEvent: printTrace });
}
// output
//
// == tool1: "Handles requests"
// [step 1] answer I don't have a tool that can do that.
//
// == lookup: "Looks things up"
// [step 1] answer I don't have a tool that can do that.
//
// == get_weather: "Get the current weather for a city. Use for temperature and conditions."
// [step 1] call   get_weather({"city":"Phnom Penh"})
// [step 1] result Phnom Penh: 33C, sunny
// [step 2] answer Result: Phnom Penh: 33C, sunny

The mock failed twice because the words "weather" and "temperature" appear nowhere in the vague descriptions. A real model is smarter than word counting and might guess right even from lookup, but it would be guessing, and guessing gets worse as the tool list grows. Do not make the model guess.

A good description has up to four parts:

  1. What it does in one short sentence: "Get the current weather for a city."
  2. When to use it (and the trigger words): "Use for temperature and conditions."
  3. When not to use it, if another tool is nearby: "For forecasts beyond today, use get_forecast."
  4. What it returns, if it is not obvious: "Returns name, class and score."

Keep it short. Every description is sent on every model call (see The Agent Loop, section 10), so a 500-word description costs you tokens at every step. Two or three sentences is usually enough.

4. Small Tools or One Big Tool

Should you make one notes tool with an action argument (save, read, list), or three tools? Here is the mega-tool:

// a4.js
import { runAgent } from "./src/agent.js";
import { defineTool, toApiTools } from "./src/tools.js";
import { makeDemoTools } from "./src/demo-tools.js";
import { printTrace } from "./src/trace.js";

// One mega-tool doing the job of three.
const notes = new Map();
const notesTool = defineTool({
  name: "notes",
  description: "Save, read or list notes",
  parameters: {
    type: "object",
    properties: {
      action: { type: "string", enum: ["save", "read", "list"], description: "What to do" },
      title: { type: "string", description: "Note title (save, read)" },
      content: { type: "string", description: "Note text (save only)" },
    },
    required: ["action"],
  },
  run: ({ action, title, content }) => {
    if (action === "save") { notes.set(title, content); return `Saved "${title}".`; }
    if (action === "read") return notes.get(title) ?? "(none)";
    return [...notes.keys()].join(", ") || "(no notes)";
  },
});
await runAgent({ task: 'save a note called "todo" with "buy milk"', tools: [notesTool], onEvent: printTrace });

// Size of what the model must read on EVERY request.
const size = (tools) => JSON.stringify(toApiTools(tools)).length;
console.log("mega-tool schema:", size([notesTool]), "chars");
console.log("three small tools:", size(makeDemoTools().filter((t) => /note/.test(t.name))), "chars");
console.log("all five demo tools:", size(makeDemoTools()), "chars");
// output
// [step 1] call   notes({"action":"save","title":"todo","content":"buy milk"})
// [step 1] result Saved "todo".
// [step 2] answer Result: Saved "todo".
// mega-tool schema: 374 chars
// three small tools: 580 chars
// all five demo tools: 1015 chars

It works, and notice that the mega-tool is actually smaller in characters. So the argument for splitting is not token cost. It is clarity and safety:

  • Each small tool has one job and a description that fits it. The model does not have to learn which argument combinations are valid ("content only when action is save").
  • Required arguments become real. In the mega-tool, title is optional in the schema, because list does not need it. The schema can no longer tell the model, or your validator, that save needs a title. Small tools can mark exactly what each one requires.
  • Permissions get simple. If read_note and save_note are separate tools, you can give an agent only the read tool, or require approval only for the write tool (chapter 9). With one notes tool that is hard.
  • Traces are readable. delete_note in a log means something. notes({"action":"delete"}) hides the risk inside an argument.

The opposite mistake exists too: dozens of tiny tools for every possible field. A reasonable middle: one tool per user intention ("get one student", "list students in a class"), not per database column and not one tool for the whole domain.

5. Schemas: Types, Enums and Argument Descriptions

The schema (JSON Schema, from chapter 2) tells the model which arguments exist and what shape they have. Three habits make arguments reliable.

Use enum for a closed set. If an argument can only be "A1" or "B2", say so. The model sees the allowed values, and your validator rejects anything else. Without the enum the model invents "class A1" or "a1".

Use the narrowest type. integer rather than number when only whole numbers make sense. Strings that must look like something (a date, an id) should say so in the description with an example.

Describe every argument. A short description with an example is worth more than a clever name:

properties: {
  id: { type: "integer", description: "Student id, a whole number such as 2" },
},

Keep arguments few. If a tool needs eight arguments, a model will sometimes get one wrong. Provide sensible defaults in your code, make rarely used options optional, and accept the simplest form of each value ("2026-10-03" rather than a nested date object).

The schema is also checked by your code. Here is what the model reads when it breaks each rule, using the example tools from section 14:

// a3.js (part 1)
import { runToolCall } from "./src/tools.js";
import { goodTools } from "./src/examples/students.js";

const call = (name, args) => ({ id: "c", type: "function", function: { name, arguments: JSON.stringify(args) } });

console.log((await runToolCall(call("list_students", { class: "C3" }), goodTools)).content);
console.log((await runToolCall(call("get_student", { id: "2" }), goodTools)).content);
console.log((await runToolCall(call("get_student", {}), goodTools)).content);
console.log((await runToolCall(call("get_student", { id: 2, verbose: true }), goodTools)).content);
// output
// Error: "class" must be one of: A1, B2
// Error: "id" must be integer, got string
// Error: missing required argument "id"
// Error: unknown argument "verbose"

Each message names the argument and states the rule. That is enough for a model to fix its next call. (Our validator is a small subset of JSON Schema. If you need ranges, patterns or nested objects, extend it or use a full validator library.)

6. Errors the Model Can Act On

When a tool fails, the error text goes back to the model, and it is the only feedback the model gets. A good error answers three questions: what went wrong, why, and what to do instead.

Compare the same failure with two messages:

// a3.js (part 2)
import { defineTool, runToolCall } from "./src/tools.js";

const make = (message) =>
  defineTool({
    name: "get_weather",
    description: "Get the current weather for a city",
    parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
    run: () => { throw new Error(message); },
  });
for (const m of ["failed", 'no weather data for "Pnom Penh". Did you mean "Phnom Penh"? Known cities: Phnom Penh, Siem Reap, Takeo']) {
  console.log((await runToolCall(call("get_weather", { city: "Pnom Penh" }), [make(m)])).content);
}
// output
// Error: failed
// Error: no weather data for "Pnom Penh". Did you mean "Phnom Penh"? Known cities: Phnom Penh, Siem Reap, Takeo

With Error: failed, a model has nothing to work with. It may retry the same call, give up, or make something up. With the second message, a real model can retry with "Phnom Penh" on its very next step. Same code, one better sentence.

What the mock can and cannot show here. The mock does not read error text. Its rule is simple: after an error it either gives up with "I could not finish: ..." or, for mock-sloppy, retries once with the correct arguments. So it cannot prove that a helpful message works. It can prove that the message reaches the model. A real model reads it.

mock-sloppy does show one case where the error text is produced by your framework and recovery works: broken JSON.

// a3.js (part 3)
import { runAgent } from "./src/agent.js";
import { printTrace } from "./src/trace.js";

await runAgent({ task: "what is the weather in Takeo", model: "mock-sloppy", tools: [make("unused")], onEvent: printTrace });
// output
// [step 1] call   get_weather({"city":"Takeo")
// [step 1] result ERROR Error: the arguments were not valid JSON. Send one complete JSON object.
// [step 2] call   get_weather({"city":"Takeo"})
// [step 2] result ERROR Error: unused
// [step 3] answer I could not finish: Error: unused

(The trace helper clips long lines to 70 characters, so on your screen the first error ends with "..." The text above is the full message.) Step 1 failed because of a message that runToolCall wrote. The mock recovered at step 2. Then the tool itself failed with unused, the message we gave it, and the run ended. The first error was fixable because it said what was wrong.

Rules for error messages:

  • Say what to do. "Send one complete JSON object" beats "bad input".
  • List valid options when the set is small: valid ids, known cities, allowed enum values.
  • Keep them short. A 2,000-character stack trace wastes tokens and leaks internals. Log the stack on your side; send the model one line.
  • Do not leak secrets. No file paths from your server, connection strings, tokens or SQL. The model's context may be shown to users or logged.
  • Distinguish "your call was wrong" from "the world failed". "Invalid id" (retry with a different id) is different from "the database is down" (do not retry, tell the user).

7. Return Short, Useful Results

Whatever your tool returns goes into the history and is re-sent on every later step. Return what the model needs for its next decision, not everything you have.

Here is the same student record two ways. The bad tool (from section 14) returns the entire record as JSON:

// a2.js (part 1)
import { runToolCall } from "./src/tools.js";
import { badTool } from "./src/examples/students.js";

const call = { id: "c1", type: "function", function: { name: "data", arguments: '{"type":"student","q":"2"}' } };
const bad = await runToolCall(call, [badTool], { maxChars: 100000 });
console.log(bad.content.length, "characters");
console.log(bad.content.slice(0, 160) + "...");
// output
// 381 characters
// [{"id":2,"name":"Dara","class":"A1","score":92,"email":"dara@example.com","address":"Wat Bo Road, Siem Reap","notes":"Debate club. Debate club. Debate club. Deb...

381 characters for one student, including an email, an address and a long notes field the question did not need. The good tool answers the same need with Dara, class A1, score 92 (24 characters). Beyond cost, returning everything has two other problems:

  • Privacy. The model now has the email and address, and may repeat them to a user who should not see them. Returning less is also a security measure.
  • Distraction. The more text, the more chances the model latches onto the wrong part. (Long, noisy context is a common cause of mistakes.)

Practical rules:

  • Return plain text or small JSON, not a raw database row or an HTML page. For lists, return a short line per item with an id the model can use in a follow-up call (3: Srey).
  • Paginate big results: accept limit (with a small default) and return "showing 10 of 240; call again with offset 10".
  • Truncate as a safety net. Our runToolCall cuts anything over maxChars (4000 by default) and appends a note. Check:
// a7.js (part 1)
import { runToolCall, defineTool } from "./src/tools.js";

const big = defineTool({ name: "dump", description: "dump", run: () => "x".repeat(9000) });
const r = await runToolCall({ id: "c", type: "function", function: { name: "dump", arguments: "{}" } }, [big]);
console.log(r.content.length, JSON.stringify(r.content.slice(-30)));
// output
// 4028 "xx\n[truncated 5000 characters]"

Truncation is a last resort. Better to design the tool so the model asks for what it needs ("search for X", "read lines 100 to 150") than to hand over everything and cut it blindly.

  • Make the end state obvious. "Saved note 'todo'." is clearer than ok, and "No results for 'milk'" is clearer than an empty string, which a model may misread as a failure.

8. Reads, Writes and Idempotency

Sort every tool into one of three kinds:

KindExamplesRisk
Readget_student, search_docsLow. Can leak data.
Write (reversible)save_note (overwrites)Medium.
Write (irreversible or external)send_message, delete_note, charge_cardHigh.

Prefer to give an agent read tools first. Add write tools only when needed, and give them the most care.

Idempotent means "doing it twice has the same effect as doing it once". save_note("todo", "buy milk") is idempotent: the second call overwrites with the same text. send_message is not: the second call sends a second message. It matters for agents because calls repeat. The model may retry after a timeout, the loop may run again after a crash, and a confused model may call the same tool twice.

A standard fix for non-idempotent tools is a request id: the caller supplies a unique id, and the tool remembers ids it has already done.

// a6.js (part 1)
import { defineTool, runToolCall } from "./src/tools.js";

const outbox = [];
const sendNaive = defineTool({
  name: "send_message",
  description: "Send a message to a student",
  parameters: { type: "object", properties: { to: { type: "string" }, text: { type: "string" } }, required: ["to", "text"] },
  run: ({ to, text }) => { outbox.push({ to, text }); return `Sent to ${to}.`; },
});
const sent = new Set();
const sendSafe = defineTool({
  ...sendNaive,
  parameters: {
    ...sendNaive.parameters,
    properties: { ...sendNaive.parameters.properties, request_id: { type: "string", description: "Unique id for this send. Reuse it when retrying." } },
    required: [...sendNaive.parameters.required, "request_id"],
  },
  run: ({ to, text, request_id }) => {
    if (sent.has(request_id)) return `Already sent (request_id ${request_id}). Nothing done.`;
    sent.add(request_id);
    outbox.push({ to, text });
    return `Sent to ${to}.`;
  },
});
const c = (name, args) => ({ id: "c", type: "function", function: { name, arguments: JSON.stringify(args) } });
for (let i = 0; i < 2; i++) await runToolCall(c("send_message", { to: "Dara", text: "Class at 8" }), [sendNaive]);
console.log("naive, called twice:", outbox.length, "messages sent");
outbox.length = 0;
for (let i = 0; i < 2; i++) console.log((await runToolCall(c("send_message", { to: "Dara", text: "Class at 8", request_id: "r-1" }), [sendSafe])).content);
console.log("with request_id, called twice:", outbox.length, "message sent");
// output
// naive, called twice: 2 messages sent
// Sent to Dara.
// Already sent (request_id r-1). Nothing done.
// with request_id, called twice: 1 message sent

The naive tool sent two messages. The safe one sent one and told the caller why the second call did nothing. In real systems the "seen ids" live in a database, not in memory.

Other ways to make writes safer: prefer "set X to value" over "add 1 to X"; make a delete a "move to trash"; and do not combine a read and a write in one tool. Keep them separate so each can be permissioned and logged on its own.

9. Preview Before You Write

For actions that are hard to undo, build the tool in two phases: a preview that changes nothing, and a commit that does. The model (and ideally a human) sees what will happen before it happens.

// a6.js (part 2)
import { runAgent } from "./src/agent.js";
import { printTrace } from "./src/trace.js";

const notes = new Map([["todo", "buy milk"]]);
const deleteNote = defineTool({
  name: "delete_note",
  description: "Delete a saved note by title. First call without confirm shows a preview; call again with confirm true to delete.",
  parameters: {
    type: "object",
    properties: { title: { type: "string" }, confirm: { type: "boolean", description: "true only after the user agreed" } },
    required: ["title"],
  },
  run: ({ title, confirm }) => {
    if (!notes.has(title)) throw new Error(`no note titled "${title}"`);
    if (confirm !== true) return `PREVIEW: would delete note "${title}" (${notes.get(title).length} characters). Nothing deleted yet.`;
    notes.delete(title);
    return `Deleted note "${title}".`;
  },
});
await runAgent({ task: 'delete the note "todo"', tools: [deleteNote], onEvent: printTrace });
console.log("notes after the agent run:", [...notes.keys()]);
console.log((await runToolCall(c("delete_note", { title: "todo", confirm: true }), [deleteNote])).content);
console.log("notes after confirm:", [...notes.keys()]);
// output
// [step 1] call   delete_note({"title":"todo"})
// [step 1] result PREVIEW: would delete note "todo" (8 characters). Nothing deleted yet.
// [step 2] answer Result: PREVIEW: would delete note "todo" (8 characters). Nothing deleted yet.
// notes after the agent run: [ 'todo' ]
// Deleted note "todo".
// notes after confirm: []

The agent run ended with the note still there, and only the second, explicit call removed it.

Be honest about the limit of this pattern: confirm is just an argument, and the model can set it to true by itself. It is a speed bump and a good habit, not a lock. Real approval means your code, outside the model, stops and asks a human, and the model cannot skip it. We build that guard in chapter 9 (withApproval). Treat this chapter's preview as the design half: make tools that can describe what they are about to do.

10. How Many Tools?

More tools means a longer prompt (cost on every call) and more choices to confuse the model. The common experience of people building agents is that quality drops when a model is given a large menu of overlapping tools, and improves when the list is short and each tool is distinct. There is no magic number. It depends on the model and how different the tools are. Treat these as rules of thumb, not laws:

  • Start with what the task needs. Five to ten distinct tools is a comfortable place to be. Test before going much higher.
  • Give each agent only the tools for its job. A "notes" agent does not need email. Fewer tools is also fewer things to secure.
  • If you really need many tools, group them: route the request to a small agent with the relevant subset, or add a "find the right tool" step. (Chapter 8 returns to this with MCP, where connecting several servers can give an agent a long list by accident.)
  • Measure. Run the same tasks with 5 tools and with 25, and compare results (chapter 10 shows how).

11. Naming Collisions and Overlap

Two tools that sound alike are a bigger problem than one tool that sounds odd. When two descriptions overlap, the model chooses between them almost at random. See it with the mock, which breaks ties by taking the first tool in the list:

// a5.js
import { runAgent } from "./src/agent.js";
import { defineTool } from "./src/tools.js";
import { printTrace } from "./src/trace.js";

const lookup = (name) => defineTool({
  name,
  description: "Look up a record",
  parameters: { type: "object", properties: { id: { type: "number" } }, required: ["id"] },
  run: ({ id }) => `${name} #${id}`,
});
console.log("== two tools, same description");
await runAgent({ task: "look up record 7", tools: [lookup("lookup_user"), lookup("lookup_order")], onEvent: printTrace });

console.log("\n== descriptions say when to use which");
const user = defineTool({ ...lookup("lookup_user"), description: "Look up a customer account by customer id" });
const order = defineTool({ ...lookup("lookup_order"), description: "Look up one order by order number" });
await runAgent({ task: "look up the order 7", tools: [user, order], onEvent: printTrace });
// output
// == two tools, same description
// [step 1] call   lookup_user({"id":7})
// [step 1] result lookup_user #7
// [step 2] answer Result: lookup_user #7
//
// == descriptions say when to use which
// [step 1] call   lookup_order({"id":7})
// [step 1] result lookup_order #7
// [step 2] answer Result: lookup_order #7

In the first run, "look up record 7" fits both tools equally, so the mock silently picked the first. A real model would also be unsure, and might pick either on different runs. In the second run each description says what it is for, and the right tool wins.

Collisions also happen across sources. If you connect two MCP servers (chapter 8) that both offer search, you have two tools with the same name. Prefix names by source (docs__search, web__search) or rename them. Either way, make each description say what it searches.

12. Changing Tools Over Time

Once an agent uses a tool, changing it can break things, in the same way changing a public API breaks callers. The "caller" here is a model, and sometimes a prompt or a saved conversation that mentions the old shape.

  • Adding an optional argument is safe. Old calls still validate.
  • Renaming or removing an argument is breaking. Try it:
// a7.js (part 2)
import { goodTools } from "./src/examples/students.js";

const r2 = await runToolCall({ id: "c", type: "function", function: { name: "get_student", arguments: '{"student_id":2}' } }, goodTools);
console.log(r2.content);
// output
// Error: missing required argument "id"; unknown argument "student_id"

Because our validator reports both problems, the model can see that id is the right name and recover. That is a nice side effect of a strict validator with clear errors.

  • For a real behaviour change, add a new tool (get_student_v2, or better a new descriptive name) and keep the old one for a while with a description that says "Deprecated: use get_student_details." Remove it once logs show it is no longer called.
  • Change descriptions carefully too. A rewrite of a description can change which tool the model picks. Re-run your evaluation cases (chapter 10) after editing one.
  • Keep a changelog for tools used by other people or other agents.

13. Testing a Tool Without the Model

You do not need a model, a network or a mock server to test a tool. A tool is a function plus a contract, and runToolCall is the same entry point the loop uses. Build the call exactly as the model would, and check the result.

This is the most valuable testing you can do on an agent, because it is fast, free and deterministic. Create test/students.test.js using Node's built-in test runner:

// test/students.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { runToolCall } from "../src/tools.js";
import { goodTools, badTool } from "../src/examples/students.js";

// Build a tool call the way the model would, then run it. No model, no network.
const call = (name, args) => ({
  id: "test",
  type: "function",
  function: { name, arguments: typeof args === "string" ? args : JSON.stringify(args) },
});
const run = (name, args, tools = goodTools) => runToolCall(call(name, args), tools);

test("get_student returns a short summary", async () => {
  const r = await run("get_student", { id: 2 });
  assert.equal(r.isError, false);
  assert.equal(r.content, "Dara, class A1, score 92");
});

test("get_student does not leak private fields", async () => {
  const r = await run("get_student", { id: 2 });
  assert.doesNotMatch(r.content, /@|Wat Bo/);
});

test("unknown id gives an error that lists valid ids", async () => {
  const r = await run("get_student", { id: 9 });
  assert.equal(r.isError, true);
  assert.match(r.content, /Valid ids: 1, 2, 3/);
});

test("wrong argument type is rejected before the tool runs", async () => {
  const r = await run("get_student", { id: "2" });
  assert.equal(r.isError, true);
  assert.match(r.content, /"id" must be integer/);
});

test("enum values are enforced", async () => {
  const r = await run("list_students", { class: "C3" });
  assert.match(r.content, /must be one of: A1, B2/);
});

test("broken JSON becomes an error the model can read", async () => {
  const r = await run("get_student", '{"id":2');
  assert.equal(r.isError, true);
  assert.match(r.content, /not valid JSON/);
});

test("every tool has a clear contract", () => {
  for (const t of goodTools) {
    assert.match(t.name, /^[a-z][a-z0-9_]*$/, `${t.name}: use snake_case`);
    assert.ok(t.description.length >= 20, `${t.name}: description too short`);
    for (const [key, def] of Object.entries(t.parameters.properties)) {
      assert.ok(def.type, `${t.name}.${key}: missing type`);
    }
    for (const key of t.parameters.required ?? []) {
      assert.ok(key in t.parameters.properties, `${t.name}: required "${key}" is not a property`);
    }
  }
});

test("the bad tool fails the same contract (this is why we test)", () => {
  assert.ok(badTool.description.length < 20);
});

Run all tests from the agentlab folder:

node --test --test-reporter=spec
# output (the timings vary)
# ✔ get_student returns a short summary (0.789083ms)
# ✔ get_student does not leak private fields (0.1105ms)
# ✔ unknown id gives an error that lists valid ids (0.060541ms)
# ✔ wrong argument type is rejected before the tool runs (0.048458ms)
# ✔ enum values are enforced (0.1025ms)
# ✔ broken JSON becomes an error the model can read (0.072625ms)
# ✔ every tool has a clear contract (0.147959ms)
# ✔ the bad tool fails the same contract (this is why we test) (0.034875ms)
# ℹ tests 8
# ℹ suites 0
# ℹ pass 8
# ℹ fail 0
# ℹ cancelled 0
# ℹ skipped 0
# ℹ todo 0
# ℹ duration_ms 53.025083

(node --test finds files named *.test.js and files inside a test folder by itself. The exact output format can differ a little between Node versions.)

What to test for every tool:

  1. The happy path returns the expected short text.
  2. Each bad input gives a helpful error: wrong type, missing argument, unknown value, broken JSON.
  3. Nothing private leaks into the result.
  4. Side effects happen once, even if the call is repeated (section 8).
  5. Size limits hold: a huge input does not produce a huge output.
  6. The contract (name style, description present, argument descriptions present) so a sloppy tool cannot slip in.

Testing with the model, to see whether it picks the right tool, is a different job (an evaluation) and comes in chapter 10. Do this one first.

14. Refactor: A Bad Tool Becomes a Good Set

Here is the example behind the earlier demos. Put this in src/examples/students.js (the full file is in the Recipe section). The bad tool:

export const badTool = defineTool({
  name: "data",
  description: "Gets data",
  parameters: {
    type: "object",
    properties: { type: { type: "string" }, q: { type: "string" } },
    required: ["type"],
  },
  run: ({ type, q }) => JSON.stringify(type === "student" ? STUDENTS.filter((s) => !q || String(s.id) === q) : []),
});

What is wrong with it:

  • The name (data) and the description (Gets data) say nothing.
  • The arguments type and q have no descriptions, no enum and no hint of what valid values look like. q is a string even though ids are numbers.
  • It is a mini query language in disguise: type selects a table. The model has to guess the secret values ("student").
  • It returns raw rows with private fields.
  • Unknown types silently return [], which looks like success.

The good replacement is two tools (see the Recipe for the code): get_student and list_students, with clear descriptions, an integer id, an enum for the class, short results and helpful errors. Compare how the mock handles the same request against each:

// a2.js (part 2)
import { runAgent } from "./src/agent.js";
import { badTool, goodTools } from "./src/examples/students.js";
import { printTrace } from "./src/trace.js";

console.log("== bad tool through the agent");
await runAgent({ task: "how many points did student 2 score", tools: [badTool], onEvent: printTrace });

console.log("\n== good tools through the agent");
await runAgent({ task: "get the student with id 2", tools: goodTools, onEvent: printTrace });
await runAgent({ task: "list the students in class B2", tools: goodTools, onEvent: printTrace });
await runAgent({ task: "get the student with id 9", tools: goodTools, onEvent: printTrace });
// output
// == bad tool through the agent
// [step 1] answer I don't have a tool that can do that.
//
// == good tools through the agent
// [step 1] call   get_student({"id":2})
// [step 1] result Dara, class A1, score 92
// [step 2] answer Result: Dara, class A1, score 92
// [step 1] call   list_students({"class":"B2"})
// [step 1] result 3: Srey
// [step 2] answer Result: 3: Srey
// [step 1] call   get_student({"id":9})
// [step 1] result ERROR Error: no student with id 9. Valid ids: 1, 2, 3
// [step 2] answer I could not finish: Error: no student with id 9. Valid ids: 1, 2, 3

The mock extracted id: 2 as a number because the schema said integer, and it extracted "B2" for class because the enum listed it. Those are the same hints a real model uses. In the last run the error lists the valid ids, which is the message we want a real model to read. Notice also what we did not do: we did not make the model smarter. We made the tools easier to use correctly.

15. Documenting Your Tools

Every tool is an API, and APIs need documentation, for the humans who maintain the agent and not only for the model. A short block next to each tool is enough. Keep a table or a TOOLS.md with these columns:

FieldExample
Nameget_student
PurposeOne student's name, class and score
KindRead (no side effects)
Argumentsid (integer, required)
ReturnsDara, class A1, score 92
ErrorsUnknown id: lists valid ids
Data exposedName, class, score. Not email or address.
LimitsNone; one record per call
Owner / versionTeam A, 1.0

The "data exposed" and "kind" rows matter most for security reviews (chapter 9). The "owner" row matters at 2 a.m. when a tool misbehaves. Put the same one-line summary in the tool's description so the model and the humans read the same thing.

16. Recipe: The Example Tools and Their Tests

This chapter adds no module to agentlab. It adds one example file and one test file. These are the complete versions. (src/trace.js is from chapter 3. The src/examples folder keeps teaching code apart from your real agentlab modules.)

// src/examples/students.js
import { defineTool } from "../tools.js";

// Fake data. The same data behind a BAD tool and a GOOD set of tools.
export const STUDENTS = [
  { id: 1, name: "Sokha", class: "A1", score: 88, email: "sokha@example.com", address: "Street 51, Phnom Penh", notes: "Likes robotics. ".repeat(20) },
  { id: 2, name: "Dara", class: "A1", score: 92, email: "dara@example.com", address: "Wat Bo Road, Siem Reap", notes: "Debate club. ".repeat(20) },
  { id: 3, name: "Srey", class: "B2", score: 75, email: "srey@example.com", address: "National Road 3, Takeo", notes: "Needs extra math help. ".repeat(20) },
];

// BAD: vague name, vague description, vague arguments, dumps everything.
export const badTool = defineTool({
  name: "data",
  description: "Gets data",
  parameters: {
    type: "object",
    properties: { type: { type: "string" }, q: { type: "string" } },
    required: ["type"],
  },
  run: ({ type, q }) => JSON.stringify(type === "student" ? STUDENTS.filter((s) => !q || String(s.id) === q) : []),
});

// GOOD: two small tools, clear names, descriptions that say when to use
// them, and short results that contain only what the model needs.
export const goodTools = [
  defineTool({
    name: "get_student",
    description: "Get one student by id. Returns name, class and score.",
    parameters: {
      type: "object",
      properties: { id: { type: "integer", description: "Student id, a whole number such as 2" } },
      required: ["id"],
    },
    run: ({ id }) => {
      const s = STUDENTS.find((x) => x.id === id);
      if (!s) throw new Error(`no student with id ${id}. Valid ids: ${STUDENTS.map((x) => x.id).join(", ")}`);
      return `${s.name}, class ${s.class}, score ${s.score}`;
    },
  }),
  defineTool({
    name: "list_students",
    description: "List students in a class with their ids. Use to find an id.",
    parameters: {
      type: "object",
      properties: { class: { type: "string", enum: ["A1", "B2"], description: "Class code" } },
      required: ["class"],
    },
    run: ({ class: c }) => STUDENTS.filter((s) => s.class === c).map((s) => `${s.id}: ${s.name}`).join("; "),
  }),
];

The test file is the one shown in section 13 (test/students.test.js). Keep both in your project: chapter 10 reuses the same pattern for whole-agent tests.

17. Try It Yourself

  1. Improve the bad tool without splitting it. Rewrite badTool with a real name, a description, an enum for type and descriptions for each argument. Does the mock now choose it for "get the student with id 2"? What does it still do badly compared to the two-tool version?
  2. Add a tool. Write get_class_average that takes a class enum and returns the average score as one short line. Add three tests: a valid class, an invalid class and the contract test.
  3. Make a tool idempotent. Write add_score_point that adds one point to a student. Why is calling it twice a problem? Redesign it as set_score and compare.
  4. Paginate. Add limit (default 2) and offset to list_students and return "showing 2 of 3; call again with offset 2". Test the boundary cases.
  5. Hunt for a collision. Create two tools with similar descriptions and see which one the mock picks for five different requests. Then rewrite the descriptions so each request picks the right one.
  6. Leak check. Write a test that runs every tool in goodTools and asserts that no result contains an @ sign or the word "Road". Then break a tool on purpose and watch the test catch it.

18. 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.

Why does the model call the wrong tool?

Usually because two tools sound alike or a description is vague. The model chooses from names and descriptions, so fix those first: say what each tool is for and when to use it. Then look at the tool list: fewer, more distinct tools help.

Should I write long descriptions to be safe?

No. Descriptions are sent on every model call, so long ones cost tokens each step and can bury the important part. Two or three clear sentences with an example for tricky arguments is usually best. Put long explanations in your own documentation.

One tool with an action argument, or many small tools?

For most cases, small tools. Each has a clear description, required arguments are enforced, and you can allow or require approval per tool. A single mega-tool hides risk inside an argument. A handful of closely related options on one tool is fine, though.

How do I stop the model sending bad arguments?

You cannot fully stop it, so design for it. Use strict schemas with enums and types, validate every call, and return error messages that say what was wrong and what is valid. Most models recover after one clear error.

Is returning an error better than throwing an exception?

For the model, yes, but through your own code: catch the exception and return a short Error: ... text. That is what runToolCall does. An uncaught exception would crash the loop, and the model would never get the chance to fix its call.

How many tools can an agent handle?

It depends on the model and on how different the tools are. Many people find that quality drops when the list is long and overlapping, so keep it as small as the job allows and test any increase. There is no official magic number.

How do I know a tool is good?

Test it two ways. First, without a model: unit tests for the happy path, bad input and leaks (this chapter). Second, with a model, on real tasks, checking that it picks the right tool and recovers from errors (chapter 10). A tool that passes the first and fails the second usually has a description problem.

Where to go next. Your agent can now call well-designed tools in a loop, but its only memory is the message list, which grows and costs more every step. In Memory, State and Planning you will shorten history safely, give the agent notes that survive between runs, and see how planning helps with longer tasks.

ទិញកាហ្វេជូនខ្ញុំ

ចូលចិត្តមេរៀននេះមែនទេ? សូមពិចារណាទិញកាហ្វេជូនខ្ញុំមួយកែវ ដើម្បីគាំទ្រការបង្កើតមាតិកាឥតគិតថ្លៃបន្ថែមទៀត!