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
20 changes: 8 additions & 12 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
# Do not commit real credentials.

# Core LLM toggle
CHAINCOPILOT_LLM_ENABLED=true
CHAINCOPILOT_LLM_TIMEOUT_S=60
CHAINCOPILOT_LLM_ENABLED=false
CHAINCOPILOT_LLM_TIMEOUT_S=4
CHAINCOPILOT_LLM_RETRY_ATTEMPTS=1
CHAINCOPILOT_LLM_MODEL=gemma-4-31B-it
CHAINCOPILOT_LLM_MODEL=gemini-2.0-flash-lite

# Planner mode
# - hybrid: use LLM for candidate drafts, then deterministic scoring/selection
Expand All @@ -15,22 +15,18 @@ CHAINCOPILOT_PLANNER_MODE=hybrid
# Provider selection
# - gemini: Gemini Developer API
# - vertex: Vertex AI Gemini via aiplatform.googleapis.com
CHAINCOPILOT_LLM_PROVIDER=fpt
CHAINCOPILOT_LLM_PROVIDER=gemini

# Gemini Developer API
CHAINCOPILOT_LLM_API_KEY=
CHAINCOPILOT_LLM_API_KEY=REPLACE_WITH_GEMINI_API_KEY

# Vertex AI Gemini
# Switch CHAINCOPILOT_LLM_PROVIDER=vertex when these are set correctly.
# VERTEX_AI_API_KEY=REPLACE_WITH_VERTEX_API_KEY
# VERTEX_AI_PROJECT_ID=REPLACE_WITH_GCP_PROJECT_ID
# VERTEX_AI_REGION=global
VERTEX_AI_API_KEY=REPLACE_WITH_VERTEX_API_KEY
VERTEX_AI_PROJECT_ID=REPLACE_WITH_GCP_PROJECT_ID
VERTEX_AI_REGION=global

# Optional fallbacks recognized by the runtime
# GEMINI_API_KEY=REPLACE_WITH_GEMINI_API_KEY
# GOOGLE_CLOUD_PROJECT=REPLACE_WITH_GCP_PROJECT_ID
# GOOGLE_CLOUD_LOCATION=global




135 changes: 81 additions & 54 deletions agents/planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from uuid import uuid4

