瀏覽代碼

fix(fc): FC schema 覆盖全部工具(含注入的 KB/Memory) + research67/data67 切 qwen-FC

实跑 research67(切 qwen 文本后)暴露:ReadFile 连续 8 次 "missing file_path" 被封锁——
qwen 文本路径(react-over-text)读文件参数漂移。bench 用的 literature-mapper 是生成型
(只写不读)所以没暴露读路径漂移。FC 从结构上根治(schema 锁死 file_path)。

但 FC 还有缺口:_tools_json_schema 只覆盖有 schema 类的工具,research67 的 7 个平台注入
工具(KBSearch/MemoryRecall/DocGen/ChunkSplit/KB*)无 schema → 开 FC 会丢。修:
- _tools_json_schema 加 tool_names 参(传编译后 tools.keys() 覆盖注入工具);无 schema 的
  给宽松对象(additionalProperties),工具不再消失。FC 门控改传 list(tools.keys())。
- research67 v4→v5、data67 v3→v4:nativeToolCalls=True(qwen-plus + FC)。

回归 test_function_calling(16,+宽松 schema)+ lambdagent 614 全绿。gated 重启已生效。
教训:bench 任务要覆盖"读"路径,否则漏掉 react-over-text 的读参数漂移。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
kenny67nju 2 月之前
父節點
當前提交
b3c51c0e68

+ 29 - 22
lambdagent/src/lambdagent/fromconfig/compiler.py

@@ -733,16 +733,20 @@ def _json_type_of(annotation) -> str:
     return _PY_TO_JSON_TYPE.get(annotation, "string")
 
 
-def _tools_json_schema(cfg: Dict) -> list:
-    """从工具 schema 类的 __init__ 注解生成 OpenAI function-calling tools 规格,
-    供原生 tool_calls 用(替代 _generate_tool_schema_docs 的文本文档)。
-    见 docs/NATIVE_FUNCTION_CALLING_DESIGN.md 阶段 1。"""
+def _tools_json_schema(cfg: Dict, tool_names=None) -> list:
+    """从工具 schema 类的 __init__ 注解生成 OpenAI function-calling tools 规格。
+    见 docs/NATIVE_FUNCTION_CALLING_DESIGN.md。
+    覆盖 **agent 的全部工具**:有 schema 类的内省;**无 schema 的(平台注入的 KB/Memory/
+    DocGen 等)给宽松对象**(additionalProperties)——否则这些工具在 FC 下消失,工具丰富的
+    agent(如 research67)开 FC 就用不了 KBSearch。tool_names 传编译后的 tools.keys() 时
+    能覆盖注入工具;不传则退回 cfg.mcp.localTools。"""
     import inspect
     try:
         from lambdagent.builtin_tools.registry import BUILTIN_TOOLS
     except ImportError:
-        return []
-    names = (cfg.get("mcp", {}) or {}).get("localTools", []) or []
+        BUILTIN_TOOLS = {}
+    names = tool_names if tool_names is not None else \
+        ((cfg.get("mcp", {}) or {}).get("localTools", []) or [])
     out = []
     for name in names:
         if name == "terminate":
@@ -755,23 +759,26 @@ def _tools_json_schema(cfg: Dict) -> list:
             continue
         tool = BUILTIN_TOOLS.get(name)
         schema_cls = getattr(tool, "schema", None) if tool else None
-        if not schema_cls:
-            continue
-        try:
-            sig = inspect.signature(schema_cls.__init__)
-        except (TypeError, ValueError):
-            continue
-        props, required = {}, []
-        for pn, p in sig.parameters.items():
-            if pn == "self":
+        if schema_cls:
+            try:
+                sig = inspect.signature(schema_cls.__init__)
+                props, required = {}, []
+                for pn, p in sig.parameters.items():
+                    if pn == "self":
+                        continue
+                    props[pn] = {"type": _json_type_of(p.annotation)}
+                    if p.default is inspect.Parameter.empty:
+                        required.append(pn)
+                out.append({"type": "function", "function": {
+                    "name": name, "description": getattr(tool, "description", "") or name,
+                    "parameters": {"type": "object", "properties": props, "required": required}}})
                 continue
-            props[pn] = {"type": _json_type_of(p.annotation)}
-            if p.default is inspect.Parameter.empty:
-                required.append(pn)
+            except (TypeError, ValueError):
+                pass
+        # 无 schema(平台注入工具等)→ 宽松对象,模型可自由传参(总比工具消失好)
         out.append({"type": "function", "function": {
-            "name": name,
-            "description": getattr(tool, "description", "") or name,
-            "parameters": {"type": "object", "properties": props, "required": required}}})
+            "name": name, "description": getattr(tool, "description", "") or name,
+            "parameters": {"type": "object", "properties": {}, "additionalProperties": True}}})
     return out
 
 
@@ -934,7 +941,7 @@ def _compile_react(cfg: Dict, overrides: Dict) -> Term:
             _el = react_cfg.get("enforceLoop", {}) or {}
             fc_term = _FCReactTerm(
                 f"{agent_name}.fc_react", provider=_prov, system_prompt=cfg.get("systemPrompt", ""),
-                tools=tools, tools_schema=_tools_json_schema(cfg),
+                tools=tools, tools_schema=_tools_json_schema(cfg, list(tools.keys())),
                 max_steps=max_steps, tool_timeout=tool_timeout,
                 on_step=overrides.get("on_step"), cancel=_fc_cancel,
                 enforce_tool=_el.get("tool", ""), enforce_min=int(_el.get("minCount", 0) or 0))

+ 12 - 0
lambdagent/tests/test_function_calling.py

@@ -190,3 +190,15 @@ class TestFCTrace(unittest.TestCase):
         _run_fc_loop(prov, "s", "q", {"WriteFile": lambda a: "ok"}, [],
                      max_steps=4, tool_timeout=10, ctx=ctx)
         assert any(e.term_name == "fc:WriteFile" for e in ctx.trace)  # per-tool trace 写了
