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"""
44s01_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
2718import os
2819import subprocess
20+ from dataclasses import dataclass
2921
3022try :
3123 import readline
4941client = Anthropic (base_url = os .getenv ("ANTHROPIC_BASE_URL" ))
5042MODEL = 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
5449TOOLS = [{
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" }},
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+
6568def 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
104148if __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 ()
0 commit comments