Agentic 函数防自递归机制

现状:从「deny 屏蔽工具」改成「处境引导 + 递归深度上限兜底」(commit 1f6f5fce)。
本文档基于真实代码逐条对应 file:line,可照着核对。
相关代码:function.py · runtime.py · 测试 test_self_recursion_guard.py(8 用例)
1 · 问题 2 · 设计理念 3 · 三机制协同 4 · 代码位置表 5 · 行为契约 6 · 新旧对比 7 · 已知局限

1. 问题:agentic 函数为什么会自递归

一个 agentic 函数(如 wiki_agent)的函数体里跑一个内层 agent loop——通过 runtime.exec(content=[task]) 驱动内层 LLM。两个诱因叠加:

  1. 默认 toolset = full,含函数自己。runtime.exec(content=...) 不传 tools=/toolset=,解析成 DEFAULT_TOOLSET = "full"(runtime.py:1467),而 full 工具集列出了所有 harness 入口本身(wiki_agent/research_agent/gui_agent…)。内层模型的工具列表里有它正在执行的那个函数。
  2. 模型看到 docstring 匹配任务,误以为该调。 模型看到 wiki_agent 的描述("Maintain a wiki vault — route to ingest…")正好匹配当前任务 → 调自己 → 进去又是裸 exec、又看到自己 → 无限递归。

实战根因:7 层嵌套实例(4d76→0c07→0964→c6f9→f1c9→4379→8746→100c),记录于 TODO-doc-code-gaps.md §1

2. 设计理念:为什么用「引导」而非「deny」

让模型理解自己的处境、自主判断不调,而不是强行从工具列表里屏蔽掉它自己。

旧 deny 方案(wrapper 把函数自己名字推进 _current_tool_policy["deny"],使内层模型看不到自己)的问题:

新方案把「不调自己」变成模型能理解的一条处境信息(你正在 X 体内,调 X = 无限递归),模型据此自主不调;同时保留一个与模型判断无关的深度上限作为止损兜底。

3. 三个机制怎么协同

处境提示 —— 防「发生」

_situational_prefix(fn_name, fn_doc)(runtime.py:321-341)生成一段英文处境提示:

[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.

fn_doc 非空时,docstring 被降级置后(runtime.py:339-340)——诱因(docstring 描述)不再盖过警告。

注入到哪:user turn 开头的 text block,不进 system 前缀。

为什么放 user turn、不放 system: 决策6 要求项目共用一个统一恒定的 system prompt 以最大化 KV 缓存命中——前缀一变,长上下文后全不命中、成本爆。处境提示是逐函数/逐调用点变化的,放进 system 会破坏前缀恒定。放 user turn 开头既能让模型看到,又不碰 system 前缀。

deny —— 工具列表含函数自己,靠引导不靠屏蔽

wrapper 不再把函数自己名字推进 _current_tool_policy["deny"]。内层模型的工具列表里仍然能看到它自己,靠处境提示让模型自主不调。

_current_tool_policy其它用途保留未动source/allow/toolset/unattended deny(runtime.py:1451-1483)。删的只是「把函数自己名字注入 deny」这一处。

兜底 深度上限 —— 止损安全网

正常调用永不触及上限:处境提示先拦住「发生」,深度计数只在模型无视引导、连续 re-enter 同名函数 5 层后才触发。

三者定位:处境提示 = 防发生 · 删 deny = 配套(工具可见,引导才有对象) · 深度上限 = 止损安全网。

4. 关键代码位置表

机制代码file:line
深度上限常量_MAX_AGENTIC_RECURSION_DEPTH = 5function.py:48
深度计数 contextvar_recursion_depthfunction.py:49-51
sync:本函数名getattr(self,"tool_name",None) or fn.__name__function.py:964
sync:超限抛错if cur >= MAX: raise RecursionErrorfunction.py:967-972
sync:+1 写回set({**prev, name: cur+1})function.py:973-976
sync:finally 复位reset(token)function.py:989
async:本函数名 / 抛错 / +1 / 复位同上function.py:852 / 855-860 / 861-864 / 877
处境提示文案_situational_prefix(fn_name, fn_doc)runtime.py:321-341
注入(DAG 路径)frame_prefix_blocks → _build_pi_contextruntime.py:578-587, 597
注入(standalone 回退)取最深名 → 拼 content 前runtime.py:1518-1532
system 前缀(不含提示)self.system + _skills_block()runtime.py:1535-1539
_current_tool_policy 其它用途deny/source/allow/toolset 解析runtime.py:1451-1483

5. 行为契约(从测试提炼)

来自 tests/agentic_programming/test_self_recursion_guard.py · 8 passed

#契约测试
1处境提示含函数名、"do NOT call it"、"recursion",docstring 降级到末尾test_situational_prefix_warns_against_self_call
2空 docstring 时不追加 "This function's job",提示仍含函数名test_situational_prefix_handles_empty_doc
3函数自己的名字不再进 deny(self-deny 删干净)test_self_name_NOT_denied_during_call
4正常调用一层时本函数名深度 = 1test_depth_increments_during_call
5无脑自调超限抛 RecursionError,进入函数体次数恰为上限值(到上限止住)test_depth_backstop_raises_past_limit
6A→B 不同名独立计数,per-name 不误伤test_distinct_subcalls_not_collateral_damage
7return 后深度复位test_depth_restored_after_return
8抛异常后深度也复位test_depth_restored_after_exception

6. 与旧 deny 方案的对比

维度旧:deny 屏蔽工具新:处境引导 + 深度上限
怎么做函数自己名字推进 deny,内层模型看不到自己工具列表含自己;user turn 注入处境提示让模型自主不调;超 5 层抛错兜底
模型认知不知道「我在 X 内部」,只是 X 不在列表明确知道处境(你在 X 体内、调 X = 递归)
system 前缀缓存不动 system,但「替模型决定」提示放 user turn、不进 system,前缀仍恒定(符合决策6)
失控止损靠屏蔽间接挡(屏蔽失效就无底)显式深度上限 5 层硬止损
直接、无需模型配合模型学会处境判断;符合理念;兜底确定性强
模型学不会处境判断;违背理念;屏蔽失效就裸奔纯引导对弱模型不 100% 可靠(故有深度上限兜底)

7. 已知局限

  1. 纯引导对弱模型 / 长上下文不 100% 可靠。 处境提示是让模型自主判断,弱模型或上下文过长稀释提示时可能仍调自己——所以保留深度上限作为确定性兜底。
  2. 跨函数环(A→B→A 交替)第一版未覆盖。 深度上限按同名计数,只挡直接自递归(A→A→A…)。A→B→A→B 交替环里任一名都不到上限。整条调用链识别是增强项,未做。
  3. 旧 deny 实现其实也只挡直接自递归、不挡跨函数环。 旧 deny 把「当前函数自己」推进 deny,B 仍可被调、B 里再调 A 也不在 B 的 deny 里。所以新方案在「跨函数环」这点上不是回退——两版都只防直接自递归,跨链识别是共同待办增强。

关联文档:dag/overview.md 决策6(统一 system 前缀约束) · TODO-doc-code-gaps.md §1(7 层嵌套根因)