Stage 05 — AI Agents and Agentic Workflows

Building AI That Takes Action  ·  Technical + Architecture  ·  ⏱ 10–14 hours

Learning Objectives

By the end of this stage you will be able to:

  • Explain the agent loop and how LLMs make decisions sequentially
  • Build tool-using agents from scratch with Anthropic's tool use API
  • Implement ReAct pattern (Reason + Act) for multi-step tasks
  • Design multi-agent systems where agents collaborate
  • Handle agent failures, loops, and safety guardrails
  • Use LangChain/LangGraph for production agent workflows
  • Understand when to use agents vs. simpler approaches

Section 1: What Is an AI Agent?

An agent is an LLM that can take actions in the world — not just respond with text, but call functions, read files, search the web, execute code, and interact with external services. Crucially, agents operate in a loop: they reason about what to do, take an action, observe the result, and decide what to do next.

The Agent Loop

┌─────────────────────────────────────┐
│              AGENT LOOP             │
│                                     │
│  User Task ──> Think: What do I     │
│                need to do next?     │
│                     │               │
│                     ▼               │
│              Choose Action          │
│              (tool call)            │
│                     │               │
│                     ▼               │
│           Execute Action            │
│           (run the tool)            │
│                     │               │
│                     ▼               │
│          Observe Result             │
│                     │               │
│                     ▼               │
│         Done? ──No─> Think again    │
│           │                         │
│          Yes                        │
│           │                         │
│           ▼                         │
│     Return Final Answer             │
└─────────────────────────────────────┘

This loop runs until the model decides it has enough information to answer, or until a stop condition (max iterations, error, user interrupt) is hit.


Section 2: Why Agents Are Powerful (and Dangerous)

What Agents Enable

  • Complex reasoning: Multi-step research that requires synthesizing multiple sources
  • Dynamic tool use: Choosing which tool to call based on intermediate results
  • Long-horizon tasks: Tasks that require many steps to complete (e.g., "Research competitors and write a report")
  • Parallelization: Multiple agents working on different subtasks simultaneously

Why Agents Fail

  • Loops: Agent calls the same tool repeatedly, making no progress
  • Hallucinated tool calls: Model invents tool parameters that don't exist
  • Goal drift: Agent pursues an intermediate goal and loses sight of the original task
  • Cost explosion: An unconstrained agent can make hundreds of API calls
  • Security: Prompt injection attacks through tool results

When NOT to Use Agents

Agents are not the default choice. Use them only when:

  • The task genuinely requires multiple steps where later steps depend on earlier results
  • You don't know in advance which sequence of tools to call
  • Single LLM calls with long prompts can't solve it

For most tasks, a simple prompt or a two-step chain is better.


Section 3: Building an Agent from Scratch

The Core Pattern: ReAct

ReAct (Reason + Act) prompts the model to alternately reason ("Thought: I need to find...") and act ("Action: search(...)") until it reaches a final answer.

import anthropic
import json
import re
from typing import Callable

client = anthropic.Anthropic()

# Tool definitions
tools = [
    {
        "name": "web_search",
        "description": "Search the web for current information. Use for recent events, current prices, or facts you're uncertain about.",
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "The search query"
                }
            },
            "required": ["query"]
        }
    },
    {
        "name": "calculator",
        "description": "Evaluate a mathematical expression. Input must be a valid Python math expression.",
        "input_schema": {
            "type": "object",
            "properties": {
                "expression": {
                    "type": "string",
                    "description": "Mathematical expression to evaluate, e.g. '2 ** 10 + 500'"
                }
            },
            "required": ["expression"]
        }
    },
    {
        "name": "read_file",
        "description": "Read the contents of a file from disk.",
        "input_schema": {
            "type": "object",
            "properties": {
                "filepath": {
                    "type": "string",
                    "description": "Relative path to the file"
                }
            },
            "required": ["filepath"]
        }
    }
]


# Tool implementations
def web_search(query: str) -> str:
    """Simulated web search — replace with real API (SerpAPI, Tavily, etc.)"""
    # In production: call Tavily, SerpAPI, or similar
    return f"[Simulated search results for '{query}': Results would appear here from a real search API]"


