fred-runtime 3.3.7__tar.gz → 3.4.0__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 (162) hide show
  1. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/PKG-INFO +1 -1
  2. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/app/agent_app.py +170 -20
  3. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/app/context.py +34 -6
  4. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/app/openai_compat_router.py +4 -0
  5. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/capabilities/assembly.py +15 -1
  6. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/capabilities/errors.py +10 -6
  7. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/graph/graph_runtime.py +59 -9
  8. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/model_routing/__init__.py +4 -0
  9. fred_runtime-3.4.0/fred_runtime/model_routing/authz.py +104 -0
  10. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/model_routing/contracts.py +40 -0
  11. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/model_routing/provider.py +68 -3
  12. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/model_routing/resolver.py +54 -0
  13. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/react_prompting.py +42 -5
  14. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/runtime_context.py +7 -0
  15. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/runtime_support/sql_checkpointer.py +22 -0
  16. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime.egg-info/PKG-INFO +1 -1
  17. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime.egg-info/SOURCES.txt +3 -0
  18. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/pyproject.toml +1 -1
  19. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_agent_app.py +401 -5
  20. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_capability_agent_1973.py +43 -0
  21. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_capability_endpoints_1974.py +3 -0
  22. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_context.py +75 -0
  23. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_graph_capability_bridge.py +128 -1
  24. fred_runtime-3.4.0/tests/test_model_enforcement.py +385 -0
  25. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_model_routing.py +113 -1
  26. fred_runtime-3.4.0/tests/test_models_catalog_projection.py +139 -0
  27. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_react_prompting.py +23 -3
  28. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_sql_checkpointer_owner.py +34 -0
  29. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/README.md +0 -0
  30. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/__init__.py +0 -0
  31. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/__main__.py +0 -0
  32. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/app/__init__.py +0 -0
  33. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/app/_catalogs.py +0 -0
  34. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/app/config.py +0 -0
  35. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/app/config_loader.py +0 -0
  36. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/app/container.py +0 -0
  37. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/app/dependencies.py +0 -0
  38. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/app/mcp_config.py +0 -0
  39. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/app/observability_factory.py +0 -0
  40. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/capabilities/__init__.py +1 -1
  41. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/capabilities/assets.py +0 -0
  42. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/capabilities/demo.py +0 -0
  43. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/capabilities/demo_migrations/env.py +0 -0
  44. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/capabilities/demo_migrations/versions/d1e2m0a0b0c0_create_demo_echo_notes.py +0 -0
  45. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/capabilities/document_access/__init__.py +0 -0
  46. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/capabilities/document_access/capability.py +0 -0
  47. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/capabilities/mcp.py +0 -0
  48. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/capabilities/openapi_dump.py +0 -0
  49. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/capabilities/registry.py +1 -1
  50. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/cli/__init__.py +0 -0
  51. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/cli/completion.py +0 -0
  52. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/cli/entrypoint.py +0 -0
  53. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/cli/history_display.py +0 -0
  54. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/cli/kpi_display.py +0 -0
  55. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/cli/pod_client.py +0 -0
  56. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/cli/repl.py +0 -0
  57. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/cli/repl_helpers.py +0 -0
  58. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/cli/url_helpers.py +0 -0
  59. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/client.py +0 -0
  60. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/__init__.py +0 -0
  61. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/context_aware_tool.py +0 -0
  62. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/kf_base_client.py +0 -0
  63. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/kf_document_client.py +0 -0
  64. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/kf_fast_text_client.py +0 -0
  65. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/kf_http_client.py +0 -0
  66. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/kf_markdown_media_client.py +0 -0
  67. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/kf_tag_client.py +0 -0
  68. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/kf_vectorsearch_client.py +0 -0
  69. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/kf_workspace_client.py +0 -0
  70. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/mcp_interceptors.py +0 -0
  71. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/mcp_runtime.py +0 -0
  72. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/mcp_toolkit.py +0 -0
  73. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/mcp_utils.py +0 -0
  74. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/structures.py +0 -0
  75. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/token_expiry.py +0 -0
  76. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/tool_node_utils.py +0 -0
  77. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/deep/__init__.py +0 -0
  78. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/deep/deep_runtime.py +0 -0
  79. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/eval/__init__.py +0 -0
  80. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/eval/collector.py +0 -0
  81. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/graph/__init__.py +0 -0
  82. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/integrations/__init__.py +0 -0
  83. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/integrations/inprocess_toolkit_registry.py +0 -0
  84. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/integrations/kf_vector_search/__init__.py +0 -0
  85. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/integrations/kf_vector_search/toolkit.py +0 -0
  86. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/integrations/v2_runtime/__init__.py +0 -0
  87. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/integrations/v2_runtime/adapters.py +0 -0
  88. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/migrations.py +0 -0
  89. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/model_routing/catalog.py +0 -0
  90. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/__init__.py +0 -0
  91. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/middleware/__init__.py +0 -0
  92. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/middleware/checkpoint_hygiene.py +0 -0
  93. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/middleware/dynamic_prompt.py +0 -0
  94. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/middleware/frame.py +0 -0
  95. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/middleware/hitl.py +0 -0
  96. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/middleware/model_routing.py +0 -0
  97. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/middleware/shared.py +0 -0
  98. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/middleware/tool_observability.py +0 -0
  99. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/middleware/tracing_kpi.py +0 -0
  100. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/react_langchain_adapter.py +0 -0
  101. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/react_message_codec.py +0 -0
  102. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/react_model_adapter.py +0 -0
  103. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/react_runtime.py +0 -0
  104. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/react_stream_adapter.py +0 -0
  105. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/react_tool_binding.py +0 -0
  106. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/react_tool_loop.py +0 -0
  107. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/react_tool_rendering.py +0 -0
  108. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/react_tool_resolution.py +0 -0
  109. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/react_tool_utils.py +0 -0
  110. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/react_tracing.py +0 -0
  111. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/runtime_support/__init__.py +0 -0
  112. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/runtime_support/checkpoints.py +0 -0
  113. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/runtime_support/model_metadata.py +0 -0
  114. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/runtime_support/request_context_helpers.py +0 -0
  115. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/runtime_support/user_token_refresher.py +0 -0
  116. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/support/__init__.py +0 -0
  117. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/support/filesystem_context.py +0 -0
  118. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/support/thinking.py +0 -0
  119. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/support/tool_loop.py +0 -0
  120. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime.egg-info/dependency_links.txt +0 -0
  121. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime.egg-info/entry_points.txt +0 -0
  122. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime.egg-info/requires.txt +0 -0
  123. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime.egg-info/top_level.txt +0 -0
  124. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/setup.cfg +0 -0
  125. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_capability_chat_controls_1976.py +0 -0
  126. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_capability_chat_parts_1977.py +0 -0
  127. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_capability_document_access_1906.py +0 -0
  128. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_capability_registry_1973.py +1 -1
  129. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_capability_routes_tables_1979.py +0 -0
  130. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_capability_selection_1974.py +0 -0
  131. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_capability_tool_return_convention.py +1 -1
  132. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_client.py +0 -0
  133. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_config_loader.py +0 -0
  134. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_context_aware_tool.py +0 -0
  135. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_conversational_memory.py +0 -0
  136. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_deep_agent_middleware.py +0 -0
  137. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_eval_collector.py +0 -0
  138. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_eval_trace.py +0 -0
  139. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_fred_workspace_fs.py +0 -0
  140. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_graph_runtime_invoke_agent.py +0 -0
  141. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_graph_runtime_observability.py +0 -0
  142. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_history.py +0 -0
  143. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_kf_vector_search_toolkit.py +0 -0
  144. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_kf_workspace_client.py +0 -0
  145. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_knowledge_search_tool_invoker_sources.py +0 -0
  146. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_kpi_display.py +0 -0
  147. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_mcp_config.py +0 -0
  148. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_openai_compat_router.py +0 -0
  149. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_pod_client.py +0 -0
  150. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_react_loop_regressions_1972.py +0 -0
  151. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_react_middleware_frame.py +0 -0
  152. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_react_thinking.py +0 -0
  153. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_repl_helpers.py +0 -0
  154. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_runtime_context_prompt_injection.py +0 -0
  155. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_smoke.py +0 -0
  156. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_spike_capability_state_1971.py +0 -0
  157. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_token_expiry.py +0 -0
  158. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_tool_loop_trim.py +0 -0
  159. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_tool_observability_middleware.py +0 -0
  160. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_tracing_kpi_content_confinement.py +0 -0
  161. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_url_helpers.py +0 -0
  162. {fred_runtime-3.3.7 → fred_runtime-3.4.0}/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.0
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
@@ -58,6 +58,7 @@ from fastapi.responses import StreamingResponse
58
58
  from fred_core.common.config_loader import get_config