from actions.executor import simulate_actions
from agents.base import BaseAgent
from core.enums import ActionType, ApprovalStatus, PlanStatus
from core.models import (
Expand All @@ -24,14 +23,15 @@
explain_rejected_actions,
)
from policies.constraints import evaluate_plan_constraints, mode_rationale, evaluate_hard_constraints, evaluate_soft_constraints
from policies.guardrails import approval_required
from policies.scoring import compute_score
from policies.strategic_prompt import (
from policies.guardrails import approval_required
from policies.scoring import compute_score
from policies.strategic_prompt import (
retrieve_relevant_cases,
compute_memory_influence,
derive_strategy_rationale,
build_strategic_prompt,
derive_strategy_rationale,
build_strategic_prompt,
)
from simulation.evaluator import evaluate_candidate_plan

logger = get_logger(__name__)

Expand Down Expand Up @@ -338,35 +338,44 @@ def _evaluate_candidate(
actions=actions,
)
feasible = not violations
if not feasible:
return CandidatePlanEvaluation(
strategy_label=strategy_label,
action_ids=[action.action_id for action in actions],
score=0.0,
if not feasible:
return CandidatePlanEvaluation(
strategy_label=strategy_label,
action_ids=[action.action_id for action in actions],
score=0.0,
score_breakdown={
"service_level": 0.0,
"total_cost": 0.0,
"disruption_risk": 0.0,
"recovery_speed": 0.0,
},
projected_kpis=before_kpis.model_copy(deep=True),
feasible=False,
violations=violations,
mode_rationale=selected_mode_rationale,
approval_required=False,
approval_reason="not evaluated because the candidate plan is infeasible",
rationale=rationale,
llm_used=llm_used,
)
simulated = simulate_actions(state, actions)
score, breakdown = compute_score(
service_level=simulated.kpis.service_level,
total_cost=simulated.kpis.total_cost,
disruption_risk=simulated.kpis.disruption_risk,
recovery_speed=simulated.kpis.recovery_speed,
mode=state.mode,
baseline_cost=before_kpis.total_cost,
)
"recovery_speed": 0.0,
},
projected_kpis=before_kpis.model_copy(deep=True),
worst_case_kpis=before_kpis.model_copy(deep=True),
simulation_horizon_days=0,
projection_steps=[],
projected_state_summary=None,
projection_summary="candidate was not simulated because hard constraints failed",
feasible=False,
violations=violations,
mode_rationale=selected_mode_rationale,
approval_required=False,
approval_reason="not evaluated because the candidate plan is infeasible",
rationale=rationale,
llm_used=llm_used,
)
projection = evaluate_candidate_plan(
state=state,
event=event,
actions=actions,
)
score, breakdown = compute_score(
service_level=projection.worst_case_kpis.service_level,
total_cost=projection.projected_kpis.total_cost,
disruption_risk=projection.worst_case_kpis.disruption_risk,
recovery_speed=projection.projected_kpis.recovery_speed,
mode=state.mode,
baseline_cost=before_kpis.total_cost,
)
transient_plan = Plan(
plan_id=f"plan_eval_{uuid4().hex[:8]}",
mode=state.mode,
Expand All @@ -375,22 +384,32 @@ def _evaluate_candidate(
score=score,
score_breakdown=breakdown,
strategy_label=strategy_label,
generated_by="llm" if llm_used else "deterministic_fallback",
planner_reasoning=rationale,
status=PlanStatus.PROPOSED,
)
needs_approval, reason = approval_required(transient_plan, before_kpis, simulated.kpis, event)
generated_by="llm" if llm_used else "deterministic_fallback",
planner_reasoning=rationale,
status=PlanStatus.PROPOSED,
)
needs_approval, reason = approval_required(
transient_plan,
before_kpis,
projection.projected_kpis,
event,
)
return CandidatePlanEvaluation(
strategy_label=strategy_label,
action_ids=[action.action_id for action in actions],
score=score,
score_breakdown=breakdown,
projected_kpis=simulated.kpis,
feasible=True,
violations=[],
mode_rationale=selected_mode_rationale,
approval_required=needs_approval,
approval_reason=reason if needs_approval else "no approval required: thresholds not triggered",
score_breakdown=breakdown,
projected_kpis=projection.projected_kpis,
worst_case_kpis=projection.worst_case_kpis,
simulation_horizon_days=projection.simulation_horizon_days,
projection_steps=projection.projection_steps,
projected_state_summary=projection.projected_state_summary,
projection_summary=projection.projection_summary,
feasible=True,
violations=[],
mode_rationale=selected_mode_rationale,
approval_required=needs_approval,
approval_reason=reason if needs_approval else "no approval required: thresholds not triggered",
rationale=rationale,
llm_used=llm_used,
)
Expand Down Expand Up @@ -808,17 +827,22 @@ def _safe_hold_evaluation(
mode=state.mode,
baseline_cost=before_kpis.total_cost,
)
return CandidatePlanEvaluation(
strategy_label="safe_hold",
action_ids=[safe_action.action_id],
score=score,
score_breakdown=breakdown,
projected_kpis=before_kpis.model_copy(deep=True),
feasible=True,
violations=[],
mode_rationale=mode_rationale(state, event),
approval_required=False,
approval_reason="no approval required: retaining current state because all candidate plans were infeasible",
return CandidatePlanEvaluation(
strategy_label="safe_hold",
action_ids=[safe_action.action_id],
score=score,
score_breakdown=breakdown,
projected_kpis=before_kpis.model_copy(deep=True),
worst_case_kpis=before_kpis.model_copy(deep=True),
simulation_horizon_days=0,
projection_steps=[],
projected_state_summary=None,
projection_summary="the system retained the current network position because no feasible candidate plan was available",
feasible=True,
violations=[],
mode_rationale=mode_rationale(state, event),
approval_required=False,
approval_reason="no approval required: retaining current state because all candidate plans were infeasible",
rationale=(
f"no candidate plan satisfied hard constraints; retaining the current network state after excluding "
f"{infeasible_count} infeasible candidate plan(s)"
Expand Down Expand Up @@ -998,6 +1022,9 @@ def run(self, state: SystemState, event: Event | None = None) -> AgentProposal:
mode=state.mode,
runner_up=runner_up,
mode_rationale=selected_evaluation.mode_rationale,
projection_summary=selected_evaluation.projection_summary,
simulation_horizon_days=selected_evaluation.simulation_horizon_days,
worst_case_kpis=selected_evaluation.worst_case_kpis,
)

final_plan = Plan(
Expand Down
14 changes: 12 additions & 2 deletions agents/risk.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ class RiskAgent(BaseAgent):

custom_system_prompt = (
"Role: {agent_name} specialist agent in an autonomous supply chain control tower. "
"CRITICAL: Write the 'domain_summary' in English summarizing the disruption based on external_api_data"
"Make it sound natural but precise. Do not invent new actions or make up false information."
"CRITICAL: Your ONLY goal is to write a comprehensive 'domain_summary' in English "
"summarizing the disruption based on external_api_data. "
"Make it sound natural but precise. Do not invent new actions or make up false information. "
"Return empty arrays for impacts, tradeoffs, and recommended actions."
)

def run(self, state: SystemState, event: Event | None = None) -> AgentProposal:
Expand Down Expand Up @@ -80,8 +82,16 @@ def run(self, state: SystemState, event: Event | None = None) -> AgentProposal:
"external_api_data": api_payloads,
},
)

if not proposal.downstream_impacts and event is not None:
proposal.downstream_impacts.append(
"Elevated disruption risk may affect downstream service level and recovery speed."
)

# Keep the risk agent non-prescriptive even when the LLM returns extras.
proposal.recommended_action_ids.clear()
proposal.tradeoffs.clear()

return proposal


49 changes: 39 additions & 10 deletions api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from fastapi import FastAPI, HTTPException, Request
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
Expand All @@ -10,7 +10,7 @@
from app_api.schemas import ErrorResponse
from app_api.services import ControlTowerRuntime, make_runtime
from core.memory import SQLiteStore
from core.state import load_initial_state, refresh_operational_baseline
from core.state import load_initial_state, refresh_operational_baseline
from orchestrator.graph import build_graph
from simulation.runner import ScenarioRunner
from execution.dispatch_service import ActionDispatchService
Expand Down Expand Up @@ -51,20 +51,22 @@ def replace_runtime(
"""Reconfigures the service with fresh components."""
global RUNTIME
selected_store = store or SQLiteStore()
RUNTIME = ControlTowerRuntime(
store=selected_store,
state=state or refresh_operational_baseline(load_initial_state()),
graph=graph or build_graph(),
runner=runner or ScenarioRunner(store=selected_store),
dispatch_service=dispatch_service or ActionDispatchService(),
RUNTIME = ControlTowerRuntime(
store=selected_store,
state=state or refresh_operational_baseline(load_initial_state()),
graph=graph or build_graph(),
runner=runner or ScenarioRunner(store=selected_store),
dispatch_service=dispatch_service or ActionDispatchService(),
)
sync_legacy_globals()
return RUNTIME


def create_app(runtime: ControlTowerRuntime | None = None) -> FastAPI:
"""Application factory for the ChainCopilot API."""
print(">>> CALLING create_app")
if runtime is not None:

replace_runtime(
store=runtime.store,
state=runtime.state,
Expand All @@ -74,7 +76,6 @@ def create_app(runtime: ControlTowerRuntime | None = None) -> FastAPI:
)
instance = FastAPI(title="ChainCopilot API", version="0.2.0")

# Configure CORS middleware
instance.add_middleware(
CORSMiddleware,
allow_origins=["*"],
Expand All @@ -83,8 +84,32 @@ def create_app(runtime: ControlTowerRuntime | None = None) -> FastAPI:
allow_headers=["*"],
)

@instance.get("/health")
def health():
return {"status": "ok", "version": "0.2.0-ws-debug"}

# Modular routers - Includes all /api/v1 endpoints
instance.include_router(create_router(lambda: RUNTIME))

instance.include_router(create_router(lambda: RUNTIME), prefix="/api/v1")

# Direct WebSocket route (UNIQUE PATH - NO PREFIX)
print(">>> REGISTERING WEBSOCKET: /thinking-stream/{run_id}")
@instance.websocket("/thinking-stream/{run_id}")
async def thinking_stream(websocket: WebSocket, run_id: str) -> None:
from streaming.event_bus import event_bus
await websocket.accept()
try:
async for thinking_event in event_bus.subscribe(run_id):
await websocket.send_text(thinking_event.model_dump_json())
except WebSocketDisconnect:
pass
except Exception:
pass
finally:
try:
await websocket.close()
except Exception:
pass

register_error_handlers(instance)
return instance
Expand Down Expand Up @@ -150,3 +175,7 @@ async def _unhandled_exception_handler(_: Request, exc: Exception) -> JSONRespon

sync_legacy_globals()
app = create_app(RUNTIME)

@app.get("/health-module")
def health_module():
return {"status": "ok", "source": "module-level"}
Loading
Loading