An agentic function guards against calling itself through situational guidance, with a recursion-depth ceiling as a backstop. This document maps to the real code line by line via file:line, so you can check along. Related code:
openprogram/agentic_programming/function.pyopenprogram/agentic_programming/runtime.py- Tests:
tests/unit/programs/test_self_recursion_guard.py(8 cases)
An agentic function (e.g. wiki_agent) runs an inner agent loop in its body — it drives the inner LLM via runtime.exec(content=[task]).
Two triggers compound:
-
The default toolset = full, which includes the function itself. A bare
runtime.exec(content=...)passes notools=/toolset=, so_call_via_providersresolves it toDEFAULT_TOOLSET = "full":openprogram/agentic_programming/runtime.py:1467DEFAULT_TOOLSET = "full"runtime.py:1468-1483theraw_tools is Nonebranch →_resolve_agent_tools(toolset="full", ...)- and the
fulltoolset lists all harness entry points themselves (wiki_agent/research_agent/gui_agent…, seeopenprogram.programs.TOOLSETS["full"]). So the inner model's tool list contains the very function it is executing.
-
The model sees the docstring match the task and mistakenly thinks it should call it. The model sees
wiki_agent's tool description ("Maintain a wiki vault — route to ingest…") match the current task exactly, decides it should route towiki_agent→ calls itself → enters another bare exec, sees itself again → infinite recursion.
A worked root-cause example of 7-level nesting is in docs/reference/design/TODO-doc-code-gaps.md §1; the session log's context_tree there shows the chain 4d76→0c07→0964→c6f9→f1c9→4379→8746→100c.
The model understands its own situation and decides on its own not to call, rather than having the function hidden from its tool list.
The alternative is a deny approach, where the wrapper pushes the function's own name into _current_tool_policy["deny"] so the inner model cannot see itself. Two things argue against it:
- The model never learns situational judgment. It does not know "I'm inside X"; it only sees that X is not in the tool list. In a context where deny does not apply, such as a cross-function cycle, it makes the same mistake.
- The framework decides for the model instead of giving the model enough information to decide correctly itself.
Guidance turns "don't call yourself" into situational information the model can act on: you are inside X, and calling X re-enters where you are. The model then decides on its own not to call. A depth ceiling independent of the model's judgment stays in place as a loss-limiting backstop.
_situational_prefix(fn_name, fn_doc) (runtime.py:321-341) generates an English situational prompt:
[Execution context] You are currently running INSIDE the agentic function `{fn_name}`.
The tool list may include `{fn_name}` itself — do NOT call it. Calling `{fn_name}`
re-enters where you are now and causes infinite recursion. Use lower-level tools
(search / read-write files / run code) to do the work directly.
When fn_doc is non-empty, the docstring is demoted to the end (text += f"\n\nThis function's job: {fn_doc.strip()}", runtime.py:339-340) — the trigger (the docstring description) no longer outweighs the warning.
Where it is injected: the text block at the start of the user turn, not into the system prefix.
- DAG path:
runtime.py:578-587buildsframe_prefix_blocks(readingname+metadata.docfrom the current frame node), thenruntime.py:597_build_pi_context(frame_prefix_blocks + (content or []))— prepended before the current turn'scontent, as the leading block of the current turn's user message. - standalone fallback path (no store):
runtime.py:1518-1532, takes the deepest function name from_recursion_depth(max(_depths, key=_depths.get),runtime.py:1525), calls_situational_prefix(_cur_fn, "")(no doc), and likewise prepends beforecontent(runtime.py:1532). - The system prefix is assembled separately (
runtime.py:1535-1539:self.system+_skills_block()); the situational prompt does not go into system.
Why put it in the user turn, not in system: the whole project shares a unified and constant system prompt (identity + project memory + unified tool list + skills) to maximize KV cache hits (dag/overview.md) — change the prefix and a long context misses entirely afterward, blowing up cost. The situational prompt varies per function, per call site (each function name/docstring differs); putting it in system would break the constant prefix. Putting it at the start of the user turn lets the model see it without touching the system prefix.
The wrapper does not push the function's own name into _current_tool_policy["deny"]. The inner model's tool list still shows the function itself, which is what gives the situational prompt a subject; the prompt makes the model decide on its own not to call.
The other uses of _current_tool_policy are unaffected: source / allow / toolset / unattended deny. See runtime.py:1451-1458 — policy.get("deny") is in use, merged with unattended's denied_ask_tools (runtime.py:1457); source/allow/toolset take effect at runtime.py:1469/1479-1482. The only thing absent is injecting the function's own name into deny.
_MAX_AGENTIC_RECURSION_DEPTH = 5(function.py:48)._recursion_depthis aContextVar[Optional[dict]](function.py:49-51) holding the current nesting depth per function name{name: depth}.- On entering the wrapper: take this function's name (
getattr(self, "tool_name", None) or fn.__name__, syncfunction.py:964, asyncfunction.py:852), read the current depth, raiseRecursionErrorif over the limit, otherwise +1 and write it back (saving the token):- sync:
function.py:964-976 - async:
function.py:852-864
- sync:
- Exact raise condition:
_cur_depth >= _MAX_AGENTIC_RECURSION_DEPTH(i.e. raise when already at level 5 and about to enter level 6). Message:f"agentic function {name} exceeded max nesting depth {5} — possible runaway recursion"(syncfunction.py:967-972, asyncfunction.py:855-860). finallyreset:_recursion_depth.reset(token)(syncfunction.py:989, asyncfunction.py:877) — reset on both return and exception.
Normal calls never reach the ceiling: the situational prompt stops it from "happening" first; the depth counter only fires after the model ignores the guidance and re-enters the same-named function 5 levels in a row.
Roles of the three: situational prompt = prevent occurrence (let the model decide not to call); no self-deny = the complement (the tool is visible, so the guidance has a subject); depth ceiling = loss-limiting safety net (don't burn infinite tokens when the model goes out of control).
| Mechanism | Code | file:line |
|---|---|---|
| Depth-ceiling constant | _MAX_AGENTIC_RECURSION_DEPTH = 5 |
function.py:48 |
| Depth-counter contextvar | _recursion_depth |
function.py:49-51 |
| sync wrapper: this function's name | getattr(self,"tool_name",None) or fn.__name__ |
function.py:964 |
| sync wrapper: raise when over limit | if _cur_depth >= MAX: raise RecursionError |
function.py:967-972 |
| sync wrapper: +1 write-back | _recursion_depth.set({**prev, name: cur+1}) |
function.py:973-976 |
| sync wrapper: finally reset | _recursion_depth.reset(token) |
function.py:989 |
| async wrapper: this function's name | same as above | function.py:852 |
| async wrapper: raise when over limit | same as above | function.py:855-860 |
| async wrapper: +1 write-back | same as above | function.py:861-864 |
| async wrapper: finally reset | same as above | function.py:877 |
| Situational-prompt text | _situational_prefix(fn_name, fn_doc) |
runtime.py:321-341 |
| Situational-prompt injection (DAG path) | frame_prefix_blocks → _build_pi_context(prefix + content) |
runtime.py:578-587, 597 |
| Situational-prompt injection (standalone fallback) | take deepest name from _recursion_depth → prepend before content |
runtime.py:1518-1532 |
| System prefix assembled separately (no prompt) | self.system + _skills_block() |
runtime.py:1535-1539 |
Other uses of _current_tool_policy |
deny/source/allow/toolset resolution | runtime.py:1451-1483 |
From tests/unit/programs/test_self_recursion_guard.py:
| # | Contract | Test |
|---|---|---|
| 1 | The situational prompt contains the function name, contains "do NOT call it", contains "recursion", and the docstring is demoted to the end (recursion appears before the docstring) |
test_situational_prefix_warns_against_self_call |
| 2 | With an empty docstring, "This function's job" is not appended, and the prompt still contains the function name | test_situational_prefix_handles_empty_doc |
| 3 | The function's own name is not put into _current_tool_policy["deny"] |
test_self_name_NOT_denied_during_call |
| 4 | During a normal one-level call, this function's name has depth = 1 (+1 on entry) | test_depth_increments_during_call |
| 5 | Mindless self-calling over the limit raises RecursionError, with the message containing the function name + the limit number; the number of times the function body is entered is exactly _MAX_AGENTIC_RECURSION_DEPTH (stops at the limit, doesn't go deeper) |
test_depth_backstop_raises_past_limit |
| 6 | A→B with different names count independently: B's deep nesting doesn't count toward A's quota, and vice versa (per-name, no collateral damage) | test_distinct_subcalls_not_collateral_damage |
| 7 | After return, the depth resets back to its value before the call | test_depth_restored_after_return |
| 8 | After an exception is raised, the depth also resets | test_depth_restored_after_exception |
Supplement: the test uses a _deny() helper (test:51-53) that reads _current_tool_policy.get(None).get("deny"), and a _depth(name) helper (test:55-56) that reads _recursion_depth.get(None).get(name, 0) — when checking, you can use these two read patterns to confirm the count/deny shape.
| Dimension | Deny: hide the tool | This design: situational guidance + depth ceiling |
|---|---|---|
| How | the wrapper pushes the function's own name into _current_tool_policy["deny"], so the inner model can't see itself |
the tool list includes the function itself; a situational prompt is injected at the start of the user turn so the model decides on its own not to call; over 5 levels raises RecursionError as a backstop |
| Model awareness | doesn't know "I'm inside X", just that X isn't in the list | explicitly knows the situation (you are inside X, calling X = recursion) |
| Effect on the system-prefix cache | deny is at the policy layer and doesn't touch system, but hiding decides for the model | the prompt goes in the user turn, not into system, so the prefix stays constant |
| Loss-limiting on runaway | relies on hiding to block indirectly, and is bottomless if hiding fails | an explicit 5-level depth ceiling as a hard stop |
| Strengths | direct, no model cooperation needed | the model learns situational judgment; deterministic backstop |
| Weaknesses | the model never learns situational judgment; runs wild once hiding doesn't take effect | pure guidance isn't fully reliable for weak models, hence the depth-ceiling backstop |
- Pure guidance isn't fully reliable for weak models / long contexts. The situational prompt asks the model to judge on its own; a weak model, or a context long enough to dilute the prompt, may still call itself — hence the depth ceiling as a deterministic backstop.
- Cross-function cycles (alternating A→B→A) are not covered. The depth ceiling counts per same name (
_recursion_depth[name]) and blocks direct self-recursion (A→A→A…) only. In an alternating cycle like A→B→A→B, A's depth increments to a certain level and B's likewise, so neither name's ceiling fires. Whole-call-chain detection, counting it as a cycle if A appears anywhere on the call chain, is a possible enhancement. - A deny approach would not cover cross-function cycles either. Deny pushes the current function itself into deny; when A runs, deny holds A, but B can still be called, and A called inside B is not in B's deny either. Both approaches guard against direct self-recursion only, so cross-chain detection is an enhancement to either one.
docs/reference/design/runtime/dag/overview.md— the unified-system-prefix constraint; this mechanism puts the situational prompt in the user turn to obey that constraint.docs/reference/design/TODO-doc-code-gaps.md§1 — the 7-level nesting root-cause example.