ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.sandbox_packages/
|
||||
# 运行时由 Agent 自动生成的工具(演示产物),不纳入版本库
|
||||
tool_library/*.json
|
||||
@@ -0,0 +1,338 @@
|
||||
## English
|
||||
|
||||
# Supplementary Case: Agent Finds and Validates Tools from the Web (Alita Style)
|
||||
|
||||
> Companion code for "Deep Understanding of AI Agents" · ★★★
|
||||
> Core concept: **"Minimum predefined, maximum self-evolution"**.
|
||||
|
||||
## Purpose
|
||||
|
||||
The capability ceiling of most agents is determined by "human-predefined tools". This experiment takes the opposite approach: the agent **has no domain-specific tools predefined**, only five generic "meta-tools". When it encounters a task it cannot handle, it will search the web for **open-source libraries/APIs**, **read documentation**, **test in a sandbox**, **package the viable solution as a new tool and store it in the tool library**, and then use the new tool to complete the task—evolving like Alita. When encountering a similar task again, it will first **reuse** the already-built tool from the library instead of reinventing the wheel.
|
||||
|
||||
The entire process emphasizes **hallucination control**: all numbers and conclusions must come from real search results, documentation, or code execution output.
|
||||
|
||||
## Five Base Tools (No Domain-Specific Tools)
|
||||
|
||||
| Tool | Purpose | Implementation |
|
||||
| --- | --- | --- |
|
||||
| `web_search` | Search for open-source libraries / APIs | DuckDuckGo, **no API key required** (lite + html dual endpoints, with backoff retry) |
|
||||
| `read_webpage` | Read README / API documentation | requests + BeautifulSoup to extract main text |
|
||||
| `code_interpreter` | Actually execute code in a sandbox to verify the solution | **Subprocess sandbox** + timeout; can `pip_install` to a temporary directory |
|
||||
| `create_tool` | Package a verified function as a standard tool and persist it | Write to `tool_library/<name>.json` (metadata + code) |
|
||||
| `search_tools` | Search the tool library by name/description for **reuse** | Keyword matching |
|
||||
|
||||
## Self-Evolution Pipeline
|
||||
|
||||
```text
|
||||
Analyze task
|
||||
→ search_tools (first check if a reusable tool already exists in the library)
|
||||
Hit ─────────────────► Directly call that tool to answer (tool reuse)
|
||||
Miss ↓
|
||||
→ web_search find open-source Python libraries requiring no API key
|
||||
→ read_webpage read README / PyPI documentation
|
||||
→ code_interpreter actually run in sandbox, print real data (can pip install dependencies)
|
||||
→ create_tool package as a "generic, parameterized" standard tool
|
||||
└─ Pre-save validation: syntax compilation + actually run run() once with test_args, only register if it passes
|
||||
→ Call the new tool, answer with real data
|
||||
```
|
||||
|
||||
To suppress hallucinations and "laziness", several **guardrails** are built into the code:
|
||||
|
||||
- Without `code_interpreter` printing real data, `create_tool` is **prohibited**;
|
||||
- If the code for `create_tool` contains words like `mock / simulated / sample data / fake`, it is **rejected** from the library;
|
||||
- When real data has been verified but the agent tries to skip packaging and answer directly, it is forced to `create_tool` first;
|
||||
- A tool in the library must first be hit by `search_tools` (or just created) to be "unlocked" as callable—thus enforcing the "retrieve before reuse" flow.
|
||||
|
||||
There is also a **"pre-save validation" gate** (corresponding to the "Test" step in the pipeline of Figure 8-7, and also addressing the chapter's warning about "tool quality degradation"—bad tools propagate errors to subsequent tasks through reuse): before `create_tool` persists the tool to disk, it will
|
||||
|
||||
- First perform a **syntax compilation check**; code with syntax errors is blocked from the library;
|
||||
- If the caller provides `test_args` (a set of example input parameters), it will **actually run `run(**test_args)` once** in the sandbox; only if it successfully returns a result is it allowed into the library. The system prompt requires the model to provide `test_args` when creating a tool, thus keeping "broken tools that don't run" out of the tool library, rather than waiting for them to crash when reused by a subsequent task.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
# From the repository root: use the shared Chapter 8 environment
|
||||
uv sync --locked --python 3.12 --extra ch8
|
||||
# Apple Silicon macOS needs macOS 14+ for the locked bitsandbytes wheel;
|
||||
# older macOS users should use the single-project compatibility path below.
|
||||
|
||||
# Activate it before changing directories:
|
||||
# macOS/Linux:
|
||||
source .venv/bin/activate
|
||||
# Windows PowerShell: .\.venv\Scripts\Activate.ps1
|
||||
# Windows cmd: .venv\Scripts\activate.bat
|
||||
|
||||
# pip fallback when uv is not installed:
|
||||
# python -m pip install -e ".[ch8]"
|
||||
|
||||
cd chapter8/self-evolving-tools
|
||||
|
||||
# Single-project compatibility path, still supported during migration:
|
||||
# python -m pip install -r requirements.txt
|
||||
|
||||
cp env.example .env # Fill in OPENAI_API_KEY (default model gpt-5.6-luna)
|
||||
# Fallback: if no OPENAI_API_KEY but OPENROUTER_API_KEY is set, automatically switch to OpenRouter (maps to openai/gpt-5.6-luna, etc.)
|
||||
python demo.py # Run the two default "evolution + reuse" tasks (requires API + internet)
|
||||
python demo.py --fresh # Clear tool_library/ first, then run, reproducing "evolution from scratch" (recommended for repeated demos)
|
||||
python demo.py --offline # Offline mechanism self-check: no API/network required, verify the evolution loop itself
|
||||
python demo.py --help # View all parameters
|
||||
```
|
||||
|
||||
**Command-line arguments** (Chinese `--help`):
|
||||
|
||||
| Argument | Purpose |
|
||||
| --- | --- |
|
||||
| `--task task description` | Custom task; can be repeated multiple times to run several tasks in sequence. If not given, runs the default NVDA/AAPL two tasks |
|
||||
| `--offline` | Offline mechanism self-check: does not call LLM/network, directly drives the "search miss → create tool → pre-save validation → register → reuse" loop |
|
||||
| `--fresh` | Clears `tool_library/` before running, reproducing "evolution from scratch" |
|
||||
| `--no-create` | Disables the ability to create tools (removes `create_tool`), used for comparison demos showing "without evolution ability, can only reuse/cannot complete" |
|
||||
| `--model model name` | Override the LLM model name (higher priority than the `LLM_MODEL` environment variable) |
|
||||
| `--output path` | Write tasks, answers, action traces, and reuse conclusions to this JSON file |
|
||||
|
||||
> The tool library is **persisted** to `tool_library/`. If `get_stock_price` was already packaged in a previous run, running again directly will have task one hit `search_tools` and reuse it at step 0, so you won't see the full "evolution" process; add `--fresh` to reproduce evolution.
|
||||
|
||||
**Without an API key / no internet access**, use `python demo.py --offline` for a mechanism self-check—it uses a purely offline, deterministic tool (calculating the number of days between two dates) to run through the complete loop: task one `search_tools` miss → `create_tool` (with pre-save validation) → register → call; task two `search_tools` hit → **direct reuse**, no reinventing the wheel; and additionally demonstrates that the pre-save validation gate will reject a "broken tool that doesn't run" from entering the library. The self-check runs in a temporary directory and **will not pollute** your real `tool_library/`. A real offline run output:
|
||||
|
||||
```text
|
||||
[Validation Gate] Attempting to register a broken tool that will crash (with test_args)...
|
||||
Result: success=False -> Tool registration pre-validation failed: run(**test_args) did not return successfully...
|
||||
✅ Pre-save validation blocked the broken tool (not stored), consistent with 'Don't save bad programs'.
|
||||
[step 1] search_tools -> 0 hits (tool library empty, no hit)
|
||||
[step 2] create_tool(days_between) -> success=True validated=True (pre-save validation actually ran run() once)
|
||||
[step 3] days_between(...) -> {'start': '2020-01-01', 'end': '2020-03-01', 'days': 60}
|
||||
[step 1] search_tools -> 1 hit: ['days_between'] (reuse!)
|
||||
[step 2] days_between(...) -> {'start': '2021-01-01', 'end': '2021-12-31', 'days': 364}
|
||||
Task one trace: ['search_tools', 'create_tool', 'days_between']
|
||||
Task two trace: ['search_tools', 'days_between']
|
||||
Did task two reuse the tool created by task one (did not re-create_tool): Yes ✅
|
||||
Did the pre-save validation gate block the broken tool: Yes ✅
|
||||
```
|
||||
|
||||
`demo.py` will run two tasks consecutively:
|
||||
|
||||
1. **NVDA** (demonstrates evolution): Starting from zero base tools, search → read documentation → sandbox test → package `get_stock_price` tool → provide NVIDIA's real stock price and weekly change.
|
||||
2. **AAPL** (demonstrates reuse): `search_tools` hits the just-created `get_stock_price`, **directly reuses** it, no re-searching/creating.
|
||||
|
||||
> You can also switch to other OpenAI-compatible providers: `LLM_PROVIDER=moonshot|ark` (with corresponding `MOONSHOT_API_KEY` / `ARK_API_KEY`), or use `LLM_MODEL` to override the model name. Search uses DuckDuckGo, no search key required.
|
||||
|
||||
## A Real Run Trace (Excerpt, Real Internet + Real OpenAI Calls)
|
||||
|
||||
**Task One · NVDA** (Self-Evolution, Note Error Recovery):
|
||||
|
||||
```text
|
||||
[step 1] search_tools("stock price") -> 0 hits (tool library empty)
|
||||
[step 2] web_search("open source python library stock price") -> yfinance · PyPI ...
|
||||
[step 3] read_webpage(pypi.org/project/yfinance) / github.com/ranaroussi/yfinance
|
||||
[step 4] code_interpreter(...) -> stdout empty, note: "No real data printed, not considered verification passed"
|
||||
[step 5] code_interpreter(...) -> "Latest stock price: 205.91..., Change: 1.54" ← Real data, verification passed
|
||||
[step 6] create_tool("get_stock_price", parameterized ticker/period, internally calls yfinance)
|
||||
[step 7] get_stock_price(ticker="NVDA") -> {latest_price: 205.71, change_percentage: 1.44}
|
||||
[Final Answer] NVIDIA (NVDA) latest stock price $205.71, +1.44% compared to one week ago. Data source: yfinance.
|
||||
```
|
||||
|
||||
**Task Two · AAPL** (Tool Reuse, No Re-Searching/Creating):
|
||||
|
||||
```text
|
||||
[step 1] search_tools("stock price") -> hit get_stock_price (reuse!)
|
||||
[step 2] get_stock_price(ticker="AAPL") -> {latest_price: 330.48, change_percentage: 4.51}
|
||||
[Final Answer] Apple (AAPL) latest stock price $330.48, +4.51% compared to one week ago.
|
||||
Task two trace = ['search_tools', 'get_stock_price'] → No web_search / create_tool ✅ Reuse confirmed
|
||||
```
|
||||
|
||||
(Numbers change in real-time with market data, different each run; above is the result of one real run.)
|
||||
|
||||
## Conclusion
|
||||
|
||||
- Starting from **zero domain tools**, with only five meta-tools, the agent autonomously discovered `yfinance`, packaged a generic `get_stock_price` tool, and provided **real** stock prices and changes.
|
||||
- The second task **hit and reused** the already-built tool via `search_tools`, without re-searching/reinventing—the tool library makes the agent "stronger with use".
|
||||
- Empty output reminder + anti-mock guard + "verify before package" effectively **suppressed hallucinations**: in one successful run, the model's first test code forgot to `print`, was reminded by the note, self-corrected, and ultimately answered based on real execution results.
|
||||
|
||||
## Regarding Task One in the Book (YouTube Subtitles)
|
||||
|
||||
The book's "Task One: YouTube subtitle understanding, answer 100000000" depends on `youtube-transcript-api` + a specific video. Internet connectivity, access controls, or video takedowns can cause instability, so this repository **uses a reliably reproducible real-time financial task to actually verify the mechanism**.
|
||||
To reproduce the YouTube scenario, the same pipeline applies: let the agent `web_search` find `youtube-transcript-api` → read documentation → sandbox test → `create_tool` to package a subtitle fetching tool.
|
||||
|
||||
## ⚠️ Security Boundary Reminder (Must Read)
|
||||
|
||||
This experiment **executes model-generated code** and **installs third-party packages from the network**, which inherently carries risks:
|
||||
|
||||
- **Supply chain risk**: `code_interpreter` will `pip install` the package selected by the model. In real/production environments, package sources must have **whitelisting/auditing/pinned versions and hashes** to guard against typosquatting and malicious packages.
|
||||
- **Code execution isolation**: The sandbox here is only **demonstration-grade** (subprocess isolation + timeout), **not a security sandbox**. Production environments should use containers / gVisor / seccomp / network-namespace isolation / read-only filesystems / resource limits for strong isolation, and ideally disable networking or only allow whitelisted domains.
|
||||
- **Self-evolving tool library requires human review**: Tools persisted by `create_tool` will be reused by subsequent tasks, effectively turning "model-written code" into a permanent capability. It is recommended to perform manual/automated review of tools entering the library, and record sources and audit logs.
|
||||
- This directory by default includes `tool_library/*.json` and `.sandbox_packages/` in `.gitignore` (runtime artifacts).
|
||||
|
||||
---
|
||||
|
||||
## 中文
|
||||
|
||||
# 补充案例:Agent 从网络寻找并验证工具(Alita 式)
|
||||
|
||||
> 《深入理解 AI Agent》配套代码 · ★★★
|
||||
> 核心理念:**「最小预定义,最大自我进化」**。
|
||||
|
||||
## 目的
|
||||
|
||||
大多数 Agent 的能力上限由「人类预先写好的工具」决定。本实验反其道而行之:Agent **不预置任何领域工具**,
|
||||
只有五个通用的「元工具」。当它遇到自己不会做的任务时,会自己上网**寻找开源库 / API**、**阅读文档**、
|
||||
**在沙箱里测试**、把可行方案**封装成新工具存入工具库**,然后用新工具完成任务——像 Alita 一样自我进化。
|
||||
再次遇到同类任务时,它会先在工具库里**复用**已造好的工具,而不是重新造轮子。
|
||||
|
||||
全程强调**幻觉控制**:所有数字与结论必须来自真实的搜索结果、文档或代码执行输出。
|
||||
|
||||
## 五个基础工具(没有任何领域工具)
|
||||
|
||||
| 工具 | 作用 | 实现 |
|
||||
| --- | --- | --- |
|
||||
| `web_search` | 搜索开源库 / API | DuckDuckGo,**无需 key**(lite + html 双端点,带退避重试) |
|
||||
| `read_webpage` | 阅读 README / API 文档 | requests + BeautifulSoup 抽取正文 |
|
||||
| `code_interpreter` | 沙箱里真实执行代码验证方案 | **子进程沙箱** + 超时;可 `pip_install` 到临时目录 |
|
||||
| `create_tool` | 把验证过的功能封装为标准工具并持久化 | 写入 `tool_library/<name>.json`(元数据 + 代码) |
|
||||
| `search_tools` | 从工具库按名称/描述检索,用于**复用** | 关键词匹配 |
|
||||
|
||||
## 自我进化流水线
|
||||
|
||||
```text
|
||||
分析任务
|
||||
→ search_tools(先查工具库是否已有可复用工具)
|
||||
命中 ─────────────────► 直接调用该工具作答(工具复用)
|
||||
未命中 ↓
|
||||
→ web_search 找无需 key 的开源 Python 库
|
||||
→ read_webpage 读 README / PyPI 文档
|
||||
→ code_interpreter 在沙箱里真跑,print 出真实数据(可 pip 安装依赖)
|
||||
→ create_tool 封装为「通用、参数化」的标准工具
|
||||
└─ 存前验证:语法编译 + 用 test_args 真跑一次 run(),通过才注册入库
|
||||
→ 调用新工具,用真实数据作答
|
||||
```
|
||||
|
||||
为抑制幻觉与「偷懒」,代码里内置了几道**守卫**:
|
||||
|
||||
- 未用 `code_interpreter` 打印出真实数据前,**禁止** `create_tool`;
|
||||
- `create_tool` 的代码若含 `mock / 模拟 / 示例数据 / fake` 等字样,**拒绝**入库;
|
||||
- 已验证真实数据却想跳过封装直接作答时,强制提醒先 `create_tool`;
|
||||
- 工具库里的工具需先经 `search_tools` 命中(或刚创建)才「解锁」为可调用——从而强制「先检索复用」的流程。
|
||||
|
||||
还有一道**「存前验证」闸门**(对应图 8-7 流水线里的「测试」一步,也回应本章「工具质量退化」的告诫——
|
||||
坏工具会通过复用把错误传播到后续任务):`create_tool` 在把工具落盘之前会
|
||||
|
||||
- 先做**语法编译检查**,语法有误的代码一律挡在库外;
|
||||
- 若调用方给了 `test_args`(一组示例入参),就在沙箱里**真跑一次 `run(**test_args)`**,
|
||||
只有成功返回结果才准入库。系统提示词要求模型造工具时一并给出 `test_args`,从而把
|
||||
「跑不通的坏工具」挡在工具库门外,而不是等它被后续任务复用时才崩。
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
# 从仓库根目录开始:使用共享的第 8 章环境
|
||||
uv sync --locked --python 3.12 --extra ch8
|
||||
# Apple Silicon macOS 需要 macOS 14+(锁文件中的 bitsandbytes wheel 要求);
|
||||
# 更早的 macOS 请使用下方单项目兼容路径。
|
||||
|
||||
# 切换目录前先激活环境:
|
||||
# macOS/Linux:
|
||||
source .venv/bin/activate
|
||||
# Windows PowerShell: .\.venv\Scripts\Activate.ps1
|
||||
# Windows cmd: .venv\Scripts\activate.bat
|
||||
|
||||
# 未安装 uv 时可用 pip 兜底:
|
||||
# python -m pip install -e ".[ch8]"
|
||||
|
||||
cd chapter8/self-evolving-tools
|
||||
|
||||
# 迁移期间仍支持单项目兼容路径:
|
||||
# python -m pip install -r requirements.txt
|
||||
|
||||
cp env.example .env # 填入 OPENAI_API_KEY(默认模型 gpt-5.6-luna)
|
||||
# 兜底:若无 OPENAI_API_KEY 但设置了 OPENROUTER_API_KEY,自动改走 OpenRouter(映射到 openai/gpt-5.6-luna 等)
|
||||
python demo.py # 跑「进化 + 复用」两个默认任务(需 API + 联网)
|
||||
python demo.py --fresh # 先清空 tool_library/ 再跑,重现「从零进化」(重复演示时推荐)
|
||||
python demo.py --offline # 离线机制自检:无需 API/网络,验证进化闭环本身
|
||||
python demo.py --help # 查看全部参数
|
||||
```
|
||||
|
||||
**命令行参数**(Chinese `--help`):
|
||||
|
||||
| 参数 | 作用 |
|
||||
| --- | --- |
|
||||
| `--task 任务描述` | 自定义任务;可重复多次以按顺序运行多个任务。不给则跑默认的 NVDA/AAPL 两任务 |
|
||||
| `--offline` | 离线机制自检:不调用 LLM/网络,直接驱动「搜索未命中→造工具→存前验证→注册→复用」闭环 |
|
||||
| `--fresh` | 运行前清空 `tool_library/`,重现「从零进化」 |
|
||||
| `--no-create` | 禁用造工具能力(移除 `create_tool`),用于对照演示「没有进化能力时只能复用/无法完成」 |
|
||||
| `--model 模型名` | 覆盖 LLM 模型名(优先级高于 `LLM_MODEL` 环境变量) |
|
||||
| `--output 路径` | 把任务、答案、动作轨迹与复用结论写入该 JSON 文件 |
|
||||
|
||||
> 工具库会**持久化**到 `tool_library/`。若上一轮已封装出 `get_stock_price`,再次直接运行时任务一会在第 0 步
|
||||
> 就 `search_tools` 命中并复用它,从而看不到"进化"全过程;想重现进化请加 `--fresh`。
|
||||
|
||||
**无 API key / 无法联网时**,用 `python demo.py --offline` 做机制自检——它用一个纯离线、确定性的工具
|
||||
(计算两个日期相差的天数)跑通完整闭环:任务一 `search_tools` 未命中→`create_tool`(含存前验证)→注册→调用;
|
||||
任务二 `search_tools` 命中→**直接复用**,不再造轮子;并额外演示存前验证闸门会拒绝一个「跑不通的坏工具」入库。
|
||||
自检在临时目录里进行,**不会污染**你真实的 `tool_library/`。一次真实的离线运行输出:
|
||||
|
||||
```text
|
||||
[验证闸门] 尝试注册一个运行会崩溃的坏工具(附 test_args)...
|
||||
结果: success=False -> 工具注册前验证失败:run(**test_args) 没有成功返回...
|
||||
✅ 存前验证挡住了坏工具(未入库),符合『别把坏程序存进去』。
|
||||
[step 1] search_tools -> 命中 0 个(工具库为空,未命中)
|
||||
[step 2] create_tool(days_between) -> success=True validated=True(存前验证已真跑一次 run())
|
||||
[step 3] days_between(...) -> {'start': '2020-01-01', 'end': '2020-03-01', 'days': 60}
|
||||
[step 1] search_tools -> 命中 1 个:['days_between'](复用!)
|
||||
[step 2] days_between(...) -> {'start': '2021-01-01', 'end': '2021-12-31', 'days': 364}
|
||||
任务一轨迹: ['search_tools', 'create_tool', 'days_between']
|
||||
任务二轨迹: ['search_tools', 'days_between']
|
||||
任务二是否复用了任务一造的工具(未重新 create_tool): 是 ✅
|
||||
存前验证闸门是否挡住了坏工具: 是 ✅
|
||||
```
|
||||
|
||||
`demo.py` 会连续跑两个任务:
|
||||
|
||||
1. **NVDA**(演示进化):从零基础工具出发,搜索→读文档→沙箱测试→封装 `get_stock_price` 工具→给出 NVIDIA 真实股价与周涨跌幅。
|
||||
2. **AAPL**(演示复用):`search_tools` 命中刚创建的 `get_stock_price`,**直接复用**,不再重新搜索/创建。
|
||||
|
||||
> 也可切换到其它 OpenAI 兼容供应商:`LLM_PROVIDER=moonshot|ark`(配合对应的 `MOONSHOT_API_KEY` / `ARK_API_KEY`),
|
||||
> 或用 `LLM_MODEL` 覆盖模型名。搜索用 DuckDuckGo,不需要任何搜索 key。
|
||||
|
||||
## 一次真实运行的轨迹(节选,真实联网 + 真实调用 OpenAI)
|
||||
|
||||
**任务一 · NVDA**(自我进化,注意错误恢复):
|
||||
|
||||
```text
|
||||
[step 1] search_tools("stock price") -> 命中 0 个(工具库为空)
|
||||
[step 2] web_search("open source python library stock price") -> yfinance · PyPI ...
|
||||
[step 3] read_webpage(pypi.org/project/yfinance) / github.com/ranaroussi/yfinance
|
||||
[step 4] code_interpreter(...) -> stdout 为空,note: “没有 print 出真实数据,不算验证通过”
|
||||
[step 5] code_interpreter(...) -> "最新股价: 205.91..., 涨跌幅: 1.54" ← 真实数据,验证通过
|
||||
[step 6] create_tool("get_stock_price", 参数化 ticker/period, 内部真调 yfinance)
|
||||
[step 7] get_stock_price(ticker="NVDA") -> {latest_price: 205.71, change_percentage: 1.44}
|
||||
[最终回答] NVIDIA(NVDA) 最新股价 205.71 美元,与一周前相比 +1.44%。数据来源 yfinance。
|
||||
```
|
||||
|
||||
**任务二 · AAPL**(工具复用,未重新搜索/创建):
|
||||
|
||||
```text
|
||||
[step 1] search_tools("stock price") -> 命中 get_stock_price(复用!)
|
||||
[step 2] get_stock_price(ticker="AAPL") -> {latest_price: 330.48, change_percentage: 4.51}
|
||||
[最终回答] Apple(AAPL) 最新股价 330.48 美元,与一周前相比 +4.51%。
|
||||
任务二轨迹 = ['search_tools', 'get_stock_price'] → 没有 web_search / create_tool ✅ 复用成立
|
||||
```
|
||||
|
||||
(数字随行情实时变化,每次运行不同;上面是某次真实运行的结果。)
|
||||
|
||||
## 结论
|
||||
|
||||
- Agent 从**零领域工具**出发,仅凭五个元工具,就自主发现了 `yfinance`、封装出通用 `get_stock_price` 工具,并给出**真实**股价与涨跌幅。
|
||||
- 第二个任务通过 `search_tools` **命中并复用**了已造好的工具,未重复搜索/造轮子——工具库让 Agent「越用越强」。
|
||||
- 空输出提醒 + 反 mock 守卫 + 「先验证再封装」有效**抑制了幻觉**:一次跑通中,模型第一次测试代码忘了 `print`,被 note 提醒后自行修正,最终基于真实执行结果作答。
|
||||
|
||||
## 关于书中任务一(YouTube 字幕)
|
||||
|
||||
书中「任务一:YouTube 字幕理解,答案 100000000」依赖 `youtube-transcript-api` + 特定视频,联网/风控/视频下架都可能导致不稳定,故本仓库**用可稳定复现的实时金融任务来实际验证机制**。
|
||||
若要复现 YouTube 场景,同一套流水线适用:让 Agent `web_search` 找到 `youtube-transcript-api` → 读文档 → 沙箱测试 → `create_tool` 封装字幕抓取工具即可。
|
||||
|
||||
## ⚠️ 安全边界提醒(务必阅读)
|
||||
|
||||
本实验会**执行模型生成的代码**并**从网络安装第三方包**,天然带有风险:
|
||||
|
||||
- **供应链风险**:`code_interpreter` 会 `pip install` 模型选中的包。真实/生产环境必须对包来源做**白名单/审计/固定版本与哈希**,谨防拼写抢注(typosquatting)与恶意包。
|
||||
- **代码执行隔离**:这里的沙箱仅为**演示级**(子进程隔离 + 超时),**不是安全沙箱**。生产环境应使用容器 / gVisor / seccomp / 无网络命名空间 / 只读文件系统 / 资源限额等强隔离,并最好断网或仅放通白名单域名。
|
||||
- **自进化工具库需人审**:`create_tool` 落盘的工具会被后续任务反复复用,等于把「模型写的代码」变成常驻能力。建议对入库工具做人工/自动审查,并记录来源与审计日志。
|
||||
- 本目录默认把 `tool_library/*.json` 与 `.sandbox_packages/` 纳入 `.gitignore`(运行时产物)。
|
||||
@@ -0,0 +1,368 @@
|
||||
"""
|
||||
自我进化 Agent(Alita 式)。
|
||||
|
||||
只预定义五个基础工具:
|
||||
web_search / read_webpage / code_interpreter / create_tool / search_tools
|
||||
没有任何领域工具。Agent 必须:
|
||||
分析任务 → 识别能力缺口 → web_search 找库/API → read_webpage 读文档
|
||||
→ code_interpreter 沙箱测试 → create_tool 封装入库 → 用新工具完成任务。
|
||||
再次遇到同类任务时,应先 search_tools 复用已有工具,而非重新搜索创建。
|
||||
|
||||
模型:OpenAI SDK,默认 gpt-5.6-luna,function calling。
|
||||
可通过 LLM_PROVIDER=openai|moonshot|ark 切换(三者均为 OpenAI 兼容接口);
|
||||
若对应 Key 缺失但设置了 OPENROUTER_API_KEY,则自动改走 OpenRouter 兜底。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
import base_tools
|
||||
from tool_manager import ToolLibrary, normalize_schema
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# LLM 客户端(OpenAI / Moonshot / ARK 都是 OpenAI 兼容接口)
|
||||
# --------------------------------------------------------------------------- #
|
||||
_PROVIDERS = {
|
||||
"openai": ("OPENAI_API_KEY", None, "gpt-5.6-luna"),
|
||||
"moonshot": ("MOONSHOT_API_KEY", "https://api.moonshot.cn/v1", "kimi-k3"),
|
||||
"ark": ("ARK_API_KEY", "https://ark.cn-beijing.volces.com/api/v3", "doubao-seed-1-6-250615"),
|
||||
}
|
||||
|
||||
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||||
|
||||
|
||||
def _to_openrouter_model(model: str) -> str:
|
||||
"""把常见模型名映射到 OpenRouter 命名空间。"""
|
||||
if not model:
|
||||
return "openai/gpt-5.6-luna"
|
||||
if "/" in model:
|
||||
return model
|
||||
if model.startswith("gpt-"):
|
||||
return "openai/" + model
|
||||
if model.startswith("claude-"):
|
||||
return "anthropic/claude-opus-4.8"
|
||||
return "openai/gpt-5.6-luna"
|
||||
|
||||
|
||||
def build_client():
|
||||
provider = os.environ.get("LLM_PROVIDER", "openai").lower()
|
||||
key_env, base_url, default_model = _PROVIDERS.get(provider, _PROVIDERS["openai"])
|
||||
model = os.environ.get("LLM_MODEL", default_model)
|
||||
api_key = os.environ.get(key_env)
|
||||
# 统一兜底:provider 自己的 Key 缺失,但有 OPENROUTER_API_KEY 时改走 OpenRouter
|
||||
if not api_key and os.environ.get("OPENROUTER_API_KEY"):
|
||||
client = OpenAI(api_key=os.environ["OPENROUTER_API_KEY"], base_url=OPENROUTER_BASE_URL)
|
||||
return client, _to_openrouter_model(model)
|
||||
if not api_key:
|
||||
raise RuntimeError(
|
||||
f"missing {key_env} in environment (provider={provider});"
|
||||
f"也未设置 OPENROUTER_API_KEY(OpenRouter 可作为统一兜底)。"
|
||||
)
|
||||
client = OpenAI(api_key=api_key, base_url=base_url) if base_url else OpenAI(api_key=api_key)
|
||||
return client, model
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """你是一个「自我进化」智能体(Alita 式)。你只有五个基础工具:
|
||||
web_search、read_webpage、code_interpreter、create_tool、search_tools。
|
||||
你没有任何现成的领域工具(没有查股价、查字幕之类的工具)。你的使命是:为缺失的能力
|
||||
**构建可复用的工具**,让自己越用越强,而不是每次都手工临时凑一个答案。
|
||||
|
||||
必须严格遵守下面的固定流水线:
|
||||
|
||||
第 0 步(复用优先):先调用 **search_tools** 检查工具库里是否已有能完成任务的工具。
|
||||
- 如果命中:直接调用那个已封装的工具得到数据并作答。**禁止**再调用 web_search / create_tool
|
||||
重新造轮子。这是「工具复用」,务必这样做。
|
||||
- 如果没有命中:进入下面第 1-5 步「进化」流程。
|
||||
|
||||
进化流程(当工具库没有可用工具时):
|
||||
1. 用 web_search 搜索能**编程调用**的**开源 Python 库**。搜索关键词要用「open source python
|
||||
library」「python package」这类词,而不是「API」——在线 API 往往要注册 key。很多数据
|
||||
(包括金融行情)都有无需 key、pip 安装后直接调用、自动从公开数据源抓取的成熟开源库,
|
||||
优先找这类库。
|
||||
2. 用 read_webpage 阅读候选库的 README / PyPI 页 / 文档,了解安装方式和调用方法。
|
||||
read_webpage 只用于「读文档」,不要用它去抓取最终答案数字。
|
||||
3. 用 code_interpreter 在沙箱里**真实运行代码**验证该库可行(用 pip_install 安装依赖)。
|
||||
**强约束**:优先选择「完全无需 API key、pip 安装后即可离线调用」的 Python 库;
|
||||
凡是需要注册申请 key(如需要 apikey/token 参数)的在线 API,一律跳过,换免费无 key 的库。
|
||||
「验证成功」的唯一标准是:你的测试代码用 print 打印出了**真实的价格数字**(不是占位符、
|
||||
不是报错、不是空输出)。只有 print 出真实数据,才算验证通过;绝不编造数据,也不要轻易放弃。
|
||||
4. 验证成功后,用 create_tool 把它封装成一个**通用、可复用**的标准工具。
|
||||
工具必须参数化(例如按 ticker 参数查询任意股票,而不是把某只股票写死),命名要通用
|
||||
(如 get_stock_price,而不是 get_nvidia_price),description 用通用描述,方便日后复用命中。
|
||||
code 中定义 def run(**kwargs) 并 return 结构化结果。工具内部**必须真正调用**你上一步验证
|
||||
通过的那个库来现取数据。
|
||||
调用 create_tool 时**务必带上 test_args**(一组示例入参):系统会用它在注册前真跑一次
|
||||
run(**test_args)「存前验证」,只有跑通才准入库——这能挡住跑不通的坏工具污染工具库。
|
||||
注意:验证通过后**必须**执行本步 create_tool 再作答,不能跳过封装直接回答。
|
||||
5. 调用你刚 create_tool 创建的那个工具,拿到**真实数据**来回答用户。
|
||||
|
||||
硬性要求(违反即视为失败):
|
||||
- 对于「获取实时/结构化数据」类任务,你**不允许**仅凭 read_webpage 抓到的某个网页数字直接作答;
|
||||
必须走「找库→测试→封装工具→调用工具」这条路,因为只有这样才能复用且可靠。
|
||||
- **严禁编造数据**:绝不能在工具代码里写死价格/日期等数字,绝不能用「模拟数据 / 示例数据 /
|
||||
mock」。工具必须在运行时通过库真正获取当前数据。如果你还没有用 code_interpreter
|
||||
真正 print 出真实数字,就**不许**调用 create_tool。
|
||||
- 若始终找不到可用的免费库,就如实说明失败原因,也**不要**编造一个数字答案。
|
||||
- 用中文回答最终结论,并说明数据来源(用了哪个库/工具)。"""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 基础工具的 function-calling schema
|
||||
# --------------------------------------------------------------------------- #
|
||||
BASE_TOOL_SCHEMAS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "web_search",
|
||||
"description": "用 DuckDuckGo 搜索网页,返回标题/URL/摘要。用于寻找开源库或公开 API。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "搜索关键词"},
|
||||
"num_results": {"type": "integer", "description": "返回结果数(1-10)", "default": 6},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_webpage",
|
||||
"description": "抓取网页并抽取正文文本,用于阅读 README 或 API 文档。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"url": {"type": "string", "description": "网页 URL"}},
|
||||
"required": ["url"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "code_interpreter",
|
||||
"description": "在子进程沙箱中执行 Python 代码来验证方案;可用 pip_install 先安装第三方库。返回 stdout/stderr。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {"type": "string", "description": "要执行的 Python 代码"},
|
||||
"pip_install": {
|
||||
"type": "array", "items": {"type": "string"},
|
||||
"description": "执行前需要 pip 安装的包名列表,可选",
|
||||
},
|
||||
},
|
||||
"required": ["code"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "create_tool",
|
||||
"description": "把一个已验证可行的功能封装为标准工具并持久化到工具库。code 中必须定义 def run(**kwargs)。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "工具名(合法 Python 标识符)"},
|
||||
"description": {"type": "string", "description": "工具用途描述,供日后检索"},
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"description": "该工具的参数 JSON Schema (type=object, properties, required)",
|
||||
},
|
||||
"code": {"type": "string", "description": "工具实现,必须包含 def run(**kwargs) 并 return 可 JSON 序列化结果"},
|
||||
"test_args": {
|
||||
"type": "object",
|
||||
"description": "一组用于「存前验证」的示例入参:注册前会用它真跑一次 run(**test_args),"
|
||||
"只有成功返回才准入库。强烈建议提供,以挡住跑不通的坏工具。",
|
||||
},
|
||||
},
|
||||
"required": ["name", "description", "parameters", "code"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search_tools",
|
||||
"description": "在工具库中按关键词检索已有工具,用于复用。动手上网前必须先调用它。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string", "description": "检索关键词,如 'stock price'"}},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class SelfEvolvingAgent:
|
||||
def __init__(self, verbose: bool = True, allow_create: bool = True, model: str | None = None):
|
||||
self.client, self.model = build_client()
|
||||
if model: # CLI/调用方可覆盖模型名(优先级高于 LLM_MODEL 环境变量)
|
||||
self.model = model
|
||||
self.library = ToolLibrary()
|
||||
self.verbose = verbose
|
||||
# 是否允许「自我进化」中的造工具动作。False 时移除 create_tool 能力,
|
||||
# 用于对照演示「没有造工具能力时只能复用/无法完成」的差异。
|
||||
self.allow_create = allow_create
|
||||
self.trajectory = [] # 记录动作轨迹,便于「证明工具复用」
|
||||
self._verified_real_data = False # 本轮任务是否已用 code_interpreter 打印出真实数据
|
||||
self._created_tool = False # 本轮是否创建了工具
|
||||
self._used_library_tool = False # 本轮是否复用了库中已封装的工具
|
||||
# 已「解锁」的工具库工具:只有经 search_tools 检索命中(或刚 create_tool 新建)后,
|
||||
# 才把它暴露为可调用函数。这样能强制「先 search_tools 复用」的流程,而非绕过检索直接调用。
|
||||
self._unlocked = set()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
def _tools(self):
|
||||
"""暴露给模型的工具 = 五个基础工具 + 本轮已解锁(经 search_tools 命中或刚创建)的工具。"""
|
||||
base = BASE_TOOL_SCHEMAS
|
||||
if not self.allow_create: # 关闭造工具能力:不把 create_tool 暴露给模型
|
||||
base = [s for s in base if s["function"]["name"] != "create_tool"]
|
||||
dynamic = []
|
||||
for rec in self.library.list_tools():
|
||||
if rec["name"] not in self._unlocked:
|
||||
continue
|
||||
dynamic.append(
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": rec["name"],
|
||||
"description": "[已封装工具] " + rec["description"],
|
||||
"parameters": normalize_schema(rec["parameters"]),
|
||||
},
|
||||
}
|
||||
)
|
||||
return base + dynamic
|
||||
|
||||
def _log(self, *a):
|
||||
if self.verbose:
|
||||
print(*a, flush=True)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
def _dispatch(self, name: str, args: dict) -> dict:
|
||||
"""执行一次工具调用,并记录轨迹。"""
|
||||
self.trajectory.append(name)
|
||||
if name == "web_search":
|
||||
return base_tools.web_search(args.get("query", ""), args.get("num_results", 6))
|
||||
if name == "read_webpage":
|
||||
return base_tools.read_webpage(args.get("url", ""))
|
||||
if name == "code_interpreter":
|
||||
res = base_tools.code_interpreter(args.get("code", ""), args.get("pip_install"))
|
||||
# 记录:跑通且有真实输出,才认为已完成「真实数据验证」
|
||||
if res.get("success") and res.get("stdout", "").strip():
|
||||
self._verified_real_data = True
|
||||
return res
|
||||
if name == "create_tool":
|
||||
if not self.allow_create:
|
||||
return {"success": False, "error": "本次运行禁用了造工具能力(--no-create)。"}
|
||||
code = args.get("code", "")
|
||||
# 反幻觉守卫 1:必须先用 code_interpreter 打印出真实数据,才允许封装工具
|
||||
if not self._verified_real_data:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "尚未验证真实数据:请先用 code_interpreter 真正调用库并 print 出"
|
||||
"真实数字,验证通过后再封装工具。不要用未经验证或编造的数据封装工具。",
|
||||
}
|
||||
# 反幻觉守卫 2:拒绝含「模拟/示例/写死数据」气味的工具代码
|
||||
lowered = code.lower()
|
||||
if any(k in lowered for k in ("mock", "模拟", "示例数据", "sample data", "fake", "dummy")):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "工具代码疑似包含模拟/示例/写死数据。工具必须在运行时通过库真正获取"
|
||||
"数据,请改用真实的库调用后重新提交。",
|
||||
}
|
||||
res = self.library.create_tool(
|
||||
args.get("name", ""), args.get("description", ""),
|
||||
args.get("parameters", {}), code, args.get("test_args"),
|
||||
)
|
||||
if res.get("success"):
|
||||
self._created_tool = True
|
||||
self._unlocked.add(res["name"]) # 新建后立即解锁,便于本轮直接调用
|
||||
return res
|
||||
if name == "search_tools":
|
||||
res = self.library.search_tools(args.get("query", ""))
|
||||
for t in res.get("tools", []): # 命中的工具解锁为可调用函数(工具复用)
|
||||
self._unlocked.add(t["name"])
|
||||
return res
|
||||
# 否则:调用一个已封装的工具(工具复用)
|
||||
if self.library.get_tool(name) is not None:
|
||||
self._used_library_tool = True
|
||||
return self.library.execute_tool(name, args)
|
||||
return {"success": False, "error": f"unknown tool: {name}"}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
def run(self, task: str, max_steps: int = 20) -> str:
|
||||
self._verified_real_data = False
|
||||
self._created_tool = False
|
||||
self._used_library_tool = False
|
||||
self._unlocked = set()
|
||||
nudges = 0 # 已发出的「请先封装工具」提醒次数(限次,避免死循环)
|
||||
messages = [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": task},
|
||||
]
|
||||
self._log(f"\n{'='*70}\n[任务] {task}\n{'='*70}")
|
||||
|
||||
for step in range(max_steps):
|
||||
resp = self.client.chat.completions.create(
|
||||
model=self.model, messages=messages,
|
||||
tools=self._tools(), tool_choice="auto", temperature=0,
|
||||
)
|
||||
msg = resp.choices[0].message
|
||||
messages.append(msg.model_dump(exclude_none=True))
|
||||
|
||||
if not msg.tool_calls:
|
||||
# 进化守卫:若已用库验证出真实数据,却既没封装工具、也没复用工具就想直接作答,
|
||||
# 强制它先 create_tool 把能力固化下来(这正是「自我进化」的关键动作)。
|
||||
if (
|
||||
self._verified_real_data
|
||||
and not self._created_tool
|
||||
and not self._used_library_tool
|
||||
and nudges < 2
|
||||
):
|
||||
nudges += 1
|
||||
self._log("\n[进化守卫] 已验证真实数据但未封装工具,提醒模型先 create_tool。")
|
||||
messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": "你已经用真实数据验证了方案,但还没有把它封装成可复用工具。"
|
||||
"请**先调用 create_tool**(通用命名、按 ticker 参数化、内部真正调用该库),"
|
||||
"然后调用你新建的工具得到真实数据再作答。",
|
||||
}
|
||||
)
|
||||
continue
|
||||
self._log(f"\n[最终回答]\n{msg.content}")
|
||||
return msg.content or ""
|
||||
|
||||
for tc in msg.tool_calls:
|
||||
fname = tc.function.name
|
||||
try:
|
||||
fargs = json.loads(tc.function.arguments or "{}")
|
||||
except json.JSONDecodeError:
|
||||
fargs = {}
|
||||
self._log(f"\n[step {step+1}] 调用工具 -> {fname} args={_short(fargs)}")
|
||||
result = self._dispatch(fname, fargs)
|
||||
self._log(f" 结果: {_short(result)}")
|
||||
messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.id,
|
||||
"content": json.dumps(result, ensure_ascii=False, default=str)[:8000],
|
||||
}
|
||||
)
|
||||
|
||||
return "(达到最大步数上限)"
|
||||
|
||||
|
||||
def _short(obj, n: int = 240) -> str:
|
||||
s = json.dumps(obj, ensure_ascii=False, default=str)
|
||||
return s if len(s) <= n else s[:n] + f"...(+{len(s)-n} chars)"
|
||||
@@ -0,0 +1,211 @@
|
||||
"""
|
||||
五个基础工具中的「非工具库」部分:web_search / read_webpage / code_interpreter。
|
||||
|
||||
设计原则(对应补充案例“最小预定义,最大自我进化”):
|
||||
- 这里 **不包含任何领域工具**(没有 get_stock_price、没有 get_youtube_transcript ...)。
|
||||
- Agent 只能靠 web_search 找开源库/API,read_webpage 读文档,
|
||||
code_interpreter 在子进程沙箱里真实执行代码来验证方案是否可行。
|
||||
- 所有输出都基于「真实网络结果 / 真实执行结果」,从而抑制大模型的幻觉。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
# 沙箱内 pip install --target 的目标目录:安装的第三方包会持久化到这里,
|
||||
# 后续被封装的工具在同一个 PYTHONPATH 下也能直接 import 使用。
|
||||
PROJECT_DIR = Path(__file__).resolve().parent
|
||||
SANDBOX_PKG_DIR = PROJECT_DIR / ".sandbox_packages"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 工具 1:web_search —— DuckDuckGo(无需 API key)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def web_search(query: str, num_results: int = 6) -> dict:
|
||||
"""
|
||||
使用 DuckDuckGo 进行网页搜索(免费、无需 key)。
|
||||
|
||||
实现要点(参考 chapter4/perception-tools 的风格):
|
||||
- 主用 lite.duckduckgo.com(返回更稳定、不易被限流);
|
||||
- 备用 html.duckduckgo.com;
|
||||
- 带指数退避重试,DDG 偶发返回 202(限流)时自动重试,避免「网络抖动即失败」。
|
||||
"""
|
||||
query = (query or "").strip()
|
||||
if not query:
|
||||
return {"success": False, "error": "search query is empty", "results": []}
|
||||
|
||||
try: # 模型可能传 null 或非数字字符串,兜底为默认值
|
||||
num_results = max(1, min(int(num_results or 6), 10))
|
||||
except (TypeError, ValueError):
|
||||
num_results = 6
|
||||
headers = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15"
|
||||
)
|
||||
}
|
||||
|
||||
last_err = None
|
||||
# 两个端点各重试若干次
|
||||
for endpoint in ("https://lite.duckduckgo.com/lite/", "https://html.duckduckgo.com/html/"):
|
||||
for attempt in range(3):
|
||||
try:
|
||||
resp = requests.post(
|
||||
endpoint, data={"q": query, "kl": "wt-wt"}, headers=headers, timeout=15
|
||||
)
|
||||
if resp.status_code == 202: # DDG 限流信号
|
||||
raise RuntimeError("rate limited (202)")
|
||||
resp.raise_for_status()
|
||||
results = _parse_ddg(endpoint, resp.text, num_results)
|
||||
if results:
|
||||
return {"success": True, "query": query, "count": len(results), "results": results}
|
||||
last_err = "no results parsed"
|
||||
except Exception as e: # noqa: BLE001
|
||||
last_err = str(e)
|
||||
time.sleep(1.5 * (attempt + 1)) # 退避
|
||||
|
||||
return {"success": False, "error": f"search failed: {last_err}", "results": []}
|
||||
|
||||
|
||||
def _parse_ddg(endpoint: str, html: str, num_results: int) -> list:
|
||||
"""解析 DuckDuckGo 的两种页面结构。"""
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
results = []
|
||||
|
||||
if "html.duckduckgo" in endpoint:
|
||||
for div in soup.find_all("div", class_="result")[:num_results]:
|
||||
a = div.find("a", class_="result__a")
|
||||
if not a:
|
||||
continue
|
||||
snip = div.find("a", class_="result__snippet")
|
||||
results.append(
|
||||
{
|
||||
"title": a.get_text(strip=True),
|
||||
"url": a.get("href", ""),
|
||||
"snippet": snip.get_text(strip=True) if snip else "",
|
||||
}
|
||||
)
|
||||
else: # lite 版:结果是普通 <a href="http...">
|
||||
for a in soup.find_all("a"):
|
||||
href = a.get("href", "")
|
||||
text = a.get_text(strip=True)
|
||||
if href.startswith("http") and text:
|
||||
results.append({"title": text, "url": href, "snippet": ""})
|
||||
if len(results) >= num_results:
|
||||
break
|
||||
return results
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 工具 2:read_webpage —— 抓取网页并抽取正文
|
||||
# --------------------------------------------------------------------------- #
|
||||
def read_webpage(url: str, max_chars: int = 6000) -> dict:
|
||||
"""抓取网页并抽取纯文本正文,供 Agent 阅读 README / API 文档。"""
|
||||
if not url or not url.startswith(("http://", "https://")):
|
||||
return {"success": False, "error": "invalid url"}
|
||||
headers = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15"
|
||||
)
|
||||
}
|
||||
try:
|
||||
resp = requests.get(url, headers=headers, timeout=20)
|
||||
resp.raise_for_status()
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"success": False, "error": f"fetch failed: {e}", "url": url}
|
||||
|
||||
soup = BeautifulSoup(resp.text, "html.parser")
|
||||
for tag in soup(["script", "style", "noscript", "nav", "footer", "header"]):
|
||||
tag.decompose()
|
||||
text = "\n".join(line.strip() for line in soup.get_text("\n").splitlines() if line.strip())
|
||||
truncated = len(text) > max_chars
|
||||
return {
|
||||
"success": True,
|
||||
"url": url,
|
||||
"title": soup.title.get_text(strip=True) if soup.title else "",
|
||||
"text": text[:max_chars],
|
||||
"truncated": truncated,
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 工具 3:code_interpreter —— 子进程沙箱执行 Python
|
||||
# --------------------------------------------------------------------------- #
|
||||
def code_interpreter(code: str, pip_install: list | None = None, timeout: int = 60) -> dict:
|
||||
"""
|
||||
在 **独立子进程** 中执行 Python 代码(沙箱),用于验证从网上找到的库 / API。
|
||||
|
||||
- pip_install: 需要先安装的第三方包列表;安装到临时目录 .sandbox_packages(--target),
|
||||
不污染系统环境,并通过 PYTHONPATH 让子进程可 import。
|
||||
- timeout: 超时强制终止,避免死循环 / 挂起。
|
||||
|
||||
安全边界提醒:这是「演示级」沙箱(仅进程隔离 + 超时),不是安全沙箱。
|
||||
生产环境请使用容器 / gVisor / 无网络命名空间等强隔离,并审计要安装的包(供应链风险)。
|
||||
"""
|
||||
SANDBOX_PKG_DIR.mkdir(exist_ok=True)
|
||||
logs = []
|
||||
|
||||
# 子进程环境:把沙箱包目录加入 PYTHONPATH(系统 site-packages 仍可用)
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = str(SANDBOX_PKG_DIR) + os.pathsep + env.get("PYTHONPATH", "")
|
||||
|
||||
# 1) 按需 pip install --target
|
||||
if pip_install:
|
||||
for pkg in pip_install:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "--quiet",
|
||||
"--target", str(SANDBOX_PKG_DIR), pkg],
|
||||
capture_output=True, text=True, timeout=180, env=env,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
logs.append(f"[pip install {pkg}] FAILED: {r.stderr.strip()[-500:]}")
|
||||
else:
|
||||
logs.append(f"[pip install {pkg}] ok")
|
||||
except Exception as e: # noqa: BLE001
|
||||
logs.append(f"[pip install {pkg}] error: {e}")
|
||||
|
||||
# 2) 执行代码
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False, dir=SANDBOX_PKG_DIR) as f:
|
||||
f.write(code)
|
||||
script = f.name
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[sys.executable, script],
|
||||
capture_output=True, text=True, timeout=timeout, env=env,
|
||||
)
|
||||
out = r.stdout[-8000:]
|
||||
result = {
|
||||
"success": r.returncode == 0,
|
||||
"stdout": out,
|
||||
"stderr": r.stderr[-4000:],
|
||||
"returncode": r.returncode,
|
||||
"pip_logs": logs,
|
||||
}
|
||||
# 提醒模型:跑通但没有任何 print 输出 = 没有拿到真实数据,不能据此作答或封装工具。
|
||||
if r.returncode == 0 and not out.strip():
|
||||
result["note"] = (
|
||||
"代码执行成功但 stdout 为空——你没有打印出任何真实数据。"
|
||||
"这不算验证通过:请修改代码,真正调用库并 print 出真实数字。"
|
||||
)
|
||||
return result
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"success": False, "error": f"timeout after {timeout}s", "pip_logs": logs}
|
||||
finally:
|
||||
try:
|
||||
os.unlink(script)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def run_python_snippet(code: str, timeout: int = 60) -> dict:
|
||||
"""供 tool_manager 复用:在同一沙箱环境执行一段脚本并返回结果(不做 pip)。"""
|
||||
return code_interpreter(code, pip_install=None, timeout=timeout)
|
||||
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
补充案例一键演示:`python demo.py`
|
||||
|
||||
演示两件事:
|
||||
1) 进化:Agent 从零基础工具出发 —— 搜索 → 读文档 → 沙箱测试 → 封装工具 →
|
||||
用新工具给出 NVIDIA(NVDA) 的真实股价与「相对一周前」的真实涨跌幅。
|
||||
2) 复用:换一支股票(AAPL) 再问一次。Agent 应先 search_tools 命中已创建的工具并直接复用,
|
||||
不再重新上网搜索、重新造轮子。程序会打印轨迹并自动校验「复用」是否成立。
|
||||
|
||||
在线路径(默认)需要真实联网 + 真实调用 OpenAI,请先配置 OPENAI_API_KEY。
|
||||
若手头没有 API key / 无法联网,可用 `--offline` 跑「机制自检」:不调用 LLM/网络,
|
||||
直接驱动工具库的「搜索未命中 → 造工具 → 存前验证 → 注册 → 复用」闭环(见下)。
|
||||
|
||||
常用示例:
|
||||
python demo.py # 跑「进化 + 复用」两个默认任务(需 API)
|
||||
python demo.py --fresh # 先清空 tool_library/ 再跑(重现「从零进化」)
|
||||
python demo.py --offline # 离线机制自检(无需 API/网络),演示完整进化闭环
|
||||
python demo.py --task "查询比特币当前美元价格及24小时涨跌幅" # 自定义任务(可多次)
|
||||
python demo.py --no-create # 禁用造工具能力(对照:只能复用/无法进化)
|
||||
python demo.py --model gpt-5.6-luna --output run.json # 覆盖模型并把结果写入 JSON
|
||||
python demo.py --help # 查看全部参数
|
||||
|
||||
提示:工具库会持久化到 tool_library/。若上一轮已封装出 get_stock_price,再次直接运行时
|
||||
任务一会在第 0 步就命中并复用它,从而看不到「进化」过程;想重现进化请加 --fresh。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from tool_manager import LIBRARY_DIR, ToolLibrary
|
||||
|
||||
|
||||
TASK_1 = "查询 NVIDIA(股票代码 NVDA) 的最新股价,以及与一周前相比的涨跌幅(百分比)。请给出真实数据。"
|
||||
TASK_2 = "查询 Apple(股票代码 AAPL) 的最新股价,以及与一周前相比的涨跌幅(百分比)。请给出真实数据。"
|
||||
|
||||
_META_TOOLS = {"web_search", "read_webpage", "code_interpreter", "create_tool", "search_tools"}
|
||||
|
||||
|
||||
def _clear_library():
|
||||
"""清空持久化的工具库(仅删除生成的 *.json 工件),用于重现「从零进化」。"""
|
||||
removed = 0
|
||||
for p in glob.glob(os.path.join(str(LIBRARY_DIR), "*.json")):
|
||||
try:
|
||||
os.remove(p)
|
||||
removed += 1
|
||||
except OSError:
|
||||
pass
|
||||
print(f"[--fresh] 已清空 tool_library/(删除 {removed} 个已封装工具),将从零开始进化。\n")
|
||||
|
||||
|
||||
def _is_reuse(traj: list) -> bool:
|
||||
"""某条轨迹是否属于「工具复用」:调用了 search_tools、没有重新 web_search/create_tool,
|
||||
且真的调用了某个已封装(非元)工具。"""
|
||||
return (
|
||||
"search_tools" in traj
|
||||
and "web_search" not in traj
|
||||
and "create_tool" not in traj
|
||||
and any(t not in _META_TOOLS for t in traj)
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 离线机制自检:不调用 LLM / 网络,直接驱动工具库的进化闭环
|
||||
# 搜索未命中 → 造工具(带存前验证)→ 注册 → 调用 → 复用
|
||||
# 用一个纯离线、确定性的工具(计算两个日期之间的天数)来跑通全流程,
|
||||
# 便于在没有 API key / 无网络时验证「自我进化 + 复用」机制本身是否可靠。
|
||||
# --------------------------------------------------------------------------- #
|
||||
_DAYS_TOOL_CODE = (
|
||||
"from datetime import date\n\n"
|
||||
"def run(start, end):\n"
|
||||
" s = date.fromisoformat(start)\n"
|
||||
" e = date.fromisoformat(end)\n"
|
||||
" return {'start': start, 'end': end, 'days': (e - s).days}\n"
|
||||
)
|
||||
_DAYS_TOOL_PARAMS = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"start": {"type": "string", "description": "起始日期 YYYY-MM-DD"},
|
||||
"end": {"type": "string", "description": "结束日期 YYYY-MM-DD"},
|
||||
},
|
||||
"required": ["start", "end"],
|
||||
}
|
||||
# 一个「跑不通」的坏工具:用来证明存前验证闸门确实会拒绝它入库
|
||||
_BAD_TOOL_CODE = (
|
||||
"def run(start, end):\n"
|
||||
" return {'days': undefined_name}\n" # NameError at runtime
|
||||
)
|
||||
|
||||
|
||||
def run_offline_selftest(output_path: str | None = None) -> int:
|
||||
print("=" * 70)
|
||||
print("离线机制自检(--offline):不调用 LLM/网络,直接驱动工具库进化闭环")
|
||||
print(" 闭环:search_tools 未命中 → create_tool(存前验证) → 注册 → 调用 → 复用")
|
||||
print("=" * 70)
|
||||
|
||||
tmp = Path(tempfile.mkdtemp(prefix="selfevolve_selftest_"))
|
||||
lib = ToolLibrary(library_dir=tmp) # 用临时库,绝不污染用户真实的 tool_library/
|
||||
try:
|
||||
# ---------- 存前验证闸门演示:坏工具应被拒绝入库 ----------
|
||||
print("\n[验证闸门] 尝试注册一个运行会崩溃的坏工具(附 test_args)...")
|
||||
bad = lib.create_tool(
|
||||
"days_between_bad", "会崩溃的示例工具", _DAYS_TOOL_PARAMS, _BAD_TOOL_CODE,
|
||||
test_args={"start": "2020-01-01", "end": "2020-03-01"},
|
||||
)
|
||||
print(f" 结果: success={bad.get('success')} -> {bad.get('error', '')[:60]}")
|
||||
assert not bad["success"], "坏工具竟然通过了存前验证!"
|
||||
assert lib.get_tool("days_between_bad") is None, "坏工具不应落盘!"
|
||||
print(" ✅ 存前验证挡住了坏工具(未入库),符合『别把坏程序存进去』。")
|
||||
|
||||
# ---------- 任务一:进化(造工具)----------
|
||||
traj1: list = []
|
||||
print("\n########## 离线任务一:计算 2020-01-01 到 2020-03-01 的天数(演示进化)##########")
|
||||
traj1.append("search_tools")
|
||||
hit = lib.search_tools("date days between")
|
||||
print(f"[step 1] search_tools -> 命中 {hit['count']} 个(工具库为空,未命中)")
|
||||
|
||||
traj1.append("create_tool")
|
||||
created = lib.create_tool(
|
||||
"days_between",
|
||||
"计算两个 ISO 日期(YYYY-MM-DD)之间相差的天数",
|
||||
_DAYS_TOOL_PARAMS, _DAYS_TOOL_CODE,
|
||||
test_args={"start": "2020-01-01", "end": "2020-01-11"},
|
||||
)
|
||||
print(f"[step 2] create_tool(days_between) -> success={created['success']} "
|
||||
f"validated={created.get('validated')}(存前验证已真跑一次 run())")
|
||||
|
||||
traj1.append("days_between")
|
||||
r1 = lib.execute_tool("days_between", {"start": "2020-01-01", "end": "2020-03-01"})
|
||||
ans1 = r1.get("result", {}).get("days")
|
||||
print(f"[step 3] days_between(...) -> {r1.get('result')}")
|
||||
print(f"[离线任务一结论] 2020-01-01 到 2020-03-01 共 {ans1} 天。")
|
||||
|
||||
# ---------- 任务二:复用(不再造轮子)----------
|
||||
traj2: list = []
|
||||
print("\n########## 离线任务二:计算 2021-01-01 到 2021-12-31 的天数(演示复用)##########")
|
||||
traj2.append("search_tools")
|
||||
hit2 = lib.search_tools("date days between")
|
||||
print(f"[step 1] search_tools -> 命中 {hit2['count']} 个:{[t['name'] for t in hit2['tools']]}(复用!)")
|
||||
|
||||
traj2.append("days_between")
|
||||
r2 = lib.execute_tool("days_between", {"start": "2021-01-01", "end": "2021-12-31"})
|
||||
ans2 = r2.get("result", {}).get("days")
|
||||
print(f"[step 2] days_between(...) -> {r2.get('result')}")
|
||||
print(f"[离线任务二结论] 2021-01-01 到 2021-12-31 共 {ans2} 天。")
|
||||
|
||||
reused = _is_reuse(traj2)
|
||||
print("\n" + "=" * 70)
|
||||
print("离线自检结论")
|
||||
print("=" * 70)
|
||||
print(f"任务一轨迹: {traj1}")
|
||||
print(f"任务二轨迹: {traj2}")
|
||||
print(f"任务二是否复用了任务一造的工具(未重新 create_tool): {'是 ✅' if reused else '否 ❌'}")
|
||||
print(f"存前验证闸门是否挡住了坏工具: {'是 ✅' if not bad['success'] else '否 ❌'}")
|
||||
|
||||
if output_path:
|
||||
payload = {
|
||||
"mode": "offline_selftest",
|
||||
"gate_rejected_bad_tool": (not bad["success"]),
|
||||
"tasks": [
|
||||
{"task": "2020-01-01→2020-03-01 天数", "answer_days": ans1, "trajectory": traj1},
|
||||
{"task": "2021-01-01→2021-12-31 天数", "answer_days": ans2, "trajectory": traj2},
|
||||
],
|
||||
"reused": reused,
|
||||
}
|
||||
Path(output_path).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"\n[已写入] {output_path}")
|
||||
|
||||
return 0 if (reused and not bad["success"]) else 1
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 在线路径:真实 LLM + 真实网络
|
||||
# --------------------------------------------------------------------------- #
|
||||
def run_online(tasks: list, allow_create: bool, model: str | None, output_path: str | None) -> int:
|
||||
# 延迟导入:--offline 时无需 openai 依赖也能跑
|
||||
from agent import SelfEvolvingAgent
|
||||
|
||||
try:
|
||||
agent = SelfEvolvingAgent(verbose=True, allow_create=allow_create, model=model)
|
||||
except RuntimeError as e:
|
||||
print(f"[配置错误] {e}", file=sys.stderr)
|
||||
print(
|
||||
"请先配置对应供应商的 API Key(默认 OpenAI):\n"
|
||||
" cp env.example .env 然后在 .env 中填入 OPENAI_API_KEY;\n"
|
||||
" 或直接 export OPENAI_API_KEY=your-openai-api-key\n"
|
||||
"如需切换供应商:export LLM_PROVIDER=moonshot|ark 并配置对应的 "
|
||||
"MOONSHOT_API_KEY / ARK_API_KEY。\n"
|
||||
"(若只想验证机制而无 API key,可运行:python demo.py --offline)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
default = tasks == [TASK_1, TASK_2]
|
||||
runs = []
|
||||
for i, task in enumerate(tasks, 1):
|
||||
label = {1: "任务一", 2: "任务二"}.get(i, f"任务{i}") if default else f"任务{i}"
|
||||
tag = {1: "(演示 搜索→测试→封装→用)", 2: "(演示 工具复用)"}.get(i, "") if default else ""
|
||||
print(f"\n########## {label}{tag} ##########")
|
||||
agent.trajectory = []
|
||||
ans = agent.run(task)
|
||||
traj = list(agent.trajectory)
|
||||
created = [t["name"] for t in agent.library.list_tools()]
|
||||
print(f"\n>>> {label}结束。当前工具库已封装工具: {created}")
|
||||
print(f">>> {label}动作轨迹: {traj}")
|
||||
runs.append({"task": task, "answer": ans, "trajectory": traj, "reused": _is_reuse(traj)})
|
||||
|
||||
# 复用校验:只要有「非首个」任务发生了复用即算成立
|
||||
reused = any(r["reused"] for r in runs[1:])
|
||||
print("\n" + "=" * 70)
|
||||
print("结论汇总")
|
||||
print("=" * 70)
|
||||
for i, r in enumerate(runs, 1):
|
||||
print(f"[任务{i}] {r['answer']}")
|
||||
print("-" * 70)
|
||||
if len(runs) >= 2:
|
||||
print(f"后续任务是否复用了已创建工具(未重新搜索/创建): {'是 ✅' if reused else '否 ❌'}")
|
||||
print(" 证据:复用任务调用了 search_tools 且未出现 web_search/create_tool。")
|
||||
|
||||
if output_path:
|
||||
Path(output_path).write_text(json.dumps(
|
||||
{"mode": "online", "model": agent.model, "allow_create": allow_create,
|
||||
"runs": runs, "reused": reused},
|
||||
ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"\n[已写入] {output_path}")
|
||||
|
||||
if len(runs) < 2:
|
||||
return 0
|
||||
return 0 if reused else 1
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(
|
||||
description="补充案例:Agent 从网络寻找工具并验证后复用。",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="示例:\n"
|
||||
" python demo.py 跑默认两个任务(进化 + 复用,需 API)\n"
|
||||
" python demo.py --fresh 先清空工具库再跑(重现从零进化)\n"
|
||||
" python demo.py --offline 离线机制自检(无需 API/网络)\n"
|
||||
" python demo.py --task '...' 自定义任务(可重复多次)\n"
|
||||
" python demo.py --no-create 禁用造工具能力(对照实验)\n")
|
||||
p.add_argument("--task", action="append", metavar="任务描述",
|
||||
help="要执行的任务(可重复指定多次以按顺序运行多个任务)。"
|
||||
"不指定则运行默认的 NVDA/AAPL 两个任务。")
|
||||
p.add_argument("--offline", action="store_true",
|
||||
help="离线机制自检:不调用 LLM/网络,直接驱动『搜索→造工具→存前验证→注册→复用』闭环。")
|
||||
p.add_argument("--fresh", action="store_true",
|
||||
help="运行前清空 tool_library/,以重现『从零进化』过程(重复演示时推荐)。")
|
||||
p.add_argument("--no-create", dest="allow_create", action="store_false",
|
||||
help="禁用『造工具(create_tool)』能力,用于对照演示(默认允许造工具)。")
|
||||
p.add_argument("--model", metavar="模型名", default=None,
|
||||
help="覆盖 LLM 模型名(优先级高于 LLM_MODEL 环境变量),如 gpt-5.6-luna。")
|
||||
p.add_argument("--output", metavar="路径", default=None,
|
||||
help="把本次运行的任务、答案、动作轨迹与复用结论写入该 JSON 文件。")
|
||||
return p
|
||||
|
||||
|
||||
def main():
|
||||
args = build_parser().parse_args()
|
||||
|
||||
if args.offline:
|
||||
return run_offline_selftest(output_path=args.output)
|
||||
|
||||
if args.fresh:
|
||||
_clear_library()
|
||||
|
||||
tasks = args.task if args.task else [TASK_1, TASK_2]
|
||||
return run_online(tasks, allow_create=args.allow_create,
|
||||
model=args.model, output_path=args.output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,19 @@
|
||||
# ============================================================
|
||||
# 补充案例:Alita 式工具自我扩展 · 环境变量示例
|
||||
# 复制为 .env 或直接 export 到环境中
|
||||
# ============================================================
|
||||
|
||||
# 默认使用 OpenAI(gpt-5.6-luna),function calling
|
||||
OPENAI_API_KEY=your-openai-api-key
|
||||
|
||||
# 统一兜底:若上面 provider 的 Key 缺失,但设置了 OPENROUTER_API_KEY,
|
||||
# 则自动改走 OpenRouter,并把模型名映射到 openai/gpt-5.6-luna 等。
|
||||
# OPENROUTER_API_KEY=your-openrouter-api-key
|
||||
|
||||
# 可选:切换到其它 OpenAI 兼容供应商(三选一),并可覆盖模型名
|
||||
# LLM_PROVIDER=openai # openai | moonshot | ark
|
||||
# LLM_MODEL=gpt-5.6-luna # 覆盖默认模型
|
||||
# MOONSHOT_API_KEY=your-moonshot-api-key # 当 LLM_PROVIDER=moonshot
|
||||
# ARK_API_KEY=... # 当 LLM_PROVIDER=ark
|
||||
|
||||
# 说明:本实验的 web_search 使用 DuckDuckGo,无需任何搜索 API key。
|
||||
@@ -0,0 +1,6 @@
|
||||
openai>=1.40.0
|
||||
requests>=2.31.0
|
||||
beautifulsoup4>=4.12.0
|
||||
python-dotenv>=1.0.0
|
||||
# 注意:yfinance 等「领域库」故意不列在这里。
|
||||
# 本实验的核心就是让 Agent 自己上网发现并在沙箱中按需 pip 安装它需要的库。
|
||||
@@ -0,0 +1,58 @@
|
||||
"""normalize_schema must keep bare {"type":"object"} as an empty-object schema while preserving extra metadata."""
|
||||
|
||||
from tool_manager import normalize_schema
|
||||
|
||||
|
||||
def test_bare_object_type_stays_empty_properties():
|
||||
assert normalize_schema({"type": "object"}) == {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
}
|
||||
|
||||
|
||||
def test_object_with_required_keeps_required_not_as_property():
|
||||
assert normalize_schema({"type": "object", "required": []}) == {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
}
|
||||
|
||||
|
||||
def test_object_with_properties_unchanged():
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"x": {"type": "string"}},
|
||||
"required": ["x"],
|
||||
}
|
||||
assert normalize_schema(schema) == schema
|
||||
|
||||
|
||||
def test_properties_only_still_gets_object_type():
|
||||
assert normalize_schema({"properties": {"y": {"type": "number"}}}) == {
|
||||
"type": "object",
|
||||
"properties": {"y": {"type": "number"}},
|
||||
}
|
||||
|
||||
|
||||
def test_plain_property_map_still_wrapped():
|
||||
assert normalize_schema({"z": {"type": "boolean"}}) == {
|
||||
"type": "object",
|
||||
"properties": {"z": {"type": "boolean"}},
|
||||
}
|
||||
|
||||
|
||||
def test_object_retains_extra_metadata():
|
||||
schema = {
|
||||
"type": "object",
|
||||
"description": "Tool parameters",
|
||||
"additionalProperties": False,
|
||||
"$defs": {"Item": {"type": "string"}},
|
||||
}
|
||||
expected = {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"description": "Tool parameters",
|
||||
"additionalProperties": False,
|
||||
"$defs": {"Item": {"type": "string"}},
|
||||
}
|
||||
assert normalize_schema(schema) == expected
|
||||
@@ -0,0 +1,22 @@
|
||||
from tool_manager import ToolLibrary
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_search_null_name_or_description():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
lib = ToolLibrary(library_dir=Path(tmpdir))
|
||||
# Create a tool file manually with null description
|
||||
p = Path(tmpdir) / "null_desc.json"
|
||||
p.write_text('{"name": "null_desc", "description": null, "parameters": {}, "code": "def run(): pass"}')
|
||||
|
||||
res = lib.search_tools("null")
|
||||
assert res["success"] is True
|
||||
assert res["count"] == 1
|
||||
assert res["tools"][0]["name"] == "null_desc"
|
||||
def test_get_tool_non_dict_json():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
lib = ToolLibrary(library_dir=Path(tmpdir))
|
||||
p = Path(tmpdir) / "bad_list.json"
|
||||
p.write_text('[1, 2, 3]')
|
||||
assert lib.get_tool("bad_list") is None
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Regression: malformed __TOOL_RESULT__ JSON must not raise JSONDecodeError."""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from tool_manager import ToolLibrary
|
||||
|
||||
|
||||
def test_malformed_tool_result_returns_error_dict():
|
||||
lib = ToolLibrary(library_dir=Path(tempfile.mkdtemp()))
|
||||
# Tool prints a broken marker line, then would have returned ok — driver still emits marker from wrapper.
|
||||
# Inject via code that prints a bad marker before the wrapper's print by overriding run to print bad then return.
|
||||
code = (
|
||||
"def run(**kwargs):\n"
|
||||
" print('__TOOL_RESULT__{not-json')\n"
|
||||
" return {'ok': True}\n"
|
||||
)
|
||||
rec = {"name": "t", "description": "d", "parameters": {"type": "object", "properties": {}}, "code": code}
|
||||
out = lib._run_record(rec, {})
|
||||
assert isinstance(out, dict)
|
||||
assert out.get("success") is False
|
||||
assert "invalid result marker" in out.get("error", "")
|
||||
|
||||
|
||||
def test_valid_tool_result_still_succeeds():
|
||||
lib = ToolLibrary(library_dir=Path(tempfile.mkdtemp()))
|
||||
code = "def run(**kwargs):\n return {'ok': True}\n"
|
||||
rec = {"name": "t", "description": "d", "parameters": {"type": "object", "properties": {}}, "code": code}
|
||||
out = lib._run_record(rec, {})
|
||||
assert out.get("success") is True
|
||||
assert out.get("result") == {"ok": True}
|
||||
@@ -0,0 +1,36 @@
|
||||
"""web_search 对模型给出的异常 num_results(null / 非数字字符串)应兜底为默认值,
|
||||
而不是抛 TypeError/ValueError 中断整个 Agent 循环。"""
|
||||
import json
|
||||
|
||||
import base_tools
|
||||
|
||||
|
||||
def _search_without_network(monkeypatch, num_results):
|
||||
"""屏蔽真实网络与退避 sleep,只验证参数处理不崩溃。"""
|
||||
def _fake_post(*a, **k):
|
||||
raise RuntimeError("network disabled in test")
|
||||
|
||||
monkeypatch.setattr(base_tools.requests, "post", _fake_post)
|
||||
monkeypatch.setattr(base_tools.time, "sleep", lambda *_a, **_k: None)
|
||||
return base_tools.web_search("python stock library", num_results)
|
||||
|
||||
|
||||
def test_num_results_null_falls_back(monkeypatch):
|
||||
# 模型显式传 JSON null:.get(..., 6) 的默认值挡不住,应兜底为 6
|
||||
args = json.loads('{"query": "python stock library", "num_results": null}')
|
||||
result = _search_without_network(monkeypatch, args.get("num_results", 6))
|
||||
assert result["success"] is False # 网络被禁用 -> 走失败返回,而不是异常
|
||||
assert "search failed" in result["error"]
|
||||
|
||||
|
||||
def test_num_results_garbage_string_falls_back(monkeypatch):
|
||||
args = json.loads('{"query": "python stock library", "num_results": "five"}')
|
||||
result = _search_without_network(monkeypatch, args.get("num_results", 6))
|
||||
assert result["success"] is False
|
||||
assert "search failed" in result["error"]
|
||||
|
||||
|
||||
def test_num_results_normal_still_clamped(monkeypatch):
|
||||
result = _search_without_network(monkeypatch, 3)
|
||||
assert result["success"] is False
|
||||
assert "search failed" in result["error"]
|
||||
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
工具库管理:create_tool(封装并持久化)、search_tools(检索复用)、以及被封装工具的执行。
|
||||
|
||||
这是 Alita 式「自我进化」的核心:
|
||||
- Agent 用 code_interpreter 验证过某个方案后,调用 create_tool 把它固化成一个
|
||||
「标准工具」——包含 name / description / JSON-Schema 参数 / Python 代码,持久化到 tool_library/。
|
||||
- 下次遇到同类任务,Agent 应先 search_tools 命中已有工具并直接复用,而不是重新上网搜索、重新写代码。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_DIR = Path(__file__).resolve().parent
|
||||
LIBRARY_DIR = PROJECT_DIR / "tool_library"
|
||||
SANDBOX_PKG_DIR = PROJECT_DIR / ".sandbox_packages"
|
||||
|
||||
|
||||
def normalize_schema(params) -> dict:
|
||||
"""
|
||||
把模型给出的 parameters 规整为合法的 OpenAI function-calling JSON Schema。
|
||||
模型常见错误:只给 properties 映射而漏掉顶层 {"type":"object"}。这里做容错,
|
||||
否则把这样的工具再暴露给 OpenAI 会触发 400 invalid schema 而中断整个流程。
|
||||
"""
|
||||
if not isinstance(params, dict):
|
||||
return {"type": "object", "properties": {}}
|
||||
if params.get("type") == "object":
|
||||
out = dict(params)
|
||||
out["properties"] = params.get("properties") or {}
|
||||
return out
|
||||
if "properties" in params: # 有 properties 但 type 缺失/错误
|
||||
out = dict(params)
|
||||
out["type"] = "object"
|
||||
out["properties"] = params.get("properties") or {}
|
||||
return out
|
||||
# 整个 dict 视为 properties 映射
|
||||
return {"type": "object", "properties": params}
|
||||
|
||||
|
||||
class ToolLibrary:
|
||||
"""基于文件系统的极简工具库。每个工具 = 一个 .json(元数据+代码)。"""
|
||||
|
||||
def __init__(self, library_dir: Path = LIBRARY_DIR):
|
||||
self.dir = Path(library_dir)
|
||||
self.dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ----------------------------- create_tool ----------------------------- #
|
||||
def create_tool(self, name: str, description: str, parameters: dict, code: str,
|
||||
test_args: dict | None = None) -> dict:
|
||||
"""
|
||||
把一个功能封装为标准工具并持久化。
|
||||
|
||||
约定:code 里必须定义一个名为 run(**kwargs) 的函数,返回可 JSON 序列化的结果。
|
||||
parameters 为 OpenAI function-calling 风格的 JSON Schema(type=object, properties, required)。
|
||||
|
||||
「存前验证」闸门(对应图 8-7 流水线里的「测试」一步、以及本章「工具质量退化」告诫):
|
||||
- 先做**语法编译检查**,语法错误的代码一律拒绝入库;
|
||||
- 若给了 test_args,则在沙箱里**真正执行一次 run(**test_args)**,只有成功返回结果
|
||||
才允许注册——从而挡住「封装了却根本跑不通」的坏工具污染工具库、再被后续任务反复复用。
|
||||
"""
|
||||
name = name.strip()
|
||||
if not name.isidentifier():
|
||||
return {"success": False, "error": f"invalid tool name: {name!r} (must be a valid identifier)"}
|
||||
if "def run" not in code:
|
||||
return {"success": False, "error": "tool code must define a function `def run(**kwargs)`"}
|
||||
# 存前验证 1:语法编译检查(坏语法直接挡在库外)
|
||||
try:
|
||||
compile(code, f"<tool {name}>", "exec")
|
||||
except SyntaxError as e:
|
||||
return {"success": False, "error": f"tool code has a syntax error: {e}"}
|
||||
|
||||
record = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"parameters": normalize_schema(parameters),
|
||||
"code": code,
|
||||
}
|
||||
|
||||
# 存前验证 2:给了 test_args 就真跑一次 run(),跑不通就拒绝入库
|
||||
validated = False
|
||||
if test_args is not None:
|
||||
val = self._run_record(record, test_args)
|
||||
if not val.get("success"):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "工具注册前验证失败:run(**test_args) 没有成功返回。请修正代码或 test_args"
|
||||
"后重新提交(未通过验证的工具不会入库,以免坏工具被后续任务复用)。",
|
||||
"validation": val,
|
||||
}
|
||||
validated = True
|
||||
|
||||
(self.dir / f"{name}.json").write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"tool '{name}' created and saved to tool_library/"
|
||||
+ ("(已通过存前验证)" if validated else "(未提供 test_args,跳过运行验证)"),
|
||||
"name": name,
|
||||
"validated": validated,
|
||||
}
|
||||
|
||||
# ----------------------------- search_tools ---------------------------- #
|
||||
def search_tools(self, query: str) -> dict:
|
||||
"""按名称/描述做关键词检索,返回命中的工具(用于复用)。"""
|
||||
query = (query or "").strip().lower()
|
||||
terms = [t for t in query.replace(",", " ").split() if t]
|
||||
hits = []
|
||||
for rec in self.list_tools():
|
||||
name = str(rec.get("name") or "")
|
||||
desc = str(rec.get("description") or "")
|
||||
haystack = (name + " " + desc).lower()
|
||||
score = sum(1 for t in terms if t in haystack)
|
||||
if score > 0 or not terms:
|
||||
hits.append((score, rec))
|
||||
hits.sort(key=lambda x: -x[0])
|
||||
return {
|
||||
"success": True,
|
||||
"query": query,
|
||||
"count": len(hits),
|
||||
"tools": [
|
||||
{
|
||||
"name": str(r.get("name") or ""),
|
||||
"description": str(r.get("description") or ""),
|
||||
"parameters": r.get("parameters") or {},
|
||||
}
|
||||
for _, r in hits
|
||||
],
|
||||
}
|
||||
|
||||
# ------------------------------ helpers -------------------------------- #
|
||||
def list_tools(self) -> list:
|
||||
recs = []
|
||||
for p in sorted(self.dir.glob("*.json")):
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict):
|
||||
recs.append(data)
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
return recs
|
||||
|
||||
def get_tool(self, name: str) -> dict | None:
|
||||
p = self.dir / f"{name}.json"
|
||||
if not p.exists():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else None
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
# -------------------------- execute a wrapped tool --------------------- #
|
||||
def execute_tool(self, name: str, arguments: dict, timeout: int = 60) -> dict:
|
||||
"""
|
||||
在子进程沙箱中执行已封装的工具:注入代码 + run(**args),捕获 JSON 结果。
|
||||
PYTHONPATH 指向 .sandbox_packages,使 create 时 pip 安装的依赖可用。
|
||||
"""
|
||||
rec = self.get_tool(name)
|
||||
if rec is None:
|
||||
return {"success": False, "error": f"tool '{name}' not found in library"}
|
||||
return self._run_record(rec, arguments, timeout)
|
||||
|
||||
def _run_record(self, rec: dict, arguments: dict, timeout: int = 60) -> dict:
|
||||
"""按「工具记录(含 code)」在沙箱子进程里执行 run(**arguments)。
|
||||
|
||||
直接吃 record 而不读磁盘,因此可在工具**尚未落盘时**用于「存前验证」。
|
||||
"""
|
||||
SANDBOX_PKG_DIR.mkdir(exist_ok=True)
|
||||
driver = (
|
||||
rec["code"]
|
||||
+ "\n\nif __name__ == '__main__':\n"
|
||||
" import json as _json, sys as _sys\n"
|
||||
" _args = _json.loads(_sys.argv[1])\n"
|
||||
" _out = run(**_args)\n"
|
||||
" print('__TOOL_RESULT__' + _json.dumps(_out, default=str))\n"
|
||||
)
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = str(SANDBOX_PKG_DIR) + os.pathsep + env.get("PYTHONPATH", "")
|
||||
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False, dir=SANDBOX_PKG_DIR) as f:
|
||||
f.write(driver)
|
||||
script = f.name
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[sys.executable, script, json.dumps(arguments)],
|
||||
capture_output=True, text=True, timeout=timeout, env=env,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return {"success": False, "error": "tool crashed", "stderr": r.stderr[-3000:]}
|
||||
for line in r.stdout.splitlines():
|
||||
if line.startswith("__TOOL_RESULT__"):
|
||||
raw = line[len("__TOOL_RESULT__"):]
|
||||
try:
|
||||
return {"success": True, "result": json.loads(raw)}
|
||||
except json.JSONDecodeError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"invalid result marker: {e}",
|
||||
"stdout": r.stdout[-2000:],
|
||||
}
|
||||
return {"success": False, "error": "no result marker", "stdout": r.stdout[-2000:]}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"success": False, "error": f"timeout after {timeout}s"}
|
||||
finally:
|
||||
try:
|
||||
os.unlink(script)
|
||||
except OSError:
|
||||
pass
|
||||
Reference in New Issue
Block a user