def calculator(expression: str) -> str:
    """Safe math evaluation."""
    # Allowlist safe operations only
    allowed = set("0123456789.+-*/()** ")
    if not all(c in allowed for c in expression):
        return "Error: Expression contains disallowed characters"
    try:
        result = eval(expression, {"__builtins__": {}})
        return str(result)
    except Exception as e:
        return f"Error: {e}"


def read_file(filepath: str) -> str:
    """Read a file, with path sanitization."""
    # Sanitize to prevent directory traversal
    safe_path = filepath.lstrip("/").replace("..", "")
    try:
        with open(safe_path, "r") as f:
            return f.read()[:5000]  # Limit output
    except FileNotFoundError:
        return f"File not found: {safe_path}"
    except Exception as e:
        return f"Error reading file: {e}"


tool_functions: dict[str, Callable] = {
    "web_search": web_search,
    "calculator": calculator,
    "read_file": read_file
}


def run_agent(task: str, max_iterations: int = 10) -> str:
    """Run agent loop until task is complete or max iterations reached."""
    
    system = """You are a capable AI assistant that can use tools to complete tasks.

Think step-by-step. Use tools when you need to look up information or perform calculations.
When you have enough information to answer completely, provide your final answer directly without using any tools."""

    messages = [{"role": "user", "content": task}]
    iteration = 0
    
    print(f"Task: {task}")
    print("=" * 60)
    
    while iteration < max_iterations:
        iteration += 1
        print(f"\n[Iteration {iteration}]")
        
        response = client.messages.create(
            model="claude-sonnet-4-5",
            max_tokens=2048,
            system=system,
            tools=tools,
            messages=messages
        )
        
        # Check stop reason
        if response.stop_reason == "end_turn":
            # Model is done reasoning
            final_text = ""
            for block in response.content:
                if hasattr(block, "text"):
                    final_text += block.text
            print(f"\nFinal Answer:\n{final_text}")
            return final_text
        
        elif response.stop_reason == "tool_use":
            # Process tool calls
            messages.append({"role": "assistant", "content": response.content})
            
            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    tool_name = block.name
                    tool_input = block.input
                    tool_use_id = block.id
                    
                    print(f"  → Calling: {tool_name}({json.dumps(tool_input, indent=None)})")
                    
                    # Execute the tool
                    if tool_name in tool_functions:
                        result = tool_functions[tool_name](**tool_input)
                    else:
                        result = f"Error: Unknown tool '{tool_name}'"
                    
                    print(f"  ← Result: {str(result)[:200]}")
                    
                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": tool_use_id,
                        "content": str(result)
                    })
            
            messages.append({"role": "user", "content": tool_results})
        
        else:
            print(f"Unexpected stop reason: {response.stop_reason}")
            break
    
    return "Max iterations reached. Task may be incomplete."


# Test the agent
result = run_agent("What is 2^32, and is that number larger or smaller than the number of people on Earth?")

Section 4: Multi-Agent Systems

Complex tasks can be broken into subtasks handled by specialized agents.

Orchestrator-Worker Pattern

from dataclasses import dataclass
from typing import Optional
import anthropic

client = anthropic.Anthropic()


@dataclass
class AgentResult:
    agent_name: str
    task: str
    result: str
    success: bool


def create_specialist_agent(
    name: str,
    specialty: str,
    available_tools: list[dict]
) -> Callable[[str], AgentResult]:
    """Factory function that creates a specialized agent."""
    
    def run(task: str) -> AgentResult:
        system = f"""You are a specialized AI agent: {name}.
Your specialty: {specialty}
Complete the given task using your tools. Be thorough and precise."""
        
        try:
            # Simplified agent call (single-turn for this example)
            response = client.messages.create(
                model="claude-sonnet-4-5",
                max_tokens=2048,
                system=system,
                tools=available_tools if available_tools else anthropic.NOT_GIVEN,
                messages=[{"role": "user", "content": task}]
            )
            result_text = response.content[0].text if response.content else ""
            return AgentResult(agent_name=name, task=task, result=result_text, success=True)
        except Exception as e:
            return AgentResult(agent_name=name, task=task, result=str(e), success=False)
    
    return run


