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
2 changes: 2 additions & 0 deletions agents/critic.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ def run(self, state: SystemState, event: Event | None = None) -> AgentProposal:
summary, findings = build_critic_review(
state.latest_plan,
decision_log.candidate_evaluations,
state=state,
event=event,
)
state.latest_plan.critic_summary = summary
decision_log.critic_summary = summary
Expand Down
123 changes: 69 additions & 54 deletions agents/demand.py
Original file line number Diff line number Diff line change
@@ -1,38 +1,51 @@
from __future__ import annotations

from collections import defaultdict

import pandas as pd

from agents.base import BaseAgent
from core.enums import ActionType, EventType
from core.models import Action, AgentProposal, Event, SystemState
from __future__ import annotations

from collections import defaultdict

import pandas as pd

from agents.base import BaseAgent
from core.scenario_scope import payload_demand_changes, resolve_scenario_scope
from core.enums import ActionType, EventType
from core.models import Action, AgentProposal, Event, SystemState


class DemandAgent(BaseAgent):
name = "demand"
custom_system_prompt = """You are the Demand Agent for a supply chain management system.
Your role is to analyze demand spikes, historical demand patterns, and forecast future demand.
When a disruption or event occurs (like a DEMAND_SPIKE), you evaluate how it impacts the required inventory levels.
Provide a concise, professional summary (under 4 sentences) of your demand analysis, the specific SKU affected, and your recommendations.
Write the summary entirely in English. Do NOT use markdown formatting or bullet points in the summary."""
Provide a concise, professional summary (under 4 sentences) of your demand analysis, the specific SKU affected, and your recommendations.
Write the summary entirely in English. Do NOT use markdown formatting or bullet points in the summary."""

def run(self, state: SystemState, event: Event | None = None) -> AgentProposal:
proposal = AgentProposal(agent=self.name)
grouped: dict[str, list[tuple[int, int]]] = defaultdict(list)
for demand in state.demands:
grouped[demand.sku].append((demand.day_index, demand.quantity))
@staticmethod
def _event_demand_changes(event: Event | None) -> dict[str, float]:
if event is None:
return {}
if event.type not in {EventType.DEMAND_SPIKE, EventType.COMPOUND}:
return {}
return {
str(item["sku"]): float(item.get("multiplier", 1.0))
for item in payload_demand_changes(event)
}

multiplier = 1.0
event_sku = None
if event and event.type == EventType.DEMAND_SPIKE:
multiplier = float(event.payload.get("multiplier", 1.5))
event_sku = event.payload.get("sku")
proposal.observations.append(
f"demand spike multiplier applied: {multiplier:.2f}"
)
def run(self, state: SystemState, event: Event | None = None) -> AgentProposal:
proposal = AgentProposal(agent=self.name)
grouped: dict[str, list[tuple[int, int]]] = defaultdict(list)
for demand in state.demands:
grouped[demand.sku].append((demand.day_index, demand.quantity))

event_changes = self._event_demand_changes(event)
scope = resolve_scenario_scope(state, event)
affected_skus = set(scope.demand_affected_skus or event_changes)
for sku, multiplier in event_changes.items():
proposal.observations.append(
f"{sku} demand spike multiplier applied: {multiplier:.2f}"
)

for sku, points in grouped.items():
if affected_skus and sku not in affected_skus:
continue
df = pd.DataFrame(points, columns=["day_index", "quantity"]).sort_values(
"day_index"
)
Expand All @@ -44,35 +57,36 @@ def run(self, state: SystemState, event: Event | None = None) -> AgentProposal:
forecast = int(round(avg_d)) if pd.notna(avg_d) else 0
std_d_val = float(std_d) if pd.notna(std_d) else 0.0

if sku == event_sku:
forecast = int(round(forecast * multiplier))
if sku in event_changes:
forecast = int(round(forecast * event_changes[sku]))
if sku in state.inventory:
state.inventory[sku].forecast_qty = max(0, forecast)
state.inventory[sku].std_demand = std_d_val
proposal.observations.append(
f"{sku} historical demand (30 days): Avg = {forecast}, Std Dev = {std_d_val:.2f}"
)

if (
event
and event.type == EventType.DEMAND_SPIKE
and event_sku in state.inventory
):
item = state.inventory[event_sku]
proposal.proposals.append(
Action(
action_id=f"act_demand_rebalance_{event_sku}",
action_type=ActionType.REBALANCE,
target_id=event_sku,
parameters={"quantity": max(item.forecast_qty - item.on_hand, 0)},
estimated_cost_delta=40.0,
estimated_service_delta=0.08,
estimated_risk_delta=-0.10,
estimated_recovery_hours=8.0,
reason=f"pre-position stock for {event_sku} demand spike",
priority=0.75,
if event and event.type in {EventType.DEMAND_SPIKE, EventType.COMPOUND}:
for event_sku in affected_skus:
if event_sku not in state.inventory:
continue
item = state.inventory[event_sku]
proposal.proposals.append(
Action(
action_id=f"act_demand_rebalance_{event_sku}",
action_type=ActionType.REBALANCE,
target_id=event_sku,
parameters={
"quantity": max(item.forecast_qty - item.on_hand, 0)
},
estimated_cost_delta=40.0,
estimated_service_delta=0.08,
estimated_risk_delta=-0.10,
estimated_recovery_hours=8.0,
reason=f"pre-position stock for {event_sku} demand spike",
priority=0.75,
)
)
)

affected_inventory = {
sku: {
Expand All @@ -83,17 +97,18 @@ def run(self, state: SystemState, event: Event | None = None) -> AgentProposal:
"safety_stock": item.safety_stock,
}
for sku, item in state.inventory.items()
if event_sku is None or sku == event_sku
if not affected_skus or sku in affected_skus
}
if event is not None or proposal.proposals:
self.enrich_with_llm(
state=state,
event=event,
proposal=proposal,
state_slice={
"event_sku": event_sku,
"multiplier": multiplier,
"affected_inventory": affected_inventory,
},
)
return proposal
proposal=proposal,
state_slice={
"event_skus": sorted(affected_skus),
"demand_changes": event_changes,
"scenario_scope": scope.to_dict(),
"affected_inventory": affected_inventory,
},
)
return proposal
112 changes: 63 additions & 49 deletions agents/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,61 +4,64 @@
from collections import Counter

from agents.base import BaseAgent
from core.scenario_scope import resolve_scenario_scope
from core.enums import ActionType
from core.models import Action, AgentProposal, Event, SystemState
def approximate_z_score(service_level: float) -> float:
"""Approximation of the inverse CDF of the normal distribution."""
p = max(0.5001, min(0.9999, service_level))
t = math.sqrt(-2.0 * math.log(1.0 - p))
c0, c1, c2 = 2.515517, 0.802853, 0.010328
d1, d2, d3 = 1.432788, 0.189269, 0.001308
z = t - ((c2 * t + c1) * t + c0) / (((d3 * t + d2) * t + d1) * t + 1.0)
return max(0.0, z)


def approximate_z_score(service_level: float) -> float:
"""Approximation of the inverse CDF of the normal distribution."""
p = max(0.5001, min(0.9999, service_level))
t = math.sqrt(-2.0 * math.log(1.0 - p))
c0, c1, c2 = 2.515517, 0.802853, 0.010328
d1, d2, d3 = 1.432788, 0.189269, 0.001308
z = t - ((c2 * t + c1) * t + c0) / (((d3 * t + d2) * t + d1) * t + 1.0)
return max(0.0, z)


class InventoryAgent(BaseAgent):
name = "inventory"
custom_system_prompt = """You are the Inventory Agent for a supply chain control tower.
Explain inventory risk like an operations manager would.
Be specific about stock position, expected demand, days of cover, reorder point, safety stock, and the business impact of inaction.
If replenishment is proposed, explain why that action is better than waiting.
Do not invent inventory math, customer orders, or supplier facts that are not in the provided state."""
If replenishment is proposed, explain why that action is better than waiting.
Do not invent inventory math, customer orders, or supplier facts that are not in the provided state."""

