Skip to main content
Version: v4.0.5 [Denim]

Lab 2: Building a Task-Specific Agent

In this lab, you will create a simple SLA Agent that generates an SLA policy for the Non RT RIC based on a very high level intent.

Prerequisites​

This lab assumes:

  • Basic Python and OOP knowledge
  • Some familiarity with the LangGraph framework
  • Knowledge of BubbleRAN's ADK

Some explanations are included to help you follow the code structure and logic. Keep the BAT ADK documentation handy, as it provides details on the classes and methods used in this lab. Spending some time with the documentation and experimenting with the labs will make agent development faster and easier.

Project setup​

Follow the general instruction to setup your codebase. If done correctly, your project structure should look like this:

.
β”œβ”€β”€ .env
β”œβ”€β”€ .venv/
β”œβ”€β”€ README.md
β”œβ”€β”€ __init__.py
β”œβ”€β”€ __main__.py
β”œβ”€β”€ agent.json
β”œβ”€β”€ config.yaml
β”œβ”€β”€ pyproject.toml
β”œβ”€β”€ src/
β”‚Β Β  β”œβ”€β”€ __init__.py
β”‚Β Β  β”œβ”€β”€ graph.py
β”‚Β Β  β”œβ”€β”€ tools.py
β”‚Β Β  └── llm_clients/
β”‚Β Β  β”œβ”€β”€ __init__.py
β”‚Β Β  └── sla_client.py
└── uv.lock

You may need to create any missing Python files manually.

Project Structure Overview​

  • .env: Environment variables such as model provider and API keys
  • .venv/: Virtual environment created by uv
  • README.md: Agent documentation
  • __init__.py: Marks the directory as a Python package
  • __main__.py: Agent entry point
  • agent.json: The AgentCard
  • config.yaml: The AgentConfig file used by BAT-ADK
  • pyproject.toml: Configuration for uv, listing dependencies and project metadata
  • src/: Contains source code including Agent logic and LLM clients
  • uv.lock: Locks dependency versions (for uv)

You usually don’t need to modify pyproject.toml, as uv manages it automatically. For this lab, paste the following to ensure your environment matches the lab setup:

[project]
name = "sla-agent"
version = "1.0.0"
description = "SLA Agent"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"a2a-sdk==0.3.26",
"bat-adk==2026.3",
"br-rapp-sdk==2026.2",
]

Installing Dependencies​

Install dependencies in the project's virtual environement:

uv sync

Important note: the command won't install dependencies in the environment where uv is installed, but only in the environment created for this project (available in the .venv/ directory). You should not modify the uv.lock files or the content of the .venv/ directory.

You can run the agent using the installed dependencies with:

uv run .

If you encounter issues with cached packages, use:

uv sync --no-cache

or

uv sync --reinstall

Implementing the Agent​

After setting up the codebase, as an Agent Developer you need to:

  1. Define the AgentCard in agent.json
  2. Define the AgentConfig in config.yaml
  3. Implement the agent logic in src/graph.py
  4. Implement any LLM clients in src/llm_clients/<llm_client>.py
  5. Implement the agent entry point in __main__.py
  6. Provide the necessary environment variables in .env

The following subsections will guide you through these steps for the SLA Agent.

Agent Card​

The AgentCard is a JSON file that describes the agent's capabilities, inputs, outputs, and other metadata. It was introduced by the A2A protocol and is used to register the agent in the MX-AI ecosystem and to provide information to other agents.

agent.json

{
"version": "1.0.0",
"name": "SLA Agent",
"description": "Sends SLA policies to the Non RT RIC of an ORAN 5G Network, based on very high level intents.",
"capabilities": {
"pushNotifications": false,
"streaming": true
},
"defaultInputModes": ["text", "text/plain"],
"defaultOutputModes": ["text", "text/plain"],
"skills": [
{
"description": "Send SLA policies based on very high level intents",
"examples": [
"I want to watch YouTube videos in 4K",
"I need a cheap internet",
"I need an internet connection to make professional videocalls",
"I want to navigate the web"
],
"id": "send-sla-skill",
"name": "SendSLA",
"tags": ["sla", "policy", "non-rt-ric", "autonomous-networks"]
}
]
}

Note: The Agent url is not specified in the AgentCard because it varies at runtime in Kubernetes based on the associated service and port. The URL is read from environment variables written by the Odin Operator and set in the Agent Card before starting the A2A server.

