deepanalysts 0.1.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.
- deepanalysts/__init__.py +65 -0
- deepanalysts/backends/__init__.py +48 -0
- deepanalysts/backends/basement.py +450 -0
- deepanalysts/backends/composite.py +295 -0
- deepanalysts/backends/filesystem.py +300 -0
- deepanalysts/backends/protocol.py +458 -0
- deepanalysts/backends/sandbox.py +516 -0
- deepanalysts/backends/store.py +588 -0
- deepanalysts/backends/utils.py +455 -0
- deepanalysts/clients/__init__.py +5 -0
- deepanalysts/clients/basement.py +200 -0
- deepanalysts/middleware/__init__.py +37 -0
- deepanalysts/middleware/_utils.py +28 -0
- deepanalysts/middleware/filesystem.py +967 -0
- deepanalysts/middleware/memory.py +423 -0
- deepanalysts/middleware/patch_tool_calls.py +67 -0
- deepanalysts/middleware/skills.py +696 -0
- deepanalysts/middleware/subagents.py +453 -0
- deepanalysts/middleware/summarization.py +769 -0
- deepanalysts/middleware/tool_errors.py +101 -0
- deepanalysts/utils/__init__.py +9 -0
- deepanalysts/utils/retry.py +123 -0
- deepanalysts-0.1.0.dist-info/METADATA +258 -0
- deepanalysts-0.1.0.dist-info/RECORD +26 -0
- deepanalysts-0.1.0.dist-info/WHEEL +5 -0
- deepanalysts-0.1.0.dist-info/top_level.txt +1 -0
deepanalysts/__init__.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Deep Analysts - Middleware and backends for LangChain/LangGraph agents.
|
|
2
|
+
|
|
3
|
+
This package provides:
|
|
4
|
+
- Middleware for agent orchestration (subagents, memory, skills, filesystem, etc.)
|
|
5
|
+
- Backend implementations for file storage (store, sandbox, composite routing)
|
|
6
|
+
- Basement API client for syncing skills/memories
|
|
7
|
+
- Retry utilities for transient error handling
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
from deepanalysts.middleware import (
|
|
11
|
+
FilesystemMiddleware,
|
|
12
|
+
MemoryMiddleware,
|
|
13
|
+
PatchToolCallsMiddleware,
|
|
14
|
+
SkillsMiddleware,
|
|
15
|
+
SubAgentMiddleware,
|
|
16
|
+
SummarizationMiddleware,
|
|
17
|
+
ToolErrorHandlingMiddleware,
|
|
18
|
+
)
|
|
19
|
+
from deepanalysts.backends import (
|
|
20
|
+
CompositeBackend,
|
|
21
|
+
StoreBackend,
|
|
22
|
+
RestrictedSubprocessBackend,
|
|
23
|
+
)
|
|
24
|
+
from deepanalysts.clients import BasementClient
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from deepanalysts.middleware import (
|
|
28
|
+
TASK_SYSTEM_PROMPT,
|
|
29
|
+
TASK_TOOL_DESCRIPTION,
|
|
30
|
+
CompiledSubAgent,
|
|
31
|
+
FilesystemMiddleware,
|
|
32
|
+
MemoryMiddleware,
|
|
33
|
+
PatchToolCallsMiddleware,
|
|
34
|
+
SkillMetadata,
|
|
35
|
+
SkillsMiddleware,
|
|
36
|
+
SubAgent,
|
|
37
|
+
SubAgentMiddleware,
|
|
38
|
+
SummarizationMiddleware,
|
|
39
|
+
ToolErrorHandlingMiddleware,
|
|
40
|
+
TruncateArgsSettings,
|
|
41
|
+
build_session_context,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
# Middleware classes
|
|
46
|
+
"FilesystemMiddleware",
|
|
47
|
+
"MemoryMiddleware",
|
|
48
|
+
"PatchToolCallsMiddleware",
|
|
49
|
+
"SkillsMiddleware",
|
|
50
|
+
"SubAgentMiddleware",
|
|
51
|
+
"SummarizationMiddleware",
|
|
52
|
+
"ToolErrorHandlingMiddleware",
|
|
53
|
+
# TypedDicts and types
|
|
54
|
+
"CompiledSubAgent",
|
|
55
|
+
"SkillMetadata",
|
|
56
|
+
"SubAgent",
|
|
57
|
+
"TruncateArgsSettings",
|
|
58
|
+
# Constants
|
|
59
|
+
"TASK_SYSTEM_PROMPT",
|
|
60
|
+
"TASK_TOOL_DESCRIPTION",
|
|
61
|
+
# Utility functions
|
|
62
|
+
"build_session_context",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Backends for deepanalysts middleware storage."""
|
|
2
|
+
|
|
3
|
+
from deepanalysts.backends.composite import CompositeBackend
|
|
4
|
+
from deepanalysts.backends.filesystem import FilesystemBackend, LocalFilesystemBackend
|
|
5
|
+
from deepanalysts.backends.protocol import (
|
|
6
|
+
BACKEND_TYPES,
|
|
7
|
+
BackendProtocol,
|
|
8
|
+
EditResult,
|
|
9
|
+
ExecuteResponse,
|
|
10
|
+
FileDownloadResponse,
|
|
11
|
+
FileInfo,
|
|
12
|
+
FileUploadResponse,
|
|
13
|
+
GrepMatch,
|
|
14
|
+
SandboxBackendProtocol,
|
|
15
|
+
WriteResult,
|
|
16
|
+
)
|
|
17
|
+
from deepanalysts.backends.sandbox import BaseSandbox, RestrictedSubprocessBackend
|
|
18
|
+
from deepanalysts.backends.store import StoreBackend
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
# Protocols and types
|
|
22
|
+
"BACKEND_TYPES",
|
|
23
|
+
"BackendProtocol",
|
|
24
|
+
"SandboxBackendProtocol",
|
|
25
|
+
# Result types
|
|
26
|
+
"EditResult",
|
|
27
|
+
"ExecuteResponse",
|
|
28
|
+
"FileDownloadResponse",
|
|
29
|
+
"FileInfo",
|
|
30
|
+
"FileUploadResponse",
|
|
31
|
+
"GrepMatch",
|
|
32
|
+
"WriteResult",
|
|
33
|
+
# Backend implementations
|
|
34
|
+
"BaseSandbox",
|
|
35
|
+
"CompositeBackend",
|
|
36
|
+
"FilesystemBackend",
|
|
37
|
+
"LocalFilesystemBackend",
|
|
38
|
+
"RestrictedSubprocessBackend",
|
|
39
|
+
"StoreBackend",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
# Optional imports that require additional dependencies (Basement API loaders)
|
|
43
|
+
try:
|
|
44
|
+
from deepanalysts.backends.basement import BasementMemoryLoader, BasementSkillsLoader
|
|
45
|
+
|
|
46
|
+
__all__.extend(["BasementMemoryLoader", "BasementSkillsLoader"])
|
|
47
|
+
except ImportError:
|
|
48
|
+
pass
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
"""Basement API loaders for skills and memory.
|
|
2
|
+
|
|
3
|
+
Provides loader classes that fetch skills and memories from the Basement API
|
|
4
|
+
instead of file-based backends. These loaders can be passed to the
|
|
5
|
+
MemoryMiddleware and SkillsMiddleware for API-based loading.
|
|
6
|
+
|
|
7
|
+
Note: These loaders require a token provider to be configured. The token
|
|
8
|
+
provider can be a static token, a callable, or a context variable getter.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
16
|
+
|
|
17
|
+
from deepanalysts.clients.basement import BasementClient, basement_client
|
|
18
|
+
from deepanalysts.middleware.skills import SkillMetadata
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from langgraph.store.base import BaseStore
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@runtime_checkable
|
|
27
|
+
class TokenProvider(Protocol):
|
|
28
|
+
"""Protocol for getting JWT tokens."""
|
|
29
|
+
|
|
30
|
+
def __call__(self) -> str | None:
|
|
31
|
+
"""Get the current JWT token."""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BasementMemoryLoader:
|
|
36
|
+
"""Loads user memories from Basement API.
|
|
37
|
+
|
|
38
|
+
Fetches active memories via GET /api/v1/memories/active and returns them
|
|
39
|
+
in the format expected by MemoryMiddleware.
|
|
40
|
+
|
|
41
|
+
Example:
|
|
42
|
+
```python
|
|
43
|
+
# With token provider function
|
|
44
|
+
loader = BasementMemoryLoader(token_provider=get_jwt_from_context)
|
|
45
|
+
middleware = MemoryMiddleware(loader=loader)
|
|
46
|
+
|
|
47
|
+
# With client that has token configured
|
|
48
|
+
client = BasementClient(token="jwt-token")
|
|
49
|
+
loader = BasementMemoryLoader(client=client)
|
|
50
|
+
```
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
*,
|
|
56
|
+
client: BasementClient | None = None,
|
|
57
|
+
token_provider: Callable[[], str | None] | None = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Initialize the memory loader.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
client: BasementClient instance to use. Defaults to global basement_client.
|
|
63
|
+
token_provider: Callable that returns JWT token. Used when calling API.
|
|
64
|
+
"""
|
|
65
|
+
self._client = client or basement_client
|
|
66
|
+
self._token_provider = token_provider
|
|
67
|
+
self._cache: dict[str, dict[str, str]] = {}
|
|
68
|
+
|
|
69
|
+
def _get_token(self) -> str | None:
|
|
70
|
+
"""Get JWT token from provider."""
|
|
71
|
+
if self._token_provider:
|
|
72
|
+
return self._token_provider()
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
async def load_memories(self) -> dict[str, str]:
|
|
76
|
+
"""Load active memories for user.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Dict mapping memory name to content, compatible with MemoryMiddleware.
|
|
80
|
+
"""
|
|
81
|
+
jwt_token = self._get_token()
|
|
82
|
+
if not jwt_token:
|
|
83
|
+
logger.warning("No JWT token available, skipping memory loading")
|
|
84
|
+
return {}
|
|
85
|
+
|
|
86
|
+
# Check cache (keyed by JWT to support multiple users)
|
|
87
|
+
if jwt_token in self._cache:
|
|
88
|
+
return self._cache[jwt_token]
|
|
89
|
+
|
|
90
|
+
memories = await self._client.get_active_memories(jwt_token)
|
|
91
|
+
|
|
92
|
+
# Convert to dict format expected by MemoryMiddleware
|
|
93
|
+
contents: dict[str, str] = {}
|
|
94
|
+
for mem in memories:
|
|
95
|
+
name = mem.get("name", "")
|
|
96
|
+
content = mem.get("content", "")
|
|
97
|
+
if name and content:
|
|
98
|
+
contents[name] = content
|
|
99
|
+
|
|
100
|
+
self._cache[jwt_token] = contents
|
|
101
|
+
logger.debug(f"Loaded {len(contents)} memories from Basement API")
|
|
102
|
+
return contents
|
|
103
|
+
|
|
104
|
+
def clear_cache(self) -> None:
|
|
105
|
+
"""Clear the memory cache."""
|
|
106
|
+
self._cache.clear()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class BasementSkillsLoader:
|
|
110
|
+
"""Loads user skills from Basement API with agent filtering.
|
|
111
|
+
|
|
112
|
+
Fetches active skills via GET /api/v1/skills/active and filters them
|
|
113
|
+
based on target_agents to ensure each subagent only sees relevant skills.
|
|
114
|
+
|
|
115
|
+
Optionally writes skill content to a LangGraph Store so the `read_file` tool
|
|
116
|
+
can access skill files during conversations.
|
|
117
|
+
|
|
118
|
+
Filtering logic:
|
|
119
|
+
- Empty target_agents = load for all agents
|
|
120
|
+
- "*" in target_agents = load for all agents
|
|
121
|
+
- Otherwise, agent_name must be in target_agents
|
|
122
|
+
|
|
123
|
+
Example:
|
|
124
|
+
```python
|
|
125
|
+
loader = BasementSkillsLoader(
|
|
126
|
+
token_provider=get_jwt_from_context,
|
|
127
|
+
store=app.state.store,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# For orchestrator (all skills without specific targeting)
|
|
131
|
+
orchestrator_skills = await loader.load_skills(
|
|
132
|
+
user_id="user-123", agent_name="orchestrator"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# For technical analyst (only skills targeting technical_analyst)
|
|
136
|
+
ta_skills = await loader.load_skills(
|
|
137
|
+
user_id="user-123", agent_name="technical_analyst"
|
|
138
|
+
)
|
|
139
|
+
```
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
def __init__(
|
|
143
|
+
self,
|
|
144
|
+
*,
|
|
145
|
+
client: BasementClient | None = None,
|
|
146
|
+
token_provider: Callable[[], str | None] | None = None,
|
|
147
|
+
store: BaseStore | None = None,
|
|
148
|
+
supabase_url: str | None = None,
|
|
149
|
+
) -> None:
|
|
150
|
+
"""Initialize loader with optional store for caching.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
client: BasementClient instance to use. Defaults to global basement_client.
|
|
154
|
+
token_provider: Callable that returns JWT token.
|
|
155
|
+
store: LangGraph Store instance for writing skill content.
|
|
156
|
+
If None, skills won't be available via read_file.
|
|
157
|
+
supabase_url: Supabase URL for asset downloads. Required if store is provided.
|
|
158
|
+
"""
|
|
159
|
+
self._client = client or basement_client
|
|
160
|
+
self._token_provider = token_provider
|
|
161
|
+
self._cache: dict[str, list[dict[str, Any]]] = {}
|
|
162
|
+
self._store = store
|
|
163
|
+
self._supabase_url = supabase_url
|
|
164
|
+
self._written_skills: set[str] = set() # Track which skills already written
|
|
165
|
+
|
|
166
|
+
def _get_token(self) -> str | None:
|
|
167
|
+
"""Get JWT token from provider."""
|
|
168
|
+
if self._token_provider:
|
|
169
|
+
return self._token_provider()
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
async def load_skills(
|
|
173
|
+
self,
|
|
174
|
+
agent_name: str = "orchestrator",
|
|
175
|
+
user_id: str | None = None,
|
|
176
|
+
) -> list[SkillMetadata]:
|
|
177
|
+
"""Load skills filtered for specific agent.
|
|
178
|
+
|
|
179
|
+
Writes skill content to store for read_file access if store is configured.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
agent_name: Agent to filter skills for (e.g., 'technical_analyst').
|
|
183
|
+
user_id: User ID for store namespace (multi-tenant isolation).
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
List of SkillMetadata dicts filtered by target_agents.
|
|
187
|
+
"""
|
|
188
|
+
jwt_token = self._get_token()
|
|
189
|
+
if not jwt_token:
|
|
190
|
+
logger.warning("No JWT token available, skipping skill loading")
|
|
191
|
+
return []
|
|
192
|
+
|
|
193
|
+
# Load from cache or API
|
|
194
|
+
if jwt_token not in self._cache:
|
|
195
|
+
skills = await self._client.get_active_skills(jwt_token)
|
|
196
|
+
self._cache[jwt_token] = skills
|
|
197
|
+
logger.debug(f"Loaded {len(skills)} skills from Basement API")
|
|
198
|
+
else:
|
|
199
|
+
skills = self._cache[jwt_token]
|
|
200
|
+
logger.debug("Using cached skills from Basement API")
|
|
201
|
+
|
|
202
|
+
# Write skills to store for read_file access (only on first load per user)
|
|
203
|
+
if self._store and user_id:
|
|
204
|
+
needs_write = any(
|
|
205
|
+
f"{user_id}:{self._get_store_path(skill)}/SKILL.md"
|
|
206
|
+
not in self._written_skills
|
|
207
|
+
for skill in skills
|
|
208
|
+
if skill.get("path")
|
|
209
|
+
)
|
|
210
|
+
if needs_write:
|
|
211
|
+
await self._write_skills_to_store(skills, user_id)
|
|
212
|
+
elif not user_id and self._store:
|
|
213
|
+
logger.warning("No user_id provided to load_skills, skipping store write")
|
|
214
|
+
|
|
215
|
+
all_skills = self._cache[jwt_token]
|
|
216
|
+
|
|
217
|
+
# Filter by target_agents and convert to SkillMetadata
|
|
218
|
+
filtered: list[SkillMetadata] = []
|
|
219
|
+
for skill in all_skills:
|
|
220
|
+
if self._skill_matches_agent(skill, agent_name):
|
|
221
|
+
filtered.append(self._to_skill_metadata(skill))
|
|
222
|
+
|
|
223
|
+
logger.debug(
|
|
224
|
+
f"Filtered {len(filtered)}/{len(all_skills)} skills for agent '{agent_name}'"
|
|
225
|
+
)
|
|
226
|
+
return filtered
|
|
227
|
+
|
|
228
|
+
async def _write_skills_to_store(self, skills: list[dict[str, Any]], user_id: str) -> None:
|
|
229
|
+
"""Write skill content and assets to Store for read_file access.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
skills: List of skill dicts from Basement API.
|
|
233
|
+
user_id: User ID for store namespace.
|
|
234
|
+
"""
|
|
235
|
+
import asyncio
|
|
236
|
+
|
|
237
|
+
from langgraph.store.base import PutOp
|
|
238
|
+
|
|
239
|
+
from deepanalysts.backends.utils import create_file_data
|
|
240
|
+
|
|
241
|
+
namespace = (user_id, "filesystem")
|
|
242
|
+
|
|
243
|
+
# Collect all put operations for batch execution
|
|
244
|
+
put_ops: list[PutOp] = []
|
|
245
|
+
cache_keys_to_add: list[str] = []
|
|
246
|
+
|
|
247
|
+
# Collect asset download tasks for parallel execution
|
|
248
|
+
asset_download_tasks: list[tuple[str, str, str, str]] = []
|
|
249
|
+
|
|
250
|
+
for skill in skills:
|
|
251
|
+
skill_path = skill.get("path", "")
|
|
252
|
+
content = skill.get("content", "")
|
|
253
|
+
|
|
254
|
+
if not skill_path:
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
# Strip /skills/ prefix for store
|
|
258
|
+
store_skill_path = self._get_store_path(skill)
|
|
259
|
+
|
|
260
|
+
# Queue SKILL.md write
|
|
261
|
+
if content:
|
|
262
|
+
file_path = store_skill_path.rstrip("/") + "/SKILL.md"
|
|
263
|
+
cache_key = f"{user_id}:{file_path}"
|
|
264
|
+
|
|
265
|
+
if cache_key not in self._written_skills:
|
|
266
|
+
file_data = create_file_data(content)
|
|
267
|
+
store_value = {
|
|
268
|
+
"content": file_data["content"],
|
|
269
|
+
"created_at": file_data["created_at"],
|
|
270
|
+
"modified_at": file_data["modified_at"],
|
|
271
|
+
}
|
|
272
|
+
put_ops.append(
|
|
273
|
+
PutOp(namespace=namespace, key=file_path, value=store_value)
|
|
274
|
+
)
|
|
275
|
+
cache_keys_to_add.append(cache_key)
|
|
276
|
+
|
|
277
|
+
# Process assets
|
|
278
|
+
assets = skill.get("assets", []) or skill.get("skill_assets", []) or []
|
|
279
|
+
for asset in assets:
|
|
280
|
+
asset_path = asset.get("path", "")
|
|
281
|
+
asset_type = asset.get("type", "")
|
|
282
|
+
storage_path = asset.get("storage_path", "")
|
|
283
|
+
|
|
284
|
+
if not asset_path or not storage_path:
|
|
285
|
+
continue
|
|
286
|
+
|
|
287
|
+
full_path = store_skill_path.rstrip("/") + "/" + asset_path.lstrip("/")
|
|
288
|
+
cache_key = f"{user_id}:{full_path}"
|
|
289
|
+
|
|
290
|
+
if cache_key in self._written_skills:
|
|
291
|
+
continue
|
|
292
|
+
|
|
293
|
+
# Queue text asset downloads for parallel execution
|
|
294
|
+
if asset_type in ("script", "markdown"):
|
|
295
|
+
asset_download_tasks.append(
|
|
296
|
+
(full_path, cache_key, storage_path, asset_type)
|
|
297
|
+
)
|
|
298
|
+
# For images, create reference file immediately (no download needed)
|
|
299
|
+
elif asset_type == "image" and self._supabase_url:
|
|
300
|
+
supabase_url = self._supabase_url.rstrip("/")
|
|
301
|
+
public_url = f"{supabase_url}/storage/v1/object/public/skill-assets/{storage_path}"
|
|
302
|
+
ref_content = f"# Image Asset Reference\n\nURL: {public_url}\nType: {asset.get('mime_type', 'image')}\nSize: {asset.get('size_bytes', 0)} bytes"
|
|
303
|
+
file_data = create_file_data(ref_content)
|
|
304
|
+
store_value = {
|
|
305
|
+
"content": file_data["content"],
|
|
306
|
+
"created_at": file_data["created_at"],
|
|
307
|
+
"modified_at": file_data["modified_at"],
|
|
308
|
+
}
|
|
309
|
+
put_ops.append(
|
|
310
|
+
PutOp(
|
|
311
|
+
namespace=namespace,
|
|
312
|
+
key=full_path + ".ref",
|
|
313
|
+
value=store_value,
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
cache_keys_to_add.append(cache_key)
|
|
317
|
+
|
|
318
|
+
# Download text assets in parallel
|
|
319
|
+
if asset_download_tasks:
|
|
320
|
+
download_results = await asyncio.gather(
|
|
321
|
+
*[self._download_asset(task[2]) for task in asset_download_tasks],
|
|
322
|
+
return_exceptions=True,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
for (full_path, cache_key, storage_path, _), result in zip(
|
|
326
|
+
asset_download_tasks, download_results, strict=False
|
|
327
|
+
):
|
|
328
|
+
if isinstance(result, Exception):
|
|
329
|
+
logger.warning(f"Failed to download asset {storage_path}: {result}")
|
|
330
|
+
continue
|
|
331
|
+
if result:
|
|
332
|
+
file_data = create_file_data(result)
|
|
333
|
+
store_value = {
|
|
334
|
+
"content": file_data["content"],
|
|
335
|
+
"created_at": file_data["created_at"],
|
|
336
|
+
"modified_at": file_data["modified_at"],
|
|
337
|
+
}
|
|
338
|
+
put_ops.append(
|
|
339
|
+
PutOp(namespace=namespace, key=full_path, value=store_value)
|
|
340
|
+
)
|
|
341
|
+
cache_keys_to_add.append(cache_key)
|
|
342
|
+
|
|
343
|
+
# Execute all writes in a single batch
|
|
344
|
+
if put_ops:
|
|
345
|
+
await self._store.abatch(put_ops)
|
|
346
|
+
# Update cache after successful batch write
|
|
347
|
+
self._written_skills.update(cache_keys_to_add)
|
|
348
|
+
logger.debug(
|
|
349
|
+
f"Wrote {len(put_ops)} skill files to store for user {user_id}"
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
async def _download_asset(self, storage_path: str) -> str | None:
|
|
353
|
+
"""Download text asset from Supabase storage.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
storage_path: Path in Supabase storage bucket.
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
Asset content as string, or None on failure.
|
|
360
|
+
"""
|
|
361
|
+
if not self._supabase_url:
|
|
362
|
+
logger.warning("No Supabase URL configured, cannot download asset")
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
import httpx
|
|
366
|
+
|
|
367
|
+
try:
|
|
368
|
+
supabase_url = self._supabase_url.rstrip("/")
|
|
369
|
+
url = f"{supabase_url}/storage/v1/object/public/skill-assets/{storage_path}"
|
|
370
|
+
|
|
371
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
372
|
+
response = await client.get(url)
|
|
373
|
+
if response.status_code == 200:
|
|
374
|
+
return response.text
|
|
375
|
+
else:
|
|
376
|
+
logger.warning(f"Failed to download asset: {response.status_code}")
|
|
377
|
+
return None
|
|
378
|
+
except Exception as e:
|
|
379
|
+
logger.warning(f"Error downloading asset: {e}")
|
|
380
|
+
return None
|
|
381
|
+
|
|
382
|
+
def _get_store_path(self, skill: dict[str, Any]) -> str:
|
|
383
|
+
"""Get the store path for a skill (without /skills/ prefix).
|
|
384
|
+
|
|
385
|
+
Args:
|
|
386
|
+
skill: Skill dict from API.
|
|
387
|
+
|
|
388
|
+
Returns:
|
|
389
|
+
Store path for the skill.
|
|
390
|
+
"""
|
|
391
|
+
skill_path = skill.get("path", "")
|
|
392
|
+
if skill_path.startswith("/skills/"):
|
|
393
|
+
return skill_path[len("/skills/") - 1 :] # Keep leading /
|
|
394
|
+
return skill_path
|
|
395
|
+
|
|
396
|
+
def _skill_matches_agent(self, skill: dict[str, Any], agent_name: str) -> bool:
|
|
397
|
+
"""Check if skill should be loaded for given agent.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
skill: Skill dict from API.
|
|
401
|
+
agent_name: Agent name to check against.
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
True if skill should be loaded for this agent.
|
|
405
|
+
"""
|
|
406
|
+
target_agents = skill.get("target_agents", [])
|
|
407
|
+
|
|
408
|
+
# Empty target_agents = load for all agents
|
|
409
|
+
if not target_agents:
|
|
410
|
+
return True
|
|
411
|
+
|
|
412
|
+
# Wildcard = load for all agents
|
|
413
|
+
if "*" in target_agents:
|
|
414
|
+
return True
|
|
415
|
+
|
|
416
|
+
# Check if agent is in target list
|
|
417
|
+
return agent_name in target_agents
|
|
418
|
+
|
|
419
|
+
def _to_skill_metadata(self, skill: dict[str, Any]) -> SkillMetadata:
|
|
420
|
+
"""Convert API skill dict to SkillMetadata format.
|
|
421
|
+
|
|
422
|
+
Args:
|
|
423
|
+
skill: Skill dict from Basement API.
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
SkillMetadata TypedDict compatible with SkillsMiddleware.
|
|
427
|
+
"""
|
|
428
|
+
# Extract allowed_tools from metadata if present
|
|
429
|
+
metadata = skill.get("metadata", {}) or {}
|
|
430
|
+
allowed_tools = metadata.get("allowed_tools", [])
|
|
431
|
+
if isinstance(allowed_tools, str):
|
|
432
|
+
allowed_tools = allowed_tools.split(" ") if allowed_tools else []
|
|
433
|
+
|
|
434
|
+
return SkillMetadata(
|
|
435
|
+
name=skill.get("name", ""),
|
|
436
|
+
description=skill.get("description", ""),
|
|
437
|
+
path=skill.get("path", ""),
|
|
438
|
+
license=skill.get("license"),
|
|
439
|
+
compatibility=skill.get("compatibility"),
|
|
440
|
+
metadata=metadata,
|
|
441
|
+
allowed_tools=allowed_tools,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
def clear_cache(self) -> None:
|
|
445
|
+
"""Clear the skills cache and written tracking."""
|
|
446
|
+
self._cache.clear()
|
|
447
|
+
self._written_skills.clear()
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
__all__ = ["BasementMemoryLoader", "BasementSkillsLoader"]
|