gobby 0.2.5__py3-none-any.whl → 0.2.7__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 (244) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +2 -1
  3. gobby/adapters/claude_code.py +13 -4
  4. gobby/adapters/codex_impl/__init__.py +28 -0
  5. gobby/adapters/codex_impl/adapter.py +722 -0
  6. gobby/adapters/codex_impl/client.py +679 -0
  7. gobby/adapters/codex_impl/protocol.py +20 -0
  8. gobby/adapters/codex_impl/types.py +68 -0
  9. gobby/agents/definitions.py +11 -1
  10. gobby/agents/isolation.py +395 -0
  11. gobby/agents/runner.py +8 -0
  12. gobby/agents/sandbox.py +261 -0
  13. gobby/agents/spawn.py +42 -287
  14. gobby/agents/spawn_executor.py +385 -0
  15. gobby/agents/spawners/__init__.py +24 -0
  16. gobby/agents/spawners/command_builder.py +189 -0
  17. gobby/agents/spawners/embedded.py +21 -2
  18. gobby/agents/spawners/headless.py +21 -2
  19. gobby/agents/spawners/prompt_manager.py +125 -0
  20. gobby/cli/__init__.py +6 -0
  21. gobby/cli/clones.py +419 -0
  22. gobby/cli/conductor.py +266 -0
  23. gobby/cli/install.py +4 -4
  24. gobby/cli/installers/antigravity.py +3 -9
  25. gobby/cli/installers/claude.py +15 -9
  26. gobby/cli/installers/codex.py +2 -8
  27. gobby/cli/installers/gemini.py +8 -8
  28. gobby/cli/installers/shared.py +175 -13
  29. gobby/cli/sessions.py +1 -1
  30. gobby/cli/skills.py +858 -0
  31. gobby/cli/tasks/ai.py +0 -440
  32. gobby/cli/tasks/crud.py +44 -6
  33. gobby/cli/tasks/main.py +0 -4
  34. gobby/cli/tui.py +2 -2
  35. gobby/cli/utils.py +12 -5
  36. gobby/clones/__init__.py +13 -0
  37. gobby/clones/git.py +547 -0
  38. gobby/conductor/__init__.py +16 -0
  39. gobby/conductor/alerts.py +135 -0
  40. gobby/conductor/loop.py +164 -0
  41. gobby/conductor/monitors/__init__.py +11 -0
  42. gobby/conductor/monitors/agents.py +116 -0
  43. gobby/conductor/monitors/tasks.py +155 -0
  44. gobby/conductor/pricing.py +234 -0
  45. gobby/conductor/token_tracker.py +160 -0
  46. gobby/config/__init__.py +12 -97
  47. gobby/config/app.py +69 -91
  48. gobby/config/extensions.py +2 -2
  49. gobby/config/features.py +7 -130
  50. gobby/config/search.py +110 -0
  51. gobby/config/servers.py +1 -1
  52. gobby/config/skills.py +43 -0
  53. gobby/config/tasks.py +9 -41
  54. gobby/hooks/__init__.py +0 -13
  55. gobby/hooks/event_handlers.py +188 -2
  56. gobby/hooks/hook_manager.py +50 -4
  57. gobby/hooks/plugins.py +1 -1
  58. gobby/hooks/skill_manager.py +130 -0
  59. gobby/hooks/webhooks.py +1 -1
  60. gobby/install/claude/hooks/hook_dispatcher.py +4 -4
  61. gobby/install/codex/hooks/hook_dispatcher.py +1 -1
  62. gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
  63. gobby/llm/claude.py +22 -34
  64. gobby/llm/claude_executor.py +46 -256
  65. gobby/llm/codex_executor.py +59 -291
  66. gobby/llm/executor.py +21 -0
  67. gobby/llm/gemini.py +134 -110
  68. gobby/llm/litellm_executor.py +143 -6
  69. gobby/llm/resolver.py +98 -35
  70. gobby/mcp_proxy/importer.py +62 -4
  71. gobby/mcp_proxy/instructions.py +56 -0
  72. gobby/mcp_proxy/models.py +15 -0
  73. gobby/mcp_proxy/registries.py +68 -8
  74. gobby/mcp_proxy/server.py +33 -3
  75. gobby/mcp_proxy/services/recommendation.py +43 -11
  76. gobby/mcp_proxy/services/tool_proxy.py +81 -1
  77. gobby/mcp_proxy/stdio.py +2 -1
  78. gobby/mcp_proxy/tools/__init__.py +0 -2
  79. gobby/mcp_proxy/tools/agent_messaging.py +317 -0
  80. gobby/mcp_proxy/tools/agents.py +31 -731
  81. gobby/mcp_proxy/tools/clones.py +518 -0
  82. gobby/mcp_proxy/tools/memory.py +3 -26
  83. gobby/mcp_proxy/tools/metrics.py +65 -1
  84. gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
  85. gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
  86. gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
  87. gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
  88. gobby/mcp_proxy/tools/sessions/_commits.py +232 -0
  89. gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
  90. gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
  91. gobby/mcp_proxy/tools/sessions/_handoff.py +499 -0
  92. gobby/mcp_proxy/tools/sessions/_messages.py +138 -0
  93. gobby/mcp_proxy/tools/skills/__init__.py +616 -0
  94. gobby/mcp_proxy/tools/spawn_agent.py +417 -0
  95. gobby/mcp_proxy/tools/task_orchestration.py +7 -0
  96. gobby/mcp_proxy/tools/task_readiness.py +14 -0
  97. gobby/mcp_proxy/tools/task_sync.py +1 -1
  98. gobby/mcp_proxy/tools/tasks/_context.py +0 -20
  99. gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
  100. gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
  101. gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
  102. gobby/mcp_proxy/tools/tasks/_lifecycle.py +110 -45
  103. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
  104. gobby/mcp_proxy/tools/workflows.py +1 -1
  105. gobby/mcp_proxy/tools/worktrees.py +0 -338
  106. gobby/memory/backends/__init__.py +6 -1
  107. gobby/memory/backends/mem0.py +6 -1
  108. gobby/memory/extractor.py +477 -0
  109. gobby/memory/ingestion/__init__.py +5 -0
  110. gobby/memory/ingestion/multimodal.py +221 -0
  111. gobby/memory/manager.py +73 -285
  112. gobby/memory/search/__init__.py +10 -0
  113. gobby/memory/search/coordinator.py +248 -0
  114. gobby/memory/services/__init__.py +5 -0
  115. gobby/memory/services/crossref.py +142 -0
  116. gobby/prompts/loader.py +5 -2
  117. gobby/runner.py +37 -16
  118. gobby/search/__init__.py +48 -6
  119. gobby/search/backends/__init__.py +159 -0
  120. gobby/search/backends/embedding.py +225 -0
  121. gobby/search/embeddings.py +238 -0
  122. gobby/search/models.py +148 -0
  123. gobby/search/unified.py +496 -0
  124. gobby/servers/http.py +24 -12
  125. gobby/servers/routes/admin.py +294 -0
  126. gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
  127. gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
  128. gobby/servers/routes/mcp/endpoints/execution.py +568 -0
  129. gobby/servers/routes/mcp/endpoints/registry.py +378 -0
  130. gobby/servers/routes/mcp/endpoints/server.py +304 -0
  131. gobby/servers/routes/mcp/hooks.py +1 -1
  132. gobby/servers/routes/mcp/tools.py +48 -1317
  133. gobby/servers/websocket.py +2 -2
  134. gobby/sessions/analyzer.py +2 -0
  135. gobby/sessions/lifecycle.py +1 -1
  136. gobby/sessions/processor.py +10 -0
  137. gobby/sessions/transcripts/base.py +2 -0
  138. gobby/sessions/transcripts/claude.py +79 -10
  139. gobby/skills/__init__.py +91 -0
  140. gobby/skills/loader.py +685 -0
  141. gobby/skills/manager.py +384 -0
  142. gobby/skills/parser.py +286 -0
  143. gobby/skills/search.py +463 -0
  144. gobby/skills/sync.py +119 -0
  145. gobby/skills/updater.py +385 -0
  146. gobby/skills/validator.py +368 -0
  147. gobby/storage/clones.py +378 -0
  148. gobby/storage/database.py +1 -1
  149. gobby/storage/memories.py +43 -13
  150. gobby/storage/migrations.py +162 -201
  151. gobby/storage/sessions.py +116 -7
  152. gobby/storage/skills.py +782 -0
  153. gobby/storage/tasks/_crud.py +4 -4
  154. gobby/storage/tasks/_lifecycle.py +57 -7
  155. gobby/storage/tasks/_manager.py +14 -5
  156. gobby/storage/tasks/_models.py +8 -3
  157. gobby/sync/memories.py +40 -5
  158. gobby/sync/tasks.py +83 -6
  159. gobby/tasks/__init__.py +1 -2
  160. gobby/tasks/external_validator.py +1 -1
  161. gobby/tasks/validation.py +46 -35
  162. gobby/tools/summarizer.py +91 -10
  163. gobby/tui/api_client.py +4 -7
  164. gobby/tui/app.py +5 -3
  165. gobby/tui/screens/orchestrator.py +1 -2
  166. gobby/tui/screens/tasks.py +2 -4
  167. gobby/tui/ws_client.py +1 -1
  168. gobby/utils/daemon_client.py +2 -2
  169. gobby/utils/project_context.py +2 -3
  170. gobby/utils/status.py +13 -0
  171. gobby/workflows/actions.py +221 -1135
  172. gobby/workflows/artifact_actions.py +31 -0
  173. gobby/workflows/autonomous_actions.py +11 -0
  174. gobby/workflows/context_actions.py +93 -1
  175. gobby/workflows/detection_helpers.py +115 -31
  176. gobby/workflows/enforcement/__init__.py +47 -0
  177. gobby/workflows/enforcement/blocking.py +269 -0
  178. gobby/workflows/enforcement/commit_policy.py +283 -0
  179. gobby/workflows/enforcement/handlers.py +269 -0
  180. gobby/workflows/{task_enforcement_actions.py → enforcement/task_policy.py} +29 -388
  181. gobby/workflows/engine.py +13 -2
  182. gobby/workflows/git_utils.py +106 -0
  183. gobby/workflows/lifecycle_evaluator.py +29 -1
  184. gobby/workflows/llm_actions.py +30 -0
  185. gobby/workflows/loader.py +19 -6
  186. gobby/workflows/mcp_actions.py +20 -1
  187. gobby/workflows/memory_actions.py +154 -0
  188. gobby/workflows/safe_evaluator.py +183 -0
  189. gobby/workflows/session_actions.py +44 -0
  190. gobby/workflows/state_actions.py +60 -1
  191. gobby/workflows/stop_signal_actions.py +55 -0
  192. gobby/workflows/summary_actions.py +111 -1
  193. gobby/workflows/task_sync_actions.py +347 -0
  194. gobby/workflows/todo_actions.py +34 -1
  195. gobby/workflows/webhook_actions.py +185 -0
  196. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/METADATA +87 -21
  197. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/RECORD +201 -172
  198. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/WHEEL +1 -1
  199. gobby/adapters/codex.py +0 -1292
  200. gobby/install/claude/commands/gobby/bug.md +0 -51
  201. gobby/install/claude/commands/gobby/chore.md +0 -51
  202. gobby/install/claude/commands/gobby/epic.md +0 -52
  203. gobby/install/claude/commands/gobby/eval.md +0 -235
  204. gobby/install/claude/commands/gobby/feat.md +0 -49
  205. gobby/install/claude/commands/gobby/nit.md +0 -52
  206. gobby/install/claude/commands/gobby/ref.md +0 -52
  207. gobby/install/codex/prompts/forget.md +0 -7
  208. gobby/install/codex/prompts/memories.md +0 -7
  209. gobby/install/codex/prompts/recall.md +0 -7
  210. gobby/install/codex/prompts/remember.md +0 -13
  211. gobby/llm/gemini_executor.py +0 -339
  212. gobby/mcp_proxy/tools/session_messages.py +0 -1056
  213. gobby/mcp_proxy/tools/task_expansion.py +0 -591
  214. gobby/prompts/defaults/expansion/system.md +0 -119
  215. gobby/prompts/defaults/expansion/user.md +0 -48
  216. gobby/prompts/defaults/external_validation/agent.md +0 -72
  217. gobby/prompts/defaults/external_validation/external.md +0 -63
  218. gobby/prompts/defaults/external_validation/spawn.md +0 -83
  219. gobby/prompts/defaults/external_validation/system.md +0 -6
  220. gobby/prompts/defaults/features/import_mcp.md +0 -22
  221. gobby/prompts/defaults/features/import_mcp_github.md +0 -17
  222. gobby/prompts/defaults/features/import_mcp_search.md +0 -16
  223. gobby/prompts/defaults/features/recommend_tools.md +0 -32
  224. gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
  225. gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
  226. gobby/prompts/defaults/features/server_description.md +0 -20
  227. gobby/prompts/defaults/features/server_description_system.md +0 -6
  228. gobby/prompts/defaults/features/task_description.md +0 -31
  229. gobby/prompts/defaults/features/task_description_system.md +0 -6
  230. gobby/prompts/defaults/features/tool_summary.md +0 -17
  231. gobby/prompts/defaults/features/tool_summary_system.md +0 -6
  232. gobby/prompts/defaults/research/step.md +0 -58
  233. gobby/prompts/defaults/validation/criteria.md +0 -47
  234. gobby/prompts/defaults/validation/validate.md +0 -38
  235. gobby/storage/migrations_legacy.py +0 -1359
  236. gobby/tasks/context.py +0 -747
  237. gobby/tasks/criteria.py +0 -342
  238. gobby/tasks/expansion.py +0 -626
  239. gobby/tasks/prompts/expand.py +0 -327
  240. gobby/tasks/research.py +0 -421
  241. gobby/tasks/tdd.py +0 -352
  242. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/entry_points.txt +0 -0
  243. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/licenses/LICENSE.md +0 -0
  244. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/top_level.txt +0 -0