Agent Config​

The AgentConfig is a YAML file interpreted by BAT-ADK. It defines minimal agent configuration, including reachable agents and MCP servers. It also allows you to enable memory by setting the checkpoints flag.

For this simple agent, only the following configuration is needed.

config.yaml

checkpoints: true
remote-agents: []
mcp-servers: []

Agent Logic - State​

Implementing the agent logic requires knowledge of LangGraph concepts, including:

  • State
  • Graph (nodes, edges, conditional edges)
  • Tools
  • Checkpoints (optional, more advanced)

The first step is defining the agent State, a data structure that stores user inputs, LLM outputs, and any intermediate data needed to handle a user query.

For agents built with the BAT-ADK, the state must extend the AgentState class, which provides:

  • All features of pydantic.BaseModel (runtime type checking, validation, serialization). See the Pydantic documentation for details.
  • Abstract methods that you override to align with the MX-AI development workflow, saving you from implementing the async streaming functionality.

src/graph.py

from .llm_clients import SLAClient
from .tools import send_sla_policy
from bat.agent import AgentGraph, AgentState, AgentTaskResult
from bat.prebuilt import ReActLoop
from langchain_core.messages import BaseMessage
from langgraph.graph import START, END
from typing import List, Optional, Self
from typing_extensions import override

class SLAAgentState(AgentState):
history: List[BaseMessage] = []
user_msg: str
agent_msg: Optional[str] = None
status_msg: str = ""

@classmethod
@override
def from_query(cls, query: str) -> Self:
return cls(user_msg=query)

@override
def update_after_checkpoint_restore(self, query: str) -> None:
self.user_msg = query
self.agent_msg = None

@override
def to_task_result(self) -> AgentTaskResult:
if self.agent_msg is None:
return AgentTaskResult(task_status="working", content=self.status_msg)
return AgentTaskResult(task_status="completed", content=self.agent_msg)

Fields

  • history: Chat history (list of langchain messages)
  • user_msg: User query in string format
  • agent_msg: Agent response (string or None)
  • status_msg: Status message (string)

Methods to override:

  • from_query: Initializes the state when the agent receives a new query
  • update_after_checkpoint_restore: used for multi-turn conversations, updates the state with the new query after restoring from a checkpoint.
  • to_task_result: Converts the state to an AgentTaskResult object, the expected output format for BAT-ADK
  • Optional methods (not needed for this agent):
    • is_waiting_for_human_input: used for human-in-the-loop interactions

Agent Logic - Tool (using BR rApp SDK)​

The SLA Agent uses a tool to send an SLA policy to Odin, BubbleRAN's Cloud native Non RT RIC. A Python function can be registered as a tool by adding the @tool decorator from LangChain. This makes the function available for the LLM to call.

  • The LLM receives the function signature and docstring automatically thanks to the @tool decorator.
  • In this case, the return type of the tool is str for an easy interpretation by the LLM.

src/graph.py

from br_rapp_sdk import A1Services, OAMServices
from br_rapp_sdk.a1_services.a1_policy_types import *
from br_rapp_sdk.oam_services.network_types import *
from langchain_core.tools import tool
from typing import Literal
import os

RIC_MODEL = "mosaic5g/flexric"
SLA_POLICY = "bubbleran/sla"

DEFAULT_LOW_THROUGHPUT_GUA = 70000
DEFAULT_HIGH_THROUGHPUT_GUA = 150000
LOW_THROUGHPUT_GUA = int(os.getenv("LOW_THROUGHPUT_GUA", DEFAULT_LOW_THROUGHPUT_GUA))
HIGH_THROUGHPUT_GUA = int(os.getenv("HIGH_THROUGHPUT_GUA", DEFAULT_HIGH_THROUGHPUT_GUA))

@tool()
def send_sla_policy(
network_name: str,
desired_sla: Literal["low_throughput", "high_throughput"],
) -> str:
"""
Send an SLA policy to the Non RT RIC of the specified 5G Network. The policy will be called 'agentpolicy'.

Parameters:
network_name(str): The name of the 5G network to which the SLA policy should be sent.
desired_sla(str): The desired SLA policy to be sent. It can be either "low_throughput" or "high_throughput".

Returns:
str: A message indicating the result of the operation. Either an error or a success message.
"""

if desired_sla not in ["low_throughput", "high_throughput"]:
return f"failed to send sla policy: invalid desired_sla: {desired_sla}. Please choose between 'low_throughput' and 'high_throughput'"