+
+
+class TestSchemaPermissiveFallback(unittest.TestCase):
+    def test_unknown_tool_gets_permissive_object(self):
+        # 平台注入的工具(无 schema 类)应得宽松对象而非被丢弃
+        specs = _tools_json_schema({}, ["ReadFile", "KBSearch", "MemoryRecall", "terminate"])
+        names = {s["function"]["name"] for s in specs}
+        self.assertEqual(names, {"ReadFile", "KBSearch", "MemoryRecall", "terminate"})
+        kb = next(s["function"] for s in specs if s["function"]["name"] == "KBSearch")
+        self.assertTrue(kb["parameters"].get("additionalProperties"))  # 宽松
+        rf = next(s["function"] for s in specs if s["function"]["name"] == "ReadFile")
+        self.assertIn("file_path", rf["parameters"]["required"])        # 有 schema 的仍精确

+ 74 - 0
teaching/README.md

@@ -0,0 +1,74 @@
+# 教学覆盖层 · 对照卡(机制①)
+
+把"智能体理论 ↔ 平台代码"做成可浏览的三栏对照课件。每张**对照卡**是一个 YAML,
+渲染成一页:**教科书概念 │ 平台源码落点 │ 形式化对象**,并可挂一个真实 run 的 trace。
+
+不改平台内核,纯寄生在现有源码与 run workspace 之上。
+
+## 用法
+
+```bash
+python3 teaching/gen.py            # 生成静态站点 → teaching/site/
+python3 teaching/gen.py --check    # 只校验锚点是否有效(锚点失效则 exit 1,可进 CI)
+open teaching/site/index.html      # 浏览
+```
+
+**关键性质:源码 span 在生成时实时从仓库读取**(不是抄一份),所以代码一改、行号
+一漂,`--check` 立刻报警 —— 对照卡永不与代码脱节。建议把 `gen.py --check` 接进 CI。
+
+## 写一张新对照卡
+
+在 `teaching/cards/` 下加一个 `NN-<slug>.yml`:
+
+```yaml
+id: <唯一 slug,同时是页面文件名>
+module: A            # A/B/C/D,见 gen.py 的 MODULE_NAMES
+week: 2              # 第几周
+title: "..."
+
+concept:             # 📖 教科书概念栏
+  textbook: |        # 概念正文(可多行)
+    ...
+  key_point: "..."   # 一句话要点(黄条高亮)
+
+code:                # 💻 源码栏:可多个锚点,生成时实时读取该 span
+  - file: lambdagent/src/lambdagent/fromconfig/compiler.py   # 仓库相对路径
+    symbol: _compile_react      # 仅作标注,显示用
+    lines: "878-933"            # 实际渲染的行范围,格式 a-b
+    note: "..."                 # 这段代码在讲什么
+
+formal:              # ∑ 形式化栏
+  term: "Loop(ConversationLam ∘ action_parser, cond, max_steps)"
+  effect: "IO[tool] ⊕ Partial"
+  note: "..."
+
+live_demo:           # 🎞 可选:挂一个真实 run,自动算步数/token/抽工具调用
+  run: agentexample/research67/workspace/run_20260520_004928   # 仓库相对 run 目录
+  highlight: "..."   # 这个 run 想让学生看到什么;run 留空则只显示这段文字
+
+related: [tool-native-fc, tool-claude-code-native]   # 可选:相关卡 id,生成超链接
+
+takeaway: |          # 学习目标
+  ...
+```
+
+跑 `python3 teaching/gen.py` 即可。`live_demo.run` 指向不存在的 trace 时页面会显式标注,
+不会静默成功。
+
+## 现有卡片(骨架样例)
+
+| 周 | 卡 | 对照点 |
+|---|---|---|
+| 2 | `tool-react-over-text` | ReAct ↔ `_compile_react`/`ConversationLam` ↔ `action_parser` 部分性 |
+| 2 | `tool-native-fc` | function calling ↔ `_run_fc_loop` ↔ 消除 Partial |
+| 2 | `tool-claude-code-native` | 外包给运行时 ↔ `run_native` ↔ 能力授权 Cap |
+| 8 | `sandbox-effect-cap` | 沙箱 ↔ `check_write_allowed`/`_resolve` ↔ effect 限制 ↾ Cap |
+
+第 2 周三张卡构成旗舰对比(三种工具调用范式);第 8 周这张展示同一套形式化语言
+(effect / Cap)如何复用到安全维度。后续按 14 周大纲继续加卡即可。
+
+## 下一步(对接其他机制)
+
+- `live_demo` 目前挂的是仓库里已有的 react-over-text run。范式②③的对照 run
+  待「双车道对比台」(机制③,包 `evals/bench_providers.py`)统一录制后回填。
+- Trace 解说器(机制②)可在此基础上把 trace 每步标注成概念,作为卡片的深入页。

+ 56 - 0
teaching/cards/01-tool-react-over-text.yml

