soliplex 0.77.2__tar.gz → 0.78.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 (95) hide show
  1. {soliplex-0.77.2/src/soliplex.egg-info → soliplex-0.78.1}/PKG-INFO +1 -1
  2. {soliplex-0.77.2 → soliplex-0.78.1}/pyproject.toml +1 -1
  3. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/agents.py +5 -12
  4. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/cli/audit.py +75 -41
  5. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/config/agents.py +15 -4
  6. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/config/skills.py +86 -11
  7. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/installation.py +1 -1
  8. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/models.py +2 -2
  9. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/quizzes.py +1 -1
  10. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/skills/bwrap_sandbox/SKILL.md +44 -18
  11. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/skills/bwrap_sandbox/__init__.py +194 -10
  12. {soliplex-0.77.2 → soliplex-0.78.1/src/soliplex.egg-info}/PKG-INFO +1 -1
  13. {soliplex-0.77.2 → soliplex-0.78.1}/LICENSE +0 -0
  14. {soliplex-0.77.2 → soliplex-0.78.1}/MANIFEST.in +0 -0
  15. {soliplex-0.77.2 → soliplex-0.78.1}/README.md +0 -0
  16. {soliplex-0.77.2 → soliplex-0.78.1}/example/skills/bare-bones/SKILL.md +0 -0
  17. {soliplex-0.77.2 → soliplex-0.78.1}/example/skills/stanzas/SKILL.md +0 -0
  18. {soliplex-0.77.2 → soliplex-0.78.1}/setup.cfg +0 -0
  19. {soliplex-0.77.2 → soliplex-0.78.1}/skills/soliplex-docs/SKILL.md +0 -0
  20. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/agui/__init__.py +0 -0
  21. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/agui/parser.py +0 -0
  22. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/agui/persistence.py +0 -0
  23. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/agui/schema.py +0 -0
  24. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/agui/util.py +0 -0
  25. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/authn.py +0 -0
  26. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/authz/__init__.py +0 -0
  27. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/authz/persistence.py +0 -0
  28. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/authz/schema.py +0 -0
  29. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/capabilities/filesystem.py +0 -0
  30. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/capabilities/rag_audit.py +0 -0
  31. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/cli/admin_users.py +0 -0
  32. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/cli/ask.py +0 -0
  33. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/cli/cli_util.py +0 -0
  34. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/cli/main.py +0 -0
  35. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/cli/ollama.py +0 -0
  36. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/cli/room_authz.py +0 -0
  37. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/cli/serve.py +0 -0
  38. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/cli/types.py +0 -0
  39. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/completions.py +0 -0
  40. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/config/__init__.py +0 -0
  41. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/config/_utils.py +0 -0
  42. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/config/agui.py +0 -0
  43. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/config/authsystem.py +0 -0
  44. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/config/completions.py +0 -0
  45. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/config/exceptions.py +0 -0
  46. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/config/installation.py +0 -0
  47. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/config/logfire.py +0 -0
  48. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/config/meta.py +0 -0
  49. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/config/middleware.py +0 -0
  50. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/config/quizzes.py +0 -0
  51. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/config/rag.py +0 -0
  52. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/config/rooms.py +0 -0
  53. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/config/routing.py +0 -0
  54. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/config/secrets.py +0 -0
  55. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/config/tools.py +0 -0
  56. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/examples.py +0 -0
  57. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/log_ingest.py +0 -0
  58. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/loggers.py +0 -0
  59. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/main.py +0 -0
  60. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/mcp_auth.py +0 -0
  61. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/mcp_client.py +0 -0
  62. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/mcp_server.py +0 -0
  63. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/ollama.py +0 -0
  64. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/rag_audit.py +0 -0
  65. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/sandbox_audit.py +0 -0
  66. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/secrets.py +0 -0
  67. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/titles.py +0 -0
  68. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/tools/__init__.py +0 -0
  69. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/tools/agui_run_feedback.py +0 -0
  70. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/tools/file_uploads.py +0 -0
  71. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/tools/rag.py +0 -0
  72. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/tui/cli.py +0 -0
  73. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/tui/main.py +0 -0
  74. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/tui/rest_api.py +0 -0
  75. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/tui/serve.py +0 -0
  76. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/util.py +0 -0
  77. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/views/__init__.py +0 -0
  78. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/views/agui.py +0 -0
  79. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/views/authn.py +0 -0
  80. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/views/authz.py +0 -0
  81. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/views/completions.py +0 -0
  82. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/views/installation.py +0 -0
  83. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/views/log_ingest.py +0 -0
  84. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/views/quizzes.py +0 -0
  85. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/views/room_file_uploads.py +0 -0
  86. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/views/rooms.py +0 -0
  87. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/views/sandbox_workdirs.py +0 -0
  88. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/views/stats.py +0 -0
  89. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/views/streaming.py +0 -0
  90. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex/views/thread_file_uploads.py +0 -0
  91. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex.egg-info/SOURCES.txt +0 -0
  92. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex.egg-info/dependency_links.txt +0 -0
  93. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex.egg-info/entry_points.txt +0 -0
  94. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex.egg-info/requires.txt +0 -0
  95. {soliplex-0.77.2 → soliplex-0.78.1}/src/soliplex.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: soliplex
