fred-runtime 3.3.0__tar.gz → 3.3.2__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 (119) hide show
  1. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/PKG-INFO +4 -4
  2. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/app/agent_app.py +119 -6
  3. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/common/mcp_runtime.py +112 -18
  4. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/common/mcp_utils.py +17 -8
  5. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/deep/deep_runtime.py +12 -6
  6. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/graph/graph_runtime.py +0 -1
  7. fred_runtime-3.3.2/fred_runtime/integrations/inprocess_toolkit_registry.py +50 -0
  8. fred_runtime-3.3.2/fred_runtime/integrations/kf_vector_search/__init__.py +21 -0
  9. fred_runtime-3.3.2/fred_runtime/integrations/kf_vector_search/toolkit.py +186 -0
  10. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/integrations/v2_runtime/adapters.py +1 -2
  11. fred_runtime-3.3.2/fred_runtime/react/react_prompting.py +310 -0
  12. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/react/react_runtime.py +7 -6
  13. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/runtime_context.py +3 -2
  14. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/runtime_support/sql_checkpointer.py +219 -1
  15. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime.egg-info/PKG-INFO +4 -4
  16. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime.egg-info/SOURCES.txt +7 -0
  17. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime.egg-info/requires.txt +3 -3
  18. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/pyproject.toml +4 -4
  19. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/tests/test_agent_app.py +261 -19
  20. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/tests/test_client.py +0 -1
  21. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/tests/test_config_loader.py +0 -1
  22. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/tests/test_context.py +0 -1
  23. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/tests/test_context_aware_tool.py +1 -2
  24. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/tests/test_conversational_memory.py +5 -10
  25. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/tests/test_eval_trace.py +1 -2
  26. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/tests/test_fred_workspace_fs.py +2 -3
  27. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/tests/test_graph_runtime_invoke_agent.py +5 -6
  28. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/tests/test_graph_runtime_observability.py +1 -2
  29. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/tests/test_history.py +5 -6
  30. fred_runtime-3.3.2/tests/test_kf_vector_search_toolkit.py +136 -0
  31. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/tests/test_kf_workspace_client.py +0 -1
  32. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/tests/test_mcp_config.py +0 -1
  33. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/tests/test_model_routing.py +0 -1
  34. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/tests/test_openai_compat_router.py +2 -3
  35. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/tests/test_pod_client.py +0 -1
  36. fred_runtime-3.3.2/tests/test_react_prompting.py +216 -0
  37. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/tests/test_react_thinking.py +13 -14
  38. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/tests/test_repl_helpers.py +0 -1
  39. fred_runtime-3.3.2/tests/test_runtime_context_prompt_injection.py +164 -0
  40. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/tests/test_smoke.py +5 -6
  41. fred_runtime-3.3.2/tests/test_sql_checkpointer_owner.py +271 -0
  42. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/tests/test_token_expiry.py +0 -1
  43. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/tests/test_user_token_refresher.py +0 -1
  44. fred_runtime-3.3.0/fred_runtime/react/react_prompting.py +0 -164
  45. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/README.md +0 -0
  46. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/__init__.py +0 -0
  47. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/app/__init__.py +0 -0
  48. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/app/_catalogs.py +0 -0
  49. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/app/config.py +0 -0
  50. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/app/config_loader.py +0 -0
  51. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/app/container.py +0 -0
  52. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/app/context.py +0 -0
  53. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/app/dependencies.py +0 -0
  54. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/app/mcp_config.py +0 -0
  55. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/app/observability_factory.py +0 -0
  56. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/app/openai_compat_router.py +0 -0
  57. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/cli/__init__.py +0 -0
  58. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/cli/completion.py +0 -0
  59. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/cli/entrypoint.py +0 -0
  60. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/cli/history_display.py +0 -0
  61. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/cli/kpi_display.py +0 -0
  62. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/cli/pod_client.py +0 -0
  63. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/cli/repl.py +0 -0
  64. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/cli/repl_helpers.py +0 -0
  65. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/cli/url_helpers.py +0 -0
  66. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/client.py +0 -0
  67. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/common/__init__.py +0 -0
  68. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/common/context_aware_tool.py +0 -0
  69. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/common/kf_base_client.py +0 -0
  70. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/common/kf_fast_text_client.py +0 -0
  71. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/common/kf_http_client.py +0 -0
  72. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/common/kf_logs_client.py +0 -0
  73. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/common/kf_markdown_media_client.py +0 -0
  74. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/common/kf_vectorsearch_client.py +0 -0
  75. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/common/kf_workspace_client.py +0 -0
  76. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/common/mcp_interceptors.py +0 -0
  77. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/common/mcp_toolkit.py +0 -0
  78. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/common/structures.py +0 -0
  79. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/common/token_expiry.py +0 -0
  80. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/common/tool_node_utils.py +0 -0
  81. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/deep/__init__.py +0 -0
  82. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/eval/__init__.py +0 -0
  83. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/eval/collector.py +0 -0
  84. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/graph/__init__.py +0 -0
  85. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/integrations/__init__.py +0 -0
  86. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/integrations/v2_runtime/__init__.py +0 -0
  87. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/model_routing/__init__.py +0 -0
  88. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/model_routing/catalog.py +0 -0
  89. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/model_routing/contracts.py +0 -0
  90. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/model_routing/provider.py +0 -0
  91. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/model_routing/resolver.py +0 -0
  92. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/react/__init__.py +0 -0
  93. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/react/react_langchain_adapter.py +0 -0
  94. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/react/react_message_codec.py +0 -0
  95. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/react/react_model_adapter.py +0 -0
  96. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/react/react_stream_adapter.py +0 -0
  97. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/react/react_tool_binding.py +0 -0
  98. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/react/react_tool_loop.py +0 -0
  99. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/react/react_tool_rendering.py +0 -0
  100. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/react/react_tool_resolution.py +0 -0
  101. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/react/react_tool_utils.py +0 -0
  102. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/react/react_tracing.py +0 -0
  103. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/runtime_support/__init__.py +0 -0
  104. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/runtime_support/checkpoints.py +0 -0
  105. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/runtime_support/model_metadata.py +0 -0
  106. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/runtime_support/request_context_helpers.py +0 -0
  107. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/runtime_support/user_token_refresher.py +0 -0
  108. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/support/__init__.py +0 -0
  109. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/support/filesystem_context.py +0 -0
  110. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/support/thinking.py +0 -0
  111. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/support/tool_approval.py +0 -0
  112. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime/support/tool_loop.py +0 -0
  113. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime.egg-info/dependency_links.txt +0 -0
  114. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime.egg-info/entry_points.txt +0 -0
  115. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/fred_runtime.egg-info/top_level.txt +0 -0
  116. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/setup.cfg +0 -0
  117. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/tests/test_eval_collector.py +0 -0
  118. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/tests/test_kpi_display.py +0 -0
  119. {fred_runtime-3.3.0 → fred_runtime-3.3.2}/tests/test_url_helpers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fred-runtime
