code-puppy 0.0.134__py3-none-any.whl → 0.0.136__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 (60) hide show
  1. code_puppy/agent.py +15 -17
  2. code_puppy/agents/agent_manager.py +320 -9
  3. code_puppy/agents/base_agent.py +58 -2
  4. code_puppy/agents/runtime_manager.py +68 -42
  5. code_puppy/command_line/command_handler.py +82 -33
  6. code_puppy/command_line/mcp/__init__.py +10 -0
  7. code_puppy/command_line/mcp/add_command.py +183 -0
  8. code_puppy/command_line/mcp/base.py +35 -0
  9. code_puppy/command_line/mcp/handler.py +133 -0
  10. code_puppy/command_line/mcp/help_command.py +146 -0
  11. code_puppy/command_line/mcp/install_command.py +176 -0
  12. code_puppy/command_line/mcp/list_command.py +94 -0
  13. code_puppy/command_line/mcp/logs_command.py +126 -0
  14. code_puppy/command_line/mcp/remove_command.py +82 -0
  15. code_puppy/command_line/mcp/restart_command.py +92 -0
  16. code_puppy/command_line/mcp/search_command.py +117 -0
  17. code_puppy/command_line/mcp/start_all_command.py +126 -0
  18. code_puppy/command_line/mcp/start_command.py +98 -0
  19. code_puppy/command_line/mcp/status_command.py +185 -0
  20. code_puppy/command_line/mcp/stop_all_command.py +109 -0
  21. code_puppy/command_line/mcp/stop_command.py +79 -0
  22. code_puppy/command_line/mcp/test_command.py +107 -0
  23. code_puppy/command_line/mcp/utils.py +129 -0
  24. code_puppy/command_line/mcp/wizard_utils.py +259 -0
  25. code_puppy/command_line/model_picker_completion.py +21 -4
  26. code_puppy/command_line/prompt_toolkit_completion.py +9 -0
  27. code_puppy/main.py +23 -17
  28. code_puppy/mcp/__init__.py +42 -16
  29. code_puppy/mcp/async_lifecycle.py +51 -49
  30. code_puppy/mcp/blocking_startup.py +125 -113
  31. code_puppy/mcp/captured_stdio_server.py +63 -70
  32. code_puppy/mcp/circuit_breaker.py +63 -47
  33. code_puppy/mcp/config_wizard.py +169 -136
  34. code_puppy/mcp/dashboard.py +79 -71
  35. code_puppy/mcp/error_isolation.py +147 -100
  36. code_puppy/mcp/examples/retry_example.py +55 -42
  37. code_puppy/mcp/health_monitor.py +152 -141
  38. code_puppy/mcp/managed_server.py +100 -97
  39. code_puppy/mcp/manager.py +168 -156
  40. code_puppy/mcp/registry.py +148 -110
  41. code_puppy/mcp/retry_manager.py +63 -61
  42. code_puppy/mcp/server_registry_catalog.py +271 -225
  43. code_puppy/mcp/status_tracker.py +80 -80
  44. code_puppy/mcp/system_tools.py +47 -52
  45. code_puppy/messaging/message_queue.py +20 -13
  46. code_puppy/messaging/renderers.py +30 -15
  47. code_puppy/state_management.py +103 -0
  48. code_puppy/tui/app.py +64 -7
  49. code_puppy/tui/components/chat_view.py +3 -3
  50. code_puppy/tui/components/human_input_modal.py +12 -8
  51. code_puppy/tui/screens/__init__.py +2 -2
  52. code_puppy/tui/screens/mcp_install_wizard.py +208 -179
  53. code_puppy/tui/tests/test_agent_command.py +3 -3
  54. {code_puppy-0.0.134.dist-info → code_puppy-0.0.136.dist-info}/METADATA +1 -1
  55. {code_puppy-0.0.134.dist-info → code_puppy-0.0.136.dist-info}/RECORD +59 -41
  56. code_puppy/command_line/mcp_commands.py +0 -1789
  57. {code_puppy-0.0.134.data → code_puppy-0.0.136.data}/data/code_puppy/models.json +0 -0
  58. {code_puppy-0.0.134.dist-info → code_puppy-0.0.136.dist-info}/WHEEL +0 -0
  59. {code_puppy-0.0.134.dist-info → code_puppy-0.0.136.dist-info}/entry_points.txt +0 -0
  60. {code_puppy-0.0.134.dist-info → code_puppy-0.0.136.dist-info}/licenses/LICENSE +0 -0
