cade-cli 0.3.3__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 (44) hide show
  1. cade_cli-0.3.3.dist-info/METADATA +151 -0
  2. cade_cli-0.3.3.dist-info/RECORD +44 -0
  3. cade_cli-0.3.3.dist-info/WHEEL +4 -0
  4. cade_cli-0.3.3.dist-info/entry_points.txt +2 -0
  5. cadecoder/__init__.py +1 -0
  6. cadecoder/ai/__init__.py +6 -0
  7. cadecoder/ai/prompts.py +572 -0
  8. cadecoder/cli/__init__.py +0 -0
  9. cadecoder/cli/app.py +147 -0
  10. cadecoder/cli/auth.py +483 -0
  11. cadecoder/cli/commands/__init__.py +5 -0
  12. cadecoder/cli/commands/auth.py +143 -0
  13. cadecoder/cli/commands/chat.py +264 -0
  14. cadecoder/cli/commands/mcp.py +477 -0
  15. cadecoder/cli/commands/tools.py +226 -0
  16. cadecoder/core/__init__.py +12 -0
  17. cadecoder/core/config.py +380 -0
  18. cadecoder/core/constants.py +281 -0
  19. cadecoder/core/errors.py +145 -0
  20. cadecoder/core/logging.py +148 -0
  21. cadecoder/core/types.py +235 -0
  22. cadecoder/core/utils.py +279 -0
  23. cadecoder/execution/__init__.py +46 -0
  24. cadecoder/execution/context_window.py +521 -0
  25. cadecoder/execution/orchestrator.py +562 -0
  26. cadecoder/execution/parallel.py +287 -0
  27. cadecoder/providers/__init__.py +60 -0
  28. cadecoder/providers/base.py +294 -0
  29. cadecoder/providers/openai.py +251 -0
  30. cadecoder/storage/__init__.py +0 -0
  31. cadecoder/storage/threads.py +489 -0
  32. cadecoder/templates/login_failed.html +21 -0
  33. cadecoder/templates/login_success.html +21 -0
  34. cadecoder/templates/styles.css +87 -0
  35. cadecoder/tools/__init__.py +19 -0
  36. cadecoder/tools/builtin.py +644 -0
  37. cadecoder/tools/filesystem.py +315 -0
  38. cadecoder/tools/git.py +221 -0
  39. cadecoder/tools/manager.py +1635 -0
  40. cadecoder/ui/__init__.py +7 -0
  41. cadecoder/ui/display.py +338 -0
  42. cadecoder/ui/input.py +145 -0
  43. cadecoder/ui/session.py +455 -0
  44. cadecoder/ui/state.py +20 -0
