parthpetkar/metahackathon
0
1"""CI/CD API tool dispatcher — bridges agent tool calls to the WebSocket API.
2
3The agent's tool loop calls ``execute_tool(tool_name, arguments, client)``
4where ``client`` is a ``SyncCICDWebSocketClient`` held open for the episode.
5
6Supported tools (matching API_TOOL_SCHEMAS in api_tool_schemas.py):
7 read_file → client.read_file(path)
8 write_file → client.write_file(path, content)
9 list_files → client.list_files(directory)
10 trigger_pipeline → client.trigger_pipeline() [blocks until pipeline_done]
11 set_hypothesis → local — returns {"success": True, "acknowledged": True}
12 finalize → local — returns {"success": True, "finalized": True}
13
14Errors are caught and returned as {"success": False, "error": "..."} so the
15agent's loop can surface them as tool results without crashing.
16"""
17
18from __future__ import annotations
19
20import logging
21import os
22from typing import Any, Dict, Optional, Tuple
23
24logger = logging.getLogger(__name__)
25
26# Lazy import — ws_client requires websockets which may not be installed
27_ws_client_module = None
28
29
30def _get_ws_client_module():
31 global _ws_client_module
32 if _ws_client_module is None:
33 from agent import ws_client as m
34 _ws_client_module = m
35 return _ws_client_module
36
37
38def create_ws_client(
39 workspace_id: str,
40 base_url: Optional[str] = None,
41 timeout: float = 30.0,
42 pipeline_timeout: float = 300.0,
43):
44 """Create and return a SyncCICDWebSocketClient (not yet connected).
45
46 Caller must use it as a context manager or call connect()/close() manually.
47 """
48 url = base_url or os.getenv("CICD_API_WS_URL", "ws://localhost:8001")
49 mod = _get_ws_client_module()
50 return mod.SyncCICDWebSocketClient(
51 workspace_id=workspace_id,
52 base_url=url,
53 timeout=timeout,
54 pipeline_timeout=pipeline_timeout,
55 )
56
57
58def execute_tool(
59 tool_name: str,
60 arguments: Dict[str, Any],
61 client, # SyncCICDWebSocketClient
62) -> Dict[str, Any]:
63 """Dispatch a tool call to the CI/CD WebSocket API.
64
65 Args:
66 tool_name: Name of the tool (must match API_TOOL_SCHEMAS).
67 arguments: Parsed tool arguments dict from the LLM response.
68 client: An open SyncCICDWebSocketClient instance.
69
70 Returns:
71 A dict with at least {"success": bool} and tool-specific fields.
72 """
73 try:
74 if tool_name == "read_file":
75 path = arguments.get("path", "")
76 if not path:
77 return {"success": False, "error": "read_file requires 'path'"}
78 exists, content = client.read_file(path)
79 return {"success": exists, "path": path, "content": content, "exists": exists}
80
81 elif tool_name == "write_file":
82 path = arguments.get("path", "")
83 content = arguments.get("content", "")
84 if not path:
85 return {"success": False, "error": "write_file requires 'path'"}
86 ok = client.write_file(path, content)
87 return {
88 "success": ok,
89 "path": path,
90 "message": f"File {path} {'updated' if ok else 'update failed'}",
91 }
92
93 elif tool_name == "list_files":
94 directory = arguments.get("directory", "")
95 files, directories = client.list_files(directory)
96 return {"success": True, "files": files, "directories": directories}
97
98 elif tool_name == "trigger_pipeline":
99 result = client.trigger_pipeline()
100 stage_summaries = {}
101 for stage, detail in result.stage_details.items():
102 stage_summaries[stage] = {
103 "status": detail.get("status"),
104 "duration": detail.get("duration"),
105 "logs": (detail.get("logs") or "")[:2000], # cap log size per stage
106 }
107 return {
108 "success": True,
109 "job_id": result.job_id,
110 "status": result.status,
111 "passed": result.passed,
112 "failed_stage": result.failed_stage,
113 "duration": result.duration,
114 "stages": stage_summaries,
115 }
116
117 elif tool_name == "set_hypothesis":
118 hypothesis = arguments.get("hypothesis", "")
119 if not hypothesis:
120 return {"success": False, "error": "set_hypothesis requires 'hypothesis'"}
121 return {"success": True, "acknowledged": True, "hypothesis": hypothesis}
122
123 elif tool_name == "finalize":
124 return {"success": True, "finalized": True}
125
126 else:
127 return {"success": False, "error": f"Unknown tool: {tool_name!r}"}
128
129 except Exception as exc:
130 logger.error("Tool %s failed: %s", tool_name, exc, exc_info=True)
131 return {"success": False, "error": str(exc)}
132
133
134# ── Convenience: format tool result as a human-readable string ──────────────
135
136def format_tool_result(tool_name: str, result: Dict[str, Any]) -> str:
137 """Convert a tool result dict into a compact string for the LLM context."""
138 if not result.get("success"):
139 return f"[{tool_name}] ERROR: {result.get('error', 'unknown error')}"
140
141 if tool_name == "read_file":
142 if not result.get("exists"):
143 return f"[read_file] File not found: {result.get('path')}"
144 content = result.get("content", "")
145 lines = content.splitlines()
146 preview = "\n".join(lines[:200])
147 suffix = f"\n... ({len(lines) - 200} more lines)" if len(lines) > 200 else ""
148 return f"[read_file] {result['path']} ({len(lines)} lines):\n{preview}{suffix}"
149
150 if tool_name == "write_file":
151 return f"[write_file] {result.get('message', 'done')}"
152
153 if tool_name == "list_files":
154 files = result.get("files", [])
155 dirs = result.get("directories", [])
156 parts = []
157 if dirs:
158 parts.append("Directories:\n " + "\n ".join(sorted(dirs)))
159 if files:
160 parts.append("Files:\n " + "\n ".join(sorted(files)))
161 return "[list_files]\n" + "\n".join(parts) if parts else "[list_files] (empty directory)"
162
163 if tool_name == "trigger_pipeline":
164 status = result.get("status", "unknown")
165 failed = result.get("failed_stage")
166 dur = result.get("duration")
167 header = f"[trigger_pipeline] status={status}"
168 if failed:
169 header += f" failed_stage={failed}"
170 if dur:
171 header += f" duration={dur:.1f}s"
172 stages = result.get("stages", {})
173 stage_lines = []
174 for stage, detail in stages.items():
175 st = detail.get("status", "?")
176 logs = (detail.get("logs") or "").strip()
177 stage_lines.append(f"\n--- {stage}: {st} ---\n{logs}" if logs else f"\n--- {stage}: {st} ---")
178 return header + "".join(stage_lines)
179
180 if tool_name == "set_hypothesis":
181 return f"[set_hypothesis] Hypothesis recorded: {result.get('hypothesis', '')}"
182
183 if tool_name == "finalize":
184 return "[finalize] Episode finalised — awaiting score."
185
186 return f"[{tool_name}] {result}"
187 