Skip to content

Commit 36897b1

Browse files
committed
feat: realign teaching path and web docs
1 parent d882d01 commit 36897b1

File tree

198 files changed

+48675
-6177
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

198 files changed

+48675
-6177
lines changed

README-ja.md

Lines changed: 186 additions & 301 deletions
Large diffs are not rendered by default.

README-zh.md

Lines changed: 272 additions & 288 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 191 additions & 307 deletions
Large diffs are not rendered by default.

agents/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
# agents/ - Harness implementations (s01-s12) + full reference (s_full)
1+
# agents/ - Harness implementations (s01-s19) + capstone reference (s_full)
22
# Each file is self-contained and runnable: python agents/s01_agent_loop.py
33
# The model is the agent. These files are the harness.

agents/s01_agent_loop.py

Lines changed: 101 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,23 @@
11
#!/usr/bin/env python3
2-
# Harness: the loop -- the model's first connection to the real world.
2+
# Harness: the loop -- keep feeding real tool results back into the model.
33
"""
44
s01_agent_loop.py - The Agent Loop
55
6-
The entire secret of an AI coding agent in one pattern:
7-
8-
while stop_reason == "tool_use":
9-
response = LLM(messages, tools)
10-
execute tools
11-
append results
12-
13-
+----------+ +-------+ +---------+
14-
| User | ---> | LLM | ---> | Tool |
15-
| prompt | | | | execute |
16-
+----------+ +---+---+ +----+----+
17-
^ |
18-
| tool_result |
19-
+---------------+
20-
(loop continues)
21-
22-
This is the core loop: feed tool results back to the model
23-
until the model decides to stop. Production agents layer
24-
policy, hooks, and lifecycle controls on top.
6+
This file teaches the smallest useful coding-agent pattern:
7+
8+
user message
9+
-> model reply
10+
-> if tool_use: execute tools
11+
-> write tool_result back to messages
12+
-> continue
13+
14+
It intentionally keeps the loop small, but still makes the loop state explicit
15+
so later chapters can grow from the same structure.
2516
"""
2617

2718
import os
2819
import subprocess
20+
from dataclasses import dataclass
2921

3022
try:
3123
import readline
@@ -49,11 +41,14 @@
4941
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
5042
MODEL = os.environ["MODEL_ID"]
5143

52-
SYSTEM = f"You are a coding agent at {os.getcwd()}. Use bash to solve tasks. Act, don't explain."
44+
SYSTEM = (
45+
f"You are a coding agent at {os.getcwd()}. "
46+
"Use bash to inspect and change the workspace. Act first, then report clearly."
47+
)
5348