59
59
  from fred_core.history.history_schema import ChatMessage
60
60
  from fred_core.kpi import KPIMiddleware
61
+ from fred_core.kpi.kpi_runtime_stage_metric import runtime_stage_timer
61
62
  from fred_core.kpi.kpi_writer_structures import KPIActor
62
63
  from fred_core.logs.audit_log import emit_audit_log
63
64
  from fred_core.logs.log_setup import log_setup
@@ -88,6 +89,7 @@ from fred_sdk.contracts.context import (
88
89
  PortableContext,
89
90
  PortableEnvironment,
90
91
  RuntimeContext,
92
+ TeamOperationRouteRule,
91
93
  )
92
94
  from fred_sdk.contracts.eval import EvalStep, EvalTrace
93
95
  from fred_sdk.contracts.execution import (
@@ -967,6 +969,66 @@ class _McpCatalogResponse(BaseModel):
967
969
  servers: list[_McpCatalogEntry]
968
970
 
969
971
 
972
+ class _ModelCatalogEntry(BaseModel):
973
+ id: str
974
+ provider: str
975
+ name: str
976
+ description: str | None = None
977
+ profile_ids: list[str] = Field(default_factory=list)
978
+ """Every `models_catalog.yaml` profile_id sharing this entry's
979
+ (provider, name) — TEAM-ROUTING-POLICY-RFC.md §7.1's id-space
980
+ translation: a routing policy picks by profile_id (finer-grained than
981
+ this capability id), so control-plane needs a way back from profile_id
982
+ to capability id for write-time enablement validation, and the
983
+ team-settings picker needs the reverse (which profile_ids this enabled
984
+ capability actually offers). Declaration order in the source YAML,
985
+ first-seen first — same order `default_profile_by_capability` favors."""
986
+
987
+
988
+ class _ModelCatalogResponse(BaseModel):
989
+ models: list[_ModelCatalogEntry]
990
+
991
+
992
+ def _project_model_catalog_entries(catalog: Any) -> list[_ModelCatalogEntry]:
993
+ """One `_ModelCatalogEntry` per distinct (provider, name) pair in a
994
+ loaded `ModelCatalog` (OBSERV-02 v3, `AGENT-CAPABILITY-RFC.md` §8.7).
995
+
996
+ Pulled out of `get_models_catalog` as a pure function so it is directly
997
+ unit-testable without a running app (`agent_app.py` has no existing
998
+ FastAPI TestClient harness — building one just for this route would be
999
+ disproportionate; this function is where the actual logic worth testing
1000
+ lives). `catalog` is typed `Any` here to avoid a module-level
1001
+ `..model_routing` import (see the route's own lazy import for why);
1002
+ callers pass a real `ModelCatalog`.
1003
+ """
1004
+ from fred_sdk.contracts.capability.manifest import model_capability_id
1005
+
1006
+ seen: dict[tuple[str, str], _ModelCatalogEntry] = {}
1007
+ for profile in catalog.profiles:
1008
+ provider = profile.model.provider
1009
+ name = profile.model.name
1010
+ # ModelProfile.validate_model (contracts.py) already guarantees both
1011
+ # are non-empty strings for every profile that exists — asserted
1012
+ # (not a skip/continue) so this narrows the type instead of hiding
1013
+ # an invariant violation the validator itself is supposed to prevent.
1014
+ assert provider and name, (
1015
+ f"ModelProfile {profile.profile_id!r} passed validation without provider/name"
1016
+ )
1017
+ key = (provider, name)
1018
+ existing = seen.get(key)
1019
+ if existing is None:
1020
+ seen[key] = _ModelCatalogEntry(
1021
+ id=model_capability_id(provider, name),
1022
+ provider=provider,
1023
+ name=name,
1024
+ description=profile.description,
1025
+ profile_ids=[profile.profile_id],
1026
+ )
1027
+ else:
1028
+ existing.profile_ids.append(profile.profile_id)
1029
+ return list(seen.values())
1030
+
1031
+
970
1032
  class _ResolvedAgentInstance(BaseModel):
971
1033
  agent_instance_id: str
972
1034
  template_agent_id: str
@@ -1120,7 +1182,9 @@ async def _resolve_agent_instance(
1120
1182
  registry: Mapping[str, ReActAgentDefinition | GraphAgentDefinition],
1121
1183
  access_token: str | None,
1122
1184
  control_plane_url: str | None,
1185
+ http_client: httpx.AsyncClient,
1123
1186
  team_id: str | None = None,
1187
+ request_id: str | None = None,
1124
1188
  ) -> _ResolvedExecutionTarget:
1125
1189
  """
1126
1190
  Resolve a direct or managed execution target into a concrete definition.
@@ -1195,9 +1259,17 @@ async def _resolve_agent_instance(
1195
1259
  f"{control_plane_url.rstrip('/')}/teams/{team_id}/agent-instances/"
1196
1260
  f"{request.agent_instance_id}/runtime"
1197
1261
  )
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)
1262
+ headers: dict[str, str] = {}
1263
+ if access_token:
1264
+ headers["Authorization"] = f"Bearer {access_token}"
1265
+ if request_id:
1266
+ # Correlates this pod-side call with the control-plane request it
1267
+ # triggers — both sides stamp the same id as `trace.trace_id` on their
1268
+ # `runtime.stage_latency_ms{runtime_stage=runtime_binding*}` event
1269
+ # (TURN-01 evidence gap: no way to join a turn to its binding call
1270
+ # besides timestamps).
1271
+ headers["X-Request-Id"] = request_id
1272
+ response = await http_client.get(url, headers=headers or None)
1201
1273
  if response.status_code == status.HTTP_404_NOT_FOUND:
1202
1274
  raise HTTPException(
1203
1275
  status_code=404, detail=response.text or "Unknown agent instance."
@@ -1507,7 +1579,8 @@ async def _authorize_and_resolve(
1507
1579
  )
1508
1580
  await _validate_session_checkpoint_access(request)
1509
1581
  await _enforce_session_ownership(request, authenticated_user, container)
1510
- await _authorize_execution_or_raise(request, authenticated_user, container)
1582
+ async with runtime_stage_timer(container.get_kpi_writer(), "pod_authz"):
1583
+ await _authorize_execution_or_raise(request, authenticated_user, container)
1511
1584
  internal_req = _to_internal_request(request)
1512
1585
  # Stamp the trusted service-agent verdict (never the caller-supplied
1513
1586
  # context) so per-tool-call re-authorization can mirror the bypass
@@ -1521,13 +1594,19 @@ async def _authorize_and_resolve(
1521
1594
  else:
1522
1595
  ctx.pop("is_service_agent", None)
1523
1596
  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
- )
1597
+ binding_request_id = str(uuid4())
1598
+ async with runtime_stage_timer(
1599
+ container.get_kpi_writer(), "runtime_binding", trace_id=binding_request_id
1600
+ ):
1601
+ target = await _resolve_agent_instance(
1602
+ request=internal_req,
1603
+ registry=registry,
1604
+ access_token=access_token,
1605
+ control_plane_url=get_runtime_context().config.control_plane_url,
1606
+ http_client=container.get_control_plane_http_client(),
1607
+ team_id=request.effective_team_id(),
1608
+ request_id=binding_request_id,
1609
+ )
1531
1610
  _validate_resolved_team(request, target.team_id, container)
1532
1611
  return internal_req, target
1533
1612
 
@@ -2401,8 +2480,8 @@ def _build_capability_block(
2401
2480
  # one. No production capability declares an active `HitlSpec` today,
2402
2481
  # so refusing here costs nothing real yet; reconciling Graph's own
2403
2482
  # 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
2483
+ # work, deferred (see AGENT-CAPABILITY-RFC.md §3.9). Refusing loudly
2484
+ # keeps the RFC's "never silently degrade" guarantee intact in the meantime
2406
2485
  # — a capability with `HitlSpec`s that silently ran ungated on Graph
2407
2486
  # would be exactly the kind of governance gap this platform exists to
2408
2487
  # prevent.
@@ -2472,6 +2551,19 @@ async def _iterate_runtime_event_payloads(
2472
2551
  ctx = request.context or {}
2473
2552
  correlation_id = ctx.get("correlation_id", request_id)
2474
2553
  resolved_team_id = team_id or ctx.get("team_id")
2554
+ # kind="model" enforcement (OBSERV-02 v3, AGENT-CAPABILITY-RFC.md §8.7):
2555
+ # computed ONCE per turn, here — never inside model-routing resolution,
2556
+ # which runs multiple times per turn and must never itself make a ReBAC
2557
+ # call. No team context (agent-to-agent invocation) → no restriction,
2558
+ # the same posture _authorize_execution_or_raise already applies to a
2559
+ # teamless request.
2560
+ usable_model_ids: tuple[str, ...] | None = None
2561
+ if resolved_team_id is not None:
2562
+ from ..model_routing import usable_model_capability_ids
2563
+
2564
+ rebac = get_runtime_context().config.rebac_engine
2565
+ ids = await usable_model_capability_ids(rebac, resolved_team_id)
2566
+ usable_model_ids = tuple(ids) if ids is not None else None
2475
2567
  execution_action = ctx.get("execution_action") or (
2476
2568
  ExecutionGrantAction.RESUME.value
2477
2569
  if request.resume_payload is not None
@@ -2540,11 +2632,26 @@ async def _iterate_runtime_event_payloads(
2540
2632
  # When the final file is deleted this is absent, so the per-turn runtime
2541
2633
  # notice disappears without leaving a checkpointed system message behind.
2542
2634
  attachments_markdown=ctx.get("attachments_markdown"),
2635
+ # Team routing policy snapshot (TEAM-ROUTING-POLICY-RFC.md §3/§8):
2636
+ # control-plane resolves it once at prepare-execution and the frontend
2637
+ # forwards it unchanged, same channel as context_prompt_text above — but
2638
+ # it was also silently dropped here, so resolve_team_override in
2639
+ # fred_runtime.model_routing.provider always saw None and no team's
2640
+ # routing policy ever took effect on a real chat turn. ctx already holds
2641
+ # the flat dict from to_legacy_context()'s model_dump(exclude_none=True);
2642
+ # operation_route_rules needs re-validating back into typed rows.
2643
+ chat_default_profile_id=ctx.get("chat_default_profile_id"),
2644
+ operation_route_rules=[
2645
+ TeamOperationRouteRule.model_validate(rule)
2646
+ for rule in (ctx.get("operation_route_rules") or [])
2647
+ ]
2648
+ or None,
2543
2649
  )
2544
2650
 
2545
2651
  binding = BoundRuntimeContext(
2546
2652
  runtime_context=runtime_context,
2547
2653
  portable_context=portable_context,
2654
+ usable_model_ids=usable_model_ids,
2548
2655
  )
2549
2656
 
2550
2657
  services = _build_runtime_services(
@@ -2944,6 +3051,42 @@ def _build_agent_router(
2944
3051
  ]
2945
3052
  )
2946
3053
 
3054
+ @router.get("/models-catalog")
3055
+ async def get_models_catalog() -> _ModelCatalogResponse:
3056
+ """
3057
+ Return this pod's routable models, one entry per distinct
3058
+ (provider, name) pair (OBSERV-02 v3, `AGENT-CAPABILITY-RFC.md` §8.7).
3059
+
3060
+ Why this endpoint exists:
3061
+ - control-plane's capability catalog aggregation
3062
+ (`aggregate_capability_catalog`) has no other way to learn what
3063
+ models this pod can route to — `models_catalog.yaml` is loaded
3064
+ only here, for routing, with no prior control-plane consumer.
3065
+ - one entry per (provider, name), not per catalog profile: a model
3066
+ used for both the `chat` and `language` routing capability is one
3067
+ admin enablement decision, not two.
3068
+
3069
+ Re-reads `models_catalog.yaml` fresh on every call (same file
3070
+ `_build_chat_model_factory` loaded once at boot) rather than
3071
+ caching the parsed catalog — this route is polled rarely (admin
3072
+ catalog refresh), never per-turn, so the extra local file read is
3073
+ immaterial and keeps this always consistent with whatever the pod
3074
+ would actually resolve, with no separate cache to go stale.
3075
+
3076
+ How to use it:
3077
+ - call from control-plane's `_model_capabilities_for_source`
3078
+
3079
+ Example:
3080
+ - `GET /fred/agents/v2/agents/models-catalog`
3081
+ """
3082
+ from ..model_routing import load_model_catalog
3083
+
3084
+ catalog_path = get_runtime_context().config.models_catalog_path
3085
+ if not catalog_path:
3086
+ return _ModelCatalogResponse(models=[])
3087
+ catalog = load_model_catalog(catalog_path)
3088
+ return _ModelCatalogResponse(models=_project_model_catalog_entries(catalog))
3089
+
2947
3090
  @router.post("/capabilities/{capability_id}/validate-config")
2948
3091
  async def validate_capability_config(
2949
3092
  capability_id: str,
@@ -3885,12 +4028,13 @@ def create_agent_app(
3885
4028
  # Boot order (must be preserved — each step depends on the previous):
3886
4029
  # 1. log_setup — formatter/handlers ready for all subsequent logs
3887
4030
  # 2. initialize_kpi_writer — needed by bootstrap_observability
3888
- # 3. bootstrap_observabilityglobal tracer + metrics provider
3889
- # 4. attach_pod_containercontainer 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_context wires all built parts into the global config
4031
+ # 3. initialize_control_plane_clientsync, no network until first call
4032
+ # 4. bootstrap_observabilityglobal tracer + metrics provider
4033
+ # 5. attach_pod_container container in app.state before any request
4034
+ # 6. initialize_sql async, may take time
4035
+ # 7. start_metrics_exporter prometheus thread, after KPI writer exists
4036
+ # 8. start_kpi_tasks asyncio tasks, after SQL engine is known
4037
+ # 9. set_runtime_context — wires all built parts into the global config
3894
4038
  log_setup(
3895
4039
  service_name=config.app.name,
3896
4040
  log_level=config.app.log_level,
@@ -3901,6 +4045,7 @@ def create_agent_app(
3901
4045
  )
3902
4046
  container = build_pod_container(config)
3903
4047
  container.initialize_kpi_writer()
4048
+ container.initialize_control_plane_client()
3904
4049
  bootstrap_observability(
3905
4050
  config.observability, kpi_writer=container.get_kpi_writer()
3906
4051
  )
@@ -3918,7 +4063,11 @@ def create_agent_app(
3918
4063
  # every execution against OpenFGA; a disabled/Noop engine (dev) means
3919
4064
  # identity-only. Safe in all modes — the factory returns a Noop with a
3920
4065
  # KeycloackDisabled admin client when user/m2m auth is off.
3921
- rebac_engine = rebac_factory(security) if security is not None else None
4066
+ rebac_engine = (
4067
+ rebac_factory(security, kpi_writer=container.get_kpi_writer())
4068
+ if security is not None
4069
+ else None
4070
+ )
3922
4071
  chat_factory = _build_chat_model_factory(config)
3923
4072
  await container.initialize_sql()
3924
4073
  container.start_metrics_exporter()
@@ -3939,6 +4088,7 @@ def create_agent_app(
3939
4088
  checkpointer=checkpointer,
3940
4089
  history_store=history_store,
3941
4090
  mcp_configuration=config.get_mcp_configuration(),
4091
+ models_catalog_path=config.get_models_catalog_path(),
3942
4092
  inprocess_toolkit_factory=build_inprocess_toolkit,
3943
4093
  control_plane_url=config.platform.control_plane_url,
3944
4094
  rebac_engine=rebac_engine,
@@ -23,6 +23,7 @@ from collections import deque
23
23
  from datetime import datetime, timezone
24
24
  from typing import TYPE_CHECKING, TypedDict
25
25
 
26
+ import httpx
26
27
  from fred_core.kpi.base_kpi_writer import BaseKPIWriter
27
28
  from fred_core.kpi.kpi_factory import build_kpi_writer
28
29
  from fred_core.kpi.kpi_process import emit_process_kpis, emit_sql_pool_kpis
@@ -38,6 +39,14 @@ if TYPE_CHECKING:
38
39
 
39
40
  logger = logging.getLogger(__name__)
40
41
 
42
+ # Dedicated to control-plane runtime-binding calls only (TURN-01) — a
43
+ # per-turn resolution, not the LLM/KF traffic shape, hence deliberately
44
+ # smaller pool bounds than fred_core.model.http_clients.
45
+ _CONTROL_PLANE_CLIENT_TIMEOUT = httpx.Timeout(10.0)
46
+ _CONTROL_PLANE_CLIENT_LIMITS = httpx.Limits(
47
+ max_connections=50, max_keepalive_connections=20, keepalive_expiry=10.0
48
+ )
49
+
41
50
 
42
51
  # ---------------------------------------------------------------------------
43
52
  # TypedDicts for ring buffer entries
@@ -89,10 +98,11 @@ class PodApplicationContext:
89
98
  Call the initialize_* methods inside the FastAPI lifespan after log setup.
90
99
 
91
100
  Boot order (enforced by lifespan):
92
- 1. initialize_kpi_writer() — sync, fast; needed by bootstrap_observability
93
- 2. initialize_sql() async; may take time on first start
94
- 3. start_metrics_exporter() sync; starts prometheus thread if configured
95
- 4. start_kpi_tasks() — async; starts background asyncio tasks
101
+ 1. initialize_kpi_writer() — sync, fast; needed by bootstrap_observability
102
+ 2. initialize_control_plane_client() sync, fast; no network until first call
103
+ 3. initialize_sql() async; may take time on first start
104
+ 4. start_metrics_exporter() — sync; starts prometheus thread if configured
105
+ 5. start_kpi_tasks() — async; starts background asyncio tasks
96
106
  """
97
107
 
98
108
  def __init__(self, configuration: AgentPodConfig) -> None:
@@ -101,6 +111,7 @@ class PodApplicationContext:
101
111
  self._checkpointer: object | None = None
102
112
  self._history_store: HistoryStorePort | None = None
103
113
  self._kpi_writer: BaseKPIWriter | None = None
114
+ self._control_plane_http_client: httpx.AsyncClient | None = None
104
115
  self._metrics_exporter: tuple[object, ...] | None = None
105
116
  self._kpi_tasks: list[asyncio.Task[None]] = []
106
117
  self._kpi_turns_lock = threading.Lock()
@@ -122,6 +133,13 @@ class PodApplicationContext:
122
133
  log_level=config.app.log_level,
123
134
  )
