DEV Community

Cover image for Episode 1 : The Beginning of the Agentic Era
Shubham Srivastava
Shubham Srivastava

Posted on

Episode 1 : The Beginning of the Agentic Era

PREFACE

Large Language Models have transformed how we build software, but most applications barely scratch the surface of their capabilities. The real shift isn't just using an LLM—it's building intelligent agents that can reason, use tools, maintain context, and solve complex tasks. In this series, we'll explore the foundations of the agentic era and learn how to engineer systems that truly scale.

Today, we have numerous agentic frameworks that make it easy to build anything from simple AI agents to sophisticated multi-agent systems powered by RAG, MCP, and tool calling. While these abstractions are incredibly useful, they often hide the core principles, design decisions, and execution flow that make agentic systems work.

In this series, we'll peel back those abstractions and build our own agentic framework from scratch. The goal isn't to replace existing frameworks—it's to understand the architecture, reasoning, orchestration, and every moving part that powers modern AI agents. By the end, you'll know not just how to use these frameworks, but why they work the way they do.

Reality and Hype

Large Language Models are incredibly powerful—they can generate content, reason over information, use tools, and enable autonomous workflows. However, they are still non-deterministic systems, meaning they cannot guarantee 100% accurate or consistent outputs. Their responses are probabilistic by nature.

This is why not every software problem needs an LLM, an AI assistant, or an agent. Before introducing AI into a system, ask a simple question: Does this problem require reasoning, handling ambiguity, or making decisions in situations where the outcome isn't strictly predefined? If the answer is yes, an LLM—combined with proper guardrails, governance, observability, and human oversight—can be a great fit. If the problem is deterministic and follows well-defined business rules, a traditional software solution will almost always be simpler, faster, cheaper, and more reliable.

The goal isn't to add AI everywhere—it's to use it where it creates real value.

1.0 The 10,000-Foot View of Agentic AI

Before we dive into building agentic systems, it's important to understand one fundamental idea: everything revolves around the LLM. It acts as the reasoning engine that interprets instructions, decides what to do next, and generates responses.

However, LLMs have two important characteristics. First, they are probabilistic, meaning they generate responses based on probabilities rather than deterministic rules, so the same input may not always produce the exact same output. Second, they are stateless—they don't remember previous conversations or actions on their own. Every request is independent, which means we are responsible for maintaining the conversation history, context, and any relevant state, and sending it back to the LLM with each request.

You can think of an LLM as the brain or boss of an agentic system. It doesn't execute code, call APIs, or access databases directly. Instead, it understands the context you provide, reasons about the problem, and outputs instructions in text (or structured tool calls). The surrounding application is responsible for carrying out those instructions and feeding the results back to the LLM.

1.1 Tool Calling

As we've discussed, an LLM doesn't execute code, query databases, or call external APIs on its own—and that's by design. Its primary responsibility is reasoning and generating the next best action based on the context it receives. Offloading execution to external tools allows the LLM to focus on what it does best: understanding language, reasoning over information, and making decisions.

One of the most powerful capabilities of modern LLMs is tool calling. Instead of performing an action directly, the model can decide which tool to use and generate a structured tool call containing the tool name and the required arguments. Your application then executes the tool and returns the result back to the LLM for further reasoning.

It's worth noting that not every LLM supports tool calling. While most modern frontier models from providers such as OpenAI, Anthropic, and Google do, some lightweight or smaller reasoning models may not. Always verify that your chosen model supports tool calling before building an agentic workflow.

Under the hood, models that support tool calling are trained to generate structured tool calls through techniques such as Supervised Fine-Tuning (SFT), enabling them to reliably produce JSON-like outputs that conform to predefined tool schemas.

For example, when using the OpenAI API, tools are defined using a JSON schema like the one below:

