tool-claude-code-native.html 26 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
  1. <!doctype html><html lang="zh"><head><meta charset="utf-8">
  2. <meta name="viewport" content="width=device-width,initial-scale=1">
  3. <title>工具调用范式③:claude-code 原生车道(把工具调用外包给运行时)</title><style>
  4. :root{--fg:#1a1a1a;--bg:#fff;--mut:#666;--line:#e3e3e3;--accent:#3b5bdb;
  5. --codebg:#f6f8fa;--warn:#b00020;--okbg:#eef4ff;--formalbg:#f3f0ff;--conceptbg:#fbfbf9;}
  6. *{box-sizing:border-box}
  7. body{font:15px/1.65 -apple-system,"PingFang SC",Segoe UI,Roboto,sans-serif;
  8. color:var(--fg);background:var(--bg);margin:0;padding:0}
  9. a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
  10. .wrap{max-width:1180px;margin:0 auto;padding:28px 22px 80px}
  11. header.top{border-bottom:1px solid var(--line);padding-bottom:14px;margin-bottom:22px}
  12. header.top h1{margin:0 0 4px;font-size:20px}
  13. .sub{color:var(--mut);font-size:13px}
  14. .crumbs{font-size:13px;margin-bottom:18px}.crumbs a{color:var(--mut)}
  15. .badge{display:inline-block;font-size:12px;background:var(--okbg);color:var(--accent);
  16. border-radius:4px;padding:1px 8px;margin-right:6px}
  17. h2.ctitle{font-size:19px;margin:6px 0 4px}
  18. .keypoint{background:#fff8e6;border-left:3px solid #e6a700;padding:10px 14px;
  19. border-radius:4px;margin:14px 0;font-size:14px}
  20. .cols{display:grid;grid-template-columns:1fr 1.5fr 1fr;gap:16px;margin:22px 0}
  21. .col{border:1px solid var(--line);border-radius:8px;padding:14px 16px;min-width:0}
  22. .col h3{margin:0 0 10px;font-size:13px;letter-spacing:.04em;text-transform:uppercase;color:var(--mut)}
  23. .col.concept{background:var(--conceptbg)}
  24. .col.formal{background:var(--formalbg)}
  25. .col p{margin:0 0 10px;white-space:pre-wrap;font-size:14px}
  26. .anchor{margin:0 0 16px}
  27. .anchor .meta{font-size:12px;color:var(--mut);margin-bottom:4px}
  28. .anchor .meta code{background:var(--codebg);padding:1px 5px;border-radius:3px}
  29. pre{background:var(--codebg);border:1px solid var(--line);border-radius:6px;
  30. overflow:auto;margin:0 0 6px;padding:10px 0;font-size:12px;line-height:1.5}
  31. pre code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;display:block}
  32. .ln{display:flex}.ln .n{color:#aaa;text-align:right;width:46px;padding:0 10px;
  33. user-select:none;flex:none}.ln .c{white-space:pre;padding-right:14px}
  34. .note{font-size:12.5px;color:var(--mut);margin:0 0 14px}
  35. .warn{color:var(--warn);background:#fff0f0;border:1px solid #ffd0d0;border-radius:6px;
  36. padding:8px 12px;font-size:13px}
  37. .kv{font-size:13px}.kv code{background:var(--codebg);padding:1px 5px;border-radius:3px}
  38. .formal .term{font-family:ui-monospace,Menlo,monospace;background:#fff;border:1px solid var(--line);
  39. border-radius:5px;padding:8px 10px;font-size:13px;margin-bottom:8px;word-break:break-word}
  40. .effect{font-family:ui-monospace,Menlo,monospace;color:#7048e8;font-size:13px;margin-bottom:8px}
  41. .trace{border:1px solid var(--line);border-radius:8px;padding:14px 16px;margin:22px 0;background:#fafafa}
  42. .trace h3{margin:0 0 10px;font-size:14px}
  43. .stat{display:inline-block;margin:0 18px 6px 0;font-size:13px}
  44. .stat b{font-size:17px;color:var(--accent)}
  45. .toolchip{display:inline-block;background:#eef;border:1px solid #dde;border-radius:12px;
  46. padding:1px 9px;margin:0 6px 6px 0;font-size:12px}
  47. .takeaway{background:var(--okbg);border-radius:8px;padding:14px 18px;margin:22px 0;
  48. white-space:pre-wrap;font-size:14px}
  49. .rel a{margin-right:14px;font-size:13px}
  50. table.idx{border-collapse:collapse;width:100%;margin-top:8px}
  51. table.idx td{border-bottom:1px solid var(--line);padding:9px 8px;vertical-align:top}
  52. table.idx .wk{color:var(--mut);font-size:13px;width:64px;white-space:nowrap}
  53. .modhdr{margin:26px 0 4px;font-size:15px;color:var(--accent)}
  54. footer{margin-top:50px;color:var(--mut);font-size:12px;border-top:1px solid var(--line);padding-top:14px}
  55. @media(max-width:900px){.cols{grid-template-columns:1fr}}
  56. </style></head><body><div class="wrap">
  57. <div class="crumbs"><a href="index.html">← 对照卡索引</a></div>
  58. <header class="top">
  59. <span class="badge">模块 A</span><span class="badge">第 2 周</span>
  60. <span class="badge">tool-claude-code-native</span>
  61. <h2 class="ctitle">工具调用范式③:claude-code 原生车道(把工具调用外包给运行时)</h2></header>
  62. <div class="keypoint">🎯 单体工作区助手套进 react-over-text 全是成本没收益(实测 76 调用 0 执行); 正解是开第二条车道:直接原生跑 claude-code,native 工具开、流式事件映射回平台 SSE。
  63. </div>
  64. <div class="cols">
  65. <div class="col concept"><h3>📖 教科书概念</h3><p>第三种范式不在平台内实现工具循环,而是把整段任务**外包**给一个已经内建了
  66. 工具执行能力的智能体运行时(这里是 claude-code CLI)。平台只负责:起进程、
  67. 喂 prompt、把运行时吐出的事件流翻译成自己的 SSE,以及划定它能动的目录与工具集。
  68. 这对应工程上&quot;何时自己写 agent loop、何时复用现成 agent&quot;的取舍:当任务是
  69. &quot;在一个文件夹里自由读写、跑命令&quot;的单体助手时,直接调一次成熟运行时,往往优于
  70. 把它套进自家 react-over-text 封装(那是给&quot;必须派活给子智能体&quot;的 orchestrator 设计的)。
  71. </p></div>
  72. <div class="col code"><h3>💻 平台源码落点</h3><div class="anchor"><div class="meta"><code>lambdagent/src/lambdagent/providers/claude_code_native.py</code> · <b>run_native</b> · L127-210</div><pre><code><div class="ln"><span class="n">127</span><span class="c">def run_native(prompt: str, *, cwd: str, model: str = &quot;sonnet&quot;,</span></div><div class="ln"><span class="n">128</span><span class="c"> system_append: str = &quot;&quot;, session_id: Optional[str] = None,</span></div><div class="ln"><span class="n">129</span><span class="c"> on_event: Optional[Callable[[dict], None]] = None,</span></div><div class="ln"><span class="n">130</span><span class="c"> config_extra: Optional[dict] = None,</span></div><div class="ln"><span class="n">131</span><span class="c"> cancel=None) -&gt; NativeResult:</span></div><div class="ln"><span class="n">132</span><span class="c"> &quot;&quot;&quot;在 cwd 里原生跑一次 claude-code,流式回调事件,返回最终结果。</span></div><div class="ln"><span class="n">133</span><span class="c"></span></div><div class="ln"><span class="n">134</span><span class="c"> auth(401)→ 抛 ProviderError(_AUTH_HINT)。卡死/超时 → 抛 ProviderError(retryable)。</span></div><div class="ln"><span class="n">135</span><span class="c"> &quot;&quot;&quot;</span></div><div class="ln"><span class="n">136</span><span class="c"> extra = config_extra or {}</span></div><div class="ln"><span class="n">137</span><span class="c"> claude_bin = _find_working_claude(extra.get(&quot;claude_bin&quot;, &quot;claude&quot;)) or &quot;claude&quot;</span></div><div class="ln"><span class="n">138</span><span class="c"> allowed = extra.get(&quot;native_allowed_tools&quot;) or _DEFAULT_ALLOWED_TOOLS</span></div><div class="ln"><span class="n">139</span><span class="c"> strict_mcp = not bool(extra.get(&quot;native_allow_mcp&quot;, False))</span></div><div class="ln"><span class="n">140</span><span class="c"> cmd = build_cmd(claude_bin, prompt, cwd=cwd, model=model,</span></div><div class="ln"><span class="n">141</span><span class="c"> system_append=system_append, session_id=session_id,</span></div><div class="ln"><span class="n">142</span><span class="c"> allowed_tools=allowed, strict_mcp=strict_mcp)</span></div><div class="ln"><span class="n">143</span><span class="c"></span></div><div class="ln"><span class="n">144</span><span class="c"> first_byte_timeout = float(extra.get(&quot;native_first_byte_timeout&quot;, _DEFAULT_FIRST_BYTE_TIMEOUT_S))</span></div><div class="ln"><span class="n">145</span><span class="c"> idle_timeout = float(extra.get(&quot;native_idle_timeout&quot;, _DEFAULT_IDLE_TIMEOUT_S))</span></div><div class="ln"><span class="n">146</span><span class="c"> hard_timeout = float(extra.get(&quot;native_hard_timeout&quot;, _DEFAULT_HARD_TIMEOUT_S))</span></div><div class="ln"><span class="n">147</span><span class="c"></span></div><div class="ln"><span class="n">148</span><span class="c"> logger.info(&quot;claude-code NATIVE spawn: cwd=%s model=%s resume=%s tools=%s strict_mcp=%s&quot;,</span></div><div class="ln"><span class="n">149</span><span class="c"> cwd, model, bool(session_id), &quot;,&quot;.join(allowed), strict_mcp)</span></div><div class="ln"><span class="n">150</span><span class="c"></span></div><div class="ln"><span class="n">151</span><span class="c"> result = NativeResult()</span></div><div class="ln"><span class="n">152</span><span class="c"> t0 = time.time()</span></div><div class="ln"><span class="n">153</span><span class="c"> proc = subprocess.Popen(</span></div><div class="ln"><span class="n">154</span><span class="c"> cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,</span></div><div class="ln"><span class="n">155</span><span class="c"> bufsize=0, # 二进制非阻塞读,不用行缓冲(bufsize=1 在 binary 模式会告警)</span></div><div class="ln"><span class="n">156</span><span class="c"> )</span></div><div class="ln"><span class="n">157</span><span class="c"> buf = b&quot;&quot;</span></div><div class="ln"><span class="n">158</span><span class="c"> last_byte_at: Optional[float] = None</span></div><div class="ln"><span class="n">159</span><span class="c"> err_chunks: List[bytes] = []</span></div><div class="ln"><span class="n">160</span><span class="c"> saw_result = False</span></div><div class="ln"><span class="n">161</span><span class="c"></span></div><div class="ln"><span class="n">162</span><span class="c"> def _process_line(line: str):</span></div><div class="ln"><span class="n">163</span><span class="c"> nonlocal saw_result</span></div><div class="ln"><span class="n">164</span><span class="c"> line = line.strip()</span></div><div class="ln"><span class="n">165</span><span class="c"> if not line:</span></div><div class="ln"><span class="n">166</span><span class="c"> return</span></div><div class="ln"><span class="n">167</span><span class="c"> try:</span></div><div class="ln"><span class="n">168</span><span class="c"> obj = json.loads(line)</span></div><div class="ln"><span class="n">169</span><span class="c"> except json.JSONDecodeError:</span></div><div class="ln"><span class="n">170</span><span class="c"> return</span></div><div class="ln"><span class="n">171</span><span class="c"> _handle_event(obj, on_event, result)</span></div><div class="ln"><span class="n">172</span><span class="c"> if obj.get(&quot;type&quot;) == &quot;result&quot;:</span></div><div class="ln"><span class="n">173</span><span class="c"> saw_result = True</span></div><div class="ln"><span class="n">174</span><span class="c"></span></div><div class="ln"><span class="n">175</span><span class="c"> try:</span></div><div class="ln"><span class="n">176</span><span class="c"> import fcntl</span></div><div class="ln"><span class="n">177</span><span class="c"> for f in (proc.stdout, proc.stderr):</span></div><div class="ln"><span class="n">178</span><span class="c"> fl = fcntl.fcntl(f, fcntl.F_GETFL)</span></div><div class="ln"><span class="n">179</span><span class="c"> fcntl.fcntl(f, fcntl.F_SETFL, fl | os.O_NONBLOCK)</span></div><div class="ln"><span class="n">180</span><span class="c"></span></div><div class="ln"><span class="n">181</span><span class="c"> while proc.poll() is None:</span></div><div class="ln"><span class="n">182</span><span class="c"> now = time.time()</span></div><div class="ln"><span class="n">183</span><span class="c"> if cancel is not None and cancel.is_cancelled():</span></div><div class="ln"><span class="n">184</span><span class="c"> proc.kill()</span></div><div class="ln"><span class="n">185</span><span class="c"> raise cancel.CancelledRun()</span></div><div class="ln"><span class="n">186</span><span class="c"> if now - t0 &gt; hard_timeout:</span></div><div class="ln"><span class="n">187</span><span class="c"> proc.kill()</span></div><div class="ln"><span class="n">188</span><span class="c"> raise ProviderError(</span></div><div class="ln"><span class="n">189</span><span class="c"> f&quot;claude-code native hard-timeout after {now-t0:.0f}s&quot;, &quot;claude-code&quot;,</span></div><div class="ln"><span class="n">190</span><span class="c"> retryable=True)</span></div><div class="ln"><span class="n">191</span><span class="c"> if last_byte_at is None and now - t0 &gt; first_byte_timeout:</span></div><div class="ln"><span class="n">192</span><span class="c"> proc.kill()</span></div><div class="ln"><span class="n">193</span><span class="c"> raise ProviderError(</span></div><div class="ln"><span class="n">194</span><span class="c"> f&quot;claude-code native first-byte-timeout after {now-t0:.0f}s&quot;, &quot;claude-code&quot;,</span></div><div class="ln"><span class="n">195</span><span class="c"> retryable=True)</span></div><div class="ln"><span class="n">196</span><span class="c"> # idle 只在没有工具在飞时判定。工具在飞(Bash 跑实验/编译可达数分钟,</span></div><div class="ln"><span class="n">197</span><span class="c"> # 期间 stream-json 静默)时不杀,靠上面的 hard_timeout 兜底——否则会把</span></div><div class="ln"><span class="n">198</span><span class="c"> # 正常长跑命令误判成卡死(run_84f6fdb6f11b: idle-timeout after 181s)。</span></div><div class="ln"><span class="n">199</span><span class="c"> if (last_byte_at is not None and result.pending_tools == 0</span></div><div class="ln"><span class="n">200</span><span class="c"> and now - last_byte_at &gt; idle_timeout):</span></div><div class="ln"><span class="n">201</span><span class="c"> proc.kill()</span></div><div class="ln"><span class="n">202</span><span class="c"> raise ProviderError(</span></div><div class="ln"><span class="n">203</span><span class="c"> f&quot;claude-code native idle-timeout after {now-last_byte_at:.0f}s &quot;</span></div><div class="ln"><span class="n">204</span><span class="c"> f&quot;(no tool in flight)&quot;, &quot;claude-code&quot;, retryable=True)</span></div><div class="ln"><span class="n">205</span><span class="c"></span></div><div class="ln"><span class="n">206</span><span class="c"> ready, _, _ = select.select([proc.stdout, proc.stderr], [], [], 0.5)</span></div><div class="ln"><span class="n">207</span><span class="c"> for fobj in ready:</span></div><div class="ln"><span class="n">208</span><span class="c"> try:</span></div><div class="ln"><span class="n">209</span><span class="c"> chunk = fobj.read()</span></div><div class="ln"><span class="n">210</span><span class="c"> except (BlockingIOError, OSError):</span></div></code></pre><div class="note">claude -p --output-format stream-json --add-dir &lt;文件夹&gt; --allowedTools Read/Write/Edit/Bash/... --strict-mcp-config --permission-mode bypassPermissions;解析 stream-json 事件映射成现有 SSE。
  73. </div></div><div class="anchor"><div class="meta"><code>lambdagent/src/lambdagent/providers/claude_code_native.py</code> · <b>pending_tools (idle 看门狗)</b> · L44-110</div><pre><code><div class="ln"><span class="n">44</span><span class="c"> output_tokens: int = 0</span></div><div class="ln"><span class="n">45</span><span class="c"> cache_read_tokens: int = 0</span></div><div class="ln"><span class="n">46</span><span class="c"> cache_creation_tokens: int = 0</span></div><div class="ln"><span class="n">47</span><span class="c"> cost_usd: float = 0.0</span></div><div class="ln"><span class="n">48</span><span class="c"> num_turns: int = 0</span></div><div class="ln"><span class="n">49</span><span class="c"> is_error: bool = False</span></div><div class="ln"><span class="n">50</span><span class="c"> trace: List[dict] = field(default_factory=list)</span></div><div class="ln"><span class="n">51</span><span class="c"> pending_tools: int = 0 # 在飞工具数(tool_use 未等到 tool_result);&gt;0 时禁 idle 超时</span></div><div class="ln"><span class="n">52</span><span class="c"></span></div><div class="ln"><span class="n">53</span><span class="c"></span></div><div class="ln"><span class="n">54</span><span class="c">def build_cmd(claude_bin: str, prompt: str, *, cwd: str, model: str,</span></div><div class="ln"><span class="n">55</span><span class="c"> system_append: str = &quot;&quot;, session_id: Optional[str] = None,</span></div><div class="ln"><span class="n">56</span><span class="c"> allowed_tools: Optional[List[str]] = None,</span></div><div class="ln"><span class="n">57</span><span class="c"> strict_mcp: bool = True) -&gt; List[str]:</span></div><div class="ln"><span class="n">58</span><span class="c"> &quot;&quot;&quot;构造 native claude-code 命令。cwd 由调用方在 spawn 时设置。&quot;&quot;&quot;</span></div><div class="ln"><span class="n">59</span><span class="c"> cmd = [</span></div><div class="ln"><span class="n">60</span><span class="c"> claude_bin, &quot;-p&quot;, prompt,</span></div><div class="ln"><span class="n">61</span><span class="c"> &quot;--output-format&quot;, &quot;stream-json&quot;, &quot;--verbose&quot;,</span></div><div class="ln"><span class="n">62</span><span class="c"> &quot;--model&quot;, model,</span></div><div class="ln"><span class="n">63</span><span class="c"> # native 工具限定在工作文件夹(claude-code 的文件操作 root)。</span></div><div class="ln"><span class="n">64</span><span class="c"> &quot;--add-dir&quot;, cwd,</span></div><div class="ln"><span class="n">65</span><span class="c"> # -p 非交互:必须 bypass,否则工具卡在权限审批等不到回应。</span></div><div class="ln"><span class="n">66</span><span class="c"> &quot;--permission-mode&quot;, &quot;bypassPermissions&quot;,</span></div><div class="ln"><span class="n">67</span><span class="c"> &quot;--allowedTools&quot;, *(allowed_tools or _DEFAULT_ALLOWED_TOOLS),</span></div><div class="ln"><span class="n">68</span><span class="c"> ]</span></div><div class="ln"><span class="n">69</span><span class="c"> if strict_mcp:</span></div><div class="ln"><span class="n">70</span><span class="c"> cmd.append(&quot;--strict-mcp-config&quot;) # 隔离用户 MCP(Gmail/Calendar 等)</span></div><div class="ln"><span class="n">71</span><span class="c"> if system_append:</span></div><div class="ln"><span class="n">72</span><span class="c"> cmd += [&quot;--append-system-prompt&quot;, system_append]</span></div><div class="ln"><span class="n">73</span><span class="c"> if session_id:</span></div><div class="ln"><span class="n">74</span><span class="c"> cmd += [&quot;--resume&quot;, session_id]</span></div><div class="ln"><span class="n">75</span><span class="c"> return cmd</span></div><div class="ln"><span class="n">76</span><span class="c"></span></div><div class="ln"><span class="n">77</span><span class="c"></span></div><div class="ln"><span class="n">78</span><span class="c">def _emit(on_event, etype: str, payload: dict):</span></div><div class="ln"><span class="n">79</span><span class="c"> if on_event is not None:</span></div><div class="ln"><span class="n">80</span><span class="c"> try:</span></div><div class="ln"><span class="n">81</span><span class="c"> on_event({&quot;event&quot;: etype, **payload})</span></div><div class="ln"><span class="n">82</span><span class="c"> except Exception as e: # 回调异常不能拖垮主流程</span></div><div class="ln"><span class="n">83</span><span class="c"> logger.warning(&quot;native on_event(%s) failed: %s&quot;, etype, e)</span></div><div class="ln"><span class="n">84</span><span class="c"></span></div><div class="ln"><span class="n">85</span><span class="c"></span></div><div class="ln"><span class="n">86</span><span class="c">def _handle_event(obj: dict, on_event, result: NativeResult):</span></div><div class="ln"><span class="n">87</span><span class="c"> &quot;&quot;&quot;把一条 claude stream-json 事件映射成平台 SSE 事件 + 累积结果。&quot;&quot;&quot;</span></div><div class="ln"><span class="n">88</span><span class="c"> t = obj.get(&quot;type&quot;)</span></div><div class="ln"><span class="n">89</span><span class="c"> if t == &quot;system&quot; and obj.get(&quot;subtype&quot;) == &quot;init&quot;:</span></div><div class="ln"><span class="n">90</span><span class="c"> result.session_id = obj.get(&quot;session_id&quot;) or result.session_id</span></div><div class="ln"><span class="n">91</span><span class="c"> return</span></div><div class="ln"><span class="n">92</span><span class="c"> if t == &quot;assistant&quot;:</span></div><div class="ln"><span class="n">93</span><span class="c"> for c in (obj.get(&quot;message&quot;, {}) or {}).get(&quot;content&quot;, []) or []:</span></div><div class="ln"><span class="n">94</span><span class="c"> if not isinstance(c, dict):</span></div><div class="ln"><span class="n">95</span><span class="c"> continue</span></div><div class="ln"><span class="n">96</span><span class="c"> if c.get(&quot;type&quot;) == &quot;text&quot; and c.get(&quot;text&quot;):</span></div><div class="ln"><span class="n">97</span><span class="c"> _emit(on_event, &quot;think_chunk&quot;, {&quot;text&quot;: c[&quot;text&quot;]})</span></div><div class="ln"><span class="n">98</span><span class="c"> elif c.get(&quot;type&quot;) == &quot;tool_use&quot;:</span></div><div class="ln"><span class="n">99</span><span class="c"> result.pending_tools += 1 # 工具开飞 → 禁 idle 超时直到结果回来</span></div><div class="ln"><span class="n">100</span><span class="c"> _emit(on_event, &quot;tool_call&quot;,</span></div><div class="ln"><span class="n">101</span><span class="c"> {&quot;tool&quot;: c.get(&quot;name&quot;, &quot;&quot;), &quot;input&quot;: c.get(&quot;input&quot;, {})})</span></div><div class="ln"><span class="n">102</span><span class="c"> result.trace.append({&quot;tool&quot;: c.get(&quot;name&quot;, &quot;&quot;),</span></div><div class="ln"><span class="n">103</span><span class="c"> &quot;input&quot;: str(c.get(&quot;input&quot;, &quot;&quot;))[:300]})</span></div><div class="ln"><span class="n">104</span><span class="c"> return</span></div><div class="ln"><span class="n">105</span><span class="c"> if t == &quot;user&quot;:</span></div><div class="ln"><span class="n">106</span><span class="c"> for c in (obj.get(&quot;message&quot;, {}) or {}).get(&quot;content&quot;, []) or []:</span></div><div class="ln"><span class="n">107</span><span class="c"> if isinstance(c, dict) and c.get(&quot;type&quot;) == &quot;tool_result&quot;:</span></div><div class="ln"><span class="n">108</span><span class="c"> result.pending_tools = max(0, result.pending_tools - 1)</span></div><div class="ln"><span class="n">109</span><span class="c"> out = c.get(&quot;content&quot;, &quot;&quot;)</span></div><div class="ln"><span class="n">110</span><span class="c"> if isinstance(out, list):</span></div></code></pre><div class="note">坑:长命令(实验/pdflatex)期间 stream-json 在工具返回前完全静默,会被 idle 看门狗误杀。修法:跟踪 pending_tools(tool_use+1 / tool_result-1),idle 仅在 pending_tools==0 时生效,在飞工具靠 hard_timeout 兜底。
  74. </div></div></div>
  75. <div class="col formal"><h3>∑ 形式化对象</h3><div class="term">外包算子 RunNative(cwd, allowedTools) : Task → (Effects, Result)</div><div class="effect">effect: IO[fs, shell] ⊗ Cap(allowedTools, cwd)</div><p>与①②不同,这里的 effect 不是平台逐步推断的,而是把一整束能力(读写文件、跑 shell) **以能力集 Cap 的形式**授予外部运行时,并用 cwd + allowedTools 划界。形式化关注点 从&quot;每步 effect&quot;转为&quot;授权边界是否正确&quot;——这正好引出 week 8 沙箱/能力的讨论。
  76. </p></div>
  77. </div>
  78. <div class="trace"><h3>🎞 Live Demo</h3><p class="note">对照实验同样进对比台。已 live 验证:真起 claude -p 在临时目录写出文件, 产出 tool_call / tool_result 事件,cost/session 正确。注意路由按 provider 分流—— 切到 qwen/ollama 必须退回 react 车道(claude -p 只认 sonnet/opus/haiku)。
  79. </p></div>
  80. <div class="takeaway"><b>学习目标 / Takeaway</b>
  81. 三范式并排,学生应能在&quot;自己写 loop 的精细控制&quot;与&quot;外包给成熟运行时的省心&quot;之间
  82. 做权衡判断,并说清:范式③把 effect 推断换成了能力授权边界(Cap),
  83. 可靠性问题(静默/卡死/路由)随之从&quot;解析正确性&quot;转移到&quot;进程与授权管理&quot;。
  84. </div>
  85. <p class="rel"><b>相关对照卡:</b> <a href="tool-react-over-text.html">工具调用范式①:react-over-text(把工具调用当散文手写 JSON)</a> <a href="tool-native-fc.html">工具调用范式②:原生 function-calling(结构化 tool_calls)</a> <a href="sandbox-effect-cap.html">硬沙箱:把&#x27;写文件&#x27;这一 effect 限制在能力边界内</a></p>
  86. <footer>对照卡源文件: <code>teaching/cards/03-tool-claude-code-native.yml</code> ·
  87. 源码 span 由 gen.py 在生成时实时读自仓库</footer>
  88. </div></body></html>