Synsemadocsv0.6.xENES

LLM

Tool calling

The four ops return text. To let the model pick a tool (a structured {tool, args} choice), Synsema adds llm_step + call_tool. The safety loop is written in-language, so the model never gains new powers.

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

The primitives§

The safe loop§

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"

The model only returns a tool name and an args map; your program decides whether to run it, and a wrong argument comes back to the model as an error in the context instead of stopping the loop. Security = allow-list + per-task capability gate + frozen intent + bounded loop. Offline, llm_step returns {kind: "final", text: "[no llm provider]", tokens: 0}.

Typed parameters and tool_spec§

Engine v0.6.46+. Before, every argument the model returned arrived as text ("2", not 2) and the catalog was written by hand next to the task. Now the task's signature carries the types, and the catalog comes from it:

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

What comes back in step["args"] (measured with an OpenAI-compatible endpoint):

The model returns, forit arrives as
a: number → "2" or 2the number 2
ok: bool → "true"true
xs: list → "[1,2]"[1, 2]
m: map → "{\"a\":1}"{a: 1}
s: text → 5the text "5" (a text argument stays text)
an untyped parameter → 2the text "2", as before
a: number → "two"the text "two", and call_tool rejects it
null or "null", for an optional parameter or a typed one other than textnothing: the key is dropped, so the default runs, or call_tool says it is missing

The check in call and call_tool is strict and never converts:

synsema code outline --json publishes them per task, as parameters and input_schema — see Tasks & lambdas.

Private data in the loop§

Engine v0.6.46+, with --labels. With require llm("customers") and a host ceiling item such as llm=customers@api.anthropic.com (LLM primitives), llm_step may take a private prompt. Its answer is the whole map, private: the tool name, the args, even kind. So the branch that picks the tool needs a declassify, which has its own ceiling item:

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"))

Without that declassify, when step["name"] == "add" is a branch on private data, and a print inside it ends the run with a label_violation. What the model chose is private but not trusted — a prompt injection inside the customer's text can name any tool — so the allow-list and the declassify are what decide.