fred-runtime 3.3.7__tar.gz → 3.4.1__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 (168) hide show
  1. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/PKG-INFO +1 -1
  2. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/app/agent_app.py +365 -28
  3. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/app/context.py +86 -20
  4. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/app/openai_compat_router.py +9 -2
  5. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/capabilities/assembly.py +18 -2
  6. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/capabilities/document_access/capability.py +22 -158
  7. fred_runtime-3.4.1/fred_runtime/capabilities/document_summarize/__init__.py +38 -0
  8. fred_runtime-3.4.1/fred_runtime/capabilities/document_summarize/capability.py +325 -0
  9. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/capabilities/errors.py +10 -6
  10. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/deep/deep_runtime.py +1 -2
  11. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/graph/graph_runtime.py +59 -9
  12. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/model_routing/__init__.py +4 -0
  13. fred_runtime-3.4.1/fred_runtime/model_routing/authz.py +104 -0
  14. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/model_routing/contracts.py +107 -0
  15. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/model_routing/provider.py +100 -4
  16. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/model_routing/resolver.py +54 -0
  17. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/react/middleware/checkpoint_hygiene.py +15 -11
  18. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/react/middleware/tool_observability.py +5 -4
  19. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/react/react_prompting.py +42 -5
  20. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/react/react_runtime.py +1 -8
  21. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/react/react_tool_binding.py +30 -0
  22. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/runtime_context.py +7 -0
  23. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/runtime_support/sql_checkpointer.py +22 -0
  24. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/support/thinking.py +72 -0
  25. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime.egg-info/PKG-INFO +1 -1
  26. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime.egg-info/SOURCES.txt +8 -0
  27. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime.egg-info/entry_points.txt +1 -0
  28. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/pyproject.toml +4 -1
  29. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_agent_app.py +668 -18
  30. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_capability_agent_1973.py +43 -0
  31. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_capability_document_access_1906.py +9 -175
  32. fred_runtime-3.4.1/tests/test_capability_document_summarize.py +324 -0
  33. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_capability_endpoints_1974.py +17 -4
  34. fred_runtime-3.4.1/tests/test_context.py +370 -0
  35. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_deep_agent_middleware.py +0 -8
  36. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_graph_capability_bridge.py +128 -1
  37. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_history.py +219 -0
  38. fred_runtime-3.4.1/tests/test_model_enforcement.py +385 -0
  39. fred_runtime-3.4.1/tests/test_model_reasoning_enablement.py +328 -0
  40. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_model_routing.py +113 -1
  41. fred_runtime-3.4.1/tests/test_models_catalog_projection.py +199 -0
  42. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_react_middleware_frame.py +42 -0
  43. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_react_prompting.py +68 -3
  44. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_react_thinking.py +124 -0
  45. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_sql_checkpointer_owner.py +34 -0
  46. fred_runtime-3.4.1/tests/test_tool_carrier_middleware_naming.py +139 -0
  47. fred_runtime-3.3.7/tests/test_context.py +0 -133
  48. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/README.md +0 -0
  49. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/__init__.py +0 -0
  50. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/__main__.py +0 -0
  51. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/app/__init__.py +0 -0
  52. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/app/_catalogs.py +0 -0
  53. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/app/config.py +0 -0
  54. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/app/config_loader.py +0 -0
  55. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/app/container.py +0 -0
  56. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/app/dependencies.py +0 -0
  57. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/app/mcp_config.py +0 -0
  58. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/app/observability_factory.py +0 -0
  59. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/capabilities/__init__.py +1 -1
  60. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/capabilities/assets.py +0 -0
  61. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/capabilities/demo.py +0 -0
  62. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/capabilities/demo_migrations/env.py +0 -0
  63. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/capabilities/demo_migrations/versions/d1e2m0a0b0c0_create_demo_echo_notes.py +0 -0
  64. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/capabilities/document_access/__init__.py +0 -0
  65. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/capabilities/mcp.py +0 -0
  66. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/capabilities/openapi_dump.py +0 -0
  67. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/capabilities/registry.py +1 -1
  68. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/cli/__init__.py +0 -0
  69. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/cli/completion.py +0 -0
  70. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/cli/entrypoint.py +0 -0
  71. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/cli/history_display.py +0 -0
  72. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/cli/kpi_display.py +0 -0
  73. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/cli/pod_client.py +0 -0
  74. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/cli/repl.py +0 -0
  75. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/cli/repl_helpers.py +0 -0
  76. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/cli/url_helpers.py +0 -0
  77. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/client.py +0 -0
  78. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/common/__init__.py +0 -0
  79. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/common/context_aware_tool.py +0 -0
  80. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/common/kf_base_client.py +0 -0
  81. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/common/kf_document_client.py +0 -0
  82. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/common/kf_fast_text_client.py +0 -0
  83. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/common/kf_http_client.py +0 -0
  84. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/common/kf_markdown_media_client.py +0 -0
  85. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/common/kf_tag_client.py +0 -0
  86. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/common/kf_vectorsearch_client.py +0 -0
  87. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/common/kf_workspace_client.py +0 -0
  88. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/common/mcp_interceptors.py +0 -0
  89. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/common/mcp_runtime.py +0 -0
  90. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/common/mcp_toolkit.py +0 -0
  91. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/common/mcp_utils.py +0 -0
  92. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/common/structures.py +0 -0
  93. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/common/token_expiry.py +0 -0
  94. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/common/tool_node_utils.py +0 -0
  95. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/deep/__init__.py +0 -0
  96. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/eval/__init__.py +0 -0
  97. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/eval/collector.py +0 -0
  98. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/graph/__init__.py +0 -0
  99. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/integrations/__init__.py +0 -0
  100. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/integrations/inprocess_toolkit_registry.py +0 -0
  101. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/integrations/kf_vector_search/__init__.py +0 -0
  102. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/integrations/kf_vector_search/toolkit.py +0 -0
  103. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/integrations/v2_runtime/__init__.py +0 -0
  104. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/integrations/v2_runtime/adapters.py +0 -0
  105. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/migrations.py +0 -0
  106. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/model_routing/catalog.py +0 -0
  107. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/react/__init__.py +0 -0
  108. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/react/middleware/__init__.py +0 -0
  109. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/react/middleware/dynamic_prompt.py +0 -0
  110. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/react/middleware/frame.py +0 -0
  111. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/react/middleware/hitl.py +0 -0
  112. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/react/middleware/model_routing.py +0 -0
  113. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/react/middleware/shared.py +0 -0
  114. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/react/middleware/tracing_kpi.py +0 -0
  115. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/react/react_langchain_adapter.py +0 -0
  116. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/react/react_message_codec.py +0 -0
  117. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/react/react_model_adapter.py +0 -0
  118. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/react/react_stream_adapter.py +0 -0
  119. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/react/react_tool_loop.py +0 -0
  120. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/react/react_tool_rendering.py +0 -0
  121. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/react/react_tool_resolution.py +0 -0
  122. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/react/react_tool_utils.py +0 -0
  123. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/react/react_tracing.py +0 -0
  124. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/runtime_support/__init__.py +0 -0
  125. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/runtime_support/checkpoints.py +0 -0
  126. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/runtime_support/model_metadata.py +0 -0
  127. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/runtime_support/request_context_helpers.py +0 -0
  128. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/runtime_support/user_token_refresher.py +0 -0
  129. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/support/__init__.py +0 -0
  130. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/support/filesystem_context.py +0 -0
  131. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime/support/tool_loop.py +0 -0
  132. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime.egg-info/dependency_links.txt +0 -0
  133. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime.egg-info/requires.txt +0 -0
  134. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/fred_runtime.egg-info/top_level.txt +0 -0
  135. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/setup.cfg +0 -0
  136. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_capability_chat_controls_1976.py +0 -0
  137. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_capability_chat_parts_1977.py +0 -0
  138. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_capability_registry_1973.py +1 -1
  139. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_capability_routes_tables_1979.py +0 -0
  140. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_capability_selection_1974.py +0 -0
  141. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_capability_tool_return_convention.py +1 -1
  142. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_client.py +0 -0
  143. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_config_loader.py +0 -0
  144. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_context_aware_tool.py +0 -0
  145. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_conversational_memory.py +0 -0
  146. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_eval_collector.py +0 -0
  147. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_eval_trace.py +0 -0
  148. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_fred_workspace_fs.py +0 -0
  149. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_graph_runtime_invoke_agent.py +0 -0
  150. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_graph_runtime_observability.py +0 -0
  151. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_kf_vector_search_toolkit.py +0 -0
  152. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_kf_workspace_client.py +0 -0
  153. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_knowledge_search_tool_invoker_sources.py +0 -0
  154. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_kpi_display.py +0 -0
  155. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_mcp_config.py +0 -0
  156. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_openai_compat_router.py +0 -0
  157. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_pod_client.py +0 -0
  158. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_react_loop_regressions_1972.py +0 -0
  159. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_repl_helpers.py +0 -0
  160. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_runtime_context_prompt_injection.py +0 -0
  161. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_smoke.py +0 -0
  162. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_spike_capability_state_1971.py +0 -0
  163. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_token_expiry.py +0 -0
  164. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_tool_loop_trim.py +0 -0
  165. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_tool_observability_middleware.py +0 -0
  166. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_tracing_kpi_content_confinement.py +0 -0
  167. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_url_helpers.py +0 -0
  168. {fred_runtime-3.3.7 → fred_runtime-3.4.1}/tests/test_user_token_refresher.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fred-runtime
