init commit
This commit is contained in:
293
agents/handover.py
Normal file
293
agents/handover.py
Normal file
@@ -0,0 +1,293 @@
|
||||
"""Shift Handover agent — compiles all active ops state into a prioritized brief.
|
||||
|
||||
Connects to: shared + ops MCP servers.
|
||||
Gathers data across all hubs, triages by severity and time sensitivity,
|
||||
generates a structured handover brief.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from agents.shared.mcp_client import MCPMultiClient
|
||||
|
||||
ALL_HUBS = ["ORD", "EWR", "IAH", "SFO", "DEN"]
|
||||
|
||||
# WMO weather severity mapping
|
||||
WMO_SEVERITY = {
|
||||
95: 9, 96: 9, 99: 10, # thunderstorms
|
||||
65: 6, 75: 7, 82: 7, 86: 8, # heavy precip
|
||||
45: 5, 48: 5, # fog
|
||||
}
|
||||
|
||||
|
||||
async def run_handover(
|
||||
hubs: list[str] | None = None,
|
||||
mcp: MCPMultiClient | None = None,
|
||||
on_event: Any = None,
|
||||
) -> dict:
|
||||
"""Run the Shift Handover agent.
|
||||
|
||||
Args:
|
||||
hubs: Hubs to cover (default: all 5).
|
||||
mcp: Connected MCPMultiClient (shared + ops).
|
||||
on_event: Optional async callback for real-time events.
|
||||
|
||||
Returns:
|
||||
Handover brief result dict.
|
||||
"""
|
||||
target_hubs = hubs or ALL_HUBS
|
||||
run_start = time.time()
|
||||
errors = []
|
||||
tool_calls = []
|
||||
|
||||
async def emit(event_type: str, **data):
|
||||
event = {"type": event_type, "timestamp": datetime.now(timezone.utc).isoformat(), **data}
|
||||
tool_calls.append(event)
|
||||
if on_event:
|
||||
await on_event(event)
|
||||
|
||||
# ── Node 1: Gather All (parallel tool calls across hubs) ──
|
||||
|
||||
await emit("node_enter", node="gather_all")
|
||||
|
||||
async def _call(server, tool, args, is_live=False):
|
||||
t = time.time()
|
||||
try:
|
||||
result = await mcp.call_tool(server, tool, args)
|
||||
lat = int((time.time() - t) * 1000)
|
||||
await emit("tool_call_end", tool=tool, latency_ms=lat, is_live=is_live)
|
||||
return result
|
||||
except Exception as e:
|
||||
lat = int((time.time() - t) * 1000)
|
||||
await emit("tool_call_error", tool=tool, error=str(e), latency_ms=lat)
|
||||
errors.append(f"{tool}: {e}")
|
||||
return None
|
||||
|
||||
# Per-hub calls
|
||||
hub_tasks = {}
|
||||
for hub in target_hubs:
|
||||
hub_tasks[hub] = {
|
||||
"irrops": asyncio.create_task(
|
||||
_call("shared", "get_irregular_ops", {"hub": hub})
|
||||
),
|
||||
"airport": asyncio.create_task(
|
||||
_call("shared", "get_airport_status", {"airport_code": hub}, is_live=True)
|
||||
),
|
||||
"rebookings": asyncio.create_task(
|
||||
_call("ops", "get_pending_rebookings", {"hub": hub})
|
||||
),
|
||||
}
|
||||
|
||||
# Global calls
|
||||
weather_task = asyncio.create_task(
|
||||
_call("shared", "get_hub_forecasts", {}, is_live=True)
|
||||
)
|
||||
|
||||
# Gather all hub results
|
||||
hub_data = {}
|
||||
for hub in target_hubs:
|
||||
hub_data[hub] = {
|
||||
"irrops": await hub_tasks[hub]["irrops"],
|
||||
"airport": await hub_tasks[hub]["airport"],
|
||||
"rebookings": await hub_tasks[hub]["rebookings"],
|
||||
}
|
||||
|
||||
weather_forecast = await weather_task
|
||||
|
||||
# Get crew duty status for all crew in disrupted flights
|
||||
all_crew_ids = []
|
||||
for hub in target_hubs:
|
||||
irrops = hub_data[hub].get("irrops")
|
||||
if isinstance(irrops, list):
|
||||
for irrop in irrops:
|
||||
if isinstance(irrop, dict):
|
||||
# Get full flight data to get crew IDs
|
||||
flight_data = await _call(
|
||||
"shared", "get_flight_details",
|
||||
{"flight_id": irrop.get("flight_id", "")}
|
||||
)
|
||||
if isinstance(flight_data, dict):
|
||||
all_crew_ids.extend(flight_data.get("crew_ids", []))
|
||||
|
||||
crew_status = None
|
||||
if all_crew_ids:
|
||||
crew_status = await _call("ops", "get_crew_duty_status", {"crew_ids": all_crew_ids})
|
||||
|
||||
# Get maintenance flags for disrupted aircraft
|
||||
maintenance_data = {}
|
||||
for hub in target_hubs:
|
||||
irrops = hub_data[hub].get("irrops")
|
||||
if isinstance(irrops, list):
|
||||
for irrop in irrops:
|
||||
if isinstance(irrop, dict):
|
||||
flight_data = await _call(
|
||||
"shared", "get_flight_details",
|
||||
{"flight_id": irrop.get("flight_id", "")}
|
||||
)
|
||||
if isinstance(flight_data, dict):
|
||||
tail = flight_data.get("aircraft_tail", "")
|
||||
if tail and tail not in maintenance_data:
|
||||
mel = await _call("shared", "get_maintenance_flags", {"aircraft_tail": tail})
|
||||
if mel:
|
||||
maintenance_data[tail] = mel
|
||||
|
||||
await emit("node_exit", node="gather_all")
|
||||
|
||||
# ── Node 2: Triage ──
|
||||
|
||||
await emit("node_enter", node="triage")
|
||||
|
||||
immediate = []
|
||||
monitor = []
|
||||
fyi = []
|
||||
|
||||
# Crew at risk
|
||||
if isinstance(crew_status, list):
|
||||
for crew in crew_status:
|
||||
if isinstance(crew, dict) and crew.get("at_risk"):
|
||||
hours_left = crew.get("hours_until_limit", 0)
|
||||
immediate.append(
|
||||
f"{crew.get('next_scheduled_flight', '?')} — "
|
||||
f"{crew.get('name', '?')} ({crew.get('role', '?')}) "
|
||||
f"duty limit in {hours_left:.1f}h. "
|
||||
f"Swap required if departure slips."
|
||||
)
|
||||
|
||||
# IROPs
|
||||
for hub in target_hubs:
|
||||
irrops = hub_data[hub].get("irrops")
|
||||
if isinstance(irrops, list) and irrops:
|
||||
total_pax = sum(
|
||||
i.get("affected_pax_count", 0) for i in irrops if isinstance(i, dict)
|
||||
)
|
||||
cancelled = [i for i in irrops if isinstance(i, dict) and i.get("irrop_type") == "CANCELLED"]
|
||||
|
||||
if cancelled:
|
||||
for c in cancelled:
|
||||
immediate.append(
|
||||
f"{c.get('flight_id', '?')} ({hub}→{c.get('destination', '?')}) — "
|
||||
f"CANCELLED ({c.get('delay_cause', '?')}). "
|
||||
f"{c.get('affected_pax_count', 0)} pax need rebooking."
|
||||
)
|
||||
elif total_pax > 100:
|
||||
monitor.append(
|
||||
f"{hub}: {len(irrops)} flights disrupted, {total_pax} pax affected."
|
||||
)
|
||||
|
||||
# Rebookings
|
||||
rebookings = hub_data[hub].get("rebookings")
|
||||
if isinstance(rebookings, list) and rebookings:
|
||||
high_priority = [r for r in rebookings if isinstance(r, dict) and r.get("urgency") == "HIGH"]
|
||||
if high_priority:
|
||||
immediate.append(
|
||||
f"{hub}: {len(rebookings)} pax awaiting rebooking "
|
||||
f"({len(high_priority)} HIGH priority)."
|
||||
)
|
||||
|
||||
# Weather risks
|
||||
if isinstance(weather_forecast, dict):
|
||||
for hub_code, forecast in weather_forecast.get("hubs", {}).items():
|
||||
if isinstance(forecast, dict) and forecast.get("risk_flag"):
|
||||
monitor.append(
|
||||
f"Weather risk at {hub_code}: convective activity or low visibility "
|
||||
f"forecast in next 4 hours."
|
||||
)
|
||||
|
||||
# MEL items
|
||||
for tail, items in maintenance_data.items():
|
||||
if isinstance(items, list):
|
||||
for item in items:
|
||||
if isinstance(item, dict) and item.get("restriction"):
|
||||
monitor.append(
|
||||
f"MEL {item.get('mel_id', '?')} on {tail}: "
|
||||
f"{item.get('system', '?')} — {item.get('restriction', '')}"
|
||||
)
|
||||
|
||||
# Airport status (live FAA)
|
||||
for hub in target_hubs:
|
||||
airport = hub_data[hub].get("airport")
|
||||
if isinstance(airport, dict) and airport.get("has_delays"):
|
||||
for delay in airport.get("delays", []):
|
||||
if isinstance(delay, dict):
|
||||
if delay.get("type") == "ground_stop":
|
||||
immediate.append(
|
||||
f"{hub} GROUND STOP: {delay.get('reason', 'unknown')}. "
|
||||
f"End time: {delay.get('end_time', 'TBD')}."
|
||||
)
|
||||
elif delay.get("type") == "ground_delay_program":
|
||||
monitor.append(
|
||||
f"{hub} GDP: {delay.get('reason', 'unknown')}. "
|
||||
f"Avg delay: {delay.get('average_delay', 'unknown')}."
|
||||
)
|
||||
|
||||
# Nominal hubs go to FYI
|
||||
for hub in target_hubs:
|
||||
irrops = hub_data[hub].get("irrops")
|
||||
airport = hub_data[hub].get("airport")
|
||||
has_issues = (isinstance(irrops, list) and len(irrops) > 0) or (
|
||||
isinstance(airport, dict) and airport.get("has_delays")
|
||||
)
|
||||
if not has_issues:
|
||||
fyi.append(f"{hub} fully nominal. No open items.")
|
||||
|
||||
await emit("node_exit", node="triage")
|
||||
|
||||
# ── Node 3: Synthesize ──
|
||||
|
||||
await emit("node_enter", node="synthesize")
|
||||
|
||||
shift_time = datetime.now(timezone.utc).strftime("%H:%M UTC")
|
||||
hub_label = ", ".join(target_hubs) if len(target_hubs) < 5 else "ALL HUBS"
|
||||
|
||||
t0 = time.time()
|
||||
brief_text = await mcp.call_tool("ops", "generate_narrative", {
|
||||
"context": {
|
||||
"hub": hub_label,
|
||||
"shift_time": shift_time,
|
||||
"immediate": immediate,
|
||||
"monitor": monitor,
|
||||
"fyi": fyi,
|
||||
}
|
||||
})
|
||||
latency = int((time.time() - t0) * 1000)
|
||||
await emit("tool_call_end", tool="generate_narrative", latency_ms=latency, is_live=False)
|
||||
|
||||
await emit("node_exit", node="synthesize")
|
||||
|
||||
# ── Node 4: Format Output ──
|
||||
|
||||
await emit("node_enter", node="format_output")
|
||||
|
||||
result = {
|
||||
"type": "HANDOVER_BRIEF",
|
||||
"hubs": target_hubs,
|
||||
"brief_text": brief_text if isinstance(brief_text, str) else str(brief_text),
|
||||
"summary": {
|
||||
"immediate_count": len(immediate),
|
||||
"monitor_count": len(monitor),
|
||||
"fyi_count": len(fyi),
|
||||
},
|
||||
"items": {
|
||||
"immediate": immediate,
|
||||
"monitor": monitor,
|
||||
"fyi": fyi,
|
||||
},
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"errors": errors,
|
||||
"duration_ms": int((time.time() - run_start) * 1000),
|
||||
"tool_calls": tool_calls,
|
||||
}
|
||||
|
||||
await emit("node_exit", node="format_output")
|
||||
await emit("agent_end", output_summary=f"Handover brief for {hub_label}")
|
||||
|
||||
# Store the brief for the ops://handover/latest resource
|
||||
try:
|
||||
from mcp_servers.ops.server import store_handover_brief
|
||||
store_handover_brief(result)
|
||||
except Exception:
|
||||
pass # not critical
|
||||
|
||||
return result
|
||||
Reference in New Issue
Block a user