# 1. Define a list of callable tools for the model
tools = [
    {
        "type": "function",
        "name": "get_horoscope",
        "description": "Get today's horoscope for an astrological sign.",
        "parameters": {
            "type": "object",
            "properties": {
                "sign": {
                    "type": "string",
                    "description": "An astrological sign like Taurus or Aquarius",
                },
            },
            "required": ["sign"],
        },
    },
]
Enter fullscreen mode Exit fullscreen mode

Once you've created the OpenAI client, you can pass the list of tool definitions along with your request. This makes the model aware of the tools it is allowed to use. Whenever the LLM determines that a user's request requires one of those tools, it doesn't execute the tool itself. Instead, it generates a tool call containing the tool name and the required arguments in a structured format. Your application is then responsible for executing the tool and sending the result back to the LLM so it can continue reasoning and generate the final response.

# 2. Prompt the model with tools defined
response = client.responses.create(
    model="gpt-5.5",
    tools=tools,
    input=input_list,
)
Enter fullscreen mode Exit fullscreen mode

1.2 Memory Management

Since LLM's are stateless(It's Ghajini😁(Indian Movie where hero need external source to remember)). Generally LLM's are trained to consume long term conversation in terms of roles that what they fine tuned for to better understand the context. So we can store this conversation while while keeping track of the roles for each conversation.

For our framework, we'll introduce the concept of sessions, where each session maintains its own conversation history and context. To keep things simple while still demonstrating persistence, we'll store each session on the local file system. This allows conversations to survive application restarts, and we'll also implement session lifecycle management, including creating, updating, and deleting session data when it's no longer needed.

1.3 Context Management

In an agentic framework,

💾 Memory Management and 🎛️ Context Management handle two entirely different stages of the data lifecycle.

💾 Memory Management is the Storage and Retrieval Layer. It dictates how an agent's experiences, facts, and trajectories are persisted over time. This component manages database writes to systems like Redis for short-term history, or Vector Databases for long-term semantic knowledge. Its primary job is data preservation and indexing.

🎛️ Context Management is the Runtime Governance Layer. It sits between your memory stores and the LLM. Because context windows (e.g.,128k tokens) are bounded and expensive, this layer dynamically selects, trims, and formats the retrieved memory data into the final prompt payload. Its primary job is token optimization and relevance.


Handling Long Contexts

One of the biggest challenges in building an agentic AI framework is managing the context window of the LLM. As agents interact with users, call tools, and reason over multiple steps, the conversation history continues to grow. Since every model has a finite context window, sending the entire history with every request eventually becomes impossible.

A common solution is context compression. Instead of retaining every message verbatim, we summarize older parts of the conversation while preserving the essential information needed for future reasoning. Fortunately, LLMs excel at summarizing large amounts of text, making them well-suited for compressing conversation history without losing the most important context. This allows long-running agentic workflows to remain efficient while staying within the model's context limits.

The decision to summarize the conversation is handled by the any method such as is_summarization_needed() method. This method periodically checkpoints the current session while also monitoring the total number of tokens consumed, using the model's usage information. Once the token count approaches or exceeds a predefined threshold of the model's context window, the engine automatically triggers a summarization process. The summarized conversation then replaces the older message history, allowing the agent to preserve the most important context while staying within the model's context limits for future interactions.

1.4 The Orchestrator

Agents are the heart of our framework. From the moment an agent is initialized with an LLM, it becomes the central orchestrator responsible for coordinating the entire workflow. It manages conversation context, memory, session persistence, tool execution, interactions with the LLM, response handling, retries, error handling, security guardrails, observability, callbacks, and the overall execution lifecycle.

Below is a glimpse of how an agent will look in our framework. Rather than building a single agent for every use case, we'll design specialized agents optimized for different execution patterns.

  • MonoQAgent — A single-agent runtime designed for linear and deterministic workflows. The LLM reasons about the user's request, decides which tools to invoke, processes the results, and returns the final response. This is ideal for tasks such as "What's the weather in London?", "Summarize this YouTube video", or "Analyze this document".

  • SwarmAgent — A multi-agent orchestration framework where multiple specialized agents collaborate to solve complex problems. Each agent has a well-defined responsibility—for example, planning, research, coding, reviewing, or validation—and they communicate to produce a final result. This architecture is well suited for long-running, multi-step workflows that require collaboration and task decomposition.

