diagram-to-iac 0.6.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.
Files changed (77) hide show
  1. diagram_to_iac/__init__.py +10 -0
  2. diagram_to_iac/actions/__init__.py +7 -0
  3. diagram_to_iac/actions/git_entry.py +174 -0
  4. diagram_to_iac/actions/supervisor_entry.py +116 -0
  5. diagram_to_iac/actions/terraform_agent_entry.py +207 -0
  6. diagram_to_iac/agents/__init__.py +26 -0
  7. diagram_to_iac/agents/demonstrator_langgraph/__init__.py +10 -0
  8. diagram_to_iac/agents/demonstrator_langgraph/agent.py +826 -0
  9. diagram_to_iac/agents/git_langgraph/__init__.py +10 -0
  10. diagram_to_iac/agents/git_langgraph/agent.py +1018 -0
  11. diagram_to_iac/agents/git_langgraph/pr.py +146 -0
  12. diagram_to_iac/agents/hello_langgraph/__init__.py +9 -0
  13. diagram_to_iac/agents/hello_langgraph/agent.py +621 -0
  14. diagram_to_iac/agents/policy_agent/__init__.py +15 -0
  15. diagram_to_iac/agents/policy_agent/agent.py +507 -0
  16. diagram_to_iac/agents/policy_agent/integration_example.py +191 -0
  17. diagram_to_iac/agents/policy_agent/tools/__init__.py +14 -0
  18. diagram_to_iac/agents/policy_agent/tools/tfsec_tool.py +259 -0
  19. diagram_to_iac/agents/shell_langgraph/__init__.py +21 -0
  20. diagram_to_iac/agents/shell_langgraph/agent.py +122 -0
  21. diagram_to_iac/agents/shell_langgraph/detector.py +50 -0
  22. diagram_to_iac/agents/supervisor_langgraph/__init__.py +17 -0
  23. diagram_to_iac/agents/supervisor_langgraph/agent.py +1947 -0
  24. diagram_to_iac/agents/supervisor_langgraph/demonstrator.py +22 -0
  25. diagram_to_iac/agents/supervisor_langgraph/guards.py +23 -0
  26. diagram_to_iac/agents/supervisor_langgraph/pat_loop.py +49 -0
  27. diagram_to_iac/agents/supervisor_langgraph/router.py +9 -0
  28. diagram_to_iac/agents/terraform_langgraph/__init__.py +15 -0
  29. diagram_to_iac/agents/terraform_langgraph/agent.py +1216 -0
  30. diagram_to_iac/agents/terraform_langgraph/parser.py +76 -0
  31. diagram_to_iac/core/__init__.py +7 -0
  32. diagram_to_iac/core/agent_base.py +19 -0
  33. diagram_to_iac/core/enhanced_memory.py +302 -0
  34. diagram_to_iac/core/errors.py +4 -0
  35. diagram_to_iac/core/issue_tracker.py +49 -0
  36. diagram_to_iac/core/memory.py +132 -0
  37. diagram_to_iac/services/__init__.py +10 -0
  38. diagram_to_iac/services/observability.py +59 -0
  39. diagram_to_iac/services/step_summary.py +77 -0
  40. diagram_to_iac/tools/__init__.py +11 -0
  41. diagram_to_iac/tools/api_utils.py +108 -26
  42. diagram_to_iac/tools/git/__init__.py +45 -0
  43. diagram_to_iac/tools/git/git.py +956 -0
  44. diagram_to_iac/tools/hello/__init__.py +30 -0
  45. diagram_to_iac/tools/hello/cal_utils.py +31 -0
  46. diagram_to_iac/tools/hello/text_utils.py +97 -0
  47. diagram_to_iac/tools/llm_utils/__init__.py +20 -0
  48. diagram_to_iac/tools/llm_utils/anthropic_driver.py +87 -0
  49. diagram_to_iac/tools/llm_utils/base_driver.py +90 -0
  50. diagram_to_iac/tools/llm_utils/gemini_driver.py +89 -0
  51. diagram_to_iac/tools/llm_utils/openai_driver.py +93 -0
  52. diagram_to_iac/tools/llm_utils/router.py +303 -0
  53. diagram_to_iac/tools/sec_utils.py +4 -2
  54. diagram_to_iac/tools/shell/__init__.py +17 -0
  55. diagram_to_iac/tools/shell/shell.py +415 -0
  56. diagram_to_iac/tools/text_utils.py +277 -0
  57. diagram_to_iac/tools/tf/terraform.py +851 -0
  58. diagram_to_iac-0.8.0.dist-info/METADATA +99 -0
  59. diagram_to_iac-0.8.0.dist-info/RECORD +64 -0
  60. {diagram_to_iac-0.6.0.dist-info → diagram_to_iac-0.8.0.dist-info}/WHEEL +1 -1
  61. diagram_to_iac-0.8.0.dist-info/entry_points.txt +4 -0
  62. diagram_to_iac/agents/codegen_agent.py +0 -0
  63. diagram_to_iac/agents/consensus_agent.py +0 -0
  64. diagram_to_iac/agents/deployment_agent.py +0 -0
  65. diagram_to_iac/agents/github_agent.py +0 -0
  66. diagram_to_iac/agents/interpretation_agent.py +0 -0
  67. diagram_to_iac/agents/question_agent.py +0 -0
  68. diagram_to_iac/agents/supervisor.py +0 -0
  69. diagram_to_iac/agents/vision_agent.py +0 -0
  70. diagram_to_iac/core/config.py +0 -0
  71. diagram_to_iac/tools/cv_utils.py +0 -0
  72. diagram_to_iac/tools/gh_utils.py +0 -0
  73. diagram_to_iac/tools/tf_utils.py +0 -0
  74. diagram_to_iac-0.6.0.dist-info/METADATA +0 -16
  75. diagram_to_iac-0.6.0.dist-info/RECORD +0 -32
  76. diagram_to_iac-0.6.0.dist-info/entry_points.txt +0 -2
  77. {diagram_to_iac-0.6.0.dist-info → diagram_to_iac-0.8.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1018 @@
1
+ """
2
+ Git Agent - Phase 4 Implementation
3
+
4
+ Hybrid-agent architecture for DevOps automation featuring:
5
+ - Light LLM "planner" node that decides the next action via routing tokens
6
+ - Small, single-purpose tool agents (Shell, Git, GitHub) that execute real commands
7
+ - LangGraph state machine that orchestrates the control flow
8
+ - Configuration-driven behavior with robust error handling
9
+ - Memory integration for operation tracking
10
+
11
+ This agent demonstrates the orchestration pattern before scaling to Terraform, Ansible, etc.
12
+
13
+ Architecture:
14
+ 1. Planner LLM analyzes user input and emits routing tokens:
15
+ - "ROUTE_TO_CLONE" for git clone operations
16
+ - "ROUTE_TO_ISSUE" for GitHub issue creation
17
+ - "ROUTE_TO_SHELL" for shell command execution
18
+ - "ROUTE_TO_END" when no action needed
19
+ 2. Router function maps tokens to appropriate tool nodes
20
+ 3. Tool nodes execute real operations using battle-tested tools
21
+ 4. State machine handles error paths and success flows
22
+ """
23
+
24
+ import os
25
+ import uuid
26
+ import logging
27
+ from typing import TypedDict, Annotated, Optional, List, Dict, Any
28
+
29
+ import yaml
30
+ from langchain_core.messages import HumanMessage, BaseMessage
31
+ from langgraph.graph import StateGraph, END
32
+ from langgraph.checkpoint.memory import MemorySaver
33
+ from pydantic import BaseModel, Field
34
+
35
+ # Import our battle-tested tools
36
+ from diagram_to_iac.tools.git import (
37
+ GitExecutor,
38
+ git_clone,
39
+ gh_open_issue,
40
+ GitCloneInput,
41
+ )
42
+ # Shell tools were moved from the shell_langgraph agent package into the common
43
+ # tools module. Import from the new location to keep the agent functional.
44
+ from diagram_to_iac.tools.shell import ShellExecutor, shell_exec
45
+ from diagram_to_iac.tools.llm_utils.router import get_llm, LLMRouter
46
+ from diagram_to_iac.core.agent_base import AgentBase
47
+ from diagram_to_iac.core.memory import create_memory, LangGraphMemoryAdapter
48
+ from diagram_to_iac.services.observability import log_event
49
+
50
+ from diagram_to_iac.services.observability import log_event
51
+
52
+ from .pr import GitPrCreator
53
+
54
+
55
+
56
+ # --- Pydantic Schemas for Agent I/O ---
57
+ class GitAgentInput(BaseModel):
58
+ """Input schema for GitAgent operations."""
59
+ query: str = Field(..., description="The DevOps request (git clone, GitHub issue, shell command)")
60
+ thread_id: str | None = Field(None, description="Optional thread ID for conversation history")
61
+ workspace_path: str | None = Field(None, description="Optional workspace directory for shell commands")
62
+ issue_id: int | None = Field(None, description="Existing issue number to comment on")
63
+ trigger_pr_creation_for_error_type: Optional[str] = Field(None, description="If set, triggers PR creation for this error type (e.g., 'syntax_fmt', 'missing_backend').")
64
+ pr_title: Optional[str] = Field(None, description="Title for the auto-created PR.")
65
+ pr_body: Optional[str] = Field(None, description="Body for the auto-created PR.")
66
+ repo_local_path: Optional[str] = Field(None, description="Local path to the git repository for PR creation.")
67
+
68
+
69
+ class GitAgentOutput(BaseModel):
70
+ """Output schema for GitAgent operations."""
71
+ result: str = Field(..., description="The result of the DevOps operation")
72
+ thread_id: str = Field(..., description="Thread ID used for the conversation")
73
+ repo_path: Optional[str] = Field(None, description="Repository path for clone operations")
74
+ error_message: Optional[str] = Field(None, description="Error message if the operation failed")
75
+ operation_type: Optional[str] = Field(None, description="Type of operation performed (clone, issue, shell)")
76
+ pr_url: Optional[str] = Field(None, description="URL of the created pull request, if any.")
77
+
78
+ @property
79
+ def answer(self) -> str:
80
+ """Alias for result to match learning guide tests."""
81
+ return self.result
82
+
83
+
84
+ # --- Agent State Definition ---
85
+ class GitAgentState(TypedDict):
86
+ """State definition for the GitAgent LangGraph."""
87
+ input_message: HumanMessage
88
+ tool_output: Annotated[list[BaseMessage], lambda x, y: x + y]
89
+ final_result: str
90
+ error_message: Optional[str]
91
+ operation_type: Optional[str]
92
+ workspace_path: Optional[str]
93
+ repo_path: Optional[str]
94
+ issue_id: Optional[int]
95
+ trigger_pr_creation_for_error_type: Optional[str]
96
+ pr_title: Optional[str]
97
+ pr_body: Optional[str]
98
+ pr_url: Optional[str]
99
+ repo_local_path: Optional[str]
100
+
101
+
102
+ # --- Main Agent Class ---
103
+ class GitAgent(AgentBase):
104
+ """
105
+ GitAgent is a LangGraph-based DevOps automation agent that can:
106
+ - Clone Git repositories via git_clone tool
107
+ - Create GitHub issues via gh_open_issue tool
108
+ - Execute shell commands via shell_exec tool
109
+
110
+ Uses a light LLM planner for routing decisions and delegates to specialized tool nodes.
111
+ Demonstrates hybrid-agent architecture with configuration-driven behavior.
112
+ """
113
+
114
+ def __init__(self, config_path: str = None, memory_type: str = "persistent"):
115
+ """
116
+ Initialize the GitAgent with configuration and tools.
117
+
118
+ Args:
119
+ config_path: Optional path to YAML configuration file
120
+ memory_type: Type of memory ("persistent", "memory", or "langgraph")
121
+ """
122
+ # Configure logger for this agent instance
123
+ self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
124
+ if not logging.getLogger().hasHandlers():
125
+ logging.basicConfig(
126
+ level=logging.INFO,
127
+ format='%(asctime)s - %(name)s - %(levelname)s - %(threadName)s - %(message)s',
128
+ datefmt='%Y-%m-%d %H:%M:%S'
129
+ )
130
+
131
+ # Store memory type for tool initialization
132
+ self.memory_type = memory_type
133
+
134
+ # Load configuration
135
+ if config_path is None:
136
+ base_dir = os.path.dirname(os.path.abspath(__file__))
137
+ config_path = os.path.join(base_dir, 'config.yaml')
138
+ self.logger.debug(f"Default config path set to: {config_path}")
139
+
140
+ try:
141
+ with open(config_path, 'r') as f:
142
+ self.config = yaml.safe_load(f)
143
+ if self.config is None:
144
+ self.logger.warning(f"Configuration file at {config_path} is empty. Using defaults.")
145
+ self._set_default_config()
146
+ else:
147
+ self.logger.info(f"Configuration loaded successfully from {config_path}")
148
+ except FileNotFoundError:
149
+ self.logger.warning(f"Configuration file not found at {config_path}. Using defaults.")
150
+ self._set_default_config()
151
+ except yaml.YAMLError as e:
152
+ self.logger.error(f"Error parsing YAML configuration: {e}. Using defaults.", exc_info=True)
153
+ self._set_default_config()
154
+
155
+ # Initialize enhanced LLM router
156
+ self.llm_router = LLMRouter()
157
+ self.logger.info("Enhanced LLM router initialized")
158
+
159
+ # Initialize enhanced memory system
160
+ self.memory = create_memory(memory_type)
161
+ self.logger.info(f"Enhanced memory system initialized: {type(self.memory).__name__}")
162
+
163
+ # Initialize checkpointer
164
+ self.checkpointer = MemorySaver()
165
+ self.logger.info("MemorySaver checkpointer initialized")
166
+
167
+ # Initialize tools (using our battle-tested implementations)
168
+ self._initialize_tools()
169
+
170
+ # Build and compile the LangGraph
171
+ self.runnable = self._build_graph()
172
+ self.logger.info("GitAgent initialized successfully")
173
+
174
+ def _set_default_config(self):
175
+ """Set default configuration values."""
176
+ self.logger.info("Setting default configuration for GitAgent")
177
+ self.config = {
178
+ 'llm': {
179
+ 'model_name': 'gpt-4o-mini',
180
+ 'temperature': 0.1
181
+ },
182
+ 'routing_keys': {
183
+ 'git_clone': 'ROUTE_TO_GIT_CLONE', # Changed from 'clone'
184
+ 'github_cli': 'ROUTE_TO_GITHUB_CLI', # Changed from 'issue'
185
+ 'shell_exec': 'ROUTE_TO_SHELL_EXEC', # Changed from 'shell'
186
+ 'create_pr': 'ROUTE_TO_CREATE_PR',
187
+ 'end': 'ROUTE_TO_END'
188
+ },
189
+ 'git_pr_creator': {
190
+ 'copilot_assignee': "CopilotUser", # Placeholder
191
+ 'default_assignees': ["team-infra"], # Placeholder
192
+ 'remote_name': "origin"
193
+ }
194
+ }
195
+
196
+ def _initialize_tools(self):
197
+ """Initialize the DevOps tools."""
198
+ try:
199
+ # Initialize GitExecutor (handles both git clone and GitHub CLI)
200
+ self.git_executor = GitExecutor(memory_type=self.memory_type)
201
+
202
+ # Initialize ShellExecutor
203
+ self.shell_executor = ShellExecutor(memory_type=self.memory_type)
204
+
205
+ # GitPrCreator is initialized on-demand in _create_pr_node
206
+ # self.git_pr_creator can be removed if not used elsewhere, or kept as None
207
+ self.git_pr_creator = None
208
+
209
+ # Register tools for easy access
210
+ self.tools = {
211
+ "git_clone": git_clone,
212
+ "gh_open_issue": gh_open_issue,
213
+ "shell_exec": shell_exec
214
+ }
215
+
216
+ self.logger.info(f"Tools initialized: {list(self.tools.keys())}")
217
+
218
+ except Exception as e:
219
+ self.logger.error(f"Failed to initialize tools: {e}", exc_info=True)
220
+ raise
221
+
222
+ def _planner_llm_node(self, state: GitAgentState):
223
+ """
224
+ LLM planner node that analyzes input and decides routing.
225
+ Emits routing tokens based on the user's DevOps request.
226
+ """
227
+ # Check for direct PR creation trigger
228
+ if state.get('trigger_pr_creation_for_error_type') and state.get('repo_local_path'):
229
+ self.logger.info(f"PR creation triggered directly for error type: {state.get('trigger_pr_creation_for_error_type')}")
230
+ return {
231
+ "final_result": self.config['routing_keys']['create_pr'],
232
+ "operation_type": "create_pr",
233
+ "error_message": None
234
+ }
235
+
236
+ # Get LLM configuration
237
+ llm_config = self.config.get('llm', {})
238
+ model_name = llm_config.get('model_name')
239
+ temperature = llm_config.get('temperature')
240
+
241
+ # Use enhanced LLM router
242
+ try:
243
+ if model_name is not None or temperature is not None:
244
+ actual_model_name = model_name if model_name is not None else 'gpt-4o-mini'
245
+ actual_temperature = temperature if temperature is not None else 0.1
246
+ self.logger.debug(f"Planner using LLM: {actual_model_name}, Temp: {actual_temperature}")
247
+
248
+ llm = self.llm_router.get_llm(
249
+ model_name=actual_model_name,
250
+ temperature=actual_temperature,
251
+ agent_name="git_agent"
252
+ )
253
+ else:
254
+ self.logger.debug("Planner using agent-specific LLM configuration")
255
+ llm = self.llm_router.get_llm_for_agent("git_agent")
256
+ except Exception as e:
257
+ self.logger.error(f"Failed to get LLM from router: {e}. Falling back to basic get_llm.")
258
+ llm = get_llm(model_name=model_name, temperature=temperature)
259
+
260
+ # Store conversation in memory
261
+ query_content = state['input_message'].content
262
+ self.memory.add_to_conversation("user", query_content, {
263
+ "agent": "git_agent",
264
+ "node": "planner"
265
+ })
266
+
267
+ try:
268
+ self.logger.debug(f"Planner LLM input: {query_content}")
269
+
270
+ # Build the analysis prompt
271
+ analysis_prompt_template = self.config.get('prompts', {}).get('planner_prompt', """
272
+ User input: "{user_input}"
273
+ Analyze this DevOps request and determine the appropriate action:
274
+ 1. If requesting to clone a Git repository (keywords: 'clone', 'download repo', 'git clone'), respond with "{route_git_clone}"
275
+ 2. If requesting to open a GitHub issue (keywords: 'open issue', 'create issue', 'report bug'), respond with "{route_github_cli}"
276
+ 3. If requesting a shell command (keywords: 'run command', 'execute', 'shell'), respond with "{route_shell_exec}"
277
+ 4. If the request is complete or no action needed, respond with "{route_end}"
278
+
279
+ Important: Only use routing tokens if the input contains actionable DevOps requests.
280
+ """)
281
+
282
+ routing_keys = self.config.get('routing_keys', {
283
+ "git_clone": "ROUTE_TO_GIT_CLONE",
284
+ "github_cli": "ROUTE_TO_GITHUB_CLI",
285
+ "shell_exec": "ROUTE_TO_SHELL_EXEC",
286
+ "end": "ROUTE_TO_END"
287
+ })
288
+
289
+ analysis_prompt = analysis_prompt_template.format(
290
+ user_input=query_content,
291
+ route_git_clone=routing_keys['git_clone'],
292
+ route_github_cli=routing_keys['github_cli'],
293
+ route_shell_exec=routing_keys['shell_exec'],
294
+ route_end=routing_keys['end']
295
+ )
296
+
297
+ self.logger.debug(f"Planner LLM prompt: {analysis_prompt}")
298
+
299
+ response = llm.invoke([HumanMessage(content=analysis_prompt)])
300
+ self.logger.debug(f"Planner LLM response: {response.content}")
301
+ response_content = response.content.strip()
302
+
303
+ # Store LLM response in memory
304
+ self.memory.add_to_conversation("assistant", response_content, {
305
+ "agent": "git_agent",
306
+ "node": "planner",
307
+ "model": model_name
308
+ })
309
+
310
+ # Determine routing based on response content
311
+ new_state_update = {}
312
+ if routing_keys['git_clone'] in response_content:
313
+ new_state_update = {
314
+ "final_result": "route_to_clone",
315
+ "operation_type": "clone",
316
+ "error_message": None
317
+ }
318
+ elif routing_keys['github_cli'] in response_content:
319
+ new_state_update = {
320
+ "final_result": "route_to_issue",
321
+ "operation_type": "issue",
322
+ "error_message": None
323
+ }
324
+ elif routing_keys['shell_exec'] in response_content:
325
+ new_state_update = {
326
+ "final_result": "route_to_shell",
327
+ "operation_type": "shell",
328
+ "error_message": None
329
+ }
330
+ elif routing_keys['end'] in response_content: # Explicitly check for 'end'
331
+ # Direct answer or route to end
332
+ new_state_update = {
333
+ "final_result": response.content,
334
+ "operation_type": "direct_answer",
335
+ "error_message": None
336
+ }
337
+
338
+ else: # Default if no specific route token is found
339
+ new_state_update = {
340
+ "final_result": response.content, # Treat as direct answer
341
+ "operation_type": "direct_answer",
342
+ "error_message": None
343
+ }
344
+
345
+ self.logger.info(f"Planner decision: {new_state_update.get('final_result', 'N/A')}")
346
+ return new_state_update
347
+
348
+ except Exception as e:
349
+ self.logger.error(f"LLM error in planner: {e}", exc_info=True)
350
+ self.memory.add_to_conversation("system", f"Error in planner: {str(e)}", {
351
+ "agent": "git_agent",
352
+ "node": "planner",
353
+ "error": True
354
+ })
355
+ return {
356
+ "final_result": "Sorry, I encountered an issue processing your request.",
357
+ "error_message": str(e),
358
+ "operation_type": "error"
359
+ }
360
+
361
+ def _clone_repo_node(self, state: GitAgentState):
362
+ """
363
+ Git clone tool node that handles repository cloning operations.
364
+ """
365
+ self.logger.info(f"Clone repo node invoked for: {state['input_message'].content}")
366
+
367
+ try:
368
+ text_content = state['input_message'].content
369
+
370
+ # Store tool invocation in memory
371
+ self.memory.add_to_conversation("system", f"Git clone tool invoked", {
372
+ "agent": "git_agent",
373
+ "node": "clone_repo",
374
+ "input": text_content
375
+ })
376
+
377
+ # For now, extract a simple repo URL from the input
378
+ # In a real implementation, you might use more sophisticated parsing
379
+ import re
380
+ url_pattern = r'https?://github\.com/[\w\-\.]+/[\w\-\.]+'
381
+ urls = re.findall(url_pattern, text_content)
382
+
383
+ if not urls:
384
+ words = text_content.split()
385
+ repo_url = words[-1]
386
+ else:
387
+ repo_url = urls[0]
388
+
389
+ # Use the git executor directly to allow invalid URLs
390
+ git_input = GitCloneInput.model_construct(
391
+ repo_url=repo_url,
392
+ workspace=state.get("workspace_path") or None,
393
+ )
394
+ result_obj = self.git_executor.git_clone(git_input)
395
+
396
+ # Organically handle different result statuses
397
+ if result_obj.status == "SUCCESS":
398
+ result = result_obj.repo_path
399
+ state["repo_path"] = result_obj.repo_path
400
+ success_msg = f"Successfully cloned repository: {result}"
401
+
402
+ self.memory.add_to_conversation("system", f"Repository cloned: {result}", {
403
+ "agent": "git_agent",
404
+ "node": "clone_repo",
405
+ "repo_url": repo_url,
406
+ "result": "SUCCESS"
407
+ })
408
+
409
+ return {
410
+ "final_result": success_msg,
411
+ "error_message": None,
412
+ "operation_type": "clone",
413
+ "repo_path": result_obj.repo_path
414
+ }
415
+
416
+ elif result_obj.status == "AUTH_FAILED":
417
+ # Organically handle authentication failures with helpful context
418
+ github_token = os.environ.get('GITHUB_TOKEN')
419
+ if github_token and github_token.strip():
420
+ auth_msg = f"Git executor: Authentication failed for repository '{repo_url}'. GitHub token is present but may be invalid or expired. Please check token permissions."
421
+ else:
422
+ auth_msg = f"Git executor: Authentication failed for repository '{repo_url}'. For private repositories, please set the GITHUB_TOKEN environment variable with a valid personal access token."
423
+
424
+ self.memory.add_to_conversation("system", f"Repository cloned: {auth_msg}", {
425
+ "agent": "git_agent",
426
+ "node": "clone_repo",
427
+ "repo_url": repo_url,
428
+ "result": "AUTH_FAILED"
429
+ })
430
+
431
+ return {
432
+ "final_result": f"Clone failed: {auth_msg}",
433
+ "error_message": result_obj.error_message or "Authentication failed",
434
+ "operation_type": "clone",
435
+ "repo_path": None
436
+ }
437
+
438
+ else:
439
+ # Handle other errors (timeout, invalid URL, etc.)
440
+ error_msg = result_obj.error_message or f"Git executor: Failed to clone repository '{repo_url}'"
441
+
442
+ self.memory.add_to_conversation("system", f"Repository cloned: {error_msg}", {
443
+ "agent": "git_agent",
444
+ "node": "clone_repo",
445
+ "repo_url": repo_url,
446
+ "result": result_obj.status
447
+ })
448
+
449
+ return {
450
+ "final_result": f"Clone failed: {error_msg}",
451
+ "error_message": result_obj.error_message or error_msg,
452
+ "operation_type": "clone",
453
+ "repo_path": None
454
+ }
455
+
456
+ except Exception as e:
457
+ self.logger.error(f"Error in clone repo node: {e}", exc_info=True)
458
+ self.memory.add_to_conversation("system", f"Clone error: {str(e)}", {
459
+ "agent": "git_agent",
460
+ "node": "clone_repo",
461
+ "error": True
462
+ })
463
+ return {
464
+ "final_result": "Sorry, I couldn't clone the repository due to an error.",
465
+ "error_message": str(e),
466
+ "operation_type": "clone",
467
+ "repo_path": None
468
+ }
469
+
470
+ def _open_issue_node(self, state: GitAgentState):
471
+ """
472
+ GitHub issue tool node that handles issue creation operations.
473
+ """
474
+ self.logger.info(f"Open issue node invoked for: {state['input_message'].content}")
475
+
476
+ try:
477
+ text_content = state['input_message'].content
478
+
479
+ # Store tool invocation in memory
480
+ self.memory.add_to_conversation("system", f"GitHub issue tool invoked", {
481
+ "agent": "git_agent",
482
+ "node": "open_issue",
483
+ "input": text_content
484
+ })
485
+
486
+ # Enhanced parsing for both test format and supervisor format
487
+ import re
488
+
489
+ # Pattern 1: Enhanced supervisor format - "open issue TITLE for repository URL: BODY"
490
+ supervisor_pattern = r'^open issue\s+(.+?)\s+for repository\s+(https://github\.com/[\w\-\.]+/[\w\-\.]+):\s*(.+)$'
491
+ supervisor_match = re.search(supervisor_pattern, text_content, re.IGNORECASE | re.DOTALL)
492
+
493
+ if supervisor_match:
494
+ title = supervisor_match.group(1).strip()
495
+ repo_url = supervisor_match.group(2).strip()
496
+ body = supervisor_match.group(3).strip()
497
+ repo = repo_url.replace('https://github.com/', '')
498
+
499
+ # Clean ANSI codes from title and body for better presentation
500
+ from diagram_to_iac.tools.text_utils import clean_ansi_codes, enhance_error_message_for_issue
501
+ title = clean_ansi_codes(title)
502
+ body = enhance_error_message_for_issue(body)
503
+
504
+ self.logger.info(f"Parsed enhanced supervisor format - Title: {title}, Repo: {repo}")
505
+
506
+ else:
507
+ # Pattern 2: Legacy supervisor format - "open issue TITLE: BODY"
508
+ legacy_supervisor_pattern = r'^open issue\s+([^:]+):\s*(.+)$'
509
+ legacy_supervisor_match = re.search(legacy_supervisor_pattern, text_content, re.IGNORECASE | re.DOTALL)
510
+
511
+ if legacy_supervisor_match:
512
+ title = legacy_supervisor_match.group(1).strip()
513
+ body = legacy_supervisor_match.group(2).strip()
514
+
515
+ # Clean ANSI codes from title and body for better presentation
516
+ from diagram_to_iac.tools.text_utils import clean_ansi_codes, enhance_error_message_for_issue
517
+ title = clean_ansi_codes(title)
518
+ body = enhance_error_message_for_issue(body)
519
+
520
+ # Extract repository from the body if present
521
+ repo_in_body = re.search(r'\*?\*?Repository:\*?\*?\s*(https://github\.com/[\w\-\.]+/[\w\-\.]+)', body)
522
+ if repo_in_body:
523
+ repo_url = repo_in_body.group(1)
524
+ repo = repo_url.replace('https://github.com/', '')
525
+ else:
526
+ # No fallback - require explicit repository specification
527
+ error_msg = "Could not extract repository from issue request. Repository must be specified in body as 'Repository: https://github.com/owner/repo'"
528
+ self.logger.warning(error_msg)
529
+ self.memory.add_to_conversation("system", error_msg, {
530
+ "agent": "git_agent",
531
+ "node": "open_issue",
532
+ "error": True
533
+ })
534
+ return {
535
+ "final_result": error_msg,
536
+ "error_message": "Missing repository specification",
537
+ "operation_type": "issue"
538
+ }
539
+
540
+ self.logger.info(f"Parsed legacy supervisor format - Title: {title}, Repo: {repo}")
541
+
542
+ else:
543
+ # Pattern 3: Test format - "titled 'X' with body 'Y' in repository user/repo"
544
+ repo_pattern = r'(?:in|for|on|repository)\s+(?:repository\s+)?(\w+/\w+)'
545
+ title_pattern = r"titled\s*['\"]([^'\"]+)['\"]"
546
+ body_pattern = r'(?:with\s+body|body|content|description)\s*[\'"]?([^\'"\n]+)[\'"]?'
547
+
548
+ repo_match = re.search(repo_pattern, text_content, re.IGNORECASE)
549
+ title_match = re.search(title_pattern, text_content, re.IGNORECASE)
550
+ body_match = re.search(body_pattern, text_content, re.IGNORECASE)
551
+
552
+ if not repo_match or not title_match:
553
+ error_msg = "Could not extract repository name and issue title from the request."
554
+ self.logger.warning(error_msg)
555
+ self.memory.add_to_conversation("system", error_msg, {
556
+ "agent": "git_agent",
557
+ "node": "open_issue",
558
+ "error": True
559
+ })
560
+ return {
561
+ "final_result": error_msg,
562
+ "error_message": "Missing repository or issue details",
563
+ "operation_type": "issue"
564
+ }
565
+
566
+ repo = repo_match.group(1)
567
+ title = title_match.group(1).strip()
568
+ body = body_match.group(1).strip() if body_match else f"Issue created via GitAgent: {text_content}"
569
+ repo_url = f"https://github.com/{repo}"
570
+
571
+ # Clean ANSI codes from title and body for better presentation
572
+ from diagram_to_iac.tools.text_utils import clean_ansi_codes, enhance_error_message_for_issue
573
+ title = clean_ansi_codes(title)
574
+ body = enhance_error_message_for_issue(body)
575
+
576
+ self.logger.info(f"Parsed test format - Title: {title}, Repo: {repo}")
577
+
578
+ # Convert repo to full URL format expected by the tool
579
+ gh_params = {
580
+ "repo_url": repo_url,
581
+ "title": title,
582
+ "body": body,
583
+ }
584
+ if state.get("issue_id") is not None:
585
+ gh_params["issue_id"] = state["issue_id"]
586
+
587
+ result = self.tools['gh_open_issue'].invoke(gh_params)
588
+
589
+ # Store successful result in memory
590
+ self.memory.add_to_conversation("system", f"Issue created: {result}", {
591
+ "agent": "git_agent",
592
+ "node": "open_issue",
593
+ "repo": repo,
594
+ "title": title,
595
+ "result": result
596
+ })
597
+
598
+ action = "added comment to" if state.get("issue_id") is not None else "created"
599
+ return {
600
+ "final_result": f"Successfully {action} GitHub issue: {result}",
601
+ "error_message": None,
602
+ "operation_type": "issue"
603
+ }
604
+
605
+ except Exception as e:
606
+ self.logger.error(f"Error in open issue node: {e}", exc_info=True)
607
+ self.memory.add_to_conversation("system", f"Issue creation error: {str(e)}", {
608
+ "agent": "git_agent",
609
+ "node": "open_issue",
610
+ "error": True
611
+ })
612
+ return {
613
+ "final_result": "Sorry, I couldn't create the GitHub issue due to an error.",
614
+ "error_message": str(e),
615
+ "operation_type": "issue"
616
+ }
617
+
618
+ def _shell_exec_node(self, state: GitAgentState):
619
+ """
620
+ Shell execution tool node that handles command execution operations.
621
+ """
622
+ self.logger.info(f"Shell exec node invoked for: {state['input_message'].content}")
623
+
624
+ try:
625
+ text_content = state['input_message'].content
626
+
627
+ # Store tool invocation in memory
628
+ self.memory.add_to_conversation("system", f"Shell exec tool invoked", {
629
+ "agent": "git_agent",
630
+ "node": "shell_exec",
631
+ "input": text_content
632
+ })
633
+
634
+ # Extract command from input (simplified parsing)
635
+ import re
636
+
637
+ # More flexible patterns to match test inputs
638
+ command_patterns = [
639
+ r'(?:run|execute|command):\s*[\'"]?(.+?)[\'"]?(?:\.|$)', # "execute: command"
640
+ r'(?:run|execute)\s+[\'"]([^\'\"]+)[\'"]', # "execute 'command'"
641
+ r'(?:run|execute)\s+([^\'\"\n\.]+)', # "execute command"
642
+ ]
643
+
644
+ command = None
645
+ for pattern in command_patterns:
646
+ command_match = re.search(pattern, text_content, re.IGNORECASE)
647
+ if command_match:
648
+ command = command_match.group(1).strip()
649
+ break
650
+
651
+ if not command:
652
+ # Try to extract anything that looks like a command
653
+ words = text_content.split()
654
+ command_candidates = []
655
+ for word in words:
656
+ if word in ['git', 'ls', 'pwd', 'echo', 'cat', 'grep', 'find', 'mkdir', 'rm']:
657
+ # Find the rest of the command
658
+ idx = words.index(word)
659
+ # Look for quoted command or take words until common sentence endings
660
+ remaining = words[idx:]
661
+ command_end = len(remaining)
662
+ for i, w in enumerate(remaining):
663
+ if w.lower() in ['in', 'to', 'for', 'directory', 'files', 'folder']:
664
+ command_end = i
665
+ break
666
+ command_candidates.append(' '.join(remaining[:command_end]))
667
+ break
668
+
669
+ if not command_candidates:
670
+ error_msg = "Could not extract a shell command from the request."
671
+ self.logger.warning(error_msg)
672
+ self.memory.add_to_conversation("system", error_msg, {
673
+ "agent": "git_agent",
674
+ "node": "shell_exec",
675
+ "error": True
676
+ })
677
+ return {
678
+ "final_result": error_msg,
679
+ "error_message": "No command found",
680
+ "operation_type": "shell"
681
+ }
682
+ command = command_candidates[0]
683
+
684
+ # Use the shell_exec tool
685
+ tool_input = {"command": command}
686
+ if state.get("workspace_path"):
687
+ tool_input["cwd"] = state["workspace_path"]
688
+ result = self.tools['shell_exec'].invoke(tool_input)
689
+
690
+ # Store successful result in memory
691
+ self.memory.add_to_conversation("system", f"Command executed: {result}", {
692
+ "agent": "git_agent",
693
+ "node": "shell_exec",
694
+ "command": command,
695
+ "result": result
696
+ })
697
+
698
+ return {
699
+ "final_result": f"Command executed successfully: {result}",
700
+ "error_message": None,
701
+ "operation_type": "shell"
702
+ }
703
+
704
+ except Exception as e:
705
+ self.logger.error(f"Error in shell exec node: {e}", exc_info=True)
706
+ self.memory.add_to_conversation("system", f"Shell exec error: {str(e)}", {
707
+ "agent": "git_agent",
708
+ "node": "shell_exec",
709
+ "error": True
710
+ })
711
+ return {
712
+ "final_result": "Sorry, I couldn't execute the command due to an error.",
713
+ "error_message": str(e),
714
+ "operation_type": "shell"
715
+ }
716
+
717
+ def _create_pr_node(self, state: GitAgentState):
718
+ self.logger.info(f"Create PR node invoked for error type: {state.get('trigger_pr_creation_for_error_type')}")
719
+ error_type = state.get('trigger_pr_creation_for_error_type')
720
+ title = state.get('pr_title') or f"Auto-fix for {error_type}"
721
+ body = state.get('pr_body') or f"Automated PR to address {error_type}."
722
+ repo_local_path = state.get('repo_local_path')
723
+
724
+ if not error_type or not repo_local_path:
725
+ error_msg = "Missing error_type or repo_local_path for PR creation."
726
+ self.logger.error(error_msg)
727
+ return {
728
+ "final_result": f"PR creation failed: {error_msg}",
729
+ "error_message": error_msg,
730
+ "operation_type": "create_pr",
731
+ "pr_url": None,
732
+ "repo_path": repo_local_path # Keep repo_path consistent
733
+ }
734
+
735
+ # Initialize GitPrCreator with the specific repo_local_path for this operation
736
+ git_pr_config = self.config.get('git_pr_creator', {})
737
+ copilot_assignee = git_pr_config.get('copilot_assignee')
738
+ default_assignees = git_pr_config.get('default_assignees', [])
739
+ remote_name = git_pr_config.get('remote_name', 'origin')
740
+
741
+ pr_creator = GitPrCreator(
742
+ repo_path=repo_local_path,
743
+ remote_name=remote_name,
744
+ copilot_assignee=copilot_assignee,
745
+ default_assignees=default_assignees
746
+ )
747
+
748
+ pr_result = pr_creator.create_draft_pr(
749
+ error_type=error_type,
750
+ title=title,
751
+ body=body
752
+ )
753
+
754
+ if pr_result and pr_result.get("status") == "success" and pr_result.get("pr_url"):
755
+ self.memory.add_to_conversation("system", f"Draft PR created: {pr_result['pr_url']}", {
756
+ "agent": "git_agent", "node": "create_pr", "result": "SUCCESS", "pr_details": pr_result
757
+ })
758
+ return {
759
+ "final_result": f"Successfully created draft PR: {pr_result['pr_url']}",
760
+ "error_message": None,
761
+ "operation_type": "create_pr",
762
+ "pr_url": pr_result["pr_url"],
763
+ "repo_path": repo_local_path
764
+ }
765
+ else:
766
+ error_msg = "Failed to create draft PR."
767
+ if pr_result and pr_result.get("message"):
768
+ error_msg = pr_result.get("message")
769
+ self.logger.error(f"PR creation failed: {error_msg}")
770
+ self.memory.add_to_conversation("system", f"PR creation failed: {error_msg}", {
771
+ "agent": "git_agent", "node": "create_pr", "result": "FAILURE", "error_details": pr_result
772
+ })
773
+ return {
774
+ "final_result": f"PR creation failed: {error_msg}",
775
+ "error_message": error_msg,
776
+ "operation_type": "create_pr",
777
+ "pr_url": None,
778
+ "repo_path": repo_local_path
779
+ }
780
+
781
+ def _route_after_planner(self, state: GitAgentState):
782
+ """
783
+ Router function that determines the next node based on planner output.
784
+ Maps routing tokens to appropriate tool nodes or END.
785
+ """
786
+ self.logger.debug(f"Routing after planner. State: {state.get('final_result')}, error: {state.get('error_message')}")
787
+
788
+ if state.get("error_message"):
789
+ self.logger.warning(f"Error detected in planner, routing to END: {state['error_message']}")
790
+ return END
791
+
792
+ final_result = state.get("final_result", "").strip()
793
+
794
+ # Enhanced routing logic with multiple token support
795
+ if final_result in ["route_to_clone", "ROUTE_TO_GIT_CLONE"]:
796
+ return "clone_repo"
797
+ elif final_result in ["route_to_issue", "ROUTE_TO_GITHUB_ISSUE"]:
798
+ return "open_issue"
799
+ elif final_result in ["route_to_shell", "ROUTE_TO_SHELL"]:
800
+ return "shell_exec"
801
+ elif final_result == self.config['routing_keys']['create_pr']:
802
+ return "create_pr_node"
803
+ elif final_result in ["route_to_end", "ROUTE_TO_END"]:
804
+ return END
805
+ else:
806
+ self.logger.warning(f"Unknown routing token: {final_result}, routing to END")
807
+ return END
808
+
809
+ def _build_graph(self):
810
+ """
811
+ Build and compile the LangGraph state machine.
812
+ Creates nodes for planner and each tool, sets up routing.
813
+ """
814
+ graph_builder = StateGraph(GitAgentState)
815
+
816
+ # Add nodes
817
+ graph_builder.add_node("planner_llm", self._planner_llm_node)
818
+ graph_builder.add_node("clone_repo", self._clone_repo_node)
819
+ graph_builder.add_node("open_issue", self._open_issue_node)
820
+ graph_builder.add_node("shell_exec", self._shell_exec_node)
821
+ graph_builder.add_node("create_pr_node", self._create_pr_node)
822
+
823
+ # Set entry point
824
+ graph_builder.set_entry_point("planner_llm")
825
+
826
+ # Configure routing map
827
+ routing_map = self.config.get('routing_map', {
828
+ "clone_repo": "clone_repo",
829
+ "open_issue": "open_issue",
830
+ "shell_exec": "shell_exec",
831
+ "create_pr_node": "create_pr_node", # Added for PR creation
832
+ END: END
833
+ })
834
+
835
+ # Add conditional edges from planner
836
+ graph_builder.add_conditional_edges(
837
+ "planner_llm",
838
+ self._route_after_planner,
839
+ routing_map
840
+ )
841
+
842
+ # Add edges from tool nodes to END
843
+ graph_builder.add_edge("clone_repo", END)
844
+ graph_builder.add_edge("open_issue", END)
845
+ graph_builder.add_edge("shell_exec", END)
846
+ graph_builder.add_edge("create_pr_node", END) # New edge for PR node
847
+
848
+ # Compile with checkpointer
849
+ return graph_builder.compile(checkpointer=self.checkpointer)
850
+
851
+ def run(self, agent_input: GitAgentInput) -> GitAgentOutput:
852
+ """
853
+ Run the agent with the given input.
854
+
855
+ Args:
856
+ agent_input: GitAgentInput with query and optional thread_id
857
+
858
+ Returns:
859
+ GitAgentOutput with result, thread_id, and optional error
860
+ """
861
+ current_thread_id = agent_input.thread_id if agent_input.thread_id else str(uuid.uuid4())
862
+ self.logger.info(
863
+ f"Run invoked with query: '{agent_input.query}', thread_id: {current_thread_id}"
864
+ )
865
+ log_event(
866
+ "git_agent_run_start",
867
+ query=agent_input.query,
868
+ thread_id=current_thread_id,
869
+ )
870
+
871
+ # Initial state for LangGraph
872
+ initial_state = {
873
+ "input_message": HumanMessage(content=agent_input.query),
874
+ "tool_output": [],
875
+ "error_message": None,
876
+ "operation_type": None,
877
+ "workspace_path": agent_input.workspace_path,
878
+ "repo_path": None, # This is for clone output, repo_local_path is for PR input
879
+ "issue_id": agent_input.issue_id,
880
+ "trigger_pr_creation_for_error_type": agent_input.trigger_pr_creation_for_error_type,
881
+ "pr_title": agent_input.pr_title,
882
+ "pr_body": agent_input.pr_body,
883
+ "repo_local_path": agent_input.repo_local_path,
884
+ "pr_url": None,
885
+ }
886
+
887
+ langgraph_config = {"configurable": {"thread_id": current_thread_id}}
888
+
889
+ try:
890
+ # Run the graph
891
+ result_state = self.runnable.invoke(initial_state, config=langgraph_config)
892
+
893
+ # Extract results
894
+ final_result = result_state.get("final_result", "No result found.")
895
+ error_message = result_state.get("error_message")
896
+ operation_type = result_state.get("operation_type")
897
+ repo_path = result_state.get("repo_path") # Output from clone
898
+ pr_url = result_state.get("pr_url") # Output from PR creation
899
+
900
+ if error_message:
901
+ self.logger.error(f"Run completed with error: {error_message}")
902
+ else:
903
+ self.logger.info(f"Run completed successfully: {final_result}")
904
+
905
+ log_event(
906
+ "git_agent_run_end",
907
+ thread_id=current_thread_id,
908
+ error=error_message,
909
+ result=final_result,
910
+ )
911
+
912
+ output = GitAgentOutput(
913
+ result=final_result,
914
+ thread_id=current_thread_id,
915
+ repo_path=repo_path,
916
+ error_message=error_message,
917
+ operation_type=operation_type,
918
+ pr_url=pr_url
919
+ )
920
+ return output
921
+
922
+ except Exception as e:
923
+ self.logger.error(f"Error during agent run: {e}", exc_info=True)
924
+ log_event(
925
+ "git_agent_run_exception",
926
+ thread_id=current_thread_id,
927
+ error=str(e),
928
+ )
929
+ return GitAgentOutput(
930
+ result="An unexpected error occurred during execution.",
931
+ thread_id=current_thread_id,
932
+ repo_path=None, # Or more specifically result_state.get("repo_path") if available
933
+ error_message=str(e),
934
+ operation_type="error",
935
+ pr_url=None
936
+ )
937
+
938
+ def get_conversation_history(self) -> List[Dict[str, Any]]:
939
+ """Get conversation history from memory."""
940
+ return self.memory.get_conversation_history()
941
+
942
+ def get_memory_state(self) -> Dict[str, Any]:
943
+ """Get current memory state."""
944
+ return self.memory.get_state()
945
+
946
+ def plan(self, query: str, **kwargs):
947
+ """
948
+ Generates a plan for the agent to execute (required by AgentBase).
949
+ For GitAgent, the plan analyzes the DevOps request to predict routing.
950
+
951
+ Args:
952
+ query: The DevOps query to plan for
953
+ **kwargs: Additional parameters (e.g., thread_id)
954
+
955
+ Returns:
956
+ dict: A plan containing the input and predicted action
957
+ """
958
+ self.logger.info(f"Planning for DevOps query: '{query}'")
959
+
960
+ plan = {
961
+ "input_query": query,
962
+ "predicted_action": "analyze_and_route",
963
+ "description": "Analyze DevOps request to determine appropriate tool routing"
964
+ }
965
+
966
+ # Simple analysis to predict the route
967
+ query_lower = query.lower()
968
+ if any(word in query_lower for word in ['clone', 'download repo', 'git clone']):
969
+ plan["predicted_route"] = "git_clone"
970
+ elif any(word in query_lower for word in ['open issue', 'create issue', 'report bug', 'issue']) or \
971
+ ('open' in query_lower and 'issue' in query_lower):
972
+ plan["predicted_route"] = "github_issue"
973
+ elif any(word in query_lower for word in ['run command', 'execute', 'shell']):
974
+ plan["predicted_route"] = "shell_command"
975
+ else:
976
+ plan["predicted_route"] = "direct_response"
977
+
978
+ self.logger.debug(f"Generated plan: {plan}")
979
+ return plan
980
+
981
+ def report(self, result=None, **kwargs):
982
+ """
983
+ Reports the results or progress of the agent's execution (required by AgentBase).
984
+
985
+ Args:
986
+ result: The result to report (GitAgentOutput or string)
987
+ **kwargs: Additional parameters
988
+
989
+ Returns:
990
+ dict: A report containing execution details
991
+ """
992
+ if isinstance(result, GitAgentOutput):
993
+ report = {
994
+ "status": "completed",
995
+ "result": result.result,
996
+ "thread_id": result.thread_id,
997
+ "error": result.error_message,
998
+ "operation_type": result.operation_type,
999
+ "success": result.error_message is None
1000
+ }
1001
+ elif isinstance(result, str):
1002
+ report = {
1003
+ "status": "completed",
1004
+ "result": result,
1005
+ "success": True
1006
+ }
1007
+ else:
1008
+ report = {
1009
+ "status": "no_result",
1010
+ "message": "No result provided to report"
1011
+ }
1012
+
1013
+ self.logger.info(f"GitAgent execution report: {report}")
1014
+ return report
1015
+
1016
+
1017
+ # Alias for backward compatibility
1018
+ GitAgent = GitAgent