Browse Source

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 4 months ago
parent
commit
ec5c067e8e
41 changed files with 2508 additions and 1421 deletions
  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__/
 __pycache__/
-*.pyc
-*.pyo
-*.egg-info/
-dist/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
 build/
 build/
+develop-eggs/
+dist/
+downloads/
+eggs/
 .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
+.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
 Parameters
 
 
 Licensor:             kenny67nju
 Licensor:             kenny67nju
-Licensed Work:        lambdagentpaas
+Licensed Work:        lambdagent
                       The Licensed Work is (c) 2025 kenny67nju.
                       The Licensed Work is (c) 2025 kenny67nju.
 Additional Use Grant: You may make production use of the Licensed Work,
 Additional Use Grant: You may make production use of the Licensed Work,
                       provided such use does not include more than 10
                       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.
 **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
 ## 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.
 **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
 YAML Config ──→ from_config() ──→ Lambda Term ──→ Runtime ──→ Result
@@ -20,16 +23,34 @@ YAML Config ──→ from_config() ──→ Lambda Term ──→ Runtime ─
 
 
 ## Installation
 ## 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 .
 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)
 **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
 ## Quick Start
 
 
 ### Python DSL
 ### Python DSL
@@ -170,7 +191,7 @@ save_context(ctx, "checkpoint.json")
 
 
 ```python
 ```python
 # Describe what you need in natural language → auto-generate YAML → compile → run
 # 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"
     -t "Research the latest Agent DSL frameworks"
 ```
 ```
 
 
@@ -234,46 +255,114 @@ python nl2agent.py "Build a research assistant that can search and analyze" \
 ## Architecture
 ## 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)
 ├── core.py                  # Term, Context, TraceEntry (base abstractions)
 ├── primitives.py            # Lam, Compose, If, Loop, Pair, Fst, Snd, Tool
 ├── primitives.py            # Lam, Compose, If, Loop, Pair, Fst, Snd, Tool
 ├── extensions.py            # Par, Route, Memory, Guard
 ├── extensions.py            # Par, Route, Memory, Guard
 ├── dataset.py               # Dataset → Lam converter
 ├── dataset.py               # Dataset → Lam converter
+├── conversation.py          # ConversationLam — history-aware Lambda
 ├── multiagent.py            # Channel, Send, Receive, SharedMemory,
 ├── multiagent.py            # Channel, Send, Receive, SharedMemory,
 │                            #   GroupChat, Handoff, AsyncPar
 │                            #   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,
 ├── skills.py                # Skill, SkillSignature, SkillPack,
 │                            #   SkillRegistry, SkillAgent, @skill
 │                            #   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,
 ├── 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
 │   ├── compiler.py          #   from_config(), build_agent() — 5 agent types
 │   ├── schema.py            #   YAML schema validation
 │   ├── schema.py            #   YAML schema validation
 │   ├── lint.py              #   Static analysis (L001-L016)
 │   ├── lint.py              #   Static analysis (L001-L016)
 │   ├── lambda_expr.py       #   Export pure Lambda notation
 │   ├── lambda_expr.py       #   Export pure Lambda notation
 │   └── errors.py            #   CompileError, SchemaError, SemanticError
 │   └── errors.py            #   CompileError, SchemaError, SemanticError
+│
+│   ── Runtime ─────────────────────────────────────────────────────
 ├── agentruntime/            # Runtime: Term × Input → Result
 ├── 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
 │   ├── 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
 │   ├── 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
 │   ├── 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
 │   └── 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
 └── 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
     └── shell_tool.py        #   Shell tool integration
 ```
 ```
 
 
@@ -292,8 +381,17 @@ lambdagent repl config.yml
 # Static analysis
 # Static analysis
 lambdagent lint config.yml
 lambdagent lint config.yml
 
 