As the series progresses, we'll continue extending the framework with more execution models, giving us the flexibility to build everything from simple AI assistants to production-grade autonomous agent systems.

2.0 Understanding Our First Agent called MonoAgent

Let's build our first agent, MonoAgent. This agent will be responsible for managing the complete execution lifecycle of a single-agent workflow, including tool calling, maintaining conversation history, handling different message roles, interacting with the LLM, and orchestrating the overall reasoning process.

Before we implement the agent itself, let's first understand how tools are defined and registered in our framework. A flexible tool system is essential, as it allows end users to easily create, register, and expose custom tools that the agent can discover and invoke during execution.

2.1 How we wire tools into our agent

There are 3 things you need to know :

  1. ToolRegistry
  2. ToolRegistryExecutor
  3. FalconAukTool

The ToolRegistry acts as the central repository for all registered tools. Internally, it maintains a key-value mapping where the key is the tool's unique name and the value is the corresponding tool instance.

The ToolRegistryExecutor is responsible for registering tools into a specific ToolRegistry. It exposes a decorator that, when applied to a function, converts it into a framework-compatible tool and automatically registers it with the associated registry. This provides a clean and intuitive API for end users to define and register custom tools.

Every registered tool is represented internally as a FalconAukTool. This abstraction exposes a to_model_specific(model_provider: ModelProvider) method, which converts the generic tool definition into the provider-specific schema required by the target LLM. Since providers such as OpenAI, Anthropic, and Gemini each expect different tool definitions and request formats, this abstraction allows our framework to support multiple model providers without changing the user-defined tool.

This design also enables tool isolation. Each agent can be initialized with its own ToolRegistry, giving it access only to the tools it requires. For example, a MonoAgent may be configured with one set of tools, while a SwarmAgent can use an entirely different registry. This separation improves modularity, security, and maintainability by ensuring that agents only have access to the capabilities they are explicitly granted.

class ToolRegistry:
    def __init__(self):
        self._registry: Dict[str, Tool] = {}
        self.current_model_provider: str = (
            None  # This will hold the current model provider
        )

    def register_tool(self, tool_instance: Tool):
        self._registry[tool_instance.name] = tool_instance

    def get_tool(self, name: str) -> Optional[Tool]:
        return self._registry.get(name)

    def get_all_schemas(self, target_provider: ModelProvider) -> List[Dict]:
        return [t.to_model_specific(target_provider) for t in self._registry.values()]
Enter fullscreen mode Exit fullscreen mode

Now we can see the ToolRegistryExecutor which takes the ToolRegistry instance and registers all the tools created.

class ToolRegistryExecutor:
    def __init__(self, context: ToolRegistry):
        self.context = context

    def tool(self, name: str, description: str):
        def decorator(func):
            tool = FalconAukTool(
                name=name,
                description=description,
                func=func,
            )

            self.context.register_tool(tool)
            return tool

        return decorator
Enter fullscreen mode Exit fullscreen mode

Inside ToolRegistryExecutor, the tool method acts as the decorator used to define and register tools. When a function is decorated, it is automatically wrapped by FalconAukTool, which serves as the framework's internal abstraction for all tools.

Rather than exposing the raw Python function directly, FalconAukTool encapsulates the tool's metadata, schema, and execution logic. It also provides the to_model_specific(model_provider: ModelProvider) method, which converts the generic tool definition into the provider-specific format required by the target LLM. Since providers such as OpenAI, Anthropic, and Gemini each define tool schemas differently, this abstraction allows the same tool implementation to work seamlessly across multiple model providers without requiring any changes from the developer.

