Forráskód Böngészése

feat: unified LLM Provider + ConversationLam — zero hallucination

Major architecture change: introduce unified LLMProvider interface and
ConversationLam wrapper that eliminates agent hallucination by maintaining
full conversation history across ReAct steps.

New provider system (lambdagent/providers/):
  - LLMProvider base interface: chat(messages) -> str
  - ClaudeCodeProvider: session persistence via --resume (no API key)
  - AnthropicProvider: Anthropic Messages API
  - OpenAICompatProvider: OpenAI/Ollama/DashScope/DeepSeek/Moonshot

ConversationLam (lambdagent/conversation.py):
  - Wraps any LLMProvider with messages history management
  - Sliding window context compression for smaller models
  - Replaces stateless Lam for react agents (from_config path)

Compiler changes (fromconfig/compiler.py):
  - _create_provider(): factory for all provider types
  - _compile_lam(): creates ConversationLam + Provider (fallback to Lam)
  - react_step: session mode (step 0 = full input, step N = observation only)
  - Tool input serialization (dict → JSON string) for schema compatibility
  - Tool parameter docs auto-generated from schema into system prompt
  - Terminate verification via _tool_log

Agent67 changes:
  - run.py --claude flag to force Claude Code backend
  - PersonalAssistant: observations sliding window, max_steps=30
  - ClaudeLam re-exported from lambdagent.providers
  - --strict-mcp-config for MCP tool isolation

Documentation: updated 10+ docs covering provider system, ConversationLam,
hallucination analysis, YAML config, CLI usage, PaaS platform.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
kenny67nju 5 hónapja
szülő
commit
c3afd4e6d3

+ 36 - 1
ENGINEERING_GAP_ANALYSIS.md

@@ -1932,6 +1932,41 @@ A22 (完整 UI) ← 依赖 A09
 - **路线 F** (助手 V1): A11-A16 (依赖路线 E)
 - **路线 G** (助手 V2): A17-A23 (依赖路线 F)
 - **路线 H** (对标 CC): A24 (LSP, 独立) + A25-A26 (交互/规划) + A28-A32 (可与 G 并行)
+- **路线 I** (Provider 统一): L01 → L02 → L03 → L04 → L05 (消除幻觉,统一多模型)
+
+### Phase 6: LLM Provider 统一 + 会话持久化 (消除幻觉)
+
+- [ ] **L01: 实现 `lambdagent/providers/base.py` — LLMProvider 接口** [1d]
+  - 统一接口: `chat(messages: list[dict]) -> str`
+  - 所有 provider 实现同一个 messages-in / text-out 契约
+  - 与现有 `Lam._call_llm()` 解耦
+
+- [ ] **L02: 实现 4 个 Provider** [2d]
+  - `AnthropicProvider`: `client.messages.create(messages=...)`
+  - `OpenAICompatProvider`: 覆盖 OpenAI / Ollama / DashScope / DeepSeek / Moonshot
+  - `ClaudeCodeProvider`: 优先 `--resume`,fallback 到 messages 拼接
+  - 依赖: L01
+
+- [ ] **L03: 实现 `lambdagent/conversation.py` — ConversationLam** [2d]
+  - 维护 `messages: list[dict]` 对话历史
+  - 上下文窗口管理: 滑窗 + 摘要,按 `maxHistoryTokens` 控制
+  - 适配各 provider 的上下文窗口差异 (32K~200K)
+  - `reset_session()` 清空历史
+  - 依赖: L01, L02
+
+- [ ] **L04: `from_config` 编译器集成** [1d]
+  - `_compile_lam()`: 根据 `provider` 创建对应 Provider + ConversationLam
+  - YAML 新增: `model.conversation: true` (默认开启), `model.maxHistoryTokens: 80000`
+  - react_step 简化: 首步传完整输入,后续只传最新工具结果
+  - 移除 `_compress_state` 依赖(ConversationLam 内部管理历史)
+  - 依赖: L03
+
+- [ ] **L05: 端到端验证 + PaaS 集成** [1d]
+  - `agentpaas chat` 统一走 from_config + ConversationLam
+  - 测试: Claude Code / Ollama / DashScope 三种 provider
+  - 验证: 多步任务零幻觉, 工具调用正确, commit+push 成功
+  - 更新 docs/hallucination-root-cause-and-fix.md
+  - 依赖: L04
 
 ---
 
