04 / writing

Building a Multi-Agent System for Incident Response with LLMs

· 10 min read

Illustration of an incident response team working under cloud alerts, with a shield and padlock in the centre

Ever had that moment when your production system goes haywire, and you're frantically searching through documentation trying to remember if this was the incident that needed a database rollback or a cache flush? Yeah, me too. Let's talk about how we can use Language Learning Models (LLMs) to make our lives easier when dealing with incidents.

The Multi-Agent Vision

What's better than one AI assistant? Multiple AI assistants! Today we're diving into a fascinating approach: using multiple specialised agents to handle different types of incidents. Think of it as having a team of super-specialised first responders, each one an expert in their domain. It's like having a SWAT team, firefighters, and paramedics all ready to jump in — but in this case, they're AI agents.

Why Multiple Agents?

You might be thinking, "Why not just dump all the runbooks into one super-agent and call it a day?" (I know I was tempted!). But there's a method to our madness, and it comes down to three key benefits:

  1. Specialised knowledge: Each agent has a focused system prompt containing detailed instructions for handling specific types of incidents. This specialisation allows for more accurate and relevant responses compared to a generalist approach. With growing context windows, you could argue this is important, but if your runbooks are really big and detailed, then you could be hitting the limit sooner than you wish!
  2. Tool access control: Different types of incidents will require different tools. Our security incident agent might need access to log analysis and network isolation controls, while our service outage agent needs service restart capabilities and load balancer controls. Giving every agent access to every tool is like giving every employee a master key to the building — probably not the best idea! To me, this is one of the key benefits.
  3. Clear responsibility boundaries: The coordinator handles routing while specialised agents handle specific incident types. This separation of concerns makes the system easier to maintain and update.

The Architecture

At the heart of our system is what a "Traffic Controller" — a coordinator agent that knows exactly which specialist to call for each type of incident. When an alert comes in, this coordinator doesn't panic (unlike some of us humans may do). Instead, it calmly analyses the situation and routes it to the right specialist.

The Conversation Flow

One of the trickier aspects to figure out was the conversation flow. Should every follow-up question bounce through the coordinator? We solved this with a "full handoff" approach:

  1. The coordinator receives the initial incident report
  2. It determines the type of incident and selects the appropriate specialist
  3. The specialist takes over the conversation, maintaining its own context and state
  4. The specialist has access only to its designated tools

This approach reduces latency (no need to route every message through the coordinator) and allows for better context maintenance.

Implementation Details

Our implementation uses OpenAI's GPT-4 model, but you could adapt it for any LLM. We've organised the code into a clean, modular structure that separates concerns and makes it easy to add new specialist types or tools.

The system consists of:

Configuration and Setup

All you need to get started is Python 3.8+ and an OpenAI API key. Put your API key in a .env file:

OPENAI_API_KEY=your-key-here

Adding New Specialists

Want to add a new type of specialist? Just:

  1. Add the new incident type to the IncidentType enum
  2. Create a system prompt for your specialist
  3. Define which tools they can access
  4. Register the specialist with the coordinator

Results and Considerations

I'm still working on implementing this system, but I am hoping to gain the following benefits:

The Actual Code

Project Structure

First, let's look at how we'll organise our code:

incident_response/
│
├── .env                   # Environment variables (API keys)
├── requirements.txt       # Project dependencies
├── README.md              # Project documentation
│
├── src/
│   ├── __init__.py
│   ├── agents/
│   │   ├── __init__.py
│   │   ├── base.py        # Base agent class
│   │   ├── coordinator.py # Coordinator agent
│   │   └── specialist.py  # Specialist agents
│   │
│   ├── models/
│   │   ├── __init__.py
│   │   ├── incident.py     # Incident data models
│   │   └── conversation.py # Conversation handling
│   │
│   └── tools/
│       ├── __init__.py
│       └── incident_tools.py # Tool implementations
│
└── examples/
    └── handle_incident.py  # Usage examples

src/models/incident.py

from enum import Enum
from typing import List
from pydantic import BaseModel
from datetime import datetime

class IncidentType(Enum):
    SECURITY_BREACH = "security_breach"  # When someone tries to be sneaky
    SERVICE_OUTAGE = "service_outage"    # The classic "why is everything down?!"
    DATA_CORRUPTION = "data_corruption"  # The "oh no, the data!" moment

class Incident(BaseModel):
    """Represents an incident - because every crisis needs structure!"""
    type: IncidentType
    description: str
    severity: int  # On a scale of "meh" to "DEFCON 1"
    affected_systems: List[str]
    created_at: datetime = datetime.now()

class Message(BaseModel):
    """A message in the conversation"""
    content: str
    timestamp: datetime = datetime.now()
    from_user: bool

class ConversationState(str, Enum):
    INITIAL = "initial"
    SPECIALIST_ENGAGED = "specialist_engaged"
    RESOLVED = "resolved"

