Welcome to Day 12 of #30DaysOfLangChain! Over the past two days, we’ve explored the foundations of LangGraph: understanding nodes, edges, state, and the crucial ability to create conditional paths and loops. Today, we’re bringing it all together to build our first truly autonomous agent powered by LangGraph.

An autonomous agent, in this context, is an LLM-based system that can:

  1. Understand a complex request.
  2. Reason about the steps needed to fulfill it.
  3. Decide which tools to use (or if no tool is needed).
  4. Execute those tools.
  5. Observe the results.
  6. Iterate on this process until the task is complete.

LangGraph provides the perfect canvas for orchestrating this kind of intelligent, iterative behavior.

Components of Our Autonomous Agent

Our LangGraph agent will typically consist of:

  1. AgentState: The central data store, primarily holding messages that form the conversation history and interaction log (user input, LLM thoughts, tool calls, tool outputs, final answers).
  2. LLM (The Brain): A large language model capable of tool calling (e.g., OpenAI models or specifically fine-tuned Ollama models). Its role is to generate thoughts, actions (tool calls), or final answers based on the current state.
  3. Tools (The Hands): Functions that allow the LLM to interact with the external world (e.g., a calculator, a search engine, a custom API).
  4. Nodes:
    • call_llm: Invokes the LLM to get its next “thought” or action.
    • call_tool: Executes the tool(s) proposed by the LLM.
  5. Conditional Edge (route_decision): The “decision-maker” that determines whether the LLM’s output requires a tool execution (loop back) or is a final answer (terminate).
  6. Looping Edge: An edge from call_tool back to call_llm, enabling the agent to re-evaluate after observing a tool’s output.

Crafting the Agent Prompt: Guiding the Brain

The prompt is paramount for an agent. It instructs the LLM on its role, the tools available, and the expected output format (often ReAct-style, as we’ve seen). Key elements include:

  • System Message: Define the agent’s persona and purpose.
  • Tool Definitions: Clearly describe the tools’ names, purposes, and required inputs. The LLM relies on these descriptions to know when and how to use a tool.
  • Response Format: Guide the LLM to output structured tool calls or a final answer (e.g., using JSON for tool calls, or a clear “Final Answer” tag).
  • MessagesPlaceholder: Crucial for injecting the conversation history (chat_history or directly messages) and the agent’s internal scratchpad.

LangGraph Agent vs. AgentExecutor

You might recall AgentExecutor from Day 8/9. While AgentExecutor simplifies running a ReAct agent, LangGraph offers more explicit control:

  • Explicit State: LangGraph’s GraphState gives you direct access and modification capabilities over the shared context.
  • Complex Flows: LangGraph handles branching, merging, and complex custom logic much more elegantly than AgentExecutor‘s more rigid loop.
  • Visualization: LangGraph graphs can often be visualized, aiding understanding and debugging of intricate workflows.

For building highly customized, robust, and debuggable multi-step LLM applications, LangGraph is often the preferred choice.

For more details, explore the comprehensive agent examples in the LangGraph documentation:

Project: An Autonomous Question-Answering Agent

We’ll build an autonomous agent that can answer questions using a custom “date/time” tool and a “calculator” tool. The agent will decide when to use these tools and engage in multiple turns if necessary to resolve a query.

Before you run the code:

  • Ensure Ollama is installed and running (ollama serve) if using Ollama.
  • Pull any necessary Ollama models (e.g., llama2).
  • Ensure your OPENAI_API_KEY is set if using OpenAI models.
  • Ensure langgraph is installed (pip install langgraph).
# Save this as day12-autonomous-agent.py
import os
from typing import TypedDict, Annotated, List, Union
from datetime import datetime
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, ToolMessage
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from langchain_ollama import ChatOllama
from langchain import tool
from langgraph.graph import StateGraph, END
from langgraph.graph.message import add_messages
import operator # For Annotated[str, operator.add] if needed for string concatenation

# Load environment variables from .env file
from dotenv import load_dotenv
load_dotenv()

# --- Configuration ---
LLM_PROVIDER = os.getenv("LLM_PROVIDER", "openai").lower()
OLLAMA_MODEL_CHAT = os.getenv("OLLAMA_MODEL_CHAT", "llama2").lower()

# --- Step 1: Define Agent State ---
class AgentState(TypedDict):
    """
    Represents the state of our agent's graph.

    - messages: A list of messages forming the conversation history.
                New messages are appended using add_messages.
    """
    messages: Annotated[List[BaseMessage], add_messages]