@@ -0,0 +1,562 @@
1
+ """Orchestrator for task execution."""
2
+
3
+ import json
4
+ from collections.abc import AsyncIterator
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+ from cadecoder.ai.prompts import AGENT_SYSTEM_PROMPT, get_environment_context
10
+ from cadecoder.core.config import get_config
11
+ from cadecoder.core.constants import (
12
+ DEFAULT_TEMPERATURE,
13
+ MAX_EXECUTION_ITERATIONS,
14
+ )
15
+ from cadecoder.core.logging import log
16
+ from cadecoder.core.types import ExecutionEventType, extract_tool_output_content
17
+ from cadecoder.execution.context_window import (
18
+ CompactionStrategy,
19
+ ContextWindowManager,
20
+ create_context_manager,
21
+ )
22
+ from cadecoder.execution.parallel import ParallelToolExecutor
23
+ from cadecoder.providers.base import (
24
+ Provider,
25
+ ProviderRequest,
26
+ provider_registry,
27
+ )
28
+ from cadecoder.tools.manager import (
29
+ CompositeToolManager,
30
+ ToolAuthorizationRequired,
31
+ ToolManager,
32
+ )
33
+
34
+
35
+ def create_orchestrator(
36
+ provider: Provider | None = None,
37
+ tool_manager: ToolManager | None = None,
38
+ default_model: str | None = None,
39
+ context_manager: ContextWindowManager | None = None,
40
+ ) -> "Orchestrator":
41
+ """Factory function to create an orchestrator with sensible defaults.
42
+
43
+ Args:
44
+ provider: LLM provider (uses default if not specified)
45
+ tool_manager: Tool manager (creates CompositeToolManager if not specified)
46
+ default_model: Default model to use
47
+ context_manager: Context window manager (created if not specified)
48
+
49
+ Returns:
50
+ Configured Orchestrator instance
51
+ """
52
+ if tool_manager is None:
53
+ tool_manager = CompositeToolManager()
54
+
55
+ return Orchestrator(
56
+ provider=provider,
57
+ tool_manager=tool_manager,
58
+ default_model=default_model,
59
+ context_manager=context_manager,
60
+ )
61
+
62
+
63
+ # --- Execution Models ---
64
+
65
+
66
+ class ExecutionMode:
67
+ """Execution mode constants."""
68
+
69
+ STREAMING = "streaming"
70
+
71
+
72
+ class ExecutionContext(BaseModel):
73
+ """Context for task execution."""
74
+
75
+ task: str
76
+ conversation_history: list[dict[str, Any]] = Field(default_factory=list)
77
+ mode: str = ExecutionMode.STREAMING
78
+ metadata: dict[str, Any] = Field(default_factory=dict)
79
+
80
+
81
+ class ExecutionEvent(BaseModel):
82
+ """Event emitted during execution."""
83
+
84
+ type: str
85
+ content: str | None = None
86
+ metadata: dict[str, Any] = Field(default_factory=dict)
87
+
88
+
89
+ class ExecutionResult(BaseModel):
90
+ """Result of task execution."""
91
+
92
+ content: str
93
+ tool_calls: list[dict[str, Any]] | None = None
94
+ tool_results: list[dict[str, Any]] | None = None
95
+ mode: str = ExecutionMode.STREAMING
96
+ metadata: dict[str, Any] = Field(default_factory=dict)
97
+
98
+
99
+ class ContinuationDecision(BaseModel):
100
+ """Decision about whether to continue execution."""
101
+
102
+ should_continue: bool = False
103
+ reason: str = ""
104
+ needs_user_input: bool = False
105
+
106
+
107
+ class Orchestrator:
108
+ """Orchestrator for task execution.
109
+
110
+ This orchestrator:
111
+ - Executes tasks in streaming mode with iterative execution
112
+ - Manages tool execution with parallel support
113
+ - Handles both execute() and stream() interfaces
114
+ - Manages context window with automatic compaction
115
+ """
116
+
117
+ def __init__(
118
+ self,
119
+ provider: Provider | None = None,
120
+ tool_manager: ToolManager | None = None,
121
+ default_model: str | None = None,
122
+ context_manager: ContextWindowManager | None = None,
123
+ ):
124
+ """Initialize orchestrator.
125
+
126
+ Args:
127
+ provider: LLM provider instance
128
+ tool_manager: Tool manager for executing tools
129
+ default_model: Default model to use
130
+ context_manager: Context window manager (created if not provided)
131
+ """
132
+ self.provider = provider or provider_registry.get_default()
133
+ if not self.provider:
134
+ raise ValueError("No provider available")
135
+
136
+ self.tool_manager = tool_manager
137
+ self.default_model = default_model or get_config().settings.default_model
138
+ self._tools_cache: list[dict[str, Any]] | None = None
139
+ self._tools_description_cache: str | None = None
140
+
141
+ # Context window management
142
+ self.context_manager = context_manager or create_context_manager(model=self.default_model)
143
+
144
+ # Parallel executor
145
+ self._parallel_executor = None
146
+ if self.tool_manager:
147
+ self._parallel_executor = ParallelToolExecutor(self.tool_manager)
148
+
149
+ async def execute(self, context: ExecutionContext) -> ExecutionResult:
150
+ """Execute a task in streaming mode."""
151
+ context.mode = ExecutionMode.STREAMING
152
+
153
+ log.info(f"Executing task with mode: {context.mode}")
154
+
155
+ result_content = ""
156
+ tool_calls = []
157
+ final_metadata: dict[str, Any] = {}
158
+
159
+ async for event in self.stream(context):
160
+ if event.type == ExecutionEventType.CONTENT:
161
+ result_content += event.content or ""
162
+ elif event.type == ExecutionEventType.TOOL_CALL:
163
+ tool_calls.append(event.metadata.get("tool_call", {}))
164
+ elif event.type == ExecutionEventType.COMPLETE:
165
+ final_metadata = event.metadata
166
+
167
+ return ExecutionResult(
168
+ content=result_content,
169
+ tool_calls=tool_calls if tool_calls else None,
170
+ tool_results=None,
171
+ mode=ExecutionMode.STREAMING,
172
+ metadata={"streamed": True, **final_metadata},
173
+ )
174
+
175
+ async def stream(self, context: ExecutionContext) -> AsyncIterator[ExecutionEvent]:
176
+ """Stream execution events."""
177
+ context.mode = ExecutionMode.STREAMING
178
+
179
+ # Load tools
180
+ tools = await self._load_tools()
181
+
182
+ # Build messages
183
+ messages = self._build_messages(context)
184
+
185
+ # Clear tool outputs for new execution
186
+ self.context_manager.clear_tool_outputs()
187
+
188
+ iteration = 0
189
+ should_continue = True
190
+
191
+ while should_continue and iteration < MAX_EXECUTION_ITERATIONS:
192
+ iteration += 1
193
+ log.info(f"Execution iteration {iteration}")
194
+
195
+ # Check context window status before each turn
196
+ context_status = self.context_manager.check_context_status(messages)
197
+ log.debug(
198
+ f"Context status: {context_status['token_count']:,} tokens "
199
+ f"({context_status['percentage_used']}% used)"
200
+ )
201
+
202
+ # Compact context if needed
203
+ if context_status["needs_compaction"]:
204
+ log.warning(
205
+ f"Context window near limit ({context_status['percentage_used']}%), "
206
+ "compacting..."
207
+ )
208
+ yield ExecutionEvent(
209
+ type=ExecutionEventType.CONTEXT_COMPACTION,
210
+ metadata={
211
+ "before_tokens": context_status["token_count"],
212
+ "strategy": CompactionStrategy.KEEP_RECENT.value,
213
+ },
214
+ )
215
+
216
+ messages, backup = self.context_manager.compact_context(
217
+ messages,
218
+ strategy=CompactionStrategy.KEEP_RECENT,
219
+ )
220
+
221
+ new_status = self.context_manager.check_context_status(messages)
222
+ log.info(
223
+ f"Context compacted: {context_status['token_count']:,} -> "
224
+ f"{new_status['token_count']:,} tokens"
225
+ )
226
+ yield ExecutionEvent(
227
+ type=ExecutionEventType.CONTEXT_COMPACTION,
228
+ metadata={
229
+ "after_tokens": new_status["token_count"],
230
+ "backup_timestamp": backup.timestamp.isoformat(),
231
+ },
232
+ )
233
+
234
+ # Build provider request
235
+ request = ProviderRequest(
236
+ messages=messages,
237
+ model=self.default_model,
238
+ tools=tools if tools else None,
239
+ temperature=DEFAULT_TEMPERATURE,
240
+ max_tokens=None,
241
+ tool_choice="auto",
242
+ system_prompt=None,
243
+ stream=True,
244
+ )
245
+
246
+ # Stream from provider
247
+ accumulated_content = ""
248
+ tool_calls: list[dict[str, Any]] = []
249
+
250
+ async for stream_event in self.provider.stream(request):
251
+ if stream_event.content:
252
+ accumulated_content += stream_event.content
253
+ yield ExecutionEvent(
254
+ type=ExecutionEventType.CONTENT,
255
+ content=stream_event.content,
256
+ )
257
+
258
+ # Handle tool call events from provider
259
+ if stream_event.type == ExecutionEventType.TOOL_CALL.value:
260
+ tool_call = stream_event.metadata.get("tool_call")
261
+ if tool_call:
262
+ tool_calls.append(tool_call)
263
+ yield ExecutionEvent(
264
+ type=ExecutionEventType.TOOL_CALL,
265
+ metadata={"tool_call": tool_call},
266
+ )
267
+
268
+ # If we have tool calls, execute them
269
+ if tool_calls:
270
+ yield ExecutionEvent(
271
+ type=ExecutionEventType.TOOL_EXECUTION_START,
272
+ metadata={"tool_count": len(tool_calls)},
273
+ )
274
+
275
+ tool_results = await self._execute_tool_calls(tool_calls)
276
+
277
+ for tc, result in zip(tool_calls, tool_results):
278
+ tool_name = result.get("name", "unknown")
279
+ tool_content = result.get("content", "")
280
+ tool_call_id = tc.get("id", "")
281
+
282
+ # Track tool output in context manager
283
+ self.context_manager.add_tool_output(
284
+ tool_name=tool_name,
285
+ output=tool_content,
286
+ tool_call_id=tool_call_id,
287
+ )
288
+
289
+ yield ExecutionEvent(
290
+ type=ExecutionEventType.TOOL_RESULT,
291
+ content=tool_content,
292
+ metadata={
293
+ "tool_name": tool_name,
294
+ "tool_call_id": tool_call_id,
295
+ },
296
+ )
297
+
298
+ # Add assistant message with tool calls
299
+ messages.append(
300
+ {
301
+ "role": "assistant",
302
+ "content": accumulated_content if accumulated_content else None,
303
+ "tool_calls": tool_calls,
304
+ }
305
+ )
306
+
307
+ # Add tool results as messages
308
+ for tc, result in zip(tool_calls, tool_results):
309
+ messages.append(
310
+ {
311
+ "role": "tool",
312
+ "tool_call_id": tc.get("id", ""),
313
+ "content": result.get("content", ""),
314
+ }
315
+ )
316
+
317
+ # Check if we should continue
318
+ decision = self._decide_continuation(accumulated_content, tool_results)
319
+ should_continue = decision.should_continue and not decision.needs_user_input
320
+ else:
321
+ # No tool calls, check if we should continue
322
+ decision = self._decide_continuation(accumulated_content, [])
323
+ should_continue = decision.should_continue and not decision.needs_user_input
324
+
325
+ if accumulated_content:
326
+ messages.append(
327
+ {
328
+ "role": "assistant",
329
+ "content": accumulated_content,
330
+ }
331
+ )
332
+
333
+ yield ExecutionEvent(
334
+ type=ExecutionEventType.COMPLETE,
335
+ metadata={"iterations": iteration},
336
+ )
337
+
338
+ async def _load_tools(self) -> list[dict[str, Any]]:
339
+ """Load available tools."""
340
+ if not self.tool_manager:
341
+ return []
342
+
343
+ if self._tools_cache is None:
344
+ self._tools_cache = await self.tool_manager.get_tools()
345
+
346
+ return self._tools_cache
347
+
348
+ async def _execute_tool_calls(self, tool_calls: list[dict[str, Any]]) -> list[dict[str, Any]]:
349
+ """Execute tool calls and return results."""
350
+ if not self.tool_manager:
351
+ return []
352
+
353
+ if self._parallel_executor:
354
+ log.info(f"Using parallel executor for {len(tool_calls)} tool calls")
355
+ parallel_results = await self._parallel_executor.execute_tools(
356
+ tool_calls, preserve_order=True
357
+ )
358
+
359
+ formatted_results: list[dict[str, Any]] = []
360
+ for result in parallel_results:
361
+ result_dict: dict[str, Any] = {
362
+ "name": result.name,
363
+ "content": result.content,
364
+ "status": result.status,
365
+ }
366
+ if result.authorization_url:
367
+ result_dict["authorization_url"] = result.authorization_url
368
+ formatted_results.append(result_dict)
369
+ return formatted_results
370
+
371
+ # Fallback to sequential execution
372
+ log.info(f"Using sequential execution for {len(tool_calls)} tool calls")
373
+ sequential_results: list[dict[str, Any]] = []
374
+
375
+ for tool_call in tool_calls:
376
+ function = tool_call.get("function", {})
377
+ name = function.get("name", "unknown")
378
+
379
+ try:
380
+ args = json.loads(function.get("arguments", "{}"))
381
+ result_content = await self.tool_manager.execute(name, args)
382
+ actual_content = extract_tool_output_content(result_content)
383
+
384
+ sequential_results.append(
385
+ {
386
+ "name": name,
387
+ "content": str(actual_content),
388
+ "status": "success",
389
+ }
390
+ )
391
+
392
+ except Exception as e:
393
+ auth_url = None
394
+ if isinstance(e, ToolAuthorizationRequired):
395
+ auth_url = e.authorization_url
396
+ log.error(f"Authorization required for tool {name}")
397
+ else:
398
+ log.error(f"Tool execution failed for {name}: {e}")
399
+
400
+ error_dict: dict[str, Any] = {
401
+ "name": name,
402
+ "content": str(e),
403
+ "status": "error",
404
+ }
405
+ if auth_url:
406
+ error_dict["authorization_url"] = auth_url
407
+
408
+ sequential_results.append(error_dict)
409
+
410
+ return sequential_results
411
+
412
+ def _decide_continuation(
413
+ self, content: str, tool_results: list[dict[str, Any]]
414
+ ) -> ContinuationDecision:
415
+ """Decide whether to continue execution."""
416
+ # Check for explicit signals
417
+ content_lower = content.lower() if content else ""
418
+
419
+ if "[task_complete]" in content_lower or "task complete" in content_lower:
420
+ return ContinuationDecision(
421
+ should_continue=False,
422
+ reason="Task marked as complete",
423
+ )
424
+
425
+ if "[need_user_input]" in content_lower or "need user input" in content_lower:
426
+ return ContinuationDecision(
427
+ should_continue=False,
428
+ reason="Needs user input",
429
+ needs_user_input=True,
430
+ )
431
+
432
+ if "[continue]" in content_lower:
433
+ return ContinuationDecision(
434
+ should_continue=True,
435
+ reason="Explicit continue signal",
436
+ )
437
+
438
+ # If there were tool calls with results, continue
439
+ if tool_results:
440
+ return ContinuationDecision(
441
+ should_continue=True,
442
+ reason="Tool results available",
443
+ )
444
+
445
+ # Default: don't continue
446
+ return ContinuationDecision(
447
+ should_continue=False,
448
+ reason="No continuation signal",
449
+ )
450
+
451
+ def _build_messages(self, context: ExecutionContext) -> list[dict[str, Any]]:
452
+ """Build messages for the provider.
453
+
454
+ Constructs the full message list including:
455
+ - System prompt with environment context and tools list
456
+ - Conversation history
457
+ - Current task as user message
458
+ """
459
+ messages: list[dict[str, Any]] = []
460
+
461
+ # Check if we need to add a system prompt
462
+ has_system_message = any(
463
+ msg.get("role") == "system" for msg in context.conversation_history
464
+ )
465
+
466
+ if not has_system_message:
467
+ # Build tools list
468
+ tools_list = "(No tools available)"
469
+ if self._tools_description_cache:
470
+ tools_list = self._tools_description_cache
471
+ elif self.tool_manager:
472
+ tools_list = "(Tools available - use tools to see list)"
473
+
474
+ # Build environment context
475
+ try:
476
+ env_context = get_environment_context()
477
+ except Exception as e:
478
+ log.warning(f"Failed to get environment context: {e}")
479
+ env_context = "(Environment context unavailable)"
480
+
481
+ # Replace placeholders in system prompt
482
+ system_content = AGENT_SYSTEM_PROMPT.replace(
483
+ "{_TOOLS_BULLET_LIST}", tools_list
484
+ ).replace("{_ENVIRONMENT_CONTEXT}", env_context)
485
+
486
+ messages.append(
487
+ {
488
+ "role": "system",
489
+ "content": system_content,
490
+ }
491
+ )
492
+
493
+ # Add conversation history
494
+ messages.extend(context.conversation_history)
495
+
496
+ # Add the current task
497
+ messages.append(
498
+ {
499
+ "role": "user",
500
+ "content": context.task,
501
+ }
502
+ )
503
+
504
+ return messages
505
+
506
+ def get_context_status(self, conversation_history: list[dict[str, Any]]) -> dict[str, Any]:
507
+ """Get current context window status.
508
+
509
+ Args:
510
+ conversation_history: Current conversation messages
511
+
512
+ Returns:
513
+ Dict with token_count, percentage_used, needs_compaction, etc.
514
+ """
515
+ return self.context_manager.check_context_status(conversation_history)
516
+
517
+ def get_tool_outputs_summary(self) -> dict[str, Any]:
518
+ """Get summary of collected tool outputs.
519
+
520
+ Returns:
521
+ Dict with total_outputs, unique_tools, total_size_chars, estimated_tokens
522
+ """
523
+ return self.context_manager.get_tool_outputs_summary()
524
+
525
+ def get_all_tool_outputs(self) -> list[dict[str, Any]]:
526
+ """Get all collected tool outputs.
527
+
528
+ Returns:
529
+ List of all tool output records
530
+ """
531
+ return self.context_manager.tool_outputs.get_all_outputs()
532
+
533
+ def get_final_tool_outputs(self) -> dict[str, str]:
534
+ """Get only the final output for each tool.
535
+
536
+ Returns:
537
+ Dict mapping tool_name to final output
538
+ """
539
+ return self.context_manager.tool_outputs.get_final_outputs()
540
+
541
+ def compact_context(
542
+ self,
543
+ messages: list[dict[str, Any]],
544
+ strategy: CompactionStrategy = CompactionStrategy.KEEP_RECENT,
545
+ ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
546
+ """Manually compact context.
547
+
548
+ Args:
549
+ messages: Current messages to compact
550
+ strategy: Compaction strategy to use
551
+
552
+ Returns:
553
+ Tuple of (compacted_messages, backup_info)
554
+ """
555
+ compacted, backup = self.context_manager.compact_context(messages, strategy)
556
+ return compacted, {
557
+ "timestamp": backup.timestamp.isoformat(),
558
+ "original_token_count": backup.token_count,
559
+ "new_token_count": self.context_manager.estimate_tokens(compacted),
560
+ "messages_before": len(messages),
561
+ "messages_after": len(compacted),
562
+ }