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 uvREADME.md: Agent documentation__init__.py: Marks the directory as a Python package__main__.py: Agent entry pointagent.json: The AgentCardconfig.yaml: The AgentConfig file used by BAT-ADKpyproject.toml: Configuration for uv, listing dependencies and project metadatasrc/: Contains source code including Agent logic and LLM clientsuv.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:
- Define the AgentCard in
agent.json - Define the AgentConfig in
config.yaml - Implement the agent logic in
src/graph.py - Implement any LLM clients in
src/llm_clients/<llm_client>.py - Implement the agent entry point in
__main__.py - 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 formatagent_msg: Agent response (string orNone)status_msg: Status message (string)
Methods to override:
from_query: Initializes the state when the agent receives a new queryupdate_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 anAgentTaskResultobject, 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
@tooldecorator. - In this case, the return type of the tool is
strfor 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
AgentGraphfrom BAT-ADK.- Provides built-in methods to manage nodes, edges, and execution.
- Use
ReActLoopto 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.
- Instantiate the LLM Client (
SLAClient) and provide thesend_sla_policytool.- The client must be assigned to a
self.<client_name>attribute to enable automatic usage metadata collection by BAT-ADK.
- The client must be assigned to a
- Create a
ReActLoopnode:- Provide the
SLAClientto it. - Specify the keys of the State to read input from, write output to, and store all the messages and status.
- Provide the
- Add the node to the Graph using
add_nodemethod of thegraph_builderproperty exposed by the base class. - Connect the nodes: START β SLA_LOOP β END
- No conditional edges are needed for this simple agent
- Do not compile the graph builder, it is handled internally by the
AgentGraphclass.
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
SLAClientextendsChatModelClientand 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
invokemethod of theChatModelClientto customize input/output processing. When using aReActLoop, 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
AgentGraphandAgentStatetypes, 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