ソースを参照

sync(lambdagent): backport everything from kenny67nju/lambdagent v0.1 + safe_eval

This brings the monorepo lambdagent/ in sync with the public GitHub repo
(kenny67nju/lambdagent), porting back two months of public-repo fixes
without disturbing the local production WIP in providers/, validated_tool.py,
and fromconfig/compiler.py.

Renames (with deprecation shims for backward compat)
  - types.py    → lam_types.py  (was shadowing Python's stdlib types)
  - trace.py    → tracing.py    (was shadowing stdlib trace)
  Old names are kept as thin shims that re-export from the new module and
  emit DeprecationWarning; will be removed in 0.3.0. All downstream
  `from lambdagent.types import ...` / `from lambdagent.trace import ...`
  imports in demo/, agentpaas/, etc. continue to work.

Security
  - fromconfig/compiler.py::_compile_guard now runs validator expressions
    through AST-validated _safe_eval() instead of bare eval(). Closes a
    YAML → RCE vector (verified blocks .__class__.__mro__, __import__,
    lambda etc.) while still accepting the common cases like
    `len(x) > 100` and `"OK" in x`.
  - sandbox.py: guard POSIX-only `import resource` + signal.SIGKILL behind
    _SANDBOX_AVAILABLE so `import lambdagent` works on Windows (raises
    NotImplementedError with WSL hint when sandbox classes are instantiated).
  - providers/claude_code.py::ClaudeLam: add DeprecationWarning at
    construction with migration path; will be removed in 0.3.0.

Cross-platform
  - New module _shell_compat.py with run_shell() / popen_shell() /
    resolve_bash() — routes through ["bash.exe", "-c", cmd] on Windows
    when Git-Bash is available, /bin/sh elsewhere. Adopted by 4 shell-exec
    sites (shell_tools, code_tools, cli/shell_tool, hooks).
  - builtin_tools/{code,file}_tools.py::_find_rg() uses shutil.which()
    first, then probes brew/macports/chocolatey/scoop fallback paths.

Packaging / install
  - pyproject.toml: license SPDX string ("BUSL-1.1"), 10 new optional
    extras (rag/knowledge/ocr/pdfgen/sandbox/checkpoint-crypto/redis/otel/
    tui/dev), real maintainer email, py.typed marker for PEP 561.
  - Drop the empty mcp_server_package/ stub (was leaking an old smail.nju
    email and claiming MIT despite the project being BUSL).
  - Add py.typed empty marker file.

Docs
  - README architecture tree expanded (13 → 60+ entries), new "Platform
    support" matrix, new "MCP Server" section, Contact section with
    maintainer email for BUSL commercial inquiries.
  - New SECURITY.md / CONTRIBUTING.md / CODE_OF_CONDUCT.md / CHANGELOG.md
    aligned with the public GitHub repo policy.

Tests
  - tests/test_extractors.py: skip lambdagent_guard tests via
    importorskip (graceful on standalone build, runs in monorepo).
  - tests/test_providers.py: accept either localhost or 127.0.0.1 for
    ollama base_url.
  - tests/test_phase2.py: replace flaky wall-clock parallelism timing
    assert with threading.Barrier sync (caught the actual correctness
    invariant rather than relying on runner speed).
  - tests/test_builtin_tools.py: tempfile.gettempdir() instead of /tmp;
    add portable echo smoke test + resolve_bash() shape test.

Preserved local WIP (not touched)
  - providers/claude_code_provider.py: production logger setup,
    _strip_tool_docs() helper, ReAct constraint tweaks.
  - validated_tool.py: extended JSON-input dispatch logic.
  - providers/claude_code.py: only added the deprecation banner; rest is
    your WIP unchanged.
  - fromconfig/compiler.py: only added _safe_eval + import ast + 3-line
    replacement of the eval() call; the other ~80 lines of your WIP
    (ToolGateway integration, MCP retry, etc.) untouched.

Monorepo tests:
  95 passed, 3 pre-existing failures unrelated to this sync
  (test_hash_key — agentpaas pbkdf2 WIP, test_memory_factory —
  namespace kwarg test/impl drift, test_compile_simple — ConversationLam
  vs Lam check in your compiler WIP).

Upstream commits backported (see lambdagent/CHANGELOG.md for full list):
  e5cf32d chore: initial public release
  e5902ce chore: drop tests/examples that depend on sibling packages
  a134660 docs(readme): fix stale stats, broken URLs, missing modules, CLI
  7f1b506 P0: unblock install + stdlib-shadow + test suite
  5367925 P1: security + extras + provider deprecation + email exposure
  c833502 P2: py.typed marker + remove bare except in examples
  ed30abe P3: governance — CI + SECURITY/CONTRIBUTING/CoC/CHANGELOG
  bd7a4bc ci: enhancement bundle 1-7 (Node24, OS matrix, codecov, …)
  b924212 ci(test): defaults.run.shell=bash on Windows
  d7f9a31 test(builtin_tools): tempfile.gettempdir() instead of /tmp
  cc87870 test(phase2): loosen flaky parallelism timing
  fc31ce4 feat(b): cross-platform shell + path resolution (Windows)
  e5f84ba test(bash): use echo instead of 'python -c'
  aa5f9a5 fix(shell): list-form argv on Windows (space-in-path)
  db90940 test(phase2): verify Par parallelism via Barrier sync
kenny67nju 3 ヶ月 前
コミット
ec5c067e8e
41 ファイル変更2508 行追加1421 行削除
  1. 82 5
      lambdagent/.gitignore
  2. 41 0
      lambdagent/CHANGELOG.md
  3. 17 0
      lambdagent/CODE_OF_CONDUCT.md
  4. 62 0
      lambdagent/CONTRIBUTING.md
  5. 1 1
      lambdagent/LICENSE
  6. 168 33
      lambdagent/README.md
  7. 36 0
      lambdagent/SECURITY.md
  8. 2 2
      lambdagent/__init__.py
  9. 82 0
      lambdagent/_shell_compat.py
  10. 24 5
      lambdagent/builtin_tools/code_tools.py
  11. 14 3
      lambdagent/builtin_tools/file_tools.py
  12. 30 23
      lambdagent/builtin_tools/registry.py
  13. 12 4
      lambdagent/builtin_tools/shell_tools.py
  14. 4 4
      lambdagent/cli/shell_tool.py
  15. 6 6
      lambdagent/core.py
  16. 3 3
      lambdagent/examples/demo_advanced.py
  17. 1 1
      lambdagent/examples/ex08_mcp_checkpoint.py
  18. 230 27
      lambdagent/fromconfig/compiler.py
  19. 3 2
      lambdagent/hooks.py
  20. 444 0
      lambdagent/lam_types.py
  21. 2 2
      lambdagent/mcp_server.py
  22. 0 58
      lambdagent/mcp_server_package/README.md
  23. 0 30
      lambdagent/mcp_server_package/pyproject.toml
  24. 1 1
      lambdagent/primitives.py
  25. 2 2
      lambdagent/providers/__init__.py
  26. 49 1
      lambdagent/providers/claude_code.py
  27. 248 33
      lambdagent/providers/claude_code_provider.py
  28. 0 0
      lambdagent/py.typed
  29. 87 9
      lambdagent/pyproject.toml
  30. 33 1
      lambdagent/sandbox.py
  31. 25 2
      lambdagent/tests/test_builtin_tools.py
  32. 1 1
      lambdagent/tests/test_core_primitives.py
  33. 8 1
      lambdagent/tests/test_extractors.py
  34. 1 1
      lambdagent/tests/test_handlers.py
  35. 19 8
      lambdagent/tests/test_phase2.py
  36. 4 1
      lambdagent/tests/test_providers.py
  37. 1 1
      lambdagent/tests/test_types.py
  38. 18 712
      lambdagent/trace.py
  39. 719 0
      lambdagent/tracing.py
  40. 21 436
      lambdagent/types.py
  41. 7 2
      lambdagent/validated_tool.py

+ 82 - 5
lambdagent/.gitignore

@@ -1,9 +1,86 @@
+# Python bytecode
 __pycache__/
-*.pyc
-*.pyo
-*.egg-info/
-dist/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
 build/
+develop-eggs/
+dist/
+downloads/
+eggs/
 .eggs/
-*.so
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+*.egg
+MANIFEST
+
+# Virtual environments
+venv/
+.venv/
+ENV/
+env/
+env.bak/
+venv.bak/
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+.pytest_cache/
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# PyInstaller
+*.manifest
+*.spec
+
+# Logs
+*.log
+
+# OS files
+.DS_Store
+Thumbs.db
+
+# Database / sqlite
+*.db
+*.sqlite
+*.sqlite3
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+
+# Secrets / local config
 .env
+.env.local
+.env.*.local
+*.key
+*.pem
+
+# Type checker caches
+.mypy_cache/
+.pyre/
+.pytype/
+.ruff_cache/

+ 41 - 0
lambdagent/CHANGELOG.md

@@ -0,0 +1,41 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+Format inspired by [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
+
+## [Unreleased]
+
+### Fixed
+- **`pip install -e .` is now unblocked.** `pyproject.toml::project.license` migrated from the deprecated `{text = "BUSL-1.1"}` dict form to the SPDX string form (`"BUSL-1.1"`) required by setuptools ≥ 77.
+- **Stop shadowing stdlib `types` and `trace`.** Renamed top-level `types.py → lam_types.py` and `trace.py → tracing.py`. All public symbols (`LamType`, `T_STR`, `TraceStore`, …) are re-exported unchanged from `lambdagent`; only direct sub-module imports need updating.
+- **Closed YAML→eval RCE vector** in `fromconfig.compiler._compile_guard`. The `guard.validator` expression now runs through an AST-validated `_safe_eval` that blocks attribute access, dunders, and non-whitelisted calls.
+- README inconsistencies: stale stats (lines/files/symbols), `your-org` placeholder URLs (3 places), nonexistent `serve` CLI command, missing `trace`/`tools`/`version` subcommands, install instruction pointing at PyPI before publishing, broken `nl2agent.py` path, under-documented architecture tree (13 → 60+ entries).
+
+### Added
+- 10 new optional-dependency extras matching the actual feature set: `[rag]`, `[knowledge]`, `[ocr]`, `[pdfgen]`, `[sandbox]`, `[checkpoint-crypto]`, `[redis]`, `[otel]`, `[tui]`, `[dev]`. Use `pip install -e ".[knowledge,rag]"` etc.
+- `py.typed` marker (PEP 561) so downstream type checkers consume the 92%-covered annotations.
+- `SECURITY.md` with disclosure policy and scope.
+- `CONTRIBUTING.md` with setup, workflow, and PR expectations.
+- `CODE_OF_CONDUCT.md` (Contributor Covenant 2.1).
+- GitHub Actions CI: pytest matrix on Python 3.10 / 3.11 / 3.12.
+- `conftest.py` at repo root for flat-layout test discovery.
+- `[tool.pytest.ini_options]` block: `importlib` import mode, deprecation-warning filters.
+
+### Changed
+- `providers.ClaudeLam` is **deprecated** (DeprecationWarning, removed in 0.3.0). Use `lambdagent.Lam(provider=ClaudeCodeProvider(...))` instead.
+- `fromconfig.compiler` no longer silently registers a fake `ToolSearch` when the optional `agentexample` sibling is absent — calls now KeyError loudly.
+- Maintainer email surfaced consistently: **qinliu@nju.edu.cn** in `pyproject.toml`, `SECURITY.md`, `CONTRIBUTING.md`, README.
+
+### Removed
+- `mcp_server_package/` placeholder directory (empty stub with a leaked `smail.nju.edu.cn` email and a conflicting MIT license claim). The MCP server setup snippet now lives in the main README.
+- `tests/__init__.py` (interfered with pytest collection in the flat layout).
+
+## [0.1.0] — 2026-06-04
+
+- Initial public release on GitHub.
+- Paper I + II + III implementations: 11 core λ-constructs, 5 multi-agent π-constructs, skill system, MCP/A2A/RAG/sandbox, type+effect system, CEK machine, graded cost prediction, algebraic-law rewriting, store-independence analysis.
+- ~35k lines, 125 source files, 152 exported symbols.
+- Business Source License 1.1, converting to Apache 2.0 on 2031-04-05.
+
+[Unreleased]: https://github.com/kenny67nju/lambdagent/compare/v0.1.0...HEAD
+[0.1.0]: https://github.com/kenny67nju/lambdagent/releases/tag/v0.1.0

+ 17 - 0
lambdagent/CODE_OF_CONDUCT.md

@@ -0,0 +1,17 @@
+# Code of Conduct
+
+This project follows the [Contributor Covenant 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
+
+**TL;DR**: be respectful, be precise, focus on the work.
+
+## Reporting
+
+Report incidents to **qinliu@nju.edu.cn** with subject line starting `[lambdagent-conduct]`.
+
+Reports are confidential. I commit to:
+
+1. Acknowledging within 72 hours.
+2. Investigating with appropriate due care.
+3. Taking action proportional to the incident — from warning to permanent ban from the project's GitHub spaces.
+
+The full text of the Contributor Covenant 2.1 applies — see the link above.

+ 62 - 0
lambdagent/CONTRIBUTING.md

@@ -0,0 +1,62 @@
+# Contributing to lambdagent
+
+Thanks for considering a contribution!
+
+## Quick Setup
+
+```bash
+git clone https://github.com/kenny67nju/lambdagent.git
+cd lambdagent
+python3 -m venv .venv && source .venv/bin/activate
+pip install -e ".[dev,all]"
+```
+
+Confirm the install:
+
+```bash
+pytest tests/ -q
+```
+
+You should see `~517 passed, 1 skipped` in ~25 seconds.
+
+## Development Workflow
+
+1. **Fork** the repo on GitHub.
+2. **Branch**: `git checkout -b feat/short-description` or `fix/short-description`.
+3. **Code** + **test**: add or update tests in `tests/`. A change without tests is rarely merged.
+4. **Lint**: `ruff check .` (config inherits sensible defaults).
+5. **Format**: keep imports grouped, prefer `from __future__ import annotations` in new files for forward-ref types.
+6. **PR**: open against `main` with a clear title and a one-paragraph summary linking any related issue.
+
+## What I Look For
+
+- **Mathematical correctness** for any change in `primitives.py`, `extensions.py`, `multiagent.py`, `lam_types.py`, `effects.py`, `cek_machine.py`, `cost_grade.py`, `rewrite.py`. These trace back to the three lambdagent papers — please cite the paper section affected.
+- **Stability** of the public API in `__init__.py`. Adding symbols is fine; renaming or removing requires a deprecation cycle.
+- **Tests pass** locally and in CI on Python 3.10 / 3.11 / 3.12.
+- **No new top-level module names that shadow stdlib** (`types`, `trace`, `io`, `json`, …).
+- **Optional dependencies stay optional** — gate them with `try / except ImportError` and add an entry in `[project.optional-dependencies]`.
+
+## Commit Style
+
+Loosely Conventional Commits is preferred but not enforced:
+
+```
+feat(skills): add SkillRegistry.discover() with tag filtering
+fix(react): handle empty action sequence in step 7
+docs(readme): correct Loop signature in table
+chore(ci): bump pytest to 8.x
+```
+
+Keep the subject under 72 chars. Body explains the *why*, not just the *what*.
+
+## License of Contributions
+
+By submitting a PR you agree your contribution is licensed under the same
+[BUSL-1.1](./LICENSE) as the rest of the project, and that you grant the maintainer the right to relicense the project as needed (e.g. to apply a Change License earlier than 2031-04-05 if appropriate).
+
+## Questions
+
+- **Bugs / feature requests**: [Issues](https://github.com/kenny67nju/lambdagent/issues)
+- **Open discussion**: [Discussions](https://github.com/kenny67nju/lambdagent/discussions)
+- **Security**: see [SECURITY.md](./SECURITY.md) — do NOT open a public issue
+- **Commercial licensing (BUSL-1.1)** or anything else: **qinliu@nju.edu.cn**

+ 1 - 1
lambdagent/LICENSE

@@ -3,7 +3,7 @@ Business Source License 1.1
 Parameters
 
 Licensor:             kenny67nju
-Licensed Work:        lambdagentpaas
+Licensed Work:        lambdagent
                       The Licensed Work is (c) 2025 kenny67nju.
 Additional Use Grant: You may make production use of the Licensed Work,
                       provided such use does not include more than 10

+ 168 - 33
lambdagent/README.md

@@ -2,16 +2,19 @@
 
 **Lambda Calculus Agent DSL** — Every agent is a function. Every composition is function composition. Every loop is a Y combinator.
 
-[![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
-[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](./LICENSE)
+[![tests](https://github.com/kenny67nju/lambdagent/actions/workflows/test.yml/badge.svg)](https://github.com/kenny67nju/lambdagent/actions/workflows/test.yml)
+[![codecov](https://codecov.io/gh/kenny67nju/lambdagent/branch/main/graph/badge.svg)](https://codecov.io/gh/kenny67nju/lambdagent)
+[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
+[![License: BUSL-1.1](https://img.shields.io/badge/License-BUSL--1.1-orange.svg)](./LICENSE)
+[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
 
 ## Overview
 
-`lambdagent` is a Python DSL that models AI agents as Lambda calculus terms. Instead of ad-hoc agent frameworks, it provides **11 core + 5 multi-agent + 4 skill + sandbox + protocol constructs** with rigorous mathematical foundations — each one maps directly to a concept in Lambda calculus or pi-calculus.
+`lambdagent` is a Python DSL that models AI agents as Lambda calculus terms. Instead of ad-hoc agent frameworks, it provides **11 core + 5 multi-agent + 5 skill + sandbox + protocol constructs** with rigorous mathematical foundations — each one maps directly to a concept in Lambda calculus or pi-calculus.
 
 **Core insight**: An LLM-Dataset Pair (M, D) is equivalent to a λ-term. Training defines the function; inference is β-reduction.
 
-**Stats**: ~11,300 lines of Python | 81 exported symbols | 46 source files | 4 patents filed
+**Stats**: ~35,000 lines of Python | 152 exported symbols | 125 source files | 4 patents filed
 
 ```
 YAML Config ──→ from_config() ──→ Lambda Term ──→ Runtime ──→ Result
@@ -20,16 +23,34 @@ YAML Config ──→ from_config() ──→ Lambda Term ──→ Runtime ─
 
 ## Installation
 
-```bash
-pip install lambdagent
+`lambdagent` is not yet published to PyPI. Install from source:
 
-# Or from source:
-git clone https://github.com/your-org/lambdagent.git
+```bash
+git clone https://github.com/kenny67nju/lambdagent.git
 cd lambdagent && pip install -e .
+
+# Optional LLM provider extras:
+pip install -e ".[anthropic]"   # Anthropic Claude
+pip install -e ".[openai]"      # OpenAI / DashScope (OpenAI-compatible)
+pip install -e ".[all]"         # everything
 ```
 
 **Dependencies**: `pyyaml` (required), `anthropic` / `openai` (optional, for LLM providers)
 
+### Platform support
+
+CI passes on **Linux, macOS, and Windows** across Python 3.10 / 3.11 / 3.12. A few caveats on Windows:
+
+| Feature | Linux / macOS | Windows |
+|---------|:-:|:-:|
+| 11 core λ-constructs + 5 multi-agent + Skills + RAG + MCP / A2A + Types / Effects / Costs | ✅ | ✅ |
+| YAML compiler, runtime, CLI | ✅ | ✅ |
+| `Bash` / `Git*` / `RunTests` built-in tools | ✅ | ✅ via Git-Bash (auto-detected); falls back to `cmd.exe` if Git for Windows is absent |
+| `ripgrep`-backed `SearchContent` / `CodeSearch` | ✅ if `rg` is installed | ✅ if `rg.exe` is on PATH; Python fallback otherwise |
+| `SandboxedTool` / `SecureExecutor` / `ResourceLimiter` | ✅ | ❌ — raises `NotImplementedError` (POSIX resource limits + `SIGKILL` are required). Run lambdagent inside **WSL2** or a Linux container if you need sandboxed execution. |
+
+For the best Windows experience, install [Git for Windows](https://git-scm.com/download/win) (provides `bash.exe`) and optionally [ripgrep](https://github.com/BurntSushi/ripgrep#installation).
+
 ## Quick Start
 
 ### Python DSL
@@ -170,7 +191,7 @@ save_context(ctx, "checkpoint.json")
 
 ```python
 # Describe what you need in natural language → auto-generate YAML → compile → run
-python nl2agent.py "Build a research assistant that can search and analyze" \
+python examples/nl2agent_demo.py "Build a research assistant that can search and analyze" \
     -t "Research the latest Agent DSL frameworks"
 ```
 
@@ -234,46 +255,114 @@ python nl2agent.py "Build a research assistant that can search and analyze" \
 ## Architecture
 
 ```
-lambdagent/                  # ~11,300 lines, 46 .py files, 81 exported symbols
-├── __init__.py              # Public API — 81 symbols
+lambdagent/                  # ~35,000 lines, 125 .py files, 152 exported symbols
+│
+│   ── Core λ-calculus ─────────────────────────────────────────────
+├── __init__.py              # Public API — 152 symbols
 ├── core.py                  # Term, Context, TraceEntry (base abstractions)
 ├── primitives.py            # Lam, Compose, If, Loop, Pair, Fst, Snd, Tool
 ├── extensions.py            # Par, Route, Memory, Guard
 ├── dataset.py               # Dataset → Lam converter
+├── conversation.py          # ConversationLam — history-aware Lambda
 ├── multiagent.py            # Channel, Send, Receive, SharedMemory,
 │                            #   GroupChat, Handoff, AsyncPar
+├── async_core.py            # Async aapply() on all Term types
+├── patterns.py              # Reusable multi-agent collaboration patterns
+│
+│   ── Paper II / III: types, effects, costs, rewrites ─────────────
+├── types.py                 # LamType, Effect tags, T-Compose checking
+├── effects.py               # Paper III effect algebra (Pure/IO/LLM/STATE)
+├── handlers.py              # Algebraic effect handlers (Production/Test/Trace)
+├── cost_grade.py            # Graded types for static cost prediction
+├── cek_machine.py           # CEK abstract machine + CostVector
+├── rewrite.py               # Algebraic-law AST rewriting (optimize_agent)
+├── store_analysis.py        # Store-independence analysis (Prop 30)
+│
+│   ── Skills / MCP / A2A / RAG / Checkpoint ───────────────────────
 ├── skills.py                # Skill, SkillSignature, SkillPack,
 │                            #   SkillRegistry, SkillAgent, @skill
-├── mcp_client.py            # MCPServer, MCPTool, MCPTransport (HTTP+stdio),
-│                            #   mcp_tools(), mcp_tool()
-├── checkpoint.py            # Checkpoint, CheckpointManager,
-│                            #   save_context(), load_context()
-├── a2a.py                   # AgentCard, A2AServer, A2AClient, A2ATask,
-│                            #   skill_to_agent_card(), registry_to_agent_card()
+├── mcp_client.py            # MCPServer, MCPTool (HTTP + stdio)
+├── mcp_server.py            # Expose lambdagent as an MCP server
+├── resilient_mcp.py         # MCP with circuit breaker + retry + caching
+├── a2a.py                   # AgentCard, A2AServer, A2AClient
 ├── rag.py                   # RAGTool, AgenticRAG, SimpleVectorStore,
-│                            #   ChromaStore, Document, SearchResult, create_rag()
-├── sandbox.py               # SandboxedTool, SandboxPolicy, SecureExecutor,
-│                            #   ResourceLimiter, @sandboxed, SandboxViolation
-├── from_config.py           # YAML → Lambda compiler (v1)
-├── fromconfig/              # YAML → Lambda Term compiler (v2)
+│                            #   ChromaStore, Document, SearchResult
+├── checkpoint.py            # Checkpoint, save_context, load_context
+├── execution_checkpoint.py  # Resumable execution position
+│
+│   ── Sandbox / Isolation / Safety ────────────────────────────────
+├── sandbox.py               # SandboxedTool, SandboxPolicy, SecureExecutor
+├── isolation.py             # Git-worktree based agent file isolation
+├── tool_gateway.py          # Tool-call permission gateway
+├── validated_tool.py        # Schema-validated tool wrapper
+├── concurrent_tools.py      # Concurrency-safety declarations
+│
+│   ── Resilience / Observability / Resource control ───────────────
+├── cancellation.py          # Hierarchical cancellation tokens
+├── retry.py                 # Retry, exponential backoff, timeouts
+├── rate_limiter.py          # Token-bucket LLM rate limiting
+├── token_budget.py          # Token budget tracking + enforcement
+├── context_manager.py       # Context window compaction
+├── hooks.py                 # 3-layer hook system (registry/term/decorator)
+├── observability.py         # OpenTelemetry-style β-reduction tracing
+├── trace.py                 # Enhanced trace store + anomaly detection
+│
+│   ── YAML compiler ───────────────────────────────────────────────
+├── from_config.py           # v1 compiler (compat shim)
+├── lint.py                  # v1 lint (compat shim → fromconfig.lint)
+├── fromconfig/              # v2 compiler
 │   ├── compiler.py          #   from_config(), build_agent() — 5 agent types
 │   ├── schema.py            #   YAML schema validation
 │   ├── lint.py              #   Static analysis (L001-L016)
 │   ├── lambda_expr.py       #   Export pure Lambda notation
 │   └── errors.py            #   CompileError, SchemaError, SemanticError
+│
+│   ── Runtime ─────────────────────────────────────────────────────
 ├── agentruntime/            # Runtime: Term × Input → Result
-│   ├── executor.py          #   β-reduction engine (pattern-match on Term type)
+│   ├── executor.py          #   β-reduction engine
 │   ├── react_engine.py      #   ReAct 7-phase loop engine
-│   ├── action_parser.py     #   Extract actions from LLM output (JSON/XML/keyword)
-│   ├── llm_adapter.py       #   Multi-provider LLM (Anthropic/OpenAI/DashScope)
+│   ├── adaptive_engine.py   #   Adaptive engine selection
+│   ├── async_react_engine.py #  Async ReAct engine
+│   ├── cek_engine.py        #   CEK-machine driven engine
+│   ├── action_parser.py     #   Action extraction (JSON/XML/keyword)
+│   ├── llm_adapter.py       #   Multi-provider LLM dispatch
 │   ├── mcp_client.py        #   MCP JSON-RPC 2.0 HTTP client
-│   ├── memory_backend.py    #   Local/SQLite/Redis memory backends
+│   ├── memory_backend.py    #   Local/SQLite/Redis memory
 │   ├── trace_store.py       #   β-reduction trace recording
-│   ├── termination.py       #   Y combinator base case detection
-│   ├── config.py            #   RuntimeConfig dataclasses
+│   ├── termination.py       #   Y-combinator base-case detection
 │   └── runtime.py           #   Top-level Runtime class
+│
+│   ── LLM providers ───────────────────────────────────────────────
+├── providers/               # Pluggable LLM providers
+│   ├── anthropic_provider.py #  Anthropic Claude
+│   ├── openai_compat_provider.py # OpenAI / DashScope / Ollama
+│   ├── claude_code_provider.py #  Claude-Code CLI provider
+│   └── base.py              #   LLMProvider protocol
+│
+│   ── Built-in tools ──────────────────────────────────────────────
+├── builtin_tools/           # 30+ ready-to-use tools
+│   ├── file_tools.py        #   Read/Edit/Write/List/Search
+│   ├── shell_tools.py       #   Bash + Git
+│   ├── code_tools.py        #   CodeSearch / ProjectMap / RunTests
+│   ├── web_tools.py         #   WebSearch / WebFetch / NotebookEdit
+│   ├── knowledge_tools.py   #   Chunk/OCR/DocGen/KB management
+│   ├── qa_tools.py          #   IngestFiles / QueryKnowledge / DeepAnalysis
+│   ├── wiki_tools.py        #   WikiIngest / WikiQuery / WikiLint
+│   ├── task_manager.py      #   TaskCreate / TaskUpdate / TaskList
+│   ├── permission_ui.py     #   Interactive permission prompts
+│   ├── terminal_ui.py       #   Rich terminal rendering
+│   └── registry.py          #   BUILTIN_TOOLS master registry
+│
+│   ── Framework migration / Skill packs ──────────────────────────
+├── extractors/              # Migrate from other frameworks
+│   ├── langchain_extractor.py
+│   ├── autogen_extractor.py
+│   └── crewai_extractor.py
+├── skillpacks/              # Curated skill collections
+│   └── research/            #   Research-oriented skills
+│
 └── cli/                     # Command-line interface
-    ├── main.py              #   compile, run, repl, lint, lambda, serve
+    ├── main.py              #   compile / run / repl / lint / lambda / trace / tools / version
     └── shell_tool.py        #   Shell tool integration
 ```
 
@@ -292,8 +381,17 @@ lambdagent repl config.yml
 # Static analysis
 lambdagent lint config.yml
 
-# Export Lambda expression
+# Export pure Lambda expression
 lambdagent lambda config.yml
+
+# View / replay β-reduction trace
+lambdagent trace <run-id>
+
+# List and test built-in tools
+lambdagent tools
+
+# Print version info
+lambdagent version
 ```
 
 ## Agent Types
@@ -315,7 +413,7 @@ This project is grounded in the equivalence between LLM-Dataset Pairs and Lambda
 - **S and K Combinators**: Proven Turing-complete via SKI calculus ✓
 - **Arithmetic & Logic**: ADD, MUL, AND, OR, NOT all verified at 94-100% accuracy ✓
 
-See the `experiments/` directory in the [MDPair](https://github.com/your-org/MDPair) repo for verification code.
+See the `experiments/` directory in the [MDPair](https://github.com/kenny67nju/MDPair) repo for verification code.
 
 ## Multi-Provider LLM Support
 
@@ -335,9 +433,46 @@ Provider is auto-detected from model name. API keys are read from environment va
 - `OPENAI_API_KEY`
 - `DASHSCOPE_API_KEY`
 
+## MCP Server (use lambdagent from your AI IDE)
+
+`python -m lambdagent.mcp_server` exposes lambdagent's static analysis (lint, cost prediction, type checking, parallel safety) as MCP tools for Claude Code, Cursor, and other MCP clients.
+
+```jsonc
+// Claude Code: .claude/settings.json    |    Cursor: .cursor/mcp.json
+{
+  "mcpServers": {
+    "lambdagent": {
+      "command": "python3",
+      "args": ["-m", "lambdagent.mcp_server"]
+    }
+  }
+}
+```
+
+Exposed tools:
+
+| Tool | Description |
+|------|-------------|
+| `lint_agent_config` | 26-rule structural lint for LangChain/CrewAI/AutoGen/Dify configs |
+| `estimate_agent_cost` | Worst-case cost prediction (tokens, latency, USD, success probability) |
+| `check_agent_types` | T-Compose type checking (output(f) <: input(g)) |
+| `check_parallel_safety` | Store independence verification (Paper II Proposition 30) |
+| `monitor_agent_cost` | Runtime cost anomaly detection (actual vs predicted) |
+
 ## License
 
-MIT License. See [LICENSE](./LICENSE).
+Business Source License 1.1 (BUSL-1.1). Free for non-production use and for production use up to 10 users; converts to Apache 2.0 on 2031-04-05. See [LICENSE](./LICENSE).
+
+Commercial licensing inquiries: **qinliu@nju.edu.cn**.
+
+## Contact
+
+- 🐛 Bugs / feature requests: [Issues](https://github.com/kenny67nju/lambdagent/issues)
+- 💬 Open discussion: [Discussions](https://github.com/kenny67nju/lambdagent/discussions)
+- 🔒 Security: see [SECURITY.md](./SECURITY.md) — do **not** open a public issue
+- 🤝 Contributing: see [CONTRIBUTING.md](./CONTRIBUTING.md)
+- 📜 Changelog: see [CHANGELOG.md](./CHANGELOG.md)
+- 📧 Anything else (including BUSL commercial licensing): **qinliu@nju.edu.cn**
 
 ## Citation
 
@@ -346,6 +481,6 @@ MIT License. See [LICENSE](./LICENSE).
   title={lambdagent: Lambda Calculus Agent DSL},
   author={Qin Liu},
   year={2026},
-  url={https://github.com/your-org/lambdagent}
+  url={https://github.com/kenny67nju/lambdagent}
 }
 ```

+ 36 - 0
lambdagent/SECURITY.md

@@ -0,0 +1,36 @@
+# Security Policy
+
+## Reporting a Vulnerability
+
+Please **do not** open a public GitHub issue for security vulnerabilities.
+
+Email **qinliu@nju.edu.cn** with subject line starting `[lambdagent-security]`.
+
+I will respond within 7 days and coordinate disclosure.
+
+You can also use GitHub's [Private Vulnerability Reporting](https://github.com/kenny67nju/lambdagent/security/advisories/new).
+
+## Supported Versions
+
+| Version | Supported |
+|---------|-----------|
+| 0.1.x   | ✅ active |
+| < 0.1   | ❌        |
+
+## Scope
+
+In scope:
+- `lambdagent.fromconfig.compiler` YAML → Term compiler (including the `guard.validator` safe-eval sandbox)
+- `lambdagent.sandbox` process isolation and resource limits
+- `lambdagent.tool_gateway` permission gateway
+- `lambdagent.builtin_tools.shell_tools` / `code_tools` (any command-injection vector)
+- `lambdagent.mcp_client` / `mcp_server` (RPC parsing, transport handling)
+- `lambdagent.providers.*` (API key handling, credential leakage)
+
+Out of scope:
+- Vulnerabilities in third-party LLM provider APIs (report to those providers)
+- Vulnerabilities in optional MCP servers users connect to
+- Issues that require physical or local-file access to the developer's machine
+- DoS via resource exhaustion when the user explicitly disables sandbox limits
+
+See `SECURITYSPEC.md` for the detailed security model.

+ 2 - 2
lambdagent/__init__.py

@@ -37,7 +37,7 @@ lambdagent — 基于 Lambda 演算的 Agent DSL
 
 from .core import Term, Context, TraceEntry
 from .core import LambdagentError, UnboundVariable, RouteError, ValidationError
-from .trace import (
+from .tracing import (
     TraceStore, TraceEntry as EnhancedTraceEntry, Anomaly,
     colorize_timeline, detect_anomalies, format_anomalies,
     generate_flamegraph_html, save_flamegraph,
@@ -82,7 +82,7 @@ from .rag import (
     create_rag,
 )
 # Paper III: Type & Effect System
-from .types import (
+from .lam_types import (
     LamType, TypeTag, AgentType, AgentTypeError,
     T_ANY, T_NONE, T_STR, T_INT, T_FLOAT, T_BOOL, T_JSON, T_TUPLE, T_UNION,
     is_subtype, check_compose_types, parse_type_annotation, infer_type_from_value,

+ 82 - 0
lambdagent/_shell_compat.py

@@ -0,0 +1,82 @@
+"""
+lambdagent._shell_compat — Cross-platform shell helpers.
+
+Two related concerns:
+
+1. ``run_shell(command, **kwargs)`` — invoke a shell command portably. On
+   POSIX uses ``shell=True`` (``/bin/sh``). On Windows uses ``shell=False``
+   plus an explicit ``[bash.exe, "-c", command]`` argv when Git-for-Windows
+   ``bash.exe`` is available — this is what lets bash-style commands
+   (pipes, ``$VAR``, single-quote quoting, ``&&``, heredocs) work on
+   Windows runners. When Git-Bash isn't installed, falls back to
+   ``cmd.exe`` semantics — user-supplied bash syntax will then likely fail,
+   so we recommend installing Git for Windows in the README.
+
+   We don't pass ``executable=bash.exe`` with ``shell=True``: Python on
+   Windows builds the command line as ``"<executable> <command>"`` without
+   re-quoting, so a path with spaces ("C:\\Program Files\\...") gets split
+   by CreateProcess and the exec fails with EXIT:127.
+
+2. ``resolve_bash()`` — return the discovered ``bash.exe`` path on Windows
+   (or ``None``) so callers that want to construct their own argv can do so.
+
+Usage::
+
+    from .._shell_compat import run_shell
+    result = run_shell("echo hello", capture_output=True, text=True)
+"""
+from __future__ import annotations
+
+import os
+import platform
+import shutil
+import subprocess
+from typing import Any, Optional
+
+_IS_WINDOWS = platform.system() == "Windows"
+_CACHED_BASH: Optional[str] = None    # "" sentinel = searched, not found
+
+
+def resolve_bash() -> Optional[str]:
+    """Return the path to ``bash.exe`` on Windows, or ``None``.
+
+    Looks at PATH first, then Git-for-Windows default install locations.
+    Returns ``None`` on POSIX (where ``/bin/sh`` is always available).
+    Result is cached after the first probe.
+    """
+    global _CACHED_BASH
+    if not _IS_WINDOWS:
+        return None
+    if _CACHED_BASH is None:
+        for candidate in [
+            shutil.which("bash"),
+            r"C:\Program Files\Git\bin\bash.exe",
+            r"C:\Program Files (x86)\Git\bin\bash.exe",
+        ]:
+            if candidate and os.path.isfile(candidate):
+                _CACHED_BASH = candidate
+                break
+        else:
+            _CACHED_BASH = ""
+    return _CACHED_BASH or None
+
+
+def run_shell(command: str, **kwargs: Any) -> subprocess.CompletedProcess:
+    """Run ``command`` portably via ``subprocess.run``.
+
+    On POSIX: ``subprocess.run(command, shell=True, **kwargs)``.
+    On Windows: ``subprocess.run([bash, "-c", command], shell=False, **kwargs)``
+    when Git-Bash is available; else ``shell=True`` (cmd.exe).
+    """
+    bash = resolve_bash()
+    if bash:
+        return subprocess.run([bash, "-c", command], shell=False, **kwargs)
+    return subprocess.run(command, shell=True, **kwargs)
+
+
+def popen_shell(command: str, **kwargs: Any) -> subprocess.Popen:
+    """Background variant of :func:`run_shell` — returns the live ``Popen``."""
+    bash = resolve_bash()
+    if bash:
+        return subprocess.Popen([bash, "-c", command], shell=False, **kwargs)
+    return subprocess.Popen(command, shell=True, **kwargs)

+ 24 - 5
lambdagent/builtin_tools/code_tools.py

@@ -12,6 +12,8 @@ import os
 import subprocess
 from typing import Any, Dict, Optional
 
+from .._shell_compat import run_shell as _run_shell
+
 
 # ════════════════════════════════════════════════════════════
 # A11: CodeSearch — semantic code search
@@ -168,11 +170,29 @@ def _do_search(pattern: str, path: str, language: str, max_results: int) -> list
 
 
 def _find_rg() -> Optional[str]:
-    for cmd in ["rg", "/usr/local/bin/rg", "/opt/homebrew/bin/rg"]:
+    """Locate the ripgrep binary across platforms.
+
+    Order: PATH (works on Linux/macOS/Windows via shutil.which) → known
+    homebrew/macports/winget locations. Returns None if rg is unavailable;
+    callers fall back to a slower Python regex walk.
+    """
+    import shutil
+    found = shutil.which("rg")
+    if found:
+        return found
+    # Per-platform fallback locations (Brew, MacPorts, Chocolatey, scoop)
+    candidates = [
+        "/usr/local/bin/rg",            # Linux / Intel-mac Brew
+        "/opt/homebrew/bin/rg",         # Apple-silicon Brew
+        "/opt/local/bin/rg",            # MacPorts
+        "C:/ProgramData/chocolatey/bin/rg.exe",   # Chocolatey
+        "C:/tools/ripgrep/rg.exe",                # scoop default
+    ]
+    for cmd in candidates:
         try:
             subprocess.run([cmd, "--version"], capture_output=True, timeout=3)
             return cmd
-        except (FileNotFoundError, subprocess.TimeoutExpired):
+        except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
             continue
     return None
 
@@ -325,9 +345,8 @@ def run_tests(input_val: Any) -> str:
 
     # Execute
     try:
-        result = subprocess.run(
-            cmd, shell=True, cwd=cwd,
-            capture_output=True, text=True, timeout=300,
+        result = _run_shell(
+            cmd, cwd=cwd, capture_output=True, text=True, timeout=300,
         )
         output = result.stdout
         if result.stderr:

+ 14 - 3
lambdagent/builtin_tools/file_tools.py

@@ -357,12 +357,23 @@ def search_content(input_val: Any) -> str:
 
 
 def _find_rg() -> Optional[str]:
-    """Find ripgrep binary."""
-    for cmd in ["rg", "/usr/local/bin/rg", "/opt/homebrew/bin/rg"]:
+    """Find ripgrep binary across Linux / macOS / Windows."""
+    import shutil
+    found = shutil.which("rg")
+    if found:
+        return found
+    candidates = [
+        "/usr/local/bin/rg",
+        "/opt/homebrew/bin/rg",
+        "/opt/local/bin/rg",
+        "C:/ProgramData/chocolatey/bin/rg.exe",
+        "C:/tools/ripgrep/rg.exe",
+    ]
+    for cmd in candidates:
         try:
             subprocess.run([cmd, "--version"], capture_output=True, timeout=5)
             return cmd
-        except (FileNotFoundError, subprocess.TimeoutExpired):
+        except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
             continue
     return None
 

+ 30 - 23
lambdagent/builtin_tools/registry.py

@@ -35,13 +35,17 @@ from .knowledge_tools import chunk_split, ocr_extract, doc_generate, kb_manage
 from .qa_tools import ingest_files, query_knowledge, list_knowledge, remove_knowledge, deep_analysis
 from .wiki_tools import wiki_ingest, wiki_query, wiki_lint, wiki_search, wiki_status, wiki_grow
 
-# PaaS services (P01-P08)
-from agentpaas.services.memory_service import memory_store, memory_recall, memory_list, memory_forget
-from agentpaas.services.scheduler import schedule_create, schedule_list, schedule_delete
-from agentpaas.services.notification import notify
-from agentpaas.services.event_bus import event_subscribe, event_list
-from agentpaas.services.profile import profile_get, profile_update
-from agentpaas.services.learning import learning_feedback, learning_strategies
+# PaaS services (P01-P08) — optional, only available when agentpaas is installed
+try:
+    from agentpaas.services.memory_service import memory_store, memory_recall, memory_list, memory_forget
+    from agentpaas.services.scheduler import schedule_create, schedule_list, schedule_delete
+    from agentpaas.services.notification import notify
+    from agentpaas.services.event_bus import event_subscribe, event_list
+    from agentpaas.services.profile import profile_get, profile_update
+    from agentpaas.services.learning import learning_feedback, learning_strategies
+    _HAS_PAAS_SERVICES = True
+except ImportError:
+    _HAS_PAAS_SERVICES = False
 
 
 def _make_tool(name: str, fn, schema=None, description: str = "") -> ValidatedTool:
@@ -95,22 +99,6 @@ BUILTIN_TOOLS = {
     "KBSearch":       _make_tool("KBSearch",      kb_manage,     None, "Search knowledge base"),
     "KBList":         _make_tool("KBList",        kb_manage,     None, "List knowledge bases"),
 
-    # PaaS Services (P01-P08)
-    "MemoryStore":    _make_tool("MemoryStore",    memory_store,    None, "Store a memory (persistent)"),
-    "MemoryRecall":   _make_tool("MemoryRecall",   memory_recall,   None, "Recall memories by query"),
-    "MemoryList":     _make_tool("MemoryList",     memory_list,     None, "List all memories"),
-    "MemoryForget":   _make_tool("MemoryForget",   memory_forget,   None, "Forget a memory"),
-    "ScheduleCreate": _make_tool("ScheduleCreate", schedule_create, None, "Create scheduled task"),
-    "ScheduleList":   _make_tool("ScheduleList",   schedule_list,   None, "List scheduled tasks"),
-    "ScheduleDelete": _make_tool("ScheduleDelete", schedule_delete, None, "Delete scheduled task"),
-    "Notify":         _make_tool("Notify",         notify,          None, "Send notification"),
-    "EventSubscribe": _make_tool("EventSubscribe", event_subscribe, None, "Subscribe to system events"),
-    "EventList":      _make_tool("EventList",      event_list,      None, "List event subscriptions"),
-    "ProfileGet":     _make_tool("ProfileGet",     profile_get,     None, "Get user profile"),
-    "ProfileUpdate":  _make_tool("ProfileUpdate",  profile_update,  None, "Update user profile"),
-    "LearningFeedback":   _make_tool("LearningFeedback",   learning_feedback,   None, "Record learning feedback"),
-    "LearningStrategies": _make_tool("LearningStrategies", learning_strategies, None, "List learned strategies"),
-
     # QA Agent tools (qaagent67)
     "IngestFiles":     _make_tool("IngestFiles",     ingest_files,     None, "Ingest files into knowledge base (batch, supports dir/glob/single file)"),
     "QueryKnowledge":  _make_tool("QueryKnowledge",  query_knowledge,  None, "Answer questions from knowledge base with source citations"),
@@ -130,6 +118,25 @@ BUILTIN_TOOLS = {
     "terminate":      Tool("terminate", fn=lambda x: x),
 }
 
+# PaaS Services (P01-P08) — register only when agentpaas is importable
+if _HAS_PAAS_SERVICES:
+    BUILTIN_TOOLS.update({
+        "MemoryStore":        _make_tool("MemoryStore",        memory_store,        None, "Store a memory (persistent)"),
+        "MemoryRecall":       _make_tool("MemoryRecall",       memory_recall,       None, "Recall memories by query"),
+        "MemoryList":         _make_tool("MemoryList",         memory_list,         None, "List all memories"),
+        "MemoryForget":       _make_tool("MemoryForget",       memory_forget,       None, "Forget a memory"),
+        "ScheduleCreate":     _make_tool("ScheduleCreate",     schedule_create,     None, "Create scheduled task"),
+        "ScheduleList":       _make_tool("ScheduleList",       schedule_list,       None, "List scheduled tasks"),
+        "ScheduleDelete":     _make_tool("ScheduleDelete",     schedule_delete,     None, "Delete scheduled task"),
+        "Notify":             _make_tool("Notify",             notify,              None, "Send notification"),
+        "EventSubscribe":     _make_tool("EventSubscribe",     event_subscribe,     None, "Subscribe to system events"),
+        "EventList":          _make_tool("EventList",          event_list,          None, "List event subscriptions"),
+        "ProfileGet":         _make_tool("ProfileGet",         profile_get,         None, "Get user profile"),
+        "ProfileUpdate":      _make_tool("ProfileUpdate",      profile_update,      None, "Update user profile"),
+        "LearningFeedback":   _make_tool("LearningFeedback",   learning_feedback,   None, "Record learning feedback"),
+        "LearningStrategies": _make_tool("LearningStrategies", learning_strategies, None, "List learned strategies"),
+    })
+
 
 def get_builtin_tool(name: str) -> Tool | None:
     """Look up a built-in tool by name. Returns None if not found."""

+ 12 - 4
lambdagent/builtin_tools/shell_tools.py

@@ -3,6 +3,12 @@ lambdagent.builtin_tools.shell_tools — Enhanced shell execution
 
 Bash     λx. exec(command)  — persistent CWD, background, timeout
 Git*     λx. git(args)      — status, diff, log, commit, branch
+
+Cross-platform note: by default ``shell=True`` routes through ``cmd.exe`` on
+Windows. ``_resolve_shell()`` prefers Git-Bash (``bash.exe``) when it's on
+PATH so user-supplied bash-style commands (``&&``, ``$VAR``, single-quote
+quoting, heredocs) keep working. Pure ``cmd``-syntax also works for users
+who explicitly want it.
 """
 from __future__ import annotations
 
@@ -13,6 +19,8 @@ import subprocess
 import threading
 from typing import Any, Dict, Optional
 
+from .._shell_compat import run_shell as _run_shell, popen_shell as _popen_shell
+
 
 # Shared session CWD (persists across calls)
 _session_cwd = os.getcwd()
@@ -94,8 +102,8 @@ def run_bash(input_val: Any) -> str:
     if background:
         # Background execution
         try:
-            proc = subprocess.Popen(
-                command, shell=True, cwd=working_dir, env=env,
+            proc = _popen_shell(
+                command, cwd=working_dir, env=env,
                 stdout=subprocess.PIPE, stderr=subprocess.PIPE,
             )
             return f"[BACKGROUND] PID={proc.pid}, command='{command[:60]}'"
@@ -104,8 +112,8 @@ def run_bash(input_val: Any) -> str:
 
     # Foreground execution
     try:
-        result = subprocess.run(
-            command, shell=True, cwd=working_dir, env=env,
+        result = _run_shell(
+            command, cwd=working_dir, env=env,
             capture_output=True, text=True, timeout=timeout,
         )
         stdout = result.stdout

+ 4 - 4
lambdagent/cli/shell_tool.py

@@ -16,6 +16,8 @@ import shlex
 import subprocess
 from typing import Optional
 
+from .._shell_compat import run_shell as _run_shell
+
 
 DANGEROUS_PATTERNS = [
     "rm -rf /",
@@ -76,9 +78,8 @@ class ShellTool:
             cmd = self.command
 
         try:
-            result = subprocess.run(
+            result = _run_shell(
                 cmd,
-                shell=True,
                 capture_output=True,
                 text=True,
                 timeout=self.timeout,
@@ -124,9 +125,8 @@ class CLIAgent:
         last_error = None
         for attempt in range(1 + self.retry):
             try:
-                proc = subprocess.run(
+                proc = _run_shell(
                     self.command,
-                    shell=True,
                     input=str(input_text),
                     capture_output=True,
                     text=True,

+ 6 - 6
lambdagent/core.py

@@ -17,7 +17,7 @@ from dataclasses import dataclass, field
 from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING
 
 if TYPE_CHECKING:
-    from .types import LamType, AgentType
+    from .lam_types import LamType, AgentType
     from .effects import Effect, ComposedEffect
 
 
@@ -165,7 +165,7 @@ class Term(ABC):
         """Agent 的输入类型 (Paper III Definition 3)"""
         if self._input_type is not None:
             return self._input_type
-        from .types import T_ANY
+        from .lam_types import T_ANY
         return T_ANY
 
     @input_type.setter
@@ -177,7 +177,7 @@ class Term(ABC):
         """Agent 的输出类型 (Paper III Definition 3)"""
         if self._output_type is not None:
             return self._output_type
-        from .types import T_ANY
+        from .lam_types import T_ANY
         return T_ANY
 
     @output_type.setter
@@ -199,7 +199,7 @@ class Term(ABC):
     @property
     def agent_type(self) -> AgentType:
         """完整的 Agent 函数类型 τ1 →^ε τ2"""
-        from .types import AgentType
+        from .lam_types import AgentType
         eff = self.effect
         eff_str = repr(eff)
         return AgentType(self.input_type, self.output_type, effect=eff_str)
@@ -224,12 +224,12 @@ class Term(ABC):
             raise TypeError("Use instances, not classes")
 
         # Paper III T-Compose: 类型检查 (仅当两端都有非 Any 类型标注时)
-        from .types import T_ANY, is_subtype
+        from .lam_types import T_ANY, is_subtype
         f_out = self.output_type if hasattr(self, 'stages') else self.output_type
         g_in = other.input_type
         if f_out != T_ANY and g_in != T_ANY:
             if not is_subtype(f_out, g_in):
-                from .types import AgentTypeError
+                from .lam_types import AgentTypeError
                 raise AgentTypeError(
                     f"Type mismatch: {self._name} >> {other._name}",
                     source_type=f_out,

+ 3 - 3
lambdagent/examples/demo_advanced.py

@@ -106,7 +106,7 @@ def demo_self_correcting_translator():
         score_raw = judge(f"Original: {source}\nBack-translated: {back}", ctx)
         try:
             score = int(score_raw.strip())
-        except:
+        except (ValueError, IndexError):
             score = 5
         print(f"  [score] {score}/10")
 
@@ -298,7 +298,7 @@ def demo_recursive_document():
         lines = review.strip().split("\n")
         try:
             score = int(lines[0].strip().split()[0])
-        except:
+        except (ValueError, IndexError):
             score = 6
         feedback = lines[1] if len(lines) > 1 else "No specific feedback"
 
@@ -380,7 +380,7 @@ def demo_self_learning():
             actual_raw = learner(str(x), ctx)
             try:
                 actual = int(actual_raw.strip())
-            except:
+            except (ValueError, IndexError):
                 actual = -1
 
             ok = actual == expected

+ 1 - 1
lambdagent/examples/ex08_mcp_checkpoint.py

@@ -97,7 +97,7 @@ class MockMCPHandler(BaseHTTPRequestHandler):
             try:
                 result = eval(expr, {"__builtins__": {}}, {})
                 return {"content": [{"type": "text", "text": f"{expr} = {result}"}]}
-            except:
+            except Exception:
                 return {"content": [{"type": "text", "text": f"Cannot evaluate: {expr}"}]}
         elif name == "translator":
             text = args.get("text", args.get("input", ""))

+ 230 - 27
lambdagent/fromconfig/compiler.py

@@ -13,6 +13,7 @@ v2 Optimizations:
 """
 from __future__ import annotations
 
+import ast
 import json
 import os
 import re
@@ -246,7 +247,11 @@ def build_agent(cfg: Dict[str, Any], overrides: Dict = None, _depth: int = 0) ->
 
     # Step 0: Compile subAgents if present (multi-agent orchestrator support)
     # subAgents 节定义了子代理,编译后作为 call_* 工具注入到协调者
-    sub_agents_cfg = cfg.get("subAgents", {})
+    # Accept both `subAgents` (camelCase) and `sub_agents` (snake_case) —
+    # the latter silently fell through to placeholder stub tools before,
+    # which was an extremely confusing failure mode (orchestrator "ran"
+    # but no sub-agent ever fired). See run_40aab2706a1a.
+    sub_agents_cfg = cfg.get("subAgents") or cfg.get("sub_agents") or {}
     if sub_agents_cfg and "tools" not in overrides:
         overrides = {**overrides, "tools": _compile_sub_agents(cfg, sub_agents_cfg, overrides)}
 
@@ -644,6 +649,30 @@ def _compile_react(cfg: Dict, overrides: Dict) -> Term:
     verbose = react_cfg.get("verbose", False)
     agent_name = cfg.get("name", cfg.get("agentId", "agent"))
 
+    # enforceLoop: hard floor on tool execution before terminate is allowed.
+    # Two modes:
+    #
+    #  (1) Counter mode (legacy):
+    #        enforceLoop: {tool: call_physreview, minCount: 3}
+    #      Requires `tool` to have been called minCount times. Easy to game —
+    #      orchestrator learned to spam `call_physreview` 3× back-to-back
+    #      without ever calling the other 4 sub-agents (run_ba0d79701903).
+    #
+    #  (2) Sequence mode (new — closes the loophole):
+    #        enforceLoop: {sequence: [t1, t2, ..., t_last], minCycles: 3}
+    #      A "cycle" is bounded by adjacent firings of `sequence[-1]`. Before
+    #      each `sequence[-1]` call counts as a complete cycle, every other
+    #      tool in `sequence` must have appeared at least once since the
+    #      previous cycle boundary (or run start). Cycles that don't satisfy
+    #      this don't count toward minCycles. On premature terminate the
+    #      push-back message names exactly which prereqs the current partial
+    #      cycle is still missing.
+    enforce_loop_cfg = react_cfg.get("enforceLoop", {}) or {}
+    enforce_loop_tool = enforce_loop_cfg.get("tool", "")
+    enforce_loop_min = int(enforce_loop_cfg.get("minCount", 0))
+    enforce_loop_sequence = list(enforce_loop_cfg.get("sequence", []) or [])
+    enforce_loop_min_cycles = int(enforce_loop_cfg.get("minCycles", 0))
+
     # Streaming callback (injected via overrides["on_step"])
     _on_step = overrides.get("on_step")
 
@@ -716,8 +745,90 @@ def _compile_react(cfg: Dict, overrides: Dict) -> Term:
 
         # ── 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:
+            # HARD floor: enforceLoop forces a minimum number of calls to
+            # a specific tool before terminate is accepted. Configured via
+            # `react.enforceLoop: {tool: <name>, minCount: <N>}` in YAML.
+            # This is the only check that fires even on step 0 (the others
+            # gate on `step < max_steps - 2` and a non-empty _tool_log).
+            enforce_loop_satisfied = False
+            if step < max_steps - 1:
+                # ── Sequence mode: each cycle must visit every prereq tool
+                #    before sequence[-1] counts as a complete cycle ──
+                if enforce_loop_sequence and enforce_loop_min_cycles > 0:
+                    boundary_tool = enforce_loop_sequence[-1]
+                    prereqs = set(enforce_loop_sequence[:-1])
+                    complete_cycles = 0
+                    seen_in_cycle = set()
+                    for e in _tool_log:
+                        t = e.get("tool")
+                        if t == boundary_tool:
+                            if prereqs.issubset(seen_in_cycle):
+                                complete_cycles += 1
+                            # Either way the cycle boundary resets — a bare
+                            # boundary call doesn't carry over to the next.
+                            seen_in_cycle = set()
+                        elif t in prereqs:
+                            seen_in_cycle.add(t)
+
+                    if complete_cycles < enforce_loop_min_cycles:
+                        missing = sorted(prereqs - seen_in_cycle)
+                        next_tool = (
+                            missing[0] if missing
+                            else boundary_tool
+                        )
+                        _last_observation[0] = (
+                            f"[SYSTEM] 循环未完成。已完成 "
+                            f"{complete_cycles}/{enforce_loop_min_cycles} 轮。\n"
+                            f"当前轮还缺:{', '.join(missing) if missing else '(只差 ' + boundary_tool + ')'}。\n"
+                            f"禁止 terminate。请按顺序调用 "
+                            f"{' → '.join(enforce_loop_sequence)},下一步应该是 "
+                            f"{next_tool}。"
+                        )
+                        if verbose:
+                            print(
+                                f"  B[{step}] terminate BLOCKED — sequence "
+                                f"{complete_cycles}/{enforce_loop_min_cycles} cycles, "
+                                f"missing this cycle: {missing}"
+                            )
+                        return f"{_user_input[0]}\n[Step {step+1}] pending (cycle {complete_cycles}/{enforce_loop_min_cycles})"
+                    enforce_loop_satisfied = True
+                    if verbose:
+                        print(
+                            f"  B[{step}] enforceLoop OK — "
+                            f"{complete_cycles}/{enforce_loop_min_cycles} complete cycles; "
+                            f"terminate allowed"
+                        )
+
+                # ── Counter mode (legacy fallback) ──
+                elif enforce_loop_tool and enforce_loop_min > 0:
+                    done_count = sum(
+                        1 for e in _tool_log if e.get("tool") == enforce_loop_tool
+                    )
+                    if done_count < enforce_loop_min:
+                        remaining = enforce_loop_min - done_count
+                        _last_observation[0] = (
+                            f"[SYSTEM] 循环未完成:{enforce_loop_tool} 已调用 "
+                            f"{done_count}/{enforce_loop_min} 次。还需 {remaining} 轮。\n"
+                            f"禁止 terminate。请继续执行 sub-agent 序列直到 "
+                            f"{enforce_loop_tool} 累计调用 {enforce_loop_min} 次。"
+                        )
+                        if verbose:
+                            print(
+                                f"  B[{step}] terminate BLOCKED — {enforce_loop_tool} "
+                                f"called {done_count}/{enforce_loop_min} times"
+                            )
+                        return f"{_user_input[0]}\n[Step {step+1}] pending (loop {done_count}/{enforce_loop_min})"
+                    enforce_loop_satisfied = True
+                    if verbose:
+                        print(
+                            f"  B[{step}] enforceLoop OK — {enforce_loop_tool} "
+                            f"{done_count}/{enforce_loop_min} satisfied; terminate allowed"
+                        )
+
+            # Verify completion against actual tool log.
+            # Skipped when enforceLoop is configured *and* satisfied — that
+            # check already proves the agent did the work it was supposed to.
+            if _tool_log and step < max_steps - 2 and not enforce_loop_satisfied:
                 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)
@@ -1109,12 +1220,70 @@ def _compile_mcp_caller(server_name: str, tool_name: str, cfg: Dict) -> Callable
 # Guard & Memory compilation
 # ============================================================
 
+_SAFE_FUNCS = {
+    "len": len, "str": str, "int": int, "float": float, "bool": bool,
+    "abs": abs, "min": min, "max": max, "sum": sum,
+    "all": all, "any": any, "isinstance": isinstance,
+}
+_SAFE_NODES = (
+    ast.Expression, ast.BoolOp, ast.BinOp, ast.UnaryOp, ast.Compare,
+    ast.IfExp, ast.Constant, ast.Name, ast.Load,
+    ast.Subscript, ast.Slice, ast.Index if hasattr(ast, "Index") else ast.Slice,
+    ast.List, ast.Tuple, ast.Dict, ast.Set,
+    ast.And, ast.Or, ast.Not, ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Mod,
+    ast.Pow, ast.FloorDiv, ast.BitAnd, ast.BitOr, ast.BitXor,
+    ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.Is, ast.IsNot,
+    ast.In, ast.NotIn, ast.USub, ast.UAdd, ast.Invert,
+    ast.Call,  # restricted via node walk
+)
+
+
+def _safe_eval(expr: str, x):
+    """Safely evaluate a guard validator expression.
+
+    Allowed: arithmetic, comparison, boolean, indexing, calls to whitelisted
+    builtins (len/str/int/float/bool/abs/min/max/sum/all/any/isinstance).
+    Single variable: ``x`` (the value being validated).
+    Disallowed: attribute access, lambda, comprehensions, imports, dunders.
+
+    Returns the expression's truthiness, or False on any error.
+    """
+    try:
+        tree = ast.parse(expr, mode="eval")
+        # Validate AST shape — reject anything outside the safe whitelist.
+        for node in ast.walk(tree):
+            if not isinstance(node, _SAFE_NODES):
+                return False
+            # No attribute access — blocks .__class__ / .__mro__ / .__globals__
+            if isinstance(node, ast.Attribute):
+                return False
+            # Calls must be to a whitelisted Name only (no chained calls or attrs).
+            if isinstance(node, ast.Call):
+                if not isinstance(node.func, ast.Name) or node.func.id not in _SAFE_FUNCS:
+                    return False
+            # Names must reference x or a whitelisted callable.
+            if isinstance(node, ast.Name) and node.id != "x" and node.id not in _SAFE_FUNCS:
+                return False
+        # Disable builtins explicitly in case of any miss.
+        return bool(eval(  # noqa: S307 — AST-validated above
+            compile(tree, "<guard.validator>", "eval"),
+            {"__builtins__": {}},
+            {"x": x, **_SAFE_FUNCS},
+        ))
+    except Exception:
+        return False
+
+
 def _compile_guard(agent: Term, guard_cfg: Dict) -> Term:
     """Wrap agent with Guard (dependent type: {x:T | P(x)}).
 
     Now also enforces maxOutputLength by truncating output in the validator.
     dangerousCommandBlock and highRiskConfirmation are enforced via ToolGateway
     (injected in build_agent → _compile_tools), not here.
+
+    Security: the user-supplied ``validator`` expression is evaluated through
+    ``_safe_eval`` (AST whitelist), not bare ``eval()``. Closes a YAML→RCE
+    vector — see the lambdagent CHANGELOG entry for details.
     """
     validator_expr = guard_cfg.get("validator", "True")
     retry = guard_cfg.get("retry", 0)
@@ -1122,13 +1291,10 @@ def _compile_guard(agent: Term, guard_cfg: Dict) -> Term:
     max_output_length = guard_cfg.get("maxOutputLength", 0)
 
     def validator_fn(x):
-        try:
-            # Enforce maxOutputLength: truncate if exceeded
-            if max_output_length > 0 and isinstance(x, str) and len(x) > max_output_length:
-                return False  # trigger retry or fallback
-            return bool(eval(validator_expr, {"x": x, "len": len, "str": str, "int": int, "float": float}))
-        except Exception:
-            return False
+        # Enforce maxOutputLength: truncate if exceeded
+        if max_output_length > 0 and isinstance(x, str) and len(x) > max_output_length:
+            return False  # trigger retry or fallback
+        return _safe_eval(validator_expr, x)
 
     on_fail = None
     if fallback == "empty":
@@ -1197,20 +1363,44 @@ def _extract_tool_call(thought: str, tools: Dict[str, Tool]):
     Returns (Tool, input_dict) or (None, None).
     Priority: JSON > XML > keyword
     """
-    # Try JSON block: ```json ... ```
-    json_block = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', thought, re.DOTALL)
-    if json_block:
-        result = _parse_json_action(json_block.group(1), tools)
-        if result:
-            return result
+    # ── JSON via raw_decode (handles nested {...}) ──
+    # The original lazy regex `\{.*?\}` breaks on nested objects like
+    # `{"tool":"X","input":{"a":1,"b":2}}` because it matches up to the FIRST
+    # closing brace, producing invalid JSON. json.JSONDecoder.raw_decode
+    # handles brace balance natively.
+    #
+    # Strategy:
+    #   1. If a ```json ... ``` fence exists, scan inside it first.
+    #   2. Otherwise scan the entire thought for the first valid JSON object
+    #      that names a known tool.
+    def _scan_for_json_action(text: str):
+        decoder = json.JSONDecoder()
+        i = 0
+        while i < len(text):
+            if text[i] == '{':
+                try:
+                    obj, _end = decoder.raw_decode(text, i)
+                    if isinstance(obj, dict):
+                        result = _parse_json_action(obj, tools)
+                        if result:
+                            return result
+                except json.JSONDecodeError:
+                    pass
+            i += 1
+        return None
 
-    # Try inline JSON: {"action": "...", ...}
-    json_inline = re.findall(r'\{[^{}]*"(?:action|tool)"[^{}]*\}', thought)
-    for candidate in json_inline:
-        result = _parse_json_action(candidate, tools)
+    # Try ```json ... ``` (or unlabeled ``` ... ```) fenced block first.
+    fence = re.search(r'```(?:json)?\s*(.+?)\s*```', thought, re.DOTALL)
+    if fence:
+        result = _scan_for_json_action(fence.group(1))
         if result:
             return result
 
+    # Fallback: scan the whole thought for any embedded JSON action.
+    result = _scan_for_json_action(thought)
+    if result:
+        return result
+
     # Try XML: <action>name</action>
     xml_action = re.search(r'<action>\s*(\w+)\s*</action>', thought)
     if xml_action:
@@ -1230,18 +1420,31 @@ def _extract_tool_call(thought: str, tools: Dict[str, Tool]):
     return None, None
 
 
-def _parse_json_action(json_str: str, tools: Dict[str, Tool]):
-    """Parse a JSON action object, return (Tool, input) or None."""
-    try:
-        data = json.loads(json_str)
-    except (json.JSONDecodeError, ValueError):
-        return None
+def _parse_json_action(json_str_or_obj, tools: Dict[str, Tool]):
+    """Parse a JSON action object, return (Tool, input) or None.
+
+    Accepts either a JSON string (the legacy callers) or an already-decoded
+    dict (the new raw_decode-based scanner).
+    """
+    if isinstance(json_str_or_obj, dict):
+        data = json_str_or_obj
+    else:
+        try:
+            data = json.loads(json_str_or_obj)
+        except (json.JSONDecodeError, ValueError):
+            return None
+        if not isinstance(data, dict):
+            return None
 
     tool_name = data.get("action") or data.get("tool") or data.get("name")
     if not tool_name or tool_name not in tools:
         return None
 
-    tool_input = data.get("input") or data.get("args") or data.get("arguments") or data.get("answer", "")
+    tool_input = data.get("input") or data.get("args") or data.get("arguments")
+    if not tool_input:
+        # Flat format: {"tool":"WriteFile","file_path":"...","content":"..."} — use remaining keys
+        remaining = {k: v for k, v in data.items() if k not in ("tool", "action", "name", "answer")}
+        tool_input = remaining if remaining else data.get("answer", "")
     return tools[tool_name], tool_input
 
 

+ 3 - 2
lambdagent/hooks.py

@@ -25,6 +25,7 @@ from dataclasses import dataclass, field
 from typing import Any, Callable, Dict, List, Optional
 
 from .core import Term, Context
+from ._shell_compat import run_shell as _run_shell
 
 
 # ════════════════════════════════════════════════════════════
@@ -295,8 +296,8 @@ def compile_shell_hook(command: str, event: str) -> Callable:
         import os
         full_env = {**os.environ, **env}
         try:
-            result = subprocess.run(
-                command, shell=True, env=full_env,
+            result = _run_shell(
+                command, env=full_env,
                 capture_output=True, text=True, timeout=10,
             )
             if result.returncode != 0 and result.stderr:

+ 444 - 0
lambdagent/lam_types.py

@@ -0,0 +1,444 @@
+"""
+lambdagent.types — Paper III 类型与效果系统
+
+实现论文 III 的类型系统:
+  - AgentType: Agent 函数类型 τ1 →^ε τ2
+  - LamType: 基础类型构造 (Str, Int, Bool, Float, Any, Json(S))
+  - Json(S): 复用 JSON Schema 作为结构化类型语言 (Definition 2)
+  - 子类型关系 <: (Definition 5): 宽度/深度子类型
+  - T-Compose 规则: f >> g 要求 output(f) <: input(g) (Paper III §3.3.3)
+
+核心方程:
+    is_subtype(τ1, τ2) = True  ⟺  τ1 <: τ2
+
+依赖图:
+    types.py  ←  effects.py (效果标注)
+              ←  compiler.py (编译时类型检查)
+              ←  core.py (Term.input_type / output_type)
+"""
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass, field
+from enum import Enum, auto
+from typing import Any, Dict, FrozenSet, List, Optional, Set, Tuple, Union
+
+
+# ============================================================
+# 基础类型 (Paper III Definition 1)
+# ============================================================
+
+class TypeTag(Enum):
+    """类型标签枚举"""
+    ANY = auto()      # ⊤ — 顶类型,所有类型的超类型
+    NONE = auto()     # ⊥ — 底类型,所有类型的子类型
+    STR = auto()      # 字符串
+    INT = auto()      # 整数
+    FLOAT = auto()    # 浮点数
+    BOOL = auto()     # 布尔
+    JSON = auto()     # Json(S) — JSON Schema 结构化类型
+    TUPLE = auto()    # 元组类型 (Pair 的输出)
+    UNION = auto()    # 联合类型 (Route/If 的输出)
+
+
+@dataclass(frozen=True)
+class LamType:
+    """
+    Lambda Agent 的类型。
+
+    Paper III Definition 1:
+        τ ::= Str | Int | Bool | Float | Any | Json(S) | τ1 × τ2 | τ1 | τ2
+
+    其中 S 是 JSON Schema(Definition 2),
+    复用 JSON Schema 作为结构化类型语言。
+    """
+    tag: TypeTag
+    # Json(S): JSON Schema dict (when tag == JSON)
+    schema: Optional[Dict[str, Any]] = field(default=None, hash=False)
+    # Tuple: element types (when tag == TUPLE)
+    elements: Optional[Tuple[LamType, ...]] = None
+    # Union: member types (when tag == UNION)
+    members: Optional[FrozenSet[LamType]] = None
+
+    def __repr__(self) -> str:
+        if self.tag == TypeTag.ANY:
+            return "Any"
+        elif self.tag == TypeTag.NONE:
+            return "None"
+        elif self.tag == TypeTag.STR:
+            return "Str"
+        elif self.tag == TypeTag.INT:
+            return "Int"
+        elif self.tag == TypeTag.FLOAT:
+            return "Float"
+        elif self.tag == TypeTag.BOOL:
+            return "Bool"
+        elif self.tag == TypeTag.JSON:
+            if self.schema:
+                t = self.schema.get("type", "object")
+                if t == "object":
+                    props = self.schema.get("properties", {})
+                    if props:
+                        fields = ", ".join(f"{k}: {v.get('type', '?')}" for k, v in list(props.items())[:3])
+                        if len(props) > 3:
+                            fields += ", ..."
+                        return f"Json({{{fields}}})"
+                elif t == "array":
+                    items = self.schema.get("items", {})
+                    return f"Json([{items.get('type', '?')}])"
+                return f"Json({t})"
+            return "Json"
+        elif self.tag == TypeTag.TUPLE:
+            if self.elements:
+                inner = ", ".join(str(e) for e in self.elements)
+                return f"({inner})"
+            return "()"
+        elif self.tag == TypeTag.UNION:
+            if self.members:
+                inner = " | ".join(str(m) for m in sorted(self.members, key=str))
+                return f"({inner})"
+            return "Never"
+        return f"LamType({self.tag})"
+
+
+# ============================================================
+# 类型常量(快捷方式)
+# ============================================================
+
+T_ANY = LamType(TypeTag.ANY)
+T_NONE = LamType(TypeTag.NONE)
+T_STR = LamType(TypeTag.STR)
+T_INT = LamType(TypeTag.INT)
+T_FLOAT = LamType(TypeTag.FLOAT)
+T_BOOL = LamType(TypeTag.BOOL)
+
+
+def T_JSON(schema: Dict[str, Any] | None = None) -> LamType:
+    """构造 Json(S) 类型"""
+    return LamType(TypeTag.JSON, schema=schema)
+
+
+def T_TUPLE(*elements: LamType) -> LamType:
+    """构造元组类型"""
+    return LamType(TypeTag.TUPLE, elements=tuple(elements))
+
+
+def T_UNION(*members: LamType) -> LamType:
+    """构造联合类型"""
+    return LamType(TypeTag.UNION, members=frozenset(members))
+
+
+# ============================================================
+# AgentType: Agent 函数类型 (Paper III Definition 3)
+# ============================================================
+
+@dataclass(frozen=True)
+class AgentType:
+    """
+    Agent 函数类型: τ1 →^ε τ2
+
+    Paper III Definition 3:
+        每个 Agent 的类型签名是 input_type →^effect output_type
+
+    effect 在 effects.py 中定义,此处暂用字符串占位。
+    """
+    input_type: LamType
+    output_type: LamType
+    effect: str = "pure"  # 暂用字符串;P0-2 后替换为 Effect 类型
+
+    def __repr__(self) -> str:
+        eff = f"^{self.effect}" if self.effect != "pure" else ""
+        return f"{self.input_type} →{eff} {self.output_type}"
+
+
+# ============================================================
+# 子类型关系 <: (Paper III Definition 5)
+# ============================================================
+
+def is_subtype(sub: LamType, sup: LamType) -> bool:
+    """
+    子类型判断: sub <: sup
+
+    Paper III Definition 5:
+        1. ⊥ <: τ (None 是所有类型的子类型)
+        2. τ <: ⊤ (所有类型是 Any 的子类型)
+        3. τ <: τ (自反性)
+        4. Str <: Json(string) (字符串嵌入 JSON)
+        5. Int <: Float (数值提升)
+        6. Bool <: Int (布尔嵌入整数)
+        7. Json(S1) <: Json(S2) when S1 structurally subtypes S2
+           (宽度子类型: S1 有更多字段 → S1 <: S2)
+           (深度子类型: 对应字段类型 S1.f <: S2.f)
+        8. (τ1, τ2) <: (σ1, σ2) when τ1 <: σ1 ∧ τ2 <: σ2 (元组协变)
+        9. τ <: (τ | σ) (联合类型引入)
+    """
+    # ⊥ <: τ
+    if sub.tag == TypeTag.NONE:
+        return True
+
+    # τ <: ⊤
+    if sup.tag == TypeTag.ANY:
+        return True
+
+    # 自反性
+    if sub == sup:
+        return True
+
+    # τ <: (τ | σ) — 联合类型:sub 是 sup 的某个 member 的子类型
+    if sup.tag == TypeTag.UNION and sup.members:
+        return any(is_subtype(sub, m) for m in sup.members)
+
+    # (τ1 | τ2) <: σ — 联合类型的子类型:所有 member 都是 σ 的子类型
+    if sub.tag == TypeTag.UNION and sub.members:
+        return all(is_subtype(m, sup) for m in sub.members)
+
+    # Bool <: Int <: Float
+    if sub.tag == TypeTag.BOOL and sup.tag == TypeTag.INT:
+        return True
+    if sub.tag == TypeTag.BOOL and sup.tag == TypeTag.FLOAT:
+        return True
+    if sub.tag == TypeTag.INT and sup.tag == TypeTag.FLOAT:
+        return True
+
+    # Str <: Json(string)
+    if sub.tag == TypeTag.STR and sup.tag == TypeTag.JSON:
+        if sup.schema and sup.schema.get("type") == "string":
+            return True
+        # Str <: Json (untyped JSON) — 字符串可以被解析为 JSON
+        if sup.schema is None:
+            return True
+
+    # 基础类型 <: Json(对应类型)
+    _tag_to_json_type = {
+        TypeTag.STR: "string",
+        TypeTag.INT: "integer",
+        TypeTag.FLOAT: "number",
+        TypeTag.BOOL: "boolean",
+    }
+    if sub.tag in _tag_to_json_type and sup.tag == TypeTag.JSON:
+        if sup.schema and sup.schema.get("type") == _tag_to_json_type[sub.tag]:
+            return True
+        # FIX-04: 基础类型 <: Json (无 schema = untyped JSON, 接受一切)
+        # Paper III S-StrJson / S-NumJson / S-BoolJson
+        if sup.schema is None:
+            return True
+
+    # Json(S1) <: Json(S2) — 结构子类型
+    if sub.tag == TypeTag.JSON and sup.tag == TypeTag.JSON:
+        return _json_schema_subtype(sub.schema, sup.schema)
+
+    # 元组协变: (τ1, τ2) <: (σ1, σ2)
+    if sub.tag == TypeTag.TUPLE and sup.tag == TypeTag.TUPLE:
+        if sub.elements and sup.elements:
+            if len(sub.elements) != len(sup.elements):
+                return False
+            return all(
+                is_subtype(s, t) for s, t in zip(sub.elements, sup.elements)
+            )
+
+    return False
+
+
+def _json_schema_subtype(
+    sub_schema: Optional[Dict[str, Any]],
+    sup_schema: Optional[Dict[str, Any]],
+) -> bool:
+    """
+    JSON Schema 结构子类型检查。
+
+    Paper III Definition 5 规则 7:
+        Json(S1) <: Json(S2) 当且仅当:
+          - S2 的所有 required 字段在 S1 中都存在
+          - 对应字段类型满足 S1.field <: S2.field (深度子类型)
+          - S1 可以有额外字段 (宽度子类型)
+
+    类似 TypeScript 的结构子类型。
+    """
+    # 无 schema → Any JSON → Json <: Json
+    if sup_schema is None:
+        return True
+    if sub_schema is None:
+        # 未指定的 JSON 不是有具体 schema 的子类型
+        return sup_schema is None
+
+    sub_type = sub_schema.get("type")
+    sup_type = sup_schema.get("type")
+
+    # 类型不同 → 检查 JSON 原始类型的子类型关系
+    if sub_type != sup_type:
+        # integer <: number
+        if sub_type == "integer" and sup_type == "number":
+            return True
+        return False
+
+    # object 子类型: 宽度 + 深度
+    if sup_type == "object":
+        sub_props = sub_schema.get("properties", {})
+        sup_props = sup_schema.get("properties", {})
+        sup_required = set(sup_schema.get("required", []))
+
+        # sup 的所有 required 字段必须在 sub 中存在
+        for req_field in sup_required:
+            if req_field not in sub_props:
+                return False
+
+        # 深度子类型: 公共字段类型兼容
+        for field_name, sup_field_schema in sup_props.items():
+            if field_name in sub_props:
+                if not _json_schema_subtype(sub_props[field_name], sup_field_schema):
+                    return False
+            elif field_name in sup_required:
+                return False
+            # sup 有字段但 sub 没有 + 非 required → OK (宽度子类型的逆方向,
+            # 这里 sub 少字段不影响,因为 sup 不要求该字段)
+
+        return True
+
+    # array 子类型: items 协变
+    if sup_type == "array":
+        sub_items = sub_schema.get("items", {})
+        sup_items = sup_schema.get("items", {})
+        if sub_items and sup_items:
+            return _json_schema_subtype(sub_items, sup_items)
+        return True
+
+    # 基础类型相同 → 子类型
+    return True
+
+
+# ============================================================
+# 类型检查错误
+# ============================================================
+
+class AgentTypeError(Exception):
+    """Agent 类型检查错误 — T-Compose 规则违反"""
+
+    def __init__(self, message: str, source_type: Optional[LamType] = None,
+                 target_type: Optional[LamType] = None, position: int = -1):
+        self.source_type = source_type
+        self.target_type = target_type
+        self.position = position
+        detail = ""
+        if source_type and target_type:
+            detail = f"\n  Output type: {source_type}\n  Input type:  {target_type}"
+            if position >= 0:
+                detail += f"\n  At composition boundary: step {position} >> step {position + 1}"
+        detail += "\n  Rule: Paper III T-Compose: f: A →^ε1 B, g: B' →^ε2 C requires B <: B'"
+        super().__init__(f"{message}{detail}")
+
+
+# ============================================================
+# 类型检查:T-Compose 规则 (Paper III §3.3.3)
+# ============================================================
+
+def check_compose_types(agent_types: List[AgentType]) -> AgentType:
+    """
+    T-Compose 类型检查。
+
+    Paper III §3.3.3:
+        f: A →^ε1 B,  g: B' →^ε2 C,  B <: B'
+        ─────────────────────────────────────────
+              f >> g : A →^(ε1 · ε2) C
+
+    检查链式组合中每对相邻 agent 的类型兼容性。
+    返回整个组合的类型。
+
+    Raises:
+        AgentTypeError: 类型不兼容时抛出
+    """
+    if not agent_types:
+        return AgentType(T_ANY, T_ANY)
+
+    if len(agent_types) == 1:
+        return agent_types[0]
+
+    for i in range(len(agent_types) - 1):
+        f_type = agent_types[i]
+        g_type = agent_types[i + 1]
+
+        # T-Compose: output(f) <: input(g)
+        if not is_subtype(f_type.output_type, g_type.input_type):
+            raise AgentTypeError(
+                f"Type mismatch in pipeline at step {i} >> step {i + 1}: "
+                f"{f_type.output_type} is not a subtype of {g_type.input_type}",
+                source_type=f_type.output_type,
+                target_type=g_type.input_type,
+                position=i,
+            )
+
+    # 组合结果类型: input(first) → output(last)
+    combined_effect = " · ".join(at.effect for at in agent_types if at.effect != "pure")
+    return AgentType(
+        input_type=agent_types[0].input_type,
+        output_type=agent_types[-1].output_type,
+        effect=combined_effect or "pure",
+    )
+
+
+# ============================================================
+# 类型推断辅助
+# ============================================================
+
+def parse_type_annotation(annotation: Any) -> LamType:
+    """
+    从 YAML 配置中的类型标注解析为 LamType。
+
+    支持的格式:
+        "Str"                    → T_STR
+        "Int"                    → T_INT
+        "Float"                  → T_FLOAT
+        "Bool"                   → T_BOOL
+        "Any"                    → T_ANY
+        "Json"                   → T_JSON()
+        {"type": "object", ...}  → T_JSON(schema)
+        {"type": "string"}       → T_JSON({"type": "string"})
+    """
+    if annotation is None:
+        return T_ANY
+
+    if isinstance(annotation, str):
+        _name_map = {
+            "str": T_STR, "string": T_STR,
+            "int": T_INT, "integer": T_INT,
+            "float": T_FLOAT, "number": T_FLOAT,
+            "bool": T_BOOL, "boolean": T_BOOL,
+            "any": T_ANY,
+            "json": T_JSON(),
+        }
+        lower = annotation.lower().strip()
+        if lower in _name_map:
+            return _name_map[lower]
+        # 可能是 JSON Schema 字符串
+        try:
+            parsed = json.loads(annotation)
+            if isinstance(parsed, dict):
+                return T_JSON(parsed)
+        except (json.JSONDecodeError, TypeError):
+            pass
+        return T_ANY
+
+    if isinstance(annotation, dict):
+        # JSON Schema dict
+        return T_JSON(annotation)
+
+    return T_ANY
+
+
+def infer_type_from_value(value: Any) -> LamType:
+    """从运行时值推断类型(用于调试/trace)"""
+    if isinstance(value, str):
+        return T_STR
+    elif isinstance(value, bool):
+        return T_BOOL
+    elif isinstance(value, int):
+        return T_INT
+    elif isinstance(value, float):
+        return T_FLOAT
+    elif isinstance(value, dict):
+        return T_JSON()
+    elif isinstance(value, (tuple, list)):
+        if isinstance(value, tuple):
+            return T_TUPLE(*(infer_type_from_value(v) for v in value))
+        return T_JSON({"type": "array"})
+    return T_ANY

+ 2 - 2
lambdagent/mcp_server.py

@@ -310,7 +310,7 @@ def handle_estimate_agent_cost(args: Dict) -> Dict:
 
 def handle_check_agent_types(args: Dict) -> Dict:
     """I01: Type-check agent pipeline."""
-    from lambdagent.types import check_compose_types
+    from lambdagent.lam_types import check_compose_types
 
     cfg = _load_config(args)
     term = _compile_term(cfg)
@@ -327,7 +327,7 @@ def handle_check_agent_types(args: Dict) -> Dict:
             f_out = getattr(stages[i], 'output_type', None)
             g_in = getattr(stages[i + 1], 'input_type', None)
             if f_out and g_in:
-                from lambdagent.types import is_subtype
+                from lambdagent.lam_types import is_subtype
                 if not is_subtype(f_out, g_in):
                     errors.append({
                         "stage": i,

+ 0 - 58
lambdagent/mcp_server_package/README.md

@@ -1,58 +0,0 @@
-# lambdagent-mcp-server
-
-MCP Server for **lambdagent** static analysis. Lint agent configs, predict costs, type-check pipelines, and verify parallel safety — all from your AI IDE.
-
-## Quick Setup
-
-### Claude Code
-```json
-// .claude/settings.json
-{
-  "mcpServers": {
-    "lambdagent": {
-      "command": "python3",
-      "args": ["-m", "lambdagent.mcp_server"]
-    }
-  }
-}
-```
-
-### Cursor
-```json
-// .cursor/mcp.json
-{
-  "mcpServers": {
-    "lambdagent": {
-      "command": "python3",
-      "args": ["-m", "lambdagent.mcp_server"]
-    }
-  }
-}
-```
-
-## Tools
-
-| Tool | Description |
-|------|-------------|
-| `lint_agent_config` | 26-rule structural lint for LangChain/CrewAI/AutoGen/Dify configs |
-| `estimate_agent_cost` | Worst-case cost prediction (tokens, latency, USD, success probability) |
-| `check_agent_types` | T-Compose type checking (output(f) <: input(g)) |
-| `check_parallel_safety` | Store independence verification (Paper II Proposition 30) |
-| `monitor_agent_cost` | Runtime cost anomaly detection (actual vs predicted) |
-
-## Example
-
-```
-User: Check my agent config for issues
-Claude: [calls lint_agent_config with config_path="agent-config.yml"]
-
-Result: 2 errors found
-  L004a: No terminate tool in ReAct loop (maxSteps=200)
-  T-COMPOSE: Stage 2 output Json(object) incompatible with Stage 3 input Str
-```
-
-## Based On
-
-- **Paper I**: lambdagent — A Formally-Grounded DSL for LLM Agent Composition
-- **Paper II**: Operational Semantics for LLM Agent Programs
-- **Paper III**: A Type and Effect System for LLM Agent Composition

+ 0 - 30
lambdagent/mcp_server_package/pyproject.toml

@@ -1,30 +0,0 @@
-[build-system]
-requires = ["setuptools>=68.0", "wheel"]
-build-backend = "setuptools.build_meta"
-
-[project]
-name = "lambdagent-mcp-server"
-version = "0.1.0"
-description = "MCP Server for lambdagent static analysis — lint, type check, cost prediction, parallel safety"
-readme = "README.md"
-license = {text = "MIT"}
-requires-python = ">=3.9"
-authors = [{name = "Qin Liu", email = "qinliu@smail.nju.edu.cn"}]
-keywords = ["agent", "llm", "mcp", "lint", "type-check", "cost-prediction", "lambda-calculus"]
-classifiers = [
-    "Development Status :: 3 - Alpha",
-    "Intended Audience :: Developers",
-    "Topic :: Software Development :: Quality Assurance",
-    "Programming Language :: Python :: 3",
-]
-dependencies = [
-    "pyyaml>=6.0",
-    "lambdagent>=0.1.0",
-]
-
-[project.scripts]
-lambdagent-mcp-server = "lambdagent.mcp_server:main"
-
-[project.urls]
-Homepage = "https://github.com/qinliu-nju/lambdagentpaas"
-Documentation = "https://github.com/qinliu-nju/lambdagentpaas/blob/main/docs/integration-strategy.md"

+ 1 - 1
lambdagent/primitives.py

@@ -340,7 +340,7 @@ class Pair(Term):
         self.first = first
         self.second = second
         # Paper III: Pair 输出类型 = (output(first), output(second))
-        from .types import T_TUPLE
+        from .lam_types import T_TUPLE
         self._output_type = T_TUPLE(first.output_type, second.output_type)
 
     def apply(self, input: Any, ctx: Context | None = None) -> tuple:

+ 2 - 2
lambdagent/providers/__init__.py

@@ -7,7 +7,7 @@ L02: Added ChatMessage, ChatResponse exports and create_provider() factory
 for unified multi-provider instantiation.
 """
 from .base import LLMProvider, ProviderConfig, ProviderError, Message, ChatMessage, ChatResponse
-from .claude_code import ClaudeLam  # backward compat
+from .claude_code import ClaudeLam  # deprecated, removed in 0.3.0 — use ClaudeCodeProvider
 from .claude_code_provider import ClaudeCodeProvider
 from .anthropic_provider import AnthropicProvider
 from .openai_compat_provider import OpenAICompatProvider
@@ -68,7 +68,7 @@ def create_provider(provider_name: str, **kwargs) -> LLMProvider:
 __all__ = [
     "LLMProvider", "ProviderConfig", "ProviderError", "Message",
     "ChatMessage", "ChatResponse",
-    "ClaudeLam",  # backward compat for PersonalAssistant
+    "ClaudeLam",  # deprecated, removed in 0.3.0
     "ClaudeCodeProvider",
     "AnthropicProvider",
     "OpenAICompatProvider",

+ 49 - 1
lambdagent/providers/claude_code.py

@@ -15,6 +15,7 @@ Session persistence:
 from __future__ import annotations
 
 import json
+import os
 import subprocess
 import sys
 import time
@@ -23,10 +24,50 @@ from typing import Any, Callable, Optional
 from lambdagent.core import Term, Context
 
 
+def _find_working_claude(preferred: str = "claude") -> str:
+    """Find the first claude CLI binary that passes --version (handles nvm Node version mismatch)."""
+    import shutil
+
+    def _works(path: str) -> bool:
+        try:
+            r = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=10)
+            return r.returncode == 0 and "Claude Code" in r.stdout
+        except Exception:
+            return False
+
+    if os.path.isabs(preferred):
+        return preferred
+
+    found = shutil.which(preferred)
+    if found and _works(found):
+        return found
+
+    nvm_base = os.path.expanduser("~/.nvm/versions/node")
+    if os.path.isdir(nvm_base):
+        for ver in sorted(os.listdir(nvm_base), reverse=True):
+            candidate = os.path.join(nvm_base, ver, "bin", "claude")
+            if os.path.isfile(candidate) and _works(candidate):
+                return candidate
+
+    return found or preferred
+
+
 class ClaudeLam(Term):
     """
     Lambda abstraction backed by Claude Code CLI with session persistence.
 
+    .. deprecated:: 0.1.0
+        Use :class:`lambdagent.providers.ClaudeCodeProvider` together with
+        :class:`lambdagent.Lam` instead. ``ClaudeLam`` will be removed in 0.3.0.
+        Migration::
+
+            # before
+            agent = ClaudeLam("name", "prompt")
+
+            # after
+            from lambdagent.providers import ClaudeCodeProvider
+            agent = Lam("name", "prompt", provider=ClaudeCodeProvider(...))
+
     Key improvement over stateless `-p`:
       Each apply() continues the same conversation session.
       Claude retains full memory of previous steps, file contents, etc.
@@ -45,13 +86,20 @@ class ClaudeLam(Term):
         on_chunk: Callable[[str], None] | None = None,
         inject_override: bool = True,
     ):
+        import warnings
+        warnings.warn(
+            "ClaudeLam is deprecated and will be removed in lambdagent 0.3.0. "
+            "Use Lam(provider=ClaudeCodeProvider(...)) instead.",
+            DeprecationWarning,
+            stacklevel=2,
+        )
         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.claude_bin = _find_working_claude(claude_bin)
         self.stream = stream
         self.on_chunk = on_chunk
         self.inject_override = inject_override

+ 248 - 33
lambdagent/providers/claude_code_provider.py

@@ -7,16 +7,97 @@ 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
+  - Auto-detects a working claude binary across nvm versions
 """
 from __future__ import annotations
 
 import json
-import subprocess
+import logging
+import os
 import shutil
-from typing import Dict, List
+import subprocess
+from typing import Dict, List, Optional
 
 from .base import LLMProvider, ProviderConfig, ProviderError
 
+# Attach under "agentpaas" so the host's logging handler picks us up.
+# Using __name__ ("lambdagent.providers.claude_code_provider") gives a
+# silent logger when running inside agentpaas — its setup_logging() only
+# configures handlers on the "agentpaas" tree.
+logger = logging.getLogger("agentpaas.claude-code")
+
+
+def _find_working_claude(preferred: str = "claude") -> Optional[str]:
+    """
+    Find a claude binary that actually supports modern flags (--tools, --mcp-config).
+
+    Strategy:
+      1. If preferred is an absolute path, use it directly.
+      2. Test whatever shutil.which finds first.
+      3. Scan ~/.nvm/versions/node in reverse order (newest first).
+
+    A binary is "working" if:
+      - It's executable and exits 0 for --version
+      - Its version output contains "Claude Code"
+      - Its --help output mentions "--tools" (rules out old v1.x CLI)
+    """
+    def _works(path: str) -> bool:
+        try:
+            r = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=10)
+            if r.returncode != 0 or "Claude Code" not in r.stdout:
+                return False
+            # v1.x (old) doesn't have --tools; v2.x does. Use --help as a proxy check.
+            h = subprocess.run([path, "--help"], capture_output=True, text=True, timeout=10)
+            help_text = h.stdout + h.stderr
+            return "--tools" in help_text
+        except Exception:
+            return False
+
+    # 1. Absolute path given — trust it.
+    if os.path.isabs(preferred):
+        return preferred if os.path.isfile(preferred) else None
+
+    # 2. Whatever is first in PATH.
+    found = shutil.which(preferred)
+    if found and _works(found):
+        return found
+
+    # 3. Scan nvm versions, newest first.
+    nvm_base = os.path.expanduser("~/.nvm/versions/node")
+    if os.path.isdir(nvm_base):
+        try:
+            versions = sorted(os.listdir(nvm_base), reverse=True)
+        except OSError:
+            versions = []
+        for ver in versions:
+            candidate = os.path.join(nvm_base, ver, "bin", "claude")
+            if os.path.isfile(candidate) and _works(candidate):
+                return candidate
+
+    # Fall back to whatever shutil found, even if it might not work.
+    return found or preferred
+
+
+def _strip_tool_docs(system_prompt: str) -> str:
+    """Remove the auto-generated tool-schema section from a system prompt.
+
+    _generate_tool_schema_docs() appends a '## 工具参数参考' block that
+    lists Bash, WriteFile, etc. with JSON call signatures.  When this reaches
+    claude-code via --system-prompt, claude decides it *has* those native tools
+    and tries to use them directly instead of outputting a JSON routing decision.
+    Stripping the section before the subprocess call fixes the hang.
+    """
+    for marker in (
+        "\n\n## 工具参数参考",   # Chinese header added by _generate_tool_schema_docs
+        "\n\n## Available Tools",
+        "\n## 工具参数参考",
+        "\n## Available Tools",
+    ):
+        idx = system_prompt.find(marker)
+        if idx != -1:
+            return system_prompt[:idx]
+    return system_prompt
+
 
 class ClaudeCodeProvider(LLMProvider):
     """
@@ -27,13 +108,15 @@ class ClaudeCodeProvider(LLMProvider):
 
     def __init__(self, config: ProviderConfig):
         super().__init__(config)
-        self.claude_bin = config.extra.get("claude_bin", "claude")
+        preferred = config.extra.get("claude_bin", "claude") if config.extra else "claude"
+        self.claude_bin = _find_working_claude(preferred) or preferred
         self._session_id = None
 
-        if not shutil.which(self.claude_bin):
+        if not shutil.which(self.claude_bin) and not os.path.isfile(self.claude_bin):
             raise ProviderError(
-                f"claude command not found. Install: npm install -g @anthropic-ai/claude-code",
-                "claude-code"
+                f"claude command not found at '{self.claude_bin}'. "
+                f"Install: npm install -g @anthropic-ai/claude-code",
+                "claude-code",
             )
 
     def chat(self, messages: List[Dict[str, str]]) -> str:
@@ -42,6 +125,22 @@ class ClaudeCodeProvider(LLMProvider):
         else:
             return self._call_new(messages)
 
+    # Injected into user message on every call to prevent native tool execution.
+    _REACT_CONSTRAINT = (
+        "\n\n[REACT_MODE] 你是决策模块,不是执行模块。"
+        "只输出下一步的JSON工具调用,格式:{\"tool\":\"工具名\",\"input\":\"...\"},"
+        "或输出 terminate 表示任务完成。"
+        "严禁直接执行任何搜索、Bash命令或文件读写操作。"
+    )
+
+    # Appended to system prompt to override any "do everything" framing in agent YAML.
+    _SYSTEM_PROMPT_SUFFIX = (
+        "\n\n===核心约束(优先级最高)==="
+        "\n你是工具路由决策器,不是执行器。"
+        "\n每次只输出一个JSON工具调用命令,不做任何实际操作。"
+        "\n格式:{\"tool\": \"工具名\", \"input\": \"...\"} 或 terminate"
+    )
+
     def _call_new(self, messages: List[Dict[str, str]]) -> str:
         """First call: create session with system prompt, capture session_id."""
         system_prompt = ""
@@ -52,30 +151,73 @@ class ClaudeCodeProvider(LLMProvider):
             elif m["role"] == "user":
                 user_content = m["content"]
 
+        # Strip tool-schema docs (## 工具参数参考 section) — they expose Bash/WriteFile
+        # which makes claude-code try to use them natively instead of outputting JSON.
+        effective_system = _strip_tool_docs(system_prompt) + self._SYSTEM_PROMPT_SUFFIX
+        prompt_arg = user_content + self._REACT_CONSTRAINT
+
         cmd = [
-            self.claude_bin, "-p",
+            self.claude_bin, "-p", prompt_arg,
             "--output-format", "json",
             "--model", self.config.model,
-            "--system-prompt", system_prompt,
-            "--tools", "",
-            "--mcp-config", '{"mcpServers":{}}',
-            "--strict-mcp-config",
+            "--system-prompt", effective_system,
+            "--dangerously-skip-permissions",
         ]
 
+        # Diagnostics: log argv sizes before spawning. argv on macOS is
+        # capped at ARG_MAX (~1MB); large system prompts can push us close.
+        total_argv = sum(len(s) for s in cmd)
+        logger.info(
+            "claude-code first-turn spawn: prompt=%d sys=%d total_argv=%d model=%s timeout=%ds",
+            len(prompt_arg), len(effective_system), total_argv,
+            self.config.model, self.config.timeout,
+        )
+
+        import time as _time
+        t0 = _time.time()
         try:
             result = subprocess.run(
-                cmd, input=user_content,
+                cmd,
+                stdin=subprocess.DEVNULL,   # avoid 3-s stdin-wait warning
                 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)
+        except subprocess.TimeoutExpired as e:
+            elapsed = _time.time() - t0
+            partial_out = (e.stdout.decode(errors="replace") if e.stdout else "")[:300]
+            partial_err = (e.stderr.decode(errors="replace") if e.stderr else "")[:300]
+            logger.warning(
+                "claude-code TIMEOUT after %.1fs (limit=%ds) partial_stdout=%r partial_stderr=%r",
+                elapsed, self.config.timeout, partial_out, partial_err,
+            )
+            raise ProviderError(
+                f"Claude Code timeout ({self.config.timeout}s) on first turn",
+                "claude-code", retryable=True,
+            )
 
+        elapsed = _time.time() - t0
         if result.returncode != 0:
-            stderr = result.stderr.strip()[:500]
-            raise ProviderError(f"Claude Code error: {stderr}", "claude-code")
+            stderr = (result.stderr or "").strip()
+            stdout_hint = (result.stdout or "").strip()[:200]
+            detail = stderr or stdout_hint or "(no output)"
+            logger.warning(
+                "claude-code FAILED exit=%d after %.1fs stderr=%r stdout=%r",
+                result.returncode, elapsed, stderr[:500], stdout_hint,
+            )
+            raise ProviderError(
+                f"Claude Code error (exit {result.returncode}): {detail}",
+                "claude-code",
+            )
+        logger.info("claude-code first-turn ok: elapsed=%.1fs out_len=%d", elapsed, len(result.stdout or ""))
 
         raw = result.stdout.strip()
+        if not raw:
+            raise ProviderError(
+                f"Claude Code returned empty output (exit {result.returncode}). "
+                f"stderr: {(result.stderr or '').strip()[:200] or '(empty)'}",
+                "claude-code", retryable=True,
+            )
+
         try:
             data = json.loads(raw)
             self._session_id = data.get("session_id")
@@ -85,36 +227,109 @@ class ClaudeCodeProvider(LLMProvider):
 
     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
 
+        # Same ReAct constraint to keep each resume turn as a single JSON decision.
+        prompt_arg = last_user + self._REACT_CONSTRAINT
+
         cmd = [
-            self.claude_bin, "-p",
+            self.claude_bin, "-p", prompt_arg,
             "--output-format", "text",
             "--model", self.config.model,
             "--resume", self._session_id,
-            "--tools", "",
-            "--mcp-config", '{"mcpServers":{}}',
-            "--strict-mcp-config",
+            "--dangerously-skip-permissions",
         ]
 
-        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)
+        logger.info(
+            "claude-code resume spawn: prompt=%d session=%s timeout=%ds",
+            len(prompt_arg), self._session_id[:8] if self._session_id else "?",
+            self.config.timeout,
+        )
 
-        if result.returncode != 0:
-            stderr = result.stderr.strip()[:500]
-            self._session_id = None  # session expired/invalid — next call creates a fresh one
-            raise ProviderError(f"Claude Code error: {stderr}", "claude-code")
+        import time as _time
+
+        # Transport-error patterns worth retrying once. Long-running sessions
+        # sporadically die from Anthropic-side socket drops or 5xx — losing the
+        # whole 22-turn paper-writing session over one flake is unacceptable.
+        _TRANSPORT_RETRY_MARKERS = (
+            "socket connection",       # "API Error: The socket connection ..."
+            "socket hang up",
+            "ECONNRESET",
+            "fetch failed",
+            "ETIMEDOUT",
+            "503",
+            "502",
+            "overloaded",
+        )
+
+        def _is_transport_error(text: str) -> bool:
+            low = text.lower()
+            return any(m.lower() in low for m in _TRANSPORT_RETRY_MARKERS)
+
+        def _run_once():
+            t0 = _time.time()
+            try:
+                result = subprocess.run(
+                    cmd,
+                    stdin=subprocess.DEVNULL,
+                    capture_output=True, text=True,
+                    timeout=self.config.timeout,
+                )
+                return result, _time.time() - t0, None
+            except subprocess.TimeoutExpired as exc:
+                return None, _time.time() - t0, exc
+
+        for attempt in (1, 2):
+            result, elapsed, timeout_exc = _run_once()
+
+            if timeout_exc is not None:
+                partial_out = (timeout_exc.stdout.decode(errors="replace") if timeout_exc.stdout else "")[:300]
+                partial_err = (timeout_exc.stderr.decode(errors="replace") if timeout_exc.stderr else "")[:300]
+                logger.warning(
+                    "claude-code RESUME TIMEOUT (attempt %d) after %.1fs partial_stdout=%r partial_stderr=%r",
+                    attempt, elapsed, partial_out, partial_err,
+                )
+                # Timeouts indicate the model itself is wedged — don't retry,
+                # would just hang another 600s. Clear session and fail.
+                self._session_id = None
+                raise ProviderError(
+                    f"Claude Code timeout ({self.config.timeout}s) on resume",
+                    "claude-code", retryable=True,
+                )
+
+            if result.returncode == 0:
+                logger.info(
+                    "claude-code resume ok (attempt %d): elapsed=%.1fs out_len=%d",
+                    attempt, elapsed, len(result.stdout or ""),
+                )
+                break
+
+            # Non-zero exit. Inspect for transport-error marker.
+            stderr = (result.stderr or "").strip()
+            stdout_hint = (result.stdout or "").strip()[:500]
+            detail = stderr or stdout_hint or "(no output)"
+
+            if attempt == 1 and _is_transport_error(detail):
+                logger.warning(
+                    "claude-code RESUME transport error (attempt 1) exit=%d after %.1fs — retrying in 2s. detail=%r",
+                    result.returncode, elapsed, detail[:300],
+                )
+                _time.sleep(2)
+                continue  # Keep session_id; retry the same resume
+
+            logger.warning(
+                "claude-code RESUME FAILED (attempt %d) exit=%d after %.1fs stderr=%r stdout=%r",
+                attempt, result.returncode, elapsed, stderr[:500], stdout_hint,
+            )
+            self._session_id = None
+            raise ProviderError(
+                f"Claude Code resume error (exit {result.returncode}): {detail}",
+                "claude-code",
+            )
 
         output = result.stdout.strip()
         return output if output else "[no output]"

+ 0 - 0
lambdagent/py.typed


+ 87 - 9
lambdagent/pyproject.toml

@@ -1,5 +1,5 @@
 [build-system]
-requires = ["setuptools>=61.0", "wheel"]
+requires = ["setuptools>=77.0", "wheel"]
 build-backend = "setuptools.build_meta"
 
 [project]
@@ -7,17 +7,19 @@ name = "lambdagent"
 version = "0.1.0"
 description = "A Lambda Calculus Agent DSL — every agent is a function, every composition is function composition, every loop is a Y combinator."
 readme = "README.md"
-license = {text = "MIT"}
+license = "BUSL-1.1"
 requires-python = ">=3.10"
 authors = [
-    {name = "kenny67nju"},
+    {name = "Qin Liu", email = "qinliu@nju.edu.cn"},
+]
+maintainers = [
+    {name = "Qin Liu", email = "qinliu@nju.edu.cn"},
 ]
 keywords = ["agent", "lambda-calculus", "dsl", "llm", "ai"]
 classifiers = [
     "Development Status :: 3 - Alpha",
     "Intended Audience :: Developers",
     "Intended Audience :: Science/Research",
-    "License :: OSI Approved :: MIT License",
     "Programming Language :: Python :: 3",
     "Programming Language :: Python :: 3.10",
     "Programming Language :: Python :: 3.11",
@@ -30,9 +32,66 @@ dependencies = [
 ]
 
 [project.optional-dependencies]
+# LLM providers
 anthropic = ["anthropic>=0.20.0"]
 openai = ["openai>=1.0.0"]
-all = ["anthropic>=0.20.0", "openai>=1.0.0"]
+
+# RAG / vector backends
+rag = ["chromadb>=0.4.0"]
+
+# Knowledge / document ingestion (PDF, DOCX, OCR, HTML)
+knowledge = [
+    "PyPDF2>=3.0.0",
+    "python-docx>=1.0.0",
+    "markdown>=3.4.0",
+    "markdownify>=0.11.0",
+    "html2text>=2024.0.0",
+]
+ocr = ["paddleocr>=2.7.0"]
+pdfgen = ["weasyprint>=60.0"]
+
+# Sandbox / process isolation
+sandbox = ["cloudpickle>=2.2.0", "dill>=0.3.6"]
+
+# Checkpoint encryption
+checkpoint-crypto = ["cryptography>=41.0.0"]
+
+# Memory backends
+redis = ["redis>=5.0.0"]
+
+# Observability
+otel = ["opentelemetry-api>=1.20.0", "opentelemetry-sdk>=1.20.0"]
+
+# Terminal UI
+tui = ["rich>=13.0.0"]
+
+# Dev / contributor toolchain
+dev = [
+    "pytest>=7.0.0",
+    "pytest-asyncio>=0.21.0",
+    "pytest-cov>=4.0.0",
+    "ruff>=0.1.0",
+    "build>=1.0.0",
+]
+
+# Everything (no dev / no ocr — too heavy)
+all = [
+    "anthropic>=0.20.0",
+    "openai>=1.0.0",
+    "chromadb>=0.4.0",
+    "PyPDF2>=3.0.0",
+    "python-docx>=1.0.0",
+    "markdown>=3.4.0",
+    "markdownify>=0.11.0",
+    "html2text>=2024.0.0",
+    "cloudpickle>=2.2.0",
+    "dill>=0.3.6",
+    "cryptography>=41.0.0",
+    "redis>=5.0.0",
+    "opentelemetry-api>=1.20.0",
+    "opentelemetry-sdk>=1.20.0",
+    "rich>=13.0.0",
+]
 
 [project.scripts]
 lambdagent = "lambdagent.cli.main:main"
@@ -42,8 +101,27 @@ Homepage = "https://github.com/kenny67nju/lambdagent"
 Repository = "https://github.com/kenny67nju/lambdagent"
 Issues = "https://github.com/kenny67nju/lambdagent/issues"
 
-[tool.setuptools.package-dir]
-lambdagent = "."
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+addopts = "--import-mode=importlib"
+filterwarnings = [
+    "ignore::DeprecationWarning:lambdagent.from_config",
+    "ignore::DeprecationWarning:lambdagent.lint",
+]
+
+[tool.setuptools]
+package-dir = {"lambdagent" = "."}
+packages = [
+    "lambdagent",
+    "lambdagent.agentruntime",
+    "lambdagent.builtin_tools",
+    "lambdagent.cli",
+    "lambdagent.extractors",
+    "lambdagent.fromconfig",
+    "lambdagent.providers",
+    "lambdagent.skillpacks",
+    "lambdagent.skillpacks.research",
+]
 
-[tool.setuptools.packages]
-find = {namespaces = false, where = [".."], include = ["lambdagent", "lambdagent.*"]}
+[tool.setuptools.package-data]
+lambdagent = ["py.typed"]

+ 33 - 1
lambdagent/sandbox.py

@@ -32,7 +32,6 @@ from __future__ import annotations
 import json
 import os
 import platform
-import resource
 import signal
 import subprocess
 import sys
@@ -43,6 +42,28 @@ import traceback
 from dataclasses import dataclass, field
 from typing import Any, Callable, Dict, List, Optional, Set
 
+# POSIX-only: resource module unavailable on Windows.
+# SandboxedTool / SecureExecutor / ResourceLimiter raise NotImplementedError
+# at instantiation time on platforms without it.
+try:
+    import resource  # type: ignore[import-not-found]
+    _HAS_RESOURCE = True
+except ImportError:  # pragma: no cover — Windows path
+    resource = None  # type: ignore[assignment]
+    _HAS_RESOURCE = False
+
+# POSIX-only signals: SIGKILL is the canonical "force kill" that resource limits
+# trigger. On Windows we use TerminateProcess via subprocess.kill() instead.
+_SIGKILL = getattr(signal, "SIGKILL", None)
+
+_SANDBOX_AVAILABLE = _HAS_RESOURCE and _SIGKILL is not None
+_SANDBOX_UNAVAILABLE_MSG = (
+    "Sandbox features (SandboxedTool, SecureExecutor, ResourceLimiter) require "
+    "POSIX resource limits and signals; not available on this platform "
+    f"({platform.system()}). Run lambdagent in WSL2 or a Linux container for "
+    "sandboxed execution."
+)
+
 from .core import Term, Context, ValidationError
 
 
@@ -147,9 +168,16 @@ class ResourceLimiter:
 
     _subprocess_blocked = False
 
+    def __new__(cls):
+        if not _SANDBOX_AVAILABLE:
+            raise NotImplementedError(_SANDBOX_UNAVAILABLE_MSG)
+        return super().__new__(cls)
+
     @staticmethod
     def apply(policy: SandboxPolicy):
         """Apply resource limits in current process. Call after fork."""
+        if not _SANDBOX_AVAILABLE:
+            raise NotImplementedError(_SANDBOX_UNAVAILABLE_MSG)
         # CPU time limit
         if policy.cpu_time > 0:
             soft = int(policy.cpu_time)
@@ -275,6 +303,8 @@ class SandboxedTool(Term):
 
     def __init__(self, name: str, fn: Callable, policy: Optional[SandboxPolicy] = None,
                  description: str = ""):
+        if not _SANDBOX_AVAILABLE:
+            raise NotImplementedError(_SANDBOX_UNAVAILABLE_MSG)
         super().__init__(name)
         self.fn = fn
         self.policy = policy or SandboxPolicy.default()
@@ -441,6 +471,8 @@ class SecureExecutor:
     """
 
     def __init__(self, default_policy: Optional[SandboxPolicy] = None):
+        if not _SANDBOX_AVAILABLE:
+            raise NotImplementedError(_SANDBOX_UNAVAILABLE_MSG)
         self.default_policy = default_policy or SandboxPolicy.default()
         self._stats = {
             "sandboxed_calls": 0,

+ 25 - 2
lambdagent/tests/test_builtin_tools.py

@@ -277,13 +277,36 @@ class TestBash:
         assert "TIMEOUT" in result
 
     def test_cd_persistent(self):
+        import tempfile
         from lambdagent.builtin_tools.shell_tools import run_bash, _get_cwd
         original = _get_cwd()
-        run_bash({"command": "cd /tmp"})
-        assert _get_cwd() == "/tmp" or _get_cwd() == os.path.realpath("/tmp")
+        # Use platform's actual tempdir instead of hardcoded /tmp so the test
+        # works on Windows (where /tmp doesn't exist).
+        target = tempfile.gettempdir()
+        run_bash({"command": f"cd {target}"})
+        assert _get_cwd() == target or _get_cwd() == os.path.realpath(target)
         # Restore
         run_bash({"command": f"cd {original}"})
 
+    def test_portable_command_works_across_platforms(self):
+        """`echo` works in /bin/sh, bash, cmd.exe — true cross-shell smoke test."""
+        from lambdagent.builtin_tools.shell_tools import run_bash
+        # No quoting / glob / variable expansion / pipe — works the same in
+        # every shell on Linux, macOS, and Windows (both Git-Bash and cmd.exe).
+        result = run_bash({"command": "echo lambdagent-portable-579"})
+        assert "lambdagent-portable-579" in result, f"got: {result!r}"
+
+    def test_resolve_bash_returns_sensible_value(self):
+        """resolve_bash() returns None on POSIX, bash.exe path on Windows when available."""
+        import platform
+        from lambdagent._shell_compat import resolve_bash
+        result = resolve_bash()
+        if platform.system() == "Windows":
+            # CI runner has Git-Bash; if user installs without it, result is None.
+            assert result is None or result.lower().endswith("bash.exe"), result
+        else:
+            assert result is None, f"POSIX should return None, got {result!r}"
+
     def test_interactive_rejected(self):
         from lambdagent.builtin_tools.shell_tools import run_bash
         with pytest.raises(ValueError, match="Interactive"):

+ 1 - 1
lambdagent/tests/test_core_primitives.py

@@ -171,7 +171,7 @@ class TestLam:
     def test_lam_has_type_properties(self):
         agent = Lam("typed", "prompt")
         # Default types are T_ANY
-        from lambdagent.types import T_ANY
+        from lambdagent.lam_types import T_ANY
         assert agent.input_type == T_ANY
         assert agent.output_type == T_ANY
 

+ 8 - 1
lambdagent/tests/test_extractors.py

@@ -198,9 +198,16 @@ class TestAutoDetect:
 
 
 # ============================================================
-# Guard Core Tests (I10)
+# Guard Core Tests (I10) — require the optional `lambdagent_guard` sibling
+# package, skipped in the standalone install.
 # ============================================================
 
+lambdagent_guard = pytest.importorskip(
+    "lambdagent_guard",
+    reason="lambdagent_guard sibling package not installed (standalone build)",
+)
+
+
 class TestGuardCore:
     def test_runtime_monitor_cost_tracking(self):
         from lambdagent_guard.core import RuntimeMonitor, GuardConfig

+ 1 - 1
lambdagent/tests/test_handlers.py

@@ -271,7 +271,7 @@ class TestComposedAgentsWithHandler:
     def test_handler_preserves_types(self):
         """Handler switching preserves type annotations (Paper III Theorem)"""
         lam = Lam("typed", "Process input")
-        from lambdagent.types import T_STR, T_JSON
+        from lambdagent.lam_types import T_STR, T_JSON
         lam.input_type = T_STR
         lam.output_type = T_JSON({"type": "object"})
 

+ 19 - 8
lambdagent/tests/test_phase2.py

@@ -27,16 +27,27 @@ from lambdagent.primitives import Tool, Lam, Compose, Loop
 
 class TestParTrueParallel:
     def test_par_runs_parallel(self):
+        """Verify true parallelism via thread synchronization, not wall-clock.
+
+        Wall-clock timing is too flaky on overloaded CI runners (macOS 3.10
+        regularly hit ~0.2s for what should be a 0.1s parallel run). Instead
+        we use a Barrier: each tool blocks until BOTH have entered. If Par
+        ran sequentially the second one would never enter and Barrier would
+        time out.
+        """
+        import threading
         from lambdagent.extensions import Par
-        t0 = time.time()
-        slow1 = Tool("s1", lambda x: (time.sleep(0.1), "a")[1])
-        slow2 = Tool("s2", lambda x: (time.sleep(0.1), "b")[1])
-        par = Par(slow1, slow2)
+        gate = threading.Barrier(2, timeout=2.0)
+
+        def make_tool(label):
+            def fn(_):
+                gate.wait()   # raises BrokenBarrierError if Par is sequential
+                return label
+            return Tool(f"s_{label}", fn)
+
+        par = Par(make_tool("a"), make_tool("b"))
         result = par.apply("x", Context())
-        elapsed = time.time() - t0
-        assert result == ("a", "b")
-        # Parallel: should be ~0.1s not ~0.2s
-        assert elapsed < 0.18
+        assert result == ("a", "b"), f"expected (a, b), got {result!r}"
 
     def test_par_single_agent(self):
         from lambdagent.extensions import Par

+ 4 - 1
lambdagent/tests/test_providers.py

@@ -88,7 +88,10 @@ class TestCreateProvider(unittest.TestCase):
         """create_provider('ollama') returns OpenAICompatProvider with localhost URL."""
         provider = create_provider("ollama")
         self.assertIsInstance(provider, OpenAICompatProvider)
-        self.assertIn("localhost:11434", provider.base_url)
+        self.assertTrue(
+            "localhost:11434" in provider.base_url
+            or "127.0.0.1:11434" in provider.base_url
+        )
         self.assertEqual(provider.provider_name, "ollama")
 
     def test_create_openai_provider(self):

+ 1 - 1
lambdagent/tests/test_types.py

@@ -10,7 +10,7 @@ Tests cover:
 """
 
 import pytest
-from lambdagent.types import (
+from lambdagent.lam_types import (
     LamType, TypeTag, AgentType, AgentTypeError,
     T_ANY, T_NONE, T_STR, T_INT, T_FLOAT, T_BOOL, T_JSON, T_TUPLE, T_UNION,
     is_subtype, check_compose_types, parse_type_annotation, infer_type_from_value,

+ 18 - 712
lambdagent/trace.py

@@ -1,719 +1,25 @@
 """
-lambdagent.trace — Enhanced β-reduction trace system
+lambdagent.trace — DEPRECATED compatibility shim.
 
-Features:
-  1. Colorized terminal output with progress bar
-  2. parent_step nesting (tree structure)
-  3. Anomaly detection (5 algorithms)
-  4. Flamegraph HTML export
-  5. Replay + diff comparison
+Renamed to lambdagent.tracing to stop shadowing the stdlib ``trace`` module
+(which broke Python import resolution when running scripts from the project
+root).
 
-Every feature maps to the patent:
-  "基于β-规约追踪的大语言模型智能体调试方法及系统"
-"""
-from __future__ import annotations
-
-import json
-import os
-import sys
-import time
-from dataclasses import dataclass, field, asdict
-from typing import Any, Dict, List, Optional, Tuple
-
-
-# ════════════════════════════════════════════════════════════
-# 1. Enhanced TraceEntry with parent_step nesting
-# ════════════════════════════════════════════════════════════
-
-@dataclass
-class TraceEntry:
-    """One β-reduction step with nesting support."""
-    step: int = 0
-    term_type: str = ""      # Lam | Tool | Compose | Loop | Route | Guard | Memory
-    name: str = ""
-    input: str = ""
-    output: str = ""
-    elapsed_ms: float = 0.0
-    model: str = ""
-    tokens_in: int = 0
-    tokens_out: int = 0
-    parent_step: Optional[int] = None   # nesting: which step spawned this
-    depth: int = 0                       # nesting depth (0 = top-level)
-    terminated: bool = False             # is this the base case?
-    error: Optional[str] = None
-    timestamp: float = 0.0
-
-    def __post_init__(self):
-        if self.timestamp == 0.0:
-            self.timestamp = time.time()
-
-
-# ════════════════════════════════════════════════════════════
-# 2. EnhancedTraceStore
-# ════════════════════════════════════════════════════════════
-
-class TraceStore:
-    """
-    Enhanced β-reduction trace storage.
-
-    Lambda: TraceStore = List[β-reduction] with tree structure
-    """
-
-    def __init__(self):
-        self._entries: List[TraceEntry] = []
-        self._step_counter: int = 0
-        self._depth_stack: List[int] = []  # stack of parent steps
-
-    @property
-    def entries(self) -> List[TraceEntry]:
-        return list(self._entries)
-
-    @property
-    def step_count(self) -> int:
-        return len(self._entries)
-
-    def push_scope(self, parent_step: int):
-        """Enter a nested scope (Compose, Loop, etc.)."""
-        self._depth_stack.append(parent_step)
-
-    def pop_scope(self):
-        """Leave a nested scope."""
-        if self._depth_stack:
-            self._depth_stack.pop()
-
-    def record(self, term_type: str, name: str, inp: Any, out: Any,
-               elapsed_ms: float, model: str = "", tokens_in: int = 0,
-               tokens_out: int = 0, terminated: bool = False,
-               error: Optional[str] = None) -> TraceEntry:
-        """Record one β-reduction step."""
-        entry = TraceEntry(
-            step=self._step_counter,
-            term_type=term_type,
-            name=name,
-            input=_truncate(inp, 500),
-            output=_truncate(out, 500),
-            elapsed_ms=elapsed_ms,
-            model=model,
-            tokens_in=tokens_in,
-            tokens_out=tokens_out,
-            parent_step=self._depth_stack[-1] if self._depth_stack else None,
-            depth=len(self._depth_stack),
-            terminated=terminated,
-            error=error,
-        )
-        self._entries.append(entry)
-        self._step_counter += 1
-        return entry
-
-    # ── Serialization ──
-
-    def to_json(self, indent: int = 2) -> str:
-        return json.dumps([asdict(e) for e in self._entries], indent=indent, default=str)
-
-    def save(self, path: str):
-        with open(path, "w", encoding="utf-8") as f:
-            f.write(self.to_json())
-
-    @classmethod
-    def load(cls, path: str) -> "TraceStore":
-        store = cls()
-        with open(path, "r", encoding="utf-8") as f:
-            data = json.load(f)
-        for d in data:
-            entry = TraceEntry(**{k: v for k, v in d.items() if k in TraceEntry.__dataclass_fields__})
-            store._entries.append(entry)
-        store._step_counter = len(store._entries)
-        return store
-
-    # ── Stats ──
-
-    def stats(self) -> Dict[str, Any]:
-        total_ms = sum(e.elapsed_ms for e in self._entries)
-        total_tokens = sum(e.tokens_in + e.tokens_out for e in self._entries)
-        llm_calls = sum(1 for e in self._entries if e.term_type == "Lam")
-        tool_calls = sum(1 for e in self._entries if e.term_type in ("Tool", "MCP"))
-        errors = sum(1 for e in self._entries if e.error)
-        terminated_by = "unknown"
-        if self._entries:
-            last = self._entries[-1]
-            if last.terminated:
-                terminated_by = "base_case"
-            elif last.error:
-                terminated_by = "error"
-            else:
-                terminated_by = "max_steps"
-        return {
-            "total_steps": len(self._entries),
-            "total_ms": total_ms,
-            "total_tokens": total_tokens,
-            "llm_calls": llm_calls,
-            "tool_calls": tool_calls,
-            "errors": errors,
-            "terminated_by": terminated_by,
-            "avg_step_ms": total_ms / max(1, len(self._entries)),
-        }
-
-
-# ════════════════════════════════════════════════════════════
-# 3. Colorized Terminal Output
-# ════════════════════════════════════════════════════════════
-
-# ANSI color codes
-_RESET = "\033[0m"
-_BOLD = "\033[1m"
-_DIM = "\033[2m"
-_RED = "\033[31m"
-_GREEN = "\033[32m"
-_YELLOW = "\033[33m"
-_BLUE = "\033[34m"
-_MAGENTA = "\033[35m"
-_CYAN = "\033[36m"
-_WHITE = "\033[37m"
-_BG_RED = "\033[41m"
-
-_TYPE_COLORS = {
-    "Lam": _BLUE,
-    "Tool": _GREEN,
-    "Compose": _CYAN,
-    "Loop": _MAGENTA,
-    "Route": _YELLOW,
-    "Guard": _RED,
-    "Memory": _WHITE,
-    "MCP": _GREEN,
-    "Pair": _CYAN,
-}
-
-
-def _supports_color() -> bool:
-    """Check if terminal supports color."""
-    if os.environ.get("NO_COLOR"):
-        return False
-    if os.environ.get("FORCE_COLOR"):
-        return True
-    return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
-
-
-def colorize_timeline(store: TraceStore, show_io: bool = True, max_io_len: int = 60) -> str:
-    """
-    Generate colorized timeline view.
-
-    β[0] ████████████ Lam:think      2.8s  "帮我写排序" → "需要搜索"
-    β[1]   ██████     Tool:search    1.2s  "排序算法" → "快排..."
-    β[2] ████████████ Lam:think      3.1s  "选择快排" → "开始写代码"
-    """
-    entries = store.entries
-    if not entries:
-        return "(empty trace)"
-
-    use_color = _supports_color()
-    max_ms = max(e.elapsed_ms for e in entries) if entries else 1
-    lines = []
-
-    # Header
-    if use_color:
-        lines.append(f"{_BOLD}β-reduction trace ({len(entries)} steps){_RESET}")
-        lines.append(f"{_DIM}{'─' * 70}{_RESET}")
-    else:
-        lines.append(f"β-reduction trace ({len(entries)} steps)")
-        lines.append("─" * 70)
-
-    for e in entries:
-        indent = "  " * e.depth
-        bar_len = max(1, int(20 * e.elapsed_ms / max(max_ms, 1)))
-        bar = "█" * bar_len
-
-        type_color = _TYPE_COLORS.get(e.term_type, "") if use_color else ""
-        reset = _RESET if use_color else ""
-        bold = _BOLD if use_color else ""
-        dim = _DIM if use_color else ""
-        red = _RED if use_color else ""
-        green = _GREEN if use_color else ""
-
-        # Step number
-        step_str = f"β[{e.step}]"
-
-        # Status indicator
-        if e.terminated:
-            status = f"{green}■{reset}" if use_color else "■"
-        elif e.error:
-            status = f"{red}✗{reset}" if use_color else "✗"
-        else:
-            status = " "
-
-        # Time formatting
-        if e.elapsed_ms >= 1000:
-            time_str = f"{e.elapsed_ms/1000:.1f}s"
-        else:
-            time_str = f"{e.elapsed_ms:.0f}ms"
-
-        # Main line
-        line = (f"  {step_str:6s} {indent}{status} "
-                f"{type_color}{bar} {e.term_type}:{e.name}{reset} "
-                f"{dim}({time_str}){reset}")
-
-        if e.error:
-            line += f" {red}ERROR: {e.error[:40]}{reset}"
-
-        lines.append(line)
-
-        # I/O detail
-        if show_io:
-            inp_s = str(e.input)[:max_io_len]
-            out_s = str(e.output)[:max_io_len]
-            io_line = f"         {indent}  {dim}{inp_s} → {out_s}{reset}"
-            lines.append(io_line)
-
-    # Footer: stats
-    s = store.stats()
-    lines.append(f"{_DIM if use_color else ''}{'─' * 70}{_RESET if use_color else ''}")
-    lines.append(
-        f"  Total: {s['total_steps']} β-reductions, "
-        f"{s['total_ms']/1000:.1f}s, "
-        f"~{s['total_tokens']} tokens, "
-        f"terminated by: {s['terminated_by']}"
-    )
-
-    # Progress bar
-    if use_color and entries:
-        total_ms = s["total_ms"]
-        cumulative = 0
-        progress = []
-        for e in entries:
-            pct = e.elapsed_ms / max(total_ms, 1)
-            color = _TYPE_COLORS.get(e.term_type, _WHITE)
-            seg_len = max(1, int(60 * pct))
-            progress.append(f"{color}{'▓' * seg_len}")
-            cumulative += e.elapsed_ms
-        lines.append(f"  {_DIM}[{''.join(progress)}{_RESET}{_DIM}]{_RESET}")
-
-    return "\n".join(lines)
-
-
-# ════════════════════════════════════════════════════════════
-# 4. Anomaly Detection (5 algorithms)
-# ════════════════════════════════════════════════════════════
-
-@dataclass
-class Anomaly:
-    """Detected anomaly in trace."""
-    type: str           # LATENCY_SPIKE | LOOP_DIVERGENCE | GUARD_STREAK | TOOL_REPEAT | EXCESSIVE_STEPS
-    severity: str       # ERROR | WARN
-    step: int           # which step triggered it
-    message: str
-    details: Dict[str, Any] = field(default_factory=dict)
-
-
-def detect_anomalies(store: TraceStore,
-                     latency_factor: float = 3.0,
-                     similarity_threshold: float = 0.90,
-                     max_guard_fails: int = 3,
-                     max_tool_repeats: int = 5,
-                     step_warn: int = 50,
-                     step_error: int = 100) -> List[Anomaly]:
-    """
-    Run all 5 anomaly detection algorithms on a trace.
-    Returns list of detected anomalies.
-    """
-    entries = store.entries
-    if not entries:
-        return []
+Will be removed in lambdagent 0.3.0. Please migrate downstream code:
 
-    anomalies = []
+    # before
+    from lambdagent.trace import TraceStore, EnhancedTraceEntry, ...
 
-    # ── Algorithm 1: Latency Spike ──
-    elapsed_values = [e.elapsed_ms for e in entries if e.elapsed_ms > 0]
-    if len(elapsed_values) >= 3:
-        mean_ms = sum(elapsed_values) / len(elapsed_values)
-        for e in entries:
-            if e.elapsed_ms > mean_ms * latency_factor and e.elapsed_ms > 100:
-                anomalies.append(Anomaly(
-                    type="LATENCY_SPIKE",
-                    severity="WARN",
-                    step=e.step,
-                    message=f"β[{e.step}] {e.term_type}:{e.name} took {e.elapsed_ms:.0f}ms "
-                            f"(mean: {mean_ms:.0f}ms, {e.elapsed_ms/mean_ms:.1f}x)",
-                    details={"elapsed_ms": e.elapsed_ms, "mean_ms": mean_ms,
-                             "factor": e.elapsed_ms / mean_ms},
-                ))
-
-    # ── Algorithm 2: Loop Divergence ──
-    loop_entries = [e for e in entries if e.term_type == "Loop"]
-    if len(loop_entries) >= 3:
-        for i in range(2, len(loop_entries)):
-            sim = _text_similarity(loop_entries[i].output, loop_entries[i-1].output)
-            if sim > similarity_threshold:
-                anomalies.append(Anomaly(
-                    type="LOOP_DIVERGENCE",
-                    severity="WARN",
-                    step=loop_entries[i].step,
-                    message=f"Loop output similarity {sim:.1%} at β[{loop_entries[i].step}] — "
-                            f"output not changing, possible stall",
-                    details={"similarity": sim, "consecutive": i},
-                ))
-                break  # report once
-
-    # ── Algorithm 3: Guard Streak Failure ──
-    guard_entries = [e for e in entries if e.term_type == "Guard"]
-    consecutive_fails = 0
-    for e in guard_entries:
-        if e.error or "stuck" in str(e.output).lower():
-            consecutive_fails += 1
-            if consecutive_fails >= max_guard_fails:
-                anomalies.append(Anomaly(
-                    type="GUARD_STREAK",
-                    severity="ERROR",
-                    step=e.step,
-                    message=f"Guard failed {consecutive_fails} consecutive times at β[{e.step}]",
-                    details={"consecutive_fails": consecutive_fails},
-                ))
-                break
-        else:
-            consecutive_fails = 0
-
-    # ── Algorithm 4: Tool Repetition ──
-    recent_tools: List[str] = []
-    for e in entries:
-        if e.term_type in ("Tool", "MCP"):
-            recent_tools.append(e.name)
-            if len(recent_tools) >= max_tool_repeats:
-                last_n = recent_tools[-max_tool_repeats:]
-                if all(t == last_n[0] for t in last_n):
-                    anomalies.append(Anomaly(
-                        type="TOOL_REPEAT",
-                        severity="WARN",
-                        step=e.step,
-                        message=f"Tool '{e.name}' called {max_tool_repeats} times "
-                                f"consecutively at β[{e.step}]",
-                        details={"tool": e.name, "repeat_count": max_tool_repeats},
-                    ))
-                    break
-
-    # ── Algorithm 5: Excessive Steps ──
-    total = len(entries)
-    if total > step_error:
-        anomalies.append(Anomaly(
-            type="EXCESSIVE_STEPS",
-            severity="ERROR",
-            step=total - 1,
-            message=f"Trace has {total} steps (> {step_error} threshold)",
-            details={"total_steps": total, "threshold": step_error},
-        ))
-    elif total > step_warn:
-        anomalies.append(Anomaly(
-            type="EXCESSIVE_STEPS",
-            severity="WARN",
-            step=total - 1,
-            message=f"Trace has {total} steps (> {step_warn} threshold)",
-            details={"total_steps": total, "threshold": step_warn},
-        ))
-
-    return anomalies
-
-
-def format_anomalies(anomalies: List[Anomaly]) -> str:
-    """Format anomalies for terminal output."""
-    if not anomalies:
-        return "  No anomalies detected ✓"
-
-    use_color = _supports_color()
-    lines = []
-    for a in anomalies:
-        if use_color:
-            color = _RED if a.severity == "ERROR" else _YELLOW
-            icon = "✗" if a.severity == "ERROR" else "⚠"
-            lines.append(f"  {color}{icon} [{a.severity}] {a.type}{_RESET}: {a.message}")
-        else:
-            icon = "x" if a.severity == "ERROR" else "!"
-            lines.append(f"  [{icon}] [{a.severity}] {a.type}: {a.message}")
-    return "\n".join(lines)
-
-
-# ════════════════════════════════════════════════════════════
-# 5. Flamegraph (HTML export)
-# ════════════════════════════════════════════════════════════
-
-def generate_flamegraph_html(store: TraceStore, title: str = "lambdagent β-reduction flamegraph") -> str:
-    """
-    Generate a self-contained HTML flamegraph from trace data.
-    Each bar = one β-reduction, width = elapsed time, depth = nesting.
-    """
-    entries = store.entries
-    if not entries:
-        return "<html><body>Empty trace</body></html>"
-
-    total_ms = sum(e.elapsed_ms for e in entries)
-    stats = store.stats()
-
-    # Build rows by depth
-    max_depth = max(e.depth for e in entries) if entries else 0
-
-    # Generate SVG bars
-    bars_svg = []
-    bar_height = 24
-    padding = 2
-    svg_width = 900
-    y_offset = 40  # space for title
-
-    # Group entries by depth level for positioning
-    for e in entries:
-        if total_ms == 0:
-            width_pct = 100
-        else:
-            width_pct = max(0.5, (e.elapsed_ms / total_ms) * 100)
-
-        # x position: based on cumulative time of entries before this one at same depth
-        preceding_ms = sum(
-            prev.elapsed_ms for prev in entries[:e.step]
-            if prev.depth == e.depth
-        )
-        x_pct = (preceding_ms / max(total_ms, 1)) * 100
-
-        y = y_offset + (max_depth - e.depth) * (bar_height + padding)
-
-        # Color by type
-        colors = {
-            "Lam": "#4A90D9", "Tool": "#50B050", "Compose": "#40B0B0",
-            "Loop": "#B060B0", "Route": "#D0A030", "Guard": "#D05050",
-            "Memory": "#808080", "MCP": "#50B050", "Pair": "#40B0B0",
-        }
-        color = colors.get(e.term_type, "#999999")
-
-        time_str = f"{e.elapsed_ms:.0f}ms" if e.elapsed_ms < 1000 else f"{e.elapsed_ms/1000:.1f}s"
-        label = f"{e.term_type}:{e.name} ({time_str})"
-
-        bars_svg.append(f'''
-        <g class="bar" data-step="{e.step}" data-type="{e.term_type}"
-           data-name="{e.name}" data-ms="{e.elapsed_ms:.1f}"
-           data-input="{_html_escape(_truncate(e.input, 100))}"
-           data-output="{_html_escape(_truncate(e.output, 100))}">
-          <rect x="{x_pct}%" y="{y}" width="{width_pct}%" height="{bar_height}"
-                fill="{color}" rx="3" stroke="#fff" stroke-width="1"
-                opacity="0.9"/>
-          <text x="{x_pct + width_pct/2}%" y="{y + bar_height/2 + 4}"
-                text-anchor="middle" font-size="11" fill="white"
-                style="pointer-events:none">{_html_escape(label[:40])}</text>
-        </g>''')
-
-    svg_height = y_offset + (max_depth + 1) * (bar_height + padding) + 20
-
-    html = f'''<!DOCTYPE html>
-<html>
-<head>
-<meta charset="utf-8">
-<title>{_html_escape(title)}</title>
-<style>
-  body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace;
-         margin: 20px; background: #1a1a2e; color: #eee; }}
-  h1 {{ font-size: 18px; color: #4A90D9; }}
-  .stats {{ font-size: 13px; color: #888; margin-bottom: 10px; }}
-  .bar rect:hover {{ opacity: 1; stroke: #FFD700; stroke-width: 2; cursor: pointer; }}
-  #tooltip {{ position: fixed; background: #2a2a4a; border: 1px solid #4A90D9;
-              border-radius: 6px; padding: 10px; font-size: 12px; display: none;
-              max-width: 400px; z-index: 100; box-shadow: 0 4px 12px rgba(0,0,0,0.5); }}
-  #tooltip .label {{ color: #4A90D9; font-weight: bold; }}
-  #tooltip .io {{ color: #aaa; font-size: 11px; word-break: break-all; }}
-  .legend {{ display: flex; gap: 12px; margin: 10px 0; font-size: 12px; }}
-  .legend span {{ display: flex; align-items: center; gap: 4px; }}
-  .legend .swatch {{ width: 14px; height: 14px; border-radius: 3px; }}
-</style>
-</head>
-<body>
-<h1>🔥 {_html_escape(title)}</h1>
-<div class="stats">
-  {stats['total_steps']} β-reductions · {stats['total_ms']/1000:.1f}s ·
-  ~{stats['total_tokens']} tokens · {stats['llm_calls']} LLM calls ·
-  {stats['tool_calls']} tool calls · terminated by: {stats['terminated_by']}
-</div>
-<div class="legend">
-  <span><div class="swatch" style="background:#4A90D9"></div> Lam (LLM)</span>
-  <span><div class="swatch" style="background:#50B050"></div> Tool</span>
-  <span><div class="swatch" style="background:#B060B0"></div> Loop</span>
-  <span><div class="swatch" style="background:#D0A030"></div> Route</span>
-  <span><div class="swatch" style="background:#D05050"></div> Guard</span>
-  <span><div class="swatch" style="background:#40B0B0"></div> Compose</span>
-</div>
-<svg width="100%" height="{svg_height}" xmlns="http://www.w3.org/2000/svg">
-  <text x="10" y="25" font-size="14" fill="#888">
-    depth ↑ | time →  (total: {total_ms/1000:.1f}s)
-  </text>
-  {''.join(bars_svg)}
-</svg>
-<div id="tooltip"></div>
-<script>
-document.querySelectorAll('.bar').forEach(bar => {{
-  bar.addEventListener('mouseenter', e => {{
-    const d = bar.dataset;
-    const tt = document.getElementById('tooltip');
-    tt.innerHTML = `<div class="label">β[${{d.step}}] ${{d.type}}:${{d.name}}</div>
-      <div>${{d.ms}}ms</div>
-      <div class="io">Input: ${{d.input}}</div>
-      <div class="io">Output: ${{d.output}}</div>`;
-    tt.style.display = 'block';
-    tt.style.left = (e.clientX + 15) + 'px';
-    tt.style.top = (e.clientY + 15) + 'px';
-  }});
-  bar.addEventListener('mouseleave', () => {{
-    document.getElementById('tooltip').style.display = 'none';
-  }});
-}});
-</script>
-</body>
-</html>'''
-    return html
-
-
-def save_flamegraph(store: TraceStore, path: str, title: str = "lambdagent β-reduction flamegraph"):
-    """Save flamegraph as HTML file."""
-    html = generate_flamegraph_html(store, title)
-    with open(path, "w", encoding="utf-8") as f:
-        f.write(html)
-
-
-# ════════════════════════════════════════════════════════════
-# 6. Replay + Diff
-# ════════════════════════════════════════════════════════════
-
-def replay(store: TraceStore, speed: float = 1.0, show_io: bool = True):
-    """
-    Replay a trace step by step with timing.
-    speed=1.0 replays at original speed, speed=2.0 at 2x, etc.
-    """
-    entries = store.entries
-    use_color = _supports_color()
-
-    print(f"\n{'─' * 60}")
-    print(f"  Replaying {len(entries)} β-reductions (speed: {speed}x)")
-    print(f"{'─' * 60}\n")
-
-    for e in entries:
-        indent = "  " * e.depth
-        type_color = _TYPE_COLORS.get(e.term_type, "") if use_color else ""
-        reset = _RESET if use_color else ""
-        dim = _DIM if use_color else ""
-
-        time_str = f"{e.elapsed_ms:.0f}ms" if e.elapsed_ms < 1000 else f"{e.elapsed_ms/1000:.1f}s"
-
-        # Print step
-        status = "■" if e.terminated else ("✗" if e.error else "▶")
-        print(f"  β[{e.step}] {indent}{status} "
-              f"{type_color}{e.term_type}:{e.name}{reset} "
-              f"{dim}({time_str}){reset}")
-
-        if show_io:
-            inp_s = str(e.input)[:60]
-            out_s = str(e.output)[:60]
-            print(f"         {indent}  {dim}{inp_s} → {out_s}{reset}")
-
-        # Wait proportional to original time
-        wait_ms = e.elapsed_ms / speed / 1000
-        if wait_ms > 0.01:  # don't sleep for trivial durations
-            time.sleep(min(wait_ms, 3.0))  # cap at 3s per step
-
-    print(f"\n{'─' * 60}")
-    print(f"  Replay complete.")
-    print(f"{'─' * 60}")
-
-
-def diff_traces(trace_a: TraceStore, trace_b: TraceStore,
-                label_a: str = "A", label_b: str = "B") -> str:
-    """
-    Compare two traces side by side.
-    Aligns by (term_type, name) and reports differences.
-    """
-    entries_a = trace_a.entries
-    entries_b = trace_b.entries
-    use_color = _supports_color()
-
-    lines = []
-    lines.append(f"Trace diff: {label_a} ({len(entries_a)} steps) vs {label_b} ({len(entries_b)} steps)")
-    lines.append("═" * 70)
-
-    max_len = max(len(entries_a), len(entries_b))
-    same_count = 0
-    changed_count = 0
-    added_count = 0
-    removed_count = 0
-
-    for i in range(max_len):
-        ea = entries_a[i] if i < len(entries_a) else None
-        eb = entries_b[i] if i < len(entries_b) else None
-
-        if ea and eb:
-            if ea.term_type == eb.term_type and ea.name == eb.name:
-                if ea.output == eb.output:
-                    # Same
-                    same_count += 1
-                    if use_color:
-                        lines.append(f"  {_DIM}β[{i}] {ea.term_type}:{ea.name} — SAME{_RESET}")
-                    else:
-                        lines.append(f"  β[{i}] {ea.term_type}:{ea.name} — SAME")
-                else:
-                    # Changed output
-                    changed_count += 1
-                    if use_color:
-                        lines.append(f"  {_YELLOW}β[{i}] {ea.term_type}:{ea.name} — CHANGED{_RESET}")
-                        lines.append(f"    {_RED}  {label_a}: {str(ea.output)[:50]}{_RESET}")
-                        lines.append(f"    {_GREEN}  {label_b}: {str(eb.output)[:50]}{_RESET}")
-                    else:
-                        lines.append(f"  β[{i}] {ea.term_type}:{ea.name} — CHANGED")
-                        lines.append(f"    - {label_a}: {str(ea.output)[:50]}")
-                        lines.append(f"    + {label_b}: {str(eb.output)[:50]}")
-            else:
-                # Different structure
-                changed_count += 1
-                lines.append(f"  β[{i}] STRUCTURE CHANGED: "
-                             f"{ea.term_type}:{ea.name} → {eb.term_type}:{eb.name}")
-        elif ea:
-            removed_count += 1
-            if use_color:
-                lines.append(f"  {_RED}β[{i}] {ea.term_type}:{ea.name} — REMOVED (only in {label_a}){_RESET}")
-            else:
-                lines.append(f"  β[{i}] {ea.term_type}:{ea.name} — REMOVED (only in {label_a})")
-        elif eb:
-            added_count += 1
-            if use_color:
-                lines.append(f"  {_GREEN}β[{i}] {eb.term_type}:{eb.name} — ADDED (only in {label_b}){_RESET}")
-            else:
-                lines.append(f"  β[{i}] {eb.term_type}:{eb.name} — ADDED (only in {label_b})")
-
-    lines.append("═" * 70)
-    lines.append(f"  Same: {same_count}, Changed: {changed_count}, "
-                 f"Added: {added_count}, Removed: {removed_count}")
-
-    # Timing comparison
-    ms_a = sum(e.elapsed_ms for e in entries_a)
-    ms_b = sum(e.elapsed_ms for e in entries_b)
-    if ms_a > 0:
-        speedup = ms_a / max(ms_b, 1)
-        lines.append(f"  Time: {label_a}={ms_a/1000:.1f}s, {label_b}={ms_b/1000:.1f}s "
-                     f"({speedup:.2f}x {'faster' if speedup > 1 else 'slower'})")
-
-    return "\n".join(lines)
-
-
-# ════════════════════════════════════════════════════════════
-# Utility functions
-# ════════════════════════════════════════════════════════════
-
-def _truncate(value: Any, max_len: int = 200) -> str:
-    s = str(value)
-    if len(s) > max_len:
-        return s[:max_len - 3] + "..."
-    return s
-
-
-def _text_similarity(a: str, b: str) -> float:
-    """Simple Jaccard similarity on word sets."""
-    if not a or not b:
-        return 0.0
-    words_a = set(str(a).lower().split())
-    words_b = set(str(b).lower().split())
-    if not words_a and not words_b:
-        return 1.0
-    intersection = words_a & words_b
-    union = words_a | words_b
-    return len(intersection) / max(len(union), 1)
+    # after
+    from lambdagent.tracing import TraceStore, EnhancedTraceEntry, ...
+"""
+import warnings as _warnings
 
+_warnings.warn(
+    "lambdagent.trace is deprecated and will be removed in 0.3.0. "
+    "Use lambdagent.tracing instead (same symbols).",
+    DeprecationWarning,
+    stacklevel=2,
+)
 
-def _html_escape(s: str) -> str:
-    return str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
+from .tracing import *  # noqa: F401,F403

+ 719 - 0
lambdagent/tracing.py

@@ -0,0 +1,719 @@
+"""
+lambdagent.trace — Enhanced β-reduction trace system
+
+Features:
+  1. Colorized terminal output with progress bar
+  2. parent_step nesting (tree structure)
+  3. Anomaly detection (5 algorithms)
+  4. Flamegraph HTML export
+  5. Replay + diff comparison
+
+Every feature maps to the patent:
+  "基于β-规约追踪的大语言模型智能体调试方法及系统"
+"""
+from __future__ import annotations
+
+import json
+import os
+import sys
+import time
+from dataclasses import dataclass, field, asdict
+from typing import Any, Dict, List, Optional, Tuple
+
+
+# ════════════════════════════════════════════════════════════
+# 1. Enhanced TraceEntry with parent_step nesting
+# ════════════════════════════════════════════════════════════
+
+@dataclass
+class TraceEntry:
+    """One β-reduction step with nesting support."""
+    step: int = 0
+    term_type: str = ""      # Lam | Tool | Compose | Loop | Route | Guard | Memory
+    name: str = ""
+    input: str = ""
+    output: str = ""
+    elapsed_ms: float = 0.0
+    model: str = ""
+    tokens_in: int = 0
+    tokens_out: int = 0
+    parent_step: Optional[int] = None   # nesting: which step spawned this
+    depth: int = 0                       # nesting depth (0 = top-level)
+    terminated: bool = False             # is this the base case?
+    error: Optional[str] = None
+    timestamp: float = 0.0
+
+    def __post_init__(self):
+        if self.timestamp == 0.0:
+            self.timestamp = time.time()
+
+
+# ════════════════════════════════════════════════════════════
+# 2. EnhancedTraceStore
+# ════════════════════════════════════════════════════════════
+
+class TraceStore:
+    """
+    Enhanced β-reduction trace storage.
+
+    Lambda: TraceStore = List[β-reduction] with tree structure
+    """
+
+    def __init__(self):
+        self._entries: List[TraceEntry] = []
+        self._step_counter: int = 0
+        self._depth_stack: List[int] = []  # stack of parent steps
+
+    @property
+    def entries(self) -> List[TraceEntry]:
+        return list(self._entries)
+
+    @property
+    def step_count(self) -> int:
+        return len(self._entries)
+
+    def push_scope(self, parent_step: int):
+        """Enter a nested scope (Compose, Loop, etc.)."""
+        self._depth_stack.append(parent_step)
+
+    def pop_scope(self):
+        """Leave a nested scope."""
+        if self._depth_stack:
+            self._depth_stack.pop()
+
+    def record(self, term_type: str, name: str, inp: Any, out: Any,
+               elapsed_ms: float, model: str = "", tokens_in: int = 0,
+               tokens_out: int = 0, terminated: bool = False,
+               error: Optional[str] = None) -> TraceEntry:
+        """Record one β-reduction step."""
+        entry = TraceEntry(
+            step=self._step_counter,
+            term_type=term_type,
+            name=name,
+            input=_truncate(inp, 500),
+            output=_truncate(out, 500),
+            elapsed_ms=elapsed_ms,
+            model=model,
+            tokens_in=tokens_in,
+            tokens_out=tokens_out,
+            parent_step=self._depth_stack[-1] if self._depth_stack else None,
+            depth=len(self._depth_stack),
+            terminated=terminated,
+            error=error,
+        )
+        self._entries.append(entry)
+        self._step_counter += 1
+        return entry
+
+    # ── Serialization ──
+
+    def to_json(self, indent: int = 2) -> str:
+        return json.dumps([asdict(e) for e in self._entries], indent=indent, default=str)
+
+    def save(self, path: str):
+        with open(path, "w", encoding="utf-8") as f:
+            f.write(self.to_json())
+
+    @classmethod
+    def load(cls, path: str) -> "TraceStore":
+        store = cls()
+        with open(path, "r", encoding="utf-8") as f:
+            data = json.load(f)
+        for d in data:
+            entry = TraceEntry(**{k: v for k, v in d.items() if k in TraceEntry.__dataclass_fields__})
+            store._entries.append(entry)
+        store._step_counter = len(store._entries)
+        return store
+
+    # ── Stats ──
+
+    def stats(self) -> Dict[str, Any]:
+        total_ms = sum(e.elapsed_ms for e in self._entries)
+        total_tokens = sum(e.tokens_in + e.tokens_out for e in self._entries)
+        llm_calls = sum(1 for e in self._entries if e.term_type == "Lam")
+        tool_calls = sum(1 for e in self._entries if e.term_type in ("Tool", "MCP"))
+        errors = sum(1 for e in self._entries if e.error)
+        terminated_by = "unknown"
+        if self._entries:
+            last = self._entries[-1]
+            if last.terminated:
+                terminated_by = "base_case"
+            elif last.error:
+                terminated_by = "error"
+            else:
+                terminated_by = "max_steps"
+        return {
+            "total_steps": len(self._entries),
+            "total_ms": total_ms,
+            "total_tokens": total_tokens,
+            "llm_calls": llm_calls,
+            "tool_calls": tool_calls,
+            "errors": errors,
+            "terminated_by": terminated_by,
+            "avg_step_ms": total_ms / max(1, len(self._entries)),
+        }
+
+
+# ════════════════════════════════════════════════════════════
+# 3. Colorized Terminal Output
+# ════════════════════════════════════════════════════════════
+
+# ANSI color codes
+_RESET = "\033[0m"
+_BOLD = "\033[1m"
+_DIM = "\033[2m"
+_RED = "\033[31m"
+_GREEN = "\033[32m"
+_YELLOW = "\033[33m"
+_BLUE = "\033[34m"
+_MAGENTA = "\033[35m"
+_CYAN = "\033[36m"
+_WHITE = "\033[37m"
+_BG_RED = "\033[41m"
+
+_TYPE_COLORS = {
+    "Lam": _BLUE,
+    "Tool": _GREEN,
+    "Compose": _CYAN,
+    "Loop": _MAGENTA,
+    "Route": _YELLOW,
+    "Guard": _RED,
+    "Memory": _WHITE,
+    "MCP": _GREEN,
+    "Pair": _CYAN,
+}
+
+
+def _supports_color() -> bool:
+    """Check if terminal supports color."""
+    if os.environ.get("NO_COLOR"):
+        return False
+    if os.environ.get("FORCE_COLOR"):
+        return True
+    return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
+
+
+def colorize_timeline(store: TraceStore, show_io: bool = True, max_io_len: int = 60) -> str:
+    """
+    Generate colorized timeline view.
+
+    β[0] ████████████ Lam:think      2.8s  "帮我写排序" → "需要搜索"
+    β[1]   ██████     Tool:search    1.2s  "排序算法" → "快排..."
+    β[2] ████████████ Lam:think      3.1s  "选择快排" → "开始写代码"
+    """
+    entries = store.entries
+    if not entries:
+        return "(empty trace)"
+
+    use_color = _supports_color()
+    max_ms = max(e.elapsed_ms for e in entries) if entries else 1
+    lines = []
+
+    # Header
+    if use_color:
+        lines.append(f"{_BOLD}β-reduction trace ({len(entries)} steps){_RESET}")
+        lines.append(f"{_DIM}{'─' * 70}{_RESET}")
+    else:
+        lines.append(f"β-reduction trace ({len(entries)} steps)")
+        lines.append("─" * 70)
+
+    for e in entries:
+        indent = "  " * e.depth
+        bar_len = max(1, int(20 * e.elapsed_ms / max(max_ms, 1)))
+        bar = "█" * bar_len
+
+        type_color = _TYPE_COLORS.get(e.term_type, "") if use_color else ""
+        reset = _RESET if use_color else ""
+        bold = _BOLD if use_color else ""
+        dim = _DIM if use_color else ""
+        red = _RED if use_color else ""
+        green = _GREEN if use_color else ""
+
+        # Step number
+        step_str = f"β[{e.step}]"
+
+        # Status indicator
+        if e.terminated:
+            status = f"{green}■{reset}" if use_color else "■"
+        elif e.error:
+            status = f"{red}✗{reset}" if use_color else "✗"
+        else:
+            status = " "
+
+        # Time formatting
+        if e.elapsed_ms >= 1000:
+            time_str = f"{e.elapsed_ms/1000:.1f}s"
+        else:
+            time_str = f"{e.elapsed_ms:.0f}ms"
+
+        # Main line
+        line = (f"  {step_str:6s} {indent}{status} "
+                f"{type_color}{bar} {e.term_type}:{e.name}{reset} "
+                f"{dim}({time_str}){reset}")
+
+        if e.error:
+            line += f" {red}ERROR: {e.error[:40]}{reset}"
+
+        lines.append(line)
+
+        # I/O detail
+        if show_io:
+            inp_s = str(e.input)[:max_io_len]
+            out_s = str(e.output)[:max_io_len]
+            io_line = f"         {indent}  {dim}{inp_s} → {out_s}{reset}"
+            lines.append(io_line)
+
+    # Footer: stats
+    s = store.stats()
+    lines.append(f"{_DIM if use_color else ''}{'─' * 70}{_RESET if use_color else ''}")
+    lines.append(
+        f"  Total: {s['total_steps']} β-reductions, "
+        f"{s['total_ms']/1000:.1f}s, "
+        f"~{s['total_tokens']} tokens, "
+        f"terminated by: {s['terminated_by']}"
+    )
+
+    # Progress bar
+    if use_color and entries:
+        total_ms = s["total_ms"]
+        cumulative = 0
+        progress = []
+        for e in entries:
+            pct = e.elapsed_ms / max(total_ms, 1)
+            color = _TYPE_COLORS.get(e.term_type, _WHITE)
+            seg_len = max(1, int(60 * pct))
+            progress.append(f"{color}{'▓' * seg_len}")
+            cumulative += e.elapsed_ms
+        lines.append(f"  {_DIM}[{''.join(progress)}{_RESET}{_DIM}]{_RESET}")
+
+    return "\n".join(lines)
+
+
+# ════════════════════════════════════════════════════════════
+# 4. Anomaly Detection (5 algorithms)
+# ════════════════════════════════════════════════════════════
+
+@dataclass
+class Anomaly:
+    """Detected anomaly in trace."""
+    type: str           # LATENCY_SPIKE | LOOP_DIVERGENCE | GUARD_STREAK | TOOL_REPEAT | EXCESSIVE_STEPS
+    severity: str       # ERROR | WARN
+    step: int           # which step triggered it
+    message: str
+    details: Dict[str, Any] = field(default_factory=dict)
+
+
+def detect_anomalies(store: TraceStore,
+                     latency_factor: float = 3.0,
+                     similarity_threshold: float = 0.90,
+                     max_guard_fails: int = 3,
+                     max_tool_repeats: int = 5,
+                     step_warn: int = 50,
+                     step_error: int = 100) -> List[Anomaly]:
+    """
+    Run all 5 anomaly detection algorithms on a trace.
+    Returns list of detected anomalies.
+    """
+    entries = store.entries
+    if not entries:
+        return []
+
+    anomalies = []
+
+    # ── Algorithm 1: Latency Spike ──
+    elapsed_values = [e.elapsed_ms for e in entries if e.elapsed_ms > 0]
+    if len(elapsed_values) >= 3:
+        mean_ms = sum(elapsed_values) / len(elapsed_values)
+        for e in entries:
+            if e.elapsed_ms > mean_ms * latency_factor and e.elapsed_ms > 100:
+                anomalies.append(Anomaly(
+                    type="LATENCY_SPIKE",
+                    severity="WARN",
+                    step=e.step,
+                    message=f"β[{e.step}] {e.term_type}:{e.name} took {e.elapsed_ms:.0f}ms "
+                            f"(mean: {mean_ms:.0f}ms, {e.elapsed_ms/mean_ms:.1f}x)",
+                    details={"elapsed_ms": e.elapsed_ms, "mean_ms": mean_ms,
+                             "factor": e.elapsed_ms / mean_ms},
+                ))
+
+    # ── Algorithm 2: Loop Divergence ──
+    loop_entries = [e for e in entries if e.term_type == "Loop"]
+    if len(loop_entries) >= 3:
+        for i in range(2, len(loop_entries)):
+            sim = _text_similarity(loop_entries[i].output, loop_entries[i-1].output)
+            if sim > similarity_threshold:
+                anomalies.append(Anomaly(
+                    type="LOOP_DIVERGENCE",
+                    severity="WARN",
+                    step=loop_entries[i].step,
+                    message=f"Loop output similarity {sim:.1%} at β[{loop_entries[i].step}] — "
+                            f"output not changing, possible stall",
+                    details={"similarity": sim, "consecutive": i},
+                ))
+                break  # report once
+
+    # ── Algorithm 3: Guard Streak Failure ──
+    guard_entries = [e for e in entries if e.term_type == "Guard"]
+    consecutive_fails = 0
+    for e in guard_entries:
+        if e.error or "stuck" in str(e.output).lower():
+            consecutive_fails += 1
+            if consecutive_fails >= max_guard_fails:
+                anomalies.append(Anomaly(
+                    type="GUARD_STREAK",
+                    severity="ERROR",
+                    step=e.step,
+                    message=f"Guard failed {consecutive_fails} consecutive times at β[{e.step}]",
+                    details={"consecutive_fails": consecutive_fails},
+                ))
+                break
+        else:
+            consecutive_fails = 0
+
+    # ── Algorithm 4: Tool Repetition ──
+    recent_tools: List[str] = []
+    for e in entries:
+        if e.term_type in ("Tool", "MCP"):
+            recent_tools.append(e.name)
+            if len(recent_tools) >= max_tool_repeats:
+                last_n = recent_tools[-max_tool_repeats:]
+                if all(t == last_n[0] for t in last_n):
+                    anomalies.append(Anomaly(
+                        type="TOOL_REPEAT",
+                        severity="WARN",
+                        step=e.step,
+                        message=f"Tool '{e.name}' called {max_tool_repeats} times "
+                                f"consecutively at β[{e.step}]",
+                        details={"tool": e.name, "repeat_count": max_tool_repeats},
+                    ))
+                    break
+
+    # ── Algorithm 5: Excessive Steps ──
+    total = len(entries)
+    if total > step_error:
+        anomalies.append(Anomaly(
+            type="EXCESSIVE_STEPS",
+            severity="ERROR",
+            step=total - 1,
+            message=f"Trace has {total} steps (> {step_error} threshold)",
+            details={"total_steps": total, "threshold": step_error},
+        ))
+    elif total > step_warn:
+        anomalies.append(Anomaly(
+            type="EXCESSIVE_STEPS",
+            severity="WARN",
+            step=total - 1,
+            message=f"Trace has {total} steps (> {step_warn} threshold)",
+            details={"total_steps": total, "threshold": step_warn},
+        ))
+
+    return anomalies
+
+
+def format_anomalies(anomalies: List[Anomaly]) -> str:
+    """Format anomalies for terminal output."""
+    if not anomalies:
+        return "  No anomalies detected ✓"
+
+    use_color = _supports_color()
+    lines = []
+    for a in anomalies:
+        if use_color:
+            color = _RED if a.severity == "ERROR" else _YELLOW
+            icon = "✗" if a.severity == "ERROR" else "⚠"
+            lines.append(f"  {color}{icon} [{a.severity}] {a.type}{_RESET}: {a.message}")
+        else:
+            icon = "x" if a.severity == "ERROR" else "!"
+            lines.append(f"  [{icon}] [{a.severity}] {a.type}: {a.message}")
+    return "\n".join(lines)
+
+
+# ════════════════════════════════════════════════════════════
+# 5. Flamegraph (HTML export)
+# ════════════════════════════════════════════════════════════
+
+def generate_flamegraph_html(store: TraceStore, title: str = "lambdagent β-reduction flamegraph") -> str:
+    """
+    Generate a self-contained HTML flamegraph from trace data.
+    Each bar = one β-reduction, width = elapsed time, depth = nesting.
+    """
+    entries = store.entries
+    if not entries:
+        return "<html><body>Empty trace</body></html>"
+
+    total_ms = sum(e.elapsed_ms for e in entries)
+    stats = store.stats()
+
+    # Build rows by depth
+    max_depth = max(e.depth for e in entries) if entries else 0
+
+    # Generate SVG bars
+    bars_svg = []
+    bar_height = 24
+    padding = 2
+    svg_width = 900
+    y_offset = 40  # space for title
+
+    # Group entries by depth level for positioning
+    for e in entries:
+        if total_ms == 0:
+            width_pct = 100
+        else:
+            width_pct = max(0.5, (e.elapsed_ms / total_ms) * 100)
+
+        # x position: based on cumulative time of entries before this one at same depth
+        preceding_ms = sum(
+            prev.elapsed_ms for prev in entries[:e.step]
+            if prev.depth == e.depth
+        )
+        x_pct = (preceding_ms / max(total_ms, 1)) * 100
+
+        y = y_offset + (max_depth - e.depth) * (bar_height + padding)
+
+        # Color by type
+        colors = {
+            "Lam": "#4A90D9", "Tool": "#50B050", "Compose": "#40B0B0",
+            "Loop": "#B060B0", "Route": "#D0A030", "Guard": "#D05050",
+            "Memory": "#808080", "MCP": "#50B050", "Pair": "#40B0B0",
+        }
+        color = colors.get(e.term_type, "#999999")
+
+        time_str = f"{e.elapsed_ms:.0f}ms" if e.elapsed_ms < 1000 else f"{e.elapsed_ms/1000:.1f}s"
+        label = f"{e.term_type}:{e.name} ({time_str})"
+
+        bars_svg.append(f'''
+        <g class="bar" data-step="{e.step}" data-type="{e.term_type}"
+           data-name="{e.name}" data-ms="{e.elapsed_ms:.1f}"
+           data-input="{_html_escape(_truncate(e.input, 100))}"
+           data-output="{_html_escape(_truncate(e.output, 100))}">
+          <rect x="{x_pct}%" y="{y}" width="{width_pct}%" height="{bar_height}"
+                fill="{color}" rx="3" stroke="#fff" stroke-width="1"
+                opacity="0.9"/>
+          <text x="{x_pct + width_pct/2}%" y="{y + bar_height/2 + 4}"
+                text-anchor="middle" font-size="11" fill="white"
+                style="pointer-events:none">{_html_escape(label[:40])}</text>
+        </g>''')
+
+    svg_height = y_offset + (max_depth + 1) * (bar_height + padding) + 20
+
+    html = f'''<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<title>{_html_escape(title)}</title>
+<style>
+  body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace;
+         margin: 20px; background: #1a1a2e; color: #eee; }}
+  h1 {{ font-size: 18px; color: #4A90D9; }}
+  .stats {{ font-size: 13px; color: #888; margin-bottom: 10px; }}
+  .bar rect:hover {{ opacity: 1; stroke: #FFD700; stroke-width: 2; cursor: pointer; }}
+  #tooltip {{ position: fixed; background: #2a2a4a; border: 1px solid #4A90D9;
+              border-radius: 6px; padding: 10px; font-size: 12px; display: none;
+              max-width: 400px; z-index: 100; box-shadow: 0 4px 12px rgba(0,0,0,0.5); }}
+  #tooltip .label {{ color: #4A90D9; font-weight: bold; }}
+  #tooltip .io {{ color: #aaa; font-size: 11px; word-break: break-all; }}
+  .legend {{ display: flex; gap: 12px; margin: 10px 0; font-size: 12px; }}
+  .legend span {{ display: flex; align-items: center; gap: 4px; }}
+  .legend .swatch {{ width: 14px; height: 14px; border-radius: 3px; }}
+</style>
+</head>
+<body>
+<h1>🔥 {_html_escape(title)}</h1>
+<div class="stats">
+  {stats['total_steps']} β-reductions · {stats['total_ms']/1000:.1f}s ·
+  ~{stats['total_tokens']} tokens · {stats['llm_calls']} LLM calls ·
+  {stats['tool_calls']} tool calls · terminated by: {stats['terminated_by']}
+</div>
+<div class="legend">
+  <span><div class="swatch" style="background:#4A90D9"></div> Lam (LLM)</span>
+  <span><div class="swatch" style="background:#50B050"></div> Tool</span>
+  <span><div class="swatch" style="background:#B060B0"></div> Loop</span>
+  <span><div class="swatch" style="background:#D0A030"></div> Route</span>
+  <span><div class="swatch" style="background:#D05050"></div> Guard</span>
+  <span><div class="swatch" style="background:#40B0B0"></div> Compose</span>
+</div>
+<svg width="100%" height="{svg_height}" xmlns="http://www.w3.org/2000/svg">
+  <text x="10" y="25" font-size="14" fill="#888">
+    depth ↑ | time →  (total: {total_ms/1000:.1f}s)
+  </text>
+  {''.join(bars_svg)}
+</svg>
+<div id="tooltip"></div>
+<script>
+document.querySelectorAll('.bar').forEach(bar => {{
+  bar.addEventListener('mouseenter', e => {{
+    const d = bar.dataset;
+    const tt = document.getElementById('tooltip');
+    tt.innerHTML = `<div class="label">β[${{d.step}}] ${{d.type}}:${{d.name}}</div>
+      <div>${{d.ms}}ms</div>
+      <div class="io">Input: ${{d.input}}</div>
+      <div class="io">Output: ${{d.output}}</div>`;
+    tt.style.display = 'block';
+    tt.style.left = (e.clientX + 15) + 'px';
+    tt.style.top = (e.clientY + 15) + 'px';
+  }});
+  bar.addEventListener('mouseleave', () => {{
+    document.getElementById('tooltip').style.display = 'none';
+  }});
+}});
+</script>
+</body>
+</html>'''
+    return html
+
+
+def save_flamegraph(store: TraceStore, path: str, title: str = "lambdagent β-reduction flamegraph"):
+    """Save flamegraph as HTML file."""
+    html = generate_flamegraph_html(store, title)
+    with open(path, "w", encoding="utf-8") as f:
+        f.write(html)
+
+
+# ════════════════════════════════════════════════════════════
+# 6. Replay + Diff
+# ════════════════════════════════════════════════════════════
+
+def replay(store: TraceStore, speed: float = 1.0, show_io: bool = True):
+    """
+    Replay a trace step by step with timing.
+    speed=1.0 replays at original speed, speed=2.0 at 2x, etc.
+    """
+    entries = store.entries
+    use_color = _supports_color()
+
+    print(f"\n{'─' * 60}")
+    print(f"  Replaying {len(entries)} β-reductions (speed: {speed}x)")
+    print(f"{'─' * 60}\n")
+
+    for e in entries:
+        indent = "  " * e.depth
+        type_color = _TYPE_COLORS.get(e.term_type, "") if use_color else ""
+        reset = _RESET if use_color else ""
+        dim = _DIM if use_color else ""
+
+        time_str = f"{e.elapsed_ms:.0f}ms" if e.elapsed_ms < 1000 else f"{e.elapsed_ms/1000:.1f}s"
+
+        # Print step
+        status = "■" if e.terminated else ("✗" if e.error else "▶")
+        print(f"  β[{e.step}] {indent}{status} "
+              f"{type_color}{e.term_type}:{e.name}{reset} "
+              f"{dim}({time_str}){reset}")
+
+        if show_io:
+            inp_s = str(e.input)[:60]
+            out_s = str(e.output)[:60]
+            print(f"         {indent}  {dim}{inp_s} → {out_s}{reset}")
+
+        # Wait proportional to original time
+        wait_ms = e.elapsed_ms / speed / 1000
+        if wait_ms > 0.01:  # don't sleep for trivial durations
+            time.sleep(min(wait_ms, 3.0))  # cap at 3s per step
+
+    print(f"\n{'─' * 60}")
+    print(f"  Replay complete.")
+    print(f"{'─' * 60}")
+
+
+def diff_traces(trace_a: TraceStore, trace_b: TraceStore,
+                label_a: str = "A", label_b: str = "B") -> str:
+    """
+    Compare two traces side by side.
+    Aligns by (term_type, name) and reports differences.
+    """
+    entries_a = trace_a.entries
+    entries_b = trace_b.entries
+    use_color = _supports_color()
+
+    lines = []
+    lines.append(f"Trace diff: {label_a} ({len(entries_a)} steps) vs {label_b} ({len(entries_b)} steps)")
+    lines.append("═" * 70)
+
+    max_len = max(len(entries_a), len(entries_b))
+    same_count = 0
+    changed_count = 0
+    added_count = 0
+    removed_count = 0
+
+    for i in range(max_len):
+        ea = entries_a[i] if i < len(entries_a) else None
+        eb = entries_b[i] if i < len(entries_b) else None
+
+        if ea and eb:
+            if ea.term_type == eb.term_type and ea.name == eb.name:
+                if ea.output == eb.output:
+                    # Same
+                    same_count += 1
+                    if use_color:
+                        lines.append(f"  {_DIM}β[{i}] {ea.term_type}:{ea.name} — SAME{_RESET}")
+                    else:
+                        lines.append(f"  β[{i}] {ea.term_type}:{ea.name} — SAME")
+                else:
+                    # Changed output
+                    changed_count += 1
+                    if use_color:
+                        lines.append(f"  {_YELLOW}β[{i}] {ea.term_type}:{ea.name} — CHANGED{_RESET}")
+                        lines.append(f"    {_RED}  {label_a}: {str(ea.output)[:50]}{_RESET}")
+                        lines.append(f"    {_GREEN}  {label_b}: {str(eb.output)[:50]}{_RESET}")
+                    else:
+                        lines.append(f"  β[{i}] {ea.term_type}:{ea.name} — CHANGED")
+                        lines.append(f"    - {label_a}: {str(ea.output)[:50]}")
+                        lines.append(f"    + {label_b}: {str(eb.output)[:50]}")
+            else:
+                # Different structure
+                changed_count += 1
+                lines.append(f"  β[{i}] STRUCTURE CHANGED: "
+                             f"{ea.term_type}:{ea.name} → {eb.term_type}:{eb.name}")
+        elif ea:
+            removed_count += 1
+            if use_color:
+                lines.append(f"  {_RED}β[{i}] {ea.term_type}:{ea.name} — REMOVED (only in {label_a}){_RESET}")
+            else:
+                lines.append(f"  β[{i}] {ea.term_type}:{ea.name} — REMOVED (only in {label_a})")
+        elif eb:
+            added_count += 1
+            if use_color:
+                lines.append(f"  {_GREEN}β[{i}] {eb.term_type}:{eb.name} — ADDED (only in {label_b}){_RESET}")
+            else:
+                lines.append(f"  β[{i}] {eb.term_type}:{eb.name} — ADDED (only in {label_b})")
+
+    lines.append("═" * 70)
+    lines.append(f"  Same: {same_count}, Changed: {changed_count}, "
+                 f"Added: {added_count}, Removed: {removed_count}")
+
+    # Timing comparison
+    ms_a = sum(e.elapsed_ms for e in entries_a)
+    ms_b = sum(e.elapsed_ms for e in entries_b)
+    if ms_a > 0:
+        speedup = ms_a / max(ms_b, 1)
+        lines.append(f"  Time: {label_a}={ms_a/1000:.1f}s, {label_b}={ms_b/1000:.1f}s "
+                     f"({speedup:.2f}x {'faster' if speedup > 1 else 'slower'})")
+
+    return "\n".join(lines)
+
+
+# ════════════════════════════════════════════════════════════
+# Utility functions
+# ════════════════════════════════════════════════════════════
+
+def _truncate(value: Any, max_len: int = 200) -> str:
+    s = str(value)
+    if len(s) > max_len:
+        return s[:max_len - 3] + "..."
+    return s
+
+
+def _text_similarity(a: str, b: str) -> float:
+    """Simple Jaccard similarity on word sets."""
+    if not a or not b:
+        return 0.0
+    words_a = set(str(a).lower().split())
+    words_b = set(str(b).lower().split())
+    if not words_a and not words_b:
+        return 1.0
+    intersection = words_a & words_b
+    union = words_a | words_b
+    return len(intersection) / max(len(union), 1)
+
+
+def _html_escape(s: str) -> str:
+    return str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")

+ 21 - 436
lambdagent/types.py

@@ -1,444 +1,29 @@
 """
-lambdagent.types — Paper III 类型与效果系统
+lambdagent.types — DEPRECATED compatibility shim.
 
-实现论文 III 的类型系统:
-  - AgentType: Agent 函数类型 τ1 →^ε τ2
-  - LamType: 基础类型构造 (Str, Int, Bool, Float, Any, Json(S))
-  - Json(S): 复用 JSON Schema 作为结构化类型语言 (Definition 2)
-  - 子类型关系 <: (Definition 5): 宽度/深度子类型
-  - T-Compose 规则: f >> g 要求 output(f) <: input(g) (Paper III §3.3.3)
+Renamed to lambdagent.lam_types to stop shadowing the stdlib ``types`` module
+(which broke ``import enum`` etc. when running Python scripts from the
+project root).
 
-核心方程:
-    is_subtype(τ1, τ2) = True  ⟺  τ1 <: τ2
+Will be removed in lambdagent 0.3.0. Please migrate downstream code:
 
-依赖图:
-    types.py  ←  effects.py (效果标注)
-              ←  compiler.py (编译时类型检查)
-              ←  core.py (Term.input_type / output_type)
-"""
-
-from __future__ import annotations
-
-import json
-from dataclasses import dataclass, field
-from enum import Enum, auto
-from typing import Any, Dict, FrozenSet, List, Optional, Set, Tuple, Union
-
-
-# ============================================================
-# 基础类型 (Paper III Definition 1)
-# ============================================================
-
-class TypeTag(Enum):
-    """类型标签枚举"""
-    ANY = auto()      # ⊤ — 顶类型,所有类型的超类型
-    NONE = auto()     # ⊥ — 底类型,所有类型的子类型
-    STR = auto()      # 字符串
-    INT = auto()      # 整数
-    FLOAT = auto()    # 浮点数
-    BOOL = auto()     # 布尔
-    JSON = auto()     # Json(S) — JSON Schema 结构化类型
-    TUPLE = auto()    # 元组类型 (Pair 的输出)
-    UNION = auto()    # 联合类型 (Route/If 的输出)
-
-
-@dataclass(frozen=True)
-class LamType:
-    """
-    Lambda Agent 的类型。
-
-    Paper III Definition 1:
-        τ ::= Str | Int | Bool | Float | Any | Json(S) | τ1 × τ2 | τ1 | τ2
-
-    其中 S 是 JSON Schema(Definition 2),
-    复用 JSON Schema 作为结构化类型语言。
-    """
-    tag: TypeTag
-    # Json(S): JSON Schema dict (when tag == JSON)
-    schema: Optional[Dict[str, Any]] = field(default=None, hash=False)
-    # Tuple: element types (when tag == TUPLE)
-    elements: Optional[Tuple[LamType, ...]] = None
-    # Union: member types (when tag == UNION)
-    members: Optional[FrozenSet[LamType]] = None
-
-    def __repr__(self) -> str:
-        if self.tag == TypeTag.ANY:
-            return "Any"
-        elif self.tag == TypeTag.NONE:
-            return "None"
-        elif self.tag == TypeTag.STR:
-            return "Str"
-        elif self.tag == TypeTag.INT:
-            return "Int"
-        elif self.tag == TypeTag.FLOAT:
-            return "Float"
-        elif self.tag == TypeTag.BOOL:
-            return "Bool"
-        elif self.tag == TypeTag.JSON:
-            if self.schema:
-                t = self.schema.get("type", "object")
-                if t == "object":
-                    props = self.schema.get("properties", {})
-                    if props:
-                        fields = ", ".join(f"{k}: {v.get('type', '?')}" for k, v in list(props.items())[:3])
-                        if len(props) > 3:
-                            fields += ", ..."
-                        return f"Json({{{fields}}})"
-                elif t == "array":
-                    items = self.schema.get("items", {})
-                    return f"Json([{items.get('type', '?')}])"
-                return f"Json({t})"
-            return "Json"
-        elif self.tag == TypeTag.TUPLE:
-            if self.elements:
-                inner = ", ".join(str(e) for e in self.elements)
-                return f"({inner})"
-            return "()"
-        elif self.tag == TypeTag.UNION:
-            if self.members:
-                inner = " | ".join(str(m) for m in sorted(self.members, key=str))
-                return f"({inner})"
-            return "Never"
-        return f"LamType({self.tag})"
-
-
-# ============================================================
-# 类型常量(快捷方式)
-# ============================================================
-
-T_ANY = LamType(TypeTag.ANY)
-T_NONE = LamType(TypeTag.NONE)
-T_STR = LamType(TypeTag.STR)
-T_INT = LamType(TypeTag.INT)
-T_FLOAT = LamType(TypeTag.FLOAT)
-T_BOOL = LamType(TypeTag.BOOL)
-
-
-def T_JSON(schema: Dict[str, Any] | None = None) -> LamType:
-    """构造 Json(S) 类型"""
-    return LamType(TypeTag.JSON, schema=schema)
-
-
-def T_TUPLE(*elements: LamType) -> LamType:
-    """构造元组类型"""
-    return LamType(TypeTag.TUPLE, elements=tuple(elements))
-
-
-def T_UNION(*members: LamType) -> LamType:
-    """构造联合类型"""
-    return LamType(TypeTag.UNION, members=frozenset(members))
-
-
-# ============================================================
-# AgentType: Agent 函数类型 (Paper III Definition 3)
-# ============================================================
-
-@dataclass(frozen=True)
-class AgentType:
-    """
-    Agent 函数类型: τ1 →^ε τ2
-
-    Paper III Definition 3:
-        每个 Agent 的类型签名是 input_type →^effect output_type
-
-    effect 在 effects.py 中定义,此处暂用字符串占位。
-    """
-    input_type: LamType
-    output_type: LamType
-    effect: str = "pure"  # 暂用字符串;P0-2 后替换为 Effect 类型
-
-    def __repr__(self) -> str:
-        eff = f"^{self.effect}" if self.effect != "pure" else ""
-        return f"{self.input_type} →{eff} {self.output_type}"
-
-
-# ============================================================
-# 子类型关系 <: (Paper III Definition 5)
-# ============================================================
-
-def is_subtype(sub: LamType, sup: LamType) -> bool:
-    """
-    子类型判断: sub <: sup
-
-    Paper III Definition 5:
-        1. ⊥ <: τ (None 是所有类型的子类型)
-        2. τ <: ⊤ (所有类型是 Any 的子类型)
-        3. τ <: τ (自反性)
-        4. Str <: Json(string) (字符串嵌入 JSON)
-        5. Int <: Float (数值提升)
-        6. Bool <: Int (布尔嵌入整数)
-        7. Json(S1) <: Json(S2) when S1 structurally subtypes S2
-           (宽度子类型: S1 有更多字段 → S1 <: S2)
-           (深度子类型: 对应字段类型 S1.f <: S2.f)
-        8. (τ1, τ2) <: (σ1, σ2) when τ1 <: σ1 ∧ τ2 <: σ2 (元组协变)
-        9. τ <: (τ | σ) (联合类型引入)
-    """
-    # ⊥ <: τ
-    if sub.tag == TypeTag.NONE:
-        return True
-
-    # τ <: ⊤
-    if sup.tag == TypeTag.ANY:
-        return True
-
-    # 自反性
-    if sub == sup:
-        return True
-
-    # τ <: (τ | σ) — 联合类型:sub 是 sup 的某个 member 的子类型
-    if sup.tag == TypeTag.UNION and sup.members:
-        return any(is_subtype(sub, m) for m in sup.members)
-
-    # (τ1 | τ2) <: σ — 联合类型的子类型:所有 member 都是 σ 的子类型
-    if sub.tag == TypeTag.UNION and sub.members:
-        return all(is_subtype(m, sup) for m in sub.members)
-
-    # Bool <: Int <: Float
-    if sub.tag == TypeTag.BOOL and sup.tag == TypeTag.INT:
-        return True
-    if sub.tag == TypeTag.BOOL and sup.tag == TypeTag.FLOAT:
-        return True
-    if sub.tag == TypeTag.INT and sup.tag == TypeTag.FLOAT:
-        return True
-
-    # Str <: Json(string)
-    if sub.tag == TypeTag.STR and sup.tag == TypeTag.JSON:
-        if sup.schema and sup.schema.get("type") == "string":
-            return True
-        # Str <: Json (untyped JSON) — 字符串可以被解析为 JSON
-        if sup.schema is None:
-            return True
+    # before
+    from lambdagent.types import LamType, T_STR, AgentType, ...
 
-    # 基础类型 <: Json(对应类型)
-    _tag_to_json_type = {
-        TypeTag.STR: "string",
-        TypeTag.INT: "integer",
-        TypeTag.FLOAT: "number",
-        TypeTag.BOOL: "boolean",
-    }
-    if sub.tag in _tag_to_json_type and sup.tag == TypeTag.JSON:
-        if sup.schema and sup.schema.get("type") == _tag_to_json_type[sub.tag]:
-            return True
-        # FIX-04: 基础类型 <: Json (无 schema = untyped JSON, 接受一切)
-        # Paper III S-StrJson / S-NumJson / S-BoolJson
-        if sup.schema is None:
-            return True
+    # after
+    from lambdagent.lam_types import LamType, T_STR, AgentType, ...
 
-    # Json(S1) <: Json(S2) — 结构子类型
-    if sub.tag == TypeTag.JSON and sup.tag == TypeTag.JSON:
-        return _json_schema_subtype(sub.schema, sup.schema)
-
-    # 元组协变: (τ1, τ2) <: (σ1, σ2)
-    if sub.tag == TypeTag.TUPLE and sup.tag == TypeTag.TUPLE:
-        if sub.elements and sup.elements:
-            if len(sub.elements) != len(sup.elements):
-                return False
-            return all(
-                is_subtype(s, t) for s, t in zip(sub.elements, sup.elements)
-            )
-
-    return False
-
-
-def _json_schema_subtype(
-    sub_schema: Optional[Dict[str, Any]],
-    sup_schema: Optional[Dict[str, Any]],
-) -> bool:
-    """
-    JSON Schema 结构子类型检查。
-
-    Paper III Definition 5 规则 7:
-        Json(S1) <: Json(S2) 当且仅当:
-          - S2 的所有 required 字段在 S1 中都存在
-          - 对应字段类型满足 S1.field <: S2.field (深度子类型)
-          - S1 可以有额外字段 (宽度子类型)
-
-    类似 TypeScript 的结构子类型。
-    """
-    # 无 schema → Any JSON → Json <: Json
-    if sup_schema is None:
-        return True
-    if sub_schema is None:
-        # 未指定的 JSON 不是有具体 schema 的子类型
-        return sup_schema is None
-
-    sub_type = sub_schema.get("type")
-    sup_type = sup_schema.get("type")
-
-    # 类型不同 → 检查 JSON 原始类型的子类型关系
-    if sub_type != sup_type:
-        # integer <: number
-        if sub_type == "integer" and sup_type == "number":
-            return True
-        return False
-
-    # object 子类型: 宽度 + 深度
-    if sup_type == "object":
-        sub_props = sub_schema.get("properties", {})
-        sup_props = sup_schema.get("properties", {})
-        sup_required = set(sup_schema.get("required", []))
-
-        # sup 的所有 required 字段必须在 sub 中存在
-        for req_field in sup_required:
-            if req_field not in sub_props:
-                return False
-
-        # 深度子类型: 公共字段类型兼容
-        for field_name, sup_field_schema in sup_props.items():
-            if field_name in sub_props:
-                if not _json_schema_subtype(sub_props[field_name], sup_field_schema):
-                    return False
-            elif field_name in sup_required:
-                return False
-            # sup 有字段但 sub 没有 + 非 required → OK (宽度子类型的逆方向,
-            # 这里 sub 少字段不影响,因为 sup 不要求该字段)
-
-        return True
-
-    # array 子类型: items 协变
-    if sup_type == "array":
-        sub_items = sub_schema.get("items", {})
-        sup_items = sup_schema.get("items", {})
-        if sub_items and sup_items:
-            return _json_schema_subtype(sub_items, sup_items)
-        return True
-
-    # 基础类型相同 → 子类型
-    return True
-
-
-# ============================================================
-# 类型检查错误
-# ============================================================
-
-class AgentTypeError(Exception):
-    """Agent 类型检查错误 — T-Compose 规则违反"""
-
-    def __init__(self, message: str, source_type: Optional[LamType] = None,
-                 target_type: Optional[LamType] = None, position: int = -1):
-        self.source_type = source_type
-        self.target_type = target_type
-        self.position = position
-        detail = ""
-        if source_type and target_type:
-            detail = f"\n  Output type: {source_type}\n  Input type:  {target_type}"
-            if position >= 0:
-                detail += f"\n  At composition boundary: step {position} >> step {position + 1}"
-        detail += "\n  Rule: Paper III T-Compose: f: A →^ε1 B, g: B' →^ε2 C requires B <: B'"
-        super().__init__(f"{message}{detail}")
-
-
-# ============================================================
-# 类型检查:T-Compose 规则 (Paper III §3.3.3)
-# ============================================================
-
-def check_compose_types(agent_types: List[AgentType]) -> AgentType:
-    """
-    T-Compose 类型检查。
-
-    Paper III §3.3.3:
-        f: A →^ε1 B,  g: B' →^ε2 C,  B <: B'
-        ─────────────────────────────────────────
-              f >> g : A →^(ε1 · ε2) C
-
-    检查链式组合中每对相邻 agent 的类型兼容性。
-    返回整个组合的类型。
-
-    Raises:
-        AgentTypeError: 类型不兼容时抛出
-    """
-    if not agent_types:
-        return AgentType(T_ANY, T_ANY)
-
-    if len(agent_types) == 1:
-        return agent_types[0]
-
-    for i in range(len(agent_types) - 1):
-        f_type = agent_types[i]
-        g_type = agent_types[i + 1]
-
-        # T-Compose: output(f) <: input(g)
-        if not is_subtype(f_type.output_type, g_type.input_type):
-            raise AgentTypeError(
-                f"Type mismatch in pipeline at step {i} >> step {i + 1}: "
-                f"{f_type.output_type} is not a subtype of {g_type.input_type}",
-                source_type=f_type.output_type,
-                target_type=g_type.input_type,
-                position=i,
-            )
-
-    # 组合结果类型: input(first) → output(last)
-    combined_effect = " · ".join(at.effect for at in agent_types if at.effect != "pure")
-    return AgentType(
-        input_type=agent_types[0].input_type,
-        output_type=agent_types[-1].output_type,
-        effect=combined_effect or "pure",
-    )
-
-
-# ============================================================
-# 类型推断辅助
-# ============================================================
-
-def parse_type_annotation(annotation: Any) -> LamType:
-    """
-    从 YAML 配置中的类型标注解析为 LamType。
-
-    支持的格式:
-        "Str"                    → T_STR
-        "Int"                    → T_INT
-        "Float"                  → T_FLOAT
-        "Bool"                   → T_BOOL
-        "Any"                    → T_ANY
-        "Json"                   → T_JSON()
-        {"type": "object", ...}  → T_JSON(schema)
-        {"type": "string"}       → T_JSON({"type": "string"})
-    """
-    if annotation is None:
-        return T_ANY
-
-    if isinstance(annotation, str):
-        _name_map = {
-            "str": T_STR, "string": T_STR,
-            "int": T_INT, "integer": T_INT,
-            "float": T_FLOAT, "number": T_FLOAT,
-            "bool": T_BOOL, "boolean": T_BOOL,
-            "any": T_ANY,
-            "json": T_JSON(),
-        }
-        lower = annotation.lower().strip()
-        if lower in _name_map:
-            return _name_map[lower]
-        # 可能是 JSON Schema 字符串
-        try:
-            parsed = json.loads(annotation)
-            if isinstance(parsed, dict):
-                return T_JSON(parsed)
-        except (json.JSONDecodeError, TypeError):
-            pass
-        return T_ANY
-
-    if isinstance(annotation, dict):
-        # JSON Schema dict
-        return T_JSON(annotation)
-
-    return T_ANY
+All symbols are re-exported here unchanged so existing imports keep working
+during the deprecation window.
+"""
+import warnings as _warnings
 
+_warnings.warn(
+    "lambdagent.types is deprecated and will be removed in 0.3.0. "
+    "Use lambdagent.lam_types instead (same symbols).",
+    DeprecationWarning,
+    stacklevel=2,
+)
 
-def infer_type_from_value(value: Any) -> LamType:
-    """从运行时值推断类型(用于调试/trace)"""
-    if isinstance(value, str):
-        return T_STR
-    elif isinstance(value, bool):
-        return T_BOOL
-    elif isinstance(value, int):
-        return T_INT
-    elif isinstance(value, float):
-        return T_FLOAT
-    elif isinstance(value, dict):
-        return T_JSON()
-    elif isinstance(value, (tuple, list)):
-        if isinstance(value, tuple):
-            return T_TUPLE(*(infer_type_from_value(v) for v in value))
-        return T_JSON({"type": "array"})
-    return T_ANY
+# Re-export everything from the new location.
+from .lam_types import *  # noqa: F401,F403

+ 7 - 2
lambdagent/validated_tool.py

@@ -56,13 +56,18 @@ class ValidatedTool(Term):
 
             if isinstance(input, dict):
                 # Extract nested "input" field if present (from ReAct JSON format)
-                if "input" in input and ("action" in input or "tool" in input):
+                # Case 1: {"tool":"X","input":{...}} or {"action":"X","input":{...}}
+                # Case 2: {"input": raw_string} — JSON parse failed fallback, unwrap
+                has_input_key = "input" in input
+                has_tool_key = "action" in input or "tool" in input
+                is_bare_input = has_input_key and len(input) == 1
+                if has_input_key and (has_tool_key or is_bare_input):
                     input = input["input"]
                     if isinstance(input, str):
                         try:
                             input = json.loads(input)
                         except (json.JSONDecodeError, ValueError):
-                            input = {"command": input} if "Bash" in self._name or "Shell" in self._name else {"input": input}
+                            input = {"command": input} if "Bash" in self._name or "Shell" in self._name else {}
                 validated = self.schema(**input) if isinstance(input, dict) else self.schema(input)
             else:
                 validated = self.schema(input=input)