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
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DaemonClient - HTTP communication with Gobby daemon.
|
|
3
|
+
|
|
4
|
+
This module provides a clean interface for communicating with the Gobby daemon's
|
|
5
|
+
HTTP API. It handles health checks, authentication verification, and HTTP API calls.
|
|
6
|
+
|
|
7
|
+
The DaemonClient is session-agnostic and thread-safe, designed to be shared across
|
|
8
|
+
multiple sessions while maintaining cached health status for performance.
|
|
9
|
+
|
|
10
|
+
Example:
|
|
11
|
+
```python
|
|
12
|
+
from gobby.utils.daemon_client import DaemonClient
|
|
13
|
+
|
|
14
|
+
client = DaemonClient(host="localhost", port=8765)
|
|
15
|
+
|
|
16
|
+
# Check daemon health
|
|
17
|
+
is_healthy, error = client.check_health()
|
|
18
|
+
|
|
19
|
+
# Call HTTP API endpoint
|
|
20
|
+
response = client.call_http_api("/sessions/register", method="POST", json_data={
|
|
21
|
+
"external_id": "abc123"
|
|
22
|
+
})
|
|
23
|
+
```
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
import threading
|
|
28
|
+
from typing import Any, ClassVar, cast
|
|
29
|
+
|
|
30
|
+
import httpx
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DaemonClient:
|
|
34
|
+
"""
|
|
35
|
+
Client for communicating with Gobby daemon HTTP API.
|
|
36
|
+
|
|
37
|
+
Provides methods for:
|
|
38
|
+
- Health checking with caching
|
|
39
|
+
- Authentication verification
|
|
40
|
+
- HTTP API calls
|
|
41
|
+
|
|
42
|
+
Thread-safe and session-agnostic.
|
|
43
|
+
|
|
44
|
+
Attributes:
|
|
45
|
+
url: Base URL for daemon HTTP API
|
|
46
|
+
timeout: Request timeout in seconds
|
|
47
|
+
logger: Logger instance for this client
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
# Status text mapping (class-level constant)
|
|
51
|
+
DAEMON_STATUS_TEXT: ClassVar[dict[str, str]] = {
|
|
52
|
+
"not_running": "Not Running",
|
|
53
|
+
"cannot_access": "Cannot Access",
|
|
54
|
+
"ready": "Ready",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
host: str = "localhost",
|
|
60
|
+
port: int = 8765,
|
|
61
|
+
timeout: float = 5.0,
|
|
62
|
+
logger: logging.Logger | None = None,
|
|
63
|
+
):
|
|
64
|
+
"""
|
|
65
|
+
Initialize DaemonClient.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
host: Daemon host address
|
|
69
|
+
port: Daemon port number
|
|
70
|
+
timeout: HTTP request timeout in seconds
|
|
71
|
+
logger: Optional logger instance (creates one if not provided)
|
|
72
|
+
"""
|
|
73
|
+
self.url = f"http://{host}:{port}"
|
|
74
|
+
self.timeout = timeout
|
|
75
|
+
self.logger = logger or logging.getLogger(__name__)
|
|
76
|
+
|
|
77
|
+
# Health status cache (thread-safe)
|
|
78
|
+
self._cache_lock = threading.Lock()
|
|
79
|
+
self._cached_is_ready: bool | None = None
|
|
80
|
+
self._cached_message: str | None = None
|
|
81
|
+
self._cached_status: str | None = None
|
|
82
|
+
self._cached_error: str | None = None
|
|
83
|
+
|
|
84
|
+
def check_health(self) -> tuple[bool, str | None]:
|
|
85
|
+
"""
|
|
86
|
+
Check if daemon is available and healthy.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Tuple of (is_healthy, error_reason) where:
|
|
90
|
+
- is_healthy: True if daemon is healthy, False otherwise
|
|
91
|
+
- error_reason: None if healthy, otherwise error description
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
response = httpx.get(
|
|
95
|
+
f"{self.url}/admin/status",
|
|
96
|
+
timeout=self.timeout,
|
|
97
|
+
)
|
|
98
|
+
is_healthy = response.status_code == 200
|
|
99
|
+
if is_healthy:
|
|
100
|
+
self.logger.info(f"Daemon health check passed at {self.url}")
|
|
101
|
+
return True, None
|
|
102
|
+
else:
|
|
103
|
+
error_reason = f"HTTP {response.status_code}"
|
|
104
|
+
self.logger.warning(f"Daemon health check failed: status {response.status_code}")
|
|
105
|
+
return False, error_reason
|
|
106
|
+
except Exception as e:
|
|
107
|
+
error_msg = str(e)
|
|
108
|
+
# Check if it's a connection refused (daemon not running)
|
|
109
|
+
if "refused" in error_msg.lower() or "connection" in error_msg.lower():
|
|
110
|
+
self.logger.warning(f"Daemon not running: {e}")
|
|
111
|
+
return False, None # None means daemon not running
|
|
112
|
+
else:
|
|
113
|
+
# Other errors (timeout, DNS, etc.)
|
|
114
|
+
self.logger.error(f"Daemon health check error: {e}")
|
|
115
|
+
return False, error_msg
|
|
116
|
+
|
|
117
|
+
def check_status(self) -> tuple[bool, str | None, str, str | None]:
|
|
118
|
+
"""
|
|
119
|
+
Check daemon health status.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Tuple of (is_ready, message, status, error_reason) where:
|
|
123
|
+
- is_ready: True if daemon is healthy
|
|
124
|
+
- message: Human-readable status message
|
|
125
|
+
- status: One of: "ready", "not_running", "cannot_access"
|
|
126
|
+
- error_reason: Error details if status != "ready"
|
|
127
|
+
"""
|
|
128
|
+
is_healthy, health_error = self.check_health()
|
|
129
|
+
|
|
130
|
+
if not is_healthy:
|
|
131
|
+
if health_error is None:
|
|
132
|
+
return False, "Daemon is not running", "not_running", None
|
|
133
|
+
else:
|
|
134
|
+
return False, f"Cannot access daemon: {health_error}", "cannot_access", health_error
|
|
135
|
+
|
|
136
|
+
return True, "Daemon is ready", "ready", None
|
|
137
|
+
|
|
138
|
+
def call_http_api(
|
|
139
|
+
self,
|
|
140
|
+
endpoint: str,
|
|
141
|
+
method: str = "POST",
|
|
142
|
+
json_data: dict[str, Any] | None = None,
|
|
143
|
+
timeout: float | None = None,
|
|
144
|
+
) -> Any:
|
|
145
|
+
"""
|
|
146
|
+
Call daemon HTTP API endpoint directly (for non-MCP endpoints).
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
endpoint: API endpoint path (e.g., "/sessions/register")
|
|
150
|
+
method: HTTP method (default: POST)
|
|
151
|
+
json_data: JSON data to send
|
|
152
|
+
timeout: Request timeout (default: uses self.timeout)
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Response object (httpx.Response)
|
|
156
|
+
"""
|
|
157
|
+
url = f"{self.url}{endpoint}"
|
|
158
|
+
timeout_val = timeout or self.timeout
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
if method.upper() == "GET":
|
|
162
|
+
response = httpx.get(url, timeout=timeout_val)
|
|
163
|
+
elif method.upper() == "POST":
|
|
164
|
+
response = httpx.post(url, json=json_data, timeout=timeout_val)
|
|
165
|
+
elif method.upper() == "PUT":
|
|
166
|
+
response = httpx.put(url, json=json_data, timeout=timeout_val)
|
|
167
|
+
elif method.upper() == "DELETE":
|
|
168
|
+
response = httpx.delete(url, timeout=timeout_val)
|
|
169
|
+
else:
|
|
170
|
+
raise ValueError(f"Unsupported HTTP method: {method}")
|
|
171
|
+
|
|
172
|
+
return response
|
|
173
|
+
|
|
174
|
+
except Exception as e:
|
|
175
|
+
self.logger.error(f"HTTP API call failed: {method} {endpoint} - {e}")
|
|
176
|
+
raise
|
|
177
|
+
|
|
178
|
+
def call_mcp_tool(
|
|
179
|
+
self,
|
|
180
|
+
server_name: str,
|
|
181
|
+
tool_name: str,
|
|
182
|
+
arguments: dict[str, Any],
|
|
183
|
+
timeout: float | None = None,
|
|
184
|
+
) -> dict[str, Any]:
|
|
185
|
+
"""
|
|
186
|
+
Call an MCP tool via the daemon's HTTP API.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
server_name: Name of the MCP server
|
|
190
|
+
tool_name: Name of the tool to call
|
|
191
|
+
arguments: Tool arguments
|
|
192
|
+
timeout: Request timeout
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Tool execution result
|
|
196
|
+
"""
|
|
197
|
+
endpoint = f"/mcp/{server_name}/tools/{tool_name}"
|
|
198
|
+
response = self.call_http_api(
|
|
199
|
+
endpoint=endpoint,
|
|
200
|
+
method="POST",
|
|
201
|
+
json_data=arguments,
|
|
202
|
+
timeout=timeout,
|
|
203
|
+
)
|
|
204
|
+
response.raise_for_status()
|
|
205
|
+
return cast(dict[str, Any], response.json())
|
|
206
|
+
|
|
207
|
+
def update_status_cache(self) -> None:
|
|
208
|
+
"""Update cached daemon status by calling check_status()."""
|
|
209
|
+
with self._cache_lock:
|
|
210
|
+
(
|
|
211
|
+
self._cached_is_ready,
|
|
212
|
+
self._cached_message,
|
|
213
|
+
self._cached_status,
|
|
214
|
+
self._cached_error,
|
|
215
|
+
) = self.check_status()
|
|
216
|
+
|
|
217
|
+
self.logger.debug(
|
|
218
|
+
f"Daemon status updated: {self.DAEMON_STATUS_TEXT.get(self._cached_status, 'Unknown')}"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def get_cached_status(self) -> tuple[bool | None, str | None, str | None, str | None]:
|
|
222
|
+
"""
|
|
223
|
+
Get cached daemon status without making HTTP calls.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
Tuple of (is_ready, message, status, error_reason)
|
|
227
|
+
Values may be None if status hasn't been checked yet.
|
|
228
|
+
"""
|
|
229
|
+
with self._cache_lock:
|
|
230
|
+
return (
|
|
231
|
+
self._cached_is_ready,
|
|
232
|
+
self._cached_message,
|
|
233
|
+
self._cached_status,
|
|
234
|
+
self._cached_error,
|
|
235
|
+
)
|
gobby/utils/git.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git metadata extraction utilities for Gobby Client.
|
|
3
|
+
|
|
4
|
+
Provides functions to extract git repository information including:
|
|
5
|
+
- Repository remote URL
|
|
6
|
+
- Current branch name
|
|
7
|
+
|
|
8
|
+
Handles git worktrees, detached HEAD, and missing remotes gracefully.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import subprocess # nosec B404 - subprocess needed for git commands
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TypedDict
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GitMetadata(TypedDict, total=False):
|
|
20
|
+
"""Git repository metadata structure."""
|
|
21
|
+
|
|
22
|
+
github_url: str | None
|
|
23
|
+
git_branch: str | None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run_git_command(command: list[str], cwd: str | Path, timeout: int = 5) -> str | None:
|
|
27
|
+
"""
|
|
28
|
+
Execute a git command safely with timeout protection.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
command: Git command as list of strings (e.g., ["git", "branch", "--show-current"])
|
|
32
|
+
cwd: Working directory where git command should run
|
|
33
|
+
timeout: Command timeout in seconds (default: 5)
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Command output as string (stripped), or None if command fails
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
result = subprocess.run( # nosec B603 - command passed from internal callers with hardcoded git commands
|
|
40
|
+
command,
|
|
41
|
+
cwd=cwd,
|
|
42
|
+
capture_output=True,
|
|
43
|
+
text=True,
|
|
44
|
+
timeout=timeout,
|
|
45
|
+
check=False, # Don't raise on non-zero exit
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if result.returncode == 0:
|
|
49
|
+
return result.stdout.strip()
|
|
50
|
+
|
|
51
|
+
logger.debug(f"Git command failed: {' '.join(command)}, stderr: {result.stderr.strip()}")
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
except subprocess.TimeoutExpired:
|
|
55
|
+
logger.warning(f"Git command timed out after {timeout}s: {' '.join(command)}")
|
|
56
|
+
return None
|
|
57
|
+
except FileNotFoundError:
|
|
58
|
+
logger.warning("Git executable not found in PATH")
|
|
59
|
+
return None
|
|
60
|
+
except Exception as e:
|
|
61
|
+
logger.error(f"Git command error: {' '.join(command)}, error: {e}")
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_github_url(cwd: str | Path) -> str | None:
|
|
66
|
+
"""
|
|
67
|
+
Extract git repository URL from origin remote.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
cwd: Working directory (git repository path)
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Remote URL string, or None if not available
|
|
74
|
+
"""
|
|
75
|
+
# Try to get origin remote URL
|
|
76
|
+
url = run_git_command(["git", "remote", "get-url", "origin"], cwd)
|
|
77
|
+
|
|
78
|
+
if url:
|
|
79
|
+
# Sanitize URL (remove auth tokens, convert SSH to HTTPS for privacy)
|
|
80
|
+
# Keep original format for now - can sanitize later if needed
|
|
81
|
+
return url
|
|
82
|
+
|
|
83
|
+
# If origin doesn't exist, try to list all remotes and use first one
|
|
84
|
+
remotes = run_git_command(["git", "remote"], cwd)
|
|
85
|
+
if remotes:
|
|
86
|
+
remote_names = remotes.split("\n")
|
|
87
|
+
if remote_names:
|
|
88
|
+
first_remote = remote_names[0]
|
|
89
|
+
url = run_git_command(["git", "remote", "get-url", first_remote], cwd)
|
|
90
|
+
if url:
|
|
91
|
+
logger.debug(f"Using remote '{first_remote}' (origin not found)")
|
|
92
|
+
return url
|
|
93
|
+
|
|
94
|
+
logger.debug("No git remotes found")
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_git_branch(cwd: str | Path) -> str | None:
|
|
99
|
+
"""
|
|
100
|
+
Get current git branch name.
|
|
101
|
+
|
|
102
|
+
Handles detached HEAD state gracefully.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
cwd: Working directory (git repository path)
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Branch name string, or None if detached HEAD or error
|
|
109
|
+
"""
|
|
110
|
+
branch = run_git_command(["git", "branch", "--show-current"], cwd)
|
|
111
|
+
|
|
112
|
+
if branch:
|
|
113
|
+
return branch
|
|
114
|
+
|
|
115
|
+
# Check if we're in detached HEAD state
|
|
116
|
+
symbolic_ref = run_git_command(["git", "symbolic-ref", "-q", "HEAD"], cwd)
|
|
117
|
+
if symbolic_ref is None:
|
|
118
|
+
logger.debug("Git repository in detached HEAD state")
|
|
119
|
+
return None # Detached HEAD
|
|
120
|
+
|
|
121
|
+
logger.debug("Unable to determine current git branch")
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def get_git_metadata(cwd: str | Path | None = None) -> GitMetadata:
|
|
126
|
+
"""
|
|
127
|
+
Extract comprehensive git repository metadata.
|
|
128
|
+
|
|
129
|
+
Extracts:
|
|
130
|
+
- github_url: Remote repository URL (from origin or first remote)
|
|
131
|
+
- git_branch: Current branch name (None if detached HEAD)
|
|
132
|
+
|
|
133
|
+
Handles errors gracefully and works with git worktrees.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
cwd: Working directory to check. Defaults to current directory.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
GitMetadata dict with available information.
|
|
140
|
+
All fields are optional and will be None if unavailable.
|
|
141
|
+
|
|
142
|
+
Example:
|
|
143
|
+
>>> metadata = get_git_metadata("/path/to/repo")
|
|
144
|
+
>>> metadata["github_url"]
|
|
145
|
+
'https://github.com/user/repo.git'
|
|
146
|
+
>>> metadata["git_branch"]
|
|
147
|
+
'main'
|
|
148
|
+
"""
|
|
149
|
+
if cwd is None:
|
|
150
|
+
cwd = Path.cwd()
|
|
151
|
+
else:
|
|
152
|
+
cwd = Path(cwd)
|
|
153
|
+
|
|
154
|
+
# Verify path exists
|
|
155
|
+
if not cwd.exists():
|
|
156
|
+
logger.warning(f"Path does not exist: {cwd}")
|
|
157
|
+
return GitMetadata()
|
|
158
|
+
|
|
159
|
+
# Check if directory is in a git repository
|
|
160
|
+
is_git_repo = run_git_command(["git", "rev-parse", "--git-dir"], cwd)
|
|
161
|
+
if not is_git_repo:
|
|
162
|
+
logger.debug(f"Not a git repository: {cwd}")
|
|
163
|
+
return GitMetadata()
|
|
164
|
+
|
|
165
|
+
# Extract metadata
|
|
166
|
+
metadata = GitMetadata()
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
metadata["github_url"] = get_github_url(cwd)
|
|
170
|
+
metadata["git_branch"] = get_git_branch(cwd)
|
|
171
|
+
|
|
172
|
+
logger.debug(
|
|
173
|
+
f"Git metadata extracted: repo={metadata.get('github_url')}, "
|
|
174
|
+
f"branch={metadata.get('git_branch')}"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
except Exception as e:
|
|
178
|
+
logger.error(f"Error extracting git metadata: {e}")
|
|
179
|
+
|
|
180
|
+
return metadata
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def normalize_commit_sha(sha: str, cwd: str | Path | None = None) -> str | None:
|
|
184
|
+
"""
|
|
185
|
+
Normalize a commit SHA to dynamic short format.
|
|
186
|
+
|
|
187
|
+
Uses git rev-parse --short which returns the minimum characters
|
|
188
|
+
needed for uniqueness (typically 7, more in large repos).
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
sha: Short or full commit SHA
|
|
192
|
+
cwd: Working directory for git commands (defaults to current directory)
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Shortened SHA (7+ chars), or None if SHA cannot be resolved
|
|
196
|
+
"""
|
|
197
|
+
if not sha or len(sha) < 4:
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
if cwd is None:
|
|
201
|
+
cwd = Path.cwd()
|
|
202
|
+
|
|
203
|
+
# Use git rev-parse --short to get canonical short form
|
|
204
|
+
result = run_git_command(["git", "rev-parse", "--short", sha], cwd=cwd)
|
|
205
|
+
return result if result else None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def is_valid_sha_format(sha: str) -> bool:
|
|
209
|
+
"""
|
|
210
|
+
Check if string looks like a valid SHA format (hex, >= 4 chars).
|
|
211
|
+
|
|
212
|
+
This is a format check only - does not verify the SHA exists in any repo.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
sha: String to validate
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
True if string could be a valid SHA format
|
|
219
|
+
"""
|
|
220
|
+
if not sha or len(sha) < 4:
|
|
221
|
+
return False
|
|
222
|
+
return all(c in "0123456789abcdefABCDEF" for c in sha)
|
gobby/utils/id.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""ID generation utilities."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def generate_prefixed_id(prefix: str, content: str | None = None, length: int = 8) -> str:
|
|
8
|
+
"""
|
|
9
|
+
Generate a prefixed ID (e.g., 'mm-a1b2c3d4').
|
|
10
|
+
|
|
11
|
+
If content is provided, the ID is deterministic based on the content hash.
|
|
12
|
+
If content is None, a random UUID is used.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
prefix: The prefix for the ID (e.g., 'mm', 'sk')
|
|
16
|
+
content: Optional content to hash for deterministic IDs
|
|
17
|
+
length: Length of the hash part (default: 8)
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Formatted ID string
|
|
21
|
+
Raises:
|
|
22
|
+
ValueError: If prefix is empty or length is invalid
|
|
23
|
+
"""
|
|
24
|
+
if not prefix:
|
|
25
|
+
raise ValueError("prefix cannot be empty")
|
|
26
|
+
if length <= 0:
|
|
27
|
+
raise ValueError("length must be positive")
|
|
28
|
+
if length > 64: # SHA-256 produces 64 hex characters
|
|
29
|
+
raise ValueError("length cannot exceed 64")
|
|
30
|
+
|
|
31
|
+
if content is not None:
|
|
32
|
+
hash_obj = hashlib.sha256(content.encode("utf-8"))
|
|
33
|
+
hash_hex = hash_obj.hexdigest()[:length]
|
|
34
|
+
else:
|
|
35
|
+
# Use random UUID if no content provided
|
|
36
|
+
hash_hex = uuid.uuid4().hex[:length]
|
|
37
|
+
|
|
38
|
+
return f"{prefix}-{hash_hex}"
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""JSON extraction utilities for parsing LLM responses.
|
|
2
|
+
|
|
3
|
+
This module provides robust JSON extraction from text that may contain
|
|
4
|
+
markdown code blocks, preamble text, or other non-JSON content.
|
|
5
|
+
|
|
6
|
+
Also provides typed JSON decoding using msgspec for structured LLM responses.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import msgspec
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def extract_json_from_text(text: str) -> str | None:
|
|
21
|
+
"""
|
|
22
|
+
Extract JSON from text, handling markdown code blocks and mixed content.
|
|
23
|
+
|
|
24
|
+
Uses json.JSONDecoder.raw_decode() which properly handles all JSON
|
|
25
|
+
edge cases (nested strings, escapes, backticks in strings, etc.)
|
|
26
|
+
rather than brittle regex patterns.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
text: Raw text that may contain JSON, possibly wrapped in markdown
|
|
30
|
+
code blocks or with preamble/postamble text.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Extracted JSON string, or None if no valid JSON found.
|
|
34
|
+
|
|
35
|
+
Examples:
|
|
36
|
+
>>> extract_json_from_text('{"key": "value"}')
|
|
37
|
+
'{"key": "value"}'
|
|
38
|
+
|
|
39
|
+
>>> extract_json_from_text('Here is the result:\\n```json\\n{"key": "value"}\\n```')
|
|
40
|
+
'{"key": "value"}'
|
|
41
|
+
|
|
42
|
+
>>> extract_json_from_text('No JSON here')
|
|
43
|
+
None
|
|
44
|
+
"""
|
|
45
|
+
if not text:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
decoder = json.JSONDecoder()
|
|
49
|
+
|
|
50
|
+
# Build list of positions to try, prioritizing code block content
|
|
51
|
+
positions_to_try: list[int] = []
|
|
52
|
+
|
|
53
|
+
# Look for ```json marker first (most specific)
|
|
54
|
+
code_block_idx = text.find("```json")
|
|
55
|
+
if code_block_idx != -1:
|
|
56
|
+
brace_pos = text.find("{", code_block_idx + 7)
|
|
57
|
+
if brace_pos != -1:
|
|
58
|
+
positions_to_try.append(brace_pos)
|
|
59
|
+
|
|
60
|
+
# Then try plain ``` marker
|
|
61
|
+
if not positions_to_try:
|
|
62
|
+
code_block_idx = text.find("```")
|
|
63
|
+
if code_block_idx != -1:
|
|
64
|
+
brace_pos = text.find("{", code_block_idx + 3)
|
|
65
|
+
if brace_pos != -1:
|
|
66
|
+
positions_to_try.append(brace_pos)
|
|
67
|
+
|
|
68
|
+
# Finally try raw JSON (first { in text)
|
|
69
|
+
first_brace = text.find("{")
|
|
70
|
+
if first_brace != -1 and first_brace not in positions_to_try:
|
|
71
|
+
positions_to_try.append(first_brace)
|
|
72
|
+
|
|
73
|
+
# Try each position until we find valid JSON
|
|
74
|
+
for pos in positions_to_try:
|
|
75
|
+
try:
|
|
76
|
+
# raw_decode returns (obj, end_idx) where end_idx is absolute position
|
|
77
|
+
_, end_idx = decoder.raw_decode(text, pos)
|
|
78
|
+
return text[pos:end_idx]
|
|
79
|
+
except json.JSONDecodeError:
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def extract_json_object(text: str) -> dict[str, Any] | None:
|
|
86
|
+
"""
|
|
87
|
+
Extract and parse a JSON object from text.
|
|
88
|
+
|
|
89
|
+
Convenience wrapper that extracts JSON string and parses it.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
text: Raw text that may contain JSON.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Parsed JSON dict, or None if no valid JSON found.
|
|
96
|
+
"""
|
|
97
|
+
json_str = extract_json_from_text(text)
|
|
98
|
+
if json_str is None:
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
result = json.loads(json_str)
|
|
103
|
+
if isinstance(result, dict):
|
|
104
|
+
return result
|
|
105
|
+
logger.warning(f"Extracted JSON is not an object: {type(result)}")
|
|
106
|
+
return None
|
|
107
|
+
except json.JSONDecodeError as e:
|
|
108
|
+
logger.warning(f"Failed to parse extracted JSON: {e}")
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def decode_llm_response[T](
|
|
113
|
+
text: str,
|
|
114
|
+
response_type: type[T],
|
|
115
|
+
*,
|
|
116
|
+
strict: bool = True,
|
|
117
|
+
) -> T | None:
|
|
118
|
+
"""
|
|
119
|
+
Extract JSON from LLM response and decode to a typed struct.
|
|
120
|
+
|
|
121
|
+
Uses msgspec for efficient, type-safe JSON decoding with clear error messages.
|
|
122
|
+
Combines extract_json_from_text() with msgspec.json.decode().
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
text: Raw LLM response text (may contain markdown code blocks, preamble, etc.)
|
|
126
|
+
response_type: The msgspec.Struct or other type to decode to
|
|
127
|
+
strict: If True (default), type mismatches raise errors.
|
|
128
|
+
If False, allows coercion (e.g., "5" -> 5 for int fields).
|
|
129
|
+
Configure via llm_providers.json_strict in config.yaml,
|
|
130
|
+
or override per-workflow with llm_json_strict variable.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Decoded response of type T, or None if extraction/decoding fails.
|
|
134
|
+
|
|
135
|
+
Examples:
|
|
136
|
+
>>> class TaskResult(msgspec.Struct):
|
|
137
|
+
... status: str
|
|
138
|
+
... count: int
|
|
139
|
+
>>> result = decode_llm_response('{"status": "ok", "count": 5}', TaskResult)
|
|
140
|
+
>>> result.status
|
|
141
|
+
'ok'
|
|
142
|
+
|
|
143
|
+
>>> # With strict=False, string "5" coerces to int 5
|
|
144
|
+
>>> result = decode_llm_response('{"status": "ok", "count": "5"}', TaskResult, strict=False)
|
|
145
|
+
>>> result.count
|
|
146
|
+
5
|
|
147
|
+
"""
|
|
148
|
+
json_str = extract_json_from_text(text)
|
|
149
|
+
if json_str is None:
|
|
150
|
+
logger.debug("No JSON found in LLM response")
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
# msgspec.json.decode returns Any at runtime when using TypeVar
|
|
155
|
+
return msgspec.json.decode(json_str.encode(), type=response_type, strict=strict)
|
|
156
|
+
except msgspec.ValidationError as e:
|
|
157
|
+
logger.warning(f"Invalid LLM response structure: {e}")
|
|
158
|
+
return None
|
|
159
|
+
except msgspec.DecodeError as e:
|
|
160
|
+
logger.warning(f"Failed to decode LLM response JSON: {e}")
|
|
161
|
+
return None
|