class Conversation(BaseModel):
    """Tracks the state of an incident response conversation"""
    id: str
    messages: List[Message] = []
    state: ConversationState = ConversationState.INITIAL
    current_agent: str = None

src/tools/incident_tools.py

class IncidentTools:
    """Available tools for incident response"""
    @staticmethod
    def check_logs(system: str, timeframe: str) -> str:
        """Simulated log checking functionality"""
        return f"Logs retrieved for {system} during {timeframe}"

    @staticmethod
    def restart_service(service_name: str) -> str:
        """Simulated service restart"""
        return f"Service {service_name} restarted"

    @staticmethod
    def query_metrics(metric: str, duration: str) -> str:
        """Simulated metrics query"""
        return f"Metrics for {metric} over {duration}"

    @staticmethod
    def isolate_system(system: str) -> str:
        """Simulated system isolation"""
        return f"System {system} isolated from network"

    @staticmethod
    def validate_backup(backup_id: str) -> str:
        """Simulated backup validation"""
        return f"Backup {backup_id} validated successfully"

class ToolRegistry:
    """Registry of available tools for each incident type"""
    SECURITY_TOOLS = ["check_logs", "isolate_system", "query_metrics"]
    OUTAGE_TOOLS = ["restart_service", "query_metrics", "check_logs"]
    DATA_TOOLS = ["validate_backup", "query_metrics", "check_logs"]

src/agents/base.py

import os
from typing import List, Dict
from openai import OpenAI
from dotenv import load_dotenv
from ..models.incident import Conversation, Message
from ..tools.incident_tools import IncidentTools

# Load environment variables
load_dotenv()
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

class BaseAgent:
    def __init__(self, system_prompt: str, allowed_tools: List[str]):
        self.system_prompt = system_prompt
        self.allowed_tools = allowed_tools
        self.tools = IncidentTools()

    def _build_messages(self, conversation: Conversation) -> List[Dict]:
        """Build the messages array for the OpenAI API"""
        messages = [
            {"role": "system", "content": self.system_prompt}
        ]

        # Add available tools context
        tools_context = f"Available tools: {', '.join(self.allowed_tools)}"
        messages.append({
            "role": "system",
            "content": tools_context
        })

        # Add conversation history
        for msg in conversation.messages[-5:]:  # Last 5 messages for context
            role = "user" if msg.from_user else "assistant"
            messages.append({"role": role, "content": msg.content})

        return messages

    def generate_response(self, conversation: Conversation) -> str:
        """Generate a response using the OpenAI API"""
        messages = self._build_messages(conversation)

        formatted_messages = []
        for msg in messages:
            formatted_messages.append({
                "role": msg["role"],
                "content": [{"type": "text", "text": msg["content"]}]
            })

        response = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=formatted_messages,
            temperature=0.7,
            max_tokens=500
        )

        return response.choices[0].message.content

src/agents/specialist.py

from typing import Dict
from .base import BaseAgent
from ..models.incident import Conversation, Message, ConversationState

class SpecialistAgent(BaseAgent):
    def __init__(self, specialty: str, system_prompt: str, allowed_tools: List[str]):
        super().__init__(system_prompt, allowed_tools)
        self.specialty = specialty

    def handle_message(self, message: str, conversation: Conversation) -> str:
        """Handle a message in the context of the ongoing conversation"""
        # Add the new message to conversation
        conversation.messages.append(Message(
            content=message,
            from_user=True
        ))

        # Generate response
        response = self.generate_response(conversation)

        # Add response to conversation history
        conversation.messages.append(Message(
            content=response,
            from_user=False
        ))

        return response

# Pre-configured specialist prompts
SPECIALIST_PROMPTS = {
    "security": """You are a security incident response specialist.
    Think of yourself as the digital equivalent of a master detective.

    Follow these steps for security breaches:
    1. Isolate affected systems (quarantine the suspicious ones!)
    2. Assess breach scope (how bad is it, really?)
    3. Contain the breach (stop the bleeding)
    4. Collect forensic data (gather evidence)
    5. Remediate vulnerabilities (close those doors!)""",

    "reliability": """You are a service reliability expert.
    The person everyone calls when things go dark.

    Follow these steps for service outages:
    1. Check monitoring dashboards (where's the fire?)
    2. Identify failure points (find the culprit)
    3. Execute recovery procedures (work your magic)
    4. Verify service restoration (is it really fixed?)
    5. Document root cause (what did we learn?)""",

    "data": """You are a data recovery specialist.
    The last hope for corrupted data everywhere.

    Follow these steps for data corruption:
    1. Stop affected processes (freeze everything!)
    2. Assess corruption scope (how much data is having a bad day?)
    3. Restore from backups (you did backup, right?)
    4. Verify data integrity (trust but verify)
    5. Implement prevention measures (never again!)"""
}

src/agents/coordinator.py

from typing import Dict
from .base import BaseAgent
from .specialist import SpecialistAgent, SPECIALIST_PROMPTS
from ..models.incident import Conversation, Message, ConversationState, IncidentType
from ..tools.incident_tools import ToolRegistry

