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.
@@ -0,0 +1,319 @@
1
+ """Planning phase for complex tasks with read-only exploration."""
2
+
3
+ from typing import Dict, List
4
+
5
+ from rich.console import Console
6
+ from rich.markdown import Markdown
7
+ from rich.panel import Panel
8
+ from rich.prompt import Prompt
9
+
10
+ console = Console()
11
+
12
+
13
+ # Read-only tools allowed in planning mode
14
+ PLANNING_ALLOWED_TOOLS = {
15
+ "read_file",
16
+ "list_files",
17
+ "search_files",
18
+ # run_bash is allowed but will be restricted to read-only commands
19
+ }
20
+
21
+ # Dangerous bash commands that are never allowed in planning
22
+ DANGEROUS_BASH_PATTERNS = [
23
+ "rm ",
24
+ "mv ",
25
+ "cp ",
26
+ "chmod",
27
+ "chown",
28
+ "sudo",
29
+ "> ",
30
+ ">>",
31
+ "|",
32
+ "git push",
33
+ "git commit",
34
+ ]
35
+
36
+
37
+ class PlanningPhase:
38
+ """
39
+ Handles the planning phase for complex tasks.
40
+
41
+ In planning mode:
42
+ - Agent can only use read-only tools
43
+ - Agent explores the codebase to understand structure
44
+ - Agent creates an implementation plan
45
+ - User approves/modifies/rejects the plan
46
+ - After approval, execution proceeds with full tools
47
+ """
48
+
49
+ def __init__(self, llm_client, tool_executor, agent_loop):
50
+ """
51
+ Initialize planning phase.
52
+
53
+ Args:
54
+ llm_client: LLM client instance
55
+ tool_executor: Tool executor instance
56
+ agent_loop: Agent loop instance for execution
57
+ """
58
+ self.llm_client = llm_client
59
+ self.tool_executor = tool_executor
60
+ self.agent_loop = agent_loop
61
+
62
+ def execute_planning(self, user_message: str, base_messages: List[Dict]) -> str:
63
+ """
64
+ Execute the planning phase.
65
+
66
+ Args:
67
+ user_message: The user's original request
68
+ base_messages: Base conversation messages
69
+
70
+ Returns:
71
+ The generated plan text
72
+ """
73
+ console.print("\n[bold yellow]→ Entering PLANNING MODE[/bold yellow]")
74
+ console.print(
75
+ "[dim]Agent will explore the codebase with read-only tools and create a plan.[/dim]\n"
76
+ )
77
+
78
+ planning_messages = base_messages.copy()
79
+
80
+ planning_messages.append(
81
+ {
82
+ "role": "system",
83
+ "content": """# PLANNING MODE
84
+
85
+ You are now in PLANNING MODE. Your task is to:
86
+ 1. EXPLORE the codebase using read-only tools
87
+ 2. DESIGN an implementation approach
88
+ 3. CREATE a detailed plan
89
+
90
+ # Available Tools (READ-ONLY)
91
+
92
+ You can ONLY use these tools:
93
+ - read_file: Read file contents
94
+ - list_files: List files matching patterns (supports recursive patterns like "**/*.py")
95
+ - search_files: Search for text in files
96
+ - run_bash: Run ONLY read-only commands (ls, find, cat, grep, tree, etc.)
97
+
98
+ You CANNOT use:
99
+ - write_file
100
+ - edit_file
101
+ - create_directory
102
+ - Any destructive bash commands
103
+
104
+ # How to Explore the Codebase
105
+
106
+ **IMPORTANT**: To get a complete understanding, you MUST explore systematically:
107
+
108
+ 1. **Start with directory structure**:
109
+ - Use `list_files` with pattern `**/*` to see ALL files and directories recursively
110
+ - Or use `run_bash` with command `find . -type f` to list all files
111
+ - Or use `run_bash` with command `tree` or `ls -R` to see the full structure
112
+
113
+ 2. **List files by type**:
114
+ - Python: `list_files` with pattern `**/*.py`
115
+ - JavaScript: `list_files` with pattern `**/*.js`
116
+ - All code files: Try multiple patterns to cover all file types
117
+
118
+ 3. **Read key files**:
119
+ - README files
120
+ - Configuration files (package.json, requirements.txt, pyproject.toml, etc.)
121
+ - Main entry points
122
+ - Important modules based on the task
123
+
124
+ 4. **Search for specific patterns**:
125
+ - Use `search_files` to find classes, functions, imports, etc.
126
+
127
+ **Example exploration workflow**:
128
+ ```tool_call
129
+ {
130
+ "tool": "list_files",
131
+ "parameters": {
132
+ "pattern": "**/*"
133
+ }
134
+ }
135
+ ```
136
+
137
+ Then read relevant files:
138
+ ```tool_call
139
+ {
140
+ "tool": "read_file",
141
+ "parameters": {
142
+ "file_path": "path/to/important/file.py"
143
+ }
144
+ }
145
+ ```
146
+
147
+ # Plan Format
148
+
149
+ When ready, create a plan in this format:
150
+
151
+ ## Implementation Plan
152
+
153
+ ### Overview
154
+ [Brief description of what you'll do]
155
+
156
+ ### Steps
157
+ 1. [First step]
158
+ 2. [Second step]
159
+ 3. [etc.]
160
+
161
+ ### Files to Modify
162
+ - file1.py: [what changes]
163
+ - file2.py: [what changes]
164
+
165
+ ### Risks
166
+ - [Potential issues or concerns]
167
+
168
+ # Completion
169
+
170
+ Say "PLAN_COMPLETE" when your plan is ready.""",
171
+ }
172
+ )
173
+
174
+ planning_messages.append(
175
+ {
176
+ "role": "user",
177
+ "content": (
178
+ f"Task: {user_message}\n\n"
179
+ "Please explore the codebase and create an implementation plan."
180
+ ),
181
+ }
182
+ )
183
+
184
+ original_executor = self.agent_loop.tool_executor
185
+ restricted_executor = RestrictedToolExecutor(
186
+ self.tool_executor, PLANNING_ALLOWED_TOOLS
187
+ )
188
+ self.agent_loop.tool_executor = restricted_executor
189
+
190
+ try:
191
+ old_max = self.agent_loop.max_iterations
192
+ self.agent_loop.max_iterations = 10
193
+
194
+ from .main import KubrickCLI
195
+
196
+ temp_cli = KubrickCLI.__new__(KubrickCLI)
197
+ tool_parser = temp_cli.parse_tool_calls
198
+
199
+ self.agent_loop.run(
200
+ messages=planning_messages,
201
+ tool_parser=tool_parser,
202
+ display_callback=None,
203
+ )
204
+
205
+ self.agent_loop.max_iterations = old_max
206
+
207
+ plan_text = ""
208
+ for msg in reversed(planning_messages):
209
+ if msg["role"] == "assistant":
210
+ plan_text = msg["content"]
211
+ break
212
+
213
+ return plan_text
214
+
215
+ finally:
216
+ self.agent_loop.tool_executor = original_executor
217
+
218
+ def get_user_approval(self, plan: str) -> Dict:
219
+ """
220
+ Present plan to user and get approval.
221
+
222
+ Args:
223
+ plan: The generated plan text
224
+
225
+ Returns:
226
+ Dict with 'approved' (bool) and optional 'modifications' (str)
227
+ """
228
+ console.print("\n" + "=" * 70)
229
+ console.print(
230
+ Panel(
231
+ Markdown(plan),
232
+ title="[bold cyan]Implementation Plan[/bold cyan]",
233
+ border_style="cyan",
234
+ )
235
+ )
236
+ console.print("=" * 70 + "\n")
237
+
238
+ choice = Prompt.ask(
239
+ "[bold yellow]Approve this plan?[/bold yellow]",
240
+ choices=["approve", "modify", "reject"],
241
+ default="approve",
242
+ )
243
+
244
+ if choice == "approve":
245
+ console.print(
246
+ "[green]✓ Plan approved, proceeding with implementation[/green]"
247
+ )
248
+ return {"approved": True}
249
+
250
+ elif choice == "modify":
251
+ modifications = Prompt.ask(
252
+ "[yellow]What modifications would you like?[/yellow]"
253
+ )
254
+ console.print("[yellow]Plan modifications noted, will adjust[/yellow]")
255
+ return {"approved": True, "modifications": modifications}
256
+
257
+ else:
258
+ console.print("[red]Plan rejected, cancelling task[/red]")
259
+ return {"approved": False}
260
+
261
+
262
+ class RestrictedToolExecutor:
263
+ """
264
+ Wraps ToolExecutor to restrict to read-only tools during planning.
265
+ """
266
+
267
+ def __init__(self, base_executor, allowed_tools: set):
268
+ """
269
+ Initialize restricted executor.
270
+
271
+ Args:
272
+ base_executor: Base ToolExecutor instance
273
+ allowed_tools: Set of allowed tool names
274
+ """
275
+ self.base_executor = base_executor
276
+ self.allowed_tools = allowed_tools
277
+
278
+ def execute(self, tool_name: str, parameters: Dict) -> Dict:
279
+ """
280
+ Execute tool with restrictions.
281
+
282
+ Args:
283
+ tool_name: Name of tool to execute
284
+ parameters: Tool parameters
285
+
286
+ Returns:
287
+ Result dict
288
+ """
289
+ if tool_name not in self.allowed_tools:
290
+ return {
291
+ "success": False,
292
+ "error": f"Tool '{tool_name}' is not allowed in planning mode (read-only)",
293
+ }
294
+
295
+ if tool_name == "run_bash":
296
+ command = parameters.get("command", "")
297
+ if self._is_dangerous_command(command):
298
+ return {
299
+ "success": False,
300
+ "error": (
301
+ f"Bash command '{command}' is not allowed in "
302
+ "planning mode (read-only)"
303
+ ),
304
+ }
305
+
306
+ return self.base_executor.execute(tool_name, parameters)
307
+
308
+ def _is_dangerous_command(self, command: str) -> bool:
309
+ """Check if bash command is dangerous."""
310
+ command_lower = command.lower()
311
+ for pattern in DANGEROUS_BASH_PATTERNS:
312
+ if pattern in command_lower:
313
+ return True
314
+ return False
315
+
316
+ @property
317
+ def working_dir(self):
318
+ """Pass through working_dir property."""
319
+ return self.base_executor.working_dir
@@ -0,0 +1,162 @@
1
+ """Progress tracking and visualization for multi-step tasks."""
2
+
3
+ from typing import Optional
4
+
5
+ from rich.console import Console
6
+ from rich.progress import (
7
+ BarColumn,
8
+ Progress,
9
+ SpinnerColumn,
10
+ TaskID,
11
+ TextColumn,
12
+ TimeElapsedColumn,
13
+ )
14
+
15
+ console = Console()
16
+
17
+
18
+ class ProgressTracker:
19
+ """
20
+ Tracks and displays progress for multi-step agentic tasks.
21
+
22
+ Provides visual feedback during long-running operations.
23
+ """
24
+
25
+ def __init__(self, enabled: bool = True):
26
+ """
27
+ Initialize progress tracker.
28
+
29
+ Args:
30
+ enabled: Whether progress tracking is enabled
31
+ """
32
+ self.enabled = enabled
33
+ self.progress: Optional[Progress] = None
34
+ self.current_task: Optional[TaskID] = None
35
+ self.step_count = 0
36
+ self.total_steps = 0
37
+
38
+ def start(self, total_steps: int = None, description: str = "Working"):
39
+ """
40
+ Start progress tracking.
41
+
42
+ Args:
43
+ total_steps: Total number of steps (if known)
44
+ description: Initial description
45
+ """
46
+ if not self.enabled:
47
+ return
48
+
49
+ self.total_steps = total_steps or 0
50
+ self.step_count = 0
51
+
52
+ if total_steps:
53
+ self.progress = Progress(
54
+ SpinnerColumn(),
55
+ TextColumn("[bold cyan]{task.description}"),
56
+ BarColumn(),
57
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
58
+ TimeElapsedColumn(),
59
+ console=console,
60
+ )
61
+ else:
62
+ self.progress = Progress(
63
+ SpinnerColumn(),
64
+ TextColumn("[bold cyan]{task.description}"),
65
+ TimeElapsedColumn(),
66
+ console=console,
67
+ )
68
+
69
+ self.progress.start()
70
+ self.current_task = self.progress.add_task(description, total=total_steps)
71
+
72
+ def update(self, description: str = None, advance: int = 1, completed: int = None):
73
+ """
74
+ Update progress.
75
+
76
+ Args:
77
+ description: New description (optional)
78
+ advance: Amount to advance (default: 1)
79
+ completed: Set absolute completion (optional)
80
+ """
81
+ if not self.enabled or not self.progress:
82
+ return
83
+
84
+ if completed is not None:
85
+ self.step_count = completed
86
+ self.progress.update(self.current_task, completed=completed)
87
+ else:
88
+ self.step_count += advance
89
+ self.progress.update(self.current_task, advance=advance)
90
+
91
+ if description:
92
+ self.progress.update(self.current_task, description=description)
93
+
94
+ def update_description(self, description: str):
95
+ """
96
+ Update just the description.
97
+
98
+ Args:
99
+ description: New description
100
+ """
101
+ if not self.enabled or not self.progress:
102
+ return
103
+
104
+ self.progress.update(self.current_task, description=description)
105
+
106
+ def step(self, description: str):
107
+ """
108
+ Mark a step complete with new description.
109
+
110
+ Args:
111
+ description: Description of the new step
112
+ """
113
+ if not self.enabled or not self.progress:
114
+ console.print(f"[dim]→ {description}[/dim]")
115
+ return
116
+
117
+ self.step_count += 1
118
+ step_info = ""
119
+ if self.total_steps > 0:
120
+ step_info = f"[Step {self.step_count}/{self.total_steps}] "
121
+
122
+ self.progress.update(
123
+ self.current_task,
124
+ description=f"{step_info}{description}",
125
+ advance=1,
126
+ )
127
+
128
+ def complete(self, message: str = "Complete"):
129
+ """
130
+ Mark progress as complete.
131
+
132
+ Args:
133
+ message: Completion message
134
+ """
135
+ if not self.enabled or not self.progress:
136
+ return
137
+
138
+ if self.total_steps:
139
+ self.progress.update(self.current_task, completed=self.total_steps)
140
+
141
+ self.progress.update(self.current_task, description=message)
142
+ self.progress.stop()
143
+ self.progress = None
144
+ self.current_task = None
145
+
146
+ def stop(self):
147
+ """Stop progress tracking."""
148
+ if not self.enabled or not self.progress:
149
+ return
150
+
151
+ self.progress.stop()
152
+ self.progress = None
153
+ self.current_task = None
154
+
155
+ def __enter__(self):
156
+ """Context manager entry."""
157
+ return self
158
+
159
+ def __exit__(self, exc_type, exc_val, exc_tb):
160
+ """Context manager exit."""
161
+ if self.progress:
162
+ self.stop()
@@ -0,0 +1,6 @@
1
+ """Provider adapters for different LLM backends."""
2
+
3
+ from .base import ProviderAdapter, ProviderMetadata
4
+ from .factory import ProviderFactory
5
+
6
+ __all__ = ["ProviderAdapter", "ProviderMetadata", "ProviderFactory"]
@@ -0,0 +1,209 @@
1
+ """Anthropic 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 AnthropicProvider(ProviderAdapter):
12
+ """Provider adapter for Anthropic API."""
13
+
14
+ METADATA = ProviderMetadata(
15
+ name="anthropic",
16
+ display_name="Anthropic",
17
+ description="Anthropic API (Claude Sonnet 4.5, etc.)",
18
+ config_fields=[
19
+ {
20
+ "key": "anthropic_api_key",
21
+ "label": "Anthropic API key",
22
+ "type": "password",
23
+ "help_text": "Get your API key from: https://console.anthropic.com/settings/keys",
24
+ },
25
+ {
26
+ "key": "anthropic_model",
27
+ "label": "Model name",
28
+ "type": "text",
29
+ "default": "claude-sonnet-4-5-20250929",
30
+ },
31
+ ],
32
+ )
33
+
34
+ def __init__(
35
+ self,
36
+ anthropic_api_key: str,
37
+ anthropic_model: str = "claude-sonnet-4-5-20250929",
38
+ ):
39
+ """
40
+ Initialize Anthropic provider.
41
+
42
+ Args:
43
+ anthropic_api_key: Anthropic API key
44
+ anthropic_model: Model name (default: claude-3-5-sonnet-20241022)
45
+ """
46
+ self.api_key = anthropic_api_key
47
+ self._model_name = anthropic_model
48
+ self.base_url = "api.anthropic.com"
49
+ self.timeout = 600
50
+ self.api_version = "2023-06-01"
51
+
52
+ def generate_streaming(
53
+ self, messages: List[Dict[str, str]], stream_options: Dict = None
54
+ ) -> Iterator[str]:
55
+ """
56
+ Generate streaming response from Anthropic.
57
+
58
+ Args:
59
+ messages: List of message dicts with 'role' and 'content'
60
+ stream_options: Optional streaming parameters
61
+
62
+ Yields:
63
+ Text chunks as they arrive
64
+ """
65
+ system_message = ""
66
+ conversation_messages = []
67
+
68
+ for msg in messages:
69
+ if msg["role"] == "system":
70
+ system_message = msg["content"]
71
+ else:
72
+ conversation_messages.append(msg)
73
+
74
+ payload = {
75
+ "model": self._model_name,
76
+ "messages": conversation_messages,
77
+ "max_tokens": 4096,
78
+ "stream": True,
79
+ }
80
+
81
+ if system_message:
82
+ payload["system"] = system_message
83
+
84
+ if stream_options:
85
+ if "temperature" in stream_options:
86
+ payload["temperature"] = stream_options["temperature"]
87
+ if "max_tokens" in stream_options:
88
+ payload["max_tokens"] = stream_options["max_tokens"]
89
+
90
+ headers = {
91
+ "Content-Type": "application/json",
92
+ "x-api-key": self.api_key,
93
+ "anthropic-version": self.api_version,
94
+ }
95
+
96
+ body = json.dumps(payload).encode("utf-8")
97
+
98
+ context = ssl.create_default_context()
99
+ conn = http.client.HTTPSConnection(
100
+ self.base_url, 443, timeout=self.timeout, context=context
101
+ )
102
+
103
+ try:
104
+ conn.request("POST", "/v1/messages", body=body, headers=headers)
105
+ response = conn.getresponse()
106
+
107
+ if response.status != 200:
108
+ error_body = response.read().decode("utf-8")
109
+ raise Exception(f"Anthropic API error {response.status}: {error_body}")
110
+
111
+ buffer = ""
112
+ while True:
113
+ chunk = response.read(1024)
114
+ if not chunk:
115
+ break
116
+
117
+ if isinstance(chunk, bytes):
118
+ chunk = chunk.decode("utf-8")
119
+
120
+ buffer += chunk
121
+
122
+ while "\n" in buffer:
123
+ line, buffer = buffer.split("\n", 1)
124
+ line = line.strip()
125
+
126
+ if not line:
127
+ continue
128
+
129
+ if line.startswith("data: "):
130
+ line = line[6:]
131
+
132
+ if line.startswith("event: "):
133
+ continue
134
+
135
+ try:
136
+ data = json.loads(line)
137
+
138
+ event_type = data.get("type")
139
+
140
+ if event_type == "content_block_delta":
141
+ delta = data.get("delta", {})
142
+ if delta.get("type") == "text_delta":
143
+ text = delta.get("text", "")
144
+ if text:
145
+ yield text
146
+
147
+ elif event_type == "message_stop":
148
+ return
149
+
150
+ except json.JSONDecodeError:
151
+ continue
152
+
153
+ finally:
154
+ conn.close()
155
+
156
+ def generate(
157
+ self, messages: List[Dict[str, str]], stream_options: Dict = None
158
+ ) -> str:
159
+ """
160
+ Generate non-streaming response from Anthropic.
161
+
162
+ Args:
163
+ messages: List of message dicts with 'role' and 'content'
164
+ stream_options: Optional parameters
165
+
166
+ Returns:
167
+ Complete response text
168
+ """
169
+ chunks = []
170
+ for chunk in self.generate_streaming(messages, stream_options):
171
+ chunks.append(chunk)
172
+ return "".join(chunks)
173
+
174
+ def is_healthy(self) -> bool:
175
+ """
176
+ Check if Anthropic API is accessible.
177
+
178
+ Returns:
179
+ True if healthy, False otherwise
180
+ """
181
+ try:
182
+ context = ssl.create_default_context()
183
+ conn = http.client.HTTPSConnection(
184
+ self.base_url, 443, timeout=10, context=context
185
+ )
186
+ headers = {
187
+ "x-api-key": self.api_key,
188
+ "anthropic-version": self.api_version,
189
+ }
190
+ conn.request("GET", "/v1/models", headers=headers)
191
+ response = conn.getresponse()
192
+ conn.close()
193
+ return response.status in (200, 404)
194
+ except Exception:
195
+ return False
196
+
197
+ @property
198
+ def provider_name(self) -> str:
199
+ """Get provider name."""
200
+ return "anthropic"
201
+
202
+ @property
203
+ def model_name(self) -> str:
204
+ """Get model name."""
205
+ return self._model_name
206
+
207
+ def set_model(self, model_name: str):
208
+ """Set model name dynamically."""
209
+ self._model_name = model_name