oam_services = OAMServices()
result = oam_services.network.get_network(network_id=network_name)
if not result.status == "success":
return f"failed to send sla policy: cannot retrieve network '{network_name}': {result.error}"

network: NetworkSpec = result.data.get('item')
ric_name = None
for edge in network.edge:
if edge.model == RIC_MODEL:
ric_name = edge.name
break
if ric_name is None:
return f"failed to send sla policy: network '{network_name}' does not have a ric with model '{RIC_MODEL}'."

gua_dl = LOW_THROUGHPUT_GUA if desired_sla == "low_throughput" else HIGH_THROUGHPUT_GUA
max_dl = gua_dl + 10000

policy_object = PolicyObjectInformation(
near_rt_ric_id =NearRtRicId(f"{ric_name}.{network_name}"),
policy_type_id=PolicyTypeId(SLA_POLICY),
policy_object=PolicyObject(
scope_identifier=ScopeIdentifier(
slice_id=SliceId(
sst=1,
sd="000000",
plmn_id=PlmnId(
mcc="001",
mnc="01",
),
),
),
policy_statements=PolicyStatements(
policy_objectives=PolicyObjectives(
slice_sla_objectives=SliceSlaObjectives(
gua_dl_thpt_per_slice=gua_dl,
max_dl_thpt_per_slice=max_dl,
),
),
),
),
)
policy_name = "agentpolicy"

a1_services = A1Services()
result = a1_services.apply_policy(
policy_name=policy_name,
policy_object=policy_object,
)

if not result.status == "success":
return f"failed to apply the sla policy: {result.error}"
return f"success: sla policy applied successfully."

Agent Logic - Graph​

Next, define the graph that handles user queries.

  • Extend AgentGraph from BAT-ADK.
    • Provides built-in methods to manage nodes, edges, and execution.
  • Use ReActLoop to implement a ReAct-style agent.
    • Handles reasoning, tool calling, and metadata tracking.
    • Reduces boilerplate compared to implementing a ReAct agent manually.

src/__init__.py

from .graph import SLAAgentGraph, SLAAgentState

src/graph.py

class SLAAgentGraph(AgentGraph):
SLA_LOOP = "sla_loop_node"

@override
def setup(self, config) -> None:
# Create an LLM client providing the tool
self.sla_client = SLAClient(tools=[send_sla_policy])

# Create the ReAct loop providing the SLAClient
# Note: 'query' is a key in SLAAgentState
self.sla_loop = ReActLoop(
config=config,
StateType=SLAAgentState,
chat_model_client=self.sla_client,
loop_name="sla_loop",
input_key="user_msg",
output_key="agent_msg",
messages_key="history",
status_key="status_msg",
)

# Add nodes to the graph
self.graph_builder.add_node(SLAAgentGraph.SLA_LOOP, self.sla_loop.as_runnable())

# Add edges to connect the nodes
self.graph_builder.add_edge(START, SLAAgentGraph.SLA_LOOP)
self.graph_builder.add_edge(SLAAgentGraph.SLA_LOOP, END)

Step-by-step explanation: override the setup method of the AgentGraph class to define the graph structure and the necessary LLM Clients.

  1. Instantiate the LLM Client (SLAClient) and provide the send_sla_policy tool.
    • The client must be assigned to a self.<client_name> attribute to enable automatic usage metadata collection by BAT-ADK.
  2. Create a ReActLoop node:
    • Provide the SLAClient to it.
    • Specify the keys of the State to read input from, write output to, and store all the messages and status.
  3. Add the node to the Graph using add_node method of the graph_builder property exposed by the base class.
  4. Connect the nodes: START β†’ SLA_LOOP β†’ END
    • No conditional edges are needed for this simple agent
  5. Do not compile the graph builder, it is handled internally by the AgentGraph class.

Note: in this case the agent is just a simple ReAct agent which is completely implemented as a prebuilt module in BAT-ADK. When developing a more complex logic, you might need to define functions to run as nodes of the graph. These functions can be defined as methods of the graph class, and passed as parameters to the add_node method.

Chat Model Client​

The Chat Model Client is responsible for invoking the LLM with the user’s query and previous history (if available). In BAT-ADK, an LLM Client is a specialized caller that uses predefined system instructions and processes user inputs (or intermediate graph inputs) to accomplish a specific task.

