soliplex 0.75.3__tar.gz → 0.76__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 (96) hide show
  1. {soliplex-0.75.3/src/soliplex.egg-info → soliplex-0.76}/PKG-INFO +34 -34
  2. {soliplex-0.75.3 → soliplex-0.76}/pyproject.toml +4 -3
  3. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/agents.py +24 -6
  4. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/agui/__init__.py +29 -0
  5. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/agui/parser.py +33 -2
  6. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/agui/persistence.py +5 -1
  7. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/cli/admin_users.py +2 -2
  8. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/cli/audit.py +1 -1
  9. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/cli/room_authz.py +2 -2
  10. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/config/agents.py +100 -44
  11. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/config/installation.py +40 -10
  12. soliplex-0.76/src/soliplex/config/meta.py +687 -0
  13. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/config/middleware.py +17 -5
  14. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/config/quizzes.py +3 -1
  15. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/config/rag.py +78 -19
  16. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/config/rooms.py +24 -13
  17. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/config/routing.py +78 -37
  18. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/config/secrets.py +54 -3
  19. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/config/skills.py +133 -11
  20. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/config/tools.py +50 -12
  21. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/secrets.py +1 -1
  22. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/tools/agui_run_feedback.py +95 -6
  23. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/util.py +3 -3
  24. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/views/agui.py +7 -14
  25. {soliplex-0.75.3 → soliplex-0.76/src/soliplex.egg-info}/PKG-INFO +34 -34
  26. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex.egg-info/requires.txt +2 -2
  27. soliplex-0.75.3/src/soliplex/config/meta.py +0 -398
  28. {soliplex-0.75.3 → soliplex-0.76}/LICENSE +0 -0
  29. {soliplex-0.75.3 → soliplex-0.76}/MANIFEST.in +0 -0
  30. {soliplex-0.75.3 → soliplex-0.76}/README.md +31 -31
  31. {soliplex-0.75.3 → soliplex-0.76}/example/skills/bare-bones/SKILL.md +0 -0
  32. {soliplex-0.75.3 → soliplex-0.76}/example/skills/stanzas/SKILL.md +0 -0
  33. {soliplex-0.75.3 → soliplex-0.76}/setup.cfg +0 -0
  34. {soliplex-0.75.3 → soliplex-0.76}/skills/soliplex-docs/SKILL.md +0 -0
  35. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/agui/schema.py +0 -0
  36. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/agui/util.py +0 -0
  37. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/authn.py +0 -0
  38. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/authz/__init__.py +0 -0
  39. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/authz/persistence.py +0 -0
  40. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/authz/schema.py +0 -0
  41. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/capabilities/filesystem.py +0 -0
  42. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/capabilities/rag_audit.py +0 -0
  43. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/cli/ask.py +0 -0
  44. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/cli/cli_util.py +0 -0
  45. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/cli/main.py +0 -0
  46. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/cli/ollama.py +0 -0
  47. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/cli/serve.py +0 -0
  48. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/cli/types.py +0 -0
  49. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/completions.py +0 -0
  50. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/config/__init__.py +0 -0
  51. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/config/_utils.py +0 -0
  52. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/config/agui.py +0 -0
  53. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/config/authsystem.py +0 -0
  54. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/config/completions.py +0 -0
  55. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/config/exceptions.py +0 -0
  56. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/config/logfire.py +0 -0
  57. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/examples.py +0 -0
  58. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/installation.py +0 -0
  59. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/log_ingest.py +0 -0
  60. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/loggers.py +0 -0
  61. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/main.py +0 -0
  62. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/mcp_auth.py +0 -0
  63. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/mcp_client.py +0 -0
  64. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/mcp_server.py +0 -0
  65. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/models.py +0 -0
  66. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/ollama.py +0 -0
  67. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/quizzes.py +0 -0
  68. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/rag_audit.py +0 -0
  69. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/sandbox_audit.py +0 -0
  70. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/skills/bwrap_sandbox/SKILL.md +0 -0
  71. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/skills/bwrap_sandbox/__init__.py +0 -0
  72. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/titles.py +0 -0
  73. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/tools/__init__.py +0 -0
  74. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/tools/file_uploads.py +0 -0
  75. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/tools/rag.py +0 -0
  76. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/tui/cli.py +0 -0
  77. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/tui/main.py +0 -0
  78. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/tui/rest_api.py +0 -0
  79. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/tui/serve.py +0 -0
  80. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/views/__init__.py +0 -0
  81. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/views/authn.py +0 -0
  82. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/views/authz.py +0 -0
  83. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/views/completions.py +0 -0
  84. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/views/installation.py +0 -0
  85. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/views/log_ingest.py +0 -0
  86. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/views/quizzes.py +0 -0
  87. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/views/room_file_uploads.py +0 -0
  88. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/views/rooms.py +0 -0
  89. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/views/sandbox_workdirs.py +0 -0
  90. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/views/stats.py +0 -0
  91. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/views/streaming.py +0 -0
  92. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex/views/thread_file_uploads.py +0 -0
  93. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex.egg-info/SOURCES.txt +0 -0
  94. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex.egg-info/dependency_links.txt +0 -0
  95. {soliplex-0.75.3 → soliplex-0.76}/src/soliplex.egg-info/entry_points.txt +0 -0
  96. {soliplex-0.75.3 → soliplex-0.76}/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.75.3