-# Export Lambda expression
+# Export pure Lambda expression
 lambdagent lambda config.yml
 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
 ## 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 ✓
 - **S and K Combinators**: Proven Turing-complete via SKI calculus ✓
 - **Arithmetic & Logic**: ADD, MUL, AND, OR, NOT all verified at 94-100% accuracy ✓
 - **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
 ## 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`
 - `OPENAI_API_KEY`
 - `DASHSCOPE_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
 ## 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
 ## Citation
 
 
@@ -346,6 +481,6 @@ MIT License. See [LICENSE](./LICENSE).
   title={lambdagent: Lambda Calculus Agent DSL},
   title={lambdagent: Lambda Calculus Agent DSL},
   author={Qin Liu},
   author={Qin Liu},
   year={2026},
   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 Term, Context, TraceEntry
 from .core import LambdagentError, UnboundVariable, RouteError, ValidationError
 from .core import LambdagentError, UnboundVariable, RouteError, ValidationError
-from .trace import (
+from .tracing import (
     TraceStore, TraceEntry as EnhancedTraceEntry, Anomaly,
     TraceStore, TraceEntry as EnhancedTraceEntry, Anomaly,
     colorize_timeline, detect_anomalies, format_anomalies,
     colorize_timeline, detect_anomalies, format_anomalies,
     generate_flamegraph_html, save_flamegraph,
     generate_flamegraph_html, save_flamegraph,
@@ -82,7 +82,7 @@ from .rag import (
     create_rag,
     create_rag,
 )
 )
 # Paper III: Type & Effect System
 # Paper III: Type & Effect System
-from .types import (
+from .lam_types import (
     LamType, TypeTag, AgentType, AgentTypeError,
     LamType, TypeTag, AgentType, AgentTypeError,
     T_ANY, T_NONE, T_STR, T_INT, T_FLOAT, T_BOOL, T_JSON, T_TUPLE, T_UNION,
     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,
     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
 import subprocess
 from typing import Any, Dict, Optional
 from typing import Any, Dict, Optional
 
 
+from .._shell_compat import run_shell as _run_shell
+
 
 
 # ════════════════════════════════════════════════════════════
 # ════════════════════════════════════════════════════════════
 # A11: CodeSearch — semantic code search
 # 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]:
 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:
         try:
             subprocess.run([cmd, "--version"], capture_output=True, timeout=3)
             subprocess.run([cmd, "--version"], capture_output=True, timeout=3)
             return cmd
             return cmd
-        except (FileNotFoundError, subprocess.TimeoutExpired):
+        except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
             continue
             continue
     return None
     return None
 
 
@@ -325,9 +345,8 @@ def run_tests(input_val: Any) -> str:
 
 
     # Execute
     # Execute
     try:
     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
         output = result.stdout
         if result.stderr:
         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]:
 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:
         try:
             subprocess.run([cmd, "--version"], capture_output=True, timeout=5)
             subprocess.run([cmd, "--version"], capture_output=True, timeout=5)
             return cmd
             return cmd
-        except (FileNotFoundError, subprocess.TimeoutExpired):
+        except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
             continue
             continue
     return None
     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 .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
 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:
 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"),
     "KBSearch":       _make_tool("KBSearch",      kb_manage,     None, "Search knowledge base"),
     "KBList":         _make_tool("KBList",        kb_manage,     None, "List knowledge bases"),
     "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)
     # QA Agent tools (qaagent67)
     "IngestFiles":     _make_tool("IngestFiles",     ingest_files,     None, "Ingest files into knowledge base (batch, supports dir/glob/single file)"),
     "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"),
     "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),
     "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:
 def get_builtin_tool(name: str) -> Tool | None:
     """Look up a built-in tool by name. Returns None if not found."""
     """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
 Bash     λx. exec(command)  — persistent CWD, background, timeout
 Git*     λx. git(args)      — status, diff, log, commit, branch
 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
 from __future__ import annotations
 
 
@@ -13,6 +19,8 @@ import subprocess
 import threading
 import threading
 from typing import Any, Dict, Optional
 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)
 # Shared session CWD (persists across calls)
 _session_cwd = os.getcwd()
 _session_cwd = os.getcwd()
@@ -94,8 +102,8 @@ def run_bash(input_val: Any) -> str:
     if background:
     if background:
         # Background execution
         # Background execution
         try:
         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,
                 stdout=subprocess.PIPE, stderr=subprocess.PIPE,
             )
             )
             return f"[BACKGROUND] PID={proc.pid}, command='{command[:60]}'"
             return f"[BACKGROUND] PID={proc.pid}, command='{command[:60]}'"
@@ -104,8 +112,8 @@ def run_bash(input_val: Any) -> str:
 
 
     # Foreground execution
     # Foreground execution
     try:
     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,
             capture_output=True, text=True, timeout=timeout,
         )
         )
         stdout = result.stdout
         stdout = result.stdout

+ 4 - 4
lambdagent/cli/shell_tool.py

@@ -16,6 +16,8 @@ import shlex
 import subprocess
 import subprocess
 from typing import Optional
 from typing import Optional
 
 
+from .._shell_compat import run_shell as _run_shell
+
 
 
 DANGEROUS_PATTERNS = [
 DANGEROUS_PATTERNS = [
     "rm -rf /",
     "rm -rf /",
@@ -76,9 +78,8 @@ class ShellTool:
             cmd = self.command
             cmd = self.command
 
 
         try:
         try:
-            result = subprocess.run(
+            result = _run_shell(
                 cmd,
                 cmd,
-                shell=True,
                 capture_output=True,
                 capture_output=True,
                 text=True,
                 text=True,
                 timeout=self.timeout,
                 timeout=self.timeout,
@@ -124,9 +125,8 @@ class CLIAgent:
         last_error = None
         last_error = None
         for attempt in range(1 + self.retry):
         for attempt in range(1 + self.retry):
             try:
             try:
-                proc = subprocess.run(
+                proc = _run_shell(
                     self.command,
                     self.command,
-                    shell=True,
                     input=str(input_text),
                     input=str(input_text),
                     capture_output=True,
                     capture_output=True,
                     text=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
 from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
-    from .types import LamType, AgentType
+    from .lam_types import LamType, AgentType
     from .effects import Effect, ComposedEffect
     from .effects import Effect, ComposedEffect
 
 
 
 
@@ -165,7 +165,7 @@ class Term(ABC):
         """Agent 的输入类型 (Paper III Definition 3)"""
         """Agent 的输入类型 (Paper III Definition 3)"""
         if self._input_type is not None:
         if self._input_type is not None:
             return self._input_type
             return self._input_type
-        from .types import T_ANY
+        from .lam_types import T_ANY
         return T_ANY
         return T_ANY
 
 
     @input_type.setter
     @input_type.setter
@@ -177,7 +177,7 @@ class Term(ABC):
         """Agent 的输出类型 (Paper III Definition 3)"""
         """Agent 的输出类型 (Paper III Definition 3)"""
         if self._output_type is not None:
         if self._output_type is not None:
             return self._output_type
             return self._output_type
-        from .types import T_ANY
+        from .lam_types import T_ANY
         return T_ANY
         return T_ANY
 
 
     @output_type.setter
     @output_type.setter
@@ -199,7 +199,7 @@ class Term(ABC):
     @property
     @property
     def agent_type(self) -> AgentType:
     def agent_type(self) -> AgentType:
         """完整的 Agent 函数类型 τ1 →^ε τ2"""
         """完整的 Agent 函数类型 τ1 →^ε τ2"""
-        from .types import AgentType
+        from .lam_types import AgentType
         eff = self.effect
         eff = self.effect
         eff_str = repr(eff)
         eff_str = repr(eff)
         return AgentType(self.input_type, self.output_type, effect=eff_str)
         return AgentType(self.input_type, self.output_type, effect=eff_str)
@@ -224,12 +224,12 @@ class Term(ABC):
             raise TypeError("Use instances, not classes")
             raise TypeError("Use instances, not classes")
 
 
         # Paper III T-Compose: 类型检查 (仅当两端都有非 Any 类型标注时)
         # 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
         f_out = self.output_type if hasattr(self, 'stages') else self.output_type
         g_in = other.input_type
         g_in = other.input_type
         if f_out != T_ANY and g_in != T_ANY:
         if f_out != T_ANY and g_in != T_ANY:
             if not is_subtype(f_out, g_in):
             if not is_subtype(f_out, g_in):
-                from .types import AgentTypeError
+                from .lam_types import AgentTypeError
                 raise AgentTypeError(
                 raise AgentTypeError(
                     f"Type mismatch: {self._name} >> {other._name}",
                     f"Type mismatch: {self._name} >> {other._name}",
                     source_type=f_out,
                     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)
         score_raw = judge(f"Original: {source}\nBack-translated: {back}", ctx)
         try:
         try:
             score = int(score_raw.strip())
             score = int(score_raw.strip())
-        except:
+        except (ValueError, IndexError):
             score = 5
             score = 5
         print(f"  [score] {score}/10")
         print(f"  [score] {score}/10")
 
 
@@ -298,7 +298,7 @@ def demo_recursive_document():
         lines = review.strip().split("\n")
         lines = review.strip().split("\n")
         try:
         try:
             score = int(lines[0].strip().split()[0])
             score = int(lines[0].strip().split()[0])
-        except:
+        except (ValueError, IndexError):
             score = 6
             score = 6
         feedback = lines[1] if len(lines) > 1 else "No specific feedback"
         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)
             actual_raw = learner(str(x), ctx)
             try:
             try:
                 actual = int(actual_raw.strip())
                 actual = int(actual_raw.strip())
-            except:
+            except (ValueError, IndexError):
                 actual = -1
                 actual = -1
 
 
             ok = actual == expected
             ok = actual == expected

+ 1 - 1
lambdagent/examples/ex08_mcp_checkpoint.py

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

+ 230 - 27
lambdagent/fromconfig/compiler.py

@@ -13,6 +13,7 @@ v2 Optimizations:
 """
 """
 from __future__ import annotations
 from __future__ import annotations
 
 
+import ast
 import json
 import json
 import os
 import os
 import re
 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)
     # Step 0: Compile subAgents if present (multi-agent orchestrator support)
     # subAgents 节定义了子代理,编译后作为 call_* 工具注入到协调者
     # 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:
     if sub_agents_cfg and "tools" not in overrides:
         overrides = {**overrides, "tools": _compile_sub_agents(cfg, sub_agents_cfg, 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)
     verbose = react_cfg.get("verbose", False)
     agent_name = cfg.get("name", cfg.get("agentId", "agent"))
     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"])
     # Streaming callback (injected via overrides["on_step"])
     _on_step = overrides.get("on_step")
     _on_step = overrides.get("on_step")
 
 
@@ -716,8 +745,90 @@ def _compile_react(cfg: Dict, overrides: Dict) -> Term:
 
 
         # ── Phase 3: Termination check ──
         # ── Phase 3: Termination check ──
         if selected_tool is None or selected_tool._name == "terminate":
         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 ""
                 user_lower = _user_input[0].lower() if _user_input[0] else ""
                 all_obs = " ".join(e["observation"] for e in _tool_log)
                 all_obs = " ".join(e["observation"] for e in _tool_log)
                 all_inputs = " ".join(e["input"] 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
 # 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:
 def _compile_guard(agent: Term, guard_cfg: Dict) -> Term:
     """Wrap agent with Guard (dependent type: {x:T | P(x)}).
     """Wrap agent with Guard (dependent type: {x:T | P(x)}).
 
 
     Now also enforces maxOutputLength by truncating output in the validator.
     Now also enforces maxOutputLength by truncating output in the validator.
     dangerousCommandBlock and highRiskConfirmation are enforced via ToolGateway
     dangerousCommandBlock and highRiskConfirmation are enforced via ToolGateway
     (injected in build_agent → _compile_tools), not here.
     (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")
     validator_expr = guard_cfg.get("validator", "True")
     retry = guard_cfg.get("retry", 0)
     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)
     max_output_length = guard_cfg.get("maxOutputLength", 0)
 
 
     def validator_fn(x):
     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
     on_fail = None
     if fallback == "empty":
     if fallback == "empty":
@@ -1197,20 +1363,44 @@ def _extract_tool_call(thought: str, tools: Dict[str, Tool]):
     Returns (Tool, input_dict) or (None, None).
     Returns (Tool, input_dict) or (None, None).
     Priority: JSON > XML > keyword
     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:
         if result:
             return 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>
     # Try XML: <action>name</action>
     xml_action = re.search(r'<action>\s*(\w+)\s*</action>', thought)
     xml_action = re.search(r'<action>\s*(\w+)\s*</action>', thought)
     if xml_action:
     if xml_action:
@@ -1230,18 +1420,31 @@ def _extract_tool_call(thought: str, tools: Dict[str, Tool]):
     return None, None
     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")
     tool_name = data.get("action") or data.get("tool") or data.get("name")
     if not tool_name or tool_name not in tools:
     if not tool_name or tool_name not in tools:
         return None
         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
     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 typing import Any, Callable, Dict, List, Optional
 
 
 from .core import Term, Context
 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
         import os
         full_env = {**os.environ, **env}
         full_env = {**os.environ, **env}
         try:
         try:
-            result = subprocess.run(
-                command, shell=True, env=full_env,
+            result = _run_shell(
+                command, env=full_env,
                 capture_output=True, text=True, timeout=10,
                 capture_output=True, text=True, timeout=10,
             )
             )
             if result.returncode != 0 and result.stderr:
             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:
 def handle_check_agent_types(args: Dict) -> Dict:
     """I01: Type-check agent pipeline."""
     """I01: Type-check agent pipeline."""
-    from lambdagent.types import check_compose_types
+    from lambdagent.lam_types import check_compose_types
 
 
     cfg = _load_config(args)
     cfg = _load_config(args)
     term = _compile_term(cfg)
     term = _compile_term(cfg)
@@ -327,7 +327,7 @@ def handle_check_agent_types(args: Dict) -> Dict:
             f_out = getattr(stages[i], 'output_type', None)
             f_out = getattr(stages[i], 'output_type', None)
             g_in = getattr(stages[i + 1], 'input_type', None)
             g_in = getattr(stages[i + 1], 'input_type', None)
             if f_out and g_in:
             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):
                 if not is_subtype(f_out, g_in):
                     errors.append({
                     errors.append({
                         "stage": i,
                         "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.first = first
         self.second = second
         self.second = second
         # Paper III: Pair 输出类型 = (output(first), output(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)
         self._output_type = T_TUPLE(first.output_type, second.output_type)
 
 
     def apply(self, input: Any, ctx: Context | None = None) -> tuple:
     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.
 for unified multi-provider instantiation.
 """
 """
 from .base import LLMProvider, ProviderConfig, ProviderError, Message, ChatMessage, ChatResponse
 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 .claude_code_provider import ClaudeCodeProvider
 from .anthropic_provider import AnthropicProvider
 from .anthropic_provider import AnthropicProvider
 from .openai_compat_provider import OpenAICompatProvider
 from .openai_compat_provider import OpenAICompatProvider
@@ -68,7 +68,7 @@ def create_provider(provider_name: str, **kwargs) -> LLMProvider:
 __all__ = [
 __all__ = [
     "LLMProvider", "ProviderConfig", "ProviderError", "Message",
     "LLMProvider", "ProviderConfig", "ProviderError", "Message",
     "ChatMessage", "ChatResponse",
     "ChatMessage", "ChatResponse",
-    "ClaudeLam",  # backward compat for PersonalAssistant
+    "ClaudeLam",  # deprecated, removed in 0.3.0
     "ClaudeCodeProvider",
     "ClaudeCodeProvider",
     "AnthropicProvider",
     "AnthropicProvider",
     "OpenAICompatProvider",
     "OpenAICompatProvider",

+ 49 - 1
lambdagent/providers/claude_code.py

@@ -15,6 +15,7 @@ Session persistence:
 from __future__ import annotations
 from __future__ import annotations
 
 
 import json
 import json
+import os
 import subprocess
 import subprocess
 import sys
 import sys
 import time
 import time
@@ -23,10 +24,50 @@ from typing import Any, Callable, Optional
 from lambdagent.core import Term, Context
 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):
 class ClaudeLam(Term):
     """
     """
     Lambda abstraction backed by Claude Code CLI with session persistence.
     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`:
     Key improvement over stateless `-p`:
       Each apply() continues the same conversation session.
       Each apply() continues the same conversation session.
       Claude retains full memory of previous steps, file contents, etc.
       Claude retains full memory of previous steps, file contents, etc.