def run(self, state: SystemState, event: Event | None = None) -> AgentProposal:
proposal = AgentProposal(agent=self.name)
sku_order_counts = Counter(order.sku for order in state.orders)
risk_rows: list[dict[str, object]] = []
scope = resolve_scenario_scope(state, event)
affected_skus = set(scope.affected_skus)
for sku, item in state.inventory.items():
if affected_skus and sku not in affected_skus:
continue
# --- 1. Calculate parameters from Demand and Supplier ---
avg_d = item.forecast_qty
std_d = getattr(item, "std_demand", 0.0)
supplier_key = f"{item.preferred_supplier_id}_{sku}"
supplier = state.suppliers.get(supplier_key)
lead_time = supplier.lead_time_days if supplier else 0
reliability = supplier.reliability if supplier else 0.95
z_score = approximate_z_score(reliability)
# --- 2. Calculate LTD, SS, ROP ---
ltd = avg_d * lead_time
ss = z_score * std_d * math.sqrt(lead_time) if lead_time > 0 else 0.0
rop = ltd + ss
# --- 3. Save back to InventoryItem ---

supplier_key = f"{item.preferred_supplier_id}_{sku}"
supplier = state.suppliers.get(supplier_key)

lead_time = supplier.lead_time_days if supplier else 0
reliability = supplier.reliability if supplier else 0.95

z_score = approximate_z_score(reliability)

# --- 2. Calculate LTD, SS, ROP ---
ltd = avg_d * lead_time
ss = z_score * std_d * math.sqrt(lead_time) if lead_time > 0 else 0.0
rop = ltd + ss

# --- 3. Save back to InventoryItem ---
item.reorder_point = int(round(rop))
item.safety_stock = int(round(ss))

