Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 14 additions & 54 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,61 +1,21 @@
# API Key (required)
# Get yours at: https://console.anthropic.com/
ANTHROPIC_API_KEY=sk-ant-xxx

# Model ID (required)
MODEL_ID=claude-sonnet-4-6

# Base URL (optional, for Anthropic-compatible providers)
# ANTHROPIC_BASE_URL=https://api.anthropic.com

# =============================================================================
# Anthropic-compatible providers
#
# Provider MODEL_ID SWE-bench TB2 Base URL
# --------------- -------------------- --------- ------ -------------------
# Anthropic claude-sonnet-4-6 79.6% 59.1% (default)
# MiniMax MiniMax-M2.5 80.2% - see below
# GLM (Zhipu) glm-5 77.8% - see below
# Kimi (Moonshot) kimi-k2.5 76.8% - see below
# DeepSeek deepseek-chat 73.0% - see below
# (V3.2)
#
# SWE-bench = SWE-bench Verified (Feb 2026)
# TB2 = Terminal-Bench 2.0 (Feb 2026)
# OpenAI 兼容配置(推荐:DashScope 千问)
# =============================================================================

# ---- International ----

# MiniMax https://www.minimax.io
# ANTHROPIC_BASE_URL=https://api.minimax.io/anthropic
# MODEL_ID=MiniMax-M2.5

# GLM (Zhipu) https://z.ai
# ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic
# MODEL_ID=glm-5

# Kimi (Moonshot) https://platform.moonshot.ai
# ANTHROPIC_BASE_URL=https://api.moonshot.ai/anthropic
# MODEL_ID=kimi-k2.5

# DeepSeek https://platform.deepseek.com
# ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic
# MODEL_ID=deepseek-chat

# ---- China mainland ----
# 必填:OpenAI 兼容接口 Key(DashScope 可在控制台创建)
OPENAI_API_KEY=sk-xxx

# MiniMax https://platform.minimax.io
# ANTHROPIC_BASE_URL=https://api.minimaxi.com/anthropic
# MODEL_ID=MiniMax-M2.5
# 可选:OpenAI 兼容 Base URL(默认即 DashScope)
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1

# GLM (Zhipu) https://open.bigmodel.cn
# ANTHROPIC_BASE_URL=https://open.bigmodel.cn/api/anthropic
# MODEL_ID=glm-5
# 可选:默认模型(代码优先读取 MODEL_ID)
MODEL_ID=qwen3.6-plus

# Kimi (Moonshot) https://platform.moonshot.cn
# ANTHROPIC_BASE_URL=https://api.moonshot.cn/anthropic
# MODEL_ID=kimi-k2.5
# 可选:与 MODEL_ID 保持一致的别名
OPENAI_MODEL=qwen3.6-plus

# DeepSeek (no regional split, same endpoint globally)
# ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic
# MODEL_ID=deepseek-chat
# -----------------------------------------------------------------------------
# 兼容保留(可选)
# -----------------------------------------------------------------------------
# 仅为兼容旧环境变量。新部署请优先使用 OPENAI_API_KEY。
# ANTHROPIC_API_KEY=sk-ant-xxx
487 changes: 186 additions & 301 deletions README-ja.md

Large diffs are not rendered by default.

560 changes: 272 additions & 288 deletions README-zh.md

Large diffs are not rendered by default.

498 changes: 191 additions & 307 deletions README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion agents/__init__.py → agents/en/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# agents/ - Harness implementations (s01-s12) + full reference (s_full)
# agents/ - Harness implementations (s01-s19) + capstone reference (s_full)
# Each file is self-contained and runnable: python agents/s01_agent_loop.py
# The model is the agent. These files are the harness.
165 changes: 165 additions & 0 deletions agents/en/s01_agent_loop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
#!/usr/bin/env python3
# Harness: the loop -- keep feeding real tool results back into the model.
"""
s01_agent_loop.py - The Agent Loop

This file teaches the smallest useful coding-agent pattern:

user message
-> model reply
-> if tool_use: execute tools
-> write tool_result back to messages
-> continue

It intentionally keeps the loop small, but still makes the loop state explicit
so later chapters can grow from the same structure.
"""

import os
import subprocess
from dataclasses import dataclass

try:
import readline
# #143 UTF-8 backspace fix for macOS libedit
readline.parse_and_bind('set bind-tty-special-chars off')
readline.parse_and_bind('set input-meta on')
readline.parse_and_bind('set output-meta on')
readline.parse_and_bind('set convert-meta off')
readline.parse_and_bind('set enable-meta-keybindings on')
except ImportError:
pass

from anthropic import Anthropic
from dotenv import load_dotenv

load_dotenv(override=True)

if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)

client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]

SYSTEM = (
f"You are a coding agent at {os.getcwd()}. "
"Use bash to inspect and change the workspace. Act first, then report clearly."
)

TOOLS = [{
"name": "bash",
"description": "Run a shell command in the current workspace.",
"input_schema": {
"type": "object",
"properties": {"command": {"type": "string"}},
"required": ["command"],
},
}]


@dataclass
class LoopState:
# The minimal loop state: history, loop count, and why we continue.
messages: list
turn_count: int = 1
transition_reason: str | None = None