3
- Version: 0.77.2
3
+ Version: 0.78.1
4
4
  Summary: An AI-powered Retrieval-Augmented Generation (RAG) system with a modern web interface.
5
5
  Author-email: Enfold <info@enfoldsystems.net>
6
6
  Classifier: Programming Language :: Python :: 3
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "soliplex"
7
- version = "0.77.2"
7
+ version = "0.78.1"
8
8
  description = "An AI-powered Retrieval-Augmented Generation (RAG) system with a modern web interface."
9
9
  authors = [{ name = "Enfold", email = "info@enfoldsystems.net" }]
10
10
  readme = "README.md"
@@ -144,18 +144,11 @@ def get_default_agent_from_configs(
144
144
  if isinstance(capability, HR_VisionCapabilities):
145
145
  capability.vision = agent_config.multimodal
146
146
 
147
- # A single model-facing capability loads eagerly so its tools are visible;
148
- #
149
- # Multiple stay deferred so the model routes between them via
150
- # 'load_capability'.
151
- routing_capabilities = [
152
- capability
153
- for capability in capabilities
154
- if _is_routing_capability(capability)
155
- ]
156
- defer_loading = len(routing_capabilities) > 1
157
- for capability in routing_capabilities:
158
- capability.defer_loading = defer_loading
147
+ # A hook-only capability is never deferred: nothing would load it, and
148
+ # its hooks only fire once loaded.
149
+ for capability in capabilities:
150
+ if not _is_routing_capability(capability):
151
+ capability.defer_loading = False
159
152
 
160
153
  return pydantic_ai.Agent(
161
154
  model=model,
@@ -568,22 +568,39 @@ async def _list_admin_discriminators(dburi):
568
568
  return await policy.list_admin_user_discriminators()
569
569
 
570
570
 
571
- def _room_authz_groups(the_installation):
572
- """Bucket configured rooms by authorization state; collect stale rows."""
573
- configured = sorted(the_installation._config.room_configs)
571
+ def _room_policies(the_installation) -> tuple[list, str | None]:
572
+ """Return ``(policies, error)`` for the stored room policies.
573
+
574
+ ``policies`` holds the unchecked policy models read via
575
+ 'RoomAuthorizationPolicy.list_room_policies' and ``error`` is ``None``
576
+ on success. A RAM-based authz DB can hold no persisted rows, so it
577
+ short-circuits to ``([], None)`` without touching the database.
578
+
579
+ When the database itself cannot be reached -- e.g. its DBURI names a
580
+ Postgres server that isn't listening -- ``policies`` is empty and
581
+ ``error`` carries the exception message, so the audit can report the
582
+ unreachable DB instead of dying on the traceback.
583
+ """
574
584
  dburi = the_installation.authorization_dburi_async
575
585
 
576
586
  if dburi in cli_util._RAM_DBURIS:
577
- return {
578
- "default": list(configured),
579
- "public": [],
580
- "private": [],
581
- "stale": [],
582
- }
587
+ return [], None
588
+
589
+ try:
590
+ return list(asyncio.run(_list_room_policies(dburi))), None
591
+ except Exception as exc:
592
+ return [], f"{type(exc).__name__}: {exc}"
593
+
594
+
595
+ def _room_authz_groups(the_installation, room_policies):
596
+ """Bucket configured rooms by authorization state; collect stale rows.
597
+
598
+ ``room_policies`` is the policy list from '_room_policies'.
599
+ """
600
+ configured = sorted(the_installation._config.room_configs)
583
601
 
584
602
  policies = {
585
- policy.room_id: policy.default_allow_deny
586
- for policy in asyncio.run(_list_room_policies(dburi))
603
+ policy.room_id: policy.default_allow_deny for policy in room_policies
587
604
  }
588
605
 
589
606
  configured_set = set(configured)
@@ -606,26 +623,21 @@ def _room_authz_groups(the_installation):
606
623
  }
607
624
 
608
625
 
609
- def _invalid_acl_json_paths(the_installation) -> dict:
626
+ def _invalid_acl_json_paths(room_policies) -> dict:
610
627
  """Collect ACL entries whose stored 'json_path' fails to validate.
611
628
 
612
- Reads every room policy via 'RoomAuthorizationPolicy.list_room_policies'
613
- -- which returns the unchecked models, tolerating entries that would
614
- fail 'policy.as_model' -- and re-validates each surfaced 'json_path'
615
- against the currently-loaded JSONPath environment. Typically an
616
- entry lands here when it was authored under a meta-config that
617
- registered filter functions which are no longer present.
629
+ ``room_policies`` is the policy list from '_room_policies' -- the
630
+ unchecked models, which tolerate entries that would fail
631
+ 'policy.as_model'. Each surfaced 'json_path' is re-validated against
632
+ the currently-loaded JSONPath environment. Typically an entry lands
633
+ here when it was authored under a meta-config that registered filter
634
+ functions which are no longer present.
618
635
 
619
636
  Returns a dict mapping 'room_id' to a list of '(json_path, error)'
620
- pairs. A RAM-based authz DB has no persisted rows and returns '{}'.
637
+ pairs.
621
638
  """
622
- dburi = the_installation.authorization_dburi_async
623
-
624
- if dburi in cli_util._RAM_DBURIS:
625
- return {}
626
-
627
639
  invalid: dict = {}
628
- for policy in asyncio.run(_list_room_policies(dburi)):
640
+ for policy in room_policies:
629
641
  for entry in policy.acl_entries:
630
642
  if entry.json_path is None:
631
643
  continue
@@ -651,7 +663,13 @@ def _audit_room_authz_section(
651
663
  tc_rule("Configured rooms by authorization state")
652
664
  tc_line()
653
665
 
654
- groups = _room_authz_groups(the_installation)
666
+ room_policies, db_error = _room_policies(the_installation)
667
+ if db_error is not None:
668
+ tc_print(f"ERROR: authorization database unreachable: {db_error}")
669
+ tc_line()
670
+ return {"room_authz": {"unreachable": db_error}}
671
+
672
+ groups = _room_authz_groups(the_installation, room_policies)
655
673
 
656
674
  for label, room_ids in (
657
675
  (
@@ -677,7 +695,7 @@ def _audit_room_authz_section(
677
695
  tc_print(" (none)")
678
696
  tc_line()
679
697
 
680
- invalid_acls = _invalid_acl_json_paths(the_installation)
698
+ invalid_acls = _invalid_acl_json_paths(room_policies)
681
699
  tc_print("ACL entries with invalid JSONPath:")
682
700
  if invalid_acls:
683
701
  for room_id in sorted(invalid_acls):
@@ -699,36 +717,47 @@ def _audit_room_authz_section(
699
717
  return errors
700
718
 
701
719
 
702
- def _admin_user_json_paths(the_installation) -> list[str]:
703
- """Return every stored 'AdminUser.json_path', in insertion order.
704
-
705
- Reads them via
706
- 'AdminUserPolicy.list_admin_user_discriminators'. Returns an
707
- empty list when the authz DB is RAM-based (no persisted rows can
708
- exist).
720
+ def _admin_user_json_paths(
721
+ the_installation,
722
+ ) -> tuple[list[str], str | None]:
723
+ """Return ``(json_paths, error)`` for the stored admin rows.
724
+
725
+ ``json_paths`` holds every stored 'AdminUser.json_path', in insertion
726
+ order, read via 'AdminUserPolicy.list_admin_user_discriminators';
727
+ ``error`` is ``None`` on success. A RAM-based authz DB can hold no
728
+ persisted rows, so it short-circuits to ``([], None)`` without
729
+ touching the database.
730
+
731
+ When the database itself cannot be reached -- e.g. its DBURI names a
732
+ Postgres server that isn't listening -- ``json_paths`` is empty and
733
+ ``error`` carries the exception message, so the audit can report the
734
+ unreachable DB instead of dying on the traceback.
709
735
  """
710
736
  dburi = the_installation.authorization_dburi_async
711
737
 
712
738
  if dburi in cli_util._RAM_DBURIS:
713
- return []
739
+ return [], None
714
740
 
715
- return asyncio.run(_list_admin_discriminators(dburi))
741
+ try:
742
+ return list(asyncio.run(_list_admin_discriminators(dburi))), None
743
+ except Exception as exc:
744
+ return [], f"{type(exc).__name__}: {exc}"
716
745
 
717
746
 
718
747
  def _invalid_admin_user_json_paths(
719
- the_installation,
748
+ json_paths,
720
749
  ) -> list[tuple[str, str]]:
721
750
  """Collect admin entries whose 'json_path' fails to validate.
722
751
 
723
752
  Same intent as '_invalid_acl_json_paths' but for the 'AdminUser'
724
753
  table: a row typically lands here when it was authored under a
725
754
  meta-config that registered filter functions which are no longer
726
- present.
755
+ present. ``json_paths`` is the list from '_admin_user_json_paths'.
727
756
 
728
757
  Returns a list of '(json_path, error)' pairs.
729
758
  """
730
759
  invalid: list[tuple[str, str]] = []
731
- for json_path in _admin_user_json_paths(the_installation):
760
+ for json_path in json_paths:
732
761
  try:
733
762
  authz.validate_json_path(json_path)
734
763
  except authz.InvalidJSONPath as exc:
@@ -749,7 +778,12 @@ def _audit_admin_users_section(
749
778
  tc_rule("Configured admin users")
750
779
  tc_line()
751
780
 
752
- json_paths = _admin_user_json_paths(the_installation)
781
+ json_paths, db_error = _admin_user_json_paths(the_installation)
782
+ if db_error is not None:
783
+ tc_print(f"ERROR: authorization database unreachable: {db_error}")
784
+ tc_line()
785
+ return {"admin_users": {"unreachable": db_error}}
786
+
753
787
  tc_print(f"Admin users ({len(json_paths)}):")
754
788
  if json_paths:
755
789
  for json_path in json_paths:
@@ -763,7 +797,7 @@ def _audit_admin_users_section(
763
797
  tc_print(" (none)")
764
798
  tc_line()
765
799
 
766
- invalid = _invalid_admin_user_json_paths(the_installation)
800
+ invalid = _invalid_admin_user_json_paths(json_paths)
767
801
  tc_print("Admin users with invalid JSONPath:")
768
802
  if invalid:
769
803
  for json_path, error in invalid:
@@ -271,6 +271,16 @@ class AgentConfig:
271
271
  else: # pragma: NO COVER
272
272
  pass
273
273
 
274
+ @property
275
+ def llm_model_name(self) -> str | None:
276
+ model_name = self.model_name
277
+ i_config = self._installation_config
278
+
279
+ if i_config is not None:
280
+ model_name = i_config.interpolate_environment(model_name)
281
+
282
+ return model_name
283
+
274
284
  @property
275
285
  def llm_provider_base_url(self) -> str | None:
276
286
  ic = self._installation_config
@@ -324,7 +334,7 @@ class AgentConfig:
324
334
  return {
325
335
  "id": self.id,
326
336
  "kind": self.kind,
327
- "model_name": self.model_name,
337
+ "model_name": self.model_name, # not interpolated
328
338
  "retries": self.retries,
329
339
  "system_prompt": prompt,
330
340
  "model_settings": self.model_settings,
@@ -478,6 +488,7 @@ def get_model_from_config(
478
488
  provider_kw = agent_config.llm_provider_kw
479
489
 
480
490
  model_settings_kw = {}
491
+ model_name = agent_config.llm_model_name
481
492
 
482
493
  if agent_config.model_settings:
483
494
  model_settings_kw["settings"] = ai_settings.ModelSettings(
@@ -487,7 +498,7 @@ def get_model_from_config(
487
498
  if agent_config.provider_type == LLMProviderType.GOOGLE:
488
499
  provider = google_providers.GoogleProvider(**provider_kw)
489
500
  return google_models.GoogleModel(
490
- model_name=agent_config.model_name,
501
+ model_name=model_name,
491
502
  provider=provider,
492
503
  **model_settings_kw,
493
504
  )
@@ -496,7 +507,7 @@ def get_model_from_config(
496
507
  provider_kw["api_key"] = "dummy"
497
508
  provider = ollama_providers.OllamaProvider(**provider_kw)
498
509
  return openai_models.OpenAIChatModel(
499
- model_name=agent_config.model_name,
510
+ model_name=model_name,
500
511
  provider=provider,
501
512
  profile=_OPENAI_COMPAT_PROFILE,
502
513
  **model_settings_kw,
@@ -510,7 +521,7 @@ def get_model_from_config(
510
521
  )
511
522
  provider = openai_providers.OpenAIProvider(**provider_kw)
512
523
  return openai_models.OpenAIChatModel(
513
- model_name=agent_config.model_name,
524
+ model_name=model_name,
514
525
  provider=provider,
515
526
  **profile_kw,
516
527
  **model_settings_kw,
@@ -179,6 +179,22 @@ class FilesystemSkillConfig:
179
179
  def extra_parameters(self) -> dict[str, pathlib.Path]:
180
180
  return {"path": self.path}
181
181
 
182
+ def with_defer_loading(self, defer_loading: bool) -> FilesystemSkillConfig:
183
+ """A copy whose capability loads as a room asked.
184
+
185
+ Installation skill configs are shared between the rooms which name
186
+ them, so a room's choice cannot be written onto the capability.
187
+ """
188
+ if defer_loading == self._capability.defer_loading:
189
+ return self
190
+ return dataclasses.replace(
191
+ self,
192
+ _capability=dataclasses.replace(
193
+ self._capability,
194
+ defer_loading=defer_loading,
195
+ ),
196
+ )
197
+
182
198
 
183
199
  @dataclasses.dataclass(kw_only=True)
184
200
  class _HaikuRAGCapabilityConfig(
@@ -192,6 +208,8 @@ class _HaikuRAGCapabilityConfig(
192
208
  state_type: typing.ClassVar[type[pydantic.BaseModel]]
193
209
  source: typing.ClassVar[SkillKind] = SkillKind.NATIVE
194
210
 
211
+ defer_loading: bool = False
212
+
195
213
  @classmethod
196
214
  def from_yaml(
197
215
  cls,
@@ -230,7 +248,7 @@ class _HaikuRAGCapabilityConfig(
230
248
  return type(self).capability_factory(
231
249
  db_path=self.rag_lancedb_path,
232
250
  config=self.haiku_rag_config,
233
- defer_loading=True,
251
+ defer_loading=self.defer_loading,
234
252
  )
235
253
 
236
254
  @property
@@ -239,7 +257,10 @@ class _HaikuRAGCapabilityConfig(
239
257
 
240
258
  @property
241
259
  def as_yaml(self) -> dict:
242
- result = {"kind": self.kind}
260
+ result = {
261
+ "kind": self.kind,
262
+ "defer_loading": self.defer_loading,
263
+ }
243
264
  if self.rag_lancedb_stem is not None:
244
265
  result["rag_lancedb_stem"] = self.rag_lancedb_stem
245
266
  else:
@@ -383,6 +404,7 @@ class BwrapSandboxSkillConfig:
383
404
  allowed_environments: bwrap_sandbox.AllowedEnvironments = None
384
405
  sandbox_config: bs_config.Config = None
385
406
  volumes: bs_models.VolumeMap = _default_dict_field()
407
+ defer_loading: bool = False
386
408
 
387
409
  @classmethod
388
410
  def from_yaml(
@@ -420,6 +442,7 @@ class BwrapSandboxSkillConfig:
420
442
  sandbox_config=self.sandbox_config,
421
443
  volumes=self.volumes,
422
444
  installation_config=self._installation_config,
445
+ defer_loading=self.defer_loading,
423
446
  )
424
447
 
425
448
  @property
@@ -427,6 +450,7 @@ class BwrapSandboxSkillConfig:
427
450
  result = {
428
451
  "kind": self.kind,
429
452
  "default_environment": self.default_environment,
453
+ "defer_loading": self.defer_loading,
430
454
  }
431
455
  if self.id is not None:
432
456
  result["id"] = self.id
@@ -475,6 +499,7 @@ class EntrypointCapabilityConfig:
475
499
 
476
500
  name: str
477
501
  params: dict[str, typing.Any] = _default_dict_field()
502
+ defer_loading: bool = True
478
503
 
479
504
  def __post_init__(self) -> None:
480
505
  # Register the capability's typed AG-UI state feature so the room can
@@ -500,10 +525,13 @@ class EntrypointCapabilityConfig:
500
525
  params = dict(config_dict)
501
526
  params.pop("kind", None)
502
527
  name = params.pop("name", None)
528
+ # Soliplex's own flag, not one of the plugin's parameters.
529
+ defer_loading = params.pop("defer_loading", True)
503
530
  try:
504
531
  return cls(
505
532
  name=name,
506
533
  params=params,
534
+ defer_loading=defer_loading,
507
535
  _installation_config=installation_config,
508
536
  _config_path=config_path,
509
537
  )
@@ -521,7 +549,7 @@ class EntrypointCapabilityConfig:
521
549
  @property
522
550
  def capability(self) -> ai_capabilities.AbstractCapability:
523
551
  return self._module.create_capability(
524
- defer_loading=True, **self.params
552
+ defer_loading=self.defer_loading, **self.params
525
553
  )
526
554
 
527
555
  @property
@@ -543,7 +571,12 @@ class EntrypointCapabilityConfig:
543
571
 
544
572
  @property
545
573
  def as_yaml(self) -> dict:
546
- return {"kind": self.kind, "name": self.name, **self.params}
574
+ return {
575
+ "kind": self.kind,
576
+ "name": self.name,
577
+ "defer_loading": self.defer_loading,
578
+ **self.params,
579
+ }
547
580
 
548
581
  @property
549
582
  def extra_parameters(self) -> dict[str, typing.Any]:
@@ -611,24 +644,57 @@ def extract_skill_configs(
611
644
  return skill_configs
612
645
 
613
646
 
647
+ @dataclasses.dataclass(kw_only=True)
648
+ class InstallationSkillRef:
649
+ """A room's reference to one of the installation's skills.
650
+
651
+ Spelled as a bare name, or as a mapping when the room sets a flag.
652
+ """
653
+
654
+ name: str
655
+ defer_loading: bool = True
656
+
657
+ @classmethod
658
+ def from_yaml(cls, entry: str | dict | InstallationSkillRef):
659
+ if isinstance(entry, cls):
660
+ return entry
661
+ if isinstance(entry, str):
662
+ return cls(name=entry)
663
+ return cls(**entry)
664
+
665
+ @property
666
+ def as_yaml(self) -> str | dict:
667
+ return {"name": self.name, "defer_loading": self.defer_loading}
668
+
669
+
614
670
  @dataclasses.dataclass(kw_only=True)
615
671
  class RoomSkillsConfig:
616
672
  """Select native capabilities for a room."""
617
673
 
618
- installation_skill_names: list[str] = _default_list_field()
674
+ installation_skill_names: list[InstallationSkillRef] = (
675
+ _default_list_field()
676
+ )
619
677
  _skill_configs: SkillConfigMap = _default_dict_field()
620
678
  _installation_config: config_installation.InstallationConfig = (
621
679
  _no_repr_no_compare_none()
622
680
  )
623
681
  _config_path: pathlib.Path = None
624
682
 
683
+ def __post_init__(self) -> None:
684
+ # Callers which build the config directly, rather than via
685
+ # 'from_yaml', may spell the entries as bare names.
686
+ self.installation_skill_names = [
687
+ InstallationSkillRef.from_yaml(entry)
688
+ for entry in self.installation_skill_names
689
+ ]
690
+
625
691
  @staticmethod
626
692
  def _check_installation_skills(
627
693
  installation_config: config_installation.InstallationConfig,
628
694
  config_path: pathlib.Path,
629
- config_dict: dict,
695
+ refs: typing.Sequence[InstallationSkillRef],
630
696
  ) -> None:
631
- requested = set(config_dict.get("installation_skill_names", ()))
697
+ requested = {ref.name for ref in refs}
632
698
  available = set(installation_config.skill_configs)
633
699
  if missing := requested - available:
634
700
  raise MissingSkillNames(
@@ -645,11 +711,16 @@ class RoomSkillsConfig:
645
711
  config_dict: dict,
646
712
  ):
647
713
  try:
714
+ refs = [
715
+ InstallationSkillRef.from_yaml(entry)
716
+ for entry in config_dict.get("installation_skill_names", ())
717
+ ]
648
718
  cls._check_installation_skills(
649
719
  installation_config,
650
720
  config_path,
651
- config_dict,
721
+ refs,
652
722
  )
723
+ config_dict["installation_skill_names"] = refs
653
724
  config_dict["_skill_configs"] = extract_skill_configs(
654
725
  installation_config,
655
726
  config_path,
@@ -671,7 +742,9 @@ class RoomSkillsConfig:
671
742
  def as_yaml(self) -> dict:
672
743
  result = {}
673
744
  if self.installation_skill_names:
674
- result["installation_skill_names"] = self.installation_skill_names
745
+ result["installation_skill_names"] = [
746
+ ref.as_yaml for ref in self.installation_skill_names
747
+ ]
675
748
  if self._skill_configs:
676
749
  result["skill_configs"] = [
677
750
  config.as_yaml for config in self._skill_configs.values()
@@ -682,8 +755,10 @@ class RoomSkillsConfig:
682
755
  def skill_configs(self) -> SkillConfigMap:
683
756
  installation_configs = self._installation_config.skill_configs
684
757
  return {
685
- name: installation_configs[name]
686
- for name in self.installation_skill_names
758
+ ref.name: installation_configs[ref.name].with_defer_loading(
759
+ ref.defer_loading
760
+ )
761
+ for ref in self.installation_skill_names
687
762
  } | self._skill_configs
688
763
 
689
764
  @property
@@ -178,7 +178,7 @@ class Installation:
178
178
  type_info = found.setdefault(provider_type, {})
179
179
  base_url = agent_config.llm_provider_base_url
180
180
  url_models = type_info.setdefault(base_url, set())
181
- url_models.add(agent_config.model_name)
181
+ url_models.add(agent_config.llm_model_name)
182
182
 
183
183
  return found
184
184
 
@@ -174,7 +174,7 @@ ConfiguredSkills = dict[str, Skill]
174
174
 
175
175
  class DefaultAgent(pydantic.BaseModel):
176
176
  id: str
177
- model_name: str
177
+ model_name: str | None
178
178
  retries: int
179
179
  system_prompt: str | None
180
180
  provider_type: config_agents.LLMProviderType # enum, not dataclass
@@ -187,7 +187,7 @@ class DefaultAgent(pydantic.BaseModel):
187
187
  llm_provider_kw = agent_config.llm_provider_kw
188
188
  return cls(
189
189
  id=agent_config.id,
190
- model_name=agent_config.model_name,
190
+ model_name=agent_config.llm_model_name,
191
191
  retries=agent_config.retries,
192
192
  system_prompt=agent_config.get_system_prompt(),
193
193
  provider_type=agent_config.provider_type,
@@ -52,7 +52,7 @@ def get_quiz_judge_agent(quiz: config_quizzes.QuizConfig):
52
52
  model_provider = ollama_providers.OllamaProvider(**llm_provider_kw)
53
53
 
54
54
  ollama_model = openai_models.OpenAIChatModel(
55
- model_name=quiz.judge_agent.model_name,
55
+ model_name=quiz.judge_agent.llm_model_name,
56
56
  provider=model_provider,
57
57
  )
58
58
 
@@ -55,11 +55,30 @@ Do NOT use the sandbox if **any** of these is true:
55
55
  - `run_python(script, environment_name=None, timeout=None)` — run a Python
56
56
  script. `script` is the full source as one string.
57
57
 
58
- When calling `run` and `run_python`, `environment_name` and `timeout` are both
59
- optional and fall back to the values this skill was configured with.
60
- Always pass `environment_name` explicitly, using a name that
61
- `list_environments` returned — the configured default is not visible to you
62
- and may not be the environment you want.
58
+ ## Environment names come only from `list_environments`
59
+
60
+ **Never guess an environment name.** The only values `environment_name`
61
+ accepts are the exact `name` strings that a `list_environments` call
62
+ returned **to you, in this conversation**.
63
+
64
+ - Call `list_environments` before the first `run` or `run_python` of a
65
+ turn. It is cheap; skipping it is the most common way this skill fails.
66
+ - Every environment name written in this document is a placeholder —
67
+ `<name from list_environments>` — standing in for a name you have yet to
68
+ look up. No placeholder names a real environment.
69
+ - A name that appears in a task, a filename, a library you plan to import,
70
+ or an earlier conversation is not an environment name. Neither is the
71
+ name of a package: an environment's `name` and its `dependencies` are
72
+ different things, and are usually spelled differently.
73
+ - Passing a name that was not in the `list_environments` result fails the
74
+ call. The failure names the environments you may actually use — call
75
+ `list_environments` and retry with one of those, rather than guessing
76
+ again.
77
+
78
+ `environment_name` and `timeout` are both optional and fall back to the
79
+ values this skill was configured with. Always pass `environment_name`
80
+ explicitly: the configured default is not visible to you and may not be
81
+ the environment you want.
63
82
 
64
83
  ## Workflow
65
84
 
@@ -68,9 +87,9 @@ and may not be the environment you want.
68
87
 
69
88
  ```python
70
89
  {
71
- "name": "pandas",
72
- "description": "Minimal Python with Pandas",
73
- "dependencies": ["pandas"],
90
+ "name": "<name from list_environments>",
91
+ "description": "<what this environment is for>",
92
+ "dependencies": ["<installed package>"],
74
93
  }
75
94
  ```
76
95
 
@@ -78,10 +97,11 @@ and may not be the environment you want.
78
97
  - If the list is empty, stop and tell the user the skill
79
98
  is not configured — do not proceed.
80
99
  - If the list contains exactly one environment, use its `name`.
81
- - Otherwise, pick the `name` of the first environment whose `dependencies`
82
- include a library the task needs (e.g. `pandas` for tabular data,
83
- `numpy` for numeric work, `pillow` for images). If no environments
84
- match, use the `name` of the first entry in the list.
100
+ - Otherwise, compare the task against each entry's `dependencies` —
101
+ a `pandas` dependency suits tabular data, `numpy` numeric work,
102
+ `pillow` images — and use the `name` of the first entry that matches.
103
+ Use the entry's `name`, not the name of the dependency that matched.
104
+ If no entry matches, use the `name` of the first entry in the list.
85
105
 
86
106
  2. **List files in both volumes.** This step is mandatory — do not skip it,
87
107
  even if the task seems to involve one volume. Run both:
@@ -113,7 +133,7 @@ and may not be the environment you want.
113
133
 
114
134
  ```python
115
135
  run(command=["head", "-n", "5", "/sandbox/volumes/thread/orders.csv"],
116
- environment_name="<selected-environment>")
136
+ environment_name="<name from list_environments>")
117
137
  ```
118
138
 
119
139
  (or `["wc", "-l", <path>]`, `["file", <path>]`, etc.). For anything
@@ -125,7 +145,8 @@ and may not be the environment you want.
125
145
  argument to `run_python`:
126
146
 
127
147
  ```python
128
- run_python(script="<python source>", environment_name="<selected-environment>")
148
+ run_python(script="<python source>",
149
+ environment_name="<name from list_environments>")
129
150
  ```
130
151
 
131
152
  Write the whole program as a single string, and use real newlines between
@@ -147,7 +168,10 @@ and may not be the environment you want.
147
168
  ```
148
169
 
149
170
  5. **On failure.**
150
- - Change exactly one thing per retry.
171
+ - If the failure says the environment is not available, you guessed a
172
+ name. Call `list_environments` and retry with a name it returned —
173
+ do not try a different guess.
174
+ - Otherwise change exactly one thing per retry.
151
175
  - After 3 failed runs, stop. Report the error to the user (paste the
152
176
  `Exited with code: <N>` line and any traceback), instead of retrying
153
177
  further.
@@ -168,8 +192,10 @@ and may not be the environment you want.
168
192
  Task: user uploads `orders.csv` and asks "what's the total order value?".
169
193
  A full run looks like:
170
194
 
171
- 1. `list_environments()` — shows one environment named `pandas`
172
- with `pandas` in its dependencies. Use it.
195
+ 1. `list_environments()` — returns exactly one environment, whose
196
+ `dependencies` include `pandas`. Use that entry's `name`; in step 3 it
197
+ is written as `<name from list_environments>`, because what the name
198
+ actually is can only be read off this call's result.
173
199
 
174
200
  2. `list_volume_files("thread")` — returns
175
201
  `["/sandbox/volumes/thread/orders.csv"]`.
@@ -184,7 +210,7 @@ A full run looks like:
184
210
  df = pd.read_csv('/sandbox/volumes/thread/orders.csv')
185
211
  print(f"Total: {df['amount'].sum():.2f}")
186
212
  """,
187
- environment_name="pandas",
213
+ environment_name="<name from list_environments>",
188
214
  )
189
215
  ```
190
216