In this case, we define an SLAClient that calls the LLM with a system prompt instructing it to infer an appropriate SLA policy and send it to the Non RT RIC using the send_sla_policy tool.

src/llm_clients/__init__.py

from .sla_client import SLAClient

src/llm_clients/sla_client.py

from bat.chat_model_client import  ChatModelClient, ChatModelClientConfig

class SLAClient(ChatModelClient):

SYSTEM_INSTRUCTIONS = (
"You are a specialized assistant which enforces SLA policies in a 5G Network.\n"
"Based on the conversation with the user, your task is to send the right SLA policy "
"based on a high level intent from the the user."
"\n\n"
"For example, if the user asks to watch a 4K video, you can infer that they need a high "
"throughput SLA policy. On the other hand if the user asks to do some light web browsing, "
"you can infer that they need a low throughput SLA policy.\n"
"You are provided a tool to directly send the SLA policy."
"\n\n"
"If the request is out of scope, politely inform the user about that.\n"
"If you need more information to call the tools, just ask the user.\n"
"If the operation fails, provide a very basic explanation to the user.\n"
"If the operation succeeds, confirm to the user that the SLA policy has been applied successfully."
)

def __init__(
self,
tools,
):
super().__init__(
system_instructions=self.SYSTEM_INSTRUCTIONS,
chat_model_config=ChatModelClientConfig.from_env(client_name="SLAClient"),
tools=tools,
)

Explanation

  • SLAClient extends ChatModelClient and defines the system instructions for the LLM.
  • The __init__ method initializes the client with the system instructions, environment-based configuration, and registers the tools by calling the base class constructor.
  • If needed, you can also override the invoke method of the ChatModelClient to customize input/output processing. When using a ReActLoop, like in this case, it is recommended to stick to the default implementation or at least keep the same function signature to ensure compatibility with the framework.

Important note

Instead of implementing a custom class inheriting from ChatModelClient, you can also directly use ChatModelClient as a generic client and provide the system instructions and tools as parameters when instantiating it in the graph. However, implementing a custom class allows you to better organize your code in a big project, reuse the client in different graphs, and customize the behavior by overriding the invoke method.

Agent Entry Point​

The agent entry point is __main__.py. It initializes and runs the agent.

__main__.py

from bat.agent import AgentApplication
from src import SLAAgentGraph, SLAAgentState

if __name__ == '__main__':
agent = AgentApplication(
SLAAgentGraph,
SLAAgentState,
)
agent.run()

The AgentApplication:

  • Automatically reads the AgentCard and AgentConfig files, so no additional code is needed for that.
  • Instantiates the specified AgentGraph and AgentState types, and runs the A2A server to listen for incoming queries. The server will handle the execution of the graph for each query, including state management, tool calling, and response streaming.

Environment Variables​

The .env file configures the LLM client, logging, and other settings.

.env

MODEL_PROVIDER=openai
MODEL=gpt-4.1-mini
OPENAI_API_KEY=<your-api-key>
LOG_LEVEL=debug
URL=http://localhost
PORT=9900

Running the Agent (Bare-Metal)​

Run the agent locally:

uv run .

Test the agent with curl:

curl -X POST http://localhost:9900 \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "message/stream",
"params": {
"configuration": {
"acceptedOutputModes": [
"text"
]
},
"message": {
"contextId": "8f01f3d172cd4396a0e535ae8aec6680",
"messageId": "1",
"role": "user",
"parts": [
{
"type": "text",
"text": "I want a fast internet connection to download large files and watch videos without buffering in the `bubbleran` network."
}
]
}
}
}'
  • The response is streamed as JSON objects representing graph execution steps.
  • The final response (handled by MX-AI) may look like:
 It seems that the network 'bubbleran' is not found. Could you please confirm the name of the 5G network you are referring to?

Note

The agent replies that it cannot find the bubbleran network because such network has not been deployed yet. To test the agent within a functional environment, you need to deploy a 5G network with a Network yaml file and provide the name of your network to the agent. This will be covered in the next lab.

What You Learned​

  • Design and implement a task-specific agent using BAT-ADK
  • Define an Agent Card and Agent Config for agent registration
  • Build agent logic using LangGraph concepts (State, Tools, Graphs)
  • Implement a ReAct-style agent with tool calling
  • Create and configure a custom Chat Model Client
  • Run and test an agent in a bare-metal environment