Advanced Self-Made Agent: Building a Smarter, Multi-Step AI Agent

Andreas Nigg
Andreas Nigg · Co-Founder, Pondhouse Data
Published Updated 6 min read
Advanced Self-Made Agent: Building a Smarter, Multi-Step AI Agent
On this page

This post turns a simple, framework-free AI agent into a multi-step agent. It adds three things: an AgentState enum for explicit state management, a loop that lets the agent think and act several times before answering, and a way to ask the user for clarification. The full Python code is at the end.

In our previous blog post on creating AI agents without frameworks, we built an agent by exposing tools like query_database and search_wikipedia directly to the LLM. That agent only needs to:

  1. Understand available tools
  2. Decide when and how to use them
  3. Maintain relevant context
  4. Arrange the final answer

That first design covered the basic loop of processing user input and optionally calling tools. The multi-step version goes further. It can:

  • Decide on its own whether it needs more information from the user
  • Run several steps of "thinking" before settling on a final answer
  • Exit cleanly when it reaches the maximum number of iterations

The new code relies on the same functions and tool definitions as the last post. What's new is the AgentState enum and the multi-step processing loop, which let the agent refine its answers. If you want to tune the instructions the agent gets in each of these iterations, have a look at DSPy: Build Better AI Systems with Automated Prompt Optimization.

Note: This is a continuation of our previous tutorial on building AI agents from scratch. If you haven't read it yet, start there to learn the basics before moving on to this agent.

What the multi-step agent adds

State management with an enum

The agent's states, THINKING, DONE, ERROR and NEED_MORE_INFO, live in an enum instead of loose local variables. At any point you know exactly where the agent is in the conversation cycle.

An iteration loop for complex reasoning

The agent repeats a think→act→evaluate cycle up to max_iterations times. That lets it handle more elaborate tasks, check partial results and try more than one line of reasoning.

Follow-up questions to the user

If the conversation is missing something essential, the agent returns a "need more info" response. The user can answer and the conversation continues, instead of ending in an error message.

The same tools as before

The agent still uses the tool descriptors from the first tutorial (query_database and search_wikipedia). The agent logic changed, the tools didn't.

How the multi-step agent's code is structured

Here is a conceptual overview before the full code. The agent is built from an enum, a multi-iteration loop and a separate step for each iteration.

  1. AgentState definition. The possible states are defined in an enum:

    • THINKING: the agent is still working out an answer or deciding on next steps
    • DONE: the agent has a final answer
    • ERROR: an error occurred or the agent couldn't continue
    • NEED_MORE_INFO: the agent needs clarification from the user before it can proceed
  2. Loop with max iterations. The previous agent ended after a single pass. This one can make repeated attempts at an answer:

    1while self.iteration_count < self.max_iterations:
    2 # 1. Agent decides next step
    3 # 2. Possibly calls tools
    4 # 3. Handles results or asks user for more info

    The loop keeps going until the agent has an answer, needs the user's input, or runs out of iterations.

  3. Asking for clarification. If the LLM realizes mid-conversation that it can't proceed with the data it has, it switches to NEED_MORE_INFO. When the user responds with more context, the loop resumes.

  4. Separate "think and act" step. The logic for choosing an action (TOOL_CALL, ASK_USER, FINAL_ANSWER) sits in its own _think_and_act() method. That keeps the main loop short and easy to debug.

Why multi-step agents are useful

Say a user asks your agent about a complex database topic:

  1. In the first iteration, the agent checks whether it already has the data it needs. If not, it calls query_database.
  2. The tool call might return partial or ambiguous data. The agent runs another iteration and decides whether to refine the query or ask the user a clarifying question.
  3. Only after several steps does the agent produce a final answer, or conclude that it needs more information.

The user doesn't get stuck with a half-finished reply or a single-step error message. The back-and-forth feels closer to talking to a person.

The agent still uses the tools from our previous blog post and keeps the framework-free approach, so debugging and extending it stays simple. What you gain is iterative reasoning. If you need reliable answers rather than one-shot replies, this loop is the next thing to build.

Full Python implementation of the multi-step agent

Below is a complete code example of the multi-step agent. It uses an enum for the possible states, a loop for multiple "thinking" iterations and a simple mechanism for asking the user for more information. The code is heavily commented, so you can copy it into your project and follow along.

Note: In the code example below, we use mock functions for the used tools (query_database and search_wikipedia). Please have a look at our previous blog post for the original tool definitions.