124
135
 
136
+ def initialize_control_plane_client(self) -> None:
137
+ """Build the pod-wide async HTTP client for control-plane runtime-binding calls."""
138
+ self._control_plane_http_client = httpx.AsyncClient(
139
+ timeout=_CONTROL_PLANE_CLIENT_TIMEOUT,
140
+ limits=_CONTROL_PLANE_CLIENT_LIMITS,
141
+ )
142
+
125
143
  async def initialize_sql(self) -> None:
126
144
  """Build SQL engine, checkpointer, and history store (SQL-backed only)."""
127
145
  from fred_core.history.postgres_history_store import PostgresHistoryStore
@@ -135,8 +153,8 @@ class PodApplicationContext:
135
153
  self.configuration.storage.postgres
136
154
  )
137
155
  init_user_store(engine)
138
- checkpointer = FredSqlCheckpointer(engine)
139
- history_store = PostgresHistoryStore(engine)
156
+ checkpointer = FredSqlCheckpointer(engine, kpi=self.get_kpi_writer())
157
+ history_store = PostgresHistoryStore(engine, kpi=self.get_kpi_writer())
140
158
  self._sql_engine = engine
141
159
  self._checkpointer = checkpointer
142
160
  self._history_store = history_store
@@ -199,6 +217,14 @@ class PodApplicationContext:
199
217
  def get_history_store(self) -> HistoryStorePort | None:
