mirror of
https://github.com/CopilotKit/CopilotKit.git
synced 2026-09-14 16:26:20 +08:00
1139 lines
35 KiB
Python
1139 lines
35 KiB
Python
"""
|
|
CloudPlot Agent - AI-powered cloud infrastructure architect.
|
|
|
|
This agent helps design, validate, and estimate costs for simulated AWS
|
|
infrastructure rendered as resource cards in the frontend workspace.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import json
|
|
import logging
|
|
import time
|
|
import uuid
|
|
from collections.abc import Mapping
|
|
from typing import Annotated, Literal, NotRequired, TypedDict
|
|
|
|
from copilotkit import CopilotKitState
|
|
from langchain.tools import tool
|
|
from langchain_core.messages import SystemMessage, ToolMessage
|
|
from langchain_core.runnables import RunnableConfig
|
|
from langchain_openai import ChatOpenAI
|
|
from langgraph.checkpoint.memory import MemorySaver
|
|
from langgraph.graph import END, StateGraph
|
|
from langgraph.prebuilt import ToolNode
|
|
from langgraph.types import Command
|
|
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
|
|
LOGGER = logging.getLogger("cloudplot.agent")
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Type Definitions
|
|
# -----------------------------------------------------------------------------
|
|
|
|
ResourceType = Literal["s3", "ec2", "rds", "lambda", "vpc", "alb"]
|
|
StatusType = Literal["healthy", "warning", "error", "stopped"]
|
|
AgentStatusType = Literal["idle", "designing", "validating"]
|
|
TierType = Literal["network", "frontend", "compute", "data", "storage"]
|
|
|
|
|
|
class StrictResourceConfig(BaseModel):
|
|
"""Reject coercion and unknown model-generated configuration fields."""
|
|
|
|
model_config = ConfigDict(extra="forbid", strict=True)
|
|
|
|
|
|
class S3ResourceConfig(StrictResourceConfig):
|
|
bucket_name: str
|
|
access_level: Literal["public", "private"]
|
|
versioning: bool
|
|
|
|
|
|
class EC2ResourceConfig(StrictResourceConfig):
|
|
instance_type: str
|
|
ami: str
|
|
name: str
|
|
security_group: str | None = None
|
|
|
|
|
|
class RDSResourceConfig(StrictResourceConfig):
|
|
engine: str
|
|
instance_class: str
|
|
multi_az: bool
|
|
encryption: bool
|
|
name: str
|
|
|
|
|
|
class LambdaResourceConfig(StrictResourceConfig):
|
|
runtime: str
|
|
memory: int
|
|
timeout: int
|
|
name: str
|
|
|
|
|
|
class VPCResourceConfig(StrictResourceConfig):
|
|
cidr_block: str
|
|
subnets: list[str]
|
|
name: str
|
|
|
|
|
|
class ALBResourceConfig(StrictResourceConfig):
|
|
listeners: list[int]
|
|
target_groups: list[str]
|
|
name: str
|
|
|
|
|
|
class S3ResourceUpdate(StrictResourceConfig):
|
|
resource_type: Literal["s3"]
|
|
bucket_name: str | None = None
|
|
access_level: Literal["public", "private"] | None = None
|
|
versioning: bool | None = None
|
|
|
|
|
|
class EC2ResourceUpdate(StrictResourceConfig):
|
|
resource_type: Literal["ec2"]
|
|
instance_type: str | None = None
|
|
ami: str | None = None
|
|
name: str | None = None
|
|
security_group: str | None = None
|
|
|
|
|
|
class RDSResourceUpdate(StrictResourceConfig):
|
|
resource_type: Literal["rds"]
|
|
engine: str | None = None
|
|
instance_class: str | None = None
|
|
multi_az: bool | None = None
|
|
encryption: bool | None = None
|
|
name: str | None = None
|
|
|
|
|
|
class LambdaResourceUpdate(StrictResourceConfig):
|
|
resource_type: Literal["lambda"]
|
|
runtime: str | None = None
|
|
memory: int | None = None
|
|
timeout: int | None = None
|
|
name: str | None = None
|
|
|
|
|
|
class VPCResourceUpdate(StrictResourceConfig):
|
|
resource_type: Literal["vpc"]
|
|
cidr_block: str | None = None
|
|
subnets: list[str] | None = None
|
|
name: str | None = None
|
|
|
|
|
|
class ALBResourceUpdate(StrictResourceConfig):
|
|
resource_type: Literal["alb"]
|
|
listeners: list[int] | None = None
|
|
target_groups: list[str] | None = None
|
|
name: str | None = None
|
|
|
|
|
|
ResourceUpdate = Annotated[
|
|
S3ResourceUpdate
|
|
| EC2ResourceUpdate
|
|
| RDSResourceUpdate
|
|
| LambdaResourceUpdate
|
|
| VPCResourceUpdate
|
|
| ALBResourceUpdate,
|
|
Field(discriminator="resource_type"),
|
|
]
|
|
|
|
|
|
class UpdateResourceArgs(BaseModel):
|
|
resource_id: str
|
|
update: ResourceUpdate
|
|
|
|
|
|
RESOURCE_CONFIG_MODELS: dict[str, type[StrictResourceConfig]] = {
|
|
"s3": S3ResourceConfig,
|
|
"ec2": EC2ResourceConfig,
|
|
"rds": RDSResourceConfig,
|
|
"lambda": LambdaResourceConfig,
|
|
"vpc": VPCResourceConfig,
|
|
"alb": ALBResourceConfig,
|
|
}
|
|
|
|
# Tier mapping by resource type (for automatic assignment)
|
|
RESOURCE_TIER_MAP: dict[ResourceType, TierType] = {
|
|
"vpc": "network",
|
|
"alb": "frontend",
|
|
"ec2": "compute",
|
|
"lambda": "compute",
|
|
"rds": "data",
|
|
"s3": "storage",
|
|
}
|
|
|
|
|
|
class NodeData(TypedDict, total=False):
|
|
"""AWS resource node data."""
|
|
|
|
id: str
|
|
type: ResourceType
|
|
position: dict # {x: float, y: float}
|
|
config: dict
|
|
status: StatusType
|
|
tier: TierType
|
|
parentId: str # ID of parent VPC for contained resources
|
|
|
|
|
|
class EdgeData(TypedDict):
|
|
"""Connection between resources."""
|
|
|
|
id: str
|
|
source: str
|
|
target: str
|
|
|
|
|
|
class ValidationResult(TypedDict):
|
|
"""Validation error or warning."""
|
|
|
|
level: Literal["error", "warning"]
|
|
message: str
|
|
node_id: str
|
|
|
|
|
|
class ThoughtLogEntry(TypedDict):
|
|
"""Agent thinking log entry."""
|
|
|
|
timestamp: float
|
|
node: str
|
|
message: str
|
|
type: Literal["info", "warning", "success", "error"]
|
|
|
|
|
|
class AppliedToolResult(TypedDict):
|
|
"""Copied infrastructure state plus optional model-visible rejection detail."""
|
|
|
|
nodes: list[NodeData]
|
|
edges: list[EdgeData]
|
|
logs: list[ThoughtLogEntry]
|
|
tool_error: NotRequired[str]
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Agent State
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
class AgentState(CopilotKitState):
|
|
"""
|
|
CloudPlot agent state - synced with frontend via CopilotKit.
|
|
|
|
Attributes:
|
|
nodes: AWS resource nodes on the canvas
|
|
edges: Connections between resources
|
|
logs: Agent thinking/activity log
|
|
cost: Estimated monthly cost in USD
|
|
status: Current agent status
|
|
validation_errors: List of validation issues
|
|
copilotkit: CopilotKit frontend action metadata
|
|
"""
|
|
|
|
nodes: list[NodeData]
|
|
edges: list[EdgeData]
|
|
logs: list[ThoughtLogEntry]
|
|
cost: float
|
|
status: AgentStatusType
|
|
validation_errors: list[ValidationResult]
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Pricing (mock AWS pricing)
|
|
# -----------------------------------------------------------------------------
|
|
|
|
RESOURCE_PRICING = {
|
|
"s3": 2.30, # per month, 100GB assumed
|
|
"ec2": {
|
|
"t3.micro": 7.59,
|
|
"t3.small": 15.18,
|
|
"t3.medium": 30.37,
|
|
"t3.large": 60.74,
|
|
"default": 30.37,
|
|
},
|
|
"rds": {
|
|
"db.t3.micro": 12.41,
|
|
"db.t3.small": 24.82,
|
|
"db.t3.medium": 49.64,
|
|
"default": 24.82,
|
|
},
|
|
"lambda": 0.20, # per month estimate
|
|
"vpc": 0.00, # VPC is free, NAT gateway would cost
|
|
"alb": 16.43, # per month
|
|
}
|
|
|
|
|
|
def calculate_resource_cost(node: NodeData) -> float:
|
|
"""Calculate monthly cost for a single resource."""
|
|
resource_type = node["type"]
|
|
config = node.get("config", {})
|
|
|
|
if resource_type == "s3":
|
|
return RESOURCE_PRICING["s3"]
|
|
elif resource_type == "ec2":
|
|
instance_type = config.get("instance_type", "default")
|
|
return RESOURCE_PRICING["ec2"].get(
|
|
instance_type, RESOURCE_PRICING["ec2"]["default"]
|
|
)
|
|
elif resource_type == "rds":
|
|
instance_class = config.get("instance_class", "default")
|
|
return RESOURCE_PRICING["rds"].get(
|
|
instance_class, RESOURCE_PRICING["rds"]["default"]
|
|
)
|
|
elif resource_type == "lambda":
|
|
return RESOURCE_PRICING["lambda"]
|
|
elif resource_type == "vpc":
|
|
return RESOURCE_PRICING["vpc"]
|
|
elif resource_type == "alb":
|
|
return RESOURCE_PRICING["alb"]
|
|
return 0.0
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Helper Functions
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def log_thought(
|
|
state: AgentState,
|
|
node_name: str,
|
|
message: str,
|
|
log_type: Literal["info", "warning", "success", "error"] = "info",
|
|
) -> ThoughtLogEntry:
|
|
"""Create a thought log entry."""
|
|
return {
|
|
"timestamp": time.time(),
|
|
"node": node_name,
|
|
"message": message,
|
|
"type": log_type,
|
|
}
|
|
|
|
|
|
def generate_position(existing_nodes: list[NodeData]) -> dict:
|
|
"""Generate a position for a new node based on existing nodes."""
|
|
if not existing_nodes:
|
|
return {"x": 100, "y": 100}
|
|
|
|
# Find the rightmost node and place new one to the right
|
|
max_x = max(n.get("position", {}).get("x", 0) for n in existing_nodes)
|
|
avg_y = sum(n.get("position", {}).get("y", 200) for n in existing_nodes) / len(
|
|
existing_nodes
|
|
)
|
|
|
|
return {"x": max_x + 250, "y": avg_y}
|
|
|
|
|
|
def generate_state_summary(nodes: list[NodeData], edges: list[EdgeData]) -> str:
|
|
"""Generate a summary of current infrastructure state for tool responses."""
|
|
if not nodes:
|
|
return "\n[Current state: No resources exist. Workspace is empty.]"
|
|
|
|
nodes_list = ", ".join([f"{n['type']}({n['id']})" for n in nodes])
|
|
edges_list = (
|
|
", ".join([f"{e['source']}->{e['target']}" for e in edges]) if edges else "none"
|
|
)
|
|
|
|
return f"\n[Current state: Resources: {nodes_list}. Connections: {edges_list}]"
|
|
|
|
|
|
def format_validation_error(error: ValidationError) -> str:
|
|
"""Return a compact model-visible validation failure without a traceback."""
|
|
|
|
details = []
|
|
for item in error.errors(include_url=False, include_context=False):
|
|
location = ".".join(str(part) for part in item["loc"])
|
|
details.append(f"{location}: {item['msg']}")
|
|
return "Invalid resource configuration: " + "; ".join(details)
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Backend Tools
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
@tool
|
|
def add_resource(
|
|
resource_type: str,
|
|
name: str,
|
|
resource_config: dict | None = None,
|
|
vpc_id: str | None = None,
|
|
) -> dict:
|
|
"""
|
|
Add a new AWS resource to the infrastructure diagram.
|
|
|
|
Args:
|
|
resource_type: Type of resource (s3, ec2, rds, lambda, vpc, alb)
|
|
name: Display name for the resource
|
|
resource_config: Resource-specific configuration
|
|
vpc_id: Optional ID of parent VPC to place this resource inside
|
|
|
|
Returns:
|
|
The created node data
|
|
"""
|
|
if resource_type not in ["s3", "ec2", "rds", "lambda", "vpc", "alb"]:
|
|
return {"error": f"Invalid resource type: {resource_type}"}
|
|
|
|
node_id = f"{resource_type}-{uuid.uuid4().hex[:8]}"
|
|
|
|
# Default configs per type
|
|
default_configs = {
|
|
"s3": {"bucket_name": name, "access_level": "private", "versioning": False},
|
|
"ec2": {"instance_type": "t3.medium", "ami": "ami-12345678", "name": name},
|
|
"rds": {
|
|
"engine": "postgresql",
|
|
"instance_class": "db.t3.micro",
|
|
"multi_az": False,
|
|
"encryption": True,
|
|
"name": name,
|
|
},
|
|
"lambda": {"runtime": "python3.12", "memory": 128, "timeout": 30, "name": name},
|
|
"vpc": {"cidr_block": "10.0.0.0/16", "subnets": [], "name": name},
|
|
"alb": {"listeners": [80, 443], "target_groups": [], "name": name},
|
|
}
|
|
|
|
merged_config = {
|
|
**default_configs.get(resource_type, {}),
|
|
**(resource_config or {}),
|
|
}
|
|
try:
|
|
validated_config = RESOURCE_CONFIG_MODELS[resource_type].model_validate(
|
|
merged_config
|
|
)
|
|
except ValidationError as error:
|
|
return {"success": False, "error": format_validation_error(error)}
|
|
|
|
result = {
|
|
"id": node_id,
|
|
"type": resource_type,
|
|
"config": validated_config.model_dump(exclude_none=True),
|
|
"status": "healthy",
|
|
"tier": RESOURCE_TIER_MAP.get(resource_type, "compute"),
|
|
}
|
|
|
|
# If vpc_id provided, include parentId for containment
|
|
if vpc_id:
|
|
result["parentId"] = vpc_id
|
|
|
|
return result
|
|
|
|
|
|
@tool
|
|
def connect_resources(source_id: str, target_id: str, label: str = "") -> dict:
|
|
"""
|
|
Connect two resources with a directional edge.
|
|
|
|
Args:
|
|
source_id: ID of the source resource
|
|
target_id: ID of the target resource
|
|
label: Optional label for the connection
|
|
|
|
Returns:
|
|
The created edge data
|
|
"""
|
|
edge_id = f"edge-{uuid.uuid4().hex[:8]}"
|
|
return {
|
|
"id": edge_id,
|
|
"source": source_id,
|
|
"target": target_id,
|
|
"label": label,
|
|
}
|
|
|
|
|
|
@tool
|
|
def remove_resource(resource_id: str) -> dict:
|
|
"""
|
|
Remove a resource from the infrastructure diagram.
|
|
|
|
Args:
|
|
resource_id: ID of the resource to remove
|
|
|
|
Returns:
|
|
Confirmation of removal
|
|
"""
|
|
return {"removed": resource_id, "success": True}
|
|
|
|
|
|
@tool(args_schema=UpdateResourceArgs)
|
|
def update_resource(resource_id: str, update: ResourceUpdate) -> dict:
|
|
"""
|
|
Update an existing resource's configuration.
|
|
|
|
Args:
|
|
resource_id: ID of the resource to update
|
|
update: Typed resource-specific configuration values to merge
|
|
|
|
Returns:
|
|
Updated resource info
|
|
"""
|
|
return {
|
|
"updated": resource_id,
|
|
"resource_type": update.resource_type,
|
|
"config": update.model_dump(exclude={"resource_type"}, exclude_none=True),
|
|
"success": True,
|
|
}
|
|
|
|
|
|
@tool
|
|
def move_to_vpc(resource_id: str, vpc_id: str | None = None) -> dict:
|
|
"""
|
|
Move a resource into or out of a VPC.
|
|
|
|
Args:
|
|
resource_id: ID of the resource to move (ec2, rds, lambda, alb)
|
|
vpc_id: ID of the target VPC, or None/empty to remove from VPC
|
|
|
|
Returns:
|
|
Move operation result
|
|
"""
|
|
return {"moved": resource_id, "vpc_id": vpc_id, "success": True}
|
|
|
|
|
|
backend_tools = [
|
|
add_resource,
|
|
connect_resources,
|
|
remove_resource,
|
|
update_resource,
|
|
move_to_vpc,
|
|
]
|
|
|
|
backend_tool_names = [t.name for t in backend_tools]
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Graph Nodes
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def create_architect_model() -> ChatOpenAI:
|
|
"""Create the architect model without sampling or output truncation overrides."""
|
|
|
|
return ChatOpenAI(model="gpt-5.1")
|
|
|
|
|
|
def frontend_tools_from_state(state: AgentState) -> list[dict]:
|
|
"""Return V2 frontend actions supplied by the CopilotKit AG-UI middleware."""
|
|
|
|
copilotkit_state = state.get("copilotkit", {})
|
|
actions = copilotkit_state.get("actions", [])
|
|
return actions if isinstance(actions, list) else []
|
|
|
|
|
|
async def architect_node(state: AgentState, config: RunnableConfig) -> Command[str]:
|
|
"""
|
|
Main architect node - LLM with tools for designing infrastructure.
|
|
Uses ReAct pattern to iteratively build the diagram.
|
|
"""
|
|
model = create_architect_model()
|
|
frontend_tools = frontend_tools_from_state(state)
|
|
|
|
# Bind all tools
|
|
model_with_tools = model.bind_tools(
|
|
[
|
|
*frontend_tools,
|
|
*backend_tools,
|
|
],
|
|
parallel_tool_calls=False,
|
|
)
|
|
|
|
# Build context about current state
|
|
current_nodes = state.get("nodes", [])
|
|
current_edges = state.get("edges", [])
|
|
current_cost = state.get("cost", 0)
|
|
validation_errors = state.get("validation_errors", [])
|
|
|
|
nodes_summary = (
|
|
", ".join([f"{n['type']}({n['id']})" for n in current_nodes])
|
|
if current_nodes
|
|
else "none"
|
|
)
|
|
edges_summary = (
|
|
", ".join([f"{e['source']}->{e['target']}" for e in current_edges])
|
|
if current_edges
|
|
else "none"
|
|
)
|
|
errors_summary = (
|
|
"\n".join([f"- {e['level']}: {e['message']}" for e in validation_errors])
|
|
if validation_errors
|
|
else "none"
|
|
)
|
|
|
|
system_prompt = f"""You are CloudPlot, a Senior Cloud Architect AI assistant.
|
|
You help users design simulated AWS infrastructure in a visual workspace.
|
|
|
|
You are a senior architect. Make decisions confidently using AWS best practices.
|
|
Propose complete solutions without asking for user preferences.
|
|
Only ask questions when the request is genuinely ambiguous.
|
|
|
|
CURRENT INFRASTRUCTURE STATE:
|
|
- Resources: {nodes_summary}
|
|
- Connections: {edges_summary}
|
|
- Estimated monthly cost: ${current_cost:.2f}
|
|
- Validation issues: {errors_summary}
|
|
|
|
AVAILABLE TOOLS:
|
|
- add_resource: Add S3, EC2, RDS, Lambda, VPC, or ALB resources. Use vpc_id parameter to place EC2, RDS, Lambda inside a VPC.
|
|
- connect_resources: Create connections between resources
|
|
- remove_resource: Remove a resource
|
|
- update_resource: Modify resource configuration
|
|
- move_to_vpc: Move an existing resource into a VPC (use this to relocate resources)
|
|
- approveDeployment: Ask the operator to approve or reject a simulated deployment. Call this when the user asks to deploy or approve the proposed architecture. Include the affected resource names, estimated monthly cost impact, and a risk level.
|
|
|
|
CRITICAL: CloudPlot is a simulation only. Never claim that approval creates or deploys AWS resources.
|
|
|
|
GUIDELINES:
|
|
1. When adding resources, use descriptive names
|
|
2. Connect resources logically (e.g., ALB -> EC2 -> RDS)
|
|
3. Create COMPLETE connection topologies: if an ALB serves multiple EC2 instances, connect it to ALL of them. If multiple EC2 instances need database access, connect EACH one to the RDS. Never leave resources partially connected.
|
|
4. Place compute (EC2, Lambda) and data (RDS) resources inside VPCs when appropriate - use vpc_id parameter
|
|
5. S3 is a global service and should NOT be placed inside VPCs
|
|
6. Consider security: private subnets for RDS, security groups for EC2
|
|
7. Warn about cost implications for expensive resources
|
|
8. Do not explain your actions. The UI shows what happened visually.
|
|
9. Only speak when there's an error or the user asks a question.
|
|
|
|
Only communicate errors or answers to direct questions."""
|
|
|
|
system_message = SystemMessage(content=system_prompt)
|
|
|
|
response = await model_with_tools.ainvoke(
|
|
[system_message, *state["messages"]],
|
|
config,
|
|
)
|
|
|
|
# Check if we need to route to tool node
|
|
tool_calls = getattr(response, "tool_calls", None)
|
|
if tool_calls:
|
|
for tc in tool_calls:
|
|
if tc.get("name") in backend_tool_names:
|
|
# Create thought log
|
|
new_log = log_thought(
|
|
state,
|
|
"architect",
|
|
f"Calling tool: {tc.get('name')}",
|
|
"info",
|
|
)
|
|
return Command(
|
|
goto="tool_node",
|
|
update={
|
|
"messages": [response],
|
|
"status": "designing",
|
|
"logs": [*state.get("logs", []), new_log],
|
|
},
|
|
)
|
|
|
|
frontend_tool_names = {
|
|
tool.get("name")
|
|
for tool in frontend_tools
|
|
if isinstance(tool, dict) and isinstance(tool.get("name"), str)
|
|
}
|
|
if any(tc.get("name") in frontend_tool_names for tc in tool_calls):
|
|
return Command(
|
|
goto=END,
|
|
update={
|
|
"messages": [response],
|
|
"status": "idle",
|
|
},
|
|
)
|
|
|
|
# No backend tools called, end turn
|
|
return Command(
|
|
goto="validate_node",
|
|
update={
|
|
"messages": [response],
|
|
"status": "validating",
|
|
},
|
|
)
|
|
|
|
|
|
def parse_tool_result(content: object) -> dict | None:
|
|
"""Decode a structured tool result without rewriting Python repr strings."""
|
|
|
|
if isinstance(content, Mapping):
|
|
return dict(content)
|
|
if not isinstance(content, str):
|
|
LOGGER.warning("Unsupported tool result type: %s", type(content).__name__)
|
|
return None
|
|
|
|
try:
|
|
decoded = json.loads(content)
|
|
except json.JSONDecodeError as error:
|
|
LOGGER.warning("Could not parse tool result as JSON: %s", error)
|
|
return None
|
|
|
|
if not isinstance(decoded, dict):
|
|
LOGGER.warning(
|
|
"Tool result JSON must be an object, got %s", type(decoded).__name__
|
|
)
|
|
return None
|
|
return decoded
|
|
|
|
|
|
def apply_tool_result(state: AgentState, data: Mapping) -> AppliedToolResult:
|
|
"""Apply one backend tool result to a copied infrastructure snapshot."""
|
|
|
|
new_nodes = copy.deepcopy(state.get("nodes", []))
|
|
new_edges = copy.deepcopy(state.get("edges", []))
|
|
new_logs = list(state.get("logs", []))
|
|
node_ids = {node["id"] for node in new_nodes}
|
|
|
|
if data.get("success") is False and isinstance(data.get("error"), str):
|
|
error_message = data["error"]
|
|
new_logs.append(log_thought(state, "tool_node", error_message, "error"))
|
|
return {
|
|
"nodes": new_nodes,
|
|
"edges": new_edges,
|
|
"logs": new_logs,
|
|
"tool_error": error_message,
|
|
}
|
|
|
|
if "id" in data and "type" in data:
|
|
parent_id = data.get("parentId")
|
|
if parent_id and not any(
|
|
node["id"] == parent_id and node["type"] == "vpc" for node in new_nodes
|
|
):
|
|
error_message = f"Invalid vpc_id: {parent_id} does not exist"
|
|
new_logs.append(
|
|
log_thought(
|
|
state,
|
|
"tool_node",
|
|
error_message,
|
|
"error",
|
|
)
|
|
)
|
|
return {
|
|
"nodes": new_nodes,
|
|
"edges": new_edges,
|
|
"logs": new_logs,
|
|
"tool_error": error_message,
|
|
}
|
|
|
|
resource_type = data["type"]
|
|
new_node = {
|
|
"id": data["id"],
|
|
"type": resource_type,
|
|
"position": {"x": 0, "y": 0},
|
|
"config": data.get("config", {}),
|
|
"status": data.get("status", "healthy"),
|
|
"tier": data.get("tier", RESOURCE_TIER_MAP.get(resource_type, "compute")),
|
|
}
|
|
if parent_id:
|
|
new_node["parentId"] = parent_id
|
|
new_nodes.append(new_node)
|
|
location = f" inside VPC {parent_id}" if parent_id else ""
|
|
new_logs.append(
|
|
log_thought(
|
|
state,
|
|
"tool_node",
|
|
f"Added {resource_type} resource: {data['id']}{location}",
|
|
"success",
|
|
)
|
|
)
|
|
elif "source" in data and "target" in data:
|
|
missing_ids = [
|
|
resource_id
|
|
for resource_id in (data["source"], data["target"])
|
|
if resource_id not in node_ids
|
|
]
|
|
if missing_ids:
|
|
error_message = (
|
|
f"Cannot connect resources: {', '.join(missing_ids)} does not exist"
|
|
)
|
|
new_logs.append(
|
|
log_thought(
|
|
state,
|
|
"tool_node",
|
|
error_message,
|
|
"warning",
|
|
)
|
|
)
|
|
return {
|
|
"nodes": new_nodes,
|
|
"edges": new_edges,
|
|
"logs": new_logs,
|
|
"tool_error": error_message,
|
|
}
|
|
|
|
new_edges.append(
|
|
{"id": data["id"], "source": data["source"], "target": data["target"]}
|
|
)
|
|
new_logs.append(
|
|
log_thought(
|
|
state,
|
|
"tool_node",
|
|
f"Connected {data['source']} -> {data['target']}",
|
|
"success",
|
|
)
|
|
)
|
|
elif "removed" in data:
|
|
resource_id = data["removed"]
|
|
if any(node["id"] == resource_id for node in new_nodes):
|
|
new_nodes = [node for node in new_nodes if node["id"] != resource_id]
|
|
new_edges = [
|
|
edge
|
|
for edge in new_edges
|
|
if edge["source"] != resource_id and edge["target"] != resource_id
|
|
]
|
|
new_logs.append(
|
|
log_thought(state, "tool_node", f"Removed resource: {resource_id}")
|
|
)
|
|
else:
|
|
new_logs.append(
|
|
log_thought(
|
|
state,
|
|
"tool_node",
|
|
f"Resource {resource_id} not found (already removed)",
|
|
"warning",
|
|
)
|
|
)
|
|
elif "updated" in data:
|
|
resource_id = data["updated"]
|
|
if resource_id not in node_ids:
|
|
error_message = f"Cannot update {resource_id}: resource does not exist"
|
|
new_logs.append(
|
|
log_thought(
|
|
state,
|
|
"tool_node",
|
|
error_message,
|
|
"warning",
|
|
)
|
|
)
|
|
return {
|
|
"nodes": new_nodes,
|
|
"edges": new_edges,
|
|
"logs": new_logs,
|
|
"tool_error": error_message,
|
|
}
|
|
|
|
actual_type = next(
|
|
node["type"] for node in new_nodes if node["id"] == resource_id
|
|
)
|
|
requested_type = data.get("resource_type")
|
|
if requested_type != actual_type:
|
|
error_message = (
|
|
f"Cannot update {resource_id}: resource type is {actual_type}, "
|
|
f"not {requested_type}"
|
|
)
|
|
new_logs.append(log_thought(state, "tool_node", error_message, "error"))
|
|
return {
|
|
"nodes": new_nodes,
|
|
"edges": new_edges,
|
|
"logs": new_logs,
|
|
"tool_error": error_message,
|
|
}
|
|
|
|
new_config = data.get("config", {})
|
|
for node in new_nodes:
|
|
if node["id"] == resource_id:
|
|
node["config"] = {**node.get("config", {}), **new_config}
|
|
break
|
|
new_logs.append(
|
|
log_thought(state, "tool_node", f"Updated resource: {resource_id}")
|
|
)
|
|
elif "moved" in data:
|
|
resource_id = data["moved"]
|
|
target_vpc_id = data.get("vpc_id")
|
|
if resource_id not in node_ids:
|
|
error_message = f"Cannot move {resource_id}: resource does not exist"
|
|
new_logs.append(
|
|
log_thought(
|
|
state,
|
|
"tool_node",
|
|
error_message,
|
|
"warning",
|
|
)
|
|
)
|
|
return {
|
|
"nodes": new_nodes,
|
|
"edges": new_edges,
|
|
"logs": new_logs,
|
|
"tool_error": error_message,
|
|
}
|
|
|
|
if target_vpc_id and not any(
|
|
node["id"] == target_vpc_id and node["type"] == "vpc" for node in new_nodes
|
|
):
|
|
error_message = (
|
|
f"Cannot move {resource_id}: VPC {target_vpc_id} does not exist"
|
|
)
|
|
new_logs.append(
|
|
log_thought(
|
|
state,
|
|
"tool_node",
|
|
error_message,
|
|
"error",
|
|
)
|
|
)
|
|
return {
|
|
"nodes": new_nodes,
|
|
"edges": new_edges,
|
|
"logs": new_logs,
|
|
"tool_error": error_message,
|
|
}
|
|
|
|
for node in new_nodes:
|
|
if node["id"] != resource_id:
|
|
continue
|
|
if target_vpc_id:
|
|
node["parentId"] = target_vpc_id
|
|
message = f"Moved {resource_id} into VPC {target_vpc_id}"
|
|
log_type = "success"
|
|
else:
|
|
node.pop("parentId", None)
|
|
message = f"Removed {resource_id} from VPC"
|
|
log_type = "info"
|
|
new_logs.append(log_thought(state, "tool_node", message, log_type))
|
|
break
|
|
|
|
return {"nodes": new_nodes, "edges": new_edges, "logs": new_logs}
|
|
|
|
|
|
async def tool_node_wrapper(state: AgentState, config: RunnableConfig) -> Command[str]:
|
|
"""Run backend tools, then apply their structured results to agent state."""
|
|
|
|
result = await ToolNode(tools=backend_tools).ainvoke(state, config)
|
|
messages = result.get("messages", [])
|
|
updated = {
|
|
"nodes": copy.deepcopy(state.get("nodes", [])),
|
|
"edges": copy.deepcopy(state.get("edges", [])),
|
|
"logs": list(state.get("logs", [])),
|
|
}
|
|
tool_errors: list[str | None] = []
|
|
|
|
for message in messages:
|
|
if getattr(message, "status", None) == "error":
|
|
error_message = (
|
|
f"Invalid arguments for {getattr(message, 'name', 'backend tool')}: "
|
|
f"{getattr(message, 'content', 'validation failed')}"
|
|
)
|
|
updated["logs"].append(
|
|
log_thought(state, "tool_node", error_message, "error")
|
|
)
|
|
tool_errors.append(error_message)
|
|
continue
|
|
|
|
data = parse_tool_result(getattr(message, "content", message))
|
|
if data is None:
|
|
error_message = "Malformed backend tool result: expected a JSON object"
|
|
updated["logs"].append(
|
|
log_thought(
|
|
state,
|
|
"tool_node",
|
|
error_message,
|
|
"error",
|
|
)
|
|
)
|
|
tool_errors.append(error_message)
|
|
continue
|
|
applied = apply_tool_result({**state, **updated}, data)
|
|
tool_errors.append(applied.get("tool_error"))
|
|
updated = {
|
|
"nodes": applied["nodes"],
|
|
"edges": applied["edges"],
|
|
"logs": applied["logs"],
|
|
}
|
|
|
|
updated_cost = sum(calculate_resource_cost(node) for node in updated["nodes"])
|
|
|
|
# Append current state summary to tool messages so agent knows what exists
|
|
state_summary = generate_state_summary(updated["nodes"], updated["edges"])
|
|
enriched_messages = []
|
|
for msg, tool_error in zip(messages, tool_errors, strict=True):
|
|
if tool_error:
|
|
content = json.dumps({"success": False, "error": tool_error})
|
|
elif hasattr(msg, "content") and isinstance(msg.content, str):
|
|
content = msg.content
|
|
else:
|
|
enriched_messages.append(msg)
|
|
continue
|
|
|
|
if hasattr(msg, "content"):
|
|
enriched_msg = ToolMessage(
|
|
content=content + state_summary,
|
|
tool_call_id=getattr(msg, "tool_call_id", ""),
|
|
name=getattr(msg, "name", None),
|
|
)
|
|
enriched_messages.append(enriched_msg)
|
|
|
|
return Command(
|
|
goto="architect_node",
|
|
update={
|
|
"messages": enriched_messages,
|
|
"nodes": updated["nodes"],
|
|
"edges": updated["edges"],
|
|
"logs": updated["logs"],
|
|
"cost": updated_cost,
|
|
},
|
|
)
|
|
|
|
|
|
async def validate_node(state: AgentState, config: RunnableConfig) -> Command[str]:
|
|
"""
|
|
Validate the infrastructure design.
|
|
Checks for meaningful issues: orphaned nodes, missing connections, explicit misconfigurations.
|
|
"""
|
|
nodes = state.get("nodes", [])
|
|
edges = state.get("edges", [])
|
|
errors: list[ValidationResult] = []
|
|
|
|
new_logs = list(state.get("logs", []))
|
|
new_logs.append(
|
|
log_thought(state, "validate", "Running validation checks...", "info")
|
|
)
|
|
|
|
# Build a set of connected node IDs
|
|
connected_nodes = set()
|
|
for edge in edges:
|
|
connected_nodes.add(edge["source"])
|
|
connected_nodes.add(edge["target"])
|
|
|
|
# Build a set of VPC IDs for containment validation
|
|
vpc_ids = {node["id"] for node in nodes if node["type"] == "vpc"}
|
|
|
|
for node in nodes:
|
|
node_id = node["id"]
|
|
node_type = node["type"]
|
|
node_config = node.get("config", {})
|
|
|
|
# S3 validations - only warn about explicit public access
|
|
if node_type == "s3":
|
|
if node_config.get("access_level") == "public":
|
|
errors.append(
|
|
{
|
|
"level": "warning",
|
|
"message": f"S3 bucket {node_id} is publicly accessible",
|
|
"node_id": node_id,
|
|
}
|
|
)
|
|
|
|
# RDS validations - encryption explicitly disabled, or orphaned
|
|
elif node_type == "rds":
|
|
# Only warn if encryption is explicitly set to False
|
|
if node_config.get("encryption") is False:
|
|
errors.append(
|
|
{
|
|
"level": "warning",
|
|
"message": f"RDS instance {node_id} has encryption disabled",
|
|
"node_id": node_id,
|
|
}
|
|
)
|
|
# Orphaned RDS (not connected to anything)
|
|
if node_id not in connected_nodes:
|
|
errors.append(
|
|
{
|
|
"level": "warning",
|
|
"message": f"RDS instance {node_id} is not connected to any resource",
|
|
"node_id": node_id,
|
|
}
|
|
)
|
|
|
|
# EC2/Lambda orphan check - should be connected or inside a VPC
|
|
elif node_type in ("ec2", "lambda"):
|
|
parent_id = node.get("parentId")
|
|
if node_id not in connected_nodes and not parent_id:
|
|
errors.append(
|
|
{
|
|
"level": "warning",
|
|
"message": f"{node_type.upper()} {node_id} is orphaned (no connections or VPC)",
|
|
"node_id": node_id,
|
|
}
|
|
)
|
|
|
|
# Lambda memory validation (only if explicitly set too high)
|
|
if node_type == "lambda":
|
|
memory = node_config.get("memory", 128)
|
|
if memory > 3008:
|
|
errors.append(
|
|
{
|
|
"level": "warning",
|
|
"message": f"Lambda {node_id} has high memory ({memory}MB) - check if needed",
|
|
"node_id": node_id,
|
|
}
|
|
)
|
|
|
|
# Validate parentId references exist
|
|
parent_id = node.get("parentId")
|
|
if parent_id and parent_id not in vpc_ids:
|
|
errors.append(
|
|
{
|
|
"level": "error",
|
|
"message": f"Resource {node_id} references non-existent VPC: {parent_id}",
|
|
"node_id": node_id,
|
|
}
|
|
)
|
|
|
|
# Log validation results
|
|
if errors:
|
|
new_logs.append(
|
|
log_thought(
|
|
state,
|
|
"validate",
|
|
f"Found {len(errors)} validation issue(s)",
|
|
"warning",
|
|
)
|
|
)
|
|
else:
|
|
new_logs.append(
|
|
log_thought(state, "validate", "All validation checks passed!", "success")
|
|
)
|
|
|
|
return Command(
|
|
goto="cost_estimator_node",
|
|
update={
|
|
"validation_errors": errors,
|
|
"logs": new_logs,
|
|
},
|
|
)
|
|
|
|
|
|
async def cost_estimator_node(
|
|
state: AgentState, config: RunnableConfig
|
|
) -> Command[str]:
|
|
"""
|
|
Calculate the estimated monthly cost for all resources.
|
|
"""
|
|
nodes = state.get("nodes", [])
|
|
total_cost = sum(calculate_resource_cost(node) for node in nodes)
|
|
|
|
new_logs = list(state.get("logs", []))
|
|
new_logs.append(
|
|
log_thought(
|
|
state,
|
|
"cost_estimator",
|
|
f"Estimated monthly cost: ${total_cost:.2f}",
|
|
"info",
|
|
)
|
|
)
|
|
|
|
return Command(
|
|
goto=END,
|
|
update={
|
|
"cost": total_cost,
|
|
"status": "idle",
|
|
"logs": new_logs,
|
|
},
|
|
)
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Graph Definition
|
|
# -----------------------------------------------------------------------------
|
|
|
|
workflow = StateGraph(AgentState)
|
|
|
|
# Add nodes
|
|
workflow.add_node("architect_node", architect_node)
|
|
workflow.add_node("tool_node", tool_node_wrapper)
|
|
workflow.add_node("validate_node", validate_node)
|
|
workflow.add_node("cost_estimator_node", cost_estimator_node)
|
|
|
|
# Set entry point
|
|
workflow.set_entry_point("architect_node")
|
|
|
|
# Edges are handled via Command returns in each node
|
|
|
|
# MemorySaver preserves threads only for the lifetime of this agent process.
|
|
# Railway restarts discard it; durable persistence requires an external
|
|
# checkpointer and is intentionally outside this simulation demo's scope.
|
|
graph = workflow.compile(checkpointer=MemorySaver()).with_config(recursion_limit=75)
|