3
+ Version: 0.76
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
@@ -13,13 +13,13 @@ Requires-Dist: aiohttp<3.15,>=3.14.2
13
13
  Requires-Dist: aiosqlite
14
14
  Requires-Dist: alembic<1.19,>=1.18.4
15
15
  Requires-Dist: authlib
16
- Requires-Dist: bubble-sandbox<0.11,>=0.10.0
16
+ Requires-Dist: bubble-sandbox<0.12,>=0.11.0
17
17
  Requires-Dist: cryptography>=50.0.0
18
18
  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.74,>=0.73.0
22
+ Requires-Dist: haiku.rag-slim<0.75,>=0.74.0
23
23
  Requires-Dist: idna>=3.15
24
24
  Requires-Dist: itsdangerous
25
25
  Requires-Dist: joserfc<1.7,>=1.6.7
@@ -46,6 +46,17 @@ Dynamic: license-file
46
46
 
47
47
  An AI-powered Retrieval-Augmented Generation (RAG) system with a modern web interface.
48
48
 
49
+ ## Overview
50
+
51
+ Soliplex is a self-hosted, full-stack RAG/AI system: a FastAPI backend
52
+ (a thin configuration and authentication layer around
53
+ [pydantic-ai](https://ai.pydantic.dev/)), a hardened
54
+ [Flutter client](https://github.com/soliplex/frontend), and a terminal UI.
55
+
56
+ See the [Overview](docs/overview.md) for what Soliplex is, when (and when not)
57
+ to use it, how it compares to alternatives, and the full feature and
58
+ architecture breakdown.
59
+
49
60
  ## Quickstart
50
61
 
51
62
  The fastest way to stand up and run your own Soliplex is to let a
@@ -123,17 +134,6 @@ and then *"What is a Soliplex installation?"*
123
134
  > and developing on the backend itself. If you only want to *use* Soliplex,
124
135
  > the two skills above are all you need.
125
136
 
126
- ## Overview
127
-
128
- Soliplex is a self-hosted, full-stack RAG/AI system: a FastAPI backend
129
- (a thin configuration and authentication layer around
130
- [pydantic-ai](https://ai.pydantic.dev/)), a hardened
131
- [Flutter client](https://github.com/soliplex/frontend), and a terminal UI.
132
-
133
- See the [Overview](docs/overview.md) for what Soliplex is, when (and when not)
134
- to use it, how it compares to alternatives, and the full feature and
135
- architecture breakdown.
136
-
137
137
  ## Running from source
138
138
 
139
139
  The skills-based [Quickstart](#quickstart) above is the recommended path for
@@ -150,6 +150,26 @@ most users. To run Soliplex from a checkout of this repository instead, see:
150
150
  - [Client Setup](docs/client.md) -- run the Flutter web client.
151
151
  - [Docker Deployment](docs/docker.md) -- run the full stack with Docker Compose.
152
152
 
153
+ ## Documentation
154
+
155
+ The full documentation set lives in [`docs/`](docs/) and is published as a
156
+ [Zensical site](https://soliplex.github.io/soliplex/):
157
+
158
+ > **Tip:** the same documentation is also available as the `soliplex-docs`
159
+ > Agent Skill, so a skills-compatible agent can answer questions straight from
160
+ > it -- see the [Quickstart](#quickstart) above.
161
+
162
+ - [Overview](docs/overview.md)
163
+ - [Prerequisites](docs/prerequisites.md)
164
+ - [Server Setup](docs/server/index.md) and
165
+ [CLI Reference](docs/server/cli.md)
166
+ - [Client Setup](docs/client.md)
167
+ - [Docker Deployment](docs/docker.md)
168
+ - [RAG Database](docs/rag.md)
169
+ - [Documentation Skill](docs/docs-skill.md)
170
+ - [Configuration](docs/config/)
171
+ - [Usage](docs/usage.md)
172
+
153
173
  ## Configuration
154
174
 
155
175
  Soliplex is configured by a tree of YAML files rooted at an
@@ -173,26 +193,6 @@ Soliplex is configured by a tree of YAML files rooted at an
173
193
  [Console logging](docs/config/logging.md),
174
194
  [SQLAlchemy DBURIs](docs/config/dburis.md)
175
195
 
176
- ## Documentation
177
-
178
- The full documentation set lives in [`docs/`](docs/) and is published as a
179
- [Zensical site](https://soliplex.github.io/soliplex/):
180
-
181
- > **Tip:** the same documentation is also available as the `soliplex-docs`
182
- > Agent Skill, so a skills-compatible agent can answer questions straight from
183
- > it -- see the [Quickstart](#quickstart) above.
184
-
185
- - [Overview](docs/overview.md)
186
- - [Prerequisites](docs/prerequisites.md)
187
- - [Server Setup](docs/server/index.md) and
188
- [CLI Reference](docs/server/cli.md)
189
- - [Client Setup](docs/client.md)
190
- - [Docker Deployment](docs/docker.md)
191
- - [RAG Database](docs/rag.md)
192
- - [Documentation Skill](docs/docs-skill.md)
193
- - [Configuration](docs/config/)
194
- - [Usage](docs/usage.md)
195
-
196
196
  ## Development
197
197
 
198
198
  Contributor guidance -- setup, build and test commands, code style, the
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "soliplex"
7
- version = "0.75.3"
7
+ version = "0.76"
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"
@@ -19,13 +19,13 @@ dependencies = [
19
19
  "aiosqlite",
20
20
  "alembic >= 1.18.4, < 1.19",
21
21
  "authlib",
22
- "bubble-sandbox >= 0.10.0, < 0.11",
22
+ "bubble-sandbox >= 0.11.0, < 0.12",
23
23
  "cryptography >= 50.0.0", # PYSEC-2026-3552
24
24
  "fastapi",
25
25
  "fastmcp >= 3.3.0, < 3.4",
26
26
  "fakeredis != 2.35.0", # brownbag
27
27
  "greenlet",
28
- "haiku.rag-slim >= 0.73.0, < 0.74",
28
+ "haiku.rag-slim >= 0.74.0, < 0.75",
29
29
  "idna >= 3.15",
30
30
  "itsdangerous",
31
31
  "joserfc >= 1.6.7, < 1.7", # CVE-2026-48990
@@ -93,6 +93,7 @@ markers = [
93
93
 
94
94
  [tool.coverage.run]
95
95
  omit = [
96
+ "scripts/lint_textio.py", # runs its own '--self-test' during lint
96
97
  "src/soliplex/cli/__init__.py",
97
98
  "src/soliplex/tui.py",
98
99
  ]
@@ -4,8 +4,10 @@ import dataclasses
4
4
  import typing
5
5
 
6
6
  import pydantic_ai
7
- from haiku.rag.capabilities import AnalysisCapability
8
- from haiku.rag.capabilities import RAGCapability
7
+ from haiku.rag.capabilities import analysis as hr_caps_analysis
8
+ from haiku.rag.capabilities import compaction as hr_caps_compaction
9
+ from haiku.rag.capabilities import policy as hr_caps_policy
10
+ from haiku.rag.capabilities import rag as hr_caps_rag
9
11
  from pydantic_ai import agent as ai_agent
10
12
  from pydantic_ai import capabilities as ai_capabilities
11
13
  from pydantic_ai import tools as ai_tools
@@ -20,6 +22,18 @@ from soliplex.config import tools as config_tools
20
22
 
21
23
  ToolConfigMap = dict[str, typing.Any]
22
24
 
25
+ # Capabilities which take 'vision' from agent config's 'multimodal'
26
+ HR_VisionCapabilities = (
27
+ hr_caps_rag.RAGCapability | hr_caps_analysis.AnalysisCapability
28
+ )
29
+
30
+ # Capabilities which can always be eagerly loaded
31
+ HookOnlyCapabilities = (
32
+ hr_caps_compaction.EvidenceCompactionCapability
33
+ | hr_caps_policy.CitationPolicyCapability
34
+ | cap_rag_audit.RAGAccessAuditCapability
35
+ )
36
+
23
37
 
24
38
  class CapabilityConfig(typing.Protocol):
25
39
  """Room-specific native capabilities and their audit targets."""
@@ -114,16 +128,20 @@ def get_default_agent_from_configs(
114
128
  # agent's model, not haiku.rag's configured model, so gate on the room
115
129
  # agent's declared multimodality.
116
130
  for capability in capabilities:
117
- if isinstance(capability, (RAGCapability, AnalysisCapability)):
131
+ if isinstance(capability, HR_VisionCapabilities):
118
132
  capability.vision = agent_config.multimodal
119
133
 
120
134
  # A single model-facing capability loads eagerly so its tools are visible;
121
- # multiple stay deferred so the model routes between them via
122
- # load_capability. The audit capability is hook-only and always eager.
135
+ #
136
+ # Multiple stay deferred so the model routes between them via
137
+ # 'load_capability'.
138
+ #
139
+ # Hook-only capabilities expose no tools to route
140
+ # between, so they neither defer nor count.
123
141
  routing_capabilities = [
124
142
  capability
125
143
  for capability in capabilities
126
- if not isinstance(capability, cap_rag_audit.RAGAccessAuditCapability)
144
+ if not isinstance(capability, HookOnlyCapabilities)
127
145
  ]
128
146
  defer_loading = len(routing_capabilities) > 1
129
147
  for capability in routing_capabilities:
@@ -8,6 +8,7 @@ import typing
8
8
 
9
9
  import fastapi
10
10
  from ag_ui import core as agui_core
11
+ from pydantic_ai import ui as ai_ui
11
12
  from sqlalchemy.ext import asyncio as sqla_asyncio
12
13
 
13
14
  AGUI_Events = list[agui_core.Event]
@@ -22,6 +23,16 @@ _COMPACTIBLE_TYPES = { # event_type: compacting_attr
22
23
  agui_core.EventType.TOOL_CALL_ARGS: "tool_call_id",
23
24
  }
24
25
 
26
+ #
27
+ # Events which terminate a run's event stream
28
+ #
29
+ TERMINAL_EVENT_TYPES = frozenset(
30
+ [
31
+ agui_core.EventType.RUN_FINISHED,
32
+ agui_core.EventType.RUN_ERROR,
33
+ ]
34
+ )
35
+
25
36
 
26
37
  class AGUI_Exception(ValueError):
27
38
  status_code = 400
@@ -582,6 +593,24 @@ async def compact_event_stream(stream: AGUI_EventStream):
582
593
  yield event
583
594
 
584
595
 
596
+ async def with_final_state(
597
+ *,
598
+ stream: AGUI_EventStream,
599
+ deps: ai_ui.StateHandler | None,
600
+ ) -> AGUI_EventStream:
601
+ """Yield events from 'stream', adding a final AG-UI state snapshot.
602
+
603
+ The snapshot precedes the run's terminal event: AG-UI clients may stop
604
+ reading after a terminal event, and on replay the parser only accepts a
605
+ snapshot while the run is still in the RUNNING state.
606
+ """
607
+ async for event in stream:
608
+ if deps is not None and event.type in TERMINAL_EVENT_TYPES:
609
+ yield agui_core.StateSnapshotEvent(snapshot=deps.state)
610
+
611
+ yield event
612
+
613
+
585
614
  async def get_the_threads(request: fastapi.Request) -> ThreadStorage:
586
615
  from . import persistence
587
616
 
@@ -244,6 +244,15 @@ class EventStreamParser:
244
244
  result: typing.Any = None
245
245
 
246
246
  state: dict = dataclasses.field(default_factory=dict)
247
+
248
+ # Currently-skipped deltas: non-empty means our 'state' is incomplete.
249
+ # See 'STATE_DELTA' handling below (#1257).
250
+ invalid_state_deltas: Events = dataclasses.field(default_factory=list)
251
+
252
+ # All skipped deltas, preserved even when a snapshot heals our 'state'.
253
+ # See 'STATE_DELTA' handling below (#1257).
254
+ skipped_state_deltas: Events = dataclasses.field(default_factory=list)
255
+
247
256
  messages: Messages = dataclasses.field(default_factory=list)
248
257
 
249
258
  messages_by_id: MessagesByID = dataclasses.field(default_factory=dict)
@@ -484,15 +493,37 @@ class EventStreamParser:
484
493
  case agui_core.EventType.STATE_DELTA:
485
494
  self._assert_running(event)
486
495
 
487
- new_state = jsonpatch.apply_patch(self.state, event.delta)
496
+ try:
497
+ new_state = jsonpatch.apply_patch(self.state, event.delta)
498
+
499
+ except (
500
+ jsonpatch.JsonPatchException,
501
+ jsonpatch.JsonPointerException,
502
+ ):
503
+ # The delta was recorded against state we don't have,
504
+ # and therefore cannot be applied. Skip it, but record
505
+ # it twice:
506
+ #
507
+ # - 'invalid_state_deltas' signals that our current 'state'
508
+ # is incomplete.
509
+ #
510
+ # - 'skipped_state_deltas' preserves the skip for later
511
+ # debugging, even when a subsequent 'STATE_SNAPSHOT"
512
+ # clears the invalid list.
513
+ self.invalid_state_deltas.append(event)
514
+ self.skipped_state_deltas.append(event)
488
515
 
489
- self.state = new_state
516
+ else:
517
+ self.state = new_state
490
518
 
491
519
  case agui_core.EventType.STATE_SNAPSHOT:
492
520
  self._assert_running(event)
493
521
 
494
522
  self.state = event.snapshot
495
523
 
524
+ # Clear the invalid list, because our 'state' is now correct.
525
+ self.invalid_state_deltas.clear()
526
+
496
527
  case agui_core.EventType.MESSAGES_SNAPSHOT:
497
528
  self._assert_running(event)
498
529
 
@@ -962,7 +962,11 @@ async def drive_agui_turn(
962
962
 
963
963
  ``run_stream_kwargs`` is forwarded verbatim to the Pydantic AI adapter.
964
964
  """
965
- event_stream = adapter.run_stream(**(run_stream_kwargs or {}))
965
+ stream_kwargs = run_stream_kwargs or {}
966
+ event_stream = agui.with_final_state(
967
+ stream=adapter.run_stream(**stream_kwargs),
968
+ deps=stream_kwargs.get("deps"),
969
+ )
966
970
  compacted = agui.compact_event_stream(event_stream)
967
971
  async for event in compacted:
968
972
  try:
@@ -472,7 +472,7 @@ def admin_users_as_yaml(
472
472
  yaml_text = _admin_users_as_yaml(discriminators)
473
473
 
474
474
  if output is not None:
475
- output.write_text(yaml_text)
475
+ output.write_text(yaml_text, encoding="utf-8")
476
476
  else:
477
477
  print(yaml_text, end="")
478
478
 
@@ -508,7 +508,7 @@ def admin_users_from_yaml(
508
508
  cli_util._check_ram_dburi(dburi, "admin-users from-yaml")
509
509
 
510
510
  if input_ is not None:
511
- yaml_text = input_.read_text()
511
+ yaml_text = input_.read_text(encoding="utf-8")
512
512
  else:
513
513
  yaml_text = sys.stdin.read()
514
514
 
@@ -1060,7 +1060,7 @@ def _load_logging_config(the_installation):
1060
1060
  pyl_config = the_installation._config.logging_config_file
1061
1061
  if pyl_config is None:
1062
1062
  return None
1063
- with pyl_config.open() as f:
1063
+ with pyl_config.open(encoding="utf-8") as f:
1064
1064
  return yaml.safe_load(f)
1065
1065
 
1066
1066
 
@@ -325,7 +325,7 @@ def room_authz_as_yaml(
325
325
  yaml_text = _room_policy_as_yaml(policy)
326
326
 
327
327
  if output is not None:
328
- output.write_text(yaml_text)
328
+ output.write_text(yaml_text, encoding="utf-8")
329
329
  else:
330
330
  print(yaml_text, end="")
331
331
 
@@ -372,7 +372,7 @@ def room_authz_from_yaml(
372
372
  cli_util._check_ram_dburi(dburi, "room-authz from-yaml")
373
373
 
374
374
  if input_ is not None:
375
- yaml_text = input_.read_text()
375
+ yaml_text = input_.read_text(encoding="utf-8")
376
376
  else:
377
377
  yaml_text = sys.stdin.read()
378
378
 
@@ -5,6 +5,7 @@ import enum
5
5
  import functools
6
6
  import pathlib
7
7
  import typing
8
+ import warnings
8
9
  from collections import abc
9
10
 
10
11
  from pydantic_ai import capabilities as ai_capabilities
@@ -50,13 +51,29 @@ class UnknownCapability(KeyError):
50
51
  def __init__(self, name, _config_path=None):
51
52
  self.name = name
52
53
  self._config_path = _config_path
53
- if _config_path is not None:
54
- super().__init__(
55
- f"Unknown capability name: {name} "
56
- f"(configured in {_config_path})"
57
- )
58
- else:
59
- super().__init__(f"Unknown capability name: {name}")
54
+
55
+ super().__init__(
56
+ f"Unknown capability name: {name} (configured in {_config_path})"
57
+ )
58
+
59
+
60
+ class UnknownAgentConfigKind(KeyError):
61
+ def __init__(self, kind, _config_path=None):
62
+ self.kind = kind
63
+ self._config_path = _config_path
64
+ super().__init__(
65
+ f"Unknown agent config kind: {kind} (configured in {_config_path})"
66
+ )
67
+
68
+
69
+ class AgentConfigKindMismatch(ValueError):
70
+ def __init__(self, found_kind, expected_kind):
71
+ self.found_kind = found_kind
72
+ self.expected_kind = expected_kind
73
+ super().__init__(
74
+ "Agent config kind mismatch: "
75
+ f"found '{found_kind}', expected '{expected_kind}'"
76
+ )
60
77
 
61
78
 
62
79
  class LLMProviderType(enum.StrEnum):
@@ -97,6 +114,13 @@ def _apply_agent_config_template(
97
114
  return config_dict
98
115
 
99
116
 
117
+ ACC_BBB_NO_NAME_KEY_DEPRECATED = """\
118
+ AgentCapabilityConfig.from_yaml: '{<name>: <kwargs>}' form is deprecated;
119
+ use '{"name": <name>, "kwargs": <kwargs>}' instead. Support for the form
120
+ will be removed after 'v0.76'.
121
+ """
122
+
123
+
100
124
  @dataclasses.dataclass(kw_only=True)
101
125
  class AgentCapabilityConfig:
102
126
  name: str
@@ -104,36 +128,47 @@ class AgentCapabilityConfig:
104
128
 
105
129
  _config_path: pathlib.Path = None
106
130
 
131
+ @classmethod
132
+ def from_yaml(
133
+ cls,
134
+ config_path: pathlib.Path,
135
+ config_dict_or_str: str | dict,
136
+ ):
137
+ if isinstance(config_dict_or_str, str):
138
+ name = config_dict_or_str
139
+ config_dict = {"name": name}
140
+ else:
141
+ if "name" not in config_dict_or_str: # BBB deprecated
142
+ warnings.warn(
143
+ ACC_BBB_NO_NAME_KEY_DEPRECATED,
144
+ DeprecationWarning,
145
+ stacklevel=2,
146
+ )
147
+ ((name, kwargs),) = config_dict_or_str.items()
148
+ config_dict = {"name": name, "kwargs": kwargs}
149
+ else:
150
+ name = config_dict_or_str["name"]
151
+ config_dict = config_dict_or_str
152
+
153
+ if name not in AGENT_CAPABILITY_CLASSES_BY_NAME:
154
+ raise UnknownCapability(name, config_path)
155
+
156
+ return cls(**config_dict, _config_path=config_path)
157
+
158
+ @property
159
+ def as_yaml(self) -> dict:
160
+ return {"name": self.name, "kwargs": self.kwargs}
161
+
107
162
  @property
108
163
  def as_capability(self) -> ai_capabilities.AbstractCapability:
109
164
  try:
110
165
  cap_klass = AGENT_CAPABILITY_CLASSES_BY_NAME[self.name]
111
166
  except KeyError:
112
- raise UnknownCapability(self.name) from None
167
+ raise UnknownCapability(self.name, self._config_path) from None
113
168
 
114
169
  return cap_klass(**self.kwargs)
115
170
 
116
171
 
117
- def extract_capability_config(
118
- cap_config: str | dict[str, typing.Any],
119
- _config_path: pathlib.Path,
120
- ) -> AgentCapabilityConfig:
121
- if isinstance(cap_config, str):
122
- name = cap_config
123
- kwargs = {}
124
- else:
125
- ((name, kwargs),) = cap_config.items()
126
-
127
- if name not in AGENT_CAPABILITY_CLASSES_BY_NAME:
128
- raise UnknownCapability(name, _config_path)
129
-
130
- return AgentCapabilityConfig(
131
- name=name,
132
- kwargs=kwargs,
133
- _config_path=_config_path,
134
- )
135
-
136
-
137
172
  @dataclasses.dataclass(kw_only=True)
138
173
  class AgentConfig:
139
174
  #
@@ -177,6 +212,11 @@ class AgentConfig:
177
212
  if system_prompt is not None:
178
213
  self._system_prompt_text = system_prompt
179
214
 
215
+ @classmethod
216
+ def _check_kind(cls, kind):
217
+ if kind not in (None, cls.kind):
218
+ raise AgentConfigKindMismatch(kind, cls.kind)
219
+
180
220
  @classmethod
181
221
  def from_yaml(
182
222
  cls,
@@ -185,6 +225,9 @@ class AgentConfig:
185
225
  config_dict: dict,
186
226
  ):
187
227
  try:
228
+ kind = config_dict.pop("kind", None)
229
+ cls._check_kind(kind)
230
+
188
231
  config_dict["_installation_config"] = installation_config
189
232
  config_dict["_config_path"] = config_path
190
233
 
@@ -210,7 +253,7 @@ class AgentConfig:
210
253
  capabilities = config_dict.pop("capabilities", None)
211
254
  if capabilities is not None:
212
255
  config_dict["_capability_configs"] = [
213
- extract_capability_config(cap, config_path)
256
+ AgentCapabilityConfig.from_yaml(config_path, cap)
214
257
  for cap in capabilities
215
258
  ]
216
259
 
@@ -236,7 +279,7 @@ class AgentConfig:
236
279
  system_prompt_file = (
237
280
  self._config_path.parent / self._system_prompt_path
238
281
  )
239
- return system_prompt_file.read_text()
282
+ return system_prompt_file.read_text(encoding="utf-8")
240
283
 
241
284
  else: # pragma: NO COVER
242
285
  pass
@@ -282,24 +325,25 @@ class AgentConfig:
282
325
  if self._system_prompt_text is None
283
326
  else self._system_prompt_text
284
327
  )
285
- if self.provider_base_url is None:
286
- provider_base_url = self._installation_config.get_environment(
287
- "OLLAMA_BASE_URL"
288
- )
289
- else:
290
- provider_base_url = self.provider_base_url
328
+ capabilities = {}
329
+
330
+ for cap_cfg in self._capability_configs:
331
+ cap_list = capabilities.setdefault("capabilities", [])
332
+ cap_list.append(cap_cfg.as_yaml)
291
333
 
292
334
  return {
293
335
  "id": self.id,
336
+ "kind": self.kind,
294
337
  "model_name": self.model_name,
295
338
  "retries": self.retries,
296
339
  "system_prompt": prompt,
297
340
  "model_settings": self.model_settings,
298
341
  "multimodal": self.multimodal,
299
342
  "provider_type": str(self.provider_type),
300
- "provider_base_url": provider_base_url,
343
+ "provider_base_url": self.provider_base_url,
301
344
  "provider_key": self.provider_key, # "secret:SECRET_NAME"
302
- }
345
+ "agui_feature_names": self.agui_feature_names,
346
+ } | capabilities
303
347
 
304
348
 
305
349
  AgentFactory = abc.Callable[[], ai_ag_abstract.AbstractAgent]
@@ -342,6 +386,11 @@ class FactoryAgentConfig:
342
386
 
343
387
  return self._factory
344
388
 
389
+ @classmethod
390
+ def _check_kind(cls, kind):
391
+ if kind not in (None, cls.kind):
392
+ raise AgentConfigKindMismatch(kind, cls.kind)
393
+
345
394
  @classmethod
346
395
  def from_yaml(
347
396
  cls,
@@ -350,6 +399,9 @@ class FactoryAgentConfig:
350
399
  config_dict: dict,
351
400
  ):
352
401
  try:
402
+ kind = config_dict.pop("kind", None)
403
+ cls._check_kind(kind)
404
+
353
405
  config_dict["_installation_config"] = installation_config
354
406
  config_dict["_config_path"] = config_path
355
407
 
@@ -379,6 +431,7 @@ class FactoryAgentConfig:
379
431
  "factory_name": self.factory_name,
380
432
  "with_agent_config": self.with_agent_config,
381
433
  "extra_config": self.extra_config,
434
+ "agui_feature_names": self.agui_feature_names,
382
435
  }
383
436
 
384
437
 
@@ -400,14 +453,17 @@ def extract_agent_config(
400
453
  config_path: pathlib.Path,
401
454
  config_dict: dict,
402
455
  ) -> AgentConfig: # or subclass
403
- agent_kind = config_dict.get("kind")
404
456
 
405
- if agent_kind is not None: # kind is a typing.ClassVar
406
- config_dict = {
407
- key: value for key, value in config_dict.items() if key != "kind"
408
- }
457
+ # YAML not required to specify 'kind'
458
+ agent_kind = config_dict.get("kind", AgentConfig.kind)
409
459
 
410
- ac_class = AGENT_CONFIG_CLASSES_BY_KIND.get(agent_kind, AgentConfig)
460
+ try:
461
+ ac_class = AGENT_CONFIG_CLASSES_BY_KIND[agent_kind]
462
+ except KeyError:
463
+ raise UnknownAgentConfigKind(
464
+ agent_kind,
465
+ config_path,
466
+ ) from None
411
467
 
412
468
  return ac_class.from_yaml(
413
469
  installation_config,