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
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.

499 changes: 193 additions & 306 deletions README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion agents/__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.
157 changes: 101 additions & 56 deletions agents/s01_agent_loop.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,23 @@
#!/usr/bin/env python3
# Harness: the loop -- the model's first connection to the real world.
# Harness: the loop -- keep feeding real tool results back into the model.
"""
s01_agent_loop.py - The Agent Loop

The entire secret of an AI coding agent in one pattern:

while stop_reason == "tool_use":
response = LLM(messages, tools)
execute tools
append results

+----------+ +-------+ +---------+
| User | ---> | LLM | ---> | Tool |
| prompt | | | | execute |
+----------+ +---+---+ +----+----+
^ |
| tool_result |
+---------------+
(loop continues)

This is the core loop: feed tool results back to the model
until the model decides to stop. Production agents layer
policy, hooks, and lifecycle controls on top.
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
Expand All @@ -49,11 +41,14 @@
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 solve tasks. Act, don't explain."
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.",
"description": "Run a shell command in the current workspace.",
"input_schema": {
"type": "object",
"properties": {"command": {"type": "string"}},
Expand All @@ -62,43 +57,92 @@
}]


@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(d in command for d in dangerous):
if any(item in command for item in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(command, shell=True, cwd=os.getcwd(),
capture_output=True, text=True, timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
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}"


# -- The core pattern: a while loop that calls tools until the model stops --
def agent_loop(messages: list):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
# Append assistant turn
messages.append({"role": "assistant", "content": response.content})
# If the model didn't call a tool, we're done
if response.stop_reason != "tool_use":
return
# Execute each tool call, collect results
results = []
for block in response.content:
if block.type == "tool_use":
print(f"\033[33m$ {block.input['command']}\033[0m")
output = run_bash(block.input["command"])
print(output[:200])
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": output})
messages.append({"role": "user", "content": results})
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__":
Expand All @@ -110,11 +154,12 @@ def agent_loop(messages: list):
break
if query.strip().lower() in ("q", "exit", ""):
break

history.append({"role": "user", "content": query})
agent_loop(history)
response_content = history[-1]["content"]
if isinstance(response_content, list):
for block in response_content:
if hasattr(block, "text"):
print(block.text)
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
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