Files
ai-agent-book/chapter10/staged-system-prompt/tools.py
T
liqiang b119135836
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
ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
2026-08-20 13:12:50 +00:00

330 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
工具实现与工具集定义。
本文件包含两部分:
1. Workspace:一个进程内的“虚拟工作区”,负责保存需求、文件内容,
并提供真实的代码执行 / 语法检查 / 复杂度分析能力。
2. 三个阶段各自的工具 JSON Schema(供 OpenAI function calling 使用)。
关键点:不同阶段暴露给模型的工具集是不同的,这是“阶段化系统提示词”实验
的核心之一——提示词换了角色,工具也随之切换。
"""
from __future__ import annotations
import ast
import hashlib
import os
import subprocess
import sys
import tempfile
from typing import Dict, List
# ----------------------------------------------------------------------------
# 触发阶段转换的“信号工具”名字。Agent 主循环看到这些工具被调用就切换阶段。
# ----------------------------------------------------------------------------
COMPLETE_REQUIREMENTS = "complete_requirements_analysis" # 阶段1 -> 阶段2
SUBMIT_FOR_REVIEW = "submit_for_review" # 阶段2 -> 阶段3
REQUEST_REVISION = "request_revision" # 阶段3 -> 阶段2(回退)
APPROVE_CODE = "approve_code" # 阶段3 -> 完成
class Workspace:
"""跨阶段共享的任务状态(需求、文件、审查意见)。"""
def __init__(self) -> None:
# 阶段1 收集到的、已确认的需求(key -> value
self.requirements: Dict[str, str] = {}
# 阶段2 写出的“文件系统”(path -> content
self.files: Dict[str, str] = {}
# 阶段3 退回时记录的问题清单,供阶段2 修复时参考
self.review_issues: List[str] = []
# --- 阶段1:需求分析师的工具实现 -------------------------------------
def save_requirement(self, key: str, value: str) -> str:
self.requirements[key] = value
return f"已记录需求 [{key}] = {value}"
# --- 阶段2:软件工程师的工具实现 -------------------------------------
def write_file(self, path: str, content: str) -> str:
self.files[path] = content
return f"已写入文件 {path}{len(content)} 字符,{content.count(chr(10)) + 1} 行)"
def read_file(self, path: str) -> str:
if path not in self.files:
return f"错误:文件 {path} 不存在。当前文件列表:{list(self.files) or '空'}"
return self.files[path]
def execute_code(self, code: str) -> str:
"""在含当前工作区文件的临时目录里真实执行代码(带超时)。"""
return _run_python_source(code, files=self.files)
# --- 阶段3:代码审查员的工具实现 -------------------------------------
def run_linter(self, path: str) -> str:
"""轻量静态检查:语法编译 + 常见坏味道,不引入额外依赖。"""
if path not in self.files:
return f"错误:文件 {path} 不存在。"
source = self.files[path]
problems: List[str] = []
# 1) 语法能否编译
try:
tree = ast.parse(source)
except SyntaxError as exc:
return f"[linter] 语法错误:第 {exc.lineno}{exc.msg}"
# 2) 逐行的风格问题(阈值定得“严格但可达标”,方便演示先退回再通过)
for i, line in enumerate(source.splitlines(), start=1):
if len(line) > 120:
problems.append(f"L{i}: 行超过 120 字符({len(line)}),请折行或精简")
if line.rstrip() != line:
problems.append(f"L{i}: 行尾有多余空白")
if "\t" in line:
problems.append(f"L{i}: 使用了 Tab 缩进,建议用空格")
# 3) 基于 AST 的问题:缺少模块 docstring、裸 except
if not ast.get_docstring(tree):
problems.append("模块缺少文件级 docstring(请在文件开头加一段三引号说明)")
for node in ast.walk(tree):
if isinstance(node, ast.ExceptHandler) and node.type is None:
problems.append(f"L{node.lineno}: 使用了裸 except,建议捕获具体异常")
if not problems:
return "[linter] 通过:未发现问题。"
return "[linter] 发现 %d 个问题:\n- %s" % (len(problems), "\n- ".join(problems))
def run_tests(self, path: str) -> str:
"""冒烟测试:把文件跑起来,验证 import / 主流程不崩溃。"""
if path not in self.files:
return f"错误:文件 {path} 不存在。"
# 造一个假的“下载文件夹”,让整理脚本有东西可整理
harness = (
"import os, tempfile, runpy, sys\n"
"d = tempfile.mkdtemp()\n"
"for name in ['a.jpg','b.pdf','c.txt','d.mp3','readme']:\n"
" open(os.path.join(d, name), 'w').close()\n"
f"sys.argv = [{path!r}, d]\n"
"print('SMOKE_TEST target dir:', d)\n"
f"runpy.run_path({path!r}, run_name='__main__')\n"
)
# Keep the harness and submitted module as separate files. Concatenating
# the harness before the module used to make a valid `from __future__`
# import fail solely because it was no longer at the start of its file.
result, returncode = _run_python(harness, files={path: self.files[path]})
# 以退出码为准:超时(returncode 为 None)和非零退出都不能算 PASS;
# Traceback/Error 子串检查作为额外防线保留。
ok = returncode == 0 and "Traceback" not in result and "Error" not in result
verdict = "PASS" if ok else "FAIL"
return f"[tests] 冒烟测试结果:{verdict}\n{result}"
def analyze_complexity(self, path: str) -> str:
"""用 AST 估算复杂度:函数数量、最大分支数、最大嵌套深度。"""
if path not in self.files:
return f"错误:文件 {path} 不存在。"
try:
tree = ast.parse(self.files[path])
except SyntaxError as exc:
return f"[complexity] 无法解析:{exc.msg}"
funcs = [n for n in ast.walk(tree)
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]
branch_types = (ast.If, ast.For, ast.While, ast.Try, ast.With)
total_branches = sum(1 for n in ast.walk(tree) if isinstance(n, branch_types))
def depth(node: ast.AST, level: int = 0) -> int:
best = level
for child in ast.iter_child_nodes(node):
inc = 1 if isinstance(child, branch_types) else 0
best = max(best, depth(child, level + inc))
return best
return (
"[complexity] 函数数量=%d,分支/循环语句=%d,最大嵌套深度=%d"
% (len(funcs), total_branches, depth(tree))
)
def inject_review_fault(self, path: str) -> dict:
"""Insert one real, auditable lint defect for the rollback campaign.
The normal demo never calls this method. The strict acceptance runner
uses it once, after the first implementation submission, so that the
reviewer must observe a genuine tool failure and the implementation
Agent must remove the defect before approval. This is fault injection,
not a fabricated linter response: ``run_linter`` inspects the mutated
source normally.
"""
if path not in self.files:
raise KeyError(f"cannot inject review fault: {path!r} is not in the workspace")
marker = "EXPERIMENT_10_1_REVIEW_CANARY"
before = self.files[path]
if marker in before:
raise ValueError(f"review fault marker already exists in {path!r}")
# A tab plus trailing spaces is valid Python inside a comment, but it
# deterministically violates two independent checks in run_linter.
after = before.rstrip("\n") + f"\n\t# {marker} \n"
self.files[path] = after
return {
"path": path,
"kind": "tab_and_trailing_whitespace_comment",
"marker": marker,
"before_sha256": hashlib.sha256(before.encode()).hexdigest(),
"after_sha256": hashlib.sha256(after.encode()).hexdigest(),
"source_changed": before != after,
}
def _run_python(source: str, timeout: int = 10, files: Dict[str, str] | None = None) -> tuple:
"""执行源码,返回 (合并后的输出, 退出码);超时时退出码为 None。"""
with tempfile.TemporaryDirectory() as tmp:
for name, content in (files or {}).items():
relative = os.path.normpath(name)
if os.path.isabs(relative) or relative == ".." or relative.startswith(".." + os.sep):
return f"工作区文件路径越界:{name}", 1
destination = os.path.join(tmp, relative)
os.makedirs(os.path.dirname(destination), exist_ok=True)
with open(destination, "w", encoding="utf-8") as fh:
fh.write(content)
script = os.path.join(tmp, "_workspace_runner.py")
with open(script, "w", encoding="utf-8") as fh:
fh.write(source)
try:
proc = subprocess.run(
[sys.executable, script],
capture_output=True,
text=True,
timeout=timeout,
cwd=tmp,
)
except subprocess.TimeoutExpired:
return f"执行超时(>{timeout}s", None
out = (proc.stdout or "").strip()
err = (proc.stderr or "").strip()
parts = []
if out:
parts.append("stdout:\n" + out)
if err:
parts.append("stderr:\n" + err)
parts.append(f"退出码: {proc.returncode}")
return "\n".join(parts), proc.returncode
def _run_python_source(
source: str, timeout: int = 10, files: Dict[str, str] | None = None
) -> str:
"""把源码写到临时文件并用子进程执行,返回合并后的输出。"""
return _run_python(source, timeout, files=files)[0]
# ----------------------------------------------------------------------------
# 各阶段的工具 SchemaOpenAI tools 格式)。每个阶段只暴露自己那套工具。
# ----------------------------------------------------------------------------
def _tool(name: str, description: str, properties: dict, required: list) -> dict:
return {
"type": "function",
"function": {
"name": name,
"description": description,
"parameters": {
"type": "object",
"properties": properties,
"required": required,
},
},
}
STAGE1_TOOLS = [
_tool(
"ask_clarifying_question",
"向用户提出一个澄清需求的问题,用户会回答。需求不明确时必须先问清楚。",
{"question": {"type": "string", "description": "要问用户的问题"}},
["question"],
),
_tool(
"save_requirement",
"把一条已经确认的需求记录到需求文档中,供后续实现阶段使用。",
{
"key": {"type": "string", "description": "需求项名称,如 file_types"},
"value": {"type": "string", "description": "需求项取值/描述"},
},
["key", "value"],
),
_tool(
COMPLETE_REQUIREMENTS,
"当所有关键需求都已澄清并记录后调用,结束需求分析阶段,进入代码实现阶段。",
{"summary": {"type": "string", "description": "对已确认需求的一句话总结"}},
["summary"],
),
]
STAGE2_TOOLS = [
_tool(
"write_file",
"写入(或覆盖)一个文件的完整内容。",
{
"path": {"type": "string", "description": "文件路径,如 organize_downloads.py"},
"content": {"type": "string", "description": "文件的完整内容"},
},
["path", "content"],
),
_tool(
"read_file",
"读取一个已写入文件的内容。",
{"path": {"type": "string", "description": "文件路径"}},
["path"],
),
_tool(
"execute_code",
"执行一段 Python 代码用于自测/验证,返回标准输出与错误。",
{"code": {"type": "string", "description": "要执行的 Python 代码"}},
["code"],
),
_tool(
SUBMIT_FOR_REVIEW,
"当代码实现完成且自测通过后调用,提交给代码审查员,进入审查阶段。",
{"file": {"type": "string", "description": "要提交审查的主文件路径"}},
["file"],
),
]
STAGE3_TOOLS = [
_tool(
"run_linter",
"对文件运行静态检查,返回代码风格/规范问题。",
{"file": {"type": "string", "description": "文件路径"}},
["file"],
),
_tool(
"run_tests",
"对文件运行冒烟测试,验证能否正常运行。",
{"file": {"type": "string", "description": "文件路径"}},
["file"],
),
_tool(
"analyze_complexity",
"分析文件的代码复杂度(函数数、分支数、嵌套深度)。",
{"file": {"type": "string", "description": "文件路径"}},
["file"],
),
_tool(
REQUEST_REVISION,
"当审查发现必须修复的问题时调用,把代码退回实现阶段并附上问题清单。",
{
"issues": {
"type": "array",
"items": {"type": "string"},
"description": "需要修复的问题列表",
}
},
["issues"],
),
_tool(
APPROVE_CODE,
"当代码通过所有审查、质量达标时调用,批准代码,任务完成。",
{"comment": {"type": "string", "description": "审查通过的简短评语"}},
["comment"],
),
]