200
218
  return self._history_store
201
219
 
220
+ def get_control_plane_http_client(self) -> httpx.AsyncClient:
221
+ if self._control_plane_http_client is None:
222
+ raise RuntimeError(
223
+ "Control-plane HTTP client not initialized — "
224
+ "call initialize_control_plane_client() first"
225
+ )
226
+ return self._control_plane_http_client
227
+
202
228
  def get_kpi_writer(self) -> BaseKPIWriter:
203
229
  if self._kpi_writer is None:
204
230
  raise RuntimeError(
@@ -220,6 +246,8 @@ class PodApplicationContext:
220
246
  if self._sql_engine is not None:
221
247
  await self._sql_engine.dispose()
222
248
  logger.info("[fred-runtime] SQL engine disposed")
249
+ if self._control_plane_http_client is not None:
250
+ await self._control_plane_http_client.aclose()
223
251
  self._stop_metrics_exporter()
224
252
 
225
253
  def _stop_metrics_exporter(self) -> None:
@@ -67,6 +67,7 @@ from .agent_app import (
67
67
  _iterate_runtime_event_payloads,
68
68
  _resolve_agent_instance,
69
69
  )
70
+ from .dependencies import get_pod_container_from_app
70
71
 
71
72
  logger = logging.getLogger(__name__)
72
73
 
@@ -228,6 +229,9 @@ def create_openai_compat_router(
228
229
  registry=registry,
229
230
  access_token=access_token,
230
231
  control_plane_url=get_runtime_context().config.control_plane_url,
232
+ http_client=get_pod_container_from_app(
233
+ http_request.app
234
+ ).get_control_plane_http_client(),
231
235
  )
232
236
 
233
237
  completion_id = f"chatcmpl-{uuid4().hex}"
@@ -382,7 +382,21 @@ def build_capability_agent_block(
382
382
 
383
383
  for candidate in capability_tools:
384
384
  owner = tool_owner.get(candidate.name)
385
- if owner is not None and owner != cap_id:
385
+ if owner is not None:
386
+ # PR #2067 review: this used to only check `owner != cap_id`
387
+ # (cross-capability), so a SINGLE capability's own tools()
388
+ # returning two tools with the same .name silently kept
389
+ # whichever came last in tools_by_name — and a `HitlSpec`
390
+ # naming that tool would then bind to whichever object won,
391
+ # not necessarily the one the author meant to gate. "Tools
392
+ # must be uniquely named" now means that literally, same
393
+ # capability or not.
394
+ if owner == cap_id:
395
+ raise CapabilityAssemblyError(
396
+ f"Tool '{candidate.name}' is returned twice by its own "
397
+ f"capability's tools() ('{cap_id}'). Capability tools "
398
+ "must be uniquely named."
399
+ )
386
400
  raise CapabilityAssemblyError(
387
401
  f"Tool '{candidate.name}' is exposed by two capabilities "
388
402
  f"('{owner}' and '{cap_id}'). Capability tools must be "
@@ -58,13 +58,17 @@ class DefaultOnRequiredSettingsError(CapabilityRegistrationError):
58
58
 
59
59
  class InvalidExecutionModelError(CapabilityRegistrationError):
60
60
  """
61
- A capability overrides `middleware()` without implementing `tools()`, and
62
- its manifest never explicitly set `execution_models` (CAPAB-02, RFC §3.2,
63
- §3.9). Left at the class default (`("react", "graph")`), such a
61
+ A capability overrides `middleware()` without implementing `tools()`
62
+ it has zero Graph-visible runtime contribution yet its manifest's
63
+ `execution_models` contains `"graph"` (CAPAB-02, RFC §3.2, §3.9). This
64
+ fires in BOTH shapes of the mistake, not just one: the field was never
65
+ explicitly set and kept the class default (`("react", "graph")`), OR it
66
+ was explicitly written out that way on purpose. Either way, such a
64
67
  capability would silently contribute zero tools to any Graph agent that
65
- selects it — the exact trap `execution_models` exists to make impossible.
66
- Declare `execution_models=("react",)` explicitly, or implement `tools()`
67
- for the plain-tool portion so the capability is genuinely Graph-visible.
68
+ selects it — the exact trap `execution_models` exists to make
69
+ impossible. Declare `execution_models=("react",)` explicitly, or
70
+ implement `tools()` for the plain-tool portion so the capability is
71
+ genuinely Graph-visible.
68
72
  """
69
73
 
70
74
 
@@ -47,6 +47,8 @@ from fred_sdk.contracts.context import (
47
47
  FsEntry,
48
48
  InvocationScope,
49
49
  PublishedArtifact,
50
+ ToolContentBlock,
51
+ ToolContentKind,
50
52
  ToolInvocationRequest,
51
53
  ToolInvocationResult,
52
54
  )
@@ -640,6 +642,7 @@ class _GraphNodeExecutionContext:
640
642
  "call_id": call_id,
641
643
  },
642
644
  )
645
+ started_at = _time.monotonic()
643
646
  try:
644
647
  with _graph_phase_timer(
645
648
  metrics=self.services.metrics,
@@ -681,6 +684,7 @@ class _GraphNodeExecutionContext:
681
684
  is_error=result.is_error,
682
685
  sources=result.sources,
683
686
  ui_parts=result.ui_parts,
687
+ latency_ms=_elapsed_ms_since(started_at),
684
688
  )
685
689
  )
