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()