def run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(item in command for item in dangerous):
return "Error: Dangerous command blocked"
try:
result = subprocess.run(
command,
shell=True,
cwd=os.getcwd(),
capture_output=True,
text=True,
timeout=120,
)
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
except (FileNotFoundError, OSError) as e:
return f"Error: {e}"

output = (result.stdout + result.stderr).strip()
return output[:50000] if output else "(no output)"


def extract_text(content) -> str:
if not isinstance(content, list):
return ""
texts = []
for block in content:
text = getattr(block, "text", None)
if text:
texts.append(text)
return "\n".join(texts).strip()


def execute_tool_calls(response_content) -> list[dict]:
results = []
for block in response_content:
if block.type != "tool_use":
continue
command = block.input["command"]
print(f"\033[33m$ {command}\033[0m")
output = run_bash(command)
print(output[:200])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
return results


def run_one_turn(state: LoopState) -> bool:
response = client.messages.create(
model=MODEL,
system=SYSTEM,
messages=state.messages,
tools=TOOLS,
max_tokens=8000,
)
state.messages.append({"role": "assistant", "content": response.content})

if response.stop_reason != "tool_use":
state.transition_reason = None
return False

results = execute_tool_calls(response.content)
if not results:
state.transition_reason = None
return False

state.messages.append({"role": "user", "content": results})
state.turn_count += 1
state.transition_reason = "tool_result"
return True


def agent_loop(state: LoopState) -> None:
while run_one_turn(state):
pass


if __name__ == "__main__":
history = []
while True:
try:
query = input("\033[36ms01 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break

history.append({"role": "user", "content": query})
state = LoopState(messages=history)
agent_loop(state)

final_text = extract_text(history[-1]["content"])
if final_text:
print(final_text)
print()
87 changes: 73 additions & 14 deletions agents/s02_tool_use.py → agents/en/s02_tool_use.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,11 @@
#!/usr/bin/env python3
# Harness: tool dispatch -- expanding what the model can reach.
"""
s02_tool_use.py - Tools
s02_tool_use.py - Tool dispatch + message normalization

The agent loop from s01 didn't change. We just added tools to the array
and a dispatch map to route calls.

+----------+ +-------+ +------------------+
| User | ---> | LLM | ---> | Tool Dispatch |
| prompt | | | | { |
+----------+ +---+---+ | bash: run_bash |
^ | read: run_read |
| | write: run_wr |
+----------+ edit: run_edit |
tool_result| } |
+------------------+
The agent loop from s01 didn't change. We added tools to the dispatch map,
and a normalize_messages() function that cleans up the message list before
each API call.

Key insight: "The loop didn't change at all. I just added tools."
"""
Expand Down Expand Up @@ -91,6 +82,11 @@ def run_edit(path: str, old_text: str, new_text: str) -> str:
return f"Error: {e}"


# -- Concurrency safety classification --
# Read-only tools can safely run in parallel; mutating tools must be serialized.
CONCURRENCY_SAFE = {"read_file"}
CONCURRENCY_UNSAFE = {"write_file", "edit_file"}

# -- The dispatch map: {tool_name: handler} --
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
Expand All @@ -111,10 +107,73 @@ def run_edit(path: str, old_text: str, new_text: str) -> str:
]


def normalize_messages(messages: list) -> list:
"""Clean up messages before sending to the API.

Three jobs:
1. Strip internal metadata fields the API doesn't understand
2. Ensure every tool_use has a matching tool_result (insert placeholder if missing)
3. Merge consecutive same-role messages (API requires strict alternation)
"""
cleaned = []
for msg in messages:
clean = {"role": msg["role"]}
if isinstance(msg.get("content"), str):
clean["content"] = msg["content"]
elif isinstance(msg.get("content"), list):
clean["content"] = [
{k: v for k, v in block.items()
if not k.startswith("_")}
for block in msg["content"]
if isinstance(block, dict)
]
else:
clean["content"] = msg.get("content", "")
cleaned.append(clean)

# Collect existing tool_result IDs
existing_results = set()
for msg in cleaned:
if isinstance(msg.get("content"), list):
for block in msg["content"]:
if isinstance(block, dict) and block.get("type") == "tool_result":
existing_results.add(block.get("tool_use_id"))

# Find orphaned tool_use blocks and insert placeholder results
for msg in cleaned:
if msg["role"] != "assistant" or not isinstance(msg.get("content"), list):
continue
for block in msg["content"]:
if not isinstance(block, dict):
continue
if block.get("type") == "tool_use" and block.get("id") not in existing_results:
cleaned.append({"role": "user", "content": [
{"type": "tool_result", "tool_use_id": block["id"],
"content": "(cancelled)"}
]})

# Merge consecutive same-role messages
if not cleaned:
return cleaned
merged = [cleaned[0]]
for msg in cleaned[1:]:
if msg["role"] == merged[-1]["role"]:
prev = merged[-1]
prev_c = prev["content"] if isinstance(prev["content"], list) \
else [{"type": "text", "text": str(prev["content"])}]
curr_c = msg["content"] if isinstance(msg["content"], list) \
else [{"type": "text", "text": str(msg["content"])}]
prev["content"] = prev_c + curr_c
else:
merged.append(msg)
return merged


def agent_loop(messages: list):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
model=MODEL, system=SYSTEM,
messages=normalize_messages(messages),
tools=TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
Expand Down
Loading