kubrick-cli 0.1.4__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.
kubrick_cli/tools.py ADDED
@@ -0,0 +1,400 @@
1
+ """Tool definitions and execution handlers."""
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+ from typing import Any, Dict
6
+
7
+ TOOL_DEFINITIONS = [
8
+ {
9
+ "name": "read_file",
10
+ "description": "Read the contents of a file from the filesystem",
11
+ "read_only": True,
12
+ "estimated_duration": 1,
13
+ "parameters": {
14
+ "type": "object",
15
+ "properties": {
16
+ "file_path": {
17
+ "type": "string",
18
+ "description": "The absolute or relative path to the file to read",
19
+ },
20
+ },
21
+ "required": ["file_path"],
22
+ },
23
+ },
24
+ {
25
+ "name": "write_file",
26
+ "description": "Write content to a file (creates or overwrites)",
27
+ "read_only": False,
28
+ "estimated_duration": 2,
29
+ "parameters": {
30
+ "type": "object",
31
+ "properties": {
32
+ "file_path": {
33
+ "type": "string",
34
+ "description": "The absolute or relative path to the file to write",
35
+ },
36
+ "content": {
37
+ "type": "string",
38
+ "description": "The content to write to the file",
39
+ },
40
+ },
41
+ "required": ["file_path", "content"],
42
+ },
43
+ },
44
+ {
45
+ "name": "edit_file",
46
+ "description": "Edit a file by replacing a specific string with new content",
47
+ "read_only": False,
48
+ "estimated_duration": 2,
49
+ "parameters": {
50
+ "type": "object",
51
+ "properties": {
52
+ "file_path": {
53
+ "type": "string",
54
+ "description": "The path to the file to edit",
55
+ },
56
+ "old_string": {
57
+ "type": "string",
58
+ "description": "The exact string to find and replace",
59
+ },
60
+ "new_string": {
61
+ "type": "string",
62
+ "description": "The new string to replace with",
63
+ },
64
+ },
65
+ "required": ["file_path", "old_string", "new_string"],
66
+ },
67
+ },
68
+ {
69
+ "name": "list_files",
70
+ "description": "List files matching a glob pattern (*.py, **/*.py, etc)",
71
+ "read_only": True,
72
+ "estimated_duration": 1,
73
+ "parameters": {
74
+ "type": "object",
75
+ "properties": {
76
+ "pattern": {
77
+ "type": "string",
78
+ "description": "Glob pattern to match files (e.g., '*.py', 'src/**/*.ts')",
79
+ },
80
+ "directory": {
81
+ "type": "string",
82
+ "description": "Directory to search in (defaults to current directory)",
83
+ },
84
+ },
85
+ "required": ["pattern"],
86
+ },
87
+ },
88
+ {
89
+ "name": "search_files",
90
+ "description": "Search for text content within files using grep-like functionality",
91
+ "read_only": True,
92
+ "estimated_duration": 2,
93
+ "parameters": {
94
+ "type": "object",
95
+ "properties": {
96
+ "pattern": {
97
+ "type": "string",
98
+ "description": "Text pattern or regex to search for",
99
+ },
100
+ "file_pattern": {
101
+ "type": "string",
102
+ "description": "File glob pattern to search within (e.g., '*.py')",
103
+ },
104
+ "directory": {
105
+ "type": "string",
106
+ "description": "Directory to search in (defaults to current directory)",
107
+ },
108
+ },
109
+ "required": ["pattern"],
110
+ },
111
+ },
112
+ {
113
+ "name": "run_bash",
114
+ "description": "Execute a bash command and return its output",
115
+ "read_only": False,
116
+ "estimated_duration": 5,
117
+ "parameters": {
118
+ "type": "object",
119
+ "properties": {
120
+ "command": {
121
+ "type": "string",
122
+ "description": "The bash command to execute",
123
+ },
124
+ },
125
+ "required": ["command"],
126
+ },
127
+ },
128
+ {
129
+ "name": "create_directory",
130
+ "description": "Create a new directory",
131
+ "read_only": False,
132
+ "estimated_duration": 1,
133
+ "parameters": {
134
+ "type": "object",
135
+ "properties": {
136
+ "path": {
137
+ "type": "string",
138
+ "description": "The path of the directory to create",
139
+ },
140
+ },
141
+ "required": ["path"],
142
+ },
143
+ },
144
+ ]
145
+
146
+
147
+ class ToolExecutor:
148
+ """Handles execution of tools."""
149
+
150
+ def __init__(self, working_dir: str = None, safety_manager=None):
151
+ """
152
+ Initialize tool executor.
153
+
154
+ Args:
155
+ working_dir: Working directory for file operations (defaults to current directory)
156
+ safety_manager: Optional SafetyManager for validation
157
+ """
158
+ self.working_dir = Path(working_dir) if working_dir else Path.cwd()
159
+ self.safety_manager = safety_manager
160
+
161
+ def _resolve_path(self, path: str) -> Path:
162
+ """Resolve a path relative to working directory."""
163
+ p = Path(path)
164
+ if p.is_absolute():
165
+ return p
166
+ return (self.working_dir / p).resolve()
167
+
168
+ def execute(self, tool_name: str, parameters: Dict[str, Any]) -> Dict[str, Any]:
169
+ """
170
+ Execute a tool with given parameters.
171
+
172
+ Args:
173
+ tool_name: Name of the tool to execute
174
+ parameters: Tool parameters
175
+
176
+ Returns:
177
+ Dict with 'success', 'result', and optionally 'error' keys
178
+ """
179
+ try:
180
+ if tool_name == "read_file":
181
+ return self._read_file(parameters)
182
+ elif tool_name == "write_file":
183
+ return self._write_file(parameters)
184
+ elif tool_name == "edit_file":
185
+ return self._edit_file(parameters)
186
+ elif tool_name == "list_files":
187
+ return self._list_files(parameters)
188
+ elif tool_name == "search_files":
189
+ return self._search_files(parameters)
190
+ elif tool_name == "run_bash":
191
+ return self._run_bash(parameters)
192
+ elif tool_name == "create_directory":
193
+ return self._create_directory(parameters)
194
+ else:
195
+ return {
196
+ "success": False,
197
+ "error": f"Unknown tool: {tool_name}",
198
+ }
199
+ except Exception as e:
200
+ return {
201
+ "success": False,
202
+ "error": str(e),
203
+ }
204
+
205
+ def _read_file(self, params: Dict) -> Dict:
206
+ """Read a file."""
207
+ file_path = self._resolve_path(params["file_path"])
208
+
209
+ if not file_path.exists():
210
+ return {"success": False, "error": f"File not found: {file_path}"}
211
+
212
+ content = file_path.read_text()
213
+ return {
214
+ "success": True,
215
+ "result": f"Content of {file_path}:\n\n{content}",
216
+ }
217
+
218
+ def _write_file(self, params: Dict) -> Dict:
219
+ """Write to a file."""
220
+ file_path = self._resolve_path(params["file_path"])
221
+ content = params["content"]
222
+
223
+ file_path.parent.mkdir(parents=True, exist_ok=True)
224
+
225
+ file_path.write_text(content)
226
+ return {
227
+ "success": True,
228
+ "result": f"Successfully wrote {len(content)} characters to {file_path}",
229
+ }
230
+
231
+ def _edit_file(self, params: Dict) -> Dict:
232
+ """Edit a file by replacing text."""
233
+ file_path = self._resolve_path(params["file_path"])
234
+ old_string = params["old_string"]
235
+ new_string = params["new_string"]
236
+
237
+ if not file_path.exists():
238
+ return {"success": False, "error": f"File not found: {file_path}"}
239
+
240
+ content = file_path.read_text()
241
+
242
+ if old_string not in content:
243
+ return {
244
+ "success": False,
245
+ "error": f"String not found in file: {old_string[:100]}...",
246
+ }
247
+
248
+ new_content = content.replace(old_string, new_string, 1)
249
+ file_path.write_text(new_content)
250
+
251
+ return {
252
+ "success": True,
253
+ "result": f"Successfully edited {file_path}",
254
+ }
255
+
256
+ def _list_files(self, params: Dict) -> Dict:
257
+ """List files matching a pattern."""
258
+ pattern = params["pattern"]
259
+ directory = params.get("directory", ".")
260
+ search_dir = self._resolve_path(directory)
261
+
262
+ files = []
263
+ directories = []
264
+
265
+ for match in search_dir.glob(pattern):
266
+ rel_path = match.relative_to(search_dir)
267
+ if match.is_file():
268
+ files.append(str(rel_path))
269
+ elif match.is_dir():
270
+ directories.append(str(rel_path) + "/")
271
+
272
+ files.sort()
273
+ directories.sort()
274
+
275
+ matches = directories + files
276
+
277
+ if not matches:
278
+ result = f"No files or directories found matching pattern: {pattern}"
279
+ else:
280
+ dir_count = len(directories)
281
+ file_count = len(files)
282
+ result = f"Found {dir_count} directories and {file_count} files:\n"
283
+
284
+ if directories:
285
+ result += "\nDirectories:\n" + "\n".join(directories)
286
+ if files:
287
+ result += "\n\nFiles:\n" + "\n".join(files)
288
+
289
+ return {"success": True, "result": result}
290
+
291
+ def _search_files(self, params: Dict) -> Dict:
292
+ """Search for text in files."""
293
+ pattern = params["pattern"]
294
+ file_pattern = params.get("file_pattern", "**/*")
295
+ directory = params.get("directory", ".")
296
+ search_dir = self._resolve_path(directory)
297
+
298
+ results = []
299
+
300
+ for file_path in search_dir.glob(file_pattern):
301
+ if not file_path.is_file():
302
+ continue
303
+
304
+ try:
305
+ content = file_path.read_text()
306
+ lines = content.split("\n")
307
+
308
+ for line_num, line in enumerate(lines, 1):
309
+ if pattern in line:
310
+ rel_path = file_path.relative_to(search_dir)
311
+ results.append(f"{rel_path}:{line_num}: {line.strip()}")
312
+ except (UnicodeDecodeError, PermissionError):
313
+ continue
314
+
315
+ if not results:
316
+ result = f"No matches found for pattern: {pattern}"
317
+ else:
318
+ result = f"Found {len(results)} matches:\n" + "\n".join(results[:50])
319
+ if len(results) > 50:
320
+ result += f"\n... and {len(results) - 50} more"
321
+
322
+ return {"success": True, "result": result}
323
+
324
+ def _run_bash(self, params: Dict) -> Dict:
325
+ """Run a bash command."""
326
+ command = params["command"]
327
+
328
+ if self.safety_manager:
329
+ is_safe, warning = self.safety_manager.validate_bash_command(command)
330
+
331
+ if not is_safe:
332
+ confirmed = self.safety_manager.get_user_confirmation(warning, command)
333
+
334
+ if not confirmed:
335
+ return {
336
+ "success": False,
337
+ "error": "Command cancelled by user (dangerous command)",
338
+ }
339
+
340
+ try:
341
+ timeout = 30
342
+ if self.safety_manager and hasattr(self.safety_manager, "config"):
343
+ timeout = self.safety_manager.config.tool_timeout_seconds
344
+
345
+ # nosec B602: shell=True is required for bash command execution
346
+ # Commands are validated by SafetyManager before execution
347
+ result = subprocess.run(
348
+ command,
349
+ shell=True, # nosec
350
+ capture_output=True,
351
+ text=True,
352
+ timeout=timeout,
353
+ cwd=self.working_dir,
354
+ )
355
+
356
+ output = result.stdout
357
+ if result.stderr:
358
+ output += f"\nSTDERR:\n{result.stderr}"
359
+
360
+ return {
361
+ "success": True,
362
+ "result": f"Command: {command}\nExit code: {result.returncode}\n\n{output}",
363
+ }
364
+ except subprocess.TimeoutExpired:
365
+ return {
366
+ "success": False,
367
+ "error": f"Command timed out after {timeout} seconds",
368
+ }
369
+
370
+ def _create_directory(self, params: Dict) -> Dict:
371
+ """Create a directory."""
372
+ path = self._resolve_path(params["path"])
373
+ path.mkdir(parents=True, exist_ok=True)
374
+ return {
375
+ "success": True,
376
+ "result": f"Created directory: {path}",
377
+ }
378
+
379
+
380
+ def get_tools_prompt() -> str:
381
+ """Get a formatted string describing available tools for the LLM."""
382
+ tools_desc = ""
383
+
384
+ for tool in TOOL_DEFINITIONS:
385
+ tools_desc += f"### {tool['name']}\n"
386
+ tools_desc += f"{tool['description']}\n\n"
387
+
388
+ props = tool["parameters"].get("properties", {})
389
+ required = tool["parameters"].get("required", [])
390
+
391
+ if props:
392
+ tools_desc += "**Parameters:**\n"
393
+ for param_name, param_info in props.items():
394
+ req_marker = " (required)" if param_name in required else " (optional)"
395
+ param_desc = param_info.get("description", "No description")
396
+ tools_desc += f"- `{param_name}`: {param_desc}{req_marker}\n"
397
+
398
+ tools_desc += "\n"
399
+
400
+ return tools_desc
@@ -0,0 +1,177 @@
1
+ """Triton client for streaming LLM requests using HTTP only (no dependencies)."""
2
+
3
+ import http.client
4
+ import json
5
+ import ssl
6
+ from typing import Dict, Iterator, List
7
+ from urllib.parse import urlparse
8
+
9
+
10
+ class TritonLLMClient:
11
+ """Client for interacting with Triton LLM backend using HTTP (no extra dependencies)."""
12
+
13
+ def __init__(
14
+ self,
15
+ url: str = "localhost:8000",
16
+ model_name: str = "llm_decoupled",
17
+ ):
18
+ """
19
+ Initialize Triton LLM client.
20
+
21
+ Args:
22
+ url: Triton server URL (host:port, default: localhost:8000)
23
+ model_name: Name of the Triton model to use
24
+ """
25
+ self.model_name = model_name
26
+
27
+ if not url.startswith("http://") and not url.startswith("https://"):
28
+ url = f"http://{url}"
29
+
30
+ parsed = urlparse(url)
31
+ self.is_https = parsed.scheme == "https"
32
+ self.host = parsed.hostname or "localhost"
33
+ self.port = parsed.port or (443 if self.is_https else 8000)
34
+ self.timeout = 600
35
+ self.url = f"{parsed.scheme}://{self.host}:{self.port}"
36
+
37
+ def _get_connection(self) -> http.client.HTTPConnection:
38
+ """Create an HTTP(S) connection."""
39
+ if self.is_https:
40
+ context = ssl.create_default_context()
41
+ return http.client.HTTPSConnection(
42
+ self.host, self.port, timeout=self.timeout, context=context
43
+ )
44
+ else:
45
+ return http.client.HTTPConnection(
46
+ self.host, self.port, timeout=self.timeout
47
+ )
48
+
49
+ def generate_streaming(
50
+ self,
51
+ messages: List[Dict[str, str]],
52
+ stream_options: Dict = None,
53
+ ) -> Iterator[str]:
54
+ """
55
+ Generate streaming response from LLM.
56
+
57
+ Args:
58
+ messages: List of message dicts with 'role' and 'content'
59
+ stream_options: Optional streaming parameters
60
+
61
+ Yields:
62
+ Text chunks as they arrive
63
+ """
64
+ if stream_options is None:
65
+ stream_options = {"streaming": True}
66
+ else:
67
+ stream_options = {"streaming": True, **stream_options}
68
+
69
+ payload = {
70
+ "text_input": json.dumps(messages),
71
+ "parameters": stream_options,
72
+ }
73
+
74
+ headers = {
75
+ "Content-Type": "application/json",
76
+ "Accept": "text/event-stream",
77
+ }
78
+
79
+ body = json.dumps(payload).encode("utf-8")
80
+ path = f"/v2/models/{self.model_name}/generate_stream"
81
+
82
+ conn = None
83
+ try:
84
+ conn = self._get_connection()
85
+ conn.request("POST", path, body=body, headers=headers)
86
+ response = conn.getresponse()
87
+
88
+ if response.status not in (200, 201):
89
+ error_body = response.read().decode("utf-8")
90
+ raise Exception(f"Server returned {response.status}: {error_body}")
91
+
92
+ byte_buffer = b""
93
+ while True:
94
+ chunk = response.read(1024)
95
+ if not chunk:
96
+ break
97
+
98
+ byte_buffer += chunk
99
+
100
+ while b"\n" in byte_buffer:
101
+ line_bytes, byte_buffer = byte_buffer.split(b"\n", 1)
102
+
103
+ try:
104
+ line = line_bytes.decode("utf-8").strip()
105
+ except UnicodeDecodeError:
106
+ continue
107
+
108
+ if not line:
109
+ continue
110
+
111
+ if line.startswith("data: "):
112
+ line = line[6:]
113
+
114
+ if line == "[DONE]":
115
+ return
116
+
117
+ try:
118
+ data = json.loads(line)
119
+
120
+ output_data = None
121
+ if "text_output" in data:
122
+ output_data = data["text_output"]
123
+ elif "outputs" in data and len(data["outputs"]) > 0:
124
+ output_data = data["outputs"][0].get("data", [""])[0]
125
+
126
+ if output_data:
127
+ try:
128
+ chunk_data = json.loads(output_data)
129
+
130
+ if chunk_data.get("type") == "chunk":
131
+ yield chunk_data.get("content", "")
132
+ elif chunk_data.get("type") == "complete":
133
+ return
134
+ elif chunk_data.get("type") == "error":
135
+ raise Exception(
136
+ f"LLM error: {chunk_data.get('content')}"
137
+ )
138
+ except (json.JSONDecodeError, TypeError):
139
+ yield output_data
140
+
141
+ except json.JSONDecodeError:
142
+ continue
143
+
144
+ finally:
145
+ if conn:
146
+ conn.close()
147
+
148
+ def generate(
149
+ self,
150
+ messages: List[Dict[str, str]],
151
+ stream_options: Dict = None,
152
+ ) -> str:
153
+ """
154
+ Generate non-streaming response from LLM.
155
+
156
+ Args:
157
+ messages: List of message dicts with 'role' and 'content'
158
+ stream_options: Optional parameters
159
+
160
+ Returns:
161
+ Complete response text
162
+ """
163
+ chunks = []
164
+ for chunk in self.generate_streaming(messages, stream_options):
165
+ chunks.append(chunk)
166
+ return "".join(chunks)
167
+
168
+ def is_healthy(self) -> bool:
169
+ """Check if Triton server is healthy."""
170
+ try:
171
+ conn = self._get_connection()
172
+ conn.request("GET", "/v2/health/live")
173
+ response = conn.getresponse()
174
+ conn.close()
175
+ return response.status == 200
176
+ except Exception:
177
+ return False