1from enum import Enum
2import time
3import json
4from typing import Any, Dict, Tuple
5from openai import OpenAI
6
7# Mocked tools for example purposes
8def query_database(query: str) -> str:
9 return json.dumps({"mock_result": f"Database queried with: {query}"})
10
11def search_wikipedia(query: str) -> str:
12 return json.dumps({"mock_result": f"Wikipedia searched for: {query}"})
13
14
15# Tool descriptors, the same as in the previous post
16tools = [
17 {
18 "type": "function",
19 "function": {
20 "name": "query_database",
21 "description": "Execute a PostgreSQL SELECT query and return the results in JSON format.",
22 "parameters": {
23 "type": "object",
24 "properties": {
25 "query": {
26 "type": "string",
27 "description": "The SQL SELECT query to execute.",
28 }
29 },
30 "required": ["query"],
31 "additionalProperties": False,
32 },
33 "strict": True,
34 },
35 },
36 {
37 "type": "function",
38 "function": {
39 "name": "search_wikipedia",
40 "description": "Search Wikipedia and return a concise summary.",
41 "parameters": {
42 "type": "object",
43 "properties": {
44 "query": {
45 "type": "string",
46 "description": "The topic to search for on Wikipedia",
47 }
48 },
49 "required": ["query"],
50 "additionalProperties": False,
51 },
52 "strict": True,
53 },
54 },
55]
56
57
58class AgentState(Enum):
59 THINKING = "thinking"
60 DONE = "done"
61 ERROR = "error"
62 NEED_MORE_INFO = "need_more_info"
63
64
65class Agent:
66 def __init__(self, max_iterations: int = 5, think_time: float = 0.5):
67 self.client = OpenAI()
68 self.max_iterations = max_iterations
69 self.think_time = think_time # Time between iterations
70 self.messages = []
71 self.iteration_count = 0
72
73 def process_with_loop(self, user_input: str) -> Dict:
74 """
75 Process user input with multiple iterations if needed.
76 Returns both final answer and execution trace.
77 """
78 self.iteration_count = 0
79 trace = []
80
81 # Initial prompt
82 self.messages.append({"role": "user", "content": user_input})
83
84 while self.iteration_count < self.max_iterations:
85 self.iteration_count += 1
86
87 try:
88 # Get agent's thoughts and next action
89 state, response = self._think_and_act()
90
91 # Record this iteration
92 trace.append(
93 {
94 "iteration": self.iteration_count,
95 "state": state.value,
96 "response": response,
97 }
98 )
99
100 # Handle different states
101 if state == AgentState.DONE:
102 return {
103 "status": "success",
104 "answer": response,
105 "iterations": trace,
106 "iteration_count": self.iteration_count,
107 }
108
109 elif state == AgentState.ERROR:
110 return {
111 "status": "error",
112 "error": response,
113 "iterations": trace,
114 "iteration_count": self.iteration_count,
115 }
116
117 elif state == AgentState.NEED_MORE_INFO:
118 return {
119 "status": "need_more_info",
120 "question": response,
121 "iterations": trace,
122 "iteration_count": self.iteration_count,
123 }
124
125 # Add thinking time between iterations
126 time.sleep(self.think_time)
127
128 except Exception as e:
129 return {
130 "status": "error",
131 "error": str(e),
132 "iterations": trace,
133 "iteration_count": self.iteration_count,
134 }
135
136 return {
137 "status": "max_iterations_reached",
138 "iterations": trace,
139 "iteration_count": self.iteration_count,
140 "final_state": response,
141 }
142
143 def _think_and_act(self) -> Tuple[AgentState, str]:
144 """
145 Single iteration of thinking and acting.
146 Returns state and response.
147 """
148 completion = self.client.chat.completions.create(
149 model="gpt-4o",
150 messages=[
151 *self.messages,
152 {
153 "role": "system",
154 "content": f"""
155 This is iteration {self.iteration_count} of {self.max_iterations}.
156 Determine if you:
157 1. Need to use tools to gather more information
158 2. Need to ask the user for clarification
159 3. Have enough information to provide final answer
160
161 Format your response as:
162 THOUGHT: your reasoning process
163 ACTION: TOOL_CALL or ASK_USER or FINAL_ANSWER
164 CONTENT: your tool call, question, or final answer
165 """,
166 },
167 ],
168 tools=tools,
169 )
170
171 response = completion.choices[0].message
172 self.messages.append(response)
173
174 # Parse the response (content is None when the model only returns tool calls)
175 content = response.content or ""
176 if response.tool_calls or "ACTION: TOOL_CALL" in content:
177 # Handle tool calls through function calling
178 if response.tool_calls:
179 tool_results = []
180 for tool_call in response.tool_calls:
181 result = self.execute_tool(tool_call)
182 tool_results.append(result)
183 self.messages.append(
184 {
185 "role": "tool",
186 "tool_call_id": tool_call.id,
187 "content": result,
188 }
189 )
190 return AgentState.THINKING, "Executed tools: " + ", ".join(tool_results)
191
192 elif "ACTION: ASK_USER" in content:
193 # Extract question from CONTENT section
194 question = content.split("CONTENT:")[1].strip()
195 return AgentState.NEED_MORE_INFO, question
196
197 elif "ACTION: FINAL_ANSWER" in content:
198 # Extract final answer from CONTENT section
199 answer = content.split("CONTENT:")[1].strip()
200 return AgentState.DONE, answer
201
202 return AgentState.ERROR, "Could not determine next action"
203
204 def execute_tool(self, tool_call: Any) -> str:
205 """
206 Execute a tool based on the LLM's decision.
207
208 Args:
209 tool_call: The function call object from OpenAI's API
210
211 Returns:
212 str: JSON-formatted result of the tool execution
213 """
214 try:
215 # Extract function details
216 function_name = tool_call.function.name
217 function_args = json.loads(tool_call.function.arguments)
218
219 # Log tool usage (helpful for debugging)
220 print(f"Executing tool: {function_name} with args: {function_args}")
221
222 # Execute the appropriate tool
223 if function_name == "query_database":
224 result = query_database(function_args["query"])
225 elif function_name == "search_wikipedia":
226 result = search_wikipedia(function_args["query"])
227 else:
228 result = json.dumps({"error": f"Unknown tool: {function_name}"})
229
230 # Log tool result (helpful for debugging)
231 print(f"Tool result: {result}")
232
233 return result
234
235 except json.JSONDecodeError:
236 return json.dumps({"error": "Failed to parse tool arguments"})
237 except Exception as e:
238 return json.dumps({"error": f"Tool execution failed: {str(e)}"})
239
240
241# Usage example:
242def interact_with_agent():
243 agent = Agent(max_iterations=5)
244
245 while True:
246 user_input = input("\nYour question (or 'quit' to exit): ")
247 if user_input.lower() == "quit":
248 break
249
250 result = agent.process_with_loop(user_input)
251
252 if result["status"] == "success":
253 print(f"\nAnswer: {result['answer']}")
254 elif result["status"] == "need_more_info":
255 print(f"\nNeed more information: {result['question']}")
256 else:
257 print(f"\nError or max iterations reached: {result['status']}")
258
259 # Optional: Show iteration trace
260 print("\nExecution trace:")
261 for step in result["iterations"]:
262 print(f"\nIteration {step['iteration']} ({step['state']}):")
263 print(step["response"])