@@ -45,13 +86,20 @@ class ClaudeLam(Term):
         on_chunk: Callable[[str], None] | None = None,
         on_chunk: Callable[[str], None] | None = None,
         inject_override: bool = True,
         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)
         super().__init__(name)
         self.prompt = prompt
         self.prompt = prompt
         self.model = model
         self.model = model
         self.max_tokens = max_tokens
         self.max_tokens = max_tokens
         self.temperature = temperature
         self.temperature = temperature
         self.output_parser = output_parser or (lambda x: x)
         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.stream = stream
         self.on_chunk = on_chunk
         self.on_chunk = on_chunk
         self.inject_override = inject_override
         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
   - First call: creates session with system prompt, captures session_id
   - Subsequent calls: --resume <session_id> (Claude retains full memory)
   - Subsequent calls: --resume <session_id> (Claude retains full memory)
   - Falls back to messages-in-prompt if --resume unavailable
   - Falls back to messages-in-prompt if --resume unavailable
+  - Auto-detects a working claude binary across nvm versions
 """
 """
 from __future__ import annotations
 from __future__ import annotations
 
 
 import json
 import json
-import subprocess
+import logging
+import os
 import shutil
 import shutil
-from typing import Dict, List
+import subprocess
+from typing import Dict, List, Optional
 
 
 from .base import LLMProvider, ProviderConfig, ProviderError
 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):
 class ClaudeCodeProvider(LLMProvider):
     """
     """
