gobby 0.2.5__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 +3 -0
- gobby/adapters/__init__.py +30 -0
- gobby/adapters/base.py +93 -0
- gobby/adapters/claude_code.py +276 -0
- gobby/adapters/codex.py +1292 -0
- gobby/adapters/gemini.py +343 -0
- gobby/agents/__init__.py +37 -0
- gobby/agents/codex_session.py +120 -0
- gobby/agents/constants.py +112 -0
- gobby/agents/context.py +362 -0
- gobby/agents/definitions.py +133 -0
- gobby/agents/gemini_session.py +111 -0
- gobby/agents/registry.py +618 -0
- gobby/agents/runner.py +968 -0
- gobby/agents/session.py +259 -0
- gobby/agents/spawn.py +916 -0
- gobby/agents/spawners/__init__.py +77 -0
- gobby/agents/spawners/base.py +142 -0
- gobby/agents/spawners/cross_platform.py +266 -0
- gobby/agents/spawners/embedded.py +225 -0
- gobby/agents/spawners/headless.py +226 -0
- gobby/agents/spawners/linux.py +125 -0
- gobby/agents/spawners/macos.py +277 -0
- gobby/agents/spawners/windows.py +308 -0
- gobby/agents/tty_config.py +319 -0
- gobby/autonomous/__init__.py +32 -0
- gobby/autonomous/progress_tracker.py +447 -0
- gobby/autonomous/stop_registry.py +269 -0
- gobby/autonomous/stuck_detector.py +383 -0
- gobby/cli/__init__.py +67 -0
- gobby/cli/__main__.py +8 -0
- gobby/cli/agents.py +529 -0
- gobby/cli/artifacts.py +266 -0
- gobby/cli/daemon.py +329 -0
- gobby/cli/extensions.py +526 -0
- gobby/cli/github.py +263 -0
- gobby/cli/init.py +53 -0
- gobby/cli/install.py +614 -0
- gobby/cli/installers/__init__.py +37 -0
- gobby/cli/installers/antigravity.py +65 -0
- gobby/cli/installers/claude.py +363 -0
- gobby/cli/installers/codex.py +192 -0
- gobby/cli/installers/gemini.py +294 -0
- gobby/cli/installers/git_hooks.py +377 -0
- gobby/cli/installers/shared.py +737 -0
- gobby/cli/linear.py +250 -0
- gobby/cli/mcp.py +30 -0
- gobby/cli/mcp_proxy.py +698 -0
- gobby/cli/memory.py +304 -0
- gobby/cli/merge.py +384 -0
- gobby/cli/projects.py +79 -0
- gobby/cli/sessions.py +622 -0
- gobby/cli/tasks/__init__.py +30 -0
- gobby/cli/tasks/_utils.py +658 -0
- gobby/cli/tasks/ai.py +1025 -0
- gobby/cli/tasks/commits.py +169 -0
- gobby/cli/tasks/crud.py +685 -0
- gobby/cli/tasks/deps.py +135 -0
- gobby/cli/tasks/labels.py +63 -0
- gobby/cli/tasks/main.py +273 -0
- gobby/cli/tasks/search.py +178 -0
- gobby/cli/tui.py +34 -0
- gobby/cli/utils.py +513 -0
- gobby/cli/workflows.py +927 -0
- gobby/cli/worktrees.py +481 -0
- gobby/config/__init__.py +129 -0
- gobby/config/app.py +551 -0
- gobby/config/extensions.py +167 -0
- gobby/config/features.py +472 -0
- gobby/config/llm_providers.py +98 -0
- gobby/config/logging.py +66 -0
- gobby/config/mcp.py +346 -0
- gobby/config/persistence.py +247 -0
- gobby/config/servers.py +141 -0
- gobby/config/sessions.py +250 -0
- gobby/config/tasks.py +784 -0
- gobby/hooks/__init__.py +104 -0
- gobby/hooks/artifact_capture.py +213 -0
- gobby/hooks/broadcaster.py +243 -0
- gobby/hooks/event_handlers.py +723 -0
- gobby/hooks/events.py +218 -0
- gobby/hooks/git.py +169 -0
- gobby/hooks/health_monitor.py +171 -0
- gobby/hooks/hook_manager.py +856 -0
- gobby/hooks/hook_types.py +575 -0
- gobby/hooks/plugins.py +813 -0
- gobby/hooks/session_coordinator.py +396 -0
- gobby/hooks/verification_runner.py +268 -0
- gobby/hooks/webhooks.py +339 -0
- gobby/install/claude/commands/gobby/bug.md +51 -0
- gobby/install/claude/commands/gobby/chore.md +51 -0
- gobby/install/claude/commands/gobby/epic.md +52 -0
- gobby/install/claude/commands/gobby/eval.md +235 -0
- gobby/install/claude/commands/gobby/feat.md +49 -0
- gobby/install/claude/commands/gobby/nit.md +52 -0
- gobby/install/claude/commands/gobby/ref.md +52 -0
- gobby/install/claude/hooks/HOOK_SCHEMAS.md +632 -0
- gobby/install/claude/hooks/hook_dispatcher.py +364 -0
- gobby/install/claude/hooks/validate_settings.py +102 -0
- gobby/install/claude/hooks-template.json +118 -0
- gobby/install/codex/hooks/hook_dispatcher.py +153 -0
- gobby/install/codex/prompts/forget.md +7 -0
- gobby/install/codex/prompts/memories.md +7 -0
- gobby/install/codex/prompts/recall.md +7 -0
- gobby/install/codex/prompts/remember.md +13 -0
- gobby/install/gemini/hooks/hook_dispatcher.py +268 -0
- gobby/install/gemini/hooks-template.json +138 -0
- gobby/install/shared/plugins/code_guardian.py +456 -0
- gobby/install/shared/plugins/example_notify.py +331 -0
- gobby/integrations/__init__.py +10 -0
- gobby/integrations/github.py +145 -0
- gobby/integrations/linear.py +145 -0
- gobby/llm/__init__.py +40 -0
- gobby/llm/base.py +120 -0
- gobby/llm/claude.py +578 -0
- gobby/llm/claude_executor.py +503 -0
- gobby/llm/codex.py +322 -0
- gobby/llm/codex_executor.py +513 -0
- gobby/llm/executor.py +316 -0
- gobby/llm/factory.py +34 -0
- gobby/llm/gemini.py +258 -0
- gobby/llm/gemini_executor.py +339 -0
- gobby/llm/litellm.py +287 -0
- gobby/llm/litellm_executor.py +303 -0
- gobby/llm/resolver.py +499 -0
- gobby/llm/service.py +236 -0
- gobby/mcp_proxy/__init__.py +29 -0
- gobby/mcp_proxy/actions.py +175 -0
- gobby/mcp_proxy/daemon_control.py +198 -0
- gobby/mcp_proxy/importer.py +436 -0
- gobby/mcp_proxy/lazy.py +325 -0
- gobby/mcp_proxy/manager.py +798 -0
- gobby/mcp_proxy/metrics.py +609 -0
- gobby/mcp_proxy/models.py +139 -0
- gobby/mcp_proxy/registries.py +215 -0
- gobby/mcp_proxy/schema_hash.py +381 -0
- gobby/mcp_proxy/semantic_search.py +706 -0
- gobby/mcp_proxy/server.py +549 -0
- gobby/mcp_proxy/services/__init__.py +0 -0
- gobby/mcp_proxy/services/fallback.py +306 -0
- gobby/mcp_proxy/services/recommendation.py +224 -0
- gobby/mcp_proxy/services/server_mgmt.py +214 -0
- gobby/mcp_proxy/services/system.py +72 -0
- gobby/mcp_proxy/services/tool_filter.py +231 -0
- gobby/mcp_proxy/services/tool_proxy.py +309 -0
- gobby/mcp_proxy/stdio.py +565 -0
- gobby/mcp_proxy/tools/__init__.py +27 -0
- gobby/mcp_proxy/tools/agents.py +1103 -0
- gobby/mcp_proxy/tools/artifacts.py +207 -0
- gobby/mcp_proxy/tools/hub.py +335 -0
- gobby/mcp_proxy/tools/internal.py +337 -0
- gobby/mcp_proxy/tools/memory.py +543 -0
- gobby/mcp_proxy/tools/merge.py +422 -0
- gobby/mcp_proxy/tools/metrics.py +283 -0
- gobby/mcp_proxy/tools/orchestration/__init__.py +23 -0
- gobby/mcp_proxy/tools/orchestration/cleanup.py +619 -0
- gobby/mcp_proxy/tools/orchestration/monitor.py +380 -0
- gobby/mcp_proxy/tools/orchestration/orchestrate.py +746 -0
- gobby/mcp_proxy/tools/orchestration/review.py +736 -0
- gobby/mcp_proxy/tools/orchestration/utils.py +16 -0
- gobby/mcp_proxy/tools/session_messages.py +1056 -0
- gobby/mcp_proxy/tools/task_dependencies.py +219 -0
- gobby/mcp_proxy/tools/task_expansion.py +591 -0
- gobby/mcp_proxy/tools/task_github.py +393 -0
- gobby/mcp_proxy/tools/task_linear.py +379 -0
- gobby/mcp_proxy/tools/task_orchestration.py +77 -0
- gobby/mcp_proxy/tools/task_readiness.py +522 -0
- gobby/mcp_proxy/tools/task_sync.py +351 -0
- gobby/mcp_proxy/tools/task_validation.py +843 -0
- gobby/mcp_proxy/tools/tasks/__init__.py +25 -0
- gobby/mcp_proxy/tools/tasks/_context.py +112 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +516 -0
- gobby/mcp_proxy/tools/tasks/_factory.py +176 -0
- gobby/mcp_proxy/tools/tasks/_helpers.py +129 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +517 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +301 -0
- gobby/mcp_proxy/tools/tasks/_resolution.py +55 -0
- gobby/mcp_proxy/tools/tasks/_search.py +215 -0
- gobby/mcp_proxy/tools/tasks/_session.py +125 -0
- gobby/mcp_proxy/tools/workflows.py +973 -0
- gobby/mcp_proxy/tools/worktrees.py +1264 -0
- gobby/mcp_proxy/transports/__init__.py +0 -0
- gobby/mcp_proxy/transports/base.py +95 -0
- gobby/mcp_proxy/transports/factory.py +44 -0
- gobby/mcp_proxy/transports/http.py +139 -0
- gobby/mcp_proxy/transports/stdio.py +213 -0
- gobby/mcp_proxy/transports/websocket.py +136 -0
- gobby/memory/backends/__init__.py +116 -0
- gobby/memory/backends/mem0.py +408 -0
- gobby/memory/backends/memu.py +485 -0
- gobby/memory/backends/null.py +111 -0
- gobby/memory/backends/openmemory.py +537 -0
- gobby/memory/backends/sqlite.py +304 -0
- gobby/memory/context.py +87 -0
- gobby/memory/manager.py +1001 -0
- gobby/memory/protocol.py +451 -0
- gobby/memory/search/__init__.py +66 -0
- gobby/memory/search/text.py +127 -0
- gobby/memory/viz.py +258 -0
- gobby/prompts/__init__.py +13 -0
- gobby/prompts/defaults/expansion/system.md +119 -0
- gobby/prompts/defaults/expansion/user.md +48 -0
- gobby/prompts/defaults/external_validation/agent.md +72 -0
- gobby/prompts/defaults/external_validation/external.md +63 -0
- gobby/prompts/defaults/external_validation/spawn.md +83 -0
- gobby/prompts/defaults/external_validation/system.md +6 -0
- gobby/prompts/defaults/features/import_mcp.md +22 -0
- gobby/prompts/defaults/features/import_mcp_github.md +17 -0
- gobby/prompts/defaults/features/import_mcp_search.md +16 -0
- gobby/prompts/defaults/features/recommend_tools.md +32 -0
- gobby/prompts/defaults/features/recommend_tools_hybrid.md +35 -0
- gobby/prompts/defaults/features/recommend_tools_llm.md +30 -0
- gobby/prompts/defaults/features/server_description.md +20 -0
- gobby/prompts/defaults/features/server_description_system.md +6 -0
- gobby/prompts/defaults/features/task_description.md +31 -0
- gobby/prompts/defaults/features/task_description_system.md +6 -0
- gobby/prompts/defaults/features/tool_summary.md +17 -0
- gobby/prompts/defaults/features/tool_summary_system.md +6 -0
- gobby/prompts/defaults/research/step.md +58 -0
- gobby/prompts/defaults/validation/criteria.md +47 -0
- gobby/prompts/defaults/validation/validate.md +38 -0
- gobby/prompts/loader.py +346 -0
- gobby/prompts/models.py +113 -0
- gobby/py.typed +0 -0
- gobby/runner.py +488 -0
- gobby/search/__init__.py +23 -0
- gobby/search/protocol.py +104 -0
- gobby/search/tfidf.py +232 -0
- gobby/servers/__init__.py +7 -0
- gobby/servers/http.py +636 -0
- gobby/servers/models.py +31 -0
- gobby/servers/routes/__init__.py +23 -0
- gobby/servers/routes/admin.py +416 -0
- gobby/servers/routes/dependencies.py +118 -0
- gobby/servers/routes/mcp/__init__.py +24 -0
- gobby/servers/routes/mcp/hooks.py +135 -0
- gobby/servers/routes/mcp/plugins.py +121 -0
- gobby/servers/routes/mcp/tools.py +1337 -0
- gobby/servers/routes/mcp/webhooks.py +159 -0
- gobby/servers/routes/sessions.py +582 -0
- gobby/servers/websocket.py +766 -0
- gobby/sessions/__init__.py +13 -0
- gobby/sessions/analyzer.py +322 -0
- gobby/sessions/lifecycle.py +240 -0
- gobby/sessions/manager.py +563 -0
- gobby/sessions/processor.py +225 -0
- gobby/sessions/summary.py +532 -0
- gobby/sessions/transcripts/__init__.py +41 -0
- gobby/sessions/transcripts/base.py +125 -0
- gobby/sessions/transcripts/claude.py +386 -0
- gobby/sessions/transcripts/codex.py +143 -0
- gobby/sessions/transcripts/gemini.py +195 -0
- gobby/storage/__init__.py +21 -0
- gobby/storage/agents.py +409 -0
- gobby/storage/artifact_classifier.py +341 -0
- gobby/storage/artifacts.py +285 -0
- gobby/storage/compaction.py +67 -0
- gobby/storage/database.py +357 -0
- gobby/storage/inter_session_messages.py +194 -0
- gobby/storage/mcp.py +680 -0
- gobby/storage/memories.py +562 -0
- gobby/storage/merge_resolutions.py +550 -0
- gobby/storage/migrations.py +860 -0
- gobby/storage/migrations_legacy.py +1359 -0
- gobby/storage/projects.py +166 -0
- gobby/storage/session_messages.py +251 -0
- gobby/storage/session_tasks.py +97 -0
- gobby/storage/sessions.py +817 -0
- gobby/storage/task_dependencies.py +223 -0
- gobby/storage/tasks/__init__.py +42 -0
- gobby/storage/tasks/_aggregates.py +180 -0
- gobby/storage/tasks/_crud.py +449 -0
- gobby/storage/tasks/_id.py +104 -0
- gobby/storage/tasks/_lifecycle.py +311 -0
- gobby/storage/tasks/_manager.py +889 -0
- gobby/storage/tasks/_models.py +300 -0
- gobby/storage/tasks/_ordering.py +119 -0
- gobby/storage/tasks/_path_cache.py +110 -0
- gobby/storage/tasks/_queries.py +343 -0
- gobby/storage/tasks/_search.py +143 -0
- gobby/storage/workflow_audit.py +393 -0
- gobby/storage/worktrees.py +547 -0
- gobby/sync/__init__.py +29 -0
- gobby/sync/github.py +333 -0
- gobby/sync/linear.py +304 -0
- gobby/sync/memories.py +284 -0
- gobby/sync/tasks.py +641 -0
- gobby/tasks/__init__.py +8 -0
- gobby/tasks/build_verification.py +193 -0
- gobby/tasks/commits.py +633 -0
- gobby/tasks/context.py +747 -0
- gobby/tasks/criteria.py +342 -0
- gobby/tasks/enhanced_validator.py +226 -0
- gobby/tasks/escalation.py +263 -0
- gobby/tasks/expansion.py +626 -0
- gobby/tasks/external_validator.py +764 -0
- gobby/tasks/issue_extraction.py +171 -0
- gobby/tasks/prompts/expand.py +327 -0
- gobby/tasks/research.py +421 -0
- gobby/tasks/tdd.py +352 -0
- gobby/tasks/tree_builder.py +263 -0
- gobby/tasks/validation.py +712 -0
- gobby/tasks/validation_history.py +357 -0
- gobby/tasks/validation_models.py +89 -0
- gobby/tools/__init__.py +0 -0
- gobby/tools/summarizer.py +170 -0
- gobby/tui/__init__.py +5 -0
- gobby/tui/api_client.py +281 -0
- gobby/tui/app.py +327 -0
- gobby/tui/screens/__init__.py +25 -0
- gobby/tui/screens/agents.py +333 -0
- gobby/tui/screens/chat.py +450 -0
- gobby/tui/screens/dashboard.py +377 -0
- gobby/tui/screens/memory.py +305 -0
- gobby/tui/screens/metrics.py +231 -0
- gobby/tui/screens/orchestrator.py +904 -0
- gobby/tui/screens/sessions.py +412 -0
- gobby/tui/screens/tasks.py +442 -0
- gobby/tui/screens/workflows.py +289 -0
- gobby/tui/screens/worktrees.py +174 -0
- gobby/tui/widgets/__init__.py +21 -0
- gobby/tui/widgets/chat.py +210 -0
- gobby/tui/widgets/conductor.py +104 -0
- gobby/tui/widgets/menu.py +132 -0
- gobby/tui/widgets/message_panel.py +160 -0
- gobby/tui/widgets/review_gate.py +224 -0
- gobby/tui/widgets/task_tree.py +99 -0
- gobby/tui/widgets/token_budget.py +166 -0
- gobby/tui/ws_client.py +258 -0
- gobby/utils/__init__.py +3 -0
- gobby/utils/daemon_client.py +235 -0
- gobby/utils/git.py +222 -0
- gobby/utils/id.py +38 -0
- gobby/utils/json_helpers.py +161 -0
- gobby/utils/logging.py +376 -0
- gobby/utils/machine_id.py +135 -0
- gobby/utils/metrics.py +589 -0
- gobby/utils/project_context.py +182 -0
- gobby/utils/project_init.py +263 -0
- gobby/utils/status.py +256 -0
- gobby/utils/validation.py +80 -0
- gobby/utils/version.py +23 -0
- gobby/workflows/__init__.py +4 -0
- gobby/workflows/actions.py +1310 -0
- gobby/workflows/approval_flow.py +138 -0
- gobby/workflows/artifact_actions.py +103 -0
- gobby/workflows/audit_helpers.py +110 -0
- gobby/workflows/autonomous_actions.py +286 -0
- gobby/workflows/context_actions.py +394 -0
- gobby/workflows/definitions.py +130 -0
- gobby/workflows/detection_helpers.py +208 -0
- gobby/workflows/engine.py +485 -0
- gobby/workflows/evaluator.py +669 -0
- gobby/workflows/git_utils.py +96 -0
- gobby/workflows/hooks.py +169 -0
- gobby/workflows/lifecycle_evaluator.py +613 -0
- gobby/workflows/llm_actions.py +70 -0
- gobby/workflows/loader.py +333 -0
- gobby/workflows/mcp_actions.py +60 -0
- gobby/workflows/memory_actions.py +272 -0
- gobby/workflows/premature_stop.py +164 -0
- gobby/workflows/session_actions.py +139 -0
- gobby/workflows/state_actions.py +123 -0
- gobby/workflows/state_manager.py +104 -0
- gobby/workflows/stop_signal_actions.py +163 -0
- gobby/workflows/summary_actions.py +344 -0
- gobby/workflows/task_actions.py +249 -0
- gobby/workflows/task_enforcement_actions.py +901 -0
- gobby/workflows/templates.py +52 -0
- gobby/workflows/todo_actions.py +84 -0
- gobby/workflows/webhook.py +223 -0
- gobby/workflows/webhook_executor.py +399 -0
- gobby/worktrees/__init__.py +5 -0
- gobby/worktrees/git.py +690 -0
- gobby/worktrees/merge/__init__.py +20 -0
- gobby/worktrees/merge/conflict_parser.py +177 -0
- gobby/worktrees/merge/resolver.py +485 -0
- gobby-0.2.5.dist-info/METADATA +351 -0
- gobby-0.2.5.dist-info/RECORD +383 -0
- gobby-0.2.5.dist-info/WHEEL +5 -0
- gobby-0.2.5.dist-info/entry_points.txt +2 -0
- gobby-0.2.5.dist-info/licenses/LICENSE.md +193 -0
- gobby-0.2.5.dist-info/top_level.txt +1 -0
gobby/llm/base.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Abstract base class for LLM providers.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
# Auth mode type for providers
|
|
9
|
+
AuthMode = Literal["subscription", "api_key", "adc"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LLMProvider(ABC):
|
|
13
|
+
"""
|
|
14
|
+
Abstract base class for LLM providers.
|
|
15
|
+
|
|
16
|
+
Defines the interface for generating summaries and synthesizing titles
|
|
17
|
+
across different providers (Claude, Codex, Gemini, LiteLLM).
|
|
18
|
+
|
|
19
|
+
Properties:
|
|
20
|
+
provider_name: Unique identifier for this provider (e.g., "claude", "codex")
|
|
21
|
+
auth_mode: How this provider authenticates ("subscription", "api_key", "adc")
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def provider_name(self) -> str:
|
|
27
|
+
"""
|
|
28
|
+
Return the unique provider name.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Provider name string (e.g., "claude", "codex", "gemini", "litellm")
|
|
32
|
+
"""
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def auth_mode(self) -> AuthMode:
|
|
37
|
+
"""
|
|
38
|
+
Return the authentication mode for this provider.
|
|
39
|
+
|
|
40
|
+
Default implementation returns "subscription". Override in subclasses
|
|
41
|
+
that use different auth modes.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Authentication mode: "subscription", "api_key", or "adc"
|
|
45
|
+
"""
|
|
46
|
+
return "subscription"
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
async def generate_summary(
|
|
50
|
+
self, context: dict[str, Any], prompt_template: str | None = None
|
|
51
|
+
) -> str:
|
|
52
|
+
"""
|
|
53
|
+
Generate session summary.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
context: Dictionary containing transcript turns, git status, etc.
|
|
57
|
+
prompt_template: Optional override for the prompt.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Generated summary string.
|
|
61
|
+
"""
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
@abstractmethod
|
|
65
|
+
async def synthesize_title(
|
|
66
|
+
self, user_prompt: str, prompt_template: str | None = None
|
|
67
|
+
) -> str | None:
|
|
68
|
+
"""
|
|
69
|
+
Synthesize session title.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
user_prompt: The first user message.
|
|
73
|
+
prompt_template: Optional override for the prompt.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Synthesized title or None if failed.
|
|
77
|
+
"""
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
@abstractmethod
|
|
81
|
+
async def generate_text(
|
|
82
|
+
self,
|
|
83
|
+
prompt: str,
|
|
84
|
+
system_prompt: str | None = None,
|
|
85
|
+
model: str | None = None,
|
|
86
|
+
) -> str:
|
|
87
|
+
"""
|
|
88
|
+
Generate text from a prompt.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
prompt: User prompt
|
|
92
|
+
system_prompt: Optional system prompt
|
|
93
|
+
model: Optional model override
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Generated text response
|
|
97
|
+
"""
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
@abstractmethod
|
|
101
|
+
async def describe_image(
|
|
102
|
+
self,
|
|
103
|
+
image_path: str,
|
|
104
|
+
context: str | None = None,
|
|
105
|
+
) -> str:
|
|
106
|
+
"""
|
|
107
|
+
Generate a text description of an image.
|
|
108
|
+
|
|
109
|
+
Used for multimodal memory support - converts images to text
|
|
110
|
+
descriptions that can be stored alongside memory content.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
image_path: Path to the image file to describe
|
|
114
|
+
context: Optional context to guide the description
|
|
115
|
+
(e.g., "This is a screenshot of the settings page")
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Text description of the image suitable for memory storage
|
|
119
|
+
"""
|
|
120
|
+
pass
|
gobby/llm/claude.py
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Claude implementation of LLMProvider.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from claude_agent_sdk import (
|
|
15
|
+
AssistantMessage,
|
|
16
|
+
ClaudeAgentOptions,
|
|
17
|
+
ResultMessage,
|
|
18
|
+
TextBlock,
|
|
19
|
+
ToolResultBlock,
|
|
20
|
+
ToolUseBlock,
|
|
21
|
+
UserMessage,
|
|
22
|
+
create_sdk_mcp_server,
|
|
23
|
+
query,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from gobby.config.app import DaemonConfig
|
|
27
|
+
from gobby.llm.base import LLMProvider
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class ToolCall:
|
|
32
|
+
"""Represents a tool call made during generation."""
|
|
33
|
+
|
|
34
|
+
tool_name: str
|
|
35
|
+
"""Full tool name (e.g., mcp__gobby-tasks__create_task)."""
|
|
36
|
+
|
|
37
|
+
server_name: str
|
|
38
|
+
"""Extracted server name from the tool (e.g., gobby-tasks)."""
|
|
39
|
+
|
|
40
|
+
arguments: dict[str, Any]
|
|
41
|
+
"""Arguments passed to the tool."""
|
|
42
|
+
|
|
43
|
+
result: str | None = None
|
|
44
|
+
"""Result returned by the tool, if available."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class MCPToolResult:
|
|
49
|
+
"""Result of generate_with_mcp_tools."""
|
|
50
|
+
|
|
51
|
+
text: str
|
|
52
|
+
"""Final text output from the generation."""
|
|
53
|
+
|
|
54
|
+
tool_calls: list[ToolCall] = field(default_factory=list)
|
|
55
|
+
"""List of tool calls made during generation."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
logger = logging.getLogger(__name__)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ClaudeLLMProvider(LLMProvider):
|
|
62
|
+
"""
|
|
63
|
+
Claude implementation of LLMProvider using claude_agent_sdk.
|
|
64
|
+
|
|
65
|
+
Uses subscription-based authentication through Claude CLI.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def provider_name(self) -> str:
|
|
70
|
+
"""Return provider name."""
|
|
71
|
+
return "claude"
|
|
72
|
+
|
|
73
|
+
def __init__(self, config: DaemonConfig):
|
|
74
|
+
"""
|
|
75
|
+
Initialize ClaudeLLMProvider.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
config: Client configuration.
|
|
79
|
+
"""
|
|
80
|
+
self.config = config
|
|
81
|
+
self.logger = logger
|
|
82
|
+
self._claude_cli_path = self._find_cli_path()
|
|
83
|
+
|
|
84
|
+
def _find_cli_path(self) -> str | None:
|
|
85
|
+
"""
|
|
86
|
+
Find Claude CLI path.
|
|
87
|
+
|
|
88
|
+
DO NOT resolve symlinks - npm manages the symlink atomically during upgrades.
|
|
89
|
+
Resolving causes race conditions when Claude Code is being reinstalled.
|
|
90
|
+
"""
|
|
91
|
+
cli_path = shutil.which("claude")
|
|
92
|
+
|
|
93
|
+
if cli_path:
|
|
94
|
+
# Validate CLI exists and is executable
|
|
95
|
+
if not os.path.exists(cli_path):
|
|
96
|
+
self.logger.warning(f"Claude CLI not found: {cli_path}")
|
|
97
|
+
return None
|
|
98
|
+
elif not os.access(cli_path, os.X_OK):
|
|
99
|
+
self.logger.warning(f"Claude CLI not executable: {cli_path}")
|
|
100
|
+
return None
|
|
101
|
+
else:
|
|
102
|
+
self.logger.debug(f"Claude CLI found: {cli_path}")
|
|
103
|
+
return cli_path
|
|
104
|
+
else:
|
|
105
|
+
self.logger.warning("Claude CLI not found in PATH - LLM features disabled")
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
def _verify_cli_path(self) -> str | None:
|
|
109
|
+
"""
|
|
110
|
+
Verify CLI path is still valid and retry if needed.
|
|
111
|
+
|
|
112
|
+
Handles race condition when npm install updates Claude Code during hook execution.
|
|
113
|
+
Uses exponential backoff retry to wait for npm install to complete.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Valid CLI path if found, None otherwise
|
|
117
|
+
"""
|
|
118
|
+
cli_path = self._claude_cli_path
|
|
119
|
+
|
|
120
|
+
# Validate cached path still exists
|
|
121
|
+
# Retry with backoff if missing (may be in the middle of npm install)
|
|
122
|
+
if cli_path and not os.path.exists(cli_path):
|
|
123
|
+
self.logger.warning(
|
|
124
|
+
f"Cached CLI path no longer exists (may have been reinstalled): {cli_path}"
|
|
125
|
+
)
|
|
126
|
+
# Try to find CLI again with retry logic for npm install race condition
|
|
127
|
+
max_retries = 3
|
|
128
|
+
retry_delays = [0.5, 1.0, 2.0] # Exponential backoff
|
|
129
|
+
|
|
130
|
+
for attempt, delay in enumerate(retry_delays, 1):
|
|
131
|
+
cli_path = shutil.which("claude")
|
|
132
|
+
if cli_path and os.path.exists(cli_path):
|
|
133
|
+
self.logger.debug(
|
|
134
|
+
f"Found Claude CLI at new location after {attempt} attempt(s): {cli_path}"
|
|
135
|
+
)
|
|
136
|
+
self._claude_cli_path = cli_path
|
|
137
|
+
break
|
|
138
|
+
|
|
139
|
+
if attempt < max_retries:
|
|
140
|
+
self.logger.debug(
|
|
141
|
+
f"Claude CLI not found, waiting {delay}s before retry {attempt + 1}/{max_retries}"
|
|
142
|
+
)
|
|
143
|
+
time.sleep(delay)
|
|
144
|
+
else:
|
|
145
|
+
self.logger.warning(f"Claude CLI not found in PATH after {max_retries} retries")
|
|
146
|
+
cli_path = None
|
|
147
|
+
|
|
148
|
+
return cli_path
|
|
149
|
+
|
|
150
|
+
async def generate_summary(
|
|
151
|
+
self, context: dict[str, Any], prompt_template: str | None = None
|
|
152
|
+
) -> str:
|
|
153
|
+
"""
|
|
154
|
+
Generate session summary using Claude.
|
|
155
|
+
"""
|
|
156
|
+
cli_path = self._verify_cli_path()
|
|
157
|
+
if not cli_path:
|
|
158
|
+
return "Session summary unavailable (Claude CLI not found)"
|
|
159
|
+
|
|
160
|
+
# Build formatted context for prompt template
|
|
161
|
+
# Transform list/dict values to strings for template substitution
|
|
162
|
+
formatted_context = {
|
|
163
|
+
"transcript_summary": context.get("transcript_summary", ""),
|
|
164
|
+
"last_messages": json.dumps(context.get("last_messages", []), indent=2),
|
|
165
|
+
"git_status": context.get("git_status", ""),
|
|
166
|
+
"file_changes": context.get("file_changes", ""),
|
|
167
|
+
**{
|
|
168
|
+
k: v
|
|
169
|
+
for k, v in context.items()
|
|
170
|
+
if k not in ["transcript_summary", "last_messages", "git_status", "file_changes"]
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
# Build prompt - prompt_template is required
|
|
175
|
+
if not prompt_template:
|
|
176
|
+
raise ValueError(
|
|
177
|
+
"prompt_template is required for generate_summary. "
|
|
178
|
+
"Configure 'session_summary.prompt' in ~/.gobby/config.yaml"
|
|
179
|
+
)
|
|
180
|
+
prompt = prompt_template.format(**formatted_context)
|
|
181
|
+
|
|
182
|
+
# Configure Claude Agent SDK
|
|
183
|
+
options = ClaudeAgentOptions(
|
|
184
|
+
system_prompt="You are a session summary generator. Create comprehensive, actionable summaries.",
|
|
185
|
+
max_turns=1,
|
|
186
|
+
model=self.config.session_summary.model,
|
|
187
|
+
allowed_tools=[],
|
|
188
|
+
permission_mode="default",
|
|
189
|
+
cli_path=cli_path,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Run async query
|
|
193
|
+
async def _run_query() -> str:
|
|
194
|
+
summary_text = ""
|
|
195
|
+
async for message in query(prompt=prompt, options=options):
|
|
196
|
+
if isinstance(message, AssistantMessage):
|
|
197
|
+
for block in message.content:
|
|
198
|
+
if isinstance(block, TextBlock):
|
|
199
|
+
summary_text += block.text
|
|
200
|
+
return summary_text
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
return await _run_query()
|
|
204
|
+
except Exception as e:
|
|
205
|
+
self.logger.error(f"Failed to generate summary with Claude: {e}")
|
|
206
|
+
return f"Session summary generation failed: {e}"
|
|
207
|
+
|
|
208
|
+
async def synthesize_title(
|
|
209
|
+
self, user_prompt: str, prompt_template: str | None = None
|
|
210
|
+
) -> str | None:
|
|
211
|
+
"""
|
|
212
|
+
Synthesize session title using Claude.
|
|
213
|
+
"""
|
|
214
|
+
cli_path = self._verify_cli_path()
|
|
215
|
+
if not cli_path:
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
# Build prompt - prompt_template is required
|
|
219
|
+
if not prompt_template:
|
|
220
|
+
raise ValueError(
|
|
221
|
+
"prompt_template is required for synthesize_title. "
|
|
222
|
+
"Configure 'title_synthesis.prompt' in ~/.gobby/config.yaml"
|
|
223
|
+
)
|
|
224
|
+
prompt = prompt_template.format(user_prompt=user_prompt)
|
|
225
|
+
|
|
226
|
+
# Configure Claude Agent SDK
|
|
227
|
+
options = ClaudeAgentOptions(
|
|
228
|
+
system_prompt="You are a session title generator. Create concise, descriptive titles.",
|
|
229
|
+
max_turns=1,
|
|
230
|
+
model=self.config.title_synthesis.model,
|
|
231
|
+
allowed_tools=[],
|
|
232
|
+
permission_mode="default",
|
|
233
|
+
cli_path=cli_path,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Run async query
|
|
237
|
+
async def _run_query() -> str:
|
|
238
|
+
title_text = ""
|
|
239
|
+
async for message in query(prompt=prompt, options=options):
|
|
240
|
+
if isinstance(message, AssistantMessage):
|
|
241
|
+
for block in message.content:
|
|
242
|
+
if isinstance(block, TextBlock):
|
|
243
|
+
title_text = block.text
|
|
244
|
+
return title_text.strip()
|
|
245
|
+
|
|
246
|
+
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
|
|
262
|
+
except Exception as e:
|
|
263
|
+
self.logger.error(f"Failed to synthesize title with Claude: {e}")
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
async def generate_text(
|
|
267
|
+
self,
|
|
268
|
+
prompt: str,
|
|
269
|
+
system_prompt: str | None = None,
|
|
270
|
+
model: str | None = None,
|
|
271
|
+
) -> str:
|
|
272
|
+
"""
|
|
273
|
+
Generate text using Claude.
|
|
274
|
+
"""
|
|
275
|
+
cli_path = self._verify_cli_path()
|
|
276
|
+
if not cli_path:
|
|
277
|
+
return "Generation unavailable (Claude CLI not found)"
|
|
278
|
+
|
|
279
|
+
# Configure Claude Agent SDK
|
|
280
|
+
# Use tools=[] to disable all tools for pure text generation
|
|
281
|
+
options = ClaudeAgentOptions(
|
|
282
|
+
system_prompt=system_prompt or "You are a helpful assistant.",
|
|
283
|
+
max_turns=1,
|
|
284
|
+
model=model or "claude-haiku-4-5",
|
|
285
|
+
tools=[], # Explicitly disable all tools
|
|
286
|
+
allowed_tools=[],
|
|
287
|
+
permission_mode="default",
|
|
288
|
+
cli_path=cli_path,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# Run async query
|
|
292
|
+
async def _run_query() -> str:
|
|
293
|
+
result_text = ""
|
|
294
|
+
message_count = 0
|
|
295
|
+
async for message in query(prompt=prompt, options=options):
|
|
296
|
+
message_count += 1
|
|
297
|
+
self.logger.debug(
|
|
298
|
+
f"generate_text message {message_count}: {type(message).__name__}"
|
|
299
|
+
)
|
|
300
|
+
if isinstance(message, AssistantMessage):
|
|
301
|
+
for block in message.content:
|
|
302
|
+
if isinstance(block, TextBlock):
|
|
303
|
+
self.logger.debug(f" TextBlock: {block.text[:100]}...")
|
|
304
|
+
result_text += block.text
|
|
305
|
+
elif isinstance(block, ToolUseBlock):
|
|
306
|
+
self.logger.debug(f" ToolUseBlock: {block.name}")
|
|
307
|
+
elif isinstance(message, ResultMessage):
|
|
308
|
+
# ResultMessage contains the final result from the agent
|
|
309
|
+
self.logger.debug(
|
|
310
|
+
f" ResultMessage: result={message.result}, type={type(message.result)}"
|
|
311
|
+
)
|
|
312
|
+
if message.result:
|
|
313
|
+
result_text = message.result
|
|
314
|
+
if message_count == 0:
|
|
315
|
+
self.logger.warning("generate_text: No messages received from Claude SDK")
|
|
316
|
+
elif not result_text:
|
|
317
|
+
self.logger.warning(f"generate_text: {message_count} messages but no text content")
|
|
318
|
+
return result_text
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
return await _run_query()
|
|
322
|
+
except Exception as e:
|
|
323
|
+
self.logger.error(f"Failed to generate text with Claude: {e}", exc_info=True)
|
|
324
|
+
return f"Generation failed: {e}"
|
|
325
|
+
|
|
326
|
+
async def generate_with_mcp_tools(
|
|
327
|
+
self,
|
|
328
|
+
prompt: str,
|
|
329
|
+
allowed_tools: list[str],
|
|
330
|
+
system_prompt: str | None = None,
|
|
331
|
+
model: str | None = None,
|
|
332
|
+
max_turns: int = 10,
|
|
333
|
+
tool_functions: dict[str, list[Any]] | None = None,
|
|
334
|
+
) -> MCPToolResult:
|
|
335
|
+
"""
|
|
336
|
+
Generate text with access to MCP tools.
|
|
337
|
+
|
|
338
|
+
This method enables the agent to call MCP tools during generation,
|
|
339
|
+
tracking all tool calls made and returning them alongside the final text.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
prompt: User prompt to process.
|
|
343
|
+
allowed_tools: List of allowed MCP tool patterns.
|
|
344
|
+
Tools should be in format "mcp__{server}__{tool}" or patterns
|
|
345
|
+
like "mcp__gobby-tasks__*" for all tools from a server.
|
|
346
|
+
system_prompt: Optional system prompt.
|
|
347
|
+
model: Optional model override (default: claude-sonnet-4-5).
|
|
348
|
+
max_turns: Maximum number of agentic turns (default: 10).
|
|
349
|
+
tool_functions: Optional dict mapping server names to lists of tool
|
|
350
|
+
functions for in-process MCP servers. Example:
|
|
351
|
+
{"gobby-tasks": [create_task_func, update_task_func]}
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
MCPToolResult containing final text and list of tool calls made.
|
|
355
|
+
|
|
356
|
+
Example:
|
|
357
|
+
>>> result = await provider.generate_with_mcp_tools(
|
|
358
|
+
... prompt="Create a task called 'Fix bug'",
|
|
359
|
+
... allowed_tools=["mcp__gobby-tasks__create_task"],
|
|
360
|
+
... system_prompt="You are a task manager.",
|
|
361
|
+
... tool_functions={"gobby-tasks": [create_task]}
|
|
362
|
+
... )
|
|
363
|
+
>>> print(result.text)
|
|
364
|
+
>>> for call in result.tool_calls:
|
|
365
|
+
... print(f"Called {call.tool_name} with {call.arguments}")
|
|
366
|
+
"""
|
|
367
|
+
cli_path = self._verify_cli_path()
|
|
368
|
+
if not cli_path:
|
|
369
|
+
return MCPToolResult(
|
|
370
|
+
text="Generation unavailable (Claude CLI not found)",
|
|
371
|
+
tool_calls=[],
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
# Build mcp_servers config
|
|
375
|
+
# Can be a dict of server configs OR a path to .mcp.json file
|
|
376
|
+
from pathlib import Path
|
|
377
|
+
|
|
378
|
+
mcp_servers_config: dict[str, Any] | str | None = None
|
|
379
|
+
|
|
380
|
+
# Add in-process tool functions if provided
|
|
381
|
+
if tool_functions:
|
|
382
|
+
mcp_servers_config = {}
|
|
383
|
+
for server_name, tools in tool_functions.items():
|
|
384
|
+
mcp_servers_config[server_name] = create_sdk_mcp_server(
|
|
385
|
+
name=server_name,
|
|
386
|
+
tools=tools,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
# If no tool_functions provided but we have allowed gobby tools,
|
|
390
|
+
# use the .mcp.json config file (avoids in-process config issues)
|
|
391
|
+
if not tool_functions and any("gobby" in t for t in allowed_tools):
|
|
392
|
+
# Look for .mcp.json in the current working directory or gobby project
|
|
393
|
+
cwd_config = Path.cwd() / ".mcp.json"
|
|
394
|
+
if cwd_config.exists():
|
|
395
|
+
mcp_servers_config = str(cwd_config)
|
|
396
|
+
else:
|
|
397
|
+
# Try the gobby project root
|
|
398
|
+
gobby_root = Path(__file__).parent.parent.parent.parent
|
|
399
|
+
gobby_config = gobby_root / ".mcp.json"
|
|
400
|
+
if gobby_config.exists():
|
|
401
|
+
mcp_servers_config = str(gobby_config)
|
|
402
|
+
|
|
403
|
+
# Configure Claude Agent SDK with MCP tools
|
|
404
|
+
options = ClaudeAgentOptions(
|
|
405
|
+
system_prompt=system_prompt or "You are a helpful assistant with access to MCP tools.",
|
|
406
|
+
max_turns=max_turns,
|
|
407
|
+
model=model or "claude-sonnet-4-5",
|
|
408
|
+
allowed_tools=allowed_tools,
|
|
409
|
+
permission_mode="bypassPermissions",
|
|
410
|
+
cli_path=cli_path,
|
|
411
|
+
mcp_servers=mcp_servers_config if mcp_servers_config is not None else {},
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
# Track tool calls and results
|
|
415
|
+
tool_calls: list[ToolCall] = []
|
|
416
|
+
pending_tool_calls: dict[str, ToolCall] = {} # Map tool_use_id -> ToolCall
|
|
417
|
+
|
|
418
|
+
def _parse_server_name(full_tool_name: str) -> str:
|
|
419
|
+
"""Extract server name from mcp__{server}__{tool} format."""
|
|
420
|
+
if full_tool_name.startswith("mcp__"):
|
|
421
|
+
parts = full_tool_name.split("__")
|
|
422
|
+
if len(parts) >= 2:
|
|
423
|
+
return parts[1]
|
|
424
|
+
return "unknown"
|
|
425
|
+
|
|
426
|
+
# Run async query
|
|
427
|
+
async def _run_query() -> str:
|
|
428
|
+
result_text = ""
|
|
429
|
+
async for message in query(prompt=prompt, options=options):
|
|
430
|
+
if isinstance(message, ResultMessage):
|
|
431
|
+
# Final result from the agent
|
|
432
|
+
if message.result:
|
|
433
|
+
result_text = message.result
|
|
434
|
+
self.logger.debug(f"ResultMessage: result={message.result}")
|
|
435
|
+
|
|
436
|
+
elif isinstance(message, AssistantMessage):
|
|
437
|
+
for block in message.content:
|
|
438
|
+
if isinstance(block, TextBlock):
|
|
439
|
+
result_text += block.text
|
|
440
|
+
elif isinstance(block, ToolUseBlock):
|
|
441
|
+
# Track tool use
|
|
442
|
+
tool_call = ToolCall(
|
|
443
|
+
tool_name=block.name,
|
|
444
|
+
server_name=_parse_server_name(block.name),
|
|
445
|
+
arguments=block.input if isinstance(block.input, dict) else {},
|
|
446
|
+
)
|
|
447
|
+
tool_calls.append(tool_call)
|
|
448
|
+
pending_tool_calls[block.id] = tool_call
|
|
449
|
+
self.logger.debug(
|
|
450
|
+
f"ToolUseBlock: tool={block.name}, input={block.input}"
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
elif isinstance(message, UserMessage):
|
|
454
|
+
# UserMessage may contain tool results
|
|
455
|
+
# UserMessage.content can be str | list[...], check first
|
|
456
|
+
if isinstance(message.content, list):
|
|
457
|
+
for block in message.content:
|
|
458
|
+
if isinstance(block, ToolResultBlock):
|
|
459
|
+
# Match result to pending tool call
|
|
460
|
+
if block.tool_use_id in pending_tool_calls:
|
|
461
|
+
pending_tool_calls[block.tool_use_id].result = str(
|
|
462
|
+
block.content
|
|
463
|
+
)
|
|
464
|
+
self.logger.debug(
|
|
465
|
+
f"ToolResultBlock: id={block.tool_use_id}, content={block.content}"
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
return result_text
|
|
469
|
+
|
|
470
|
+
try:
|
|
471
|
+
final_text = await _run_query()
|
|
472
|
+
return MCPToolResult(text=final_text, tool_calls=tool_calls)
|
|
473
|
+
except ExceptionGroup as eg:
|
|
474
|
+
# Handle Python 3.11+ ExceptionGroup from TaskGroup
|
|
475
|
+
errors: list[str] = []
|
|
476
|
+
for exc in eg.exceptions:
|
|
477
|
+
errors.append(f"{type(exc).__name__}: {exc}")
|
|
478
|
+
self.logger.error(f"TaskGroup sub-exception: {type(exc).__name__}: {exc}")
|
|
479
|
+
return MCPToolResult(
|
|
480
|
+
text=f"Generation failed: {'; '.join(errors)}",
|
|
481
|
+
tool_calls=tool_calls,
|
|
482
|
+
)
|
|
483
|
+
except Exception as e:
|
|
484
|
+
self.logger.error(f"Failed to generate with MCP tools: {e}", exc_info=True)
|
|
485
|
+
return MCPToolResult(
|
|
486
|
+
text=f"Generation failed: {e}",
|
|
487
|
+
tool_calls=tool_calls,
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
async def describe_image(
|
|
491
|
+
self,
|
|
492
|
+
image_path: str,
|
|
493
|
+
context: str | None = None,
|
|
494
|
+
) -> str:
|
|
495
|
+
"""
|
|
496
|
+
Generate a text description of an image using Claude's vision capabilities.
|
|
497
|
+
|
|
498
|
+
Uses the Anthropic API directly for vision support.
|
|
499
|
+
|
|
500
|
+
Args:
|
|
501
|
+
image_path: Path to the image file to describe
|
|
502
|
+
context: Optional context to guide the description
|
|
503
|
+
|
|
504
|
+
Returns:
|
|
505
|
+
Text description of the image
|
|
506
|
+
"""
|
|
507
|
+
import base64
|
|
508
|
+
import mimetypes
|
|
509
|
+
from pathlib import Path
|
|
510
|
+
|
|
511
|
+
import anthropic
|
|
512
|
+
|
|
513
|
+
# Validate image exists
|
|
514
|
+
path = Path(image_path)
|
|
515
|
+
if not path.exists():
|
|
516
|
+
return f"Image not found: {image_path}"
|
|
517
|
+
|
|
518
|
+
# Read and encode image
|
|
519
|
+
try:
|
|
520
|
+
image_data = path.read_bytes()
|
|
521
|
+
image_base64 = base64.standard_b64encode(image_data).decode("utf-8")
|
|
522
|
+
except Exception as e:
|
|
523
|
+
self.logger.error(f"Failed to read image {image_path}: {e}")
|
|
524
|
+
return f"Failed to read image: {e}"
|
|
525
|
+
|
|
526
|
+
# Determine media type
|
|
527
|
+
mime_type, _ = mimetypes.guess_type(str(path))
|
|
528
|
+
if mime_type not in ["image/jpeg", "image/png", "image/gif", "image/webp"]:
|
|
529
|
+
# Default to png for unknown types
|
|
530
|
+
mime_type = "image/png"
|
|
531
|
+
|
|
532
|
+
# Build prompt
|
|
533
|
+
prompt = "Please describe this image in detail, focusing on the key visual elements and any text visible."
|
|
534
|
+
if context:
|
|
535
|
+
prompt = f"{context}\n\n{prompt}"
|
|
536
|
+
|
|
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
|
+
|
|
542
|
+
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,
|
|
560
|
+
messages=[
|
|
561
|
+
{
|
|
562
|
+
"role": "user",
|
|
563
|
+
"content": [image_block, text_block],
|
|
564
|
+
}
|
|
565
|
+
],
|
|
566
|
+
)
|
|
567
|
+
|
|
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"
|
|
575
|
+
|
|
576
|
+
except Exception as e:
|
|
577
|
+
self.logger.error(f"Failed to describe image with Claude: {e}")
|
|
578
|
+
return f"Image description failed: {e}"
|