To run the agent, call interact_with_agent(). Then type questions or statements and watch how the agent processes them over several iterations. You can extend the code with more complex tools, additional states or more advanced reasoning logic.

How each part of the agent code works

  1. AgentState enum. Defines the states the agent can be in at the end of each iteration:

    • THINKING: the agent is still deciding its next step.
    • DONE: there is a final answer for the user.
    • ERROR: the agent hit a problem that prevents further progress.
    • NEED_MORE_INFO: the agent can't proceed without more details from the user.
  2. process_with_loop() is the main method and handles the "thinking" iterations. It appends the user input to the conversation, calls _think_and_act() in each iteration and checks the returned state. Then it asks for more info, returns a final answer, or stops once the maximum number of iterations is reached.

  3. _think_and_act() runs once per iteration. It passes the conversation so far to the LLM, parses the "ACTION:" directive in the reply and acts on it: it calls the necessary tool, asks the user for more data or returns a final answer.

  4. execute_tool() runs when the LLM requests a tool call with "TOOL_CALL". It parses the arguments and runs the matching Python function (for example, searching Wikipedia or querying a database). It returns a JSON string with the result or an error message.

  5. interact_with_agent() is a basic read-eval-print loop (REPL) that shows how the agent handles real user input. It feeds each user query into process_with_loop(), then prints the final or partial result and an optional iteration trace.

From here you can add more logic, such as real tool integrations or more advanced planning. The main benefit is control: you decide the logic, error handling and iteration loops, rather than a large framework deciding for you.

If your agent needs external knowledge, How to: RAG with Azure AI Search shows how connecting additional data sources improves its accuracy and context.

Next step: giving the agent memory

This agent gives you a lot of flexibility and is a good starting point for more complex AI agents. The most obvious improvement left is memory, so the agent can remember previous interactions and use them in its answers. We cover that in the next tutorial, Enhancing Self-Made Agent with Memory.

Before you put an agent like this in front of users, read our best practices for building high-quality AI agent systems, which cover architecture, testing and error handling.


Interested in building high-quality AI agent systems?

We prepared a comprehensive guide based on cutting-edge research for how to build robust, reliable AI agent systems that actually work in production. This guide covers:

  • Understanding the 14 systematic failure modes in multi-agent systems
  • Evidence-based best practices for agent design
  • Structured communication protocols and verification mechanisms

Further reading

Andreas Nigg

About the author

Andreas Nigg

Co-Founder, Pondhouse Data

Andreas has spent more than eight years building data applications. At Pondhouse Data he designs and ships AI systems and integrates them into complex, real-world environments.