gobby 0.2.8__py3-none-any.whl → 0.2.11__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 (168) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +6 -0
  3. gobby/adapters/base.py +11 -2
  4. gobby/adapters/claude_code.py +5 -28
  5. gobby/adapters/codex_impl/adapter.py +38 -43
  6. gobby/adapters/copilot.py +324 -0
  7. gobby/adapters/cursor.py +373 -0
  8. gobby/adapters/gemini.py +2 -26
  9. gobby/adapters/windsurf.py +359 -0
  10. gobby/agents/definitions.py +162 -2
  11. gobby/agents/isolation.py +33 -1
  12. gobby/agents/pty_reader.py +192 -0
  13. gobby/agents/registry.py +10 -1
  14. gobby/agents/runner.py +24 -8
  15. gobby/agents/sandbox.py +8 -3
  16. gobby/agents/session.py +4 -0
  17. gobby/agents/spawn.py +9 -2
  18. gobby/agents/spawn_executor.py +49 -61
  19. gobby/agents/spawners/command_builder.py +4 -4
  20. gobby/app_context.py +64 -0
  21. gobby/cli/__init__.py +4 -0
  22. gobby/cli/install.py +259 -4
  23. gobby/cli/installers/__init__.py +12 -0
  24. gobby/cli/installers/copilot.py +242 -0
  25. gobby/cli/installers/cursor.py +244 -0
  26. gobby/cli/installers/shared.py +3 -0
  27. gobby/cli/installers/windsurf.py +242 -0
  28. gobby/cli/pipelines.py +639 -0
  29. gobby/cli/sessions.py +3 -1
  30. gobby/cli/skills.py +209 -0
  31. gobby/cli/tasks/crud.py +6 -5
  32. gobby/cli/tasks/search.py +1 -1
  33. gobby/cli/ui.py +116 -0
  34. gobby/cli/utils.py +5 -17
  35. gobby/cli/workflows.py +38 -17
  36. gobby/config/app.py +5 -0
  37. gobby/config/features.py +0 -20
  38. gobby/config/skills.py +23 -2
  39. gobby/config/tasks.py +4 -0
  40. gobby/hooks/broadcaster.py +9 -0
  41. gobby/hooks/event_handlers/__init__.py +155 -0
  42. gobby/hooks/event_handlers/_agent.py +175 -0
  43. gobby/hooks/event_handlers/_base.py +92 -0
  44. gobby/hooks/event_handlers/_misc.py +66 -0
  45. gobby/hooks/event_handlers/_session.py +487 -0
  46. gobby/hooks/event_handlers/_tool.py +196 -0
  47. gobby/hooks/events.py +48 -0
  48. gobby/hooks/hook_manager.py +27 -3
  49. gobby/install/copilot/hooks/hook_dispatcher.py +203 -0
  50. gobby/install/cursor/hooks/hook_dispatcher.py +203 -0
  51. gobby/install/gemini/hooks/hook_dispatcher.py +8 -0
  52. gobby/install/windsurf/hooks/hook_dispatcher.py +205 -0
  53. gobby/llm/__init__.py +14 -1
  54. gobby/llm/claude.py +594 -43
  55. gobby/llm/service.py +149 -0
  56. gobby/mcp_proxy/importer.py +4 -41
  57. gobby/mcp_proxy/instructions.py +9 -27
  58. gobby/mcp_proxy/manager.py +13 -3
  59. gobby/mcp_proxy/models.py +1 -0
  60. gobby/mcp_proxy/registries.py +66 -5
  61. gobby/mcp_proxy/server.py +6 -2
  62. gobby/mcp_proxy/services/recommendation.py +2 -28
  63. gobby/mcp_proxy/services/tool_filter.py +7 -0
  64. gobby/mcp_proxy/services/tool_proxy.py +19 -1
  65. gobby/mcp_proxy/stdio.py +37 -21
  66. gobby/mcp_proxy/tools/agents.py +7 -0
  67. gobby/mcp_proxy/tools/artifacts.py +3 -3
  68. gobby/mcp_proxy/tools/hub.py +30 -1
  69. gobby/mcp_proxy/tools/orchestration/cleanup.py +5 -5
  70. gobby/mcp_proxy/tools/orchestration/monitor.py +1 -1
  71. gobby/mcp_proxy/tools/orchestration/orchestrate.py +8 -3
  72. gobby/mcp_proxy/tools/orchestration/review.py +17 -4
  73. gobby/mcp_proxy/tools/orchestration/wait.py +7 -7
  74. gobby/mcp_proxy/tools/pipelines/__init__.py +254 -0
  75. gobby/mcp_proxy/tools/pipelines/_discovery.py +67 -0
  76. gobby/mcp_proxy/tools/pipelines/_execution.py +281 -0
  77. gobby/mcp_proxy/tools/sessions/_crud.py +4 -4
  78. gobby/mcp_proxy/tools/sessions/_handoff.py +1 -1
  79. gobby/mcp_proxy/tools/skills/__init__.py +184 -30
  80. gobby/mcp_proxy/tools/spawn_agent.py +229 -14
  81. gobby/mcp_proxy/tools/task_readiness.py +27 -4
  82. gobby/mcp_proxy/tools/tasks/_context.py +8 -0
  83. gobby/mcp_proxy/tools/tasks/_crud.py +27 -1
  84. gobby/mcp_proxy/tools/tasks/_helpers.py +1 -1
  85. gobby/mcp_proxy/tools/tasks/_lifecycle.py +125 -8
  86. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +2 -1
  87. gobby/mcp_proxy/tools/tasks/_search.py +1 -1
  88. gobby/mcp_proxy/tools/workflows/__init__.py +273 -0
  89. gobby/mcp_proxy/tools/workflows/_artifacts.py +225 -0
  90. gobby/mcp_proxy/tools/workflows/_import.py +112 -0
  91. gobby/mcp_proxy/tools/workflows/_lifecycle.py +332 -0
  92. gobby/mcp_proxy/tools/workflows/_query.py +226 -0
  93. gobby/mcp_proxy/tools/workflows/_resolution.py +78 -0
  94. gobby/mcp_proxy/tools/workflows/_terminal.py +175 -0
  95. gobby/mcp_proxy/tools/worktrees.py +54 -15
  96. gobby/memory/components/__init__.py +0 -0
  97. gobby/memory/components/ingestion.py +98 -0
  98. gobby/memory/components/search.py +108 -0
  99. gobby/memory/context.py +5 -5
  100. gobby/memory/manager.py +16 -25
  101. gobby/paths.py +51 -0
  102. gobby/prompts/loader.py +1 -35
  103. gobby/runner.py +131 -16
  104. gobby/servers/http.py +193 -150
  105. gobby/servers/routes/__init__.py +2 -0
  106. gobby/servers/routes/admin.py +56 -0
  107. gobby/servers/routes/mcp/endpoints/execution.py +33 -32
  108. gobby/servers/routes/mcp/endpoints/registry.py +8 -8
  109. gobby/servers/routes/mcp/hooks.py +10 -1
  110. gobby/servers/routes/pipelines.py +227 -0
  111. gobby/servers/websocket.py +314 -1
  112. gobby/sessions/analyzer.py +89 -3
  113. gobby/sessions/manager.py +5 -5
  114. gobby/sessions/transcripts/__init__.py +3 -0
  115. gobby/sessions/transcripts/claude.py +5 -0
  116. gobby/sessions/transcripts/codex.py +5 -0
  117. gobby/sessions/transcripts/gemini.py +5 -0
  118. gobby/skills/hubs/__init__.py +25 -0
  119. gobby/skills/hubs/base.py +234 -0
  120. gobby/skills/hubs/claude_plugins.py +328 -0
  121. gobby/skills/hubs/clawdhub.py +289 -0
  122. gobby/skills/hubs/github_collection.py +465 -0
  123. gobby/skills/hubs/manager.py +263 -0
  124. gobby/skills/hubs/skillhub.py +342 -0
  125. gobby/skills/parser.py +23 -0
  126. gobby/skills/sync.py +5 -4
  127. gobby/storage/artifacts.py +19 -0
  128. gobby/storage/memories.py +4 -4
  129. gobby/storage/migrations.py +118 -3
  130. gobby/storage/pipelines.py +367 -0
  131. gobby/storage/sessions.py +23 -4
  132. gobby/storage/skills.py +48 -8
  133. gobby/storage/tasks/_aggregates.py +2 -2
  134. gobby/storage/tasks/_lifecycle.py +4 -4
  135. gobby/storage/tasks/_models.py +7 -1
  136. gobby/storage/tasks/_queries.py +3 -3
  137. gobby/sync/memories.py +4 -3
  138. gobby/tasks/commits.py +48 -17
  139. gobby/tasks/external_validator.py +4 -17
  140. gobby/tasks/validation.py +13 -87
  141. gobby/tools/summarizer.py +18 -51
  142. gobby/utils/status.py +13 -0
  143. gobby/workflows/actions.py +80 -0
  144. gobby/workflows/context_actions.py +265 -27
  145. gobby/workflows/definitions.py +119 -1
  146. gobby/workflows/detection_helpers.py +23 -11
  147. gobby/workflows/enforcement/__init__.py +11 -1
  148. gobby/workflows/enforcement/blocking.py +96 -0
  149. gobby/workflows/enforcement/handlers.py +35 -1
  150. gobby/workflows/enforcement/task_policy.py +18 -0
  151. gobby/workflows/engine.py +26 -4
  152. gobby/workflows/evaluator.py +8 -5
  153. gobby/workflows/lifecycle_evaluator.py +59 -27
  154. gobby/workflows/loader.py +567 -30
  155. gobby/workflows/lobster_compat.py +147 -0
  156. gobby/workflows/pipeline_executor.py +801 -0
  157. gobby/workflows/pipeline_state.py +172 -0
  158. gobby/workflows/pipeline_webhooks.py +206 -0
  159. gobby/workflows/premature_stop.py +5 -0
  160. gobby/worktrees/git.py +135 -20
  161. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/METADATA +56 -22
  162. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/RECORD +166 -122
  163. gobby/hooks/event_handlers.py +0 -1008
  164. gobby/mcp_proxy/tools/workflows.py +0 -1023
  165. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/WHEEL +0 -0
  166. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/entry_points.txt +0 -0
  167. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/licenses/LICENSE.md +0 -0
  168. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/top_level.txt +0 -0
