Skip to content
Back to Blog
1 min read

Building AI Agents: From Chatbots to Autonomous Assistants

Function calling, released last week with the 0613 model versions, gives AI agents a proper foundation — and I’ve been rebuilding some agent prototypes to take advantage of it. The core agent loop is: the model reasons about which tool to call, you execute the tool and return the result, the model synthesises a response or decides to call another tool. What makes an agent work well isn’t just the reasoning model — it’s the quality of the tool definitions, the specificity of the system prompt about when and how to use each tool, and the error handling when tools fail. I’ve been working through ReAct (Reason + Act), Plan-and-Execute, and multi-agent patterns this week and the architectures feel much more tractable now that function calling removes the brittle text-parsing layer from the loop.

Agent Architecture

┌─────────────────────────────────────────────────────┐
│                   AI Agent                           │
├─────────────────────────────────────────────────────┤
│                                                      │
│  ┌─────────────────────────────────────────────────┐│
│  │              Perception Layer                    ││
│  │  - User input parsing                            ││
│  │  - Context understanding                         ││
│  │  - Intent recognition                            ││
│  └─────────────────────────────────────────────────┘│
│                        │                            │
│  ┌─────────────────────▼───────────────────────────┐│
│  │              Reasoning Layer                     ││
│  │  - Task decomposition                            ││
│  │  - Planning                                      ││
│  │  - Decision making                               ││
│  └─────────────────────────────────────────────────┘│
│                        │                            │
│  ┌─────────────────────▼───────────────────────────┐│
│  │              Action Layer                        ││
│  │  - Tool selection                                ││
│  │  - Function execution                            ││
│  │  - Result processing                             ││
│  └─────────────────────────────────────────────────┘│
│                        │                            │
│  ┌─────────────────────▼───────────────────────────┐│
│  │              Memory Layer                        ││
│  │  - Conversation history                          ││
│  │  - Knowledge retrieval                           ││
│  │  - Learning from interactions                    ││
│  └─────────────────────────────────────────────────┘│
│                                                      │
└─────────────────────────────────────────────────────┘

ReAct Pattern (Reasoning + Acting)

class ReActAgent:
    """Agent that reasons about actions before taking them"""

    def __init__(self, client, tools):
        self.client = client
        self.tools = tools
        self.max_steps = 10

    def run(self, task: str) -> str:
        """Execute task using ReAct pattern"""

        system_prompt = """You are an AI assistant that solves problems step by step.

For each step, you should:
1. THOUGHT: Think about what you need to do next
2. ACTION: Choose an action to take (or FINISH if done)
3. OBSERVATION: See the result of your action

Available actions:
{tools}

Always follow this format:
THOUGHT: [your reasoning]
ACTION: [tool_name](arguments)
or
THOUGHT: [your reasoning]
ACTION: FINISH(final_answer)
"""

        tools_desc = "\n".join([
            f"- {name}: {tool['description']}"
            for name, tool in self.tools.items()
        ])

        messages = [
            {"role": "system", "content": system_prompt.format(tools=tools_desc)},
            {"role": "user", "content": f"Task: {task}"}
        ]

        for step in range(self.max_steps):
            response = self.client.chat.completions.create(
                model="gpt-4",
                messages=messages,
                temperature=0
            )

            assistant_response = response.choices[0].message.content
            messages.append({"role": "assistant", "content": assistant_response})

            # Parse response
            if "ACTION: FINISH" in assistant_response:
                # Extract final answer
                final_answer = self.extract_finish(assistant_response)
                return final_answer

            # Execute action
            action = self.parse_action(assistant_response)
            if action:
                result = self.execute_action(action["tool"], action["args"])
                observation = f"OBSERVATION: {result}"
                messages.append({"role": "user", "content": observation})
            else:
                messages.append({"role": "user", "content": "OBSERVATION: Could not parse action. Please try again."})

        return "Agent reached maximum steps without completing the task."

    def parse_action(self, response: str) -> dict:
        """Parse action from response"""
        import re
        match = re.search(r'ACTION:\s*(\w+)\((.*?)\)', response)
        if match:
            return {
                "tool": match.group(1),
                "args": match.group(2)
            }
        return None

    def execute_action(self, tool_name: str, args: str) -> str:
        """Execute the specified tool"""
        if tool_name not in self.tools:
            return f"Unknown tool: {tool_name}"

        try:
            return self.tools[tool_name]["function"](args)
        except Exception as e:
            return f"Error executing {tool_name}: {str(e)}"

    def extract_finish(self, response: str) -> str:
        """Extract final answer from FINISH action"""
        import re
        match = re.search(r'FINISH\((.*?)\)', response, re.DOTALL)
        return match.group(1) if match else response