3
- Version: 3.3.7
3
+ Version: 3.4.1
4
4
  Summary: Runtime adapters and infrastructure wiring for Fred v2 agents.
5
5
  Author-email: Thales <noreply@thalesgroup.com>
6
6
  License: Apache-2.0
@@ -56,8 +56,10 @@ from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, status
56
56
  from fastapi.middleware.cors import CORSMiddleware
57
57
  from fastapi.responses import StreamingResponse
58
58
  from fred_core.common.config_loader import get_config
59
+ from fred_core.diagnostics import install_gc_diagnostics
59
60
  from fred_core.history.history_schema import ChatMessage
60
61
  from fred_core.kpi import KPIMiddleware
62
+ from fred_core.kpi.kpi_runtime_stage_metric import runtime_stage_timer
61
63
  from fred_core.kpi.kpi_writer_structures import KPIActor
62
64
  from fred_core.logs.audit_log import emit_audit_log
63
65
  from fred_core.logs.log_setup import log_setup
@@ -88,6 +90,7 @@ from fred_sdk.contracts.context import (
88
90
  PortableContext,
89
91
  PortableEnvironment,
90
92
  RuntimeContext,
93
+ TeamOperationRouteRule,
91
94
  )
92
95
  from fred_sdk.contracts.eval import EvalStep, EvalTrace