# --- Step 2: Define Custom Tools ---
@tool
def get_current_datetime() -> str:
    """Returns the current date and time in a human-readable format."""
    print("\n--- Tool Action: Executing get_current_datetime ---")
    return datetime.now().strftime("%Y-%m-%d %H:%M:%S")

@tool
def simple_calculator(expression: str) -> str:
    """
    Evaluates a simple mathematical expression.
    Example: '2 + 2', '(5 * 3) / 2'
    """
    print(f"\n--- Tool Action: Executing simple_calculator on '{expression}' ---")
    try:
        # Using eval() can be dangerous in production, use a safer math parser for real apps.
        return str(eval(expression))
    except Exception as e:
        return f"Error evaluating expression: {e}"

tools = [get_current_datetime, simple_calculator]
print(f"Available tools: {[tool.name for tool in tools]}\n")

# --- Step 3: Initialize LLM ---
def initialize_llm(provider, model_name=None, temp=0.7):
    if provider == "openai":
        if not os.getenv("OPENAI_API_KEY"):
            raise ValueError("OPENAI_API_KEY not set for OpenAI provider.")
        # Bind tools to OpenAI LLM for function calling
        return ChatOpenAI(model=model_name or "gpt-3.5-turbo", temperature=temp).bind_tools(tools)
    elif provider == "ollama":
        try:
            llm = ChatOllama(model=model_name or OLLAMA_MODEL_CHAT, temperature=temp)
            llm.invoke("Hello!") # Test connection
            return llm
        except Exception as e:
            print(f"Error connecting to Ollama LLM or model '{model_name or OLLAMA_MODEL_CHAT}' not found: {e}")
            print("Please ensure Ollama is running and the specified model is pulled.")
            exit()
    else:
        raise ValueError(f"Invalid LLM provider: {provider}. Must be 'openai' or 'ollama'.")

llm = initialize_llm(LLM_PROVIDER)
print(f"Using LLM: {LLM_PROVIDER} ({llm.model_name if hasattr(llm, 'model_name') else OLLAMA_MODEL_CHAT})\n")

# --- Step 4: Define Graph Nodes ---

def call_llm_node(state: AgentState) -> AgentState:
    """
    Node to call the LLM and get its response.
    The LLM will decide whether to call a tool or give a final answer.
    """
    print("--- Node: call_llm_node ---")
    messages = state['messages']

    if LLM_PROVIDER == "ollama":
        # For Ollama, we need to explicitly inject tool definitions into the prompt
        tool_names = ", ".join([t.name for t in tools])
        tool_descriptions = "\n".join([f"Tool Name: {t.name}\nTool Description: {t.description}\nTool Schema: {t.args_schema.schema() if t.args_schema else 'No schema'}" for t in tools])
        # A more detailed prompt for Ollama to encourage JSON tool calls
        system_message = (
            "You are a helpful AI assistant. You have access to the following tools: "
            f"{tool_names}.\n\n"
            f"Here are their descriptions and schemas:\n{tool_descriptions}\n\n"
            "If you need to use a tool, respond with a JSON object like: "
            "```json\n{{\"tool_name\": \"<tool_name>\", \"tool_input\": {{...}}}}\n```. "
            "Think step by step. If a tool is useful, call it. Otherwise, provide a direct answer. "
            "Your response should be either a tool call JSON or a direct final answer."
        )
        prompt = ChatPromptTemplate.from_messages([
            ("system", system_message),
            *messages # Pass all previous messages
        ])
        response = llm.invoke(prompt)
    else: # OpenAI handles tools automatically when bound
        response = llm.invoke(messages)

    return {"messages": [response]}


