Self-Recursion Guard for Agentic Functions#
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/agentic_programming/test_self_recursion_guard.py(8 cases)
1. The problem: why agentic functions self-recurse#
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.functions.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.
2. Design philosophy: why "guidance" instead of "deny"#
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.
3. How the three mechanisms work together#
(Primary) Situational prompt — prevents it from "happening"#
_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.
No self-deny — the tool list includes the function itself#
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.
(Backstop) Depth ceiling — a loss-limiting safety net#
_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).
4. Key code-location table#
| 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 |
5. Behavioral contract (distilled from the tests)#
From tests/agentic_programming/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.
6. Comparison with the deny approach#
| 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 |
7. Known limitations#
- 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.
Related documents#
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.