Plan-and-Execute Pattern

class PlanExecuteAgent:
    """Agent that creates a plan then executes it"""

    def __init__(self, client, tools):
        self.client = client
        self.tools = tools

    def create_plan(self, task: str) -> list:
        """Create a plan for the task"""

        planning_prompt = f"""Create a step-by-step plan to accomplish this task:

Task: {task}

Available tools:
{self._format_tools()}

Return a JSON array of steps, each with:
- step_number: int
- description: what this step accomplishes
- tool: which tool to use (or "none" for reasoning)
- inputs: what inputs are needed

Example:
[
  {{"step_number": 1, "description": "Search for...", "tool": "search", "inputs": "query"}},
  {{"step_number": 2, "description": "Process...", "tool": "none", "inputs": "step 1 result"}}
]
"""

        response = self.client.chat.completions.create(
            model="gpt-4",
            messages=[{"role": "user", "content": planning_prompt}],
            temperature=0
        )

        plan_text = response.choices[0].message.content
        return json.loads(plan_text)

    def execute_plan(self, plan: list, task: str) -> str:
        """Execute the plan step by step"""

        results = {}
        messages = [
            {"role": "system", "content": "You are executing a plan step by step."},
            {"role": "user", "content": f"Original task: {task}"}
        ]

        for step in plan:
            step_num = step["step_number"]
            tool_name = step.get("tool")

            if tool_name and tool_name != "none":
                # Execute tool
                tool_input = self._resolve_inputs(step["inputs"], results)
                result = self.tools[tool_name]["function"](tool_input)
                results[f"step_{step_num}"] = result
                messages.append({
                    "role": "assistant",
                    "content": f"Step {step_num}: {step['description']}\nResult: {result}"
                })
            else:
                # Reasoning step
                reasoning_prompt = f"""
Step {step_num}: {step['description']}
Previous results: {json.dumps(results)}
What is the conclusion for this step?
"""
                response = self.client.chat.completions.create(
                    model="gpt-4",
                    messages=messages + [{"role": "user", "content": reasoning_prompt}],
                    temperature=0
                )
                result = response.choices[0].message.content
                results[f"step_{step_num}"] = result

        # Generate final answer
        final_prompt = f"""
Task: {task}
Execution results: {json.dumps(results, indent=2)}

Based on these results, provide the final answer.
"""
        final_response = self.client.chat.completions.create(
            model="gpt-4",
            messages=[{"role": "user", "content": final_prompt}],
            temperature=0
        )

        return final_response.choices[0].message.content

    def run(self, task: str) -> str:
        """Create plan and execute"""
        print("Creating plan...")
        plan = self.create_plan(task)
        print(f"Plan created with {len(plan)} steps")

        print("Executing plan...")
        result = self.execute_plan(plan, task)

        return result

    def _format_tools(self) -> str:
        return "\n".join([
            f"- {name}: {tool['description']}"
            for name, tool in self.tools.items()
        ])

    def _resolve_inputs(self, inputs: str, results: dict) -> str:
        """Resolve references to previous step results"""
        for key, value in results.items():
            inputs = inputs.replace(f"${{{key}}}", str(value))
        return inputs

