@@ -1285,6 +1285,25 @@ class AppConfig(BaseModel):
12851285 orchestrator : OrchestratorConfig = Field (default_factory = OrchestratorConfig )
12861286 runtime : RuntimeConfig = Field (default_factory = RuntimeConfig )
12871287 ui : UIConfig = Field (default_factory = UIConfig )
1288+ # Cross-cutting framework knobs (confidence threshold, escalation
1289+ # roster, severity aliases, dedup prompt, intake tuning) read by
1290+ # the runtime directly off the loaded ``AppConfig`` — no
1291+ # app-specific provider callable required. Apps configure these
1292+ # under the ``framework:`` block of their YAML; tests build them
1293+ # in code via ``FrameworkAppConfig(...)``. Defaults are framework-
1294+ # neutral so unconfigured apps still validate cleanly.
1295+ framework : FrameworkAppConfig = Field (default_factory = FrameworkAppConfig )
1296+ # Two-stage dedup pipeline shape. Typed as ``Any`` because
1297+ # ``DedupConfig`` lives in ``runtime.dedup`` and importing it here
1298+ # would introduce a circular import (``runtime.dedup`` ->
1299+ # ``runtime.config``). The ``_coerce_dedup`` validator below
1300+ # promotes a raw dict (the YAML shape) to a real ``DedupConfig``;
1301+ # callers reading ``cfg.dedup`` get the typed object.
1302+ dedup : Any | None = None
1303+ # App-specific environments roster surfaced on the UI's
1304+ # ``GET /environments`` endpoint and the env selector. Empty list
1305+ # means "this app doesn't expose environments".
1306+ environments : list [str ] = Field (default_factory = list )
12881307 # Declarative trigger registry. Each entry is one transport-flavoured
12891308 # ``TriggerConfig`` (api/webhook/schedule/plugin). Typed as
12901309 # ``list[Any]`` because Pydantic v2's discriminated-union binding
@@ -1293,6 +1312,23 @@ class AppConfig(BaseModel):
12931312 # promotes raw dicts to the proper TriggerConfig variants.
12941313 triggers : list [Any ] = Field (default_factory = list )
12951314
1315+ @model_validator (mode = "after" )
1316+ def _coerce_dedup (self ) -> "AppConfig" :
1317+ # Lazy import to avoid the circular dep with ``runtime.dedup``
1318+ # (which imports things that re-import ``runtime.config``).
1319+
1320+ if self .dedup is None :
1321+ return self
1322+ if isinstance (self .dedup , DedupConfig ):
1323+ return self
1324+ if isinstance (self .dedup , dict ):
1325+ self .__dict__ ["dedup" ] = DedupConfig (** self .dedup )
1326+ return self
1327+ raise ValueError (
1328+ f"app.dedup must be a DedupConfig or dict; got "
1329+ f"{ type (self .dedup ).__name__ } "
1330+ )
1331+
12961332 @model_validator (mode = "after" )
12971333 def _coerce_triggers (self ) -> "AppConfig" :
12981334 # Lazy import inside the validator to avoid a circular import:
@@ -4360,9 +4396,15 @@ async def build_graph(*, cfg: AppConfig, skills: dict, store: SessionStore,
43604396 f"(known: { sorted (skills .keys ())} )"
43614397 )
43624398 if framework_cfg is None :
4363- framework_cfg = resolve_framework_app_config (
4364- getattr (cfg .runtime , "framework_app_config_path" , None ),
4365- )
4399+ # Prefer the YAML-driven ``AppConfig.framework`` field; fall
4400+ # back to the legacy provider-callable path for backward
4401+ # compatibility with deployments that still wire it.
4402+ if getattr (cfg .runtime , "framework_app_config_path" , None ) is not None :
4403+ framework_cfg = resolve_framework_app_config (
4404+ cfg .runtime .framework_app_config_path ,
4405+ )
4406+ else :
4407+ framework_cfg = getattr (cfg , "framework" , None ) or resolve_framework_app_config (None )
43664408 gated_edges = _collect_gated_edges (skills )
43674409
43684410 sg = StateGraph (GraphState )
@@ -7028,14 +7070,19 @@ async def create(cls, cfg: AppConfig) -> "Orchestrator":
70287070 stack = AsyncExitStack ()
70297071 await stack .__aenter__ ()
70307072 try :
7031- # Cross-cutting framework knobs come from the dotted-path
7032- # provider on RuntimeConfig.framework_app_config_path. When
7033- # unset the runtime falls back to a bare FrameworkAppConfig()
7034- # so unit tests that build an AppConfig without an app-side
7035- # provider keep working.
7036- framework_cfg = resolve_framework_app_config (
7037- cfg .runtime .framework_app_config_path ,
7038- )
7073+ # Cross-cutting framework knobs read directly off
7074+ # ``AppConfig.framework`` — the YAML carries them under the
7075+ # ``framework:`` block, no app-specific provider callable.
7076+ # Falls back to the dotted-path provider for backward
7077+ # compatibility with deployments that still wire it (the
7078+ # provider, when set, wins over the YAML default since
7079+ # historical configs relied on it).
7080+ if cfg .runtime .framework_app_config_path is not None :
7081+ framework_cfg = resolve_framework_app_config (
7082+ cfg .runtime .framework_app_config_path ,
7083+ )
7084+ else :
7085+ framework_cfg = cfg .framework
70397086 # Resolve the app state class once. ``None`` (default) keeps the
70407087 # framework-default ``Session`` shape; apps point this at e.g.
70417088 # ``examples.incident_management.state.IncidentState`` via YAML.
@@ -7146,13 +7193,18 @@ async def create(cls, cfg: AppConfig) -> "Orchestrator":
71467193 # about it. Production deployments with a real registry hit
71477194 # the strict validation path.
71487195 #
7149- # The DedupConfig comes through a generic provider hook
7150- # (``RuntimeConfig.dedup_config_path``) — the runtime never
7151- # imports an app-specific config to discover dedup settings.
7196+ # DedupConfig is now a first-class field on ``AppConfig``
7197+ # (read from the YAML's top-level ``dedup:`` block). The
7198+ # legacy provider-callable path is honoured when set so
7199+ # existing deployments don't break, but the YAML wins for
7200+ # bare apps.
71527201 dedup_pipeline : DedupPipeline | None = None
7153- dedup_cfg : DedupConfig | None = _resolve_dedup_config (
7154- cfg .runtime .dedup_config_path ,
7155- )
7202+ if cfg .runtime .dedup_config_path is not None :
7203+ dedup_cfg : DedupConfig | None = _resolve_dedup_config (
7204+ cfg .runtime .dedup_config_path ,
7205+ )
7206+ else :
7207+ dedup_cfg = cfg .dedup
71567208 if dedup_cfg is not None and dedup_cfg .enabled :
71577209 if dedup_cfg .stage2_model in cfg .llm .models :
71587210 _llm_cfg_capture = cfg .llm
@@ -7754,11 +7806,15 @@ async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
77547806 app .state .orchestrator = orch
77557807 # Environments roster is app-specific (incident-management has
77567808 # production/staging/dev/local; code-review doesn't expose one).
7757- # Resolve via the generic provider hook so the runtime never
7758- # imports an app config module.
7759- app .state .environments = _resolve_environments (
7760- getattr (cfg .runtime , "environments_provider_path" , None ),
7761- )
7809+ # Read it from the YAML's top-level ``environments:`` block;
7810+ # fall back to the legacy ``environments_provider_path`` callable
7811+ # for deployments that still wire it.
7812+ if cfg .environments :
7813+ app .state .environments = list (cfg .environments )
7814+ else :
7815+ app .state .environments = _resolve_environments (
7816+ getattr (cfg .runtime , "environments_provider_path" , None ),
7817+ )
77627818
77637819 # ------------------------------------------------------------
77647820 # Build & start the trigger registry
0 commit comments