gobby/llm/claude.py CHANGED
@@ -1,5 +1,9 @@
1
1
  """
2
2
  Claude implementation of LLMProvider.
3
+
4
+ Supports two authentication modes:
5
+ - subscription: Uses Claude Agent SDK via Claude CLI (requires CLI installed)
6
+ - api_key: Uses LiteLLM with anthropic/ prefix (BYOK, no CLI needed)
3
7
  """
4
8
 
5
9
  import asyncio
@@ -8,8 +12,9 @@ import logging
8
12
  import os
9
13
  import shutil
10
14
  import time
15
+ from collections.abc import AsyncIterator
11
16
  from dataclasses import dataclass, field
12
- from typing import Any
17
+ from typing import Any, Literal, cast
13
18
 
14
19
  from claude_agent_sdk import (
15
20
  AssistantMessage,
@@ -26,6 +31,9 @@ from claude_agent_sdk import (
26
31
  from gobby.config.app import DaemonConfig
27
32
  from gobby.llm.base import LLMProvider
28
33
 
34
+ # Type alias for auth mode
35
+ AuthMode = Literal["subscription", "api_key"]
36
+
29
37
 
30
38
  @dataclass
31
39
  class ToolCall:
@@ -55,14 +63,82 @@ class MCPToolResult:
55
63
  """List of tool calls made during generation."""
56
64
 
57
65
 
66
+ # Streaming event types for stream_with_mcp_tools
67
+ @dataclass
68
+ class TextChunk:
69
+ """A chunk of text from the streaming response."""
70
+
71
+ content: str
72
+ """The text content."""
73
+
74
+
75
+ @dataclass
76
+ class ToolCallEvent:
77
+ """Event when a tool is being called."""
78
+
79
+ tool_call_id: str
80
+ """Unique ID for this tool call."""
81
+
82
+ tool_name: str
83
+ """Full tool name (e.g., mcp__gobby-tasks__create_task)."""
84
+
85
+ server_name: str
86
+ """Extracted server name (e.g., gobby-tasks)."""
87
+
88
+ arguments: dict[str, Any]
89
+ """Arguments passed to the tool."""
90
+
91
+
92
+ @dataclass
93
+ class ToolResultEvent:
94
+ """Event when a tool call completes."""
95
+
96
+ tool_call_id: str
97
+ """ID matching the original ToolCallEvent."""
98
+
99
+ success: bool
100
+ """Whether the tool call succeeded."""
101
+
102
+ result: Any = None
103
+ """Result data if successful."""
104
+
105
+ error: str | None = None
106
+ """Error message if failed."""
107
+
108
+
109
+ @dataclass
110
+ class DoneEvent:
111
+ """Event when streaming is complete."""
112
+
113
+ tool_calls_count: int
114
+ """Total number of tool calls made."""
115
+
116
+ cost_usd: float | None = None
117
+ """Cost in USD if available."""
118
+
119
+ duration_ms: float | None = None
120
+ """Duration in milliseconds if available."""
121
+
122
+
123
+ # Union type for all streaming events
124
+ ChatEvent = TextChunk | ToolCallEvent | ToolResultEvent | DoneEvent
125
+
126
+
58
127
  logger = logging.getLogger(__name__)
59
128
 
60
129
 
61
130
  class ClaudeLLMProvider(LLMProvider):
62
131
  """
63
- Claude implementation of LLMProvider using claude_agent_sdk.
132
+ Claude implementation of LLMProvider.
133
+
134
+ Supports two authentication modes:
135
+ - subscription (default): Uses Claude Agent SDK via Claude CLI
136
+ - api_key: Uses LiteLLM with anthropic/ prefix (BYOK, no CLI needed)
64
137
 
65
- Uses subscription-based authentication through Claude CLI.
138
+ The auth_mode is determined by:
139
+ 1. Constructor parameter (highest priority)
140
+ 2. Config file: llm_providers.claude.auth_mode
141
+ 3. Default: "subscription"
66
142
  """
67
143
 
68
144
  @property
@@ -70,16 +146,40 @@ class ClaudeLLMProvider(LLMProvider):
70
146
  """Return provider name."""
71
147
  return "claude"
72
148
 
73
- def __init__(self, config: DaemonConfig):
149
+ @property
150
+ def auth_mode(self) -> AuthMode:
151
+ """Return current authentication mode."""
152
+ return self._auth_mode
153
+
154
+ def __init__(
155
+ self,
156
+ config: DaemonConfig,
157
+ auth_mode: AuthMode | None = None,
158
+ ):
74
159
  """
75
160
  Initialize ClaudeLLMProvider.
76
161
 
77
162
  Args:
78
163
  config: Client configuration.
164
+ auth_mode: Authentication mode override. If None, uses config or default.
79
165
  """
80
166
  self.config = config
81
167
  self.logger = logger
82
- self._claude_cli_path = self._find_cli_path()
168
+ self._litellm: Any = None
169
+
170
+ # Determine auth mode from param -> config -> default
171
+ self._auth_mode: AuthMode = "subscription"
172
+ if auth_mode:
173
+ self._auth_mode = auth_mode
174
+ elif config.llm_providers and config.llm_providers.claude:
175
+ self._auth_mode = config.llm_providers.claude.auth_mode # type: ignore[assignment]
176
+
177
+ # Set up based on auth mode
178
+ if self._auth_mode == "subscription":
179
+ self._claude_cli_path = self._find_cli_path()
180
+ else: # api_key
181
+ self._claude_cli_path = None
182
+ self._setup_litellm()
83
183
 
84
184
  def _find_cli_path(self) -> str | None:
85
185
  """
@@ -147,17 +247,37 @@ class ClaudeLLMProvider(LLMProvider):
147
247
 
148
248
  return cli_path
149
249
 
150
- async def generate_summary(
151
- self, context: dict[str, Any], prompt_template: str | None = None
152
- ) -> str:
250
+ def _setup_litellm(self) -> None:
153
251
  """
154
- Generate session summary using Claude.
252
+ Initialize LiteLLM for api_key mode.
253
+
254
+ LiteLLM reads ANTHROPIC_API_KEY from the environment automatically.
155
255
  """
156
- cli_path = self._verify_cli_path()
157
- if not cli_path:
158
- return "Session summary unavailable (Claude CLI not found)"
256
+ try:
257
+ import litellm
159
258
 
160
- # Build formatted context for prompt template
259
+ self._litellm = litellm
260
+ self.logger.debug("LiteLLM initialized for Claude api_key mode")
261
+ except ImportError:
262
+ self.logger.error("litellm package required for api_key mode")
263
+
264
+ def _format_summary_context(self, context: dict[str, Any], prompt_template: str | None) -> str:
265
+ """
266
+ Format context and validate prompt template for summary generation.
267
+
268
+ Transforms list/dict values to strings for template substitution
269
+ and validates that a prompt template is provided.
270
+
271
+ Args:
272
+ context: Raw context dict with transcript_summary, last_messages, etc.
273
+ prompt_template: Template string with placeholders for context values.
274
+
275
+ Returns:
276
+ Formatted prompt string ready for LLM consumption.
277
+
278
+ Raises:
279
+ ValueError: If prompt_template is None.
280
+ """
161
281
  # Transform list/dict values to strings for template substitution
162
282
  formatted_context = {
163
283
  "transcript_summary": context.get("transcript_summary", ""),
@@ -171,13 +291,68 @@ class ClaudeLLMProvider(LLMProvider):
171
291
  },
172
292
  }
173
293
 
174
- # Build prompt - prompt_template is required
294
+ # Validate prompt_template is provided
175
295
  if not prompt_template:
176
296
  raise ValueError(
177
297
  "prompt_template is required for generate_summary. "
178
298
  "Configure 'session_summary.prompt' in ~/.gobby/config.yaml"
179
299
  )
180
- prompt = prompt_template.format(**formatted_context)
300
+
301
+ return prompt_template.format(**formatted_context)
302
+
303
+ async def _retry_async(
304
+ self,
305
+ operation: Any,
306
+ max_retries: int = 3,
307
+ delay: float = 1.0,
308
+ on_retry: Any | None = None,
309
+ ) -> Any:
310
+ """
311
+ Execute an async operation with retry logic.
312
+
313
+ Args:
314
+ operation: Callable that returns an awaitable (coroutine factory).
315
+ max_retries: Maximum number of attempts (default: 3).
316
+ delay: Delay in seconds between retries (default: 1.0).
317
+ on_retry: Optional callback(attempt: int, error: Exception) called on retry.
318
+
319
+ Returns:
320
+ Result of the operation if successful.
321
+
322
+ Raises:
323
+ Exception: The last exception if all retries fail.
324
+ """
325
+ for attempt in range(max_retries):
326
+ try:
327
+ return await operation()
328
+ except Exception as e:
329
+ if attempt < max_retries - 1:
330
+ if on_retry:
331
+ on_retry(attempt, e)
332
+ await asyncio.sleep(delay)
333
+ else:
334
+ raise
335
+
336
+ async def generate_summary(
337
+ self, context: dict[str, Any], prompt_template: str | None = None
338
+ ) -> str:
339
+ """
340
+ Generate session summary using Claude.
341
+ """
342
+ if self._auth_mode == "subscription":
343
+ return await self._generate_summary_sdk(context, prompt_template)
344
+ else:
345
+ return await self._generate_summary_litellm(context, prompt_template)
346
+
347
+ async def _generate_summary_sdk(
348
+ self, context: dict[str, Any], prompt_template: str | None = None
349
+ ) -> str:
350
+ """Generate session summary using Claude Agent SDK (subscription mode)."""
351
+ cli_path = self._verify_cli_path()
352
+ if not cli_path:
353
+ return "Session summary unavailable (Claude CLI not found)"
354
+
355
+ prompt = self._format_summary_context(context, prompt_template)
181
356
 
182
357
  # Configure Claude Agent SDK
183
358
  options = ClaudeAgentOptions(
@@ -205,8 +380,45 @@ class ClaudeLLMProvider(LLMProvider):
205
380
  self.logger.error(f"Failed to generate summary with Claude: {e}")
206
381
  return f"Session summary generation failed: {e}"
207
382
 
383
+ async def _generate_summary_litellm(
384
+ self, context: dict[str, Any], prompt_template: str | None = None
385
+ ) -> str:
386
+ """Generate session summary using LiteLLM (api_key mode)."""
387
+ if not self._litellm:
388
+ return "Session summary unavailable (LiteLLM not initialized)"
389
+
390
+ prompt = self._format_summary_context(context, prompt_template)
391
+
392
+ try:
393
+ response = await self._litellm.acompletion(
394
+ model=f"anthropic/{self.config.session_summary.model}",
395
+ messages=[
396
+ {
397
+ "role": "system",
398
+ "content": "You are a session summary generator. Create comprehensive, actionable summaries.",
399
+ },
400
+ {"role": "user", "content": prompt},
401
+ ],
402
+ max_tokens=4000,
403
+ )
404
+ return response.choices[0].message.content or ""
405
+ except Exception as e:
406
+ self.logger.error(f"Failed to generate summary with LiteLLM: {e}")
407
+ return f"Session summary generation failed: {e}"
408
+
208
409
  async def synthesize_title(
209
410
  self, user_prompt: str, prompt_template: str | None = None
411
+ ) -> str | None:
412
+ """
413
+ Synthesize session title using Claude.
414
+ """
415
+ if self._auth_mode == "subscription":
416
+ return await self._synthesize_title_sdk(user_prompt, prompt_template)
417
+ else:
418
+ return await self._synthesize_title_litellm(user_prompt, prompt_template)
419
+
420
+ async def _synthesize_title_sdk(
421
+ self, user_prompt: str, prompt_template: str | None = None
210
422
  ) -> str | None:
211
423
  """
212
424
  Synthesize session title using Claude.
@@ -243,26 +455,63 @@ class ClaudeLLMProvider(LLMProvider):
243
455
  title_text = block.text
244
456
  return title_text.strip()
245
457
 
458
+ def _on_retry(attempt: int, error: Exception) -> None:
459
+ self.logger.warning(
460
+ f"Title synthesis failed (attempt {attempt + 1}), retrying: {error}"
461
+ )
462
+
246
463
  try:
247
- # Retry logic for title synthesis
248
- max_retries = 3
249
- for attempt in range(max_retries):
250
- try:
251
- return await _run_query()
252
- except Exception as e:
253
- if attempt < max_retries - 1:
254
- self.logger.warning(
255
- f"Title synthesis failed (attempt {attempt + 1}), retrying: {e}"
256
- )
257
- await asyncio.sleep(1)
258
- else:
259
- raise e
260
- # This should be unreachable, but mypy can't prove it
261
- return None # pragma: no cover
464
+ result = await self._retry_async(
465
+ _run_query, max_retries=3, delay=1.0, on_retry=_on_retry
466
+ )
467
+ return cast(str, result)
262
468
  except Exception as e:
263
469
  self.logger.error(f"Failed to synthesize title with Claude: {e}")
264
470
  return None
265
471
 
472
+ async def _synthesize_title_litellm(
473
+ self, user_prompt: str, prompt_template: str | None = None
474
+ ) -> str | None:
475
+ """Synthesize session title using LiteLLM (api_key mode)."""
476
+ if not self._litellm:
477
+ return None
478
+
479
+ # Build prompt - prompt_template is required
480
+ if not prompt_template:
481
+ raise ValueError(
482
+ "prompt_template is required for synthesize_title. "
483
+ "Configure 'title_synthesis.prompt' in ~/.gobby/config.yaml"
484
+ )
485
+ prompt = prompt_template.format(user_prompt=user_prompt)
486
+
487
+ async def _run_query() -> str:
488
+ response = await self._litellm.acompletion(
489
+ model=f"anthropic/{self.config.title_synthesis.model}",
490
+ messages=[
491
+ {
492
+ "role": "system",
493
+ "content": "You are a session title generator. Create concise, descriptive titles.",
494
+ },
495
+ {"role": "user", "content": prompt},
496
+ ],
497
+ max_tokens=100,
498
+ )
499
+ return (response.choices[0].message.content or "").strip()
500
+
501
+ def _on_retry(attempt: int, error: Exception) -> None:
502
+ self.logger.warning(
503
+ f"Title synthesis failed (attempt {attempt + 1}), retrying: {error}"
504
+ )
505
+
506
+ try:
507
+ result = await self._retry_async(
508
+ _run_query, max_retries=3, delay=1.0, on_retry=_on_retry
509
+ )
510
+ return cast(str, result)
511
+ except Exception as e:
512
+ self.logger.error(f"Failed to synthesize title with LiteLLM: {e}")
513
+ return None
514
+
266
515
  async def generate_text(
267
516
  self,
268
517
  prompt: str,
@@ -272,6 +521,18 @@ class ClaudeLLMProvider(LLMProvider):
272
521
  """
273
522
  Generate text using Claude.
274
523
  """
524
+ if self._auth_mode == "subscription":
525
+ return await self._generate_text_sdk(prompt, system_prompt, model)
526
+ else:
527
+ return await self._generate_text_litellm(prompt, system_prompt, model)
528
+
529
+ async def _generate_text_sdk(
530
+ self,
531
+ prompt: str,
532
+ system_prompt: str | None = None,
533
+ model: str | None = None,
534
+ ) -> str:
535
+ """Generate text using Claude Agent SDK (subscription mode)."""
275
536
  cli_path = self._verify_cli_path()
276
537
  if not cli_path:
277
538
  return "Generation unavailable (Claude CLI not found)"
@@ -323,6 +584,36 @@ class ClaudeLLMProvider(LLMProvider):
323
584
  self.logger.error(f"Failed to generate text with Claude: {e}", exc_info=True)
324
585
  return f"Generation failed: {e}"
325
586
 
587
+ async def _generate_text_litellm(
588
+ self,
589
+ prompt: str,
590
+ system_prompt: str | None = None,
591
+ model: str | None = None,
592
+ ) -> str:
593
+ """Generate text using LiteLLM (api_key mode)."""
594
+ if not self._litellm:
595
+ return "Generation unavailable (LiteLLM not initialized)"
596
+
597
+ model = model or "claude-haiku-4-5"
598
+ litellm_model = f"anthropic/{model}"
599
+
600
+ try:
601
+ response = await self._litellm.acompletion(
602
+ model=litellm_model,
603
+ messages=[
604
+ {
605
+ "role": "system",
606
+ "content": system_prompt or "You are a helpful assistant.",
607
+ },
608
+ {"role": "user", "content": prompt},
609
+ ],
610
+ max_tokens=4000,
611
+ )
612
+ return response.choices[0].message.content or ""
613
+ except Exception as e:
614
+ self.logger.error(f"Failed to generate text with LiteLLM: {e}", exc_info=True)
615
+ return f"Generation failed: {e}"
616
+
326
617
  async def generate_with_mcp_tools(
327
618
  self,
328
619
  prompt: str,
@@ -338,6 +629,9 @@ class ClaudeLLMProvider(LLMProvider):
338
629
  This method enables the agent to call MCP tools during generation,
339
630
  tracking all tool calls made and returning them alongside the final text.
340
631
 
632
+ Note: This method requires subscription mode (Claude Agent SDK).
633
+ In api_key mode, returns an error message.
634
+
341
635
  Args:
342
636
  prompt: User prompt to process.
343
637
  allowed_tools: List of allowed MCP tool patterns.
@@ -364,6 +658,14 @@ class ClaudeLLMProvider(LLMProvider):
364
658
  >>> for call in result.tool_calls:
365
659
  ... print(f"Called {call.tool_name} with {call.arguments}")
366
660
  """
661
+ # MCP tools require subscription mode (Claude Agent SDK)
662
+ if self._auth_mode == "api_key":
663
+ return MCPToolResult(
664
+ text="MCP tools require subscription mode. "
665
+ "Set auth_mode: subscription in llm_providers.claude config.",
666
+ tool_calls=[],
667
+ )
668
+
367
669
  cli_path = self._verify_cli_path()
368
670
  if not cli_path:
369
671
  return MCPToolResult(
@@ -421,7 +723,7 @@ class ClaudeLLMProvider(LLMProvider):
421
723
  parts = full_tool_name.split("__")
422
724
  if len(parts) >= 2:
423
725
  return parts[1]
424
- return "unknown"
726
+ return "builtin"
425
727
 
426
728
  # Run async query
427
729
  async def _run_query() -> str:
@@ -487,6 +789,160 @@ class ClaudeLLMProvider(LLMProvider):
487
789
  tool_calls=tool_calls,
488
790
  )
489
791
 
792
+ async def stream_with_mcp_tools(
793
+ self,
794
+ prompt: str,
795
+ allowed_tools: list[str],
796
+ system_prompt: str | None = None,
797
+ model: str | None = None,
798
+ max_turns: int = 10,
799
+ ) -> AsyncIterator[ChatEvent]:
800
+ """
801
+ Stream generation with MCP tools, yielding events as they occur.
802
+
803
+ This method enables real-time streaming of text and tool call events
804
+ during multi-turn agent conversations. Unlike generate_with_mcp_tools(),
805
+ this yields events incrementally rather than waiting for completion.
806
+
807
+ Note: This method requires subscription mode (Claude Agent SDK).
808
+
809
+ Args:
810
+ prompt: User prompt to process.
811
+ allowed_tools: List of allowed MCP tool patterns.
812
+ Tools should be in format "mcp__{server}__{tool}" or patterns
813
+ like "mcp__gobby-tasks__*" for all tools from a server.
814
+ system_prompt: Optional system prompt.
815
+ model: Optional model override (default: claude-sonnet-4-5).
816
+ max_turns: Maximum number of agentic turns (default: 10).
817
+
818
+ Yields:
819
+ ChatEvent: One of TextChunk, ToolCallEvent, ToolResultEvent, or DoneEvent.
820
+
821
+ Example:
822
+ >>> async for event in provider.stream_with_mcp_tools(
823
+ ... prompt="Create a task called 'Fix bug'",
824
+ ... allowed_tools=["mcp__gobby-tasks__*"],
825
+ ... ):
826
+ ... if isinstance(event, TextChunk):
827
+ ... print(event.content, end="")
828
+ ... elif isinstance(event, ToolCallEvent):
829
+ ... print(f"Calling {event.tool_name}...")
830
+ """
831
+ # MCP tools require subscription mode (Claude Agent SDK)
832
+ if self._auth_mode == "api_key":
833
+ yield TextChunk(
834
+ content="MCP tools require subscription mode. "
835
+ "Set auth_mode: subscription in llm_providers.claude config."
836
+ )
837
+ yield DoneEvent(tool_calls_count=0)
838
+ return
839
+
840
+ cli_path = self._verify_cli_path()
841
+ if not cli_path:
842
+ yield TextChunk(content="Generation unavailable (Claude CLI not found)")
843
+ yield DoneEvent(tool_calls_count=0)
844
+ return
845
+
846
+ # Build mcp_servers config - use .mcp.json if gobby tools requested
847
+ from pathlib import Path
848
+
849
+ mcp_servers_config: dict[str, Any] | str | None = None
850
+
851
+ if any("gobby" in t for t in allowed_tools):
852
+ cwd_config = Path.cwd() / ".mcp.json"
853
+ if cwd_config.exists():
854
+ mcp_servers_config = str(cwd_config)
855
+ else:
856
+ gobby_root = Path(__file__).parent.parent.parent.parent
857
+ gobby_config = gobby_root / ".mcp.json"
858
+ if gobby_config.exists():
859
+ mcp_servers_config = str(gobby_config)
860
+
861
+ # Configure Claude Agent SDK with MCP tools
862
+ options = ClaudeAgentOptions(
863
+ system_prompt=system_prompt
864
+ or "You are Gobby, a helpful assistant with access to tools.",
865
+ max_turns=max_turns,
866
+ model=model or "claude-sonnet-4-5",
867
+ allowed_tools=allowed_tools,
868
+ permission_mode="bypassPermissions",
869
+ cli_path=cli_path,
870
+ mcp_servers=mcp_servers_config if mcp_servers_config is not None else {},
871
+ )
872
+
873
+ def _parse_server_name(full_tool_name: str) -> str:
874
+ """Extract server name from mcp__{server}__{tool} format."""
875
+ if full_tool_name.startswith("mcp__"):
876
+ parts = full_tool_name.split("__")
877
+ if len(parts) >= 2:
878
+ return parts[1]
879
+ return "builtin"
880
+
881
+ tool_calls_count = 0
882
+ pending_tool_calls: dict[str, str] = {} # Map tool_use_id -> tool_name
883
+ needs_spacing_before_text = False # Track if we need spacing before text
884
+
885
+ try:
886
+ async for message in query(prompt=prompt, options=options):
887
+ if isinstance(message, ResultMessage):
888
+ # Final result - extract metadata
889
+ cost_usd = getattr(message, "total_cost_usd", None)
890
+ duration_ms = getattr(message, "duration_ms", None)
891
+ yield DoneEvent(
892
+ tool_calls_count=tool_calls_count,
893
+ cost_usd=cost_usd,
894
+ duration_ms=duration_ms,
895
+ )
896
+
897
+ elif isinstance(message, AssistantMessage):
898
+ for block in message.content:
899
+ if isinstance(block, TextBlock):
900
+ # Add spacing before text that follows tool calls/results
901
+ # This ensures proper paragraph separation in the UI
902
+ text = block.text
903
+ if needs_spacing_before_text and text:
904
+ # Ensure we have a proper paragraph break (double newline)
905
+ # even if the text starts with a single newline
906
+ text = text.lstrip("\n")
907
+ if text:
908
+ text = "\n\n" + text
909
+ yield TextChunk(content=text)
910
+ needs_spacing_before_text = False
911
+ elif isinstance(block, ToolUseBlock):
912
+ tool_calls_count += 1
913
+ server_name = _parse_server_name(block.name)
914
+ pending_tool_calls[block.id] = block.name
915
+ yield ToolCallEvent(
916
+ tool_call_id=block.id,
917
+ tool_name=block.name,
918
+ server_name=server_name,
919
+ arguments=block.input if isinstance(block.input, dict) else {},
920
+ )
921
+
922
+ elif isinstance(message, UserMessage):
923
+ # UserMessage may contain tool results
924
+ if isinstance(message.content, list):
925
+ for block in message.content:
926
+ if isinstance(block, ToolResultBlock):
927
+ # Determine success based on is_error attribute
928
+ is_error = getattr(block, "is_error", False)
929
+ yield ToolResultEvent(
930
+ tool_call_id=block.tool_use_id,
931
+ success=not is_error,
932
+ result=block.content if not is_error else None,
933
+ error=str(block.content) if is_error else None,
934
+ )
935
+ needs_spacing_before_text = True
936
+
937
+ except ExceptionGroup as eg:
938
+ errors = [f"{type(exc).__name__}: {exc}" for exc in eg.exceptions]
939
+ yield TextChunk(content=f"Generation failed: {'; '.join(errors)}")
940
+ yield DoneEvent(tool_calls_count=tool_calls_count)
941
+ except Exception as e:
942
+ self.logger.error(f"Failed to stream with MCP tools: {e}", exc_info=True)
943
+ yield TextChunk(content=f"Generation failed: {e}")
944
+ yield DoneEvent(tool_calls_count=tool_calls_count)
945
+
490
946
  async def describe_image(
491
947
  self,
492
948
  image_path: str,
@@ -495,7 +951,8 @@ class ClaudeLLMProvider(LLMProvider):
495
951
  """
496
952
  Generate a text description of an image using Claude's vision capabilities.
497
953
 
498
- Uses LiteLLM for unified cost tracking with anthropic/claude-haiku-4-5 model.
954
+ In subscription mode, uses Claude Agent SDK.
955
+ In api_key mode, uses LiteLLM with anthropic/ prefix.
499
956
 
500
957
  Args:
501
958
  image_path: Path to the image file to describe
@@ -504,6 +961,21 @@ class ClaudeLLMProvider(LLMProvider):
504
961
  Returns:
505
962
  Text description of the image
506
963
  """
964
+ if self._auth_mode == "subscription":
965
+ return await self._describe_image_sdk(image_path, context)
966
+ else:
967
+ return await self._describe_image_litellm(image_path, context)
968
+
969
+ def _prepare_image_data(self, image_path: str) -> tuple[str, str] | str:
970
+ """
971
+ Validate and prepare image data for API calls.
972
+
973
+ Args:
974
+ image_path: Path to the image file.
975
+
976
+ Returns:
977
+ Tuple of (image_base64, mime_type) on success, or error string on failure.
978
+ """
507
979
  import base64
508
980
  import mimetypes
509
981
  from pathlib import Path
@@ -524,21 +996,103 @@ class ClaudeLLMProvider(LLMProvider):
524
996
  # Determine media type
525
997
  mime_type, _ = mimetypes.guess_type(str(path))
526
998
  if mime_type not in ["image/jpeg", "image/png", "image/gif", "image/webp"]:
527
- # Default to png for unknown types
528
999
  mime_type = "image/png"
529
1000
 
1001
+ return (image_base64, mime_type)
1002
+
1003
+ async def _describe_image_sdk(
1004
+ self,
1005
+ image_path: str,
1006
+ context: str | None = None,
1007
+ ) -> str:
1008
+ """Describe image using Claude Agent SDK (subscription mode)."""
1009
+ cli_path = self._verify_cli_path()
1010
+ if not cli_path:
1011
+ return "Image description unavailable (Claude CLI not found)"
1012
+
1013
+ # Prepare image data
1014
+ result = self._prepare_image_data(image_path)
1015
+ if isinstance(result, str):
1016
+ return result
1017
+ image_base64, mime_type = result
1018
+
1019
+ # Build prompt with image
1020
+ text_prompt = "Please describe this image in detail, focusing on the key visual elements and any text visible."
1021
+ if context:
1022
+ text_prompt = f"{context}\n\n{text_prompt}"
1023
+
1024
+ # Configure Claude Agent SDK
1025
+ options = ClaudeAgentOptions(
1026
+ system_prompt="You are a vision assistant that describes images in detail.",
1027
+ max_turns=1,
1028
+ model="claude-haiku-4-5",
1029
+ tools=[],
1030
+ allowed_tools=[],
1031
+ permission_mode="default",
1032
+ cli_path=cli_path,
1033
+ )
1034
+
1035
+ # Build async generator yielding structured message with image content
1036
+ # The SDK accepts AsyncIterable[dict] for multimodal input
1037
+ async def _message_generator() -> Any:
1038
+ yield {
1039
+ "role": "user",
1040
+ "content": [
1041
+ {"type": "text", "text": text_prompt},
1042
+ {
1043
+ "type": "image",
1044
+ "source": {
1045
+ "type": "base64",
1046
+ "media_type": mime_type,
1047
+ "data": image_base64,
1048
+ },
1049
+ },
1050
+ ],
1051
+ }
1052
+
1053
+ async def _run_query() -> str:
1054
+ result_text = ""
1055
+ async for message in query(prompt=_message_generator(), options=options):
1056
+ if isinstance(message, AssistantMessage):
1057
+ for block in message.content:
1058
+ if isinstance(block, TextBlock):
1059
+ result_text += block.text
1060
+ elif isinstance(message, ResultMessage):
1061
+ if message.result:
1062
+ result_text = message.result
1063
+ return result_text
1064
+
1065
+ try:
1066
+ return await _run_query()
1067
+ except Exception as e:
1068
+ self.logger.error(f"Failed to describe image with Claude SDK: {e}")
1069
+ return f"Image description failed: {e}"
1070
+
1071
+ async def _describe_image_litellm(
1072
+ self,
1073
+ image_path: str,
1074
+ context: str | None = None,
1075
+ ) -> str:
1076
+ """Describe image using LiteLLM (api_key mode)."""
1077
+ if not self._litellm:
1078
+ return "Image description unavailable (LiteLLM not initialized)"
1079
+
1080
+ # Prepare image data
1081
+ result = self._prepare_image_data(image_path)
1082
+ if isinstance(result, str):
1083
+ return result
1084
+ image_base64, mime_type = result
1085
+
530
1086
  # Build prompt
531
1087
  prompt = "Please describe this image in detail, focusing on the key visual elements and any text visible."
532
1088
  if context:
533
1089
  prompt = f"{context}\n\n{prompt}"
534
1090
 
535
- # Use LiteLLM for unified cost tracking
536
1091
  try:
537
- import litellm
538
-
539
- # Route through LiteLLM with anthropic prefix for cost tracking
540
- response = await litellm.acompletion(
541
- model="anthropic/claude-haiku-4-5-20251001", # Use haiku for cost efficiency
1092
+ # Route through LiteLLM with anthropic prefix
1093
+ # Use same model as SDK path for consistency
1094
+ response = await self._litellm.acompletion(
1095
+ model="anthropic/claude-haiku-4-5",
542
1096
  messages=[
543
1097
  {
544
1098
  "role": "user",
@@ -558,9 +1112,6 @@ class ClaudeLLMProvider(LLMProvider):
558
1112
  return "No description generated"
559
1113
  return response.choices[0].message.content or "No description generated"
560
1114
 
561
- except ImportError:
562
- self.logger.error("LiteLLM not installed, falling back to unavailable")
563
- return "Image description unavailable (LiteLLM not installed)"
564
1115
  except Exception as e:
565
- self.logger.error(f"Failed to describe image with Claude via LiteLLM: {e}")
1116
+ self.logger.error(f"Failed to describe image with LiteLLM: {e}")
566
1117
  return f"Image description failed: {e}"