5449
TOOLS = [{
5550
"name": "bash",
56-
"description": "Run a shell command.",
51+
"description": "Run a shell command in the current workspace.",
5752
"input_schema": {
5853
"type": "object",
5954
"properties": {"command": {"type": "string"}},
@@ -62,43 +57,92 @@
6257
}]
6358

6459

60+
@dataclass
61+
class LoopState:
62+
# The minimal loop state: history, loop count, and why we continue.
63+
messages: list
64+
turn_count: int = 1
65+
transition_reason: str | None = None
66+
67+
6568
def run_bash(command: str) -> str:
6669
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
67-
if any(d in command for d in dangerous):
70+
if any(item in command for item in dangerous):
6871
return "Error: Dangerous command blocked"
6972
try:
70-
r = subprocess.run(command, shell=True, cwd=os.getcwd(),
71-
capture_output=True, text=True, timeout=120)
72-
out = (r.stdout + r.stderr).strip()
73-
return out[:50000] if out else "(no output)"
73+
result = subprocess.run(
74+
command,
75+
shell=True,
76+
cwd=os.getcwd(),
77+
capture_output=True,
78+
text=True,
79+
timeout=120,
80+
)
7481
except subprocess.TimeoutExpired:
7582
return "Error: Timeout (120s)"
7683
except (FileNotFoundError, OSError) as e:
7784
return f"Error: {e}"
7885

79-
80-
# -- The core pattern: a while loop that calls tools until the model stops --
81-
def agent_loop(messages: list):
82-
while True:
83-
response = client.messages.create(
84-
model=MODEL, system=SYSTEM, messages=messages,
85-
tools=TOOLS, max_tokens=8000,
86-
)
87-
# Append assistant turn
88-
messages.append({"role": "assistant", "content": response.content})
89-
# If the model didn't call a tool, we're done
90-
if response.stop_reason != "tool_use":
91-
return
92-
# Execute each tool call, collect results
93-
results = []
94-
for block in response.content:
95-
if block.type == "tool_use":
96-
print(f"\033[33m$ {block.input['command']}\033[0m")
97-
output = run_bash(block.input["command"])
98-
print(output[:200])
99-
results.append({"type": "tool_result", "tool_use_id": block.id,
100-
"content": output})
101-
messages.append({"role": "user", "content": results})
86+
output = (result.stdout + result.stderr).strip()
87+
return output[:50000] if output else "(no output)"
88+
89+
90+
def extract_text(content) -> str:
91+
if not isinstance(content, list):
92+
return ""
93+
texts = []
94+
for block in content:
95+
text = getattr(block, "text", None)
96+
if text:
97+
texts.append(text)
98+
return "\n".join(texts).strip()
99+
100+
101+
def execute_tool_calls(response_content) -> list[dict]:
102+
results = []
103+
for block in response_content:
104+
if block.type != "tool_use":
105+
continue
106+
command = block.input["command"]
107+
print(f"\033[33m$ {command}\033[0m")
108+
output = run_bash(command)
109+
print(output[:200])
110+
results.append({
111+
"type": "tool_result",
112+
"tool_use_id": block.id,
113+
"content": output,
114+
})
115+
return results
116+
117+
118+
def run_one_turn(state: LoopState) -> bool:
119+
response = client.messages.create(
120+
model=MODEL,
121+
system=SYSTEM,
122+
messages=state.messages,
123+
tools=TOOLS,
124+
max_tokens=8000,
125+
)
126+
state.messages.append({"role": "assistant", "content": response.content})
127+
128+
if response.stop_reason != "tool_use":
129+
state.transition_reason = None
130+
return False
131+
132+
results = execute_tool_calls(response.content)
133+
if not results:
134+
state.transition_reason = None
135+
return False
136+
137+
state.messages.append({"role": "user", "content": results})
138+
state.turn_count += 1
139+
state.transition_reason = "tool_result"
140+
return True
141+
142+
143+
def agent_loop(state: LoopState) -> None:
144+
while run_one_turn(state):
145+
pass
102146

103147

104148
if __name__ == "__main__":
@@ -110,11 +154,12 @@ def agent_loop(messages: list):
110154
break
111155
if query.strip().lower() in ("q", "exit", ""):
112156
break
157+
113158
history.append({"role": "user", "content": query})
114-
agent_loop(history)
115-
response_content = history[-1]["content"]
116-
if isinstance(response_content, list):
117-
for block in response_content:
118-
if hasattr(block, "text"):
119-
print(block.text)
159+
state = LoopState(messages=history)
160+
agent_loop(state)
161+
162+
final_text = extract_text(history[-1]["content"])
163+
if final_text:
164+
print(final_text)
120165
print()

agents/s02_tool_use.py

Lines changed: 73 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,11 @@
11
#!/usr/bin/env python3
22
# Harness: tool dispatch -- expanding what the model can reach.
33
"""
4-
s02_tool_use.py - Tools
4+
s02_tool_use.py - Tool dispatch + message normalization
55
6-
The agent loop from s01 didn't change. We just added tools to the array
7-
and a dispatch map to route calls.
8-
9-
+----------+ +-------+ +------------------+
10-
| User | ---> | LLM | ---> | Tool Dispatch |
11-
| prompt | | | | { |
12-
+----------+ +---+---+ | bash: run_bash |
13-
^ | read: run_read |
14-
| | write: run_wr |
15-
+----------+ edit: run_edit |
16-
tool_result| } |
17-
+------------------+
6+
The agent loop from s01 didn't change. We added tools to the dispatch map,
7+
and a normalize_messages() function that cleans up the message list before
8+
each API call.
189
1910
Key insight: "The loop didn't change at all. I just added tools."
2011
"""
@@ -91,6 +82,11 @@ def run_edit(path: str, old_text: str, new_text: str) -> str:
9182
return f"Error: {e}"
9283

9384

85+
# -- Concurrency safety classification --
86+
# Read-only tools can safely run in parallel; mutating tools must be serialized.
87+
CONCURRENCY_SAFE = {"read_file"}
88+
CONCURRENCY_UNSAFE = {"write_file", "edit_file"}
89+
9490
# -- The dispatch map: {tool_name: handler} --
9591
TOOL_HANDLERS = {
9692
"bash": lambda **kw: run_bash(kw["command"]),
@@ -111,10 +107,73 @@ def run_edit(path: str, old_text: str, new_text: str) -> str:
111107
]
112108

113109

110+
def normalize_messages(messages: list) -> list:
111+
"""Clean up messages before sending to the API.
112+
113+
Three jobs:
114+
1. Strip internal metadata fields the API doesn't understand
115+
2. Ensure every tool_use has a matching tool_result (insert placeholder if missing)
116+
3. Merge consecutive same-role messages (API requires strict alternation)
117+
"""
118+
cleaned = []
119+
for msg in messages:
120+
clean = {"role": msg["role"]}
121+
if isinstance(msg.get("content"), str):
122+
clean["content"] = msg["content"]
123+
elif isinstance(msg.get("content"), list):
124+
clean["content"] = [
125+
{k: v for k, v in block.items()
126+
if not k.startswith("_")}
127+
for block in msg["content"]
128+
if isinstance(block, dict)
129+
]
130+
else:
131+
clean["content"] = msg.get("content", "")
132+
cleaned.append(clean)
133+
134+
# Collect existing tool_result IDs
135+
existing_results = set()
136+
for msg in cleaned:
137+
if isinstance(msg.get("content"), list):
138+
for block in msg["content"]:
139+
if isinstance(block, dict) and block.get("type") == "tool_result":
140+
existing_results.add(block.get("tool_use_id"))
141+
142+
# Find orphaned tool_use blocks and insert placeholder results
143+
for msg in cleaned:
144+
if msg["role"] != "assistant" or not isinstance(msg.get("content"), list):
145+
continue
146+
for block in msg["content"]:
147+
if not isinstance(block, dict):
148+
continue
149+
if block.get("type") == "tool_use" and block.get("id") not in existing_results:
150+
cleaned.append({"role": "user", "content": [
151+
{"type": "tool_result", "tool_use_id": block["id"],
152+
"content": "(cancelled)"}
153+
]})
154+
155+
# Merge consecutive same-role messages
156+
if not cleaned:
157+
return cleaned
158+
merged = [cleaned[0]]
159+
for msg in cleaned[1:]:
160+
if msg["role"] == merged[-1]["role"]:
161+
prev = merged[-1]
162+
prev_c = prev["content"] if isinstance(prev["content"], list) \
163+
else [{"type": "text", "text": str(prev["content"])}]
164+
curr_c = msg["content"] if isinstance(msg["content"], list) \
165+
else [{"type": "text", "text": str(msg["content"])}]
166+
prev["content"] = prev_c + curr_c
167+
else:
168+
merged.append(msg)
169+
return merged
170+
171+
114172
def agent_loop(messages: list):
115173
while True:
116174
response = client.messages.create(
117-
model=MODEL, system=SYSTEM, messages=messages,
175+
model=MODEL, system=SYSTEM,
176+
messages=normalize_messages(messages),
118177
tools=TOOLS, max_tokens=8000,
119178
)
120179
messages.append({"role": "assistant", "content": response.content})

0 commit comments

Comments
 (0)