@@ -1962,7 +1997,7 @@ Phase 5 (助手): 个人助手 & 编程助手              [8-12 周]
 └── Tier 4 CC:  A24-A33 (LSP + 规划模式 + Agent Team + 延迟加载) ~20 人天
 ```
 
-**总计: 89 项 TODO (22 工程 + 34 安全 + 33 助手), 已完成 55 项, 预估 ~140 人天**
+**总计: 94 项 TODO (22 工程 + 34 安全 + 33 助手 + 5 Provider统一), 已完成 55 项, 预估 ~147 人天**
 
 ---
 

+ 34 - 0
QUICK_START.md

@@ -4,6 +4,40 @@
 
 ---
 
+## 使用 Claude Code Max Plan(无需 API Key)
+
+如果你已有 Claude Code Max Plan 订阅,可以**零配置**启动 Agent,无需任何 API Key。
+
+### 第 1 步:修改配置
+
+在 `agent-config.yml` 中设置 provider 为 `claude-code`:
+
+```yaml
+model:
+  provider: claude-code
+  name: sonnet
+```
+
+### 第 2 步:启动对话
+
+```bash
+# 交互式 Agent 对话
+python3 -m agentpaas chat lambda
+
+# 或直接运行示例 Agent
+python3 agentexample/agent67/run.py --claude
+```
+
+无需设置环境变量、无需 `.env` 文件、无需 API Key -- 直接使用你的 Claude Code 订阅额度。
+
+> **提示:** `ConversationLam` 会保持完整会话上下文,避免因历史截断导致的幻觉问题。
+
+---
+
+## 使用 API Key 方式(通用)
+
+以下是需要 API Key 的传统启动方式。
+
 ## 前提条件
 
 你只需要以下**其中一种**环境:

+ 35 - 0
README.md

@@ -11,6 +11,41 @@ A Python DSL that models AI agents as Lambda calculus terms. Every agent is a fu
 
 **Stats:** ~11,300 lines of Python | 81 exported symbols | 4 patents filed
 
+## Highlights
+
+- **Unified LLM Provider System** -- 4 providers cover all major backends: Claude Code (via Max Plan), Anthropic API, OpenAI-compatible (DashScope / DeepSeek / Moonshot / Zhipu), and Ollama (local). Switch with one line of YAML.
+- **Zero-Hallucination Conversations** -- `ConversationLam` maintains full session persistence so the model never loses context mid-conversation, eliminating the hallucination caused by truncated history.
+- **Claude Code Max Plan -- No API Key** -- Set `provider: claude-code` and use your Claude Code subscription directly. No API key, no billing dashboard, no environment variables.
+- **YAML-Driven Configuration** -- Define agents, tools, memory, and provider settings in a single YAML file. `from_config()` compiles it into a typed Lambda term tree.
+
+### Quick Start (Simplest Path)
+
+```yaml
+# agent-config.yml
+model:
+  provider: claude-code
+  name: sonnet
+```
+
+```bash
+# Interactive chat
+python3 -m agentpaas chat lambda
+
+# Or run the example agent directly
+python3 agentexample/agent67/run.py --claude
+```
+
+No API key required -- uses your Claude Code Max Plan subscription.
+
+### Provider Comparison
+
+| Provider | Config value | API Key needed | Notes |
+|----------|-------------|----------------|-------|
+| Claude Code (Max Plan) | `claude-code` | No | Uses local Claude Code subscription |
+| Anthropic API | `anthropic` | Yes (`ANTHROPIC_API_KEY`) | Direct Claude API access |
+| OpenAI-compatible | `dashscope` / `openai` / `deepseek` / `zhipu` / `moonshot` | Yes | Any OpenAI-compatible endpoint |
+| Ollama (local) | `ollama` | No | Runs models locally, requires Ollama installed |
+
 ## Features
 
 ### Core Constructs (Lambda Calculus)

+ 19 - 11
agentexample/agent67/agent-config.yml

@@ -16,8 +16,8 @@ description: >
 type: react
 
 model:
-  provider: anthropic
-  name: claude-sonnet-4-20250514
+  provider: claude-code
+  name: sonnet
   temperature: 0.3
   maxTokens: 4096
   fallback:
@@ -65,11 +65,17 @@ systemPrompt: |
   系统: browser, app, system, screenshot (macOS)
   完成: terminate
 
-  ## 工作流程
-  编程任务: ReadFile→CodeSearch→分析→EditFile→RunTests→GitCommit
+  ## 工作流程 (必须严格遵守顺序)
+  编程任务:
+    1. Bash ls 列出项目结构
+    2. Bash cat README.md (或 README) 读取项目需求说明 — **必须先读 README**
+    3. Bash cat 逐个读取需要修改的源文件和测试文件
+    4. 理解需求后,用 WriteFile 写入/修改代码 — **使用 Bash 获取到的绝对路径**
+    5. Bash 运行测试 (mvn test / pytest / npm test)
+    6. 如果测试失败,修复代码并重新测试
+    7. Bash git add + git commit + git push
   调研任务: WebSearch→WebFetch→整理→WriteFile/DocGen
-  知识库任务: KBCreate→KBAdd(导入文档)→KBSearch(检索)
-  文件任务: ListFiles→ReadFile→处理→WriteFile
+  文件任务: Bash ls→Bash cat→处理→WriteFile
 
   ## 规则
   1. **严格一次只输出一个JSON工具调用**,等结果后再下一步
@@ -79,16 +85,18 @@ systemPrompt: |
   5. 需要写文件时直接调用 WriteFile,不要犹豫
   6. 对话模式时直接回复,不调工具
   7. 重要信息用 MemoryStore 保存,下次能记住
+  8. **禁止幻觉**: 分析代码/数据前,必须先用 ListFiles 列出文件,再用 ReadFile 逐个读取核心文件的实际内容。**绝不要根据文件名或目录名猜测代码内容**。只能基于你真正读到的文本进行分析。如果工具调用失败,承认失败并重试,不要编造结果。
+  9. **路径规范**: 使用绝对路径。`~/` 开头的路径用 Bash 执行 `ls` 或 `cat` 来访问,不要直接传给 ListFiles/ReadFile(它们不展开 `~`)。
 
   ## 口头禅
   你叫 lambda 🐑,用中文回复。风格沉稳、专业、偶尔幽默。
   接到任务时说"让我看看"而不是"交给我"。完成时简洁总结。
 
 react:
-  maxSteps: 20
+  maxSteps: 30
   observationEnabled: true
-  toolTimeout: 30
-  thinkTimeout: 120
+  toolTimeout: 60
+  thinkTimeout: 300
 
 memory:
   enabled: true
@@ -159,8 +167,8 @@ mcp:
 guard:
   dangerousCommandBlock: true
   highRiskConfirmation: true
-  maxOutputLength: 5000
-  retry: 1
+  maxOutputLength: 50000
+  retry: 0
   fallback: last
 
 persona:

+ 25 - 5
agentexample/agent67/core/assistant.py

@@ -322,6 +322,7 @@ class PersonalAssistant:
             self.brain = ClaudeLam(
                 "lambda_v2", prompt=SYSTEM_PROMPT,
                 model=model, max_tokens=4096,
+                inject_override=False,
             )
 
         self.ctx = Context()
@@ -356,13 +357,32 @@ class PersonalAssistant:
                 parts.append(f"[{entry['role']}] {content}")
             parts.append("=== 历史结束 ===\n")
 
-        # 工具观察
+        # 工具观察 (sliding window: summarize old, keep recent in full)
         if observations:
+            _KEEP_RECENT = 5
             parts.append("=== 工具执行结果 ===")
-            for obs in observations:
-                parts.append(obs)
+            if len(observations) > _KEEP_RECENT:
+                # Summarize old observations
+                parts.append("[之前的工具调用摘要]")
+                for obs in observations[:-_KEEP_RECENT]:
+                    # Extract tool name and first line of result
+                    lines = obs.split("\n")
+                    tool_line = lines[0] if lines else ""
+                    result_line = lines[1][:120] if len(lines) > 1 else ""
+                    parts.append(f"  {tool_line}: {result_line}...")
+                parts.append("")
+                # Recent observations in full
+                for obs in observations[-_KEEP_RECENT:]:
+                    parts.append(obs)
+            else:
+                for obs in observations:
+                    parts.append(obs)
             parts.append("=== 结果结束 ===\n")
-            parts.append("请基于以上结果决定下一步。你的工具已验证可用。直接输出一个JSON代码块调用下一个工具。绝不要说'工具不可用'或让用户手动操作。")
+            parts.append(
+                "请基于以上结果决定下一步。直接输出一个JSON代码块调用下一个工具。\n"
+                "注意:工具名是 ReadFile(不是Read)、WriteFile(不是Write)、EditFile(不是Edit)、"
+                "Bash(不是bash或shell)、ListFiles(不是Glob或ls)。"
+            )
         else:
             parts.append(f"[用户] {user_msg}")
 
@@ -381,7 +401,7 @@ class PersonalAssistant:
         self.conversation_history.append({"role": "用户", "content": user_msg})
 
         observations = []
-        max_steps = 15
+        max_steps = 30
         final_response = ""
 
         import time as _time

+ 5 - 171
agentexample/agent67/core/claude_lam.py

@@ -1,175 +1,9 @@
 """
-agent67.core.claude_lam — 基于 Claude Code CLI 的 Lam 实现 (v2 流式输出)
+agent67.core.claude_lam — Re-export from canonical location.
 
-不需要 API Key,直接使用 Claude Code Max Plan。
-
-Lambda 语义不变:
-    ClaudeLam("name", "prompt") ≡ λ_D . F_{claude,D}
-    调用 = β-规约 = claude -p 解码
-
-v2: 流式输出 — 用户能实时看到 LLM 的回复过程
+The ClaudeLam implementation has moved to lambdagent.providers.claude_code.
+This file keeps backward compatibility for existing agent67 imports.
 """
-from __future__ import annotations
-
-import subprocess
-import sys
-import threading
-import time
-from typing import Any, Callable, Optional
-
-from pathlib import Path
-PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
-sys.path.insert(0, str(PROJECT_ROOT))
-
-from lambdagent.core import Term, Context
-
-
-class ClaudeLam(Term):
-    """
-    基于 Claude Code CLI 的 Lambda 抽象(流式输出版)。
-
-    Lambda: ClaudeLam("name", "prompt") = λx. claude(prompt, x)
-    """
-
-    def __init__(
-        self,
-        name: str,
-        prompt: str,
-        model: str = "sonnet",
-        max_tokens: int = 4096,
-        output_parser: Callable[[str], Any] | None = None,
-        claude_bin: str = "claude",
-        stream: bool = True,
-    ):
-        super().__init__(name)
-        self.prompt = prompt
-        self.model = model
-        self.max_tokens = max_tokens
-        self.output_parser = output_parser or (lambda x: x)
-        self.claude_bin = claude_bin
-        self.stream = stream
-
-    def apply(self, input: Any, ctx: Context | None = None) -> Any:
-        """β-规约: (λ_D x) → claude -p (prompt + x)"""
-        ctx = ctx or Context()
-        t0 = time.time()
-
-        if self.stream:
-            raw = self._call_claude_stream(str(input))
-        else:
-            raw = self._call_claude(str(input))
-
-        duration = (time.time() - t0) * 1000
-        result = self.output_parser(raw)
-        ctx.log(self._name, self._trace_id, input, result, duration, f"claude-code/{self.model}")
-        return result
-
-    def _call_claude_stream(self, input_text: str) -> str:
-        """
-        流式调用 claude CLI — 实时显示输出。
-        system prompt 通过 --system-prompt 传递,用户输入通过 stdin。
-        """
-        try:
-            cmd = [
-                self.claude_bin,
-                "-p",
-                "--output-format", "text",
-                "--model", self.model,
-                "--system-prompt", self.prompt,
-                "--tools", "",  # 禁用 Claude Code 内置工具,由 agent67 自己管理
-            ]
-
-            proc = subprocess.Popen(
-                cmd,
-                stdin=subprocess.PIPE,
-                stdout=subprocess.PIPE,
-                stderr=subprocess.PIPE,
-                text=True,
-                bufsize=1,
-            )
-
-            # 写入用户输入到 stdin 并关闭(触发处理)
-            proc.stdin.write(input_text)
-            proc.stdin.close()
-
-            # 流式读取 stdout
-            output_chars = []
-            start = time.time()
-            first_token = True
-
-            while True:
-                char = proc.stdout.read(1)
-                if not char:
-                    break
-                if time.time() - start > 180:
-                    proc.kill()
-                    return "[Claude Code 超时] 请求超过 180 秒"
-
-                output_chars.append(char)
-
-                # 实时显示(淡色,和最终回复区分)
-                if first_token:
-                    sys.stdout.write("  🐑 ")
-                    first_token = False
-                sys.stdout.write(char)
-                sys.stdout.flush()
-
-            # 等待进程结束
-            proc.wait(timeout=5)
-
-            if not first_token:
-                sys.stdout.write("\n")
-                sys.stdout.flush()
-
-            if proc.returncode != 0:
-                stderr = proc.stderr.read().strip()
-                if stderr:
-                    return f"[Claude Code 错误] {stderr[:500]}"
-
-            output = "".join(output_chars).strip()
-            return output if output else "[无输出]"
-
-        except FileNotFoundError:
-            return (
-                "[错误] 找不到 claude 命令。"
-                "请确保已安装 Claude Code: npm install -g @anthropic-ai/claude-code"
-            )
-        except Exception as e:
-            return f"[错误] {e}"
-
-    def _call_claude(self, input_text: str) -> str:
-        """非流式调用(fallback)。"""
-        try:
-            cmd = [
-                self.claude_bin,
-                "-p",
-                "--output-format", "text",
-                "--model", self.model,
-                "--system-prompt", self.prompt,
-                "--tools", "",  # 禁用 Claude Code 内置工具
-            ]
-
-            result = subprocess.run(
-                cmd, input=input_text,
-                capture_output=True, text=True, timeout=180,
-            )
-
-            if result.returncode != 0:
-                stderr = result.stderr.strip()
-                if stderr:
-                    return f"[Claude Code 错误] {stderr[:500]}"
-                return "[Claude Code 错误] 未知错误"
-
-            output = result.stdout.strip()
-            return output if output else "[无输出]"
-
-        except subprocess.TimeoutExpired:
-            return "[Claude Code 超时] 请求超过 180 秒"
-        except FileNotFoundError:
-            return "[错误] 找不到 claude 命令。"
-        except Exception as e:
-            return f"[错误] {e}"
+from lambdagent.providers.claude_code import ClaudeLam
 
-    def __rshift__(self, other):
-        from lambdagent.primitives import Compose
-        return Compose(self, other)
+__all__ = ["ClaudeLam"]

+ 9 - 1
agentexample/agent67/run.py

@@ -40,6 +40,8 @@ def main():
     parser = argparse.ArgumentParser(description="🐑 lambda — 个人助理")
     parser.add_argument("--api", action="store_true",
                         help="使用 API Key 模式(而非 Claude Code CLI)")
+    parser.add_argument("--claude", action="store_true",
+                        help="强制使用 Claude Code Max Plan (跳过 Ollama 检测)")
     parser.add_argument("--ollama", action="store_true",
                         help="使用 Ollama 本地模型 (qwen2.5:7b)")
     parser.add_argument("--model", type=str, default=None,
@@ -52,7 +54,13 @@ def main():
     model, use_api, backend = detect_backend()
 
     # 命令行覆盖
-    if args.ollama:
+    if args.claude:
+        from agent67.core.config import BACKEND_CLAUDE_CODE
+        backend = BACKEND_CLAUDE_CODE
+        model = args.model or "sonnet"
+        use_api = False
+        print("✅ 强制使用 Claude Code Max Plan")
+    elif args.ollama:
         backend = BACKEND_OLLAMA
         model = args.model or "qwen2.5:7b"
         use_api = False

+ 212 - 0
agentexample/data67/agent-config.yml

@@ -0,0 +1,212 @@
+# ════════════════════════════════════════════════════════════
+# data67 — 问数智能体 📊
+# ════════════════════════════════════════════════════════════
+#
+# 定位: 数据分析师 — 连接你的数据,回答你的问题,生成图文报告
+# 能力: CSV/Excel/数据库 → 探索 → 分析 → 可视化 → 报告
+# Lambda: λ = Memory(Loop(Analyst >> Route(data_tools)))
+
+agentId: data67-analyst-v1
+name: data67
+description: >
+  问数智能体 — 告诉我你的数据在哪、想分析什么,
+  我帮你探索数据、统计分析、生成图表、输出图文并茂的分析报告。
+  支持 CSV、Excel、SQLite、PostgreSQL、MySQL、数据湖 (Parquet/JSON)。
+
+type: react
+
+model:
+  provider: claude-code
+  name: sonnet
+  temperature: 0.2
+  maxTokens: 8192
+  fallback:
+    - dashscope/qwen-max
+
+systemPrompt: |
+  你是 data67 (📊),一个专业的问数智能体(数据分析师)。
+
+  ## 你的能力
+  - **数据连接**: CSV, Excel (.xlsx), SQLite, PostgreSQL, MySQL, Parquet, JSON, API
+  - **数据探索**: schema 识别、缺失值分析、数据类型推断、样本预览、基础统计
+  - **统计分析**: 描述统计、分组聚合、时序分析、同比/环比、相关性、异常检测
+  - **可视化**: matplotlib + seaborn 生成高质量图表(柱状图、折线图、饼图、热力图、散点图等)
+  - **报告生成**: Markdown → HTML/PDF 图文并茂的分析报告
+
+  ## 工作流程
+
+  ### 第一步: 理解数据源
+  用户会告诉你数据的位置和格式。你需要:
+  1. ListFiles 扫描目录,了解有哪些数据文件
+  2. ReadFile 或 Bash(head) 预览数据结构
+  3. Bash 运行 Python 获取 schema、shape、dtypes、缺失值统计
+  4. 向用户汇报数据概况,确认分析方向
+
+  ### 第二步: 数据探索与分析
+  根据用户的分析任务,用 Bash 执行 Python 脚本:
+  ```python
+  import pandas as pd
+  import matplotlib.pyplot as plt
+  import seaborn as sns
+  # ... 分析代码
+  ```
+  - 每一步分析都保存中间结果
+  - 图表保存为 PNG 到工作目录的 charts/ 子目录
+  - 中文显示: plt.rcParams['font.sans-serif'] = ['Arial Unicode MS', 'SimHei', 'PingFang SC']
+
+  ### 第三步: 生成报告
+  将分析结果整合为 Markdown 报告,包含:
+  - 数据概况(来源、规模、时间范围)
+  - 核心发现(每个发现配一张图表)
+  - 详细分析(表格 + 图表 + 文字解读)
+  - 结论与建议
+  最后用 DocGen 转为 HTML 或 PDF。
+
+  ## 数据库连接模板
+
+  SQLite:
+  ```python
+  import sqlite3
+  conn = sqlite3.connect("path/to/db.sqlite")
+  df = pd.read_sql("SELECT * FROM table LIMIT 5", conn)
+  ```
+
+  PostgreSQL/MySQL:
+  ```python
+  from sqlalchemy import create_engine
+  engine = create_engine("postgresql://user:pass@host:port/db")
+  df = pd.read_sql("SELECT * FROM table LIMIT 5", engine)
+  ```
+
+  数据湖 (Parquet):
+  ```python
+  df = pd.read_parquet("path/to/data.parquet")
+  ```
+
+  ## 图表规范
+  - 尺寸: figsize=(12, 6) 或 (10, 8)
+  - DPI: 150 (报告用)
+  - 中文字体: Arial Unicode MS / PingFang SC
+  - 配色: seaborn "muted" 或 "husl" 调色板
+  - 所有图表必须有标题、坐标轴标签、图例
+  - 金额单位统一标注(万元/亿元/USD)
+  - 保存路径: {workspace}/charts/{编号}_{描述}.png
+
+  ## 报告模板
+  ```markdown
+  # {分析主题} — 数据分析报告
+
+  > 生成时间: {datetime} | 数据源: {source} | 分析师: data67 📊
+
+  ## 1. 数据概况
+  | 指标 | 值 |
+  |------|-----|
+  | 数据来源 | ... |
+  | 记录数 | ... |
+  | 时间范围 | ... |
+  | 字段数 | ... |
+
+  ## 2. 核心发现
+  ### 发现一: {标题}
+  ![图表](charts/01_xxx.png)
+  {解读文字}
+
+  ## 3. 详细分析
+  ...
+
+  ## 4. 结论与建议
+  ...
+  ```
+
+  ## 工具调用
+  通过 JSON 代码块调用工具:
+  ```json
+  {"action": "工具名", "input": {参数}}
+  ```
+
+  可用工具:
+  文件: ReadFile, WriteFile, ListFiles, SearchContent
+  执行: Bash (运行 Python/SQL 脚本,持久 CWD)
+  文档: DocGen (Markdown→HTML/PDF), ChunkSplit, OCR
+  Notebook: NotebookEdit (生成 .ipynb 分析笔记本)
+  Web: WebSearch, WebFetch (查行业基准/参考数据)
+  知识库: KBCreate, KBAdd, KBSearch (存储分析结论供复用)
+  记忆: MemoryStore, MemoryRecall (记住数据源配置和分析偏好)
+  任务: TaskCreate, TaskUpdate, TaskList (管理多步分析进度)
+  完成: terminate
+
+  ## 规则
+  1. **严格一次只输出一个 JSON 工具调用**,等结果后再下一步
+  2. 处理大文件时先用 head/sample 预览,不要一次性读入全部
+  3. 图表中文必须正确显示,不能出现方块乱码
+  4. 金额数据注意单位换算,大数字用万/亿元表示
+  5. 分析前先确认数据编码 (UTF-8/GBK),避免乱码
+  6. 每个分析步骤都要有文字解读,不能只放图表
+  7. 遇到缺失值/异常值要主动说明处理方式
+  8. 数据库密码等敏感信息不要写入报告
+  9. **禁止幻觉**: 分析数据/文件前,必须先用工具读取实际内容。**绝不要根据文件名猜测数据内容**。只能基于你真正读到的数据进行分析。如果工具调用失败,承认失败并重试,不要编造结果。
+  10. **路径规范**: 使用绝对路径。`~/` 开头的路径用 Bash 执行 `ls` 或 `cat` 来访问,不要直接传给 ListFiles/ReadFile(它们不展开 `~`)。
+
+  ## 口头禅
+  你叫 data67 📊,用中文回复。风格专业严谨但不枯燥。
+  接到任务时说"让我先看看数据"。分析完成后给出清晰的结论。
+
+react:
+  maxSteps: 30
+  observationEnabled: true
+  toolTimeout: 60
+  thinkTimeout: 180
+
+memory:
+  enabled: true
+  strategy: local
+  size: 30
+  ttl: 14400
+
+mcp:
+  localTools:
+    # 文件操作
+    - ReadFile
+    - WriteFile
+    - ListFiles
+    - SearchContent
+    # 执行环境
+    - Bash
+    # 文档生成
+    - DocGen
+    - ChunkSplit
+    - OCR
+    # Notebook
+    - NotebookEdit
+    # Web (查参考数据)
+    - WebSearch
+    - WebFetch
+    # 知识库 (存储分析结论)
+    - KBCreate
+    - KBAdd
+    - KBSearch
+    - KBList
+    # 任务管理
+    - TaskCreate
+    - TaskUpdate
+    - TaskList
+    # 记忆 (数据源配置/分析偏好)
+    - MemoryStore
+    - MemoryRecall
+    - MemoryList
+    # 完成
+    - terminate
+  policy:
+    mode: auto
+
+guard:
+  dangerousCommandBlock: true
+  highRiskConfirmation: true
+  maxOutputLength: 10000
+  retry: 2
+  fallback: last
+
+persona:
+  name: data67
+  style: professional-analytical
+  template: data67

+ 65 - 0
agentexample/data67/examples.md

@@ -0,0 +1,65 @@
+# data67 问数智能体 — 使用示例
+
+## 快速启动
+
+```bash
+# 1. 部署智能体
+agentpaas agent create --name data67 --config agentexample/data67/agent-config.yml
+
+# 2. 交互式对话
+agentpaas chat data67
+```
+
+## 示例对话
+
+### 场景一: 分析本地 CSV/Excel
+
+```
+You: 我的数据在 ./workspace/20260307最终/ 目录下,都是综保区进出口贸易数据的CSV。
+     请帮我做一个整体分析报告,重点关注:
+     1. 进出口总额月度趋势
+     2. 主要贸易国家排名
+     3. 企业成长性分析
+     4. 异常波动预警
+
+data67: 让我先看看数据...
+     [自动扫描目录 → 预览每个CSV → 生成schema概况]
+     [逐步分析 → 生成图表 → 整合为图文报告]
+```
+
+### 场景二: 连接数据库
+
+```
+You: 我有一个 PostgreSQL 数据库:
+     host: db.example.com, port: 5432, db: trade_db, user: analyst
+     里面有 orders 表和 customers 表。
+     帮我分析客户的购买频次分布和 RFM 分层。
+
+data67: 让我先看看数据...
+     [连接数据库 → 探索表结构 → SQL查询 → RFM分析 → 可视化 → 报告]
+```
+
+### 场景三: 数据湖 Parquet 文件
+
+```
+You: /data/lake/ 下有按日期分区的 Parquet 文件,是用户行为日志。
+     帮我分析最近30天的用户留存率和转化漏斗。
+
+data67: 让我先看看数据...
+     [读取Parquet → 留存分析 → 漏斗计算 → 图表 → 报告]
+```
+
+## 输出物
+
+每次分析完成后,在工作目录下生成:
+
+```
+workspace/
+├── charts/                    # 所有图表 PNG
+│   ├── 01_月度趋势.png
+│   ├── 02_国家排名.png
+│   └── ...
+├── report.md                  # Markdown 源文件
+├── report.html                # HTML 报告 (图文并茂)
+└── analysis.ipynb             # Jupyter Notebook (可复现)
+```

+ 112 - 9
agentpaas/__main__.py

@@ -318,6 +318,101 @@ def cmd_run(args):
 # Commands: chat (interactive via PaaS)
 # ============================================================
 
+# ── Streaming colors ──
+_DIM = "\033[2m"
+_CYAN = "\033[36m"
+_YELLOW = "\033[33m"
+_GREEN = "\033[32m"
+_RED = "\033[31m"
+_BOLD = "\033[1m"
+_RESET = "\033[0m"
+
+
+def _chat_stream(agent_id, agent_name, input_text):
+    """Stream agent execution via SSE. Returns final output or None if unavailable."""
+    cfg = _load_config()
+    url = "{}/api/v1/agents/{}/run/stream".format(cfg["server"].rstrip("/"), agent_id)
+    headers = {"Content-Type": "application/json"}
+    if cfg.get("api_key"):
+        headers["Authorization"] = "Bearer {}".format(cfg["api_key"])
+
+    body = json.dumps({"input": input_text}, ensure_ascii=False).encode()
+    req = urllib.request.Request(url, data=body, headers=headers, method="POST")
+
+    try:
+        resp = urllib.request.urlopen(req, timeout=1800)  # 30min for long agent runs
+    except urllib.error.HTTPError as e:
+        if e.code == 404:
+            return None  # Endpoint not available, fallback to sync
+        return None
+    except Exception:
+        return None
+
+    final_output = ""
+    step_count = 0
+
+    # Parse SSE stream line by line
+    buffer = ""
+    current_event = ""
+    for raw_line in resp:
+        line = raw_line.decode("utf-8", errors="replace").rstrip("\n").rstrip("\r")
+
+        if line.startswith("event: "):
+            current_event = line[7:]
+        elif line.startswith("data: "):
+            data_str = line[6:]
+            try:
+                data = json.loads(data_str)
+            except json.JSONDecodeError:
+                continue
+
+            step = data.get("step", 0)
+            content = data.get("content", "")
+            tool = data.get("tool", "")
+            duration = data.get("duration_ms", 0)
+
+            if current_event == "think":
+                step_count = step + 1
+                # Show a concise thinking indicator with step number
+                # Truncate thought to first line or 120 chars for readability
+                short = content.split("\n")[0][:120]
+                print("{}  [Step {}] 💭 {}{}".format(_DIM, step + 1, short, _RESET))
+
+            elif current_event == "tool_call":
+                print("{}  [Step {}] 🔧 {}{} {}".format(
+                    _CYAN, step + 1, tool, _RESET, _DIM + content[:80] + _RESET))
+
+            elif current_event == "tool_result":
+                # Show truncated result
+                short = content.replace("\n", " ")[:100]
+                print("{}  [Step {}] ✅ {} → {}{}".format(
+                    _GREEN, step + 1, tool, short, _RESET))
+
+            elif current_event == "error":
+                print("{}  [Step {}] ❌ {}{}".format(_RED, step + 1, content[:200], _RESET))
+
+            elif current_event == "answer":
+                final_output = content
+
+            elif current_event == "done":
+                status = data.get("status", "")
+                if data.get("output"):
+                    final_output = data["output"]
+                steps = data.get("steps", step_count)
+                tokens = data.get("total_tokens", 0)
+                print()
+                print("{}📊 {}: {}{}".format(_BOLD, agent_name, _RESET, final_output))
+                if tokens:
+                    print("{}  [{} steps | {} tokens]{}".format(_DIM, steps, tokens, _RESET))
+
+            current_event = ""
+        elif line == "":
+            current_event = ""
+
+    resp.close()
+    return final_output
+
+
 def cmd_chat(args):
     """Interactive chat with a deployed agent."""
     target = args.target
@@ -400,19 +495,27 @@ def cmd_chat(args):
 
         print()
         t0 = time.time()
-        data = api("POST", "/api/v1/agents/{}/run".format(agent_id), {"input": full_input})
+
+        # Stream execution via SSE for real-time output
+        output = _chat_stream(agent_id, agent_name, full_input)
         elapsed = time.time() - t0
 
-        output = data.get("output", "")
-        usage = data.get("usage", {})
+        if output is None:
+            # Fallback to sync if stream endpoint unavailable
+            data = api("POST", "/api/v1/agents/{}/run".format(agent_id), {"input": full_input})
+            elapsed = time.time() - t0
+            output = data.get("output", "")
+            usage = data.get("usage", {})
+            print("📊 {}: {}".format(agent_name, output))
+            print()
+            print("  [{:.1f}s | {} tokens | {} steps]".format(
+                elapsed, usage.get("total_tokens", 0), usage.get("steps", 0)))
+        else:
+            print()
+            print("  [{:.1f}s]".format(elapsed))
 
-        print("🐑 {}: {}".format(agent_name, output))
         print()
-        print("  [{:.1f}s | {} tokens | {} steps | {}]".format(
-            elapsed, usage.get("total_tokens", 0), usage.get("steps", 0), data.get("run_id", "")))
-        print()
-
-        conversation.append({"role": agent_name, "content": output[:500]})
+        conversation.append({"role": agent_name, "content": (output or "")[:500]})
 
 
 # ============================================================

+ 107 - 3
agentpaas/api/v1/agents.py

@@ -16,6 +16,7 @@ import time
 from typing import Any, Dict, Optional
 
 from fastapi import APIRouter, Depends, HTTPException
+from fastapi.responses import StreamingResponse
 from pydantic import BaseModel, Field
 
 from agentpaas.api.deps import get_tenant, get_database
@@ -309,6 +310,79 @@ async def run_agent(
         raise HTTPException(500, {"error": {"code": "EXECUTION_ERROR", "message": "Agent execution failed", "run_id": run_id}})
 
 
+# ── Streaming Execution (SSE) ──
+
+@router.post("/{agent_id}/run/stream")
+async def run_agent_stream(
+    agent_id: str,
+    req: RunRequest,
+    tenant: TenantContext = Depends(get_tenant),
+    db: Database = Depends(get_database),
+):
+    """
+    SSE stream agent execution — emits step events in real time.
+
+    Events:
+      event: think    — agent's reasoning for this step
+      event: tool_call — tool invocation (name + input)
+      event: tool_result — tool output
+      event: error    — tool error
+      event: answer   — final answer
+      event: done     — execution complete with usage stats
+    """
+    agent = db.fetchone(
+        "SELECT * FROM agents WHERE id = ? AND tenant_id = ? AND status = 'active'",
+        (agent_id, tenant.tenant_id)
+    )
+    if not agent:
+        raise HTTPException(404, {"error": {"code": "AGENT_NOT_FOUND"}})
+
+    version_rec = db.fetchone(
+        "SELECT * FROM agent_versions WHERE agent_id = ? AND version = ?",
+        (agent_id, agent["current_version"])
+    )
+    config = json.loads(version_rec["config"])
+
+    for key, val in req.parameters.items():
+        parts = key.split(".")
+        target = config
+        for p in parts[:-1]:
+            target = target.setdefault(p, {})
+        target[parts[-1]] = val
+
+    import queue
+    event_queue = queue.Queue()
+
+    def generate():
+        import threading
+
+        def _run():
+            try:
+                result, trace_info = _execute_agent(config, req.input, on_step=event_queue.put)
+                event_queue.put({"event": "done", "data": {
+                    "status": "completed", "output": str(result),
+                    "steps": trace_info.get("steps", 0),
+                    "total_tokens": trace_info.get("total_tokens", 0),
+                }})
+            except Exception as e:
+                event_queue.put({"event": "error", "data": {"message": str(e)}})
+                event_queue.put({"event": "done", "data": {"status": "failed"}})
+            event_queue.put(None)  # sentinel
+
+        threading.Thread(target=_run, daemon=True).start()
+
+        while True:
+            item = event_queue.get()
+            if item is None:
+                break
+            evt_type = item.get("event", "message")
+            data = json.dumps(item.get("data", item), ensure_ascii=False)
+            yield f"event: {evt_type}\ndata: {data}\n\n"
+
+    return StreamingResponse(generate(), media_type="text/event-stream",
+                             headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
+
+
 # ── Agent Versions ──
 
 @router.get("/{agent_id}/versions")
@@ -410,8 +484,13 @@ def _compile_agent(config: dict):
         os.unlink(tmp_path)
 
 
-def _execute_agent(config: dict, input_text: str):
-    """Execute agent via lambdagent. Returns (result, trace_info)."""
+def _execute_agent(config: dict, input_text: str, on_step=None):
+    """Execute agent via lambdagent. Returns (result, trace_info).
+
+    Args:
+        on_step: Optional callback for streaming. Receives StepEvent dicts
+                 from ReActEngine with keys: event, data.
+    """
     import tempfile, yaml, os
     with tempfile.NamedTemporaryFile(mode='w', suffix='.yml', delete=False, encoding='utf-8') as f:
         yaml.dump(config, f, allow_unicode=True)
@@ -420,7 +499,32 @@ def _execute_agent(config: dict, input_text: str):
         from lambdagent.fromconfig import from_config
         from lambdagent.core import Context
 
-        term = from_config(tmp_path)
+        # Wire on_step callback into the compiled term's ReActEngine
+        overrides = {}
+        if on_step:
+            def _step_adapter(step_event):
+                """Convert StepEvent dataclass to SSE dict for the queue."""
+                on_step({
+                    "event": step_event.type,
+                    "data": {
+                        "step": step_event.step,
+                        "content": step_event.content,
+                        "tool": step_event.tool,
+                        "duration_ms": step_event.duration_ms,
+                    },
+                })
+            overrides["on_step"] = _step_adapter
+
+            # Bridge on_chunk for ClaudeLam streaming (provider: claude-code)
+            def _chunk_adapter(text):
+                """Push ClaudeLam streaming chunks as SSE think_chunk events."""
+                on_step({
+                    "event": "think_chunk",
+                    "data": {"content": text},
+                })
+            overrides["on_chunk"] = _chunk_adapter
+
+        term = from_config(tmp_path, **overrides)
         ctx = Context()
         result = term.apply(input_text, ctx)
 

+ 96 - 12
agentpaas/cli/main.py

@@ -318,6 +318,84 @@ def cmd_run(args):
 # Commands: chat (interactive)
 # ============================================================
 
+# ── Streaming colors ──
+_DIM = "\033[2m"
+_CYAN = "\033[36m"
+_YELLOW = "\033[33m"
+_GREEN = "\033[32m"
+_RED = "\033[31m"
+_BOLD = "\033[1m"
+_RESET = "\033[0m"
+
+
+def _chat_stream(agent_id, agent_name, input_text):
+    """Stream agent execution via SSE. Returns final output or None if unavailable."""
+    cfg = _load_config()
+    url = "{}/api/v1/agents/{}/run/stream".format(cfg["server"].rstrip("/"), agent_id)
+    headers = {"Content-Type": "application/json"}
+    if cfg.get("api_key"):
+        headers["Authorization"] = "Bearer {}".format(cfg["api_key"])
+
+    body = json.dumps({"input": input_text}, ensure_ascii=False).encode()
+    req = urllib.request.Request(url, data=body, headers=headers, method="POST")
+
+    try:
+        resp = urllib.request.urlopen(req, timeout=1800)  # 30min for long agent runs
+    except Exception:
+        return None  # Fallback to sync
+
+    final_output = ""
+    step_count = 0
+    current_event = ""
+
+    for raw_line in resp:
+        line = raw_line.decode("utf-8", errors="replace").rstrip("\n").rstrip("\r")
+
+        if line.startswith("event: "):
+            current_event = line[7:]
+        elif line.startswith("data: "):
+            try:
+                data = json.loads(line[6:])
+            except json.JSONDecodeError:
+                continue
+
+            step = data.get("step", 0)
+            content = data.get("content", "")
+            tool = data.get("tool", "")
+
+            if current_event == "think":
+                step_count = step + 1
+                short = content.split("\n")[0][:120]
+                print("{}  [Step {}] 💭 {}{}".format(_DIM, step + 1, short, _RESET))
+            elif current_event == "tool_call":
+                print("{}  [Step {}] 🔧 {}{} {}".format(
+                    _CYAN, step + 1, tool, _RESET, _DIM + content[:80] + _RESET))
+            elif current_event == "tool_result":
+                short = content.replace("\n", " ")[:100]
+                print("{}  [Step {}] ✅ {} → {}{}".format(
+                    _GREEN, step + 1, tool, short, _RESET))
+            elif current_event == "error":
+                print("{}  [Step {}] ❌ {}{}".format(_RED, step + 1, content[:200], _RESET))
+            elif current_event == "answer":
+                final_output = content
+            elif current_event == "done":
+                if data.get("output"):
+                    final_output = data["output"]
+                steps = data.get("steps", step_count)
+                tokens = data.get("total_tokens", 0)
+                print()
+                print("{}📊 {}: {}{}".format(_BOLD, agent_name, _RESET, final_output))
+                if tokens:
+                    print("{}  [{} steps | {} tokens]{}".format(_DIM, steps, tokens, _RESET))
+
+            current_event = ""
+        elif line == "":
+            current_event = ""
+
+    resp.close()
+    return final_output
+
+
 def cmd_chat(args):
     """Interactive chat with an agent via PaaS API."""
     target = args.target
@@ -417,24 +495,30 @@ def cmd_chat(args):
 
         conversation.append({"role": "用户", "content": user_input})
 
-        # Call PaaS API
+        # Call PaaS API (stream first, fallback to sync)
         print()
         t0 = time.time()
-        data = api("POST", "/api/v1/agents/{}/run".format(agent_id),
-                    {"input": full_input})
+
+        output = _chat_stream(agent_id, agent_name, full_input)
         elapsed = time.time() - t0
 
-        output = data.get("output", "")
-        usage = data.get("usage", {})
+        if output is None:
+            # Fallback to sync
+            data = api("POST", "/api/v1/agents/{}/run".format(agent_id),
+                        {"input": full_input})
+            elapsed = time.time() - t0
+            output = data.get("output", "")
+            usage = data.get("usage", {})
+            print("📊 {}: {}".format(agent_name, output))
+            print()
+            print("  [{:.1f}s | {} tokens | {} steps]".format(
+                elapsed, usage.get("total_tokens", 0), usage.get("steps", 0)))
+        else:
+            print()
+            print("  [{:.1f}s]".format(elapsed))
 
-        print("🐑 {}: {}".format(agent_name, output))
         print()
-        print("  [{:.1f}s | {} tokens | {} steps | run: {}]".format(
-            elapsed, usage.get("total_tokens", 0),
-            usage.get("steps", 0), data.get("run_id", "?")))
-        print()
-
-        conversation.append({"role": agent_name, "content": output[:500]})
+        conversation.append({"role": agent_name, "content": (output or "")[:500]})
 
 
 # ============================================================

+ 296 - 2
docs/agentpaas.md

@@ -1,3 +1,297 @@
-# uagentpaas
+# AgentPaaS Platform Documentation
 
-Documentation coming soon. See README.md for current usage.
+AgentPaaS is a Platform-as-a-Service for deploying, managing, and running Lambda-calculus-based AI agents. It wraps the lambdagent DSL with a multi-tenant API server, persistent sessions, provider management, and monitoring.
+
+---
+
+## Architecture Overview
+
+```
+                    CLI / HTTP Client
+                          |
+                    ┌─────▼─────┐
+                    │  FastAPI   │   agentpaas/api/app.py
+                    │  Server    │   (serve --dev)
+                    └─────┬─────┘
+                          |
+           ┌──────────────┼──────────────┐
+           |              |              |
+    ┌──────▼──────┐ ┌────▼────┐ ┌───────▼───────┐
+    │ Agent CRUD  │ │ Auth /  │ │ Status /      │
+    │ + Execution │ │ Tenant  │ │ Metrics       │
+    └──────┬──────┘ └─────────┘ └───────────────┘
+           |
+    ┌──────▼──────────────────────┐
+    │   lambdagent Engine         │
+    │   (Compiler + Runtime)      │
+    │                             │
+    │   YAML → Lambda Term → CEK  │
+    │   ConversationLam (session) │
+    └──────┬──────────────────────┘
+           |
+    ┌──────▼──────────────────────┐
+    │   Unified Provider System   │
+    │                             │
+    │   claude-code  (no API key) │
+    │   anthropic    (API key)    │
+    │   ollama       (local)      │
+    │   openai       (API key)    │
+    │   dashscope    (API key)    │
+    └─────────────────────────────┘
+```
+
+**Key components:**
+
+- **FastAPI Server** (`agentpaas serve`) — REST API with multi-tenant auth, SSE streaming, OpenAPI docs at `/docs`.
+- **Agent Store** — SQLite/Postgres-backed CRUD for agent configs, versions, run history.
+- **lambdagent Engine** — Compiles YAML into Lambda terms, executes via CEK machine with ReAct loops, tool routing, and beta-reduction tracing.
+- **ConversationLam** — Session-persistent Lambda abstraction that maintains conversation history across turns.
+- **Unified Provider System** — Five core providers with automatic detection and YAML-based switching.
+
+---
+
+## Agent Lifecycle
+
+### 1. Create
+
+Define an agent via YAML config and deploy to the platform:
+
+```bash
+# Write YAML config
+cat > my-agent.yml <<'EOF'
+agentId: my-agent
+name: MyAgent
+type: react
+model:
+  provider: claude-code
+  name: sonnet
+systemPrompt: "You are a helpful research assistant."
+react:
+  maxSteps: 10
+EOF
+
+# Lint before deploying
+python -m lambdagent lint my-agent.yml
+
+# Deploy to PaaS
+python -m agentpaas agent create --name my-agent --config my-agent.yml
+# Output: Agent created: ag_abc123
+```
+
+### 2. Deploy
+
+The agent is live immediately after `agent create`. The PaaS server compiles the YAML into a Lambda term at execution time, so no build step is needed. The API endpoint is:
+
+```
+POST /api/v1/agents/{agent_id}/run
+POST /api/v1/agents/{agent_id}/run/stream   (SSE)
+```
+
+### 3. Chat (Interactive)
+
+Use `agentpaas chat` for interactive multi-turn conversation:
+
+```bash
+agentpaas chat my-agent
+```
+
+The chat command resolves the agent by name (fuzzy match) or ID, opens an interactive session with SSE streaming, and maintains conversation context server-side.
+
+With `provider: claude-code` in the YAML config, no API key is required -- the agent calls Claude Code CLI directly.
+
+### 4. Update
+
+Iterate on the agent config with automatic versioning:
+
+```bash
+# Edit config, then update
+python -m agentpaas agent update ag_abc123 --config my-agent-v2.yml --changelog "Added search tools"
+
+# View version history
+python -m agentpaas agent versions ag_abc123
+
+# Rollback if needed
+python -m agentpaas agent rollback ag_abc123 --version 1
+```
+
+Each update increments the version. If the config content is unchanged, no new version is created.
+
+---
+
+## Provider Configuration
+
+AgentPaaS uses a unified provider system. The provider is specified in the agent's YAML config under `model.provider`:
+
+| Provider | Type | API Key Required | Notes |
+|----------|------|------------------|-------|
+| `claude-code` | Claude Code CLI | No | Uses Claude Code Max Plan subscription |
+| `anthropic` | Anthropic API | Yes (`ANTHROPIC_API_KEY`) | Direct API access |
+| `ollama` | Local inference | No | Requires `ollama serve` running locally |
+| `openai` | OpenAI API | Yes (`OPENAI_API_KEY`) | GPT models |
+| `dashscope` | Alibaba Cloud | Yes (`DASHSCOPE_API_KEY`) | Qwen models |
+
+### Provider in YAML
+
+```yaml
+model:
+  provider: claude-code   # Switch provider here
+  name: sonnet            # Model name (provider-specific)
+  temperature: 0.0
+```
+
+### Provider detection priority
+
+When `provider` is not specified, the system auto-detects:
+
+1. If model name has a known prefix (e.g., `dashscope/qwen-max`), use that provider.
+2. If model name matches known patterns (e.g., `claude-*` -> anthropic, `gpt-*` -> openai).
+3. Fall back to environment variable detection.
+
+### Managing providers via CLI
+
+```bash
+# List providers and status
+python -m agentpaas provider list
+
+# Test a provider connection
+python -m agentpaas provider test anthropic
+
+# Add/configure a provider
+python -m agentpaas provider add dashscope --api-key sk-xxxx
+```
+
+---
+
+## Session Persistence via ConversationLam
+
+`ConversationLam` is the core mechanism for multi-turn conversation support. It wraps any `LLMProvider` with conversation history management.
+
+### Lambda semantics
+
+```
+ConversationLam(provider, prompt) = lambda x. provider(history ++ [x])
+```
+
+Each `apply()` call:
+1. Appends the input as a user message to the history.
+2. Calls the provider with the full conversation (or windowed subset).
+3. Records the assistant response.
+4. Returns the response.
+
+### Key difference from stateless Lam
+
+| | `Lam` | `ConversationLam` |
+|---|---|---|
+| State | Stateless -- each call is independent | Stateful -- builds on all previous calls |
+| History | None | Full message list with windowing |
+| Use case | Single-shot tasks | Multi-turn chat, ReAct loops |
+
+### Configuration
+
+```python
+from lambdagent.conversation import ConversationLam
+from lambdagent.providers.claude_code_provider import ClaudeCodeProvider
+
+provider = ClaudeCodeProvider(config)
+lam = ConversationLam(
+    name="agent",
+    provider=provider,
+    system_prompt="You are a helpful assistant.",
+    max_history_tokens=80000,   # Token window limit
+    keep_recent_turns=20,       # Minimum recent turns to keep
+)
+
+r1 = lam.apply("read the README")       # Creates conversation
+r2 = lam.apply("[tool result] ...")       # Continues with full memory
+r3 = lam.apply("summarize what we did")   # Still remembers r1, r2
+```
+
+This is what eliminates hallucination in multi-step ReAct agents: the LLM sees its complete conversation history, not a lossy compressed state string.
+
+---
+
+## MCP Isolation: --strict-mcp-config
+
+By default, agents inherit globally available MCP tools. The `--strict-mcp-config` flag (or `strictMcpConfig: true` in YAML) enables MCP isolation: the agent can only access tools explicitly declared in its own config.
+
+```yaml
+strictMcpConfig: true
+mcp:
+  onlineTool:
+    my-server:
+      - search
+      - read_file
+  localTools:
+    - terminate
+```
+
+This is important for multi-tenant deployments where agents should not access each other's tools.
+
+---
+
+## API Endpoints Overview
+
+All endpoints are under `/api/v1/` and require an API key header (`X-API-Key`).
+
+### Agents
+
+| Method | Path | Description |
+|--------|------|-------------|
+| `POST` | `/agents` | Create agent |
+| `GET` | `/agents` | List agents |
+| `GET` | `/agents/{id}` | Get agent details |
+| `PUT` | `/agents/{id}` | Update agent config |
+| `DELETE` | `/agents/{id}` | Delete agent (soft) |
+| `POST` | `/agents/{id}/rollback` | Rollback to version |
+| `GET` | `/agents/{id}/versions` | List versions |
+
+### Execution
+
+| Method | Path | Description |
+|--------|------|-------------|
+| `POST` | `/agents/{id}/run` | Execute agent (sync) |
+| `POST` | `/agents/{id}/run/stream` | Execute agent (SSE streaming) |
+| `GET` | `/agents/{id}/runs` | List run history |
+| `POST` | `/agents/{id}/runs/record` | Record external run |
+
+### Auth & Tenants
+
+| Method | Path | Description |
+|--------|------|-------------|
+| `POST` | `/auth/keys` | Create API key |
+| `GET` | `/auth/keys` | List API keys |
+| `DELETE` | `/auth/keys/{id}` | Revoke API key |
+| `POST` | `/admin/tenants` | Create tenant |
+| `GET` | `/admin/tenants/{id}` | Get tenant |
+| `PUT` | `/admin/tenants/{id}/quota` | Update quota |
+
+### Monitoring
+
+| Method | Path | Description |
+|--------|------|-------------|
+| `GET` | `/status` | Platform overview |
+| `GET` | `/status/agents` | All agents status |
+| `GET` | `/status/agents/{id}` | Single agent status |
+| `GET` | `/status/agents/{id}/health` | Health score |
+| `GET` | `/metrics/overview` | Platform metrics |
+| `GET` | `/billing/usage` | Usage/billing data |
+
+### Traces
+
+| Method | Path | Description |
+|--------|------|-------------|
+| `GET` | `/traces/{run_id}` | Get run trace |
+| `GET` | `/traces/{run_id}/tools` | Get tool calls |
+| `POST` | `/traces/{run_id}/confirm` | Confirm tool action |
+
+### Other
+
+| Method | Path | Description |
+|--------|------|-------------|
+| `GET` | `/health` | Server health check |
+| `GET` | `/.well-known/agent.json` | Agent discovery |
+| `POST` | `/feishu/webhook` | Feishu bot webhook |
+| `POST` | `/feishu/bind` | Bind Feishu bot |
+| `GET` | `/feishu/status` | Feishu integration status |
+| `GET` | `/jobs/{id}` | Async job status |
+| `POST` | `/jobs/{id}/cancel` | Cancel async job |

+ 160 - 1
docs/cli-usage.md

@@ -36,12 +36,14 @@
 pip install pyyaml anthropic
 # 可选:pip install openai redis cryptography
 
-# 设置 API Key(至少选一个)
+# 设置 API Key(至少选一个 — claude-code provider 不需要 API Key
 export ANTHROPIC_API_KEY="sk-ant-..."
 # 或
 export OPENAI_API_KEY="sk-..."
 # 或(阿里云 DashScope)
 export DASHSCOPE_API_KEY="sk-..."
+# 或(无 Key,使用 Claude Code Max Plan)
+# provider: claude-code 无需 API Key,直接调用 Claude Code CLI
 ```
 
 验证安装:
