LLM
Tool calling
Las cuatro ops devuelven texto. Para que el modelo elija una tool (una decisión estructurada {tool, args}), Synsema agrega llm_step + call_tool. El loop de seguridad se escribe in-language, así el modelo nunca gana poderes nuevos.
-- Doc example: tool-calling. A "tool" is just a task; call() invokes it with named
-- args. The llm_step loop that lets the MODEL pick a tool is shown in the prose.
intent: "doc example: tool-calling"
require llm
task get_weather(city)
give "weather in " + city + ": sunny"
print(call(get_weather, {"city": "Lima"})) -- run shows: weather in Lima: sunny
test "a tool is a normal task; call() invokes it with named args from a map"
assert_eq(get_weather("Paris"), "weather in Paris: sunny")
assert_eq(call(get_weather, {"city": "Lima"}), "weather in Lima: sunny")
-- The model writes the args map, so it can name a parameter the task does not have.
-- That is an error, never a silent drop: catch it and tell the model.
test "an argument the task does not declare is an error (catch it in the loop)"
assert_error(() => call(get_weather, {"city": "Lima", "units": "celsius"}))
assert_error(() => call_tool(get_weather, {"city": "Lima", "units": "celsius"}))
-- A parameter the map leaves out takes its default, or arrives as nothing: call() is the
-- host calling your task, like a callback, so strict arity does not apply to it.
task pair(a, b = 2)
give [a, b]
test "a missing key takes the default, or arrives as nothing"
assert_eq(call(pair, {"a": 1}), [1, 2])
assert_eq(call(pair, {}), [nothing, 2])
test "offline, llm_step answers final with the placeholder and 0 tokens"
when not llm_available()
assert_eq(llm_step("Weather in Lima?", [], ""), {"kind": "final", "text": "[no llm provider]", "tokens": 0})
-- v0.6.46: types on parameters. Checked only where the arguments arrive as data (call and
-- call_tool), strictly, without converting; a normal call does not check them.
task add(a: number, b: number = 1)
give a + b
task tag(name: text, extra = nothing)
give [name, extra]
test "tool_spec builds the catalog entry from the signature"
assert_eq(tool_spec(add, "Add two numbers"), {"name": "add", "describe": "Add two numbers", "params": ["a: number", "b: number"], "optional": ["b"]})
assert_eq(tool_spec(get_weather, "Weather for a city").params, ["city"])
test "call_tool checks the declared types and the typed parameters it needs"
assert_eq(call_tool(add, {"a": 2}), 3)
assert(contains(error_of(() => call_tool(add, {"a": "2"})), "'a' must be number, got text"))
assert(contains(error_of(() => call_tool(add, {})), "missing 'a' (number)"))
assert(contains(error_of(() => call_tool(add, {"a": 1, "b": nothing})), "'b' must be number, got nothing"))
assert(contains(error_of(() => call(add, {"a": 1, "c": 2})), "did you mean 'a'?"))
test "a default of nothing accepts nothing; a normal call does not check types"
assert_eq(call_tool(tag, {"name": "n", "extra": nothing}), ["n", nothing])
assert_eq(add("x", "y"), "xy")
-- The message of the error `f` raises, or nothing when it does not raise.
task error_of(f)
try
f()
recover err
give err
give nothingLas primitivas§
llm_step(prompt, catalog, context)→ la decisión del modelo:{kind: "final", text, tokens}o{kind: "tool", name, args, tokens}.cataloges data plana;tokenste deja imponer un presupuesto.call(task, args_map)→ llama una task con args nombrados desde un map. Una clave que la task no declara es un error (unknown parameter 'units' — did you mean 'unit'?; the parameters are: city, unitdesde el motor v0.6.46), nunca un descarte silencioso; un parámetro sin tipo que el map no trae toma su default, o llega comonothing—calles el host llamando a tu task, como un callback, así que la aridad estricta de v0.6.29 no se le aplica. Un parámetro con tipo se chequea (sección siguiente).call_tool(task, args_map)→ despacha una tool elegida con mínimo privilegio: corre solo con las capacidades que declaró (∩ las del agente), aunque el agente tenga más. Mismas reglas de argumentos quecall.
El loop seguro§
require llm
task get_weather(city)
require net("api.weather.com") -- the tool's own declaration
give "weather in " + city + ": sunny"
let tools be {"get_weather": get_weather} -- ALLOW-LIST (name → task)
let catalog be [{"name": "get_weather", "describe": "Weather for a city", "params": ["city"]}]
task run_agent(question, max_steps, budget)
let spent be 0
let n be 0
let ctx be ""
while (n < max_steps) and (spent <= budget) -- bounded loop (budget guard in the condition)
set n to n + 1
let step be llm_step(question, catalog, ctx)
set spent to spent + step["tokens"]
when step["kind"] == "final"
give step["text"]
otherwise when contains(tools, step["name"]) -- only allow-listed names dispatch
try -- the model wrote the args: they can be wrong
let result be call_tool(tools[step["name"]], step["args"])
set ctx to ctx + " [" + step["name"] + " => " + text(result) + "]"
recover err
set ctx to ctx + " [" + step["name"] + " => error: " + err + "]"
otherwise
set ctx to ctx + " [" + step["name"] + " => not allowed]" -- injected/hallucinated → rejected
give "out of steps"
El modelo solo devuelve un nombre de tool y un map de args; tu programa decide si la corre, y un argumento equivocado vuelve al modelo como un error en el contexto en vez de frenar el loop. Seguridad = allow-list + gate de capacidad por task + intent congelado + loop acotado. Offline, llm_step devuelve {kind: "final", text: "[no llm provider]", tokens: 0}.
Parámetros con tipo y tool_spec§
Motor v0.6.46+. Antes, cada argumento que devolvía el modelo llegaba como texto ("2", no 2) y el catálogo se escribía a mano al lado de la task. Ahora la firma de la task lleva los tipos, y el catálogo sale de ella:
require llm
task add(a: number, b: number = 1)
give a + b
let tools be {"add": add}
let catalog be [tool_spec(add, "Add two numbers")]
-- catalog[0] is {name: "add", describe: "Add two numbers", params: ["a: number", "b: number"], optional: ["b"]}
let step be llm_step("What is 2 plus 3?", catalog, "")
when step["kind"] == "tool" and contains(tools, step["name"])
print(call_tool(tools[step["name"]], step["args"])) -- 5, a number
- Tipos en los parámetros:
task f(id: text, items: bool = false), con los cinco tipos deexpect body—text,number,bool,list,map. La sintaxis y los errores de carga están en Tasks y lambdas. tool_spec(task, describe)→{name, describe, params, optional}.paramses["a: number", …](un parámetro sin tipo es sólo su nombre) yoptionallista los parámetros que tienen default.describese puede omitir (→""). Una lambda es un error (tool_spec needs a named task: a lambda has no name for the model to call), y un builtin también (tool_spec needs a task of the program, not the builtin 'upper').- Un catálogo escrito a mano tipa sus parámetros igual —
"params": ["n: number", "s"]— y"optional": ["s"]saca nombres delrequireddel schema (un nombre enoptionalque no es un parámetro se ignora). Un tipo desconocido es un error enllm_step: `llm_step: tool 'f' declares the parameter 'n: integer' with an unknown type: the types are text, number, bool, list or map`. - Lo que recibe el proveedor. Los proveedores hosteados (Anthropic, compatibles con OpenAI) mandan cada tipo declarado en el JSON Schema de la tool —
number→"number",text→"string",bool→"boolean",list→"array",map→"object"; un parámetro sin tipo va como"string"— yrequiredes todo parámetro que no está enoptional.
Lo que vuelve en step["args"] (medido con un endpoint compatible con OpenAI):
| El modelo devuelve, para | llega como |
|---|---|
a: number → "2" o 2 | el número 2 |
ok: bool → "true" | true |
xs: list → "[1,2]" | [1, 2] |
m: map → "{\"a\":1}" | {a: 1} |
s: text → 5 | el texto "5" (un argumento text sigue siendo texto) |
un parámetro sin tipo → 2 | el texto "2", como antes |
a: number → "two" | el texto "two", y call_tool lo rechaza |
null o "null", para un parámetro opcional o uno con tipo que no sea text | nada: la clave se descarta, así que corre el default, o call_tool dice que falta |
El chequeo de call y call_tool es estricto y nunca convierte:
- un tipo equivocado → `
call_tool(add): 'a' must be number, got text — a tool call fromllm_stepbrings typed arguments only for the parameters the catalog types (tool_spec(task, describe)takes them from the signature). Arreglo: tipá el catálogo (tool_spec` lo hace), o convertí adentro de la task. - falta un parámetro con tipo y sin default →
call_tool(add): missing 'a' (number). Uno sin tipo sigue llegando comonothing. nothingpara un parámetro con tipo →call_tool(add): 'b' must be number, got nothing, salvo que su default seanothing(extra = nothinglo acepta).- un secret cuenta como
text. - Una llamada normal no chequea los tipos y no cuesta nada:
add("x", "y")da"xy". Los tipos son metadata para datos que cruzan una frontera, no un sistema de tipos estático.
synsema code outline --json los publica por task, como parameters e input_schema — ver Tasks y lambdas.
Datos privados en el loop§
Motor v0.6.46+, con --labels. Con require llm("customers") y un ítem del techo del host como llm=customers@api.anthropic.com (Primitivas LLM), llm_step puede recibir un prompt privado. Su respuesta es el mapa entero, privado: el nombre de la tool, los args, hasta kind. Así que la rama que elige la tool necesita un declassify, que tiene su propio ítem del techo:
synsema run --labels --cap-set "stdout,llm=customers@api.anthropic.com,declassify=customers" app.syn
require llm("customers")
task add(a: number, b: number)
give a + b
let tools be {"add": add}
let msg be private("Add two and three for Ana", "customers")
let step be llm_step(msg, [tool_spec(add, "Add two numbers")], "")
let name be declassify(step["name"], "which tool the model chose is public")
when name == "add"
let v be call_tool(tools[name], step["args"]) -- the args are private, and so is v
print(declassify(v, "the sum is public"))
Sin ese declassify, when step["name"] == "add" es una rama sobre un dato privado, y un print adentro termina la corrida con un label_violation. Lo que eligió el modelo es privado pero no es confiable — una inyección de prompt adentro del texto del cliente puede nombrar cualquier tool — así que lo que decide son la allow-list y el declassify.