diff --git a/.env.example b/.env.example index 1c16f1f05..c8cb6269f 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,9 @@ MODEL_ID=claude-sonnet-4-6 # MODEL_ID=MiniMax-M3 # MODEL_ID=MiniMax-M2.7 # MODEL_ID=MiniMax-M2.7-highspeed +# Image generation tool (optional; s02) +# MINIMAX_API_KEY=sk-xxx +# MINIMAX_API_HOST=https://api.minimax.io # GLM (Zhipu) https://z.ai # ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic @@ -53,6 +56,9 @@ MODEL_ID=claude-sonnet-4-6 # MODEL_ID=MiniMax-M3 # MODEL_ID=MiniMax-M2.7 # MODEL_ID=MiniMax-M2.7-highspeed +# Image generation tool (optional; s02) +# MINIMAX_API_KEY=sk-xxx +# MINIMAX_API_HOST=https://api.minimaxi.com # GLM (Zhipu) https://open.bigmodel.cn # ANTHROPIC_BASE_URL=https://open.bigmodel.cn/api/anthropic diff --git a/s02_tool_use/README.en.md b/s02_tool_use/README.en.md index b939810a6..81706a018 100644 --- a/s02_tool_use/README.en.md +++ b/s02_tool_use/README.en.md @@ -30,7 +30,7 @@ Adding a tool to the Agent requires just two things: --- -## From 1 Tool to 5 Tools +## From 1 Tool to 6 Tools s01 had only bash: @@ -40,7 +40,7 @@ TOOLS = [{"name": "bash", ...}] def run_bash(command): ... ``` -s02 expands to 5 tools, each independently defined: +s02 expands to 6 tools, each independently defined: ```python TOOLS = [ @@ -49,6 +49,7 @@ TOOLS = [ {"name": "write_file", "description": "Write content to file.", ...}, {"name": "edit_file", "description": "Replace text in file once.", ...}, {"name": "glob", "description": "Find files by pattern.", ...}, + {"name": "generate_image", "description": "Generate an image from a text prompt.", ...}, ] ``` @@ -75,6 +76,9 @@ def run_edit(path, old_text, new_text): def run_glob(pattern): import glob as g return "\n".join(g.glob(pattern, root_dir=WORKDIR)) + +def run_generate_image(prompt, aspect_ratio="1:1"): + ... ``` --- @@ -88,6 +92,7 @@ TOOL_HANDLERS = { "write_file": run_write, "edit_file": run_edit, "glob": run_glob, + "generate_image": run_generate_image, } # Only one line changed in the loop — from hardcoded run_bash to dispatch lookup: @@ -125,7 +130,7 @@ The teaching version executes them one by one in the original `response.content` | Component | Before (s01) | After (s02) | |-----------|-------------|-------------| -| Tool count | 1 (bash) | 5 (+read, write, edit, glob) | +| Tool count | 1 (bash) | 6 (+read, write, edit, glob, generate_image) | | Tool execution | Hardcoded `run_bash()` | TOOL_HANDLERS dispatch lookup | | Path safety | None | safe_path validation (file tools only) | | Loop | `while True` + `stop_reason` | Identical to s01 | @@ -139,12 +144,20 @@ cd learn-claude-code python s02_tool_use/code.py ``` +The optional `generate_image` tool also needs these values in `.env`. Use `https://api.minimaxi.com` as the host for China mainland. + +```sh +MINIMAX_API_KEY=sk-xxx +MINIMAX_API_HOST=https://api.minimax.io +``` + Try these prompts: 1. `Read the file README.md and tell me what this project is about` 2. `Create a file called test.py that prints "hello", then read it back` 3. `Find all Python files in this directory` 4. `Read both README.md and requirements.txt, then create a summary file` +5. `Generate a 16:9 image of a coding workspace at sunrise` What to watch for: When does the model call just one tool, and when does it call multiple at once? Are multiple tool calls executed in the correct order? @@ -152,7 +165,7 @@ What to watch for: When does the model call just one tool, and when does it call ## What's Next -The Agent now has 5 specialized tools. File tools are protected by `safe_path`, but bash is unrestricted — `rm -rf /` still runs. +The Agent now has 6 specialized tools. File tools are protected by `safe_path`, but bash is unrestricted — `rm -rf /` still runs. → s03 Permission: Add a gate before tool execution — is this operation safe? Does it need user approval? diff --git a/s02_tool_use/README.ja.md b/s02_tool_use/README.ja.md index 23ff30aaa..440c43ecb 100644 --- a/s02_tool_use/README.ja.md +++ b/s02_tool_use/README.ja.md @@ -30,7 +30,7 @@ Agent にツールを追加するには、たった二つ: --- -## 1 つのツールから 5 つのツールへ +## From 1 Tool to 6 Tools s01 には bash だけだった: @@ -40,7 +40,7 @@ TOOLS = [{"name": "bash", ...}] def run_bash(command): ... ``` -s02 では 5 つに増え、各ツールは独立して定義される: +s02 expands to 6 tools, each independently defined: ```python TOOLS = [ @@ -49,6 +49,7 @@ TOOLS = [ {"name": "write_file", "description": "Write content to file.", ...}, {"name": "edit_file", "description": "Replace text in file once.", ...}, {"name": "glob", "description": "Find files by pattern.", ...}, + {"name": "generate_image", "description": "Generate an image from a text prompt.", ...}, ] ``` @@ -75,6 +76,9 @@ def run_edit(path, old_text, new_text): def run_glob(pattern): import glob as g return "\n".join(g.glob(pattern, root_dir=WORKDIR)) + +def run_generate_image(prompt, aspect_ratio="1:1"): + ... ``` --- @@ -88,6 +92,7 @@ TOOL_HANDLERS = { "write_file": run_write, "edit_file": run_edit, "glob": run_glob, + "generate_image": run_generate_image, } # ループ内で変更されたのは一行だけ — ハードコードの run_bash から検索ディスパッチへ: @@ -125,7 +130,7 @@ for block in response.content: | コンポーネント | 変更前 (s01) | 変更後 (s02) | |--------------|-------------|-------------| -| ツール数 | 1 (bash) | 5 (+read, write, edit, glob) | +| Tool count | 1 (bash) | 6 (+read, write, edit, glob, generate_image) | | ツール実行 | ハードコード `run_bash()` | TOOL_HANDLERS 検索ディスパッチ | | パス安全性 | なし | safe_path 検証(file tools のみ) | | ループ | `while True` + `stop_reason` | s01 と完全に同一 | @@ -139,12 +144,20 @@ cd learn-claude-code python s02_tool_use/code.py ``` +The optional `generate_image` tool also needs these values in `.env`. Use `https://api.minimaxi.com` as the host for China mainland. + +```sh +MINIMAX_API_KEY=sk-xxx +MINIMAX_API_HOST=https://api.minimax.io +``` + 以下のプロンプトを試してみよう: 1. `Read the file README.md and tell me what this project is about` 2. `Create a file called test.py that prints "hello", then read it back` 3. `Find all Python files in this directory` 4. `Read both README.md and requirements.txt, then create a summary file` +5. `Generate a 16:9 image of a coding workspace at sunrise` 観察のポイント:モデルがツールを一つだけ呼び出すときと、複数同時に呼び出すときの違い。複数のツール呼び出しは正しい順序で実行されているか? @@ -152,7 +165,7 @@ python s02_tool_use/code.py ## 次へ -Agent は 5 つの専用ツールを持つようになった。file tools は `safe_path` で保護されるが、bash は制限なし — `rm -rf /` はまだ実行できる。 +The Agent now has 6 specialized tools. File tools are protected by `safe_path`, but bash remains unrestricted: `rm -rf /` still runs. → s03 Permission:ツール実行前にゲートを追加 — この操作は安全か? ユーザーの承認が必要か? diff --git a/s02_tool_use/README.md b/s02_tool_use/README.md index 179df58cd..fd28005ca 100644 --- a/s02_tool_use/README.md +++ b/s02_tool_use/README.md @@ -30,7 +30,7 @@ s01 的循环完全保留(LLM 调用、stop_reason 判断、消息追加)。 --- -## 从 1 个工具到 5 个工具 +## From 1 Tool to 6 Tools s01 只有一个 bash: @@ -40,7 +40,7 @@ TOOLS = [{"name": "bash", ...}] def run_bash(command): ... ``` -s02 加到 5 个,每个工具都是独立定义: +s02 expands to 6 tools, each independently defined: ```python TOOLS = [ @@ -49,6 +49,7 @@ TOOLS = [ {"name": "write_file", "description": "Write content to file.", ...}, {"name": "edit_file", "description": "Replace text in file once.", ...}, {"name": "glob", "description": "Find files by pattern.", ...}, + {"name": "generate_image", "description": "Generate an image from a text prompt.", ...}, ] ``` @@ -75,6 +76,9 @@ def run_edit(path, old_text, new_text): def run_glob(pattern): import glob as g return "\n".join(g.glob(pattern, root_dir=WORKDIR)) + +def run_generate_image(prompt, aspect_ratio="1:1"): + ... ``` --- @@ -88,6 +92,7 @@ TOOL_HANDLERS = { "write_file": run_write, "edit_file": run_edit, "glob": run_glob, + "generate_image": run_generate_image, } # 循环里只改了一行——从硬编码 run_bash 变成查表: @@ -125,7 +130,7 @@ for block in response.content: | 组件 | 之前 (s01) | 之后 (s02) | |------|-----------|-----------| -| 工具数量 | 1 (bash) | 5 (+read, write, edit, glob) | +| Tool count | 1 (bash) | 6 (+read, write, edit, glob, generate_image) | | 工具执行 | 硬编码 `run_bash()` | TOOL_HANDLERS 查表分发 | | 路径安全 | 无 | safe_path 校验(仅 file tools) | | 循环 | `while True` + `stop_reason` | 与 s01 完全一致 | @@ -139,12 +144,20 @@ cd learn-claude-code python s02_tool_use/code.py ``` +The optional `generate_image` tool also needs these values in `.env`. Use `https://api.minimaxi.com` as the host for China mainland. + +```sh +MINIMAX_API_KEY=sk-xxx +MINIMAX_API_HOST=https://api.minimax.io +``` + 试试这些 prompt: 1. `Read the file README.md and tell me what this project is about` 2. `Create a file called test.py that prints "hello", then read it back` 3. `Find all Python files in this directory` 4. `Read both README.md and requirements.txt, then create a summary file` +5. `Generate a 16:9 image of a coding workspace at sunrise` 观察重点:模型什么时候只调一个工具,什么时候一次调多个?多个工具调用的顺序和结果是否正确? @@ -152,7 +165,7 @@ python s02_tool_use/code.py ## 接下来 -现在 Agent 有 5 个专用工具。file tools 受 `safe_path` 保护,但 bash 不受限制,`rm -rf /` 还是能跑。 +The Agent now has 6 specialized tools. File tools are protected by `safe_path`, but bash remains unrestricted: `rm -rf /` still runs. s03 Permission → 在工具执行之前加一道门:这个操作安全吗?需要用户批准吗? diff --git a/s02_tool_use/code.py b/s02_tool_use/code.py index e6575119a..f2c69483e 100644 --- a/s02_tool_use/code.py +++ b/s02_tool_use/code.py @@ -1,19 +1,19 @@ #!/usr/bin/env python3 """ -s02: Tool Use — 在 s01 基础上新增 4 个工具 + 分发映射。 +s02: Tool Use - adds five tools and a dispatch map on top of s01. 运行: python s02_tool_use/code.py 需要: pip install anthropic python-dotenv + .env 中配置 ANTHROPIC_API_KEY 本文件 = s01 的全部代码 + 以下新增: - + run_read / run_write / run_edit / run_glob 四个工具实现 + + Five tool implementations: run_read / run_write / run_edit / run_glob / run_generate_image + TOOL_HANDLERS 分发映射(替代 s01 中硬编码的 run_bash 调用) + safe_path 路径安全校验 循环本身(agent_loop)与 s01 完全一致。 """ -import os, subprocess +import os, subprocess, json, urllib.request from pathlib import Path try: @@ -60,7 +60,7 @@ def run_bash(command: str) -> str: # ═══════════════════════════════════════════════════════════ -# NEW in s02: 4 个新工具 +# NEW in s02: 5 tools # ═══════════════════════════════════════════════════════════ def safe_path(p: str) -> Path: @@ -114,8 +114,44 @@ def run_glob(pattern: str) -> str: return f"Error: {e}" +def run_generate_image(prompt: str, aspect_ratio: str = "1:1") -> str: + """Generate an image with MiniMax and return the image URL.""" + api_key = os.getenv("MINIMAX_API_KEY") + if not api_key: + return "Error: set MINIMAX_API_KEY to use image generation" + api_host = (os.getenv("MINIMAX_API_HOST") or "https://api.minimax.io").rstrip("/") + + payload = { + "model": "image-01", + "prompt": prompt, + "aspect_ratio": aspect_ratio, + "n": 1, + "response_format": "url", + } + req = urllib.request.Request( + f"{api_host}/v1/image_generation", + data=json.dumps(payload).encode("utf-8"), + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=120) as resp: + data = json.loads(resp.read().decode("utf-8")) + status = data.get("base_resp", {}).get("status_code") + if status not in (None, 0): + msg = data.get("base_resp", {}).get("status_msg", "unknown error") + return f"Error: MiniMax image generation failed ({status}): {msg}" + urls = data.get("data", {}).get("image_urls") or [] + return urls[0] if urls else f"Error: no image URL returned: {data}" + except Exception as e: + return f"Error: {e}" + + # ═══════════════════════════════════════════════════════════ -# NEW in s02: 工具定义(s01 只有一个 bash,现在扩展到 5 个) +# NEW in s02: tool definitions (s01 has only bash; s02 has 6 tools) # ═══════════════════════════════════════════════════════════ TOOLS = [ @@ -129,6 +165,8 @@ def run_glob(pattern: str) -> str: "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}}, {"name": "glob", "description": "Find files matching a glob pattern.", "input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}}, + {"name": "generate_image", "description": "Generate an image from a text prompt using MiniMax.", + "input_schema": {"type": "object", "properties": {"prompt": {"type": "string", "maxLength": 1500}, "aspect_ratio": {"type": "string", "default": "1:1", "enum": ["1:1", "16:9", "4:3", "3:2", "2:3", "3:4", "9:16", "21:9"]}}, "required": ["prompt"]}}, ] # ═══════════════════════════════════════════════════════════ @@ -137,7 +175,7 @@ def run_glob(pattern: str) -> str: TOOL_HANDLERS = { "bash": run_bash, "read_file": run_read, "write_file": run_write, - "edit_file": run_edit, "glob": run_glob, + "edit_file": run_edit, "glob": run_glob, "generate_image": run_generate_image, } @@ -171,7 +209,7 @@ def agent_loop(messages: list): if __name__ == "__main__": - print("s02: Tool Use — 在 s01 基础上加了 4 个工具") + print("s02: Tool Use - adds 5 tools on top of s01") print("输入问题,回车发送。输入 q 退出。\n") history = [] diff --git a/s02_tool_use/images/tool-dispatch.en.svg b/s02_tool_use/images/tool-dispatch.en.svg index 6fd2e6667..31e7e94b5 100644 --- a/s02_tool_use/images/tool-dispatch.en.svg +++ b/s02_tool_use/images/tool-dispatch.en.svg @@ -90,9 +90,14 @@ → run_edit() - - glob - → run_glob() + + glob + → run_glob() + + + + generate_image + → run_generate_image() @@ -101,8 +106,8 @@ - s01 Preserved (loop, LLM, decision — completely unchanged) + s01 Preserved (loop unchanged) - s02 New (5 tools + dispatch mapping) + s02 New (6 tools + dispatch mapping) Only 1 line changed in the loop: run_bash() → TOOL_HANDLERS[block.name]() diff --git a/s02_tool_use/images/tool-dispatch.ja.svg b/s02_tool_use/images/tool-dispatch.ja.svg index 8971d06ef..f5aaaecaa 100644 --- a/s02_tool_use/images/tool-dispatch.ja.svg +++ b/s02_tool_use/images/tool-dispatch.ja.svg @@ -90,9 +90,14 @@ → run_edit() - - glob - → run_glob() + + glob + → run_glob() + + + + generate_image + → run_generate_image() @@ -101,8 +106,8 @@ - s01 保持(ループ、LLM、判定 — 完全に不変) + s01 Preserved (loop unchanged) - s02 新規(5 つのツール + ディスパッチマッピング) + s02 New (6 tools + dispatch mapping) ループ内で変更されたのは 1 行だけ:run_bash() → TOOL_HANDLERS[block.name]() diff --git a/s02_tool_use/images/tool-dispatch.svg b/s02_tool_use/images/tool-dispatch.svg index a6b16ce2a..a3e783cae 100644 --- a/s02_tool_use/images/tool-dispatch.svg +++ b/s02_tool_use/images/tool-dispatch.svg @@ -90,9 +90,14 @@ → run_edit() - - glob - → run_glob() + + glob + → run_glob() + + + + generate_image + → run_generate_image() @@ -101,8 +106,8 @@ - s01 保留(循环、LLM、判断——完全不变) + s01 Preserved (loop unchanged) - s02 新增(5 个工具 + 分发映射) + s02 New (6 tools + dispatch mapping) 循环里只改了 1 行:run_bash() → TOOL_HANDLERS[block.name]() diff --git a/tests/test_s02_generate_image.py b/tests/test_s02_generate_image.py new file mode 100644 index 000000000..cc5cf4b31 --- /dev/null +++ b/tests/test_s02_generate_image.py @@ -0,0 +1,141 @@ +import importlib.util +import json +import os +import sys +import types +import unittest +from pathlib import Path +from unittest.mock import patch + + +REPO_ROOT = Path(__file__).resolve().parents[1] +MODULE_PATH = REPO_ROOT / "s02_tool_use" / "code.py" + + +def load_course_module(): + fake_anthropic = types.ModuleType("anthropic") + + class FakeAnthropic: + def __init__(self, *args, **kwargs): + self.messages = types.SimpleNamespace(create=None) + + fake_dotenv = types.ModuleType("dotenv") + setattr(fake_anthropic, "Anthropic", FakeAnthropic) + setattr(fake_dotenv, "load_dotenv", lambda override=True: None) + + previous_modules = { + "anthropic": sys.modules.get("anthropic"), + "dotenv": sys.modules.get("dotenv"), + } + previous_model_id = os.environ.get("MODEL_ID") + spec = importlib.util.spec_from_file_location("s02_generate_image_test", MODULE_PATH) + if spec is None or spec.loader is None: + raise RuntimeError(f"Unable to load {MODULE_PATH}") + module = importlib.util.module_from_spec(spec) + + sys.modules["anthropic"] = fake_anthropic + sys.modules["dotenv"] = fake_dotenv + try: + os.environ["MODEL_ID"] = "test-model" + spec.loader.exec_module(module) + return module + finally: + if previous_model_id is None: + os.environ.pop("MODEL_ID", None) + else: + os.environ["MODEL_ID"] = previous_model_id + for name, previous in previous_modules.items(): + if previous is None: + sys.modules.pop(name, None) + else: + sys.modules[name] = previous + + +class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + return False + + def read(self): + return json.dumps({ + "data": {"image_urls": ["https://example.com/generated.png"]}, + "base_resp": {"status_code": 0, "status_msg": "success"}, + }).encode("utf-8") + + +class GenerateImageTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.module = load_course_module() + + def test_tool_schema_matches_the_image_api_contract(self): + tool = next(tool for tool in self.module.TOOLS if tool["name"] == "generate_image") + schema = tool["input_schema"] + + self.assertEqual(schema["required"], ["prompt"]) + self.assertEqual(schema["properties"]["prompt"]["maxLength"], 1500) + self.assertEqual(schema["properties"]["aspect_ratio"]["default"], "1:1") + self.assertEqual( + schema["properties"]["aspect_ratio"]["enum"], + ["1:1", "16:9", "4:3", "3:2", "2:3", "3:4", "9:16", "21:9"], + ) + + def test_uses_the_selected_regional_api_host(self): + previous_key = os.environ.get("MINIMAX_API_KEY") + previous_host = os.environ.get("MINIMAX_API_HOST") + try: + os.environ["MINIMAX_API_KEY"] = "test-key" + cases = [ + (None, "https://api.minimax.io/v1/image_generation"), + ("https://api.minimaxi.com/", "https://api.minimaxi.com/v1/image_generation"), + ] + for host, expected_url in cases: + with self.subTest(host=host): + if host is None: + os.environ.pop("MINIMAX_API_HOST", None) + else: + os.environ["MINIMAX_API_HOST"] = host + captured = {} + + def fake_urlopen(request, timeout): + captured["request"] = request + captured["timeout"] = timeout + return FakeResponse() + + with patch.object( + self.module.urllib.request, + "urlopen", + side_effect=fake_urlopen, + ): + result = self.module.run_generate_image("sunrise", "16:9") + + request = captured["request"] + self.assertEqual(result, "https://example.com/generated.png") + self.assertEqual(request.full_url, expected_url) + self.assertEqual(captured["timeout"], 120) + self.assertEqual( + json.loads(request.data.decode("utf-8")), + { + "model": "image-01", + "prompt": "sunrise", + "aspect_ratio": "16:9", + "n": 1, + "response_format": "url", + }, + ) + self.assertEqual(request.get_header("Authorization"), "Bearer test-key") + finally: + if previous_key is None: + os.environ.pop("MINIMAX_API_KEY", None) + else: + os.environ["MINIMAX_API_KEY"] = previous_key + if previous_host is None: + os.environ.pop("MINIMAX_API_HOST", None) + else: + os.environ["MINIMAX_API_HOST"] = previous_host + + +if __name__ == "__main__": + unittest.main()