@@ -26,11 +26,12 @@ Exit Codes:
26
26
 
27
27
  import argparse
28
28
  import json
29
+ import os
29
30
  import sys
30
31
  from pathlib import Path
31
32
 
32
33
  # Default daemon configuration
33
- DEFAULT_DAEMON_PORT = 8765
34
+ DEFAULT_DAEMON_PORT = 60887
34
35
  DEFAULT_CONFIG_PATH = "~/.gobby/config.yaml"
35
36
 
36
37
 
@@ -38,10 +39,10 @@ def get_daemon_url() -> str:
38
39
  """Get the daemon HTTP URL from config file.
39
40
 
40
41
  Reads daemon_port from ~/.gobby/config.yaml if it exists,
41
- otherwise uses the default port 8765.
42
+ otherwise uses the default port 60887.
42
43
 
43
44
  Returns:
44
- Full daemon URL like http://localhost:8765
45
+ Full daemon URL like http://localhost:60887
45
46
  """
46
47
  config_path = Path(DEFAULT_CONFIG_PATH).expanduser()
47
48
 
@@ -61,6 +62,55 @@ def get_daemon_url() -> str:
61
62
  return f"http://localhost:{port}"
62
63
 
63
64
 
65
+ def get_terminal_context() -> dict[str, str | int | bool | None]:
66
+ """Capture terminal/process context for session correlation.
67
+
68
+ Returns:
69
+ Dict with terminal identifiers (values may be None if unavailable)
70
+ """
71
+ context: dict[str, str | int | bool | None] = {}
72
+
73
+ # Parent process ID (shell or Gemini process)
74
+ try:
75
+ context["parent_pid"] = os.getppid()
76
+ except Exception:
77
+ context["parent_pid"] = None
78
+
79
+ # TTY device name
80
+ try:
81
+ context["tty"] = os.ttyname(0)
82
+ except Exception:
83
+ context["tty"] = None
84
+
85
+ # macOS Terminal.app session ID
86
+ context["term_session_id"] = os.environ.get("TERM_SESSION_ID")
87
+
88
+ # iTerm2 session ID
89
+ context["iterm_session_id"] = os.environ.get("ITERM_SESSION_ID")
90
+
91
+ # VS Code integrated terminal detection
92
+ # VSCODE_IPC_HOOK_CLI is set when running in VS Code's integrated terminal
93
+ # TERM_PROGRAM == "vscode" is also a reliable indicator
94
+ vscode_ipc_hook = os.environ.get("VSCODE_IPC_HOOK_CLI")
95
+ term_program = os.environ.get("TERM_PROGRAM")
96
+ context["vscode_ipc_hook_cli"] = vscode_ipc_hook
97
+ context["vscode_terminal_detected"] = bool(vscode_ipc_hook) or term_program == "vscode"
98
+
99
+ # Tmux pane (if running in tmux)
100
+ context["tmux_pane"] = os.environ.get("TMUX_PANE")
101
+
102
+ # Kitty terminal window ID
103
+ context["kitty_window_id"] = os.environ.get("KITTY_WINDOW_ID")
104
+
105
+ # Alacritty IPC socket path (unique per instance)
106
+ context["alacritty_socket"] = os.environ.get("ALACRITTY_SOCKET")
107
+
108
+ # Generic terminal program identifier (set by many terminals)
109
+ context["term_program"] = os.environ.get("TERM_PROGRAM")
110
+
111
+ return context
112
+
113
+
64
114
  def parse_arguments() -> argparse.Namespace:
