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.
- {soliplex-0.71/src/soliplex.egg-info → soliplex-0.72}/PKG-INFO +2 -2
- {soliplex-0.71 → soliplex-0.72}/pyproject.toml +2 -4
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/agents.py +9 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/agui/persistence.py +151 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/authz/__init__.py +9 -2
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/authz/persistence.py +20 -5
- soliplex-0.72/src/soliplex/cli/ask.py +311 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/cli/main.py +3 -1
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/cli/serve.py +9 -7
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/installation.py +15 -0
- soliplex-0.72/src/soliplex/config/middleware.py +115 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/rooms.py +5 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/examples.py +50 -3
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/installation.py +6 -12
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/loggers.py +72 -7
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/main.py +39 -23
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/views/agui.py +6 -115
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/views/authz.py +20 -8
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/views/installation.py +20 -8
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/views/room_file_uploads.py +5 -2
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/views/rooms.py +23 -2
- {soliplex-0.71 → soliplex-0.72/src/soliplex.egg-info}/PKG-INFO +2 -2
- {soliplex-0.71 → soliplex-0.72}/src/soliplex.egg-info/SOURCES.txt +2 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex.egg-info/requires.txt +1 -1
- {soliplex-0.71 → soliplex-0.72}/LICENSE +0 -0
- {soliplex-0.71 → soliplex-0.72}/MANIFEST.in +0 -0
- {soliplex-0.71 → soliplex-0.72}/README.md +0 -0
- {soliplex-0.71 → soliplex-0.72}/example/skills/bare-bones/SKILL.md +0 -0
- {soliplex-0.71 → soliplex-0.72}/setup.cfg +0 -0
- {soliplex-0.71 → soliplex-0.72}/skills/soliplex-docs/SKILL.md +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/agui/__init__.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/agui/parser.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/agui/schema.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/agui/util.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/authn.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/authz/schema.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/cli/admin_users.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/cli/audit.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/cli/cli_util.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/cli/ollama.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/cli/room_authz.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/cli/types.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/completions.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/__init__.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/_utils.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/agents.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/agui.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/authsystem.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/completions.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/exceptions.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/logfire.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/meta.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/quizzes.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/rag.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/routing.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/secrets.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/skills.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/config/tools.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/log_ingest.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/mcp_auth.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/mcp_client.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/mcp_server.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/models.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/ollama.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/quizzes.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/rag_audit.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/sandbox_audit.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/secrets.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/skills/bwrap_sandbox/SKILL.md +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/skills/bwrap_sandbox/__init__.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/titles.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/tools/__init__.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/tools/agui_run_feedback.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/tools/file_uploads.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/tools/rag.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/tui/cli.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/tui/main.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/tui/rest_api.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/tui/serve.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/util.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/views/__init__.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/views/authn.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/views/completions.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/views/log_ingest.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/views/quizzes.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/views/sandbox_workdirs.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/views/stats.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/views/streaming.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex/views/thread_file_uploads.py +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex.egg-info/dependency_links.txt +0 -0
- {soliplex-0.71 → soliplex-0.72}/src/soliplex.egg-info/entry_points.txt +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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(
|
|
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
|
-
|
|
235
|
+
result = allow_deny == authz.AllowDeny.ALLOW
|
|
228
236
|
else:
|
|
229
|
-
|
|
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
|
-
#
|
|
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)
|