3
- Version: 3.3.0
3
+ Version: 3.3.2
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
@@ -12,8 +12,8 @@ Classifier: Programming Language :: Python :: 3 :: Only
12
12
  Classifier: Operating System :: OS Independent
13
13
  Requires-Python: <3.13,>=3.12
14
14
  Description-Content-Type: text/markdown
15
- Requires-Dist: fred-core>=3.4.0
16
- Requires-Dist: fred-sdk>=3.3.0
15
+ Requires-Dist: fred-core>=3.4.1
16
+ Requires-Dist: fred-sdk>=3.3.1
17
17
  Requires-Dist: alembic>=1.18.4
18
18
  Requires-Dist: deepagents>=0.4.11
19
19
  Requires-Dist: httpx>=0.28.1
@@ -25,7 +25,7 @@ Requires-Dist: langfuse>=3.0.0
25
25
  Requires-Dist: requests<3,>=2.32
26
26
  Requires-Dist: PyYAML>=6.0
27
27
  Provides-Extra: app
28
- Requires-Dist: fastapi>=0.116; extra == "app"
28
+ Requires-Dist: fastapi>=0.139.0; extra == "app"
29
29
  Provides-Extra: dev
30
30
  Requires-Dist: bandit>=1.8.6; extra == "dev"
31
31
  Requires-Dist: basedpyright==1.31.0; extra == "dev"
@@ -56,6 +56,7 @@ 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.common.team_id import is_personal_team_id, personal_team_id
59
60
  from fred_core.history.history_schema import ChatMessage
60
61
  from fred_core.kpi import KPIMiddleware
61
62
  from fred_core.kpi.kpi_writer_structures import KPIActor
@@ -63,9 +64,13 @@ from fred_core.logs.log_setup import log_setup
63
64
  from fred_core.logs.memory_log_store import RamLogStore
64
65
  from fred_core.security.models import AuthorizationError
65
66
  from fred_core.security.oidc import get_keycloak_client_id, get_keycloak_url
66
- from fred_core.security.rebac.rebac_engine import TeamPermission
67
+ from fred_core.security.rebac.rebac_engine import (
68
+ ORGANIZATION_ID,
69
+ OrganizationPermission,
70
+ TeamPermission,
71
+ )
67
72
  from fred_core.security.rebac.rebac_factory import rebac_factory