93
96
  from fred_sdk.contracts.execution import (
@@ -791,6 +794,7 @@ def _build_runtime_services(
791
794
  return RuntimeServices(
792
795
  tracer=build_default_tracer(),
793
796
  metrics=KPIWriterMetricsAdapter(runtime_config.kpi_writer),
797
+ kpi_writer=runtime_config.kpi_writer,
794
798
  chat_model_factory=runtime_config.chat_model_factory,
795
799
  tool_invoker=tool_invoker,
796
800
  tool_provider=tool_provider,
@@ -967,6 +971,84 @@ class _McpCatalogResponse(BaseModel):
967
971
  servers: list[_McpCatalogEntry]
968
972
 
969
973
 
974
+ class _ModelCatalogEntry(BaseModel):
975
+ id: str
976
+ provider: str
977
+ name: str
978
+ description: str | None = None
979
+ profile_ids: list[str] = Field(default_factory=list)
980
+ """Every `models_catalog.yaml` profile_id sharing this entry's
981
+ (provider, name) — TEAM-ROUTING-POLICY-RFC.md §7.1's id-space
982
+ translation: a routing policy picks by profile_id (finer-grained than
983
+ this capability id), so control-plane needs a way back from profile_id
984
+ to capability id for write-time enablement validation, and the
985
+ team-settings picker needs the reverse (which profile_ids this enabled
986
+ capability actually offers). Declaration order in the source YAML,
987
+ first-seen first — same order `default_profile_by_capability` favors."""
988
+ thinking_profile_ids: list[str] = Field(default_factory=list)
989
+ """The subset of `profile_ids` above declaring `supports_thinking`
990
+ (`MODEL-REASONING-ENABLEMENT-RFC.md` §5.3, REASON-01). DERIVED here,
991
+ never authored twice: aptitude is declared per profile (§4, where the
992
+ behaviour actually differs), while the admin toggle is keyed per model
993
+ because that is what the capability id space and the admin UI both are
994
+ (§5.2 — the id space is not negotiable). This field is the projection
995
+ between the two.
996
+
997
+ Empty means this model has NO reasoning-capable profile, and the admin row
998
+ shows no reasoning control at all — aptitude is not a choice, an
999
+ administrator cannot make a model reason. Non-empty means the row carries
1000
+ the toggle. Same established join as `profile_ids` above."""
1001
+
1002
+
1003
+ class _ModelCatalogResponse(BaseModel):
1004
+ models: list[_ModelCatalogEntry]
1005
+
1006
+
1007
+ def _project_model_catalog_entries(catalog: Any) -> list[_ModelCatalogEntry]:
1008
+ """One `_ModelCatalogEntry` per distinct (provider, name) pair in a
1009
+ loaded `ModelCatalog` (OBSERV-02 v3, `AGENT-CAPABILITY-RFC.md` §8.7).
1010
+
1011
+ Pulled out of `get_models_catalog` as a pure function so it is directly
1012
+ unit-testable without a running app (`agent_app.py` has no existing
1013
+ FastAPI TestClient harness — building one just for this route would be
1014
+ disproportionate; this function is where the actual logic worth testing
1015
+ lives). `catalog` is typed `Any` here to avoid a module-level
1016
+ `..model_routing` import (see the route's own lazy import for why);
1017
+ callers pass a real `ModelCatalog`.
1018
+ """
1019
+ from fred_sdk.contracts.capability.manifest import model_capability_id
1020
+
1021
+ seen: dict[tuple[str, str], _ModelCatalogEntry] = {}
1022
+ for profile in catalog.profiles:
1023
+ provider = profile.model.provider
1024
+ name = profile.model.name
1025
+ # ModelProfile.validate_model (contracts.py) already guarantees both
1026
+ # are non-empty strings for every profile that exists — asserted
1027
+ # (not a skip/continue) so this narrows the type instead of hiding
1028
+ # an invariant violation the validator itself is supposed to prevent.
1029
+ assert provider and name, (
1030
+ f"ModelProfile {profile.profile_id!r} passed validation without provider/name"
1031
+ )
1032
+ key = (provider, name)
1033
+ existing = seen.get(key)
1034
+ if existing is None:
1035
+ existing = _ModelCatalogEntry(
1036
+ id=model_capability_id(provider, name),
1037
+ provider=provider,
1038
+ name=name,
1039
+ description=profile.description,
1040
+ profile_ids=[profile.profile_id],
1041
+ )
1042
+ seen[key] = existing
1043
+ else:
1044
+ existing.profile_ids.append(profile.profile_id)
1045
+ # REASON-01 §5.3: aptitude is per profile, the toggle is per model —
1046
+ # this one condition is the whole projection between them.
1047
+ if profile.supports_thinking:
1048
+ existing.thinking_profile_ids.append(profile.profile_id)
1049
+ return list(seen.values())
1050
+
1051
+
970
1052
  class _ResolvedAgentInstance(BaseModel):
971
1053
  agent_instance_id: str
972
1054
  template_agent_id: str
@@ -1120,7 +1202,9 @@ async def _resolve_agent_instance(
1120
1202
  registry: Mapping[str, ReActAgentDefinition | GraphAgentDefinition],
1121
1203
  access_token: str | None,
1122
1204
  control_plane_url: str | None,
1205
+ http_client: httpx.AsyncClient,
1123
1206
  team_id: str | None = None,
1207
+ request_id: str | None = None,
1124
1208
  ) -> _ResolvedExecutionTarget:
1125
1209
  """
1126
1210
  Resolve a direct or managed execution target into a concrete definition.
@@ -1195,9 +1279,17 @@ async def _resolve_agent_instance(
1195
1279
  f"{control_plane_url.rstrip('/')}/teams/{team_id}/agent-instances/"
1196
1280
  f"{request.agent_instance_id}/runtime"
1197
1281
  )
1198
- headers = {"Authorization": f"Bearer {access_token}"} if access_token else None
1199
- async with httpx.AsyncClient(timeout=10.0) as client:
1200
- response = await client.get(url, headers=headers)
1282
+ headers: dict[str, str] = {}
1283
+ if access_token:
1284
+ headers["Authorization"] = f"Bearer {access_token}"
1285
+ if request_id:
1286
+ # Correlates this pod-side call with the control-plane request it
1287
+ # triggers — both sides stamp the same id as `trace.trace_id` on their
1288
+ # `runtime.stage_latency_ms{runtime_stage=runtime_binding*}` event
1289
+ # (TURN-01 evidence gap: no way to join a turn to its binding call
1290
+ # besides timestamps).
1291
+ headers["X-Request-Id"] = request_id
1292
+ response = await http_client.get(url, headers=headers or None)
1201
1293
  if response.status_code == status.HTTP_404_NOT_FOUND:
1202
1294
  raise HTTPException(
1203
1295
  status_code=404, detail=response.text or "Unknown agent instance."
@@ -1286,12 +1378,21 @@ async def _authorize_execution_or_raise(
1286
1378
  the owner's `team_editor` tuple self-heals on first touch inside
1287
1379
  `RebacEngine.check_user_team_permission_or_raise`, and `add_relation`'s
1288
1380
  write-guard refuses any tuple naming a personal team except that one, so
1289
- the plain `CAN_READ` check below already authorizes the owner and denies
1290
- everyone else (another user's space, or the bare `"personal"` alias, for
1291
- which no tuple is ever provisioned) no special-casing needed here.
1292
- - otherwise require the caller to hold `CAN_READ` on the requested team — the
1293
- same relation the control-plane required before it would mint a grant. The
1294
- team is caller-supplied but safe: OpenFGA only authorizes teams the user
1381
+ the `CAN_USE_TEAM_AGENTS` check below already authorizes the owner
1382
+ (`team_editor` implies `team_member`) and denies everyone else (another
1383
+ user's space, or the bare `"personal"` alias, for which no tuple is
1384
+ ever provisioned) no special-casing needed here.
1385
+ - otherwise require the caller to hold `CAN_USE_TEAM_AGENTS` on the
1386
+ requested team `team_member`-only, unlike `CAN_READ` (which also
1387
+ admits the `public` marketplace-discovery relation, TEAM-09/TEAM-10).
1388
+ Execution is real content access, not discovery, so a public-team
1389
+ visitor must not pass here even though they can see the team profile.
1390
+ This used to check `CAN_READ` (the same relation the control-plane
1391
+ required before it would mint a grant) — that predates TEAM-09's
1392
+ `public` relation and was never revisited; `product/api.py`'s
1393
+ `post_prepare_execution` made the identical correction for the same
1394
+ reason (returns prompt content, not just config). The team is
1395
+ caller-supplied but safe: OpenFGA only authorizes teams the user
1295
1396
  actually holds a relation to. Authorization and denial are both audited;
1296
1397
  any OpenFGA denial fails closed (403).
1297
1398
 
@@ -1374,7 +1475,7 @@ async def _authorize_execution_or_raise(
1374
1475
  # since no tuple is ever provisioned for that literal string).
1375
1476
  try:
1376
1477
  await rebac.check_user_team_permission_or_raise(
1377
- authenticated_user, TeamPermission.CAN_READ, team_id
1478
+ authenticated_user, TeamPermission.CAN_USE_TEAM_AGENTS, team_id
1378
1479
  )
1379
1480
  except AuthorizationError as exc:
1380
1481
  _emit_audit_event(
@@ -1507,7 +1608,8 @@ async def _authorize_and_resolve(
1507
1608
  )
1508
1609
  await _validate_session_checkpoint_access(request)
1509
1610
  await _enforce_session_ownership(request, authenticated_user, container)
1510
- await _authorize_execution_or_raise(request, authenticated_user, container)
1611
+ async with runtime_stage_timer(container.get_kpi_writer(), "pod_authz"):
1612
+ await _authorize_execution_or_raise(request, authenticated_user, container)
1511
1613
  internal_req = _to_internal_request(request)
1512
1614
  # Stamp the trusted service-agent verdict (never the caller-supplied
1513
1615
  # context) so per-tool-call re-authorization can mirror the bypass
@@ -1521,13 +1623,19 @@ async def _authorize_and_resolve(
1521
1623
  else:
1522
1624
  ctx.pop("is_service_agent", None)
1523
1625
  internal_req.context = ctx
1524
- target = await _resolve_agent_instance(
1525
- request=internal_req,
1526
- registry=registry,
1527
- access_token=access_token,
1528
- control_plane_url=get_runtime_context().config.control_plane_url,
1529
- team_id=request.effective_team_id(),
1530
- )
1626
+ binding_request_id = str(uuid4())
1627
+ async with runtime_stage_timer(
1628
+ container.get_kpi_writer(), "runtime_binding", trace_id=binding_request_id
1629
+ ):
1630
+ target = await _resolve_agent_instance(
1631
+ request=internal_req,
1632
+ registry=registry,
1633
+ access_token=access_token,
1634
+ control_plane_url=get_runtime_context().config.control_plane_url,
1635
+ http_client=container.get_control_plane_http_client(),
1636
+ team_id=request.effective_team_id(),
1637
+ request_id=binding_request_id,
1638
+ )
1531
1639
  _validate_resolved_team(request, target.team_id, container)
1532
1640
  return internal_req, target
1533
1641
 
@@ -1651,6 +1759,27 @@ async def _validate_session_checkpoint_access(
1651
1759
  )
1652
1760
 
1653
1761
 
1762
+ @dataclass
1763
+ class _OpenThought:
1764
+ """
1765
+ One reasoning block being accumulated while mapping a turn to history rows.
1766
+
1767
+ Why the rank is reserved at THOUGHT_START rather than at THOUGHT_END:
1768
+ - a model-native block opens on the first reasoning token and closes only at
1769
+ the first answer delta (RFC AGENT-THINKING-API §7.3), so it brackets every
1770
+ tool call of the turn
1771
+ - the live UI assigns the row's rank at `thought_start` (`useChatSse.ts`);
1772
+ ranking it at close instead would file the reasoning *after* the tools it
1773
+ actually preceded, so a reloaded trace would not match what the user saw
1774
+ """
1775
+
1776
+ rank: int
1777
+ phase: str
1778
+ title: str | None
1779
+ source: str | None
1780
+ text: list[str] = field(default_factory=list)
1781
+
1782
+
1654
1783
  async def _write_turn_history(
1655
1784
  *,
1656
1785
  session_id: str,
@@ -1686,6 +1815,7 @@ async def _write_turn_history(
1686
1815
  Event-to-message mapping:
1687
1816
  - ``tool_call`` → ``Role.assistant / Channel.tool_call``
1688
1817
  - ``tool_result`` → ``Role.tool / Channel.tool_result``
1818
+ - ``thought_*`` → ``Role.assistant / Channel.thought`` (one row per block)
1689
1819
  - ``awaiting_human`` → ``Role.system / Channel.hitl_request`` (full choices)
1690
1820
  - ``node_error`` → ``Role.system / Channel.error``
1691
1821
  - ``final`` → ``Role.assistant / Channel.final`` (answer + sources)
@@ -1695,6 +1825,7 @@ async def _write_turn_history(
1695
1825
  from fred_core.history.history_schema import (
1696
1826
  Channel,
1697
1827
  ChatMessage,
1828
+ ChatMetadata,
1698
1829
  ChatTokenUsage,
1699
1830
  Role,
1700
1831
  TextPart,
@@ -1738,6 +1869,55 @@ async def _write_turn_history(
1738
1869
  messages.append(make_user_text(session_id, exchange_id, rank, request_message))
1739
1870
  rank += 1
1740
1871
 
1872
+ # Reasoning blocks still accumulating, keyed by thought_id. A block reserves
1873
+ # its rank when it opens but only becomes a row once its text is complete.
1874
+ open_thoughts: dict[str, _OpenThought] = {}
1875
+
1876
+ def _thought_row(
1877
+ block: _OpenThought,
1878
+ *,
1879
+ thought_id: str,
1880
+ conclusion: str | None = None,
1881
+ duration_ms: int | None = None,
1882
+ ) -> ChatMessage:
1883
+ """
1884
+ Build one ``Channel.thought`` row from an accumulated reasoning block.
1885
+
1886
+ The envelope must match what the live stream emits client-side
1887
+ (`useChatSse.ts`) — same role, same channel, same ``metadata.extras``
1888
+ keys — because the chat UI feeds streamed and reloaded messages through
1889
+ a single rendering path (`traceUtils.thoughtExtras`).
1890
+
1891
+ ``streaming_delta`` is deliberately absent: a persisted block is
1892
+ complete, and carrying that flag would render it as still running.
1893
+ """
1894
+ return ChatMessage(
1895
+ session_id=session_id,
1896
+ exchange_id=exchange_id,
1897
+ rank=block.rank,
1898
+ timestamp=datetime.now(timezone.utc),
1899
+ role=Role.assistant,
1900
+ channel=Channel.thought,
1901
+ parts=[TextPart(text="".join(block.text))],
1902
+ # `extras` is not a declared field: `ChatMetadata` sets
1903
+ # `extra="allow"` precisely so callers can attach structured payloads
1904
+ # without every consumer of the model growing a dependency on them.
1905
+ # `model_validate` is how that is reached type-safely — a keyword
1906
+ # argument would not type-check against the declared fields.
1907
+ metadata=ChatMetadata.model_validate(
1908
+ {
1909
+ "extras": {
1910
+ "thought_id": thought_id,
1911
+ "phase": block.phase,
1912
+ "title": block.title,
1913
+ "source": block.source,
1914
+ "conclusion": conclusion,
1915
+ "duration_ms": duration_ms,
1916
+ }
1917
+ }
1918
+ ),
1919
+ )
1920
+
1741
1921
  # 2. Map runtime events to messages
1742
1922
  final_content = ""
1743
1923
  final_sources: list[VectorSearchHit] = []
@@ -1775,6 +1955,48 @@ async def _write_turn_history(
1775
1955
  )
1776
1956
  rank += 1
1777
1957
 
1958
+ elif kind == "thought_start":
1959
+ # `tool_use` blocks are synthetic bookkeeping the runtime opens around
1960
+ # every tool call (RFC Amendment A, Layer 1). The chat UI has hidden
1961
+ # them since Amendment B — the tool call/result combo row already
1962
+ # carries what ran, how it went and how long it took. Persisting them
1963
+ # would file up to `max_tool_calls_per_turn` content-free rows a turn.
1964
+ if payload.get("phase") == "tool_use":
1965
+ continue
1966
+ thought_id = str(payload.get("thought_id") or "")
1967
+ if not thought_id:
1968
+ continue
1969
+ open_thoughts[thought_id] = _OpenThought(
1970
+ rank=rank,
1971
+ phase=str(payload.get("phase") or "planning"),
1972
+ title=payload.get("title"),
1973
+ source=payload.get("source"),
1974
+ )
1975
+ rank += 1
1976
+
1977
+ elif kind == "thought_delta":
1978
+ block = open_thoughts.get(str(payload.get("thought_id") or ""))
1979
+ if block is not None:
1980
+ block.text.append(str(payload.get("delta") or ""))
1981
+
1982
+ elif kind == "thought_end":
1983
+ thought_id = str(payload.get("thought_id") or "")
1984
+ block = open_thoughts.pop(thought_id, None)
1985
+ # A block with neither text nor conclusion carries nothing to show;
1986
+ # persisting it would render an empty reasoning card on reload. Its
1987
+ # reserved rank is simply left unused — ranks must increase, not be
1988
+ # contiguous.
1989
+ conclusion = payload.get("conclusion")
1990
+ if block is not None and ("".join(block.text) or conclusion):
1991
+ messages.append(
1992
+ _thought_row(
1993
+ block,
1994
+ thought_id=thought_id,
1995
+ conclusion=conclusion,
1996
+ duration_ms=payload.get("duration_ms"),
1997
+ )
1998
+ )
1999
+
1778
2000
  elif kind == "awaiting_human":
1779
2001
  # Store the full HITL gate definition — question and all choices —
1780
2002
  # so audit logs and UI replay have the complete structured record.
@@ -1833,6 +2055,14 @@ async def _write_turn_history(
1833
2055
  final_model = payload.get("model_name")
1834
2056
  final_finish_reason = payload.get("finish_reason")
1835
2057
 
2058
+ # 2b. Blocks the stream never closed (truncated turn, crashed node). The live
2059
+ # UI closes them itself on `final` so the accordion does not pulse forever
2060
+ # (`useChatSse.ts`); history keeps the same parity — reasoning the user was
2061
+ # shown must not disappear on reload just because the block was left open.
2062
+ for thought_id, block in open_thoughts.items():
2063
+ if "".join(block.text):
2064
+ messages.append(_thought_row(block, thought_id=thought_id))
2065
+
1836
2066
  # 3. Terminal assistant message (from FinalRuntimeEvent)
1837
2067
  if final_content or final_model:
1838
2068
  messages.append(
@@ -2401,8 +2631,8 @@ def _build_capability_block(
2401
2631
  # one. No production capability declares an active `HitlSpec` today,
2402
2632
  # so refusing here costs nothing real yet; reconciling Graph's own
2403
2633
  # node-level pause/resume with the per-tool HITL gate is real design
2404
- # work, deferred (id-legend.yaml CAPAB-02). Refusing loudly keeps the
2405
- # RFC §3.9 "never silently degrade" guarantee intact in the meantime
2634
+ # work, deferred (see AGENT-CAPABILITY-RFC.md §3.9). Refusing loudly
2635
+ # keeps the RFC's "never silently degrade" guarantee intact in the meantime
2406
2636
  # — a capability with `HitlSpec`s that silently ran ungated on Graph
2407
2637
  # would be exactly the kind of governance gap this platform exists to
2408
2638
  # prevent.
@@ -2472,6 +2702,19 @@ async def _iterate_runtime_event_payloads(
2472
2702
  ctx = request.context or {}
2473
2703
  correlation_id = ctx.get("correlation_id", request_id)
2474
2704
  resolved_team_id = team_id or ctx.get("team_id")
2705
+ # kind="model" enforcement (OBSERV-02 v3, AGENT-CAPABILITY-RFC.md §8.7):
2706
+ # computed ONCE per turn, here — never inside model-routing resolution,
2707
+ # which runs multiple times per turn and must never itself make a ReBAC
2708
+ # call. No team context (agent-to-agent invocation) → no restriction,
2709
+ # the same posture _authorize_execution_or_raise already applies to a
2710
+ # teamless request.
2711
+ usable_model_ids: tuple[str, ...] | None = None
2712
+ if resolved_team_id is not None:
2713
+ from ..model_routing import usable_model_capability_ids
2714
+
2715
+ rebac = get_runtime_context().config.rebac_engine
2716
+ ids = await usable_model_capability_ids(rebac, resolved_team_id)
2717
+ usable_model_ids = tuple(ids) if ids is not None else None
2475
2718
  execution_action = ctx.get("execution_action") or (
2476
2719
  ExecutionGrantAction.RESUME.value
2477
2720
  if request.resume_payload is not None
@@ -2540,11 +2783,55 @@ async def _iterate_runtime_event_payloads(
2540
2783
  # When the final file is deleted this is absent, so the per-turn runtime
2541
2784
  # notice disappears without leaving a checkpointed system message behind.
2542
2785
  attachments_markdown=ctx.get("attachments_markdown"),
2786
+ # Team routing policy snapshot (TEAM-ROUTING-POLICY-RFC.md §3/§8):
2787
+ # control-plane resolves it once at prepare-execution and the frontend
2788
+ # forwards it unchanged, same channel as context_prompt_text above — but
2789
+ # it was also silently dropped here, so resolve_team_override in
2790
+ # fred_runtime.model_routing.provider always saw None and no team's
2791
+ # routing policy ever took effect on a real chat turn. ctx already holds
2792
+ # the flat dict from to_legacy_context()'s model_dump(exclude_none=True);
2793
+ # operation_route_rules needs re-validating back into typed rows.
2794
+ chat_default_profile_id=ctx.get("chat_default_profile_id"),
2795
+ operation_route_rules=[
2796
+ TeamOperationRouteRule.model_validate(rule)
2797
+ for rule in (ctx.get("operation_route_rules") or [])
2798
+ ]
2799
+ or None,
2800
+ # Platform reasoning activation snapshot (REASON-01,
2801
+ # `MODEL-REASONING-ENABLEMENT-RFC.md` §5.5) — same channel and same
2802
+ # session-prep lifecycle as the two fields above, and forwarded here
2803
+ # for the same hard-won reason: this mapping is field-by-field, so a
2804
+ # field that is not named is silently dropped. Both comments above
2805
+ # record a production bug caused by exactly that. `RoutedChatModelFactory`
2806
+ # strips the reasoning settings for every model absent from this list
2807
+ # (§5.6.2), so dropping it here would pin reasoning permanently OFF and
2808
+ # make the admin toggle decorative — the failure §5.6.2 exists to
2809
+ # prevent, one layer up from where it is enforced.
2810
+ #
2811
+ # Intersected with level 3 (the agent's own switch) rather than taken
2812
+ # as-is, and that is the whole point of doing it HERE: this list rides
2813
+ # the request, `tuning` is resolved server-side from the managed
2814
+ # instance. An agent whose author did not enable reasoning must not
2815
+ # reason whatever the request claims the platform allows — all four
2816
+ # levels must hold (§3), so the ceiling is `level 2 AND level 3`.
2817
+ # Absent tuning (agent-to-agent invocation, no managed instance) means
2818
+ # no author ever enabled it: off, the default everywhere in REASON-01.
2819
+ reasoning_enabled_model_ids=(
2820
+ ctx.get("reasoning_enabled_model_ids")
2821
+ if tuning is not None and tuning.reasoning_enabled
2822
+ else []
2823
+ ),
2824
+ # The user's per-question reasoning choice (REASON-01 level 4). Same
2825
+ # trap as every field above: unnamed here means silently dropped. Kept
2826
+ # tri-state on purpose — `ctx.get` yielding None means "the agent never
2827
+ # offered the choice", which is NOT the same as the user answering no.
2828
+ reasoning=ctx.get("reasoning"),
2543
2829
  )
2544
2830
 
2545
2831
  binding = BoundRuntimeContext(
2546
2832
  runtime_context=runtime_context,
2547
2833
  portable_context=portable_context,
2834
+ usable_model_ids=usable_model_ids,
2548
2835
  )
2549
2836
 
2550
2837
  services = _build_runtime_services(
@@ -2944,6 +3231,42 @@ def _build_agent_router(
2944
3231
  ]
2945
3232
  )
2946
3233
 
3234
+ @router.get("/models-catalog")
3235
+ async def get_models_catalog() -> _ModelCatalogResponse:
3236
+ """
3237
+ Return this pod's routable models, one entry per distinct
3238
+ (provider, name) pair (OBSERV-02 v3, `AGENT-CAPABILITY-RFC.md` §8.7).
3239
+
3240
+ Why this endpoint exists:
3241
+ - control-plane's capability catalog aggregation
3242
+ (`aggregate_capability_catalog`) has no other way to learn what
3243
+ models this pod can route to — `models_catalog.yaml` is loaded
3244
+ only here, for routing, with no prior control-plane consumer.
3245
+ - one entry per (provider, name), not per catalog profile: a model
3246
+ used for both the `chat` and `language` routing capability is one
3247
+ admin enablement decision, not two.
3248
+
3249
+ Re-reads `models_catalog.yaml` fresh on every call (same file
3250
+ `_build_chat_model_factory` loaded once at boot) rather than
3251
+ caching the parsed catalog — this route is polled rarely (admin
3252
+ catalog refresh), never per-turn, so the extra local file read is
3253
+ immaterial and keeps this always consistent with whatever the pod
3254
+ would actually resolve, with no separate cache to go stale.
3255
+
3256
+ How to use it:
3257
+ - call from control-plane's `_model_capabilities_for_source`
3258
+
3259
+ Example:
3260
+ - `GET /fred/agents/v2/agents/models-catalog`
3261
+ """
3262
+ from ..model_routing import load_model_catalog
3263
+
3264
+ catalog_path = get_runtime_context().config.models_catalog_path
3265
+ if not catalog_path:
3266
+ return _ModelCatalogResponse(models=[])
3267
+ catalog = load_model_catalog(catalog_path)
3268
+ return _ModelCatalogResponse(models=_project_model_catalog_entries(catalog))
3269
+
2947
3270
  @router.post("/capabilities/{capability_id}/validate-config")
2948
3271
  async def validate_capability_config(
2949
3272
  capability_id: str,
@@ -3884,13 +4207,19 @@ def create_agent_app(
3884
4207
  async def lifespan(app: FastAPI) -> AsyncIterator[None]:
3885
4208
  # Boot order (must be preserved — each step depends on the previous):
3886
4209
  # 1. log_setup — formatter/handlers ready for all subsequent logs
3887
- # 2. initialize_kpi_writerneeded by bootstrap_observability
3888
- # 3. bootstrap_observability global tracer + metrics provider
3889
- # 4. attach_pod_container container in app.state before any request
3890
- # 5. initialize_sql — async, may take time
3891
- # 6. start_metrics_exporter prometheus thread, after KPI writer exists
3892
- # 7. start_kpi_tasks asyncio tasks, after SQL engine is known
3893
- # 8. set_runtime_contextwires all built parts into the global config
4210
+ # 2. install_gc_diagnosticsindependent of everything below; installed
4211
+ # early so SIGUSR1/SIGUSR2 (fred_core.diagnostics) are live for the
4212
+ # pod's whole lifetime, protecting every agent pod (ours and third
4213
+ # parties' alike) against the reference-cycle class of leak found in
4214
+ # ISSUE-010 without any per-pod code
4215
+ # 3. initialize_kpi_writer needed by bootstrap_observability
4216
+ # 4. initialize_control_plane_clientsync, no network until first call
4217
+ # 5. bootstrap_observability — global tracer + metrics provider
4218
+ # 6. attach_pod_container — container in app.state before any request
4219
+ # 7. initialize_sql — async, may take time
4220
+ # 8. start_metrics_exporter — prometheus thread, after KPI writer exists
4221
+ # 9. start_kpi_tasks — asyncio tasks, after SQL engine is known
4222
+ # 10. set_runtime_context — wires all built parts into the global config
3894
4223
  log_setup(
3895
4224
  service_name=config.app.name,
3896
4225
  log_level=config.app.log_level,
@@ -3899,8 +4228,10 @@ def create_agent_app(
3899
4228
  opensearch_config=config.storage.opensearch,
3900
4229
  ),
3901
4230
  )
4231
+ gc_diagnostics = install_gc_diagnostics()
3902
4232
  container = build_pod_container(config)
3903
4233
  container.initialize_kpi_writer()
4234
+ container.initialize_control_plane_client()
3904
4235
  bootstrap_observability(
3905
4236
  config.observability, kpi_writer=container.get_kpi_writer()
3906
4237
  )
@@ -3918,7 +4249,11 @@ def create_agent_app(
3918
4249
  # every execution against OpenFGA; a disabled/Noop engine (dev) means
3919
4250
  # identity-only. Safe in all modes — the factory returns a Noop with a
3920
4251
  # KeycloackDisabled admin client when user/m2m auth is off.
3921
- rebac_engine = rebac_factory(security) if security is not None else None
4252
+ rebac_engine = (
4253
+ rebac_factory(security, kpi_writer=container.get_kpi_writer())
4254
+ if security is not None
4255
+ else None
4256
+ )
3922
4257
  chat_factory = _build_chat_model_factory(config)
3923
4258
  await container.initialize_sql()
3924
4259
  container.start_metrics_exporter()
@@ -3939,6 +4274,7 @@ def create_agent_app(
3939
4274
  checkpointer=checkpointer,
3940
4275
  history_store=history_store,
3941
4276
  mcp_configuration=config.get_mcp_configuration(),
4277
+ models_catalog_path=config.get_models_catalog_path(),
3942
4278
  inprocess_toolkit_factory=build_inprocess_toolkit,
3943
4279
  control_plane_url=config.platform.control_plane_url,
3944
4280
  rebac_engine=rebac_engine,
@@ -3961,6 +4297,7 @@ def create_agent_app(
3961
4297
  list(registry.keys()),
3962
4298
  )
3963
4299
  yield
4300
+ await gc_diagnostics.stop()
3964
4301
  await container.shutdown()
3965
4302
 
3966
4303
  app = FastAPI(