686
690
  return result
@@ -723,6 +727,7 @@ class _GraphNodeExecutionContext:
723
727
  "tool_name": tool_name,
724
728
  },
725
729
  ) as kpi_dims:
730
+ started_at = _time.monotonic()
726
731
  try:
727
732
  raw_result = await tool.ainvoke(arguments)
728
733
  normalized = _normalize_runtime_tool_output(raw_result)
@@ -731,14 +736,18 @@ class _GraphNodeExecutionContext:
731
736
  # §3.9 — a failing tool must never crash the turn). Read
732
737
  # is_error/sources/ui_parts off the TYPED result — before
733
738
  # normalization erases its identity — rather than off the
734
- # normalized dict's `is_error` key, which any plain MCP tool
739
+ # normalized dict's `is_error` key, which any plain tool
735
740
  # could coincidentally also carry for an unrelated reason.
736
- # `raw_result` is the actual `ToolInvocationResult` instance
737
- # for a capability tool (default "content" response_format
738
- # survives a plain-dict `.ainvoke()` unchanged, per Phase 1);
739
- # never true for an MCP-resolved tool, so this never
740
- # misclassifies a business payload as this platform's own
741
- # error contract.
741
+ # The safety is in the isinstance check itself, not in an
742
+ # assumption about which source produced `raw_result`: a
743
+ # bare `ToolInvocationResult` return (no `response_format`
744
+ # override) survives a plain-dict `.ainvoke()` unchanged
745
+ # (Phase 1) for ANY tool built that way — a capability tool
746
+ # or an in-process toolkit tool like `KfVectorSearchToolkit`
747
+ # (also `runtime_tools`-reachable) alike. Either is a
748
+ # legitimate, intentional hit, not a misclassification; a
749
+ # tool returning some other shape (a plain dict/string) just
750
+ # fails the isinstance check and falls through untouched.
742
751
  typed_result = (
743
752
  raw_result if isinstance(raw_result, ToolInvocationResult) else None
744
753
  )