def call_tool_node(state: AgentState) -> AgentState:
    """
    Node to execute a tool if the LLM has decided to call one.
    It takes the last AI message (which should contain tool calls) and executes them.
    """
    print("--- Node: call_tool_node ---")
    messages = state['messages']
    last_message = messages[-1]
    tool_outputs = []

    # Handle OpenAI structured tool calls
    if last_message.tool_calls:
        for tool_call in last_message.tool_calls:
            tool_name = tool_call.name
            tool_input = tool_call.args
            print(f"Executing tool: {tool_name} with input: {tool_input}")
            selected_tool = next((t for t in tools if t.name == tool_name), None)
            if selected_tool:
                try:
                    output = selected_tool.invoke(tool_input)
                    tool_outputs.append(ToolMessage(content=str(output), tool_call_id=tool_call.id))
                except Exception as e:
                    tool_outputs.append(ToolMessage(content=f"Error executing {tool_name}: {e}", tool_call_id=tool_call.id))
            else:
                tool_outputs.append(ToolMessage(content=f"Tool '{tool_name}' not found.", tool_call_id=tool_call.id))
    # Basic parsing for Ollama if it tried to output JSON tool call
    elif LLM_PROVIDER == "ollama" and isinstance(last_message.content, str):
        import json
        try:
            # Attempt to find JSON block in the string
            json_str = last_message.content[last_message.content.find('{'):last_message.content.rfind('}')+1]
            tool_call_data = json.loads(json_str)
            tool_name = tool_call_data.get("tool_name")
            tool_input = tool_call_data.get("tool_input", {})
            print(f"Executing Ollama-parsed tool: {tool_name} with input: {tool_input}")
            selected_tool = next((t for t in tools if t.name == tool_name), None)
            if selected_tool:
                output = selected_tool.invoke(tool_input)
                # For Ollama, represent tool output as an AIMessage or a HumanMessage with context
                tool_outputs.append(AIMessage(content=f"Tool output for {tool_name}: {output}"))
            else:
                tool_outputs.append(AIMessage(content=f"Tool '{tool_name}' not found or invalid format: {last_message.content}"))
        except (json.JSONDecodeError, StopIteration, ValueError) as e:
            print(f"Ollama tool parsing failed or no valid tool call JSON found: {e}")
            tool_outputs.append(AIMessage(content=f"LLM did not provide a valid tool call or final answer. Its response was: {last_message.content}"))
    else:
        print("No tool calls detected or parsed for execution.")
        # If no tool calls, it means the LLM likely intended a direct answer already.
        # This node would ideally only be reached if a tool was intended.
        # For robustness, we might add a "no_tool_found" path or error handling.
        pass

    return {"messages": tool_outputs}

# --- Step 5: Define the Routing/Decider Function ---
def route_decision(state: AgentState) -> str:
    """
    Decides the next step based on the last message from the LLM.
    Returns 'tool_call' if a tool needs to be called, otherwise 'end'.
    """
    print("--- Decider: route_decision ---")
    last_message = state['messages'][-1]

    # Check for OpenAI's structured tool calls
    if last_message.tool_calls:
        print("Decision: LLM wants to call a tool (OpenAI structured call).")
        return "tool_call"
    
    # Check for Ollama's string output with potential JSON tool call
    if LLM_PROVIDER == "ollama" and isinstance(last_message.content, str):
        # A simple check for the presence of a tool_name pattern.
        # For production, use more robust JSON parsing.
        if "tool_name" in last_message.content and "tool_input" in last_message.content:
             try:
                 # Attempt to parse as JSON to confirm it's a tool call
                 json_str = last_message.content[last_message.content.find('{'):last_message.content.rfind('}')+1]
                 json.loads(json_str) # Just try to load, don't need the result here
                 print("Decision: Ollama LLM seems to want to call a tool (based on string content and JSON parse).")
                 return "tool_call"
             except json.JSONDecodeError:
                 print("Decision: Ollama LLM content looks like text, not a tool call JSON.")
                 return "end" # It's a final answer or not a tool call
        print("Decision: Ollama LLM content looks like text, not a tool call JSON.")
        return "end"
    
    # Default case: if no tool calls detected for any provider
    print("Decision: LLM has a final answer or no tool needed.")
    return "end"

# --- Step 6: Build the LangGraph ---
print("--- Building the Autonomous Agent with LangGraph ---")
workflow = StateGraph(AgentState)

# Add nodes
workflow.add_node("llm", call_llm_node)
workflow.add_node("tool", call_tool_node)

# Set entry point
workflow.set_entry_point("llm")

# Add conditional edge from 'llm' node
# The 'route_decision' function will determine the next step
workflow.add_conditional_edges(
    "llm", # Source node
    route_decision, # The function that decides the next step
    {
        "tool_call": "tool", # If 'tool_call', go to 'tool' node
        "end": END           # If 'end', terminate the graph
    }
)

# Add a normal edge from 'tool' node back to 'llm' node
# This creates the agentic loop: execute tool, then re-evaluate with LLM
workflow.add_edge("tool", "llm")

# Compile the graph
app = workflow.compile()
print("Autonomous agent graph compiled successfully.\n")

