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
43 changes: 36 additions & 7 deletions agents/logistics.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,38 @@ class LogisticsAgent(BaseAgent):

def run(self, state: SystemState, event: Event | None = None) -> AgentProposal:
proposal = AgentProposal(agent=self.name)
blocked_route = event.payload.get("route_id") if event and event.type == EventType.ROUTE_BLOCKAGE else None
available_routes = [route for route in state.routes.values() if route.status != "blocked"]
ranked = sorted(available_routes, key=lambda route: (route.risk_score, route.transit_days, route.cost))
blocked_route = (
event.payload.get("route_id")
if event and event.type in {EventType.ROUTE_BLOCKAGE, EventType.COMPOUND}
else None
)
available_routes = [
route for route in state.routes.values() if route.status != "blocked"
]

for sku, item in state.inventory.items():
current_route = state.routes.get(item.preferred_route_id)
if not current_route:
continue

valid_routes = [
r
for r in available_routes
if r.destination == current_route.destination
and r.origin == current_route.origin
]
ranked = sorted(
valid_routes,
key=lambda route: (route.risk_score, route.transit_days, route.cost),
)

best = ranked[0] if ranked else current_route
if best.route_id == blocked_route and len(ranked) > 1:
best = ranked[1]
if best.route_id != current_route.route_id and (current_route.route_id == blocked_route or best.risk_score < current_route.risk_score):
if best.route_id != current_route.route_id and (
current_route.route_id == blocked_route
or best.risk_score < current_route.risk_score
):
proposal.proposals.append(
Action(
action_id=f"act_reroute_{sku}_{best.route_id}",
Expand All @@ -37,10 +57,19 @@ def run(self, state: SystemState, event: Event | None = None) -> AgentProposal:
estimated_risk_delta=-0.10,
estimated_recovery_hours=float(best.transit_days * 8),
reason=f"reroute {sku} from {current_route.route_id} to {best.route_id}",
priority=0.85 if current_route.route_id == blocked_route else 0.45,
priority=0.85
if current_route.route_id == blocked_route
else 0.45,
)
)
proposal.observations.append(f"{sku} reroute candidate: {best.route_id}")
proposal.observations.append(
f"{sku} reroute candidate: {best.route_id}"
)

