setUsername(e.target.value)} placeholder="you@example.com" className={inp} />
@@ -262,6 +257,15 @@ export default function InitPage() {
Configure the AWS region and optional SNS topic for alert delivery.
+
+
+
setOrgName(e.target.value)} placeholder="Acme Corp" className={inp} />
+
+ Used to group users. You can add team members later from Settings.
+
+
diff --git a/frontend/src/pages/MonitoringPage.tsx b/frontend/src/pages/MonitoringPage.tsx
index c8c3995..0bad377 100644
--- a/frontend/src/pages/MonitoringPage.tsx
+++ b/frontend/src/pages/MonitoringPage.tsx
@@ -81,8 +81,12 @@ export default function MonitoringPage() {
: 'Listening for AWS events. Incidents will appear here when detected.';
const investigate = (a: Alert) => {
- const sessionId = crypto.randomUUID();
- navigate(`/chat/${sessionId}?prompt=${encodeURIComponent(buildPrompt(a))}`);
+ if (a.session_id) {
+ navigate(`/chat/${a.session_id}`);
+ } else {
+ const sessionId = crypto.randomUUID();
+ navigate(`/chat/${sessionId}?prompt=${encodeURIComponent(buildPrompt(a))}`);
+ }
};
return (
@@ -175,18 +179,25 @@ export default function MonitoringPage() {
{a.service}
- {a.confidence}
+ {a.status === 'failed' ? (
+
+ FAILED
+
+ ) : (
+ {a.confidence}
+ )}
{a.error}
@@ -196,7 +207,7 @@ export default function MonitoringPage() {
onClick={e => { e.stopPropagation(); investigate(a); }}
className="hidden group-hover:flex items-center gap-1 text-xs font-medium text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-500/10 px-2 py-1 rounded-md hover:bg-indigo-100 dark:hover:bg-indigo-500/20 transition-colors"
>
-
Investigate
+
{a.session_id ? 'View investigation' : 'Investigate'}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 1714cfe..202ca3e 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -104,8 +104,10 @@ export interface Alert {
error: string;
resolution: string;
confidence: 'HIGH' | 'MEDIUM' | 'LOW';
+ status: 'completed' | 'failed';
sns_sent: boolean;
timestamp: string;
+ session_id?: string;
}
export interface ServiceStatus {
diff --git a/migrations/008_alerts_status.sql b/migrations/008_alerts_status.sql
new file mode 100644
index 0000000..088861c
--- /dev/null
+++ b/migrations/008_alerts_status.sql
@@ -0,0 +1,3 @@
+-- Add status to alerts to distinguish completed vs failed investigations
+ALTER TABLE alerts ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'completed';
+CREATE INDEX IF NOT EXISTS idx_alerts_status ON alerts (status);
diff --git a/migrations/009_session_source_alert_session.sql b/migrations/009_session_source_alert_session.sql
new file mode 100644
index 0000000..919e187
--- /dev/null
+++ b/migrations/009_session_source_alert_session.sql
@@ -0,0 +1,6 @@
+-- Distinguish event-triggered sessions from user-initiated chats
+ALTER TABLE sessions ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'chat';
+CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions (source);
+
+-- Link alerts back to the session that produced them
+ALTER TABLE alerts ADD COLUMN IF NOT EXISTS session_id UUID REFERENCES sessions(id) ON DELETE SET NULL;
diff --git a/migrations/010_session_user_interacted.sql b/migrations/010_session_user_interacted.sql
new file mode 100644
index 0000000..e14bab5
--- /dev/null
+++ b/migrations/010_session_user_interacted.sql
@@ -0,0 +1,3 @@
+-- Track whether a user has ever sent a message in an event-triggered session.
+-- FALSE = auto-run only (hidden from sidebar); TRUE = user engaged (show in sidebar).
+ALTER TABLE sessions ADD COLUMN IF NOT EXISTS user_interacted BOOLEAN NOT NULL DEFAULT FALSE;
diff --git a/scripts/test_pipeline.py b/scripts/test_pipeline.py
index 38f4aa6..8ef619e 100644
--- a/scripts/test_pipeline.py
+++ b/scripts/test_pipeline.py
@@ -12,7 +12,26 @@
uv run python scripts/test_pipeline.py --region eu-west-1
uv run python scripts/test_pipeline.py --invocations 10
uv run python scripts/test_pipeline.py --list # list available Lambdas
- uv run python scripts/test_pipeline.py --lambda-only # real alarm path
+ uv run python scripts/test_pipeline.py --lambda-only # real alarm path (~2-4 min)
+
+─────────────────────────────────────────────────────────────────────────────────
+MANUAL SQS TEST COMMANDS (bypass script — push directly to the queue)
+Replace QUEUE_URL and ACCOUNT_ID with your values from Settings → AWS Configuration.
+─────────────────────────────────────────────────────────────────────────────────
+
+# Syntax error (Runtime.UserCodeSyntaxError) — fast, focused investigation:
+aws sqs send-message --profile devops-agent-readonly --queue-url "https://sqs.us-east-1.amazonaws.com/ACCOUNT_ID/opendevops-agent-events" --message-body "{\"source\":\"aws.lambda\",\"detail-type\":\"Lambda Function Invocation Result - Failure\",\"time\":\"2026-05-17T12:00:00Z\",\"detail\":{\"requestContext\":{\"functionArn\":\"arn:aws:lambda:us-east-1:ACCOUNT_ID:function:opendevops-test-failure\",\"condition\":\"RetriesExhausted\",\"approximateInvokeCount\":3},\"responsePayload\":{\"errorMessage\":\"Syntax error in module lambda_function: unexpected indent (lambda_function.py, line 9)\",\"errorType\":\"Runtime.UserCodeSyntaxError\",\"stackTrace\":[\"File /var/task/lambda_function.py line 9: def lambda_handler(event, context):\"]}}}
+
+# CloudWatch alarm event — slower path (~1 min CloudWatch delay in real scenario):
+aws sqs send-message --profile devops-agent-readonly \
+ --queue-url "https://sqs.us-east-1.amazonaws.com/ACCOUNT_ID/opendevops-agent-events" \
+ --message-body "{\"source\":\"aws.cloudwatch\",\"detail-type\":\"CloudWatch Alarm State Change\",\"time\":\"2026-05-17T12:00:00Z\",\"detail\":{\"alarmName\":\"opendevops-test-lambda-errors\",\"state\":{\"value\":\"ALARM\",\"reason\":\"TEST: Lambda error rate exceeded threshold\"},\"configuration\":{\"metrics\":[{\"metricStat\":{\"metric\":{\"namespace\":\"AWS/Lambda\",\"name\":\"Errors\",\"dimensions\":{\"FunctionName\":\"opendevops-test-failure\"}},\"period\":300,\"stat\":\"Sum\"}}]}}}"
+
+Notes:
+ - Event consumer picks up the message within 20s (SQS long-poll interval)
+ - Use aws.lambda source for faster, more focused investigations
+ - MAX_TOOL_CALLS=30 recommended to avoid recursion errors with weaker models
+─────────────────────────────────────────────────────────────────────────────────
"""
from __future__ import annotations
diff --git a/src/agent/db/base.py b/src/agent/db/base.py
index 98c095c..40620ef 100644
--- a/src/agent/db/base.py
+++ b/src/agent/db/base.py
@@ -31,6 +31,7 @@ async def upsert_session(
model: str,
aws_region: str,
title: str | None = None,
+ source: str = "chat",
) -> None: ...
@abstractmethod
@@ -137,6 +138,8 @@ async def add_alert(
confidence: str,
sns_sent: bool,
dedup_key: str | None = None,
+ status: str = "completed",
+ session_id: str | None = None,
) -> str:
return ""
diff --git a/src/agent/db/memory.py b/src/agent/db/memory.py
index b69ab12..ffd8b1f 100644
--- a/src/agent/db/memory.py
+++ b/src/agent/db/memory.py
@@ -4,7 +4,7 @@
import uuid
from collections import defaultdict
-from datetime import datetime, timezone
+from datetime import UTC, datetime
from typing import Any
from loguru import logger
@@ -43,7 +43,7 @@ def checkpointer(self) -> Any:
@staticmethod
def _now() -> str:
- return datetime.now(timezone.utc).isoformat()
+ return datetime.now(UTC).isoformat()
# ── App helpers ───────────────────────────────────────────────────────────
@@ -53,16 +53,21 @@ async def upsert_session(
model: str,
aws_region: str,
title: str | None = None,
+ source: str = "chat",
) -> None:
if session_id in self._sessions:
self._sessions[session_id]["last_active_at"] = self._now()
self._sessions[session_id]["model"] = model
+ if source == "chat":
+ self._sessions[session_id]["user_interacted"] = True
else:
self._sessions[session_id] = {
"id": session_id,
"title": title,
"model": model,
"aws_region": aws_region,
+ "source": source,
+ "user_interacted": source == "chat",
"created_at": self._now(),
"last_active_at": self._now(),
"is_deleted": False,
@@ -133,7 +138,11 @@ async def save_usage_event(
})
async def list_sessions(self, limit: int = 15, offset: int = 0) -> list[dict]:
- active = [s for s in self._sessions.values() if not s.get("is_deleted")]
+ active = [
+ s for s in self._sessions.values()
+ if not s.get("is_deleted")
+ and (s.get("source", "chat") == "chat" or s.get("user_interacted", False))
+ ]
sorted_sessions = sorted(active, key=lambda s: s["last_active_at"], reverse=True)
return sorted_sessions[offset: offset + limit]
@@ -241,6 +250,9 @@ async def add_alert(
resolution: str,
confidence: str,
sns_sent: bool,
+ dedup_key: str | None = None,
+ status: str = "completed",
+ session_id: str | None = None,
) -> str:
alert_id = str(uuid.uuid4())
self._alerts.append({
@@ -251,6 +263,9 @@ async def add_alert(
"confidence": confidence,
"sns_sent": sns_sent,
"timestamp": self._now(),
+ "dedup_key": dedup_key,
+ "status": status,
+ "session_id": session_id,
})
return alert_id
diff --git a/src/agent/db/postgres.py b/src/agent/db/postgres.py
index d2a415b..bc0c8ca 100644
--- a/src/agent/db/postgres.py
+++ b/src/agent/db/postgres.py
@@ -27,8 +27,8 @@ async def init(self) -> Any:
try:
import psycopg # type: ignore
- from psycopg_pool import AsyncConnectionPool
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
+ from psycopg_pool import AsyncConnectionPool
self._pool = AsyncConnectionPool(
conninfo=settings.database_url,
@@ -123,16 +123,21 @@ async def upsert_session(
model: str,
aws_region: str,
title: str | None = None,
+ source: str = "chat",
) -> None:
await self._exec(
"""
- INSERT INTO sessions (id, title, model, aws_region)
- VALUES (%s, %s, %s, %s)
+ INSERT INTO sessions (id, title, model, aws_region, source)
+ VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (id) DO UPDATE SET
last_active_at = NOW(),
- model = EXCLUDED.model
+ model = EXCLUDED.model,
+ user_interacted = CASE
+ WHEN EXCLUDED.source = 'chat' THEN TRUE
+ ELSE sessions.user_interacted
+ END
""",
- uuid.UUID(session_id), title, model, aws_region,
+ uuid.UUID(session_id), title, model, aws_region, source,
)
async def save_message(
@@ -198,7 +203,9 @@ async def save_usage_event(
async def list_sessions(self, limit: int = 15, offset: int = 0) -> list[dict]:
rows = await self._fetchall(
- "SELECT id, title, last_active_at, model, aws_region FROM sessions WHERE is_deleted = FALSE ORDER BY last_active_at DESC LIMIT %s OFFSET %s",
+ "SELECT id, title, last_active_at, model, aws_region FROM sessions"
+ " WHERE is_deleted = FALSE AND (source = 'chat' OR user_interacted = TRUE)"
+ " ORDER BY last_active_at DESC LIMIT %s OFFSET %s",
limit, offset,
)
return [
@@ -568,11 +575,15 @@ async def add_alert(
confidence: str,
sns_sent: bool,
dedup_key: str | None = None,
+ status: str = "completed",
+ session_id: str | None = None,
) -> str:
row = await self._fetchrow(
- "INSERT INTO alerts (service, error, resolution, confidence, sns_sent, dedup_key) "
- "VALUES (%s, %s, %s, %s, %s, %s) RETURNING id",
- service, error, resolution, confidence, sns_sent, dedup_key,
+ "INSERT INTO alerts"
+ " (service, error, resolution, confidence, sns_sent, dedup_key, status, session_id)"
+ " VALUES (%s, %s, %s, %s, %s, %s, %s, %s) RETURNING id",
+ service, error, resolution, confidence, sns_sent, dedup_key, status,
+ uuid.UUID(session_id) if session_id else None,
)
return str(row["id"]) if row else ""
@@ -586,7 +597,7 @@ async def is_recent_alert(self, dedup_key: str, within_minutes: int = 3) -> bool
async def get_alerts(self, limit: int = 50) -> list[dict]:
rows = await self._fetchall(
- "SELECT id, service, error, resolution, confidence, sns_sent, created_at "
+ "SELECT id, service, error, resolution, confidence, sns_sent, status, created_at, session_id "
"FROM alerts ORDER BY created_at DESC LIMIT %s",
min(limit, 200),
)
@@ -598,14 +609,16 @@ async def get_alerts(self, limit: int = 50) -> list[dict]:
"resolution": r["resolution"],
"confidence": r["confidence"],
"sns_sent": bool(r["sns_sent"]),
+ "status": r["status"],
"timestamp": r["created_at"].isoformat() if r["created_at"] else None,
+ "session_id": str(r["session_id"]) if r["session_id"] else None,
}
for r in rows
]
async def get_alert(self, alert_id: str) -> dict | None:
row = await self._fetchrow(
- "SELECT id, service, error, resolution, confidence, sns_sent, created_at "
+ "SELECT id, service, error, resolution, confidence, sns_sent, status, created_at, session_id "
"FROM alerts WHERE id = %s",
uuid.UUID(alert_id),
)
@@ -618,7 +631,9 @@ async def get_alert(self, alert_id: str) -> dict | None:
"resolution": row["resolution"],
"confidence": row["confidence"],
"sns_sent": bool(row["sns_sent"]),
+ "status": row["status"],
"timestamp": row["created_at"].isoformat() if row["created_at"] else None,
+ "session_id": str(row["session_id"]) if row["session_id"] else None,
}
async def get_app_config(self, key: str) -> dict | None:
diff --git a/src/agent/db/sqlite.py b/src/agent/db/sqlite.py
index 6bd646c..e30ad76 100644
--- a/src/agent/db/sqlite.py
+++ b/src/agent/db/sqlite.py
@@ -12,17 +12,18 @@
from agent.db.base import DatabaseBackend
from config import settings
-
_DDL = """
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
title TEXT,
model TEXT NOT NULL DEFAULT '',
aws_region TEXT NOT NULL DEFAULT 'us-east-1',
- created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')),
- last_active_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')),
- is_deleted INTEGER NOT NULL DEFAULT 0,
- deleted_at TEXT
+ source TEXT NOT NULL DEFAULT 'chat',
+ user_interacted INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')),
+ last_active_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')),
+ is_deleted INTEGER NOT NULL DEFAULT 0,
+ deleted_at TEXT
);
CREATE TABLE IF NOT EXISTS messages (
@@ -132,6 +133,17 @@ async def init(self) -> Any:
except Exception:
pass # column already exists
+ # Migrate existing sessions tables: add source / user_interacted columns if absent
+ for col_sql in (
+ "ALTER TABLE sessions ADD COLUMN source TEXT NOT NULL DEFAULT 'chat'",
+ "ALTER TABLE sessions ADD COLUMN user_interacted INTEGER NOT NULL DEFAULT 0",
+ ):
+ try:
+ await self._conn.execute(col_sql)
+ await self._conn.commit()
+ except Exception:
+ pass # column already exists
+
# Migrate existing databases: create alerts table if absent
try:
await self._conn.execute(
@@ -139,12 +151,26 @@ async def init(self) -> Any:
"id TEXT PRIMARY KEY, service TEXT NOT NULL, error TEXT NOT NULL, "
"resolution TEXT NOT NULL DEFAULT '', confidence TEXT NOT NULL DEFAULT 'LOW', "
"sns_sent INTEGER NOT NULL DEFAULT 0, "
+ "dedup_key TEXT, status TEXT NOT NULL DEFAULT 'completed', "
+ "session_id TEXT, "
"created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')))"
)
await self._conn.commit()
except Exception:
pass
+ # Migrate existing alerts tables: add dedup_key / status / session_id if absent
+ for col_sql in (
+ "ALTER TABLE alerts ADD COLUMN dedup_key TEXT",
+ "ALTER TABLE alerts ADD COLUMN status TEXT NOT NULL DEFAULT 'completed'",
+ "ALTER TABLE alerts ADD COLUMN session_id TEXT",
+ ):
+ try:
+ await self._conn.execute(col_sql)
+ await self._conn.commit()
+ except Exception:
+ pass # column already exists
+
try:
await self._conn.execute(
"CREATE TABLE IF NOT EXISTS app_config ("
@@ -219,16 +245,21 @@ async def upsert_session(
model: str,
aws_region: str,
title: str | None = None,
+ source: str = "chat",
) -> None:
await self._exec(
"""
- INSERT INTO sessions (id, title, model, aws_region)
- VALUES (?, ?, ?, ?)
+ INSERT INTO sessions (id, title, model, aws_region, source)
+ VALUES (?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
last_active_at = strftime('%Y-%m-%dT%H:%M:%S', 'now'),
- model = excluded.model
+ model = excluded.model,
+ user_interacted = CASE
+ WHEN excluded.source = 'chat' THEN 1
+ ELSE sessions.user_interacted
+ END
""",
- session_id, title, model, aws_region,
+ session_id, title, model, aws_region, source,
)
async def save_message(
@@ -291,7 +322,9 @@ async def save_usage_event(
async def list_sessions(self, limit: int = 15, offset: int = 0) -> list[dict]:
rows = await self._fetchall(
- "SELECT id, title, last_active_at, model, aws_region FROM sessions WHERE is_deleted = 0 ORDER BY last_active_at DESC LIMIT ? OFFSET ?",
+ "SELECT id, title, last_active_at, model, aws_region FROM sessions"
+ " WHERE is_deleted = 0 AND (source = 'chat' OR user_interacted = 1)"
+ " ORDER BY last_active_at DESC LIMIT ? OFFSET ?",
limit, offset,
)
return [
@@ -624,11 +657,17 @@ async def add_alert(
resolution: str,
confidence: str,
sns_sent: bool,
+ dedup_key: str | None = None,
+ status: str = "completed",
+ session_id: str | None = None,
) -> str:
alert_id = str(uuid.uuid4())
await self._exec(
- "INSERT INTO alerts (id, service, error, resolution, confidence, sns_sent) VALUES (?, ?, ?, ?, ?, ?)",
- alert_id, service, error, resolution, confidence, 1 if sns_sent else 0,
+ "INSERT INTO alerts"
+ " (id, service, error, resolution, confidence, sns_sent, dedup_key, status, session_id)"
+ " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ alert_id, service, error, resolution, confidence,
+ 1 if sns_sent else 0, dedup_key, status, session_id,
)
return alert_id
diff --git a/src/agent/event_consumer.py b/src/agent/event_consumer.py
index 5eee2a6..2bcce17 100644
--- a/src/agent/event_consumer.py
+++ b/src/agent/event_consumer.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
+import hashlib
import json
import uuid
from typing import Any
@@ -18,6 +19,11 @@
from agent.monitor_store import add_alert, is_recent_alert, update_service
from config import settings
+# In-memory set of dedup keys currently being investigated.
+# Prevents a second identical event from starting a parallel agent run
+# before the first one finishes and writes its key to the DB.
+_in_progress: set[str] = set()
+
def _build_investigation_prompt(event: dict) -> str:
"""Turn a raw EventBridge event into a focused investigation prompt."""
@@ -100,77 +106,150 @@ def _build_investigation_prompt(event: dict) -> str:
)
-async def _run_investigation(prompt: str) -> dict[str, Any] | None:
- """Run a full agent investigation and return the submit_investigation result."""
+async def _run_investigation(prompt: str, session_id: str) -> dict[str, Any] | None:
+ """Run a full agent investigation, persist the session, and return the result."""
from agent.core import get_agent
+ from agent.turns import save_turn
- thread_id = str(uuid.uuid4())
config = {
- "configurable": {"thread_id": thread_id},
+ "configurable": {"thread_id": session_id},
"recursion_limit": settings.max_tool_calls * 3 + 15,
}
+ investigation_result: dict[str, Any] | None = None
+ messages: list = []
+
try:
result = await get_agent().ainvoke(
{"messages": [{"role": "user", "content": prompt}]},
config=config,
)
- for msg in reversed(result.get("messages", [])):
+ messages = result.get("messages", [])
+ for msg in reversed(messages):
for tc in getattr(msg, "tool_calls", []) or []:
name = tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", None)
args = tc.get("args") if isinstance(tc, dict) else getattr(tc, "args", {})
if name == "submit_investigation":
- return args
- messages = result.get("messages", [])
- if messages:
+ investigation_result = args
+ break
+ if investigation_result:
+ break
+ if not investigation_result and messages:
last = messages[-1]
content = last.content if hasattr(last, "content") else str(last)
if content:
- return {
+ investigation_result = {
"root_cause_summary": content,
"confidence": "MEDIUM",
"mitigation_steps": [],
}
except Exception as e:
logger.error("Agent investigation failed: {}", e)
- return None
+ investigation_result = {
+ "_status": "failed",
+ "root_cause_summary": f"Investigation failed: {e}",
+ "confidence": "LOW",
+ "mitigation_steps": [
+ "The agent hit its tool call limit or encountered an error.",
+ "Re-investigate manually via the chat — use a higher MAX_TOOL_CALLS if needed.",
+ ],
+ }
+
+ # Persist the session regardless of success/failure
+ input_tokens, output_tokens = 0, 0
+ for msg in messages:
+ um = getattr(msg, "usage_metadata", None) or {}
+ input_tokens += um.get("input_tokens", 0)
+ output_tokens += um.get("output_tokens", 0)
+
+ # Build a readable assistant summary from the investigation result
+ if investigation_result and investigation_result.get("root_cause_summary"):
+ steps = investigation_result.get("mitigation_steps", [])
+ steps_text = (
+ "\n".join(f"- {s}" for s in steps) if steps else "_No steps provided._"
+ )
+ confidence = investigation_result.get("confidence", "MEDIUM")
+ assistant_text = (
+ f"**Root Cause ({confidence} confidence):** "
+ f"{investigation_result['root_cause_summary']}\n\n"
+ f"**Mitigation Steps:**\n{steps_text}"
+ )
+ else:
+ assistant_text = "Investigation completed. See tool calls for details."
+
+ await save_turn(
+ session_id=session_id,
+ user_message=prompt,
+ assistant_text=assistant_text,
+ tool_calls_log=[],
+ usage={
+ "model": settings.llm_model,
+ "input_tokens": input_tokens,
+ "output_tokens": output_tokens,
+ "latency_ms": 0,
+ },
+ source="event",
+ )
+
+ return investigation_result
+
+
+_VOLATILE_KEYS = {
+ "time", "timestamp", "eventTime", "requestId", "eventID",
+ "approximateInvokeCount", "startTime", "endTime", "updatedAt",
+ "stateTransitionedAt", "lastUpdatedTime",
+}
+
+
+def _strip_volatile(obj: Any) -> Any:
+ if isinstance(obj, dict):
+ return {k: _strip_volatile(v) for k, v in obj.items() if k not in _VOLATILE_KEYS}
+ if isinstance(obj, list):
+ return [_strip_volatile(i) for i in obj]
+ return obj
def _event_dedup_key(event: dict) -> str:
- """Derive a stable dedup key from an EventBridge event."""
+ """MD5 fingerprint of the event's stable fields — works for any AWS service."""
source = event.get("source", "unknown")
+ fingerprint = {
+ "source": source,
+ "detail-type": event.get("detail-type", ""),
+ "detail": _strip_volatile(event.get("detail", {})),
+ }
+ digest = hashlib.md5(
+ json.dumps(fingerprint, sort_keys=True, default=str).encode()
+ ).hexdigest()[:12]
+ return f"{source}:{digest}"
+
+
+def _extract_aws_error(event: dict) -> str:
+ """Pull the original AWS error message out of the event payload."""
+ source = event.get("source", "")
detail = event.get("detail", {})
- if source == "aws.cloudwatch":
- alarm_name = detail.get("alarmName", "")
- state = detail.get("state", {}).get("value", "")
- return f"alarm:{alarm_name}" if alarm_name else f"cloudwatch:{state}"
if source == "aws.lambda":
- fn = (
- detail.get("requestParameters", {}).get("functionName", "")
- or detail.get("resource", {}).get("functionName", "")
- )
- return f"lambda_errors:{fn}" if fn else f"lambda:{event.get('detail-type', '')}"
+ return detail.get("responsePayload", {}).get("errorMessage", "")
+ if source == "aws.cloudwatch":
+ return detail.get("state", {}).get("reason", "")
if source == "aws.ecs":
- task_arn = detail.get("taskArn", detail.get("taskDefinitionArn", ""))
- return f"ecs:{task_arn.split('/')[-1]}" if task_arn else "ecs:unknown"
+ return detail.get("stoppedReason", "")
if source == "aws.rds":
- return f"rds:{detail.get('SourceIdentifier', 'unknown')}"
- if source == "aws.ec2":
- return f"ec2:{detail.get('instance-id', 'unknown')}"
- return f"{source}:{event.get('detail-type', 'unknown')}"
+ return detail.get("Message", "")
+ return ""
-async def _deliver(result: dict[str, Any], event: dict, dedup_key: str) -> None:
+async def _deliver(result: dict[str, Any], event: dict, dedup_key: str, session_id: str) -> None:
"""Send investigation result to SNS + Slack and persist to the monitor store."""
+ status = result.pop("_status", "completed")
+ is_test = bool(event.get("_opendevops_test", False))
services_affected = result.get("services_affected", [])
- service = result.get(
- "service_name",
- result.get("service", services_affected[0] if services_affected else "unknown"),
- )
+ fallback = services_affected[0] if services_affected else event.get("source", "unknown")
+ service = result.get("service_name", result.get("service", fallback))
root_cause = result.get("root_cause_summary", "")
mitigation = result.get("mitigation_steps", [])
confidence = result.get("confidence", "MEDIUM")
resolution = "\n".join(mitigation) if isinstance(mitigation, list) else str(mitigation)
+ aws_error = _extract_aws_error(event)
sns_arn = get_runtime_sns_topic_arn()
@@ -191,11 +270,12 @@ async def _deliver(result: dict[str, Any], event: dict, dedup_key: str) -> None:
f"Confidence: {confidence}\n"
f"Event Time: {event.get('time', '')}"
)
+ subject_prefix = "[FAILED]" if status == "failed" else f"[{confidence}]"
await asyncio.get_event_loop().run_in_executor(
None,
publish_sns_alert,
sns_arn,
- f"[{confidence}] {service} — {root_cause[:80]}",
+ f"{subject_prefix} {service} — {root_cause[:80]}",
message,
)
sns_sent = True
@@ -205,14 +285,21 @@ async def _deliver(result: dict[str, Any], event: dict, dedup_key: str) -> None:
# Also notify Slack (keeps parity with the poller)
if settings.slack_webhook_url:
try:
- from integrations.slack_webhook import post_investigation
-
- session_id = str(uuid.uuid4())
- await post_investigation(settings.slack_webhook_url, result, session_id)
+ from integrations.slack_webhook import post_failed_investigation, post_investigation
+
+ if status == "failed":
+ await post_failed_investigation(
+ settings.slack_webhook_url, service, root_cause,
+ session_id, aws_error=aws_error, is_test=is_test,
+ )
+ else:
+ await post_investigation(
+ settings.slack_webhook_url, result, session_id, is_test=is_test
+ )
except Exception as e:
logger.error("Slack delivery failed from event consumer: {}", e)
- update_service(service, "error", root_cause)
+ update_service(service, status, root_cause)
await add_alert(
service=service,
error=root_cause,
@@ -220,6 +307,8 @@ async def _deliver(result: dict[str, Any], event: dict, dedup_key: str) -> None:
confidence=confidence,
sns_sent=sns_sent,
dedup_key=dedup_key,
+ status=status,
+ session_id=session_id,
)
logger.info("Alert delivered: service={} confidence={} sns={}", service, confidence, sns_sent)
@@ -253,10 +342,16 @@ async def _process_event(event: dict) -> None:
return
dedup_key = _event_dedup_key(event)
- if await is_recent_alert(dedup_key):
- logger.info("Skipping duplicate event {} — already investigated recently", dedup_key)
+ if dedup_key in _in_progress or await is_recent_alert(dedup_key):
+ logger.info(
+ "Dedup: skipping {} / {} (key={}) — {}",
+ source, detail_type, dedup_key,
+ "investigation already in progress" if dedup_key in _in_progress
+ else "same event investigated within last 3 min",
+ )
return
+ _in_progress.add(dedup_key)
logger.info("Processing event: {} / {}", source, detail_type)
prompt = _build_investigation_prompt(event)
@@ -276,11 +371,15 @@ async def _process_event(event: dict) -> None:
except Exception as e:
logger.debug("Context collection skipped: {}", e)
- result = await _run_investigation(prompt)
- if result:
- await _deliver(result, event, dedup_key)
- else:
- logger.warning("Investigation produced no result for {} / {}", source, detail_type)
+ session_id = str(uuid.uuid4())
+ try:
+ result = await _run_investigation(prompt, session_id)
+ if not result:
+ logger.warning("Investigation produced no result for {} / {}", source, detail_type)
+ return
+ await _deliver(result, event, dedup_key, session_id)
+ finally:
+ _in_progress.discard(dedup_key)
async def event_consumer_loop() -> None:
diff --git a/src/agent/monitor_store.py b/src/agent/monitor_store.py
index e9f02e1..09d934a 100644
--- a/src/agent/monitor_store.py
+++ b/src/agent/monitor_store.py
@@ -17,12 +17,16 @@ async def add_alert(
confidence: str,
sns_sent: bool,
dedup_key: str | None = None,
+ status: str = "completed",
+ session_id: str | None = None,
) -> str:
"""Persist an alert to the DB backend."""
from agent.db import db
try:
- return await db.add_alert(service, error, resolution, confidence, sns_sent, dedup_key)
+ return await db.add_alert(
+ service, error, resolution, confidence, sns_sent, dedup_key, status, session_id
+ )
except Exception as e:
logger.error("Failed to persist alert: {}", e)
return ""
diff --git a/src/agent/poller.py b/src/agent/poller.py
index ed40971..eb89984 100644
--- a/src/agent/poller.py
+++ b/src/agent/poller.py
@@ -71,7 +71,7 @@ async def _persist_and_notify(
tool_calls_log = [{"tool": "submit_investigation", "args": result, "result": {}}]
usage = {"model": settings.llm_model, "latency_ms": 0}
- await save_turn(session_id, prompt, "", tool_calls_log, usage)
+ await save_turn(session_id, prompt, "", tool_calls_log, usage, source="event")
await notify_slack(session_id, tool_calls_log)
services_affected = result.get("services_affected", [])
@@ -88,6 +88,7 @@ async def _persist_and_notify(
confidence=confidence,
sns_sent=False,
dedup_key=dedup_key,
+ session_id=session_id,
)
diff --git a/src/agent/turns.py b/src/agent/turns.py
index d6bb5bb..f440d7f 100644
--- a/src/agent/turns.py
+++ b/src/agent/turns.py
@@ -42,11 +42,14 @@ async def save_turn(
assistant_text: str,
tool_calls_log: list[dict[str, Any]],
usage: dict[str, Any],
+ source: str = "chat",
) -> None:
"""Persist a completed turn to Postgres. Errors are logged, never raised."""
try:
title = user_message[:80] if user_message else None
- await db.upsert_session(session_id, usage.get("model", ""), get_runtime_aws_region(), title)
+ await db.upsert_session(
+ session_id, usage.get("model", ""), get_runtime_aws_region(), title, source
+ )
await db.save_message(session_id, "user", user_message)
asst_msg_id = await db.save_message(
diff --git a/src/api/routers/init.py b/src/api/routers/init.py
index d29929f..58226b2 100644
--- a/src/api/routers/init.py
+++ b/src/api/routers/init.py
@@ -284,6 +284,7 @@ async def send_test_event(
now = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
event: dict = {
+ "_opendevops_test": True,
"source": "aws.cloudwatch",
"detail-type": "CloudWatch Alarm State Change",
"time": now,
diff --git a/src/integrations/slack_webhook.py b/src/integrations/slack_webhook.py
index 4ecd299..511120b 100644
--- a/src/integrations/slack_webhook.py
+++ b/src/integrations/slack_webhook.py
@@ -6,7 +6,6 @@
from loguru import logger
-
# Sidebar colour per root-cause category
_CATEGORY_COLORS: dict[str, str] = {
"SYSTEM_CHANGE": "#e67e22",
@@ -22,7 +21,12 @@
}
-def _build_payload(result: dict[str, Any], session_id: str, app_url: str | None = None) -> dict:
+def _build_payload(
+ result: dict[str, Any],
+ session_id: str,
+ app_url: str | None = None,
+ is_test: bool = False,
+) -> dict:
category = result.get("root_cause_category", "UNKNOWN")
summary = result.get("root_cause_summary", "No summary provided.")
confidence = result.get("confidence", "LOW")
@@ -31,6 +35,11 @@ def _build_payload(result: dict[str, Any], session_id: str, app_url: str | None
services = result.get("services_affected", [])
color = _CATEGORY_COLORS.get(category, "#95a5a6")
conf_emoji = _CONFIDENCE_EMOJI.get(confidence, "🔴")
+ header_text = (
+ "🧪 [TEST] OpenDevOps Agent — Investigation Complete"
+ if is_test
+ else "🔍 OpenDevOps Agent — Investigation Complete"
+ )
def _bullets(items: list[str]) -> str:
return "\n".join(f"• {i}" for i in items) if items else "_None recorded_"
@@ -38,7 +47,7 @@ def _bullets(items: list[str]) -> str:
blocks: list[dict] = [
{
"type": "header",
- "text": {"type": "plain_text", "text": "🔍 OpenDevOps Agent — Investigation Complete"},
+ "text": {"type": "plain_text", "text": header_text},
},
{"type": "divider"},
{
@@ -89,21 +98,58 @@ def _bullets(items: list[str]) -> str:
}
-async def post_investigation(
- webhook_url: str,
- result: dict[str, Any],
- session_id: str,
- app_url: str | None = None,
-) -> None:
- """Fire-and-forget: post a completed investigation result to Slack."""
+async def _post(webhook_url: str, payload: dict) -> None:
try:
import httpx
- payload = _build_payload(result, session_id, app_url)
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(webhook_url, json=payload)
if resp.status_code != 200:
logger.warning("Slack webhook returned {}: {}", resp.status_code, resp.text)
- else:
- logger.info("Slack notification sent for session {}", session_id[:8])
except Exception as e:
logger.error("Slack notification failed: {}", e)
+
+
+async def post_investigation(
+ webhook_url: str,
+ result: dict[str, Any],
+ session_id: str,
+ app_url: str | None = None,
+ is_test: bool = False,
+) -> None:
+ """Post a completed investigation result to Slack."""
+ payload = _build_payload(result, session_id, app_url, is_test=is_test)
+ await _post(webhook_url, payload)
+ logger.info("Slack notification sent for session {}", session_id[:8])
+
+
+async def post_failed_investigation(
+ webhook_url: str,
+ service: str,
+ error: str,
+ session_id: str,
+ aws_error: str = "",
+ is_test: bool = False,
+) -> None:
+ """Post a failed investigation alert to Slack."""
+ header_text = (
+ "🧪 [TEST] OpenDevOps Agent — Investigation Failed"
+ if is_test
+ else "⚠️ OpenDevOps Agent — Investigation Failed"
+ )
+ detail_text = f"*Service:* `{service}`"
+ if aws_error:
+ detail_text += f"\n*AWS error:* {aws_error}"
+ detail_text += f"\n*Agent error:* {error}"
+
+ blocks: list[dict] = [
+ {"type": "header", "text": {"type": "plain_text", "text": header_text}},
+ {"type": "divider"},
+ {"type": "section", "text": {"type": "mrkdwn", "text": detail_text}},
+ {
+ "type": "context",
+ "elements": [{"type": "mrkdwn", "text": f"Session `{session_id[:8]}`"}],
+ },
+ ]
+ payload = {"attachments": [{"color": "#e74c3c", "blocks": blocks}]}
+ await _post(webhook_url, payload)
+ logger.info("Slack failure notification sent for session {}", session_id[:8])
From 4819684fde65e80b04a3f47023d4449b11165a61 Mon Sep 17 00:00:00 2001
From: AhmadHammad21
Date: Mon, 18 May 2026 09:40:11 +0300
Subject: [PATCH 15/16] refactor: switch background investigations to astream
for full tool-call capture
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Both event consumer and poller now use astream(stream_mode="messages") — the
same streaming path as the chat endpoint — so tool calls are captured and saved
to the DB identically regardless of how an investigation is triggered. Dashboard
tool-call counts and session history now reflect event-driven investigations.
---
src/agent/event_consumer.py | 110 ++++++++++++++++----------
src/agent/poller.py | 151 +++++++++++++++++++++++++++++-------
2 files changed, 195 insertions(+), 66 deletions(-)
diff --git a/src/agent/event_consumer.py b/src/agent/event_consumer.py
index 2bcce17..a00c243 100644
--- a/src/agent/event_consumer.py
+++ b/src/agent/event_consumer.py
@@ -107,42 +107,82 @@ def _build_investigation_prompt(event: dict) -> str:
async def _run_investigation(prompt: str, session_id: str) -> dict[str, Any] | None:
- """Run a full agent investigation, persist the session, and return the result."""
+ """Run a full agent investigation using the same streaming path as the chat endpoint."""
from agent.core import get_agent
from agent.turns import save_turn
+ def _f(obj: Any, key: str, default: Any = None) -> Any:
+ return obj.get(key, default) if isinstance(obj, dict) else getattr(obj, key, default)
+
config = {
"configurable": {"thread_id": session_id},
"recursion_limit": settings.max_tool_calls * 3 + 15,
}
+ tc_accum: dict[int, dict[str, Any]] = {}
+ pending_calls: dict[str, dict[str, Any]] = {}
+ tool_calls_log: list[dict[str, Any]] = []
investigation_result: dict[str, Any] | None = None
- messages: list = []
+ response_text = ""
+ usage_meta: Any = None
try:
- result = await get_agent().ainvoke(
+ async for chunk, _meta in get_agent().astream(
{"messages": [{"role": "user", "content": prompt}]},
config=config,
- )
- messages = result.get("messages", [])
- for msg in reversed(messages):
- for tc in getattr(msg, "tool_calls", []) or []:
- name = tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", None)
- args = tc.get("args") if isinstance(tc, dict) else getattr(tc, "args", {})
- if name == "submit_investigation":
- investigation_result = args
- break
- if investigation_result:
- break
- if not investigation_result and messages:
- last = messages[-1]
- content = last.content if hasattr(last, "content") else str(last)
- if content:
- investigation_result = {
- "root_cause_summary": content,
- "confidence": "MEDIUM",
- "mitigation_steps": [],
- }
+ stream_mode="messages",
+ ):
+ um = getattr(chunk, "usage_metadata", None)
+ if um:
+ usage_meta = um
+
+ for tcc in getattr(chunk, "tool_call_chunks", []) or []:
+ idx = _f(tcc, "index", 0)
+ if idx not in tc_accum:
+ tc_accum[idx] = {"id": "", "name": "", "args_str": ""}
+ if tc_id := _f(tcc, "id"):
+ tc_accum[idx]["id"] = tc_id
+ if name := (_f(tcc, "name") or ""):
+ tc_accum[idx]["name"] += name
+ if args := (_f(tcc, "args") or ""):
+ tc_accum[idx]["args_str"] += args
+
+ for tc in getattr(chunk, "tool_calls", []) or []:
+ tc_id = _f(tc, "id") or ""
+ name = _f(tc, "name") or ""
+ args = _f(tc, "args") or {}
+ if tc_id and name:
+ pending_calls[tc_id] = {"tool": name, "args": args if isinstance(args, dict) else {}}
+
+ content = getattr(chunk, "content", "")
+ tc_id = getattr(chunk, "tool_call_id", None)
+
+ if content and isinstance(content, str) and not tc_id:
+ response_text += content
+
+ if tc_id:
+ for entry in tc_accum.values():
+ if eid := entry["id"]:
+ try:
+ eargs: Any = json.loads(entry["args_str"]) if entry["args_str"] else {}
+ except json.JSONDecodeError:
+ eargs = {}
+ pending_calls[eid] = {"tool": entry["name"], "args": eargs}
+ tc_accum.clear()
+
+ call_info = pending_calls.pop(
+ tc_id,
+ {"tool": getattr(chunk, "name", None) or "unknown", "args": {}},
+ )
+ try:
+ result = json.loads(content)
+ except (json.JSONDecodeError, TypeError):
+ result = {"raw": str(content)[:500]}
+
+ tool_calls_log.append({"tool": call_info["tool"], "args": call_info["args"], "result": result})
+ if call_info["tool"] == "submit_investigation":
+ investigation_result = call_info["args"]
+
except Exception as e:
logger.error("Agent investigation failed: {}", e)
investigation_result = {
@@ -150,30 +190,22 @@ async def _run_investigation(prompt: str, session_id: str) -> dict[str, Any] | N
"root_cause_summary": f"Investigation failed: {e}",
"confidence": "LOW",
"mitigation_steps": [
- "The agent hit its tool call limit or encountered an error.",
"Re-investigate manually via the chat — use a higher MAX_TOOL_CALLS if needed.",
],
}
- # Persist the session regardless of success/failure
- input_tokens, output_tokens = 0, 0
- for msg in messages:
- um = getattr(msg, "usage_metadata", None) or {}
- input_tokens += um.get("input_tokens", 0)
- output_tokens += um.get("output_tokens", 0)
-
- # Build a readable assistant summary from the investigation result
- if investigation_result and investigation_result.get("root_cause_summary"):
+ # Build readable assistant summary
+ if investigation_result and investigation_result.get("root_cause_summary") and "_status" not in investigation_result:
steps = investigation_result.get("mitigation_steps", [])
- steps_text = (
- "\n".join(f"- {s}" for s in steps) if steps else "_No steps provided._"
- )
+ steps_text = "\n".join(f"- {s}" for s in steps) if steps else "_No steps provided._"
confidence = investigation_result.get("confidence", "MEDIUM")
assistant_text = (
f"**Root Cause ({confidence} confidence):** "
f"{investigation_result['root_cause_summary']}\n\n"
f"**Mitigation Steps:**\n{steps_text}"
)
+ elif response_text.strip():
+ assistant_text = response_text.strip()
else:
assistant_text = "Investigation completed. See tool calls for details."
@@ -181,11 +213,11 @@ async def _run_investigation(prompt: str, session_id: str) -> dict[str, Any] | N
session_id=session_id,
user_message=prompt,
assistant_text=assistant_text,
- tool_calls_log=[],
+ tool_calls_log=tool_calls_log,
usage={
"model": settings.llm_model,
- "input_tokens": input_tokens,
- "output_tokens": output_tokens,
+ "input_tokens": _f(usage_meta, "input_tokens", 0) or 0,
+ "output_tokens": _f(usage_meta, "output_tokens", 0) or 0,
"latency_ms": 0,
},
source="event",
diff --git a/src/agent/poller.py b/src/agent/poller.py
index eb89984..9ca0362 100644
--- a/src/agent/poller.py
+++ b/src/agent/poller.py
@@ -33,54 +33,150 @@ def _mark_investigated(key: str) -> None:
_last_investigated[key] = datetime.now(UTC)
-async def _run_investigation(prompt: str, trigger_key: str) -> dict[str, Any] | None:
- """Invoke the agent synchronously in a thread and return the submit_investigation args."""
- import uuid
+async def _run_investigation(
+ prompt: str, session_id: str
+) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
+ """Run a full agent investigation using the same streaming path as the chat endpoint."""
+ import json
from agent.core import get_agent
+ from agent.turns import save_turn
+
+ def _f(obj: Any, key: str, default: Any = None) -> Any:
+ return obj.get(key, default) if isinstance(obj, dict) else getattr(obj, key, default)
- thread_id = str(uuid.uuid4())
config = {
- "configurable": {"thread_id": thread_id},
+ "configurable": {"thread_id": session_id},
"recursion_limit": settings.max_tool_calls * 3 + 15,
}
+ tc_accum: dict[int, dict[str, Any]] = {}
+ pending_calls: dict[str, dict[str, Any]] = {}
+ tool_calls_log: list[dict[str, Any]] = []
+ investigation_result: dict[str, Any] | None = None
+ response_text = ""
+ usage_meta: Any = None
+
try:
- agent = get_agent()
- result = await agent.ainvoke(
+ async for chunk, _meta in get_agent().astream(
{"messages": [{"role": "user", "content": prompt}]},
config=config,
- )
- # Extract submit_investigation args from tool calls in the message history
- for msg in reversed(result.get("messages", [])):
- for tc in getattr(msg, "tool_calls", []) or []:
- name = tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", None)
- args = tc.get("args") if isinstance(tc, dict) else getattr(tc, "args", {})
- if name == "submit_investigation":
- return args
+ stream_mode="messages",
+ ):
+ um = getattr(chunk, "usage_metadata", None)
+ if um:
+ usage_meta = um
+
+ for tcc in getattr(chunk, "tool_call_chunks", []) or []:
+ idx = _f(tcc, "index", 0)
+ if idx not in tc_accum:
+ tc_accum[idx] = {"id": "", "name": "", "args_str": ""}
+ if tc_id := _f(tcc, "id"):
+ tc_accum[idx]["id"] = tc_id
+ if name := (_f(tcc, "name") or ""):
+ tc_accum[idx]["name"] += name
+ if args := (_f(tcc, "args") or ""):
+ tc_accum[idx]["args_str"] += args
+
+ for tc in getattr(chunk, "tool_calls", []) or []:
+ tc_id = _f(tc, "id") or ""
+ name = _f(tc, "name") or ""
+ args = _f(tc, "args") or {}
+ if tc_id and name:
+ pending_calls[tc_id] = {
+ "tool": name, "args": args if isinstance(args, dict) else {}
+ }
+
+ content = getattr(chunk, "content", "")
+ tc_id = getattr(chunk, "tool_call_id", None)
+
+ if content and isinstance(content, str) and not tc_id:
+ response_text += content
+
+ if tc_id:
+ for entry in tc_accum.values():
+ if eid := entry["id"]:
+ try:
+ eargs: Any = json.loads(entry["args_str"]) if entry["args_str"] else {}
+ except json.JSONDecodeError:
+ eargs = {}
+ pending_calls[eid] = {"tool": entry["name"], "args": eargs}
+ tc_accum.clear()
+
+ call_info = pending_calls.pop(
+ tc_id,
+ {"tool": getattr(chunk, "name", None) or "unknown", "args": {}},
+ )
+ try:
+ result = json.loads(content)
+ except (json.JSONDecodeError, TypeError):
+ result = {"raw": str(content)[:500]}
+
+ tool_calls_log.append({
+ "tool": call_info["tool"], "args": call_info["args"], "result": result
+ })
+ if call_info["tool"] == "submit_investigation":
+ investigation_result = call_info["args"]
+
except Exception as e:
- logger.error("Poller investigation failed for {}: {}", trigger_key, e)
- return None
+ logger.error("Poller investigation failed: {}", e)
+ investigation_result = {
+ "_status": "failed",
+ "root_cause_summary": f"Investigation failed: {e}",
+ "confidence": "LOW",
+ "mitigation_steps": [
+ "Re-investigate manually via the chat — use a higher MAX_TOOL_CALLS if needed.",
+ ],
+ }
+
+ if (investigation_result and investigation_result.get("root_cause_summary")
+ and "_status" not in investigation_result):
+ steps = investigation_result.get("mitigation_steps", [])
+ steps_text = "\n".join(f"- {s}" for s in steps) if steps else "_No steps provided._"
+ confidence = investigation_result.get("confidence", "MEDIUM")
+ assistant_text = (
+ f"**Root Cause ({confidence} confidence):** "
+ f"{investigation_result['root_cause_summary']}\n\n"
+ f"**Mitigation Steps:**\n{steps_text}"
+ )
+ elif response_text.strip():
+ assistant_text = response_text.strip()
+ else:
+ assistant_text = "Investigation completed. See tool calls for details."
+
+ await save_turn(
+ session_id=session_id,
+ user_message=prompt,
+ assistant_text=assistant_text,
+ tool_calls_log=tool_calls_log,
+ usage={
+ "model": settings.llm_model,
+ "input_tokens": _f(usage_meta, "input_tokens", 0) or 0,
+ "output_tokens": _f(usage_meta, "output_tokens", 0) or 0,
+ "latency_ms": 0,
+ },
+ source="event",
+ )
+
+ return investigation_result, tool_calls_log
async def _persist_and_notify(
- prompt: str, result: dict[str, Any], session_id: str, dedup_key: str
+ result: dict[str, Any], tool_calls_log: list[dict[str, Any]], session_id: str, dedup_key: str
) -> None:
from agent.monitor_store import add_alert, update_service
- from agent.turns import notify_slack, save_turn
+ from agent.turns import notify_slack
- tool_calls_log = [{"tool": "submit_investigation", "args": result, "result": {}}]
- usage = {"model": settings.llm_model, "latency_ms": 0}
- await save_turn(session_id, prompt, "", tool_calls_log, usage, source="event")
await notify_slack(session_id, tool_calls_log)
+ status = result.pop("_status", "completed")
services_affected = result.get("services_affected", [])
service = services_affected[0] if services_affected else "unknown"
root_cause = result.get("root_cause_summary", "")
mitigation = result.get("mitigation_steps", [])
confidence = result.get("confidence", "MEDIUM")
resolution = "\n".join(mitigation) if isinstance(mitigation, list) else str(mitigation)
- update_service(service, "error", root_cause)
+ update_service(service, status, root_cause)
await add_alert(
service=service,
error=root_cause,
@@ -88,6 +184,7 @@ async def _persist_and_notify(
confidence=confidence,
sns_sent=False,
dedup_key=dedup_key,
+ status=status,
session_id=session_id,
)
@@ -123,10 +220,10 @@ async def _check_alarms() -> None:
"Please investigate the root cause and provide mitigation steps."
)
session_id = str(uuid.uuid4())
- result = await _run_investigation(prompt, key)
+ result, tool_calls_log = await _run_investigation(prompt, session_id)
if result:
logger.info("Poller investigation complete for alarm: {}", name)
- await _persist_and_notify(prompt, result, session_id, key)
+ await _persist_and_notify(result, tool_calls_log, session_id, key)
async def _check_lambda_errors() -> None:
@@ -175,10 +272,10 @@ async def _check_lambda_errors() -> None:
"Please investigate the root cause and suggest a fix."
)
session_id = str(uuid.uuid4())
- result = await _run_investigation(prompt, key)
+ result, tool_calls_log = await _run_investigation(prompt, session_id)
if result:
logger.info("Poller investigation complete for Lambda: {}", name)
- await _persist_and_notify(prompt, result, session_id, key)
+ await _persist_and_notify(result, tool_calls_log, session_id, key)
async def polling_loop() -> None:
From eb359b3c56c98511d9c7f8fc7c660d97ee4db3c1 Mon Sep 17 00:00:00 2001
From: AhmadHammad21
Date: Mon, 18 May 2026 10:10:30 +0300
Subject: [PATCH 16/16] fix: update poller tests for new _run_investigation and
_persist_and_notify signatures
---
tests/integration/test_poller.py | 60 +++++++++++++++++---------------
1 file changed, 32 insertions(+), 28 deletions(-)
diff --git a/tests/integration/test_poller.py b/tests/integration/test_poller.py
index 000400d..7947f6d 100644
--- a/tests/integration/test_poller.py
+++ b/tests/integration/test_poller.py
@@ -31,22 +31,24 @@ def fake_get_alarms(_state):
]
}
- async def fake_run(prompt, trigger_key):
+ async def fake_run(prompt, session_id):
calls["run"] += 1
assert "HighErrorRate-payment-processor" in prompt
- assert trigger_key == "alarm:HighErrorRate-payment-processor"
- return {
- "root_cause_category": "RESOURCE_LIMIT",
- "root_cause_summary": "concurrency exhausted",
- "confidence": "HIGH",
- "evidence": ["throttles spiked"],
- "mitigation_steps": ["increase reserved concurrency"],
- "validation_steps": ["watch metrics"],
- "services_affected": ["Lambda"],
- "recommended_follow_up": "add alarm",
- }
-
- async def fake_persist(_prompt, _result, _session_id):
+ return (
+ {
+ "root_cause_category": "RESOURCE_LIMIT",
+ "root_cause_summary": "concurrency exhausted",
+ "confidence": "HIGH",
+ "evidence": ["throttles spiked"],
+ "mitigation_steps": ["increase reserved concurrency"],
+ "validation_steps": ["watch metrics"],
+ "services_affected": ["Lambda"],
+ "recommended_follow_up": "add alarm",
+ },
+ [],
+ )
+
+ async def fake_persist(_result, _tool_calls_log, _session_id, _dedup_key):
calls["persist"] += 1
monkeypatch.setattr("tools.cloudwatch.get_alarms", fake_get_alarms)
@@ -70,22 +72,24 @@ def fake_list_lambda_functions():
def fake_get_lambda_error_rate(_name, _hours):
return {"error_rate_pct": 12.5}
- async def fake_run(prompt, trigger_key):
+ async def fake_run(prompt, session_id):
calls["run"] += 1
assert "payment-processor" in prompt
- assert trigger_key == "lambda_errors:payment-processor"
- return {
- "root_cause_category": "RESOURCE_LIMIT",
- "root_cause_summary": "lambda saturation",
- "confidence": "HIGH",
- "evidence": ["high throttles"],
- "mitigation_steps": ["raise concurrency"],
- "validation_steps": ["observe errors"],
- "services_affected": ["Lambda"],
- "recommended_follow_up": "right-size limits",
- }
-
- async def fake_persist(_prompt, _result, _session_id):
+ return (
+ {
+ "root_cause_category": "RESOURCE_LIMIT",
+ "root_cause_summary": "lambda saturation",
+ "confidence": "HIGH",
+ "evidence": ["high throttles"],
+ "mitigation_steps": ["raise concurrency"],
+ "validation_steps": ["observe errors"],
+ "services_affected": ["Lambda"],
+ "recommended_follow_up": "right-size limits",
+ },
+ [],
+ )
+
+ async def fake_persist(_result, _tool_calls_log, _session_id, _dedup_key):
calls["persist"] += 1
monkeypatch.setattr("tools.lambda_.list_lambda_functions", fake_list_lambda_functions)