@@ -0,0 +1,56 @@
+id: tool-react-over-text
+module: A
+week: 2
+title: "工具调用范式①:react-over-text(把工具调用当散文手写 JSON)"
+
+concept:
+  textbook: |
+    ReAct(Yao et al., 2022)让 LLM 在 Thought → Action → Observation 之间交替:
+    模型先"想"(Thought),再发一个"动作"(Action),环境执行后把结果(Observation)
+    回灌,如此循环直到给出答案。
+
+    最朴素的工程实现是 **react-over-text**:不依赖模型原生的工具调用能力,而是要求
+    模型把 Action 以一段约定格式的文本写出来(通常是一段 JSON),平台再用正则把这段
+    文本"抠"出来、自己去执行。模型在这里并不"调用"工具,它只是在"描述"一个调用。
+  key_point: >
+    react-over-text 把工具调用降格成"生成文本 → 正则解析 JSON",是 0 执行 /
+    格式飘移 / path-vs-file_path 不一致的总根源。它适合做 fallback,不该当主车道。
+
+code:
+  - file: lambdagent/src/lambdagent/fromconfig/compiler.py
+    symbol: _compile_react
+    lines: "878-933"
+    note: >
+      文本车道主入口。顶部做门控分叉:react.nativeToolCalls 关闭时(默认),
+      返回基于 ConversationLam 的文本 ReAct 循环,模型输出靠正则解析。
+  - file: lambdagent/src/lambdagent/conversation.py
+    symbol: ConversationLam
+    lines: "60-126"
+    note: >
+      文本车道的"思考"算子。每步把历史 + 最新 observation 拼成 prompt 发给 provider,
+      拿回纯文本,再交给上层正则解析出 action。靠 max_input_chars 兜住上下文膨胀。
+
+formal:
+  term: "Loop(ConversationLam ∘ action_parser, cond, max_steps)"
+  effect: "IO[tool] ⊕ Partial"
+  note: >
+    action_parser 是一个**部分函数**:模型文本不合约定时解析失败(返回空动作或抛错)。
+    这个"部分性(Partial)"正是范式②原生 FC 要从类型上消除的东西——FC 让工具调用
+    成为结构化、类型受控的对象,而非一段可能解析失败的散文。
+
+live_demo:
+  run: agentexample/research67/workspace/run_20260520_004928
+  highlight: >
+    999 步 react_step,工具调用全部以文本形式嵌在每步 output 里(看 "[Step N] X done")。
+    这是 react-over-text 在长任务里空转/重做的典型形态:步数巨大,真正落盘寥寥。
+
+related:
+  - tool-native-fc
+  - tool-claude-code-native
+
+takeaway: |
+  对照三栏后学生应能说清:
+  1. 概念上,react-over-text 仍是合法的 ReAct,只是 Action 用文本承载;
+  2. 代码上,它落在 _compile_react 的"非 nativeToolCalls"分支 + ConversationLam;
+  3. 形式上,它引入了一个部分函数 action_parser,把"解析失败"这一失效模式带进了系统。
+  这就是"为什么需要形式化"的第一个实证:范式②用类型把这个失效模式提前消除。

+ 55 - 0
teaching/cards/02-tool-native-fc.yml

@@ -0,0 +1,55 @@
+id: tool-native-fc
+module: A
+week: 2
+title: "工具调用范式②:原生 function-calling(结构化 tool_calls)"
+
+concept:
+  textbook: |
+    现代 LLM API(OpenAI / Anthropic / 兼容协议)原生支持 function calling:
+    把可用工具以 JSON Schema 形式声明给模型,模型在需要时返回一个**结构化**的
+    tool_calls 对象(工具名 + 已按 schema 校验的参数),而不是一段自由文本。
+
+    平台执行完工具后,以 role=tool 的消息把结果回灌,继续下一轮。整个过程里
+    "调用"是 API 协议的一等公民,参数有结构、可校验,不存在"解析模型散文"这一步。
+  key_point: >
+    原生 FC 把工具调用从"文本 + 正则"提升为"结构化 + schema 校验",从根上消除了
+    格式飘移与 path/file_path 不一致。它应当作主车道,react-over-text 退为 fallback。
+
+code:
+  - file: lambdagent/src/lambdagent/fromconfig/compiler.py
+    symbol: _run_fc_loop
+    lines: "778-878"
+    note: >
+      FC 版 ReAct 循环:chat_with_tools(messages, tools) → 直接执行返回的 tool_calls
+      → 以 role=tool 回灌。复用 _timeout_call 与 StepEvent,带 enforceLoop 计数。
+  - file: lambdagent/src/lambdagent/fromconfig/compiler.py
+    symbol: _tools_json_schema
+    lines: "736-778"
+    note: >
+      从工具 schema 类的 __init__ 注解生成 OpenAI tools 规格。坑:from __future__
+      import annotations 让注解变字符串,需 _json_type_of 按名兜底(int→integer)。
+
+formal:
+  term: "Loop(chat_with_tools ▷ exec_tool_calls, cond, max_steps)"
+  effect: "IO[tool]"
+  note: >
+    相比范式①,这里**没有 Partial**:tool_calls 由 API 按 schema 产出并校验,
+    参数类型在调用边界即被约束,解析失败这一失效模式在类型层被消除。
+    这是"形式化提前拒绝"的具体兑现:坏调用编译/校验期即挡下,而非运行期才暴露。
+
+live_demo:
+  run: ""
+  highlight: >
+    对照实验在「双车道对比台」(机制③,evals/bench_providers.py)录制:同一 golden
+    任务并排跑范式①②③,出"执行次数 / 落盘次数 / 格式飘移率 / token / cost"对照表。
+    已知 live 结果:qwen-plus 走 FC 真返回结构化 tool_call,file_path 不飘。
+
+related:
+  - tool-react-over-text
+  - tool-claude-code-native
+
+takeaway: |
+  与范式①并排看,核心差异是一个 effect 标注:Partial 的有无。
+  学生应能指出:范式①的失效来自"模型写错文本",范式②把这条路堵死在 API 协议层;
+  但要强调 codex 的修正——FC 只治格式/解析类问题,"路径落错(写到别处)"是工具层
+  (_resolve / _sandbox)的事,见范式相关的沙箱卡(week 8)。

+ 58 - 0
teaching/cards/03-tool-claude-code-native.yml