ranked_all = sorted(
available_routes,
key=lambda route: (route.risk_score, route.transit_days, route.cost),
)
if event is not None or proposal.proposals:
route_snapshot = [
{
Expand All @@ -52,7 +81,7 @@ def run(self, state: SystemState, event: Event | None = None) -> AgentProposal:
"risk_score": route.risk_score,
"status": route.status,
}
for route in ranked
for route in ranked_all
]
self.enrich_with_llm(
state=state,
Expand Down
2 changes: 1 addition & 1 deletion agents/planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -959,7 +959,7 @@ def run(self, state: SystemState, event: Event | None = None) -> AgentProposal:
evaluation.unresolved_critical = int(coverage["unresolved_critical"])

if not any(item.feasible for item in evaluations):
safe_action = next(action for action in feasible_candidates if action.action_type == ActionType.NO_OP)
safe_action = next((action for action in feasible_candidates if action.action_type == ActionType.NO_OP), Action(action_id="act_no_op_fallback", action_type=ActionType.NO_OP, target_id="system", reason="all candidate actions violated hard constraints", priority=0.0))
evaluations.append(
_safe_hold_evaluation(
state=state, event=event, before_kpis=before_kpis,
Expand Down
2 changes: 1 addition & 1 deletion agents/supplier.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def run(self, state: SystemState, event: Event | None = None) -> AgentProposal:

delayed_supplier = (
event.payload.get("supplier_id")
if event and event.type == EventType.SUPPLIER_DELAY
if event and event.type in {EventType.SUPPLIER_DELAY, EventType.COMPOUND}
else None
)
for sku, item in state.inventory.items():
Expand Down
82 changes: 42 additions & 40 deletions app_api/routers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@
import asyncio
from collections.abc import Callable

from fastapi import APIRouter, BackgroundTasks, HTTPException, WebSocket, WebSocketDisconnect
from fastapi import (
APIRouter,
BackgroundTasks,
HTTPException,
WebSocket,
WebSocketDisconnect,
)

from app_api.schemas import (
ActionExecutionRecordView,
Expand Down Expand Up @@ -182,22 +188,22 @@ def mock_routes() -> dict:
],
"routes": [
{
"route_id": "R1",
"summary": "QL5 and AH14",
"route_id": "R_BN_HN_MAIN",
"summary": "QL1A and QL5",
"legs": [
{
"distance": {"text": "104 km", "value": 104320},
"duration": {"text": "3 hours 45 mins", "value": 13500},
"distance": {"text": "35 km", "value": 35000},
"duration": {"text": "50 mins", "value": 3000},
"duration_in_traffic": {
"text": "4 hours 10 mins",
"value": 15000,
"text": "1 hours 10 mins",
"value": 4200,
},
"steps": [],
"traffic_speed_entry": [],
}
],
"warnings": [
"Severe flooding detected on QL5 near Hai Duong.",
"Severe flooding detected on QL1A near Bac Ninh.",
"Road closure ahead due to submerged lane.",
"Expect heavy delays and consider alternative routing.",
],
Expand All @@ -208,14 +214,8 @@ def mock_routes() -> dict:
"incident_id": "INC_001",
"type": "ROAD_CLOSED",
"description": "Lane submerged due to heavy flood",
"location": {"lat": 20.938611, "lng": 106.314444},
},
{
"incident_id": "INC_002",
"type": "ROAD_CLOSED",
"description": "Traffic accident associated with weather",
"location": {"lat": 20.942150, "lng": 106.321890},
},
"location": {"lat": 21.1861, "lng": 106.0763},
}
],
}
],
Expand All @@ -225,15 +225,15 @@ def mock_routes() -> dict:
"geocoded_waypoints": [],
"routes": [
{
"route_id": "R1",
"summary": "QL5 and AH14",
"route_id": "R_BN_HN_MAIN",
"summary": "QL1A and QL5",
"legs": [
{
"distance": {"text": "104 km", "value": 104320},
"duration": {"text": "1 hour 50 mins", "value": 6600},
"distance": {"text": "35 km", "value": 35000},
"duration": {"text": "50 mins", "value": 3000},
"duration_in_traffic": {
"text": "1 hour 55 mins",
"value": 6900,
"text": "55 mins",
"value": 3300,
},
}
],
Expand All @@ -254,18 +254,18 @@ def mock_suppliers() -> dict:
"timestamp": "2026-04-13T08:00:00Z",
"vendors": [
{
"vendor_id": "SUP_A",
"company_name": "Alpha Manufacturing Ltd.",
"region": "Shenzhen, CN",
"vendor_id": "SUP_BN",
"company_name": "Bac Ninh Precision Mfg",
"region": "Bac Ninh, VN",
"compliance_score": 0.92,
"facilities": [
{
"facility_id": "FAC_A1",
"facility_id": "FAC_BN1",
"status": "Operational",
"capacity_utilization": 0.85,
},
{
"facility_id": "FAC_A2",
"facility_id": "FAC_BN2",
"status": "Degraded",
"capacity_utilization": 0.30,
"active_alerts": [
Expand All @@ -277,7 +277,7 @@ def mock_suppliers() -> dict:
{
"alert_code": "MATERIAL_DELAY",
"severity": "MEDIUM",
"message": "Raw material shipments delayed at local port.",
"message": "Raw material shipments delayed.",
},
],
},
Expand All @@ -296,13 +296,13 @@ def mock_suppliers() -> dict:
"timestamp": "2026-04-13T08:00:00Z",
"vendors": [
{
"vendor_id": "SUP_A",
"company_name": "Alpha Manufacturing Ltd.",
"region": "Shenzhen, CN",
"vendor_id": "SUP_BN",
"company_name": "Bac Ninh Precision Mfg",
"region": "Bac Ninh, VN",
"compliance_score": 0.94,
"facilities": [
{
"facility_id": "FAC_A1",
"facility_id": "FAC_BN1",
"status": "Operational",
"capacity_utilization": 0.78,
"active_alerts": [],
Expand Down Expand Up @@ -610,13 +610,16 @@ def run_scenario(request: ScenarioRequest) -> dict:
return payload

@router.post("/scenarios/run/stream")
def stream_run_scenario(request: ScenarioRequest, background_tasks: BackgroundTasks) -> dict:
def stream_run_scenario(
request: ScenarioRequest, background_tasks: BackgroundTasks
) -> dict:
"""
Trigger a scenario run with streaming via WebSocket.
"""
global MOCK_ENVIRONMENT
MOCK_ENVIRONMENT = request.scenario_name
from core.runtime_tracking import new_run_id

run_id = new_run_id()
background_tasks.add_task(
_stream_scenario_plan,
Expand Down Expand Up @@ -668,6 +671,7 @@ async def daily_plan_stream(background_tasks: BackgroundTasks) -> dict:
Backward compat: Legacy endpoint POST /plan/daily remains independent.
"""
from core.runtime_tracking import new_run_id

run_id = new_run_id()
background_tasks.add_task(_stream_daily_plan, runtime_getter, run_id)
return {
Expand All @@ -679,19 +683,19 @@ async def daily_plan_stream(background_tasks: BackgroundTasks) -> dict:
async def websocket_thinking_stream(websocket: WebSocket, run_id: str) -> None:
"""
WebSocket endpoint for real-time thinking/reasoning stream.

Subscribes to the event bus for the given run_id and sends
a combined payload of the current event and the full trace update.
"""
from streaming.event_bus import event_bus

await websocket.accept()
runtime = runtime_getter()
try:
async for event in event_bus.subscribe(run_id):
if event is None:
break

# Send a combined payload: the specific event + the overall trace snapshot
payload = {
"event": event.model_dump(mode="json"),
Expand All @@ -713,14 +717,14 @@ async def websocket_thinking_stream(websocket: WebSocket, run_id: str) -> None:
# Background task helpers (module-level — outside create_router)
# ------------------------------------------------------------------


async def _stream_daily_plan(runtime_getter: Callable, run_id: str) -> None:
"""
Runs the graph in a threadpool executor to avoid blocking the event loop.
Ensures event_bus.close() is always called, even on exception.
"""
from streaming.event_bus import event_bus
from streaming.schemas import ThinkingEvent
from datetime import datetime

loop = asyncio.get_event_loop()
runtime = runtime_getter()
Expand Down Expand Up @@ -752,11 +756,9 @@ def _sync_run_with_run_id(runtime, run_id: str) -> None:
Saves state and artifacts after completion (mirrors run_daily logic).
"""
from orchestrator.service import (
PendingApprovalError,
_save_state,
ensure_no_pending_plan,
)
from fastapi import HTTPException as _HTTPException

ensure_no_pending_plan(runtime.state)
# graph.invoke() forwards run_id into OrchestrationState
Expand Down Expand Up @@ -797,6 +799,6 @@ async def _stream_scenario_plan(
def _sync_scenario_with_run_id(
runtime, run_id: str, scenario_name: str, seed: int
) -> None:
# run_scenario internally calls graph.invoke via ScenarioRunner
# run_scenario internally calls graph.invoke via ScenarioRunner
# and handles Saving artifacts
runtime.run_scenario(scenario_name, seed=seed, run_id=run_id)
20 changes: 10 additions & 10 deletions data/historical_cases.csv
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
case_id,event_type,event_severity,actions_taken,outcome_service_level,outcome_total_cost,outcome_disruption_risk,outcome_recovery_speed,reflection_notes
CASE_2024_001,supplier_delay,0.8,"REORDER SKU_1 from backup SUP_B|REROUTE via R4",0.95,4800.0,0.22,0.78,Switching to backup supplier with priority reorder prevented stockout within 48h. High cost but acceptable service level maintained.
CASE_2024_002,route_blockage,0.7,"REROUTE all SKUs from R3 to R1|REORDER SKU_2 safety buffer",0.98,5200.0,0.18,0.85,Rerouting early before stockout reduced risk significantly. Building safety buffer for high-demand SKUs proved effective.
CASE_2024_003,demand_spike,0.65,"REORDER SKU_2 x50 emergency|SWITCH_SUPPLIER to high-capacity SUP_D",0.88,6100.0,0.30,0.60,Demand spike required immediate large reorder. Switching to high-capacity supplier avoided stockout but at higher unit cost.
CASE_2024_004,supplier_delay,0.5,"REORDER from primary supplier with extended lead time buffer",0.92,3900.0,0.25,0.70,Moderate delay handled by increasing order quantity from primary. No supplier switch needed — cost-effective resolution.
CASE_2024_005,compound,0.9,"SWITCH_SUPPLIER all SKUs|REROUTE via secondary routes|REORDER emergency buffer x2",0.82,8500.0,0.45,0.50,Compound disruption required full mobilization. Despite high cost service level held above 80%. Prior early warning improved response time.
CASE_2024_006,route_blockage,0.4,"REROUTE SKU_1 to backup route|monitor only",0.99,3200.0,0.12,0.90,Low-severity blockage resolved by simple reroute. No inventory action needed. Fast recovery at minimal cost.
CASE_2024_007,demand_spike,0.85,"REORDER all SKUs x2|SWITCH_SUPPLIER to SUP_B|add buffer stock",0.90,7200.0,0.35,0.65,Extreme demand spike required multi-SKU emergency reorder. Margin compressed but service level held.
CASE_2024_008,supplier_delay,0.95,"EMERGENCY SWITCH_SUPPLIER|expedite REORDER|REROUTE to fastest route",0.80,9100.0,0.40,0.55,Critical supplier failure at peak season. Required full emergency protocol. Cost was very high but stockout was avoided.
CASE_2025_MEGA_DISASTER,compound,0.98,"FULL REDIRECT|SWITCH_SUPPLIER to backup network|REBALANCE global inventory",0.85,15000.0,0.50,0.40,Matched a severe regional blackout + port strike. Using backup suppliers immediately and accepting 3x cost was the only way to maintain 85% service level.
case_id,event_type,event_severity,actions_taken,outcome_service_level,outcome_total_cost,outcome_disruption_risk,outcome_recovery_speed,reflection_notes
CASE_2024_001,supplier_delay,0.8,REORDER SKU_1 from backup SUP_HP|REROUTE via R_BN_HN_ALT,0.95,4800.0,0.22,0.78,Switching to backup supplier with priority reorder prevented stockout within 48h. High cost but acceptable service level maintained.
CASE_2024_002,route_blockage,0.7,REROUTE all SKUs from R_HP_HN_MAIN to R_BN_HN_MAIN|REORDER SKU_2 safety buffer,0.98,5200.0,0.18,0.85,Rerouting early before stockout reduced risk significantly. Building safety buffer for high-demand SKUs proved effective.
CASE_2024_003,demand_spike,0.65,REORDER SKU_2 x50 emergency|SWITCH_SUPPLIER to high-capacity SUP_BD,0.88,6100.0,0.3,0.6,Demand spike required immediate large reorder. Switching to high-capacity supplier avoided stockout but at higher unit cost.
CASE_2024_004,supplier_delay,0.5,REORDER from primary supplier with extended lead time buffer,0.92,3900.0,0.25,0.7,Moderate delay handled by increasing order quantity from primary. No supplier switch needed — cost-effective resolution.
CASE_2024_005,compound,0.9,SWITCH_SUPPLIER all SKUs|REROUTE via secondary routes|REORDER emergency buffer x2,0.82,8500.0,0.45,0.5,Compound disruption required full mobilization. Despite high cost service level held above 80%. Prior early warning improved response time.
CASE_2024_006,route_blockage,0.4,REROUTE SKU_1 to backup route|monitor only,0.99,3200.0,0.12,0.9,Low-severity blockage resolved by simple reroute. No inventory action needed. Fast recovery at minimal cost.
CASE_2024_007,demand_spike,0.85,REORDER all SKUs x2|SWITCH_SUPPLIER to SUP_HP|add buffer stock,0.9,7200.0,0.35,0.65,Extreme demand spike required multi-SKU emergency reorder. Margin compressed but service level held.
CASE_2024_008,supplier_delay,0.95,EMERGENCY SWITCH_SUPPLIER|expedite REORDER|REROUTE to fastest route,0.8,9100.0,0.4,0.55,Critical supplier failure at peak season. Required full emergency protocol. Cost was very high but stockout was avoided.
CASE_2025_MEGA_DISASTER,compound,0.98,FULL REDIRECT|SWITCH_SUPPLIER to backup network|REBALANCE global inventory,0.85,15000.0,0.5,0.4,Matched a severe regional blackout + port strike. Using backup suppliers immediately and accepting 3x cost was the only way to maintain 85% service level.
Loading
Loading