65
115
  """Parse command line arguments.
66
116
 
@@ -128,11 +178,26 @@ def main() -> int:
128
178
 
129
179
  # Check if gobby daemon is running before processing hooks
130
180
  if not check_daemon_running():
131
- # Daemon is not running - return gracefully without processing
132
- print(
133
- json.dumps({"status": "daemon_not_running", "message": "gobby daemon is not running"})
134
- )
135
- return 0 # Exit 0 (allow) - this is expected behavior, not an error
181
+ # Critical hooks that manage session state MUST have daemon running
182
+ # Per Gemini CLI docs: SessionEnd, Notification, PreCompress are async/non-blocking
183
+ # Only SessionStart is critical for session initialization
184
+ critical_hooks = {"SessionStart"}
185
+ if hook_type in critical_hooks:
186
+ # Block the hook - forces user to start daemon before critical lifecycle events
187
+ print(
188
+ f"Gobby daemon is not running. Start with 'gobby start' before continuing. "
189
+ f"({hook_type} requires daemon for session state management)",
190
+ file=sys.stderr,
191
+ )
192
+ return 2 # Exit 2 = block operation
193
+ else:
194
+ # Non-critical hooks can proceed without daemon
195
+ print(
196
+ json.dumps(
197
+ {"status": "daemon_not_running", "message": "gobby daemon is not running"}
198
+ )
199
+ )
200
+ return 0 # Exit 0 (allow) - allow operation to continue
136
201
 
137
202
  # Setup logger for dispatcher (not HookManager)
138
203
  import logging
@@ -147,6 +212,11 @@ def main() -> int:
147
212
  # Read JSON input from stdin
148
213
  input_data = json.load(sys.stdin)
149
214
 
215
+ # Inject terminal context for SessionStart hooks
216
+ # This captures the terminal/process info for session correlation
217
+ if hook_type == "SessionStart":
218
+ input_data["terminal_context"] = get_terminal_context()
219
+
150
220
  # Log what Gemini CLI sends us (for debugging hook data issues)
151
221
  logger.info(f"[{hook_type}] Received input keys: {list(input_data.keys())}")
152
222
 
@@ -228,13 +298,18 @@ def main() -> int:
228
298
  # Determine exit code based on decision
229
299
  decision = result.get("decision", "allow")
230
300
 
231
- # Print JSON output for Gemini CLI
301
+ # Check for block/deny decision - return exit code 2 to signal blocking
302
+ # For blocking, output goes to STDERR (Gemini reads stderr on exit 2)
303
+ if result.get("continue") is False or decision in ("deny", "block"):
304
+ # Output just the reason, not the full JSON
305
+ reason = result.get("stopReason") or result.get("reason") or "Blocked by hook"
306
+ print(reason, file=sys.stderr)
307
+ return 2
308
+
309
+ # Only print output if there's something meaningful to show
232
310
  if result and result != {}:
233
311
  print(json.dumps(result))
234
312
 
235
- # Exit code: 0 = allow, 2 = deny
236
- if decision == "deny":
237
- return 2
238
313
  return 0
239
314
  else:
240
315
  # HTTP error from daemon
gobby/llm/claude.py CHANGED
@@ -495,7 +495,7 @@ class ClaudeLLMProvider(LLMProvider):
495
495
  """
