Synsemadocsv0.6.xENES

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.

tool-calling.syn
-- 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 nothing

Las primitivas§

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

Lo que vuelve en step["args"] (medido con un endpoint compatible con OpenAI):

El modelo devuelve, parallega como
a: number → "2" o 2el número 2
ok: bool → "true"true
xs: list → "[1,2]"[1, 2]
m: map → "{\"a\":1}"{a: 1}
s: text → 5el texto "5" (un argumento text sigue siendo texto)
un parámetro sin tipo → 2el 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 textnada: 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:

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.