def orchestrator_agent(user_request: str) -> str:
    """Orchestrator that delegates to specialist agents."""
    
    # Define specialist agents
    researcher = create_specialist_agent(
        "ResearchAgent",
        "Finding and synthesizing information from multiple sources",
        [tools[0]]  # web_search only
    )
    
    analyst = create_specialist_agent(
        "AnalysisAgent", 
        "Data analysis, calculations, and logical reasoning",
        [tools[1]]  # calculator only
    )
    
    writer = create_specialist_agent(
        "WriterAgent",
        "Writing clear, well-structured reports and summaries",
        []  # No tools needed for writing
    )
    
    # Step 1: Orchestrator plans the task
    plan_response = client.messages.create(
        model="claude-sonnet-4-5",
        max_tokens=1024,
        system="""You are an orchestrator that breaks complex tasks into subtasks for specialist agents.
Available agents:
- ResearchAgent: Finds information (has web search)
- AnalysisAgent: Does calculations and data analysis
- WriterAgent: Writes reports and summaries

Return a JSON list of subtasks: [{"agent": "name", "task": "description"}]""",
        messages=[{"role": "user", "content": f"Break this into subtasks: {user_request}"}]
    )
    
    # Parse plan (simplified — in production, use JSON mode)
    plan_text = plan_response.content[0].text
    
    # Step 2: Execute subtasks
    results = []
    
    # For demo, run a fixed sequence
    research_result = researcher(f"Research information needed for: {user_request}")
    results.append(research_result)
    
    # Step 3: Synthesize results
    synthesis_input = f"""Original request: {user_request}

Research findings:
{research_result.result}

Write a comprehensive response synthesizing these findings."""
    
    final_result = writer(synthesis_input)
    return final_result.result


# Example
result = orchestrator_agent("Analyze the pros and cons of Pinecone vs ChromaDB for a startup building a RAG system")
print(result)

Section 5: LangChain and LangGraph

For production agents, use LangChain's ecosystem instead of raw API calls.

Installation

pip install langchain langchain-anthropic langgraph langchain-community

LangChain Agent (Simple)

from langchain_anthropic import ChatAnthropic
from langchain_core.tools import tool
from langchain.agents import create_tool_calling_agent, AgentExecutor
from langchain_core.prompts import ChatPromptTemplate

# Define tools with decorators
@tool
def search_courses(query: str) -> str:
    """Search TechNodeX courses for relevant content."""
    # Connect to your RAG system here
    return f"Course results for '{query}': Python Security Stage 3 covers {query}"

@tool
def get_course_progress(course_name: str, user_id: str) -> str:
    """Get a user's progress in a specific course."""
    return f"User {user_id} has completed 3/6 stages of {course_name}"

# Create agent
llm = ChatAnthropic(model="claude-sonnet-4-5")
tools = [search_courses, get_course_progress]

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a helpful TechNodeX learning assistant."),
    ("human", "{input}"),
    ("placeholder", "{agent_scratchpad}")
])

agent = create_tool_calling_agent(llm, tools, prompt)
agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True, max_iterations=5)

result = agent_executor.invoke({
    "input": "I want to learn Python for security. What stage should I start at?"
})
print(result["output"])

LangGraph for Complex Workflows

LangGraph builds on LangChain to create stateful, multi-step agent workflows as graphs.

from typing import TypedDict, Annotated
from langgraph.graph import StateGraph, END
from langgraph.prebuilt import ToolNode
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import HumanMessage, AIMessage, BaseMessage
import operator

# Define the agent state
class AgentState(TypedDict):
    messages: Annotated[list[BaseMessage], operator.add]
    current_step: str
    research_results: list[str]
    final_answer: str

# Define nodes
llm = ChatAnthropic(model="claude-sonnet-4-5")

def research_node(state: AgentState) -> dict:
    """Node: Research phase."""
    messages = state["messages"]
    response = llm.invoke(messages + [
        HumanMessage(content="What information do you need to answer this? List 3 specific things to research.")
    ])
    return {
        "messages": [response],
        "current_step": "research",
        "research_results": [response.content]
    }

def answer_node(state: AgentState) -> dict:
    """Node: Final answer synthesis."""
    all_context = "\n".join(state["research_results"])
    response = llm.invoke([
        HumanMessage(content=f"Based on this research:\n{all_context}\n\nAnswer the original question comprehensively.")
    ])
    return {
        "messages": [response],
        "current_step": "complete",
        "final_answer": response.content
    }