@@ -0,0 +1,58 @@
+id: tool-claude-code-native
+module: A
+week: 2
+title: "工具调用范式③:claude-code 原生车道(把工具调用外包给运行时)"
+
+concept:
+  textbook: |
+    第三种范式不在平台内实现工具循环,而是把整段任务**外包**给一个已经内建了
+    工具执行能力的智能体运行时(这里是 claude-code CLI)。平台只负责:起进程、
+    喂 prompt、把运行时吐出的事件流翻译成自己的 SSE,以及划定它能动的目录与工具集。
+
+    这对应工程上"何时自己写 agent loop、何时复用现成 agent"的取舍:当任务是
+    "在一个文件夹里自由读写、跑命令"的单体助手时,直接调一次成熟运行时,往往优于
+    把它套进自家 react-over-text 封装(那是给"必须派活给子智能体"的 orchestrator 设计的)。
+  key_point: >
+    单体工作区助手套进 react-over-text 全是成本没收益(实测 76 调用 0 执行);
+    正解是开第二条车道:直接原生跑 claude-code,native 工具开、流式事件映射回平台 SSE。
+
+code:
+  - file: lambdagent/src/lambdagent/providers/claude_code_native.py
+    symbol: run_native
+    lines: "127-210"
+    note: >
+      claude -p --output-format stream-json --add-dir <文件夹>
+      --allowedTools Read/Write/Edit/Bash/... --strict-mcp-config
+      --permission-mode bypassPermissions;解析 stream-json 事件映射成现有 SSE。
+  - file: lambdagent/src/lambdagent/providers/claude_code_native.py
+    symbol: pending_tools (idle 看门狗)
+    lines: "44-110"
+    note: >
+      坑:长命令(实验/pdflatex)期间 stream-json 在工具返回前完全静默,会被 idle
+      看门狗误杀。修法:跟踪 pending_tools(tool_use+1 / tool_result-1),idle 仅在
+      pending_tools==0 时生效,在飞工具靠 hard_timeout 兜底。
+
+formal:
+  term: "外包算子 RunNative(cwd, allowedTools) : Task → (Effects, Result)"
+  effect: "IO[fs, shell] ⊗ Cap(allowedTools, cwd)"
+  note: >
+    与①②不同,这里的 effect 不是平台逐步推断的,而是把一整束能力(读写文件、跑 shell)
+    **以能力集 Cap 的形式**授予外部运行时,并用 cwd + allowedTools 划界。形式化关注点
+    从"每步 effect"转为"授权边界是否正确"——这正好引出 week 8 沙箱/能力的讨论。
+
+live_demo:
+  run: ""
+  highlight: >
+    对照实验同样进对比台。已 live 验证:真起 claude -p 在临时目录写出文件,
+    产出 tool_call / tool_result 事件,cost/session 正确。注意路由按 provider 分流——
+    切到 qwen/ollama 必须退回 react 车道(claude -p 只认 sonnet/opus/haiku)。
+
+related:
+  - tool-react-over-text
+  - tool-native-fc
+  - sandbox-effect-cap
+
+takeaway: |
+  三范式并排,学生应能在"自己写 loop 的精细控制"与"外包给成熟运行时的省心"之间
+  做权衡判断,并说清:范式③把 effect 推断换成了能力授权边界(Cap),
+  可靠性问题(静默/卡死/路由)随之从"解析正确性"转移到"进程与授权管理"。

+ 58 - 0
teaching/cards/04-sandbox-effect-cap.yml

@@ -0,0 +1,58 @@
+id: sandbox-effect-cap
+module: C
+week: 8
+title: "硬沙箱:把'写文件'这一 effect 限制在能力边界内"
+
+concept:
+  textbook: |
+    智能体一旦能写文件、跑命令,就具备了改变环境的副作用(side effect)。仅靠
+    prompt 约束("请只在 F 目录内操作")是不可靠的——模型会越界。工程上必须在
+    **工具层**做硬性能力限制(capability confinement):无论模型怎么想,越界的写
+    操作在执行边界被直接拒绝。
+
+    这对应操作系统里的沙箱/权限模型:能力(capability)显式授予且不可逾越,
+    realpath 解析符号链接以防 `..` 或 symlink 逃逸。
+  key_point: >
+    prompt 约束挡不住模型(实测它把 5 次 WriteFile 全写到 F 之外、还跑偏续跑别人的研究);
+    必须工具层硬拦。只拦写不拦读;Bash 靠 CWD + dangerousCommandBlock(shell 无法工具层硬沙箱)。
+
+code:
+  - file: lambdagent/src/lambdagent/builtin_tools/_sandbox.py
+    symbol: check_write_allowed
+    lines: "38-56"
+    note: >
+      模块级 root + 锁。未设 root → 总允许;设了 → realpath 后必须 == root 或落在
+      root/ 之下,否则返回 [SANDBOX_DENIED]。realpath 防 symlink/.. 逃逸。
+  - file: lambdagent/src/lambdagent/builtin_tools/file_tools.py
+    symbol: _resolve
+    lines: "21-35"
+    note: >
+      相对路径必须按会话 CWD(shell_tools._get_cwd)解析,而非服务进程 CWD。
+      配套坑:加任何新文件工具都要走 _resolve,否则读侧目录会和写侧对不上。
+
+formal:
+  term: "Guard(WriteFile, check_write_allowed) ; root = realpath(F)"
+  effect: "State[fs] ↾ Cap(root)   (effect 被限制到能力 root 内)"
+  note: >
+    沙箱在形式上是一个 effect 限制算子:把 WriteFile 的 State[fs] effect 投影
+    (restrict, ↾)到能力集 Cap(root) 之内。越界写不是"运行期再补救",而是被
+    Guard 直接拒绝并把 [SANDBOX_DENIED] 反馈给模型纠正——这正是"形式化提前拒绝"
+    在安全维度的兑现,和范式③的能力授权(Cap)是同一套语言。
+
+live_demo:
+  run: ""
+  highlight: >
+    回归测试 test_sandbox_blocks_writes_outside_root 验证越界写被拒。
+    教学演示:在工作区模式下令 agent 尝试写 F 之外,观察 trace 里的 [SANDBOX_DENIED]
+    与模型随后的自我纠正。
+
+related:
+  - tool-claude-code-native
+  - tool-native-fc
+
+takeaway: |
+  学生应能把三件事连起来:
+  1. 副作用(effect)是危险的来源,所以需要边界;
+  2. 边界必须在工具层强制(prompt 不够),代码落点是 check_write_allowed + _resolve;
+  3. 形式上这是 effect 到能力集的限制(↾ Cap),与范式③的外包能力授权是同一抽象。
+  这张卡把"安全"从一句口号变成一个可指认的 effect 限制算子。

+ 353 - 0
teaching/gen.py

