Skip to content
Merged
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ src/synthorg/
persistence/ # Operational data persistence — pluggable PersistenceBackend protocol, SQLite initial, SettingsRepository (namespaced settings CRUD) (see Memory & Persistence design page)
observability/ # Structured logging, correlation tracking, log sinks
providers/ # LLM provider abstraction (LiteLLM adapter)
settings/ # Runtime-editable settings persistence (DB > env > YAML > code defaults), typed definitions (9 namespaces), Fernet encryption for sensitive values, config bridge, ConfigResolver (typed composed reads for controllers), validation, registry, change notifications via message bus, SettingsSubscriber protocol (subscriber.py), SettingsChangeDispatcher (dispatcher.py, polls #settings channel, routes to subscribers, restart_required filtering)
settings/ # Runtime-editable settings persistence (DB > env > YAML > code defaults), typed definitions (9 namespaces, including JSON type for structural data), Fernet encryption for sensitive values, config bridge (JSON serialization for Pydantic models/collections), ConfigResolver (typed scalar + structural data accessors for controllers — get_agents, get_departments, get_provider_configs with validation fallbacks to YAML), validation, registry, change notifications via message bus, SettingsSubscriber protocol (subscriber.py), SettingsChangeDispatcher (dispatcher.py, polls #settings channel, routes to subscribers, restart_required filtering)
definitions/ # Per-namespace setting definitions (api, company, providers, memory, budget, security, coordination, observability, backup)
subscribers/ # Concrete settings subscribers (ProviderSettingsSubscriber — rebuilds ModelRouter on strategy change, MemorySettingsSubscriber — advisory logging for memory config)
security/ # SecOps agent, rule engine (soft-allow/hard-deny, fail-closed), audit log, output scanner, output scan response policies (redact/withhold/log-only/autonomy-tiered), risk classifier, risk tier classifier, action type registry, ToolInvoker security integration, progressive trust (4 strategies: disabled/weighted/per-category/milestone), autonomy levels (presets, resolver, change strategy), timeout policies (park/resume)
Expand Down
2 changes: 1 addition & 1 deletion docs/design/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -1042,7 +1042,7 @@ and retry guidance.
- **Budget Panel**: Spending charts, per-agent breakdown (projections/alerts planned)
- **Meeting Logs**: Placeholder — coming soon
- **Artifact Browser**: Placeholder — coming soon
- **Settings**: Runtime-editable configuration via DB-backed settings persistence (9 namespaces: api, company, providers, memory, budget, security, coordination, observability, backup). 4-layer resolution (DB > env > YAML > code defaults), Fernet encryption for sensitive values, REST API (GET/PUT/DELETE + schema endpoints for dynamic UI generation), change notifications via message bus. `ConfigResolver` provides typed composed reads for API controllers (assembles full Pydantic config models from individually resolved settings, using `asyncio.TaskGroup` for parallel resolution). **Hot-reload**: `SettingsChangeDispatcher` polls the `#settings` bus channel and routes change notifications to registered `SettingsSubscriber` implementations. Settings marked `restart_required=True` are filtered (logged as WARNING, not dispatched). Concrete subscribers: `ProviderSettingsSubscriber` (rebuilds `ModelRouter` on `routing_strategy` change via `AppState.swap_model_router`), `MemorySettingsSubscriber` (advisory logging for non-restart memory settings)
- **Settings**: Runtime-editable configuration via DB-backed settings persistence (9 namespaces: api, company, providers, memory, budget, security, coordination, observability, backup). Setting types: STRING, INTEGER, FLOAT, BOOLEAN, ENUM, JSON. 4-layer resolution (DB > env > YAML > code defaults), Fernet encryption for sensitive values, REST API (GET/PUT/DELETE + schema endpoints for dynamic UI generation), change notifications via message bus. `ConfigResolver` provides typed scalar accessors and structural data accessors for API controllers: scalar reads assemble full Pydantic config models from individually resolved settings (using `asyncio.TaskGroup` for parallel resolution); structural reads (`get_agents`, `get_departments`, `get_provider_configs`) resolve JSON-typed settings with Pydantic schema validation and graceful fallback to `RootConfig` defaults on invalid data. **Hot-reload**: `SettingsChangeDispatcher` polls the `#settings` bus channel and routes change notifications to registered `SettingsSubscriber` implementations. Settings marked `restart_required=True` are filtered (logged as WARNING, not dispatched). Concrete subscribers: `ProviderSettingsSubscriber` (rebuilds `ModelRouter` on `routing_strategy` change via `AppState.swap_model_router`), `MemorySettingsSubscriber` (advisory logging for non-restart memory settings).