@@ -8,12 +8,10 @@ logic with exponential backoff for failed servers.
8
8
 
9
9
  import asyncio
10
10
  import logging
11
- from datetime import datetime, timedelta
12
11
  from dataclasses import dataclass, field
13
- from typing import Any, Callable, Dict, Optional
12
+ from datetime import datetime, timedelta
14
13
  from enum import Enum
15
- import traceback
16
-
14
+ from typing import Any, Callable, Dict, Optional
17
15
 
18
16
  logger = logging.getLogger(__name__)
19
17
 
@@ -21,6 +19,7 @@ logger = logging.getLogger(__name__)
21
19
  @dataclass
22
20
  class ErrorStats:
23
21
  """Statistics for MCP server errors and quarantine status."""
22
+
24
23
  total_errors: int = 0
25
24
  consecutive_errors: int = 0
26
25
  last_error: Optional[datetime] = None
@@ -31,6 +30,7 @@ class ErrorStats:
31
30
 
32
31
  class ErrorCategory(Enum):
33
32
  """Categories of errors that can be isolated."""
33
+
34
34
  NETWORK = "network"
35
35
  PROTOCOL = "protocol"
36
36
  SERVER = "server"
@@ -42,18 +42,18 @@ class ErrorCategory(Enum):
42
42
  class MCPErrorIsolator:
43
43
  """
44
44
  Isolates MCP server errors to prevent application crashes.
45
-
45
+
46
46
  Features:
47
47
  - Quarantine servers after consecutive failures
48
48
  - Exponential backoff for quarantine duration
49
49
  - Error categorization and tracking
50
50
  - Automatic recovery after successful calls
51
51
  """
52
-
52
+
53
53
  def __init__(self, quarantine_threshold: int = 5, max_quarantine_minutes: int = 30):
54
54
  """
55
55
  Initialize the error isolator.
56
-
56
+
57
57
  Args:
58
58
  quarantine_threshold: Number of consecutive errors to trigger quarantine
59
59
  max_quarantine_minutes: Maximum quarantine duration in minutes
@@ -62,25 +62,27 @@ class MCPErrorIsolator:
62
62
  self.max_quarantine_duration = timedelta(minutes=max_quarantine_minutes)
63
63
  self.server_stats: Dict[str, ErrorStats] = {}
64
64
  self._lock = asyncio.Lock()
65
-
65
+
66
66
  logger.info(
67
67
  f"MCPErrorIsolator initialized with threshold={quarantine_threshold}, "
68
68
  f"max_quarantine={max_quarantine_minutes}min"
69
69
  )
70
-
71
- async def isolated_call(self, server_id: str, func: Callable, *args, **kwargs) -> Any:
70
+
71
+ async def isolated_call(
72
+ self, server_id: str, func: Callable, *args, **kwargs
73
+ ) -> Any:
72
74
  """
73
75
  Execute a function call with error isolation.
74
-
76
+
75
77
  Args:
76
78
  server_id: ID of the MCP server making the call
77
79
  func: Function to execute
78
80
  *args: Arguments for the function
79
81
  **kwargs: Keyword arguments for the function
80
-
82
+
81
83
  Returns:
82
84
  Result of the function call
83
-
85
+
84
86
  Raises:
85
87
  Exception: If the server is quarantined or the call fails
86
88
  """
@@ -91,32 +93,32 @@ class MCPErrorIsolator:
91
93
  raise QuarantinedServerError(
92
94
  f"Server {server_id} is quarantined until {quarantine_until}"
93
95
  )
94
-
96
+
95
97
  try:
96
98
  # Execute the function
97
99
  if asyncio.iscoroutinefunction(func):
98
100
  result = await func(*args, **kwargs)
99
101
  else:
100
102
  result = func(*args, **kwargs)
101
-
103
+
102
104
  # Record success
103
105
  async with self._lock:
104
106
  await self._record_success(server_id)
105
-
107
+
106
108
  return result
107
-
109
+
108
110
  except Exception as error:
109
111
  # Record and categorize the error
110
112
  async with self._lock:
111
113
  await self._record_error(server_id, error)
112
-
114
+
113
115
  # Re-raise the error
114
116
  raise
115
-
117
+
116
118
  async def quarantine_server(self, server_id: str, duration: int) -> None:
117
119
  """