496
496
  Generate a text description of an image using Claude's vision capabilities.
497
497
 
498
- Uses the Anthropic API directly for vision support.
498
+ Uses LiteLLM for unified cost tracking with anthropic/claude-haiku-4-5 model.
499
499
 
500
500
  Args:
501
501
  image_path: Path to the image file to describe
@@ -508,8 +508,6 @@ class ClaudeLLMProvider(LLMProvider):
508
508
  import mimetypes
509
509
  from pathlib import Path
510
510
 
511
- import anthropic
512
-
513
511
  # Validate image exists
514
512
  path = Path(image_path)
515
513
  if not path.exists():
@@ -534,45 +532,35 @@ class ClaudeLLMProvider(LLMProvider):
534
532
  if context:
535
533
  prompt = f"{context}\n\n{prompt}"
536
534
 
537
- # Use Anthropic API for vision
538
- api_key = os.environ.get("ANTHROPIC_API_KEY")
539
- if not api_key:
540
- return "Image description unavailable (ANTHROPIC_API_KEY not set)"
541
-
535
+ # Use LiteLLM for unified cost tracking
542
536
  try:
543
- client = anthropic.AsyncAnthropic(api_key=api_key)
544
- # Type annotation to satisfy mypy
545
- image_block: anthropic.types.ImageBlockParam = {
546
- "type": "image",
547
- "source": {
548
- "type": "base64",
549
- "media_type": mime_type, # type: ignore[typeddict-item]
550
- "data": image_base64,
551
- },
552
- }
553
- text_block: anthropic.types.TextBlockParam = {
554
- "type": "text",
555
- "text": prompt,
556
- }
557
- message = await client.messages.create(
558
- model="claude-haiku-4-5-latest", # Use haiku for cost efficiency
559
- max_tokens=1024,
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
560
542
  messages=[
561
543
  {
562
544
  "role": "user",
563
- "content": [image_block, text_block],
545
+ "content": [
546
+ {"type": "text", "text": prompt},
547
+ {
548
+ "type": "image_url",
549
+ "image_url": {"url": f"data:{mime_type};base64,{image_base64}"},
550
+ },
551
+ ],
564
552
  }
565
553
  ],
554
+ max_tokens=1024,
566
555
  )