@@ -0,0 +1,353 @@
+#!/usr/bin/env python3
+"""教学覆盖层 · 对照卡静态视图生成器(机制①)。
+
+读 teaching/cards/*.yml,渲染成"概念 │ 源码 │ 形式化"三栏对照网页 + 索引。
+设计原则:
+  - 零额外依赖(stdlib + 仓库已有的 pyyaml);
+  - 源码 span 在生成时**实时从仓库读取**(read_source_span),对照卡永不与代码脱节;
+    锚点失效(文件/行号不存在)会在页面上显式报警,而非静默腐烂;
+  - live_demo 直接挂现有 run 的 trace.json,自动算统计 + 抽工具调用。
+
+用法:  python3 teaching/gen.py            # 生成到 teaching/site/
+       python3 teaching/gen.py --check   # 只校验锚点,不写文件(CI 用)
+"""
+from __future__ import annotations
+
+import html
+import json
+import re
+import sys
+from pathlib import Path
+
+import yaml
+
+HERE = Path(__file__).resolve().parent
+REPO = HERE.parent
+CARDS_DIR = HERE / "cards"
+SITE_DIR = HERE / "site"
+
+MODULE_NAMES = {
+    "A": "A · 智能体即解释器",
+    "B": "B · 状态、记忆与成本",
+    "C": "C · 安全、编排与产物",
+    "D": "D · 可靠性与形式化收口",
+}
+
+# 已知内置工具名,用于从 react-over-text 的文本 trace 里抽"被调用的工具"。
+KNOWN_TOOLS = [
+    "ReadFile", "WriteFile", "EditFile", "ListFiles", "SearchContent",
+    "Bash", "WebSearch", "WebFetch", "DocGen", "ChunkSplit", "KBSearch",
+    "RunTests", "Git", "terminate", "done",
+]
+
+
+# ──────────────────────────── 数据加载 ────────────────────────────
+def load_cards():
+    cards = []
+    for f in sorted(CARDS_DIR.glob("*.yml")):
+        data = yaml.safe_load(f.read_text(encoding="utf-8"))
+        data["_src"] = f.name
+        cards.append(data)
+    cards.sort(key=lambda c: (c.get("week", 99), c.get("id", "")))
+    return cards
+
+
+def read_source_span(file_rel: str, lines: str):
+    """实时从仓库读源码 span。返回 (ok, [(lineno, text)...] | error_msg)。"""
+    p = REPO / file_rel
+    if not p.exists():
+        return False, f"锚点文件不存在: {file_rel}"
+    try:
+        start, end = (int(x) for x in str(lines).split("-"))
+    except ValueError:
+        return False, f"行号格式应为 'a-b',得到: {lines}"
+    all_lines = p.read_text(encoding="utf-8", errors="replace").splitlines()
+    if start < 1 or end > len(all_lines) or start > end:
+        return False, f"行号越界: {lines}(文件共 {len(all_lines)} 行)"
+    return True, [(i, all_lines[i - 1]) for i in range(start, end + 1)]
+
+
+def summarize_trace(run_rel: str):
+    """读 run 的 trace.json,返回统计 dict 或 None。"""
+    if not run_rel:
+        return None
+    run = REPO / run_rel
+    tp = run / "trace.json"
+    if not tp.exists():
+        return {"missing": True, "path": run_rel}
+    try:
+        events = json.loads(tp.read_text(encoding="utf-8", errors="replace"))
+    except Exception as e:  # noqa: BLE001
+        return {"missing": True, "path": run_rel, "error": str(e)}
+    if not isinstance(events, list):
+        events = [events]
+    tools = {}
+    pat = re.compile(r"\[Step \d+\]\s*([A-Za-z_]\w*)")
+    for e in events:
+        text = f"{e.get('output', '')}"
+        for m in pat.findall(text):
+            if m in KNOWN_TOOLS:
+                tools[m] = tools.get(m, 0) + 1
+    # 兜底:若 [Step] 模式没抽到,扫已知工具词
+    if not tools:
+        blob = " ".join(str(e.get("output", "")) for e in events)
+        for t in KNOWN_TOOLS:
+            n = blob.count(t)
+            if n:
+                tools[t] = n
+    cost = {}
+    cp = run / "cost.json"
+    if cp.exists():
+        try:
+            cost = json.loads(cp.read_text(encoding="utf-8"))
+        except Exception:  # noqa: BLE001
+            cost = {}
+    return {
+        "missing": False,
+        "path": run_rel,
+        "events": len(events),
+        "tokens": sum(int(e.get("tokens_used", 0) or 0) for e in events),
+        "duration_ms": sum(float(e.get("duration_ms", 0) or 0) for e in events),
+        "tools": sorted(tools.items(), key=lambda kv: -kv[1]),
+        "status": cost.get("status", ""),
+        "steps": cost.get("steps", len(events)),
+    }
+
+
+# ──────────────────────────── 渲染 ────────────────────────────
+def esc(s):
+    return html.escape(str(s if s is not None else ""))
+
+
+CSS = """
+:root{--fg:#1a1a1a;--bg:#fff;--mut:#666;--line:#e3e3e3;--accent:#3b5bdb;
+--codebg:#f6f8fa;--warn:#b00020;--okbg:#eef4ff;--formalbg:#f3f0ff;--conceptbg:#fbfbf9;}
+*{box-sizing:border-box}
+body{font:15px/1.65 -apple-system,"PingFang SC",Segoe UI,Roboto,sans-serif;
+color:var(--fg);background:var(--bg);margin:0;padding:0}
+a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
+.wrap{max-width:1180px;margin:0 auto;padding:28px 22px 80px}
+header.top{border-bottom:1px solid var(--line);padding-bottom:14px;margin-bottom:22px}
+header.top h1{margin:0 0 4px;font-size:20px}
+.sub{color:var(--mut);font-size:13px}
+.crumbs{font-size:13px;margin-bottom:18px}.crumbs a{color:var(--mut)}
+.badge{display:inline-block;font-size:12px;background:var(--okbg);color:var(--accent);
+border-radius:4px;padding:1px 8px;margin-right:6px}
+h2.ctitle{font-size:19px;margin:6px 0 4px}
+.keypoint{background:#fff8e6;border-left:3px solid #e6a700;padding:10px 14px;
+border-radius:4px;margin:14px 0;font-size:14px}
+.cols{display:grid;grid-template-columns:1fr 1.5fr 1fr;gap:16px;margin:22px 0}
+.col{border:1px solid var(--line);border-radius:8px;padding:14px 16px;min-width:0}
+.col h3{margin:0 0 10px;font-size:13px;letter-spacing:.04em;text-transform:uppercase;color:var(--mut)}
+.col.concept{background:var(--conceptbg)}
+.col.formal{background:var(--formalbg)}
+.col p{margin:0 0 10px;white-space:pre-wrap;font-size:14px}
+.anchor{margin:0 0 16px}
+.anchor .meta{font-size:12px;color:var(--mut);margin-bottom:4px}
+.anchor .meta code{background:var(--codebg);padding:1px 5px;border-radius:3px}
+pre{background:var(--codebg);border:1px solid var(--line);border-radius:6px;
+overflow:auto;margin:0 0 6px;padding:10px 0;font-size:12px;line-height:1.5}
+pre code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;display:block}
+.ln{display:flex}.ln .n{color:#aaa;text-align:right;width:46px;padding:0 10px;
+user-select:none;flex:none}.ln .c{white-space:pre;padding-right:14px}
+.note{font-size:12.5px;color:var(--mut);margin:0 0 14px}
+.warn{color:var(--warn);background:#fff0f0;border:1px solid #ffd0d0;border-radius:6px;
+padding:8px 12px;font-size:13px}
+.kv{font-size:13px}.kv code{background:var(--codebg);padding:1px 5px;border-radius:3px}
+.formal .term{font-family:ui-monospace,Menlo,monospace;background:#fff;border:1px solid var(--line);
+border-radius:5px;padding:8px 10px;font-size:13px;margin-bottom:8px;word-break:break-word}
+.effect{font-family:ui-monospace,Menlo,monospace;color:#7048e8;font-size:13px;margin-bottom:8px}
+.trace{border:1px solid var(--line);border-radius:8px;padding:14px 16px;margin:22px 0;background:#fafafa}
+.trace h3{margin:0 0 10px;font-size:14px}
+.stat{display:inline-block;margin:0 18px 6px 0;font-size:13px}
+.stat b{font-size:17px;color:var(--accent)}
+.toolchip{display:inline-block;background:#eef;border:1px solid #dde;border-radius:12px;
+padding:1px 9px;margin:0 6px 6px 0;font-size:12px}
+.takeaway{background:var(--okbg);border-radius:8px;padding:14px 18px;margin:22px 0;
+white-space:pre-wrap;font-size:14px}
+.rel a{margin-right:14px;font-size:13px}
+table.idx{border-collapse:collapse;width:100%;margin-top:8px}
+table.idx td{border-bottom:1px solid var(--line);padding:9px 8px;vertical-align:top}
+table.idx .wk{color:var(--mut);font-size:13px;width:64px;white-space:nowrap}
+.modhdr{margin:26px 0 4px;font-size:15px;color:var(--accent)}
+footer{margin-top:50px;color:var(--mut);font-size:12px;border-top:1px solid var(--line);padding-top:14px}
+@media(max-width:900px){.cols{grid-template-columns:1fr}}
+"""
+
+
+def render_code_block(file_rel, lines):
+    ok, payload = read_source_span(file_rel, lines)
+    if not ok:
+        return f'<div class="warn">⚠ {esc(payload)}</div>'
+    rows = []
+    for n, text in payload:
+        rows.append(f'<div class="ln"><span class="n">{n}</span>'
+                    f'<span class="c">{esc(text)}</span></div>')
+    return f'<pre><code>{"".join(rows)}</code></pre>'
+
+
+def render_card(card, cards_by_id):
+    cid = card.get("id", "?")
+    title = card.get("title", cid)
+    week = card.get("week", "?")
+    module = card.get("module", "?")
+    concept = card.get("concept", {}) or {}
+    formal = card.get("formal", {}) or {}
+
+    # 概念栏
+    concept_html = f'<p>{esc(concept.get("textbook", ""))}</p>'
+
+    # 源码栏
+    anchors = []
+    for a in card.get("code", []) or []:
+        block = render_code_block(a.get("file", ""), a.get("lines", ""))
+        anchors.append(
+            '<div class="anchor"><div class="meta">'
+            f'<code>{esc(a.get("file",""))}</code> · '
+            f'<b>{esc(a.get("symbol",""))}</b> · L{esc(a.get("lines",""))}'
+            f'</div>{block}'
+            + (f'<div class="note">{esc(a.get("note",""))}</div>' if a.get("note") else "")
+            + "</div>"
+        )
+    code_html = "".join(anchors) or '<div class="note">(无源码锚点)</div>'
+
+    # 形式化栏
+    formal_html = ""
+    if formal.get("term"):
+        formal_html += f'<div class="term">{esc(formal["term"])}</div>'
+    if formal.get("effect"):
+        formal_html += f'<div class="effect">effect: {esc(formal["effect"])}</div>'
+    if formal.get("note"):
+        formal_html += f'<p>{esc(formal["note"])}</p>'
+
+    # trace 面板
+    trace_html = ""
+    ld = card.get("live_demo") or {}
+    summ = summarize_trace(ld.get("run", ""))
+    if summ is not None:
+        if summ.get("missing"):
+            if ld.get("run"):
+                trace_html = (f'<div class="trace"><h3>🎞 Live Demo</h3>'
+                              f'<div class="warn">trace 未找到: {esc(summ["path"])}</div>'
+                              + (f'<p class="note">{esc(ld.get("highlight",""))}</p>' if ld.get("highlight") else "")
+                              + "</div>")
+        else:
+            chips = "".join(f'<span class="toolchip">{esc(t)} ×{n}</span>'
+                            for t, n in summ["tools"][:10]) or '<span class="note">未从文本抽到工具调用</span>'
+            trace_html = (
+                '<div class="trace"><h3>🎞 Live Demo · 真实 run</h3>'
+                f'<div class="note"><code>{esc(summ["path"])}</code> · status={esc(summ["status"])}</div>'
+                f'<div class="stat"><b>{summ["events"]}</b> 步</div>'
+                f'<div class="stat"><b>{summ["tokens"]}</b> tokens</div>'
+                f'<div class="stat"><b>{summ["duration_ms"]/1000:.1f}</b> s</div>'
+                f'<div style="margin-top:8px">{chips}</div>'
+                + (f'<p class="note" style="margin-top:8px">{esc(ld.get("highlight",""))}</p>' if ld.get("highlight") else "")
+                + "</div>"
+            )
+    elif ld.get("highlight"):
+        trace_html = (f'<div class="trace"><h3>🎞 Live Demo</h3>'
+                      f'<p class="note">{esc(ld["highlight"])}</p></div>')
+
+    # 相关卡
+    rel = card.get("related", []) or []
+    rel_links = []
+    for r in rel:
+        rc = cards_by_id.get(r)
+        label = rc.get("title", r) if rc else r
+        if rc:
+            rel_links.append(f'<a href="{r}.html">{esc(label)}</a>')
+        else:
+            rel_links.append(f'<span class="note">{esc(r)}(待建)</span>')
+    rel_html = (f'<p class="rel"><b>相关对照卡:</b> {" ".join(rel_links)}</p>'
+                if rel_links else "")
+
+    keypoint = (f'<div class="keypoint">🎯 {esc(concept.get("key_point",""))}</div>'
+                if concept.get("key_point") else "")
+    takeaway = (f'<div class="takeaway"><b>学习目标 / Takeaway</b>\n{esc(card.get("takeaway",""))}</div>'
+                if card.get("takeaway") else "")
+
+    body = f"""<!doctype html><html lang="zh"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>{esc(title)}</title><style>{CSS}</style></head><body><div class="wrap">
+<div class="crumbs"><a href="index.html">← 对照卡索引</a></div>
+<header class="top">
+<span class="badge">模块 {esc(module)}</span><span class="badge">第 {esc(week)} 周</span>
+<span class="badge">{esc(cid)}</span>
+<h2 class="ctitle">{esc(title)}</h2></header>
+{keypoint}
+<div class="cols">
+  <div class="col concept"><h3>📖 教科书概念</h3>{concept_html}</div>
+  <div class="col code"><h3>💻 平台源码落点</h3>{code_html}</div>
+  <div class="col formal"><h3>∑ 形式化对象</h3>{formal_html}</div>
+</div>
+{trace_html}
+{takeaway}
+{rel_html}
+<footer>对照卡源文件: <code>teaching/cards/{esc(card.get("_src",""))}</code> ·
+源码 span 由 gen.py 在生成时实时读自仓库</footer>
+</div></body></html>"""
+    return body
+
+
+def render_index(cards):
+    by_mod = {}
+    for c in cards:
+        by_mod.setdefault(c.get("module", "?"), []).append(c)
+    rows = []
+    for mod in sorted(by_mod):
+        rows.append(f'<div class="modhdr">{esc(MODULE_NAMES.get(mod, mod))}</div>')
+        rows.append('<table class="idx">')
+        for c in by_mod[mod]:
+            cid = c.get("id")
+            kp = (c.get("concept") or {}).get("key_point", "")
+            rows.append(
+                f'<tr><td class="wk">第 {esc(c.get("week","?"))} 周</td>'
+                f'<td><a href="{esc(cid)}.html"><b>{esc(c.get("title",cid))}</b></a>'
+                f'<div class="note">{esc(kp)}</div></td></tr>'
+            )
+        rows.append("</table>")
+    body = f"""<!doctype html><html lang="zh"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>对照卡索引 · 理论↔代码对应</title><style>{CSS}</style></head><body><div class="wrap">
+<header class="top"><h1>智能体:理论 ↔ 代码 对照卡</h1>
+<div class="sub">每张卡三栏对照:教科书概念 │ 平台源码落点(实时读仓库) │ 形式化对象。
+共 {len(cards)} 张卡。</div></header>
+{"".join(rows)}
+<footer>由 <code>teaching/gen.py</code> 生成 · 卡片源: <code>teaching/cards/*.yml</code></footer>
+</div></body></html>"""
+    return body
+
+
+def main():
+    check_only = "--check" in sys.argv
+    cards = load_cards()
+    cards_by_id = {c.get("id"): c for c in cards}
+
+    # 锚点校验(check 模式 + 正常模式都跑)
+    problems = []
+    for c in cards:
+        for a in c.get("code", []) or []:
+            ok, msg = read_source_span(a.get("file", ""), a.get("lines", ""))
+            if not ok:
+                problems.append(f"[{c.get('id')}] {msg}")
+    if problems:
+        print("⚠ 锚点问题:")
+        for p in problems:
+            print("   -", p)
+    else:
+        print(f"✓ {sum(len(c.get('code',[]) or []) for c in cards)} 个源码锚点全部有效")
+
+    if check_only:
+        sys.exit(1 if problems else 0)
+
+    SITE_DIR.mkdir(exist_ok=True)
+    (SITE_DIR / "index.html").write_text(render_index(cards), encoding="utf-8")
+    for c in cards:
+        (SITE_DIR / f"{c['id']}.html").write_text(
+            render_card(c, cards_by_id), encoding="utf-8")
+    print(f"✓ 生成 {len(cards)+1} 个页面 → {SITE_DIR.relative_to(REPO)}/")
+    print(f"  打开: file://{SITE_DIR}/index.html")
+
+
+if __name__ == "__main__":
+    main()