@@ -27,13 +108,15 @@ class ClaudeCodeProvider(LLMProvider):
 
 
     def __init__(self, config: ProviderConfig):
     def __init__(self, config: ProviderConfig):
         super().__init__(config)
         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
         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(
             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:
     def chat(self, messages: List[Dict[str, str]]) -> str:
@@ -42,6 +125,22 @@ class ClaudeCodeProvider(LLMProvider):
         else:
         else:
             return self._call_new(messages)
             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:
     def _call_new(self, messages: List[Dict[str, str]]) -> str:
         """First call: create session with system prompt, capture session_id."""
         """First call: create session with system prompt, capture session_id."""
         system_prompt = ""
         system_prompt = ""
@@ -52,30 +151,73 @@ class ClaudeCodeProvider(LLMProvider):
             elif m["role"] == "user":
             elif m["role"] == "user":
                 user_content = m["content"]
                 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 = [
         cmd = [
-            self.claude_bin, "-p",
+            self.claude_bin, "-p", prompt_arg,
             "--output-format", "json",
             "--output-format", "json",
             "--model", self.config.model,
             "--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:
         try:
             result = subprocess.run(
             result = subprocess.run(
-                cmd, input=user_content,
+                cmd,
+                stdin=subprocess.DEVNULL,   # avoid 3-s stdin-wait warning
                 capture_output=True, text=True,
                 capture_output=True, text=True,
                 timeout=self.config.timeout,
                 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:
         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()
         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:
         try:
             data = json.loads(raw)
             data = json.loads(raw)
             self._session_id = data.get("session_id")
             self._session_id = data.get("session_id")
@@ -85,36 +227,109 @@ class ClaudeCodeProvider(LLMProvider):
 
 
     def _call_resume(self, messages: List[Dict[str, str]]) -> str:
     def _call_resume(self, messages: List[Dict[str, str]]) -> str:
         """Subsequent calls: resume session (Claude has full memory)."""
         """Subsequent calls: resume session (Claude has full memory)."""
-        # Only pass the latest user message — Claude remembers everything else
         last_user = ""
         last_user = ""
         for m in reversed(messages):
         for m in reversed(messages):
             if m["role"] == "user":
             if m["role"] == "user":
                 last_user = m["content"]
                 last_user = m["content"]
                 break
                 break
 
 
+        # Same ReAct constraint to keep each resume turn as a single JSON decision.
+        prompt_arg = last_user + self._REACT_CONSTRAINT
+
         cmd = [
         cmd = [
-            self.claude_bin, "-p",
+            self.claude_bin, "-p", prompt_arg,
             "--output-format", "text",
             "--output-format", "text",
             "--model", self.config.model,
             "--model", self.config.model,
             "--resume", self._session_id,
             "--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()
         output = result.stdout.strip()
         return output if output else "[no output]"
         return output if output else "[no output]"

+ 0 - 0
lambdagent/py.typed


+ 87 - 9
lambdagent/pyproject.toml

@@ -1,5 +1,5 @@
 [build-system]
 [build-system]
-requires = ["setuptools>=61.0", "wheel"]
+requires = ["setuptools>=77.0", "wheel"]
 build-backend = "setuptools.build_meta"
 build-backend = "setuptools.build_meta"
 
 
 [project]
 [project]
@@ -7,17 +7,19 @@ name = "lambdagent"
 version = "0.1.0"
 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."
 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"
 readme = "README.md"
-license = {text = "MIT"}
+license = "BUSL-1.1"
 requires-python = ">=3.10"
 requires-python = ">=3.10"
 authors = [
 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"]
 keywords = ["agent", "lambda-calculus", "dsl", "llm", "ai"]
 classifiers = [
 classifiers = [
     "Development Status :: 3 - Alpha",
     "Development Status :: 3 - Alpha",
     "Intended Audience :: Developers",
     "Intended Audience :: Developers",
     "Intended Audience :: Science/Research",
     "Intended Audience :: Science/Research",
-    "License :: OSI Approved :: MIT License",
     "Programming Language :: Python :: 3",
     "Programming Language :: Python :: 3",
     "Programming Language :: Python :: 3.10",
     "Programming Language :: Python :: 3.10",
     "Programming Language :: Python :: 3.11",
     "Programming Language :: Python :: 3.11",
@@ -30,9 +32,66 @@ dependencies = [
 ]
 ]
 
 
 [project.optional-dependencies]
 [project.optional-dependencies]
+# LLM providers
 anthropic = ["anthropic>=0.20.0"]
 anthropic = ["anthropic>=0.20.0"]
 openai = ["openai>=1.0.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]
 [project.scripts]
 lambdagent = "lambdagent.cli.main:main"
 lambdagent = "lambdagent.cli.main:main"
@@ -42,8 +101,27 @@ Homepage = "https://github.com/kenny67nju/lambdagent"
 Repository = "https://github.com/kenny67nju/lambdagent"
 Repository = "https://github.com/kenny67nju/lambdagent"
 Issues = "https://github.com/kenny67nju/lambdagent/issues"
 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 json
 import os
 import os
 import platform
 import platform
-import resource
 import signal
 import signal
 import subprocess
 import subprocess
 import sys
 import sys
@@ -43,6 +42,28 @@ import traceback
 from dataclasses import dataclass, field
 from dataclasses import dataclass, field
 from typing import Any, Callable, Dict, List, Optional, Set
 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
 from .core import Term, Context, ValidationError
 
 
 
 
@@ -147,9 +168,16 @@ class ResourceLimiter:
 
 
     _subprocess_blocked = False
     _subprocess_blocked = False
 
 
+    def __new__(cls):
+        if not _SANDBOX_AVAILABLE:
+            raise NotImplementedError(_SANDBOX_UNAVAILABLE_MSG)
+        return super().__new__(cls)
+
     @staticmethod
     @staticmethod
     def apply(policy: SandboxPolicy):
     def apply(policy: SandboxPolicy):
         """Apply resource limits in current process. Call after fork."""
         """Apply resource limits in current process. Call after fork."""
+        if not _SANDBOX_AVAILABLE:
+            raise NotImplementedError(_SANDBOX_UNAVAILABLE_MSG)
         # CPU time limit
         # CPU time limit
         if policy.cpu_time > 0:
         if policy.cpu_time > 0:
             soft = int(policy.cpu_time)
             soft = int(policy.cpu_time)
@@ -275,6 +303,8 @@ class SandboxedTool(Term):
 
 
     def __init__(self, name: str, fn: Callable, policy: Optional[SandboxPolicy] = None,
     def __init__(self, name: str, fn: Callable, policy: Optional[SandboxPolicy] = None,
                  description: str = ""):
                  description: str = ""):
+        if not _SANDBOX_AVAILABLE:
+            raise NotImplementedError(_SANDBOX_UNAVAILABLE_MSG)
         super().__init__(name)
         super().__init__(name)
         self.fn = fn
         self.fn = fn
         self.policy = policy or SandboxPolicy.default()
         self.policy = policy or SandboxPolicy.default()
@@ -441,6 +471,8 @@ class SecureExecutor:
     """
     """
 
 
     def __init__(self, default_policy: Optional[SandboxPolicy] = None):
     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.default_policy = default_policy or SandboxPolicy.default()
         self._stats = {
         self._stats = {
             "sandboxed_calls": 0,
             "sandboxed_calls": 0,

+ 25 - 2
lambdagent/tests/test_builtin_tools.py

@@ -277,13 +277,36 @@ class TestBash:
         assert "TIMEOUT" in result
         assert "TIMEOUT" in result
 
 
     def test_cd_persistent(self):
     def test_cd_persistent(self):
+        import tempfile
         from lambdagent.builtin_tools.shell_tools import run_bash, _get_cwd
         from lambdagent.builtin_tools.shell_tools import run_bash, _get_cwd
         original = _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
         # Restore
         run_bash({"command": f"cd {original}"})
         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):
     def test_interactive_rejected(self):
         from lambdagent.builtin_tools.shell_tools import run_bash
         from lambdagent.builtin_tools.shell_tools import run_bash
         with pytest.raises(ValueError, match="Interactive"):
         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):
     def test_lam_has_type_properties(self):
         agent = Lam("typed", "prompt")
         agent = Lam("typed", "prompt")
         # Default types are T_ANY
         # 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.input_type == T_ANY
         assert agent.output_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:
 class TestGuardCore:
     def test_runtime_monitor_cost_tracking(self):
     def test_runtime_monitor_cost_tracking(self):
         from lambdagent_guard.core import RuntimeMonitor, GuardConfig
         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):
     def test_handler_preserves_types(self):
         """Handler switching preserves type annotations (Paper III Theorem)"""
         """Handler switching preserves type annotations (Paper III Theorem)"""
         lam = Lam("typed", "Process input")
         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.input_type = T_STR
         lam.output_type = T_JSON({"type": "object"})
         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:
 class TestParTrueParallel:
     def test_par_runs_parallel(self):
     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
         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())
         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):
     def test_par_single_agent(self):
         from lambdagent.extensions import Par
         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."""
         """create_provider('ollama') returns OpenAICompatProvider with localhost URL."""
         provider = create_provider("ollama")
         provider = create_provider("ollama")
         self.assertIsInstance(provider, OpenAICompatProvider)
         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")
         self.assertEqual(provider.provider_name, "ollama")
 
 
     def test_create_openai_provider(self):
     def test_create_openai_provider(self):

+ 1 - 1
lambdagent/tests/test_types.py

@@ -10,7 +10,7 @@ Tests cover:
 """
 """
 
 
 import pytest
 import pytest
-from lambdagent.types import (
+from lambdagent.lam_types import (
     LamType, TypeTag, AgentType, AgentTypeError,
     LamType, TypeTag, AgentType, AgentTypeError,
     T_ANY, T_NONE, T_STR, T_INT, T_FLOAT, T_BOOL, T_JSON, T_TUPLE, T_UNION,
     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,
     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):
             if isinstance(input, dict):
                 # Extract nested "input" field if present (from ReAct JSON format)
                 # 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"]
                     input = input["input"]
                     if isinstance(input, str):
                     if isinstance(input, str):
                         try:
                         try:
                             input = json.loads(input)
                             input = json.loads(input)
                         except (json.JSONDecodeError, ValueError):
                         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)
                 validated = self.schema(**input) if isinstance(input, dict) else self.schema(input)
             else:
             else:
                 validated = self.schema(input=input)
                 validated = self.schema(input=input)