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.
-- 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 nothingThe primitives§
llm_step(prompt, catalog, context)→ the model's decision:{kind: "final", text, tokens}or{kind: "tool", name, args, tokens}.catalogis plain data;tokenslets you enforce a budget.call(task, args_map)→ call a task with named args from a map. A key the task does not declare is an error (unknown parameter 'units' — did you mean 'unit'?; the parameters are: city, unitsince engine v0.6.46), never a silent drop; an untyped parameter the map leaves out takes its default, or arrives asnothing—callis the host calling your task, like a callback, so the strict arity of v0.6.29 does not apply to it. A typed parameter is checked (next section).call_tool(task, args_map)→ dispatch a chosen tool least-privilege: it runs with only the capabilities it declared (∩ the agent's), even if the agent has more. Same argument rules ascall.
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
- Types on parameters:
task f(id: text, items: bool = false), with the five types ofexpect body—text,number,bool,list,map. Syntax and load errors are in Tasks & lambdas. tool_spec(task, describe)→{name, describe, params, optional}.paramsis["a: number", …](an untyped parameter is just its name) andoptionallists the parameters that have a default.describemay be left out (→""). A lambda is an error (tool_spec needs a named task: a lambda has no name for the model to call), and so is a builtin (tool_spec needs a task of the program, not the builtin 'upper').- A hand-written catalog types its parameters the same way —
"params": ["n: number", "s"]— and"optional": ["s"]takes names out of the schema'srequired(a name inoptionalthat is not a parameter is ignored). An unknown type is an error atllm_step: `llm_step: tool 'f' declares the parameter 'n: integer' with an unknown type: the types are text, number, bool, list or map`. - What the provider receives. The hosted providers (Anthropic, OpenAI-compatible) send each declared type in the tool's JSON Schema —
number→"number",text→"string",bool→"boolean",list→"array",map→"object"; an untyped parameter goes as"string"— andrequiredis every parameter not inoptional.
What comes back in step["args"] (measured with an OpenAI-compatible endpoint):
| The model returns, for | it arrives as |
|---|---|
a: number → "2" or 2 | the number 2 |
ok: bool → "true" | true |
xs: list → "[1,2]" | [1, 2] |
m: map → "{\"a\":1}" | {a: 1} |
s: text → 5 | the text "5" (a text argument stays text) |
an untyped parameter → 2 | the 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 text | nothing: 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:
- a wrong type → `
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). Fix: type the catalog (tool_spec` does it), or convert inside the task. - a typed parameter without a default left out →
call_tool(add): missing 'a' (number). An untyped one still arrives asnothing. nothingfor a typed parameter →call_tool(add): 'b' must be number, got nothing, unless its default isnothing(extra = nothingaccepts it).- a secret counts as
text. - A normal call does not check the types and costs nothing:
add("x", "y")is"xy". The types are metadata for data that crosses a boundary, not a static type system.
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.