Nível 8
Skills sob demanda
- user
- assistant
- tool_result
EventBus
O problema
O assistente de encomendas de uma padaria precisa conhecer as regras da casa. Qual tamanho de bolo serve 20 pessoas. Que bolos sem castanhas só são assados na sexta de manhã. Quanto é o sinal. Como orçar uma bandeja de buffet. O que vai em cada produto que já está nas prateleiras. O modelo não sabe nada disso.
O conserto óbvio é escrever tudo e colar no system prompt. Funciona no primeiro dia. Depois os manuais crescem, e já são dez. Toda requisição agora carrega todos os manuais, a cada turno, quer o cliente queira um bolo de casamento, quer só o horário de funcionamento. Você paga por tudo isso, as requisições ficam mais lentas, e a única regra que importa fica enterrada entre as que não importam.
Esse é o Tomebloat, o system prompt inchado. Ele alimenta o Gulp, do nível 7: um prompt que já está cheio deixa menos espaço para a conversa em si.
A solução
Divida cada manual em dois. Uma descrição de uma ou duas linhas diz quando a skill se aplica. O corpo diz como fazer o trabalho. Só as descrições vão no system prompt, como um catálogo. Quando uma requisição combina com uma delas, o modelo pede o corpo, e o corpo entra na conversa dali em diante.
Isso é progressive disclosure (revelação progressiva): mostrar primeiro o índice, e o detalhe só quando for
preciso. O formato usual é uma pasta por skill com um arquivo SKILL.md. O frontmatter no topo (o
bloco entre as linhas ---) guarda o nome e a descrição. Tudo abaixo dele é o corpo.
---
name: custom-cake-order
description: Cakes made to order. Use when a customer wants a cake baked for them — size by guests, flavors, allergies, lead time, deposit.
---
# Custom cake orders
1. Size by guests: up to 12 → 18 cm, up to 24 → 24 cm, more → two tiers.
2. Flavors: chocolate, vanilla, dulce de leche. Nothing else.
3. Nut allergy → the nut-free line. It only bakes on Friday mornings.
4. Check the calendar before you promise a date. Never less than 72 hours.
5. Quote the 50% deposit, and ask before you book. Never book on your own.
Há três jeitos comuns de entregar skills ao modelo. O astorlm suporta os três com skillMode:
-
allO corpo completo de cada skill vai no system prompt, desde a primeira requisição.
Sem chamadas extras, e o modelo não tem como pular um manual. Serve para duas ou três skills curtas de que quase toda requisição precisa. Passou disso, é o Tomebloat.
-
on-demandO system prompt lista o nome e a descrição de cada skill. Uma ferramenta load_skill devolve o corpo completo quando o modelo pede.
Escala para dezenas de skills. Custa uma chamada de ferramenta por skill carregada, e modelos pequenos às vezes respondem sem carregar a skill de que precisavam.
-
filesystemO catálogo também informa o caminho de cada SKILL.md, e o modelo o abre com a ferramenta comum de leitura de arquivos.
A mesma ideia sem ferramenta especial. É assim que o Claude Code, o Codex e o Gemini CLI fazem, então a mesma pasta de skills funciona em todos eles. Precisa de um agente que consiga ler arquivos.
Uma skill não é uma ferramenta. Uma ferramenta é algo que o loop executa para o modelo. Uma skill é algo que o modelo lê, e ela pode dizer ao modelo quais ferramentas chamar e em que ordem, como a receita que diz “confira o calendário antes de prometer uma data”.
O elenco
O mesmo elenco de sempre, desta vez na cozinha de uma padaria.
- O Oráculo o modelo
- O chef atrás do passe. Lê o que estiver na frente dele, a cada turno, e não cozinha nada sozinho.
- As comandas o catálogo
- Uma por skill, no trilho sobre o passe: um nome e uma linha sobre quando usá-la. Fazem parte do system prompt, então vão em toda requisição.
- A prateleira os corpos das skills
- Um livro de receitas por skill, fechado. Quanto mais grosso o livro, mais tokens ele pesa. Nada disso chega ao modelo até alguém ir buscar.
- O livro aberto uma skill carregada
-
load_skilldevolve o corpo como resultado de ferramenta (o marcador verde). Ele entra no bandoneón como qualquer resultado, então fica aberto sobre o passe pelo resto da execução. - O forno check_calendar
- Uma ferramenta comum. É a receita que diz ao modelo para usá-la.
- A barra tamanho da requisição
- Quantos tokens a próxima requisição carrega. O comprimento total dela é o que carregaria com os três livros colados no system prompt.
No painel EventBus, load_skill aparece como um tool_execution_start e um
tool_execution_end comuns. O loop não faz ideia de que skills existem: para ele, carregar um manual é
só mais uma chamada de ferramenta.
O código
Com astorlm: Aponte skillSources para uma pasta de skills. O padrão
skillMode: 'on-demand' coloca o catálogo no system prompt e adiciona a ferramenta
load_skill para você.
Do zero: Leia cada SKILL.md, coloque uma linha por skill no system prompt e adicione
uma ferramenta load_skill que devolva um corpo. O loop do nível 2 não muda nada.
import { OpenAIProvider, createFileSystemSkillSource, createLocalAgent, tool } from 'astorlm'
import { z } from 'zod'
const checkCalendar = tool({
name: 'check_calendar',
description: 'Free baking slots and pickup times for a day, per production line.',
schema: z.object({ day: z.string(), line: z.enum(['regular', 'nut-free']) }),
execute: async ({ day, line }) => bakerySlots(day, line), // your code
})
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: [checkCalendar],
// Every folder in ./skills with a SKILL.md is one skill:
// skills/custom-cake-order/SKILL.md, skills/catering-quote/SKILL.md, …
skillSources: [createFileSystemSkillSource({ dir: './skills' })],
// The default. Only each skill's name and description go in the system prompt,
// and the agent gets a load_skill tool to fetch a full body when it needs one.
skillMode: 'on-demand',
maxTurns: 10,
})
agent.on('tool-end', ({ name, output }) => {
if (name === 'load_skill') console.log('loaded:', output.slice(0, 60))
})
const last = await agent.run('I need a cake for 20 people this Saturday. Chocolate, and one guest can’t have nuts.')
console.log(last.content)
// On-demand skills, from scratch. Plain fetch and node:fs, no SDK.
import { readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
// Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
const LLM = {
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
}
type ToolCall = { id: string; function: { name: string; arguments: string } }
type Message =
| { role: 'system' | 'user'; content: string }
| { role: 'assistant'; content: string | null; tool_calls?: ToolCall[] }
| { role: 'tool'; tool_call_id: string; content: string }
// 1. Read the skills: one folder per skill, each with a SKILL.md.
// The frontmatter (the block between the --- lines) holds name and description.
type Skill = { name: string; description: string; body: string }
function readSkill(file: string): Skill {
const [, front = '', body = ''] = readFileSync(file, 'utf8').match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/) ?? []
const field = (key: string) => front.match(new RegExp(`^${key}:\\s*(.+)$`, 'm'))?.[1]?.trim() ?? ''
return { name: field('name'), description: field('description'), body: body.trim() }
}
const SKILLS_DIR = './skills'
const skills = new Map(
readdirSync(SKILLS_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => readSkill(join(SKILLS_DIR, entry.name, 'SKILL.md')))
.map((skill) => [skill.name, skill]),
)
// 2. The catalog goes in the system prompt: one line per skill, never the body.
const catalog = [...skills.values()].map((s) => `- \`${s.name}\` — ${s.description}`).join('\n')
const SYSTEM = `You are the order assistant of a bakery.
<available-skills>
When the customer's request matches a skill, call load_skill with its name
before you answer, and follow the instructions it returns.
${catalog}
</available-skills>`
// 3. load_skill is a tool like any other. Its result is the body.
const loadSkill = ({ name }: { name: string }): string => {
const skill = skills.get(name)
if (!skill) throw new Error(`Unknown skill "${name}". Available: ${[...skills.keys()].join(', ')}`)
return `<skill name="${skill.name}">\n${skill.body}\n</skill>`
}
type ToolFn = (args: Record<string, string>) => string | Promise<string>
const tools: Record<string, ToolFn> = {
load_skill: (args) => loadSkill({ name: args.name ?? '' }),
check_calendar: checkCalendar, // your code
}
const toolSchemas = [
{
type: 'function',
function: {
name: 'load_skill',
description: "Load a skill's full instructions by name. Skills are listed in <available-skills>.",
parameters: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
},
},
/* check_calendar's schema */
]
// 4. The loop from level 2. Nothing in it knows about skills.
export async function runAgent(prompt: string, maxTurns = 10): Promise<string> {
const messages: Message[] = [
{ role: 'system', content: SYSTEM },
{ role: 'user', content: prompt },
]
for (let turn = 1; turn <= maxTurns; turn++) {
const res = await fetch(`${LLM.baseURL}/chat/completions`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${LLM.apiKey}` },
body: JSON.stringify({ model: LLM.model, messages, tools: toolSchemas }),
})
const [choice] = (await res.json()).choices
const reply: Message = choice.message
messages.push(reply)
if (choice.finish_reason !== 'tool_calls') return reply.content ?? ''
for (const call of reply.tool_calls ?? []) {
const run = tools[call.function.name]
let output = `Unknown tool: ${call.function.name}`
try {
if (run) output = await run(JSON.parse(call.function.arguments))
} catch (err) {
output = `Error: ${err instanceof Error ? err.message : err}`
}
// A loaded body lands here, in the history, and every later turn resends it.
messages.push({ role: 'tool', tool_call_id: call.id, content: output })
}
}
throw new Error(`No answer after ${maxTurns} turns`)
}
console.log(await runAgent('I need a cake for 20 people this Saturday. Chocolate, and one guest can’t have nuts.'))
# On-demand skills, from scratch. Standard library only, no SDK.
import json
import re
import urllib.request
from pathlib import Path
# Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy...
LLM = {
"base_url": "http://localhost:11434/v1", # e.g. Ollama's default address
"model": "your-model", # e.g. "llama3.1", "gpt-4o-mini"
"api_key": "YOUR_API_KEY", # local servers usually ignore it
}
# 1. Read the skills: one folder per skill, each with a SKILL.md.
# The frontmatter (the block between the --- lines) holds name and description.
def read_skill(path):
match = re.match(r"^---\n(.*?)\n---\n?(.*)$", path.read_text(encoding="utf-8"), re.S)
front, body = match.groups() if match else ("", "")
def field(key):
found = re.search(rf"^{key}:\s*(.+)$", front, re.M)
return found.group(1).strip() if found else ""
return {"name": field("name"), "description": field("description"), "body": body.strip()}
SKILLS = {
skill["name"]: skill
for skill in (read_skill(folder / "SKILL.md") for folder in Path("skills").iterdir() if folder.is_dir())
}
# 2. The catalog goes in the system prompt: one line per skill, never the body.
CATALOG = "\n".join(f"- `{s['name']}` — {s['description']}" for s in SKILLS.values())
SYSTEM = f"""You are the order assistant of a bakery.
<available-skills>
When the customer's request matches a skill, call load_skill with its name
before you answer, and follow the instructions it returns.
{CATALOG}
</available-skills>"""
# 3. load_skill is a tool like any other. Its result is the body.
def load_skill(name):
if name not in SKILLS:
raise ValueError(f'Unknown skill "{name}". Available: {", ".join(SKILLS)}')
return f'<skill name="{name}">\n{SKILLS[name]["body"]}\n</skill>'
TOOLS = {"load_skill": load_skill, "check_calendar": check_calendar} # check_calendar: your code
TOOL_SCHEMAS = [
{
"type": "function",
"function": {
"name": "load_skill",
"description": "Load a skill's full instructions by name. Skills are listed in <available-skills>.",
"parameters": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]},
},
},
# check_calendar's schema
]
def chat(messages):
request = urllib.request.Request(
f"{LLM['base_url']}/chat/completions",
data=json.dumps({"model": LLM["model"], "messages": messages, "tools": TOOL_SCHEMAS}).encode(),
headers={"Content-Type": "application/json", "Authorization": f"Bearer {LLM['api_key']}"},
)
with urllib.request.urlopen(request) as response:
return json.load(response)["choices"][0]
# 4. The loop from level 2. Nothing in it knows about skills.
def run_agent(prompt, max_turns=10):
messages = [{"role": "system", "content": SYSTEM}, {"role": "user", "content": prompt}]
for _ in range(max_turns):
choice = chat(messages)
reply = choice["message"]
messages.append(reply)
if choice["finish_reason"] != "tool_calls":
return reply.get("content") or ""
for call in reply.get("tool_calls", []):
name = call["function"]["name"]
try:
output = TOOLS[name](**json.loads(call["function"]["arguments"]))
except Exception as err:
output = f"Error: {err}"
# A loaded body lands here, in the history, and every later turn resends it.
messages.append({"role": "tool", "tool_call_id": call["id"], "content": output})
raise RuntimeError(f"No answer after {max_turns} turns")
print(run_agent("I need a cake for 20 people this Saturday. Chocolate, and one guest can't have nuts."))
O que observar
-
A descrição é o gatilho. O modelo escolhe uma skill só pela descrição, do mesmo jeito que escolhe
uma ferramenta (nível 3). Diga quando usá-la, não o que ela contém: “Use quando um cliente quer um bolo feito por
encomenda” é melhor que “Informações sobre bolos”. Na animação,
allergen-infotambém fala de alergias; só as descrições impedem o modelo de carregar o livro errado. -
Modelos pequenos pulam a etapa. Alguns respondem direto sem carregar a skill de que precisavam.
Diga isso com todas as letras no system prompt (“carregue a skill correspondente antes de responder”), teste o
modo filesystem ou, para uma skill de que toda requisição precisa, mantenha-a em
all. - Uma skill carregada continua carregada. O corpo dela está no histórico, então cada turno seguinte paga por ele, e a compactação (nível 7) pode truncá-lo depois. Mantenha os corpos curtos e divida skills grandes em duas.
-
Skills são instruções, então são código. Quem escreve um
SKILL.mddirige o seu agente. Não carregue skills de pastas ou registros que você não controla (nível 14).