"""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, trace: Any = None, ) -> 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, timeout=15.0): t = time.time() span = trace.span(name=tool, input=args, metadata={"server": server, "is_live": is_live}) if trace else None try: result = await asyncio.wait_for( mcp.call_tool(server, tool, args), timeout=timeout, ) lat = int((time.time() - t) * 1000) if span: span.end(output=result, metadata={"latency_ms": lat}) await emit("tool_call_end", tool=tool, latency_ms=lat, is_live=is_live) return result except asyncio.TimeoutError: lat = int((time.time() - t) * 1000) if span: span.end(output={"error": "timeout"}, level="ERROR") await emit("tool_call_error", tool=tool, error="timeout", latency_ms=lat) errors.append(f"{tool}: timeout after {timeout}s") return None except Exception as e: lat = int((time.time() - t) * 1000) if span: span.end(output={"error": str(e)}, level="ERROR") 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() raw_result = 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) # Parse structured response import json as _json llm_provider = "template" if isinstance(raw_result, dict) and "text" in raw_result: brief_text = raw_result["text"] llm_provider = raw_result.get("provider", "unknown") elif isinstance(raw_result, str): try: parsed = _json.loads(raw_result) brief_text = parsed.get("text", raw_result) llm_provider = parsed.get("provider", "unknown") except (_json.JSONDecodeError, TypeError): brief_text = raw_result else: brief_text = str(raw_result) 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, "llm_provider": llm_provider, "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