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.
@@ -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"]