deepanalysts 0.2.0__tar.gz → 0.2.1__tar.gz

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.
Files changed (39) hide show
  1. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/PKG-INFO +1 -1
  2. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/deepanalysts/backends/basement.py +25 -33
  3. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/deepanalysts/backends/sandbox.py +5 -19
  4. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/deepanalysts/middleware/_utils.py +1 -3
  5. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/deepanalysts/middleware/memory.py +5 -15
  6. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/deepanalysts/middleware/patch_tool_calls.py +1 -5
  7. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/deepanalysts/middleware/skills.py +11 -34
  8. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/deepanalysts/middleware/summarization.py +9 -31
  9. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/deepanalysts.egg-info/PKG-INFO +1 -1
  10. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/pyproject.toml +1 -1
  11. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/tests/test_basement.py +42 -9
  12. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/tests/test_filesystem_middleware.py +1 -3
  13. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/tests/test_sandbox_backend.py +1 -4
  14. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/tests/test_skills_middleware.py +9 -28
  15. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/tests/test_summarization_middleware.py +6 -13
  16. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/README.md +0 -0
  17. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/deepanalysts/__init__.py +0 -0
  18. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/deepanalysts/backends/__init__.py +0 -0
  19. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/deepanalysts/backends/composite.py +0 -0
  20. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/deepanalysts/backends/filesystem.py +0 -0
  21. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/deepanalysts/backends/protocol.py +0 -0
  22. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/deepanalysts/backends/state.py +0 -0
  23. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/deepanalysts/backends/store.py +0 -0
  24. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/deepanalysts/backends/utils.py +0 -0
  25. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/deepanalysts/clients/__init__.py +0 -0
  26. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/deepanalysts/clients/basement.py +0 -0
  27. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/deepanalysts/middleware/__init__.py +0 -0
  28. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/deepanalysts/middleware/filesystem.py +0 -0
  29. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/deepanalysts/middleware/subagents.py +0 -0
  30. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/deepanalysts/middleware/tool_errors.py +0 -0
  31. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/deepanalysts/utils/__init__.py +0 -0
  32. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/deepanalysts/utils/retry.py +0 -0
  33. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/deepanalysts.egg-info/SOURCES.txt +0 -0
  34. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/deepanalysts.egg-info/dependency_links.txt +0 -0
  35. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/deepanalysts.egg-info/requires.txt +0 -0
  36. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/deepanalysts.egg-info/top_level.txt +0 -0
  37. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/setup.cfg +0 -0
  38. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/tests/test_composite_backend.py +0 -0
  39. {deepanalysts-0.2.0 → deepanalysts-0.2.1}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepanalysts
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: LangChain/LangGraph middleware for building AI agents with memory, skills, and filesystem support
5
5
  Author-email: Ganchuluun Narantsatsralt <tsatsralt@swifttech.cloud>
6
6
  License: MIT
@@ -130,14 +130,10 @@ class BasementSkillsLoader:
130
130
  )
131
131
 
132
132
  # For orchestrator (all skills without specific targeting)
133
- orchestrator_skills = await loader.load_skills(
134
- user_id="user-123", agent_name="orchestrator"
135
- )
133
+ orchestrator_skills = await loader.load_skills(user_id="user-123", agent_name="orchestrator")
136
134
 
137
135
  # For technical analyst (only skills targeting technical_analyst)