def to_model_specific(self, model_provider: ModelProvider):

        match model_provider:
            case ModelProvider.OPENAI:
                return self._to_openai_tool()
            case ModelProvider.ANTHROPIC:
                raise NotImplementedError(
                    "Anthropic tool conversion is not implemented yet."
                )
            case ModelProvider.GEMINI:
                raise NotImplementedError(
                    "Gemini tool conversion is not implemented yet."
                )
Enter fullscreen mode Exit fullscreen mode

Example for OpenAI it will look like :

def _to_openai_tool(self):
        _type_map = {
            str: "string",
            int: "integer",
            float: "number",
            bool: "boolean",
            list: "array",
            dict: "object",
            type(None): "null",
        }

        sig = inspect.signature(self.func)
        properties = {}
        required = []

        for name, param in sig.parameters.items():
            raw_type = (
                param.annotation if param.annotation != inspect.Parameter.empty else str
            )
            json_type = _type_map.get(raw_type, "string")

            properties[name] = {
                "type": json_type,
            }

            if param.default is inspect.Parameter.empty:
                required.append(name)

        return {
            "type": "function",
            "function": {
                "name": self.name,
                "description": self.description,
                "parameters": {
                    "type": "object",
                    "properties": properties,
                    "required": required,
                },
            },
        }
Enter fullscreen mode Exit fullscreen mode

Now that we've explored how tool registration works internally, let's see how an end user can define and register custom tools for different agents. The framework is designed to keep the developer experience simple, allowing users to create tools with minimal boilerplate while the framework takes care of registration, metadata, and model-specific conversions behind the scenes.

First create instance for ToolRegistry and ToolRegistryExecutor

tool_registration = ToolRegistry()
registry = ToolRegistryExecutor(tool_registration)
Enter fullscreen mode Exit fullscreen mode

Now create tool like :

@registry.tool(
    name="get_weather",
    description="Provides the current weather based on the location.",
)
def get_weather(location: str) -> str:
    return f"The current weather of the {location} is 21 degree celcius and rainy and cold."
Enter fullscreen mode Exit fullscreen mode

Now we can use the ToolRegistry to retrieve all the registered tools, extract their metadata, and automatically convert them into the provider-specific schema required by the selected LLM. This abstraction allows the framework to generate the appropriate tool definitions for providers such as OpenAI, Anthropic, or Gemini without requiring any additional work from the developer.

For example, the following code generates the model-specific tool definitions from the registered tools:

tool_registration.get_all_schemas(ModelProvider.OPENAI)
Enter fullscreen mode Exit fullscreen mode

This will give the response that we want to send to as tool_functions as required by the OpenAI. So, by calling above you will get an idea.

2.1 Under the Hood : Tool and Agent communication

First our agent provide the LLM with the list of tools that it have and says LLM.
🤖 Agent: "Here are the tools available to you. Use them whenever they're needed."

🧠 LLM: "Got it."

👤 User: "What's the weather in Paris?"

🧠 LLM: Thinking... 💭 "I don't know the current weather, but I have access to a get_weather(location) tool. I'll ask the agent to execute it with location = "Paris"."

🤖 Agent: "Received the tool request. Executing get_weather(location="Paris") and returning the result."

🧠 LLM: "Perfect! I now have the latest weather information. Using the tool's response, I can generate an accurate answer for the user."

🤖 Agent: "Final response delivered to the user."

2.2 Managing conversation History

As of now we are using the simple list to manage all these conversation hostory.

messages: List[BaseMessage] = [
            SystemMessage(content=self.system_prompt),
            UserMessage(content=user_input),
        ]
Enter fullscreen mode Exit fullscreen mode

Once everything is set up, we start the agent by calling the run() method of our MonoAgent. This method orchestrates the entire execution loop, maintaining the conversation history by continuously appending AIMessage and ToolMessage objects as the LLM reasons, invokes tools, receives their results, and generates the final response.

# Tool Message Response
messages.append(
                        ToolMessage(
                            content=result,
                            tool_call_id=tc.id,
                            name=tc.function["name"],
                        )
                    )
