soliplex 0.71__tar.gz → 0.72__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 (92) hide show
  1. {soliplex-0.71/src/soliplex.egg-info → soliplex-0.72}/PKG-INFO +2 -2
  2. {soliplex-0.71 → soliplex-0.72}/pyproject.toml +2 -4
  3. {soliplex-0.71 → soliplex-0.72}/src/soliplex/agents.py +9 -0
  4. {soliplex-0.71 → soliplex-0.72}/src/soliplex/agui/persistence.py +151 -0
  5. {soliplex-0.71 → soliplex-0.72}/src/soliplex/authz/__init__.py +9 -2
  6. {soliplex-0.71 → soliplex-0.72}/src/soliplex/authz/persistence.py +20 -5
  7. soliplex-0.72/src/soliplex/cli/ask.py +311 -0
  8. {soliplex-0.71 → soliplex-0.72}/src/soliplex/cli/main.py +3 -1
  9. {soliplex-0.71 → soliplex-0.72}/src/soliplex/cli/serve.py +9 -7
  10. {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/installation.py +15 -0
  11. soliplex-0.72/src/soliplex/config/middleware.py +115 -0
  12. {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/rooms.py +5 -0
  13. {soliplex-0.71 → soliplex-0.72}/src/soliplex/examples.py +50 -3
  14. {soliplex-0.71 → soliplex-0.72}/src/soliplex/installation.py +6 -12
  15. {soliplex-0.71 → soliplex-0.72}/src/soliplex/loggers.py +72 -7
  16. {soliplex-0.71 → soliplex-0.72}/src/soliplex/main.py +39 -23
  17. {soliplex-0.71 → soliplex-0.72}/src/soliplex/views/agui.py +6 -115
  18. {soliplex-0.71 → soliplex-0.72}/src/soliplex/views/authz.py +20 -8
  19. {soliplex-0.71 → soliplex-0.72}/src/soliplex/views/installation.py +20 -8
  20. {soliplex-0.71 → soliplex-0.72}/src/soliplex/views/room_file_uploads.py +5 -2
  21. {soliplex-0.71 → soliplex-0.72}/src/soliplex/views/rooms.py +23 -2
  22. {soliplex-0.71 → soliplex-0.72/src/soliplex.egg-info}/PKG-INFO +2 -2
  23. {soliplex-0.71 → soliplex-0.72}/src/soliplex.egg-info/SOURCES.txt +2 -0
  24. {soliplex-0.71 → soliplex-0.72}/src/soliplex.egg-info/requires.txt +1 -1
  25. {soliplex-0.71 → soliplex-0.72}/LICENSE +0 -0
  26. {soliplex-0.71 → soliplex-0.72}/MANIFEST.in +0 -0
  27. {soliplex-0.71 → soliplex-0.72}/README.md +0 -0
  28. {soliplex-0.71 → soliplex-0.72}/example/skills/bare-bones/SKILL.md +0 -0
  29. {soliplex-0.71 → soliplex-0.72}/setup.cfg +0 -0
  30. {soliplex-0.71 → soliplex-0.72}/skills/soliplex-docs/SKILL.md +0 -0
  31. {soliplex-0.71 → soliplex-0.72}/src/soliplex/agui/__init__.py +0 -0
  32. {soliplex-0.71 → soliplex-0.72}/src/soliplex/agui/parser.py +0 -0
  33. {soliplex-0.71 → soliplex-0.72}/src/soliplex/agui/schema.py +0 -0
  34. {soliplex-0.71 → soliplex-0.72}/src/soliplex/agui/util.py +0 -0
  35. {soliplex-0.71 → soliplex-0.72}/src/soliplex/authn.py +0 -0
  36. {soliplex-0.71 → soliplex-0.72}/src/soliplex/authz/schema.py +0 -0
  37. {soliplex-0.71 → soliplex-0.72}/src/soliplex/cli/admin_users.py +0 -0
  38. {soliplex-0.71 → soliplex-0.72}/src/soliplex/cli/audit.py +0 -0
  39. {soliplex-0.71 → soliplex-0.72}/src/soliplex/cli/cli_util.py +0 -0
  40. {soliplex-0.71 → soliplex-0.72}/src/soliplex/cli/ollama.py +0 -0
  41. {soliplex-0.71 → soliplex-0.72}/src/soliplex/cli/room_authz.py +0 -0
  42. {soliplex-0.71 → soliplex-0.72}/src/soliplex/cli/types.py +0 -0
  43. {soliplex-0.71 → soliplex-0.72}/src/soliplex/completions.py +0 -0
  44. {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/__init__.py +0 -0
  45. {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/_utils.py +0 -0
  46. {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/agents.py +0 -0
  47. {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/agui.py +0 -0
  48. {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/authsystem.py +0 -0
  49. {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/completions.py +0 -0
  50. {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/exceptions.py +0 -0
  51. {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/logfire.py +0 -0
  52. {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/meta.py +0 -0
  53. {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/quizzes.py +0 -0
  54. {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/rag.py +0 -0
  55. {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/routing.py +0 -0
  56. {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/secrets.py +0 -0
  57. {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/skills.py +0 -0
  58. {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/tools.py +0 -0
  59. {soliplex-0.71 → soliplex-0.72}/src/soliplex/log_ingest.py +0 -0
  60. {soliplex-0.71 → soliplex-0.72}/src/soliplex/mcp_auth.py +0 -0
  61. {soliplex-0.71 → soliplex-0.72}/src/soliplex/mcp_client.py +0 -0
  62. {soliplex-0.71 → soliplex-0.72}/src/soliplex/mcp_server.py +0 -0
  63. {soliplex-0.71 → soliplex-0.72}/src/soliplex/models.py +0 -0
  64. {soliplex-0.71 → soliplex-0.72}/src/soliplex/ollama.py +0 -0
  65. {soliplex-0.71 → soliplex-0.72}/src/soliplex/quizzes.py +0 -0
  66. {soliplex-0.71 → soliplex-0.72}/src/soliplex/rag_audit.py +0 -0
  67. {soliplex-0.71 → soliplex-0.72}/src/soliplex/sandbox_audit.py +0 -0
  68. {soliplex-0.71 → soliplex-0.72}/src/soliplex/secrets.py +0 -0
  69. {soliplex-0.71 → soliplex-0.72}/src/soliplex/skills/bwrap_sandbox/SKILL.md +0 -0
  70. {soliplex-0.71 → soliplex-0.72}/src/soliplex/skills/bwrap_sandbox/__init__.py +0 -0
  71. {soliplex-0.71 → soliplex-0.72}/src/soliplex/titles.py +0 -0
  72. {soliplex-0.71 → soliplex-0.72}/src/soliplex/tools/__init__.py +0 -0
  73. {soliplex-0.71 → soliplex-0.72}/src/soliplex/tools/agui_run_feedback.py +0 -0
  74. {soliplex-0.71 → soliplex-0.72}/src/soliplex/tools/file_uploads.py +0 -0
  75. {soliplex-0.71 → soliplex-0.72}/src/soliplex/tools/rag.py +0 -0
  76. {soliplex-0.71 → soliplex-0.72}/src/soliplex/tui/cli.py +0 -0
  77. {soliplex-0.71 → soliplex-0.72}/src/soliplex/tui/main.py +0 -0
  78. {soliplex-0.71 → soliplex-0.72}/src/soliplex/tui/rest_api.py +0 -0
  79. {soliplex-0.71 → soliplex-0.72}/src/soliplex/tui/serve.py +0 -0
  80. {soliplex-0.71 → soliplex-0.72}/src/soliplex/util.py +0 -0
  81. {soliplex-0.71 → soliplex-0.72}/src/soliplex/views/__init__.py +0 -0
  82. {soliplex-0.71 → soliplex-0.72}/src/soliplex/views/authn.py +0 -0
  83. {soliplex-0.71 → soliplex-0.72}/src/soliplex/views/completions.py +0 -0
  84. {soliplex-0.71 → soliplex-0.72}/src/soliplex/views/log_ingest.py +0 -0
  85. {soliplex-0.71 → soliplex-0.72}/src/soliplex/views/quizzes.py +0 -0
  86. {soliplex-0.71 → soliplex-0.72}/src/soliplex/views/sandbox_workdirs.py +0 -0
  87. {soliplex-0.71 → soliplex-0.72}/src/soliplex/views/stats.py +0 -0
  88. {soliplex-0.71 → soliplex-0.72}/src/soliplex/views/streaming.py +0 -0
  89. {soliplex-0.71 → soliplex-0.72}/src/soliplex/views/thread_file_uploads.py +0 -0
  90. {soliplex-0.71 → soliplex-0.72}/src/soliplex.egg-info/dependency_links.txt +0 -0
  91. {soliplex-0.71 → soliplex-0.72}/src/soliplex.egg-info/entry_points.txt +0 -0
  92. {soliplex-0.71 → soliplex-0.72}/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.71
3
+ Version: 0.72
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
@@ -19,7 +19,7 @@ Requires-Dist: fastapi
19
19
  Requires-Dist: fastmcp<3.4,>=3.3.0
20
20
  Requires-Dist: fakeredis!=2.35.0
21
21
  Requires-Dist: greenlet
22
- Requires-Dist: haiku.rag-slim<0.65,>=0.64
22
+ Requires-Dist: haiku.rag-slim<0.66,>=0.65
23
23
  Requires-Dist: haiku-skills<0.19,>=0.18.0
24
24
  Requires-Dist: idna>=3.15
25
25
  Requires-Dist: itsdangerous
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "soliplex"
7
- version = "0.71"
7
+ version = "0.72"
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"
@@ -25,7 +25,7 @@ dependencies = [
25
25
  "fastmcp >= 3.3.0, < 3.4",
26
26
  "fakeredis != 2.35.0", # brownbag
27
27
  "greenlet",
28
- "haiku.rag-slim >= 0.64, < 0.65",
28
+ "haiku.rag-slim >= 0.65, < 0.66",
29
29
  "haiku-skills >= 0.18.0, < 0.19",
30
30
  "idna >= 3.15",
31
31
  "itsdangerous",
@@ -92,8 +92,6 @@ markers = [
92
92
  [tool.coverage.run]
93
93
  omit = [
94
94
  "src/soliplex/cli/__init__.py",
95
-
96
- "src/soliplex/examples.py",
97
95
  "src/soliplex/tui.py",
98
96
  ]
99
97
 
@@ -150,3 +150,12 @@ def get_agent_from_configs(
150
150
  mcp_client_toolset_configs=mcp_client_toolset_configs,
151
151
  skill_toolset_config=skill_toolset_config,
152
152
  )
153
+
154
+
155
+ def find_skill_toolset(
156
+ agent: pydantic_ai.Agent,
157
+ ) -> hs_agent.SkillToolset | None:
158
+ for toolset in getattr(agent, "toolsets", ()):
159
+ if isinstance(toolset, hs_agent.SkillToolset):
160
+ return toolset
161
+ return None
@@ -1,14 +1,20 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import collections.abc
3
4
  import contextlib
4
5
  import datetime
5
6
  import typing
6
7
 
8
+ import logfire
7
9
  from ag_ui import core as agui_core
10
+ from haiku.skills import agent as hs_agent
11
+ from sqlalchemy import exc as sqla_exc
8
12
  from sqlalchemy import sql as sqla_sql
9
13
  from sqlalchemy.ext import asyncio as sqla_asyncio
10
14
 
11
15
  from soliplex import agui
16
+ from soliplex import loggers
17
+ from soliplex import rag_audit
12
18
  from soliplex.agui import persistence as agui_persistence
13
19
  from soliplex.agui import schema as agui_schema
14
20
  from soliplex.agui import util as agui_util
@@ -855,3 +861,148 @@ class ThreadStorage(agui.ThreadStorage):
855
861
  )
856
862
 
857
863
  return history_entry
864
+
865
+
866
+ # --------------------------------------------------------------------------
867
+ # Engine-owning run-persistence helpers
868
+ #
869
+ # Each builds its own short-lived session (the one bound to a request's
870
+ # lifetime may already be closed, e.g. after an early connection reset) and
871
+ # owns its commit: 'ThreadStorage' methods no longer commit on their own.
872
+ # Committing per event is what lets a reconnecting client's poll observe
873
+ # events incrementally.
874
+ # --------------------------------------------------------------------------
875
+
876
+
877
+ async def capture_usage_after_stream(
878
+ result,
879
+ *,
880
+ sqla_engine,
881
+ user_name: str,
882
+ room_id: str,
883
+ thread_id: str,
884
+ run_id: str,
885
+ ):
886
+ """Save the run usage to the database."""
887
+ usage = getattr(result, "usage", None)
888
+
889
+ if usage is not None:
890
+ async with sqla_asyncio.AsyncSession(bind=sqla_engine) as session:
891
+ the_threads = ThreadStorage(session)
892
+
893
+ async with session.begin():
894
+ await the_threads.save_run_usage(
895
+ user_name=user_name,
896
+ room_id=room_id,
897
+ thread_id=thread_id,
898
+ run_id=run_id,
899
+ input_tokens=usage.input_tokens,
900
+ output_tokens=usage.output_tokens,
901
+ requests=usage.requests,
902
+ tool_calls=usage.tool_calls,
903
+ )
904
+
905
+
906
+ async def save_single_event(
907
+ sqla_engine,
908
+ user_name: str,
909
+ room_id: str,
910
+ thread_id: str,
911
+ run_id: str,
912
+ event,
913
+ ):
914
+ """Save a single run event to the database (incremental persistence)."""
915
+ async with sqla_asyncio.AsyncSession(bind=sqla_engine) as session:
916
+ the_threads = ThreadStorage(session)
917
+
918
+ async with session.begin():
919
+ await the_threads.save_single_event(
920
+ user_name=user_name,
921
+ room_id=room_id,
922
+ thread_id=thread_id,
923
+ run_id=run_id,
924
+ event=event,
925
+ )
926
+
927
+
928
+ async def finish_run(
929
+ sqla_engine,
930
+ user_name: str,
931
+ room_id: str,
932
+ thread_id: str,
933
+ run_id: str,
934
+ ):
935
+ """Mark a run as finished in the database."""
936
+ async with sqla_asyncio.AsyncSession(bind=sqla_engine) as session:
937
+ the_threads = ThreadStorage(session)
938
+
939
+ async with session.begin():
940
+ await the_threads.finish_run(
941
+ user_name=user_name,
942
+ room_id=room_id,
943
+ thread_id=thread_id,
944
+ run_id=run_id,
945
+ )
946
+
947
+
948
+ async def drive_agui_turn(
949
+ *,
950
+ adapter,
951
+ skill_toolset: hs_agent.SkillToolset | None,
952
+ engine,
953
+ user_name: str,
954
+ room_id: str,
955
+ thread_id: str,
956
+ run_id: str,
957
+ claims: dict | None = None,
958
+ rag_db_paths: dict[str, str] | None = None,
959
+ run_stream_kwargs: dict | None = None,
960
+ ) -> collections.abc.AsyncIterator:
961
+ """Drive one room-agent turn as an AG-UI event stream.
962
+
963
+ An async generator: for each event it records any RAG access (skill
964
+ retrievals surface as AG-UI activity events) and persists the event
965
+ (via :func:`save_single_event`), then yields it. Callers own
966
+ presentation (an SSE queue, or collecting the assistant text) and any
967
+ post-run work such as :func:`finish_run`, usage capture, or title
968
+ generation.
969
+
970
+ ``run_stream_kwargs`` is forwarded verbatim to
971
+ ``haiku.skills.agent.run_agui_stream`` (``deps``, ``conversation_id``,
972
+ ``on_complete``).
973
+ """
974
+ auditor = rag_audit.RagAccessAuditor(
975
+ loggers.RAGAccessAuditLog(
976
+ claims=claims or {},
977
+ room_id=room_id,
978
+ thread_id=thread_id,
979
+ run_id=run_id,
980
+ ),
981
+ (rag_db_paths or {}).get,
982
+ )
983
+
984
+ async with hs_agent.run_agui_stream(
985
+ adapter,
986
+ toolset=skill_toolset,
987
+ **(run_stream_kwargs or {}),
988
+ ) as event_stream:
989
+ compacted = agui.compact_event_stream(event_stream)
990
+ async for event in compacted:
991
+ auditor.handle(event)
992
+
993
+ try:
994
+ await save_single_event(
995
+ engine,
996
+ user_name=user_name,
997
+ room_id=room_id,
998
+ thread_id=thread_id,
999
+ run_id=run_id,
1000
+ event=event,
1001
+ )
1002
+ except sqla_exc.SQLAlchemyError as sa_exc:
1003
+ logfire.error(
1004
+ "Error saving event: {error_message}",
1005
+ error_message=str(sa_exc),
1006
+ )
1007
+
1008
+ yield event
@@ -231,11 +231,18 @@ class AdminUserPolicy(abc.ABC):
231
231
  """
232
232
 
233
233
  @abc.abstractmethod
234
- async def check_admin_access(self, user_token: UserToken) -> bool:
234
+ async def check_admin_access(
235
+ self,
236
+ user_token: UserToken,
237
+ *,
238
+ resource: str,
239
+ action: str,
240
+ ) -> bool:
235
241
  """Is the user represented by 'user_token' an admin user?
236
242
 
237
243
  Matches 'user_token' against each admin entry's JSONPath query;
238
- the user is an admin when any query matches.
244
+ the user is an admin when any query matches. 'resource' / 'action'
245
+ name the privileged operation being gated (for the audit record).
239
246
  """
240
247
 
241
248
 
@@ -186,14 +186,21 @@ class AdminUserPolicy(_SessionPolicy, authz.AdminUserPolicy):
186
186
  async def check_admin_access(
187
187
  self,
188
188
  user_token: authz.UserToken,
189
+ *,
190
+ resource: str,
191
+ action: str,
189
192
  ) -> bool:
190
- """Is the user represented by 'user_token' an admin user?"""
193
+ """Is the user represented by 'user_token' an admin user?
194
+
195
+ 'resource' / 'action' name the privileged operation being gated;
196
+ they are folded into the emitted 'admin access' audit record.
197
+ """
191
198
  async with self.session as session:
192
199
  is_admin = await _user_is_admin(user_token, session)
193
200
  if is_admin:
194
- self._audit.admin_access_allowed()
201
+ self._audit.admin_access_allowed(resource=resource, action=action)
195
202
  else:
196
- self._audit.admin_access_denied()
203
+ self._audit.admin_access_denied(resource=resource, action=action)
197
204
  return is_admin
198
205
 
199
206
 
@@ -205,6 +212,7 @@ class RoomAuthorizationPolicy(_SessionPolicy, authz.RoomAuthorizationPolicy):
205
212
  ):
206
213
  super().__init__(session)
207
214
  self._audit = loggers.RoomAuthzAuditLog(claims=claims)
215
+ self._access_audit = loggers.RoomAccessAuditLog(claims=claims)
208
216
 
209
217
  async def check_room_access(
210
218
  self,
@@ -224,9 +232,16 @@ class RoomAuthorizationPolicy(_SessionPolicy, authz.RoomAuthorizationPolicy):
224
232
  if policy is not None:
225
233
  await policy.awaitable_attrs.acl_entries
226
234
  allow_deny = policy.check_token(user_token)
227
- return allow_deny == authz.AllowDeny.ALLOW
235
+ result = allow_deny == authz.AllowDeny.ALLOW
228
236
  else:
229
- return True
237
+ result = True
238
+
239
+ if result:
240
+ self._access_audit.room_access_allowed(room_id)
241
+ else:
242
+ self._access_audit.room_access_denied(room_id)
243
+
244
+ return result
230
245
 
231
246
  async def filter_room_ids(
232
247
  self,
@@ -0,0 +1,311 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import dataclasses
5
+ import json
6
+ import pathlib
7
+ import sys
8
+ import typing
9
+ import uuid
10
+
11
+ import typer
12
+ from ag_ui import core as agui_core
13
+ from pydantic_ai.ui import ag_ui as ai_ag_ui
14
+ from sqlalchemy.ext import asyncio as sqla_asyncio
15
+
16
+ from soliplex import agents
17
+ from soliplex import installation
18
+ from soliplex import loggers
19
+ from soliplex import models
20
+ from soliplex import util
21
+ from soliplex.agui import persistence as agui_persistence
22
+ from soliplex.agui import schema as agui_schema
23
+ from soliplex.cli import cli_util
24
+ from soliplex.cli import types
25
+
26
+ app = typer.Typer()
27
+
28
+
29
+ @dataclasses.dataclass
30
+ class _AskResult:
31
+ thread_id: str
32
+ ok: bool
33
+ response: str | None = None
34
+ usage: typing.Any = None
35
+ error: str | None = None
36
+
37
+
38
+ def _fail(json_output: bool, message: str) -> typing.NoReturn:
39
+ """Report a failure on stderr and exit non-zero.
40
+
41
+ Keeps the success channel (stdout) clean for scripting: diagnostics go
42
+ to stderr, either as a human line or a ``{"error": ...}`` object under
43
+ ``--json``.
44
+ """
45
+ if json_output:
46
+ print(json.dumps({"error": message}), file=sys.stderr)
47
+ else:
48
+ typer.echo(f"Error: {message}", err=True)
49
+ raise typer.Exit(1)
50
+
51
+
52
+ def _check_room_id(the_installation, room_id, json_output):
53
+ room_configs = the_installation._config.room_configs
54
+ if room_id in room_configs:
55
+ return
56
+
57
+ configured = ", ".join(sorted(room_configs)) or "(none)"
58
+ _fail(
59
+ json_output,
60
+ f"No room configured with id '{room_id}'. "
61
+ f"Configured rooms: {configured}",
62
+ )
63
+
64
+
65
+ def _usage_as_dict(usage) -> dict:
66
+ if usage is None:
67
+ return {}
68
+ return {
69
+ "input_tokens": usage.input_tokens,
70
+ "output_tokens": usage.output_tokens,
71
+ }
72
+
73
+
74
+ async def _create_thread_run(engine, *, user_name, email, room_id):
75
+ """Create a persisted thread with its initial run; return their ids."""
76
+ async with sqla_asyncio.AsyncSession(bind=engine) as session:
77
+ async with session.begin():
78
+ thread = await agui_persistence.ThreadStorage(session).new_thread(
79
+ user_name=user_name,
80
+ email=email,
81
+ room_id=room_id,
82
+ initial_run=True,
83
+ )
84
+ (run,) = await thread.list_runs()
85
+ return thread.thread_id, run.run_id
86
+
87
+
88
+ async def _add_run_input(
89
+ engine, *, user_name, room_id, thread_id, run_id, run_input
90
+ ):
91
+ async with sqla_asyncio.AsyncSession(bind=engine) as session:
92
+ async with session.begin():
93
+ await agui_persistence.ThreadStorage(session).add_run_input(
94
+ user_name=user_name,
95
+ room_id=room_id,
96
+ thread_id=thread_id,
97
+ run_id=run_id,
98
+ run_input=run_input,
99
+ )
100
+
101
+
102
+ async def _run_ask(the_installation, room_id, prompt, claims) -> _AskResult:
103
+ """Run one room-agent turn over the AG-UI stream, auditing + persisting.
104
+
105
+ Skips per-room ACL enforcement (a trusted CLI operator), but records the
106
+ access and any RAG retrievals through the same audit log the web/MCP
107
+ paths use, and persists the thread/run/events so a future turn can build
108
+ on them.
109
+ """
110
+ user = models.UserProfile.from_user_claims(claims)
111
+ user_name = claims["actor"]
112
+
113
+ agent = await the_installation.get_agent_for_room(
114
+ room_id=room_id,
115
+ user=user,
116
+ )
117
+ room_config = await the_installation.get_room_config(
118
+ room_id=room_id,
119
+ user=user,
120
+ )
121
+
122
+ engine = installation._create_async_engine(
123
+ the_installation.thread_persistence_dburi_async,
124
+ json_serializer=util.serialize_sqla_json,
125
+ pool_pre_ping=True,
126
+ )
127
+ try:
128
+ async with engine.begin() as connection:
129
+ await connection.run_sync(agui_schema.Base.metadata.create_all)
130
+
131
+ thread_id, run_id = await _create_thread_run(
132
+ engine,
133
+ user_name=user_name,
134
+ email="<unknown>",
135
+ room_id=room_id,
136
+ )
137
+
138
+ audit = loggers.RoomAccessAuditLog(
139
+ claims=claims,
140
+ room_id=room_id,
141
+ thread_id=thread_id,
142
+ run_id=run_id,
143
+ )
144
+ # Record the access *before* the run begins, so an aborted or
145
+ # crashed run still leaves a trace that the agent was invoked.
146
+ audit.room_access_allowed(room_id)
147
+
148
+ run_input = agui_core.RunAgentInput(
149
+ thread_id=thread_id,
150
+ run_id=run_id,
151
+ state={},
152
+ messages=[
153
+ agui_core.UserMessage(
154
+ id=uuid.uuid4().hex,
155
+ role="user",
156
+ content=prompt,
157
+ ),
158
+ ],
159
+ tools=(),
160
+ context=(),
161
+ forwarded_props=None,
162
+ )
163
+ await _add_run_input(
164
+ engine,
165
+ user_name=user_name,
166
+ room_id=room_id,
167
+ thread_id=thread_id,
168
+ run_id=run_id,
169
+ run_input=run_input,
170
+ )
171
+
172
+ deps = await the_installation.get_agent_deps_for_room(
173
+ room_id=room_id,
174
+ user=user,
175
+ run_agent_input=run_input,
176
+ )
177
+ adapter = ai_ag_ui.AGUIAdapter(
178
+ agent=agent,
179
+ run_input=run_input,
180
+ accept=None,
181
+ )
182
+
183
+ captured: dict = {}
184
+
185
+ async def _on_complete(result):
186
+ captured["usage"] = getattr(result, "usage", None)
187
+ await agui_persistence.capture_usage_after_stream(
188
+ result,
189
+ sqla_engine=engine,
190
+ user_name=user_name,
191
+ room_id=room_id,
192
+ thread_id=thread_id,
193
+ run_id=run_id,
194
+ )
195
+
196
+ text_parts: list[str] = []
197
+ last_event = None
198
+ try:
199
+ async for event in agui_persistence.drive_agui_turn(
200
+ adapter=adapter,
201
+ skill_toolset=agents.find_skill_toolset(agent),
202
+ engine=engine,
203
+ user_name=user_name,
204
+ room_id=room_id,
205
+ thread_id=thread_id,
206
+ run_id=run_id,
207
+ claims=claims,
208
+ rag_db_paths=room_config.rag_db_paths,
209
+ run_stream_kwargs=dict(
210
+ deps=deps,
211
+ conversation_id=thread_id,
212
+ on_complete=_on_complete,
213
+ ),
214
+ ):
215
+ if event.type == agui_core.EventType.TEXT_MESSAGE_CONTENT:
216
+ text_parts.append(event.delta)
217
+ last_event = event
218
+
219
+ await agui_persistence.finish_run(
220
+ engine,
221
+ user_name=user_name,
222
+ room_id=room_id,
223
+ thread_id=thread_id,
224
+ run_id=run_id,
225
+ )
226
+ except Exception as exc:
227
+ audit.run_failed(str(exc))
228
+ raise
229
+ finally:
230
+ await engine.dispose()
231
+
232
+ if last_event is not None and (
233
+ last_event.type == agui_core.EventType.RUN_FINISHED
234
+ ):
235
+ audit.run_finished()
236
+ return _AskResult(
237
+ thread_id=thread_id,
238
+ ok=True,
239
+ response="".join(text_parts),
240
+ usage=captured.get("usage"),
241
+ )
242
+
243
+ if last_event is not None and (
244
+ last_event.type == agui_core.EventType.RUN_ERROR
245
+ ):
246
+ reason = last_event.message
247
+ else:
248
+ reason = "the run produced no result"
249
+ audit.run_failed(reason)
250
+ return _AskResult(thread_id=thread_id, ok=False, error=reason)
251
+
252
+
253
+ @app.command("ask")
254
+ def ask(
255
+ ctx: typer.Context,
256
+ installation_path: types.installation_path_type,
257
+ room_id: str,
258
+ prompt: str,
259
+ json_output: bool = typer.Option(
260
+ False,
261
+ "--json",
262
+ help=(
263
+ "Emit a JSON object (room_id, thread_id, prompt, response, "
264
+ "usage) instead of the plain-text response."
265
+ ),
266
+ ),
267
+ cli_log_config: pathlib.Path | None = cli_util.CLI_LOG_CONFIG_OPTION,
268
+ ):
269
+ """Send a single prompt to a room's agent and print the response.
270
+
271
+ Runs the room's agent in-process, skipping per-room ACL enforcement (a
272
+ trusted operator), but records the access -- and any RAG retrievals --
273
+ through the audit log (enable it with ``--cli-log-config``), and
274
+ persists the thread/run so a follow-up turn can build on it. Exits 0 on
275
+ success (response on stdout) and non-zero on failure (diagnostic on
276
+ stderr), so the call can be scripted.
277
+ """
278
+ cli_util._configure_cli_logging(cli_log_config)
279
+
280
+ the_installation = cli_util.get_installation(installation_path)
281
+
282
+ _check_room_id(the_installation, room_id, json_output)
283
+
284
+ claims = cli_util._audit_claims()
285
+
286
+ try:
287
+ the_installation.resolve_secrets()
288
+ the_installation.resolve_environment()
289
+ result = asyncio.run(
290
+ _run_ask(the_installation, room_id, prompt, claims)
291
+ )
292
+ except Exception as exc:
293
+ _fail(json_output, str(exc))
294
+
295
+ if not result.ok:
296
+ _fail(json_output, result.error)
297
+
298
+ if json_output:
299
+ print(
300
+ json.dumps(
301
+ {
302
+ "room_id": room_id,
303
+ "thread_id": result.thread_id,
304
+ "prompt": prompt,
305
+ "response": result.response,
306
+ "usage": _usage_as_dict(result.usage),
307
+ }
308
+ )
309
+ )
310
+ else:
311
+ print(result.response)
@@ -12,6 +12,7 @@ import yaml
12
12
  from soliplex import secrets
13
13
  from soliplex import util
14
14
  from soliplex.cli import admin_users
15
+ from soliplex.cli import ask
15
16
  from soliplex.cli import audit
16
17
  from soliplex.cli import cli_util
17
18
  from soliplex.cli import ollama
@@ -81,6 +82,7 @@ _hidden_alias("add-admin-user", admin_users.add_admin_user)
81
82
  _hidden_alias("show-room-authz", room_authz.show_room_authz)
82
83
 
83
84
  the_cli.add_typer(serve.app)
85
+ the_cli.add_typer(ask.app)
84
86
  the_cli.add_typer(audit.app)
85
87
  the_cli.add_typer(admin_users.app)
86
88
  the_cli.add_typer(room_authz.app)
@@ -158,5 +160,5 @@ def shell(
158
160
 
159
161
  the_cli.add_typer(misc_app)
160
162
 
161
- if __name__ == "__main__":
163
+ if __name__ == "__main__": # pragma NO COVER
162
164
  the_cli()
@@ -218,15 +218,21 @@ Passing this option now fails with a non-zero exit.
218
218
 
219
219
  reload_dirs = [str(rd) for rd in reload_dirs]
220
220
 
221
+ # 'soliplex.main.app_with_session' reads this env var in-process to
222
+ # decide the session cookie's 'Secure' attribute, so set it for both
223
+ # run paths (not just the uvicorn-factory contract below).
224
+ if insecure_session_cookie:
225
+ os.environ["_SOLIPLEX_INSECURE_SESSION_COOKIE"] = "Y"
226
+
221
227
  if reload or workers:
222
228
  # Work around uvicorn's aversion to passing arguments to the app
223
229
  # factory.
224
230
  #
225
231
  # N.B.: The environment variables set here are a private contract
226
232
  # between this command and the
227
- # 'soliplex.main.create_app_from_environment'
228
- # function: do not try setting them yourself, either
229
- # directly or via a '.env' file.
233
+ # 'soliplex.main.create_app_from_environment' /
234
+ # 'soliplex.main.app_with_session' functions: do not try
235
+ # setting them yourself, either directly or via a '.env' file.
230
236
  os.environ["_SOLIPLEX_INSTALLATION_PATH"] = str(installation_path)
231
237
 
232
238
  if no_auth_mode:
@@ -235,9 +241,6 @@ Passing this option now fails with a non-zero exit.
235
241
  if log_config is not None: # pass to the app, disable Uvicorn.
236
242
  os.environ["_SOLIPLEX_LOG_CONFIG_FILE"] = str(log_config)
237
243
 
238
- if insecure_session_cookie:
239
- os.environ["_SOLIPLEX_INSECURE_SESSION_COOKIE"] = "Y"
240
-
241
244
  if app_factory_name is None:
242
245
  app_factory_name = "soliplex.main:create_app_from_environment"
243
246
 
@@ -257,6 +260,5 @@ Passing this option now fails with a non-zero exit.
257
260
  installation_path=installation_path,
258
261
  no_auth_mode=no_auth_mode,
259
262
  log_config_file=log_config,
260
- session_https_only=not insecure_session_cookie,
261
263
  )
262
264
  uvicorn.run(app, **uvicorn_kw)