# --- Step 7: Invoke the Agent ---
print("--- Invoking the Autonomous Agent (Verbose output below) ---")

# Example questions for the agent
agent_questions = [
    "What is the current date and time?",
    "Calculate (15 * 3) + 7.",
    "Tell me a fun fact about giraffes.", # Should not use a tool
    "What is the current date and time, then what is 100 divided by 4?" # Multi-step
]

for i, question in enumerate(agent_questions):
    print(f"\n===== Agent Turn {i+1} =====")
    print(f"User Question: {question}")
    # Initial input to the graph
    initial_input = {"messages": [HumanMessage(content=question)]}

    try:
        # Invoke the graph with the initial input
        # The 'verbose' output will show the step-by-step reasoning and tool use
        final_state = app.invoke(initial_input)
        print(f"\nAgent Final Answer: {final_state['messages'][-1].content}")
    except Exception as e:
        print(f"Agent encountered an error: {e}")
    print("\n" + "="*80 + "\n")

# Optional: You can visualize the graph (requires graphviz)
# from IPython.display import Image, display
# try:
#     display(Image(app.get_graph().draw_mermaid_png()))
#     print("Graph visualization generated (if graphviz is installed and path configured).")
# except Exception as e:
#     print(f"Could not generate graph visualization: {e}")
#     print("Ensure `pip install pygraphviz graphviz` and Graphviz binaries are in PATH.")

Code Explanation:

  1. AgentState: Remains the same as Day 11, focusing on messages as the central state for conversation and internal agent communication.
  2. Custom Tools: We add get_current_datetime and simple_calculator. The simple_calculator is a good example where the LLM needs a tool to perform a reliable calculation.
  3. LLM Initialization:
    • For OpenAI, bind_tools(tools) is critical. It provides the LLM with the schema and descriptions of the tools, enabling it to generate tool_calls.
    • For Ollama, a more detailed system_message is constructed within the call_llm_node to instruct the model on how to output tool calls as JSON, as native tool calling is less common for general Ollama models (unless you’re using a specific function-calling fine-tune). The parsing logic in call_tool_node and route_decision for Ollama is still a basic JSON check.
  4. Nodes (call_llm_node, call_tool_node): These are the core steps.
    • call_llm_node: Takes the current messages, calls the LLM, and appends the LLM’s response to messages.
    • call_tool_node: Inspects the last message (the LLM’s response). If it detects tool_calls (OpenAI) or a parsable JSON tool call (Ollama), it executes the tool(s) and appends the ToolMessage (or a regular AIMessage with the tool’s output for Ollama) back to the messages list. This observation is then fed back to the LLM in the next loop.
  5. Router Function (route_decision): This function examines the last message. It’s the brain that decides the flow: if the LLM wants to use a tool, it returns "tool_call"; otherwise, it returns "end".
  6. Graph Construction (StateGraph, add_node, set_entry_point, add_conditional_edges, add_edge):
    • We define the llm and tool nodes.
    • The llm node is the entry_point.
    • The add_conditional_edges from llm to tool (if a tool call is detected) or END (if a final answer).
    • The add_edge("tool", "llm") creates the crucial loop, allowing the agent to receive tool output and re-evaluate, potentially leading to another tool call or a final answer.
  7. Invocation: We invoke the compiled app with various questions, including those requiring single-tool use, no-tool use, and multi-step tool use. The verbose output will clearly show the agent’s internal thought process and how it navigates the graph.

This project provides a robust foundation for building autonomous, conversational agents capable of complex problem-solving by intelligently using tools and iterating through a dynamic workflow.

Leave a comment

I’m Arpan

I’m a Software Engineer driven by curiosity and a deep interest in Generative AI Technologies. I believe we’re standing at the frontier of a new era—where machines not only learn but create, and I’m excited to explore what’s possible at this intersection of intelligence and imagination.

When I’m not writing code or experimenting with new AI models, you’ll probably find me travelling, soaking in new cultures, or reading a book that challenges how I think. I thrive on new ideas—especially ones that can be turned into meaningful, impactful projects. If it’s bold, innovative, and GenAI-related, I’m all in.

“The future belongs to those who believe in the beauty of their dreams.”Eleanor Roosevelt

“Imagination is more important than knowledge. For knowledge is limited, whereas imagination embraces the entire world.”Albert Einstein

This blog, MLVector, is my space to share technical insights, project breakdowns, and explorations in GenAI—from the models shaping tomorrow to the code powering today.

Let’s build the future, one vector at a time.

Let’s connect