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.
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/PKG-INFO +1 -1
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/app/agent_app.py +170 -20
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/app/context.py +34 -6
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/app/openai_compat_router.py +4 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/capabilities/assembly.py +15 -1
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/capabilities/errors.py +10 -6
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/graph/graph_runtime.py +59 -9
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/model_routing/__init__.py +4 -0
- fred_runtime-3.4.0/fred_runtime/model_routing/authz.py +104 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/model_routing/contracts.py +40 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/model_routing/provider.py +68 -3
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/model_routing/resolver.py +54 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/react_prompting.py +42 -5
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/runtime_context.py +7 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/runtime_support/sql_checkpointer.py +22 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime.egg-info/PKG-INFO +1 -1
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime.egg-info/SOURCES.txt +3 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/pyproject.toml +1 -1
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_agent_app.py +401 -5
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_capability_agent_1973.py +43 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_capability_endpoints_1974.py +3 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_context.py +75 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_graph_capability_bridge.py +128 -1
- fred_runtime-3.4.0/tests/test_model_enforcement.py +385 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_model_routing.py +113 -1
- fred_runtime-3.4.0/tests/test_models_catalog_projection.py +139 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_react_prompting.py +23 -3
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_sql_checkpointer_owner.py +34 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/README.md +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/__init__.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/__main__.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/app/__init__.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/app/_catalogs.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/app/config.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/app/config_loader.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/app/container.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/app/dependencies.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/app/mcp_config.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/app/observability_factory.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/capabilities/__init__.py +1 -1
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/capabilities/assets.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/capabilities/demo.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/capabilities/demo_migrations/env.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/capabilities/demo_migrations/versions/d1e2m0a0b0c0_create_demo_echo_notes.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/capabilities/document_access/__init__.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/capabilities/document_access/capability.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/capabilities/mcp.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/capabilities/openapi_dump.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/capabilities/registry.py +1 -1
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/cli/__init__.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/cli/completion.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/cli/entrypoint.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/cli/history_display.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/cli/kpi_display.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/cli/pod_client.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/cli/repl.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/cli/repl_helpers.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/cli/url_helpers.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/client.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/__init__.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/context_aware_tool.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/kf_base_client.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/kf_document_client.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/kf_fast_text_client.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/kf_http_client.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/kf_markdown_media_client.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/kf_tag_client.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/kf_vectorsearch_client.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/kf_workspace_client.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/mcp_interceptors.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/mcp_runtime.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/mcp_toolkit.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/mcp_utils.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/structures.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/token_expiry.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/common/tool_node_utils.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/deep/__init__.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/deep/deep_runtime.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/eval/__init__.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/eval/collector.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/graph/__init__.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/integrations/__init__.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/integrations/inprocess_toolkit_registry.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/integrations/kf_vector_search/__init__.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/integrations/kf_vector_search/toolkit.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/integrations/v2_runtime/__init__.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/integrations/v2_runtime/adapters.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/migrations.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/model_routing/catalog.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/__init__.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/middleware/__init__.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/middleware/checkpoint_hygiene.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/middleware/dynamic_prompt.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/middleware/frame.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/middleware/hitl.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/middleware/model_routing.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/middleware/shared.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/middleware/tool_observability.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/middleware/tracing_kpi.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/react_langchain_adapter.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/react_message_codec.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/react_model_adapter.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/react_runtime.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/react_stream_adapter.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/react_tool_binding.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/react_tool_loop.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/react_tool_rendering.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/react_tool_resolution.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/react_tool_utils.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/react/react_tracing.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/runtime_support/__init__.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/runtime_support/checkpoints.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/runtime_support/model_metadata.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/runtime_support/request_context_helpers.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/runtime_support/user_token_refresher.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/support/__init__.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/support/filesystem_context.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/support/thinking.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime/support/tool_loop.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime.egg-info/dependency_links.txt +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime.egg-info/entry_points.txt +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime.egg-info/requires.txt +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/fred_runtime.egg-info/top_level.txt +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/setup.cfg +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_capability_chat_controls_1976.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_capability_chat_parts_1977.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_capability_document_access_1906.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_capability_registry_1973.py +1 -1
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_capability_routes_tables_1979.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_capability_selection_1974.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_capability_tool_return_convention.py +1 -1
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_client.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_config_loader.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_context_aware_tool.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_conversational_memory.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_deep_agent_middleware.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_eval_collector.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_eval_trace.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_fred_workspace_fs.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_graph_runtime_invoke_agent.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_graph_runtime_observability.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_history.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_kf_vector_search_toolkit.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_kf_workspace_client.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_knowledge_search_tool_invoker_sources.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_kpi_display.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_mcp_config.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_openai_compat_router.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_pod_client.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_react_loop_regressions_1972.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_react_middleware_frame.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_react_thinking.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_repl_helpers.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_runtime_context_prompt_injection.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_smoke.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_spike_capability_state_1971.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_token_expiry.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_tool_loop_trim.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_tool_observability_middleware.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_tracing_kpi_content_confinement.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_url_helpers.py +0 -0
- {fred_runtime-3.3.7 → fred_runtime-3.4.0}/tests/test_user_token_refresher.py +0 -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
|
|
1199
|
-
|
|
1200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
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 (
|
|
2405
|
-
# RFC
|
|
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.
|
|
3889
|
-
# 4.
|
|
3890
|
-
# 5.
|
|
3891
|
-
# 6.
|
|
3892
|
-
# 7.
|
|
3893
|
-
# 8.
|
|
4031
|
+
# 3. initialize_control_plane_client — sync, no network until first call
|
|
4032
|
+
# 4. bootstrap_observability — global 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 =
|
|
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()
|
|
93
|
-
2.
|
|
94
|
-
3.
|
|
95
|
-
4.
|
|
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
|
|
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()
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
66
|
-
Declare `execution_models=("react",)` explicitly, or
|
|
67
|
-
for the plain-tool portion so the capability is
|
|
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
|
|
739
|
+
# normalized dict's `is_error` key, which any plain tool
|
|
735
740
|
# could coincidentally also carry for an unrelated reason.
|
|
736
|
-
#
|
|
737
|
-
#
|
|
738
|
-
#
|
|
739
|
-
#
|
|
740
|
-
#
|
|
741
|
-
#
|
|
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
|
-
|
|
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
|
]
|