diagram-to-iac 0.7.0__py3-none-any.whl → 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- diagram_to_iac/__init__.py +10 -0
- diagram_to_iac/actions/__init__.py +7 -0
- diagram_to_iac/actions/git_entry.py +174 -0
- diagram_to_iac/actions/supervisor_entry.py +116 -0
- diagram_to_iac/actions/terraform_agent_entry.py +207 -0
- diagram_to_iac/agents/__init__.py +26 -0
- diagram_to_iac/agents/demonstrator_langgraph/__init__.py +10 -0
- diagram_to_iac/agents/demonstrator_langgraph/agent.py +826 -0
- diagram_to_iac/agents/git_langgraph/__init__.py +10 -0
- diagram_to_iac/agents/git_langgraph/agent.py +1018 -0
- diagram_to_iac/agents/git_langgraph/pr.py +146 -0
- diagram_to_iac/agents/hello_langgraph/__init__.py +9 -0
- diagram_to_iac/agents/hello_langgraph/agent.py +621 -0
- diagram_to_iac/agents/policy_agent/__init__.py +15 -0
- diagram_to_iac/agents/policy_agent/agent.py +507 -0
- diagram_to_iac/agents/policy_agent/integration_example.py +191 -0
- diagram_to_iac/agents/policy_agent/tools/__init__.py +14 -0
- diagram_to_iac/agents/policy_agent/tools/tfsec_tool.py +259 -0
- diagram_to_iac/agents/shell_langgraph/__init__.py +21 -0
- diagram_to_iac/agents/shell_langgraph/agent.py +122 -0
- diagram_to_iac/agents/shell_langgraph/detector.py +50 -0
- diagram_to_iac/agents/supervisor_langgraph/__init__.py +17 -0
- diagram_to_iac/agents/supervisor_langgraph/agent.py +1947 -0
- diagram_to_iac/agents/supervisor_langgraph/demonstrator.py +22 -0
- diagram_to_iac/agents/supervisor_langgraph/guards.py +23 -0
- diagram_to_iac/agents/supervisor_langgraph/pat_loop.py +49 -0
- diagram_to_iac/agents/supervisor_langgraph/router.py +9 -0
- diagram_to_iac/agents/terraform_langgraph/__init__.py +15 -0
- diagram_to_iac/agents/terraform_langgraph/agent.py +1216 -0
- diagram_to_iac/agents/terraform_langgraph/parser.py +76 -0
- diagram_to_iac/core/__init__.py +7 -0
- diagram_to_iac/core/agent_base.py +19 -0
- diagram_to_iac/core/enhanced_memory.py +302 -0
- diagram_to_iac/core/errors.py +4 -0
- diagram_to_iac/core/issue_tracker.py +49 -0
- diagram_to_iac/core/memory.py +132 -0
- diagram_to_iac/services/__init__.py +10 -0
- diagram_to_iac/services/observability.py +59 -0
- diagram_to_iac/services/step_summary.py +77 -0
- diagram_to_iac/tools/__init__.py +11 -0
- diagram_to_iac/tools/api_utils.py +108 -26
- diagram_to_iac/tools/git/__init__.py +45 -0
- diagram_to_iac/tools/git/git.py +956 -0
- diagram_to_iac/tools/hello/__init__.py +30 -0
- diagram_to_iac/tools/hello/cal_utils.py +31 -0
- diagram_to_iac/tools/hello/text_utils.py +97 -0
- diagram_to_iac/tools/llm_utils/__init__.py +20 -0
- diagram_to_iac/tools/llm_utils/anthropic_driver.py +87 -0
- diagram_to_iac/tools/llm_utils/base_driver.py +90 -0
- diagram_to_iac/tools/llm_utils/gemini_driver.py +89 -0
- diagram_to_iac/tools/llm_utils/openai_driver.py +93 -0
- diagram_to_iac/tools/llm_utils/router.py +303 -0
- diagram_to_iac/tools/sec_utils.py +4 -2
- diagram_to_iac/tools/shell/__init__.py +17 -0
- diagram_to_iac/tools/shell/shell.py +415 -0
- diagram_to_iac/tools/text_utils.py +277 -0
- diagram_to_iac/tools/tf/terraform.py +851 -0
- diagram_to_iac-0.8.0.dist-info/METADATA +99 -0
- diagram_to_iac-0.8.0.dist-info/RECORD +64 -0
- {diagram_to_iac-0.7.0.dist-info → diagram_to_iac-0.8.0.dist-info}/WHEEL +1 -1
- diagram_to_iac-0.8.0.dist-info/entry_points.txt +4 -0
- diagram_to_iac/agents/codegen_agent.py +0 -0
- diagram_to_iac/agents/consensus_agent.py +0 -0
- diagram_to_iac/agents/deployment_agent.py +0 -0
- diagram_to_iac/agents/github_agent.py +0 -0
- diagram_to_iac/agents/interpretation_agent.py +0 -0
- diagram_to_iac/agents/question_agent.py +0 -0
- diagram_to_iac/agents/supervisor.py +0 -0
- diagram_to_iac/agents/vision_agent.py +0 -0
- diagram_to_iac/core/config.py +0 -0
- diagram_to_iac/tools/cv_utils.py +0 -0
- diagram_to_iac/tools/gh_utils.py +0 -0
- diagram_to_iac/tools/tf_utils.py +0 -0
- diagram_to_iac-0.7.0.dist-info/METADATA +0 -16
- diagram_to_iac-0.7.0.dist-info/RECORD +0 -32
- diagram_to_iac-0.7.0.dist-info/entry_points.txt +0 -2
- {diagram_to_iac-0.7.0.dist-info → diagram_to_iac-0.8.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1216 @@
|
|
1
|
+
"""
|
2
|
+
Terraform Agent - Phase 3 Implementation (Error Handling & GitHub Issues Integration)
|
3
|
+
|
4
|
+
This is the complete TerraformAgent with full LLM-based routing, tool integration, and
|
5
|
+
GitHub issue creation for error handling, following the modular LangGraph pattern
|
6
|
+
established by the GitAgent. This agent handles:
|
7
|
+
- terraform init operations via tf_init_node
|
8
|
+
- terraform plan operations via tf_plan_node
|
9
|
+
- terraform apply operations via tf_apply_node
|
10
|
+
- GitHub issue creation for critical errors via open_issue_node
|
11
|
+
- Error handling and automatic issue reporting throughout the workflow
|
12
|
+
|
13
|
+
Architecture (following GitAgent pattern):
|
14
|
+
1. Planner LLM analyzes user input and emits routing tokens:
|
15
|
+
- "ROUTE_TO_TF_INIT" for terraform init operations
|
16
|
+
- "ROUTE_TO_TF_PLAN" for terraform plan operations
|
17
|
+
- "ROUTE_TO_TF_APPLY" for terraform apply operations
|
18
|
+
- "ROUTE_TO_OPEN_ISSUE" for GitHub issue creation
|
19
|
+
- "ROUTE_TO_END" when no action needed
|
20
|
+
2. Router function maps tokens to appropriate tool nodes
|
21
|
+
3. Tool nodes execute real Terraform operations using TerraformExecutor
|
22
|
+
4. State machine handles error paths and automatically creates GitHub issues for critical errors
|
23
|
+
5. Enhanced error detection identifies critical failures that warrant issue creation
|
24
|
+
|
25
|
+
Phase 3: Complete implementation with GitHub issue integration for comprehensive error handling
|
26
|
+
"""
|
27
|
+
|
28
|
+
import os
|
29
|
+
import uuid
|
30
|
+
import logging
|
31
|
+
import re
|
32
|
+
from typing import TypedDict, Annotated, Optional, List, Dict, Any
|
33
|
+
|
34
|
+
import yaml
|
35
|
+
from langchain_core.messages import HumanMessage, BaseMessage
|
36
|
+
from langgraph.graph import StateGraph, END
|
37
|
+
from langgraph.checkpoint.memory import MemorySaver
|
38
|
+
from pydantic import BaseModel, Field
|
39
|
+
|
40
|
+
# Import core infrastructure (following GitAgent pattern)
|
41
|
+
from diagram_to_iac.tools.llm_utils.router import get_llm, LLMRouter
|
42
|
+
from diagram_to_iac.core.agent_base import AgentBase
|
43
|
+
from diagram_to_iac.core.memory import create_memory, LangGraphMemoryAdapter
|
44
|
+
from diagram_to_iac.services.observability import log_event
|
45
|
+
from .parser import classify_terraform_error
|
46
|
+
|
47
|
+
|
48
|
+
# --- Pydantic Schemas for Agent I/O ---
|
49
|
+
class TerraformAgentInput(BaseModel):
|
50
|
+
"""Input schema for TerraformAgent operations."""
|
51
|
+
|
52
|
+
query: str = Field(
|
53
|
+
..., description="The Terraform DevOps request (init, plan, apply)"
|
54
|
+
)
|
55
|
+
thread_id: str | None = Field(
|
56
|
+
None, description="Optional thread ID for conversation history"
|
57
|
+
)
|
58
|
+
context: str | None = Field(
|
59
|
+
None, description="Optional context information for error reporting"
|
60
|
+
)
|
61
|
+
|
62
|
+
|
63
|
+
class TerraformAgentOutput(BaseModel):
|
64
|
+
"""Output schema for TerraformAgent operations."""
|
65
|
+
|
66
|
+
result: str = Field(..., description="The result of the Terraform operation")
|
67
|
+
thread_id: str = Field(..., description="Thread ID used for the conversation")
|
68
|
+
error_message: Optional[str] = Field(
|
69
|
+
None, description="Error message if the operation failed"
|
70
|
+
)
|
71
|
+
operation_type: Optional[str] = Field(
|
72
|
+
None, description="Type of operation performed (init, plan, apply)"
|
73
|
+
)
|
74
|
+
error_tags: Optional[list[str]] = Field(
|
75
|
+
None,
|
76
|
+
description="Classification tags describing the error, if any",
|
77
|
+
)
|
78
|
+
|
79
|
+
|
80
|
+
# --- Agent State Definition ---
|
81
|
+
class TerraformAgentState(TypedDict):
|
82
|
+
"""State definition for the TerraformAgent LangGraph."""
|
83
|
+
|
84
|
+
input_message: HumanMessage
|
85
|
+
tool_output: Annotated[list[BaseMessage], lambda x, y: x + y]
|
86
|
+
final_result: str
|
87
|
+
error_message: Optional[str]
|
88
|
+
operation_type: Optional[str]
|
89
|
+
error_tags: Optional[list[str]]
|
90
|
+
|
91
|
+
|
92
|
+
# --- Main Agent Class ---
|
93
|
+
class TerraformAgent(AgentBase):
|
94
|
+
"""
|
95
|
+
TerraformAgent is a LangGraph-based DevOps automation agent that handles:
|
96
|
+
- Terraform init operations via tf_init_node
|
97
|
+
- Terraform plan operations via tf_plan_node
|
98
|
+
- Terraform apply operations via tf_apply_node
|
99
|
+
- GitHub issue creation for critical errors via open_issue_node
|
100
|
+
|
101
|
+
Uses a light LLM planner for routing decisions and delegates to specialized tool nodes.
|
102
|
+
Follows the hybrid-agent architecture pattern established by GitAgent.
|
103
|
+
|
104
|
+
Phase 3: Complete implementation with LLM routing, comprehensive tool integration,
|
105
|
+
and GitHub issue creation for automatic error reporting.
|
106
|
+
"""
|
107
|
+
|
108
|
+
def __init__(self, config_path: str = None, memory_type: str = "persistent"):
|
109
|
+
"""
|
110
|
+
Initialize the TerraformAgent with configuration and tools.
|
111
|
+
|
112
|
+
Args:
|
113
|
+
config_path: Optional path to YAML configuration file
|
114
|
+
memory_type: Type of memory ("persistent", "memory", or "langgraph")
|
115
|
+
"""
|
116
|
+
# Configure logger for this agent instance
|
117
|
+
self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
|
118
|
+
if not logging.getLogger().hasHandlers():
|
119
|
+
logging.basicConfig(
|
120
|
+
level=logging.INFO,
|
121
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(threadName)s - %(message)s",
|
122
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
123
|
+
)
|
124
|
+
|
125
|
+
# Store memory type for tool initialization
|
126
|
+
self.memory_type = memory_type
|
127
|
+
|
128
|
+
# Load configuration
|
129
|
+
if config_path is None:
|
130
|
+
base_dir = os.path.dirname(os.path.abspath(__file__))
|
131
|
+
config_path = os.path.join(base_dir, "config.yaml")
|
132
|
+
self.logger.debug(f"Default config path set to: {config_path}")
|
133
|
+
|
134
|
+
try:
|
135
|
+
with open(config_path, "r") as f:
|
136
|
+
self.config = yaml.safe_load(f)
|
137
|
+
if self.config is None:
|
138
|
+
self.logger.warning(
|
139
|
+
f"Configuration file at {config_path} is empty. Using defaults."
|
140
|
+
)
|
141
|
+
self._set_default_config()
|
142
|
+
else:
|
143
|
+
self.logger.info(
|
144
|
+
f"Configuration loaded successfully from {config_path}"
|
145
|
+
)
|
146
|
+
if "terraform_token_env" not in self.config:
|
147
|
+
self.config["terraform_token_env"] = "TFE_TOKEN"
|
148
|
+
except FileNotFoundError:
|
149
|
+
self.logger.warning(
|
150
|
+
f"Configuration file not found at {config_path}. Using defaults."
|
151
|
+
)
|
152
|
+
self._set_default_config()
|
153
|
+
except yaml.YAMLError as e:
|
154
|
+
self.logger.error(
|
155
|
+
f"Error parsing YAML configuration: {e}. Using defaults.", exc_info=True
|
156
|
+
)
|
157
|
+
self._set_default_config()
|
158
|
+
|
159
|
+
# Initialize enhanced LLM router
|
160
|
+
self.llm_router = LLMRouter()
|
161
|
+
self.logger.info("Enhanced LLM router initialized")
|
162
|
+
|
163
|
+
# Initialize enhanced memory system
|
164
|
+
self.memory = create_memory(memory_type)
|
165
|
+
self.logger.info(
|
166
|
+
f"Enhanced memory system initialized: {type(self.memory).__name__}"
|
167
|
+
)
|
168
|
+
|
169
|
+
# Initialize checkpointer
|
170
|
+
self.checkpointer = MemorySaver()
|
171
|
+
self.logger.info("MemorySaver checkpointer initialized")
|
172
|
+
|
173
|
+
# Initialize tools (Phase 1 will implement actual Terraform tools)
|
174
|
+
self._initialize_tools()
|
175
|
+
|
176
|
+
# Build and compile the LangGraph
|
177
|
+
self.runnable = self._build_graph()
|
178
|
+
self.logger.info("TerraformAgent initialized successfully")
|
179
|
+
|
180
|
+
def _set_default_config(self):
|
181
|
+
"""Set default configuration values."""
|
182
|
+
self.logger.info("Setting default configuration for TerraformAgent")
|
183
|
+
self.config = {
|
184
|
+
"llm": {"model_name": "gpt-4o-mini", "temperature": 0.1},
|
185
|
+
"routing_keys": {
|
186
|
+
"terraform_init": "ROUTE_TO_TF_INIT",
|
187
|
+
"terraform_plan": "ROUTE_TO_TF_PLAN",
|
188
|
+
"terraform_apply": "ROUTE_TO_TF_APPLY",
|
189
|
+
"open_issue": "ROUTE_TO_OPEN_ISSUE",
|
190
|
+
"end": "ROUTE_TO_END",
|
191
|
+
},
|
192
|
+
"terraform_token_env": "TFE_TOKEN",
|
193
|
+
}
|
194
|
+
|
195
|
+
def _initialize_tools(self):
|
196
|
+
"""Initialize the Terraform tools following the established pattern."""
|
197
|
+
try:
|
198
|
+
# Import Terraform tools from our comprehensive implementation
|
199
|
+
from diagram_to_iac.tools.tf.terraform import (
|
200
|
+
TerraformExecutor,
|
201
|
+
terraform_init,
|
202
|
+
terraform_plan,
|
203
|
+
terraform_apply,
|
204
|
+
)
|
205
|
+
from diagram_to_iac.tools.shell import ShellExecutor, shell_exec
|
206
|
+
|
207
|
+
# Import GitHub tools from GitAgent for Phase 3 error handling
|
208
|
+
from diagram_to_iac.tools.git import GitExecutor, gh_open_issue
|
209
|
+
|
210
|
+
# Initialize executors
|
211
|
+
self.terraform_executor = TerraformExecutor(memory_type=self.memory_type)
|
212
|
+
self.shell_executor = ShellExecutor(memory_type=self.memory_type)
|
213
|
+
self.git_executor = GitExecutor(memory_type=self.memory_type)
|
214
|
+
|
215
|
+
# Register tools for easy access
|
216
|
+
self.tools = {
|
217
|
+
"terraform_init": terraform_init,
|
218
|
+
"terraform_plan": terraform_plan,
|
219
|
+
"terraform_apply": terraform_apply,
|
220
|
+
"shell_exec": shell_exec,
|
221
|
+
"gh_open_issue": gh_open_issue,
|
222
|
+
}
|
223
|
+
|
224
|
+
self.logger.info(f"Terraform tools initialized: {list(self.tools.keys())}")
|
225
|
+
|
226
|
+
except Exception as e:
|
227
|
+
self.logger.error(
|
228
|
+
f"Failed to initialize Terraform tools: {e}", exc_info=True
|
229
|
+
)
|
230
|
+
raise
|
231
|
+
|
232
|
+
def _planner_llm_node(self, state: TerraformAgentState):
|
233
|
+
"""
|
234
|
+
LLM planner node that analyzes input and decides routing.
|
235
|
+
Emits routing tokens based on the user's Terraform request.
|
236
|
+
|
237
|
+
Phase 2: Full LLM-based routing implementation following GitAgent pattern.
|
238
|
+
"""
|
239
|
+
# Get LLM configuration
|
240
|
+
llm_config = self.config.get("llm", {})
|
241
|
+
model_name = llm_config.get("model_name")
|
242
|
+
temperature = llm_config.get("temperature")
|
243
|
+
|
244
|
+
# Use enhanced LLM router following GitAgent pattern
|
245
|
+
try:
|
246
|
+
if model_name is not None or temperature is not None:
|
247
|
+
actual_model_name = (
|
248
|
+
model_name if model_name is not None else "gpt-4o-mini"
|
249
|
+
)
|
250
|
+
actual_temperature = temperature if temperature is not None else 0.1
|
251
|
+
self.logger.debug(
|
252
|
+
f"Terraform planner using LLM: {actual_model_name}, Temp: {actual_temperature}"
|
253
|
+
)
|
254
|
+
|
255
|
+
llm = self.llm_router.get_llm(
|
256
|
+
model_name=actual_model_name,
|
257
|
+
temperature=actual_temperature,
|
258
|
+
agent_name="terraform_agent",
|
259
|
+
)
|
260
|
+
else:
|
261
|
+
self.logger.debug(
|
262
|
+
"Terraform planner using agent-specific LLM configuration"
|
263
|
+
)
|
264
|
+
llm = self.llm_router.get_llm_for_agent("terraform_agent")
|
265
|
+
except Exception as e:
|
266
|
+
self.logger.error(
|
267
|
+
f"Failed to get LLM from router: {e}. Falling back to basic get_llm."
|
268
|
+
)
|
269
|
+
llm = get_llm(model_name=model_name, temperature=temperature)
|
270
|
+
|
271
|
+
# Store conversation in memory
|
272
|
+
query_content = state["input_message"].content
|
273
|
+
self.memory.add_to_conversation(
|
274
|
+
"user", query_content, {"agent": "terraform_agent", "node": "planner"}
|
275
|
+
)
|
276
|
+
|
277
|
+
try:
|
278
|
+
self.logger.debug(f"Terraform planner LLM input: {query_content}")
|
279
|
+
|
280
|
+
# Build the Terraform-specific analysis prompt
|
281
|
+
analysis_prompt_template = self.config.get("prompts", {}).get(
|
282
|
+
"planner_prompt",
|
283
|
+
"""
|
284
|
+
User input: "{user_input}"
|
285
|
+
|
286
|
+
Analyze this Terraform/Infrastructure request and determine the appropriate action:
|
287
|
+
1. If requesting to initialize Terraform (keywords: 'init', 'initialize', 'setup terraform'), respond with "{route_tf_init}"
|
288
|
+
2. If requesting to create/preview Terraform plan (keywords: 'plan', 'preview', 'show changes'), respond with "{route_tf_plan}"
|
289
|
+
3. If requesting to apply/deploy Terraform (keywords: 'apply', 'deploy', 'provision', 'create infrastructure'), respond with "{route_tf_apply}"
|
290
|
+
4. If requesting to create a GitHub issue for errors (keywords: 'open issue', 'create issue', 'report error', 'file bug'), respond with "{route_open_issue}"
|
291
|
+
5. If the request is complete or no action needed, respond with "{route_end}"
|
292
|
+
|
293
|
+
Important: Only use routing tokens if the input contains actionable Terraform infrastructure requests or error reporting requests.
|
294
|
+
""",
|
295
|
+
)
|
296
|
+
|
297
|
+
routing_keys = self.config.get(
|
298
|
+
"routing_keys",
|
299
|
+
{
|
300
|
+
"terraform_init": "ROUTE_TO_TF_INIT",
|
301
|
+
"terraform_plan": "ROUTE_TO_TF_PLAN",
|
302
|
+
"terraform_apply": "ROUTE_TO_TF_APPLY",
|
303
|
+
"open_issue": "ROUTE_TO_OPEN_ISSUE",
|
304
|
+
"end": "ROUTE_TO_END",
|
305
|
+
},
|
306
|
+
)
|
307
|
+
|
308
|
+
analysis_prompt = analysis_prompt_template.format(
|
309
|
+
user_input=query_content,
|
310
|
+
route_tf_init=routing_keys["terraform_init"],
|
311
|
+
route_tf_plan=routing_keys["terraform_plan"],
|
312
|
+
route_tf_apply=routing_keys["terraform_apply"],
|
313
|
+
route_open_issue=routing_keys["open_issue"],
|
314
|
+
route_end=routing_keys["end"],
|
315
|
+
)
|
316
|
+
|
317
|
+
self.logger.debug(f"Terraform planner LLM prompt: {analysis_prompt}")
|
318
|
+
|
319
|
+
response = llm.invoke([HumanMessage(content=analysis_prompt)])
|
320
|
+
self.logger.debug(f"Terraform planner LLM response: {response.content}")
|
321
|
+
response_content = response.content.strip()
|
322
|
+
|
323
|
+
# Store LLM response in memory
|
324
|
+
self.memory.add_to_conversation(
|
325
|
+
"assistant",
|
326
|
+
response_content,
|
327
|
+
{"agent": "terraform_agent", "node": "planner", "model": model_name},
|
328
|
+
)
|
329
|
+
|
330
|
+
# Determine routing based on response content
|
331
|
+
new_state_update = {}
|
332
|
+
if routing_keys["terraform_init"] in response_content:
|
333
|
+
new_state_update = {
|
334
|
+
"final_result": "route_to_tf_init",
|
335
|
+
"operation_type": "terraform_init",
|
336
|
+
"error_message": None,
|
337
|
+
}
|
338
|
+
elif routing_keys["terraform_plan"] in response_content:
|
339
|
+
new_state_update = {
|
340
|
+
"final_result": "route_to_tf_plan",
|
341
|
+
"operation_type": "terraform_plan",
|
342
|
+
"error_message": None,
|
343
|
+
}
|
344
|
+
elif routing_keys["terraform_apply"] in response_content:
|
345
|
+
new_state_update = {
|
346
|
+
"final_result": "route_to_tf_apply",
|
347
|
+
"operation_type": "terraform_apply",
|
348
|
+
"error_message": None,
|
349
|
+
}
|
350
|
+
elif routing_keys["open_issue"] in response_content:
|
351
|
+
new_state_update = {
|
352
|
+
"final_result": "route_to_open_issue",
|
353
|
+
"operation_type": "open_issue",
|
354
|
+
"error_message": None,
|
355
|
+
}
|
356
|
+
elif routing_keys["end"] in response_content:
|
357
|
+
# Direct answer or route to end
|
358
|
+
new_state_update = {
|
359
|
+
"final_result": response.content,
|
360
|
+
"operation_type": "direct_answer",
|
361
|
+
"error_message": None,
|
362
|
+
}
|
363
|
+
else:
|
364
|
+
# Default if no specific route token is found
|
365
|
+
new_state_update = {
|
366
|
+
"final_result": response.content, # Treat as direct answer
|
367
|
+
"operation_type": "direct_answer",
|
368
|
+
"error_message": None,
|
369
|
+
}
|
370
|
+
|
371
|
+
self.logger.info(
|
372
|
+
f"Terraform planner decision: {new_state_update.get('final_result', 'N/A')}"
|
373
|
+
)
|
374
|
+
return new_state_update
|
375
|
+
|
376
|
+
except Exception as e:
|
377
|
+
self.logger.error(f"LLM error in terraform planner: {e}", exc_info=True)
|
378
|
+
self.memory.add_to_conversation(
|
379
|
+
"system",
|
380
|
+
f"Error in planner: {str(e)}",
|
381
|
+
{"agent": "terraform_agent", "node": "planner", "error": True},
|
382
|
+
)
|
383
|
+
return {
|
384
|
+
"final_result": "Sorry, I encountered an issue processing your Terraform request.",
|
385
|
+
"error_message": str(e),
|
386
|
+
"operation_type": "error",
|
387
|
+
}
|
388
|
+
|
389
|
+
def _should_create_github_issue(self, error_result: str) -> bool:
|
390
|
+
"""
|
391
|
+
Determine if an error should trigger GitHub issue creation.
|
392
|
+
|
393
|
+
Phase 3: Error detection logic for automatic issue creation.
|
394
|
+
"""
|
395
|
+
critical_error_patterns = [
|
396
|
+
"authentication failed",
|
397
|
+
"permission denied",
|
398
|
+
"terraform crash",
|
399
|
+
"backend configuration",
|
400
|
+
"provider configuration error",
|
401
|
+
"module not found",
|
402
|
+
"workspace not found",
|
403
|
+
"invalid credentials",
|
404
|
+
"terraform cloud error",
|
405
|
+
"backend initialization failed",
|
406
|
+
# Additional patterns for test compatibility
|
407
|
+
"failed to authenticate with terraform cloud",
|
408
|
+
"backend configuration failed",
|
409
|
+
"please report this bug",
|
410
|
+
"invalid credentials for aws provider",
|
411
|
+
"resource already exists",
|
412
|
+
"panic: runtime error",
|
413
|
+
]
|
414
|
+
|
415
|
+
error_lower = error_result.lower()
|
416
|
+
for pattern in critical_error_patterns:
|
417
|
+
if pattern in error_lower:
|
418
|
+
self.logger.info(f"Critical error detected: {pattern}")
|
419
|
+
return True
|
420
|
+
|
421
|
+
return False
|
422
|
+
|
423
|
+
def _check_auth_token(self) -> tuple[bool, str]:
|
424
|
+
"""Check that required Terraform auth token environment variable exists."""
|
425
|
+
env_name = self.config.get("terraform_token_env", "TFE_TOKEN")
|
426
|
+
token = os.environ.get(env_name)
|
427
|
+
if not token:
|
428
|
+
error_msg = f"ERROR_MISSING_TERRAFORM_TOKEN: environment variable '{env_name}' not set"
|
429
|
+
self.logger.error(error_msg)
|
430
|
+
return False, env_name
|
431
|
+
return True, env_name
|
432
|
+
|
433
|
+
def _route_after_planner(self, state: TerraformAgentState):
|
434
|
+
"""
|
435
|
+
Router function that determines the next node based on planner output.
|
436
|
+
Maps routing tokens to appropriate tool nodes or END.
|
437
|
+
|
438
|
+
Phase 3: Enhanced routing with GitHub issue integration for error handling.
|
439
|
+
"""
|
440
|
+
self.logger.debug(
|
441
|
+
f"Terraform routing after planner. State: {state.get('final_result')}, error: {state.get('error_message')}"
|
442
|
+
)
|
443
|
+
|
444
|
+
if state.get("error_message"):
|
445
|
+
self.logger.warning(
|
446
|
+
f"Error detected in terraform planner, routing to END: {state['error_message']}"
|
447
|
+
)
|
448
|
+
return END
|
449
|
+
|
450
|
+
final_result = state.get("final_result", "")
|
451
|
+
|
452
|
+
# Route based on planner decision
|
453
|
+
if final_result == "route_to_tf_init":
|
454
|
+
return "tf_init_node"
|
455
|
+
elif final_result == "route_to_tf_plan":
|
456
|
+
return "tf_plan_node"
|
457
|
+
elif final_result == "route_to_tf_apply":
|
458
|
+
return "tf_apply_node"
|
459
|
+
elif final_result == "route_to_open_issue":
|
460
|
+
return "open_issue_node"
|
461
|
+
else:
|
462
|
+
return END
|
463
|
+
|
464
|
+
def _tf_init_node(self, state: TerraformAgentState):
|
465
|
+
"""
|
466
|
+
Terraform init tool node that handles terraform initialization operations.
|
467
|
+
|
468
|
+
Phase 2: Full implementation using TerraformExecutor.
|
469
|
+
"""
|
470
|
+
self.logger.info(
|
471
|
+
f"Terraform init node invoked for: {state['input_message'].content}"
|
472
|
+
)
|
473
|
+
|
474
|
+
try:
|
475
|
+
text_content = state["input_message"].content
|
476
|
+
|
477
|
+
# Store tool invocation in memory
|
478
|
+
self.memory.add_to_conversation(
|
479
|
+
"system",
|
480
|
+
f"Terraform init tool invoked",
|
481
|
+
{"agent": "terraform_agent", "node": "tf_init", "input": text_content},
|
482
|
+
)
|
483
|
+
|
484
|
+
# Extract repository path from the input or use default workspace
|
485
|
+
# In a real implementation, you might use more sophisticated parsing
|
486
|
+
import re
|
487
|
+
|
488
|
+
path_pattern = r"(?:in|from|at)\s+([^\s]+)"
|
489
|
+
paths = re.findall(path_pattern, text_content)
|
490
|
+
|
491
|
+
# Default to current workspace if no path specified
|
492
|
+
repo_path = paths[0] if paths else "/workspace"
|
493
|
+
|
494
|
+
# Token check is required before proceeding with Terraform operations
|
495
|
+
token_valid, token_env = self._check_auth_token()
|
496
|
+
if not token_valid:
|
497
|
+
error_msg = f"❌ Terraform Cloud token required: Please set the {token_env} environment variable to proceed with Terraform operations."
|
498
|
+
self.logger.error(error_msg)
|
499
|
+
self.memory.add_to_conversation(
|
500
|
+
"assistant",
|
501
|
+
error_msg,
|
502
|
+
{"agent": "terraform_agent", "node": "tf_init", "result": "error", "requires_user_action": True},
|
503
|
+
)
|
504
|
+
return {
|
505
|
+
"final_result": error_msg,
|
506
|
+
"operation_type": "terraform_init",
|
507
|
+
"error_message": error_msg,
|
508
|
+
"error_tags": ["missing_token"],
|
509
|
+
}
|
510
|
+
|
511
|
+
# Use the terraform_init tool
|
512
|
+
result = self.tools["terraform_init"].invoke(
|
513
|
+
{"repo_path": repo_path, "upgrade": "upgrade" in text_content.lower()}
|
514
|
+
)
|
515
|
+
|
516
|
+
if "✅" in result:
|
517
|
+
success_msg = f"Terraform init completed successfully: {result}"
|
518
|
+
self.logger.info(success_msg)
|
519
|
+
self.memory.add_to_conversation(
|
520
|
+
"assistant",
|
521
|
+
success_msg,
|
522
|
+
{
|
523
|
+
"agent": "terraform_agent",
|
524
|
+
"node": "tf_init",
|
525
|
+
"result": "success",
|
526
|
+
},
|
527
|
+
)
|
528
|
+
return {
|
529
|
+
"final_result": result,
|
530
|
+
"operation_type": "terraform_init",
|
531
|
+
"error_message": None,
|
532
|
+
"error_tags": None,
|
533
|
+
}
|
534
|
+
else:
|
535
|
+
error_msg = f"Terraform init failed: {result}"
|
536
|
+
self.logger.error(error_msg)
|
537
|
+
self.memory.add_to_conversation(
|
538
|
+
"assistant",
|
539
|
+
error_msg,
|
540
|
+
{"agent": "terraform_agent", "node": "tf_init", "result": "error"},
|
541
|
+
)
|
542
|
+
|
543
|
+
# Phase 3: Check if this error should trigger GitHub issue creation
|
544
|
+
if self._should_create_github_issue(result):
|
545
|
+
# Store error context for potential issue creation
|
546
|
+
error_context = {
|
547
|
+
"operation": "terraform_init",
|
548
|
+
"error": result,
|
549
|
+
"repo_path": repo_path,
|
550
|
+
"command": "terraform init",
|
551
|
+
}
|
552
|
+
self.memory.add_to_conversation(
|
553
|
+
"system",
|
554
|
+
f"Critical Terraform init error detected, consider creating GitHub issue",
|
555
|
+
error_context,
|
556
|
+
)
|
557
|
+
|
558
|
+
return {
|
559
|
+
"final_result": result,
|
560
|
+
"operation_type": "terraform_init",
|
561
|
+
"error_message": result,
|
562
|
+
"error_tags": list(classify_terraform_error(result)),
|
563
|
+
}
|
564
|
+
|
565
|
+
except Exception as e:
|
566
|
+
error_msg = f"Terraform init node error: {str(e)}"
|
567
|
+
self.logger.error(error_msg, exc_info=True)
|
568
|
+
|
569
|
+
# Phase 3: Check if this exception should trigger GitHub issue creation
|
570
|
+
if self._should_create_github_issue(str(e)):
|
571
|
+
error_context = {
|
572
|
+
"operation": "terraform_init",
|
573
|
+
"error": str(e),
|
574
|
+
"exception": True,
|
575
|
+
"command": "terraform init",
|
576
|
+
}
|
577
|
+
self.memory.add_to_conversation(
|
578
|
+
"system",
|
579
|
+
f"Critical Terraform init exception detected, consider creating GitHub issue",
|
580
|
+
error_context,
|
581
|
+
)
|
582
|
+
|
583
|
+
self.memory.add_to_conversation(
|
584
|
+
"system",
|
585
|
+
error_msg,
|
586
|
+
{"agent": "terraform_agent", "node": "tf_init", "error": True},
|
587
|
+
)
|
588
|
+
return {
|
589
|
+
"final_result": "Sorry, I encountered an issue running terraform init.",
|
590
|
+
"error_message": str(e),
|
591
|
+
"operation_type": "terraform_init",
|
592
|
+
"error_tags": list(classify_terraform_error(str(e))),
|
593
|
+
}
|
594
|
+
|
595
|
+
def _tf_plan_node(self, state: TerraformAgentState):
|
596
|
+
"""
|
597
|
+
Terraform plan tool node that handles terraform plan operations.
|
598
|
+
|
599
|
+
Phase 2: Full implementation using TerraformExecutor.
|
600
|
+
"""
|
601
|
+
self.logger.info(
|
602
|
+
f"Terraform plan node invoked for: {state['input_message'].content}"
|
603
|
+
)
|
604
|
+
|
605
|
+
try:
|
606
|
+
text_content = state["input_message"].content
|
607
|
+
|
608
|
+
# Store tool invocation in memory
|
609
|
+
self.memory.add_to_conversation(
|
610
|
+
"system",
|
611
|
+
f"Terraform plan tool invoked",
|
612
|
+
{"agent": "terraform_agent", "node": "tf_plan", "input": text_content},
|
613
|
+
)
|
614
|
+
|
615
|
+
# Extract repository path from the input or use default workspace
|
616
|
+
import re
|
617
|
+
|
618
|
+
path_pattern = r"(?:in|from|at)\s+([^\s]+)"
|
619
|
+
paths = re.findall(path_pattern, text_content)
|
620
|
+
|
621
|
+
# Default to current workspace if no path specified
|
622
|
+
repo_path = paths[0] if paths else "/workspace"
|
623
|
+
|
624
|
+
# Check for destroy flag
|
625
|
+
destroy = "destroy" in text_content.lower()
|
626
|
+
|
627
|
+
# Token check is deferred to the Terraform execution itself.
|
628
|
+
# _check_auth_token() still logs an error if called, but does not block.
|
629
|
+
self._check_auth_token()
|
630
|
+
|
631
|
+
# Use the terraform_plan tool
|
632
|
+
result = self.tools["terraform_plan"].invoke(
|
633
|
+
{"repo_path": repo_path, "destroy": destroy}
|
634
|
+
)
|
635
|
+
|
636
|
+
if "✅" in result:
|
637
|
+
success_msg = f"Terraform plan completed successfully: {result}"
|
638
|
+
self.logger.info(success_msg)
|
639
|
+
self.memory.add_to_conversation(
|
640
|
+
"assistant",
|
641
|
+
success_msg,
|
642
|
+
{
|
643
|
+
"agent": "terraform_agent",
|
644
|
+
"node": "tf_plan",
|
645
|
+
"result": "success",
|
646
|
+
},
|
647
|
+
)
|
648
|
+
return {
|
649
|
+
"final_result": result,
|
650
|
+
"operation_type": "terraform_plan",
|
651
|
+
"error_message": None,
|
652
|
+
"error_tags": None,
|
653
|
+
}
|
654
|
+
else:
|
655
|
+
error_msg = f"Terraform plan failed: {result}"
|
656
|
+
self.logger.error(error_msg)
|
657
|
+
self.memory.add_to_conversation(
|
658
|
+
"assistant",
|
659
|
+
error_msg,
|
660
|
+
{"agent": "terraform_agent", "node": "tf_plan", "result": "error"},
|
661
|
+
)
|
662
|
+
|
663
|
+
# Phase 3: Check if this error should trigger GitHub issue creation
|
664
|
+
if self._should_create_github_issue(result):
|
665
|
+
error_context = {
|
666
|
+
"operation": "terraform_plan",
|
667
|
+
"error": result,
|
668
|
+
"repo_path": repo_path,
|
669
|
+
"command": "terraform plan",
|
670
|
+
}
|
671
|
+
self.memory.add_to_conversation(
|
672
|
+
"system",
|
673
|
+
f"Critical Terraform plan error detected, consider creating GitHub issue",
|
674
|
+
error_context,
|
675
|
+
)
|
676
|
+
|
677
|
+
return {
|
678
|
+
"final_result": result,
|
679
|
+
"operation_type": "terraform_plan",
|
680
|
+
"error_message": result,
|
681
|
+
"error_tags": list(classify_terraform_error(result)),
|
682
|
+
}
|
683
|
+
|
684
|
+
except Exception as e:
|
685
|
+
error_msg = f"Terraform plan node error: {str(e)}"
|
686
|
+
self.logger.error(error_msg, exc_info=True)
|
687
|
+
|
688
|
+
# Phase 3: Check if this exception should trigger GitHub issue creation
|
689
|
+
if self._should_create_github_issue(str(e)):
|
690
|
+
error_context = {
|
691
|
+
"operation": "terraform_plan",
|
692
|
+
"error": str(e),
|
693
|
+
"exception": True,
|
694
|
+
"command": "terraform plan",
|
695
|
+
}
|
696
|
+
self.memory.add_to_conversation(
|
697
|
+
"system",
|
698
|
+
f"Critical Terraform plan exception detected, consider creating GitHub issue",
|
699
|
+
error_context,
|
700
|
+
)
|
701
|
+
|
702
|
+
self.memory.add_to_conversation(
|
703
|
+
"system",
|
704
|
+
error_msg,
|
705
|
+
{"agent": "terraform_agent", "node": "tf_plan", "error": True},
|
706
|
+
)
|
707
|
+
return {
|
708
|
+
"final_result": "Sorry, I encountered an issue running terraform plan.",
|
709
|
+
"error_message": str(e),
|
710
|
+
"operation_type": "terraform_plan",
|
711
|
+
"error_tags": list(classify_terraform_error(str(e))),
|
712
|
+
}
|
713
|
+
|
714
|
+
def _tf_apply_node(self, state: TerraformAgentState):
|
715
|
+
"""
|
716
|
+
Terraform apply tool node that handles terraform apply operations.
|
717
|
+
|
718
|
+
Phase 2: Full implementation using TerraformExecutor.
|
719
|
+
"""
|
720
|
+
self.logger.info(
|
721
|
+
f"Terraform apply node invoked for: {state['input_message'].content}"
|
722
|
+
)
|
723
|
+
|
724
|
+
try:
|
725
|
+
text_content = state["input_message"].content
|
726
|
+
|
727
|
+
# Store tool invocation in memory
|
728
|
+
self.memory.add_to_conversation(
|
729
|
+
"system",
|
730
|
+
f"Terraform apply tool invoked",
|
731
|
+
{"agent": "terraform_agent", "node": "tf_apply", "input": text_content},
|
732
|
+
)
|
733
|
+
|
734
|
+
# Extract repository path from the input or use default workspace
|
735
|
+
import re
|
736
|
+
|
737
|
+
path_pattern = r"(?:in|from|at)\s+([^\s]+)"
|
738
|
+
paths = re.findall(path_pattern, text_content)
|
739
|
+
|
740
|
+
# Default to current workspace if no path specified
|
741
|
+
repo_path = paths[0] if paths else "/workspace"
|
742
|
+
|
743
|
+
# Check for auto-approve flag (default to True for automation)
|
744
|
+
auto_approve = "no-auto-approve" not in text_content.lower()
|
745
|
+
|
746
|
+
# Token check is deferred to the Terraform execution itself.
|
747
|
+
# _check_auth_token() still logs an error if called, but does not block.
|
748
|
+
self._check_auth_token()
|
749
|
+
|
750
|
+
# Use the terraform_apply tool
|
751
|
+
result = self.tools["terraform_apply"].invoke(
|
752
|
+
{"repo_path": repo_path, "auto_approve": auto_approve}
|
753
|
+
)
|
754
|
+
|
755
|
+
if "✅" in result:
|
756
|
+
success_msg = f"Terraform apply completed successfully: {result}"
|
757
|
+
self.logger.info(success_msg)
|
758
|
+
self.memory.add_to_conversation(
|
759
|
+
"assistant",
|
760
|
+
success_msg,
|
761
|
+
{
|
762
|
+
"agent": "terraform_agent",
|
763
|
+
"node": "tf_apply",
|
764
|
+
"result": "success",
|
765
|
+
},
|
766
|
+
)
|
767
|
+
return {
|
768
|
+
"final_result": result,
|
769
|
+
"operation_type": "terraform_apply",
|
770
|
+
"error_message": None,
|
771
|
+
"error_tags": None,
|
772
|
+
}
|
773
|
+
else:
|
774
|
+
error_msg = f"Terraform apply failed: {result}"
|
775
|
+
self.logger.error(error_msg)
|
776
|
+
self.memory.add_to_conversation(
|
777
|
+
"assistant",
|
778
|
+
error_msg,
|
779
|
+
{"agent": "terraform_agent", "node": "tf_apply", "result": "error"},
|
780
|
+
)
|
781
|
+
|
782
|
+
# Phase 3: Check if this error should trigger GitHub issue creation
|
783
|
+
if self._should_create_github_issue(result):
|
784
|
+
error_context = {
|
785
|
+
"operation": "terraform_apply",
|
786
|
+
"error": result,
|
787
|
+
"repo_path": repo_path,
|
788
|
+
"command": "terraform apply",
|
789
|
+
}
|
790
|
+
self.memory.add_to_conversation(
|
791
|
+
"system",
|
792
|
+
f"Critical Terraform apply error detected, consider creating GitHub issue",
|
793
|
+
error_context,
|
794
|
+
)
|
795
|
+
|
796
|
+
return {
|
797
|
+
"final_result": result,
|
798
|
+
"operation_type": "terraform_apply",
|
799
|
+
"error_message": result,
|
800
|
+
"error_tags": list(classify_terraform_error(result)),
|
801
|
+
}
|
802
|
+
|
803
|
+
except Exception as e:
|
804
|
+
error_msg = f"Terraform apply node error: {str(e)}"
|
805
|
+
self.logger.error(error_msg, exc_info=True)
|
806
|
+
|
807
|
+
# Phase 3: Check if this exception should trigger GitHub issue creation
|
808
|
+
if self._should_create_github_issue(str(e)):
|
809
|
+
error_context = {
|
810
|
+
"operation": "terraform_apply",
|
811
|
+
"error": str(e),
|
812
|
+
"exception": True,
|
813
|
+
"command": "terraform apply",
|
814
|
+
}
|
815
|
+
self.memory.add_to_conversation(
|
816
|
+
"system",
|
817
|
+
f"Critical Terraform apply exception detected, consider creating GitHub issue",
|
818
|
+
error_context,
|
819
|
+
)
|
820
|
+
|
821
|
+
self.memory.add_to_conversation(
|
822
|
+
"system",
|
823
|
+
error_msg,
|
824
|
+
{"agent": "terraform_agent", "node": "tf_apply", "error": True},
|
825
|
+
)
|
826
|
+
return {
|
827
|
+
"final_result": "Sorry, I encountered an issue running terraform apply.",
|
828
|
+
"error_message": str(e),
|
829
|
+
"operation_type": "terraform_apply",
|
830
|
+
"error_tags": list(classify_terraform_error(str(e))),
|
831
|
+
}
|
832
|
+
|
833
|
+
def _open_issue_node(self, state: TerraformAgentState):
|
834
|
+
"""
|
835
|
+
GitHub issue tool node that handles issue creation for Terraform errors.
|
836
|
+
|
837
|
+
Phase 3: Error handling integration following GitAgent pattern.
|
838
|
+
"""
|
839
|
+
self.logger.info(
|
840
|
+
f"Open issue node invoked for: {state['input_message'].content}"
|
841
|
+
)
|
842
|
+
|
843
|
+
try:
|
844
|
+
text_content = state["input_message"].content
|
845
|
+
|
846
|
+
# Store tool invocation in memory
|
847
|
+
self.memory.add_to_conversation(
|
848
|
+
"system",
|
849
|
+
f"GitHub issue tool invoked",
|
850
|
+
{
|
851
|
+
"agent": "terraform_agent",
|
852
|
+
"node": "open_issue",
|
853
|
+
"input": text_content,
|
854
|
+
},
|
855
|
+
)
|
856
|
+
|
857
|
+
# Extract repository and issue details from input or use defaults for Terraform errors
|
858
|
+
import re
|
859
|
+
|
860
|
+
repo_pattern = r"(?:in|for|on)\s+(\w+/\w+)"
|
861
|
+
title_pattern = r"(?:issue|bug|problem|error):\s*(.+?)(?:\.|$)"
|
862
|
+
|
863
|
+
repo_match = re.search(repo_pattern, text_content, re.IGNORECASE)
|
864
|
+
title_match = re.search(title_pattern, text_content, re.IGNORECASE)
|
865
|
+
|
866
|
+
# Use defaults for Terraform-specific errors if not specified
|
867
|
+
if not repo_match:
|
868
|
+
# Default to a placeholder repo for terraform issues
|
869
|
+
repo = "terraform/infrastructure"
|
870
|
+
self.logger.info(
|
871
|
+
"No repository specified, using default: terraform/infrastructure"
|
872
|
+
)
|
873
|
+
else:
|
874
|
+
repo = repo_match.group(1)
|
875
|
+
|
876
|
+
if not title_match:
|
877
|
+
# Create a default title from the content
|
878
|
+
title = f"Terraform Error: {text_content[:50]}..."
|
879
|
+
self.logger.info(f"No explicit title found, using default: {title}")
|
880
|
+
else:
|
881
|
+
title = title_match.group(1)
|
882
|
+
|
883
|
+
# Build issue body with context from memory if available
|
884
|
+
body = f"Terraform Agent Error Report:\n\n{text_content}\n\n"
|
885
|
+
|
886
|
+
# Include previous error context from memory
|
887
|
+
conversation_history = self.memory.get_conversation_history()
|
888
|
+
error_context = []
|
889
|
+
for entry in conversation_history[-10:]: # Look at last 10 entries
|
890
|
+
if (
|
891
|
+
entry.get("metadata", {}).get("error")
|
892
|
+
or "error" in entry.get("content", "").lower()
|
893
|
+
or "failed" in entry.get("content", "").lower()
|
894
|
+
):
|
895
|
+
error_context.append(entry.get("content", ""))
|
896
|
+
|
897
|
+
if error_context:
|
898
|
+
body += "Previous Error Context:\n"
|
899
|
+
for context in error_context[-3:]: # Include last 3 error contexts
|
900
|
+
body += f"- {context}\n"
|
901
|
+
body += "\n"
|
902
|
+
|
903
|
+
body += "Generated automatically by TerraformAgent."
|
904
|
+
|
905
|
+
# Use the gh_open_issue tool with correct parameter name
|
906
|
+
result = self.tools["gh_open_issue"].invoke(
|
907
|
+
{
|
908
|
+
"repo": repo, # Fixed: use 'repo' instead of 'repo_url'
|
909
|
+
"title": title,
|
910
|
+
"body": body,
|
911
|
+
}
|
912
|
+
)
|
913
|
+
|
914
|
+
# Store successful result in memory
|
915
|
+
self.memory.add_to_conversation(
|
916
|
+
"system",
|
917
|
+
f"GitHub issue created: {result}",
|
918
|
+
{
|
919
|
+
"agent": "terraform_agent",
|
920
|
+
"node": "open_issue",
|
921
|
+
"repo": repo,
|
922
|
+
"title": title,
|
923
|
+
"result": result,
|
924
|
+
},
|
925
|
+
)
|
926
|
+
|
927
|
+
if "ERROR:" in result:
|
928
|
+
error_msg = f"Failed to create GitHub issue: {result}"
|
929
|
+
self.logger.error(error_msg)
|
930
|
+
return {
|
931
|
+
"final_result": error_msg,
|
932
|
+
"error_message": result,
|
933
|
+
"operation_type": "open_issue",
|
934
|
+
"error_tags": None,
|
935
|
+
}
|
936
|
+
else:
|
937
|
+
success_msg = (
|
938
|
+
f"Successfully created GitHub issue for Terraform error: {result}"
|
939
|
+
)
|
940
|
+
self.logger.info(success_msg)
|
941
|
+
return {
|
942
|
+
"final_result": success_msg,
|
943
|
+
"error_message": None,
|
944
|
+
"operation_type": "open_issue",
|
945
|
+
"error_tags": None,
|
946
|
+
}
|
947
|
+
|
948
|
+
except Exception as e:
|
949
|
+
error_msg = f"Error in open issue node: {str(e)}"
|
950
|
+
self.logger.error(error_msg, exc_info=True)
|
951
|
+
self.memory.add_to_conversation(
|
952
|
+
"system",
|
953
|
+
error_msg,
|
954
|
+
{"agent": "terraform_agent", "node": "open_issue", "error": True},
|
955
|
+
)
|
956
|
+
return {
|
957
|
+
"final_result": "Sorry, I couldn't create the GitHub issue due to an error.",
|
958
|
+
"error_message": str(e),
|
959
|
+
"operation_type": "open_issue",
|
960
|
+
"error_tags": list(classify_terraform_error(str(e))),
|
961
|
+
}
|
962
|
+
|
963
|
+
def _build_graph(self):
|
964
|
+
"""
|
965
|
+
Build and compile the LangGraph state machine.
|
966
|
+
|
967
|
+
Phase 3: Enhanced graph with GitHub issue integration for error handling.
|
968
|
+
"""
|
969
|
+
graph_builder = StateGraph(TerraformAgentState)
|
970
|
+
|
971
|
+
# Add nodes
|
972
|
+
graph_builder.add_node("planner_llm", self._planner_llm_node)
|
973
|
+
graph_builder.add_node("tf_init_node", self._tf_init_node)
|
974
|
+
graph_builder.add_node("tf_plan_node", self._tf_plan_node)
|
975
|
+
graph_builder.add_node("tf_apply_node", self._tf_apply_node)
|
976
|
+
graph_builder.add_node("open_issue_node", self._open_issue_node)
|
977
|
+
|
978
|
+
# Set entry point
|
979
|
+
graph_builder.set_entry_point("planner_llm")
|
980
|
+
|
981
|
+
# Configure routing map
|
982
|
+
routing_map = self.config.get(
|
983
|
+
"routing_map",
|
984
|
+
{
|
985
|
+
"tf_init_node": "tf_init_node",
|
986
|
+
"tf_plan_node": "tf_plan_node",
|
987
|
+
"tf_apply_node": "tf_apply_node",
|
988
|
+
"open_issue_node": "open_issue_node",
|
989
|
+
END: END,
|
990
|
+
},
|
991
|
+
)
|
992
|
+
|
993
|
+
# Add conditional edges from planner
|
994
|
+
graph_builder.add_conditional_edges(
|
995
|
+
"planner_llm", self._route_after_planner, routing_map
|
996
|
+
)
|
997
|
+
|
998
|
+
# Add edges from tool nodes to END
|
999
|
+
graph_builder.add_edge("tf_init_node", END)
|
1000
|
+
graph_builder.add_edge("tf_plan_node", END)
|
1001
|
+
graph_builder.add_edge("tf_apply_node", END)
|
1002
|
+
graph_builder.add_edge("open_issue_node", END)
|
1003
|
+
|
1004
|
+
# Compile with checkpointer
|
1005
|
+
return graph_builder.compile(checkpointer=self.checkpointer)
|
1006
|
+
|
1007
|
+
def run(self, agent_input: TerraformAgentInput) -> TerraformAgentOutput:
|
1008
|
+
"""
|
1009
|
+
Run the agent with the given input.
|
1010
|
+
|
1011
|
+
Args:
|
1012
|
+
agent_input: TerraformAgentInput with query and optional thread_id
|
1013
|
+
|
1014
|
+
Returns:
|
1015
|
+
TerraformAgentOutput with result, thread_id, and optional error
|
1016
|
+
"""
|
1017
|
+
current_thread_id = (
|
1018
|
+
agent_input.thread_id if agent_input.thread_id else str(uuid.uuid4())
|
1019
|
+
)
|
1020
|
+
self.logger.info(
|
1021
|
+
f"Run invoked with query: '{agent_input.query}', thread_id: {current_thread_id}"
|
1022
|
+
)
|
1023
|
+
log_event(
|
1024
|
+
"terraform_agent_run_start",
|
1025
|
+
query=agent_input.query,
|
1026
|
+
thread_id=current_thread_id,
|
1027
|
+
)
|
1028
|
+
|
1029
|
+
# Store context in memory if provided
|
1030
|
+
if agent_input.context:
|
1031
|
+
self.memory.add_to_conversation(
|
1032
|
+
"system",
|
1033
|
+
f"Context: {agent_input.context}",
|
1034
|
+
{
|
1035
|
+
"agent": "terraform_agent",
|
1036
|
+
"type": "context",
|
1037
|
+
"thread_id": current_thread_id,
|
1038
|
+
},
|
1039
|
+
)
|
1040
|
+
|
1041
|
+
# Initial state for LangGraph
|
1042
|
+
initial_state = {
|
1043
|
+
"input_message": HumanMessage(content=agent_input.query),
|
1044
|
+
"tool_output": [],
|
1045
|
+
"error_message": None,
|
1046
|
+
"operation_type": None,
|
1047
|
+
"error_tags": None,
|
1048
|
+
}
|
1049
|
+
|
1050
|
+
langgraph_config = {"configurable": {"thread_id": current_thread_id}}
|
1051
|
+
|
1052
|
+
try:
|
1053
|
+
# Run the graph
|
1054
|
+
result_state = self.runnable.invoke(initial_state, config=langgraph_config)
|
1055
|
+
|
1056
|
+
# Extract results
|
1057
|
+
final_result = result_state.get("final_result", "No result found.")
|
1058
|
+
error_message = result_state.get("error_message")
|
1059
|
+
operation_type = result_state.get("operation_type")
|
1060
|
+
error_tags = result_state.get("error_tags")
|
1061
|
+
|
1062
|
+
if (
|
1063
|
+
error_message
|
1064
|
+
and not error_tags
|
1065
|
+
and operation_type in {
|
1066
|
+
"terraform_init",
|
1067
|
+
"terraform_plan",
|
1068
|
+
"terraform_apply",
|
1069
|
+
}
|
1070
|
+
):
|
1071
|
+
error_tags = list(classify_terraform_error(error_message))
|
1072
|
+
|
1073
|
+
if error_message:
|
1074
|
+
self.logger.error(f"Run completed with error: {error_message}")
|
1075
|
+
else:
|
1076
|
+
self.logger.info(f"Run completed successfully: {final_result}")
|
1077
|
+
|
1078
|
+
log_event(
|
1079
|
+
"terraform_agent_run_end",
|
1080
|
+
thread_id=current_thread_id,
|
1081
|
+
error=error_message,
|
1082
|
+
result=final_result,
|
1083
|
+
)
|
1084
|
+
|
1085
|
+
output = TerraformAgentOutput(
|
1086
|
+
result=final_result,
|
1087
|
+
thread_id=current_thread_id,
|
1088
|
+
error_message=error_message,
|
1089
|
+
operation_type=operation_type,
|
1090
|
+
error_tags=error_tags,
|
1091
|
+
)
|
1092
|
+
|
1093
|
+
return output
|
1094
|
+
|
1095
|
+
except Exception as e:
|
1096
|
+
self.logger.error(f"Error during agent run: {e}", exc_info=True)
|
1097
|
+
log_event(
|
1098
|
+
"terraform_agent_run_exception",
|
1099
|
+
thread_id=current_thread_id,
|
1100
|
+
error=str(e),
|
1101
|
+
)
|
1102
|
+
return TerraformAgentOutput(
|
1103
|
+
result="An unexpected error occurred during execution.",
|
1104
|
+
thread_id=current_thread_id,
|
1105
|
+
error_message=str(e),
|
1106
|
+
operation_type="error",
|
1107
|
+
error_tags=list(classify_terraform_error(str(e))),
|
1108
|
+
)
|
1109
|
+
|
1110
|
+
def get_conversation_history(self) -> List[Dict[str, Any]]:
|
1111
|
+
"""Get conversation history from memory."""
|
1112
|
+
return self.memory.get_conversation_history()
|
1113
|
+
|
1114
|
+
def get_memory_state(self) -> Dict[str, Any]:
|
1115
|
+
"""Get current memory state."""
|
1116
|
+
return self.memory.get_state()
|
1117
|
+
|
1118
|
+
def plan(self, query: str, **kwargs):
|
1119
|
+
"""
|
1120
|
+
Generates a plan for the agent to execute (required by AgentBase).
|
1121
|
+
|
1122
|
+
Phase 2: Comprehensive Terraform planning logic.
|
1123
|
+
|
1124
|
+
Args:
|
1125
|
+
query: The Terraform query to plan for
|
1126
|
+
**kwargs: Additional parameters (e.g., thread_id)
|
1127
|
+
|
1128
|
+
Returns:
|
1129
|
+
dict: A plan containing the input and predicted action
|
1130
|
+
"""
|
1131
|
+
self.logger.info(f"Planning for Terraform query: '{query}'")
|
1132
|
+
|
1133
|
+
plan = {
|
1134
|
+
"input_query": query,
|
1135
|
+
"predicted_action": "analyze_terraform_request",
|
1136
|
+
"description": "Analyze Terraform request to determine appropriate infrastructure operation",
|
1137
|
+
}
|
1138
|
+
|
1139
|
+
# Comprehensive analysis to predict the route
|
1140
|
+
query_lower = query.lower()
|
1141
|
+
if any(
|
1142
|
+
word in query_lower
|
1143
|
+
for word in ["init", "initialize", "setup terraform", "terraform init"]
|
1144
|
+
):
|
1145
|
+
plan["predicted_route"] = "terraform_init"
|
1146
|
+
plan["description"] = "Initialize Terraform working directory"
|
1147
|
+
elif any(
|
1148
|
+
word in query_lower
|
1149
|
+
for word in ["plan", "preview", "show changes", "terraform plan"]
|
1150
|
+
):
|
1151
|
+
plan["predicted_route"] = "terraform_plan"
|
1152
|
+
plan["description"] = "Create Terraform execution plan"
|
1153
|
+
elif any(
|
1154
|
+
word in query_lower
|
1155
|
+
for word in [
|
1156
|
+
"apply",
|
1157
|
+
"deploy",
|
1158
|
+
"provision",
|
1159
|
+
"create infrastructure",
|
1160
|
+
"terraform apply",
|
1161
|
+
]
|
1162
|
+
):
|
1163
|
+
plan["predicted_route"] = "terraform_apply"
|
1164
|
+
plan["description"] = (
|
1165
|
+
"Apply Terraform configuration and provision infrastructure"
|
1166
|
+
)
|
1167
|
+
elif any(
|
1168
|
+
phrase in query_lower
|
1169
|
+
for phrase in [
|
1170
|
+
"open issue",
|
1171
|
+
"create issue",
|
1172
|
+
"report error",
|
1173
|
+
"file bug",
|
1174
|
+
"open github issue",
|
1175
|
+
]
|
1176
|
+
):
|
1177
|
+
plan["predicted_route"] = "open_issue"
|
1178
|
+
plan["description"] = "Create GitHub issue for error reporting"
|
1179
|
+
else:
|
1180
|
+
plan["predicted_route"] = "direct_answer"
|
1181
|
+
plan["description"] = "Provide direct answer without Terraform operation"
|
1182
|
+
|
1183
|
+
self.logger.debug(f"Generated Terraform plan: {plan}")
|
1184
|
+
return plan
|
1185
|
+
|
1186
|
+
def report(self, result=None, **kwargs):
|
1187
|
+
"""
|
1188
|
+
Reports the results or progress of the agent's execution (required by AgentBase).
|
1189
|
+
|
1190
|
+
Args:
|
1191
|
+
result: The result to report (TerraformAgentOutput or string)
|
1192
|
+
**kwargs: Additional parameters
|
1193
|
+
|
1194
|
+
Returns:
|
1195
|
+
dict: A report containing execution details
|
1196
|
+
"""
|
1197
|
+
if isinstance(result, TerraformAgentOutput):
|
1198
|
+
report = {
|
1199
|
+
"status": "completed",
|
1200
|
+
"result": result.result,
|
1201
|
+
"thread_id": result.thread_id,
|
1202
|
+
"error": result.error_message,
|
1203
|
+
"operation_type": result.operation_type,
|
1204
|
+
"success": result.error_message is None,
|
1205
|
+
}
|
1206
|
+
elif isinstance(result, str):
|
1207
|
+
report = {"status": "completed", "result": result, "success": True}
|
1208
|
+
else:
|
1209
|
+
report = {"status": "no_result", "message": "No result provided to report"}
|
1210
|
+
|
1211
|
+
self.logger.info(f"TerraformAgent execution report: {report}")
|
1212
|
+
return report
|
1213
|
+
|
1214
|
+
|
1215
|
+
# Alias for backward compatibility
|
1216
|
+
TerraformAgent = TerraformAgent
|