# LLM Response
messages.append(response.message)
Enter fullscreen mode Exit fullscreen mode

2.3 Managing different Responses from different Models

Since every model can send use different LLM response that we need to one single interface so that it can be utilized by our Agents for that we have single LLMResponse and adapter classes for each models.

class LLMResponse:
    def __init__(
        self,
        message: AssistantMessage,
        usage: Usage,
        raw_response: Any = None,
    ):
        self.message = message
        self.usage = usage
        self.raw_response = raw_response
Enter fullscreen mode Exit fullscreen mode

You can find the complete code about this setup in the Github Repo.

2.3 Response from LLM Provider

First see how our provider looks like with OpenAIProvider.

class OpenAILLMProvider(BaseLLMProvider):
    def __init__(
        self,
        api_key: str,
        model: str = "gpt-4o",
        callbacks: Optional[CallbackManager] = None,
        **kwargs,
    ):
        super().__init__(api_key=api_key, model=model, callbacks=callbacks, **kwargs)
        self._base_url = kwargs.get("base_url")
        self._max_tokens = kwargs.get("max_tokens", 4096)
        self._adapter = OpenAIAdapter()
Enter fullscreen mode Exit fullscreen mode

At this stage, you can initialize your LLM using either the provider's native API key (such as OpenAI or Anthropic) or a compatible provider like Groq by supplying the GROQ_API_KEY along with its base_url.

Now, let's look at the generate() method. Notice that by simply passing the tool registry, the LLM automatically receives the tool definitions in the schema expected by that specific model. This abstraction allows us to register tools once while letting the underlying provider handle the model-specific tool-calling format.

def generate(
        self,
        messages: list[BaseMessage],
        tool_registry: Optional[ToolRegistry] = None,
        **kwargs,
    ) -> LLMResponse:

        client = OpenAI(api_key=self._api_key, base_url=self._base_url)
        raw_messages = self._adapter.convert_messages(messages)
        raw_tools = (
            self._adapter.convert_tools(tool_registry)
            if tool_registry
            else None
        )

        params = {
            "model": kwargs.get("model", self._model),
            "messages": raw_messages,
            "max_tokens": kwargs.get("max_tokens", self._max_tokens),
        }
        if raw_tools:
            params["tools"] = raw_tools

        response = client.chat.completions.create(**params)
        normalized = self._adapter.normalize_response(response)

        return LLMResponse(
            message=AssistantMessage(
                content=normalized.content, tool_calls=normalized.tool_calls
            ),
            usage=normalized.usage,
            raw_response=response,
        )
Enter fullscreen mode Exit fullscreen mode

This LLM provider will be shared across our MonoAgent and any future agents we build. Now, let's return to the MonoAgent and walk through its run() method, where all the pieces we've built so far come together to drive the complete agent execution flow.

def run(self, user_input: str, max_iters: int = 10, **kwargs) -> LLMResponse:
        tool_runner = ToolRunner(self.tools) if self.tools else None

        messages: List[BaseMessage] = [
            SystemMessage(content=self.system_prompt),
            UserMessage(content=user_input),
        ]
        for i in range(max_iters):
            response = self.provider.generate(
                messages=messages, tool_runtime_context=self.tools
            )

            messages.append(response.message)
            tokens = response.usage.total_tokens if response.usage else 0

            if not response.message.tool_calls:
                return response

            if tool_runner:
                for tc in response.message.tool_calls:
                    _cb(current_tool=tc.function["name"], tool_name=tc.function["name"])
                    _log(f"[MonoAgent] Executing tool: {tc.function['name']}")

                    result = tool_runner.execute(tc)
                    messages.append(
                        ToolMessage(
                            content=result,
                            tool_call_id=tc.id,
                            name=tc.function["name"],
                        )
                    )

        return response
Enter fullscreen mode Exit fullscreen mode

The run() method is the heart of our MonoAgent. It implements the complete reason → act → observe loop that powers modern agentic systems.