projected = item.on_hand + item.incoming_qty - item.forecast_qty
daily_demand = max(float(avg_d), 0.0)
available_stock = item.on_hand + item.incoming_qty
days_of_cover = (
available_stock / daily_demand
if daily_demand > 0
else float("inf")
available_stock / daily_demand if daily_demand > 0 else float("inf")
)
order_count = sku_order_counts.get(sku, 0)
root_cause = []
Expand All @@ -70,7 +73,9 @@ def run(self, state: SystemState, event: Event | None = None) -> AgentProposal:
root_cause.append("below-target supplier reliability")
if daily_demand > max(item.on_hand, 1):
root_cause.append("demand running ahead of current on-hand stock")
cause_text = ", ".join(root_cause) if root_cause else "normal replenishment cycle"
cause_text = (
", ".join(root_cause) if root_cause else "normal replenishment cycle"
)

if projected < item.safety_stock:
inventory_status = "stockout risk"
Expand All @@ -92,7 +97,9 @@ def run(self, state: SystemState, event: Event | None = None) -> AgentProposal:
"projected_stock": projected,
"reorder_point": item.reorder_point,
"safety_stock": item.safety_stock,
"days_of_cover": None if days_of_cover == float("inf") else round(days_of_cover, 1),
"days_of_cover": None
if days_of_cover == float("inf")
else round(days_of_cover, 1),
"lead_time_days": lead_time,
"supplier_reliability": reliability,
"pending_orders": order_count,
Expand All @@ -104,27 +111,33 @@ def run(self, state: SystemState, event: Event | None = None) -> AgentProposal:
quantity = max(item.reorder_point + item.safety_stock - projected, 0)
proposal.proposals.append(
Action(
action_id=f"act_reorder_{sku}",
action_type=ActionType.REORDER,
target_id=sku,
parameters={"quantity": int(quantity)},
estimated_cost_delta=quantity * item.unit_cost * 1.05,
estimated_service_delta=0.12,
estimated_risk_delta=-0.15,
estimated_recovery_hours=24.0,
reason=f"projected stock ({projected}) for {sku} falls below ROP ({item.reorder_point})",
action_id=f"act_reorder_{sku}",
action_type=ActionType.REORDER,
target_id=sku,
parameters={"quantity": int(quantity)},
estimated_cost_delta=quantity * item.unit_cost * 1.05,
estimated_service_delta=0.12,
estimated_risk_delta=-0.15,
estimated_recovery_hours=24.0,
reason=f"projected stock ({projected}) for {sku} falls below ROP ({item.reorder_point})",
priority=0.8 if projected < item.safety_stock else 0.6,
)
)
ranked_rows = sorted(
risk_rows,
key=lambda row: (
0 if row["inventory_status"] == "stockout risk" else 1 if row["inventory_status"] == "reorder risk" else 2,
0
if row["inventory_status"] == "stockout risk"
else 1
if row["inventory_status"] == "reorder risk"
else 2,
row["days_of_cover"] if row["days_of_cover"] is not None else 9999,
),
)
critical_rows = [
row for row in ranked_rows if row["inventory_status"] in {"stockout risk", "reorder risk"}
row
for row in ranked_rows
if row["inventory_status"] in {"stockout risk", "reorder risk"}
]
if critical_rows:
top = critical_rows[0]
Expand All @@ -140,7 +153,7 @@ def run(self, state: SystemState, event: Event | None = None) -> AgentProposal:
else "limited demand visibility"
)
proposal.domain_summary = (
f"Inventory pressure is concentrated on {top['name']} ({top['sku']}). "
f"Inventory pressure is concentrated on {top['name']} ({top['sku']}) within the direct disruption scope. "
f"Projected stock falls to {int(top['projected_stock'])} units versus a reorder point of {int(top['reorder_point'])} "
f"and safety stock of {int(top['safety_stock'])}. At the current demand pace, that leaves {coverage_text}. "
f"The main driver is {top['root_cause']}; if no action is taken, stock availability could tighten within the next replenishment cycle{order_text}."
Expand All @@ -165,10 +178,10 @@ def run(self, state: SystemState, event: Event | None = None) -> AgentProposal:
f"{critical_rows[0]['days_of_cover'] or 0:.1f} days of cover."
)
elif ranked_rows:
stable_count = sum(1 for row in ranked_rows if row["inventory_status"] == "stable")
proposal.domain_summary = (
f"Inventory is broadly stable. {stable_count} SKUs remain above reorder point after applying current demand, lead time, and safety stock assumptions."
stable_count = sum(
1 for row in ranked_rows if row["inventory_status"] == "stable"
)
proposal.domain_summary = f"Inventory is broadly stable. {stable_count} SKUs remain above reorder point after applying current demand, lead time, and safety stock assumptions."
proposal.tradeoffs.append(
"Holding current inventory avoids unnecessary replenishment cost, but coverage should still be refreshed as demand and supplier conditions change."
)
Expand All @@ -180,6 +193,7 @@ def run(self, state: SystemState, event: Event | None = None) -> AgentProposal:
event=event,
proposal=proposal,
state_slice={
"scenario_scope": scope.to_dict(),
"inventory_focus": ranked_rows[:5],
"critical_sku_count": len(critical_rows),
"proposed_reorders": [
Expand Down
Loading
Loading