567
556
 
568
- # Extract text from response
569
- result = ""
570
- for block in message.content:
571
- if hasattr(block, "text"):
572
- result += block.text
573
-
574
- return result if result else "No description generated"
557
+ if not response or not getattr(response, "choices", None):
558
+ return "No description generated"
559
+ return response.choices[0].message.content or "No description generated"
575
560
 
561
+ except ImportError:
562
+ self.logger.error("LiteLLM not installed, falling back to unavailable")
563
+ return "Image description unavailable (LiteLLM not installed)"
576
564
  except Exception as e:
577
- self.logger.error(f"Failed to describe image with Claude: {e}")
565
+ self.logger.error(f"Failed to describe image with Claude via LiteLLM: {e}")
578
566
  return f"Image description failed: {e}"
@@ -1,22 +1,20 @@
1
1
  """
2
- Claude implementation of AgentExecutor.
2
+ Claude implementation of AgentExecutor for subscription mode only.
3
3
 
4
- Supports multiple auth modes:
5
- - api_key: Direct Anthropic API with API key
6
- - subscription: Claude Agent SDK with CLI (Pro/Team subscriptions)
4
+ This executor uses the Claude Agent SDK with CLI for Pro/Team subscriptions.
5
+
6
+ Note: api_key mode is now routed through LiteLLMExecutor for unified cost tracking.
7
+ Use the resolver.create_executor() function which handles routing automatically.
7
8
  """