def should_continue(state: AgentState) -> str:
    """Conditional edge: decide next step."""
    if state.get("current_step") == "research":
        return "answer"
    return END

# Build graph
workflow = StateGraph(AgentState)
workflow.add_node("research", research_node)
workflow.add_node("answer", answer_node)

workflow.set_entry_point("research")
workflow.add_conditional_edges("research", should_continue, {"answer": "answer", END: END})
workflow.add_edge("answer", END)

app = workflow.compile()

# Run
result = app.invoke({
    "messages": [HumanMessage(content="What are the best practices for securing a Python Flask API?")],
    "current_step": "start",
    "research_results": [],
    "final_answer": ""
})

print(result["final_answer"])

Section 6: Agent Safety and Guardrails

Production agents need safety mechanisms.

class SafeAgent:
    """Agent wrapper with safety guardrails."""
    
    def __init__(self, max_iterations: int = 10, max_cost_usd: float = 0.50):
        self.client = anthropic.Anthropic()
        self.max_iterations = max_iterations
        self.max_cost_usd = max_cost_usd
        self.total_cost = 0.0
        self.blocked_tools = set()
        
    def add_cost(self, input_tokens: int, output_tokens: int) -> None:
        """Track API costs."""
        cost = (input_tokens / 1e6 * 3.0) + (output_tokens / 1e6 * 15.0)
        self.total_cost += cost
        if self.total_cost > self.max_cost_usd:
            raise RuntimeError(f"Cost limit exceeded: ${self.total_cost:.3f} > ${self.max_cost_usd}")
    
    def validate_tool_call(self, tool_name: str, tool_input: dict) -> None:
        """Validate tool calls before execution."""
        if tool_name in self.blocked_tools:
            raise ValueError(f"Tool '{tool_name}' is blocked")
        
        # Prevent directory traversal in file operations
        if tool_name == "read_file":
            filepath = tool_input.get("filepath", "")
            if ".." in filepath or filepath.startswith("/etc") or filepath.startswith("/root"):
                raise ValueError(f"Blocked file path: {filepath}")
        
        # Rate limit search queries
        if tool_name == "web_search":
            query = tool_input.get("query", "")
            if len(query) > 500:
                raise ValueError("Search query too long")
    
    def sanitize_tool_result(self, result: str) -> str:
        """Sanitize tool results to prevent prompt injection."""
        # Remove common injection patterns
        injection_patterns = [
            "Ignore previous instructions",
            "Disregard your system prompt",
            "You are now",
            "New instruction:"
        ]
        for pattern in injection_patterns:
            if pattern.lower() in result.lower():
                return f"[Content filtered: potential prompt injection detected]"
        
        # Truncate very long results
        if len(result) > 10000:
            return result[:10000] + "\n[Result truncated for safety]"
        
        return result

Checkpoint Assessment

  1. What is the "agent loop" and what are its three key steps?
  2. When should you NOT use an agent? Give a specific example.
  3. Explain prompt injection in the context of tool use. How does a malicious search result attack an agent?
  4. What is the difference between an orchestrator agent and a worker agent?
  5. Your agent is running in an infinite loop, calling the same tool repeatedly. What guard would prevent this?
  6. Compare a ReAct pattern agent with a LangGraph workflow. When would you choose each?

Project: Research Assistant Agent

Build a multi-tool agent that:

  1. Accepts a research question from the user
  2. Plans the research approach (what to look up and in what order)
  3. Executes the plan using: web_search, read_file (for local docs), calculator
  4. Synthesizes findings into a structured research brief with sources
  5. Includes cost tracking — report total tokens and USD cost
  6. Safety features: max 15 iterations, max $1.00 cost, blocks dangerous file paths

Bonus challenge: Add a --multi-agent flag that splits the work between a ResearchAgent and a WriterAgent.


What's Next

Stage 6 is the Capstone — you'll build and deploy a complete, production-ready AI application using everything you've learned: APIs, RAG, and agents working together.

Lock In Founding Member Access

Get full access to every course on TechNodeX — AI, cybersecurity, Python, and everything we build next. $9/month, price locked forever.

Become a Founding Member →