Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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