118
120
  Manually quarantine a server for a specific duration.
119
-
121
+
120
122
  Args:
121
123
  server_id: ID of the server to quarantine
122
124
  duration: Quarantine duration in seconds
@@ -125,40 +127,40 @@ class MCPErrorIsolator:
125
127
  stats = self._get_or_create_stats(server_id)
126
128
  stats.quarantine_until = datetime.now() + timedelta(seconds=duration)
127
129
  stats.quarantine_count += 1
128
-
130
+
129
131
  logger.warning(
130
132
  f"Server {server_id} quarantined for {duration}s "
131
133
  f"(count: {stats.quarantine_count})"
132
134
  )
133
-
135
+
134
136
  def is_quarantined(self, server_id: str) -> bool:
135
137
  """
136
138
  Check if a server is currently quarantined.
137
-
139
+
138
140
  Args:
139
141
  server_id: ID of the server to check
140
-
142
+
141
143
  Returns:
142
144
  True if the server is quarantined, False otherwise
143
145
  """
144
146
  if server_id not in self.server_stats:
145
147
  return False
146
-
148
+
147
149
  stats = self.server_stats[server_id]
148
150
  if stats.quarantine_until is None:
149
151
  return False
150
-
152
+
151
153
  # Check if quarantine has expired
152
154
  if datetime.now() >= stats.quarantine_until:
153
155
  stats.quarantine_until = None
154
156
  return False
155
-
157
+
156
158
  return True
157
-
159
+
158
160
  async def release_quarantine(self, server_id: str) -> None:
159
161
  """
160
162
  Manually release a server from quarantine.
161
-
163
+
162
164
  Args:
163
165
  server_id: ID of the server to release
164
166
  """
@@ -166,180 +168,225 @@ class MCPErrorIsolator:
166
168
  if server_id in self.server_stats:
167
169
  self.server_stats[server_id].quarantine_until = None
168
170
  logger.info(f"Server {server_id} released from quarantine")
169
-
171
+
170
172
  def get_error_stats(self, server_id: str) -> ErrorStats:
171
173
  """
172
174
  Get error statistics for a server.
173
-
175
+
174
176
  Args:
175
177
  server_id: ID of the server
176
-
178
+
177
179
  Returns:
178
180
  ErrorStats object with current statistics
179
181
  """
180
182
  if server_id not in self.server_stats:
181
183
  return ErrorStats()
182
-
184
+
183
185
  return self.server_stats[server_id]
184
-
186
+
185
187
  def should_quarantine(self, server_id: str) -> bool:
186
188
  """
187
189
  Check if a server should be quarantined based on error count.
188
-
190
+
189
191
  Args:
190
192
  server_id: ID of the server to check
191
-
193
+
192
194
  Returns:
193
195
  True if the server should be quarantined
194
196
  """
195
197
  if server_id not in self.server_stats:
196
198
  return False
197
-
199
+
198
200
  stats = self.server_stats[server_id]
199
201
  return stats.consecutive_errors >= self.quarantine_threshold
200
-
202
+
201
203
  def _get_or_create_stats(self, server_id: str) -> ErrorStats:
202
204
  """Get or create error stats for a server."""
203
205
  if server_id not in self.server_stats:
204
206
  self.server_stats[server_id] = ErrorStats()
205
207
  return self.server_stats[server_id]
206
-
208
+
207
209
  async def _record_success(self, server_id: str) -> None:
208
210
  """Record a successful call and reset consecutive error count."""
209
211
  stats = self._get_or_create_stats(server_id)
210
212
  stats.consecutive_errors = 0
211
-
212
- logger.debug(f"Success recorded for server {server_id}, consecutive errors reset")
213
-
213
+
214
+ logger.debug(
215
+ f"Success recorded for server {server_id}, consecutive errors reset"
216
+ )
217
+
214
218
  async def _record_error(self, server_id: str, error: Exception) -> None:
215
219
  """Record an error and potentially quarantine the server."""
216
220
  stats = self._get_or_create_stats(server_id)
217
-
221
+
218
222
  # Update error statistics
219
223
  stats.total_errors += 1
220
224
  stats.consecutive_errors += 1
221
225
  stats.last_error = datetime.now()
222
-
226
+
223
227
  # Categorize the error
224
228
  error_category = self._categorize_error(error)
225
229
  error_type = error_category.value
226
230
  stats.error_types[error_type] = stats.error_types.get(error_type, 0) + 1
227
-
231
+
228
232
  logger.warning(
229
233
  f"Error recorded for server {server_id}: {error_type} - {str(error)} "
230
234
  f"(consecutive: {stats.consecutive_errors})"
231
235
  )
232
-
236
+
233
237
  # Check if quarantine is needed
234
238
  if self.should_quarantine(server_id):
235
- quarantine_duration = self._calculate_quarantine_duration(stats.quarantine_count)
236
- stats.quarantine_until = datetime.now() + timedelta(seconds=quarantine_duration)
239
+ quarantine_duration = self._calculate_quarantine_duration(
240
+ stats.quarantine_count
241
+ )
242
+ stats.quarantine_until = datetime.now() + timedelta(
243
+ seconds=quarantine_duration
244
+ )
237
245
  stats.quarantine_count += 1
238
-
246
+
239
247
  logger.error(
240
248
  f"Server {server_id} quarantined for {quarantine_duration}s "
241
249
  f"after {stats.consecutive_errors} consecutive errors "
242
250
  f"(quarantine count: {stats.quarantine_count})"
243
251
  )
244
-
252
+
245
253
  def _categorize_error(self, error: Exception) -> ErrorCategory:
246
254
  """
247
255
  Categorize an error based on its type and properties.
248
-
256
+
249
257
  Args:
250
258
  error: The exception to categorize
251
-
259
+
252
260
  Returns:
253
261
  ErrorCategory enum value
254
262
  """
255
263
  error_type = type(error).__name__.lower()
256
264
  error_message = str(error).lower()
257
-
265
+
258
266
  # Network errors
259
- if any(keyword in error_type for keyword in [
260
- 'connection', 'timeout', 'network', 'socket', 'dns', 'ssl'
261
- ]):
267
+ if any(
268
+ keyword in error_type
269
+ for keyword in ["connection", "timeout", "network", "socket", "dns", "ssl"]
270
+ ):
262
271
  return ErrorCategory.NETWORK
263
-
264
- if any(keyword in error_message for keyword in [
265
- 'connection', 'timeout', 'network', 'unreachable', 'refused'
266
- ]):
272
+
273
+ if any(
274
+ keyword in error_message
275
+ for keyword in [
276
+ "connection",
277
+ "timeout",
278
+ "network",
279
+ "unreachable",
280
+ "refused",
281
+ ]
282
+ ):
267
283
  return ErrorCategory.NETWORK
268
-
284
+
269
285
  # Protocol errors
270
- if any(keyword in error_type for keyword in [
271
- 'json', 'decode', 'parse', 'schema', 'validation', 'protocol'
272
- ]):
286
+ if any(
287
+ keyword in error_type
288
+ for keyword in [
289
+ "json",
290
+ "decode",
291
+ "parse",
292
+ "schema",
293
+ "validation",
294
+ "protocol",
295
+ ]
296
+ ):
273
297
  return ErrorCategory.PROTOCOL
274
-
275
- if any(keyword in error_message for keyword in [
276
- 'json', 'decode', 'parse', 'invalid', 'malformed', 'schema'
277
- ]):
298
+
299
+ if any(
300
+ keyword in error_message
301
+ for keyword in ["json", "decode", "parse", "invalid", "malformed", "schema"]
302
+ ):
278
303
  return ErrorCategory.PROTOCOL
279
-
304
+
280
305
  # Authentication errors
281
- if any(keyword in error_type for keyword in [
282
- 'auth', 'permission', 'unauthorized', 'forbidden'
283
- ]):
306
+ if any(
307
+ keyword in error_type
308
+ for keyword in ["auth", "permission", "unauthorized", "forbidden"]
309
+ ):
284
310
  return ErrorCategory.AUTHENTICATION
