agentpool 2.1.9__py3-none-any.whl → 2.5.0__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.
- acp/__init__.py +13 -4
- acp/acp_requests.py +20 -77
- acp/agent/connection.py +8 -0
- acp/agent/implementations/debug_server/debug_server.py +6 -2
- acp/agent/protocol.py +6 -0
- acp/bridge/README.md +15 -2
- acp/bridge/__init__.py +3 -2
- acp/bridge/__main__.py +60 -19
- acp/bridge/ws_server.py +173 -0
- acp/bridge/ws_server_cli.py +89 -0
- acp/client/connection.py +38 -29
- acp/client/implementations/default_client.py +3 -2
- acp/client/implementations/headless_client.py +2 -2
- acp/connection.py +2 -2
- acp/notifications.py +20 -50
- acp/schema/__init__.py +2 -0
- acp/schema/agent_responses.py +21 -0
- acp/schema/client_requests.py +3 -3
- acp/schema/session_state.py +63 -29
- acp/stdio.py +39 -9
- acp/task/supervisor.py +2 -2
- acp/transports.py +362 -2
- acp/utils.py +17 -4
- agentpool/__init__.py +6 -1
- agentpool/agents/__init__.py +2 -0
- agentpool/agents/acp_agent/acp_agent.py +407 -277
- agentpool/agents/acp_agent/acp_converters.py +196 -38
- agentpool/agents/acp_agent/client_handler.py +191 -26
- agentpool/agents/acp_agent/session_state.py +17 -6
- agentpool/agents/agent.py +607 -572
- agentpool/agents/agui_agent/__init__.py +0 -2
- agentpool/agents/agui_agent/agui_agent.py +176 -110
- agentpool/agents/agui_agent/agui_converters.py +0 -131
- agentpool/agents/agui_agent/helpers.py +3 -4
- agentpool/agents/base_agent.py +632 -17
- agentpool/agents/claude_code_agent/FORKING.md +191 -0
- agentpool/agents/claude_code_agent/__init__.py +13 -1
- agentpool/agents/claude_code_agent/claude_code_agent.py +1058 -291
- agentpool/agents/claude_code_agent/converters.py +74 -143
- agentpool/agents/claude_code_agent/history.py +474 -0
- agentpool/agents/claude_code_agent/models.py +77 -0
- agentpool/agents/claude_code_agent/static_info.py +100 -0
- agentpool/agents/claude_code_agent/usage.py +242 -0
- agentpool/agents/context.py +40 -0
- agentpool/agents/events/__init__.py +24 -0
- agentpool/agents/events/builtin_handlers.py +67 -1
- agentpool/agents/events/event_emitter.py +32 -2
- agentpool/agents/events/events.py +104 -3
- agentpool/agents/events/infer_info.py +145 -0
- agentpool/agents/events/processors.py +254 -0
- agentpool/agents/interactions.py +41 -6
- agentpool/agents/modes.py +67 -0
- agentpool/agents/slashed_agent.py +5 -4
- agentpool/agents/tool_call_accumulator.py +213 -0
- agentpool/agents/tool_wrapping.py +18 -6
- agentpool/common_types.py +56 -21
- agentpool/config_resources/__init__.py +38 -1
- agentpool/config_resources/acp_assistant.yml +2 -2
- agentpool/config_resources/agents.yml +3 -0
- agentpool/config_resources/agents_template.yml +1 -0
- agentpool/config_resources/claude_code_agent.yml +10 -6
- agentpool/config_resources/external_acp_agents.yml +2 -1
- agentpool/delegation/base_team.py +4 -30
- agentpool/delegation/pool.py +136 -289
- agentpool/delegation/team.py +58 -57
- agentpool/delegation/teamrun.py +51 -55
- agentpool/diagnostics/__init__.py +53 -0
- agentpool/diagnostics/lsp_manager.py +1593 -0
- agentpool/diagnostics/lsp_proxy.py +41 -0
- agentpool/diagnostics/lsp_proxy_script.py +229 -0
- agentpool/diagnostics/models.py +398 -0
- agentpool/functional/run.py +10 -4
- agentpool/mcp_server/__init__.py +0 -2
- agentpool/mcp_server/client.py +76 -32
- agentpool/mcp_server/conversions.py +54 -13
- agentpool/mcp_server/manager.py +34 -54
- agentpool/mcp_server/registries/official_registry_client.py +35 -1
- agentpool/mcp_server/tool_bridge.py +186 -139
- agentpool/messaging/__init__.py +0 -2
- agentpool/messaging/compaction.py +72 -197
- agentpool/messaging/connection_manager.py +11 -10
- agentpool/messaging/event_manager.py +5 -5
- agentpool/messaging/message_container.py +6 -30
- agentpool/messaging/message_history.py +99 -8
- agentpool/messaging/messagenode.py +52 -14
- agentpool/messaging/messages.py +54 -35
- agentpool/messaging/processing.py +12 -22
- agentpool/models/__init__.py +1 -1
- agentpool/models/acp_agents/base.py +6 -24
- agentpool/models/acp_agents/mcp_capable.py +126 -157
- agentpool/models/acp_agents/non_mcp.py +129 -95
- agentpool/models/agents.py +98 -76
- agentpool/models/agui_agents.py +1 -1
- agentpool/models/claude_code_agents.py +144 -19
- agentpool/models/file_parsing.py +0 -1
- agentpool/models/manifest.py +113 -50
- agentpool/prompts/conversion_manager.py +1 -1
- agentpool/prompts/prompts.py +5 -2
- agentpool/repomap.py +1 -1
- agentpool/resource_providers/__init__.py +11 -1
- agentpool/resource_providers/aggregating.py +56 -5
- agentpool/resource_providers/base.py +70 -4
- agentpool/resource_providers/codemode/code_executor.py +72 -5
- agentpool/resource_providers/codemode/helpers.py +2 -2
- agentpool/resource_providers/codemode/provider.py +64 -12
- agentpool/resource_providers/codemode/remote_mcp_execution.py +2 -2
- agentpool/resource_providers/codemode/remote_provider.py +9 -12
- agentpool/resource_providers/filtering.py +3 -1
- agentpool/resource_providers/mcp_provider.py +89 -12
- agentpool/resource_providers/plan_provider.py +228 -46
- agentpool/resource_providers/pool.py +7 -3
- agentpool/resource_providers/resource_info.py +111 -0
- agentpool/resource_providers/static.py +4 -2
- agentpool/sessions/__init__.py +4 -1
- agentpool/sessions/manager.py +33 -5
- agentpool/sessions/models.py +59 -6
- agentpool/sessions/protocol.py +28 -0
- agentpool/sessions/session.py +11 -55
- agentpool/skills/registry.py +13 -8
- agentpool/storage/manager.py +572 -49
- agentpool/talk/registry.py +4 -4
- agentpool/talk/talk.py +9 -10
- agentpool/testing.py +538 -20
- agentpool/tool_impls/__init__.py +6 -0
- agentpool/tool_impls/agent_cli/__init__.py +42 -0
- agentpool/tool_impls/agent_cli/tool.py +95 -0
- agentpool/tool_impls/bash/__init__.py +64 -0
- agentpool/tool_impls/bash/helpers.py +35 -0
- agentpool/tool_impls/bash/tool.py +171 -0
- agentpool/tool_impls/delete_path/__init__.py +70 -0
- agentpool/tool_impls/delete_path/tool.py +142 -0
- agentpool/tool_impls/download_file/__init__.py +80 -0
- agentpool/tool_impls/download_file/tool.py +183 -0
- agentpool/tool_impls/execute_code/__init__.py +55 -0
- agentpool/tool_impls/execute_code/tool.py +163 -0
- agentpool/tool_impls/grep/__init__.py +80 -0
- agentpool/tool_impls/grep/tool.py +200 -0
- agentpool/tool_impls/list_directory/__init__.py +73 -0
- agentpool/tool_impls/list_directory/tool.py +197 -0
- agentpool/tool_impls/question/__init__.py +42 -0
- agentpool/tool_impls/question/tool.py +127 -0
- agentpool/tool_impls/read/__init__.py +104 -0
- agentpool/tool_impls/read/tool.py +305 -0
- agentpool/tools/__init__.py +2 -1
- agentpool/tools/base.py +114 -34
- agentpool/tools/manager.py +57 -1
- agentpool/ui/base.py +2 -2
- agentpool/ui/mock_provider.py +2 -2
- agentpool/ui/stdlib_provider.py +2 -2
- agentpool/utils/file_watcher.py +269 -0
- agentpool/utils/identifiers.py +121 -0
- agentpool/utils/pydantic_ai_helpers.py +46 -0
- agentpool/utils/streams.py +616 -2
- agentpool/utils/subprocess_utils.py +155 -0
- agentpool/utils/token_breakdown.py +461 -0
- agentpool/vfs_registry.py +7 -2
- {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/METADATA +41 -27
- agentpool-2.5.0.dist-info/RECORD +579 -0
- {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/WHEEL +1 -1
- agentpool_cli/__main__.py +24 -0
- agentpool_cli/create.py +1 -1
- agentpool_cli/serve_acp.py +100 -21
- agentpool_cli/serve_agui.py +87 -0
- agentpool_cli/serve_opencode.py +119 -0
- agentpool_cli/ui.py +557 -0
- agentpool_commands/__init__.py +42 -5
- agentpool_commands/agents.py +75 -2
- agentpool_commands/history.py +62 -0
- agentpool_commands/mcp.py +176 -0
- agentpool_commands/models.py +56 -3
- agentpool_commands/pool.py +260 -0
- agentpool_commands/session.py +1 -1
- agentpool_commands/text_sharing/__init__.py +119 -0
- agentpool_commands/text_sharing/base.py +123 -0
- agentpool_commands/text_sharing/github_gist.py +80 -0
- agentpool_commands/text_sharing/opencode.py +462 -0
- agentpool_commands/text_sharing/paste_rs.py +59 -0
- agentpool_commands/text_sharing/pastebin.py +116 -0
- agentpool_commands/text_sharing/shittycodingagent.py +112 -0
- agentpool_commands/tools.py +57 -0
- agentpool_commands/utils.py +80 -30
- agentpool_config/__init__.py +30 -2
- agentpool_config/agentpool_tools.py +498 -0
- agentpool_config/builtin_tools.py +77 -22
- agentpool_config/commands.py +24 -1
- agentpool_config/compaction.py +258 -0
- agentpool_config/converters.py +1 -1
- agentpool_config/event_handlers.py +42 -0
- agentpool_config/events.py +1 -1
- agentpool_config/forward_targets.py +1 -4
- agentpool_config/jinja.py +3 -3
- agentpool_config/mcp_server.py +132 -6
- agentpool_config/nodes.py +1 -1
- agentpool_config/observability.py +44 -0
- agentpool_config/session.py +0 -3
- agentpool_config/storage.py +82 -38
- agentpool_config/task.py +3 -3
- agentpool_config/tools.py +11 -22
- agentpool_config/toolsets.py +109 -233
- agentpool_server/a2a_server/agent_worker.py +307 -0
- agentpool_server/a2a_server/server.py +23 -18
- agentpool_server/acp_server/acp_agent.py +234 -181
- agentpool_server/acp_server/commands/acp_commands.py +151 -156
- agentpool_server/acp_server/commands/docs_commands/fetch_repo.py +18 -17
- agentpool_server/acp_server/event_converter.py +651 -0
- agentpool_server/acp_server/input_provider.py +53 -10
- agentpool_server/acp_server/server.py +24 -90
- agentpool_server/acp_server/session.py +173 -331
- agentpool_server/acp_server/session_manager.py +8 -34
- agentpool_server/agui_server/server.py +3 -1
- agentpool_server/mcp_server/server.py +5 -2
- agentpool_server/opencode_server/.rules +95 -0
- agentpool_server/opencode_server/ENDPOINTS.md +401 -0
- agentpool_server/opencode_server/OPENCODE_UI_TOOLS_COMPLETE.md +202 -0
- agentpool_server/opencode_server/__init__.py +19 -0
- agentpool_server/opencode_server/command_validation.py +172 -0
- agentpool_server/opencode_server/converters.py +975 -0
- agentpool_server/opencode_server/dependencies.py +24 -0
- agentpool_server/opencode_server/input_provider.py +421 -0
- agentpool_server/opencode_server/models/__init__.py +250 -0
- agentpool_server/opencode_server/models/agent.py +53 -0
- agentpool_server/opencode_server/models/app.py +72 -0
- agentpool_server/opencode_server/models/base.py +26 -0
- agentpool_server/opencode_server/models/common.py +23 -0
- agentpool_server/opencode_server/models/config.py +37 -0
- agentpool_server/opencode_server/models/events.py +821 -0
- agentpool_server/opencode_server/models/file.py +88 -0
- agentpool_server/opencode_server/models/mcp.py +44 -0
- agentpool_server/opencode_server/models/message.py +179 -0
- agentpool_server/opencode_server/models/parts.py +323 -0
- agentpool_server/opencode_server/models/provider.py +81 -0
- agentpool_server/opencode_server/models/pty.py +43 -0
- agentpool_server/opencode_server/models/question.py +56 -0
- agentpool_server/opencode_server/models/session.py +111 -0
- agentpool_server/opencode_server/routes/__init__.py +29 -0
- agentpool_server/opencode_server/routes/agent_routes.py +473 -0
- agentpool_server/opencode_server/routes/app_routes.py +202 -0
- agentpool_server/opencode_server/routes/config_routes.py +302 -0
- agentpool_server/opencode_server/routes/file_routes.py +571 -0
- agentpool_server/opencode_server/routes/global_routes.py +94 -0
- agentpool_server/opencode_server/routes/lsp_routes.py +319 -0
- agentpool_server/opencode_server/routes/message_routes.py +761 -0
- agentpool_server/opencode_server/routes/permission_routes.py +63 -0
- agentpool_server/opencode_server/routes/pty_routes.py +300 -0
- agentpool_server/opencode_server/routes/question_routes.py +128 -0
- agentpool_server/opencode_server/routes/session_routes.py +1276 -0
- agentpool_server/opencode_server/routes/tui_routes.py +139 -0
- agentpool_server/opencode_server/server.py +475 -0
- agentpool_server/opencode_server/state.py +151 -0
- agentpool_server/opencode_server/time_utils.py +8 -0
- agentpool_storage/__init__.py +12 -0
- agentpool_storage/base.py +184 -2
- agentpool_storage/claude_provider/ARCHITECTURE.md +433 -0
- agentpool_storage/claude_provider/__init__.py +42 -0
- agentpool_storage/claude_provider/provider.py +1089 -0
- agentpool_storage/file_provider.py +278 -15
- agentpool_storage/memory_provider.py +193 -12
- agentpool_storage/models.py +3 -0
- agentpool_storage/opencode_provider/ARCHITECTURE.md +386 -0
- agentpool_storage/opencode_provider/__init__.py +16 -0
- agentpool_storage/opencode_provider/helpers.py +414 -0
- agentpool_storage/opencode_provider/provider.py +895 -0
- agentpool_storage/project_store.py +325 -0
- agentpool_storage/session_store.py +26 -6
- agentpool_storage/sql_provider/__init__.py +4 -2
- agentpool_storage/sql_provider/models.py +48 -0
- agentpool_storage/sql_provider/sql_provider.py +269 -3
- agentpool_storage/sql_provider/utils.py +12 -13
- agentpool_storage/zed_provider/__init__.py +16 -0
- agentpool_storage/zed_provider/helpers.py +281 -0
- agentpool_storage/zed_provider/models.py +130 -0
- agentpool_storage/zed_provider/provider.py +442 -0
- agentpool_storage/zed_provider.py +803 -0
- agentpool_toolsets/__init__.py +0 -2
- agentpool_toolsets/builtin/__init__.py +2 -12
- agentpool_toolsets/builtin/code.py +96 -57
- agentpool_toolsets/builtin/debug.py +118 -48
- agentpool_toolsets/builtin/execution_environment.py +115 -230
- agentpool_toolsets/builtin/file_edit/file_edit.py +115 -7
- agentpool_toolsets/builtin/skills.py +9 -4
- agentpool_toolsets/builtin/subagent_tools.py +64 -51
- agentpool_toolsets/builtin/workers.py +4 -2
- agentpool_toolsets/composio_toolset.py +2 -2
- agentpool_toolsets/entry_points.py +3 -1
- agentpool_toolsets/fsspec_toolset/__init__.py +13 -1
- agentpool_toolsets/fsspec_toolset/diagnostics.py +860 -73
- agentpool_toolsets/fsspec_toolset/grep.py +99 -7
- agentpool_toolsets/fsspec_toolset/helpers.py +3 -2
- agentpool_toolsets/fsspec_toolset/image_utils.py +161 -0
- agentpool_toolsets/fsspec_toolset/toolset.py +500 -95
- agentpool_toolsets/mcp_discovery/__init__.py +5 -0
- agentpool_toolsets/mcp_discovery/data/mcp_servers.parquet +0 -0
- agentpool_toolsets/mcp_discovery/toolset.py +511 -0
- agentpool_toolsets/mcp_run_toolset.py +87 -12
- agentpool_toolsets/notifications.py +33 -33
- agentpool_toolsets/openapi.py +3 -1
- agentpool_toolsets/search_toolset.py +3 -1
- agentpool-2.1.9.dist-info/RECORD +0 -474
- agentpool_config/resources.py +0 -33
- agentpool_server/acp_server/acp_tools.py +0 -43
- agentpool_server/acp_server/commands/spawn.py +0 -210
- agentpool_storage/text_log_provider.py +0 -275
- agentpool_toolsets/builtin/agent_management.py +0 -239
- agentpool_toolsets/builtin/chain.py +0 -288
- agentpool_toolsets/builtin/history.py +0 -36
- agentpool_toolsets/builtin/integration.py +0 -85
- agentpool_toolsets/builtin/tool_management.py +0 -90
- agentpool_toolsets/builtin/user_interaction.py +0 -52
- agentpool_toolsets/semantic_memory_toolset.py +0 -536
- {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/entry_points.txt +0 -0
- {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/licenses/LICENSE +0 -0
agentpool/storage/manager.py
CHANGED
|
@@ -3,36 +3,73 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
import os
|
|
6
8
|
from typing import TYPE_CHECKING, Any, Self
|
|
7
9
|
|
|
8
10
|
from anyenv import method_spawner
|
|
11
|
+
from anyenv.signals import Signal
|
|
12
|
+
from pydantic import BaseModel
|
|
9
13
|
from pydantic_ai import Agent
|
|
10
14
|
|
|
11
15
|
from agentpool.log import get_logger
|
|
16
|
+
from agentpool.messaging import ChatMessage
|
|
12
17
|
from agentpool.storage.serialization import serialize_messages
|
|
13
18
|
from agentpool.utils.tasks import TaskManager
|
|
19
|
+
from agentpool_config.session import SessionQuery
|
|
14
20
|
from agentpool_config.storage import (
|
|
21
|
+
ClaudeStorageConfig,
|
|
15
22
|
FileStorageConfig,
|
|
16
23
|
MemoryStorageConfig,
|
|
24
|
+
OpenCodeStorageConfig,
|
|
17
25
|
SQLStorageConfig,
|
|
18
|
-
|
|
26
|
+
ZedStorageConfig,
|
|
19
27
|
)
|
|
20
28
|
|
|
21
29
|
|
|
22
30
|
if TYPE_CHECKING:
|
|
23
|
-
from collections.abc import Sequence
|
|
31
|
+
from collections.abc import Callable, Sequence
|
|
24
32
|
from datetime import datetime
|
|
25
33
|
from types import TracebackType
|
|
26
34
|
|
|
27
35
|
from agentpool.common_types import JsonValue
|
|
28
|
-
from agentpool.
|
|
29
|
-
from agentpool_config.session import SessionQuery
|
|
36
|
+
from agentpool.sessions.models import ProjectData
|
|
30
37
|
from agentpool_config.storage import BaseStorageProviderConfig, StorageConfig
|
|
31
38
|
from agentpool_storage.base import StorageProvider
|
|
32
39
|
|
|
33
40
|
logger = get_logger(__name__)
|
|
34
41
|
|
|
35
42
|
|
|
43
|
+
class ConversationMetadata(BaseModel):
|
|
44
|
+
"""Generated metadata for a conversation."""
|
|
45
|
+
|
|
46
|
+
title: str
|
|
47
|
+
"""Short descriptive title (3-7 words)."""
|
|
48
|
+
|
|
49
|
+
emoji: str
|
|
50
|
+
"""Single emoji representing the topic."""
|
|
51
|
+
|
|
52
|
+
icon: str
|
|
53
|
+
"""Iconify icon name (e.g., 'mdi:code-braces')."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class TitleGeneratedEvent:
|
|
58
|
+
"""Event emitted when a conversation title is generated.
|
|
59
|
+
|
|
60
|
+
Attributes:
|
|
61
|
+
conversation_id: ID of the conversation
|
|
62
|
+
title: Generated title text
|
|
63
|
+
emoji: Generated emoji representing the topic
|
|
64
|
+
icon: Generated iconify icon name
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
conversation_id: str
|
|
68
|
+
title: str
|
|
69
|
+
emoji: str
|
|
70
|
+
icon: str
|
|
71
|
+
|
|
72
|
+
|
|
36
73
|
class StorageManager:
|
|
37
74
|
"""Manages multiple storage providers.
|
|
38
75
|
|
|
@@ -41,8 +78,19 @@ class StorageManager:
|
|
|
41
78
|
- Message distribution to providers
|
|
42
79
|
- History loading from capable providers
|
|
43
80
|
- Global logging filters
|
|
81
|
+
|
|
82
|
+
Signals:
|
|
83
|
+
- title_generated: Emitted when a conversation title is generated.
|
|
84
|
+
Subscribers receive TitleGeneratedEvent with conversation_id, title, emoji, icon.
|
|
85
|
+
|
|
86
|
+
Example:
|
|
87
|
+
manager.title_generated.connect(my_handler)
|
|
88
|
+
# Handler will be called with TitleGeneratedEvent when titles are generated
|
|
44
89
|
"""
|
|
45
90
|
|
|
91
|
+
# Signal emitted when a conversation title is generated
|
|
92
|
+
title_generated: Signal[TitleGeneratedEvent] = Signal()
|
|
93
|
+
|
|
46
94
|
def __init__(self, config: StorageConfig) -> None:
|
|
47
95
|
"""Initialize storage manager.
|
|
48
96
|
|
|
@@ -52,6 +100,7 @@ class StorageManager:
|
|
|
52
100
|
self.config = config
|
|
53
101
|
self.task_manager = TaskManager()
|
|
54
102
|
self.providers = [self._create_provider(cfg) for cfg in self.config.effective_providers]
|
|
103
|
+
self._conversation_logged: set[str] = set() # Track logged conversations for idempotency
|
|
55
104
|
|
|
56
105
|
async def __aenter__(self) -> Self:
|
|
57
106
|
"""Initialize all providers."""
|
|
@@ -123,15 +172,22 @@ class StorageManager:
|
|
|
123
172
|
from agentpool_storage.file_provider import FileProvider
|
|
124
173
|
|
|
125
174
|
return FileProvider(provider_config)
|
|
126
|
-
case TextLogConfig():
|
|
127
|
-
from agentpool_storage.text_log_provider import TextLogProvider
|
|
128
|
-
|
|
129
|
-
return TextLogProvider(provider_config)
|
|
130
|
-
|
|
131
175
|
case MemoryStorageConfig():
|
|
132
176
|
from agentpool_storage.memory_provider import MemoryStorageProvider
|
|
133
177
|
|
|
134
178
|
return MemoryStorageProvider(provider_config)
|
|
179
|
+
case ClaudeStorageConfig():
|
|
180
|
+
from agentpool_storage.claude_provider import ClaudeStorageProvider
|
|
181
|
+
|
|
182
|
+
return ClaudeStorageProvider(provider_config)
|
|
183
|
+
case OpenCodeStorageConfig():
|
|
184
|
+
from agentpool_storage.opencode_provider import OpenCodeStorageProvider
|
|
185
|
+
|
|
186
|
+
return OpenCodeStorageProvider(provider_config)
|
|
187
|
+
case ZedStorageConfig():
|
|
188
|
+
from agentpool_storage.zed_provider import ZedStorageProvider
|
|
189
|
+
|
|
190
|
+
return ZedStorageProvider(provider_config)
|
|
135
191
|
case _:
|
|
136
192
|
msg = f"Unknown provider type: {provider_config}"
|
|
137
193
|
raise ValueError(msg)
|
|
@@ -153,11 +209,7 @@ class StorageManager:
|
|
|
153
209
|
# Function to find capable provider by name
|
|
154
210
|
def find_provider(name: str) -> StorageProvider | None:
|
|
155
211
|
for p in self.providers:
|
|
156
|
-
if (
|
|
157
|
-
not getattr(p, "write_only", False)
|
|
158
|
-
and p.can_load_history
|
|
159
|
-
and p.__class__.__name__.lower() == name.lower()
|
|
160
|
-
):
|
|
212
|
+
if p.can_load_history and p.__class__.__name__.lower() == name.lower():
|
|
161
213
|
return p
|
|
162
214
|
return None
|
|
163
215
|
|
|
@@ -174,7 +226,7 @@ class StorageManager:
|
|
|
174
226
|
|
|
175
227
|
# Find first capable provider
|
|
176
228
|
for provider in self.providers:
|
|
177
|
-
if
|
|
229
|
+
if provider.can_load_history:
|
|
178
230
|
return provider
|
|
179
231
|
|
|
180
232
|
msg = "No capable provider found for loading history"
|
|
@@ -209,10 +261,10 @@ class StorageManager:
|
|
|
209
261
|
content=str(message.content),
|
|
210
262
|
role=message.role,
|
|
211
263
|
name=message.name,
|
|
264
|
+
parent_id=message.parent_id,
|
|
212
265
|
cost_info=message.cost_info,
|
|
213
266
|
model=message.model_name,
|
|
214
267
|
response_time=message.response_time,
|
|
215
|
-
forwarded_from=message.forwarded_from,
|
|
216
268
|
provider_name=message.provider_name,
|
|
217
269
|
provider_response_id=message.provider_response_id,
|
|
218
270
|
messages=serialize_messages(message.messages),
|
|
@@ -226,16 +278,68 @@ class StorageManager:
|
|
|
226
278
|
conversation_id: str,
|
|
227
279
|
node_name: str,
|
|
228
280
|
start_time: datetime | None = None,
|
|
281
|
+
initial_prompt: str | None = None,
|
|
282
|
+
on_title_generated: Callable[[str], None] | None = None,
|
|
229
283
|
) -> None:
|
|
230
|
-
"""Log conversation to all providers.
|
|
284
|
+
"""Log conversation to all providers (idempotent).
|
|
285
|
+
|
|
286
|
+
If conversation was already logged, skips provider calls but still
|
|
287
|
+
triggers title generation if initial_prompt is provided.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
conversation_id: Unique conversation identifier
|
|
291
|
+
node_name: Name of the node/agent
|
|
292
|
+
start_time: Optional start time
|
|
293
|
+
initial_prompt: Optional initial prompt to trigger title generation
|
|
294
|
+
on_title_generated: Optional callback invoked when title is generated
|
|
295
|
+
"""
|
|
231
296
|
if not self.config.log_conversations:
|
|
232
297
|
return
|
|
233
298
|
|
|
234
|
-
|
|
235
|
-
|
|
299
|
+
# Check if already logged (idempotent behavior)
|
|
300
|
+
if conversation_id not in self._conversation_logged:
|
|
301
|
+
# Mark as logged before calling providers
|
|
302
|
+
self._conversation_logged.add(conversation_id)
|
|
303
|
+
|
|
304
|
+
# Log to all providers
|
|
305
|
+
for provider in self.providers:
|
|
306
|
+
await provider.log_conversation(
|
|
307
|
+
conversation_id=conversation_id,
|
|
308
|
+
node_name=node_name,
|
|
309
|
+
start_time=start_time,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# Handle title generation based on prompt length
|
|
313
|
+
# Skip during tests to avoid external API calls
|
|
314
|
+
if not initial_prompt or os.environ.get("PYTEST_CURRENT_TEST"):
|
|
315
|
+
return
|
|
316
|
+
prompt_length = len(initial_prompt)
|
|
317
|
+
logger.info(
|
|
318
|
+
"log_conversation title decision",
|
|
319
|
+
conversation_id=conversation_id,
|
|
320
|
+
prompt_length=prompt_length,
|
|
321
|
+
has_model=bool(self.config.title_generation_model),
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
# For short prompts, use them directly as title (like Claude Code)
|
|
325
|
+
if prompt_length < 60: # noqa: PLR2004
|
|
326
|
+
logger.info(
|
|
327
|
+
"Using short prompt directly as title",
|
|
328
|
+
conversation_id=conversation_id,
|
|
329
|
+
title=initial_prompt,
|
|
330
|
+
)
|
|
331
|
+
await self.update_conversation_title(conversation_id, initial_prompt)
|
|
332
|
+
# For longer prompts, generate semantic title if model configured
|
|
333
|
+
elif self.config.title_generation_model:
|
|
334
|
+
logger.info(
|
|
335
|
+
"Creating title generation task for long prompt",
|
|
236
336
|
conversation_id=conversation_id,
|
|
237
|
-
|
|
238
|
-
|
|
337
|
+
)
|
|
338
|
+
self.task_manager.create_task(
|
|
339
|
+
self._generate_title_from_prompt(
|
|
340
|
+
conversation_id, initial_prompt, on_title_generated
|
|
341
|
+
),
|
|
342
|
+
name=f"title_gen_{conversation_id[:8]}",
|
|
239
343
|
)
|
|
240
344
|
|
|
241
345
|
@method_spawner
|
|
@@ -363,57 +467,476 @@ class StorageManager:
|
|
|
363
467
|
provider = self.get_history_provider()
|
|
364
468
|
return await provider.get_conversation_title(conversation_id)
|
|
365
469
|
|
|
366
|
-
async def
|
|
470
|
+
async def get_conversation_titles(
|
|
471
|
+
self,
|
|
472
|
+
conversation_ids: list[str],
|
|
473
|
+
) -> dict[str, str | None]:
|
|
474
|
+
"""Get titles for multiple conversations.
|
|
475
|
+
|
|
476
|
+
Args:
|
|
477
|
+
conversation_ids: List of conversation IDs
|
|
478
|
+
|
|
479
|
+
Returns:
|
|
480
|
+
Dict mapping conversation_id to title (or None if not set)
|
|
481
|
+
"""
|
|
482
|
+
if not conversation_ids:
|
|
483
|
+
return {}
|
|
484
|
+
|
|
485
|
+
provider = self.get_history_provider()
|
|
486
|
+
titles: dict[str, str | None] = {}
|
|
487
|
+
for conv_id in conversation_ids:
|
|
488
|
+
try:
|
|
489
|
+
titles[conv_id] = await provider.get_conversation_title(conv_id)
|
|
490
|
+
except Exception: # noqa: BLE001
|
|
491
|
+
titles[conv_id] = None
|
|
492
|
+
return titles
|
|
493
|
+
|
|
494
|
+
async def get_message_counts(
|
|
495
|
+
self,
|
|
496
|
+
conversation_ids: list[str],
|
|
497
|
+
) -> dict[str, int]:
|
|
498
|
+
"""Get message counts for multiple conversations.
|
|
499
|
+
|
|
500
|
+
Args:
|
|
501
|
+
conversation_ids: List of conversation IDs
|
|
502
|
+
|
|
503
|
+
Returns:
|
|
504
|
+
Dict mapping conversation_id to message count
|
|
505
|
+
"""
|
|
506
|
+
if not conversation_ids:
|
|
507
|
+
return {}
|
|
508
|
+
|
|
509
|
+
counts: dict[str, int] = {}
|
|
510
|
+
for conv_id in conversation_ids:
|
|
511
|
+
try:
|
|
512
|
+
query = SessionQuery(name=conv_id)
|
|
513
|
+
messages = await self.filter_messages(query)
|
|
514
|
+
counts[conv_id] = len(messages) if messages else 0
|
|
515
|
+
except Exception: # noqa: BLE001
|
|
516
|
+
counts[conv_id] = 0
|
|
517
|
+
return counts
|
|
518
|
+
|
|
519
|
+
@method_spawner
|
|
520
|
+
async def get_conversation_messages(
|
|
367
521
|
self,
|
|
368
522
|
conversation_id: str,
|
|
369
|
-
|
|
523
|
+
*,
|
|
524
|
+
include_ancestors: bool = False,
|
|
525
|
+
) -> list[ChatMessage[str]]:
|
|
526
|
+
"""Get all messages for a conversation.
|
|
527
|
+
|
|
528
|
+
Args:
|
|
529
|
+
conversation_id: ID of the conversation
|
|
530
|
+
include_ancestors: If True, also include messages from ancestor
|
|
531
|
+
conversations by following the parent_id chain. Useful for
|
|
532
|
+
forked conversations.
|
|
533
|
+
|
|
534
|
+
Returns:
|
|
535
|
+
List of messages ordered by timestamp.
|
|
536
|
+
"""
|
|
537
|
+
provider = self.get_history_provider()
|
|
538
|
+
return await provider.get_conversation_messages(
|
|
539
|
+
conversation_id, include_ancestors=include_ancestors
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
@method_spawner
|
|
543
|
+
async def get_message(self, message_id: str) -> ChatMessage[str] | None:
|
|
544
|
+
"""Get a single message by ID.
|
|
545
|
+
|
|
546
|
+
Args:
|
|
547
|
+
message_id: ID of the message
|
|
548
|
+
|
|
549
|
+
Returns:
|
|
550
|
+
The message if found, None otherwise.
|
|
551
|
+
"""
|
|
552
|
+
provider = self.get_history_provider()
|
|
553
|
+
return await provider.get_message(message_id)
|
|
554
|
+
|
|
555
|
+
@method_spawner
|
|
556
|
+
async def get_message_ancestry(self, message_id: str) -> list[ChatMessage[str]]:
|
|
557
|
+
"""Get the ancestry chain of a message.
|
|
558
|
+
|
|
559
|
+
Traverses the parent_id chain to build full history leading to this message.
|
|
560
|
+
|
|
561
|
+
Args:
|
|
562
|
+
message_id: ID of the message
|
|
563
|
+
|
|
564
|
+
Returns:
|
|
565
|
+
List of messages from oldest ancestor to the specified message.
|
|
566
|
+
"""
|
|
567
|
+
provider = self.get_history_provider()
|
|
568
|
+
return await provider.get_message_ancestry(message_id)
|
|
569
|
+
|
|
570
|
+
@method_spawner
|
|
571
|
+
async def fork_conversation(
|
|
572
|
+
self,
|
|
573
|
+
*,
|
|
574
|
+
source_conversation_id: str,
|
|
575
|
+
new_conversation_id: str,
|
|
576
|
+
fork_from_message_id: str | None = None,
|
|
577
|
+
new_agent_name: str | None = None,
|
|
370
578
|
) -> str | None:
|
|
371
|
-
"""
|
|
579
|
+
"""Fork a conversation at a specific point.
|
|
372
580
|
|
|
373
|
-
|
|
374
|
-
|
|
581
|
+
Creates a new conversation that branches from the source. New messages
|
|
582
|
+
in the forked conversation should use the returned fork_point_id as
|
|
583
|
+
their parent_id to maintain the history chain.
|
|
584
|
+
|
|
585
|
+
Args:
|
|
586
|
+
source_conversation_id: ID of the conversation to fork from
|
|
587
|
+
new_conversation_id: ID for the new forked conversation
|
|
588
|
+
fork_from_message_id: Message ID to fork from. If None, forks from
|
|
589
|
+
the last message.
|
|
590
|
+
new_agent_name: Agent name for the new conversation.
|
|
591
|
+
|
|
592
|
+
Returns:
|
|
593
|
+
The message_id of the fork point (use as parent_id for new messages),
|
|
594
|
+
or None if the source conversation is empty.
|
|
595
|
+
"""
|
|
596
|
+
provider = self.get_history_provider()
|
|
597
|
+
return await provider.fork_conversation(
|
|
598
|
+
source_conversation_id=source_conversation_id,
|
|
599
|
+
new_conversation_id=new_conversation_id,
|
|
600
|
+
fork_from_message_id=fork_from_message_id,
|
|
601
|
+
new_agent_name=new_agent_name,
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
@method_spawner
|
|
605
|
+
async def delete_conversation_messages(
|
|
606
|
+
self,
|
|
607
|
+
conversation_id: str,
|
|
608
|
+
) -> int:
|
|
609
|
+
"""Delete all messages for a conversation in all providers.
|
|
610
|
+
|
|
611
|
+
Used for compaction - removes existing messages so they can be
|
|
612
|
+
replaced with compacted versions.
|
|
613
|
+
|
|
614
|
+
Args:
|
|
615
|
+
conversation_id: ID of the conversation to clear
|
|
616
|
+
|
|
617
|
+
Returns:
|
|
618
|
+
Total number of messages deleted across all providers
|
|
619
|
+
"""
|
|
620
|
+
total_deleted = 0
|
|
621
|
+
for provider in self.providers:
|
|
622
|
+
try:
|
|
623
|
+
deleted = await provider.delete_conversation_messages(conversation_id)
|
|
624
|
+
total_deleted += deleted
|
|
625
|
+
except NotImplementedError:
|
|
626
|
+
# Provider doesn't support deletion (e.g., write-only log)
|
|
627
|
+
pass
|
|
628
|
+
except Exception:
|
|
629
|
+
logger.exception(
|
|
630
|
+
"Error deleting messages from provider",
|
|
631
|
+
provider=provider.__class__.__name__,
|
|
632
|
+
conversation_id=conversation_id,
|
|
633
|
+
)
|
|
634
|
+
return total_deleted
|
|
635
|
+
|
|
636
|
+
@method_spawner
|
|
637
|
+
async def replace_conversation_messages(
|
|
638
|
+
self,
|
|
639
|
+
conversation_id: str,
|
|
640
|
+
messages: Sequence[ChatMessage[Any]],
|
|
641
|
+
) -> tuple[int, int]:
|
|
642
|
+
"""Replace all messages for a conversation with new ones.
|
|
643
|
+
|
|
644
|
+
Deletes existing messages and logs new ones. Used for compaction
|
|
645
|
+
where the full history is replaced with a compacted version.
|
|
646
|
+
|
|
647
|
+
Args:
|
|
648
|
+
conversation_id: ID of the conversation
|
|
649
|
+
messages: New messages to store
|
|
650
|
+
|
|
651
|
+
Returns:
|
|
652
|
+
Tuple of (deleted_count, added_count)
|
|
653
|
+
"""
|
|
654
|
+
# First delete existing messages
|
|
655
|
+
deleted = await self.delete_conversation_messages(conversation_id)
|
|
656
|
+
|
|
657
|
+
# Then log new messages
|
|
658
|
+
added = 0
|
|
659
|
+
for message in messages:
|
|
660
|
+
# Ensure conversation_id is set on the message
|
|
661
|
+
msg_to_log: ChatMessage[Any] = message
|
|
662
|
+
if not message.conversation_id:
|
|
663
|
+
msg_to_log = ChatMessage(
|
|
664
|
+
content=message.content,
|
|
665
|
+
role=message.role,
|
|
666
|
+
name=message.name,
|
|
667
|
+
conversation_id=conversation_id,
|
|
668
|
+
message_id=message.message_id,
|
|
669
|
+
parent_id=message.parent_id,
|
|
670
|
+
model_name=message.model_name,
|
|
671
|
+
cost_info=message.cost_info,
|
|
672
|
+
response_time=message.response_time,
|
|
673
|
+
timestamp=message.timestamp,
|
|
674
|
+
provider_name=message.provider_name,
|
|
675
|
+
provider_response_id=message.provider_response_id,
|
|
676
|
+
messages=message.messages,
|
|
677
|
+
finish_reason=message.finish_reason,
|
|
678
|
+
)
|
|
679
|
+
await self.log_message(msg_to_log)
|
|
680
|
+
added += 1
|
|
681
|
+
|
|
682
|
+
return deleted, added
|
|
683
|
+
|
|
684
|
+
async def _generate_title_core(
|
|
685
|
+
self,
|
|
686
|
+
conversation_id: str,
|
|
687
|
+
prompt_text: str,
|
|
688
|
+
) -> ConversationMetadata | None:
|
|
689
|
+
"""Core title generation logic using LLM with structured output.
|
|
375
690
|
|
|
376
691
|
Args:
|
|
377
692
|
conversation_id: ID of the conversation to title
|
|
378
|
-
|
|
693
|
+
prompt_text: Formatted prompt text to send to the LLM
|
|
379
694
|
|
|
380
695
|
Returns:
|
|
381
|
-
|
|
696
|
+
ConversationMetadata with title, emoji, and icon, or None if generation fails.
|
|
382
697
|
"""
|
|
698
|
+
logger.info("_generate_title_core called", conversation_id=conversation_id)
|
|
383
699
|
if not self.config.title_generation_model:
|
|
700
|
+
logger.info("No title_generation_model configured, skipping")
|
|
384
701
|
return None
|
|
385
702
|
|
|
386
|
-
# Check if title already exists
|
|
387
|
-
existing = await self.get_conversation_title(conversation_id)
|
|
388
|
-
if existing:
|
|
389
|
-
return existing
|
|
390
|
-
|
|
391
|
-
# Format messages for the prompt
|
|
392
|
-
formatted = "\n".join(
|
|
393
|
-
f"{msg.role}: {msg.content[:500]}"
|
|
394
|
-
for msg in messages[:4] # Limit context
|
|
395
|
-
)
|
|
396
|
-
|
|
397
703
|
try:
|
|
398
|
-
|
|
399
|
-
|
|
704
|
+
from llmling_models.models.helpers import infer_model
|
|
705
|
+
|
|
706
|
+
model = infer_model(self.config.title_generation_model)
|
|
707
|
+
agent: Agent[None, ConversationMetadata] = Agent(
|
|
708
|
+
model=model,
|
|
400
709
|
instructions=self.config.title_generation_prompt,
|
|
710
|
+
output_type=ConversationMetadata,
|
|
401
711
|
)
|
|
402
|
-
|
|
403
|
-
|
|
712
|
+
logger.debug("Title generation prompt", prompt_text=prompt_text)
|
|
713
|
+
result = await agent.run(prompt_text)
|
|
714
|
+
metadata = result.output
|
|
404
715
|
|
|
405
716
|
# Store the title
|
|
406
|
-
await self.update_conversation_title(conversation_id, title)
|
|
717
|
+
await self.update_conversation_title(conversation_id, metadata.title)
|
|
407
718
|
logger.debug(
|
|
408
|
-
"Generated conversation
|
|
719
|
+
"Generated conversation metadata",
|
|
409
720
|
conversation_id=conversation_id,
|
|
410
|
-
title=title,
|
|
721
|
+
title=metadata.title,
|
|
722
|
+
emoji=metadata.emoji,
|
|
723
|
+
icon=metadata.icon,
|
|
411
724
|
)
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
725
|
+
|
|
726
|
+
# Emit signal for subscribers (e.g., OpenCode UI updates)
|
|
727
|
+
event = TitleGeneratedEvent(
|
|
728
|
+
conversation_id=conversation_id,
|
|
729
|
+
title=metadata.title,
|
|
730
|
+
emoji=metadata.emoji,
|
|
731
|
+
icon=metadata.icon,
|
|
732
|
+
)
|
|
733
|
+
logger.info(
|
|
734
|
+
"Emitting title_generated signal",
|
|
415
735
|
conversation_id=conversation_id,
|
|
736
|
+
title=metadata.title,
|
|
416
737
|
)
|
|
738
|
+
await self.title_generated.emit(event)
|
|
739
|
+
except Exception:
|
|
740
|
+
logger.exception("Failed to generate session title", conversation_id=conversation_id)
|
|
417
741
|
return None
|
|
418
742
|
else:
|
|
743
|
+
return metadata
|
|
744
|
+
|
|
745
|
+
async def _generate_title_from_prompt(
|
|
746
|
+
self,
|
|
747
|
+
conversation_id: str,
|
|
748
|
+
prompt: str,
|
|
749
|
+
on_title_generated: Callable[[str], None] | None = None,
|
|
750
|
+
) -> str | None:
|
|
751
|
+
"""Generate title from initial prompt (internal, fire-and-forget).
|
|
752
|
+
|
|
753
|
+
Called automatically by log_conversation when initial_prompt is provided.
|
|
754
|
+
|
|
755
|
+
Args:
|
|
756
|
+
conversation_id: ID of the conversation to title
|
|
757
|
+
prompt: The initial user prompt
|
|
758
|
+
on_title_generated: Optional callback invoked with the generated title
|
|
759
|
+
|
|
760
|
+
Returns:
|
|
761
|
+
The generated title, or None if generation fails/disabled.
|
|
762
|
+
"""
|
|
763
|
+
# Check if title already exists
|
|
764
|
+
existing = await self.get_conversation_title(conversation_id)
|
|
765
|
+
if existing:
|
|
766
|
+
if on_title_generated:
|
|
767
|
+
on_title_generated(existing)
|
|
768
|
+
return existing
|
|
769
|
+
|
|
770
|
+
# Generate using core logic
|
|
771
|
+
metadata = await self._generate_title_core(
|
|
772
|
+
conversation_id,
|
|
773
|
+
f"user: {prompt[:500]}",
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
if metadata:
|
|
777
|
+
title = metadata.title
|
|
778
|
+
if on_title_generated:
|
|
779
|
+
on_title_generated(title)
|
|
419
780
|
return title
|
|
781
|
+
return None
|
|
782
|
+
|
|
783
|
+
async def generate_conversation_title(
|
|
784
|
+
self,
|
|
785
|
+
conversation_id: str,
|
|
786
|
+
messages: Sequence[ChatMessage[Any]],
|
|
787
|
+
) -> str | None:
|
|
788
|
+
"""Generate and store a title for a conversation.
|
|
789
|
+
|
|
790
|
+
Uses the configured title generation model to create a short,
|
|
791
|
+
descriptive title based on the conversation content.
|
|
792
|
+
|
|
793
|
+
Args:
|
|
794
|
+
conversation_id: ID of the conversation to title
|
|
795
|
+
messages: Messages to use for title generation
|
|
796
|
+
|
|
797
|
+
Returns:
|
|
798
|
+
The generated title, or None if title generation is disabled.
|
|
799
|
+
"""
|
|
800
|
+
# Check if title already exists
|
|
801
|
+
existing = await self.get_conversation_title(conversation_id)
|
|
802
|
+
if existing:
|
|
803
|
+
return existing
|
|
804
|
+
|
|
805
|
+
# Format messages for the prompt
|
|
806
|
+
formatted = "\n".join(f"{i.role}: {i.content[:500]}" for i in messages[:4])
|
|
807
|
+
|
|
808
|
+
# Generate using core logic
|
|
809
|
+
metadata = await self._generate_title_core(conversation_id, formatted)
|
|
810
|
+
|
|
811
|
+
return metadata.title if metadata else None
|
|
812
|
+
|
|
813
|
+
# Project methods
|
|
814
|
+
|
|
815
|
+
def get_project_provider(self) -> StorageProvider:
|
|
816
|
+
"""Get provider capable of storing projects.
|
|
817
|
+
|
|
818
|
+
Returns:
|
|
819
|
+
First provider that supports project storage.
|
|
820
|
+
|
|
821
|
+
Raises:
|
|
822
|
+
RuntimeError: If no capable provider found.
|
|
823
|
+
"""
|
|
824
|
+
if self.providers:
|
|
825
|
+
return self.providers[0]
|
|
826
|
+
msg = "No provider found that supports project storage"
|
|
827
|
+
raise RuntimeError(msg)
|
|
828
|
+
|
|
829
|
+
@method_spawner
|
|
830
|
+
async def save_project(self, project: ProjectData) -> None:
|
|
831
|
+
"""Save or update a project in all capable providers.
|
|
832
|
+
|
|
833
|
+
Args:
|
|
834
|
+
project: Project data to persist
|
|
835
|
+
"""
|
|
836
|
+
for provider in self.providers:
|
|
837
|
+
try:
|
|
838
|
+
await provider.save_project(project)
|
|
839
|
+
except NotImplementedError:
|
|
840
|
+
pass
|
|
841
|
+
except Exception:
|
|
842
|
+
logger.exception(
|
|
843
|
+
"Error saving project",
|
|
844
|
+
provider=provider.__class__.__name__,
|
|
845
|
+
project_id=project.project_id,
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
@method_spawner
|
|
849
|
+
async def get_project(self, project_id: str) -> ProjectData | None:
|
|
850
|
+
"""Get a project by ID.
|
|
851
|
+
|
|
852
|
+
Args:
|
|
853
|
+
project_id: Project identifier
|
|
854
|
+
|
|
855
|
+
Returns:
|
|
856
|
+
Project data if found, None otherwise
|
|
857
|
+
"""
|
|
858
|
+
provider = self.get_project_provider()
|
|
859
|
+
return await provider.get_project(project_id)
|
|
860
|
+
|
|
861
|
+
@method_spawner
|
|
862
|
+
async def get_project_by_worktree(self, worktree: str) -> ProjectData | None:
|
|
863
|
+
"""Get a project by worktree path.
|
|
864
|
+
|
|
865
|
+
Args:
|
|
866
|
+
worktree: Absolute path to the project worktree
|
|
867
|
+
|
|
868
|
+
Returns:
|
|
869
|
+
Project data if found, None otherwise
|
|
870
|
+
"""
|
|
871
|
+
provider = self.get_project_provider()
|
|
872
|
+
return await provider.get_project_by_worktree(worktree)
|
|
873
|
+
|
|
874
|
+
@method_spawner
|
|
875
|
+
async def get_project_by_name(self, name: str) -> ProjectData | None:
|
|
876
|
+
"""Get a project by friendly name.
|
|
877
|
+
|
|
878
|
+
Args:
|
|
879
|
+
name: Project name
|
|
880
|
+
|
|
881
|
+
Returns:
|
|
882
|
+
Project data if found, None otherwise
|
|
883
|
+
"""
|
|
884
|
+
provider = self.get_project_provider()
|
|
885
|
+
return await provider.get_project_by_name(name)
|
|
886
|
+
|
|
887
|
+
@method_spawner
|
|
888
|
+
async def list_projects(self, limit: int | None = None) -> list[ProjectData]:
|
|
889
|
+
"""List all projects, ordered by last_active descending.
|
|
890
|
+
|
|
891
|
+
Args:
|
|
892
|
+
limit: Maximum number of projects to return
|
|
893
|
+
|
|
894
|
+
Returns:
|
|
895
|
+
List of project data objects
|
|
896
|
+
"""
|
|
897
|
+
provider = self.get_project_provider()
|
|
898
|
+
return await provider.list_projects(limit=limit)
|
|
899
|
+
|
|
900
|
+
@method_spawner
|
|
901
|
+
async def delete_project(self, project_id: str) -> bool:
|
|
902
|
+
"""Delete a project from all providers.
|
|
903
|
+
|
|
904
|
+
Args:
|
|
905
|
+
project_id: Project identifier
|
|
906
|
+
|
|
907
|
+
Returns:
|
|
908
|
+
True if project was deleted from at least one provider
|
|
909
|
+
"""
|
|
910
|
+
deleted = False
|
|
911
|
+
for provider in self.providers:
|
|
912
|
+
try:
|
|
913
|
+
if await provider.delete_project(project_id):
|
|
914
|
+
deleted = True
|
|
915
|
+
except NotImplementedError:
|
|
916
|
+
pass
|
|
917
|
+
except Exception:
|
|
918
|
+
logger.exception(
|
|
919
|
+
"Error deleting project",
|
|
920
|
+
provider=provider.__class__.__name__,
|
|
921
|
+
project_id=project_id,
|
|
922
|
+
)
|
|
923
|
+
return deleted
|
|
924
|
+
|
|
925
|
+
@method_spawner
|
|
926
|
+
async def touch_project(self, project_id: str) -> None:
|
|
927
|
+
"""Update project's last_active timestamp in all providers.
|
|
928
|
+
|
|
929
|
+
Args:
|
|
930
|
+
project_id: Project identifier
|
|
931
|
+
"""
|
|
932
|
+
for provider in self.providers:
|
|
933
|
+
try:
|
|
934
|
+
await provider.touch_project(project_id)
|
|
935
|
+
except NotImplementedError:
|
|
936
|
+
pass
|
|
937
|
+
except Exception:
|
|
938
|
+
logger.exception(
|
|
939
|
+
"Error touching project",
|
|
940
|
+
provider=provider.__class__.__name__,
|
|
941
|
+
project_id=project_id,
|
|
942
|
+
)
|