+ 66 - 0
teaching/site/index.html

@@ -0,0 +1,66 @@
+<!doctype html><html lang="zh"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>对照卡索引 · 理论↔代码对应</title><style>
+:root{--fg:#1a1a1a;--bg:#fff;--mut:#666;--line:#e3e3e3;--accent:#3b5bdb;
+--codebg:#f6f8fa;--warn:#b00020;--okbg:#eef4ff;--formalbg:#f3f0ff;--conceptbg:#fbfbf9;}
+*{box-sizing:border-box}
+body{font:15px/1.65 -apple-system,"PingFang SC",Segoe UI,Roboto,sans-serif;
+color:var(--fg);background:var(--bg);margin:0;padding:0}
+a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
+.wrap{max-width:1180px;margin:0 auto;padding:28px 22px 80px}
+header.top{border-bottom:1px solid var(--line);padding-bottom:14px;margin-bottom:22px}
+header.top h1{margin:0 0 4px;font-size:20px}
+.sub{color:var(--mut);font-size:13px}
+.crumbs{font-size:13px;margin-bottom:18px}.crumbs a{color:var(--mut)}
+.badge{display:inline-block;font-size:12px;background:var(--okbg);color:var(--accent);
+border-radius:4px;padding:1px 8px;margin-right:6px}
+h2.ctitle{font-size:19px;margin:6px 0 4px}
+.keypoint{background:#fff8e6;border-left:3px solid #e6a700;padding:10px 14px;
+border-radius:4px;margin:14px 0;font-size:14px}
+.cols{display:grid;grid-template-columns:1fr 1.5fr 1fr;gap:16px;margin:22px 0}
+.col{border:1px solid var(--line);border-radius:8px;padding:14px 16px;min-width:0}
+.col h3{margin:0 0 10px;font-size:13px;letter-spacing:.04em;text-transform:uppercase;color:var(--mut)}
+.col.concept{background:var(--conceptbg)}
+.col.formal{background:var(--formalbg)}
+.col p{margin:0 0 10px;white-space:pre-wrap;font-size:14px}
+.anchor{margin:0 0 16px}
+.anchor .meta{font-size:12px;color:var(--mut);margin-bottom:4px}
+.anchor .meta code{background:var(--codebg);padding:1px 5px;border-radius:3px}
+pre{background:var(--codebg);border:1px solid var(--line);border-radius:6px;
+overflow:auto;margin:0 0 6px;padding:10px 0;font-size:12px;line-height:1.5}
+pre code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;display:block}
+.ln{display:flex}.ln .n{color:#aaa;text-align:right;width:46px;padding:0 10px;
+user-select:none;flex:none}.ln .c{white-space:pre;padding-right:14px}
+.note{font-size:12.5px;color:var(--mut);margin:0 0 14px}
+.warn{color:var(--warn);background:#fff0f0;border:1px solid #ffd0d0;border-radius:6px;
+padding:8px 12px;font-size:13px}
+.kv{font-size:13px}.kv code{background:var(--codebg);padding:1px 5px;border-radius:3px}
+.formal .term{font-family:ui-monospace,Menlo,monospace;background:#fff;border:1px solid var(--line);
+border-radius:5px;padding:8px 10px;font-size:13px;margin-bottom:8px;word-break:break-word}
+.effect{font-family:ui-monospace,Menlo,monospace;color:#7048e8;font-size:13px;margin-bottom:8px}
+.trace{border:1px solid var(--line);border-radius:8px;padding:14px 16px;margin:22px 0;background:#fafafa}
+.trace h3{margin:0 0 10px;font-size:14px}
+.stat{display:inline-block;margin:0 18px 6px 0;font-size:13px}
+.stat b{font-size:17px;color:var(--accent)}
+.toolchip{display:inline-block;background:#eef;border:1px solid #dde;border-radius:12px;
+padding:1px 9px;margin:0 6px 6px 0;font-size:12px}
+.takeaway{background:var(--okbg);border-radius:8px;padding:14px 18px;margin:22px 0;
+white-space:pre-wrap;font-size:14px}
+.rel a{margin-right:14px;font-size:13px}
+table.idx{border-collapse:collapse;width:100%;margin-top:8px}
+table.idx td{border-bottom:1px solid var(--line);padding:9px 8px;vertical-align:top}
+table.idx .wk{color:var(--mut);font-size:13px;width:64px;white-space:nowrap}
+.modhdr{margin:26px 0 4px;font-size:15px;color:var(--accent)}
+footer{margin-top:50px;color:var(--mut);font-size:12px;border-top:1px solid var(--line);padding-top:14px}
+@media(max-width:900px){.cols{grid-template-columns:1fr}}
+</style></head><body><div class="wrap">
+<header class="top"><h1>智能体:理论 ↔ 代码 对照卡</h1>
+<div class="sub">每张卡三栏对照:教科书概念 │ 平台源码落点(实时读仓库) │ 形式化对象。
+共 4 张卡。</div></header>
+<div class="modhdr">A · 智能体即解释器</div><table class="idx"><tr><td class="wk">第 2 周</td><td><a href="tool-claude-code-native.html"><b>工具调用范式③:claude-code 原生车道(把工具调用外包给运行时)</b></a><div class="note">单体工作区助手套进 react-over-text 全是成本没收益(实测 76 调用 0 执行); 正解是开第二条车道:直接原生跑 claude-code,native 工具开、流式事件映射回平台 SSE。
+</div></td></tr><tr><td class="wk">第 2 周</td><td><a href="tool-native-fc.html"><b>工具调用范式②:原生 function-calling(结构化 tool_calls)</b></a><div class="note">原生 FC 把工具调用从&quot;文本 + 正则&quot;提升为&quot;结构化 + schema 校验&quot;,从根上消除了 格式飘移与 path/file_path 不一致。它应当作主车道,react-over-text 退为 fallback。
+</div></td></tr><tr><td class="wk">第 2 周</td><td><a href="tool-react-over-text.html"><b>工具调用范式①:react-over-text(把工具调用当散文手写 JSON)</b></a><div class="note">react-over-text 把工具调用降格成&quot;生成文本 → 正则解析 JSON&quot;,是 0 执行 / 格式飘移 / path-vs-file_path 不一致的总根源。它适合做 fallback,不该当主车道。
+</div></td></tr></table><div class="modhdr">C · 安全、编排与产物</div><table class="idx"><tr><td class="wk">第 8 周</td><td><a href="sandbox-effect-cap.html"><b>硬沙箱:把&#x27;写文件&#x27;这一 effect 限制在能力边界内</b></a><div class="note">prompt 约束挡不住模型(实测它把 5 次 WriteFile 全写到 F 之外、还跑偏续跑别人的研究); 必须工具层硬拦。只拦写不拦读;Bash 靠 CWD + dangerousCommandBlock(shell 无法工具层硬沙箱)。
+</div></td></tr></table>
+<footer>由 <code>teaching/gen.py</code> 生成 · 卡片源: <code>teaching/cards/*.yml</code></footer>
+</div></body></html>

File diff suppressed because it is too large
+ 72 - 0
teaching/site/sandbox-effect-cap.html


File diff suppressed because it is too large
+ 72 - 0
teaching/site/tool-claude-code-native.html


File diff suppressed because it is too large
+ 71 - 0
teaching/site/tool-native-fc.html


File diff suppressed because it is too large
+ 72 - 0
teaching/site/tool-react-over-text.html


Some files were not shown because too many files changed in this diff