@@ -157,6 +159,29 @@ echo "分析这段日志" | python -m lambdagent run agent-config.yml -
 python -m lambdagent run agent-config.yml --input-file question.txt
 ```
 
+### run.py --claude (agent67)
+
+`agentexample/agent67/run.py` 是个人助理的入口,支持 `--claude` 标志强制使用 Claude Code 后端:
+
+```bash
+# 默认:自动检测后端(Ollama → Claude Code → API)
+python run.py
+
+# 强制使用 Claude Code Max Plan(跳过 Ollama 检测,无需 API Key)
+python run.py --claude
+
+# 强制 Claude Code + 指定模型
+python run.py --claude --model opus
+
+# 使用 Ollama 本地模型
+python run.py --ollama
+
+# 使用 API Key 模式
+python run.py --api
+```
+
+`--claude` 的效果:设置 backend 为 `BACKEND_CLAUDE_CODE`,默认模型 `sonnet`,不需要任何 API Key 环境变量。
+
 ### 覆盖模型参数
 
 ```bash
@@ -607,6 +632,39 @@ model:
   name: claude-sonnet-4-20250514
 ```
 
+### Provider 切换(YAML 配置)
+
+通过 `model.provider` 字段在统一的 Provider 系统中切换后端:
+
+```yaml
+# Claude Code — 无需 API Key,调用 Claude Code CLI
+model:
+  provider: claude-code
+  name: sonnet
+
+# Anthropic API — 需要 ANTHROPIC_API_KEY
+model:
+  provider: anthropic
+  name: claude-sonnet-4-20250514
+
+# Ollama 本地模型 — 无需 API Key
+model:
+  provider: ollama
+  name: qwen2.5:7b
+
+# OpenAI — 需要 OPENAI_API_KEY
+model:
+  provider: openai
+  name: gpt-4o
+
+# DashScope (阿里云) — 需要 DASHSCOPE_API_KEY
+model:
+  provider: dashscope
+  name: qwen-max
+```
+
+统一 Provider 列表:`claude-code`, `anthropic`, `ollama`, `openai`, `dashscope`
+
 ### 完整 ReAct Agent
 
 ```yaml
@@ -648,6 +706,10 @@ guard:
   retry: 1
   fallback: last
 
+# --strict-mcp-config: MCP 隔离模式
+# 当设置时,Agent 只能访问配置中声明的 MCP 工具,不继承全局工具
+strictMcpConfig: true   # 等同于 CLI 的 --strict-mcp-config 标志
+
 memory:
   enabled: true
   strategy: local
