Function calling#
How an LLM picks a function from a list, the framework runs it, and the
result feeds back as the model's next-turn input. For the moment-by-moment
loop mechanics (how the LLM picks the next tool inside one
runtime.exec call), see
docs/capabilities/agentic-programming/choosing-the-next-step/tool-calling.md.
The governing principle is default-on, user-curated: a registered
tool is usable with zero configuration; the user narrows from there.
Exposure is registration-driven (a registered tool is visible unless it
opts out with expose=False), and a bare runtime.exec gets the full
exposed set. Tool-call results live only in run history and never enter
later prompt context, so broad exposure carries no context cost.
On the wire#
Same concept the industry calls "tool use" (tools=[] /
tool_calls=[] in the OpenAI / Anthropic / Gemini APIs). We call the
act "function calling" (authors write a function, expose it to the
LLM), but the thing in the API request stays tool to match SDK
terminology.
Authoring surface LLM API wire / providers/types.py
─────────────────────────────────────────────────────────────────
@function decorator Tool / ToolCall / ToolResultMessage
Agent method configuration tools=[...] field
agent_tools() / get_agent_tool() … tool_calls=[...] field
The boundary is the wire format: providers serialize each
AgentTool in our registry into the API's Tool JSON shape; the
model's tool_calls come back, get matched by name against our
registry, and AgentTool.execute(...) runs. Our wrapping classes
(AgentTool / AgentToolResult) carry runtime extras the wire
format doesn't have (sidecar gating, sync→async, char-cap, etc.).
Explicit registration and one registry#
Agent methods request tool registration with method_options["method"]["tool"] = True. Managed Program packages explicitly list public entries in PROGRAM_ENTRIES. Deterministic function() tools and ordinary Agent methods use the same registry.
All forms use the existing AgentTool registry and authorization rules. Automatic scope capture alone does not expose a tool. The unified Agent and Context contract owns class authoring and execution lifetime.
Shared method and tool settings#
kwarg what it controls
─────────────────────────────────────────────────────────────────
name, description, model-facing surface (the JSON the
parameters, label LLM sees)
auto-derived from def signature +
docstring if omitted (only @function;
Agent method reuses
_build_agentic_tool_spec)
max_result_chars, result truncation — head+tail with
persist_full, head_ratio, marker; persist-to-disk for full
stream_capacity_chars version; bounded tail accumulator
for streamed on_update
timeout, static + LLM-controllable timeout
timeout_min, timeout_max (clamp into range, used both as
wait_for budget and passed-through
to the fn body)
cache, cache_ttl memoize on (name, args)
check_fn Layer 4 — process-level "this tool
can run now" gate
requires_env Layer 4 — env vars that must be set
can_use Layer 4 — session-level gate
requires_approval dispatcher consults before invoking
expose Layer 2 — exposure opt-OUT. Default
True: a registered tool is visible to
the model. Set False for internal helpers
that Python calls but the LLM must never
see (e.g. _pick_stage, write_section, the
_merge_* leaves).
toolset Layer 2b — preset membership (Hermes-
style: tool also goes into the "research"
preset, etc.). A preset is a NAMED SUBSET
for callers that want fewer than all
exposed tools; it is not the visibility
gate (expose is).
unsafe_in Layer 3 — channel blacklist
(OpenClaw-style: hide on Telegram)
available_if Layer 1 — registration-time gate.
Decided once at import; False → tool
never enters the registry.
defer Layer 6 — schema-deferred. Tool is
registered but its full JSON Schema is
NOT shipped to the provider unless
the LLM calls tool_search first.
register_globally If False, build AgentTool + attach
sidecars but skip the global register.
Useful for in-test isolation.
The gating layers#
Tool selection per turn passes through these filters (skeleton from
Claude Code's tools.ts). Layer 2 is registration-driven exposure
(matching Claude Code / Hermes).
Layer When How configured Effect when rejected
─────────────────────────────────────────────────────────────────────────────────
1 at import / decoration @function(available_if=...) tool never enters
method_options _registry → invisible
available_if=...) everywhere
(Claude Code's
`feature() ?
require() : []`)
2 exposure (DEFAULT ON) @function(expose=False) expose=False → Python-
for internal helpers callable but never in
— everything else exposed any LLM tools array.
simply by being registered DEFAULT is exposed;
(plugins / MCP included). no allowlist needed.
2b preset membership @function(toolset=[...]) a named SUBSET for
(optional narrowing) TOOLSETS / DEFAULT_TOOLS callers who want fewer
(Hermes includes chain) than "all exposed";
not a visibility gate.
3 per-session mode agent_profile.toolset = this session sees a
"safe"/"research"/<folder> narrower set than the
(a Functions-page folder full exposed set
counts as a named subset)
4 per-tool-list build @function(check_fn=, filtered out of this
isEnabled-style requires_env=, can_use=) session's tools list
agent_tools(only_available= when the runtime gate
True) fails (missing key/env)
5 user/policy filter agent_tools(deny=, allow=) explicit subtraction /
agent_profile.disabled intersection by name;
Functions-page off-toggle attended-mode denies
(tools.disabled) ; attended ask_user_question
mode
6 prompt construction @function(defer=True) schema NOT in provider
schema-deferred request; name + 1-liner
in deferred catalog;
LLM calls tool_search
to load schema first
Default behaviour, end to end: a registered tool is on (Layer 2
exposed) unless its author set expose=False; a bare runtime.exec
gets the full exposed set. The framework does not restrict per call —
narrowing is the caller's choice: an agent profile (Layer 3),
per-call allow/deny, the Functions-page off-toggle (Layer 5), or
toolset="none" when a specific call wants no tools at all. The default
is always "give tools"; a caller that knows it needs none opts out.
Layers 1–5 mean "the LLM cannot see/use this tool". Layer 6 means "the LLM sees the name in a catalog but must opt-in to load the schema" — the only layer that lets the LLM itself choose what to pull in (used to keep large MCP/plugin tool sets out of the prompt until needed).
Per-layer default policy#
The guiding principle is default-on, user-curated: a freshly registered tool is usable with zero configuration; every layer's default is "don't restrict". Layers split into two kinds —
- Active (selection) layers decide what to use. Default = "everything exposed". The user/caller opts IN to a narrower set. These are the normal path.
- Passive (veto) layers decide what must not be used. Default = "veto nothing". They only ever subtract, as a safety/maintenance backstop, and stay dormant unless a condition fires.
Layer Kind Default (no config) Who overrides & when
──────────────────────────────────────────────────────────────────────────────
1 — tool registers (available_if author: only to gate a
gate absent/True) tool behind a feature/env
that makes it meaningless
otherwise
2 active EXPOSED. Registered ⇒ visible to author: expose=False for
(expose) the LLM, including plugin/MCP. an internal helper the LLM
Bare runtime.exec ⇒ full exposed must never see. That's the
set. Nothing hidden by default. ONLY reason to touch L2.
2b active no preset forced. Caller gets the caller/user: pass
(preset) full exposed set unless it names toolset="research" / a
a subset. Functions-page folder to
work with fewer tools, or
toolset="none" when this
call wants no tools.
3 passive no channel/mode restriction. framework: a tool's
(channel) source=None ⇒ nothing filtered. unsafe_in fires only on
that channel (telegram/
wechat/plan). profile
toolset narrows a session.
4 passive tool assumed runnable. Only framework: drops a tool
(avail) checked when whose key/env/can_use is
only_available=True (dispatcher missing — so the LLM never
path). sees a tool that would
error on call.
5 passive veto nothing. allow=None, user: Functions-page
(veto) deny=None, no disabled, attended off-toggle (persistent,
unless set. tools.disabled). system:
attended-mode denies
ask_user_question;
subagent/role caps via
allow/deny. Pure subtraction.
6 active* not deferred. Schema ships in the author / MCP wiring:
(defer) request by default. defer=True for large MCP
surfaces → name in catalog,
LLM tool_search to load.
*active by the LLM, not the
user.
L2b (active) vs L5 (passive) — why both exist, why not merged. They
look like "user touches tools" twice but are opposite operations: L2b is
the user/caller choosing a set to use (additive, a folder = "today's
toolbox"); L5 is blacklisting / capping (subtractive, "this tool is
broken / this session may not use it"). A veto must be independent of
selection — e.g. attended-mode must withhold ask_user_question no
matter which folder is active; you can't rely on the user remembering to
leave it out of every folder. So the Programs page surfaces L2b as
the primary action (organize & pick folders) and L5 as the exception
(a per-tool off switch); the system's own L5 vetoes (attended,
subagent caps) are code-only and invisible to the user.
Plugins and MCP servers#
Because exposure is registration-driven (Layer 2), a plugin or MCP server makes its tools available just by registering them — same as Claude Code and Hermes. There is no second step of editing a central allowlist. Concretely:
- A plugin's tools register through the same
_build_and_register_toolpath as built-ins (a plugin calls@function/ registers anAgentTool), so they land in_registryand are exposed by default. - MCP-server tools are registered as
AgentToolentries on connect and markeddefer=True(Layer 6) by default, so their names appear in the deferred catalog and the LLM loads schemas on demand viatool_search— this keeps a large MCP surface out of every prompt without making the tools invisible. - A plugin/MCP tool that should stay internal can still set
expose=False; a user can still turn any of them off on the Functions page (Layer 5). The default, though, is "registered = usable".
Tool profiles (Programs page)#
A tool profile is a named configuration that says "which tools are
enabled for this conversation". The Programs page (/programs)
manages profiles; the chat composer lets the user pick which profile to
use.
Concepts#
tool catalog all registered, exposed tools — a flat read-only
(the shelf) list on the Programs page. Shows every tool with
its name + description. The catalog itself has no
enable/disable controls; it just shows what exists.
tool profile a named set of tools to use — like a shopping cart
(the cart) built from the catalog. Each profile starts with
ALL tools (default-on); the user removes what they
don't want for this scenario.
Operations on a profile:
• remove a tool (take it out of this config)
• add a tool (put it back — pick from "not yet
in this profile" list)
• rename / delete the profile
default profile the built-in "all tools on" profile. Always
exists, cannot be deleted, contains every exposed
tool. Used when no other profile is selected.
User flow#
- Programs page shows the catalog (all tools) and a sidebar of profiles. Clicking a profile shows which tools it includes; the user adds/removes tools from that profile.
- Chat composer has a profile picker (e.g. a dropdown next to the model selector). Selecting a profile = this conversation uses that tool set. Default = "all tools".
- A profile name resolves wherever
toolset=is accepted (Layer 2b).agent_tools(toolset="research")returns the tools in the "research" profile. Agent profiles (agent.json) can reference a tool profile by name in theirtools.toolsetfield.
Storage#
Profiles are persisted in functions_meta.json (same location as
programs_meta.json), shape:
{
"profiles": {
"default": ["bash", "read", "write", ...], // immutable = all exposed
"research": ["web_search", "web_fetch", "read", "write", "bash"],
"safe": ["read", "glob", "grep", "web_search"]
},
"active": "default" // which profile the chat composer is using
}
Creating a new profile = copy of "default" (all tools). The user then removes tools they don't need for that scenario.
Relationship to Layer 5 (global disable)#
A profile says "this conversation uses these tools" (L2b, active
selection). The per-tool global disable (L5, tools.disabled)
remains as a separate, rarely-used backstop: if a tool is globally
disabled it is removed from EVERY profile automatically (the
resolution pipeline applies L5 after L2b). But the primary user
action is profile management, not per-tool global toggles.
User-editable entry points#
Entry point Controls Layer Persisted in
────────────────────────────────────────────────────────────────────────────────────────
Programs page — create / edit / delete tool L2b functions_meta.json
tool profiles profiles (named tool sets). → profiles: {name:[...]}
Add/remove tools to/from a
profile. Default profile =
all tools on.
Chat composer — pick which tool profile to L2b session state
profile picker use for this conversation. (sent per-turn with
(Tools toggle → expand Expand the "Tools" chip to tools_override)
→ profile list) see available profiles +
select one. Default = all.
Chat composer — per-turn toggles: Tools L2b per-message
"+" menu toggles on/off + Web Search on/off /L5 (tools_override)
Agent profile per-agent toolset / enabled L2b ~/.openprogram/
(tools field) / disabled / allowed. + L5 agents/<id>.json
Can reference a tool profile → tools: {...}
by name (toolset="research")
Attended / unattended withhold ask_user_question L5 session state
switch (CLI/TUI/web) when no human is watching (attended.py; applied
by the system, not
the user)
Global tool disable blacklist a single tool L5 config.json:
(Programs page / config) everywhere — rarely used. tools.disabled
Overrides any profile.
Author decorator kwargs expose / available_if / L1/2/ in-code
@function(...) defer / toolset / unsafe_in 3/6
/ check_fn
Daily use = tool profiles on the Programs page (create profiles, add/remove tools) + profile picker in the chat composer (choose which profile this conversation uses). Global disable = rarely-used backstop. Agent profile = per-agent override for advanced multi-agent setups. Author kwargs = framework internals.
Four knobs none of the reference frameworks have#
Beyond the 6-layer cascade, the framework adds four runtime knobs neither Claude Code, Hermes, nor OpenClaw ship:
1. Dynamic per-call result ceiling _effective_max_chars() +
min(per-tool max, 0.3 × ctx_window) _current_context_window_chars
small-context models auto-shrink ContextVar installed by
dispatcher per turn
2. LLM-controllable timeout (clamp) If fn declares `timeout`
LLM-passed value clamped into param AND decorator sets
[timeout_min, timeout_max]; both used timeout_min/max → clamped
as wait_for budget and fn param and passed both places
3. Streaming tail accumulator (bounded) _TailAccumulator —
long-running tools writing through capacity defaults to
on_update can't grow unbounded max_result_chars, head
evicted on overflow
4. can_use() session-level gate Distinct from check_fn
process-level "can it run" (check_fn) + (always-on installable)
channel-level "is it allowed here" and unsafe_in (channel
(unsafe_in) + session-level "is this blacklist)
user / role allowed to use it" (can_use)
Agent method registration#
Agent defines ordinary methods. method_options assigns configuration by method name. tool=True explicitly registers a method, while ordinary helpers retain call scopes without tool exposure.
from openprogram import Agent
class ResearchAgent(Agent):
method_options = {
"research": {
"tool": True,
"name": "research",
"toolset": ["research"],
"expose": "io",
},
}
def research(self, topic: str) -> str:
"""Research a topic."""
return self(topic)
researcher = ResearchAgent()
PROGRAM_ENTRIES = [researcher.research]
The Agent class installs method call handling. This handling runs cancellation hooks, runtime injection, Context derivation, DAG entry and terminal updates, and error restoration. Method options retain schema, caching, timeout, availability, approval, and explicit-step recovery settings.
Agent construction registers methods that explicitly request tool exposure. Registration uses the same existing AgentTool registry and sidecars as deterministic tools. available_if gates registration without removing ordinary method scopes.
The managed loader records package ownership and lists public entries in PROGRAM_ENTRIES. It does not add a public decorator or expand authority. The Agent and Context contract owns execution lifetime.
Resolution path (dispatcher → provider)#
1. user message arrives → dispatcher.process_user_turn
2. dispatcher seeds _loaded_deferred ContextVar (Layer 6) for this
session — starts as empty set
3. dispatcher._resolve_tools(agent_profile, …) → list[AgentTool]
→ agent_tools(toolset=…, source=req.source, only_available=True)
→ walks Layers 2/3/4/5 (filter_for + sidecar gating)
→ does NOT walk Layer 6 (defer is handled later, per provider
call)
4. dispatcher computes deferred catalog text from the *initial* set
→ injects "deferred tools available via ToolSearch:" block into
system prompt
→ NOTE: the tools list passed to agent_loop is still the full
list including deferred tools — agent_loop does the per-call
split before each provider request
5. agent_loop runs the inner tool-call loop. Each provider call:
→ split_tools_for_dispatch(context.tools) → (provider_tools, _)
- non-deferred + deferred-already-loaded → provider_tools
- deferred-not-loaded → omitted from provider_tools
→ provider receives provider_tools as its `tools=[]` field
6. when LLM emits ToolCall(name="bash"), agent_loop:
→ looks up AgentTool by name from context.tools
(or via _registry if not found in current list)
→ validates arguments against the schema
→ await agent_tool.execute(call_id, args, cancel, on_update)
7. if the LLM called tool_search(select="cron"):
→ tool_search.execute mutates _loaded_deferred (adds "cron")
→ next iteration of step 5 includes cron in provider_tools
→ cron's full schema is now in the next request
→ LLM can call cron normally
Where each piece lives#
openprogram/programs/_runtime.py
AgentTool subclass (from openprogram.agent.types)
_registry exposure source
(Layer 2: exposed =
registered & not
expose=False)
_toolset_membership, _unsafe_in_channel Layer 2b/3 data
register / get / all_tools / filter_for / reset_registry
_build_and_register_tool shared helper
function decorator user-facing
ToolReturn dataclass optional return type
_normalize_result, _cap_result_text truncation
_persist_full_result persist full result
_effective_max_chars, _current_context_window_chars dynamic ceiling
_TailAccumulator streaming tail
_parse_docstring, _build_parameters_schema schema autoderive
_evaluate_approval, tool_requires_approval approval hook
_loaded_deferred (ContextVar) Layer 6 state
install_loaded_deferred, mark_deferred_loaded
split_tools_for_dispatch Layer 6 partition
deferred_catalog_text Layer 6 prompt block
tool_search (the AgentTool itself) Layer 6 loader
openprogram/programs/_helpers.py
is_available (legacy dict, kept for older callers)
is_available_agent_tool consolidates the
Layer 4 triad
openprogram/programs/__init__.py
DEFAULT_TOOLS, TOOLSETS Layer 2 presets
agent_tools, apply_tool_policy resolution API
get_agent_tool, list_registered_agent_tools,
list_available
side-effect imports of every subpackage @function tools register
at import time
openprogram/programs/<name>/<name>.py one per tool
@function on a plain def (for the 38 leaf
tools shipped today)
openprogram/agentic_programming/agent_class.py
Agent class, configuration, ordinary method scopes
method_options, explicit tool registration
openprogram/agentic_programming/agent_method.py
MethodOptions, method execution, schema and registration
openprogram/agentic_programming/call_state.py
Task-local call identity, runtime injection, cancellation hooks
openprogram/agentic_programming/call_scope.py
Shared scope entry, exit, Context and DAG state
openprogram/agent/dispatcher/__init__.py
install_loaded_deferred(...) called at session
start
agent_tools(toolset=, source=, only_available=True) Layer 2-5
split_tools_for_dispatch + deferred_catalog_text Layer 6 prompt
block
openprogram/agent/agent_loop.py
per-provider-call split_tools_for_dispatch Layer 6 enforcement
(Mid-loop loaded
schemas appear on
the next call)
openprogram/programs/workflow/*/__init__.py Agent method
modules (each its
own directory).
Includes harness
symlinks
GUI-Agent-Harness,
Research-Agent-Harness,
Wiki-Agent-Harness.
Test invariants (what the suite locks down)#
The unit suite (tests/unit/programs/runtime/test_tools_runtime.py,
tests/component/agent/turns/test_dispatcher_tools.py) covers:
- Docstring + signature → parameters schema
- Sync / async fn dispatch
- Exception → AgentToolResult(is_error=True) wrap
- Char-cap truncation + persist_full
- on_update callback delivery + tail accumulator
- cancel event propagation
- timeout (asyncio.wait_for)
- requires_approval evaluation
- Registry filter (toolset, source, names)
- All shipped @function tools register at package import
- @function with overrides (name / description / toolset)
- Layer 1 (available_if) skips registration on False / exception
- Layer 6 defer sidecar + tool_search promotes to provider list + unknown name handling + catalog text format
- Agent method registers as AgentTool only with explicit tool=True or as_tool=True
- method_options["method"]["tool"] = False skips shared registry
- method_options["method"]["register_globally"] = False skips shared registry
but still attaches
_agent_tool - method_options["method"]["available_if"] returning False skips tool registration but retains scope handling
Stable boundary#
The registry/method/dispatcher boundary is stable. Work that doesn't touch it:
- Adding new @function tools (write the function + decorate; it is exposed by default — no whitelist edit)
- Adding Agent methods with explicit tool=True metadata
- Hiding an internal helper from the LLM (
expose=Falsekwarg only) - Defining a named subset (TOOLSETS dict) or letting the user define one (Functions-page folder → functions_meta.json)
- Flagging tools defer / available_if (kwarg only)
- Wiring MCP servers / plugins — they register AgentTool entries the
normal way and are exposed on registration (mark
defer=Truefor large MCP surfaces)
Future work that would require touching the boundary (defer unless necessary):
- Adding a new gating layer beyond the ones above
- Changing AgentTool.execute signature
- Splitting / merging the shared registry
- Replacing the deferred-loading mechanism with something other than ToolSearch
Appendix — implementation status#
The registry, the six gating layers, the two decorators, and the four runtime knobs are all in place. Two user-facing entry points described under "Tool profiles (Programs page)" and "User-editable entry points" are not built yet:
- the Functions-page profile manager (create / edit / delete tool
profiles, persisted to
functions_meta.json); - the chat-composer profile picker (choose which profile a conversation uses).
Until those ship, profile selection is reachable only through the
toolset= argument and the agent profile's tools.toolset field.