138
- ta_skills = await loader.load_skills(
139
- user_id="user-123", agent_name="technical_analyst"
140
- )
136
+ ta_skills = await loader.load_skills(user_id="user-123", agent_name="technical_analyst")
141
137
  ```
142
138
  """
143
139
 
@@ -147,6 +143,7 @@ class BasementSkillsLoader:
147
143
  client: BasementClient | None = None,
148
144
  token_provider: Callable[[], str | None] | None = None,
149
145
  store: BaseStore | None = None,
146
+ store_namespace: str = "filesystem",
150
147
  supabase_url: str | None = None,
151
148
  built_in_dirs: list[str] | None = None,
152
149
  ) -> None:
@@ -157,6 +154,9 @@ class BasementSkillsLoader:
157
154
  token_provider: Callable that returns JWT token.
158
155
  store: LangGraph Store instance for writing skill content.
159
156
  If None, skills won't be available via read_file.
157
+ store_namespace: Second element of the store namespace tuple.
158
+ Skills are written to ``(user_id, store_namespace)``.
159
+ Defaults to ``"filesystem"`` for backward compatibility.
160
160
  supabase_url: Supabase URL for asset downloads. Required if store is provided.
161
161
  built_in_dirs: Directories containing built-in skills (SKILL.md files).
162
162
  These are loaded from the filesystem and merged with API skills.
@@ -165,6 +165,7 @@ class BasementSkillsLoader:
165
165
  self._token_provider = token_provider
166
166
  self._cache: dict[str, list[dict[str, Any]]] = {}
167
167
  self._store = store
168
+ self._store_namespace = store_namespace
168
169
  self._supabase_url = supabase_url
169
170
  self._written_skills: set[str] = set() # Track which skills already written
170
171
  self._built_in_dirs = built_in_dirs or []
@@ -234,15 +235,17 @@ class BasementSkillsLoader:
234
235
  if not name or not description:
235
236
  continue
236
237
 
237
- skills.append({
238
- "name": name,
239
- "description": description,
240
- "content": content,
241
- "path": f"/skills/{name}",
242
- "target_agents": [], # Built-in skills available to all agents
243
- "metadata": frontmatter.get("metadata", {}),
244
- "assets": [],
245
- })
238
+ skills.append(
239
+ {
240
+ "name": name,
241
+ "description": description,
242
+ "content": content,
243
+ "path": f"/skills/{name}",
244
+ "target_agents": [], # Built-in skills available to all agents
245
+ "metadata": frontmatter.get("metadata", {}),
246
+ "assets": [],
247
+ }
248
+ )
246
249
 
247
250
  self._built_in_cache = skills
248
251
  logger.debug(f"Loaded {len(skills)} built-in skills from filesystem")
@@ -289,8 +292,7 @@ class BasementSkillsLoader:
289
292
  # Write skills to store for read_file access (only on first load per user)
290
293
  if self._store and user_id:
291
294
  needs_write = any(
292
- f"{user_id}:{self._get_store_path(skill)}/SKILL.md"
293
- not in self._written_skills
295
+ f"{user_id}:{self._get_store_path(skill)}/SKILL.md" not in self._written_skills
294
296
  for skill in all_skills
295
297
  if skill.get("path")
296
298
  )
@@ -305,9 +307,7 @@ class BasementSkillsLoader:
305
307
  if self._skill_matches_agent(skill, agent_name):
306
308
  filtered.append(self._to_skill_metadata(skill))
307
309
 
308
- logger.debug(
309
- f"Filtered {len(filtered)}/{len(all_skills)} skills for agent '{agent_name}'"
310
- )
310
+ logger.debug(f"Filtered {len(filtered)}/{len(all_skills)} skills for agent '{agent_name}'")
311
311
  return filtered
312
312
 
313
313
  async def _write_skills_to_store(self, skills: list[dict[str, Any]], user_id: str) -> None:
@@ -323,7 +323,7 @@ class BasementSkillsLoader:
323
323
 
324
324
  from deepanalysts.backends.utils import create_file_data
325
325
 
326
- namespace = (user_id, "filesystem")
326
+ namespace = (user_id, self._store_namespace)
327
327
 
328
328
  # Collect all put operations for batch execution
329
329
  put_ops: list[PutOp] = []
@@ -354,9 +354,7 @@ class BasementSkillsLoader:
354
354
  "created_at": file_data["created_at"],
355
355
  "modified_at": file_data["modified_at"],
356
356
  }
357
- put_ops.append(
358
- PutOp(namespace=namespace, key=file_path, value=store_value)
359
- )
357
+ put_ops.append(PutOp(namespace=namespace, key=file_path, value=store_value))
360
358
  cache_keys_to_add.append(cache_key)
361
359
 
362
360
  # Process assets
@@ -377,9 +375,7 @@ class BasementSkillsLoader:
377
375
 
378
376
  # Queue text asset downloads for parallel execution
379
377
  if asset_type in ("script", "markdown"):
380
- asset_download_tasks.append(
381
- (full_path, cache_key, storage_path, asset_type)
382
- )
378
+ asset_download_tasks.append((full_path, cache_key, storage_path, asset_type))
383
379
  # For images, create reference file immediately (no download needed)
384
380
  elif asset_type == "image" and self._supabase_url:
385
381
  supabase_url = self._supabase_url.rstrip("/")
@@ -420,9 +416,7 @@ class BasementSkillsLoader:
420
416
  "created_at": file_data["created_at"],
421
417
  "modified_at": file_data["modified_at"],
422
418
  }
423
- put_ops.append(
424
- PutOp(namespace=namespace, key=full_path, value=store_value)
425
- )
419
+ put_ops.append(PutOp(namespace=namespace, key=full_path, value=store_value))
426
420
  cache_keys_to_add.append(cache_key)
427
421
 
428
422
  # Execute all writes in a single batch
@@ -430,9 +424,7 @@ class BasementSkillsLoader:
430
424
  await self._store.abatch(put_ops)
431
425
  # Update cache after successful batch write
432
426
  self._written_skills.update(cache_keys_to_add)
433
- logger.debug(
434
- f"Wrote {len(put_ops)} skill files to store for user {user_id}"
435
- )
427
+ logger.debug(f"Wrote {len(put_ops)} skill files to store for user {user_id}")
436
428
 
437
429
  async def _download_asset(self, storage_path: str) -> str | None:
438
430
  """Download text asset from Supabase storage.
@@ -230,9 +230,7 @@ except PermissionError:
230
230
 
231
231
  def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> str:
232
232
  """Read file content with line numbers."""
233
- cmd = _READ_COMMAND_TEMPLATE.format(
234
- file_path=file_path, offset=offset, limit=limit
235
- )
233
+ cmd = _READ_COMMAND_TEMPLATE.format(file_path=file_path, offset=offset, limit=limit)
236
234
  result = self.execute(cmd)
237
235
 
238
236
  output = result.output.rstrip()
@@ -324,9 +322,7 @@ except PermissionError:
324
322
  for line in output.split("\n"):
325
323
  parts = line.split(":", 2)
326
324
  if len(parts) >= 3:
327
- matches.append(
328
- {"path": parts[0], "line": int(parts[1]), "text": parts[2]}
329
- )
325
+ matches.append({"path": parts[0], "line": int(parts[1]), "text": parts[2]})
330
326
 
331
327
  return matches
332
328
 
@@ -483,23 +479,13 @@ class RestrictedSubprocessBackend(BaseSandbox):
483
479
  try:
484
480
  file_path = self._temp_dir / path.lstrip("/")
485
481
  if not file_path.exists():
486
- responses.append(
487
- FileDownloadResponse(
488
- path=path, content=None, error="file_not_found"
489
- )
490
- )
482
+ responses.append(FileDownloadResponse(path=path, content=None, error="file_not_found"))
491
483
  continue
492
484
 
493
485
  content = file_path.read_bytes()
494
- responses.append(
495
- FileDownloadResponse(path=path, content=content, error=None)
496
- )
486
+ responses.append(FileDownloadResponse(path=path, content=content, error=None))
497
487
  except Exception:
498
- responses.append(
499
- FileDownloadResponse(
500
- path=path, content=None, error="permission_denied"
501
- )
502
- )
488
+ responses.append(FileDownloadResponse(path=path, content=None, error="permission_denied"))
503
489
 
504
490
  return responses
505
491
 
@@ -19,9 +19,7 @@ def append_to_system_message(
19
19
  Returns:
20
20
  New SystemMessage with the text appended.
21
21
  """
22
- new_content: list[str | dict[str, str]] = (
23
- list(system_message.content_blocks) if system_message else []
24
- )
22
+ new_content: list[str | dict[str, str]] = list(system_message.content_blocks) if system_message else []
25
23
  if new_content:
26
24
  text = f"\n\n{text}"
27
25
  new_content.append({"type": "text", "text": text})
@@ -152,9 +152,7 @@ class MemoryMiddleware(AgentMiddleware):
152
152
  self.loader = loader
153
153
  self.system_prompt_template = MEMORY_SYSTEM_PROMPT
154
154
 
155
- def _get_backend(
156
- self, state: MemoryState, runtime: Runtime, config: RunnableConfig
157
- ) -> BackendProtocol:
155
+ def _get_backend(self, state: MemoryState, runtime: Runtime, config: RunnableConfig) -> BackendProtocol:
158
156
  """Resolve backend from instance or factory.
159
157
 
160
158
  Args:
@@ -246,9 +244,7 @@ class MemoryMiddleware(AgentMiddleware):
246
244
  results = await backend.adownload_files([path])
247
245
  # Should get exactly one response for one path
248
246
  if len(results) != 1:
249
- raise AssertionError(
250
- f"Expected 1 response for path {path}, got {len(results)}"
251
- )
247
+ raise AssertionError(f"Expected 1 response for path {path}, got {len(results)}")
252
248
  response = results[0]
253
249
 
254
250
  if response.error is not None:
@@ -276,9 +272,7 @@ class MemoryMiddleware(AgentMiddleware):
276
272
  results = backend.download_files([path])
277
273
  # Should get exactly one response for one path
278
274
  if len(results) != 1:
279
- raise AssertionError(
280
- f"Expected 1 response for path {path}, got {len(results)}"
281
- )
275
+ raise AssertionError(f"Expected 1 response for path {path}, got {len(results)}")
282
276
  response = results[0]
283
277
 
284
278
  if response.error is not None:
@@ -289,9 +283,7 @@ class MemoryMiddleware(AgentMiddleware):
289
283
 
290
284
  return None
291
285
 
292
- def before_agent(
293
- self, state: MemoryState, runtime: Runtime, config: RunnableConfig
294
- ) -> MemoryStateUpdate | None:
286
+ def before_agent(self, state: MemoryState, runtime: Runtime, config: RunnableConfig) -> MemoryStateUpdate | None:
295
287
  """Load memory content before agent execution (synchronous).
296
288
 
297
289
  Loads memory from all configured sources and stores in state.
@@ -384,9 +376,7 @@ class MemoryMiddleware(AgentMiddleware):
384
376
  memory_contents=memory_contents,
385
377
  )
386
378
 
387
- system_message = append_to_system_message(
388
- request.system_message, memory_section
389
- )
379
+ system_message = append_to_system_message(request.system_message, memory_section)
390
380
  return request.override(system_message=system_message)
391
381
 
392
382
  def wrap_model_call(
@@ -43,11 +43,7 @@ class PatchToolCallsMiddleware(AgentMiddleware):
43
43
  if msg.type == "ai" and msg.tool_calls:
44
44
  for tool_call in msg.tool_calls:
45
45
  corresponding_tool_msg = next(
46
- (
47
- m
48
- for m in messages[i:]
49
- if m.type == "tool" and m.tool_call_id == tool_call["id"]
50
- ),
46
+ (m for m in messages[i:] if m.type == "tool" and m.tool_call_id == tool_call["id"]),
51
47
  None,
52
48
  )
53
49
  if corresponding_tool_msg is None:
@@ -182,9 +182,7 @@ def _parse_skill_metadata(
182
182
  SkillMetadata if parsing succeeds, None if parsing fails or validation errors occur
183
183
  """
184
184
  if len(content) > MAX_SKILL_FILE_SIZE:
185
- logger.warning(
186
- "Skipping %s: content too large (%d bytes)", skill_path, len(content)
187
- )
185
+ logger.warning("Skipping %s: content too large (%d bytes)", skill_path, len(content))
188
186
  return None
189
187
 
190
188
  # Match YAML frontmatter between --- delimiters
@@ -213,9 +211,7 @@ def _parse_skill_metadata(
213
211
  description = frontmatter_data.get("description")
214
212
 
215
213
  if not name or not description:
216
- logger.warning(
217
- "Skipping %s: missing required 'name' or 'description'", skill_path
218
- )
214
+ logger.warning("Skipping %s: missing required 'name' or 'description'", skill_path)
219
215
  return None
220
216
 
221
217
  # Validate name format per spec (warn but continue loading for backwards compatibility)
@@ -299,9 +295,7 @@ def _list_skills(backend: BackendProtocol, source_path: str) -> list[SkillMetada
299
295
  responses = backend.download_files(paths_to_download)
300
296
 
301
297
  # Parse each downloaded SKILL.md
302
- for (skill_dir_path, skill_md_path), response in zip(
303
- skill_md_paths, responses, strict=True
304
- ):
298
+ for (skill_dir_path, skill_md_path), response in zip(skill_md_paths, responses, strict=True):
305
299
  if response.error:
306
300
  # Skill doesn't have a SKILL.md, skip it
307
301
  continue
@@ -331,9 +325,7 @@ def _list_skills(backend: BackendProtocol, source_path: str) -> list[SkillMetada
331
325
  return skills
332
326
 
333
327
 
334
- async def _alist_skills(
335
- backend: BackendProtocol, source_path: str
336
- ) -> list[SkillMetadata]:
328
+ async def _alist_skills(backend: BackendProtocol, source_path: str) -> list[SkillMetadata]:
337
329
  """List all skills from a backend source (async version).
338
330
 
339
331
  Scans backend for subdirectories containing SKILL.md files, downloads their content,
@@ -378,9 +370,7 @@ async def _alist_skills(
378
370
  responses = await backend.adownload_files(paths_to_download)
379
371
 
380
372
  # Parse each downloaded SKILL.md
381
- for (skill_dir_path, skill_md_path), response in zip(
382
- skill_md_paths, responses, strict=True
383
- ):
373
+ for (skill_dir_path, skill_md_path), response in zip(skill_md_paths, responses, strict=True):
384
374
  if response.error:
385
375
  # Skill doesn't have a SKILL.md, skip it
386
376
  continue
@@ -479,9 +469,7 @@ class SkillsMiddleware(AgentMiddleware):
479
469
  self.agent_name = agent_name
480
470
  self.system_prompt_template = SKILLS_SYSTEM_PROMPT
481
471
 
482
- def _get_backend(
483
- self, state: SkillsState, runtime: Runtime, config: RunnableConfig
484
- ) -> BackendProtocol:
472
+ def _get_backend(self, state: SkillsState, runtime: Runtime, config: RunnableConfig) -> BackendProtocol:
485
473
  """Resolve backend from instance or factory.
486
474
 
487
475
  Args:
@@ -504,9 +492,7 @@ class SkillsMiddleware(AgentMiddleware):
504
492
  )
505
493
  backend = self._backend(tool_runtime)
506
494
  if backend is None:
507
- raise AssertionError(
508
- "SkillsMiddleware requires a valid backend instance"
509
- )
495
+ raise AssertionError("SkillsMiddleware requires a valid backend instance")
510
496
  return backend
511
497
 
512
498
  return self._backend
@@ -560,14 +546,10 @@ class SkillsMiddleware(AgentMiddleware):
560
546
  skills_list=skills_list,
561
547
  )
562
548
 
563
- system_message = append_to_system_message(
564
- request.system_message, skills_section
565
- )
549
+ system_message = append_to_system_message(request.system_message, skills_section)
566
550
  return request.override(system_message=system_message)
567
551
 
568
- def before_agent(
569
- self, state: SkillsState, runtime: Runtime, config: RunnableConfig
570
- ) -> SkillsStateUpdate | None:
552
+ def before_agent(self, state: SkillsState, runtime: Runtime, config: RunnableConfig) -> SkillsStateUpdate | None:
571
553
  """Load skills metadata before agent execution (synchronous).
572
554
 
573
555
  Runs before each agent interaction to discover available skills from all
@@ -634,14 +616,9 @@ class SkillsMiddleware(AgentMiddleware):
634
616
  if self.loader:
635
617
  # Get user_id for store namespace (multi-tenant isolation)
636
618
  user_id = config.get("configurable", {}).get("user_id")
637
- skills_metadata = await self.loader.load_skills(
638
- agent_name=self.agent_name, user_id=user_id
639
- )
619
+ skills_metadata = await self.loader.load_skills(agent_name=self.agent_name, user_id=user_id)
640
620
  if skills_metadata:
641
- logger.debug(
642
- f"Loaded {len(skills_metadata)} skills from API "
643
- f"for agent '{self.agent_name}'"
644
- )
621
+ logger.debug(f"Loaded {len(skills_metadata)} skills from API for agent '{self.agent_name}'")
645
622
 
646
623
  # Also load from backend/sources (for built-in skills and file-based skills)
647
624
  if self._backend and self.sources:
@@ -162,13 +162,9 @@ class SummarizationMiddleware(BaseSummarizationMiddleware):
162
162
  self._truncation_text = "...(argument truncated)"
163
163
  else:
164
164
  self._truncate_args_trigger = truncate_args_settings.get("trigger")
165
- self._truncate_args_keep = truncate_args_settings.get(
166
- "keep", ("messages", 20)
167
- )
165
+ self._truncate_args_keep = truncate_args_settings.get("keep", ("messages", 20))
168
166
  self._max_arg_length = truncate_args_settings.get("max_length", 2000)
169
- self._truncation_text = truncate_args_settings.get(
170
- "truncation_text", "...(argument truncated)"
171
- )
167
+ self._truncation_text = truncate_args_settings.get("truncation_text", "...(argument truncated)")
172
168
 
173
169
  def _get_backend(
174
170
  self,
@@ -255,9 +251,7 @@ class SummarizationMiddleware(BaseSummarizationMiddleware):
255
251
  """
256
252
  return [msg for msg in messages if not self._is_summary_message(msg)]
257
253
 
258
- def _build_new_messages_with_path(
259
- self, summary: str, file_path: str | None
260
- ) -> list[AnyMessage]:
254
+ def _build_new_messages_with_path(self, summary: str, file_path: str | None) -> list[AnyMessage]:
261
255
  """Build the summary message with optional file path reference.
262
256
 
263
257
  Args:
@@ -290,9 +284,7 @@ class SummarizationMiddleware(BaseSummarizationMiddleware):
290
284
  )
291
285
  ]
292
286
 
293
- def _should_truncate_args(
294
- self, messages: list[AnyMessage], total_tokens: int
295
- ) -> bool:
287
+ def _should_truncate_args(self, messages: list[AnyMessage], total_tokens: int) -> bool:
296
288
  """Check if argument truncation should be triggered.
297
289
 
298
290
  Args:
@@ -399,9 +391,7 @@ class SummarizationMiddleware(BaseSummarizationMiddleware):
399
391
  }
400
392
  return tool_call
401
393
 
402
- def _truncate_args(
403
- self, messages: list[AnyMessage]
404
- ) -> tuple[list[AnyMessage], bool]:
394
+ def _truncate_args(self, messages: list[AnyMessage]) -> tuple[list[AnyMessage], bool]:
405
395
  """Truncate large tool call arguments in old messages.
406
396
 
407
397
  Only truncates arguments for write_file and edit_file tool calls,
@@ -485,11 +475,7 @@ class SummarizationMiddleware(BaseSummarizationMiddleware):
485
475
  existing_content = ""
486
476
  try:
487
477
  responses = backend.download_files([path])
488
- if (
489
- responses
490
- and responses[0].content is not None
491
- and responses[0].error is None
492
- ):
478
+ if responses and responses[0].content is not None and responses[0].error is None:
493
479
  existing_content = responses[0].content.decode("utf-8")
494
480
  except Exception as e:
495
481
  logger.debug(
@@ -560,11 +546,7 @@ class SummarizationMiddleware(BaseSummarizationMiddleware):
560
546
  existing_content = ""
561
547
  try:
562
548
  responses = await backend.adownload_files([path])
563
- if (
564
- responses
565
- and responses[0].content is not None
566
- and responses[0].error is None
567
- ):
549
+ if responses and responses[0].content is not None and responses[0].error is None:
568
550
  existing_content = responses[0].content.decode("utf-8")
569
551
  except Exception as e:
570
552
  logger.debug(
@@ -660,9 +642,7 @@ class SummarizationMiddleware(BaseSummarizationMiddleware):
660
642
  }
661
643
  return None
662
644
 
663
- messages_to_summarize, preserved_messages = self._partition_messages(
664
- truncated_messages, cutoff_index
665
- )
645
+ messages_to_summarize, preserved_messages = self._partition_messages(truncated_messages, cutoff_index)
666
646
 
667
647
  # Offload to backend first - warn if this fails but continue with summarization
668
648
  backend = self._get_backend(state, runtime)
@@ -743,9 +723,7 @@ class SummarizationMiddleware(BaseSummarizationMiddleware):
743
723
  }
744
724
  return None
745
725
 
746
- messages_to_summarize, preserved_messages = self._partition_messages(
747
- truncated_messages, cutoff_index
748
- )
726
+ messages_to_summarize, preserved_messages = self._partition_messages(truncated_messages, cutoff_index)
749
727
 
750
728
  # Offload to backend first - warn if this fails but continue with summarization
751
729
  backend = self._get_backend(state, runtime)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepanalysts
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: LangChain/LangGraph middleware for building AI agents with memory, skills, and filesystem support
5
5
  Author-email: Ganchuluun Narantsatsralt <tsatsralt@swifttech.cloud>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deepanalysts"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "LangChain/LangGraph middleware for building AI agents with memory, skills, and filesystem support"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -120,9 +120,7 @@ async def test_basement_skills_loader_assets_to_store():
120
120
  "deepanalysts.backends.basement.basement_client.get_active_skills",
121
121
  new_callable=AsyncMock,
122
122
  ) as mock_get_skills,
123
- patch.object(
124
- BasementSkillsLoader, "_download_asset", new_callable=AsyncMock
125
- ) as mock_download,
123
+ patch.object(BasementSkillsLoader, "_download_asset", new_callable=AsyncMock) as mock_download,
126
124
  ):
127
125
  mock_get_skills.return_value = mock_skills
128
126
  mock_download.return_value = "print('hello')"
@@ -191,9 +189,7 @@ async def test_basement_skills_loader_filters_by_agent():
191
189
  mock_get_skills.return_value = mock_skills
192
190
 
193
191
  # Load for technical_analyst
194
- skills = await loader.load_skills(
195
- agent_name="technical_analyst", user_id=user_id
196
- )
192
+ skills = await loader.load_skills(agent_name="technical_analyst", user_id=user_id)
197
193
 
198
194
  # Should get shared-skill and ta-only-skill, but NOT other-skill
199
195
  skill_names = [s["name"] for s in skills]
@@ -230,9 +226,7 @@ async def test_basement_skills_loader_wildcard_target():
230
226
  mock_get_skills.return_value = mock_skills
231
227
 
232
228
  # Should match any agent due to wildcard
233
- skills = await loader.load_skills(
234
- agent_name="any_agent", user_id="test-user-123"
235
- )
229
+ skills = await loader.load_skills(agent_name="any_agent", user_id="test-user-123")
236
230
 
237
231
  assert len(skills) == 1
238
232
  assert skills[0]["name"] == "wildcard-skill"
@@ -290,3 +284,42 @@ async def test_basement_skills_loader_caching():
290
284
  loader.clear_cache()
291
285
  await loader.load_skills(agent_name="ta", user_id="user1")
292
286
  assert mock_get_skills.call_count == 2
287
+
288
+
289
+ @pytest.mark.anyio
290
+ async def test_basement_skills_loader_custom_store_namespace():
291
+ """Test BasementSkillsLoader writes to custom namespace when store_namespace is set."""
292
+ mock_skills = [
293
+ {
294
+ "name": "ns-skill",
295
+ "path": "/skills/ns-skill",
296
+ "content": "# Namespaced Skill",
297
+ "target_agents": [],
298
+ "assets": [],
299
+ }
300
+ ]
301
+
302
+ store = InMemoryStore()
303
+ jwt_token = "jwt"
304
+
305
+ loader = BasementSkillsLoader(
306
+ store=store,
307
+ store_namespace="skills",
308
+ token_provider=lambda: jwt_token,
309
+ )
310
+
311
+ with patch(
312
+ "deepanalysts.backends.basement.basement_client.get_active_skills",
313
+ new_callable=AsyncMock,
314
+ ) as mock_get_skills:
315
+ mock_get_skills.return_value = mock_skills
316
+ await loader.load_skills(agent_name="orchestrator", user_id="user-abc")
317
+
318
+ # Verify data is in the custom namespace, not "filesystem"
319
+ custom_ns = await store.aget(("user-abc", "skills"), "/ns-skill/SKILL.md")
320
+ assert custom_ns is not None
321
+ assert "Namespaced Skill" in "\n".join(custom_ns.value["content"])
322
+
323
+ # Verify data is NOT in the default namespace
324
+ default_ns = await store.aget(("user-abc", "filesystem"), "/ns-skill/SKILL.md")
325
+ assert default_ns is None
@@ -73,9 +73,7 @@ class TestReadFileTruncation:
73
73
  result = _invoke_read_file(middleware, file_path)
74
74
 
75
75
  # Result must not exceed the budget
76
- assert len(result) <= max_chars, (
77
- f"Result length {len(result)} exceeds budget {max_chars}"
78
- )
76
+ assert len(result) <= max_chars, f"Result length {len(result)} exceeds budget {max_chars}"
79
77
 
80
78
  # Truncation message should be present
81
79
  assert "[Output was truncated due to size limits" in result
@@ -4,7 +4,6 @@ Tests the sandbox backend's execute() method with timeout handling,
4
4
  math calculations, and error capture.
5
5
  """
6
6
 
7
-
8
7
  from deepanalysts.backends.sandbox import RestrictedSubprocessBackend
9
8
 
10
9
 
@@ -41,9 +40,7 @@ class TestRestrictedSubprocessBackend:
41
40
  def test_execute_captures_stderr(self):
42
41
  """Test stderr is captured in output."""
43
42
  backend = RestrictedSubprocessBackend()
44
- result = backend.execute(
45
- "python3 -c \"import sys; print('error', file=sys.stderr)\""
46
- )
43
+ result = backend.execute("python3 -c \"import sys; print('error', file=sys.stderr)\"")
47
44
  assert "error" in result.output
48
45
 
49
46
  def test_execute_fibonacci_calculation(self):
@@ -4,7 +4,6 @@ from typing import Any
4
4
  from unittest.mock import AsyncMock, MagicMock
5
5
 
6
6
  import pytest
7
-
8
7
  from deepanalysts.middleware.skills import SkillMetadata, SkillsMiddleware
9
8
 
10
9
 
@@ -42,9 +41,7 @@ class TestSkillsMiddlewareDualLoading:
42
41
  """Tests for the dual loader + backend/sources merging behavior."""
43
42
 
44
43
  @pytest.mark.anyio
45
- async def test_loader_only(
46
- self, mock_runtime: MagicMock, mock_config: dict
47
- ) -> None:
44
+ async def test_loader_only(self, mock_runtime: MagicMock, mock_config: dict) -> None:
48
45
  """Test that loader-only mode works (no backend/sources)."""
49
46
  api_skill = _make_skill("api-skill", "From API", "/skills/api-skill/SKILL.md")
50
47
  loader = AsyncMock()
@@ -60,9 +57,7 @@ class TestSkillsMiddlewareDualLoading:
60
57
  assert result["skills_metadata"] == []
61
58
 
62
59
  @pytest.mark.anyio
63
- async def test_async_loader_only(
64
- self, mock_runtime: MagicMock, mock_config: dict
65
- ) -> None:
60
+ async def test_async_loader_only(self, mock_runtime: MagicMock, mock_config: dict) -> None:
66
61
  """Test that async loader-only mode works (no backend/sources)."""
67
62
  api_skill = _make_skill("api-skill", "From API", "/skills/api-skill/SKILL.md")
68
63
  loader = AsyncMock()
@@ -79,23 +74,17 @@ class TestSkillsMiddlewareDualLoading:
79
74
  assert skills[0]["name"] == "api-skill"
80
75
 
81
76
  @pytest.mark.anyio
82
- async def test_backend_only(
83
- self, mock_runtime: MagicMock, mock_config: dict, tmp_path: Any
84
- ) -> None:
77
+ async def test_backend_only(self, mock_runtime: MagicMock, mock_config: dict, tmp_path: Any) -> None:
85
78
  """Test that backend-only mode works (no loader)."""
86
79
  # Create a skill directory structure on disk
87
80
  skill_dir = tmp_path / "skills" / "disk-skill"
88
81
  skill_dir.mkdir(parents=True)
89
- (skill_dir / "SKILL.md").write_text(
90
- "---\nname: disk-skill\ndescription: From disk\n---\nContent"
91
- )
82
+ (skill_dir / "SKILL.md").write_text("---\nname: disk-skill\ndescription: From disk\n---\nContent")
92
83
 
93
84
  from deepanalysts.backends import FilesystemBackend
94
85
 
95
86
  backend = FilesystemBackend()
96
- middleware = SkillsMiddleware(
97
- backend=backend, sources=[str(tmp_path / "skills")]
98
- )
87
+ middleware = SkillsMiddleware(backend=backend, sources=[str(tmp_path / "skills")])
99
88
 
100
89
  state: dict[str, Any] = {"messages": []}
101
90
  result = await middleware.abefore_agent(state, mock_runtime, mock_config)
@@ -118,9 +107,7 @@ class TestSkillsMiddlewareDualLoading:
118
107
  # Disk skill via backend
119
108
  skill_dir = tmp_path / "skills" / "disk-skill"
120
109
  skill_dir.mkdir(parents=True)
121
- (skill_dir / "SKILL.md").write_text(
122
- "---\nname: disk-skill\ndescription: From disk\n---\nContent"
123
- )
110
+ (skill_dir / "SKILL.md").write_text("---\nname: disk-skill\ndescription: From disk\n---\nContent")
124
111
 
125
112
  from deepanalysts.backends import FilesystemBackend
126
113
 
@@ -148,18 +135,14 @@ class TestSkillsMiddlewareDualLoading:
148
135
  ) -> None:
149
136
  """Test that API skills override backend skills with same name."""
150
137
  # API skill with name "shared-skill"
151
- api_skill = _make_skill(
152
- "shared-skill", "API version", "/api/shared-skill/SKILL.md"
153
- )
138
+ api_skill = _make_skill("shared-skill", "API version", "/api/shared-skill/SKILL.md")
154
139
  loader = AsyncMock()
155
140
  loader.load_skills = AsyncMock(return_value=[api_skill])
156
141
 
157
142
  # Disk skill with same name
158
143
  skill_dir = tmp_path / "skills" / "shared-skill"
159
144
  skill_dir.mkdir(parents=True)
160
- (skill_dir / "SKILL.md").write_text(
161
- "---\nname: shared-skill\ndescription: Disk version\n---\nContent"
162
- )
145
+ (skill_dir / "SKILL.md").write_text("---\nname: shared-skill\ndescription: Disk version\n---\nContent")
163
146
 
164
147
  from deepanalysts.backends import FilesystemBackend
165
148
 
@@ -183,9 +166,7 @@ class TestSkillsMiddlewareDualLoading:
183
166
  assert shared[0]["description"] == "API version"
184
167
 
185
168
  @pytest.mark.anyio
186
- async def test_skips_if_already_in_state(
187
- self, mock_runtime: MagicMock, mock_config: dict
188
- ) -> None:
169
+ async def test_skips_if_already_in_state(self, mock_runtime: MagicMock, mock_config: dict) -> None:
189
170
  """Test that loading is skipped if skills_metadata already in state."""
190
171
  loader = AsyncMock()
191
172
  loader.load_skills = AsyncMock(return_value=[])
@@ -47,9 +47,7 @@ def make_conversation_messages(
47
47
  AIMessage(
48
48
  content=f"AI response {i}",
49
49
  id=f"ai-{i}",
50
- tool_calls=[
51
- {"id": f"tool-call-{i}", "name": "test_tool", "args": {}}
52
- ],
50
+ tool_calls=[{"id": f"tool-call-{i}", "name": "test_tool", "args": {}}],
53
51
  )
54
52
  )
55
53
  else:
@@ -63,9 +61,7 @@ def make_conversation_messages(
63
61
 
64
62
  for i in range(num_recent):
65
63
  idx = num_old + i
66
- messages.append(
67
- HumanMessage(content=f"Recent message {idx}", id=f"recent-{idx}")
68
- )
64
+ messages.append(HumanMessage(content=f"Recent message {idx}", id=f"recent-{idx}"))
69
65
 
70
66
  return messages
71
67
 
@@ -129,17 +125,13 @@ class MockBackend(BackendProtocol):
129
125
  async def awrite(self, path: str, content: str) -> WriteResult:
130
126
  return self.write(path, content)
131
127
 
132
- def edit(
133
- self, path: str, old_string: str, new_string: str, replace_all: bool = False
134
- ) -> EditResult:
128
+ def edit(self, path: str, old_string: str, new_string: str, replace_all: bool = False) -> EditResult:
135
129
  self.edit_calls.append((path, old_string, new_string))
136
130
  if self.should_fail:
137
131
  return EditResult(error=self.error_message or "Mock edit failure")
138
132
  return EditResult(path=path, occurrences=1)
139
133
 
140
- async def aedit(
141
- self, path: str, old_string: str, new_string: str, replace_all: bool = False
142
- ) -> EditResult:
134
+ async def aedit(self, path: str, old_string: str, new_string: str, replace_all: bool = False) -> EditResult:
143
135
  return self.edit(path, old_string, new_string, replace_all)
144
136
 
145
137
 
@@ -255,7 +247,8 @@ class TestOffloadingBasic:
255
247
  assert result is not None
256
248
  # Summary message should not include file path (since offload failed)
257
249
  summary_msgs = [
258
- m for m in result["messages"]
250
+ m
251
+ for m in result["messages"]
259
252
  if hasattr(m, "additional_kwargs") and m.additional_kwargs.get("lc_source") == "summarization"
260
253
  ]
261
254
  assert len(summary_msgs) == 1
File without changes
File without changes