### Human Roles

Expand Down
12 changes: 5 additions & 7 deletions src/synthorg/api/controllers/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@


class AgentController(Controller):
"""Read-only access to agent configurations from ``RootConfig``."""
"""Read-only access to agent configurations resolved through settings."""

path = "/agents"
tags = ("agents",)
Expand All @@ -40,11 +40,8 @@ async def list_agents(
Paginated agent configurations.
"""
app_state: AppState = state.app_state
page, meta = paginate(
app_state.config.agents,
offset=offset,
limit=limit,
)
agents = await app_state.config_resolver.get_agents()
page, meta = paginate(agents, offset=offset, limit=limit)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return PaginatedResponse(data=page, pagination=meta)

@get("/{agent_name:str}")
Expand All @@ -66,7 +63,8 @@ async def get_agent(
NotFoundError: If the agent is not found.
"""
app_state: AppState = state.app_state
for agent in app_state.config.agents:
agents = await app_state.config_resolver.get_agents()
for agent in agents:
if agent.name == agent_name:
return ApiResponse(data=agent)
msg = f"Agent {agent_name!r} not found"
Expand Down
24 changes: 19 additions & 5 deletions src/synthorg/api/controllers/analytics.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Analytics controller — derived read-only metrics."""

import asyncio
from collections import Counter

from litestar import Controller, get
Expand All @@ -11,6 +12,7 @@
from synthorg.api.state import AppState # noqa: TC001
from synthorg.core.enums import TaskStatus
from synthorg.observability import get_logger
from synthorg.observability.events.api import API_REQUEST_ERROR

logger = get_logger(__name__)

Expand Down Expand Up @@ -56,17 +58,29 @@ async def get_overview(
"""
app_state: AppState = state.app_state

all_tasks = await app_state.persistence.tasks.list_tasks()
try:
async with asyncio.TaskGroup() as tg:
t_tasks = tg.create_task(app_state.persistence.tasks.list_tasks())
t_cost = tg.create_task(app_state.cost_tracker.get_total_cost())
t_agents = tg.create_task(app_state.config_resolver.get_agents())
except ExceptionGroup as eg:
logger.warning(
API_REQUEST_ERROR,
endpoint="analytics.overview",
error_count=len(eg.exceptions),
exc_info=True,
)
raise eg.exceptions[0] from eg
Comment on lines +61 to +73
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Don't emit SETTINGS_FETCH_FAILED for every TaskGroup failure.

This group wraps list_tasks() and get_total_cost() as well as config_resolver.get_agents(), so the current branch will label non-settings failures as settings outages. namespace="analytics" is also not a valid settings namespace, which can skew settings-focused alerting and log filters. Use an API/analytics event here, or only emit SETTINGS_FETCH_FAILED when the resolver task is the one that failed.

Based on learnings, "Settings namespaces: api, company, providers, memory, budget, security, coordination, observability, backup".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/api/controllers/analytics.py` around lines 61 - 74, The current
TaskGroup lumps app_state.config_resolver.get_agents() with non-settings calls
so the except block wrongly emits SETTINGS_FETCH_FAILED for unrelated failures;
fix by separating the resolver call out and only emit SETTINGS_FETCH_FAILED
(with a valid settings namespace such as "api") when
app_state.config_resolver.get_agents() fails—e.g., call get_agents() in its own
try/except and log SETTINGS_FETCH_FAILED (namespace="api") only on that
exception, while keeping the list_tasks()/get_total_cost() TaskGroup and
emitting a different analytics/error event for their failures.


all_tasks = t_tasks.result()
counts = Counter(t.status.value for t in all_tasks)
by_status = {s.value: counts.get(s.value, 0) for s in TaskStatus}

total_cost = await app_state.cost_tracker.get_total_cost()

return ApiResponse(
data=OverviewMetrics(
total_tasks=len(all_tasks),
tasks_by_status=by_status,
total_agents=len(app_state.config.agents),
total_cost_usd=total_cost,
total_agents=len(t_agents.result()),
total_cost_usd=t_cost.result(),
),
)
30 changes: 22 additions & 8 deletions src/synthorg/api/controllers/company.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Company configuration controller."""

import asyncio
from typing import Any

from litestar import Controller, get
Expand All @@ -10,6 +11,7 @@
from synthorg.api.state import AppState # noqa: TC001
from synthorg.core.company import Department # noqa: TC001
from synthorg.observability import get_logger
from synthorg.observability.events.settings import SETTINGS_FETCH_FAILED

logger = get_logger(__name__)

Expand Down Expand Up @@ -38,14 +40,25 @@ async def get_company(
Company configuration envelope.
"""
app_state: AppState = state.app_state
company_name = await app_state.config_resolver.get_str(
"company", "company_name"
)
config = app_state.config
resolver = app_state.config_resolver
try:
async with asyncio.TaskGroup() as tg:
t_name = tg.create_task(resolver.get_str("company", "company_name"))
t_agents = tg.create_task(resolver.get_agents())
t_depts = tg.create_task(resolver.get_departments())
except ExceptionGroup as eg:
logger.warning(
SETTINGS_FETCH_FAILED,
namespace="company",
key="_composed",
error_count=len(eg.exceptions),
exc_info=True,
)
raise eg.exceptions[0] from eg
Comment thread
coderabbitai[bot] marked this conversation as resolved.
data: dict[str, Any] = {
"company_name": company_name,
"agents": [a.model_dump(mode="json") for a in config.agents],
"departments": [d.model_dump(mode="json") for d in config.departments],
"company_name": t_name.result(),
"agents": [a.model_dump(mode="json") for a in t_agents.result()],
"departments": [d.model_dump(mode="json") for d in t_depts.result()],
}
return ApiResponse(data=data)

Expand All @@ -63,4 +76,5 @@ async def list_departments(
Departments envelope.
"""
app_state: AppState = state.app_state
return ApiResponse(data=app_state.config.departments)
departments = await app_state.config_resolver.get_departments()
return ApiResponse(data=departments)
10 changes: 4 additions & 6 deletions src/synthorg/api/controllers/departments.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,8 @@ async def list_departments(
Paginated department list.
"""
app_state: AppState = state.app_state
page, meta = paginate(
app_state.config.departments,
offset=offset,
limit=limit,
)
departments = await app_state.config_resolver.get_departments()
page, meta = paginate(departments, offset=offset, limit=limit)
return PaginatedResponse(data=page, pagination=meta)

@get("/{name:str}")
Expand All @@ -66,7 +63,8 @@ async def get_department(
NotFoundError: If the department is not found.
"""
app_state: AppState = state.app_state
for dept in app_state.config.departments:
departments = await app_state.config_resolver.get_departments()
for dept in departments:
if dept.name == name:
return ApiResponse(data=dept)
msg = f"Department {name!r} not found"
Expand Down
11 changes: 6 additions & 5 deletions src/synthorg/api/controllers/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,8 @@ async def list_providers(
Provider configurations envelope (api_key stripped).
"""
app_state: AppState = state.app_state
safe = {
name: _safe_provider(p) for name, p in app_state.config.providers.items()
}
providers = await app_state.config_resolver.get_provider_configs()
safe = {name: _safe_provider(p) for name, p in providers.items()}
return ApiResponse(data=safe)

@get("/{name:str}")
Expand All @@ -67,7 +66,8 @@ async def get_provider(
NotFoundError: If the provider is not found.
"""
app_state: AppState = state.app_state
provider = app_state.config.providers.get(name)
providers = await app_state.config_resolver.get_provider_configs()
provider = providers.get(name)
if provider is None:
msg = f"Provider {name!r} not found"
logger.warning(API_RESOURCE_NOT_FOUND, resource="provider", name=name)
Expand All @@ -93,7 +93,8 @@ async def list_models(
NotFoundError: If the provider is not found.
"""
app_state: AppState = state.app_state
provider = app_state.config.providers.get(name)
providers = await app_state.config_resolver.get_provider_configs()
provider = providers.get(name)
if provider is None:
msg = f"Provider {name!r} not found"
logger.warning(API_RESOURCE_NOT_FOUND, resource="provider", name=name)
Expand Down
79 changes: 76 additions & 3 deletions src/synthorg/settings/config_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,81 @@
``RootConfig`` for YAML-layer resolution in the settings service.
"""

import json

from pydantic import BaseModel

from synthorg.observability import get_logger
from synthorg.observability.events.settings import SETTINGS_CONFIG_PATH_MISS
Comment thread
coderabbitai[bot] marked this conversation as resolved.

logger = get_logger(__name__)


def _to_json_compatible(value: object) -> object:
"""Recursively convert Pydantic models to JSON-compatible dicts.

Walks nested structures so that ``BaseModel`` instances at any
depth are replaced by their ``model_dump(mode="json")`` output.
"""
if isinstance(value, BaseModel):
return value.model_dump(mode="json")
if isinstance(value, (tuple, list)):
return [_to_json_compatible(item) for item in value]
if isinstance(value, dict):
return {k: _to_json_compatible(v) for k, v in value.items()}
return value


def _serialize_value(value: object) -> str:
"""Serialize a resolved config value to a string.

Handles Pydantic models, tuples/lists, and dicts (including
nested models at any depth) by producing valid JSON. Scalar
booleans produce lowercase JSON-style ``"true"``/``"false"``.
Other accepted scalars (``str``, ``int``, ``float``) use
``str()``.

Args:
value: The resolved config attribute.

Returns:
A string representation suitable for the settings layer.

Raises:
TypeError: If *value* is not an accepted type (accepted:
``BaseModel``, ``tuple``, ``list``, ``dict``, ``str``,
``int``, ``float``, ``bool``).
"""
if isinstance(value, BaseModel):
return json.dumps(value.model_dump(mode="json"))

if isinstance(value, (tuple, list)):
return json.dumps(_to_json_compatible(value))

if isinstance(value, dict):
return json.dumps(_to_json_compatible(value))

if isinstance(value, bool):
return "true" if value else "false"

if isinstance(value, (str, int, float)):
return str(value)

msg = f"Cannot serialize {type(value).__name__} to settings string"
raise TypeError(msg)


def extract_from_config(config: object, yaml_path: str) -> str | None:
"""Resolve a dotted path against a config object.

Traverses the object attribute chain for each segment in
*yaml_path*. Returns ``str(value)`` if the final attribute
exists and is not ``None``, otherwise ``None``.
*yaml_path*. Returns a serialized string if the final
attribute exists and is not ``None``, otherwise ``None``.

For Pydantic models, tuples/lists containing models, and
dicts with model values, the result is valid JSON. Scalar
booleans produce lowercase ``"true"``/``"false"``. Other
scalars (``str``, ``int``, ``float``) use ``str(value)``.

Args:
config: Root config object (typically ``RootConfig``).
Expand All @@ -39,4 +102,14 @@ def extract_from_config(config: object, yaml_path: str) -> str | None:
return None
if current is None:
return None
return str(current)
try:
return _serialize_value(current)
except TypeError:
logger.warning(
SETTINGS_CONFIG_PATH_MISS,
yaml_path=yaml_path,
reason="unsupported_type",
value_type=type(current).__name__,
exc_info=True,
)
raise
24 changes: 24 additions & 0 deletions src/synthorg/settings/definitions/company.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,27 @@
yaml_path="graceful_shutdown.grace_seconds",
)
)

_r.register(
SettingDefinition(
namespace=SettingNamespace.COMPANY,
key="agents",
type=SettingType.JSON,
default=None,
description="Agent configurations (JSON array of AgentConfig objects)",
group="Structure",
yaml_path="agents",
)
)

_r.register(
SettingDefinition(
namespace=SettingNamespace.COMPANY,
key="departments",
type=SettingType.JSON,
default=None,
description="Department hierarchy (JSON array of Department objects)",
group="Structure",
yaml_path="departments",
)
)
13 changes: 13 additions & 0 deletions src/synthorg/settings/definitions/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,16 @@
max_value=10,
)
)

_r.register(
SettingDefinition(
namespace=SettingNamespace.PROVIDERS,
key="configs",
type=SettingType.JSON,
default=None,
description="LLM provider configurations (JSON object keyed by name)",
group="General",
yaml_path="providers",
sensitive=True,
)
)
Loading
Loading