kubrick-cli 0.1.2__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/__init__.py +47 -0
- kubrick_cli/agent_loop.py +307 -0
- kubrick_cli/classifier.py +198 -0
- kubrick_cli/config.py +248 -0
- kubrick_cli/display.py +165 -0
- kubrick_cli/execution_strategy.py +197 -0
- kubrick_cli/main.py +797 -0
- kubrick_cli/planning.py +328 -0
- kubrick_cli/progress.py +172 -0
- kubrick_cli/providers/__init__.py +6 -0
- kubrick_cli/providers/anthropic_provider.py +224 -0
- kubrick_cli/providers/base.py +137 -0
- kubrick_cli/providers/factory.py +183 -0
- kubrick_cli/providers/openai_provider.py +190 -0
- kubrick_cli/providers/triton_provider.py +96 -0
- kubrick_cli/safety.py +216 -0
- kubrick_cli/scheduler.py +197 -0
- kubrick_cli/setup_wizard.py +172 -0
- kubrick_cli/tools.py +407 -0
- kubrick_cli/triton_client.py +192 -0
- kubrick_cli-0.1.2.dist-info/METADATA +86 -0
- kubrick_cli-0.1.2.dist-info/RECORD +26 -0
- kubrick_cli-0.1.2.dist-info/WHEEL +5 -0
- kubrick_cli-0.1.2.dist-info/entry_points.txt +2 -0
- kubrick_cli-0.1.2.dist-info/licenses/LICENSE +21 -0
- kubrick_cli-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""OpenAI provider adapter."""
|
|
2
|
+
|
|
3
|
+
import http.client
|
|
4
|
+
import json
|
|
5
|
+
import ssl
|
|
6
|
+
from typing import Dict, Iterator, List
|
|
7
|
+
|
|
8
|
+
from .base import ProviderAdapter, ProviderMetadata
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OpenAIProvider(ProviderAdapter):
|
|
12
|
+
"""Provider adapter for OpenAI API."""
|
|
13
|
+
|
|
14
|
+
METADATA = ProviderMetadata(
|
|
15
|
+
name="openai",
|
|
16
|
+
display_name="OpenAI",
|
|
17
|
+
description="OpenAI API (GPT-4, GPT-3.5, etc.)",
|
|
18
|
+
config_fields=[
|
|
19
|
+
{
|
|
20
|
+
"key": "openai_api_key",
|
|
21
|
+
"label": "OpenAI API key",
|
|
22
|
+
"type": "password",
|
|
23
|
+
"help_text": "Get your API key from: https://platform.openai.com/api-keys",
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"key": "openai_model",
|
|
27
|
+
"label": "Model name",
|
|
28
|
+
"type": "text",
|
|
29
|
+
"default": "gpt-4",
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def __init__(self, openai_api_key: str, openai_model: str = "gpt-4"):
|
|
35
|
+
"""
|
|
36
|
+
Initialize OpenAI provider.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
openai_api_key: OpenAI API key
|
|
40
|
+
openai_model: Model name (default: gpt-4)
|
|
41
|
+
"""
|
|
42
|
+
self.api_key = openai_api_key
|
|
43
|
+
self._model_name = openai_model
|
|
44
|
+
self.base_url = "api.openai.com"
|
|
45
|
+
self.timeout = 600
|
|
46
|
+
|
|
47
|
+
def generate_streaming(
|
|
48
|
+
self, messages: List[Dict[str, str]], stream_options: Dict = None
|
|
49
|
+
) -> Iterator[str]:
|
|
50
|
+
"""
|
|
51
|
+
Generate streaming response from OpenAI.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
messages: List of message dicts with 'role' and 'content'
|
|
55
|
+
stream_options: Optional streaming parameters
|
|
56
|
+
|
|
57
|
+
Yields:
|
|
58
|
+
Text chunks as they arrive
|
|
59
|
+
"""
|
|
60
|
+
# Build request payload
|
|
61
|
+
payload = {
|
|
62
|
+
"model": self._model_name,
|
|
63
|
+
"messages": messages,
|
|
64
|
+
"stream": True,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# Add optional parameters
|
|
68
|
+
if stream_options:
|
|
69
|
+
if "temperature" in stream_options:
|
|
70
|
+
payload["temperature"] = stream_options["temperature"]
|
|
71
|
+
if "max_tokens" in stream_options:
|
|
72
|
+
payload["max_tokens"] = stream_options["max_tokens"]
|
|
73
|
+
|
|
74
|
+
headers = {
|
|
75
|
+
"Content-Type": "application/json",
|
|
76
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
body = json.dumps(payload).encode("utf-8")
|
|
80
|
+
|
|
81
|
+
# Create HTTPS connection
|
|
82
|
+
context = ssl.create_default_context()
|
|
83
|
+
conn = http.client.HTTPSConnection(
|
|
84
|
+
self.base_url, 443, timeout=self.timeout, context=context
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
conn.request("POST", "/v1/chat/completions", body=body, headers=headers)
|
|
89
|
+
response = conn.getresponse()
|
|
90
|
+
|
|
91
|
+
if response.status != 200:
|
|
92
|
+
error_body = response.read().decode("utf-8")
|
|
93
|
+
raise Exception(f"OpenAI API error {response.status}: {error_body}")
|
|
94
|
+
|
|
95
|
+
# Read streaming response
|
|
96
|
+
buffer = ""
|
|
97
|
+
while True:
|
|
98
|
+
chunk = response.read(1024)
|
|
99
|
+
if not chunk:
|
|
100
|
+
break
|
|
101
|
+
|
|
102
|
+
if isinstance(chunk, bytes):
|
|
103
|
+
chunk = chunk.decode("utf-8")
|
|
104
|
+
|
|
105
|
+
buffer += chunk
|
|
106
|
+
|
|
107
|
+
# Process complete lines
|
|
108
|
+
while "\n" in buffer:
|
|
109
|
+
line, buffer = buffer.split("\n", 1)
|
|
110
|
+
line = line.strip()
|
|
111
|
+
|
|
112
|
+
if not line:
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
# OpenAI SSE format: "data: {json}"
|
|
116
|
+
if line.startswith("data: "):
|
|
117
|
+
line = line[6:]
|
|
118
|
+
|
|
119
|
+
# Check for end signal
|
|
120
|
+
if line == "[DONE]":
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
data = json.loads(line)
|
|
125
|
+
# Extract delta content
|
|
126
|
+
if "choices" in data and len(data["choices"]) > 0:
|
|
127
|
+
delta = data["choices"][0].get("delta", {})
|
|
128
|
+
content = delta.get("content", "")
|
|
129
|
+
if content:
|
|
130
|
+
yield content
|
|
131
|
+
except json.JSONDecodeError:
|
|
132
|
+
# Skip malformed JSON
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
finally:
|
|
136
|
+
conn.close()
|
|
137
|
+
|
|
138
|
+
def generate(
|
|
139
|
+
self, messages: List[Dict[str, str]], stream_options: Dict = None
|
|
140
|
+
) -> str:
|
|
141
|
+
"""
|
|
142
|
+
Generate non-streaming response from OpenAI.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
messages: List of message dicts with 'role' and 'content'
|
|
146
|
+
stream_options: Optional parameters
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Complete response text
|
|
150
|
+
"""
|
|
151
|
+
chunks = []
|
|
152
|
+
for chunk in self.generate_streaming(messages, stream_options):
|
|
153
|
+
chunks.append(chunk)
|
|
154
|
+
return "".join(chunks)
|
|
155
|
+
|
|
156
|
+
def is_healthy(self) -> bool:
|
|
157
|
+
"""
|
|
158
|
+
Check if OpenAI API is accessible.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
True if healthy, False otherwise
|
|
162
|
+
"""
|
|
163
|
+
try:
|
|
164
|
+
context = ssl.create_default_context()
|
|
165
|
+
conn = http.client.HTTPSConnection(
|
|
166
|
+
self.base_url, 443, timeout=10, context=context
|
|
167
|
+
)
|
|
168
|
+
headers = {
|
|
169
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
170
|
+
}
|
|
171
|
+
conn.request("GET", "/v1/models", headers=headers)
|
|
172
|
+
response = conn.getresponse()
|
|
173
|
+
conn.close()
|
|
174
|
+
return response.status == 200
|
|
175
|
+
except Exception:
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def provider_name(self) -> str:
|
|
180
|
+
"""Get provider name."""
|
|
181
|
+
return "openai"
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def model_name(self) -> str:
|
|
185
|
+
"""Get model name."""
|
|
186
|
+
return self._model_name
|
|
187
|
+
|
|
188
|
+
def set_model(self, model_name: str):
|
|
189
|
+
"""Set model name dynamically."""
|
|
190
|
+
self._model_name = model_name
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Triton provider adapter."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Iterator, List
|
|
4
|
+
|
|
5
|
+
from ..triton_client import TritonLLMClient
|
|
6
|
+
from .base import ProviderAdapter, ProviderMetadata
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TritonProvider(ProviderAdapter):
|
|
10
|
+
"""Provider adapter for Triton Inference Server."""
|
|
11
|
+
|
|
12
|
+
METADATA = ProviderMetadata(
|
|
13
|
+
name="triton",
|
|
14
|
+
display_name="Triton",
|
|
15
|
+
description="Self-hosted Triton Inference Server (default)",
|
|
16
|
+
config_fields=[
|
|
17
|
+
{
|
|
18
|
+
"key": "triton_url",
|
|
19
|
+
"label": "Triton server URL",
|
|
20
|
+
"type": "url",
|
|
21
|
+
"default": "localhost:8000",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"key": "triton_model",
|
|
25
|
+
"label": "Triton model name",
|
|
26
|
+
"type": "text",
|
|
27
|
+
"default": "llm_decoupled",
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def __init__(self, triton_url: str = "localhost:8000", triton_model: str = "llm_decoupled"):
|
|
33
|
+
"""
|
|
34
|
+
Initialize Triton provider.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
triton_url: Triton server URL (default: localhost:8000)
|
|
38
|
+
triton_model: Triton model name (default: llm_decoupled)
|
|
39
|
+
"""
|
|
40
|
+
self.client = TritonLLMClient(url=triton_url, model_name=triton_model)
|
|
41
|
+
self._url = triton_url
|
|
42
|
+
self._model_name = triton_model
|
|
43
|
+
|
|
44
|
+
def generate_streaming(
|
|
45
|
+
self, messages: List[Dict[str, str]], stream_options: Dict = None
|
|
46
|
+
) -> Iterator[str]:
|
|
47
|
+
"""
|
|
48
|
+
Generate streaming response from Triton.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
messages: List of message dicts with 'role' and 'content'
|
|
52
|
+
stream_options: Optional streaming parameters
|
|
53
|
+
|
|
54
|
+
Yields:
|
|
55
|
+
Text chunks as they arrive
|
|
56
|
+
"""
|
|
57
|
+
yield from self.client.generate_streaming(messages, stream_options)
|
|
58
|
+
|
|
59
|
+
def generate(
|
|
60
|
+
self, messages: List[Dict[str, str]], stream_options: Dict = None
|
|
61
|
+
) -> str:
|
|
62
|
+
"""
|
|
63
|
+
Generate non-streaming response from Triton.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
messages: List of message dicts with 'role' and 'content'
|
|
67
|
+
stream_options: Optional parameters
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Complete response text
|
|
71
|
+
"""
|
|
72
|
+
return self.client.generate(messages, stream_options)
|
|
73
|
+
|
|
74
|
+
def is_healthy(self) -> bool:
|
|
75
|
+
"""
|
|
76
|
+
Check if Triton server is healthy.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
True if healthy, False otherwise
|
|
80
|
+
"""
|
|
81
|
+
return self.client.is_healthy()
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def provider_name(self) -> str:
|
|
85
|
+
"""Get provider name."""
|
|
86
|
+
return "triton"
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def model_name(self) -> str:
|
|
90
|
+
"""Get model name."""
|
|
91
|
+
return self._model_name
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def url(self) -> str:
|
|
95
|
+
"""Get Triton server URL."""
|
|
96
|
+
return self._url
|
kubrick_cli/safety.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Safety manager for dangerous command detection and validation."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Dict, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.prompt import Confirm
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class SafetyConfig:
|
|
16
|
+
"""Safety configuration settings."""
|
|
17
|
+
|
|
18
|
+
max_iterations: int = 15
|
|
19
|
+
max_tools_per_turn: int = 5
|
|
20
|
+
total_timeout_seconds: int = 600
|
|
21
|
+
tool_timeout_seconds: int = 30
|
|
22
|
+
max_file_size_mb: int = 10
|
|
23
|
+
require_dangerous_command_confirmation: bool = True
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_config(cls, config: Dict) -> "SafetyConfig":
|
|
27
|
+
"""
|
|
28
|
+
Create SafetyConfig from configuration dict.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
config: Configuration dictionary
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
SafetyConfig instance
|
|
35
|
+
"""
|
|
36
|
+
return cls(
|
|
37
|
+
max_iterations=config.get("max_iterations", 15),
|
|
38
|
+
max_tools_per_turn=config.get("max_tools_per_turn", 5),
|
|
39
|
+
total_timeout_seconds=config.get("total_timeout_seconds", 600),
|
|
40
|
+
tool_timeout_seconds=config.get("tool_timeout_seconds", 30),
|
|
41
|
+
max_file_size_mb=config.get("max_file_size_mb", 10),
|
|
42
|
+
require_dangerous_command_confirmation=config.get(
|
|
43
|
+
"require_dangerous_command_confirmation", True
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SafetyManager:
|
|
49
|
+
"""
|
|
50
|
+
Manages safety checks for tool execution.
|
|
51
|
+
|
|
52
|
+
Features:
|
|
53
|
+
- Dangerous command detection
|
|
54
|
+
- User confirmation prompts
|
|
55
|
+
- File size limits
|
|
56
|
+
- Timeout enforcement
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
# Dangerous bash command patterns
|
|
60
|
+
DANGEROUS_PATTERNS = [
|
|
61
|
+
(r"\brm\s+-rf\s+/", "Recursive delete from root"),
|
|
62
|
+
(r"\brm\s+-rf\s+~", "Recursive delete from home"),
|
|
63
|
+
(r"\brm\s+-rf\s+\*", "Recursive delete all files"),
|
|
64
|
+
(r"\brm\s+-rf", "Recursive force delete"),
|
|
65
|
+
(r"\bsudo\b", "Elevated privileges"),
|
|
66
|
+
(r"\bchmod\s+777", "Overly permissive permissions"),
|
|
67
|
+
(r"\bchmod\s+-R\s+777", "Recursive overly permissive permissions"),
|
|
68
|
+
(r">\s*/dev/", "Writing to device files"),
|
|
69
|
+
(r"\bgit\s+push\s+--force", "Force push to git"),
|
|
70
|
+
(r"\bgit\s+push\s+-f", "Force push to git (short form)"),
|
|
71
|
+
(r"\bmkfs\b", "Format filesystem"),
|
|
72
|
+
(r"\bdd\s+.*of=/dev", "Writing to block device"),
|
|
73
|
+
(r"\b:(\()\|:\1&", "Fork bomb"),
|
|
74
|
+
(r"\bcurl\b.*\|\s*bash", "Pipe curl to bash"),
|
|
75
|
+
(r"\bwget\b.*\|\s*bash", "Pipe wget to bash"),
|
|
76
|
+
(r"\beval\b.*\$\(", "Eval with command substitution"),
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
def __init__(self, config: SafetyConfig):
|
|
80
|
+
"""
|
|
81
|
+
Initialize safety manager.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
config: SafetyConfig instance
|
|
85
|
+
"""
|
|
86
|
+
self.config = config
|
|
87
|
+
|
|
88
|
+
def validate_bash_command(
|
|
89
|
+
self, command: str
|
|
90
|
+
) -> Tuple[bool, Optional[str]]:
|
|
91
|
+
"""
|
|
92
|
+
Validate a bash command for dangerous patterns.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
command: The bash command to validate
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Tuple of (is_safe, warning_message)
|
|
99
|
+
- is_safe: True if safe, False if dangerous
|
|
100
|
+
- warning_message: Description of the danger (if any)
|
|
101
|
+
"""
|
|
102
|
+
command_lower = command.lower()
|
|
103
|
+
|
|
104
|
+
# Check each dangerous pattern
|
|
105
|
+
for pattern, description in self.DANGEROUS_PATTERNS:
|
|
106
|
+
if re.search(pattern, command_lower, re.IGNORECASE):
|
|
107
|
+
return False, f"Dangerous command detected: {description}"
|
|
108
|
+
|
|
109
|
+
return True, None
|
|
110
|
+
|
|
111
|
+
def get_user_confirmation(
|
|
112
|
+
self, warning: str, command: str
|
|
113
|
+
) -> bool:
|
|
114
|
+
"""
|
|
115
|
+
Get user confirmation for a dangerous command.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
warning: Warning message
|
|
119
|
+
command: The dangerous command
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
True if user confirms, False otherwise
|
|
123
|
+
"""
|
|
124
|
+
if not self.config.require_dangerous_command_confirmation:
|
|
125
|
+
# Auto-allow if confirmation disabled
|
|
126
|
+
return True
|
|
127
|
+
|
|
128
|
+
# Display warning
|
|
129
|
+
console.print(
|
|
130
|
+
Panel(
|
|
131
|
+
f"[bold red]⚠️ WARNING[/bold red]\n\n"
|
|
132
|
+
f"{warning}\n\n"
|
|
133
|
+
f"[bold]Command:[/bold]\n"
|
|
134
|
+
f"[yellow]{command}[/yellow]\n\n"
|
|
135
|
+
f"This command could be dangerous.",
|
|
136
|
+
border_style="red",
|
|
137
|
+
title="Safety Check",
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Ask for confirmation
|
|
142
|
+
confirmed = Confirm.ask(
|
|
143
|
+
"[bold red]Do you want to execute this command?[/bold red]",
|
|
144
|
+
default=False,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
if confirmed:
|
|
148
|
+
console.print("[yellow]⚠️ Proceeding with caution...[/yellow]")
|
|
149
|
+
else:
|
|
150
|
+
console.print("[green]✓ Command cancelled[/green]")
|
|
151
|
+
|
|
152
|
+
return confirmed
|
|
153
|
+
|
|
154
|
+
def validate_file_size(self, file_path: str, size_bytes: int) -> bool:
|
|
155
|
+
"""
|
|
156
|
+
Validate file size against limits.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
file_path: Path to the file
|
|
160
|
+
size_bytes: File size in bytes
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
True if within limits, False otherwise
|
|
164
|
+
"""
|
|
165
|
+
max_bytes = self.config.max_file_size_mb * 1024 * 1024
|
|
166
|
+
|
|
167
|
+
if size_bytes > max_bytes:
|
|
168
|
+
console.print(
|
|
169
|
+
f"[yellow]⚠️ File {file_path} exceeds size limit "
|
|
170
|
+
f"({size_bytes / 1024 / 1024:.1f}MB > {self.config.max_file_size_mb}MB)[/yellow]"
|
|
171
|
+
)
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
def check_iteration_limit(
|
|
177
|
+
self, current: int, max_iterations: int
|
|
178
|
+
) -> bool:
|
|
179
|
+
"""
|
|
180
|
+
Check if iteration limit has been reached.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
current: Current iteration number
|
|
184
|
+
max_iterations: Maximum allowed iterations
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
True if within limit, False otherwise
|
|
188
|
+
"""
|
|
189
|
+
if current >= max_iterations:
|
|
190
|
+
console.print(
|
|
191
|
+
f"[yellow]⚠️ Max iteration limit reached ({max_iterations})[/yellow]"
|
|
192
|
+
)
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
return True
|
|
196
|
+
|
|
197
|
+
def check_tool_limit(
|
|
198
|
+
self, current: int, max_tools: int
|
|
199
|
+
) -> bool:
|
|
200
|
+
"""
|
|
201
|
+
Check if tool call limit has been reached.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
current: Current number of tool calls
|
|
205
|
+
max_tools: Maximum allowed tools
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
True if within limit, False otherwise
|
|
209
|
+
"""
|
|
210
|
+
if current > max_tools:
|
|
211
|
+
console.print(
|
|
212
|
+
f"[yellow]⚠️ Too many tool calls ({current} > {max_tools})[/yellow]"
|
|
213
|
+
)
|
|
214
|
+
return False
|
|
215
|
+
|
|
216
|
+
return True
|
kubrick_cli/scheduler.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Parallel tool execution scheduler for improved performance."""
|
|
2
|
+
|
|
3
|
+
import concurrent.futures
|
|
4
|
+
from typing import Dict, List, Tuple
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# Read-only tools that can be executed in parallel
|
|
12
|
+
READ_ONLY_TOOLS = {
|
|
13
|
+
"read_file",
|
|
14
|
+
"list_files",
|
|
15
|
+
"search_files",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
# Write tools that must be executed sequentially
|
|
19
|
+
WRITE_TOOLS = {
|
|
20
|
+
"write_file",
|
|
21
|
+
"edit_file",
|
|
22
|
+
"create_directory",
|
|
23
|
+
"run_bash", # Conservative: treat bash as write operation
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ToolScheduler:
|
|
28
|
+
"""
|
|
29
|
+
Schedules and executes tools with intelligent parallelization.
|
|
30
|
+
|
|
31
|
+
Strategy: Conservative parallelization
|
|
32
|
+
- Read-only tools (read_file, list_files, search_files) run in parallel
|
|
33
|
+
- Write tools (write_file, edit_file, run_bash, etc.) run sequentially
|
|
34
|
+
- Graceful error handling per tool
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self, tool_executor, max_workers: int = 3, enable_parallel: bool = True
|
|
39
|
+
):
|
|
40
|
+
"""
|
|
41
|
+
Initialize tool scheduler.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
tool_executor: ToolExecutor instance
|
|
45
|
+
max_workers: Maximum parallel workers (default: 3)
|
|
46
|
+
enable_parallel: Whether to enable parallel execution
|
|
47
|
+
"""
|
|
48
|
+
self.tool_executor = tool_executor
|
|
49
|
+
self.max_workers = max_workers
|
|
50
|
+
self.enable_parallel = enable_parallel
|
|
51
|
+
|
|
52
|
+
def execute_tools(
|
|
53
|
+
self, tool_calls: List[Tuple[str, Dict]]
|
|
54
|
+
) -> List[Dict]:
|
|
55
|
+
"""
|
|
56
|
+
Execute a list of tool calls with intelligent scheduling.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
tool_calls: List of (tool_name, parameters) tuples
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
List of result dictionaries in the same order as input
|
|
63
|
+
"""
|
|
64
|
+
if not self.enable_parallel or len(tool_calls) <= 1:
|
|
65
|
+
# Execute sequentially if parallel disabled or only one tool
|
|
66
|
+
return self._execute_sequential(tool_calls)
|
|
67
|
+
|
|
68
|
+
# Separate read-only and write tools
|
|
69
|
+
read_only_calls = []
|
|
70
|
+
write_calls = []
|
|
71
|
+
call_order = [] # Track original order
|
|
72
|
+
|
|
73
|
+
for i, (tool_name, params) in enumerate(tool_calls):
|
|
74
|
+
if tool_name in READ_ONLY_TOOLS:
|
|
75
|
+
read_only_calls.append((i, tool_name, params))
|
|
76
|
+
call_order.append(("read", len(read_only_calls) - 1))
|
|
77
|
+
else:
|
|
78
|
+
write_calls.append((i, tool_name, params))
|
|
79
|
+
call_order.append(("write", len(write_calls) - 1))
|
|
80
|
+
|
|
81
|
+
# Execute read-only tools in parallel
|
|
82
|
+
read_results = {}
|
|
83
|
+
if read_only_calls:
|
|
84
|
+
console.print(
|
|
85
|
+
f"[dim]→ Executing {len(read_only_calls)} read-only tool(s) in parallel[/dim]"
|
|
86
|
+
)
|
|
87
|
+
read_results = self._execute_parallel(read_only_calls)
|
|
88
|
+
|
|
89
|
+
# Execute write tools sequentially
|
|
90
|
+
write_results = {}
|
|
91
|
+
if write_calls:
|
|
92
|
+
write_results = self._execute_sequential_indexed(write_calls)
|
|
93
|
+
|
|
94
|
+
# Reconstruct results in original order
|
|
95
|
+
results = []
|
|
96
|
+
for call_type, index in call_order:
|
|
97
|
+
if call_type == "read":
|
|
98
|
+
results.append(read_results[index])
|
|
99
|
+
else:
|
|
100
|
+
results.append(write_results[index])
|
|
101
|
+
|
|
102
|
+
return results
|
|
103
|
+
|
|
104
|
+
def _execute_parallel(
|
|
105
|
+
self, indexed_calls: List[Tuple[int, str, Dict]]
|
|
106
|
+
) -> Dict[int, Dict]:
|
|
107
|
+
"""
|
|
108
|
+
Execute tools in parallel using ThreadPoolExecutor.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
indexed_calls: List of (index, tool_name, parameters) tuples
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Dict mapping index to result
|
|
115
|
+
"""
|
|
116
|
+
results = {}
|
|
117
|
+
|
|
118
|
+
with concurrent.futures.ThreadPoolExecutor(
|
|
119
|
+
max_workers=self.max_workers
|
|
120
|
+
) as executor:
|
|
121
|
+
# Submit all tasks
|
|
122
|
+
future_to_index = {}
|
|
123
|
+
for index, tool_name, params in indexed_calls:
|
|
124
|
+
future = executor.submit(
|
|
125
|
+
self._execute_single, tool_name, params
|
|
126
|
+
)
|
|
127
|
+
future_to_index[future] = index
|
|
128
|
+
|
|
129
|
+
# Collect results as they complete
|
|
130
|
+
for future in concurrent.futures.as_completed(future_to_index):
|
|
131
|
+
index = future_to_index[future]
|
|
132
|
+
try:
|
|
133
|
+
result = future.result()
|
|
134
|
+
results[index] = result
|
|
135
|
+
except Exception as e:
|
|
136
|
+
# Handle execution errors gracefully
|
|
137
|
+
results[index] = {
|
|
138
|
+
"success": False,
|
|
139
|
+
"error": f"Parallel execution error: {str(e)}",
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return results
|
|
143
|
+
|
|
144
|
+
def _execute_sequential(
|
|
145
|
+
self, tool_calls: List[Tuple[str, Dict]]
|
|
146
|
+
) -> List[Dict]:
|
|
147
|
+
"""
|
|
148
|
+
Execute tools sequentially.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
tool_calls: List of (tool_name, parameters) tuples
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
List of result dictionaries
|
|
155
|
+
"""
|
|
156
|
+
results = []
|
|
157
|
+
for tool_name, params in tool_calls:
|
|
158
|
+
result = self._execute_single(tool_name, params)
|
|
159
|
+
results.append(result)
|
|
160
|
+
return results
|
|
161
|
+
|
|
162
|
+
def _execute_sequential_indexed(
|
|
163
|
+
self, indexed_calls: List[Tuple[int, str, Dict]]
|
|
164
|
+
) -> Dict[int, Dict]:
|
|
165
|
+
"""
|
|
166
|
+
Execute tools sequentially with index mapping.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
indexed_calls: List of (index, tool_name, parameters) tuples
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Dict mapping index to result
|
|
173
|
+
"""
|
|
174
|
+
results = {}
|
|
175
|
+
for index, tool_name, params in indexed_calls:
|
|
176
|
+
result = self._execute_single(tool_name, params)
|
|
177
|
+
results[index] = result
|
|
178
|
+
return results
|
|
179
|
+
|
|
180
|
+
def _execute_single(self, tool_name: str, params: Dict) -> Dict:
|
|
181
|
+
"""
|
|
182
|
+
Execute a single tool.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
tool_name: Name of tool
|
|
186
|
+
params: Tool parameters
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
Result dictionary
|
|
190
|
+
"""
|
|
191
|
+
try:
|
|
192
|
+
return self.tool_executor.execute(tool_name, params)
|
|
193
|
+
except Exception as e:
|
|
194
|
+
return {
|
|
195
|
+
"success": False,
|
|
196
|
+
"error": f"Tool execution error: {str(e)}",
|
|
197
|
+
}
|