Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -373,3 +373,6 @@ MCP_PORT=8811
# Development
# -----------------------------------------------------------------------------
NGROK_URL=

# Show the initial user RCA message (the auto-generated prompt) in the chat UI.
DISPLAY__RCA_USER_MSG=false
12 changes: 12 additions & 0 deletions client/src/components/tool-calls/ToolExecutionWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,18 @@ const ToolExecutionWidget = ({ tool, className, sendMessage, sendRaw, onToolUpda
else if (tool.tool_name === "list_slack_channels" || tool.tool_name === "get_channel_history" || tool.tool_name === "get_thread_replies") {
command = parseSlackCommand(tool.tool_name, normalizedInput)
}
// Alert payload drill-down tool: show the json_path being queried
else if (tool.tool_name === "get_alert_field") {
try {
const parsed = JSON.parse(normalizedInput)
const path = parsed.json_path || parsed.kwargs?.json_path || ''
if (path) {
command = `get_alert_field: ${path}`
}
} catch {
// Keep the precomputed fallback
}
}

// If command is still JSON blob, use default
if (typeof command === "string" && command.trim().startsWith("{")) {
Expand Down
190 changes: 190 additions & 0 deletions server/chat/backend/agent/tools/alert_payload_tool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
"""
Alert Payload Drill-Down Tool

Retrieves full (untruncated) field values from the stored webhook payload.
Used by the RCA agent when the initial prompt contained a truncated payload
and the agent needs to inspect a specific field in full.
"""

import json
import logging
from datetime import timedelta
from typing import Any, Optional, Tuple

from pydantic import BaseModel, Field

logger = logging.getLogger(__name__)

_SOURCE_TABLE_MAP = {
"grafana": "grafana_alerts",
"datadog": "datadog_events",
"newrelic": "newrelic_events",
"pagerduty": "pagerduty_events",
"opsgenie": "opsgenie_events",
"sentry": "sentry_events",
"splunk": "splunk_alerts",
"dynatrace": "dynatrace_problems",
"bigpanda": "bigpanda_events",
"netdata": "netdata_alerts",
"incidentio": "incidentio_alerts",
"jenkins": "jenkins_deployment_events",
"cloudbees": "jenkins_deployment_events",
"spinnaker": "spinnaker_deployment_events",
}

_PAYLOAD_LOOKUP_WINDOW = timedelta(minutes=10)


class GetAlertFieldArgs(BaseModel):
json_path: str = Field(
description=(
"Dot-separated path to the field in the webhook payload. "
"Use numeric indices for arrays. "
"Examples: 'alerts.0.labels', 'event.incident.summary', 'results.0'"
)
)


GET_ALERT_FIELD_DESCRIPTION = (
"Retrieve the full (untruncated) value of a field from the original webhook payload. "
"Use when the RCA prompt shows a truncated field you need to inspect fully. "
"Provide a dot-separated JSON path (e.g. 'event.incident.custom_field_entries', 'alerts.0.annotations')."
)


def _validate_inputs(json_path: str, user_id: Optional[str], incident_id: Optional[str]) -> Optional[str]:
"""Validate required inputs; returns an error string or None if valid."""
if not user_id:
return "Error: User authentication required."
if not incident_id:
return "Error: No incident context. This tool is only available during RCA investigations."
if not json_path or not json_path.strip():
return "Error: json_path is required. Provide a dot-separated path like 'event.incident.summary'."
return None


def _fetch_payload(cursor, conn, incident_id: str, user_id: str) -> Tuple[Optional[Any], Optional[str]]:
"""Fetch the raw payload for an incident. Returns (payload_dict, error_string)."""
from utils.auth.stateless_auth import set_rls_context

set_rls_context(cursor, conn, user_id, log_prefix="[GetAlertField]")

cursor.execute(
"SELECT source_type, source_alert_id, created_at FROM incidents WHERE id = %s",
(incident_id,),
)
row = cursor.fetchone()
if not row:
return None, f"Error: Incident {incident_id} not found."

source_type, source_alert_id, incident_created_at = row[0], row[1], row[2]
table = _SOURCE_TABLE_MAP.get(source_type)
if not table:
return None, f"Error: Unknown source type '{source_type}'. Cannot look up payload."

# Primary: direct ID lookup
cursor.execute(
f"SELECT payload FROM {table} WHERE id = %s",
(source_alert_id,),
)
payload_row = cursor.fetchone()

# Fallback: time-window constrained lookup (fail-closed if ambiguous)
if not payload_row or not payload_row[0]:
if incident_created_at:
window_start = incident_created_at - _PAYLOAD_LOOKUP_WINDOW
window_end = incident_created_at + _PAYLOAD_LOOKUP_WINDOW
cursor.execute(
f"SELECT payload FROM {table} "
f"WHERE user_id = %s AND received_at BETWEEN %s AND %s",
(user_id, window_start, window_end),
)
rows = cursor.fetchall()
if len(rows) == 1:
payload_row = rows[0]
else:
payload_row = None

if not payload_row or not payload_row[0]:
return None, f"Error: No payload found in {table} for source_alert_id {source_alert_id}."

payload = payload_row[0]
if isinstance(payload, str):
payload = json.loads(payload)

return payload, None


def _traverse_path(payload: Any, json_path: str) -> Tuple[Optional[Any], Optional[str]]:

Check failure on line 118 in server/chat/backend/agent/tools/alert_payload_tool.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 18 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=Arvo-AI_aurora&issues=AZ51lBnOtNR1yXmdIbio&open=AZ51lBnOtNR1yXmdIbio&pullRequest=457
"""Walk a dot-separated path through the payload. Returns (value, error_string)."""
path_parts = json_path.strip().split(".")
current = payload
for part in path_parts:
if current is None:
return None, f"Error: Path '{json_path}' not found. Value is null at '{part}'."
if isinstance(current, dict):
if part in current:
current = current[part]
else:
available = list(current.keys())[:20]
return None, (
f"Error: Key '{part}' not found at this level.\n"
f"Available keys: {available}"
)
elif isinstance(current, list):
try:
idx = int(part)
except ValueError:
return None, f"Error: Expected numeric index for list, got '{part}'."
if 0 <= idx < len(current):
current = current[idx]
else:
return None, f"Error: Index {idx} out of range (list has {len(current)} items)."
else:
return None, f"Error: Cannot traverse into {type(current).__name__} at '{part}'."
return current, None


def _format_output(value: Any) -> str:
"""Serialize and truncate the extracted value for tool output."""
from chat.backend.constants import MAX_TOOL_OUTPUT_CHARS

if isinstance(value, (dict, list)):
result = json.dumps(value, ensure_ascii=False, default=str, indent=2)
else:
result = str(value) if value is not None else "null"

if len(result) > MAX_TOOL_OUTPUT_CHARS:
result = result[:MAX_TOOL_OUTPUT_CHARS] + "\n... [output truncated]"
return result


def get_alert_field(
json_path: str,
user_id: Optional[str] = None,
incident_id: Optional[str] = None,
**kwargs,
) -> str:
"""Retrieve a specific field from the stored webhook payload."""
error = _validate_inputs(json_path, user_id, incident_id)
if error:
return error

from utils.db.connection_pool import db_pool

try:
with db_pool.get_admin_connection() as conn:
with conn.cursor() as cursor:
payload, err = _fetch_payload(cursor, conn, incident_id, user_id)
if err:
return err

value, err = _traverse_path(payload, json_path)
if err:
return err

return _format_output(value)

except Exception as e:
logger.exception("[GetAlertField] Error retrieving field: %s", e)
return f"Error retrieving alert field: {e}"
18 changes: 18 additions & 0 deletions server/chat/backend/agent/tools/cloud_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -2366,6 +2366,24 @@ def _pinned_trigger(action_id: str = "", _pid=pinned_id, _fn=final_func, **kw):
except Exception as e:
logging.warning(f"Failed to add Cloudflare tools (treating as not connected): {e}")

# Add alert payload drill-down tool for RCA sessions with an incident
incident_id = getattr(state_context, 'incident_id', None) if state_context else None
if incident_id and is_background:
try:
from .alert_payload_tool import get_alert_field, GetAlertFieldArgs, GET_ALERT_FIELD_DESCRIPTION
_ctx = with_forced_context(get_alert_field)
_notif = with_completion_notification(_ctx)
_final = wrap_func_with_capture(_notif, "get_alert_field") if tool_capture else _notif
tools.append(StructuredTool.from_function(
func=_final,
name="get_alert_field",
description=GET_ALERT_FIELD_DESCRIPTION,
args_schema=GetAlertFieldArgs,
))
logging.info(f"Added get_alert_field tool for incident {incident_id}")
except Exception as e:
logging.warning(f"Failed to add get_alert_field tool: {e}")

logging.info(f"Created {len(tools)} Aurora native tools")

# Add real MCP tools if available
Expand Down
22 changes: 17 additions & 5 deletions server/chat/backend/agent/tools/output_sanitizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,38 @@

logger = logging.getLogger(__name__)

def truncate_json_fields(data, max_field_length=10000):
def truncate_json_fields(data, max_field_length=10000, max_depth=None, _current_depth=0):

Check failure on line 12 in server/chat/backend/agent/tools/output_sanitizer.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 27 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=Arvo-AI_aurora&issues=AZ5w2VbWsqj_jw08YIY7&open=AZ5w2VbWsqj_jw08YIY7&pullRequest=457
"""
Recursively truncate string fields in JSON data while preserving the JSON structure.
Only truncates individual string values, not the entire JSON object.
When max_depth is set, nested structures beyond that depth are replaced with summaries.
"""
if max_depth is not None and _current_depth >= max_depth:
if isinstance(data, dict):
return f"{{object, {len(data)} keys: {', '.join(list(data.keys())[:5])}{', ...' if len(data) > 5 else ''}}}"
elif isinstance(data, list):
return f"[array, {len(data)} items]"
elif isinstance(data, str):
if len(data) > max_field_length:
return data[:max_field_length] + "... [field truncated]"

Check failure on line 25 in server/chat/backend/agent/tools/output_sanitizer.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "... [field truncated]" 3 times.

See more on https://sonarcloud.io/project/issues?id=Arvo-AI_aurora&issues=AZ5w2VbWsqj_jw08YIY6&open=AZ5w2VbWsqj_jw08YIY6&pullRequest=457
return data
else:
return data

if isinstance(data, str):
if len(data) > max_field_length:
return data[:max_field_length] + "... [field truncated]"
return data
elif isinstance(data, dict):
truncated_dict = {}
for key, value in data.items():
# Truncate the key if it's too long
safe_key = str(key) if key is not None else "null_key"
if len(safe_key) > 200: # Reasonable limit for keys
if len(safe_key) > 200:
safe_key = safe_key[:200] + "..."
truncated_dict[safe_key] = truncate_json_fields(value, max_field_length)
truncated_dict[safe_key] = truncate_json_fields(value, max_field_length, max_depth, _current_depth + 1)
return truncated_dict
elif isinstance(data, list):
return [truncate_json_fields(item, max_field_length) for item in data]
return [truncate_json_fields(item, max_field_length, max_depth, _current_depth + 1) for item in data]
else:
return data

Expand Down
16 changes: 12 additions & 4 deletions server/chat/backend/agent/tools/trigger_rca_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ def trigger_rca(
create_background_chat_session,
run_background_chat,
)
from chat.background.rca_prompt_builder import build_chat_rca_prompt
from chat.background.rca_prompt_builder import build_rca_prompt

trigger_metadata = {"source": "chat", "incident_id": incident_id}

Expand All @@ -191,9 +191,17 @@ def trigger_rca(
trigger_metadata=trigger_metadata, incident_id=incident_id,
)

rca_prompt, rail_text = build_chat_rca_prompt(
description=issue_description, title=incident_title,
service=service, severity=severity, user_id=user_id,
# Small enough to always pass verbatim (no truncation/get_alert_field needed)
payload: dict = {
"title": incident_title,
"status": "investigating",
"description": issue_description,
"service": service,
"severity": severity,
}

rca_prompt, rail_text = build_rca_prompt(
"chat", incident_title, payload, user_id=user_id,
)

task = run_background_chat.delay(
Expand Down
Loading
Loading