class CoordinatorAgent:
    """The traffic controller of our incident response system"""
    def __init__(self):
        self.conversations: Dict[str, Conversation] = {}

        # Initialize specialist agents
        self.specialists = {
            IncidentType.SECURITY_BREACH: SpecialistAgent(
                "security",
                SPECIALIST_PROMPTS["security"],
                ToolRegistry.SECURITY_TOOLS
            ),
            IncidentType.SERVICE_OUTAGE: SpecialistAgent(
                "reliability",
                SPECIALIST_PROMPTS["reliability"],
                ToolRegistry.OUTAGE_TOOLS
            ),
            IncidentType.DATA_CORRUPTION: SpecialistAgent(
                "data",
                SPECIALIST_PROMPTS["data"],
                ToolRegistry.DATA_TOOLS
            )
        }

        # Initialize coordinator's own LLM capabilities
        self.agent = BaseAgent(
            system_prompt="""You are a coordinator for incident response.
            Your job is to analyse incoming incident reports and determine
            which specialist should handle them. Focus on identifying the
            type of incident and its severity.""",
            allowed_tools=[]
        )

    def determine_incident_type(self, message: str) -> IncidentType:
        """Use the coordinator's LLM to determine incident type"""
        system_prompt = """You are an incident coordinator. Your job is to determine the type of incident based on the user's description. The possible incident types are:

        1. SECURITY_BREACH - Any security-related incidents including unauthorized access, suspicious activities, potential breaches, unusual login patterns, etc.
        2. SERVICE_OUTAGE - Any availability issues including system downtime, performance degradation, service unavailability, connection problems, etc.
        3. DATA_CORRUPTION - Any data integrity issues including corrupt files, database inconsistencies, missing data, data quality issues, etc.

        Respond ONLY with the incident type that best matches the description. Only respond with one of: SECURITY_BREACH, SERVICE_OUTAGE, or DATA_CORRUPTION"""

        messages = [
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": f"Determine the incident type for this situation: {message}"}
        ]

        formatted_messages = [
            {
                "role": "system",
                "content": [{"type": "text", "text": system_prompt}]
            },
            {
                "role": "user",
                "content": [{
                    "type": "text",
                    "text": f"Determine the incident type for this situation: {message}"
                }]
            }
        ]

        response = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=formatted_messages,
            temperature=0,  # Use 0 for more deterministic responses
            max_tokens=50   # We only need a short response
        )

        incident_type_str = response.choices[0].message.content.strip()

        # Map the response to our IncidentType enum
        try:
            return IncidentType(incident_type_str.lower())
        except ValueError:
            # Default to data corruption if we get an unexpected response
            # In production, you might want to handle this differently
            print(f"Warning: Unexpected incident type '{incident_type_str}', defaulting to data corruption")
            return IncidentType.DATA_CORRUPTION

    def handle_message(self, conversation_id: str, message: str) -> str:
        """Handle an incoming message"""
        # Get or create conversation
        if conversation_id not in self.conversations:
            self.conversations[conversation_id] = Conversation(
                id=conversation_id
            )

        conversation = self.conversations[conversation_id]

        if conversation.state == ConversationState.INITIAL:
            # Determine which specialist should handle this
            incident_type = self.determine_incident_type(message)
            specialist = self.specialists[incident_type]

            # Update conversation state
            conversation.current_agent = specialist.specialty
            conversation.state = ConversationState.SPECIALIST_ENGAGED

            return specialist.handle_message(message, conversation)

        elif conversation.state == ConversationState.SPECIALIST_ENGAGED:
            # Continue conversation with current specialist
            specialist = self.specialists[
                IncidentType(conversation.current_agent)
            ]
            return specialist.handle_message(message, conversation)

examples/handle_incident.py

from src.agents.coordinator import CoordinatorAgent

def main():
    # Initialize the coordinator
    coordinator = CoordinatorAgent()

    # Simulate a security incident conversation
    conv_id = "incident-123"
    messages = [
        "We've detected unusual access patterns in our authentication logs",
        "Yes, there are multiple failed login attempts from the same IP",
        "Should we block these IPs and force password resets?"
    ]

    # Process each message
    for message in messages:
        print(f"\nUser: {message}")
        response = coordinator.handle_message(conv_id, message)
        print(f"Assistant: {response}")

if __name__ == "__main__":
    main()

Call the example by running:

python examples/handle_incident.py

Final Thoughts

Whether this approach is right for you depends on your specific needs. Consider:

Remember, there's no one-size-fits-all solution. The best approach is often the one that matches your organisation's specific needs and constraints. Just make sure you're not creating more complexity than you're solving!

Ready to try it yourself? The complete code is above. Feel free to adapt it to your needs, and don't forget to let me know how it works for you!

Disclaimer: The perspectives shared here are my own and do not necessarily represent those of my employer. I use GenAI as a tool to help me compose and structure my articles.

Originally published on Medium ↗ · All writing