Nivel 3
Diseñar una herramienta
- user
- assistant
- tool_result
EventBus
El problema
Cuando escribes una función, sabes qué hace. El modelo no: lo único que recibe es el cartel en la puerta del Taller. Si el cartel es vago, el modelo adivina. Una mala suposición cuesta un turno, un error y más tokens en el historial. Peor aún, una herramienta vaga puede "tener éxito" con un resultado inútil y que nadie lo note.
En la batalla, una criatura salvaje bloquea el camino y el entrenador pregunta a qué compañero mandar. El
Oráculo tiene cuatro herramientas, y el menú de movimientos muestra exactamente lo que recibe el modelo por cada
una: un nombre, sus parámetros y una descripción. Eso alcanza para descartar do_stuff, para
descartar heal_party porque su descripción dice "nunca en batalla", para saber que primero hay que
averiguar el tipo de la criatura, y para pasarle a check_matchup sus datos en el formato correcto al
primer intento.
Anatomía de una buena herramienta
-
Nombre
En lugar de
do_stuff, usa identify_wild, check_matchupUn verbo y un sustantivo concretos le dicen al modelo qué hace la herramienta antes de que lea cualquier otra cosa.
-
Descripción
En lugar de
"Does stuff.", usa Qué hace, cuándo usarla y qué devuelve.Es la única documentación que recibe el modelo. Escríbela para alguien que no puede leer tu código.
-
Parámetros
En lugar de
x: string, usa attack y defend, cada uno con su descripción, y el formato explícito: un tipo en minúsculas, como water.Marca qué es obligatorio y prohíbe los extras, así una entrada incorrecta falla rápido en lugar de hacer algo raro.
-
Salida
En lugar de
La tabla de tipos completa, 18 tipos por 18, usa Una línea: grass vs water: 2x, super effective.Todo lo que devuelve una herramienta entra en el historial, y el modelo lo vuelve a leer en cada turno.
-
Errores
En lugar de
"Invalid input", usa "defend must be one lowercase type, like water. Call identify_wild to get it."Un error que el modelo puede entender es un error que puede corregir en su siguiente turno.
Una regla más: menos herramientas, y más precisas. Cada herramienta que agregas es un cartel más que el modelo lee en cada turno, y una forma más de elegir la equivocada.
El código
Con astorlm: tool() recibe un esquema de Zod, lo convierte en el JSON Schema que
lee el modelo y valida contra él los datos de entrada del modelo antes de que se ejecute tu código. Si la entrada
no coincide, el modelo recibe un error claro en lugar de que tu función explote.
Desde cero: Una herramienta es un JSON Schema que lee el modelo más una función que el modelo
nunca ve. Aquí están el do_stuff de Crooky y las dos herramientas que usó el Oráculo en la batalla,
lado a lado. Solo los esquemas viajan al modelo, en el campo tools de cada pedido del nivel 2.
import { OpenAIProvider, createLocalAgent, tool } from 'astorlm'
import { z } from 'zod'
const type = z
.string()
.regex(/^[a-z]+$/, 'a type is one lowercase word, like water; call identify_wild to get the wild one')
// Describe the input once with Zod: astorlm turns it into the JSON Schema the model
// reads, and checks the model's input against it before running your code.
const checkMatchup = tool({
name: 'check_matchup',
description:
'How hard one type hits another. Use it to choose which partner to send into a battle. ' +
'Returns one line: the multiplier (2x, 1x or 0.5x) and what it means.',
schema: z.object({
attack: type.describe('The attacking type, one lowercase word, e.g. "grass".'),
defend: type.describe('The defending type, one lowercase word, e.g. "water".'),
}),
execute: async ({ attack, defend }) => {
const times = typeChart[attack]?.[defend] ?? 1 // your code; the model never sees it
return `${attack} vs ${defend}: ${times}x${times > 1 ? ', super effective' : ''}`
},
})
const identifyWild = tool({
name: 'identify_wild',
description: 'Identify the wild creature you are facing. Returns its name and type.',
schema: z.object({}),
execute: async () => {
const wild = battle.opponent() // your code
return `${wild.name} · type: ${wild.type}`
},
})
const agent = await createLocalAgent({
// Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
provider: new OpenAIProvider({
baseURL: 'http://localhost:11434/v1', // e.g. Ollama's default address
model: 'your-model', // e.g. 'llama3.1', 'gpt-4o-mini'
apiKey: 'YOUR_API_KEY', // local servers usually ignore it
}),
tools: [checkMatchup, identifyWild],
})
await agent.run('A wild creature appeared! Should I send Emberpup (fire) or Sproutle (grass)?')
// A tool is two things: a description the model reads, and code the model never sees.
// ✗ Crooky's tool. The model has to guess what it does and what x means.
const doStuffSchema = {
type: 'function',
function: {
name: 'do_stuff',
description: 'Does stuff.',
parameters: { type: 'object', properties: { x: { type: 'string' } } },
},
}
// ✓ Named for what it does, described for someone who can't read the code.
const checkMatchupSchema = {
type: 'function',
function: {
name: 'check_matchup',
description:
'How hard one type hits another. ' + // what it does
'Use it to choose which partner to send into a battle. ' + // when to use it
'Returns one line: the multiplier (2x, 1x or 0.5x) and what it means.', // what comes back
parameters: {
type: 'object',
properties: {
attack: { type: 'string', description: 'The attacking type, one lowercase word, e.g. "grass".' },
defend: { type: 'string', description: 'The defending type, one lowercase word, e.g. "water".' },
},
required: ['attack', 'defend'],
additionalProperties: false,
},
},
}
// ✓ A small helper tool, so the model never has to guess what it is facing.
const identifyWildSchema = {
type: 'function',
function: {
name: 'identify_wild',
description: 'Identify the wild creature you are facing. Returns its name and type.',
parameters: { type: 'object', properties: {}, additionalProperties: false },
},
}
// The code behind the names. In a real game, these read the battle state.
export function identifyWild(): string {
const wild = battle.opponent()
return `${wild.name} · type: ${wild.type}`
}
export function checkMatchup({ attack, defend }: { attack: string; defend: string }): string {
// An error the model can fix on its next turn: what went wrong, and what to send instead.
for (const value of [attack, defend]) {
if (!/^[a-z]+$/.test(value)) {
return `Error: a type is one lowercase word, like water. Got "${value}". Call identify_wild to get the wild one.`
}
}
const times = typeChart[attack]?.[defend] ?? 1
// Short, on-topic output: every character here goes into the history, and gets read every turn.
return `${attack} vs ${defend}: ${times}x${times > 1 ? ', super effective' : ''}`
}
// Only the schemas travel to the model, in the `tools` field of each request.
export const toolSchemas = [checkMatchupSchema, identifyWildSchema]
# A tool is two things: a description the model reads, and code the model never sees.
import re
# ✗ Crooky's tool. The model has to guess what it does and what x means.
DO_STUFF_SCHEMA = {
"type": "function",
"function": {
"name": "do_stuff",
"description": "Does stuff.",
"parameters": {"type": "object", "properties": {"x": {"type": "string"}}},
},
}
# ✓ Named for what it does, described for someone who can't read the code.
CHECK_MATCHUP_SCHEMA = {
"type": "function",
"function": {
"name": "check_matchup",
"description": (
"How hard one type hits another. " # what it does
"Use it to choose which partner to send into a battle. " # when to use it
"Returns one line: the multiplier (2x, 1x or 0.5x) and what it means." # what comes back
),
"parameters": {
"type": "object",
"properties": {
"attack": {"type": "string", "description": 'The attacking type, one lowercase word, e.g. "grass".'},
"defend": {"type": "string", "description": 'The defending type, one lowercase word, e.g. "water".'},
},
"required": ["attack", "defend"],
"additionalProperties": False,
},
},
}
# ✓ A small helper tool, so the model never has to guess what it is facing.
IDENTIFY_WILD_SCHEMA = {
"type": "function",
"function": {
"name": "identify_wild",
"description": "Identify the wild creature you are facing. Returns its name and type.",
"parameters": {"type": "object", "properties": {}, "additionalProperties": False},
},
}
# The code behind the names. In a real game, these read the battle state.
def identify_wild():
wild = battle.opponent()
return f"{wild['name']} · type: {wild['type']}"
def check_matchup(attack, defend):
# An error the model can fix on its next turn: what went wrong, and what to send instead.
for value in (attack, defend):
if not re.fullmatch(r"[a-z]+", value):
return f'Error: a type is one lowercase word, like water. Got "{value}". Call identify_wild to get the wild one.'
times = TYPE_CHART.get(attack, {}).get(defend, 1)
# Short, on-topic output: every character here goes into the history, and gets read every turn.
return f"{attack} vs {defend}: {times}x" + (", super effective" if times > 1 else "")
# Only the schemas travel to the model, in the `tools` field of each request.
TOOL_SCHEMAS = [CHECK_MATCHUP_SCHEMA, IDENTIFY_WILD_SCHEMA]
Revisa tus herramientas como lo haría el modelo
- Lee solo el esquema. Oculta el código y pregúntate: ¿sabría cuándo llamar a esto y qué pasarle?
- Mira las primeras llamadas. Si el modelo sigue mandando datos incorrectos, el arreglo casi siempre está en la descripción, no en el prompt.
- Mide la salida. Una herramienta que devuelve toda la tabla de tipos cuando bastaba una línea llena el historial muy rápido.