@@ -758,6 +767,7 @@ class _GraphNodeExecutionContext:
758
767
  ui_parts=typed_result.ui_parts
759
768
  if typed_result is not None
760
769
  else (),
770
+ latency_ms=_elapsed_ms_since(started_at),
761
771
  )
762
772
  )
763
773
  if reported_error:
@@ -773,6 +783,7 @@ class _GraphNodeExecutionContext:
773
783
  tool_name=tool_name,
774
784
  content=str(exc),
775
785
  is_error=True,
786
+ latency_ms=_elapsed_ms_since(started_at),
776
787
  )
777
788
  )
778
789
  if span is not None:
@@ -1968,7 +1979,15 @@ def _adapt_capability_tool_for_graph(source_tool: BaseTool) -> BaseTool:
1968
1979
  declared response_format (CAPAB-02), not "any 2-tuple" — a plain tool
1969
1980
  whose normal return value happens to be some unrelated 2-tuple must
1970
1981
  round-trip unchanged, not have its second element silently
1971
- reinterpreted as an artifact.
1982
+ reinterpreted as an artifact. **If the artifact's own `blocks` are
1983
+ empty, `content` is folded in as one `ToolContentBlock` before the
1984
+ artifact is returned** (PR #2067 review): `document_access`'s tools
1985
+ duplicate their answer into `blocks` themselves, but a tool like
1986
+ `demo_echo` puts its actual answer only in `content`, with an artifact
1987
+ carrying nothing but `ui_parts` — discarding `content` outright would
1988
+ silently drop the answer for any such tool. This is a safety net, not
1989
+ a substitute for a capability author populating `blocks` directly
1990
+ (`document_access` is still the reference pattern to copy).
1972
1991
  - anything else (already a bare `ToolInvocationResult`, or any other
1973
1992
  capability tool return): pass through unchanged.
1974
1993
 
@@ -2015,7 +2034,30 @@ def _adapt_capability_tool_for_graph(source_tool: BaseTool) -> BaseTool:
2015
2034
  async def _invoke(**kwargs: object) -> object:
2016
2035
  raw = await coroutine(**kwargs)
2017
2036
  if is_content_and_artifact and isinstance(raw, tuple) and len(raw) == 2:
2018
- return raw[1]
2037
+ content, artifact = raw
2038
+ # PR #2067 review (Codex): keeping ONLY the artifact is correct
2039
+ # for a tool like `document_access`'s, whose artifact duplicates
2040
+ # its answer into `blocks` — but a tool like `demo_echo`'s puts
2041
+ # the actual answer in `content` and an artifact carrying only
2042
+ # `ui_parts` (a UI card), never `blocks`. Unwrapping to the
2043
+ # artifact alone would silently drop the answer for any such
2044
+ # tool. If the artifact has no `blocks` of its own, fold the
2045
+ # discarded `content` in as one — never lose the answer just
2046
+ # because a capability author didn't think to duplicate it.
2047
+ if (
2048
+ isinstance(artifact, ToolInvocationResult)
2049
+ and not artifact.blocks
2050
+ and isinstance(content, str)
2051
+ and content
2052
+ ):
2053
+ artifact = artifact.model_copy(
2054
+ update={
2055
+ "blocks": (
2056
+ ToolContentBlock(kind=ToolContentKind.TEXT, text=content),
2057
+ )
2058
+ }
2059
+ )
2060
+ return artifact
2019
2061
  return raw
2020
2062
 
2021
2063
  return StructuredTool.from_function(
@@ -2041,6 +2083,14 @@ def _validated_handlers(
2041
2083
  return validated
2042
2084
 
2043
2085
 
2086
+ def _elapsed_ms_since(started_at: float) -> int:
2087
+ """Milliseconds elapsed since a `time.monotonic()` reading (react_runtime.py's
2088
+ same helper — mirrored here so `ToolResultRuntimeEvent.latency_ms` is populated
2089
+ on the Graph tool paths too, not just the ReAct one)."""
2090
+
2091
+ return int((_time.monotonic() - started_at) * 1000)
2092
+
2093
+
2044
2094
  def _graph_phase_timer(
2045
2095
  *,
2046
2096
  metrics: MetricsProvider | None,
@@ -12,6 +12,7 @@
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
14
 
15
+ from .authz import usable_model_capability_ids
15
16
  from .catalog import (
16
17
  ModelCatalog,
17
18
  load_model_catalog,
@@ -21,6 +22,7 @@ from .contracts import (
21
22
  FrozenModel,
22
23
  MatchValue,
23
24
  ModelCapability,
25
+ ModelNotUsableError,
24
26
  ModelProfile,
25
27
  ModelRouteMatch,
26
28
  ModelRouteRule,
@@ -38,6 +40,7 @@ __all__ = [
38
40
  "MatchValue",
39
41
  "ModelCapability",
40
42
  "ModelCatalog",
43
+ "ModelNotUsableError",
41
44
  "ModelProfile",
42
45
  "ModelRouteMatch",
43
46
  "ModelRouteRule",
@@ -49,4 +52,5 @@ __all__ = [
49
52
  "RoutedChatModelFactory",
50
53
  "load_model_catalog",
51
54
  "load_model_routing_policy_from_catalog",
55
+ "usable_model_capability_ids",
52
56
  ]