68
- from fred_core.security.structure import KeycloakUser
73
+ from fred_core.security.structure import KeycloakUser, is_service_agent
69
74
  from fred_sdk.contracts.context import (
70
75
  AgentInvocationRequest,
71
76
  AgentInvocationResult,
@@ -110,6 +115,7 @@ from fred_runtime.react.react_runtime import ReActRuntime
110
115
  from fred_runtime.runtime_support.checkpoints import load_checkpoint
111
116
 
112
117
  from ..common.structures import AgentSettingsLike
118
+ from ..integrations.inprocess_toolkit_registry import build_inprocess_toolkit
113
119
  from ..integrations.v2_runtime.adapters import (
114
120
  CompositeToolInvoker,
115
121
  FredKnowledgeSearchToolInvoker,
@@ -1122,6 +1128,12 @@ async def _authorize_execution_or_raise(
1122
1128
  - security disabled (no authenticated user) → skip (dev/local).
1123
1129
  - ReBAC engine absent or disabled (Noop) → skip (identity-only dev posture);
1124
1130
  the C3 profile guarantees an enabled engine in classified deployments.
1131
+ - a personal space (`personal-<uid>`) is intrinsic ownership, not a stored
1132
+ OpenFGA tuple: a human caller acting on their own canonical
1133
+ `personal_team_id(authenticated_user.uid)` is authorized without an OpenFGA
1134
+ call; any other `personal-*` id (another user's space, or the bare
1135
+ `"personal"` alias) is explicitly denied — never routed to OpenFGA (AUTHZ-05
1136
+ item 8b watch item).
1125
1137
  - otherwise require the caller to hold `CAN_READ` on the requested team — the
1126
1138
  same relation the control-plane required before it would mint a grant. The
1127
1139
  team is caller-supplied but safe: OpenFGA only authorizes teams the user
@@ -1183,6 +1195,50 @@ async def _authorize_execution_or_raise(
1183
1195
  "(runtime_context.team_id)"
1184
1196
  ),
1185
1197
  )
1198
+ if is_service_agent(authenticated_user):
1199
+ # Solution A (RFC EVAL-AUTH): a service identity (the evaluation worker) is
1200
+ # recognized for execution, scoped to the request team_id, without any stored
1201
+ # OpenFGA relation. Legitimacy is anchored upstream at campaign creation. The
1202
+ # team_id is guaranteed non-None above, so this stays team-scoped (never global).
1203
+ _emit_audit_event(
1204
+ container,
1205
+ "info",
1206
+ "service_agent_authorized",
1207
+ user_id=authenticated_user.uid,
1208
+ team_id=team_id,
1209
+ agent_instance_id=request.agent_instance_id,
1210
+ )
1211
+ return
1212
+ if team_id == "personal" or is_personal_team_id(team_id):
1213
+ # Personal spaces are intrinsic ownership (AUTHZ-05 item 8b): the id is
1214
+ # derived from the JWT subject via `personal_team_id`, is never persisted
1215
+ # as an OpenFGA tuple, and must never be resolved by OpenFGA. Only an
1216
+ # exact match against the caller's own canonical id is authorized; any
1217
+ # other personal-* id (another user's space) or the bare "personal"
1218
+ # alias (ambiguous once ReBAC is active) is denied here, before OpenFGA
1219
+ # ever sees it.
1220
+ if team_id == personal_team_id(authenticated_user.uid):
1221
+ _emit_audit_event(
1222
+ container,
1223
+ "info",
1224
+ "personal_space_owner_authorized",
1225
+ user_id=authenticated_user.uid,
1226
+ team_id=team_id,
1227
+ agent_instance_id=request.agent_instance_id,
1228
+ )
1229
+ return
1230
+ _emit_audit_event(
1231
+ container,
1232
+ "warning",
1233
+ "personal_space_denied",
1234
+ user_id=authenticated_user.uid,
1235
+ team_id=team_id,
1236
+ agent_instance_id=request.agent_instance_id,
1237
+ )
1238
+ raise HTTPException(
1239
+ status_code=status.HTTP_403_FORBIDDEN,
1240
+ detail=f"user {authenticated_user.uid!r} is not authorized for team {team_id!r}",
1241
+ )
1186
1242
  try:
1187
1243
  await rebac.check_user_team_permission_or_raise(
1188
1244
  authenticated_user, TeamPermission.CAN_READ, team_id
@@ -1255,6 +1311,31 @@ async def _enforce_session_ownership(
1255
1311
  )
1256
1312
 
1257
1313
 
1314
+ async def _caller_can_manage_platform(caller: KeycloakUser | None) -> bool:
1315
+ """True when the caller holds org-level ``can_manage_platform`` (CTRLP-12 C1).
1316
+
1317
+ The delete endpoints use this as an **admin branch**: a platform service
1318
+ principal — e.g. the control-plane lifecycle worker erasing a conversation at
1319
+ window expiry — may delete a session it does not personally own. Only the
1320
+ per-user *ownership* check is waived; *authentication* stays enforced upstream
1321
+ by ``_authenticated_user``.
1322
+
1323
+ Fails closed: returns False when there is no caller or when ReBAC is disabled
1324
+ (dev/no-security), so ownership behaviour is unchanged there and the bypass
1325
+ activates only when ReBAC is actually enforcing and the org permission is
1326
+ granted. Reuses the AUTHZ-01 ``can_manage_platform`` permission — no second
1327
+ bypass is forked.
1328
+ """
1329
+ if caller is None:
1330
+ return False
1331
+ rebac = get_runtime_context().config.rebac_engine
1332
+ if rebac is None or not rebac.enabled:
1333
+ return False
1334
+ return await rebac.has_user_permission(
1335
+ caller, OrganizationPermission.CAN_MANAGE_PLATFORM, ORGANIZATION_ID
1336
+ )
1337
+
1338
+
1258
1339
  async def _authorize_and_resolve(
1259
1340
  request: RuntimeExecuteRequest,
1260
1341
  *,
@@ -2010,7 +2091,6 @@ async def _iterate_runtime_event_payloads(
2010
2091
  checkpoint_id=resolved_checkpoint_id,
2011
2092
  user_id=ctx.get("user_id"),
2012
2093
  team_id=resolved_team_id,
2013
- user_groups=ctx.get("user_groups"),
2014
2094
  language=ctx.get("language"),
2015
2095
  access_token=access_token,
2016
2096
  refresh_token=ctx.get("refresh_token"),
@@ -2036,6 +2116,10 @@ async def _iterate_runtime_event_payloads(
2036
2116
  # at prepare-execution and the frontend forwards it — but it was also
2037
2117
  # silently dropped here, so no agent ever received a selected prompt.
2038
2118
  context_prompt_text=ctx.get("context_prompt_text"),
2119
+ # Rebuilt by the frontend from the attachments that currently exist.
2120
+ # When the final file is deleted this is absent, so the per-turn runtime
2121
+ # notice disappears without leaving a checkpointed system message behind.
2122
+ attachments_markdown=ctx.get("attachments_markdown"),
2039
2123
  )
2040
2124
 
2041
2125
  binding = BoundRuntimeContext(
@@ -2195,6 +2279,7 @@ def _build_agent_router(
2195
2279
  async def get_kpi_turns(
2196
2280
  limit: int = 50,
2197
2281
  container: PodApplicationContext = Depends(get_pod_container),
2282
+ caller: KeycloakUser | None = Depends(_authenticated_user),
2198
2283
  ) -> list[KpiTurnRecord]:
2199
2284
  """
2200
2285
  Return recent agent.turn_completed KPI events, newest first.
@@ -2209,7 +2294,20 @@ def _build_agent_router(
2209
2294
  - developers need to validate KPI emission from the CLI without
2210
2295
  Grafana or Prometheus; this exposes the pod-local ring buffer
2211
2296
  - max 200 entries retained in memory (oldest evicted automatically)
2297
+
2298
+ AUTHZ-05 review finding: item 8a's org-level CAN_READ_METRICS removal
2299
+ does not apply here — unlike the tier-2 capability it replaced
2300
+ (near-universal, protected nothing specific), this buffer holds
2301
+ cross-user/cross-team data (session_id, exchange_id, user_id, team_id,
2302
+ token counts) for every caller that has hit this pod, not just the
2303
+ caller's own. Gated the same way as the sibling `get_audit_events`
2304
+ below, on `CAN_MANAGE_PLATFORM`, not deleted outright.
2212
2305
  """
2306
+ rebac = get_runtime_context().config.rebac_engine
2307
+ if caller is not None and rebac is not None and rebac.enabled:
2308
+ await rebac.check_user_permission_or_raise(
2309
+ caller, OrganizationPermission.CAN_MANAGE_PLATFORM, ORGANIZATION_ID
2310
+ )
2213
2311
  with container._kpi_turns_lock:
2214
2312
  events = list(container.kpi_turns_buffer)
2215
2313
  events.reverse()
@@ -2219,6 +2317,7 @@ def _build_agent_router(
2219
2317
  async def get_audit_events(
2220
2318
  limit: int = 50,
2221
2319
  container: PodApplicationContext = Depends(get_pod_container),
2320
+ caller: KeycloakUser | None = Depends(_authenticated_user),
2222
2321
  ) -> list[AuditEventRecord]:
2223
2322
  """
2224
2323
  Return recent security audit events, newest first.
@@ -2233,6 +2332,11 @@ def _build_agent_router(
2233
2332
  this ring buffer so the CLI can query them directly
2234
2333
  - max 200 entries retained in memory
2235
2334
  """
2335
+ rebac = get_runtime_context().config.rebac_engine
2336
+ if caller is not None and rebac is not None and rebac.enabled:
2337
+ await rebac.check_user_permission_or_raise(
2338
+ caller, OrganizationPermission.CAN_MANAGE_PLATFORM, ORGANIZATION_ID
2339
+ )
2236
2340
  with container._audit_events_lock:
2237
2341
  events = list(container.audit_events_buffer)
2238
2342
  events.reverse()
@@ -2392,6 +2496,11 @@ def _build_agent_router(
2392
2496
  detail="No history store configured — session history is unavailable.",
2393
2497
  )
2394
2498
  caller_uid = caller.uid if caller is not None else None
2499
+ if await _caller_can_manage_platform(caller):
2500
+ # Platform admin (e.g. CTRLP-12 lifecycle erase-at-expiry): delete every
2501
+ # row for the session, not just the caller's — ownership is waived,
2502
+ # authentication stays enforced upstream.
2503
+ caller_uid = None
2395
2504
  count = await history_store.delete_session(
2396
2505
  session_id=session_id, user_id=caller_uid
2397
2506
  )
@@ -2717,7 +2826,8 @@ def _build_agent_router(
2717
2826
  caller_uid = caller.uid if caller is not None else None
2718
2827
  history_store = _get_history_store_for_owned_access(caller)
2719
2828
  if (
2720
- caller_uid is not None
2829
+ not await _caller_can_manage_platform(caller)
2830
+ and caller_uid is not None
2721
2831
  and history_store is not None
2722
2832
  and not await history_store.session_belongs_to_user(session_id, caller_uid)
2723
2833
  ):
@@ -2925,8 +3035,10 @@ def _build_agent_router(
2925
3035
  stream closes. Do not rely on it as an end-of-turn signal here.
2926
3036
 
2927
3037
  Security:
2928
- - For managed execution (agent_instance_id), an execution_grant issued by
2929
- control-plane is required. The runtime validates it structurally.
3038
+ - There is no ExecutionGrant. Identity is the caller's Keycloak JWT; for
3039
+ managed execution (agent_instance_id) the runtime authorizes each request
3040
+ itself with a per-request ReBAC (OpenFGA) check on runtime_context.team_id.
3041
+ The control plane issues no signed grant or capability token.
2930
3042
  - RBAC via Keycloak and REBAC via OpenFGA protect this endpoint.
2931
3043
 
2932
3044
  Architectural note:
@@ -3073,6 +3185,7 @@ def create_agent_app(
3073
3185
  checkpointer=checkpointer,
3074
3186
  history_store=history_store,
3075
3187
  mcp_configuration=config.get_mcp_configuration(),
3188
+ inprocess_toolkit_factory=build_inprocess_toolkit,
3076
3189
  control_plane_url=config.platform.control_plane_url,
3077
3190
  rebac_engine=rebac_engine,
3078
3191
  security_profile=(
@@ -18,8 +18,9 @@ from __future__ import annotations
18
18
 
19
19
  import asyncio
20
20
  import logging
21
+ import time
21
22
  from collections.abc import Awaitable, Iterable
22
- from typing import Any, Callable, List, Optional, cast
23
+ from typing import Any, Callable, List, Optional, Tuple, cast
23
24
 
24
25
  from fred_sdk.contracts.context import RuntimeContext as AgentRuntimeContext
25
26
  from fred_sdk.contracts.models import (
@@ -45,6 +46,105 @@ logger = logging.getLogger(__name__)
45
46
  MCP_CONNECT_MAX_ATTEMPTS = 3
46
47
  MCP_CONNECT_RETRY_BASE_DELAY_SECS = 0.5
47
48
 
49
+ # --- Cross-request connect/tool-list cache (perf) -----------------------------
50
+ # BENCH-<pending id>: `_run_lifecycle` used to reconnect + re-list tools against
51
+ # every configured MCP server on EVERY agent turn (measured: ~2-3s wall time for
52
+ # a 7-server agent, with a near-instant mock LLM). `MultiServerMCPClient` itself
53
+ # holds no persistent resources (see `_close_mcp_client_quietly` below), so the
54
+ # per-turn cost was purely the repeated connect+list_tools round trips, not
55
+ # anything a "closed" connection would have saved by staying open.
56
+ #
57
+ # This cache reuses the (client, tools) pair across turns for the SAME agent +
58
+ # server set + access token, so repeat turns in a session (or across sessions
59
+ # for the same user/dev token) skip the round trips entirely.
60
+ #
61
+ # Auth freshness on a cache hit: `langchain_mcp_adapters` reads `tool_interceptors`
62
+ # (and per-server `headers`) live off the client instance at call time, not from a
63
+ # snapshot frozen at discovery time (verified against langchain_mcp_adapters==0.3.0
64
+ # — see `execute_tool`/`_build_interceptor_chain` in its `tools.py`). So on a cache
65
+ # hit we mutate `client.tool_interceptors` IN PLACE (never reassign the list) to the
66
+ # CURRENT request's own interceptors before handing the client back — every request
67
+ # still gets its own live `ExpiredTokenRetryInterceptor` (bound to ITS OWN, live
68
+ # `agent_instance`/token-refresh callback), even though the underlying connection +
69
+ # discovered tool schemas are shared. Headers don't need the same treatment: the
70
+ # cache key already includes the exact access_token string, so a cache hit implies
71
+ # an identical token/header value already.
72
+ #
73
+ # Residual, accepted race: two truly concurrent requests for the SAME identity
74
+ # (same cache key) could have request A's in-flight tool call pick up request B's
75
+ # interceptor instance instead of its own. Both point at the same live user, so
76
+ # this is never a cross-user leak — at worst a harmless same-identity mix-up.
77
+ # Closing it fully would need a lock held for an entire turn's tool-calling phase,
78
+ # which would serialize concurrent turns for the same user; not worth it.
79
+ MCP_CLIENT_CACHE_TTL_SECS = 300.0
80
+ _mcp_client_cache: dict[
81
+ Tuple[str, Tuple[str, ...], str], Tuple[MultiServerMCPClient, List[BaseTool], float]
82
+ ] = {}
83
+ _mcp_client_cache_lock = asyncio.Lock()
84
+
85
+
86
+ def _mcp_cache_key(
87
+ agent_id: str, servers: List[MCPServerConfiguration], access_token: str | None
88
+ ) -> Tuple[str, Tuple[str, ...], str]:
89
+ return (agent_id, tuple(sorted(s.id for s in servers)), access_token or "")
90
+
91
+
92
+ def _prune_expired_mcp_cache_locked(now: float) -> None:
93
+ """
94
+ Drop every expired entry from `_mcp_client_cache`.
95
+
96
+ Why this exists:
97
+ - a cache hit only checks the ONE key being looked up; without this, any
98
+ OTHER key that expires (e.g. a rotated user token, an agent that stops
99
+ being used) stays resident in process memory forever — a real memory
100
+ leak over a long-running pod's life, and it keeps stale `Authorization`
101
+ headers (the cache key embeds the raw access_token) around indefinitely.
102
+
103
+ How to use it:
104
+ - call under `_mcp_client_cache_lock`, before a lookup or insert.
105
+ """
106
+ expired = [k for k, v in _mcp_client_cache.items() if v[2] <= now]
107
+ for k in expired:
108
+ del _mcp_client_cache[k]
109
+
110
+
111
+ async def _get_or_connect_mcp_client(
112
+ *,
113
+ agent_id: str,
114
+ mcp_servers: List[MCPServerConfiguration],
115
+ runtime_context: AgentRuntimeContext,
116
+ tool_interceptors: list,
117
+ ) -> Tuple[MultiServerMCPClient, List[BaseTool]]:
118
+ key = _mcp_cache_key(agent_id, mcp_servers, runtime_context.access_token)
119
+ now = time.monotonic()
120
+
121
+ async with _mcp_client_cache_lock:
122
+ _prune_expired_mcp_cache_locked(now)
123
+ cached = _mcp_client_cache.get(key)
124
+ if cached is not None and cached[2] > now:
125
+ cached_client = cached[0]
126
+ # In-place mutation only — reassigning `.tool_interceptors` would not
127
+ # be seen by tool closures already vended from a prior get_tools()
128
+ # call (they hold a reference to the original list object).
129
+ cached_client.tool_interceptors[:] = tool_interceptors
130
+ logger.info(
131
+ "[MCP] agent=%s cache hit: reusing connected client (%d tools), "
132
+ "current request's interceptors swapped in.",
133
+ agent_id,
134
+ len(cached[1]),
135
+ )
136
+ return cached_client, cached[1]
137
+
138
+ client, tools = await get_connected_mcp_client_for_agent(
139
+ agent_id=agent_id,
140
+ mcp_servers=mcp_servers,
141
+ runtime_context=runtime_context,
142
+ tool_interceptors=tool_interceptors,
143
+ )
144
+ async with _mcp_client_cache_lock:
145
+ _mcp_client_cache[key] = (client, tools, now + MCP_CLIENT_CACHE_TTL_SECS)
146
+ return client, tools
147
+
48
148
 
49
149
  async def _close_mcp_client_quietly(client: Optional[MultiServerMCPClient]) -> None:
50
150
  if not client:
@@ -245,29 +345,23 @@ class MCPRuntime:
245
345
  if refresh_cb:
246
346
  interceptors.append(ExpiredTokenRetryInterceptor(refresh_cb))
247
347
 
248
- new_client = await get_connected_mcp_client_for_agent(
348
+ new_client, tools = await _get_or_connect_mcp_client(
249
349
  agent_id=self._agent_id,
250
350
  mcp_servers=self.remote_servers,
251
351
  runtime_context=runtime_context,
252
352
  tool_interceptors=interceptors,
253
353
  )
254
354
  self.mcp_client = new_client
355
+ # `tools` were already fetched (per server) while connecting/validating
356
+ # above — reuse them instead of paying for a second get_tools() round
357
+ # trip against every server on every single turn.
255
358
  self.toolkit = McpToolkit(client=new_client, agent=self.agent_instance)
256
- try:
257
- # Pre-fetch tools once (async) and cache in toolkit for sync callers
258
- tools = await new_client.get_tools()
259
- self.toolkit.tools = tools
260
- logger.info(
261
- "[MCP] agent=%s init: Prefetched and cached %d tools.",
262
- self._agent_id,
263
- len(tools),
264
- )
265
- except Exception:
266
- logger.warning(
267
- "[MCP] agent=%s init: Failed to prefetch tools; toolkit will attempt best-effort discovery later.",
268
- self._agent_id,
269
- exc_info=True,
270
- )
359
+ self.toolkit.tools = tools
360
+ logger.info(
361
+ "[MCP] agent=%s init: Connected and cached %d tools.",
362
+ self._agent_id,
363
+ len(tools),
364
+ )
271
365
  logger.info(
272
366
  "[MCP] agent=%s init: Successfully built and connected client.",
273
367
  self._agent_id,
@@ -375,7 +469,7 @@ class MCPRuntime:
375
469
  )
376
470
  return
377
471
  for server in self.inprocess_servers:
378
- toolkit = factory(server.provider)
472
+ toolkit = factory(server.provider, self.agent_instance)
379
473
  if toolkit is None:
380
474
  logger.warning(
381
475
  "[MCP] agent=%s no toolkit built for provider=%s (server=%s)",
@@ -25,7 +25,8 @@ Contract
25
25
  --------
26
26
  - Requires an `access_token_provider` (a callable) to fetch the user's token
27
27
  before connecting to any MCP server.
28
- - Returns a connected `MultiServerMCPClient`.
28
+ - Returns a connected `MultiServerMCPClient` plus the tools already fetched
29
+ while validating each server (see `get_connected_mcp_client_for_agent`).
29
30
  - Raises `ExceptionGroup` if **any** server fails to connect.
30
31
 
31
32
  """
@@ -35,10 +36,11 @@ from __future__ import annotations
35
36
  import logging
36
37
  import time
37
38
  from datetime import timedelta
38
- from typing import Dict, List
39
+ from typing import Dict, List, Tuple
39
40
 
40
41
  from fred_sdk.contracts.context import RuntimeContext
41
42
  from fred_sdk.contracts.models import MCPServerConfiguration
43
+ from langchain_core.tools import BaseTool
42
44
  from langchain_mcp_adapters.client import MultiServerMCPClient
43
45
  from langchain_mcp_adapters.sessions import Connection, StreamableHttpConnection
44
46
 
@@ -184,10 +186,14 @@ async def get_connected_mcp_client_for_agent(
184
186
  *,
185
187
  tool_interceptors: list | None = None,
186
188
  # -----------------------------------------------
187
- ) -> MultiServerMCPClient:
189
+ ) -> Tuple[MultiServerMCPClient, List[BaseTool]]:
188
190
  """
189
191
  Creates and connects the MultiServerMCPClient using the token provided by
190
192
  `access_token_provider`. Supports `streamable_http` and `stdio` transports.
193
+
194
+ Also returns the tools fetched per server during connection validation, so
195
+ callers don't have to pay for a second `get_tools()` round trip against
196
+ every server just to populate their toolkit (see mcp_runtime.py).
191
197
  """
192
198
 
193
199
  for s in mcp_servers:
@@ -262,10 +268,13 @@ async def get_connected_mcp_client_for_agent(
262
268
  connections, tool_interceptors=tool_interceptors or []
263
269
  )
264
270
 
265
- # Validate connections by attempting to load tools per server
271
+ # Validate connections by attempting to load tools per server. This is also
272
+ # the ONLY tool-fetch round trip we make per server — the fetched tools are
273
+ # returned to the caller so `MCPRuntime._run_lifecycle` doesn't need to call
274
+ # `client.get_tools()` again across all servers right after this returns.
266
275
  exceptions: list[Exception] = []
267
276
  failure_messages: list[str] = []
268
- total_tools = 0
277
+ fetched_tools: list[BaseTool] = []
269
278
  for server in mcp_servers:
270
279
  conn_entry = connections.get(server.id) or {}
271
280
  transport = conn_entry.get("transport", "unknown")
@@ -294,7 +303,7 @@ async def get_connected_mcp_client_for_agent(
294
303
  len(tools),
295
304
  dur_ms,
296
305
  )
297
- total_tools += len(tools)
306
+ fetched_tools.extend(tools)
298
307
  except Exception as e:
299
308
  dur_ms = (time.perf_counter() - start) * 1000
300
309
  logger.warning(
@@ -328,6 +337,6 @@ async def get_connected_mcp_client_for_agent(
328
337
  logger.debug(
329
338
  "[MCP][%s] summary: all servers validated, total tools=%d",
330
339
  agent_id,
331
- total_tools,
340
+ len(fetched_tools),
332
341
  )
333
- return client
342
+ return client, fetched_tools
@@ -38,7 +38,7 @@ from langchain_core.tools import BaseTool
38
38
  from langgraph.types import Checkpointer
39
39
 
40
40
  from fred_runtime.react.react_prompting import (
41
- build_guardrail_suffix as _build_guardrail_suffix,
41
+ compose_system_prompt as _compose_system_prompt,
42
42
  )
43
43
  from fred_runtime.react.react_prompting import (
44
44
  render_prompt_template as _render_prompt_template,
@@ -117,11 +117,17 @@ class DeepAgentRuntime(ReActRuntime):
117
117
  binding=binding,
118
118
  agent_id=self.definition.agent_id,
119
119
  )
120
- system_prompt = (
121
- f"{system_prompt}"
122
- f"{_build_runtime_tool_prompt_suffix(bound_tools)}"
123
- f"{_build_guardrail_suffix(self.definition)}"
124
- f"{_filesystem_prompt_suffix(filesystem_tools_enabled=filesystem_tools_enabled)}"
120
+ system_prompt = _compose_system_prompt(
121
+ system_prompt,
122
+ binding=binding,
123
+ definition=self.definition,
124
+ agent_id=self.definition.agent_id,
125
+ tool_suffix=_build_runtime_tool_prompt_suffix(bound_tools),
126
+ runtime_suffixes=(
127
+ _filesystem_prompt_suffix(
128
+ filesystem_tools_enabled=filesystem_tools_enabled
129
+ ),
130
+ ),
125
131
  )
126
132
  compiled_agent = _create_compiled_deep_agent(
127
133
  model=self._model,
@@ -1906,7 +1906,6 @@ def _graph_phase_timer(
1906
1906
  return metrics.timer(
1907
1907
  "app.phase_latency_ms",
1908
1908
  dims=dims,
1909
- groups=binding.runtime_context.user_groups,
1910
1909
  )
1911
1910
 
1912
1911
 
@@ -0,0 +1,50 @@
1
+ # Copyright Thales 2026
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """
15
+ Registry mapping catalog `provider` keys to in-process MCP toolkits (MIGR-03.03).
16
+
17
+ Why this module exists:
18
+ - an MCP catalog server with `transport: inprocess` names a `provider`; the MCP
19
+ runtime asks this factory to build the matching toolkit for the current agent turn
20
+ - keeping the mapping here (not in `agent_app`) lets the runtime wire a single
21
+ factory reference at startup without hardcoding provider knowledge in the boot path
22
+
23
+ How to use it:
24
+ - pass `build_inprocess_toolkit` as `RuntimeConfig.inprocess_toolkit_factory`
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ from typing import Any
31
+
32
+ from fred_runtime.integrations.kf_vector_search import (
33
+ KF_VECTOR_SEARCH_PROVIDER,
34
+ KfVectorSearchToolkit,
35
+ )
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+
40
+ def build_inprocess_toolkit(provider: str | None, agent: Any) -> Any | None:
41
+ """
42
+ Build the in-process toolkit for `provider`, bound to the current agent turn.
43
+
44
+ Returns None for an unknown or missing provider so the MCP runtime skips it with
45
+ a warning rather than failing the whole agent.
46
+ """
47
+ if provider == KF_VECTOR_SEARCH_PROVIDER:
48
+ return KfVectorSearchToolkit(agent=agent)
49
+ logger.warning("[MCP] unknown inprocess provider=%s; no toolkit built.", provider)
50
+ return None
@@ -0,0 +1,21 @@
1
+ # Copyright Thales 2026
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """In-process Knowledge Flow vector-search MCP toolkit."""
15
+
16
+ from fred_runtime.integrations.kf_vector_search.toolkit import (
17
+ KF_VECTOR_SEARCH_PROVIDER,
18
+ KfVectorSearchToolkit,
19
+ )
20
+
21
+ __all__ = ["KF_VECTOR_SEARCH_PROVIDER", "KfVectorSearchToolkit"]