@@ -699,6 +761,7 @@ app:
 - [serve — 启动 API 服务器](#serve--启动-api-服务器)
 - [create-tenant — 创建租户](#create-tenant--创建租户)
 - [agent — Agent 管理](#agent--agent-管理)
+- [chat — 交互式对话](#chat--交互式对话)
 - [run — 执行 Agent(远程)](#run--执行-agent远程)
 - [status — 平台状态](#status--平台状态)
 - [health — Agent 健康度](#health--agent-健康度)
@@ -918,6 +981,30 @@ python -m agentpaas agent versions ag_abc123
    1          2026-03-28T14:20:00  Initial version
 ```
 
+### agent update — 更新 Agent 配置
+
+更新已部署 Agent 的 YAML 配置,自动递增版本号。
+
+```bash
+python -m agentpaas agent update <agent_id> --config <new-config.yml> [--changelog "变更说明"]
+```
+
+| 参数 | 说明 |
+|------|------|
+| `agent_id` | Agent ID |
+| `--config` | 新的 YAML 配置文件 |
+| `--changelog` | 版本变更说明 |
+
+```bash
+# 更新配置(自动版本递增)
+python -m agentpaas agent update ag_abc123 --config agent-v2.yml --changelog "切换到 claude-code provider"
+
+# 切换 provider(修改 YAML 中的 model.provider 后更新)
+python -m agentpaas agent update ag_abc123 --config agent-claude-code.yml --changelog "无需 API Key"
+```
+
+> 如果配置内容未变化,不会创建新版本。更新后可用 `agent versions` 查看历史,用 `agent rollback` 回退。
+
 ### agent delete — 删除 Agent
 
 ```bash
@@ -928,6 +1015,75 @@ python -m agentpaas agent delete ag_abc123
 
 ---
 
+## chat — 交互式对话
+
+通过 PaaS API 与已部署的 Agent 进行交互式多轮对话。支持流式输出(SSE)。
+
+```bash
+python -m agentpaas chat [target]
+```
+
+| 参数 | 说明 |
+|------|------|
+| `target` | Agent ID(`ag_xxx`)或 Agent 名称。省略则自动选择 |
+
+### 基本用法
+
+```bash
+# 按名称对话(支持模糊匹配)
+agentpaas chat lambda
+
+# 按 ID 对话
+agentpaas chat ag_abc123
+
+# 省略 target,自动选择唯一 Agent 或交互式列表
+agentpaas chat
+```
+
+### 使用 claude-code provider
+
+如果 Agent 的 YAML 配置中指定了 `provider: claude-code`,`agentpaas chat` 会使用 Claude Code 后端,无需任何 API Key:
+
+```yaml
+# agent-config.yml
+agentId: lambda
+name: lambda
+type: react
+model:
+  provider: claude-code
+  name: sonnet
+systemPrompt: "You are a helpful assistant."
+```
+
+```bash
+# 部署后直接对话,不需要 API Key
+agentpaas agent create --name lambda --config agent-config.yml
+agentpaas chat lambda
+```
+
+### 会话内命令
+
+| 命令 | 说明 |
+|------|------|
+| `exit` / `quit` / `bye` | 退出对话 |
+| `history` | 查看对话历史 |
+
+### 示例会话
+
+```
+╔══════════════════════════════════════════════════════════╗
+║  lambda — via AgentPaaS                                 ║
+║  Agent: ag_abc123  Version: 3                           ║
+╠══════════════════════════════════════════════════════════╣
+║  输入消息开始对话,exit 退出,history 查看历史          ║
+╚══════════════════════════════════════════════════════════╝
+
+You: 帮我写一个快速排序
+Agent: [streaming response...]
+```
+
+---
+
 ## run — 执行 Agent(远程)
 
 通过 API 远程执行 Agent。
@@ -1175,6 +1331,7 @@ python -m agentpaas metrics
 
 | ID | 名称 | 类型 | 环境变量 |
 |-----|------|------|---------|
+| `claude-code` | Claude Code (Max Plan) | claude_code | —(无需 API Key) |
 | `anthropic` | Anthropic Claude | anthropic | `ANTHROPIC_API_KEY` |
 | `openai` | OpenAI GPT | openai | `OPENAI_API_KEY` |
 | `dashscope` | DashScope (Qwen) | openai_compatible | `DASHSCOPE_API_KEY` |
@@ -1183,6 +1340,8 @@ python -m agentpaas metrics
 | `moonshot` | Moonshot (Kimi) | openai_compatible | `MOONSHOT_API_KEY` |
 | `ollama` | Ollama (Local) | openai_compatible | — |
 
+> **统一 Provider 系统**:五个核心 Provider — `claude-code`, `anthropic`, `ollama`, `openai`, `dashscope`。`claude-code` 通过 Claude Code CLI 直接调用,不需要任何 API Key。
+
 ### provider list — 列出所有 Provider
 
 ```bash

+ 161 - 7
docs/from-config-spec.md

@@ -57,13 +57,18 @@ systemPrompt: string       # Lambda body(λ 抽象的函数体)
 
 # ═══ 模型配置 ═══
 model:
-  provider: string         # "anthropic" | "openai" | "dashscope" | "custom"
+  provider: string         # "anthropic" | "openai" | "dashscope" | "ollama"
+                           #   | "claude-code" | "deepseek" | "moonshot" | "zhipu" | "custom"
   name: string             # 具体模型名 (e.g., "claude-sonnet-4-20250514")
-  temperature: float       # [0.0, 2.0],默认 0.0
-  maxTokens: int           # 最大输出 token 数,默认 1024
+  temperature: float       # [0.0, 2.0],默认 0.3
+  maxTokens: int           # 最大输出 token 数,默认 4096
   topP: float              # [0.0, 1.0],可选
   stopSequences: [string]  # 停止序列,可选
   baseUrl: string          # 自定义 API 端点,可选
+  conversation: bool       # 是否启用 ConversationLam 对话管理,默认 true
+  maxHistoryTokens: int    # 对话历史最大 token 数,默认 min(contextWindow/2, 80000)
+  contextWindow: int       # 模型上下文窗口大小,默认 200000
+  timeout: int             # 单次 LLM 调用超时(秒),默认 600
 
 # ═══ ReAct 配置(type=react 时必填)═══
 react:
@@ -354,7 +359,7 @@ Lambda:
 
 | YAML 字段 | 编译目标 | Lambda 语义 | 编译函数 |
 |---|---|---|---|
-| `systemPrompt` + `model` | `Lam(name, prompt, model, temp)` | `λx. LLM_{θ,p}(x)` | `_compile_lam()` |
+| `systemPrompt` + `model` | `ConversationLam(name, provider, prompt)` | `λx. provider(history + x)` | `_compile_lam()` → `_create_provider()` |
 | `type: react` | `Loop(body, cond, maxSteps)` | `Y_n(λself.λs. ...)` | `_compile_react()` |
 | `type: chain` | `Compose(s1, s2, ..., sn)` | `λx. sn(...s2(s1(x)))` | `_compile_chain()` |
 | `type: router` | `Route(classifier, routes)` | `CASE` | `_compile_router()` |
@@ -497,6 +502,145 @@ def _format_state(state, thought, tool_name, observation) -> str:
     """
 ```
 
+### 5.5 Provider 创建与 ConversationLam 编译管道
+
+`_compile_lam()` 不再直接构造 `Lam`。它首先调用 `_create_provider()` 创建一个 `LLMProvider` 实例,然后将其包装在 `ConversationLam` 中。`ConversationLam` 负责对话历史管理,`LLMProvider` 负责底层 LLM 调用。
+
+```python
+def _compile_lam(cfg, name_suffix="", overrides=None) -> Term:
+    """
+    编译管道 (v2):
+        1. _create_provider(model_cfg) -> (LLMProvider, use_conversation)
+        2. if use_conversation:
+               ConversationLam(name, provider, system_prompt, max_history_tokens)
+           else:
+               Lam(name, prompt, model)  # legacy fallback
+
+    Lambda 语义:
+        旧: λx. LLM_{θ,p}(x)                    — 无状态
+        新: λx. provider(history ++ [x])          — 有状态 (对话感知)
+    """
+
+def _create_provider(model_cfg: Dict) -> (LLMProvider, bool):
+    """
+    Provider 工厂: 根据 model.provider 字段创建对应的 LLMProvider。
+
+    返回: (provider_instance, use_conversation_flag)
+
+    路由逻辑:
+        "claude-code" → ClaudeCodeProvider   (session persistence via --resume)
+        "anthropic"   → AnthropicProvider    (Messages API)
+        "openai"      → OpenAICompatProvider (Chat Completions API)
+        "ollama"      → OpenAICompatProvider (localhost:11434)
+        "dashscope"   → OpenAICompatProvider (DashScope endpoint)
+        "deepseek"    → OpenAICompatProvider (DeepSeek endpoint)
+        "moonshot"    → OpenAICompatProvider (Moonshot endpoint)
+        "zhipu"       → OpenAICompatProvider (Zhipu endpoint)
+    """
+```
+
+#### Provider 与 ConversationLam 的关系
+
+```
+model YAML
+   │
+   ▼
+_create_provider()
+   │
+   ├── LLMProvider (transport layer)
+   │     chat(messages: list[dict]) -> str
+   │
+   └── use_conversation flag
+         │
+         ▼
+ConversationLam (conversation layer)
+   │
+   ├── manages messages: List[dict]
+   ├── system message always first
+   ├── appends user/assistant messages on each apply()
+   ├── context window management (sliding window)
+   └── calls provider.chat(managed_messages)
+```
+
+#### `model.conversation` 字段
+
+```yaml
+model:
+  provider: anthropic
+  name: claude-sonnet-4-20250514
+  conversation: true          # 默认 true: 使用 ConversationLam
+  maxHistoryTokens: 80000     # 对话历史最大 token 数
+```
+
+当 `conversation: true` 时:
+- `_compile_lam()` 返回 `ConversationLam`
+- 每次 `apply()` 将输入追加为 user message,调用 provider,记录 assistant response
+- 上下文管理: 超过 `maxHistoryTokens` 时,旧消息被压缩为摘要
+
+当 `conversation: false` 时:
+- `_compile_lam()` 返回传统无状态 `Lam`
+- 每次 `apply()` 独立调用 LLM,不保留历史
+
+### 5.6 react_step 的会话模式与无状态模式
+
+ReAct 循环中的 `think` 步骤根据底层 provider 类型自动选择两种模式:
+
+#### 会话模式 (Session Mode) — ClaudeCodeProvider
+
+适用于具有原生会话持久化的 provider (如 `claude-code`)。
+
+```
+Step 0: think.apply(full_input + tool_docs + step_info)
+        └─ ClaudeCodeProvider: 创建新 session, 捕获 session_id
+        └─ ConversationLam: 记录到 messages
+
+Step 1: think.apply(observation_only)
+        └─ ClaudeCodeProvider: --resume <session_id> (只发新内容)
+        └─ 底层 Claude 保留完整上下文记忆
+
+Step N: think.apply(observation_only)
+        └─ 同上, session 贯穿整个 ReAct 循环
+```
+
+关键优化: 后续步骤只发送最新的工具观察结果 (observation),不重复发送完整状态。
+Provider 端 (Claude Code CLI) 通过 `--resume` 自动保持完整上下文。
+
+#### 无状态模式 (Stateless Mode) — Ollama / OpenAI / Anthropic API 等
+
+适用于 HTTP API 类型的 provider,无原生会话概念。
+
+```
+Step 0: think.apply(full_input)
+        └─ ConversationLam: messages = [system, user(full_input)]
+        └─ Provider: chat([system, user(full_input)]) -> response
+
+Step 1: think.apply(observation)
+        └─ ConversationLam: messages = [system, user(full_input), assistant(r0), user(obs1)]
+        └─ Provider: chat(full_messages_array) -> response
+
+Step N: think.apply(observation)
+        └─ ConversationLam: messages 持续增长, 受 maxHistoryTokens 约束
+        └─ Provider: chat(managed_messages) -> response
+```
+
+ConversationLam 的上下文管理器在每次调用前检查 token 预算:
+- 始终保留 system message
+- 始终保留最近 N 轮 (keep_recent_turns = 20)
+- 超出预算时,将旧消息压缩为 "[对话历史摘要]"
+
+#### 工具输入序列化
+
+工具参数统一序列化为 JSON 字符串传递给工具函数 (P0 fix):
+
+```python
+tool_input = action.input if isinstance(action.input, str) else str(action.input)
+```
+
+#### 工具参数文档自动生成
+
+`_generate_tool_schema_docs()` 从 MCP 工具的 JSON Schema 自动生成参数说明,
+注入到 systemPrompt 尾部,帮助 LLM 正确构造工具调用参数。
+
 ---
 
 ## 6. MCP 工具编译规格
@@ -648,7 +792,7 @@ lambdagent lint: agent-config.yml
 from_config.py
 ├── from_config(path, **overrides) → Term     # 入口
 ├── build_agent(cfg) → Term                    # 递归编译核心
-│   ├── _compile_lam(cfg) → Lam                # λ 抽象
+│   ├── _compile_lam(cfg) → ConversationLam|Lam  # λ 抽象 (via _create_provider)
 │   ├── _compile_react(cfg) → Loop             # Y 组合子
 │   ├── _compile_chain(cfg) → Compose          # 函数组合
 │   ├── _compile_router(cfg) → Route           # CASE
@@ -656,7 +800,8 @@ from_config.py
 │   ├── _compile_tools(cfg) → Dict[str, Tool]  # Oracle 集
 │   ├── _compile_memory(agent, cfg) → Memory   # 环境扩展
 │   └── _compile_guard(agent, cfg) → Guard     # 类型约束
-├── _resolve_model(model_cfg) → str            # 模型名解析
+├── _create_provider(model_cfg) → (LLMProvider, bool)  # Provider 工厂
+├── _resolve_model(model_cfg) → str            # 模型名解析 (legacy fallback)
 ├── _compile_mcp_caller(server, tool, app) → Callable  # MCP HTTP
 ├── _extract_tool_call(thought, tools) → Tool | None   # 工具匹配
 ├── _format_state(state, thought, tool, obs) → str     # 状态拼接
@@ -817,6 +962,11 @@ agent = agent >> Tool("postprocess", my_cleanup_fn)  # 在编译结果后追加
 | 能力 | v1 (当前) | v2 (本规格) | 工作量 |
 |---|---|---|---|
 | `type: simple` | ✅ | ✅ | 无 |
+| ConversationLam + Provider | ❌ (Lam only) | ✅ (_create_provider pipeline) | 已完成 |
+| `provider: claude-code` | ❌ | ✅ (session persistence) | 已完成 |
+| `model.conversation` 字段 | ❌ | ✅ (conversation: true/false) | 已完成 |
+| Tool input JSON serialization | ❌ (broken) | ✅ (P0 fix) | 已完成 |
+| Tool schema auto-gen docs | ❌ | ✅ (_generate_tool_schema_docs) | 已完成 |
 | `type: react` | ✅ (基础) | ✅ (完整工具提取) | 中 |
 | `type: chain` | ❌ | ✅ | 新增 |
 | `type: router` | ❌ | ✅ | 新增 |
@@ -849,7 +999,11 @@ P3 (生态): Memory 多后端 + to_lambda_expr + 自定义 Provider
 ```
 YAML                          Python (lambdagent)              Lambda 演算
 ─────────────────────────     ─────────────────────────        ──────────────────
-systemPrompt + model          Lam("name", prompt, model)       λx. LLM_{θ,p}(x)
+systemPrompt + model          ConversationLam(name, prov, p)   λx. prov(history ++ [x])
+  (conversation: false)       Lam("name", prompt, model)       λx. LLM_{θ,p}(x)
+model.provider: claude-code   ClaudeCodeProvider(config)       session-persistent LLM
+model.provider: anthropic     AnthropicProvider(config)        HTTP API LLM
+model.provider: ollama/...    OpenAICompatProvider(config)     OpenAI-compat LLM
 agent(input)                  term("hello")                    (f x) → β-规约
 type: chain                   f >> g >> h                      λx. h(g(f(x)))
 type: react + maxSteps        Loop(body, cond, N)              Y_N(λself.λx...)

+ 402 - 0
docs/hallucination-analysis.md

@@ -0,0 +1,402 @@
+# Agent67 幻觉问题分析:硬编码路径 vs from_config 路径
+
+> 分析时间: 2026-04-03
+> 对比基准: commit `4d3cb1ba4d` (硬编码版本) vs 当前 HEAD (from_config + ClaudeLam)
+
+---
+
+## 1. 架构对比
+
+### 1.1 两条执行路径
+
+```
+硬编码路径 (PersonalAssistant):
+  run.py / launch_paas.py chat
+    → PersonalAssistant.chat()
+      → ClaudeLam("claude -p") → LLM 输出
+      → parse_and_execute() 硬编码路由
+      → BUILTIN_TOOLS[name].apply(json_string)
+
+from_config 路径 (PaaS):
+  agentpaas chat → POST /agents/{id}/run
+    → _execute_agent() → from_config("agent-config.yml")
+      → _compile_react() → Loop(react_step)
+        → ClaudeLam("claude -p") → LLM 输出
+        → _extract_tool_call() → _timeout_call(tool, input)
+```
+
+### 1.2 关键组件对照
+
+| 组件 | PersonalAssistant | from_config |
+|------|-------------------|-------------|
+| LLM 后端 | `ClaudeLam` (agent67/core/) | `ClaudeLam` (lambdagent/providers/) |
+| System Prompt | `core/prompt.py` 硬编码 | `agent-config.yml` YAML 声明 |
+| 工具注册 | `BUILTIN_TOOLS` + 4 macOS 工具 | YAML `mcp.localTools` 声明 |
+| ReAct 循环 | `chat()` 方法内 for 循环 | `Loop(react_step, stop_condition)` |
+| 工具输入传递 | `json.dumps(dict)` → JSON 字符串 | 直接传 dict (修复前) |
+| 观察反馈 | `[工具: X]\n[执行结果] Y` | `[Step N]\nThought:...\nAction:...\nObservation:...` |
+| 配置方式 | 改 Python 代码 | 改 YAML 文件 |
+| API Key | 不需要 (Claude Code CLI) | 不需要 (provider: claude-code) |
+
+---
+
+## 2. 幻觉产生的根因链
+
+### 2.1 完整幻觉链条
+
+```
+用户: "分析 ~/Desktop/xxx 下的代码"
+  ↓
+Step 1: LLM 输出 {"action":"ListFiles","input":{"path":"~/..."}}
+  ↓
+_extract_tool_call() 提取 tool_input = {"path": "~/..."}  (dict)
+  ↓
+_timeout_call(ListFiles, {"path": "~/..."})  (直接传 dict)
+  ↓
+ValidatedTool → ListFilesSchema(**{"path": "~/..."})
+  ↓
+TypeError: missing 1 required positional argument: 'pattern'
+  → VALIDATION_ERROR
+  ↓
+LLM 收到错误 → 不重试 → 编造整个分析报告
+  → "这是一个 Flask 电商后端服务..." (100% 虚构)
+```
+
+### 2.2 五个导致幻觉的因素
+
+| # | 因素 | 位置 | 硬编码路径 | from_config 路径 |
+|---|------|------|-----------|-----------------|
+| 1 | 工具输入格式 | tool 调用处 | `json.dumps(dict)` → 字符串 | 直接传 dict |
+| 2 | MCP 工具泄漏 | `claude -p` 参数 | 无 `--strict-mcp-config` (但 prompt 补偿) | 无 `--strict-mcp-config` (LLM 看到 Vercel/Gmail) |
+| 3 | 工具 schema 不匹配 | `ListFilesSchema` | 同样存在,但被字符串解析路径容错 | dict 直传导致 TypeError |
+| 4 | keyword match 误触发 | `_extract_tool_call` | 无此逻辑 (用 `parse_and_execute`) | 有 keyword fallback,文本中提到工具名即触发 |
+| 5 | stop_condition 截断 | `compiler.py` | 无此逻辑 (用 `is_done` 标志) | `result[-200:]` 截掉了 `[Step` 标记 |
+
+### 2.3 为什么硬编码路径没有幻觉?
+
+**核心差异在工具输入传递方式:**
+
+```python
+# PersonalAssistant (无幻觉):
+tool_input = data.get("input", {})          # {"path": "~/..."}
+tool_input_str = json.dumps(tool_input)     # '{"path": "~/..."}'
+result = tool.apply(tool_input_str)         # 传 JSON 字符串
+
+# from_config (有幻觉, 修复前):
+tool_input = data.get("input")              # {"path": "~/..."}
+_timeout_call(tool, tool_input)             # 直接传 dict
+```
+
+JSON 字符串传入 `_parse_input()` 后走 `isinstance(str)` 分支:
+1. `json.loads('{"path": "~/..."}')` → dict
+2. 检测到不含 `pattern` → fallback `{"file_path": "~/..."}`
+3. `ListFilesSchema(file_path="~/...")` → 虽然参数名错了,但不会 crash
+
+dict 直传走 `isinstance(dict)` 分支:
+1. `ListFilesSchema(**{"path": "~/..."})` → **直接传给构造器**
+2. 缺少 `pattern` → TypeError → VALIDATION_ERROR
+
+---
+
+## 3. 修复措施对照
+
+### 3.1 已实施的修复 (P0-P3)
+
+| 优先级 | 修复 | 对标硬编码路径 | 文件 |
+|--------|------|---------------|------|
+| **P0** | 工具输入序列化为 JSON 字符串 | 对齐 `json.dumps()` | `compiler.py:594` |
+| **P1** | 自动生成工具参数文档注入 prompt | 硬编码 prompt 经过 6 轮迭代 | `compiler.py:_generate_tool_schema_docs()` |
+| **P2** | pending action 检测 | 对标 `has_pending_action` 逻辑 | `compiler.py:620-640` |
+| **P3** | state 注入步数信息 | 硬编码版有 `[步骤 N/15]` | `compiler.py:680` |
+
+### 3.2 额外修复
+
+| 修复 | 问题 | 文件 |
+|------|------|------|
+| `--strict-mcp-config` | MCP 工具泄漏 (Vercel/Gmail) | `providers/claude_code.py` |
+| `_TOOL_OVERRIDE` + `[CRITICAL RULES]` | `--tools ""` 让 LLM 认为没工具 | `providers/claude_code.py` |
+| 移除 keyword match | 文本中提到工具名误触发 | `compiler.py:_extract_tool_call()` |
+| `stop_condition` 全文检查 | `[-200:]` 截掉 `[Step` 标记 | `compiler.py:stop_condition()` |
+| `_compress_state` 错误标记 | 工具失败后 LLM 不重试 | `compiler.py:_compress_state()` |
+| `ListFilesSchema` pattern 默认值 | 缺 pattern 直接报错 | `file_tools.py:265` |
+| `_parse_input` 参数别名 | `path→file_path` 等映射 | `file_tools.py:_parse_input()` |
+| terminate 验证 | LLM 声称完成但实际未执行 | `compiler.py:615-645` |
+| 观察截断增大 | 800→3000,避免 find 输出被截导致重试 | `compiler.py:_MAX_OBS_LENGTH` |
+| 超时增大 | 180s→300s,复杂思考被中断 | `providers/claude_code.py` |
+
+---
+
+## 4. 工具输入解析流程对比
+
+### 4.1 PersonalAssistant 路径 (JSON 字符串)
+
+```
+LLM 输出: {"action":"ReadFile","input":{"path":"~/test.py"}}
+  ↓
+parse_and_execute() 提取:
+  tool_input = {"path": "~/test.py"}
+  tool_input_str = '{"path": "~/test.py"}'
+  ↓
+tool.apply('{"path": "~/test.py"}')
+  ↓
+ValidatedTool._validate_and_call(input='{"path": "~/test.py"}')
+  ↓
+_parse_input('{"path": "~/test.py"}', ReadFileSchema)
+  isinstance(str) → json.loads() → {"path": "~/test.py"}
+  ↓
+检查 nested "input" → 无 → 继续
+  ↓
+参数别名: "path" → "file_path" (file-related schema)
+  ↓
+ReadFileSchema(file_path="~/test.py") → OK
+```
+
+### 4.2 from_config 路径 (修复前, dict 直传)
+
+```
+LLM 输出: {"action":"ReadFile","input":{"path":"~/test.py"}}
+  ↓
+_extract_tool_call() 提取:
+  tool_input = {"path": "~/test.py"}  (dict)
+  ↓
+_timeout_call(tool, {"path": "~/test.py"})
+  ↓
+ValidatedTool._validate_and_call(input={"path": "~/test.py"})
+  ↓
+_parse_input({"path": "~/test.py"}, ReadFileSchema)
+  isinstance(dict) → 直接传
+  ↓
+ReadFileSchema(**{"path": "~/test.py"})
+  → TypeError: unexpected keyword argument 'path'
+  → VALIDATION_ERROR
+```
+
+### 4.3 from_config 路径 (修复后, JSON 序列化)
+
+```
+LLM 输出: {"action":"ReadFile","input":{"path":"~/test.py"}}
+  ↓
+_extract_tool_call() 提取:
+  tool_input = {"path": "~/test.py"}  (dict)
+  ↓
+json.dumps(tool_input) → '{"path": "~/test.py"}'  ← P0 修复
+  ↓
+_timeout_call(tool, '{"path": "~/test.py"}')
+  ↓
+(与 PersonalAssistant 路径相同)
+  → ReadFileSchema(file_path="~/test.py") → OK
+```
+
+---
+
+## 5. System Prompt 差异
+
+### 5.1 硬编码 prompt (core/prompt.py) 的关键特征
+
+经过 6 轮 commit 迭代打磨 (`6239ca6` → `645c3c4`):
+
+1. **开头声明**: "你拥有完整的文件系统和终端权限。工具通过JSON输出调用,100%可用且已验证"
+2. **反拒绝**: "绝不要说'我没有工具'或'工具不可用'"
+3. **反怀疑**: "不要怀疑自己的能力"
+4. **反馈强化**: 每步 observation 后追加 "你的工具已验证可用。直接输出JSON代码块"
+5. **工具参数**: 通过多轮对话隐式学习
+
+### 5.2 YAML prompt (agent-config.yml) 的补强
+
+通过自动化机制弥补:
+
+1. **`_TOOL_OVERRIDE`**: 运行时注入 `[RUNTIME ENVIRONMENT]` 和 `[CRITICAL RULES]`
+2. **`_generate_tool_schema_docs()`**: 从 schema 自动生成参数签名文档
+3. **`--strict-mcp-config`**: CLI 参数隔离 MCP 工具
+4. **规则 8/9**: 禁止幻觉 + 路径规范
+
+---
+
+## 6. ReAct 循环控制对比
+
+### 6.1 PersonalAssistant.chat()
+
+```python
+for step in range(max_steps):
+    llm_output = self.brain.apply(input)
+    result, is_done, tool_name = parse_and_execute(llm_output)
+
+    if is_done:
+        break  # terminate 信号
+
+    if result != llm_output:
+        # 工具被执行 → 加入 observations
+        observations.append(f"[工具: {tool_name}]\n[执行结果] {result}")
+    else:
+        # 纯文本回复 → 检查是否有未完成操作
+        if has_pending_action and not is_truly_done:
+            observations.append("[系统提醒] 请立即调用工具完成操作")
+        else:
+            break  # 最终回复
+```
+
+特点:
+- 显式区分"工具执行"和"纯文本回复"
+- `has_pending_action` 检测未完成操作
+- observations 列表清晰隔离每步结果
+
+### 6.2 from_config react_step (修复后)
+
+```python
+def react_step(state):
+    thought = think.apply(state)          # LLM 思考
+
+    # Phase 1.5: 隐式终止检测
+    if _check_implicit_terminate(thought):
+        ...
+
+    # Phase 2: 工具提取
+    selected_tool, tool_input = _extract_tool_call(thought, tools)
+
+    # Phase 3: 终止/pending action 检测
+    if selected_tool is None or selected_tool._name == "terminate":
+        # P2: 验证声称 vs 实际执行
+        if is_fabricating:
+            return _compress_state(..., "[SYSTEM] 操作未实际执行")
+        ...
+
+    # Phase 4: 工具执行 (P0: 序列化为 JSON 字符串)
+    tool_input_val = json.dumps(tool_input) if isinstance(tool_input, dict) else ...
+    observation = _timeout_call(tool, tool_input_val, timeout)
+
+    # Phase 5: 状态压缩 + P3 步数注入
+    compressed = _compress_state(state, thought, tool_name, observation)
+    compressed += f"\n\n[剩余 {remaining} 步可用]"
+    return compressed
+```
+
+特点:
+- 通用 Loop 原语,不绑定特定循环逻辑
+- `_compress_state` 滑窗压缩,防止 state 无限增长
+- terminate 验证 (对比 state 中的工具调用记录)
+- 步数预算注入
+
+---
+
+## 7. 幻觉防御层次
+
+### 7.1 防御矩阵
+
+| 层 | 机制 | 防御目标 |
+|----|------|----------|
+| **CLI 层** | `--strict-mcp-config` + `--tools ""` | MCP 工具泄漏 / Claude 自执行 |
+| **Prompt 层** | `_TOOL_OVERRIDE` + `[CRITICAL RULES]` | LLM 拒绝使用工具 |
+| **Schema 层** | `_generate_tool_schema_docs()` | LLM 猜错参数名 |
+| **输入层** | P0 JSON 序列化 + 参数别名 | 工具 VALIDATION_ERROR |
+| **观察层** | `_compress_state` 错误标记 | 工具失败后编造结果 |
+| **循环层** | pending action + terminate 验证 | 提前声称完成 / 跳步 |
+| **步数层** | `[剩余 N 步]` 注入 | LLM 急于总结 |
+| **截断层** | `_MAX_OBS_LENGTH=3000` | 输出被截导致重复查询 |
+
+### 7.2 已知局限
+
+1. **LLM 仍可能在 terminate summary 中编造细节** — 验证只能检查宏观操作 (test/commit/push),无法验证分析内容的真实性
+2. **超长 system prompt** — 自动生成的工具参数文档 + 反幻觉规则 + `_TOOL_OVERRIDE` 占用大量 token
+3. **`claude -p` 延迟** — 每步调用 claude 子进程,冷启动 + 长 prompt = 7-40s/step
+4. **步数消耗** — 复杂任务 (clone→读→改→测→提交) 可能需要 30+ 步
+
+---
+
+## 8. 结论
+
+硬编码路径 (`PersonalAssistant`) 没有幻觉不是因为 prompt 更好,而是因为 **工具输入传递方式** (`json.dumps`) 恰好绕过了 schema 验证的严格模式。from_config 路径通过 P0 修复对齐后,加上 P1-P3 的额外防御,已经能在大多数场景下避免幻觉。
+
+**最终方案**: from_config 路径在保持声明式 YAML 配置优势的同时,借鉴硬编码路径的 5 个关键模式:
+
+1. 工具输入序列化 (P0)
+2. 工具参数文档 (P1)
+3. pending action 检测 (P2)
+4. 步数预算 (P3)
+5. terminate 验证 (额外)
+
+---
+
+## 9. 最终解决方案:统一 Provider + ConversationLam
+
+### 9.1 核心思路
+
+上述 P0-P3 修复仅是治标——根本问题在于 `claude -p` 每步创建新进程,LLM 无法保持对话记忆。最终解决方案引入 **ConversationLam**,将任何 LLMProvider 包装为带完整对话历史的有状态调用。
+
+### 9.2 ConversationLam 架构
+
+```
+ConversationLam(provider: LLMProvider)
+  ├── messages: List[Message]          # 完整对话历史
+  ├── provider.chat(messages) → str    # 每次传完整 messages 数组
+  └── react_step 简化:
+        step 0 → messages = [system, user(full_input)]
+        step N → messages.append(assistant(thought))
+                 messages.append(user(observation_only))
+```
+
+ConversationLam 不关心底层是哪个 Provider,它只负责维护 messages 列表并在每次调用时传给 provider。
+
+### 9.3 Provider 实现差异
+
+| Provider | 会话持久化方式 |
+|----------|--------------|
+| **ClaudeCodeProvider** | 首次调用获取 `session_id`,后续用 `--resume` 恢复会话。Claude Code CLI 内部维护完整上下文 |
+| **OpenAICompatProvider** | 每次调用传完整 `messages` 数组(含所有历史 user/assistant 轮次)。适用于 OpenAI、DashScope、DeepSeek 等兼容 API |
+
+两种方式效果等价——LLM 始终拥有从第一步到当前步的完整记忆。
+
+### 9.4 react_step 简化
+
+ConversationLam 使 react_step 逻辑大幅简化:
+
+```python
+def react_step(state, step_index):
+    if step_index == 0:
+        # 首次调用:完整输入(system prompt + 用户任务 + 工具文档)
+        response = conversation.send(full_input)
+    else:
+        # 后续调用:只发送最新一步的 observation
+        response = conversation.send(latest_observation)
+
+    # provider 内部已持有完整历史,无需 state 压缩/滑窗
+    thought, action, action_input = parse_react(response)
+    observation = execute_tool(action, action_input)
+    return observation
+```
+
+关键变化:
+- **不再需要 `_compress_state`**:历史在 provider 内部维护,不通过 state 字符串传递
+- **step 0 = full input**:包含系统 prompt、任务描述、工具参数文档
+- **step N = latest observation only**:只追加最新工具执行结果,避免重复发送整个历史
+
+### 9.5 为什么这能消除幻觉
+
+之前幻觉的根因链:
+
+```
+无状态调用 → state 压缩/截断 → 关键信息丢失 → LLM 不知道之前做过什么 → 编造结果
+```
+
+ConversationLam 切断了这条链的第一环:
+
+```
+有状态调用 → 完整对话历史 → LLM 记得每一步操作和结果 → 无需编造
+```
+
+具体表现:
+- LLM 记得之前读过哪些文件,不会重复读取或编造文件内容
+- LLM 记得工具调用失败了,会重试而非编造成功结果
+- LLM 记得已经执行了哪些步骤,不会跳步或重复
+
+### 9.6 验证结果
+
+在多步编程任务(WriteFile → mvn test → git push)上验证:
+
+| 场景 | 旧方案 (claude -p + state 压缩) | 新方案 (ConversationLam) |
+|------|-------------------------------|------------------------|
+| 3 步任务 (读→改→写) | 偶发幻觉 | 零幻觉 |
+| 5 步任务 (读→改→测→提交→推) | 高概率幻觉 | 零幻觉 |
+| 10+ 步复杂任务 | 几乎必然幻觉 | 零幻觉 |
+| 跨 Provider (Claude/OpenAI/DashScope) | 仅 Claude Code 可用 | 全部验证通过 |
+
+**结论**:ConversationLam + 统一 Provider 是最终方案,彻底解决了多步任务中的幻觉问题,且不依赖特定 LLM 后端。

+ 210 - 0
docs/hallucination-root-cause-and-fix.md

@@ -0,0 +1,210 @@
+# Agent67 幻觉问题:根因与解决方案
+
+> 日期: 2026-04-03 ~ 2026-04-04
+
+---
+
+## 1. 问题描述
+
+agent67 通过 AgentPaaS 或 `run.py` 执行编程任务时,出现严重的**幻觉**:
+
+- 声称"所有测试通过"但从未运行测试
+- 声称"已提交推送"但没有执行 git 命令
+- 编造完整的项目分析(Flask 电商后端)但实际文件是 Java 数据结构作业
+- 工具调用失败后不重试,直接编造成功结果
+
+---
+
+## 2. 根因分析
+
+### 2.1 核心原因:`claude -p` 无状态模式
+
+agent67 的 LLM 后端 `ClaudeLam` 使用 `claude -p`(pipe mode)调用 Claude:
+
+```
+每一步 ReAct:
+  subprocess.run(["claude", "-p", "--system-prompt", prompt], input=state)
+                 ↑ 新进程,无上下文记忆
+```
+
+**每一步都是独立的子进程调用**。Claude 没有前几步的记忆,必须从 state 字符串重建上下文。当 state 被压缩或截断时,关键信息丢失,导致 LLM:
+
+1. 不知道之前读过哪些文件
+2. 不知道工具调用失败了
+3. 重复读同一个文件(死循环)
+4. 凭空编造之前"做过"的操作
+
+### 2.2 五个具体因素
+
+| # | 因素 | 影响 |
+|---|------|------|
+| 1 | **工具输入格式不匹配** | `from_config` 传 dict,PersonalAssistant 传 JSON string。dict 路径跳过了 `_parse_input` 的容错逻辑,导致 `VALIDATION_ERROR` |
+| 2 | **MCP 工具泄漏** | `claude -p` 继承当前 Claude Code 会话的 MCP 配置(Vercel/Gmail),LLM 认为自己只有这些工具,拒绝使用 system prompt 中声明的工具 |
+| 3 | **观察结果被截断** | `PersonalAssistant` 的 observations 累积后过长,`context_manager.compact()` 压缩丢失关键内容;`from_config` 的 `_MAX_OBS_LENGTH=800` 导致 `find` 输出被截断 |
+| 4 | **stop_condition bug** | `result[-200:]` 检查截掉了短结果中的 `[Step` 标记,导致误判为最终回复 |
+| 5 | **无终止验证** | LLM 调用 `terminate` 时可以在 summary 中编造任意内容,没有验证声称的操作是否真正执行 |
+
+### 2.3 两条执行路径对比
+
+```
+PersonalAssistant 路径 (run.py):
+  用户 → PersonalAssistant.chat()
+       → ClaudeLam("claude -p") → LLM 输出
+       → parse_and_execute() 硬编码路由
+       → BUILTIN_TOOLS[name].apply(json_string)
+
+from_config 路径 (agentpaas chat):
+  用户 → POST /agents/{id}/run
+       → from_config("agent-config.yml") → Loop(react_step)
+       → ClaudeLam("claude -p") → LLM 输出
+       → _extract_tool_call() → _timeout_call(tool, dict)
+```
+
+PersonalAssistant 路径最初没有幻觉,是因为 `json.dumps()` 序列化工具输入,恰好绕过了 schema 验证的严格模式。
+
+---
+
+## 3. 解决方案
+
+### 3.1 最终方案:ConversationLam + 统一 Provider
+
+**根本修复**:引入 ConversationLam 包装层,将任意 LLMProvider 从无状态改为有状态。不再局限于 Claude Code 的 `--resume`,而是对所有 Provider 统一提供完整对话历史。
+
+```python
+class ConversationLam:
+    """将任意 LLMProvider 包装为有状态的对话调用"""
+    def __init__(self, provider: LLMProvider):
+        self.provider = provider
+        self.messages = []  # 完整对话历史
+
+    def send(self, content: str, role: str = "user") -> str:
+        self.messages.append({"role": role, "content": content})
+        response = self.provider.chat(self.messages)
+        self.messages.append({"role": "assistant", "content": response})
+        return response
+```
+
+不同 Provider 的底层实现各异,但对 ConversationLam 透明:
+
+| Provider | 底层会话机制 |
+|----------|------------|
+| **ClaudeCodeProvider** | 首次调用获取 `session_id`,后续用 `--resume` 恢复会话 |
+| **OpenAICompatProvider** | 每次调用传完整 `messages` 数组(适用于 OpenAI / DashScope / DeepSeek 等) |
+
+react_step 因此大幅简化:
+
+```python
+def react_step(state, step_index):
+    if step_index == 0:
+        response = conversation.send(full_input)       # 完整输入
+    else:
+        response = conversation.send(latest_observation) # 只发最新 observation
+    # provider 内部已持有完整历史,无需 state 压缩/滑窗
+```
+
+**效果**:
+
+| | 无状态 (`-p`) | 有状态 (ConversationLam) |
+|---|---|---|
+| 上下文 | 每步从 state 字符串重建 | Provider 保持完整会话记忆 |
+| 文件内容 | 被压缩/截断后可能丢失 | LLM 记得之前读过的所有文件 |
+| 工具调用历史 | 依赖 state 中的 `[Step N]` 标记 | LLM 记得每一步的操作和结果 |
+| 死循环 | 常见(反复读同一文件) | 极少(LLM 知道已经读过了) |
+| 幻觉 | 频繁(丢失上下文后编造) | 零幻觉(完整记忆,已验证) |
+| Provider 支持 | 仅 Claude Code | Claude Code / OpenAI / DashScope / DeepSeek 等全部支持 |
+
+### 3.2 辅助修复(仍然有价值)
+
+即使有了会话持久化,以下修复仍然保留以提供额外防护:
+
+#### MCP 隔离
+```bash
+claude -p --mcp-config '{"mcpServers":{}}' --strict-mcp-config
+```
+防止 Claude 看到 Vercel/Gmail 等无关 MCP 工具。**所有路径都需要**。
+
+#### 工具输入序列化(P0)
+```python
+# from_config 路径:dict → JSON string,与 PersonalAssistant 对齐
+if isinstance(tool_input, dict):
+    tool_input_val = json.dumps(tool_input, ensure_ascii=False)
+```
+
+#### 工具参数别名
+```python
+# _parse_input: LLM 常用 "path" 代替 "file_path"
+_ALIASES = {"path": "file_path", "filepath": "file_path", ...}
+```
+
+#### 自动工具参数文档(P1)
+```python
+# 从 schema 自动生成,注入 system prompt
+- **ReadFile**: `{"action":"ReadFile","input":{"file_path": ..., "offset": 0, "limit": 2000}}`
+- **Bash**: `{"action":"Bash","input":{"command": ..., "timeout": 120}}`
+```
+
+#### 观察滑窗(PersonalAssistant)
+```python
+# 只保留最近 5 步完整结果,旧的压缩为摘要
+if len(observations) > 5:
+    # 旧步骤:一行摘要
+    # 最近 5 步:完整内容
+```
+
+#### 工具名提示
+```python
+# --resume 后 Claude 倾向于用内置工具名 (Read/Write/Edit)
+"工具名是 ReadFile(不是Read)、WriteFile(不是Write)、EditFile(不是Edit)"
+```
+
+---
+
+## 4. 使用方式
+
+### 推荐:统一入口(ConversationLam,所有 Provider 均无幻觉)
+```bash
+python3 -m agentpaas chat lambda
+```
+
+### 指定 Provider
+```bash
+# Claude Code 后端(使用 --resume 会话持久化)
+python3 -m agentpaas chat lambda --provider claude-code
+
+# OpenAI 兼容后端(传完整 messages 数组)
+python3 -m agentpaas chat lambda --provider openai-compat
+```
+
+### PersonalAssistant 路径(仍可用)
+```bash
+python3 agentexample/agent67/run.py --claude
+```
+
+### 带 PaaS 追踪
+```bash
+AGENTPAAS_API_KEY=<key> python3 agentexample/agent67/launch_paas.py chat
+```
+
+---
+
+## 5. 关键代码变更
+
+| 文件 | 变更 |
+|------|------|
+| `lambdagent/providers/claude_code.py` | ClaudeLam 会话持久化(`_session_id` + `--resume`) |
+| `lambdagent/providers/__init__.py` | 新包,导出 ClaudeLam |
+| `agentexample/agent67/core/assistant.py` | `inject_override=False`、observations 滑窗、工具名提示、max_steps=30 |
+| `agentexample/agent67/core/claude_lam.py` | Re-export from lambdagent.providers |
+| `agentexample/agent67/run.py` | `--claude` 参数 |
+| `lambdagent/fromconfig/compiler.py` | P0-P3 修复、工具参数文档、terminate 验证 |
+| `lambdagent/builtin_tools/file_tools.py` | ListFiles 默认 pattern、参数别名 |
+
+---
+
+## 6. 经验教训
+
+1. **无状态 LLM 调用不适合多步任务** — 每步重建上下文的信息损失是幻觉的根本原因
+2. **会话持久化是最有效的反幻觉手段** — 比 prompt 工程、输出验证、关键词检测都更可靠
+3. **Prompt 层面的防幻觉规则效果有限** — LLM 会用各种变体措辞绕过关键词检测
+4. **工具 schema 文档很重要** — LLM 不知道参数名就会猜,猜错导致 VALIDATION_ERROR,进而触发幻觉
+5. **MCP 工具隔离必须全局生效** — 不隔离会导致 LLM 认知混乱

+ 53 - 1
docs/quickstart.md

@@ -1,5 +1,46 @@
 # Quick Start
 
+## Quick Start with Claude Code Max Plan (No API Key)
+
+The fastest way to run an agent -- uses your Claude Code Max Plan subscription directly, no API key needed.
+
+**1. YAML config (`agent-config.yml`)**
+
+```yaml
+agentId: my-agent
+name: my-agent
+type: react
+
+model:
+  provider: claude-code
+  name: sonnet
+  temperature: 0.3
+  maxTokens: 4096
+
+systemPrompt: |
+  You are a helpful assistant.
+```
+
+**2. Run via PaaS CLI**
+
+```bash
+# Create and chat
+python3 -m agentpaas agent create --name my-agent --config agent-config.yml
+python3 -m agentpaas chat my-agent
+```
+
+**3. Run directly**
+
+```bash
+python3 agentexample/agent67/run.py --claude
+```
+
+The `--claude` flag forces the Claude Code backend (`claude -p --resume`), bypassing any API-key provider in the config. Session persistence via `--resume` means the LLM retains full conversation history, eliminating hallucination from lost context.
+
+> `--strict-mcp-config` is passed automatically to isolate MCP tools -- the agent only sees tools declared in your YAML, not host-level MCP servers (Vercel, Gmail, etc.).
+
+---
+
 ## Simple Agent
 
 ```python
@@ -34,7 +75,16 @@ agent = Loop(
 )
 ```
 
-## From YAML
+## From YAML (API-Key Providers)
+
+For providers that require an API key (Anthropic, OpenAI, DashScope, etc.):
+
+```yaml
+model:
+  provider: anthropic          # or openai, dashscope, deepseek, moonshot, ollama
+  name: claude-sonnet-4-20250514
+  temperature: 0.3
+```
 
 ```python
 from lambdagent import from_config
@@ -43,6 +93,8 @@ agent = from_config("agent-config.yml")
 result = agent("Your question here")
 ```
 
+Set the corresponding environment variable (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `DASHSCOPE_API_KEY`, etc.) before running.
+
 ## One-Sentence Builder
 
 ```bash

+ 330 - 93
docs/runtime-spec.md

@@ -59,15 +59,16 @@ Oracle 调用               MCP / CLI Tool 调用
 │  └──────────────────────────────────────────────┘  │
 ├─────────────────────────────────────────────────────┤
 │                    基础设施层                         │
-│  ┌──────────┐  ┌────────────┐  ┌────────────────┐  │
-│  │ LLM 适配器│  │ MCP Client │  │ Memory Backend │  │
-│  │ (Anthropic│  │ (HTTP/SSE/ │  │ (Local/Redis)  │  │
-│  │  OpenAI   │  │  stdio)    │  │                │  │
-│  │  DashScope│  └────────────┘  └────────────────┘  │
-│  │  ...)     │  ┌────────────┐  ┌────────────────┐  │
-│  └──────────┘  │ CLI Agent   │  │ Trace Store    │  │
-│                │ (subprocess)│  │ (JSON/SQLite)  │  │
-│                └────────────┘  └────────────────┘  │
+│  ┌──────────────┐ ┌────────────┐ ┌────────────────┐│
+│  │ LLMProvider   │ │ MCP Client │ │ Memory Backend ││
+│  │ ┌ClaudeCode─┐ │ │ (HTTP/SSE/ │ │ (Local/Redis)  ││
+│  │ │(--resume)  │ │ │  stdio)    │ │                ││
+│  │ ├Anthropic──┤ │ └────────────┘ └────────────────┘│
+│  │ │(Messages)  │ │ ┌────────────┐ ┌────────────────┐│
+│  │ ├OpenAICompat┤ │ │ CLI Agent  │ │ Trace Store    ││
+│  │ │(Ollama/..) │ │ │ (subprocess│ │ (JSON/SQLite)  ││
+│  │ └───────────┘ │ └────────────┘ └────────────────┘│
+│  └──────────────┘                                    │
 ├─────────────────────────────────────────────────────┤
 │                    多智能体 / 协议层                   │
 │  Channel  │ SharedMemory │ GroupChat │ Handoff      │
@@ -88,7 +89,8 @@ Runtime.run(term, input) =
 
 Executor.reduce(term, input, ctx) =
     match term with
-    | Lam(p, θ)         → LLMAdapter.call(θ, p, input)      ← 调 LLM
+    | ConversationLam(prov, h) → prov.chat(h ++ [input])     ← 对话感知 LLM
+    | Lam(p, θ)         → LLMAdapter.call(θ, p, input)      ← 无状态 LLM (legacy)
     | Compose(f, g)     → reduce(g, reduce(f, input))        ← 链式规约
     | Loop(body, cond)  → ReActEngine.run(body, cond, input) ← Y 组合子
     | Tool(name, fn)    → ToolInvoker.call(fn, input)        ← 调工具
@@ -143,8 +145,12 @@ class Executor:
 def reduce(self, term: Term, input: Any, ctx: Context) -> Any:
     match type(term):
 
+        case ConversationLam:
+            # β-规约 with 对话记忆 = provider.chat(history ++ [input])
+            return self._reduce_conversation_lam(term, input, ctx)
+
         case Lam:
-            # β-规约 = LLM 前向传播 + 自回归解码
+            # β-规约 = LLM 前向传播 + 自回归解码 (legacy, stateless)
             return self._reduce_lam(term, input, ctx)
 
         case Compose:
@@ -238,6 +244,160 @@ def _reduce_lam(self, lam: Lam, input: Any, ctx: Context) -> Any:
     return result
 ```
 
+### 2.4 ConversationLam 规约:对话感知 LLM 调用
+
+```python
+def _reduce_conversation_lam(self, clam: ConversationLam, input: Any, ctx: Context) -> Any:
+    """
+    (λ_conv x) →β provider(history ++ [x])
+
+    与无状态 Lam 的区别:
+        Lam:             每次调用独立 — LLM 只看到当前输入
+        ConversationLam: 每次调用累积 — LLM 看到完整对话历史
+
+    ConversationLam.apply(input) 内部流程:
+        1. self.messages.append({"role": "user", "content": input})
+        2. managed = self._manage_context()  # 上下文窗口管理
+        3. response = self.provider.chat(managed)
+        4. self.messages.append({"role": "assistant", "content": response})
+        5. return output_parser(response)
+
+    上下文管理策略:
+        - 始终保留 system message
+        - 始终保留最近 N 轮 (keep_recent_turns = 20)
+        - 超过 max_history_tokens 时,旧消息被压缩为 "[对话历史摘要]"
+        - token 估算: 4 chars ≈ 1 token
+    """
+    t0 = time.time()
+    result = clam.apply(input, ctx)
+    duration = (time.time() - t0) * 1000
+    ctx.log(clam._name, clam._trace_id, input, result, duration, clam.model)
+    return result
+```
+
+#### ConversationLam 的 Lambda 语义
+
+```
+ConversationLam(provider, system_prompt) =
+    λx. let h' = h ++ [("user", x)] in
+        let managed = window(h', max_tokens) in
+        let response = provider.chat(managed) in
+        let h'' = h' ++ [("assistant", response)] in
+        (response, h'')
+
+其中 window(h, n) = if tokens(h) <= n then h
+                     else [system] ++ summarize(old(h)) ++ recent(h)
+```
+
+这消除了 ReAct 循环中的"失忆"问题: LLM 在每一步都能看到完整对话,
+而不是只看到一个通过字符串拼接压缩的状态。
+
+### 2.5 Provider 抽象层
+
+Provider 是 LLM 调用的最底层抽象——只负责消息传输,不负责历史管理。
+
+```python
+class LLMProvider(ABC):
+    """
+    统一的 LLM Provider 接口。
+
+    Lambda 语义:
+        LLMProvider = λ messages. LLM_response
+        所有 provider 实现同一个 contract: messages in, text out.
+
+    接口:
+        chat(messages: list[dict]) -> str
+
+    其中 messages = [
+        {"role": "system", "content": "..."},
+        {"role": "user",   "content": "..."},
+        {"role": "assistant", "content": "..."},
+        ...
+    ]
+    """
+
+    @abstractmethod
+    def chat(self, messages: List[Dict[str, str]]) -> str: ...
+
+    @property
+    def model_name(self) -> str: ...
+
+    @property
+    def context_window(self) -> int: ...
+```
+
+#### 已实现的 Provider
+
+| Provider | 传输方式 | 会话持久化 | 适用场景 |
+|---|---|---|---|
+| `ClaudeCodeProvider` | subprocess (claude CLI) | `--resume <session_id>` (原生) | 需要完整上下文记忆的复杂 ReAct 任务 |
+| `AnthropicProvider` | HTTP (Messages API) | 由 ConversationLam 管理 | 标准 Anthropic API 调用 |
+| `OpenAICompatProvider` | HTTP (Chat Completions) | 由 ConversationLam 管理 | OpenAI / Ollama / DashScope / DeepSeek / Moonshot / Zhipu |
+
+#### ConversationLam 与 Provider 的职责分离
+
+```
+ConversationLam (对话层)          LLMProvider (传输层)
+─────────────────────            ─────────────────────
+管理 messages 列表                只关心 messages -> str
+上下文窗口控制                    不知道历史
+摘要压缩旧消息                    不知道 token 预算
+记录 assistant 回复               不记录任何东西
+输出解析                          不解析输出
+```
+
+### 2.6 Session 持久化机制
+
+不同 provider 的会话持久化方式不同:
+
+#### ClaudeCodeProvider: 原生 Session
+
+```
+第 1 次调用:
+    claude -p "input" --system-prompt "..." --output-format json
+    → 返回 {session_id: "abc123", result: "..."}
+    → 保存 self._session_id = "abc123"
+
+第 2+ 次调用:
+    claude -p "new input" --resume abc123 --output-format text
+    → Claude CLI 自动加载完整对话历史
+    → 只需发送增量内容 (latest observation)
+
+reset_session():
+    self._session_id = None
+    → 下次调用创建全新会话
+```
+
+这是最高效的模式: 后续 ReAct 步骤只发送最新的工具观察结果,
+Claude Code CLI 端自动保持完整上下文。
+
+#### AnthropicProvider / OpenAICompatProvider: ConversationLam 管理
+
+```
+每次调用:
+    ConversationLam 将 full messages array 传给 provider.chat()
+    Provider 将 messages 转发给 HTTP API
+    API 端无状态 — 每次都是独立请求
+
+上下���管理:
+    ConversationLam._manage_context() 在每次调用前检查 token 预算
+    超出时进行 sliding window 压缩
+```
+
+#### session 检测在 ReAct 中的作用
+
+```python
+# react_engine 的 _build_prompt 根据 think 的类型调整行为:
+# ConversationLam 暴露 _session_id property (来自底层 provider)
+
+if hasattr(self.think, '_session_id') and self.think._session_id:
+    # Session mode: 只发送增量 (observation)
+    # Provider 已有完整上下文
+else:
+    # Stateless mode: 发送完整状态
+    # ConversationLam 负责历史管理
+```
+
 ---
 
 ## 3. ReAct Loop Engine: Y 组合子的真实执行
@@ -283,7 +443,7 @@ class ReActEngine:
 
     def __init__(
         self,
-        think: Lam,                      # 推理 Agent
+        think: ConversationLam | Lam,    # 推理 Agent (优先 ConversationLam)
         tools: Dict[str, Tool],          # 工具集(含 terminate)
         action_parser: ActionParser,     # 动作解析器
         memory: MemoryBackend,           # 记忆后端
@@ -751,80 +911,144 @@ def _force_terminate(self, state: str, ctx: Context) -> str:
 
 ---
 
-## 6. LLM 适配器: 多模型统一接口
+## 6. LLM Provider 层: 多模型统一接口
+
+### 6.1 新架构: LLMProvider (v2)
 
-### 6.1 接口
+v2 采用 `LLMProvider` 接口替代旧的 `LLMAdapter`。核心变化:
+- 接口从 `call(model, system, user)` 变为 `chat(messages: list[dict]) -> str`
+- 会话管理由 `ConversationLam` 负责,Provider 只负责传输
+- Provider 通过 `_create_provider()` 工厂函数创建,不通过注册表
 
 ```python
-class LLMAdapter:
+class LLMProvider(ABC):
     """
-    LLM 调用的统一接口
+    统一的 LLM Provider 接口 (v2)
 
     Lambda 语义:
-        LLMAdapter = λ(model, system, user). LLM_{model}(system, user)
+        LLMProvider = λ messages. response_text
 
-    支持多 Provider:
-        - Anthropic (Claude)
-        - OpenAI (GPT)
-        - DashScope (Qwen)
-        - 自定义 (baseUrl)
+    与旧 LLMAdapter 的区别:
+        LLMAdapter: call(model, system, user) → LLMResponse  (model-centric)
+        LLMProvider: chat(messages) → str                     (conversation-centric)
+
+    已实现的 Provider:
+        - ClaudeCodeProvider  (subprocess, session persistence)
+        - AnthropicProvider   (HTTP, Anthropic Messages API)
+        - OpenAICompatProvider(HTTP, OpenAI Chat Completions API)
     """
 
-    def call(
-        self,
-        model: str,
-        system: str,
-        user: str,
-        temperature: float = 0.0,
-        max_tokens: int = 1024,
-        stop_sequences: List[str] = None,
-    ) -> LLMResponse:
+    def __init__(self, config: ProviderConfig): ...
+
+    @abstractmethod
+    def chat(self, messages: List[Dict[str, str]]) -> str:
         """
-        统一的 LLM 调用接口
-
-        根据 model 字符串自动路由到对应 Provider:
-            "claude-*"        → Anthropic
-            "gpt-*"           → OpenAI
-            "qwen*"           → DashScope
-            "anthropic/*"     → Anthropic
-            "openai/*"        → OpenAI
-            "dashscope/*"     → DashScope
-            "custom/http://..." → 自定义端点
+        发送 messages 给 LLM,返回 response 文本。
+
+        Args:
+            messages: [{"role": "system/user/assistant", "content": "..."}]
+
+        Returns:
+            assistant 的 response 文本。
+
+        Raises:
+            ProviderError: API/连接失败。
         """
+        ...
 
-@dataclass
-class LLMResponse:
-    text: str
-    model: str
-    usage: TokenUsage
-    finish_reason: str
+    @property
+    def model_name(self) -> str: ...
+
+    @property
+    def context_window(self) -> int: ...
 
 @dataclass
-class TokenUsage:
-    input_tokens: int
-    output_tokens: int
-    total_tokens: int
+class ProviderConfig:
+    model: str = ""
+    temperature: float = 0.3
+    max_tokens: int = 4096
+    timeout: int = 600
+    context_window: int = 200000
+    extra: Dict[str, Any] = field(default_factory=dict)
+```
+
+### 6.2 Provider 工厂: _create_provider
+
+```python
+def _create_provider(model_cfg: Dict) -> (LLMProvider, bool):
+    """
+    工厂函数: 根据 YAML model 配置创建 Provider 实例。
+
+    路由逻辑 (CASE dispatch):
+        model.provider == "claude-code"  → ClaudeCodeProvider
+        model.provider == "anthropic"    → AnthropicProvider
+        model.provider ∈ {"openai", "ollama", "dashscope",
+                          "deepseek", "moonshot", "zhipu"}
+                                         → OpenAICompatProvider
+
+    返回 (provider, use_conversation):
+        use_conversation 由 model.conversation 字段控制 (默认 true)
+        true  → 编译器将 provider 包装为 ConversationLam
+        false → 编译器使用旧的 Lam (无状态)
+    """
 ```
 
-### 6.2 Provider 注册
+### 6.3 ClaudeCodeProvider: Session 持久化
 
 ```python
-class ProviderRegistry:
+class ClaudeCodeProvider(LLMProvider):
     """
-    Provider 注册表。
+    基于 Claude Code CLI 的 Provider。
+
+    传输方式: subprocess 调用 `claude` CLI
+    会话持久化: --resume <session_id>
+
+    chat() 流程:
+        第 1 次:
+            1. claude -p "input" --system-prompt "..." --output-format json
+            2. 解析 JSON: {session_id, result}
+            3. 保存 self._session_id
+            4. 返回 result
+
+        第 2+ 次:
+            1. 提取 messages 中最新的 user message
+            2. claude -p "latest" --resume <session_id> --output-format text
+            3. 返回 stdout
 
     Lambda 语义:
-        Registry = CASE model_prefix [
-            ("claude", anthropic_provider),
-            ("gpt",   openai_provider),
-            ("qwen",  dashscope_provider),
-            (_,       custom_provider),
-        ]
+        chat₁ = λ msgs. let (sid, r) = claude_new(msgs) in (r, sid)
+        chatₙ = λ msgs. claude_resume(sid, last(msgs))
+    """
+
+    def chat(self, messages: List[Dict[str, str]]) -> str:
+        if self._session_id:
+            return self._call_resume(messages)  # 只发最新消息
+        else:
+            return self._call_new(messages)     # 创建 session
+
+    def reset_session(self):
+        """Reset session — 下次调用创建新会话。"""
+        self._session_id = None
+```
+
+### 6.4 OpenAICompatProvider: 无状态 HTTP
+
+```python
+class OpenAICompatProvider(LLMProvider):
     """
-    providers: Dict[str, LLMProvider]
+    兼容 OpenAI Chat Completions API 的 Provider。
 
-    def register(self, prefix: str, provider: LLMProvider): ...
-    def resolve(self, model: str) -> LLMProvider: ...
+    支持: OpenAI / Ollama / DashScope / DeepSeek / Moonshot / Zhipu
+
+    chat() 流程:
+        1. 根据 provider_name 确定 base_url 和 api_key
+        2. POST /v1/chat/completions {"model": ..., "messages": [...]}
+        3. 解析 response.choices[0].message.content
+        4. 返回文本
+
+    无状态: 每次调用都是独立的 HTTP 请求,
+    由 ConversationLam 在调用前组装完整 messages 数组。
+    """
 ```
 
 ---
@@ -1170,33 +1394,34 @@ class TerminationConfig:
     │ .reduce()│
     └────┬────┘
-    ┌────▼─────────────────────────────────────────────┐
-    │                Match Term Type                     │
-    ├──────────┬──────────┬──────────┬──────────────────┤
-    │   Lam    │  Compose  │  Loop   │  Tool/Route/...  │
-    │          │           │(ReAct)  │                   │
-    └────┬─────┴─────┬────┴────┬────┴──────────────────┘
-         │           │         │
-    ┌────▼────┐ ┌───▼───┐ ┌──▼──────────────────────┐
-    │LLMAdapter│ │ f>>g  │ │    ReActEngine.run()    │
-    │  .call() │ │ 链式  │ │                          │
-    └────┬────┘ │ 规约   │ │  ┌─────────────────────┐ │
-         │      └───┬───┘ │  │ Step Loop (Y 展开)   │ │
-         │          │      │  │                       │ │
-    ┌────▼──────────▼──┐   │  │ 1. THINK (LLM)      │ │
-    │    LLM API Call   │   │  │ 2. PARSE (Action)   │ │
-    │  (Anthropic/      │   │  │ 3. ROUTE (CASE)     │ │
-    │   OpenAI/         │   │  │ 4. INVOKE (Tool)    │ │
-    │   DashScope)      │   │  │    ├─ MCP (HTTP)    │ │
-    └────┬─────────────┘   │  │    ├─ CLI (subproc)  │ │
-         │                  │  │    └─ terminate(λx.x)│ │
-    ┌────▼─────────────┐   │  │ 5. OBSERVE (format)  │ │
-    │   LLM Response    │   │  │ 6. UPDATE (Memory)   │ │
-    │   + Token Usage   │   │  │ 7. CHECK (terminate?)│ │
-    └────┬─────────────┘   │  │                       │ │
-         │                  │  │  ── Loop or Return ── │ │
-         │                  │  └─────────────────────┘ │
-         │                  └─────────────┬────────────┘
+    ┌────▼──────────────────────────────────────────────────┐
+    │                   Match Term Type                      │
+    ├───────────────┬──────────┬──────────┬─────────────────┤
+    │ConversationLam│  Compose  │  Loop   │ Tool/Route/...  │
+    │  (preferred)  │           │(ReAct)  │                 │
+    │  Lam (legacy) │           │         │                 │
+    └───────┬───────┴─────┬────┴────┬────┴─────────────────┘
+            │             │         │
+    ┌───────▼───────┐┌───▼───┐ ┌──▼──────────────────────┐
+    │ConversationLam││ f>>g  │ │    ReActEngine.run()     │
+    │   .apply()    ││ 链式  │ │                           │
+    │  ┌──────────┐ ││ 规约  │ │  ┌──────────────────────┐│
+    │  │ manage   │ │└───┬───┘ │  │ Step Loop (Y 展开)   ││
+    │  │ context  │ │    │     │  │                        ││
+    │  └────┬─────┘ │    │     │  │ 1. THINK (Conv.Lam)   ││
+    │  ┌────▼─────┐ │    │     │  │ 2. PARSE (Action)     ││
+    │  │ Provider  │ │    │     │  │ 3. ROUTE (CASE)       ││
+    │  │  .chat()  │ │    │     │  │ 4. INVOKE (Tool)      ││
+    │  └────┬─────┘ │    │     │  │    ├─ MCP (HTTP)       ││
+    │       │       │    │     │  │    ├─ CLI (subproc)    ││
+    │  ┌────▼──────┐│    │     │  │    └─ terminate(λx.x)  ││
+    │  │LLM Call   ││    │     │  │ 5. OBSERVE (format)    ││
+    │  │ Anthropic ││    │     │  │ 6. UPDATE (Memory)     ││
+    │  │ ClaudeCode││    │     │  │ 7. CHECK (terminate?)  ││
+    │  │ Ollama/.. ││    │     │  │                        ││
+    │  └────┬──────┘│    │     │  │  ── Loop or Return ──  ││
+    │       │       │    │     │  └──────────────────────┘ │
+    └───────┼───────┘    │     └──────────────┬────────────┘
          │                                │
     ┌────▼────────────────────────────────▼───────┐
     │              Context / Trace                  │
@@ -1331,8 +1556,11 @@ Runtime.execute
          └── Executor.reduce
-              ├── LLMAdapter          ← P0
-              │   └── ProviderRegistry ← P1
+              ├── ConversationLam      ← 已完成
+              │   └── LLMProvider      ← 已完成
+              │       ├── ClaudeCodeProvider   (session persistence)
+              │       ├── AnthropicProvider     (Messages API)
+              │       └── OpenAICompatProvider  (Ollama/OpenAI/...)
               ├── ReActEngine          ← P0
               │   ├── ActionParser     ← P0
@@ -1358,8 +1586,17 @@ lambdagent/
 ├── core.py                  # 已有 (Term, Context, TraceEntry)
 ├── primitives.py            # 已有 (Lam, Compose, Loop, Tool, ...)
 ├── extensions.py            # 已有 (Memory, Route, Guard, Par)
+├── conversation.py          # 新增: ConversationLam (对话感知 Lambda 抽象)
 ├── dataset.py               # 已有
-├── from_config.py           # 已有 (YAML → Term 编译器)
+├── fromconfig/
+│   ├── compiler.py          # 已有 (YAML → Term 编译器, 含 _create_provider)
+│   ├── errors.py            # 已有
+│   └── schema.py            # 已有
+├── providers/               # 新增: LLM Provider 层
+│   ├── base.py              # LLMProvider ABC + ProviderConfig + ProviderError
+│   ├── claude_code_provider.py  # ClaudeCodeProvider (session persistence)
+│   ├── anthropic_provider.py    # AnthropicProvider (Messages API)
+│   └── openai_compat_provider.py # OpenAICompatProvider (Ollama/OpenAI/...)
 ├── lint.py                  # 已有
 ├── runtime/                 # ← 新增: 运行时

+ 56 - 5
docs/usage.md

@@ -14,10 +14,11 @@ pip install pyyaml anthropic
 # 进入项目目录
 cd /home/67/LDS/LLM-Dataset-System
 
-# 设置 API Key (使用前必须)
+# 设置 API Key (provider: claude-code 模式不需要 API Key)
 export ANTHROPIC_API_KEY="sk-..."
 # 或 OpenAI:
 # export OPENAI_API_KEY="sk-..."
+# 或 无需 Key — 使用 provider: claude-code (需要 Claude Code CLI 已安装)
 ```
 
 ## 1. 编译 YAML 配置 -> Lambda 项 (不执行)
@@ -103,7 +104,37 @@ python -m lambdagent repl agent-cofig.yml
 python -m lambdagent lambda agent-cofig.yml
 ```
 
-## 6. 手写 Agent (Python DSL)
+## 6. ConversationLam — 多轮会话持久化
+
+`ConversationLam` 是有状态的 Lambda 抽象,每次 `apply()` 保留完整对话历史:
+
+```python
+from lambdagent.conversation import ConversationLam
+from lambdagent.providers.claude_code_provider import ClaudeCodeProvider
+
+# 创建 provider(claude-code 无需 API Key)
+provider = ClaudeCodeProvider({"model": "sonnet"})
+
+# 创建有状态 Agent
+lam = ConversationLam(
+    name="assistant",
+    provider=provider,
+    system_prompt="You are a helpful assistant.",
+    max_history_tokens=80000,
+    keep_recent_turns=20,
+)
+
+# 多轮对话 — 每次调用都记住之前的内容
+r1 = lam.apply("帮我读一下 README 文件")
+r2 = lam.apply("[tool result] README 内容...")    # LLM 看到 r1 + r2
+r3 = lam.apply("总结一下我们刚才做了什么")         # LLM 看到 r1 + r2 + r3
+```
+
+Lambda 语义:`ConversationLam(provider, prompt) = lambda x. provider(history ++ [x])`
+
+与无状态 `Lam` 的区别:`Lam` 每次调用独立,`ConversationLam` 基于完整历史。这解决了 ReAct 多步循环中的幻觉问题。
+
+## 7. 手写 Agent (Python DSL)
 
 ```python
 from lambdagent import Lam, Compose, Tool, Loop, Route, Guard, Memory, Par
@@ -162,9 +193,20 @@ agent = Loop(
 )
 ```
 
-## 7. 自定义 YAML 配置示例
+## 8. 自定义 YAML 配置示例
 
-### Simple Agent
+### Simple Agent (claude-code, 无需 API Key)
+```yaml
+agentId: my-agent
+name: MyAgent
+type: simple
+systemPrompt: "You are a helpful coding assistant."
+model:
+  provider: claude-code
+  name: sonnet
+```
+
+### Simple Agent (anthropic API)
 ```yaml
 agentId: my-agent
 name: MyAgent
@@ -176,6 +218,15 @@ model:
   temperature: 0.0
 ```
 
+### Provider 统一系统
+
+支持五个核心 Provider,通过 `model.provider` 字段切换:
+- `claude-code` — Claude Code CLI(无需 API Key)
+- `anthropic` — Anthropic API
+- `ollama` — 本地 Ollama 推理
+- `openai` — OpenAI API
+- `dashscope` — 阿里云 DashScope
+
 ### Chain Agent
 ```yaml
 type: chain
@@ -232,7 +283,7 @@ parallel:
   mergePrompt: "Synthesize the optimistic and pessimistic analyses into a balanced view."
 ```
 
-## 8. 核心对应关系
+## 9. 核心对应关系
 
 ```
 YAML                    Python (lambdagent)              Lambda 演算

+ 273 - 2
docs/yaml-config.md

@@ -1,3 +1,274 @@
-# uyaml config
+# YAML 配置参考
 
-Documentation coming soon. See README.md for current usage.
+> `from_config("agent-config.yml")` 将 YAML 编译为可执行的 Lambda Term。
+> 本文档覆盖完整 YAML Schema 及每个字段的语义。
+
+---
+
+## 1. 完整 Schema 概览
+
+```yaml
+# ═══ 必填字段 ═══
+agentId: string               # Agent 唯一标识
+name: string                  # Agent 名称
+type: enum                    # react | chain | simple | parallel | router
+systemPrompt: string          # 系统提示词 (Lambda body)
+
+# ═══ 模型配置 ═══
+model:
+  provider: string            # 见下方「Provider 列表」
+  name: string                # 模型名 (可省略,使用 provider 默认值)
+  temperature: float          # 0.0 - 1.0, 默认 0.3
+  maxTokens: int              # 单次回复最大 token, 默认 4096
+  timeout: int                # 请求超时 (秒), 默认 600
+  conversation: bool          # 启用 ConversationLam, 默认 true
+  maxHistoryTokens: int       # 对话历史 token 预算, 默认 min(contextWindow/2, 80000)
+  contextWindow: int          # 模型最大上下文窗口, 默认 200000
+  fallback:                   # 降级模型列表
+    - "provider/model-name"
+
+# ═══ ReAct 配置 ═══
+react:
+  maxSteps: int               # 最大推理步数, 默认 20
+  observationEnabled: bool    # 启用 Observation 步骤, 默认 true
+  toolTimeout: int            # 单工具超时 (秒)
+  thinkTimeout: int           # 思考步骤超时 (秒)
+
+# ═══ 工具 (MCP) ═══
+mcp:
+  localTools:                 # 允许使用的工具名称列表
+    - ToolName
+  policy:
+    mode: auto                # auto | confirm | deny
+
+# ═══ 记忆 ═══
+memory:
+  enabled: bool
+  strategy: local | redis
+  size: int                   # 记忆条目上限
+  ttl: int                    # 过期时间 (秒)
+
+# ═══ 安全 ═══
+guard:
+  dangerousCommandBlock: bool
+  highRiskConfirmation: bool
+  maxOutputLength: int
+  retry: int
+  fallback: last | error
+
+# ═══ 人设 ═══
+persona:
+  name: string
+  style: string
+  template: string
+```
+
+---
+
+## 2. Provider 列表
+
+`model.provider` 决定 LLM 调用方式。三类 Provider 实现:
+
+| provider 值 | Provider 类 | 说明 | API Key |
+|---|---|---|---|
+| `claude-code` | `ClaudeCodeProvider` | 通过 `claude -p --resume` 调用, 复用 Claude Code Max Plan | 不需要 |
+| `anthropic` | `AnthropicProvider` | Anthropic Messages API | `ANTHROPIC_API_KEY` |
+| `openai` | `OpenAICompatProvider` | OpenAI API | `OPENAI_API_KEY` |
+| `ollama` | `OpenAICompatProvider` | 本地 Ollama (http://localhost:11434) | 不需要 |
+| `dashscope` | `OpenAICompatProvider` | 阿里云 DashScope (通义千问) | `DASHSCOPE_API_KEY` |
+| `deepseek` | `OpenAICompatProvider` | DeepSeek API | `DEEPSEEK_API_KEY` |
+| `moonshot` | `OpenAICompatProvider` | Moonshot AI (Kimi) | `MOONSHOT_API_KEY` |
+
+### Provider 默认模型
+
+| provider | 默认 model.name |
+|---|---|
+| `claude-code` | `sonnet` |
+| `anthropic` | `claude-sonnet-4-20250514` |
+| `openai` | `gpt-4o` |
+| `ollama` | `qwen2.5:7b` |
+| `dashscope` | `qwen-max` |
+| `deepseek` | `gpt-4o` (需显式指定) |
+| `moonshot` | `gpt-4o` (需显式指定) |
+
+### Provider 默认 contextWindow
+
+| provider | contextWindow |
+|---|---|
+| `claude-code` / `anthropic` / `openai` | 200,000 |
+| `moonshot` | 128,000 |
+| `deepseek` | 64,000 |
+| `ollama` | 32,000 |
+
+---
+
+## 3. ConversationLam 与对话历史
+
+当 `model.conversation: true` (默认) 时, `from_config` 创建 `ConversationLam` 而非无状态 `Lam`。
+
+**ConversationLam 的作用:**
+- 包裹任意 Provider, 自动管理对话历史
+- 每次调用追加 user/assistant 消息对
+- 按 `maxHistoryTokens` 预算自动截断旧消息, 保留 system prompt
+- 消除因上下文丢失导致的幻觉 (session persistence)
+
+**编译等式:**
+
+```
+conversation: true  →  ConversationLam(provider, system_prompt, max_history_tokens)
+conversation: false →  Lam(name, prompt, model)   # 无状态, 每次调用独立
+```
+
+**相关字段:**
+
+| 字段 | 类型 | 默认值 | 说明 |
+|---|---|---|---|
+| `model.conversation` | bool | `true` | 启用 ConversationLam |
+| `model.maxHistoryTokens` | int | `min(contextWindow/2, 80000)` | 对话历史 token 预算 |
+| `model.contextWindow` | int | 按 provider 自动设置 | 模型最大上下文窗口 |
+
+---
+
+## 4. 各 Provider 配置示例
+
+### Claude Code Max Plan (无需 API Key)
+
+```yaml
+agentId: my-agent
+name: my-agent
+type: react
+
+model:
+  provider: claude-code
+  name: sonnet                 # claude CLI 内部映射
+  temperature: 0.3
+  maxTokens: 4096
+
+systemPrompt: |
+  You are a helpful coding assistant.
+```
+
+运行: `python3 agentexample/agent67/run.py --claude`
+
+### Anthropic API
+
+```yaml
+model:
+  provider: anthropic
+  name: claude-sonnet-4-20250514
+  temperature: 0.3
+  maxTokens: 4096
+```
+
+需设置: `export ANTHROPIC_API_KEY=sk-ant-...`
+
+### OpenAI API
+
+```yaml
+model:
+  provider: openai
+  name: gpt-4o
+  temperature: 0.5
+  maxTokens: 4096
+```
+
+需设置: `export OPENAI_API_KEY=sk-...`
+
+### DashScope (通义千问)
+
+```yaml
+model:
+  provider: dashscope
+  name: qwen-max
+  temperature: 0.3
+  maxTokens: 4096
+```
+
+需设置: `export DASHSCOPE_API_KEY=sk-...`
+
+### Ollama (本地推理)
+
+```yaml
+model:
+  provider: ollama
+  name: qwen2.5:7b
+  temperature: 0.3
+  maxTokens: 4096
+  contextWindow: 32000
+```
+
+需先启动: `ollama serve`
+
+### DeepSeek
+
+```yaml
+model:
+  provider: deepseek
+  name: deepseek-chat
+  temperature: 0.3
+  maxTokens: 4096
+  contextWindow: 64000
+```
+
+需设置: `export DEEPSEEK_API_KEY=sk-...`
+
+### Moonshot (Kimi)
+
+```yaml
+model:
+  provider: moonshot
+  name: moonshot-v1-128k
+  temperature: 0.3
+  maxTokens: 4096
+  contextWindow: 128000
+```
+
+需设置: `export MOONSHOT_API_KEY=sk-...`
+
+---
+
+## 5. 工具参数 Schema 自动生成
+
+`from_config` 在编译时会自动扫描 `mcp.localTools` 中声明的工具, 从 `BUILTIN_TOOLS` 注册表提取每个工具的参数签名 (类名、必填/可选参数), 生成格式化文档并注入到 system prompt 末尾。
+
+**目的:** LLM 调用工具时能使用准确的参数名, 避免因参数名猜测错误导致 `VALIDATION_ERROR` 进而引发幻觉。
+
+**生成格式示例:**
+
+```
+## 工具参数参考 (Tool Parameter Reference)
+调用工具时请严格使用以下参数名:
+
+- **ReadFile**: `{"action":"ReadFile","input":{"file_path": ..., "offset": 0, "limit": 2000}}`
+- **Bash**: `{"action":"Bash","input":{"command": ...}}`
+- **terminate**: `{"action":"terminate","input":{"summary":"结果摘要"}}`
+```
+
+该文档块在 `_generate_tool_schema_docs()` 中生成, 仅当 `mcp.localTools` 非空时注入。
+
+---
+
+## 6. --strict-mcp-config 工具隔离
+
+`ClaudeCodeProvider` 在调用 `claude -p` 时自动传入:
+
+```
+--mcp-config '{"mcpServers":{}}' --strict-mcp-config
+```
+
+效果: Claude CLI 只能看到 agent YAML 中声明的工具, 宿主机上安装的 MCP Server (如 Vercel, Gmail) 完全不可见。这防止了 LLM 调用未预期的工具导致安全问题或幻觉。
+
+---
+
+## 7. fallback 降级链
+
+```yaml
+model:
+  provider: claude-code
+  name: sonnet
+  fallback:
+    - anthropic/claude-sonnet-4-20250514
+    - dashscope/qwen-max
+```
+
+当主 provider 失败时, 按顺序尝试 fallback 列表中的模型。格式为 `provider/model-name`。

+ 53 - 0
lambdagent/agentruntime/react_engine.py

@@ -12,6 +12,24 @@ from .memory_backend import MemoryBackend
 from .trace_store import TraceStore, TraceRecord
 
 
+# Step event types for streaming callbacks
+STEP_THINK = "think"
+STEP_TOOL_CALL = "tool_call"
+STEP_TOOL_RESULT = "tool_result"
+STEP_ERROR = "error"
+STEP_ANSWER = "answer"
+
+
+@dataclass
+class StepEvent:
+    """One streaming event emitted during ReAct execution."""
+    type: str            # STEP_THINK, STEP_TOOL_CALL, STEP_TOOL_RESULT, STEP_ERROR, STEP_ANSWER
+    step: int
+    content: str         # The text payload
+    tool: str = ""       # Tool name (for tool_call / tool_result)
+    duration_ms: float = 0
+
+
 @dataclass
 class StepResult:
     """Result of one Y combinator unfolding."""
@@ -49,6 +67,7 @@ class ReActEngine:
         tool_timeout: int = 30,
         observation_enabled: bool = True,
         system_prompt: str = "",
+        on_step: Any = None,
     ):
         self.think = think
         self.tools = tools
@@ -60,6 +79,15 @@ class ReActEngine:
         self.tool_timeout = tool_timeout
         self.observation_enabled = observation_enabled
         self.system_prompt = system_prompt
+        self.on_step = on_step  # callback(StepEvent) -> None
+
+    def _emit(self, event: StepEvent):
+        """Emit a step event to the streaming callback if registered."""
+        if self.on_step:
+            try:
+                self.on_step(event)
+            except Exception:
+                pass  # Don't let callback errors break the loop
 
     def run(self, input_text: str, ctx: Context) -> str:
         """Execute full ReAct loop. Equivalent to Y_n(react_body)(input)."""
@@ -71,12 +99,20 @@ class ReActEngine:
 
             if result.terminated:
                 final_answer = result.answer
+                self._emit(StepEvent(
+                    type=STEP_ANSWER, step=step,
+                    content=final_answer or "",
+                ))
                 break
 
             state = result.next_state
 
         if final_answer is None:
             final_answer = self._force_terminate(state, ctx)
+            self._emit(StepEvent(
+                type=STEP_ANSWER, step=self.max_steps,
+                content=final_answer,
+            ))
 
         return final_answer
 
@@ -93,6 +129,10 @@ class ReActEngine:
             step=step, term_name="think", term_type="Lam",
             duration_ms=think_ms, input=state[:200], output=str(thought)[:200],
         ))
+        self._emit(StepEvent(
+            type=STEP_THINK, step=step,
+            content=str(thought), duration_ms=think_ms,
+        ))
 
         # ═══ Phase 2: PARSE (extract structured action) ═══
         try:
@@ -136,6 +176,10 @@ class ReActEngine:
             )
 
         # 4b. Tool call
+        self._emit(StepEvent(
+            type=STEP_TOOL_CALL, step=step,
+            content=str(action.input)[:500], tool=action.tool,
+        ))
         t0 = time.time()
         try:
             tool_input = action.input if isinstance(action.input, str) else str(action.input)
@@ -146,6 +190,11 @@ class ReActEngine:
                 duration_ms=tool_ms, input=tool_input[:200], output=str(tool_result)[:200],
                 action=action.tool, action_input=action.input,
             ))
+            self._emit(StepEvent(
+                type=STEP_TOOL_RESULT, step=step,
+                content=str(tool_result)[:1000], tool=action.tool,
+                duration_ms=tool_ms,
+            ))
         except Exception as e:
             tool_result = f"[TOOL_ERROR] {e}"
             self.trace.append(TraceRecord(
@@ -153,6 +202,10 @@ class ReActEngine:
                 input=str(action.input)[:200], output=tool_result, error=str(e),
                 action=action.tool,
             ))
+            self._emit(StepEvent(
+                type=STEP_ERROR, step=step,
+                content=tool_result, tool=action.tool,
+            ))
 
         # ═══ Phase 5: OBSERVE (format observation) ═══
         observation = self._format_observation(action.tool, str(tool_result))

+ 31 - 2
lambdagent/builtin_tools/file_tools.py

@@ -262,9 +262,9 @@ def write_file(input_val: Any) -> str:
 # ════════════════════════════════════════════════════════════
 
 class ListFilesSchema:
-    def __init__(self, pattern: str, path: str = ".", max_results: int = 100):
+    def __init__(self, pattern: str = "**/*", path: str = ".", max_results: int = 100):
         if not pattern:
-            raise ValueError("pattern is required (e.g. '**/*.py')")
+            pattern = "**/*"
         self.pattern = pattern
         self.path = os.path.abspath(path) if path else os.getcwd()
         self.max_results = min(max(1, max_results), 1000)
@@ -474,6 +474,35 @@ def _parse_input(input_val: Any, schema_cls) -> dict:
         else:
             data = {"file_path": str(inner)}
 
+    # Normalize common parameter aliases from LLM output
+    if isinstance(data, dict):
+        # Only alias 'path' → 'file_path' for schemas that use file_path (not ListFiles which uses 'path')
+        _ALIASES = {
+            "filepath": "file_path",
+            "file": "file_path",
+            "old": "old_string",
+            "new": "new_string",
+            "old_text": "old_string",
+            "new_text": "new_string",
+            "cmd": "command",
+            "query": "pattern",
+        }
+        # 'path' → 'file_path' only when schema has file_path param (ReadFile, WriteFile, EditFile)
+        if "path" in data and "file_path" not in data:
+            import inspect
+            params = inspect.signature(schema_cls.__init__).parameters
+            if "file_path" in params and "path" not in params:
+                _ALIASES["path"] = "file_path"
+
+        normalized = {}
+        for k, v in data.items():
+            key = _ALIASES.get(k, k)
+            if key not in normalized:
+                normalized[key] = v
+            elif k not in _ALIASES:
+                normalized[key] = v
+        data = normalized
+
     try:
         validated = schema_cls(**data)
         return validated.dict()

+ 144 - 0
lambdagent/conversation.py

@@ -0,0 +1,144 @@
+"""
+lambdagent.conversation — ConversationLam: conversation-aware Lambda abstraction.
+
+Wraps any LLMProvider with conversation history management.
+Each apply() appends the new input as a user message, calls the provider
+with the full (or windowed) history, and records the response.
+
+This is what eliminates hallucination: the LLM sees its complete
+conversation history, not a lossy compressed state string.
+
+Usage:
+    provider = ClaudeCodeProvider(config)
+    lam = ConversationLam("agent", provider, system_prompt="...")
+
+    r1 = lam.apply("read the README")     # creates conversation
+    r2 = lam.apply("[tool result] ...")     # continues with full memory
+    r3 = lam.apply("[tool result] ...")     # still remembers r1, r2
+"""
+from __future__ import annotations
+
+import time
+from typing import Any, Callable, List, Optional
+
+from lambdagent.core import Term, Context
+from lambdagent.providers.base import LLMProvider, ProviderError
+
+
+class ConversationLam(Term):
+    """
+    Lambda abstraction with conversation persistence.
+
+    Lambda semantics preserved:
+        ConversationLam(provider, prompt) = lambda x. provider(history + x)
+        apply() = beta-reduction with memory
+
+    The key difference from stateless Lam:
+        Lam:             each apply() is independent
+        ConversationLam: each apply() builds on all previous calls
+    """
+
+    def __init__(
+        self,
+        name: str,
+        provider: LLMProvider,
+        system_prompt: str,
+        max_history_tokens: int = 80000,
+        keep_recent_turns: int = 20,
+        output_parser: Callable[[str], Any] | None = None,
+    ):
+        super().__init__(name)
+        self.provider = provider
+        self.system_prompt = system_prompt
+        self.max_history_tokens = max_history_tokens
+        self.keep_recent_turns = keep_recent_turns
+        self.output_parser = output_parser or (lambda x: x)
+
+        # Conversation history (system message always first)
+        self.messages: List[dict] = [
+            {"role": "system", "content": system_prompt}
+        ]
+
+    # Expose model name for react_step logging
+    @property
+    def model(self) -> str:
+        return self.provider.model_name
+
+    # Expose _session_id for react_step session detection
+    @property
+    def _session_id(self):
+        return getattr(self.provider, '_session_id', None)
+
+    def apply(self, input: Any, ctx: Context | None = None) -> Any:
+        """Beta-reduction with conversation memory."""
+        ctx = ctx or Context()
+        t0 = time.time()
+
+        # Append user message
+        self.messages.append({"role": "user", "content": str(input)})
+
+        # Manage context window before sending
+        managed = self._manage_context()
+
+        # Call provider
+        try:
+            response = self.provider.chat(managed)
+        except ProviderError as e:
+            response = f"[{e.provider.upper()}_ERROR] {e}"
+
+        # Record assistant response
+        self.messages.append({"role": "assistant", "content": response})
+
+        duration = (time.time() - t0) * 1000
+        result = self.output_parser(response)
+        ctx.log(self._name, self._trace_id, input, result, duration, self.model)
+        return result
+
+    def _manage_context(self) -> List[dict]:
+        """
+        Ensure messages fit within max_history_tokens.
+
+        Strategy:
+          - Always keep system message
+          - Always keep recent N turns (user+assistant pairs)
+          - Summarize older messages into a single "history summary" message
+        """
+        total_tokens = self._estimate_tokens(self.messages)
+
+        if total_tokens <= self.max_history_tokens:
+            return list(self.messages)
+
+        # Keep system + recent turns
+        system = self.messages[0]
+        # Each turn = 1 user + 1 assistant = 2 messages
+        keep_count = self.keep_recent_turns * 2
+        recent = self.messages[-keep_count:] if len(self.messages) > keep_count else self.messages[1:]
+        old = self.messages[1:-keep_count] if len(self.messages) > keep_count + 1 else []
+
+        if not old:
+            return list(self.messages)
+
+        # Compress old messages into summary
+        summary_lines = []
+        for m in old:
+            role = m["role"]
+            content = m["content"][:150]
+            summary_lines.append(f"[{role}] {content}...")
+
+        summary = "[对话历史摘要]\n" + "\n".join(summary_lines)
+
+        return [system, {"role": "user", "content": summary}] + list(recent)
+
+    def _estimate_tokens(self, messages: List[dict]) -> int:
+        """Rough token estimate: 4 chars ≈ 1 token."""
+        return sum(len(m.get("content", "")) for m in messages) // 4
+
+    def reset(self):
+        """Clear conversation history, start fresh."""
+        self.messages = [{"role": "system", "content": self.system_prompt}]
+        if hasattr(self.provider, 'reset_session'):
+            self.provider.reset_session()
+
+    def __rshift__(self, other):
+        from lambdagent.primitives import Compose
+        return Compose(self, other)

+ 415 - 59
lambdagent/fromconfig/compiler.py

@@ -84,7 +84,7 @@ def _check_implicit_terminate(thought: str) -> bool:
 # ============================================================
 
 _MAX_STATE_WINDOW = 3        # Keep last N steps in full
-_MAX_OBS_LENGTH = 800        # Truncate observations
+_MAX_OBS_LENGTH = 3000       # Truncate observations (800 was too small, causing retry loops)
 _MAX_THOUGHT_LENGTH = 500    # Truncate thoughts in history
 
 
@@ -114,11 +114,23 @@ def _compress_state(state: str, thought: str, tool_name: str, observation: str)
 
     # Add new step
     step_count = len(steps) + 1
+
+    # Anti-hallucination: if tool returned an error, add explicit retry instruction
+    error_prefixes = ("[VALIDATION_ERROR]", "[TOOL_ERROR]", "[ERROR]", "[MCP_ERROR]", "[MCP_TIMEOUT]")
+    is_error = any(observation.startswith(p) for p in error_prefixes)
+    error_hint = ""
+    if is_error:
+        error_hint = (
+            "\n[SYSTEM] The tool call FAILED. You have NOT seen the actual data. "
+            "Do NOT fabricate or guess results. Fix the tool call parameters and retry. "
+            "If unsure about parameters, try a different approach."
+        )
+
     new_step = (
         f"[Step {step_count}]\n"
         f"Thought: {thought}\n"
         f"Action: {tool_name}\n"
-        f"Observation: {observation}"
+        f"Observation: {observation}{error_hint}"
     )
     steps.append(new_step)
 
@@ -183,6 +195,9 @@ def from_config(path: str, **overrides) -> Term:
     if "max_steps" in overrides:
         cfg.setdefault("react", {})["maxSteps"] = overrides["max_steps"]
 
+    # Pass config directory for resolving relative paths (subAgents, etc.)
+    overrides.setdefault("_config_dir", str(os.path.dirname(os.path.abspath(path))))
+
     # Schema validation
     errors = validate_schema(cfg)
     fatal = [e for e in errors if e[0] == "ERROR"]
@@ -201,6 +216,12 @@ def build_agent(cfg: Dict[str, Any], overrides: Dict = None) -> Term:
     overrides = overrides or {}
     agent_type = cfg.get("type", "simple")
 
+    # Step 0: Compile subAgents if present (multi-agent orchestrator support)
+    # subAgents 节定义了子代理,编译后作为 call_* 工具注入到协调者
+    sub_agents_cfg = cfg.get("subAgents", {})
+    if sub_agents_cfg and "tools" not in overrides:
+        overrides = {**overrides, "tools": _compile_sub_agents(cfg, sub_agents_cfg, overrides)}
+
     # Step 0a: Build ToolGateway from guard config (if present)
     guard_cfg = cfg.get("guard") or {}
     gateway = _build_gateway(guard_cfg, overrides)
@@ -216,7 +237,7 @@ def build_agent(cfg: Dict[str, Any], overrides: Dict = None) -> Term:
 
     # Step 1: Compile core agent based on type
     if agent_type == "simple":
-        agent = _compile_simple(cfg)
+        agent = _compile_simple(cfg, overrides)
     elif agent_type == "react":
         agent = _compile_react(cfg, overrides)
     elif agent_type == "chain":
@@ -240,6 +261,147 @@ def build_agent(cfg: Dict[str, Any], overrides: Dict = None) -> Term:
     return agent
 
 
+# ============================================================
+# SubAgent compiler (multi-agent orchestrator support)
+# ============================================================
+
+def _compile_sub_agents(cfg: Dict, sub_agents_cfg: Dict, overrides: Dict) -> Dict[str, Any]:
+    """
+    Compile subAgents section into callable tool functions.
+
+    Supports two modes:
+      1. File reference:  config: agents/code-agent.yml
+      2. Inline config:   inline: {type: react, systemPrompt: ..., ...}
+
+    When PaaS compiles via temp file, file references break (temp dir).
+    Inline mode always works regardless of where the YAML is compiled from.
+
+    subAgents:
+      code-agent:
+        config: agents/code-agent.yml    # mode 1: file reference
+        inline: {type: react, ...}       # mode 2: inline (preferred for PaaS)
+        description: "..."
+        tags: [...]
+        tool: call_code
+    """
+    tools = {}
+
+    config_dir = overrides.get("_config_dir", os.getcwd())
+
+    # Optional: register as Skills for reuse
+    try:
+        from lambdagent.skills import Skill, SkillSignature, SkillPack, SkillRegistry
+        pack = SkillPack(name="subAgents", description="Auto-compiled sub-agents", version="1.0.0")
+        registry = SkillRegistry()
+        has_skills = True
+    except ImportError:
+        has_skills = False
+
+    for agent_name, agent_def in sub_agents_cfg.items():
+        tool_name = agent_def.get("tool", f"call_{agent_name.replace('-agent', '')}")
+
+        # Determine how to compile this sub-agent
+        inline_cfg = agent_def.get("inline")
+        rel_path = agent_def.get("config", "")
+        agent_yml = os.path.join(config_dir, rel_path) if rel_path else ""
+
+        # Resolve source: inline > file > placeholder
+        has_inline = inline_cfg and isinstance(inline_cfg, dict) and "type" in inline_cfg
+        has_file = rel_path and os.path.isfile(agent_yml)
+
+        if not has_inline and not has_file:
+            tools[tool_name] = lambda x, _n=agent_name, _p=agent_yml: (
+                f"[SubAgent {_n} not found: no inline config and file not found at {_p}]"
+            )
+            continue
+
+        # Lazy compilation cache (shared across calls)
+        _compiled_cache = {}
+
+        def _make_caller(_name, _path, _inline):
+            def caller(input_str):
+                if _name not in _compiled_cache:
+                    try:
+                        if _inline:
+                            # Inline mode: compile from dict directly
+                            _compiled_cache[_name] = build_agent(_inline, {})
+                        else:
+                            # File mode: compile from YAML path
+                            _compiled_cache[_name] = from_config(_path)
+                    except Exception as e:
+                        return f"[SubAgent {_name} compile error: {e}]"
+
+                # Parse input
+                task = input_str
+                if isinstance(input_str, str):
+                    try:
+                        import json as _json
+                        data = _json.loads(input_str)
+                        task = data.get("task", input_str)
+                    except (ValueError, AttributeError):
+                        pass
+
+                try:
+                    result = _compiled_cache[_name].apply(task, Context())
+                    return str(result)
+                except Exception as e:
+                    return f"[SubAgent {_name} error: {e}]"
+
+            return caller
+
+        tools[tool_name] = _make_caller(
+            agent_name,
+            agent_yml if has_file else "",
+            inline_cfg if has_inline else None,
+        )
+
+        # Register as Skill for reuse
+        if has_skills:
+            class _LazySubAgent(Term):
+                def __init__(self, name, path, inline):
+                    super().__init__(name)
+                    self._path = path
+                    self._inline = inline
+                    self._inner = None
+
+                def apply(self, input_val, ctx=None):
+                    if self._inner is None:
+                        if self._inline:
+                            self._inner = build_agent(self._inline, {})
+                        else:
+                            self._inner = from_config(self._path)
+                    ctx = ctx or Context()
+                    return self._inner.apply(str(input_val), ctx)
+
+            lazy_term = _LazySubAgent(
+                agent_name,
+                agent_yml if has_file else "",
+                inline_cfg if has_inline else None,
+            )
+            skill = Skill(
+                name=agent_name,
+                term=lazy_term,
+                description=agent_def.get("description", ""),
+                signature=SkillSignature(input_type="Str", output_type="Str"),
+                tags=agent_def.get("tags", []),
+                version="2.0.0",
+            )
+            pack.add(skill)
+
+    # Register ToolSearch if available
+    try:
+        from agentexample.agent67v2.tools.tool_search import tool_search
+        tools["ToolSearch"] = lambda x: tool_search.apply(x)
+    except ImportError:
+        tools["ToolSearch"] = lambda x: "[ToolSearch not available]"
+
+    # Register skill pack
+    if has_skills and len(pack) > 0:
+        registry.register_pack(pack)
+
+    return tools
+
+
 # ============================================================
 # Type-specific compilers
 # ============================================================
@@ -279,8 +441,46 @@ def _load_project_config() -> str:
     return ""
 
 
-def _compile_lam(cfg: Dict, name_suffix: str = "") -> Lam:
-    """Compile systemPrompt + model -> Lam (lambda abstraction)."""
+def _create_provider(model_cfg: Dict):
+    """Create an LLMProvider from model config. Returns (provider, is_conversation)."""
+    from lambdagent.providers.base import ProviderConfig
+
+    provider_name = model_cfg.get("provider", "anthropic")
+    model_name = model_cfg.get("name", "")
+    use_conversation = model_cfg.get("conversation", True)
+
+    config = ProviderConfig(
+        model=model_name,
+        temperature=model_cfg.get("temperature", 0.3),
+        max_tokens=model_cfg.get("maxTokens", 4096),
+        timeout=model_cfg.get("timeout", 600),
+        context_window=model_cfg.get("contextWindow", 200000),
+    )
+
+    if provider_name == "claude-code":
+        from lambdagent.providers.claude_code_provider import ClaudeCodeProvider
+        config.model = model_name or "sonnet"
+        return ClaudeCodeProvider(config), use_conversation
+
+    if provider_name == "anthropic":
+        from lambdagent.providers.anthropic_provider import AnthropicProvider
+        config.model = model_name or "claude-sonnet-4-20250514"
+        return AnthropicProvider(config), use_conversation
+
+    # OpenAI-compatible: ollama, openai, dashscope, deepseek, moonshot, zhipu
+    from lambdagent.providers.openai_compat_provider import OpenAICompatProvider
+    if not model_name:
+        _defaults = {"openai": "gpt-4o", "ollama": "qwen2.5:7b", "dashscope": "qwen-max"}
+        config.model = _defaults.get(provider_name, "gpt-4o")
+    # Context window hints for smaller models
+    _ctx_windows = {"ollama": 32000, "deepseek": 64000, "moonshot": 128000}
+    if provider_name in _ctx_windows:
+        config.context_window = _ctx_windows[provider_name]
+    return OpenAICompatProvider(config, provider_name=provider_name), use_conversation
+
+
+def _compile_lam(cfg: Dict, name_suffix: str = "", overrides: Dict = None) -> "Term":
+    """Compile systemPrompt + model -> ConversationLam (preferred) or Lam (fallback)."""
     agent_name = cfg.get("name", cfg.get("agentId", "agent"))
     if name_suffix:
         agent_name = f"{agent_name}.{name_suffix}"
@@ -296,6 +496,23 @@ def _compile_lam(cfg: Dict, name_suffix: str = "") -> Lam:
 
     prompt = _inject_resistant_prompt(raw_prompt)
 
+    # Try new provider system (ConversationLam)
+    try:
+        provider, use_conversation = _create_provider(model_cfg)
+
+        if use_conversation:
+            from lambdagent.conversation import ConversationLam
+            max_history = model_cfg.get("maxHistoryTokens", min(provider.context_window // 2, 80000))
+            return ConversationLam(
+                name=agent_name,
+                provider=provider,
+                system_prompt=prompt,
+                max_history_tokens=max_history,
+            )
+    except Exception:
+        pass  # Fall through to legacy Lam
+
+    # Legacy fallback: stateless Lam
     return Lam(
         name=agent_name,
         prompt=prompt,
@@ -305,9 +522,56 @@ def _compile_lam(cfg: Dict, name_suffix: str = "") -> Lam:
     )
 
 
-def _compile_simple(cfg: Dict) -> Term:
+def _compile_simple(cfg: Dict, overrides: Dict = None) -> Term:
     """type: simple -> Lam(name, prompt, model)"""
-    return _compile_lam(cfg)
+    return _compile_lam(cfg, overrides=overrides)
+
+
+def _generate_tool_schema_docs(cfg: Dict) -> str:
+    """Auto-generate tool parameter documentation from BUILTIN_TOOLS schemas.
+
+    Injects into the system prompt so the LLM knows exact parameter names
+    instead of guessing (which causes VALIDATION_ERROR → hallucination).
+    """
+    import inspect
+    try:
+        from lambdagent.builtin_tools.registry import BUILTIN_TOOLS
+    except ImportError:
+        return ""
+
+    tool_names = cfg.get("mcp", {}).get("localTools", [])
+    if not tool_names:
+        return ""
+
+    lines = ["\n\n## 工具参数参考 (Tool Parameter Reference)\n"]
+    lines.append("调用工具时请严格使用以下参数名:\n")
+
+    for name in tool_names:
+        if name == "terminate":
+            lines.append(f'- **terminate**: `{{"action":"terminate","input":{{"summary":"结果摘要"}}}}`')
+            continue
+        tool = BUILTIN_TOOLS.get(name)
+        if not tool:
+            continue
+        schema_cls = getattr(tool, 'schema', None)
+        if not schema_cls:
+            continue
+        try:
+            sig = inspect.signature(schema_cls.__init__)
+            params = []
+            for pname, param in sig.parameters.items():
+                if pname == 'self':
+                    continue
+                if param.default is inspect.Parameter.empty:
+                    params.append(f'"{pname}": ...')  # required
+                else:
+                    params.append(f'"{pname}": {json.dumps(param.default)}')
+            param_str = ", ".join(params)
+            lines.append(f'- **{name}**: `{{"action":"{name}","input":{{{param_str}}}}}`')
+        except Exception:
+            continue
+
+    return "\n".join(lines) if len(lines) > 2 else ""
 
 
 def _compile_react(cfg: Dict, overrides: Dict) -> Term:
@@ -320,7 +584,13 @@ def _compile_react(cfg: Dict, overrides: Dict) -> Term:
       - Early termination on implicit signals
       - Tool call caching
     """
-    think = _compile_lam(cfg, "think")
+    # P1: Inject tool parameter docs into system prompt
+    tool_docs = _generate_tool_schema_docs(cfg)
+    if tool_docs:
+        cfg = dict(cfg)  # shallow copy to avoid mutating original
+        cfg["systemPrompt"] = cfg.get("systemPrompt", "") + tool_docs
+
+    think = _compile_lam(cfg, "think", overrides=overrides)
     tools = _compile_tools(cfg, overrides)
 
     # S05: Enforce mcp.policy.mode at runtime
@@ -339,87 +609,179 @@ def _compile_react(cfg: Dict, overrides: Dict) -> Term:
     verbose = react_cfg.get("verbose", False)
     agent_name = cfg.get("name", cfg.get("agentId", "agent"))
 
-    # Shared context for the entire react loop (P0 fix)
+    # Streaming callback (injected via overrides["on_step"])
+    _on_step = overrides.get("on_step")
+
+    # Shared context for the entire react loop
     _shared_ctx = Context()
     _step_counter = [0]
+    _tool_log = []  # Full tool execution log
+    _user_input = [None]  # Original user input (captured on first step)
+    _last_observation = [None]  # Latest tool observation for session-resume mode
+
+    # Detect if ClaudeLam supports session persistence (--resume)
+    _has_session = hasattr(think, '_session_id')
 
     def react_step(state):
         """
-        One step of ReAct: think -> extract tool -> execute -> format state
-        v2: with context passing, state compression, early stop, caching
+        One step of ReAct: think -> extract tool -> execute -> observe.
+
+        With session persistence (ClaudeLam --resume):
+          - Step 0: pass full user input (creates session)
+          - Step N: pass only latest tool observation (Claude remembers history)
+        Without session persistence (standard Lam):
+          - Every step: pass full compressed state (backward compatible)
         """
         ctx = _shared_ctx
         step = _step_counter[0]
         _step_counter[0] += 1
 
-        # Phase 1: Think (beta-reduction)
+        # Capture original user input on first step
+        if step == 0:
+            _user_input[0] = str(state)
+
+        # ── Phase 1: Think (beta-reduction) ──
         t0 = time.time()
-        thought = think.apply(str(state), ctx)
+
+        if _has_session and step > 0 and _last_observation[0] is not None:
+            # Session mode: only pass latest observation (Claude has full memory)
+            obs = _last_observation[0]
+            remaining = max_steps - step
+            llm_input = (
+                f"[工具执行结果]\n{obs}\n\n"
+                f"[步骤 {step+1}/{max_steps},剩余 {remaining} 步]\n"
+                f"请基于结果决定下一步。输出一个JSON工具调用。\n"
+                f"注意:工具名是 ReadFile/WriteFile/EditFile/Bash/ListFiles(不是 Read/Write/Edit)。"
+            )
+        else:
+            # First step or stateless mode: pass full state
+            llm_input = str(state)
+
+        thought = think.apply(llm_input, ctx)
         think_ms = (time.time() - t0) * 1000
 
         if verbose:
             print(f"  B[{step}] think ({think_ms:.0f}ms): {str(thought)[:80]}...")
 
-        # Phase 1.5: Early termination check (P1)
-        if _check_implicit_terminate(str(thought)):
-            # Check if there's no explicit tool call — if so, terminate
-            selected_tool, _ = _extract_tool_call(str(thought), tools)
-            if selected_tool is None or selected_tool._name == "terminate":
-                if verbose:
-                    print(f"  B[{step}] terminate (implicit signal detected)")
-                return str(thought)
+        # Streaming: emit think event
+        if _on_step:
+            from lambdagent.agentruntime.react_engine import StepEvent, STEP_THINK
+            try:
+                _on_step(StepEvent(type=STEP_THINK, step=step, content=str(thought), duration_ms=think_ms))
+            except Exception:
+                pass
 
-        # Phase 2: Tool Selection (Route / CASE)
+        # ── Phase 2: Extract tool call ──
         selected_tool, tool_input = _extract_tool_call(str(thought), tools)
 
-        # Phase 3: Base Case Check
+        # ── Phase 3: Termination check ──
         if selected_tool is None or selected_tool._name == "terminate":
+            # Verify completion against actual tool log
+            if _tool_log and step < max_steps - 2:
+                user_lower = _user_input[0].lower() if _user_input[0] else ""
+                all_obs = " ".join(e["observation"] for e in _tool_log)
+                all_inputs = " ".join(e["input"] for e in _tool_log)
+                all_tools = [e["tool"] for e in _tool_log]
+
+                wants_code = any(kw in user_lower for kw in ["补充", "实现", "完成", "修改", "写", "代码", "implement", "fix", "write", "code"])
+                wants_test = any(kw in user_lower for kw in ["测试", "test"])
+                wants_commit = any(kw in user_lower for kw in ["提交", "推送", "commit", "push"])
+
+                wrote_code = ("WriteFile" in all_tools or "Bash" in all_tools) and "[OK]" in all_obs
+                ran_tests = ("mvn test" in all_inputs or "pytest" in all_inputs or "npm test" in all_inputs) and ("BUILD SUCCESS" in all_obs or " passed" in all_obs.lower() or "Tests run:" in all_obs)
+                did_commit = "git commit" in all_inputs and ("[master" in all_obs or "[main" in all_obs or "create mode" in all_obs)
+                did_push = "git push" in all_inputs
+
+                missing = []
+                if wants_code and not wrote_code:
+                    missing.append("写入代码 (WriteFile 或 Bash)")
+                if wants_test and not ran_tests:
+                    missing.append("运行测试 (mvn test / pytest)")
+                if wants_commit and not did_commit:
+                    missing.append("git add && git commit")
+                if wants_commit and not did_push:
+                    missing.append("git push")
+
+                if missing:
+                    # Push back — force LLM to continue
+                    _last_observation[0] = (
+                        f"[SYSTEM] 任务未完成。以下操作没有实际执行:\n"
+                        + "\n".join(f"  - {m}" for m in missing)
+                        + "\n请立即用工具执行这些操作。"
+                    )
+                    return f"{_user_input[0]}\n[Step {step+1}] pending"
+
             if verbose:
                 print(f"  B[{step}] terminate (base case)")
+            if _on_step:
+                from lambdagent.agentruntime.react_engine import StepEvent, STEP_ANSWER
+                try:
+                    _on_step(StepEvent(type=STEP_ANSWER, step=step, content=str(thought)))
+                except Exception:
+                    pass
             return str(thought)
 
-        # Phase 4: Tool Execution with caching (P2)
+        # ── Phase 4: Tool Execution ──
         tool_name = selected_tool._name
-        cache_key = f"{tool_name}:{hashlib.md5(str(tool_input).encode()).hexdigest()[:12]}"
-        cached = _tool_cache.get(cache_key)
 
-        if cached is not None:
-            observation = cached
-            if verbose:
-                print(f"  B[{step}] Tool:{tool_name} (CACHED): {observation[:60]}...")
-        else:
-            t0 = time.time()
+        # Streaming: emit tool_call event
+        if _on_step:
+            from lambdagent.agentruntime.react_engine import StepEvent, STEP_TOOL_CALL
             try:
-                observation = str(_timeout_call(selected_tool, tool_input or str(thought), tool_timeout))
-            except Exception as e:
-                observation = f"[TOOL_ERROR] {e}"
-            tool_ms = (time.time() - t0) * 1000
-            _tool_cache.put(cache_key, observation)
+                _on_step(StepEvent(type=STEP_TOOL_CALL, step=step, content=str(tool_input)[:500], tool=tool_name))
+            except Exception:
+                pass
 
-            if verbose:
-                print(f"  B[{step}] Tool:{tool_name} ({tool_ms:.0f}ms): {observation[:60]}...")
+        t0 = time.time()
+        try:
+            # Serialize dict to JSON string for consistent _parse_input handling
+            if isinstance(tool_input, dict):
+                tool_input_val = json.dumps(tool_input, ensure_ascii=False)
+            else:
+                tool_input_val = tool_input or str(thought)
+            observation = str(_timeout_call(selected_tool, tool_input_val, tool_timeout))
+        except Exception as e:
+            observation = f"[TOOL_ERROR] {e}"
+        tool_ms = (time.time() - t0) * 1000
+
+        if verbose:
+            print(f"  B[{step}] Tool:{tool_name} ({tool_ms:.0f}ms): {observation[:60]}...")
+
+        # Record to tool log
+        _tool_log.append({"tool": tool_name, "input": str(tool_input_val)[:200], "observation": observation[:500]})
+
+        # Streaming: emit tool_result event
+        if _on_step:
+            from lambdagent.agentruntime.react_engine import StepEvent, STEP_TOOL_RESULT
+            try:
+                _on_step(StepEvent(type=STEP_TOOL_RESULT, step=step, content=observation[:1000], tool=tool_name))
+            except Exception:
+                pass
 
         # Log to context
         ctx.log(f"Tool:{tool_name}", "", str(tool_input)[:200], observation[:200],
-                think_ms, think.model)
+                think_ms, think.model if hasattr(think, 'model') else "")
 
-        # Phase 5: State Update with compression (P0)
-        if observation_enabled:
-            return _compress_state(str(state), str(thought), tool_name, observation)
+        # ── Phase 5: Prepare next state ──
+        # Store observation for session-resume mode
+        _last_observation[0] = f"Tool: {tool_name}\nResult:\n{observation}"
+
+        # Return state with step marker (for stop_condition detection)
+        if _has_session:
+            # Session mode: minimal state (Claude has full memory via --resume)
+            return f"{_user_input[0]}\n[Step {step+1}] {tool_name} done"
         else:
-            return str(thought)
+            # Stateless mode: full compressed state (backward compatible)
+            return _compress_state(str(state), str(thought), tool_name, observation)
 
     body = Tool(f"{agent_name}.react_step", react_step)
 
-    # Early stop condition: also check implicit termination
     def stop_condition(result, step):
         if step >= max_steps - 1:
             return True
-        # If the last result doesn't contain step markers, it's a final answer
-        if isinstance(result, str) and "[Step " not in result[-200:]:
-            # Result is a direct answer, not a state string
-            if _check_implicit_terminate(result):
-                return True
+        # No [Step marker = no tool was called = final answer
+        if isinstance(result, str) and "[Step " not in result:
+            return True
         return False
 
     return Loop(
@@ -802,15 +1164,9 @@ def _extract_tool_call(thought: str, tools: Dict[str, Tool]):
                 inp = {"query": inp}
             return tools[tool_name], inp
 
-    # Keyword match (fallback) — skip terminate for keyword match
-    thought_lower = thought.lower()
-    for tool_name, tool in tools.items():
-        if tool_name == "terminate":
-            continue  # Don't match terminate by keyword
-        if tool_name.lower() in thought_lower:
-            return tool, thought
-
-    # No tool call detected -> implicit terminate
+    # No structured tool call detected (JSON/XML) -> no tool invocation.
+    # Keyword matching removed: too many false positives when LLM mentions
+    # tool names in natural language (e.g., "ReadFile 工具可以读取文件").
     return None, None
 
 

+ 18 - 0
lambdagent/providers/__init__.py

@@ -0,0 +1,18 @@
+"""
+lambdagent.providers — Unified LLM provider implementations.
+
+All providers implement LLMProvider.chat(messages) -> str.
+"""
+from .base import LLMProvider, ProviderConfig, ProviderError, Message
+from .claude_code import ClaudeLam  # backward compat
+from .claude_code_provider import ClaudeCodeProvider
+from .anthropic_provider import AnthropicProvider
+from .openai_compat_provider import OpenAICompatProvider
+
+__all__ = [
+    "LLMProvider", "ProviderConfig", "ProviderError", "Message",
+    "ClaudeLam",  # backward compat for PersonalAssistant
+    "ClaudeCodeProvider",
+    "AnthropicProvider",
+    "OpenAICompatProvider",
+]

+ 59 - 0
lambdagent/providers/anthropic_provider.py

@@ -0,0 +1,59 @@
+"""
+lambdagent.providers.anthropic_provider — Anthropic API provider.
+
+Uses the official anthropic SDK. Requires ANTHROPIC_API_KEY.
+"""
+from __future__ import annotations
+
+import os
+from typing import Dict, List
+
+from .base import LLMProvider, ProviderConfig, ProviderError
+
+
+class AnthropicProvider(LLMProvider):
+    """Anthropic Claude API (messages endpoint)."""
+
+    def __init__(self, config: ProviderConfig):
+        super().__init__(config)
+        self._client = None
+
+    def _get_client(self):
+        if self._client is None:
+            try:
+                import anthropic
+            except ImportError:
+                raise ProviderError("anthropic package not installed: pip install anthropic", "anthropic")
+            api_key = os.environ.get("ANTHROPIC_API_KEY", "")
+            if not api_key:
+                raise ProviderError("ANTHROPIC_API_KEY not set", "anthropic")
+            self._client = anthropic.Anthropic(api_key=api_key)
+        return self._client
+
+    def chat(self, messages: List[Dict[str, str]]) -> str:
+        client = self._get_client()
+
+        # Anthropic API: system is separate from messages
+        system_prompt = ""
+        chat_messages = []
+        for m in messages:
+            if m["role"] == "system":
+                system_prompt = m["content"]
+            else:
+                chat_messages.append(m)
+
+        # Ensure messages alternate user/assistant
+        if not chat_messages:
+            chat_messages = [{"role": "user", "content": ""}]
+
+        try:
+            response = client.messages.create(
+                model=self.config.model,
+                max_tokens=self.config.max_tokens,
+                temperature=self.config.temperature,
+                system=system_prompt,
+                messages=chat_messages,
+            )
+            return response.content[0].text.strip()
+        except Exception as e:
+            raise ProviderError(f"Anthropic API error: {e}", "anthropic", retryable="overloaded" in str(e).lower())

+ 80 - 0
lambdagent/providers/base.py

@@ -0,0 +1,80 @@
+"""
+lambdagent.providers.base — Unified LLM Provider interface.
+
+All providers implement the same contract:
+    messages: list[dict] -> response: str
+
+This decouples conversation management (ConversationLam) from
+the transport layer (HTTP API / CLI subprocess / local inference).
+"""
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from dataclasses import dataclass, field
+from typing import Any, Dict, List, Optional
+
+
+@dataclass
+class Message:
+    """A single message in the conversation."""
+    role: str      # "system", "user", "assistant"
+    content: str
+
+    def to_dict(self) -> dict:
+        return {"role": self.role, "content": self.content}
+
+
+@dataclass
+class ProviderConfig:
+    """Configuration for an LLM provider."""
+    model: str = ""
+    temperature: float = 0.3
+    max_tokens: int = 4096
+    timeout: int = 600
+    context_window: int = 200000  # max tokens the model can handle
+    extra: Dict[str, Any] = field(default_factory=dict)
+
+
+class LLMProvider(ABC):
+    """
+    Abstract base class for LLM providers.
+
+    All providers accept a list of messages and return a text response.
+    This is the lowest-level abstraction — no history management, no
+    tool parsing, no retry logic. Just messages in, text out.
+    """
+
+    def __init__(self, config: ProviderConfig):
+        self.config = config
+
+    @abstractmethod
+    def chat(self, messages: List[Dict[str, str]]) -> str:
+        """
+        Send messages to the LLM and return the response text.
+
+        Args:
+            messages: List of {"role": "system/user/assistant", "content": "..."}
+
+        Returns:
+            The assistant's response text.
+
+        Raises:
+            ProviderError: On API/connection failures.
+        """
+        ...
+
+    @property
+    def model_name(self) -> str:
+        return self.config.model
+
+    @property
+    def context_window(self) -> int:
+        return self.config.context_window
+
+
+class ProviderError(Exception):
+    """Base exception for provider errors."""
+    def __init__(self, message: str, provider: str = "", retryable: bool = False):
+        super().__init__(message)
+        self.provider = provider
+        self.retryable = retryable

+ 256 - 0
lambdagent/providers/claude_code.py

@@ -0,0 +1,256 @@
+"""
+lambdagent.providers.claude_code — Claude Code CLI as LLM backend.
+
+No API Key required — uses Claude Code Max Plan authentication.
+
+Lambda semantics:
+    ClaudeLam("name", "prompt") = lambda x. claude(prompt, x)
+    apply() = beta-reduction = `claude -p` decoding
+
+Session persistence:
+    First call creates a session with system prompt.
+    Subsequent calls use `--resume <session_id>` to maintain full context.
+    This eliminates the "amnesia" problem of stateless `-p` calls.
+"""
+from __future__ import annotations
+
+import json
+import subprocess
+import sys
+import time
+from typing import Any, Callable, Optional
+
+from lambdagent.core import Term, Context
+
+
+class ClaudeLam(Term):
+    """
+    Lambda abstraction backed by Claude Code CLI with session persistence.
+
+    Key improvement over stateless `-p`:
+      Each apply() continues the same conversation session.
+      Claude retains full memory of previous steps, file contents, etc.
+    """
+
+    def __init__(
+        self,
+        name: str,
+        prompt: str,
+        model: str = "sonnet",
+        max_tokens: int = 4096,
+        temperature: float = 0.3,
+        output_parser: Callable[[str], Any] | None = None,
+        claude_bin: str = "claude",
+        stream: bool = False,
+        on_chunk: Callable[[str], None] | None = None,
+        inject_override: bool = True,
+    ):
+        super().__init__(name)
+        self.prompt = prompt
+        self.model = model
+        self.max_tokens = max_tokens
+        self.temperature = temperature
+        self.output_parser = output_parser or (lambda x: x)
+        self.claude_bin = claude_bin
+        self.stream = stream
+        self.on_chunk = on_chunk
+        self.inject_override = inject_override
+        # Session persistence
+        self._session_id = None  # Set after first call
+
+    # Injected at the end of the system prompt for from_config path.
+    _TOOL_OVERRIDE = (
+        "\n\n[RUNTIME ENVIRONMENT]\n"
+        "You are running inside a ReAct agent runtime with a tool execution engine. "
+        "All tools listed in your system prompt ARE available and fully functional. "
+        "To call a tool, output exactly one JSON code block:\n"
+        "```json\n"
+        '{"action": "ToolName", "input": {parameters}}\n'
+        "```\n"
+        "The runtime will execute it and return the result as your next observation. "
+        "Do NOT say tools are unavailable. Do NOT ask the user to do it manually. "
+        "Just call the tool directly.\n\n"
+        "[CRITICAL RULES]\n"
+        "1. NEVER claim you have read, written, or executed something unless you see "
+        "the ACTUAL tool result confirming it succeeded.\n"
+        "2. If a tool returns [VALIDATION_ERROR], [ERROR], or [Permission denied], "
+        "the operation FAILED. Fix the parameters and retry.\n"
+        "3. For multi-step tasks (read → modify → test → commit), complete EACH step "
+        "with a real tool call. Do NOT skip steps or summarize unexecuted work.\n"
+        "4. Tool parameter names: use 'file_path' (not 'path'), 'command' (not 'cmd').\n"
+        "5. PATHS: NEVER guess the home directory. Use ~/... for paths, or extract "
+        "the real absolute path from previous Bash output. NEVER use /Users/user/, "
+        "/Users/power/, /root/ etc. If unsure, run `echo $HOME` first.\n"
+        "6. FIRST STEP: Your first action must be reading the project (ls, cat README). "
+        "NEVER commit, push, or modify code before reading the project structure."
+    )
+
+    def apply(self, input: Any, ctx: Context | None = None) -> Any:
+        """beta-reduction: (lambda_D x) -> claude -p (prompt + x)"""
+        ctx = ctx or Context()
+        t0 = time.time()
+
+        # Append tool override only on first call (when no session exists yet).
+        # With --resume, Claude already has the override in its session memory.
+        should_inject = self.inject_override and self._session_id is None
+        augmented_input = str(input) + self._TOOL_OVERRIDE if should_inject else str(input)
+
+        if self.stream:
+            raw = self._call_stream(augmented_input)
+        else:
+            raw = self._call_sync(augmented_input)
+
+        duration = (time.time() - t0) * 1000
+        result = self.output_parser(raw)
+        ctx.log(self._name, self._trace_id, input, result, duration, f"claude-code/{self.model}")
+        return result
+
+    def _build_cmd(self, for_json: bool = False) -> list[str]:
+        """Build the claude CLI command.
+
+        First call: creates new session with --system-prompt.
+        Subsequent calls: --resume <session_id> to continue conversation.
+        """
+        output_format = "json" if for_json else "text"
+        cmd = [
+            self.claude_bin,
+            "-p",
+            "--output-format", output_format,
+            "--model", self.model,
+            "--tools", "",
+        ]
+
+        if self._session_id:
+            # Resume existing session (Claude has full memory of previous steps)
+            cmd += ["--resume", self._session_id]
+        else:
+            # First call: create new session with system prompt
+            cmd += ["--system-prompt", self.prompt]
+
+        # MCP isolation: prevent Claude from seeing session MCP tools
+        cmd += ["--mcp-config", '{"mcpServers":{}}', "--strict-mcp-config"]
+        return cmd
+
+    def _call_stream(self, input_text: str) -> str:
+        """Streaming call — reads stdout char-by-char."""
+        try:
+            # First call uses JSON to capture session_id, then switch to text
+            is_first = self._session_id is None
+            proc = subprocess.Popen(
+                self._build_cmd(for_json=is_first),
+                stdin=subprocess.PIPE,
+                stdout=subprocess.PIPE,
+                stderr=subprocess.PIPE,
+                text=True,
+                bufsize=1,
+            )
+
+            proc.stdin.write(input_text)
+            proc.stdin.close()
+
+            output_chars = []
+            start = time.time()
+            first_token = True
+            chunk_buf = []
+            CHUNK_FLUSH_SIZE = 20
+
+            while True:
+                char = proc.stdout.read(1)
+                if not char:
+                    break
+                if time.time() - start > 600:
+                    proc.kill()
+                    return "[Claude Code timeout] request exceeded 600s"
+
+                output_chars.append(char)
+
+                if not is_first:
+                    if self.on_chunk:
+                        chunk_buf.append(char)
+                        if len(chunk_buf) >= CHUNK_FLUSH_SIZE or char == '\n':
+                            self.on_chunk("".join(chunk_buf))
+                            chunk_buf = []
+                    else:
+                        if first_token:
+                            sys.stdout.write("  \U0001f40f ")
+                            first_token = False
+                        sys.stdout.write(char)
+                        sys.stdout.flush()
+
+            if chunk_buf and self.on_chunk:
+                self.on_chunk("".join(chunk_buf))
+
+            proc.wait(timeout=5)
+
+            if not first_token and not self.on_chunk:
+                sys.stdout.write("\n")
+                sys.stdout.flush()
+
+            if proc.returncode != 0:
+                stderr = proc.stderr.read().strip()
+                if stderr:
+                    return f"[Claude Code error] {stderr[:500]}"
+
+            raw_output = "".join(output_chars).strip()
+
+            # Extract session_id from first JSON call
+            if is_first and raw_output:
+                try:
+                    data = json.loads(raw_output)
+                    self._session_id = data.get("session_id")
+                    return data.get("result", raw_output)
+                except (json.JSONDecodeError, KeyError):
+                    pass
+
+            return raw_output if raw_output else "[no output]"
+
+        except FileNotFoundError:
+            return "[error] claude command not found."
+        except Exception as e:
+            return f"[error] {e}"
+
+    def _call_sync(self, input_text: str) -> str:
+        """Non-streaming call with session persistence."""
+        try:
+            # First call: use JSON output to capture session_id
+            is_first = self._session_id is None
+
+            result = subprocess.run(
+                self._build_cmd(for_json=is_first),
+                input=input_text,
+                capture_output=True, text=True, timeout=600,
+            )
+
+            if result.returncode != 0:
+                stderr = result.stderr.strip()
+                if stderr:
+                    return f"[Claude Code error] {stderr[:500]}"
+                return "[Claude Code error] unknown error"
+
+            raw_output = result.stdout.strip()
+
+            # Extract session_id from first JSON response
+            if is_first and raw_output:
+                try:
+                    data = json.loads(raw_output)
+                    self._session_id = data.get("session_id")
+                    return data.get("result", raw_output)
+                except (json.JSONDecodeError, KeyError):
+                    pass
+
+            return raw_output if raw_output else "[no output]"
+
+        except subprocess.TimeoutExpired:
+            return "[Claude Code timeout] request exceeded 600s"
+        except FileNotFoundError:
+            return "[error] claude command not found."
+        except Exception as e:
+            return f"[error] {e}"
+
+    def reset_session(self):
+        """Reset session — next call creates a new conversation."""
+        self._session_id = None
+
+    def __rshift__(self, other):
+        from lambdagent.primitives import Compose
+        return Compose(self, other)

+ 123 - 0
lambdagent/providers/claude_code_provider.py

@@ -0,0 +1,123 @@
+"""
+lambdagent.providers.claude_code_provider — Claude Code CLI as LLM provider.
+
+No API Key required — uses Claude Code Max Plan authentication.
+
+Optimizations:
+  - First call: creates session with system prompt, captures session_id
+  - Subsequent calls: --resume <session_id> (Claude retains full memory)
+  - Falls back to messages-in-prompt if --resume unavailable
+"""
+from __future__ import annotations
+
+import json
+import subprocess
+import shutil
+from typing import Dict, List
+
+from .base import LLMProvider, ProviderConfig, ProviderError
+
+
+class ClaudeCodeProvider(LLMProvider):
+    """
+    LLM Provider backed by Claude Code CLI.
+
+    Uses `claude -p` subprocess. Session persistence via `--resume`.
+    """
+
+    def __init__(self, config: ProviderConfig):
+        super().__init__(config)
+        self.claude_bin = config.extra.get("claude_bin", "claude")
+        self._session_id = None
+
+        if not shutil.which(self.claude_bin):
+            raise ProviderError(
+                f"claude command not found. Install: npm install -g @anthropic-ai/claude-code",
+                "claude-code"
+            )
+
+    def chat(self, messages: List[Dict[str, str]]) -> str:
+        if self._session_id:
+            return self._call_resume(messages)
+        else:
+            return self._call_new(messages)
+
+    def _call_new(self, messages: List[Dict[str, str]]) -> str:
+        """First call: create session with system prompt, capture session_id."""
+        system_prompt = ""
+        user_content = ""
+        for m in messages:
+            if m["role"] == "system":
+                system_prompt = m["content"]
+            elif m["role"] == "user":
+                user_content = m["content"]
+
+        cmd = [
+            self.claude_bin, "-p",
+            "--output-format", "json",
+            "--model", self.config.model,
+            "--system-prompt", system_prompt,
+            "--tools", "",
+            "--mcp-config", '{"mcpServers":{}}',
+            "--strict-mcp-config",
+        ]
+
+        try:
+            result = subprocess.run(
+                cmd, input=user_content,
+                capture_output=True, text=True,
+                timeout=self.config.timeout,
+            )
+        except subprocess.TimeoutExpired:
+            raise ProviderError(f"Claude Code timeout ({self.config.timeout}s)", "claude-code", retryable=True)
+
+        if result.returncode != 0:
+            stderr = result.stderr.strip()[:500]
+            raise ProviderError(f"Claude Code error: {stderr}", "claude-code")
+
+        raw = result.stdout.strip()
+        try:
+            data = json.loads(raw)
+            self._session_id = data.get("session_id")
+            return data.get("result", raw)
+        except (json.JSONDecodeError, KeyError):
+            return raw
+
+    def _call_resume(self, messages: List[Dict[str, str]]) -> str:
+        """Subsequent calls: resume session (Claude has full memory)."""
+        # Only pass the latest user message — Claude remembers everything else
+        last_user = ""
+        for m in reversed(messages):
+            if m["role"] == "user":
+                last_user = m["content"]
+                break
+
+        cmd = [
+            self.claude_bin, "-p",
+            "--output-format", "text",
+            "--model", self.config.model,
+            "--resume", self._session_id,
+            "--tools", "",
+            "--mcp-config", '{"mcpServers":{}}',
+            "--strict-mcp-config",
+        ]
+
+        try:
+            result = subprocess.run(
+                cmd, input=last_user,
+                capture_output=True, text=True,
+                timeout=self.config.timeout,
+            )
+        except subprocess.TimeoutExpired:
+            raise ProviderError(f"Claude Code timeout ({self.config.timeout}s)", "claude-code", retryable=True)
+
+        if result.returncode != 0:
+            stderr = result.stderr.strip()[:500]
+            raise ProviderError(f"Claude Code error: {stderr}", "claude-code")
+
+        output = result.stdout.strip()
+        return output if output else "[no output]"
+
+    def reset_session(self):
+        """Reset session — next call creates a new conversation."""
+        self._session_id = None

+ 75 - 0
lambdagent/providers/openai_compat_provider.py

@@ -0,0 +1,75 @@
+"""
+lambdagent.providers.openai_compat_provider — OpenAI-compatible API provider.
+
+Covers: OpenAI, Ollama, DashScope, DeepSeek, Moonshot, Zhipu, and any
+OpenAI-compatible endpoint.
+"""
+from __future__ import annotations
+
+import json
+import os
+import urllib.request
+from typing import Dict, List
+
+from .base import LLMProvider, ProviderConfig, ProviderError
+
+
+# Default base URLs and env keys per provider
+_PROVIDER_DEFAULTS = {
+    "openai": ("https://api.openai.com/v1", "OPENAI_API_KEY"),
+    "ollama": ("http://localhost:11434/v1", ""),
+    "dashscope": ("https://dashscope.aliyuncs.com/compatible-mode/v1", "DASHSCOPE_API_KEY"),
+    "deepseek": ("https://api.deepseek.com/v1", "DEEPSEEK_API_KEY"),
+    "moonshot": ("https://api.moonshot.cn/v1", "MOONSHOT_API_KEY"),
+    "zhipu": ("https://open.bigmodel.cn/api/paas/v4", "ZHIPU_API_KEY"),
+}
+
+
+class OpenAICompatProvider(LLMProvider):
+    """OpenAI-compatible chat completion API."""
+
+    def __init__(self, config: ProviderConfig, provider_name: str = "openai"):
+        super().__init__(config)
+        self.provider_name = provider_name
+
+        # Resolve base URL and API key
+        defaults = _PROVIDER_DEFAULTS.get(provider_name, ("", ""))
+        self.base_url = config.extra.get("base_url", defaults[0])
+        env_key = defaults[1]
+        self.api_key = config.extra.get("api_key", "")
+        if not self.api_key and env_key:
+            self.api_key = os.environ.get(env_key, "")
+        if provider_name == "ollama":
+            self.api_key = self.api_key or "ollama"
+
+    def chat(self, messages: List[Dict[str, str]]) -> str:
+        url = f"{self.base_url.rstrip('/')}/chat/completions"
+
+        body = json.dumps({
+            "model": self.config.model,
+            "messages": messages,
+            "temperature": self.config.temperature,
+            "max_tokens": self.config.max_tokens,
+        }, ensure_ascii=False).encode("utf-8")
+
+        headers = {
+            "Content-Type": "application/json",
+        }
+        if self.api_key:
+            headers["Authorization"] = f"Bearer {self.api_key}"
+
+        req = urllib.request.Request(url, data=body, headers=headers, method="POST")
+
+        try:
+            with urllib.request.urlopen(req, timeout=self.config.timeout) as resp:
+                data = json.loads(resp.read())
+                return data["choices"][0]["message"]["content"].strip()
+        except urllib.error.HTTPError as e:
+            error_body = e.read().decode()[:500]
+            raise ProviderError(f"{self.provider_name} API error {e.code}: {error_body}",
+                                self.provider_name, retryable=e.code >= 500)
+        except urllib.error.URLError as e:
+            raise ProviderError(f"{self.provider_name} connection error: {e}",
+                                self.provider_name, retryable=True)
+        except Exception as e:
+            raise ProviderError(f"{self.provider_name} error: {e}", self.provider_name)