We begin by creating a ToolRunner, which is responsible for executing any tools requested by the LLM. Next, we initialize the conversation history with a SystemMessage containing the agent's instructions and a UserMessage containing the user's query. This messages list becomes the single source of truth for the entire conversation and grows throughout the agent's execution.

The agent then enters an iterative loop, allowing it to reason and use tools multiple times before producing a final answer. During each iteration, we send the complete conversation history to the LLM by calling provider.generate(). Along with the messages, we also pass the available tools (tool runtime context), enabling the model to decide whether it should answer directly or request one or more tool calls.

Once the model responds, its AIMessage is appended to the conversation history. We also capture token usage, which can later be used for monitoring, analytics, or cost estimation.

At this point, the agent checks whether the model requested any tool calls. If no tools are required, the reasoning process is complete and the response is immediately returned to the user.

If the model does request tools, the ToolRunner executes each one. Before execution, we invoke callbacks and logging so the application can stream progress, display tool activity, or collect observability data. The tool is then executed with the arguments generated by the model, and its output is wrapped inside a ToolMessage.

Appending the ToolMessage back into the conversation is a crucial step. It gives the LLM access to the tool's result, allowing it to continue reasoning with fresh, real-world information instead of relying solely on its pretrained knowledge. The updated conversation history is then sent back to the model in the next iteration, repeating the cycle until no further tool calls are needed or the maximum number of iterations is reached.

This iterative reasoning loop is what transforms a plain LLM into an autonomous agent. Rather than producing a single response, the model can think, decide which tools to use, observe their outputs, and continue reasoning until it has enough information to generate a final answer.

2.4 Finally Using Our Agent

Now, let's see our agent in action and explore how an end user can interact with it. We'll walk through the complete flow—from receiving a user's request, reasoning about it, invoking tools when necessary, and finally generating the response.

We're also introducing a tool registration system that automatically registers tools for the type of agent you're building. For example, with a MonoAgent, you simply provide the ToolRegistry, and the agent takes care of tool discovery, schema generation, and execution behind the scenes. This keeps the API clean while hiding the underlying complexity.

tool_registration = ToolRegistry()
registry = ToolRegistryExecutor(tool_registration)

@registry.tool(
    name="get_weather",
    description="Provides the current weather based on the location.",
)
def get_weather(location: str) -> str:
    return f"The current weather of the {location} is 21 degree celcius and rainy and cold."
Enter fullscreen mode Exit fullscreen mode

Now our agent :

mono_agent = MonoAgent(
    provider=OpenAILLMProvider(
        api_key="",
        model="openai/gpt-oss-20b",
        base_url="https://api.groq.com/openai/v1",
    ),
    tools=tool_registration,
    system_prompt="You are helpful assistant.",
    agent_callback=Listener(),
)
Enter fullscreen mode Exit fullscreen mode

and final call run() method

response = mono_agent.run(user_input=user_input)
Enter fullscreen mode Exit fullscreen mode

3.0 Summary

In this first episode, we laid the foundation for everything we'll build throughout this series. We explored the reality behind the hype surrounding Agentic AI, discussed when LLMs should—and shouldn't—be used, and developed a high-level understanding of how modern agents operate. We then broke down the interaction between the Agent, the LLM, and Tools, and implemented our own MonoAgent from scratch to understand the complete reasoning loop behind tool calling.

Along the way, we built a reusable LLM provider, designed the agent execution flow, managed conversation history using structured messages, and introduced an automatic tool registration system that keeps the developer experience simple while handling the complexity under the hood.

This is just the beginning. In the upcoming episodes, we'll continue evolving our framework by introducing memory, streaming, structured outputs, multi-agent communication, planning, observability, and many more production-grade capabilities—one concept at a time.

Thanks for reading! If you're interested in understanding Agentic AI from first principles instead of treating it as a black box, stay tuned for the next episode. The journey has only just begun. 🚀

Check out this Repo for complete and show love❤️ by liking the repository.

Top comments (0)