This document defines what metadata a function in this framework must carry — whether or not it calls an LLM — where that metadata lives, and which components consume it.
Scope: every function decorated with @agentic_function, plus any plain Python callable passed into render_options or any other component of the decision-menu protocol. (runtime.exec(tools=[...]) does not accept plain callables — entries must be an @agentic_function, a {"spec", "execute"} dict, or an object with .spec/.execute; anything else raises TypeError.)
Historically the same piece of information lived in multiple places:
- Parameter descriptions could be written in the docstring
Args:section or in@agentic_function(input={...}) - Decision-menu callers hand-wrote a verbose
availableregistry dict at the call site, repeating information already present on the function - Different consumers (tool_use spec, WebUI, decision menu, meta tooling) followed slightly different read-order conventions
The result: authors of new functions didn't know where to put descriptions, the framework read metadata from inconsistent sources, and generated code didn't always match what other consumers expected.
This spec defines a single source-of-truth so every consumer reads metadata by the same rules.
| Consumer | Required fields |
|---|---|
runtime.exec(tools=[fn]) (provider-native tool_use) |
function name, overall description, parameter JSON schema (type / required / enum / description) |
render_options(options) (decision menu) |
function name, when-to-pick description, parameter name / type / description / enum, whether each parameter is system-filled |
parse_args(reply, options, runtime, ...) (decision extract + dispatch + retry) |
parameter names + types + enum + hidden flag, runtime-style auto-inject allowlist |
| WebUI parameter form | description / placeholder / multiline / options / hidden |
llm() rendered context |
docstring — carried as metadata.doc and prefixed into the rendered context text. The default system prompt comes from the ambient runtime (plus the skills block), not from the docstring. |
Session-DAG rendering (render_context) |
expose mode + render_range |
| auto-trace / persistence | function identity, argument values, return value, timing |
Every piece of information has exactly one source-of-truth. This table is the spec:
| Field | Where it lives | How to read it |
|---|---|---|
| Function name | def name(...) |
fn.__name__ |
| Parameter names | signature | inspect.signature(fn).parameters |
| Parameter types | annotation | param.annotation |
| Parameter defaults | annotation default | param.default |
| One-line summary (what / when-to-pick) | first paragraph of docstring (up to first blank line) | inspect.getdoc(fn), first paragraph |
Per-call LLM instructions (prompt + data for one specific llm()) |
the prompt passed to that llm() call |
passed directly at call time |
| Per-parameter description | @agentic_function(input={"x": {"description": ...}}) |
fn.input_meta["x"]["description"] |
| Per-parameter enum | @agentic_function(input={"x": {"options": [...]}}) |
fn.input_meta["x"]["options"] |
| Whether the parameter is LLM-visible | @agentic_function(input={"x": {"hidden": True}}) |
fn.input_meta["x"]["hidden"] |
| WebUI placeholder | @agentic_function(input={"x": {"placeholder": "..."}}) |
fn.input_meta["x"]["placeholder"] |
| WebUI multiline input | @agentic_function(input={"x": {"multiline": True}}) |
fn.input_meta["x"]["multiline"] |
| Dynamic option source | @agentic_function(input={"x": {"options_from": "functions"}}) |
fn.input_meta["x"]["options_from"] |
| Framework-auto-injected parameters | two constants in two files, both {"runtime", "exec_runtime", "review_runtime"}: _RUNTIME_PARAMS in agentic_programming/function.py (injection + tool-spec filtering) and _AUTO_PARAMS in agentic_programming/decision.py (menu hiding + dispatch) |
module level |
| Override of the ambient LLM system prompt | @agentic_function(system="...") |
fn.system |
| DAG expose mode | @agentic_function(expose="io"|"llm"|"full"|"hidden") — controls what callers see of this function in their DAG render |
fn.expose |
| DAG render range | @agentic_function(render_range={"callers": N, "subcalls": M}) — controls how much DAG history this function's own llm() calls read. Both are node-count slices on seq: callers = most-recent N nodes written before this function's frame started (default None = uncapped, 0 = wall off all prior context); subcalls = most-recent N nodes written since this function's frame started (default -1 = uncapped — the frame sees its own progress; child internals are hidden by their expose setting, not by subcalls counting; set N>=0 only to actively cap prompt size in a loop). |
fn.render_range |
| Skill trigger words / agent discovery | sibling SKILL.md frontmatter |
loaded separately by the skill loader |
Core principle: anything expressible in the signature / annotation is not repeated in the decorator; anything expressible in input= is not repeated in the docstring.
The decorator also accepts the shared tool-registration / gating kwargs (name, description, toolset, unsafe_in, requires_approval, available_if, defer, ...) documented in docs/reference/design/function/calling-unification.md; cache / cache_ttl (memoize results on name + args) and timeout (hard-kill the body after N seconds, returning an error result) behave as in @function.
| Aspect | Default | Resulting behavior |
|---|---|---|
expose |
"io" |
Callers see my name + input + output. My internal llm() calls are hidden from them. |
render_range |
None → render_context falls through to callers=None, subcalls=-1 |
Pre-frame history is uncapped. In-frame nodes (the frame's own progress: earlier llm() results, returned sub-function io) are also uncapped. Child @agentic_function internals stay hidden because the child carries expose="io", not because subcalls trims them. |
| top-level chat turn | frame_entry_seq=-1, no pre-frame |
Same code path as any other frame — all nodes are in-frame and visible. No special-casing. |
| tools | full toolset | A bare runtime.exec with neither tools= nor toolset= resolves the full registry toolset by default — tools are ON. Pass tools=[...] for an explicit menu; pass toolset="none" or tools=[] for a tool-free reasoning call. A nested exec inside a tool body inherits the outer tools= list via the _current_tools contextvar. |
system |
None |
Use the runtime's existing system prompt as-is. |
Implication: a frame naturally accumulates its own work. Set subcalls=N explicitly only to bound prompt size in a long loop. Set subcalls=0 explicitly to fully wall off in-frame nodes (rare).
These two channels can carry overlapping information, but they have different responsibilities — neither replaces the other:
| Channel | Scope | What goes here |
|---|---|---|
| docstring | Whole-function level. Describes what the function as a whole does (it may run preprocessing, several LLM calls, and postprocessing). Read by humans, decision menus, tool_use specs, and meta tooling. | One-line summary required. May also describe in detail what each LLM call does, expected output, edge cases — as much detail as you want, for readers and for context. |
llm(prompt) |
One specific LLM call inside the function. Each call is its own request; the function may make several with different prompts. | The actual prompt + data for this LLM call: what task, what output format, what constraints, plus the data to operate on. This must be written even if the docstring already describes it. |
A function may have a rich docstring explaining "this function classifies sentiment by asking the LLM and normalizing the reply", but the body still needs an explicit llm(prompt) containing the actual instruction + data going to the LLM. Documentation in the docstring does not propagate into the LLM call — the framework sends the docstring as descriptive context (carried on the DAG node as metadata.doc and rendered into the inner call's situation prompt), not as authoritative instruction. Always put the per-call instruction and data in the llm() prompt.
Docstring writing rules:
- One-line summary (the first paragraph) is required — that's what decision menus and tool_use specs read.
- The body may be as detailed as is useful for code readers — including describing what each LLM call does.
- Do not write filler ("You are a helpful assistant", "Complete the task").
- A detailed docstring does NOT eliminate the need for explicit
content=[...]text on each exec call.
content writing rules:
- Each item is a dict like
{"type": "text", "text": ...}or{"type": "image", "path": ...}. - Embed both the instruction and the data for this LLM call. Example:
llm([{"type": "text", "text": ( f"Classify the sentiment of the following text. Reply with exactly one " f"word: positive, negative, or neutral.\n\nText:\n{text}" )}])
- Define the exact output format inline; don't rely on the docstring or external context to convey it.
Historically the same parameter description could appear both in the docstring Args: section and in @agentic_function(input={...}). This spec makes input= the source-of-truth.
Minimal example:
@agentic_function(input={
"text": {"description": "Text to polish."},
"style": {"description": "Output style.", "options": ["academic", "casual"]},
})
def polish(text: str, style: str, runtime: Runtime) -> str:
"""Polish a text in the given style."""
...Fuller example exercising more metadata features (placeholder, multiline, hidden, mixed types):
@agentic_function(input={
"essay": {
"description": "Essay to review.",
"placeholder": "Paste the essay text here...",
"multiline": True,
},
"rubric_id": {
"description": "Which rubric to apply.",
"options": ["ielts_writing", "toefl_writing", "gre_argument"],
},
"max_score": {
"description": "Upper bound for the numeric score.",
},
"show_rubric_internals": {
"description": "Include rubric breakdown in the output.",
},
"session_id": {
# System-supplied; LLM does not see this.
"hidden": True,
},
})
def review_essay(
essay: str,
rubric_id: str,
max_score: int,
show_rubric_internals: bool,
session_id: str, # filled by Python via context, not LLM
runtime: Runtime, # auto-injected
) -> dict:
"""Score an essay against a named rubric and return a structured report."""
...The docstring keeps only the one-line summary — no Args: section and no Returns: section. If the meaning of return-value fields matters to downstream LLM-driven calls, encode it with a structured return type (e.g. TypedDict or a dataclass) so consumers can introspect it; do not duplicate the schema in a docstring paragraph.
@agentic_function
def polish(text: str, style: str, runtime: Runtime) -> str:
"""Polish a text in the given style.
Args:
text: Text to polish.
style: Output style.
runtime: LLM runtime.
Returns:
Polished text.
"""
...Legacy functions still run, but the Args: section is dead text — no docstring-Args: parser exists anywhere in the framework (see §10). _build_agentic_tool_spec falls back as follows:
fn.input_meta[name]["description"]
↓ not found
fn.input_meta[name]["placeholder"] (rendered as "e.g. {placeholder}")
↓ not found
no description; only parameter name + type
render_options reads only description and options from input_meta; parameters without them get name + type only.
@agentic_function is not required for the decision-menu path: plain callables can be passed to render_options (and as decision.make / choices= options), but they carry less metadata. They can not be passed to runtime.exec(tools=[...]) — _adapt_tools raises TypeError for anything that is not an @agentic_function, a {"spec", "execute"} dict, or an object with .spec/.execute.
| Field | Decorated | Undecorated |
|---|---|---|
| Parameter description | from input= |
empty (no docstring fallback exists) |
| Parameter enum | input={"x": {"options": [...]}} |
none |
| Hidden flag | input={"x": {"hidden": True}} |
only _AUTO_PARAMS names (runtime, etc.) are auto-hidden |
| Recorded in session DAG | yes | no |
Use plain callables for: simple decision branches with few parameters, no WebUI surface, and no need for rich menu hints. Upgrade to @agentic_function as soon as you need enums, hidden parameters, or detailed descriptions.
The following parameter names are reserved by convention: if a function's signature includes any of them, the framework auto-injects them at call time. The LLM does not see them and does not need to fill them.
| Parameter name | Meaning |
|---|---|
runtime |
The current Runtime instance |
exec_runtime |
The runtime used for execution (multi-runtime setups) |
review_runtime |
The runtime used for review (multi-runtime setups) |
These names live in two constants in two files: _RUNTIME_PARAMS in agentic_programming/function.py (runtime injection + filtering from tool specs) and _AUTO_PARAMS in agentic_programming/decision.py (hiding from decision menus + dispatch). To add a new auto-injected name, edit both. Do not mark them per-callsite via input={"x": {"hidden": True}}.
The WebUI does not introspect live Python objects: it AST/regex-parses the source files (openprogram/webui/_functions.py). Consequently input= must be written as a literal dict in the decorator call to show up in the form — metadata built dynamically (variables, helper calls) is invisible to the WebUI.
The WebUI form renders each parameter by the following rules (implemented in apps/web/components/chat/composer/modes/fn-form/fn-form.tsx and fn-form-fields.tsx). When authoring @agentic_function(input={...}), use this table to predict what kind of input control your function will produce:
| Parameter trait | WebUI control |
|---|---|
bool type |
Yes / No toggle buttons (not a checkbox) |
str type, multiline not set |
Defaults to a textarea (multiline=True is implied) |
str type, multiline: False |
Single-line <input> |
Non-str non-bool, multiline not set |
Single-line <input> |
options: ["a", "b", ...] |
Clickable chips plus a free-form text input (user can pick a preset OR type a custom value) |
options_from: "functions" |
<select> dropdown populated from currently-registered non-builtin / non-meta functions |
hidden: True |
Omitted from the form entirely |
Has a Python default and no explicit placeholder |
the raw default value becomes the placeholder ghost text (no "default: " prefix); pressing Tab in the empty field promotes it into the actual value; defaults of None or starting with _ are suppressed |
Parameter description renders as a small label next to the parameter name; the type annotation is shown on the same row, and non-required parameters get an "optional" marker (there is no "required" marker).
The following fields were discussed and are intentionally not introduced now, because no consumer exists yet:
| Field | Intended use | Why deferred |
|---|---|---|
effects=["fs", "net", "state"] |
Mark function side effects for permission gating / dangerous-op blocking | A gating surface already exists on the decorator (requires_approval, check_fn, unsafe_in, available_if, defer); a declarative effects= field on top of it remains future work |
permissions=[...] |
Required permission scope before calling | Same as above |
idempotent=True |
Whether the function can be safely retried | No auto-retry component exists |
latency_hint="long" |
Scheduler hint | No scheduler exists |
cost_hint=... |
Quota management | Same as above |
Revisit this section once a real upstream consumer appears.
The recommended order for migrating existing code to this spec; not strictly required:
- Add a shared helper
_parse_docstring_args(fn) -> dict[name, description]inagentic_programming/function.py _build_agentic_tool_speccalls this helper as a fallback (currently the spec does not read docstringArgs:at all)render_optionscalls the same helper for the same fallback- Existing
@agentic_functionfunctions do not need immediate rewriting; convert opportunistically when you touch them
When writing a new @agentic_function:
- First paragraph of the docstring is a one-line summary (state what the function does / when to pick it, directly)
- No
Args:orReturns:sections in the docstring (unless you specifically want them for debugging / reading) - Every LLM-visible parameter has a
descriptionininput= - Enum parameters use
optionsininput=(not buried in the description text) - System-filled parameters (DB session, current user, etc.) are marked
hidden: True - Framework auto-injected parameters (
runtime, etc.) need no annotation; the framework detects them - The function name is clear (
fn.__name__is what the LLM sees as the action name) - No role-play, no empty directives, no metaphors in the docstring
openprogram/agentic_programming/function.py—@agentic_functiondecorator implementationopenprogram/agentic_programming/decision.py— options-menu rendering, reply parsing, and the next-step decision primitive (decision.make,render_options,parse_args,DecisionError)docs/capabilities/agentic-programming/writing-functions/agentic-function.md— decorator usage guidedocs/reference/design/function/calling-unification.md— function/tool calling frameworkdocs/capabilities/agentic-programming/choosing-the-next-step/tool-calling.md— per-turn native tool-use loop mechanics