8
9
 
9
10
  import asyncio
10
11
  import concurrent.futures
11
12
  import json
12
13
  import logging
13
- import os
14
14
  import shutil
15
15
  from collections.abc import Callable
16
16
  from typing import Any, Literal
17
17
 
18
- import anthropic
19
-
20
18
  from gobby.llm.executor import (
21
19
  AgentExecutor,
22
20
  AgentResult,
@@ -28,26 +26,28 @@ from gobby.llm.executor import (
28
26
 
29
27
  logger = logging.getLogger(__name__)
30
28
 
31
- # Auth mode type
32
- ClaudeAuthMode = Literal["api_key", "subscription"]
29
+ # Auth mode type - subscription only, api_key routes through LiteLLM
30
+ ClaudeAuthMode = Literal["subscription"]
33
31
 
34
32
 
35
33
  class ClaudeExecutor(AgentExecutor):
36
34
  """
37
- Claude implementation of AgentExecutor.
35
+ Claude implementation of AgentExecutor for subscription mode only.
36
+
37
+ Uses Claude Agent SDK with CLI for Pro/Team subscriptions. This executor
38
+ is for subscription-based authentication only.
38
39
 
39
- Supports two authentication modes:
40
- - api_key: Uses the Anthropic API directly with an API key
41
- - subscription: Uses Claude Agent SDK with CLI for Pro/Team subscriptions
40
+ For api_key mode, use LiteLLMExecutor with provider="claude" which routes
41
+ through anthropic/model-name for unified cost tracking.
42
42
 
43
43
  The executor implements a proper agentic loop:
44
- 1. Send prompt to Claude with tool schemas
44
+ 1. Send prompt to Claude with tool schemas via SDK
45
45
  2. When Claude requests a tool, call tool_handler
46
46
  3. Send tool result back to Claude
47
47
  4. Repeat until Claude stops requesting tools or limits are reached
48
48
 
49
49
  Example:
50
- >>> executor = ClaudeExecutor(auth_mode="api_key", api_key="sk-ant-...")
50
+ >>> executor = ClaudeExecutor(auth_mode="subscription")
51
51
  >>> result = await executor.run(
52
52
  ... prompt="Create a task",
53
53
  ... tools=[ToolSchema(name="create_task", ...)],
@@ -55,71 +55,47 @@ class ClaudeExecutor(AgentExecutor):
55
55
  ... )
56
56
  """
57
57
 
58
- _client: anthropic.AsyncAnthropic | None
59
58
  _cli_path: str
60
59
 
61
60
  def __init__(
62
61
  self,
63
- auth_mode: ClaudeAuthMode = "api_key",
64
- api_key: str | None = None,
62
+ auth_mode: ClaudeAuthMode = "subscription",
65
63
  default_model: str = "claude-sonnet-4-20250514",
66
64
  ):
67
65
  """
68
- Initialize ClaudeExecutor.
66
+ Initialize ClaudeExecutor for subscription mode.
69
67
 
70
68
  Args:
71
- auth_mode: Authentication mode ("api_key" or "subscription").
72
- api_key: Anthropic API key (required for api_key mode).
69
+ auth_mode: Must be "subscription". API key mode is handled by LiteLLMExecutor.
73
70
  default_model: Default model to use if not specified in run().
71
+
72
+ Raises:
73
+ ValueError: If auth_mode is not "subscription" or Claude CLI not found.
74
74
  """
75
+ if auth_mode != "subscription":
76
+ raise ValueError(
77
+ "ClaudeExecutor only supports subscription mode. "
78
+ "For api_key mode, use LiteLLMExecutor with provider='claude'."
79
+ )
80
+
75
81
  self.auth_mode = auth_mode
76
82
  self.default_model = default_model
77
83
  self.logger = logger
78
- self._client = None
79
84
  self._cli_path = ""
80
85
 
81
- if auth_mode == "api_key":
82
- # Use provided key or fall back to environment variable
83
- key = api_key or os.environ.get("ANTHROPIC_API_KEY")
84
- if not key:
85
- raise ValueError(
86
- "API key required for api_key mode. "
87
- "Provide api_key parameter or set ANTHROPIC_API_KEY env var."
88
- )
89
- self._client = anthropic.AsyncAnthropic(api_key=key)
90
- elif auth_mode == "subscription":
91
- # Verify Claude CLI is available for subscription mode
92
- cli_path = shutil.which("claude")
93
- if not cli_path:
94
- raise ValueError(
95
- "Claude CLI not found in PATH. Install Claude Code for subscription mode."
96
- )
97
- self._cli_path = cli_path
98
- else:
99
- raise ValueError(f"Unknown auth_mode: {auth_mode}")
86
+ # Verify Claude CLI is available for subscription mode
87
+ cli_path = shutil.which("claude")
88
+ if not cli_path:
89
+ raise ValueError(
90
+ "Claude CLI not found in PATH. Install Claude Code for subscription mode."
91
+ )
92
+ self._cli_path = cli_path
100
93
 
101
94
  @property
102
95
  def provider_name(self) -> str:
103
96
  """Return the provider name."""
104
97
  return "claude"
105
98
 
106
- def _convert_tools_to_anthropic_format(
107
- self, tools: list[ToolSchema]
108
- ) -> list[anthropic.types.ToolParam]:
109
- """Convert ToolSchema list to Anthropic API format."""
110
- anthropic_tools: list[anthropic.types.ToolParam] = []
111
- for tool in tools:
112
- # input_schema must have "type": "object" at minimum
113
- input_schema: dict[str, Any] = {"type": "object", **tool.input_schema}
114
- anthropic_tools.append(
115
- {
116
- "name": tool.name,
117
- "description": tool.description,
118
- "input_schema": input_schema,
119
- }
120
- )
121
- return anthropic_tools
122
-
123
99
  async def run(
124
100
  self,
125
101
  prompt: str,
@@ -131,10 +107,10 @@ class ClaudeExecutor(AgentExecutor):
131
107
  timeout: float = 120.0,
132
108
  ) -> AgentResult:
133
109
  """
134
- Execute an agentic loop with tool calling.
110
+ Execute an agentic loop with tool calling via Claude Agent SDK.
135
111
 
136
- Runs Claude with the given prompt, calling tools via tool_handler
137
- until completion, max_turns, or timeout.
112
+ Runs Claude with the given prompt using subscription-based authentication,
113
+ calling tools via tool_handler until completion, max_turns, or timeout.
138
114
 
139
115
  Args:
140
116
  prompt: The user prompt to process.
@@ -148,201 +124,15 @@ class ClaudeExecutor(AgentExecutor):
148
124
  Returns:
149
125
  AgentResult with output, status, and tool call records.
150
126
  """
151
- if self.auth_mode == "api_key":
152
- return await self._run_with_api(
153
- prompt=prompt,
154
- tools=tools,
155
- tool_handler=tool_handler,
156
- system_prompt=system_prompt,
157
- model=model or self.default_model,
158
- max_turns=max_turns,
159
- timeout=timeout,
160
- )
161
- else:
162
- return await self._run_with_sdk(
163
- prompt=prompt,
164
- tools=tools,
165
- tool_handler=tool_handler,
166
- system_prompt=system_prompt,
167
- model=model or self.default_model,
168
- max_turns=max_turns,
169
- timeout=timeout,
170
- )
171
-
172
- async def _run_with_api(
173
- self,
174
- prompt: str,
175
- tools: list[ToolSchema],
176
- tool_handler: ToolHandler,
177
- system_prompt: str | None,
178
- model: str,
179
- max_turns: int,
180
- timeout: float,
181
- ) -> AgentResult:
182
- """Run using direct Anthropic API."""
183
- if self._client is None:
184
- return AgentResult(
185
- output="",
186
- status="error",
187
- error="Anthropic client not initialized",
188
- turns_used=0,
189
- )
190
-
191
- tool_calls: list[ToolCallRecord] = []
192
- anthropic_tools = self._convert_tools_to_anthropic_format(tools)
193
-
194
- # Build initial messages
195
- messages: list[anthropic.types.MessageParam] = [{"role": "user", "content": prompt}]
196
-
197
- # Track turns in outer scope so timeout handler can access the count
198
- turns_counter = [0]
199
-
200
- async def _run_loop() -> AgentResult:
201
- nonlocal messages
202
- turns_used = 0
203
- final_output = ""
204
- client = self._client
205
- if client is None:
206
- raise RuntimeError("ClaudeExecutor client not initialized")
207
-
208
- while turns_used < max_turns:
209
- turns_used += 1
210
- turns_counter[0] = turns_used
211
-
212
- # Call Claude
213
- try:
214
- response = await client.messages.create(
215
- model=model,
216
- max_tokens=8192,
217
- system=system_prompt or "You are a helpful assistant.",
218
- messages=messages,
219
- tools=anthropic_tools if anthropic_tools else [],
220
- )
221
- except anthropic.APIError as e:
222
- return AgentResult(
223
- output="",
224
- status="error",
225
- tool_calls=tool_calls,
226
- error=f"Anthropic API error: {e}",
227
- turns_used=turns_used,
228
- )
229
-
230
- # Process response
231
- assistant_content: list[anthropic.types.ContentBlockParam] = []
232
- tool_use_blocks: list[dict[str, Any]] = []
233
-
234
- for block in response.content:
235
- if block.type == "text":
236
- final_output = block.text
237
- assistant_content.append({"type": "text", "text": block.text})
238
- elif block.type == "tool_use":
239
- tool_use_blocks.append(
240
- {
241
- "id": block.id,
242
- "name": block.name,
243
- "input": block.input,
244
- }
245
- )
246
- assistant_content.append(
247
- {
248
- "type": "tool_use",
249
- "id": block.id,
250
- "name": block.name,
251
- "input": dict(block.input) if block.input else {},
252
- }
253
- )
254
-
255
- # Add assistant message to history
256
- messages.append({"role": "assistant", "content": assistant_content})
257
-
258
- # If no tool use, we're done
259
- if not tool_use_blocks:
260
- return AgentResult(
261
- output=final_output,
262
- status="success",
263
- tool_calls=tool_calls,
264
- turns_used=turns_used,
265
- )
266
-
267
- # Handle tool calls
268
- tool_results: list[anthropic.types.ToolResultBlockParam] = []
269
-
270
- for tool_use in tool_use_blocks:
271
- tool_name = tool_use["name"]
272
- arguments = tool_use["input"] if isinstance(tool_use["input"], dict) else {}
273
-
274
- # Record the tool call
275
- record = ToolCallRecord(
276
- tool_name=tool_name,
277
- arguments=arguments,
278
- )
279
- tool_calls.append(record)
280
-
281
- # Execute via handler
282
- try:
283
- result = await tool_handler(tool_name, arguments)
284
- record.result = result
285
-
286
- # Format result for Claude
287
- if result.success:
288
- content = json.dumps(result.result) if result.result else "Success"
289
- else:
290
- content = f"Error: {result.error}"
291
-
292
- tool_results.append(
293
- {
294
- "type": "tool_result",
295
- "tool_use_id": tool_use["id"],
296
- "content": content,
297
- }
298
- )
299
- except Exception as e:
300
- self.logger.error(f"Tool handler error for {tool_name}: {e}")
301
- record.result = ToolResult(
302
- tool_name=tool_name,
303
- success=False,
304
- error=str(e),
305
- )
306
- tool_results.append(
307
- {
308
- "type": "tool_result",
309
- "tool_use_id": tool_use["id"],
310
- "content": f"Error: {e}",
311
- "is_error": True,
312
- }
313
- )
314
-
315
- # Add tool results to messages
316
- messages.append({"role": "user", "content": tool_results})
317
-
318
- # Check stop reason
319
- if response.stop_reason == "end_turn":
320
- return AgentResult(
321
- output=final_output,
322
- status="success",
323
- tool_calls=tool_calls,
324
- turns_used=turns_used,
325
- )
326
-
327
- # Max turns reached
328
- return AgentResult(
329
- output=final_output,
330
- status="partial",
331
- tool_calls=tool_calls,
332
- turns_used=turns_used,
333
- )
334
-
335
- # Run with timeout
336
- try:
337
- return await asyncio.wait_for(_run_loop(), timeout=timeout)
338
- except TimeoutError:
339
- return AgentResult(
340
- output="",
341
- status="timeout",
342
- tool_calls=tool_calls,
343
- error=f"Execution timed out after {timeout}s",
344
- turns_used=turns_counter[0],
345
- )
127
+ return await self._run_with_sdk(
128
+ prompt=prompt,
129
+ tools=tools,
130
+ tool_handler=tool_handler,
131
+ system_prompt=system_prompt,
132
+ model=model or self.default_model,
133
+ max_turns=max_turns,
134
+ timeout=timeout,
135
+ )
346
136
 
347
137
  async def _run_with_sdk(
348
138
  self,