Memory-Augmented Agent

class MemoryAugmentedAgent:
    """Agent with short-term and long-term memory"""

    def __init__(self, client, vector_store):
        self.client = client
        self.vector_store = vector_store
        self.short_term_memory = []  # Recent conversation
        self.working_memory = {}  # Current task context

    def add_to_long_term_memory(self, content: str, metadata: dict = None):
        """Store information in vector database"""
        embedding = self._get_embedding(content)
        self.vector_store.add(
            content=content,
            embedding=embedding,
            metadata=metadata or {}
        )

    def recall_from_memory(self, query: str, k: int = 5) -> list:
        """Retrieve relevant memories"""
        query_embedding = self._get_embedding(query)
        results = self.vector_store.search(query_embedding, k=k)
        return results

    def chat(self, user_message: str) -> str:
        """Chat with memory retrieval"""

        # Add to short-term memory
        self.short_term_memory.append({
            "role": "user",
            "content": user_message,
            "timestamp": datetime.now().isoformat()
        })

        # Retrieve relevant long-term memories
        memories = self.recall_from_memory(user_message)
        memory_context = "\n".join([m["content"] for m in memories])

        # Prepare context
        system_prompt = f"""You are a helpful assistant with memory of past interactions.

Relevant memories from past conversations:
{memory_context}

Recent conversation:
{self._format_short_term_memory()}

Use your memories to provide personalized, contextual responses.
"""

        messages = [
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_message}
        ]

        response = self.client.chat.completions.create(
            model="gpt-4",
            messages=messages,
            temperature=0.7
        )

        assistant_response = response.choices[0].message.content

        # Add response to short-term memory
        self.short_term_memory.append({
            "role": "assistant",
            "content": assistant_response,
            "timestamp": datetime.now().isoformat()
        })

        # Optionally save to long-term memory
        self._consider_long_term_storage(user_message, assistant_response)

        return assistant_response

    def _format_short_term_memory(self) -> str:
        """Format recent conversation for context"""
        recent = self.short_term_memory[-10:]  # Last 10 messages
        return "\n".join([
            f"{m['role']}: {m['content']}"
            for m in recent
        ])

    def _consider_long_term_storage(self, user_msg: str, assistant_msg: str):
        """Decide if interaction should be stored long-term"""
        # Store if contains facts, preferences, or important info
        importance_prompt = f"""
Evaluate if this exchange contains information worth remembering long-term
(user preferences, facts about user, important decisions, etc.)

User: {user_msg}
Assistant: {assistant_msg}

Return JSON: {{"store": true/false, "summary": "brief summary if storing"}}
"""
        # ... evaluate and store if needed

    def _get_embedding(self, text: str) -> list:
        response = self.client.embeddings.create(
            model="text-embedding-ada-002",
            input=text
        )
        return response.data[0].embedding

Best Practices

agent_best_practices = {
    "design": [
        "Start simple, add complexity as needed",
        "Define clear boundaries for agent capabilities",
        "Implement graceful degradation",
        "Always have human escalation path"
    ],
    "safety": [
        "Validate all tool inputs",
        "Limit tool permissions to minimum needed",
        "Log all actions for auditing",
        "Implement rate limiting"
    ],
    "reliability": [
        "Handle API errors gracefully",
        "Implement retry with backoff",
        "Set maximum iteration limits",
        "Validate outputs before returning"
    ],
    "user_experience": [
        "Provide progress updates for long tasks",
        "Explain reasoning when appropriate",
        "Ask for clarification when uncertain",
        "Summarize actions taken"
    ]
}

Building effective AI agents requires careful architecture. Tomorrow, I will cover tool use patterns in more depth.

Resources

Michael John Peña

Michael John Peña

Senior Data Engineer based in Sydney. Writing about data, cloud, and technology.