285
-
286
- if any(keyword in error_message for keyword in [
287
- '401', '403', 'unauthorized', 'forbidden', 'authentication', 'permission'
288
- ]):
311
+
312
+ if any(
313
+ keyword in error_message
314
+ for keyword in [
315
+ "401",
316
+ "403",
317
+ "unauthorized",
318
+ "forbidden",
319
+ "authentication",
320
+ "permission",
321
+ ]
322
+ ):
289
323
  return ErrorCategory.AUTHENTICATION
290
-
324
+
291
325
  # Rate limit errors
292
- if any(keyword in error_type for keyword in ['rate', 'limit', 'throttle']):
326
+ if any(keyword in error_type for keyword in ["rate", "limit", "throttle"]):
293
327
  return ErrorCategory.RATE_LIMIT
294
-
295
- if any(keyword in error_message for keyword in [
296
- '429', 'rate limit', 'too many requests', 'throttle'
297
- ]):
328
+
329
+ if any(
330
+ keyword in error_message
331
+ for keyword in ["429", "rate limit", "too many requests", "throttle"]
332
+ ):
298
333
  return ErrorCategory.RATE_LIMIT
299
-
334
+
300
335
  # Server errors (5xx responses)
301
- if any(keyword in error_message for keyword in [
302
- '500', '501', '502', '503', '504', '505', 'internal server error',
303
- 'bad gateway', 'service unavailable', 'gateway timeout'
304
- ]):
336
+ if any(
337
+ keyword in error_message
338
+ for keyword in [
339
+ "500",
340
+ "501",
341
+ "502",
342
+ "503",
343
+ "504",
344
+ "505",
345
+ "internal server error",
346
+ "bad gateway",
347
+ "service unavailable",
348
+ "gateway timeout",
349
+ ]
350
+ ):
305
351
  return ErrorCategory.SERVER
306
-
307
- if any(keyword in error_type for keyword in ['server', 'internal']):
352
+
353
+ if any(keyword in error_type for keyword in ["server", "internal"]):
308
354
  return ErrorCategory.SERVER
309
-
355
+
310
356
  # Default to unknown
311
357
  return ErrorCategory.UNKNOWN
312
-
358
+
313
359
  def _calculate_quarantine_duration(self, quarantine_count: int) -> int:
314
360
  """
315
361
  Calculate quarantine duration using exponential backoff.
316
-
362
+
317
363
  Args:
318
364
  quarantine_count: Number of times this server has been quarantined
319
-
365
+
320
366
  Returns:
321
367
  Quarantine duration in seconds
322
368
  """
323
369
  # Base duration: 30 seconds
324
370
  base_duration = 30
325
-
371
+
326
372
  # Exponential backoff: 30s, 60s, 120s, 240s, etc.
327
- duration = base_duration * (2 ** quarantine_count)
328
-
373
+ duration = base_duration * (2**quarantine_count)
374
+
329
375
  # Cap at maximum duration (convert to seconds)
330
376
  max_seconds = int(self.max_quarantine_duration.total_seconds())
331
377
  duration = min(duration, max_seconds)
332
-
378
+
333
379
  logger.debug(
334
380
  f"Calculated quarantine duration: {duration}s "
335
381
  f"(count: {quarantine_count}, max: {max_seconds}s)"
336
382
  )
337
-
383
+
338
384
  return duration
339
385
 
340
386
 
341
387
  class QuarantinedServerError(Exception):
342
388
  """Raised when attempting to call a quarantined server."""
389
+
343
390
  pass
344
391
 
345
392
 
@@ -350,11 +397,11 @@ _isolator_instance: Optional[MCPErrorIsolator] = None
350
397
  def get_error_isolator() -> MCPErrorIsolator:
351
398
  """
352
399
  Get the global MCPErrorIsolator instance.
353
-
400
+
354
401
  Returns:
355
402
  MCPErrorIsolator instance
356
403
  """
357
404
  global _isolator_instance
358
405
  if _isolator_instance is None:
359
406
  _isolator_instance = MCPErrorIsolator()
360
- return _isolator_instance
407
+ return _isolator_instance