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.
- gobby/__init__.py +1 -1
- gobby/adapters/__init__.py +2 -1
- gobby/adapters/claude_code.py +13 -4
- gobby/adapters/codex_impl/__init__.py +28 -0
- gobby/adapters/codex_impl/adapter.py +722 -0
- gobby/adapters/codex_impl/client.py +679 -0
- gobby/adapters/codex_impl/protocol.py +20 -0
- gobby/adapters/codex_impl/types.py +68 -0
- gobby/agents/definitions.py +11 -1
- gobby/agents/isolation.py +395 -0
- gobby/agents/runner.py +8 -0
- gobby/agents/sandbox.py +261 -0
- gobby/agents/spawn.py +42 -287
- gobby/agents/spawn_executor.py +385 -0
- gobby/agents/spawners/__init__.py +24 -0
- gobby/agents/spawners/command_builder.py +189 -0
- gobby/agents/spawners/embedded.py +21 -2
- gobby/agents/spawners/headless.py +21 -2
- gobby/agents/spawners/prompt_manager.py +125 -0
- gobby/cli/__init__.py +6 -0
- gobby/cli/clones.py +419 -0
- gobby/cli/conductor.py +266 -0
- gobby/cli/install.py +4 -4
- gobby/cli/installers/antigravity.py +3 -9
- gobby/cli/installers/claude.py +15 -9
- gobby/cli/installers/codex.py +2 -8
- gobby/cli/installers/gemini.py +8 -8
- gobby/cli/installers/shared.py +175 -13
- gobby/cli/sessions.py +1 -1
- gobby/cli/skills.py +858 -0
- gobby/cli/tasks/ai.py +0 -440
- gobby/cli/tasks/crud.py +44 -6
- gobby/cli/tasks/main.py +0 -4
- gobby/cli/tui.py +2 -2
- gobby/cli/utils.py +12 -5
- gobby/clones/__init__.py +13 -0
- gobby/clones/git.py +547 -0
- gobby/conductor/__init__.py +16 -0
- gobby/conductor/alerts.py +135 -0
- gobby/conductor/loop.py +164 -0
- gobby/conductor/monitors/__init__.py +11 -0
- gobby/conductor/monitors/agents.py +116 -0
- gobby/conductor/monitors/tasks.py +155 -0
- gobby/conductor/pricing.py +234 -0
- gobby/conductor/token_tracker.py +160 -0
- gobby/config/__init__.py +12 -97
- gobby/config/app.py +69 -91
- gobby/config/extensions.py +2 -2
- gobby/config/features.py +7 -130
- gobby/config/search.py +110 -0
- gobby/config/servers.py +1 -1
- gobby/config/skills.py +43 -0
- gobby/config/tasks.py +9 -41
- gobby/hooks/__init__.py +0 -13
- gobby/hooks/event_handlers.py +188 -2
- gobby/hooks/hook_manager.py +50 -4
- gobby/hooks/plugins.py +1 -1
- gobby/hooks/skill_manager.py +130 -0
- gobby/hooks/webhooks.py +1 -1
- gobby/install/claude/hooks/hook_dispatcher.py +4 -4
- gobby/install/codex/hooks/hook_dispatcher.py +1 -1
- gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
- gobby/llm/claude.py +22 -34
- gobby/llm/claude_executor.py +46 -256
- gobby/llm/codex_executor.py +59 -291
- gobby/llm/executor.py +21 -0
- gobby/llm/gemini.py +134 -110
- gobby/llm/litellm_executor.py +143 -6
- gobby/llm/resolver.py +98 -35
- gobby/mcp_proxy/importer.py +62 -4
- gobby/mcp_proxy/instructions.py +56 -0
- gobby/mcp_proxy/models.py +15 -0
- gobby/mcp_proxy/registries.py +68 -8
- gobby/mcp_proxy/server.py +33 -3
- gobby/mcp_proxy/services/recommendation.py +43 -11
- gobby/mcp_proxy/services/tool_proxy.py +81 -1
- gobby/mcp_proxy/stdio.py +2 -1
- gobby/mcp_proxy/tools/__init__.py +0 -2
- gobby/mcp_proxy/tools/agent_messaging.py +317 -0
- gobby/mcp_proxy/tools/agents.py +31 -731
- gobby/mcp_proxy/tools/clones.py +518 -0
- gobby/mcp_proxy/tools/memory.py +3 -26
- gobby/mcp_proxy/tools/metrics.py +65 -1
- gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
- gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
- gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
- gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
- gobby/mcp_proxy/tools/sessions/_commits.py +232 -0
- gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
- gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
- gobby/mcp_proxy/tools/sessions/_handoff.py +499 -0
- gobby/mcp_proxy/tools/sessions/_messages.py +138 -0
- gobby/mcp_proxy/tools/skills/__init__.py +616 -0
- gobby/mcp_proxy/tools/spawn_agent.py +417 -0
- gobby/mcp_proxy/tools/task_orchestration.py +7 -0
- gobby/mcp_proxy/tools/task_readiness.py +14 -0
- gobby/mcp_proxy/tools/task_sync.py +1 -1
- gobby/mcp_proxy/tools/tasks/_context.py +0 -20
- gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
- gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
- gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +110 -45
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
- gobby/mcp_proxy/tools/workflows.py +1 -1
- gobby/mcp_proxy/tools/worktrees.py +0 -338
- gobby/memory/backends/__init__.py +6 -1
- gobby/memory/backends/mem0.py +6 -1
- gobby/memory/extractor.py +477 -0
- gobby/memory/ingestion/__init__.py +5 -0
- gobby/memory/ingestion/multimodal.py +221 -0
- gobby/memory/manager.py +73 -285
- gobby/memory/search/__init__.py +10 -0
- gobby/memory/search/coordinator.py +248 -0
- gobby/memory/services/__init__.py +5 -0
- gobby/memory/services/crossref.py +142 -0
- gobby/prompts/loader.py +5 -2
- gobby/runner.py +37 -16
- gobby/search/__init__.py +48 -6
- gobby/search/backends/__init__.py +159 -0
- gobby/search/backends/embedding.py +225 -0
- gobby/search/embeddings.py +238 -0
- gobby/search/models.py +148 -0
- gobby/search/unified.py +496 -0
- gobby/servers/http.py +24 -12
- gobby/servers/routes/admin.py +294 -0
- gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
- gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
- gobby/servers/routes/mcp/endpoints/execution.py +568 -0
- gobby/servers/routes/mcp/endpoints/registry.py +378 -0
- gobby/servers/routes/mcp/endpoints/server.py +304 -0
- gobby/servers/routes/mcp/hooks.py +1 -1
- gobby/servers/routes/mcp/tools.py +48 -1317
- gobby/servers/websocket.py +2 -2
- gobby/sessions/analyzer.py +2 -0
- gobby/sessions/lifecycle.py +1 -1
- gobby/sessions/processor.py +10 -0
- gobby/sessions/transcripts/base.py +2 -0
- gobby/sessions/transcripts/claude.py +79 -10
- gobby/skills/__init__.py +91 -0
- gobby/skills/loader.py +685 -0
- gobby/skills/manager.py +384 -0
- gobby/skills/parser.py +286 -0
- gobby/skills/search.py +463 -0
- gobby/skills/sync.py +119 -0
- gobby/skills/updater.py +385 -0
- gobby/skills/validator.py +368 -0
- gobby/storage/clones.py +378 -0
- gobby/storage/database.py +1 -1
- gobby/storage/memories.py +43 -13
- gobby/storage/migrations.py +162 -201
- gobby/storage/sessions.py +116 -7
- gobby/storage/skills.py +782 -0
- gobby/storage/tasks/_crud.py +4 -4
- gobby/storage/tasks/_lifecycle.py +57 -7
- gobby/storage/tasks/_manager.py +14 -5
- gobby/storage/tasks/_models.py +8 -3
- gobby/sync/memories.py +40 -5
- gobby/sync/tasks.py +83 -6
- gobby/tasks/__init__.py +1 -2
- gobby/tasks/external_validator.py +1 -1
- gobby/tasks/validation.py +46 -35
- gobby/tools/summarizer.py +91 -10
- gobby/tui/api_client.py +4 -7
- gobby/tui/app.py +5 -3
- gobby/tui/screens/orchestrator.py +1 -2
- gobby/tui/screens/tasks.py +2 -4
- gobby/tui/ws_client.py +1 -1
- gobby/utils/daemon_client.py +2 -2
- gobby/utils/project_context.py +2 -3
- gobby/utils/status.py +13 -0
- gobby/workflows/actions.py +221 -1135
- gobby/workflows/artifact_actions.py +31 -0
- gobby/workflows/autonomous_actions.py +11 -0
- gobby/workflows/context_actions.py +93 -1
- gobby/workflows/detection_helpers.py +115 -31
- gobby/workflows/enforcement/__init__.py +47 -0
- gobby/workflows/enforcement/blocking.py +269 -0
- gobby/workflows/enforcement/commit_policy.py +283 -0
- gobby/workflows/enforcement/handlers.py +269 -0
- gobby/workflows/{task_enforcement_actions.py → enforcement/task_policy.py} +29 -388
- gobby/workflows/engine.py +13 -2
- gobby/workflows/git_utils.py +106 -0
- gobby/workflows/lifecycle_evaluator.py +29 -1
- gobby/workflows/llm_actions.py +30 -0
- gobby/workflows/loader.py +19 -6
- gobby/workflows/mcp_actions.py +20 -1
- gobby/workflows/memory_actions.py +154 -0
- gobby/workflows/safe_evaluator.py +183 -0
- gobby/workflows/session_actions.py +44 -0
- gobby/workflows/state_actions.py +60 -1
- gobby/workflows/stop_signal_actions.py +55 -0
- gobby/workflows/summary_actions.py +111 -1
- gobby/workflows/task_sync_actions.py +347 -0
- gobby/workflows/todo_actions.py +34 -1
- gobby/workflows/webhook_actions.py +185 -0
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/METADATA +87 -21
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/RECORD +201 -172
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/WHEEL +1 -1
- gobby/adapters/codex.py +0 -1292
- gobby/install/claude/commands/gobby/bug.md +0 -51
- gobby/install/claude/commands/gobby/chore.md +0 -51
- gobby/install/claude/commands/gobby/epic.md +0 -52
- gobby/install/claude/commands/gobby/eval.md +0 -235
- gobby/install/claude/commands/gobby/feat.md +0 -49
- gobby/install/claude/commands/gobby/nit.md +0 -52
- gobby/install/claude/commands/gobby/ref.md +0 -52
- gobby/install/codex/prompts/forget.md +0 -7
- gobby/install/codex/prompts/memories.md +0 -7
- gobby/install/codex/prompts/recall.md +0 -7
- gobby/install/codex/prompts/remember.md +0 -13
- gobby/llm/gemini_executor.py +0 -339
- gobby/mcp_proxy/tools/session_messages.py +0 -1056
- gobby/mcp_proxy/tools/task_expansion.py +0 -591
- gobby/prompts/defaults/expansion/system.md +0 -119
- gobby/prompts/defaults/expansion/user.md +0 -48
- gobby/prompts/defaults/external_validation/agent.md +0 -72
- gobby/prompts/defaults/external_validation/external.md +0 -63
- gobby/prompts/defaults/external_validation/spawn.md +0 -83
- gobby/prompts/defaults/external_validation/system.md +0 -6
- gobby/prompts/defaults/features/import_mcp.md +0 -22
- gobby/prompts/defaults/features/import_mcp_github.md +0 -17
- gobby/prompts/defaults/features/import_mcp_search.md +0 -16
- gobby/prompts/defaults/features/recommend_tools.md +0 -32
- gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
- gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
- gobby/prompts/defaults/features/server_description.md +0 -20
- gobby/prompts/defaults/features/server_description_system.md +0 -6
- gobby/prompts/defaults/features/task_description.md +0 -31
- gobby/prompts/defaults/features/task_description_system.md +0 -6
- gobby/prompts/defaults/features/tool_summary.md +0 -17
- gobby/prompts/defaults/features/tool_summary_system.md +0 -6
- gobby/prompts/defaults/research/step.md +0 -58
- gobby/prompts/defaults/validation/criteria.md +0 -47
- gobby/prompts/defaults/validation/validate.md +0 -38
- gobby/storage/migrations_legacy.py +0 -1359
- gobby/tasks/context.py +0 -747
- gobby/tasks/criteria.py +0 -342
- gobby/tasks/expansion.py +0 -626
- gobby/tasks/prompts/expand.py +0 -327
- gobby/tasks/research.py +0 -421
- gobby/tasks/tdd.py +0 -352
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/licenses/LICENSE.md +0 -0
- {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 =
|
|
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
|
|
42
|
+
otherwise uses the default port 60887.
|
|
42
43
|
|
|
43
44
|
Returns:
|
|
44
|
-
Full daemon URL like http://localhost:
|
|
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
|
-
#
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
"
|
|
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": [
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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}"
|
gobby/llm/claude_executor.py
CHANGED
|
@@ -1,22 +1,20 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Claude implementation of AgentExecutor.
|
|
2
|
+
Claude implementation of AgentExecutor for subscription mode only.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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["
|
|
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
|
-
|
|
40
|
-
-
|
|
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="
|
|
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 = "
|
|
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:
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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,
|