From d4f656fba0ef04b7307b40367398c508b99cc4ec Mon Sep 17 00:00:00 2001 From: witbrock Date: Sat, 1 Aug 2026 00:06:06 +0100 Subject: [PATCH 01/16] Enable scoped AgentTest connector capabilities --- .../integrations/internal_mcp/catalogue.py | 3 + src/backend/server/routes/von_routes.py | 10 +++ src/backend/services/adaptive_turn_service.py | 33 ++++++-- .../agent_test_replay_mode_service.py | 57 +++++++++++++ tests/backend/test_adaptive_turn_service.py | 82 +++++++++++++++++++ .../test_agent_test_replay_mode_service.py | 54 ++++++++++++ tests/backend/test_internal_mcp_jira_tools.py | 6 ++ .../test_von_generate_workflow_instances.py | 41 ++++++++++ 8 files changed, 279 insertions(+), 7 deletions(-) create mode 100644 tests/backend/test_agent_test_replay_mode_service.py diff --git a/src/backend/integrations/internal_mcp/catalogue.py b/src/backend/integrations/internal_mcp/catalogue.py index 9f9b2e56c..5a079baba 100644 --- a/src/backend/integrations/internal_mcp/catalogue.py +++ b/src/backend/integrations/internal_mcp/catalogue.py @@ -35556,6 +35556,9 @@ def _build_default_catalogue_external_integration_definitions() -> ( input_schema=_jira_update_issue_input_schema(), output_schema=jira_update_issue_output_schema, category="write", + ordinary_turn_excluded_reason="deployment_global_account", + ordinary_turn_fixed_arguments={"execute": False}, + ordinary_turn_effect=True, timeout_sec=20.0, description=( "Update a Jira issue with safety guardrails. Default dry_run=true (no mutation). " diff --git a/src/backend/server/routes/von_routes.py b/src/backend/server/routes/von_routes.py index cacf29b9b..3b453b4b0 100644 --- a/src/backend/server/routes/von_routes.py +++ b/src/backend/server/routes/von_routes.py @@ -55,6 +55,9 @@ from ...services.adaptive_turn_service import ( execute_adaptive_turn, ) +from ...services.agent_test_replay_mode_service import ( + resolve_agent_test_trusted_capability_names, +) from ...services.background_task_service import background_task_registry from ...services.workflow_payload_store import is_workflow_payload_blob_ref from ...services.live_request_load import ( @@ -11307,6 +11310,13 @@ def _progress_update(info: dict[str, Any]) -> None: user_namespace=user_namespace, user_concept_id=user_concept_id, org_concept_id=org_concept_id, + agent_test_trusted_capability_names=( + resolve_agent_test_trusted_capability_names( + user_concept_id=user_concept_id, + organisation_concept_id=org_concept_id, + namespace=user_namespace, + ) + ), trusted_argument_values=( {"gmail_profile": request_gmail_profile} if request_gmail_profile diff --git a/src/backend/services/adaptive_turn_service.py b/src/backend/services/adaptive_turn_service.py index 2607a4cc2..f60a3ccf8 100644 --- a/src/backend/services/adaptive_turn_service.py +++ b/src/backend/services/adaptive_turn_service.py @@ -1493,6 +1493,7 @@ def ordinary_turn_capability_delegation( *, user_concept_id: str | None, trusted_argument_values: Mapping[str, Any] | None = None, + agent_test_trusted_capability_names: Sequence[str] | None = None, ) -> tuple[str, ...]: """Project the capabilities authorised for an ordinary turn. @@ -1523,9 +1524,18 @@ def trusted_binding_is_present(binding_key: str) -> bool: and not is_unresolved_tool_argument_placeholder(value) ) + def trusted_bindings_are_present(metadata: Mapping[str, Any]) -> bool: + return all( + trusted_binding_is_present(str(binding_key)) + for binding_key in ( + metadata.get("ordinary_turn_trusted_argument_bindings") or {} + ).values() + ) + + methods = gateway.describe_methods() requested = [ name - for name, metadata in gateway.describe_methods().items() + for name, metadata in methods.items() if isinstance(metadata, Mapping) and ( metadata.get("category") == "read" @@ -1543,13 +1553,20 @@ def trusted_binding_is_present(binding_key: str) -> bool: and metadata.get("ordinary_turn_public") is True ) ) - and all( - trusted_binding_is_present(str(binding_key)) - for binding_key in ( - metadata.get("ordinary_turn_trusted_argument_bindings") or {} - ).values() - ) + and trusted_bindings_are_present(metadata) ] + if authenticated: + for name in agent_test_trusted_capability_names or (): + metadata = methods.get(name) + if not isinstance(metadata, Mapping): + continue + if metadata.get("category") != "read" and not ( + metadata.get("category") == "write" + and metadata.get("ordinary_turn_effect") is True + ): + continue + if trusted_bindings_are_present(metadata): + requested.append(name) return _registered_capability_names(gateway, requested) @@ -2844,6 +2861,7 @@ def execute_adaptive_turn( user_concept_id: str | None = None, org_concept_id: str | None = None, trusted_argument_values: Mapping[str, Any] | None = None, + agent_test_trusted_capability_names: Sequence[str] | None = None, workflow_launch_inputs: Mapping[str, Any] | None = None, progress_tracker: Any = None, turn_id: str | None = None, @@ -2921,6 +2939,7 @@ def execute_adaptive_turn( gateway, user_concept_id=user_concept_id, trusted_argument_values=trusted_values, + agent_test_trusted_capability_names=agent_test_trusted_capability_names, ) delegated_lookup = {name.lower(): name for name in delegated_names} workflow_capabilities_by_name: dict[str, Any] = {} diff --git a/src/backend/services/agent_test_replay_mode_service.py b/src/backend/services/agent_test_replay_mode_service.py index 7e9084ddd..daafa7db8 100644 --- a/src/backend/services/agent_test_replay_mode_service.py +++ b/src/backend/services/agent_test_replay_mode_service.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import re from typing import Any, Mapping @@ -10,6 +11,21 @@ AGENT_TEST_SELECTOR_REPLAY_MODE_FAST_PATH = "fast_path" AGENT_TEST_SELECTOR_REPLAY_MODE_REPRESENTED_LLM = "represented_selector_llm" AGENT_TEST_REAL_POSTCONDITION_CRITIC_ENV = "VON_AGENT_TEST_REAL_POSTCONDITION_CRITIC" +AGENT_TEST_TRUSTED_CAPABILITY_NAMES_ENV = ( + "VON_AGENT_TEST_TRUSTED_CAPABILITY_GRANTS" +) +AGENT_TEST_TRUSTED_CAPABILITY_USER_ENV = ( + "VON_AGENT_TEST_TRUSTED_CAPABILITY_GRANT_USER" +) +AGENT_TEST_TRUSTED_CAPABILITY_ORGANISATION_ENV = ( + "VON_AGENT_TEST_TRUSTED_CAPABILITY_GRANT_ORGANISATION" +) +AGENT_TEST_TRUSTED_CAPABILITY_NAMESPACE_ENV = ( + "VON_AGENT_TEST_TRUSTED_CAPABILITY_GRANT_NAMESPACE" +) +_AGENT_TEST_CAPABILITY_NAME_PATTERN = re.compile( + r"^[A-Za-z_][A-Za-z0-9_.:-]{0,127}$" +) def _clean_text(value: Any) -> str: @@ -32,6 +48,47 @@ def agent_test_real_postcondition_critic_enabled( } +def resolve_agent_test_trusted_capability_names( + *, + user_concept_id: str | None, + organisation_concept_id: str | None, + namespace: str | None, + environ: Mapping[str, str] | None = None, +) -> tuple[str, ...]: + """Resolve exact test capabilities only for the configured server scope.""" + + env = environ if environ is not None else os.environ + if _clean_text(env.get("VON_AGENT_TEST_INSTANCE")).lower() not in { + "1", + "true", + "yes", + "on", + }: + return () + + configured_scope = ( + _clean_text(env.get(AGENT_TEST_TRUSTED_CAPABILITY_USER_ENV)), + _clean_text(env.get(AGENT_TEST_TRUSTED_CAPABILITY_ORGANISATION_ENV)), + _clean_text(env.get(AGENT_TEST_TRUSTED_CAPABILITY_NAMESPACE_ENV)), + ) + server_scope = ( + _clean_text(user_concept_id), + _clean_text(organisation_concept_id), + _clean_text(namespace), + ) + if not all(configured_scope) or configured_scope != server_scope: + return () + + raw_names = _clean_text(env.get(AGENT_TEST_TRUSTED_CAPABILITY_NAMES_ENV)) + names = [part.strip() for part in raw_names.split(",")] + if not raw_names or any( + not name or _AGENT_TEST_CAPABILITY_NAME_PATTERN.fullmatch(name) is None + for name in names + ): + return () + return tuple(sorted(set(names))) + + def represented_postcondition_critic_enabled_for_runtime( *, agent_test_instance: bool, diff --git a/tests/backend/test_adaptive_turn_service.py b/tests/backend/test_adaptive_turn_service.py index 612373568..4fd341e55 100644 --- a/tests/backend/test_adaptive_turn_service.py +++ b/tests/backend/test_adaptive_turn_service.py @@ -1198,6 +1198,88 @@ def test_ordinary_delegation_is_actor_capability_not_every_read_method() -> None ) == ("search_arxiv",) +def test_agent_test_exact_names_extend_only_registered_read_or_effect_capability() -> None: + catalogue = MethodCatalogue() + for definition in ( + MethodDefinition( + name="ordinary_read", + handler=lambda **_kwargs: {"success": True}, + input_schema=Schema(allow_unknown=True), + category="read", + ), + MethodDefinition( + name="deployment_read", + handler=lambda **_kwargs: {"success": True}, + input_schema=Schema(allow_unknown=True), + category="read", + ordinary_turn_excluded_reason="deployment_global_account", + ), + MethodDefinition( + name="unmarked_write", + handler=lambda **_kwargs: {"success": True}, + input_schema=Schema(allow_unknown=True), + category="write", + ), + MethodDefinition( + name="bounded_effect", + handler=lambda **_kwargs: {"success": True}, + input_schema=Schema(allow_unknown=True), + category="write", + ordinary_turn_excluded_reason="deployment_global_account", + ordinary_turn_effect=True, + ), + MethodDefinition( + name="bound_deployment_read", + handler=lambda **_kwargs: {"success": True}, + input_schema=Schema(allow_unknown=True), + category="read", + ordinary_turn_excluded_reason="deployment_global_account", + ordinary_turn_trusted_argument_bindings={"profile": "profile"}, + ), + ): + catalogue.register(definition) + gateway = InternalMCPGateway( + catalogue=catalogue, + transport=InternalMCPTransport(read_timeout_sec=1.0), + enabled=True, + ) + + default = ordinary_turn_capability_delegation( + gateway, + user_concept_id="#V#person", + ) + assert default == ("ordinary_read",) + assert ordinary_turn_capability_delegation( + gateway, + user_concept_id="#V#person", + agent_test_trusted_capability_names=(), + ) == default + + granted = ordinary_turn_capability_delegation( + gateway, + user_concept_id="#V#person", + agent_test_trusted_capability_names=( + "deployment_read", + "unmarked_write", + "bounded_effect", + "bound_deployment_read", + "unknown_capability", + ), + ) + assert granted == ("bounded_effect", "deployment_read", "ordinary_read") + assert ordinary_turn_capability_delegation( + gateway, + user_concept_id="#V#person", + trusted_argument_values={"profile": "represented-profile"}, + agent_test_trusted_capability_names=("bound_deployment_read",), + ) == ("bound_deployment_read", "ordinary_read") + assert ordinary_turn_capability_delegation( + gateway, + user_concept_id=None, + agent_test_trusted_capability_names=("deployment_read", "bounded_effect"), + ) == () + + def test_server_bound_capability_argument_is_not_model_visible() -> None: gateway = _gmail_gateway(lambda **_kwargs: {"success": True}) delegated = ordinary_turn_capability_delegation( diff --git a/tests/backend/test_agent_test_replay_mode_service.py b/tests/backend/test_agent_test_replay_mode_service.py new file mode 100644 index 000000000..44dc56d2c --- /dev/null +++ b/tests/backend/test_agent_test_replay_mode_service.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from src.backend.services.agent_test_replay_mode_service import ( + resolve_agent_test_trusted_capability_names, +) + + +_TRUSTED_SCOPE = { + "user_concept_id": "#V#user", + "organisation_concept_id": "#V#org", + "namespace": "#V#user@org", +} + + +def _trusted_capability_environ() -> dict[str, str]: + return { + "VON_AGENT_TEST_INSTANCE": "1", + "VON_AGENT_TEST_TRUSTED_CAPABILITY_GRANTS": ( + "jira_update_issue,jira_get_issue,jira_get_issue" + ), + "VON_AGENT_TEST_TRUSTED_CAPABILITY_GRANT_USER": "#V#user", + "VON_AGENT_TEST_TRUSTED_CAPABILITY_GRANT_ORGANISATION": "#V#org", + "VON_AGENT_TEST_TRUSTED_CAPABILITY_GRANT_NAMESPACE": "#V#user@org", + } + + +def test_agent_test_trusted_capabilities_require_exact_server_scope() -> None: + assert resolve_agent_test_trusted_capability_names( + **_TRUSTED_SCOPE, + environ=_trusted_capability_environ(), + ) == ("jira_get_issue", "jira_update_issue") + + mismatches = [] + for key in _TRUSTED_SCOPE: + scope = dict(_TRUSTED_SCOPE) + scope[key] = scope[key] + "_other" + mismatches.append((scope, _trusted_capability_environ())) + missing_scope = _trusted_capability_environ() + missing_scope.pop("VON_AGENT_TEST_TRUSTED_CAPABILITY_GRANT_NAMESPACE") + mismatches.append((dict(_TRUSTED_SCOPE), missing_scope)) + outside_agent_test = _trusted_capability_environ() + outside_agent_test.pop("VON_AGENT_TEST_INSTANCE") + mismatches.append((dict(_TRUSTED_SCOPE), outside_agent_test)) + malformed_names = _trusted_capability_environ() + malformed_names["VON_AGENT_TEST_TRUSTED_CAPABILITY_GRANTS"] = ( + "jira_get_issue,not a capability" + ) + mismatches.append((dict(_TRUSTED_SCOPE), malformed_names)) + + for scope, environ in mismatches: + assert resolve_agent_test_trusted_capability_names( + **scope, + environ=environ, + ) == () diff --git a/tests/backend/test_internal_mcp_jira_tools.py b/tests/backend/test_internal_mcp_jira_tools.py index f66da5923..56fe558fe 100644 --- a/tests/backend/test_internal_mcp_jira_tools.py +++ b/tests/backend/test_internal_mcp_jira_tools.py @@ -139,6 +139,12 @@ def test_jira_methods_registered_in_catalogue(): "fields", "expand", ) + update_definition = catalogue.get("jira_update_issue") + assert update_definition.ordinary_turn_excluded_reason == ( + "deployment_global_account" + ) + assert update_definition.ordinary_turn_effect is True + assert update_definition.ordinary_turn_fixed_arguments == {"execute": False} def test_jira_handlers_require_minimum_fields(): diff --git a/tests/backend/test_von_generate_workflow_instances.py b/tests/backend/test_von_generate_workflow_instances.py index c7403d0d2..1ac607645 100644 --- a/tests/backend/test_von_generate_workflow_instances.py +++ b/tests/backend/test_von_generate_workflow_instances.py @@ -259,6 +259,47 @@ def test_generate_canonicalises_actor_scope_for_the_adaptive_turn( assert adaptive_call["user_namespace"] == "#V#michael_witbrock@sail_lab" +def test_generate_resolves_agent_test_capabilities_from_canonical_scope( + app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("VON_AGENT_TEST_INSTANCE", "1") + monkeypatch.setenv( + "VON_AGENT_TEST_TRUSTED_CAPABILITY_GRANTS", + "jira_update_issue,jira_get_issue", + ) + monkeypatch.setenv( + "VON_AGENT_TEST_TRUSTED_CAPABILITY_GRANT_USER", + "#V#michael_witbrock", + ) + monkeypatch.setenv( + "VON_AGENT_TEST_TRUSTED_CAPABILITY_GRANT_ORGANISATION", + "#V#sail_lab", + ) + monkeypatch.setenv( + "VON_AGENT_TEST_TRUSTED_CAPABILITY_GRANT_NAMESPACE", + "#V#michael_witbrock@sail_lab", + ) + + response = app.test_client().post( + "/von/generate", + json={ + "prompt": "Read the authorised Jira issue.", + "user_id": "#V#spoofed_user", + "org_id": "#V#spoofed_org", + }, + ) + + assert response.status_code == 200 + adaptive_call = app.config["_ADAPTIVE_TURN_CALLS"][-1] + assert adaptive_call["agent_test_trusted_capability_names"] == ( + "jira_get_issue", + "jira_update_issue", + ) + assert adaptive_call["user_concept_id"] == "#V#michael_witbrock" + assert adaptive_call["org_concept_id"] == "#V#sail_lab" + + def test_generate_passes_authorised_workflow_inputs_to_adaptive_capabilities( app: Flask, ) -> None: From f642c5925f73ba2f4db65538efd71f849eb66087 Mon Sep 17 00:00:00 2001 From: witbrock Date: Fri, 31 Jul 2026 19:41:51 +0100 Subject: [PATCH 02/16] JVNAUTOSCI-2614 remove noisy tool semantic index --- .../von_workflow_language_manual.md | 23 +- src/backend/server/utils_flask.py | 20 - src/backend/services/adaptive_turn_service.py | 77 +-- ...tered_tool_capability_retrieval_service.py | 470 ------------------ tests/backend/test_adaptive_turn_service.py | 103 +--- ...tered_tool_capability_retrieval_service.py | 209 -------- 6 files changed, 29 insertions(+), 873 deletions(-) delete mode 100644 src/backend/services/registered_tool_capability_retrieval_service.py delete mode 100644 tests/backend/test_registered_tool_capability_retrieval_service.py diff --git a/docs/engineering/von_workflow_language_manual.md b/docs/engineering/von_workflow_language_manual.md index b409a1782..816b20880 100644 --- a/docs/engineering/von_workflow_language_manual.md +++ b/docs/engineering/von_workflow_language_manual.md @@ -482,14 +482,6 @@ not a quality signal. Actor-visible registered tools declared by a matched workflow are exposed as plausible simpler component plans without claiming that one component is equivalent to the whole workflow. -Registered tools and represented workflows participate in semantic capability -retrieval before that frontier is paged for the model. Their physical indexes -remain partitioned by authority surface: registered interface metadata may use -a shared tool-capability index, while workflow retrieval remains actor-filtered. -Only capabilities already delegated to the current actor are unified into the -turn frontier. Retrieval similarity is evidence of possible adequacy, not an -invocation permission or a proof that two plans are equivalent. - Workflow component projection includes actor-visible MCP tools named by represented step actions and by represented LLM-step tool policy (`required_tools`, `conditional_required_tools`, and `allowed_tools`). Engine @@ -503,19 +495,10 @@ the cheaper path. Persisted workflow-capability manifests carry the routing projection producer schema; a schema change invalidates and rebuilds the namespace instead of silently serving the previous metadata shape. -Within each plan shape, semantic retrieval ranks plausible candidates before -the lower-cost direct and higher-cost workflow alternatives are interleaved. -This ensures that a semantically relevant direct path is present in the -model-visible page rather than being hidden behind weak lexical matches. The -model still decides whether a direct call or small composition can produce the -material work product and evidence. - Schema discovery remains an optional ordinary capability, not a compulsory -stage. When semantic retrieval surfaces it for represented-knowledge work, the -model may use it to retrieve predicates, types, inverse directions, or reified -relationship shapes before a relation-bearing read. A typed unavailable or -degraded retrieval index falls back to the bounded catalogue; it must not be -reported as authoritative evidence that no suitable capability exists. +stage. When the catalogue surfaces it for represented-knowledge work, the model +may use it to retrieve predicates, types, inverse directions, or reified +relationship shapes before a relation-bearing read. Direction-neutral relation capability hints are similarly local to the retrieved capability: when the exact represented predicate and argument direction are unknown, they advise incidence or relation reads across any diff --git a/src/backend/server/utils_flask.py b/src/backend/server/utils_flask.py index 930eed152..63f31397c 100644 --- a/src/backend/server/utils_flask.py +++ b/src/backend/server/utils_flask.py @@ -2458,26 +2458,6 @@ def _initialise_internal_mcp_gateway(app: Flask): app.logger.info( "[mcp_gateway] Enabled with %d registered methods.", method_count ) - if not _is_running_under_pytest(): - try: - from ..services.registered_tool_capability_retrieval_service import ( - prewarm_registered_tool_capability_index, - ) - - prewarm_started = prewarm_registered_tool_capability_index( - gateway_instance - ) - app.logger.info( - "[capability_retrieval] Registered-tool semantic index " - "prewarm started=%s.", - prewarm_started, - ) - except Exception as exc: - app.logger.warning( - "[capability_retrieval] Registered-tool semantic index " - "prewarm unavailable: %s", - exc, - ) else: app.logger.info( "[mcp_gateway] Initialised (disabled). Set VON_INTERNAL_MCP_ENABLE=1 to activate. Methods=%d", diff --git a/src/backend/services/adaptive_turn_service.py b/src/backend/services/adaptive_turn_service.py index f60a3ccf8..e57161b02 100644 --- a/src/backend/services/adaptive_turn_service.py +++ b/src/backend/services/adaptive_turn_service.py @@ -477,7 +477,6 @@ def _bounded_capability_catalogue_output( "dominated_total", "ranking", "selection_policy", - "capability_retrieval", "dominated_capabilities", "catalogue_scope", "offset", @@ -1676,11 +1675,6 @@ def _capability_catalogue( query_tokens = { token for token in re.findall(r"[a-z0-9_]+", query) if len(token) > 1 } - registered_tool_retrieval: dict[str, Any] = { - "schema_version": "registered_tool_capability_retrieval.v1", - "status": "not_requested", - "result_count": 0, - } registered_candidates: list[dict[str, Any]] = [] registered_by_name: dict[str, dict[str, Any]] = {} registered_delegated_total = 0 @@ -1813,7 +1807,6 @@ def _capability_catalogue( "_ranking_literal_match_count": literal_match_count, "_ranking_literal_phrase_match": literal_phrase_match, "_ranking_component_match_count": 0, - "_ranking_semantic_relevance": 0.0, "_ranking_routing_match_tokens": routing_match_tokens, "_ranking_description_literal_match_count": ( description_literal_match_count @@ -1895,54 +1888,6 @@ def _capability_catalogue( candidate_selection["adequacy_evidence"] = adequacy_evidence candidate["selection"] = candidate_selection - if query and not exact_names: - try: - from src.backend.services.registered_tool_capability_retrieval_service import ( - retrieve_registered_tool_capability_scores, - ) - - semantic_scores, registered_tool_retrieval = ( - retrieve_registered_tool_capability_scores( - query, - registered_candidates, - gateway=gateway, - ) - ) - except Exception as exc: # noqa: BLE001 - semantic_scores = {} - registered_tool_retrieval = { - "schema_version": "registered_tool_capability_retrieval.v1", - "status": "degraded", - "result_count": 0, - "error_type": type(exc).__name__, - "error": str(exc)[:500], - } - for candidate in registered_candidates: - name = str(candidate.get("name") or "").strip() - relevance_score = float(semantic_scores.get(name) or 0.0) - if relevance_score <= 0.0: - continue - candidate["query_match"] = True - candidate["_ranking_semantic_relevance"] = relevance_score - candidate_selection = dict(candidate.get("selection") or {}) - adequacy_evidence = list( - candidate_selection.get("adequacy_evidence") or [] - ) - adequacy_evidence.append( - { - "source": "registered_tool_capability_semantic_relevance", - "relevance_score": round(relevance_score, 4), - } - ) - candidate_selection.update( - { - "frontier_status": "candidate", - "adequacy_evidence": adequacy_evidence, - "semantic_adequacy_owner": "adaptive_model", - } - ) - candidate["selection"] = candidate_selection - workflow_candidates: list[dict[str, Any]] = [] represented_workflow_total = 0 for workflow_capability in workflow_capabilities: @@ -2197,7 +2142,6 @@ def direct_sort_key(item: Mapping[str, Any]) -> tuple[Any, ...]: -int(bool(item.get("_ranking_literal_phrase_match"))), -int(item.get("_ranking_component_match_count") or 0), -float(item.get("_ranking_component_workflow_relevance") or 0.0), - -float(item.get("_ranking_semantic_relevance") or 0.0), -int(item.get("_ranking_literal_match_count") or 0), str(item.get("name") or "").lower(), ) @@ -2300,28 +2244,11 @@ def workflow_sort_key(item: Mapping[str, Any]) -> tuple[Any, ...]: "evidence_adequacy" ), "frontier_order": ( - "semantically_rank_each_plan_shape_then_interleave_direct_" - "and_workflow_candidates_with_lower_orchestration_cost_first" + "interleave_direct_and_workflow_candidates_with_lower_" + "orchestration_cost_first" ), "representedness_priority": False, }, - "capability_retrieval": { - "schema_version": "turn_capability_retrieval.v1", - "registered_tools": dict(registered_tool_retrieval), - "represented_workflows": { - "status": ( - str(workflow_discovery.get("status") or "available") - if isinstance(workflow_discovery, Mapping) - else "not_requested" - ), - "result_count": len(workflow_candidates), - "source": "represented_workflow_capability_index", - }, - "authority_partitioning": ( - "retrieval_indexes_are_partitioned_by_authority_surface_and_" - "unified_only_after_actor_delegation" - ), - }, "dominated_capabilities": dominated_capabilities, "catalogue_scope": ( "requested_exact_names" diff --git a/src/backend/services/registered_tool_capability_retrieval_service.py b/src/backend/services/registered_tool_capability_retrieval_service.py deleted file mode 100644 index b0d64e410..000000000 --- a/src/backend/services/registered_tool_capability_retrieval_service.py +++ /dev/null @@ -1,470 +0,0 @@ -"""Semantic retrieval for registered ordinary-turn tool capabilities. - -The ordinary-turn selector already retrieves represented workflows through the -workflow capability index. Registered tools need an equivalent semantic -surface so both plan shapes can be ranked on the same model-visible frontier. - -This index contains only tool interface metadata (name, description, planner -hint, and declared evidence surface). Delegation remains authoritative in the -adaptive-turn gateway: retrieval results are always intersected with the -already delegated candidate set and can neither expose nor invoke an -undelegated capability. -""" - -from __future__ import annotations - -import hashlib -import json -import logging -import os -import tempfile -import threading -from collections.abc import Mapping, Sequence -from pathlib import Path -from typing import Any - -from src.backend.services.rag_service import build_rag_retrieval_state - -logger = logging.getLogger(__name__) - -REGISTERED_TOOL_CAPABILITY_NAMESPACE = "registered_tool_capabilities_v1" -REGISTERED_TOOL_CAPABILITY_RETRIEVAL_SCHEMA_VERSION = ( - "registered_tool_capability_retrieval.v1" -) -REGISTERED_TOOL_CAPABILITY_INDEX_SCHEMA_VERSION = "registered_tool_capability_index.v1" -_DOCUMENT_TYPE = "registered_tool_capability" -_MANIFEST_FILENAME = "registered_tool_capability_manifest.json" -_RETRIEVAL_LIMIT = 30 - -_index_lock = threading.Lock() -_indexed_digest: str | None = None -_runtime_state: dict[str, Any] = { - "schema_version": REGISTERED_TOOL_CAPABILITY_INDEX_SCHEMA_VERSION, - "status": "not_initialised", - "ready": False, -} -_prewarm_thread: threading.Thread | None = None - - -def _get_capability_rag_service() -> Any: - from src.backend.services.rag_service import get_rag_service - - service = get_rag_service("llamaindex") - if type(service).__name__ != "LlamaIndexRAGService": - raise RuntimeError( - "registered tool capability retrieval requires the llamaindex backend" - ) - return service - - -def _clean_text(value: Any) -> str: - return str(value or "").strip() - - -def _capability_document(candidate: Mapping[str, Any]) -> dict[str, Any] | None: - name = _clean_text(candidate.get("name")) - if not name: - return None - description = _clean_text(candidate.get("description")) - planner_hint = _clean_text(candidate.get("planner_hint")) - surface_family = _clean_text( - candidate.get("evidence_surface_family") or candidate.get("surface_family") - ) - # Put the short, planner-authored semantic description first and avoid - # repeated boilerplate shared by every document; repeated "direct - # capability" prose dilutes rather than improves embedding discrimination. - text_parts = [ - f"Capability: {name.replace('_', ' ')}.", - planner_hint, - description, - f"Evidence surface: {surface_family}." if surface_family else "", - ] - text = "\n".join(part for part in text_parts if part) - return { - "id": f"registered_tool_capability:{name}", - "text": text, - "metadata": { - "type": _DOCUMENT_TYPE, - "capability_name": name, - "kind": "registered_tool", - }, - } - - -def _normalised_documents( - candidates: Sequence[Mapping[str, Any]], -) -> list[dict[str, Any]]: - documents: dict[str, dict[str, Any]] = {} - for candidate in candidates: - document = _capability_document(candidate) - if document is None: - continue - documents[str(document["id"])] = document - return [documents[key] for key in sorted(documents)] - - -def _document_digest(documents: Sequence[Mapping[str, Any]]) -> str: - canonical = json.dumps( - list(documents), - ensure_ascii=True, - sort_keys=True, - separators=(",", ":"), - default=str, - ) - return hashlib.sha256(canonical.encode("utf-8")).hexdigest() - - -def _manifest_path(rag_service: Any) -> Path | None: - namespace_dir_builder = getattr(rag_service, "_namespace_persist_dir", None) - if not callable(namespace_dir_builder): - return None - try: - namespace_dir = Path( - str(namespace_dir_builder(REGISTERED_TOOL_CAPABILITY_NAMESPACE)) - ).resolve() - except Exception: # noqa: BLE001 - return None - return namespace_dir / _MANIFEST_FILENAME - - -def _read_manifest(rag_service: Any) -> dict[str, Any] | None: - path = _manifest_path(rag_service) - if path is None or not path.is_file(): - return None - try: - payload = json.loads(path.read_text(encoding="utf-8")) - except Exception: # noqa: BLE001 - return None - return dict(payload) if isinstance(payload, Mapping) else None - - -def _write_manifest( - rag_service: Any, - *, - digest: str, - document_count: int, -) -> None: - path = _manifest_path(rag_service) - if path is None: - return - path.parent.mkdir(parents=True, exist_ok=True) - payload = { - "schema_version": REGISTERED_TOOL_CAPABILITY_INDEX_SCHEMA_VERSION, - "namespace": REGISTERED_TOOL_CAPABILITY_NAMESPACE, - "digest": digest, - "document_count": int(document_count), - } - handle, temporary_path = tempfile.mkstemp( - prefix=f".{_MANIFEST_FILENAME}.", - suffix=".tmp", - dir=str(path.parent), - text=True, - ) - try: - with os.fdopen(handle, "w", encoding="utf-8") as stream: - json.dump(payload, stream, ensure_ascii=True, sort_keys=True) - stream.write("\n") - os.replace(temporary_path, path) - finally: - if os.path.exists(temporary_path): - os.unlink(temporary_path) - - -def _namespace_is_compatible(rag_service: Any) -> bool: - get_state = getattr(rag_service, "get_namespace_runtime_state", None) - if not callable(get_state): - return False - try: - state = get_state(REGISTERED_TOOL_CAPABILITY_NAMESPACE) - except Exception: # noqa: BLE001 - return False - return bool(isinstance(state, Mapping) and state.get("compatible") is True) - - -def _set_runtime_state(**values: Any) -> None: - global _runtime_state - _runtime_state = { - "schema_version": REGISTERED_TOOL_CAPABILITY_INDEX_SCHEMA_VERSION, - **values, - } - - -def ensure_registered_tool_capability_index( - candidates: Sequence[Mapping[str, Any]], -) -> dict[str, Any]: - """Ensure the persisted semantic index matches current tool metadata.""" - - global _indexed_digest - - documents = _normalised_documents(candidates) - digest = _document_digest(documents) - if _indexed_digest == digest: - return get_registered_tool_capability_index_state() - current_state = get_registered_tool_capability_index_state() - if ( - current_state.get("status") == "building" - and current_state.get("digest") == digest - ): - # Startup prewarming must not turn a first-turn retrieval into a wait - # for the embedding/index build. The caller receives a typed, - # non-authoritative unavailable state and can fall back to the bounded - # catalogue until the same build becomes ready. - return current_state - - with _index_lock: - if _indexed_digest == digest: - return get_registered_tool_capability_index_state() - _set_runtime_state( - status="building", - ready=False, - digest=digest, - document_count=len(documents), - ) - try: - rag_service = _get_capability_rag_service() - manifest = _read_manifest(rag_service) - if ( - isinstance(manifest, Mapping) - and manifest.get("digest") == digest - and _namespace_is_compatible(rag_service) - ): - _indexed_digest = digest - _set_runtime_state( - status="ready", - ready=True, - source="compatible_persisted_index", - digest=digest, - document_count=len(documents), - ) - return get_registered_tool_capability_index_state() - - rag_service.reset_namespace(REGISTERED_TOOL_CAPABILITY_NAMESPACE) - success_count, failure_count = rag_service.upsert_documents( - documents, - namespace=REGISTERED_TOOL_CAPABILITY_NAMESPACE, - allow_partial_failures=False, - ) - if failure_count or success_count != len(documents): - raise RuntimeError( - "registered tool capability retrieval sync failed " - f"(success={success_count}, failed={failure_count}, " - f"expected={len(documents)})" - ) - _write_manifest( - rag_service, - digest=digest, - document_count=len(documents), - ) - _indexed_digest = digest - _set_runtime_state( - status="ready", - ready=True, - source="rebuilt", - digest=digest, - document_count=len(documents), - ) - except Exception as exc: # noqa: BLE001 - logger.warning( - "registered_tool_capability_index_build_failed: %s", - exc, - ) - _set_runtime_state( - status="unavailable", - ready=False, - digest=digest, - document_count=len(documents), - error_type=type(exc).__name__, - error=str(exc)[:500], - ) - return get_registered_tool_capability_index_state() - - -def retrieve_registered_tool_capability_scores( - query: str, - candidates: Sequence[Mapping[str, Any]], - *, - max_results: int = _RETRIEVAL_LIMIT, - gateway: Any | None = None, -) -> tuple[dict[str, float], dict[str, Any]]: - """Return semantic scores only for the supplied delegated candidates.""" - - clean_query = _clean_text(query) - allowed_names = { - name - for candidate in candidates - for name in [_clean_text(candidate.get("name"))] - if name - } - if not clean_query or not allowed_names: - return {}, { - "schema_version": REGISTERED_TOOL_CAPABILITY_RETRIEVAL_SCHEMA_VERSION, - "status": "not_requested", - "result_count": 0, - } - - index_candidates = ( - _gateway_capability_candidates(gateway) if gateway is not None else candidates - ) - index_state = ensure_registered_tool_capability_index(index_candidates) - if index_state.get("ready") is not True: - return {}, { - "schema_version": REGISTERED_TOOL_CAPABILITY_RETRIEVAL_SCHEMA_VERSION, - "status": "unavailable", - "result_count": 0, - "index": index_state, - "retrieval_state": build_rag_retrieval_state( - "unavailable", - result_count=0, - cause="registered_tool_capability_index_unavailable", - ), - } - - requested_results = max(1, min(50, int(max_results))) - try: - rag_service = _get_capability_rag_service() - rag_results = rag_service.query( - query_text=clean_query, - top_k=requested_results, - namespace=REGISTERED_TOOL_CAPABILITY_NAMESPACE, - hybrid=True, - permissions_context={ - "type": _DOCUMENT_TYPE, - "retrieval_candidate_limit": max( - requested_results, - min(200, len(allowed_names)), - ), - }, - ) - retrieval_state = getattr(rag_results, "retrieval_state", None) - scores: dict[str, float] = {} - for result in rag_results: - if not isinstance(result, Mapping): - continue - metadata = result.get("metadata") - if not isinstance(metadata, Mapping): - continue - name = _clean_text(metadata.get("capability_name")) - if name not in allowed_names: - continue - try: - score = max(0.0, min(1.0, float(result.get("score") or 0.0))) - except (TypeError, ValueError): - continue - scores[name] = max(score, scores.get(name, 0.0)) - return scores, { - "schema_version": REGISTERED_TOOL_CAPABILITY_RETRIEVAL_SCHEMA_VERSION, - "status": "results_available" if scores else "valid_empty", - "result_count": len(scores), - "requested_result_count": requested_results, - "index": index_state, - **( - {"retrieval_state": dict(retrieval_state)} - if isinstance(retrieval_state, Mapping) - else {} - ), - } - except Exception as exc: # noqa: BLE001 - logger.warning("registered_tool_capability_retrieval_failed: %s", exc) - return {}, { - "schema_version": REGISTERED_TOOL_CAPABILITY_RETRIEVAL_SCHEMA_VERSION, - "status": "degraded", - "result_count": 0, - "index": index_state, - "error_type": type(exc).__name__, - "error": str(exc)[:500], - "retrieval_state": build_rag_retrieval_state( - "degraded", - result_count=0, - cause=f"query_failed:{type(exc).__name__}", - ), - } - - -def get_registered_tool_capability_index_state() -> dict[str, Any]: - return dict(_runtime_state) - - -def reset_registered_tool_capability_index_state() -> None: - """Reset process-local state; intended for tests and explicit rebuilds.""" - - global _indexed_digest, _prewarm_thread - with _index_lock: - _indexed_digest = None - _prewarm_thread = None - _set_runtime_state(status="not_initialised", ready=False) - - -def _gateway_capability_candidates(gateway: Any) -> list[dict[str, Any]]: - from src.backend.services.tool_metadata_service import ( - get_tool_description, - get_tool_dispatch_surface_metadata, - get_tool_planner_hint, - ) - - candidates: list[dict[str, Any]] = [] - describe_methods = getattr(gateway, "describe_methods", None) - described_methods = describe_methods() if callable(describe_methods) else {} - names = ( - sorted(str(name) for name in described_methods) - if isinstance(described_methods, Mapping) - else [] - ) - for name in names: - definition = gateway.get_method_definition(name) - if definition is None: - continue - fallback_description = ( - _clean_text(definition.description) - or _clean_text(getattr(definition.input_schema, "description", None)) - or f"Use {name}." - ) - description = ( - get_tool_description( - name, - fallback_description=fallback_description, - ) - or fallback_description - ) - surface = get_tool_dispatch_surface_metadata(name) - surface_family = _clean_text(getattr(surface, "surface_family", None)) - evidence_surface_family = _clean_text( - getattr(surface, "evidence_surface_family", None) - ) - candidates.append( - { - "name": name, - "description": description, - "planner_hint": get_tool_planner_hint(name), - "surface_family": surface_family, - "evidence_surface_family": evidence_surface_family, - } - ) - return candidates - - -def prewarm_registered_tool_capability_index(gateway: Any) -> bool: - """Start one best-effort background build after the gateway is available.""" - - global _prewarm_thread - with _index_lock: - if _prewarm_thread is not None and _prewarm_thread.is_alive(): - return False - - def _run() -> None: - try: - ensure_registered_tool_capability_index( - _gateway_capability_candidates(gateway) - ) - except Exception as exc: # noqa: BLE001 - logger.warning( - "registered_tool_capability_index_prewarm_failed: %s", - exc, - ) - - _prewarm_thread = threading.Thread( - target=_run, - name="registered_tool_capability_index_prewarm", - daemon=True, - ) - _prewarm_thread.start() - return True diff --git a/tests/backend/test_adaptive_turn_service.py b/tests/backend/test_adaptive_turn_service.py index 4fd341e55..0faeec122 100644 --- a/tests/backend/test_adaptive_turn_service.py +++ b/tests/backend/test_adaptive_turn_service.py @@ -69,28 +69,6 @@ def _acknowledge_effect_observation_journal( ) -@pytest.fixture(autouse=True) -def _disable_live_registered_tool_capability_rag( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Keep unit tests independent of the configured embedding backend.""" - - from src.backend.services import registered_tool_capability_retrieval_service - - monkeypatch.setattr( - registered_tool_capability_retrieval_service, - "retrieve_registered_tool_capability_scores", - lambda _query, _candidates, **_kwargs: ( - {}, - { - "schema_version": "registered_tool_capability_retrieval.v1", - "status": "valid_empty", - "result_count": 0, - }, - ), - ) - - class _SequenceClient: def __init__(self, *responses: Any) -> None: self.responses = list(responses) @@ -3332,13 +3310,10 @@ def test_capability_frontier_promotes_direct_components_without_hiding_workflow( assert result["dominated_total"] == 0 -def test_workflow_components_remain_visible_ahead_of_unrelated_tool_rag_hits( +def test_workflow_components_remain_visible_ahead_of_unrelated_tool_matches( monkeypatch, ) -> None: - from src.backend.services import ( - registered_tool_capability_retrieval_service, - tool_metadata_service, - ) + from src.backend.services import tool_metadata_service from src.backend.services.workflow_turn_capability_service import ( WorkflowTurnCapability, ) @@ -3379,18 +3354,6 @@ def test_workflow_components_remain_visible_ahead_of_unrelated_tool_rag_hits( "get_tool_dispatch_surface_metadata", lambda _name: None, ) - monkeypatch.setattr( - registered_tool_capability_retrieval_service, - "retrieve_registered_tool_capability_scores", - lambda _query, _candidates, **_kwargs: ( - {"workflow_list_instances": 0.9}, - { - "schema_version": "registered_tool_capability_retrieval.v1", - "status": "results_available", - "result_count": 1, - }, - ), - ) workflow = WorkflowTurnCapability( name="represented_workflow_entity_lookup", workflow_id="#V#entity_lookup_workflow", @@ -3431,30 +3394,34 @@ def test_workflow_components_remain_visible_ahead_of_unrelated_tool_rag_hits( ) -def test_semantic_tool_retrieval_puts_cheaper_schema_plan_in_visible_frontier( +def test_descriptions_put_relevant_direct_plan_in_visible_frontier( monkeypatch, ) -> None: - from src.backend.services import ( - registered_tool_capability_retrieval_service, - tool_metadata_service, - ) + from src.backend.services import tool_metadata_service from src.backend.services.workflow_turn_capability_service import ( WorkflowTurnCapability, ) catalogue = MethodCatalogue() - for name in ( - "generic_read", - "search_concepts", - "find_relations_with_argument", - ): + descriptions = { + "generic_read": "Inspect an unrelated operational record.", + "search_concepts": ( + "Search represented concept names, predicates, and types for " + "relationship schema discovery." + ), + "find_relations_with_argument": ( + "Find represented relationships, including people supervised by " + "a concept, where it appears as subject or target." + ), + } + for name, description in descriptions.items(): catalogue.register( MethodDefinition( name=name, handler=lambda **_kwargs: {"success": True}, input_schema=Schema(optional={"query": str}, allow_unknown=True), category="read", - description=f"Use {name}.", + description=description, ) ) gateway = InternalMCPGateway( @@ -3477,22 +3444,6 @@ def test_semantic_tool_retrieval_puts_cheaper_schema_plan_in_visible_frontier( "get_tool_dispatch_surface_metadata", lambda _name: None, ) - monkeypatch.setattr( - registered_tool_capability_retrieval_service, - "retrieve_registered_tool_capability_scores", - lambda _query, _candidates, **_kwargs: ( - { - "search_concepts": 0.97, - "find_relations_with_argument": 0.94, - "generic_read": 0.2, - }, - { - "schema_version": "registered_tool_capability_retrieval.v1", - "status": "results_available", - "result_count": 3, - }, - ), - ) workflow = WorkflowTurnCapability( name="represented_workflow_entity_lookup", workflow_id="#V#entity_lookup_workflow", @@ -3513,21 +3464,15 @@ def test_semantic_tool_retrieval_puts_cheaper_schema_plan_in_visible_frontier( workflow_capabilities=(workflow,), ) - assert [item["name"] for item in result["capabilities"]] == [ - "search_concepts", - "represented_workflow_entity_lookup", + capability_names = [item["name"] for item in result["capabilities"]] + assert capability_names[:2] == [ "find_relations_with_argument", - "generic_read", + "represented_workflow_entity_lookup", ] - assert result["capability_retrieval"]["registered_tools"]["status"] == ( - "results_available" - ) - search_candidate = result["capabilities"][0] - assert search_candidate["plan_profile"]["cost_profile"]["durable_runtime"] is False - assert any( - evidence["source"] == "registered_tool_capability_semantic_relevance" - for evidence in search_candidate["selection"]["adequacy_evidence"] - ) + assert set(capability_names[2:]) == {"search_concepts", "generic_read"} + direct_candidate = result["capabilities"][0] + assert direct_candidate["plan_profile"]["cost_profile"]["durable_runtime"] is False + assert direct_candidate["selection"]["semantic_adequacy_owner"] == "adaptive_model" def test_capability_frontier_does_not_prefer_unrelated_direct_tool( diff --git a/tests/backend/test_registered_tool_capability_retrieval_service.py b/tests/backend/test_registered_tool_capability_retrieval_service.py deleted file mode 100644 index 72875f51c..000000000 --- a/tests/backend/test_registered_tool_capability_retrieval_service.py +++ /dev/null @@ -1,209 +0,0 @@ -from __future__ import annotations - -import re -from pathlib import Path -from typing import Any - -import pytest - -from src.backend.services import registered_tool_capability_retrieval_service as service - - -class _FakeCapabilityRAG: - def __init__(self, root: Path) -> None: - self.root = root - self.documents: dict[str, dict[str, Any]] = {} - self.reset_count = 0 - self.query_calls: list[dict[str, Any]] = [] - - def _namespace_persist_dir(self, namespace: str) -> str: - return str(self.root / namespace) - - def get_namespace_runtime_state(self, namespace: str) -> dict[str, Any]: - return { - "namespace": namespace, - "compatible": bool(self.documents), - "status": "compatible" if self.documents else "missing_index", - } - - def reset_namespace(self, namespace: str) -> None: - self.reset_count += 1 - self.documents = {} - - def upsert_documents( - self, - docs, - *, - namespace: str, - allow_partial_failures: bool, - ) -> tuple[int, int]: - rows = list(docs) - self.documents = {str(row["id"]): dict(row) for row in rows} - return len(rows), 0 - - def query( - self, - query_text: str, - *, - top_k: int, - namespace: str, - hybrid: bool, - permissions_context: dict[str, Any], - ) -> list[dict[str, Any]]: - self.query_calls.append( - { - "query_text": query_text, - "top_k": top_k, - "namespace": namespace, - "hybrid": hybrid, - "permissions_context": dict(permissions_context), - } - ) - query_tokens = set(re.findall(r"[a-z0-9]+", query_text.lower())) - results: list[dict[str, Any]] = [] - for row in self.documents.values(): - text_tokens = set( - re.findall(r"[a-z0-9]+", str(row.get("text") or "").lower()) - ) - overlap = len(query_tokens & text_tokens) - if overlap <= 0: - continue - results.append( - { - "id": row["id"], - "metadata": dict(row.get("metadata") or {}), - "score": overlap / max(1, len(query_tokens)), - } - ) - results.sort( - key=lambda row: ( - -float(row["score"]), - str(row["metadata"].get("capability_name") or ""), - ) - ) - return results[:top_k] - - -@pytest.fixture(autouse=True) -def _reset_process_state() -> None: - service.reset_registered_tool_capability_index_state() - yield - service.reset_registered_tool_capability_index_state() - - -def _candidates() -> list[dict[str, Any]]: - return [ - { - "name": "search_concepts", - "description": "Discover represented predicates and types.", - "planner_hint": "Use for schema discovery before a relation lookup.", - "evidence_surface_family": "knowledge_base", - }, - { - "name": "find_relations_with_argument", - "description": "Find relationships for an anchor entity.", - "planner_hint": "Use for entity-relative relation-bearing evidence.", - "evidence_surface_family": "knowledge_base", - }, - { - "name": "unrelated_admin_read", - "description": "Inspect server configuration.", - "evidence_surface_family": "operations", - }, - ] - - -def test_retrieval_builds_once_and_returns_only_supplied_candidate_names( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - rag = _FakeCapabilityRAG(tmp_path) - monkeypatch.setattr(service, "_get_capability_rag_service", lambda: rag) - - scores, diagnostics = service.retrieve_registered_tool_capability_scores( - "discover schema predicates for a relationship", - _candidates()[:2], - ) - second_scores, second_diagnostics = ( - service.retrieve_registered_tool_capability_scores( - "discover schema predicates for a relationship", - _candidates()[:2], - ) - ) - - assert rag.reset_count == 1 - assert set(scores).issubset({"search_concepts", "find_relations_with_argument"}) - assert scores["search_concepts"] > scores.get( - "find_relations_with_argument", - 0.0, - ) - assert second_scores == scores - assert diagnostics["status"] == "results_available" - assert second_diagnostics["index"]["ready"] is True - assert rag.query_calls[0]["permissions_context"]["type"] == ( - "registered_tool_capability" - ) - - -def test_metadata_digest_change_rebuilds_the_index( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - rag = _FakeCapabilityRAG(tmp_path) - monkeypatch.setattr(service, "_get_capability_rag_service", lambda: rag) - - first = _candidates() - service.ensure_registered_tool_capability_index(first) - changed = [dict(candidate) for candidate in first] - changed[0]["planner_hint"] = "Changed represented schema guidance." - service.ensure_registered_tool_capability_index(changed) - - assert rag.reset_count == 2 - state = service.get_registered_tool_capability_index_state() - assert state["ready"] is True - assert state["source"] == "rebuilt" - - -def test_retrieval_unavailability_is_typed_and_non_authoritative( - monkeypatch: pytest.MonkeyPatch, -) -> None: - def fail_backend() -> Any: - raise RuntimeError("embedding backend unavailable") - - monkeypatch.setattr(service, "_get_capability_rag_service", fail_backend) - - scores, diagnostics = service.retrieve_registered_tool_capability_scores( - "represented relationship lookup", - _candidates(), - ) - - assert scores == {} - assert diagnostics["status"] == "unavailable" - assert diagnostics["retrieval_state"]["authoritative_empty"] is False - assert diagnostics["retrieval_state"]["cause"] == ( - "registered_tool_capability_index_unavailable" - ) - - -def test_matching_background_build_returns_without_starting_a_second_build( - monkeypatch: pytest.MonkeyPatch, -) -> None: - documents = service._normalised_documents(_candidates()) - digest = service._document_digest(documents) - service._set_runtime_state( - status="building", - ready=False, - digest=digest, - document_count=len(documents), - ) - monkeypatch.setattr( - service, - "_get_capability_rag_service", - lambda: pytest.fail("request path must not wait for or duplicate prewarm"), - ) - - state = service.ensure_registered_tool_capability_index(_candidates()) - - assert state["status"] == "building" - assert state["ready"] is False - assert state["digest"] == digest From a9a2ee58f7ea901654da4e95ec5a5759e840e7b6 Mon Sep 17 00:00:00 2001 From: witbrock Date: Fri, 31 Jul 2026 19:41:57 +0100 Subject: [PATCH 03/16] Batch outgoing relation preview reads --- .../services/concept_relation_service.py | 15 ++++ ...t_concept_relation_service_extent_index.py | 75 +++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/src/backend/services/concept_relation_service.py b/src/backend/services/concept_relation_service.py index 5c224b63f..419c7647f 100644 --- a/src/backend/services/concept_relation_service.py +++ b/src/backend/services/concept_relation_service.py @@ -394,6 +394,21 @@ def find_relations_with_argument( if include_asserted_rows and include_structural and subject_doc: relationships = (subject_doc.get("relationships") or {}) if subject_doc else {} + _prime_concept_preview_cache( + [ + resolved_concept_id, + *( + target + for predicate_id, raw_targets in relationships.items() + if include_arg1 + and _predicate_matches_terms(predicate_id, predicate_terms) + for target in _normalise_relationship_targets(raw_targets) + if isinstance(target, str) and target.startswith("#V#") + ), + ], + include_preview=include_concept_preview, + preview_cache=preview_cache, + ) source_updated_at = ( _isoformat(subject_doc.get("updated_at")) if subject_doc else None ) diff --git a/tests/backend/test_concept_relation_service_extent_index.py b/tests/backend/test_concept_relation_service_extent_index.py index 6e16c2959..bc6fedcb5 100644 --- a/tests/backend/test_concept_relation_service_extent_index.py +++ b/tests/backend/test_concept_relation_service_extent_index.py @@ -253,6 +253,18 @@ def test_relation_hits_include_source_previews_for_both_directions( "relationships": {"is_an_instance_of": ["#V#requested_type"]}, }, } + monkeypatch.setattr(service, "should_enforce_access_control", lambda: False) + monkeypatch.setattr( + service.ConceptsRepository, + "find", + staticmethod( + lambda query, _projection: [ + preview_docs[concept_id] + for concept_id in query["concept_id"]["$in"] + if concept_id in preview_docs + ] + ), + ) monkeypatch.setattr( service, "_load_accessible_preview_document", @@ -279,6 +291,69 @@ def test_relation_hits_include_source_previews_for_both_directions( ] == ["#V#requested_type"] +def test_outgoing_relation_previews_batch_uncached_targets(monkeypatch) -> None: + from src.backend.services import concept_relation_service as service + + source_id = "#V#source" + target_ids = ("#V#target_a", "#V#target_b") + preview_queries: list[tuple[dict[str, Any], dict[str, int]]] = [] + monkeypatch.setattr( + service, + "_load_accessible_relation_subject_document", + lambda *_args, **_kwargs: { + "concept_id": source_id, + "relationships": { + "#V#supervises": list(target_ids), + "#V#unrelated": ["#V#not_requested"], + }, + }, + ) + monkeypatch.setattr(service, "should_enforce_access_control", lambda: False) + + def fake_find(query, projection): + preview_queries.append((query, projection)) + return [ + { + "concept_id": concept_id, + "name": concept_id.removeprefix("#V#"), + "relationships": {}, + } + for concept_id in (source_id, *target_ids) + ] + + monkeypatch.setattr( + service.ConceptsRepository, + "find", + staticmethod(fake_find), + ) + monkeypatch.setattr( + service, + "_load_accessible_preview_document", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("batched target previews must avoid point reads") + ), + ) + + payload = service.find_relations_with_argument( + source_id, + argument_index="subject", + predicate_filter=["#V#supervises"], + relation_kind="binary", + include_concept_preview=True, + limit=10, + ) + + assert len(preview_queries) == 1 + assert preview_queries[0][0] == { + "concept_id": {"$in": [source_id, *target_ids]} + } + assert payload["total_hits"] == 2 + assert payload["paging"]["returned"] == 2 + assert { + hit["target_concept_preview"]["concept_id"] for hit in payload["hits"] + } == set(target_ids) + + def test_incoming_relation_previews_batch_uncached_sources(monkeypatch) -> None: from src.backend.services import concept_relation_service as service From f7d21b5de71381025802aca454127d84987d4a71 Mon Sep 17 00:00:00 2001 From: witbrock Date: Fri, 31 Jul 2026 20:16:08 +0100 Subject: [PATCH 04/16] JVNAUTOSCI-2614 align capability discovery guidance --- .../integrations/internal_mcp/catalogue.py | 7 ++-- src/backend/mcp_server/vontology_mcp.json | 4 +-- src/backend/services/tool_metadata_service.py | 26 +++++++++------ ...mation_retrieval_workflow_seed_bundle.json | 8 ++--- ...on_retrieval_workflow_vontology_service.py | 27 +++++++++++----- tests/backend/test_tool_metadata_service.py | 32 ++++++++++++++++--- 6 files changed, 75 insertions(+), 29 deletions(-) diff --git a/src/backend/integrations/internal_mcp/catalogue.py b/src/backend/integrations/internal_mcp/catalogue.py index 5a079baba..b01745d3a 100644 --- a/src/backend/integrations/internal_mcp/catalogue.py +++ b/src/backend/integrations/internal_mcp/catalogue.py @@ -9729,7 +9729,8 @@ def _find_relations_with_argument_input_schema() -> Schema: "predicate_filter (list of predicate IDs or substrings), relation_kind " "('any'|'binary'|'text'), scope (optional), include_text_snippets (bool), " "include_concept_preview (bool), paging (limit/offset), sort_by, " - "uncertainty retrieval controls (include_uncertain, uncertainty_mode, " + "uncertainty retrieval controls (include_uncertain, uncertainty_mode " + "'asserted_only'|'uncertain_only'|'include_uncertain', and " "uncertainty_statuses), and optional namespace passthrough. Do not use " "top-level payload keys named subject or object; the anchor entity always " "goes in concept_id." @@ -9807,7 +9808,9 @@ def _predicate_incidence_input_schema() -> Schema: "relation_kind ('any'|'binary'|'text'), include_text_snippets, " "include_concept_preview, paging (limit/offset), sort_by " "('relation_hit_count'|'grounding_count'|'grounded_instance_count'|'predicate'), " - "uncertainty retrieval controls, include_argument_type_counts for direct asserted " + "uncertainty retrieval controls (include_uncertain, uncertainty_mode " + "'asserted_only'|'uncertain_only'|'include_uncertain', and " + "uncertainty_statuses), include_argument_type_counts for direct asserted " "type counts of non-anchor arguments, type_count_mode='direct_asserted', " "include_untyped_bucket, max_types_per_predicate, max_sample_concepts_per_type, " "and role_expansion_mode ('none'|'explicit'|'auto') with optional explicit " diff --git a/src/backend/mcp_server/vontology_mcp.json b/src/backend/mcp_server/vontology_mcp.json index e83ea5f2b..a27557a37 100644 --- a/src/backend/mcp_server/vontology_mcp.json +++ b/src/backend/mcp_server/vontology_mcp.json @@ -2210,7 +2210,7 @@ "concept_id" ], "additionalProperties": true, - "description": "find_relations_with_argument input: concept_id (required anchor entity concept ID, for example '#V#michael_witbrock'), argument_index (int or 'any'; use 'subject' or 1 to inspect subject-side relations), predicate_filter (list of predicate IDs or substrings), relation_kind ('any'|'binary'|'text'), scope (optional), include_text_snippets (bool), include_concept_preview (bool), paging (limit/offset), sort_by, uncertainty retrieval controls (include_uncertain, uncertainty_mode, uncertainty_statuses), and optional namespace passthrough. Do not use top-level payload keys named subject or object; the anchor entity always goes in concept_id." + "description": "find_relations_with_argument input: concept_id (required anchor entity concept ID, for example '#V#michael_witbrock'), argument_index (int or 'any'; use 'subject' or 1 to inspect subject-side relations), predicate_filter (list of predicate IDs or substrings), relation_kind ('any'|'binary'|'text'), scope (optional), include_text_snippets (bool), include_concept_preview (bool), paging (limit/offset), sort_by, uncertainty retrieval controls (include_uncertain, uncertainty_mode 'asserted_only'|'uncertain_only'|'include_uncertain', and uncertainty_statuses), and optional namespace passthrough. Do not use top-level payload keys named subject or object; the anchor entity always goes in concept_id." } }, { @@ -2482,7 +2482,7 @@ }, "required": [], "additionalProperties": true, - "description": "get_predicate_incidence input: exactly one of concept_id (entity mode; anchor entity concept ID such as '#V#michael_witbrock') or instance_of (type mode). Optional direct_instances_only, argument_index ('subject' inspects outgoing subject-side relations), predicate_filter, relation_kind ('any'|'binary'|'text'), include_text_snippets, include_concept_preview, paging (limit/offset), sort_by ('relation_hit_count'|'grounding_count'|'grounded_instance_count'|'predicate'), uncertainty retrieval controls, include_argument_type_counts for direct asserted type counts of non-anchor arguments, type_count_mode='direct_asserted', include_untyped_bucket, max_types_per_predicate, max_sample_concepts_per_type, and role_expansion_mode ('none'|'explicit'|'auto') with optional explicit role_node_type_filter, role_predicate_filter, and role_expansion_depth=1. Auto role expansion is metadata-gated and reports diagnostics when no authoritative reification profile is available. Optional namespace passthrough. Do not use top-level payload keys named subject or object; the anchor entity always goes in concept_id.", + "description": "get_predicate_incidence input: exactly one of concept_id (entity mode; anchor entity concept ID such as '#V#michael_witbrock') or instance_of (type mode). Optional direct_instances_only, argument_index ('subject' inspects outgoing subject-side relations), predicate_filter, relation_kind ('any'|'binary'|'text'), include_text_snippets, include_concept_preview, paging (limit/offset), sort_by ('relation_hit_count'|'grounding_count'|'grounded_instance_count'|'predicate'), uncertainty retrieval controls (include_uncertain, uncertainty_mode 'asserted_only'|'uncertain_only'|'include_uncertain', and uncertainty_statuses), include_argument_type_counts for direct asserted type counts of non-anchor arguments, type_count_mode='direct_asserted', include_untyped_bucket, max_types_per_predicate, max_sample_concepts_per_type, and role_expansion_mode ('none'|'explicit'|'auto') with optional explicit role_node_type_filter, role_predicate_filter, and role_expansion_depth=1. Auto role expansion is metadata-gated and reports diagnostics when no authoritative reification profile is available. Optional namespace passthrough. Do not use top-level payload keys named subject or object; the anchor entity always goes in concept_id.", "x-von-argument-aliases": { "target_type": "instance_of", "target_type_id": "instance_of", diff --git a/src/backend/services/tool_metadata_service.py b/src/backend/services/tool_metadata_service.py index cbcf063f7..ada445889 100644 --- a/src/backend/services/tool_metadata_service.py +++ b/src/backend/services/tool_metadata_service.py @@ -683,7 +683,10 @@ class ToolRequiredObligationMetadata: "target_concept_max_count": 5, "planner_hint": ( "Use when you already know the concept ID and need grounded represented " - "facts, predicates, or relationships for that specific concept." + "facts for that specific concept. For relationship exploration, prefer " + "a small predicate-filtered relation read; if relation enrichment is " + "needed here, start with a small page without inline previews and hydrate " + "only selected related concepts." ), }, "find_relations_with_argument": { @@ -699,7 +702,7 @@ class ToolRequiredObligationMetadata: "target_concept_argument_name": "concept_id", "target_concept_source": "focal_concept", "target_concept_max_count": 2, - "default_payload": {"limit": 20}, + "default_payload": {"include_concept_preview": False, "limit": 20}, "planner_hint": ( "Use for entity-relative relationship lookup after resolving the anchor " "concept. This is relation-bearing evidence, not mere inventory. Choose " @@ -708,9 +711,12 @@ class ToolRequiredObligationMetadata: "when the user asks about text content. When the represented predicate " "is unknown, retrieve schema candidates with vontology_concept_search or " "inspect predicate incidence, then pass the returned exact predicate IDs " - "in predicate_filter; do not start with a broad unfiltered relation page. " - "Keep argument_index='any' when direction is unknown. A one-direction or " - "lexical-predicate probe cannot support a complete negative result." + "in predicate_filter. Begin with minimally enriched, predicate-filtered " + "evidence unless broad inventory is itself requested. " + "For an initial or broad page, keep the limit small, omit inline previews, " + "and hydrate only selected concept IDs. Keep argument_index='any' when " + "direction is materially unknown. A one-direction or lexical-predicate " + "probe cannot support a complete negative result." ), }, "find_concepts_by_name": { @@ -778,16 +784,18 @@ class ToolRequiredObligationMetadata: "default_payload": { "argument_index": "subject", "relation_kind": "binary", - "include_argument_type_counts": True, + "include_argument_type_counts": False, "include_concept_preview": False, "limit": 12, }, "planner_hint": ( "Use before a filtered entity-relative lookup when the represented " "predicate, inverse form, or argument direction is not established. " - "Set argument_index='any' to inspect both directions, then use the " - "returned exact predicate IDs and type counts for the relation read. " - "Do not infer a complete negative from a one-direction incidence probe." + "Start with a small binary page without previews, snippets, or argument " + "type counts. Inspect both directions only when direction is materially " + "unknown, then use returned exact predicate IDs for the relation read. " + "Request type counts only when they contribute to the requested work " + "product. Do not infer a complete negative from a one-direction probe." ), }, "get_concept_usage_profile": { diff --git a/src/backend/workflows/repo_seed_bundles/entity_information_retrieval_workflow_seed_bundle.json b/src/backend/workflows/repo_seed_bundles/entity_information_retrieval_workflow_seed_bundle.json index 47928937d..cd3338f1d 100644 --- a/src/backend/workflows/repo_seed_bundles/entity_information_retrieval_workflow_seed_bundle.json +++ b/src/backend/workflows/repo_seed_bundles/entity_information_retrieval_workflow_seed_bundle.json @@ -2,7 +2,7 @@ "family_id": "entity_information_retrieval_workflow_seed_bundle", "managed_by": "entity_information_retrieval_workflow_vontology_service", "schema_version": "repo_seed_workflow_bundle.v1", - "seed_version": "12", + "seed_version": "13", "source_tag": "JVNAUTOSCI-2075", "supported_action_ids": [ "llm.action" @@ -11,8 +11,8 @@ { "workflow_id": "#V#entity_information_retrieval_workflow", "display_name": "Entity Information Retrieval Workflow", - "description": "Canonical reusable workflow for grounded retrieval of information of a requested kind about a resolved entity, especially authenticated self-relative questions about papers, affiliations, roles, and predicate-filtered extents.", - "content": "Resolve the target entity from authenticated or explicit context, identify the requested information kind, prefer predicate-incidence and relation retrieval before broad inventory or lexical search, filter retrieved results by the requested type or extent, and answer directly from grounded represented evidence.", + "description": "Canonical reusable workflow for a broad, multi-source entity profile or an explicit comparison of asserted facts with uncertain or proposed relationships. It reconciles focal-concept facts, text, predicate incidence, grounded relations, and uncertainty-bearing assertions.", + "content": "Resolve the target entity from authenticated or explicit context and use this workflow when the requested work product needs a multi-faceted profile, cross-checking across distinct evidence surfaces, or an explicit separation of asserted facts from uncertain or proposed relationships. For a single bounded fact or relation extent, prefer an adequate direct read or small composition. Retrieve and reconcile the workflow's required evidence, then answer directly from grounded represented evidence and preserve any material gap.", "type_ids": [ "#V#ai_workflow", "#V#durable_workflow" @@ -40,7 +40,7 @@ }, { "predicate": "#V#hasWorkflowDiscoveryExemplarsJson", - "text": "{\"schema_version\":\"workflow_discovery_exemplars.v1\",\"keywords\":[\"entity information retrieval workflow\",\"information about a resolved entity\",\"authenticated self relation lookup\",\"predicate extent lookup\",\"represented relationship lookup\",\"people related to an entity\",\"entities connected by a relation\",\"students supervised by an entity\",\"PhD students supervised by me\",\"represented affiliation role membership and supervision lookup\",\"who am i and list my papers\",\"papers of mine\"],\"examples\":[\"Tell me who I am and list my papers.\",\"What papers of mine do you know about?\",\"Can you list PhD students supervised by me?\",\"Which people do I supervise in the represented knowledge?\",\"Which roles are associated with this person?\",\"Which organisation is Michael Witbrock affiliated with in the represented knowledge?\",\"Which projects are linked to this researcher?\",\"Who is related to this person by the requested predicate?\",\"¿Qué organizaciones están relacionadas con esta persona en el conocimiento representado?\"]}", + "text": "{\"schema_version\":\"workflow_discovery_exemplars.v1\",\"keywords\":[\"entity information retrieval workflow\",\"broad multi-source entity profile\",\"bounded entity dossier\",\"multi-faceted represented self profile\",\"reconcile concept text and relationship evidence\",\"cross-check asserted and uncertain relationships\",\"separate represented facts from likely inferences\"],\"examples\":[\"Build a bounded multilingual concept dossier for #V#person from its names, descriptions and incoming/outgoing relationships. Keep it read-only and summarise the evidence.\",\"Give me a multi-faceted profile of this researcher, including descriptions, affiliations, works, and uncertainty-bearing assertions.\",\"Separate established represented facts about this person from uncertain or proposed relationships and cite the evidence.\",\"Tell me who I am, list my papers and affiliations, and distinguish asserted facts from uncertain relationships.\"]}", "lang": "en-NZ" }, { diff --git a/tests/backend/test_entity_information_retrieval_workflow_vontology_service.py b/tests/backend/test_entity_information_retrieval_workflow_vontology_service.py index c18dd879d..171edc02f 100644 --- a/tests/backend/test_entity_information_retrieval_workflow_vontology_service.py +++ b/tests/backend/test_entity_information_retrieval_workflow_vontology_service.py @@ -59,6 +59,18 @@ def test_bootstrap_materialises_entity_information_retrieval_workflow( ENTITY_INFORMATION_RETRIEVAL_WORKFLOW_ID ) assert definition is not None + assert "multi-faceted profile" in str(definition.purpose) + assert "For a single bounded fact or relation extent" in str(definition.purpose) + assert "smallest evidence path" not in str(definition.purpose) + description_rows = get_texts_for_concept( + ENTITY_INFORMATION_RETRIEVAL_WORKFLOW_ID, + predicate="hasDescription", + limit=5, + ) + assert any( + "broad, multi-source entity profile" in str((row or {}).get("text") or "") + for row in description_rows + ) routing_profile, routing_source = resolve_workflow_routing_profile( ENTITY_INFORMATION_RETRIEVAL_WORKFLOW_ID @@ -74,14 +86,13 @@ def test_bootstrap_materialises_entity_information_retrieval_workflow( assert discovery_source.startswith("text_relation:") discovery_keywords = discovery_exemplars.get("keywords") or [] discovery_examples = discovery_exemplars.get("examples") or [] - assert "who am i and list my papers" in discovery_keywords - assert "people related to an entity" in discovery_keywords - assert ( - "Which people do I supervise in the represented knowledge?" - in discovery_examples - ) - assert "Can you list PhD students supervised by me?" in discovery_examples - assert "Which projects are linked to this researcher?" in discovery_examples + assert "broad multi-source entity profile" in discovery_keywords + assert "bounded entity dossier" in discovery_keywords + assert "cross-check asserted and uncertain relationships" in discovery_keywords + assert any("multilingual concept dossier" in item for item in discovery_examples) + assert any("multi-faceted profile" in item for item in discovery_examples) + assert all("PhD students" not in item for item in discovery_examples) + assert all("Which people do I supervise" not in item for item in discovery_examples) launch_contract, launch_source = resolve_workflow_launch_input_contract( ENTITY_INFORMATION_RETRIEVAL_WORKFLOW_ID diff --git a/tests/backend/test_tool_metadata_service.py b/tests/backend/test_tool_metadata_service.py index dc3d84204..80bf42d90 100644 --- a/tests/backend/test_tool_metadata_service.py +++ b/tests/backend/test_tool_metadata_service.py @@ -238,7 +238,7 @@ def test_vontology_concept_search_alias_inherits_schema_discovery_metadata( service.invalidate_cache() -def test_relation_read_hints_preserve_direction_neutral_completeness( +def test_relation_read_hints_start_bounded_without_losing_negative_completeness( monkeypatch, ): from src.backend.services import tool_metadata_service as service @@ -248,17 +248,41 @@ def test_relation_read_hints_preserve_direction_neutral_completeness( try: incidence = service.get_tool_metadata("get_predicate_incidence") relation_read = service.get_tool_metadata("find_relations_with_argument") - - assert "argument_index='any'" in (incidence.planner_hint or "") + concept_fetch = service.get_tool_metadata("fetch_concept") + + assert incidence.default_payload == { + "argument_index": "subject", + "relation_kind": "binary", + "include_argument_type_counts": False, + "include_concept_preview": False, + "limit": 12, + } + assert "without previews, snippets, or argument type counts" in ( + incidence.planner_hint or "" + ) + assert "both directions only when direction is materially unknown" in ( + incidence.planner_hint or "" + ) assert "exact predicate IDs" in (incidence.planner_hint or "") assert "one-direction" in (incidence.planner_hint or "") + assert relation_read.default_payload == { + "include_concept_preview": False, + "limit": 20, + } assert "argument_index='any'" in (relation_read.planner_hint or "") + assert "omit inline previews" in (relation_read.planner_hint or "") assert "complete negative" in (relation_read.planner_hint or "") assert "relation_kind='binary'" in (relation_read.planner_hint or "") assert "vontology_concept_search" in (relation_read.planner_hint or "") - assert "broad unfiltered relation page" in ( + assert "unless broad inventory is itself requested" in ( relation_read.planner_hint or "" ) + assert "small predicate-filtered relation read" in ( + concept_fetch.planner_hint or "" + ) + assert "hydrate only selected related concepts" in ( + concept_fetch.planner_hint or "" + ) assert "PhD" not in (incidence.planner_hint or "") assert "student" not in (relation_read.planner_hint or "").lower() finally: From 3d0a9ff4a73a183dbd929725c0d45c89329e6ad1 Mon Sep 17 00:00:00 2001 From: witbrock Date: Fri, 31 Jul 2026 20:49:36 +0100 Subject: [PATCH 05/16] Avoid repeated broad relation materialisation --- .../services/concept_relation_service.py | 135 ++++++++-------- ...t_concept_relation_service_extent_index.py | 145 ++++++++++++++++++ 2 files changed, 210 insertions(+), 70 deletions(-) diff --git a/src/backend/services/concept_relation_service.py b/src/backend/services/concept_relation_service.py index 419c7647f..acf8f0c3b 100644 --- a/src/backend/services/concept_relation_service.py +++ b/src/backend/services/concept_relation_service.py @@ -310,6 +310,7 @@ def find_relations_with_argument( uncertainty_mode: Optional[str] = None, uncertainty_statuses: Optional[Sequence[str]] = None, context_view: RelationContextView = "base_publication", + _materialise_all_hits: bool = False, ) -> Dict[str, Any]: """Find relation hits where ``concept_id`` appears in any argument position. @@ -345,8 +346,8 @@ def find_relations_with_argument( "predicate_filter": list(predicate_display_terms), "relation_kind": relation_kind, "resolved_relation_kind": relation_filter, - "limit": resolved_limit_value, - "offset": resolved_offset, + "limit": None if _materialise_all_hits else resolved_limit_value, + "offset": 0 if _materialise_all_hits else resolved_offset, "sort_by": sort_by, "include_text_snippets": bool(include_text_snippets), "include_concept_preview": bool(include_concept_preview), @@ -384,6 +385,7 @@ def find_relations_with_argument( preview_cache: Dict[str, Optional[Dict[str, Any]]] = {} hits: List[Dict[str, Any]] = [] + incoming_asserted_binary_hits: List[Dict[str, Any]] = [] scoped_concept_query_truncated = False actor_effective_text_query_truncated = False @@ -475,7 +477,7 @@ def find_relations_with_argument( index_rows: List[Dict[str, Any]] = [] index_total = -1 bounded_index_diagnostics: Dict[str, Any] = {} - if not predicate_terms: + if not predicate_terms and not _materialise_all_hits: bounded_rows, index_used, bounded_index_diagnostics = ( incoming_dynamic_extent_rows_page_for_target( resolved_concept_id, @@ -720,14 +722,6 @@ def find_relations_with_argument( ) ) - _prime_concept_preview_cache( - [ - resolved_concept_id, - *(source_id for source_id, _, _, _ in incoming_candidates), - ], - include_preview=include_concept_preview, - preview_cache=preview_cache, - ) for ( source_id, predicate_id, @@ -739,33 +733,19 @@ def find_relations_with_argument( matched_indexes = [_ARG_INDEX_FIRST_OBJECT + target_index] if not _argument_indexes_match(matched_indexes, argument_filter): continue - source_preview = _resolve_concept_preview( - source_id, - include_concept_preview, - preview_cache, - ) hit = { "source_concept_id": source_id, "predicate_concept_id": predicate_id, "relation_kind": "binary", "argument_indexes": matched_indexes, "target_value": resolved_concept_id, - "target_concept_preview": ( - _resolve_concept_preview( - resolved_concept_id, - True, - preview_cache, - ) - if include_concept_preview - else None - ), + "target_concept_preview": None, "relation_metadata": { "relation_id": f"struct::{source_id}::{predicate_id}::incoming::{target_index}", "updated_at": _isoformat(source_updated_at), "match_type": "exact", }, - "access_granted": source_preview is not None - or not include_concept_preview, + "access_granted": not include_concept_preview, "follow_up_actions": _build_follow_up_actions( [source_id], exclude={resolved_concept_id}, @@ -774,9 +754,8 @@ def find_relations_with_argument( "is_asserted": True, "relation_state": "asserted", } - if include_concept_preview: - hit["source_concept_preview"] = source_preview hits.append(hit) + incoming_asserted_binary_hits.append(hit) if ( context_view == "actor_effective" @@ -1046,7 +1025,42 @@ def find_relations_with_argument( deduped_hits = _dedupe_argument_hits(hits) sorted_hits = _sort_argument_hits(deduped_hits, sort_by) - paged_hits = sorted_hits[resolved_offset : resolved_offset + resolved_limit_value] + paged_hits = ( + sorted_hits + if _materialise_all_hits + else sorted_hits[resolved_offset : resolved_offset + resolved_limit_value] + ) + if include_concept_preview: + paged_hit_ids = {id(hit) for hit in paged_hits} + paged_incoming_hits = [ + hit for hit in incoming_asserted_binary_hits if id(hit) in paged_hit_ids + ] + if paged_incoming_hits: + _prime_concept_preview_cache( + [ + resolved_concept_id, + *( + str(hit.get("source_concept_id") or "") + for hit in paged_incoming_hits + ), + ], + include_preview=True, + preview_cache=preview_cache, + ) + target_preview = _resolve_concept_preview( + resolved_concept_id, + True, + preview_cache, + ) + for hit in paged_incoming_hits: + source_preview = _resolve_concept_preview( + str(hit.get("source_concept_id") or ""), + True, + preview_cache, + ) + hit["source_concept_preview"] = source_preview + hit["target_concept_preview"] = target_preview + hit["access_granted"] = source_preview is not None counts_are_lower_bounds = bool( scoped_concept_query_truncated or actor_effective_text_query_truncated @@ -1066,8 +1080,8 @@ def find_relations_with_argument( ), "hits": paged_hits, "paging": { - "limit": resolved_limit_value, - "offset": resolved_offset, + "limit": None if _materialise_all_hits else resolved_limit_value, + "offset": 0 if _materialise_all_hits else resolved_offset, "returned": len(paged_hits), "total_available": len(sorted_hits), **( @@ -1412,7 +1426,6 @@ def _collect_all_argument_hits_for_incidence( uncertainty_mode: Optional[str], uncertainty_statuses: Optional[Sequence[str]], ) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: - batch_size = _MAX_LIMIT all_hits: List[Dict[str, Any]] = [] diagnostics: Dict[str, Any] = { "mode": ( @@ -1453,45 +1466,27 @@ def _collect_all_argument_hits_for_incidence( relation_queries.append((relation_filter, argument_index)) for query_relation_kind, query_argument_index in relation_queries: - offset = 0 - while True: - payload = find_relations_with_argument( - concept_id=concept_id, - argument_index=query_argument_index, - predicate_filter=predicate_filter, - relation_kind=query_relation_kind, - include_text_snippets=include_text_snippets, - # Incidence aggregation only exposes bounded sample groundings, so - # resolve previews there instead of for every raw relation hit. - include_concept_preview=False, - limit=batch_size, - offset=offset, - include_uncertain=include_uncertain, - uncertainty_mode=uncertainty_mode, - uncertainty_statuses=uncertainty_statuses, - ) - if isinstance(payload.get("uncertainty_diagnostics"), dict): - diagnostics = dict(payload["uncertainty_diagnostics"]) - batch_hits = payload.get("hits") - if not isinstance(batch_hits, list) or not batch_hits: - break - materialised_hits = [ + payload = find_relations_with_argument( + concept_id=concept_id, + argument_index=query_argument_index, + predicate_filter=predicate_filter, + relation_kind=query_relation_kind, + include_text_snippets=include_text_snippets, + # Incidence aggregation only exposes bounded sample groundings, so + # resolve previews there instead of for every raw relation hit. + include_concept_preview=False, + include_uncertain=include_uncertain, + uncertainty_mode=uncertainty_mode, + uncertainty_statuses=uncertainty_statuses, + _materialise_all_hits=True, + ) + if isinstance(payload.get("uncertainty_diagnostics"), dict): + diagnostics = dict(payload["uncertainty_diagnostics"]) + batch_hits = payload.get("hits") + if isinstance(batch_hits, list): + all_hits.extend( dict(hit) for hit in batch_hits if isinstance(hit, Mapping) - ] - all_hits.extend(materialised_hits) - paging = payload.get("paging") - total_available_value = ( - paging.get("total_available") if isinstance(paging, Mapping) else None - ) - total_available = ( - int(total_available_value) - if isinstance(total_available_value, (int, float)) - else len(all_hits) ) - returned = len(materialised_hits) - offset += returned - if returned <= 0 or offset >= total_available: - break return all_hits, diagnostics diff --git a/tests/backend/test_concept_relation_service_extent_index.py b/tests/backend/test_concept_relation_service_extent_index.py index bc6fedcb5..826234d52 100644 --- a/tests/backend/test_concept_relation_service_extent_index.py +++ b/tests/backend/test_concept_relation_service_extent_index.py @@ -440,6 +440,94 @@ def fake_find(query, projection): } == {"#V#source_a", "#V#source_b"} +def test_incoming_relation_previews_only_hydrate_the_returned_page(monkeypatch) -> None: + from src.backend.services import concept_relation_service as service + + target_id = "#V#target" + source_ids = ("#V#source_a", "#V#source_b", "#V#source_c") + preview_queries: list[tuple[dict[str, Any], dict[str, int]]] = [] + monkeypatch.setattr( + service, + "_load_accessible_relation_subject_document", + lambda *_args, **_kwargs: None, + ) + monkeypatch.setattr( + service, + "incoming_dynamic_extent_rows_page_for_target", + lambda *_args, **_kwargs: ( + [ + { + "source_concept_id": source_id, + "predicate_id": "#V#relation", + "target_value": target_id, + "arg2_index": 2, + "updated_at": None, + } + for source_id in source_ids + ], + True, + { + "used_extent_index": True, + "complete": True, + "bounded": False, + "has_more": False, + "index_rows_scanned": 3, + "rows_filtered_by_access": 0, + }, + ), + ) + monkeypatch.setattr(service, "should_enforce_access_control", lambda: False) + + def fake_find(query, projection): + preview_queries.append((query, projection)) + return [ + { + "concept_id": concept_id, + "name": concept_id.removeprefix("#V#"), + "relationships": {}, + } + for concept_id in query["concept_id"]["$in"] + ] + + monkeypatch.setattr( + service.ConceptsRepository, "find", staticmethod(fake_find) + ) + monkeypatch.setattr( + service, + "_load_accessible_preview_document", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("paged previews must avoid point reads") + ), + ) + + payload = service.find_relations_with_argument( + target_id, + relation_kind="binary", + include_concept_preview=True, + sort_by="predicate", + offset=1, + limit=1, + ) + + assert payload["total_hits"] == 3 + assert payload["paging"] == { + "limit": 1, + "offset": 1, + "returned": 1, + "total_available": 3, + } + assert len(preview_queries) == 1 + assert preview_queries[0][0] == { + "concept_id": {"$in": [target_id, "#V#source_b"]} + } + assert payload["hits"][0]["source_concept_id"] == "#V#source_b" + assert payload["hits"][0]["source_concept_preview"]["concept_id"] == ( + "#V#source_b" + ) + assert payload["hits"][0]["target_concept_preview"]["concept_id"] == target_id + assert payload["hits"][0]["access_granted"] is True + + def test_incoming_asserted_binary_falls_back_only_when_index_is_unavailable( monkeypatch, ) -> None: @@ -753,3 +841,60 @@ def test_unfiltered_extent_timeout_does_not_start_canonical_full_scan( assert diagnostics["used_relationship_extent_index"] is False assert diagnostics["bounded"] is True assert diagnostics["complete"] is False + + +def test_predicate_incidence_materialises_incoming_relations_once( + monkeypatch, +) -> None: + from src.backend.services import concept_relation_service as service + + target_id = "#V#target" + aggregate_calls: list[list[dict[str, Any]]] = [] + incoming_rows = [ + { + "concept_id": f"#V#source_{index}", + "predicate": "#V#related_to", + "targets": [target_id], + "updated_at": None, + } + for index in range(1_205) + ] + + monkeypatch.setattr( + service, + "_load_accessible_relation_subject_document", + lambda *_args, **_kwargs: None, + ) + monkeypatch.setattr( + service, + "incoming_dynamic_extent_rows_page_for_target", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("complete incidence must not use a bounded relation page") + ), + ) + monkeypatch.setattr( + service, + "query_relationship_extent_index", + lambda **_kwargs: ([], -1), + ) + monkeypatch.setattr( + service.ConceptsRepository, + "aggregate", + staticmethod( + lambda pipeline: aggregate_calls.append(pipeline) or incoming_rows + ), + ) + + payload = service.get_predicate_incidence( + concept_id=target_id, + argument_index="object", + relation_kind="binary", + include_concept_preview=False, + limit=20, + ) + + assert len(aggregate_calls) == 1 + assert payload["total_predicates"] == 1 + assert payload["predicates"][0]["predicate_concept_id"] == "#V#related_to" + assert payload["predicates"][0]["relation_hit_count"] == 1_205 + assert payload["predicates"][0]["object_argument_hit_count"] == 1_205 From 9579c39da7e8ab7a183d2eafcdc367c1aaf23fa1 Mon Sep 17 00:00:00 2001 From: witbrock Date: Fri, 31 Jul 2026 20:49:48 +0100 Subject: [PATCH 06/16] Refine bounded capability discovery prompts --- src/backend/mcp_server/vontology_mcp.json | 4 ++-- src/backend/services/tool_metadata_service.py | 16 +++++++++++----- tests/backend/test_tool_metadata_service.py | 5 +++++ 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/backend/mcp_server/vontology_mcp.json b/src/backend/mcp_server/vontology_mcp.json index a27557a37..ea2b504c4 100644 --- a/src/backend/mcp_server/vontology_mcp.json +++ b/src/backend/mcp_server/vontology_mcp.json @@ -4520,7 +4520,7 @@ }, { "name": "search_concepts", - "description": "Search Vontology concept names and descriptions, including predicates and types. Use as schema discovery when a represented entity or relationship is relevant but its exact concept, predicate, direction, or reified shape is not yet known.", + "description": "Search Vontology concept names and descriptions, including predicates and types. Use as schema discovery when a represented entity or relationship is relevant but its exact concept, predicate, direction, or reified shape is not yet known. Begin with a small exact or substring name search; request descriptions, hierarchy paths, similarity, or two-pass search only when the initial lexical result leaves a material gap.", "inputSchema": { "type": "object", "properties": { @@ -6371,7 +6371,7 @@ }, { "name": "vontology_concept_search", - "description": "Search Vontology concept names and descriptions, including predicates and types. Use as schema discovery when a represented entity or relationship is relevant but its exact concept, predicate, direction, or reified shape is not yet known.", + "description": "Search Vontology concept names and descriptions, including predicates and types. Use as schema discovery when a represented entity or relationship is relevant but its exact concept, predicate, direction, or reified shape is not yet known. Begin with a small exact or substring name search; request descriptions, hierarchy paths, similarity, or two-pass search only when the initial lexical result leaves a material gap.", "inputSchema": { "type": "object", "properties": { diff --git a/src/backend/services/tool_metadata_service.py b/src/backend/services/tool_metadata_service.py index ada445889..b8f3cd23f 100644 --- a/src/backend/services/tool_metadata_service.py +++ b/src/backend/services/tool_metadata_service.py @@ -220,15 +220,20 @@ class ToolRequiredObligationMetadata: "Search Vontology concept names and descriptions, including " "predicates and types. Use as schema discovery when a represented " "entity or relationship is relevant but its exact concept, " - "predicate, direction, or reified shape is not yet known." + "predicate, direction, or reified shape is not yet known. Begin with " + "a small exact or substring name search; request descriptions, " + "hierarchy paths, similarity, or two-pass search only when the " + "initial lexical result leaves a material gap." ), "planner_hint": ( "Schema discovery for represented knowledge. Use before a " "relation-specific read when the relevant entity, predicate, type, " "inverse direction, or reified relationship shape is uncertain; " - "then use the discovered schema with a relation-bearing lookup. " - "Skip when the exact schema is already known or the task is not " - "about represented knowledge." + "then use the discovered schema with a relation-bearing lookup. Start " + "with a small exact or substring search without hierarchy expansion " + "or two-pass similarity; add richer search only if needed. Skip when " + "the exact schema is already known or the task is not about " + "represented knowledge." ), "dispatch_surface_family": "knowledge_base", "evidence_surface_family": "knowledge_base", @@ -713,7 +718,8 @@ class ToolRequiredObligationMetadata: "inspect predicate incidence, then pass the returned exact predicate IDs " "in predicate_filter. Begin with minimally enriched, predicate-filtered " "evidence unless broad inventory is itself requested. " - "For an initial or broad page, keep the limit small, omit inline previews, " + "For an initial single-relationship question, start around limit 20, " + "omit inline previews, " "and hydrate only selected concept IDs. Keep argument_index='any' when " "direction is materially unknown. A one-direction or lexical-predicate " "probe cannot support a complete negative result." diff --git a/tests/backend/test_tool_metadata_service.py b/tests/backend/test_tool_metadata_service.py index 80bf42d90..5442d5872 100644 --- a/tests/backend/test_tool_metadata_service.py +++ b/tests/backend/test_tool_metadata_service.py @@ -232,6 +232,10 @@ def test_vontology_concept_search_alias_inherits_schema_discovery_metadata( assert alias.evidence_role == "search" assert "schema discovery" in (alias.planner_hint or "").lower() assert "relation-bearing lookup" in (alias.planner_hint or "") + assert "small exact or substring search" in (alias.planner_hint or "") + assert "without hierarchy expansion or two-pass similarity" in ( + alias.planner_hint or "" + ) assert "PhD" not in (alias.description or "") assert "student" not in (alias.planner_hint or "").lower() finally: @@ -270,6 +274,7 @@ def test_relation_read_hints_start_bounded_without_losing_negative_completeness( "limit": 20, } assert "argument_index='any'" in (relation_read.planner_hint or "") + assert "start around limit 20" in (relation_read.planner_hint or "") assert "omit inline previews" in (relation_read.planner_hint or "") assert "complete negative" in (relation_read.planner_hint or "") assert "relation_kind='binary'" in (relation_read.planner_hint or "") From 91cdea0da06d7df62274dd516c10af5cd2402d3c Mon Sep 17 00:00:00 2001 From: witbrock Date: Fri, 31 Jul 2026 21:04:18 +0100 Subject: [PATCH 07/16] Expose complete delegated tool purpose index --- .../von_workflow_language_manual.md | 7 + src/backend/services/adaptive_turn_service.py | 104 ++++++++++- tests/backend/test_adaptive_turn_service.py | 175 ++++++++++++++++++ 3 files changed, 276 insertions(+), 10 deletions(-) diff --git a/docs/engineering/von_workflow_language_manual.md b/docs/engineering/von_workflow_language_manual.md index 816b20880..111bfd458 100644 --- a/docs/engineering/von_workflow_language_manual.md +++ b/docs/engineering/von_workflow_language_manual.md @@ -482,6 +482,13 @@ not a quality signal. Actor-visible registered tools declared by a matched workflow are exposed as plausible simpler component plans without claiming that one component is equivalent to the whole workflow. +Natural-language capability discovery gives the model a complete alphabetical +purpose index of the delegated registered tools alongside bounded represented +workflow matches. The index contains mechanically shortened authored +descriptions, not code-ranked semantic choices; exact-name discovery hydrates +the selected canonical schemas. Lexical detail-page labels are hints and cannot +remove a delegated tool from that model-visible index. + Workflow component projection includes actor-visible MCP tools named by represented step actions and by represented LLM-step tool policy (`required_tools`, `conditional_required_tools`, and `allowed_tools`). Engine diff --git a/src/backend/services/adaptive_turn_service.py b/src/backend/services/adaptive_turn_service.py index e57161b02..4de4ef28d 100644 --- a/src/backend/services/adaptive_turn_service.py +++ b/src/backend/services/adaptive_turn_service.py @@ -65,6 +65,7 @@ _MODEL_EVIDENCE_INDEX_MAX_BYTES = 24_000 _MODEL_TOOL_RESULT_BATCH_MAX_BYTES = 24_000 _MODEL_EVIDENCE_PREVIEW_MAX_CHARS = 240 +_CAPABILITY_PURPOSE_MAX_CHARS = 160 _CAPABILITY_CATALOGUE_SCHEMA_VERSION = "adaptive_turn_capabilities.v1" _CAPABILITY_PLAN_PROFILE_SCHEMA_VERSION = "capability_plan_profile.v1" _CAPABILITY_EFFECT_PROFILE_SCHEMA_VERSION = "capability_effect_profile.v1" @@ -360,6 +361,58 @@ def _compact_capability_selection_profiles( return compact +def _capability_purpose_index( + capabilities: Sequence[Mapping[str, Any]], +) -> dict[str, Any]: + """Expose every candidate's authored purpose without choosing semantics.""" + + entries: list[dict[str, Any]] = [] + for capability in sorted( + capabilities, + key=lambda item: str(item.get("name") or "").lower(), + ): + name = str(capability.get("name") or "").strip() + if not name: + continue + purpose = " ".join(str(capability.get("description") or "").split()) + first_sentence_end = re.search(r"[.!?](?=(?:\s+[A-Z#])|$)", purpose) + if first_sentence_end is not None: + purpose = purpose[: first_sentence_end.end()] + if len(purpose) > _CAPABILITY_PURPOSE_MAX_CHARS: + prefix = purpose[: _CAPABILITY_PURPOSE_MAX_CHARS - 3] + if not prefix.endswith(" ") and not purpose[len(prefix)].isspace(): + word_boundary = prefix.rfind(" ") + if word_boundary > 0: + prefix = prefix[:word_boundary] + purpose = prefix.rstrip() + "..." + entry: dict[str, Any] = {"name": name, "purpose": purpose} + plan_profile = capability.get("plan_profile") + shape = ( + str(plan_profile.get("shape") or "").strip() + if isinstance(plan_profile, Mapping) + else "" + ) + if shape and shape != "single_capability": + entry["shape"] = shape + semantic_effect = capability.get("semantic_effect") + if semantic_effect is not False: + entry["semantic_effect"] = semantic_effect + entries.append(entry) + return { + "schema_version": "adaptive_turn_capability_purpose_index.v1", + "complete": True, + "ordering": "name_ascending_unranked", + "purpose_projection": "first_authored_sentence", + "purpose_max_chars": _CAPABILITY_PURPOSE_MAX_CHARS, + "truncation_marker": "...", + "exact_schema_hydration": { + "tool": _CAPABILITY_TOOL_NAME, + "arguments": {"names": [""], "limit": 1}, + }, + "entries": entries, + } + + def _capability_schema_reference( capability: Mapping[str, Any], *, @@ -480,6 +533,7 @@ def _bounded_capability_catalogue_output( "dominated_capabilities", "catalogue_scope", "offset", + "purpose_index", ) if key in value } @@ -519,7 +573,30 @@ def assemble( schema_reference_count=schema_reference_count, ) if len(_json_bytes(bounded)) > max_bytes: - return {} + purpose_index = base.get("purpose_index") + if not isinstance(purpose_index, Mapping): + return {} + base = { + key: value.get(key) + for key in ( + "schema_version", + "success", + "delegation", + "total", + "delegated_total", + "catalogue_scope", + "offset", + "purpose_index", + ) + if key in value + } + bounded = assemble( + included, + full_schema_count=full_schema_count, + schema_reference_count=schema_reference_count, + ) + if len(_json_bytes(bounded)) > max_bytes: + return {} for capability in capabilities: full_candidate = assemble( @@ -1207,12 +1284,12 @@ def _tool_definitions() -> list[ToolDefinition]: ToolDefinition( name=_CAPABILITY_TOOL_NAME, description=( - "Inspect the capabilities delegated to this turn. Search by " - "ordinary words, request exact names, or page through the complete " - "catalogue. A natural-language query also searches actor-accessible " - "represented workflows and returns executable matches as bound " - "capabilities. Returns canonical argument schemas. This is " - "discovery, not a requirement to use any particular capability." + "Inspect the capabilities delegated to this turn. Non-exact " + "discovery returns a complete unranked compact purpose index, " + "including semantically discovered represented workflows; judge " + "semantic adequacy yourself. Request an exact name to hydrate its " + "canonical argument schema. This is discovery, not a requirement " + "to use any particular capability." ), input_schema={ "type": "object", @@ -1330,9 +1407,11 @@ def _scope_message( "- Remaining effect-capable window before the protected final-answer " f"reserve: {max(0.0, remaining_effect_capable_seconds):.3f} seconds.\n" f"- {_CAPABILITY_TOOL_NAME} exposes the complete delegated catalogue " - "without interpreting the user's intent; a natural-language query also " - "retrieves actor-accessible executable represented workflows as bound " - "capabilities.\n" + "as an unranked compact purpose index without interpreting the user's " + "intent; judge semantic adequacy yourself, then request an exact name to " + "hydrate its canonical schema. Natural-language discovery also adds " + "actor-accessible executable represented workflows to the same index. " + "Detailed-page query labels are hints, never exclusions.\n" "- Treat a direct call, a small composition, and a represented workflow " "as capability plans in one decision space. Choose the least costly plan " "only after judging that it can produce the material work product and " @@ -2257,6 +2336,11 @@ def workflow_sort_key(item: Mapping[str, Any]) -> tuple[Any, ...]: ), "offset": offset, "next_offset": next_offset if next_offset < len(selected) else None, + **( + {"purpose_index": _capability_purpose_index(selected)} + if not exact_names + else {} + ), "capabilities": page, **( {"workflow_discovery": dict(workflow_discovery)} diff --git a/tests/backend/test_adaptive_turn_service.py b/tests/backend/test_adaptive_turn_service.py index 0faeec122..144039df2 100644 --- a/tests/backend/test_adaptive_turn_service.py +++ b/tests/backend/test_adaptive_turn_service.py @@ -3137,6 +3137,181 @@ def test_capability_query_ranks_without_eliminating_the_delegated_set( assert "actor_id" not in internal["input_schema"]["properties"] +def test_complete_purpose_index_keeps_zero_overlap_direct_tool_visible( + monkeypatch, +) -> None: + from src.backend.services import tool_metadata_service + from src.backend.services.workflow_turn_capability_service import ( + WorkflowTurnCapability, + ) + + target_name = "find_relations_with_argument" + target_description = ( + "Return represented relations containing a supplied entity at a selected " + "argument position, with bounded predicate, certainty, direction, preview, " + "and pagination controls for direct evidence reads. A second sentence adds " + "detail that the compact purpose index must not repeat." + ) + families = ( + "knowledge_lookup", + "mail_search", + "project_read", + "record_fetch", + "task_query", + "workflow_status", + "web_research", + "zotero_search", + ) + filler_names = [ + f"{family}_{index:02d}" + for family in families + for index in range(12) + ][:95] + capability_names = (target_name, *filler_names) + catalogue = MethodCatalogue() + for name in capability_names: + catalogue.register( + MethodDefinition( + name=name, + handler=lambda **_kwargs: {"success": True}, + input_schema=Schema( + required={"query": str}, + optional={ + f"bounded_field_{index}": str for index in range(8) + }, + allow_unknown=False, + ), + category="read", + description=( + target_description + if name == target_name + else ( + "Inspect a bounded source and return provenance-bearing " + "records for the requested research operation." + ) + ), + ) + ) + gateway = InternalMCPGateway( + catalogue=catalogue, + transport=InternalMCPTransport(read_timeout_sec=1.0), + enabled=True, + ) + monkeypatch.setattr( + tool_metadata_service, + "get_tool_description", + lambda name, *, fallback_description=None: ( + target_description if name == target_name else fallback_description + ), + ) + monkeypatch.setattr( + tool_metadata_service, + "get_tool_planner_hint", + lambda _name: None, + ) + monkeypatch.setattr( + tool_metadata_service, + "get_tool_dispatch_surface_metadata", + lambda _name: None, + ) + workflows = tuple( + WorkflowTurnCapability( + name=f"represented_workflow_academic_profile_{index:02d}", + workflow_id=f"#V#academic_profile_workflow_{index:02d}", + display_name=f"Academic profile workflow {index:02d}", + description=( + "Compile and verify a multi-source academic mentorship dossier." + if index == 0 + else "Compile and verify a bounded multi-source research dossier." + ), + relevance_score=0.97 - (index * 0.01), + input_schema={"type": "object", "properties": {}}, + declared_step_count=5, + semantic_effect=False, + semantic_effect_source="declared_registered_read_components", + ) + for index in range(10) + ) + + raw = _capability_catalogue( + gateway, + capability_names, + {"query": "Which scholar mentors Michael?", "limit": 50}, + workflow_capabilities=workflows, + ) + assert len(_json_bytes(raw)) > 24_000 + + target_detail = next( + item for item in raw["capabilities"] if item["name"] == target_name + ) + assert target_detail["query_match"] is False + purpose_index = raw["purpose_index"] + entries = purpose_index["entries"] + assert purpose_index["complete"] is True + assert purpose_index["ordering"] == "name_ascending_unranked" + assert purpose_index["purpose_projection"] == "first_authored_sentence" + assert len(entries) == len(capability_names) + len(workflows) + assert [entry["name"] for entry in entries] == sorted( + (*capability_names, *(workflow.name for workflow in workflows)), + key=str.lower, + ) + target_purpose = next( + entry for entry in entries if entry["name"] == target_name + ) + assert target_purpose["purpose"].endswith("...") + assert len(target_purpose["purpose"]) <= 160 + assert "second sentence" not in target_purpose["purpose"].lower() + assert set(target_purpose) == {"name", "purpose"} + workflow_purpose = next( + entry for entry in entries if entry["name"] == workflows[0].name + ) + assert workflow_purpose["shape"] == "represented_workflow" + assert "query_match" not in workflow_purpose + assert "selection" not in workflow_purpose + + non_english = _capability_catalogue( + gateway, + capability_names, + {"query": "Qui Michel encadre-t-il ?", "limit": 50}, + workflow_capabilities=workflows, + ) + non_english_target = next( + item for item in non_english["capabilities"] if item["name"] == target_name + ) + assert non_english_target["query_match"] is False + assert non_english["purpose_index"] == purpose_index + + bounded_results = _bound_tool_results_for_model( + [ + ToolResult( + call_id="realistic-purpose-index", + tool_name="turn_capabilities", + output=raw, + status="ok", + ) + ] + ) + assert bounded_results is not None + bounded = bounded_results[0].output + bounded_size = len(_json_bytes(bounded)) + assert len(_json_bytes(purpose_index)) < 24_000 + assert bounded_size <= 24_000 + assert bounded["purpose_index"] == purpose_index + + exact = _capability_catalogue( + gateway, + capability_names, + {"names": [target_name], "limit": 1}, + workflow_capabilities=workflows, + ) + assert "purpose_index" not in exact + assert exact["capabilities"][0]["name"] == target_name + assert set(exact["capabilities"][0]["input_schema"]["properties"]) == { + "query", + *(f"bounded_field_{index}" for index in range(8)), + } + + def test_schema_discovery_metadata_is_retrievable_without_list_word_trigger( monkeypatch, ) -> None: From da9d26f1bb0cfeeafb98756752f62b0ee8af75f0 Mon Sep 17 00:00:00 2001 From: witbrock Date: Fri, 31 Jul 2026 21:24:38 +0100 Subject: [PATCH 08/16] Clarify relation evidence coverage prompts --- .../integrations/internal_mcp/catalogue.py | 10 +++++-- src/backend/mcp_server/vontology_mcp.json | 8 ++--- src/backend/services/adaptive_turn_service.py | 5 ++++ src/backend/services/tool_metadata_service.py | 29 +++++++++++++++---- tests/backend/test_adaptive_turn_service.py | 2 ++ tests/backend/test_tool_metadata_service.py | 18 +++++++++++- 6 files changed, 60 insertions(+), 12 deletions(-) diff --git a/src/backend/integrations/internal_mcp/catalogue.py b/src/backend/integrations/internal_mcp/catalogue.py index b01745d3a..8f05d4c5b 100644 --- a/src/backend/integrations/internal_mcp/catalogue.py +++ b/src/backend/integrations/internal_mcp/catalogue.py @@ -33851,7 +33851,10 @@ def _build_default_catalogue_core_definitions() -> List[MethodDefinition]: "argument_index to choose the relation slot instead of inventing " "subject/object payload fields. Supports exact graph matching and " "full-text text-relation matching with optional predicate/kind filters " - "and pagination. Verified actors receive actor_effective results; " + "and pagination. A positive hit proves existence, not that a requested " + "list or count is complete; use small predicate incidence first when " + "the predicates actually used around a known anchor are not established. " + "Verified actors receive actor_effective results; " "payload-only identity is ignored and yields base_publication. " "Truncated actor overlays expose bounded recovery rather than a " "misleading offset continuation." @@ -33969,7 +33972,10 @@ def _build_default_catalogue_core_definitions() -> List[MethodDefinition]: "argument_index='subject' means inspect outgoing subject-side relations, " "not that the payload should contain a subject field. Use when you need " "distinct predicates plus counts before choosing a predicate-specific " - "extent or filtered relation lookup. Start with bounded minimal incidence " + "extent or filtered relation lookup. Use it early for a requested list, " + "count, broad relationship category, or negative claim around a known " + "entity: unlike lexical schema search, incidence reports predicates " + "actually in use. Start with bounded minimal incidence " "(a modest limit, without argument type counts, previews, or text snippets), " "then request those richer fields only when the initial evidence shows they " "are needed; avoid concurrent rich incidence probes by default. Set " diff --git a/src/backend/mcp_server/vontology_mcp.json b/src/backend/mcp_server/vontology_mcp.json index ea2b504c4..70a292398 100644 --- a/src/backend/mcp_server/vontology_mcp.json +++ b/src/backend/mcp_server/vontology_mcp.json @@ -2117,7 +2117,7 @@ }, { "name": "find_relations_with_argument", - "description": "Find relation assertions where a concept appears in one or more argument positions. The anchor entity must be supplied in concept_id; use argument_index to choose the relation slot instead of inventing subject/object payload fields. Supports exact graph matching and full-text text-relation matching with optional predicate/kind filters and pagination. Verified actors receive actor_effective results; payload-only identity is ignored and yields base_publication. Truncated actor overlays expose bounded recovery rather than a misleading offset continuation.", + "description": "Find relation assertions where a concept appears in one or more argument positions. The anchor entity must be supplied in concept_id; use argument_index to choose the relation slot instead of inventing subject/object payload fields. Supports exact graph matching and full-text text-relation matching with optional predicate/kind filters and pagination. A positive hit proves existence, not that a requested list or count is complete; use small predicate incidence first when the predicates actually used around a known anchor are not established. Verified actors receive actor_effective results; payload-only identity is ignored and yields base_publication. Truncated actor overlays expose bounded recovery rather than a misleading offset continuation.", "inputSchema": { "type": "object", "properties": { @@ -2320,7 +2320,7 @@ }, { "name": "get_predicate_incidence", - "description": "Summarise which predicates are actually observed around a concept or across instances of a type. The anchor entity goes in concept_id; argument_index='subject' means inspect outgoing subject-side relations, not that the payload should contain a subject field. Use when you need distinct predicates plus counts before choosing a predicate-specific extent or filtered relation lookup. Start with bounded minimal incidence (a modest limit, without argument type counts, previews, or text snippets), then request those richer fields only when the initial evidence shows they are needed; avoid concurrent rich incidence probes by default. Set role_expansion_mode='explicit' with represented node-type or role-predicate filters when the immediate neighbour is a reified/event/claim node whose other role fillers are the useful retrieval targets.", + "description": "Summarise which predicates are actually observed around a concept or across instances of a type. The anchor entity goes in concept_id; argument_index='subject' means inspect outgoing subject-side relations, not that the payload should contain a subject field. Use when you need distinct predicates plus counts before choosing a predicate-specific extent or filtered relation lookup. Use it early for a requested list, count, broad relationship category, or negative claim around a known entity: unlike lexical schema search, incidence reports predicates actually in use. Start with bounded minimal incidence (a modest limit, without argument type counts, previews, or text snippets), then request those richer fields only when the initial evidence shows they are needed; avoid concurrent rich incidence probes by default. Set role_expansion_mode='explicit' with represented node-type or role-predicate filters when the immediate neighbour is a reified/event/claim node whose other role fillers are the useful retrieval targets.", "inputSchema": { "type": "object", "properties": { @@ -4520,7 +4520,7 @@ }, { "name": "search_concepts", - "description": "Search Vontology concept names and descriptions, including predicates and types. Use as schema discovery when a represented entity or relationship is relevant but its exact concept, predicate, direction, or reified shape is not yet known. Begin with a small exact or substring name search; request descriptions, hierarchy paths, similarity, or two-pass search only when the initial lexical result leaves a material gap.", + "description": "Search Vontology concept names and descriptions, including predicates and types. Use as schema discovery when a represented entity or relationship is relevant but its exact concept, predicate, direction, or reified shape is not yet known. Begin with a small exact or substring name search; request descriptions, hierarchy paths, similarity, or two-pass search only when the initial lexical result leaves a material gap. This finds possible schema; it does not show which predicates are actually used around a known entity, so it cannot by itself establish entity-relative enumeration or cardinality coverage.", "inputSchema": { "type": "object", "properties": { @@ -6371,7 +6371,7 @@ }, { "name": "vontology_concept_search", - "description": "Search Vontology concept names and descriptions, including predicates and types. Use as schema discovery when a represented entity or relationship is relevant but its exact concept, predicate, direction, or reified shape is not yet known. Begin with a small exact or substring name search; request descriptions, hierarchy paths, similarity, or two-pass search only when the initial lexical result leaves a material gap.", + "description": "Search Vontology concept names and descriptions, including predicates and types. Use as schema discovery when a represented entity or relationship is relevant but its exact concept, predicate, direction, or reified shape is not yet known. Begin with a small exact or substring name search; request descriptions, hierarchy paths, similarity, or two-pass search only when the initial lexical result leaves a material gap. This finds possible schema; it does not show which predicates are actually used around a known entity, so it cannot by itself establish entity-relative enumeration or cardinality coverage.", "inputSchema": { "type": "object", "properties": { diff --git a/src/backend/services/adaptive_turn_service.py b/src/backend/services/adaptive_turn_service.py index 4de4ef28d..11bd150f4 100644 --- a/src/backend/services/adaptive_turn_service.py +++ b/src/backend/services/adaptive_turn_service.py @@ -1428,6 +1428,11 @@ def _scope_message( "- Treat one schema-discovery read followed by one content-bearing read " "as a small composition, not as a reason by itself to select a broader " "workflow.\n" + "- Match evidence coverage to the requested claim. A positive hit proves " + "existence, not completeness. For a requested list, count, broad category, " + "or negative claim, inspect enough relevant pages, directions, and " + "represented schema variants to support that scope, or state the bound; " + "stop once the evidence supports the requested work product.\n" "- A matched workflow may expose visible component capabilities as " "simpler alternatives. Their presence does not prove equivalence: inspect " "the request and escalate when a direct result leaves a material gap.\n" diff --git a/src/backend/services/tool_metadata_service.py b/src/backend/services/tool_metadata_service.py index b8f3cd23f..c82edf4b3 100644 --- a/src/backend/services/tool_metadata_service.py +++ b/src/backend/services/tool_metadata_service.py @@ -223,7 +223,10 @@ class ToolRequiredObligationMetadata: "predicate, direction, or reified shape is not yet known. Begin with " "a small exact or substring name search; request descriptions, " "hierarchy paths, similarity, or two-pass search only when the " - "initial lexical result leaves a material gap." + "initial lexical result leaves a material gap. This finds possible " + "schema; it does not show which predicates are actually used around " + "a known entity, so it cannot by itself establish entity-relative " + "enumeration or cardinality coverage." ), "planner_hint": ( "Schema discovery for represented knowledge. Use before a " @@ -233,7 +236,11 @@ class ToolRequiredObligationMetadata: "with a small exact or substring search without hierarchy expansion " "or two-pass similarity; add richer search only if needed. Skip when " "the exact schema is already known or the task is not about " - "represented knowledge." + "represented knowledge. When the anchor entity is known and the work " + "product enumerates or quantifies a broad relationship category, " + "prefer small predicate incidence first: lexical schema search shows " + "possible predicates, not the predicates actually in use around that " + "entity." ), "dispatch_surface_family": "knowledge_base", "evidence_surface_family": "knowledge_base", @@ -721,8 +728,14 @@ class ToolRequiredObligationMetadata: "For an initial single-relationship question, start around limit 20, " "omit inline previews, " "and hydrate only selected concept IDs. Keep argument_index='any' when " - "direction is materially unknown. A one-direction or lexical-predicate " - "probe cannot support a complete negative result." + "direction is materially unknown. A positive hit proves that relation " + "exists, not that a requested list or count is complete. For a list, " + "count, broad relationship category, or negative claim around a known " + "anchor, inspect small predicate incidence across the materially " + "possible directions first when the predicates, directions, or " + "represented forms needed for that coverage are not already " + "established; then read the exact predicates actually in use. A " + "one-direction or lexical-predicate probe cannot establish that coverage." ), }, "find_concepts_by_name": { @@ -797,11 +810,17 @@ class ToolRequiredObligationMetadata: "planner_hint": ( "Use before a filtered entity-relative lookup when the represented " "predicate, inverse form, or argument direction is not established. " + "Use it early for a requested list, count, broad relationship category, " + "or negative claim around a known entity when the predicates, " + "directions, or represented forms needed for that coverage are not " + "already established: it reports predicates actually in use, whereas " + "lexical schema search reports only possible predicates. " "Start with a small binary page without previews, snippets, or argument " "type counts. Inspect both directions only when direction is materially " "unknown, then use returned exact predicate IDs for the relation read. " "Request type counts only when they contribute to the requested work " - "product. Do not infer a complete negative from a one-direction probe." + "product. A positive hit proves existence, not list completeness; do not " + "infer a complete list, count, or negative from a one-direction probe." ), }, "get_concept_usage_profile": { diff --git a/tests/backend/test_adaptive_turn_service.py b/tests/backend/test_adaptive_turn_service.py index 144039df2..da3726340 100644 --- a/tests/backend/test_adaptive_turn_service.py +++ b/tests/backend/test_adaptive_turn_service.py @@ -166,6 +166,8 @@ def test_scope_message_prefers_progressive_low_cost_read_plans() -> None: assert "escalate only if its returned evidence exposes a material gap" in message assert "one schema-discovery read followed by one content-bearing read" in message assert "merely to hedge uncertainty" in message + assert "A positive hit proves existence, not completeness" in message + assert "stop once the evidence supports the requested work product" in message def _gmail_gateway(handler: Any) -> InternalMCPGateway: diff --git a/tests/backend/test_tool_metadata_service.py b/tests/backend/test_tool_metadata_service.py index 5442d5872..8896ff467 100644 --- a/tests/backend/test_tool_metadata_service.py +++ b/tests/backend/test_tool_metadata_service.py @@ -236,6 +236,12 @@ def test_vontology_concept_search_alias_inherits_schema_discovery_metadata( assert "without hierarchy expansion or two-pass similarity" in ( alias.planner_hint or "" ) + assert "possible predicates, not the predicates actually in use" in ( + alias.planner_hint or "" + ) + assert "requested work product is a list" not in ( + alias.planner_hint or "" + ) assert "PhD" not in (alias.description or "") assert "student" not in (alias.planner_hint or "").lower() finally: @@ -268,6 +274,10 @@ def test_relation_read_hints_start_bounded_without_losing_negative_completeness( incidence.planner_hint or "" ) assert "exact predicate IDs" in (incidence.planner_hint or "") + assert "positive hit proves existence, not list completeness" in ( + incidence.planner_hint or "" + ) + assert "not already established" in (incidence.planner_hint or "") assert "one-direction" in (incidence.planner_hint or "") assert relation_read.default_payload == { "include_concept_preview": False, @@ -276,7 +286,13 @@ def test_relation_read_hints_start_bounded_without_losing_negative_completeness( assert "argument_index='any'" in (relation_read.planner_hint or "") assert "start around limit 20" in (relation_read.planner_hint or "") assert "omit inline previews" in (relation_read.planner_hint or "") - assert "complete negative" in (relation_read.planner_hint or "") + assert "positive hit proves that relation exists" in ( + relation_read.planner_hint or "" + ) + assert "cannot establish that coverage" in ( + relation_read.planner_hint or "" + ) + assert "not already established" in (relation_read.planner_hint or "") assert "relation_kind='binary'" in (relation_read.planner_hint or "") assert "vontology_concept_search" in (relation_read.planner_hint or "") assert "unless broad inventory is itself requested" in ( From 429b313832d2b5e5451f67f8bdfdf5dc6236042e Mon Sep 17 00:00:00 2001 From: witbrock Date: Fri, 31 Jul 2026 21:40:30 +0100 Subject: [PATCH 09/16] Clarify relation argument and incidence guidance --- .../integrations/internal_mcp/catalogue.py | 24 +++++++++++++++---- src/backend/mcp_server/vontology_mcp.json | 8 +++---- src/backend/services/adaptive_turn_service.py | 4 ++++ src/backend/services/tool_metadata_service.py | 18 +++++++++----- tests/backend/test_adaptive_turn_service.py | 1 + tests/backend/test_tool_metadata_service.py | 13 +++++++++- 6 files changed, 52 insertions(+), 16 deletions(-) diff --git a/src/backend/integrations/internal_mcp/catalogue.py b/src/backend/integrations/internal_mcp/catalogue.py index 8f05d4c5b..cac87f5f2 100644 --- a/src/backend/integrations/internal_mcp/catalogue.py +++ b/src/backend/integrations/internal_mcp/catalogue.py @@ -9733,7 +9733,8 @@ def _find_relations_with_argument_input_schema() -> Schema: "'asserted_only'|'uncertain_only'|'include_uncertain', and " "uncertainty_statuses), and optional namespace passthrough. Do not use " "top-level payload keys named subject or object; the anchor entity always " - "goes in concept_id." + "goes in concept_id. A numeric object argument_index selects one exact " + "stored slot; use 'any' when all subject/object slots are relevant." ), ) @@ -9804,14 +9805,18 @@ def _predicate_incidence_input_schema() -> Schema: "get_predicate_incidence input: exactly one of concept_id (entity mode; " "anchor entity concept ID such as '#V#michael_witbrock') or instance_of " "(type mode). Optional direct_instances_only, argument_index " - "('subject' inspects outgoing subject-side relations), predicate_filter, " + "('subject' inspects outgoing subject-side relations; 'any' inspects " + "every stored subject/object slot; a numeric object index selects one " + "exact stored slot), predicate_filter, " "relation_kind ('any'|'binary'|'text'), include_text_snippets, " "include_concept_preview, paging (limit/offset), sort_by " "('relation_hit_count'|'grounding_count'|'grounded_instance_count'|'predicate'), " "uncertainty retrieval controls (include_uncertain, uncertainty_mode " "'asserted_only'|'uncertain_only'|'include_uncertain', and " - "uncertainty_statuses), include_argument_type_counts for direct asserted " - "type counts of non-anchor arguments, type_count_mode='direct_asserted', " + "uncertainty_statuses), include_argument_type_counts for aggregated direct " + "asserted types of non-anchor arguments (not related-entity identities; " + "keep false unless a type distribution is requested), " + "type_count_mode='direct_asserted', " "include_untyped_bucket, max_types_per_predicate, max_sample_concepts_per_type, " "and role_expansion_mode ('none'|'explicit'|'auto') with optional explicit " "role_node_type_filter, role_predicate_filter, and role_expansion_depth=1. " @@ -33854,6 +33859,9 @@ def _build_default_catalogue_core_definitions() -> List[MethodDefinition]: "and pagination. A positive hit proves existence, not that a requested " "list or count is complete; use small predicate incidence first when " "the predicates actually used around a known anchor are not established. " + "Use argument_index='any' when the direction or stored object slot is " + "not established; a numeric object index selects one exact stored slot, " + "not every object-side assertion. " "Verified actors receive actor_effective results; " "payload-only identity is ignored and yields base_publication. " "Truncated actor overlays expose bounded recovery rather than a " @@ -33975,7 +33983,13 @@ def _build_default_catalogue_core_definitions() -> List[MethodDefinition]: "extent or filtered relation lookup. Use it early for a requested list, " "count, broad relationship category, or negative claim around a known " "entity: unlike lexical schema search, incidence reports predicates " - "actually in use. Start with bounded minimal incidence " + "actually in use. Use argument_index='any' for one minimal call when " + "the direction or stored object slot is not established; a numeric " + "object index selects one exact stored slot, not the whole object side. " + "Argument type counts aggregate neighbour types and do not identify " + "related entities, so keep them disabled for entity enumeration and " + "enable them only for a requested type distribution. Start with bounded " + "minimal incidence " "(a modest limit, without argument type counts, previews, or text snippets), " "then request those richer fields only when the initial evidence shows they " "are needed; avoid concurrent rich incidence probes by default. Set " diff --git a/src/backend/mcp_server/vontology_mcp.json b/src/backend/mcp_server/vontology_mcp.json index 70a292398..9fa943003 100644 --- a/src/backend/mcp_server/vontology_mcp.json +++ b/src/backend/mcp_server/vontology_mcp.json @@ -2117,7 +2117,7 @@ }, { "name": "find_relations_with_argument", - "description": "Find relation assertions where a concept appears in one or more argument positions. The anchor entity must be supplied in concept_id; use argument_index to choose the relation slot instead of inventing subject/object payload fields. Supports exact graph matching and full-text text-relation matching with optional predicate/kind filters and pagination. A positive hit proves existence, not that a requested list or count is complete; use small predicate incidence first when the predicates actually used around a known anchor are not established. Verified actors receive actor_effective results; payload-only identity is ignored and yields base_publication. Truncated actor overlays expose bounded recovery rather than a misleading offset continuation.", + "description": "Find relation assertions where a concept appears in one or more argument positions. The anchor entity must be supplied in concept_id; use argument_index to choose the relation slot instead of inventing subject/object payload fields. Supports exact graph matching and full-text text-relation matching with optional predicate/kind filters and pagination. A positive hit proves existence, not that a requested list or count is complete; use small predicate incidence first when the predicates actually used around a known anchor are not established. Use argument_index='any' when the direction or stored object slot is not established; a numeric object index selects one exact stored slot, not every object-side assertion. Verified actors receive actor_effective results; payload-only identity is ignored and yields base_publication. Truncated actor overlays expose bounded recovery rather than a misleading offset continuation.", "inputSchema": { "type": "object", "properties": { @@ -2210,7 +2210,7 @@ "concept_id" ], "additionalProperties": true, - "description": "find_relations_with_argument input: concept_id (required anchor entity concept ID, for example '#V#michael_witbrock'), argument_index (int or 'any'; use 'subject' or 1 to inspect subject-side relations), predicate_filter (list of predicate IDs or substrings), relation_kind ('any'|'binary'|'text'), scope (optional), include_text_snippets (bool), include_concept_preview (bool), paging (limit/offset), sort_by, uncertainty retrieval controls (include_uncertain, uncertainty_mode 'asserted_only'|'uncertain_only'|'include_uncertain', and uncertainty_statuses), and optional namespace passthrough. Do not use top-level payload keys named subject or object; the anchor entity always goes in concept_id." + "description": "find_relations_with_argument input: concept_id (required anchor entity concept ID, for example '#V#michael_witbrock'), argument_index (int or 'any'; use 'subject' or 1 to inspect subject-side relations), predicate_filter (list of predicate IDs or substrings), relation_kind ('any'|'binary'|'text'), scope (optional), include_text_snippets (bool), include_concept_preview (bool), paging (limit/offset), sort_by, uncertainty retrieval controls (include_uncertain, uncertainty_mode 'asserted_only'|'uncertain_only'|'include_uncertain', and uncertainty_statuses), and optional namespace passthrough. Do not use top-level payload keys named subject or object; the anchor entity always goes in concept_id. A numeric object argument_index selects one exact stored slot; use 'any' when all subject/object slots are relevant." } }, { @@ -2320,7 +2320,7 @@ }, { "name": "get_predicate_incidence", - "description": "Summarise which predicates are actually observed around a concept or across instances of a type. The anchor entity goes in concept_id; argument_index='subject' means inspect outgoing subject-side relations, not that the payload should contain a subject field. Use when you need distinct predicates plus counts before choosing a predicate-specific extent or filtered relation lookup. Use it early for a requested list, count, broad relationship category, or negative claim around a known entity: unlike lexical schema search, incidence reports predicates actually in use. Start with bounded minimal incidence (a modest limit, without argument type counts, previews, or text snippets), then request those richer fields only when the initial evidence shows they are needed; avoid concurrent rich incidence probes by default. Set role_expansion_mode='explicit' with represented node-type or role-predicate filters when the immediate neighbour is a reified/event/claim node whose other role fillers are the useful retrieval targets.", + "description": "Summarise which predicates are actually observed around a concept or across instances of a type. The anchor entity goes in concept_id; argument_index='subject' means inspect outgoing subject-side relations, not that the payload should contain a subject field. Use when you need distinct predicates plus counts before choosing a predicate-specific extent or filtered relation lookup. Use it early for a requested list, count, broad relationship category, or negative claim around a known entity: unlike lexical schema search, incidence reports predicates actually in use. Use argument_index='any' for one minimal call when the direction or stored object slot is not established; a numeric object index selects one exact stored slot, not the whole object side. Argument type counts aggregate neighbour types and do not identify related entities, so keep them disabled for entity enumeration and enable them only for a requested type distribution. Start with bounded minimal incidence (a modest limit, without argument type counts, previews, or text snippets), then request those richer fields only when the initial evidence shows they are needed; avoid concurrent rich incidence probes by default. Set role_expansion_mode='explicit' with represented node-type or role-predicate filters when the immediate neighbour is a reified/event/claim node whose other role fillers are the useful retrieval targets.", "inputSchema": { "type": "object", "properties": { @@ -2482,7 +2482,7 @@ }, "required": [], "additionalProperties": true, - "description": "get_predicate_incidence input: exactly one of concept_id (entity mode; anchor entity concept ID such as '#V#michael_witbrock') or instance_of (type mode). Optional direct_instances_only, argument_index ('subject' inspects outgoing subject-side relations), predicate_filter, relation_kind ('any'|'binary'|'text'), include_text_snippets, include_concept_preview, paging (limit/offset), sort_by ('relation_hit_count'|'grounding_count'|'grounded_instance_count'|'predicate'), uncertainty retrieval controls (include_uncertain, uncertainty_mode 'asserted_only'|'uncertain_only'|'include_uncertain', and uncertainty_statuses), include_argument_type_counts for direct asserted type counts of non-anchor arguments, type_count_mode='direct_asserted', include_untyped_bucket, max_types_per_predicate, max_sample_concepts_per_type, and role_expansion_mode ('none'|'explicit'|'auto') with optional explicit role_node_type_filter, role_predicate_filter, and role_expansion_depth=1. Auto role expansion is metadata-gated and reports diagnostics when no authoritative reification profile is available. Optional namespace passthrough. Do not use top-level payload keys named subject or object; the anchor entity always goes in concept_id.", + "description": "get_predicate_incidence input: exactly one of concept_id (entity mode; anchor entity concept ID such as '#V#michael_witbrock') or instance_of (type mode). Optional direct_instances_only, argument_index ('subject' inspects outgoing subject-side relations; 'any' inspects every stored subject/object slot; a numeric object index selects one exact stored slot), predicate_filter, relation_kind ('any'|'binary'|'text'), include_text_snippets, include_concept_preview, paging (limit/offset), sort_by ('relation_hit_count'|'grounding_count'|'grounded_instance_count'|'predicate'), uncertainty retrieval controls (include_uncertain, uncertainty_mode 'asserted_only'|'uncertain_only'|'include_uncertain', and uncertainty_statuses), include_argument_type_counts for aggregated direct asserted types of non-anchor arguments (not related-entity identities; keep false unless a type distribution is requested), type_count_mode='direct_asserted', include_untyped_bucket, max_types_per_predicate, max_sample_concepts_per_type, and role_expansion_mode ('none'|'explicit'|'auto') with optional explicit role_node_type_filter, role_predicate_filter, and role_expansion_depth=1. Auto role expansion is metadata-gated and reports diagnostics when no authoritative reification profile is available. Optional namespace passthrough. Do not use top-level payload keys named subject or object; the anchor entity always goes in concept_id.", "x-von-argument-aliases": { "target_type": "instance_of", "target_type_id": "instance_of", diff --git a/src/backend/services/adaptive_turn_service.py b/src/backend/services/adaptive_turn_service.py index 11bd150f4..383ec8052 100644 --- a/src/backend/services/adaptive_turn_service.py +++ b/src/backend/services/adaptive_turn_service.py @@ -1433,6 +1433,10 @@ def _scope_message( "or negative claim, inspect enough relevant pages, directions, and " "represented schema variants to support that scope, or state the bound; " "stop once the evidence supports the requested work product.\n" + "- Reconcile quantitative summaries with the grounded items. Count only " + "items evidenced to satisfy the requested category; report related " + "artefacts or ambiguous candidates separately rather than including them " + "in the total.\n" "- A matched workflow may expose visible component capabilities as " "simpler alternatives. Their presence does not prove equivalence: inspect " "the request and escalate when a direct result leaves a material gap.\n" diff --git a/src/backend/services/tool_metadata_service.py b/src/backend/services/tool_metadata_service.py index c82edf4b3..c5af7ddce 100644 --- a/src/backend/services/tool_metadata_service.py +++ b/src/backend/services/tool_metadata_service.py @@ -728,7 +728,9 @@ class ToolRequiredObligationMetadata: "For an initial single-relationship question, start around limit 20, " "omit inline previews, " "and hydrate only selected concept IDs. Keep argument_index='any' when " - "direction is materially unknown. A positive hit proves that relation " + "the direction or stored object slot is materially unknown; a numeric " + "object index selects one exact stored slot, not every object-side " + "assertion. A positive hit proves that relation " "exists, not that a requested list or count is complete. For a list, " "count, broad relationship category, or negative claim around a known " "anchor, inspect small predicate incidence across the materially " @@ -815,11 +817,15 @@ class ToolRequiredObligationMetadata: "directions, or represented forms needed for that coverage are not " "already established: it reports predicates actually in use, whereas " "lexical schema search reports only possible predicates. " - "Start with a small binary page without previews, snippets, or argument " - "type counts. Inspect both directions only when direction is materially " - "unknown, then use returned exact predicate IDs for the relation read. " - "Request type counts only when they contribute to the requested work " - "product. A positive hit proves existence, not list completeness; do not " + "Start with one small binary page without previews, snippets, or " + "argument type counts. Use argument_index='any' in that one minimal " + "call when the direction or stored object slot is materially unknown; " + "a numeric object index selects one exact stored slot, not the whole " + "object side. Argument type counts aggregate neighbour types and do not " + "identify the related entities, so keep them off for entity enumeration " + "and enable them only when the requested work product needs a type " + "distribution. Then use returned exact predicate IDs for the relation " + "read. A positive hit proves existence, not list completeness; do not " "infer a complete list, count, or negative from a one-direction probe." ), }, diff --git a/tests/backend/test_adaptive_turn_service.py b/tests/backend/test_adaptive_turn_service.py index da3726340..1082c2a22 100644 --- a/tests/backend/test_adaptive_turn_service.py +++ b/tests/backend/test_adaptive_turn_service.py @@ -168,6 +168,7 @@ def test_scope_message_prefers_progressive_low_cost_read_plans() -> None: assert "merely to hedge uncertainty" in message assert "A positive hit proves existence, not completeness" in message assert "stop once the evidence supports the requested work product" in message + assert "Count only items evidenced to satisfy the requested category" in message def _gmail_gateway(handler: Any) -> InternalMCPGateway: diff --git a/tests/backend/test_tool_metadata_service.py b/tests/backend/test_tool_metadata_service.py index 8896ff467..ba12a5698 100644 --- a/tests/backend/test_tool_metadata_service.py +++ b/tests/backend/test_tool_metadata_service.py @@ -270,7 +270,7 @@ def test_relation_read_hints_start_bounded_without_losing_negative_completeness( assert "without previews, snippets, or argument type counts" in ( incidence.planner_hint or "" ) - assert "both directions only when direction is materially unknown" in ( + assert "one minimal call when the direction or stored object slot" in ( incidence.planner_hint or "" ) assert "exact predicate IDs" in (incidence.planner_hint or "") @@ -278,6 +278,14 @@ def test_relation_read_hints_start_bounded_without_losing_negative_completeness( incidence.planner_hint or "" ) assert "not already established" in (incidence.planner_hint or "") + assert "argument_index='any'" in (incidence.planner_hint or "") + assert "not the whole object side" in (incidence.planner_hint or "") + assert "do not identify the related entities" in ( + incidence.planner_hint or "" + ) + assert "keep them off for entity enumeration" in ( + incidence.planner_hint or "" + ) assert "one-direction" in (incidence.planner_hint or "") assert relation_read.default_payload == { "include_concept_preview": False, @@ -293,6 +301,9 @@ def test_relation_read_hints_start_bounded_without_losing_negative_completeness( relation_read.planner_hint or "" ) assert "not already established" in (relation_read.planner_hint or "") + assert "not every object-side assertion" in ( + relation_read.planner_hint or "" + ) assert "relation_kind='binary'" in (relation_read.planner_hint or "") assert "vontology_concept_search" in (relation_read.planner_hint or "") assert "unless broad inventory is itself requested" in ( From 5e67e3e158ecd2f1ab4ab0cbedf847ea0b74ef9c Mon Sep 17 00:00:00 2001 From: witbrock Date: Fri, 31 Jul 2026 21:54:31 +0100 Subject: [PATCH 10/16] Guide models across complementary relation encodings --- src/backend/integrations/internal_mcp/catalogue.py | 9 ++++++++- src/backend/mcp_server/vontology_mcp.json | 2 +- src/backend/services/tool_metadata_service.py | 11 +++++++++-- tests/backend/test_tool_metadata_service.py | 8 ++++++++ 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/backend/integrations/internal_mcp/catalogue.py b/src/backend/integrations/internal_mcp/catalogue.py index cac87f5f2..028af9c3b 100644 --- a/src/backend/integrations/internal_mcp/catalogue.py +++ b/src/backend/integrations/internal_mcp/catalogue.py @@ -33992,7 +33992,14 @@ def _build_default_catalogue_core_definitions() -> List[MethodDefinition]: "minimal incidence " "(a modest limit, without argument type counts, previews, or text snippets), " "then request those richer fields only when the initial evidence shows they " - "are needed; avoid concurrent rich incidence probes by default. Set " + "are needed. For an enumeration, treat every row whose predicate meaning " + "and anchor role fit the requested relationship as a coverage candidate, " + "including inverse forms; read all such exact predicate IDs and deduplicate " + "overlapping facts rather than choosing only the closest wording or preferred " + "direction. When a matching row grounds an event, claim, or other reified " + "node instead of the requested answer entity, inspect that node's represented " + "role fillers before including or excluding it. Avoid concurrent rich " + "incidence probes by default. Set " "role_expansion_mode=" "'explicit' with represented node-type or role-predicate filters when " "the immediate neighbour is a reified/event/claim node whose other role " diff --git a/src/backend/mcp_server/vontology_mcp.json b/src/backend/mcp_server/vontology_mcp.json index 9fa943003..9365932a5 100644 --- a/src/backend/mcp_server/vontology_mcp.json +++ b/src/backend/mcp_server/vontology_mcp.json @@ -2320,7 +2320,7 @@ }, { "name": "get_predicate_incidence", - "description": "Summarise which predicates are actually observed around a concept or across instances of a type. The anchor entity goes in concept_id; argument_index='subject' means inspect outgoing subject-side relations, not that the payload should contain a subject field. Use when you need distinct predicates plus counts before choosing a predicate-specific extent or filtered relation lookup. Use it early for a requested list, count, broad relationship category, or negative claim around a known entity: unlike lexical schema search, incidence reports predicates actually in use. Use argument_index='any' for one minimal call when the direction or stored object slot is not established; a numeric object index selects one exact stored slot, not the whole object side. Argument type counts aggregate neighbour types and do not identify related entities, so keep them disabled for entity enumeration and enable them only for a requested type distribution. Start with bounded minimal incidence (a modest limit, without argument type counts, previews, or text snippets), then request those richer fields only when the initial evidence shows they are needed; avoid concurrent rich incidence probes by default. Set role_expansion_mode='explicit' with represented node-type or role-predicate filters when the immediate neighbour is a reified/event/claim node whose other role fillers are the useful retrieval targets.", + "description": "Summarise which predicates are actually observed around a concept or across instances of a type. The anchor entity goes in concept_id; argument_index='subject' means inspect outgoing subject-side relations, not that the payload should contain a subject field. Use when you need distinct predicates plus counts before choosing a predicate-specific extent or filtered relation lookup. Use it early for a requested list, count, broad relationship category, or negative claim around a known entity: unlike lexical schema search, incidence reports predicates actually in use. Use argument_index='any' for one minimal call when the direction or stored object slot is not established; a numeric object index selects one exact stored slot, not the whole object side. Argument type counts aggregate neighbour types and do not identify related entities, so keep them disabled for entity enumeration and enable them only for a requested type distribution. Start with bounded minimal incidence (a modest limit, without argument type counts, previews, or text snippets), then request those richer fields only when the initial evidence shows they are needed. For an enumeration, treat every row whose predicate meaning and anchor role fit the requested relationship as a coverage candidate, including inverse forms; read all such exact predicate IDs and deduplicate overlapping facts rather than choosing only the closest wording or preferred direction. When a matching row grounds an event, claim, or other reified node instead of the requested answer entity, inspect that node's represented role fillers before including or excluding it. Avoid concurrent rich incidence probes by default. Set role_expansion_mode='explicit' with represented node-type or role-predicate filters when the immediate neighbour is a reified/event/claim node whose other role fillers are the useful retrieval targets.", "inputSchema": { "type": "object", "properties": { diff --git a/src/backend/services/tool_metadata_service.py b/src/backend/services/tool_metadata_service.py index c5af7ddce..eef3dff66 100644 --- a/src/backend/services/tool_metadata_service.py +++ b/src/backend/services/tool_metadata_service.py @@ -824,8 +824,15 @@ class ToolRequiredObligationMetadata: "object side. Argument type counts aggregate neighbour types and do not " "identify the related entities, so keep them off for entity enumeration " "and enable them only when the requested work product needs a type " - "distribution. Then use returned exact predicate IDs for the relation " - "read. A positive hit proves existence, not list completeness; do not " + "distribution. For an enumeration, treat every incidence row whose " + "predicate meaning and anchor role fit the requested relationship as a " + "coverage candidate, including inverse forms; read all such exact " + "predicate IDs and deduplicate overlapping facts rather than choosing " + "only the closest wording or preferred direction. When a matching row " + "grounds an event, claim, or other reified node instead of the requested " + "answer entity, inspect that node's represented role fillers before " + "including or excluding it. A positive hit proves " + "existence, not list completeness; do not " "infer a complete list, count, or negative from a one-direction probe." ), }, diff --git a/tests/backend/test_tool_metadata_service.py b/tests/backend/test_tool_metadata_service.py index ba12a5698..867c2c3b1 100644 --- a/tests/backend/test_tool_metadata_service.py +++ b/tests/backend/test_tool_metadata_service.py @@ -286,6 +286,14 @@ def test_relation_read_hints_start_bounded_without_losing_negative_completeness( assert "keep them off for entity enumeration" in ( incidence.planner_hint or "" ) + assert "every incidence row" in (incidence.planner_hint or "") + assert "including inverse forms" in (incidence.planner_hint or "") + assert "closest wording or preferred direction" in ( + incidence.planner_hint or "" + ) + assert "inspect that node's represented role fillers" in ( + incidence.planner_hint or "" + ) assert "one-direction" in (incidence.planner_hint or "") assert relation_read.default_payload == { "include_concept_preview": False, From 15a4c1dcc04bc1b6619cdc7a61971a5fe88ed05d Mon Sep 17 00:00:00 2001 From: witbrock Date: Sat, 1 Aug 2026 01:55:50 +0100 Subject: [PATCH 11/16] Refine capability guidance from Luna evidence --- .../integrations/internal_mcp/catalogue.py | 43 +++------ src/backend/mcp_server/vontology_mcp.json | 8 +- src/backend/services/adaptive_turn_service.py | 26 +++--- src/backend/services/tool_metadata_service.py | 91 +++++-------------- tests/backend/test_adaptive_turn_service.py | 11 ++- tests/backend/test_tool_metadata_service.py | 59 +++--------- 6 files changed, 72 insertions(+), 166 deletions(-) diff --git a/src/backend/integrations/internal_mcp/catalogue.py b/src/backend/integrations/internal_mcp/catalogue.py index 028af9c3b..e44c233b3 100644 --- a/src/backend/integrations/internal_mcp/catalogue.py +++ b/src/backend/integrations/internal_mcp/catalogue.py @@ -33856,12 +33856,10 @@ def _build_default_catalogue_core_definitions() -> List[MethodDefinition]: "argument_index to choose the relation slot instead of inventing " "subject/object payload fields. Supports exact graph matching and " "full-text text-relation matching with optional predicate/kind filters " - "and pagination. A positive hit proves existence, not that a requested " - "list or count is complete; use small predicate incidence first when " - "the predicates actually used around a known anchor are not established. " - "Use argument_index='any' when the direction or stored object slot is " - "not established; a numeric object index selects one exact stored slot, " - "not every object-side assertion. " + "and pagination. A positive hit proves existence, not enumeration " + "completeness. When predicate, direction, or represented-form coverage " + "is uncertain, inspect predicate incidence first and then read every " + "fitting exact predicate. " "Verified actors receive actor_effective results; " "payload-only identity is ignored and yields base_publication. " "Truncated actor overlays expose bounded recovery rather than a " @@ -33976,31 +33974,14 @@ def _build_default_catalogue_core_definitions() -> List[MethodDefinition]: category="read", description=( "Summarise which predicates are actually observed around a concept or across " - "instances of a type. The anchor entity goes in concept_id; " - "argument_index='subject' means inspect outgoing subject-side relations, " - "not that the payload should contain a subject field. Use when you need " - "distinct predicates plus counts before choosing a predicate-specific " - "extent or filtered relation lookup. Use it early for a requested list, " - "count, broad relationship category, or negative claim around a known " - "entity: unlike lexical schema search, incidence reports predicates " - "actually in use. Use argument_index='any' for one minimal call when " - "the direction or stored object slot is not established; a numeric " - "object index selects one exact stored slot, not the whole object side. " - "Argument type counts aggregate neighbour types and do not identify " - "related entities, so keep them disabled for entity enumeration and " - "enable them only for a requested type distribution. Start with bounded " - "minimal incidence " - "(a modest limit, without argument type counts, previews, or text snippets), " - "then request those richer fields only when the initial evidence shows they " - "are needed. For an enumeration, treat every row whose predicate meaning " - "and anchor role fit the requested relationship as a coverage candidate, " - "including inverse forms; read all such exact predicate IDs and deduplicate " - "overlapping facts rather than choosing only the closest wording or preferred " - "direction. When a matching row grounds an event, claim, or other reified " - "node instead of the requested answer entity, inspect that node's represented " - "role fillers before including or excluding it. Avoid concurrent rich " - "incidence probes by default. Set " - "role_expansion_mode=" + "instances of a type. The anchor entity goes in concept_id. Use before a " + "filtered relation read when a list, " + "count, broad category, or negative lacks established predicate, " + "direction, or represented-form coverage. Start with one small " + "argument_index='any' binary page; read every fitting predicate including " + "inverse forms, deduplicate overlaps, and inspect role fillers for matching " + "reified nodes. Incidence returns coverage candidates, not answer entities. " + "Set role_expansion_mode=" "'explicit' with represented node-type or role-predicate filters when " "the immediate neighbour is a reified/event/claim node whose other role " "fillers are the useful retrieval targets." diff --git a/src/backend/mcp_server/vontology_mcp.json b/src/backend/mcp_server/vontology_mcp.json index 9365932a5..d8e4e3c5f 100644 --- a/src/backend/mcp_server/vontology_mcp.json +++ b/src/backend/mcp_server/vontology_mcp.json @@ -2117,7 +2117,7 @@ }, { "name": "find_relations_with_argument", - "description": "Find relation assertions where a concept appears in one or more argument positions. The anchor entity must be supplied in concept_id; use argument_index to choose the relation slot instead of inventing subject/object payload fields. Supports exact graph matching and full-text text-relation matching with optional predicate/kind filters and pagination. A positive hit proves existence, not that a requested list or count is complete; use small predicate incidence first when the predicates actually used around a known anchor are not established. Use argument_index='any' when the direction or stored object slot is not established; a numeric object index selects one exact stored slot, not every object-side assertion. Verified actors receive actor_effective results; payload-only identity is ignored and yields base_publication. Truncated actor overlays expose bounded recovery rather than a misleading offset continuation.", + "description": "Find relation assertions where a concept appears in one or more argument positions. The anchor entity must be supplied in concept_id; use argument_index to choose the relation slot instead of inventing subject/object payload fields. Supports exact graph matching and full-text text-relation matching with optional predicate/kind filters and pagination. A positive hit proves existence, not enumeration completeness. When predicate, direction, or represented-form coverage is uncertain, inspect predicate incidence first and then read every fitting exact predicate. Verified actors receive actor_effective results; payload-only identity is ignored and yields base_publication. Truncated actor overlays expose bounded recovery rather than a misleading offset continuation.", "inputSchema": { "type": "object", "properties": { @@ -2320,7 +2320,7 @@ }, { "name": "get_predicate_incidence", - "description": "Summarise which predicates are actually observed around a concept or across instances of a type. The anchor entity goes in concept_id; argument_index='subject' means inspect outgoing subject-side relations, not that the payload should contain a subject field. Use when you need distinct predicates plus counts before choosing a predicate-specific extent or filtered relation lookup. Use it early for a requested list, count, broad relationship category, or negative claim around a known entity: unlike lexical schema search, incidence reports predicates actually in use. Use argument_index='any' for one minimal call when the direction or stored object slot is not established; a numeric object index selects one exact stored slot, not the whole object side. Argument type counts aggregate neighbour types and do not identify related entities, so keep them disabled for entity enumeration and enable them only for a requested type distribution. Start with bounded minimal incidence (a modest limit, without argument type counts, previews, or text snippets), then request those richer fields only when the initial evidence shows they are needed. For an enumeration, treat every row whose predicate meaning and anchor role fit the requested relationship as a coverage candidate, including inverse forms; read all such exact predicate IDs and deduplicate overlapping facts rather than choosing only the closest wording or preferred direction. When a matching row grounds an event, claim, or other reified node instead of the requested answer entity, inspect that node's represented role fillers before including or excluding it. Avoid concurrent rich incidence probes by default. Set role_expansion_mode='explicit' with represented node-type or role-predicate filters when the immediate neighbour is a reified/event/claim node whose other role fillers are the useful retrieval targets.", + "description": "Summarise which predicates are actually observed around a concept or across instances of a type. The anchor entity goes in concept_id. Use before a filtered relation read when a list, count, broad category, or negative lacks established predicate, direction, or represented-form coverage. Start with one small argument_index='any' binary page; read every fitting predicate including inverse forms, deduplicate overlaps, and inspect role fillers for matching reified nodes. Incidence returns coverage candidates, not answer entities. Set role_expansion_mode='explicit' with represented node-type or role-predicate filters when the immediate neighbour is a reified/event/claim node whose other role fillers are the useful retrieval targets.", "inputSchema": { "type": "object", "properties": { @@ -4520,7 +4520,7 @@ }, { "name": "search_concepts", - "description": "Search Vontology concept names and descriptions, including predicates and types. Use as schema discovery when a represented entity or relationship is relevant but its exact concept, predicate, direction, or reified shape is not yet known. Begin with a small exact or substring name search; request descriptions, hierarchy paths, similarity, or two-pass search only when the initial lexical result leaves a material gap. This finds possible schema; it does not show which predicates are actually used around a known entity, so it cannot by itself establish entity-relative enumeration or cardinality coverage.", + "description": "Search Vontology names and descriptions for possible entities, predicates, and types. Start with exact or substring search and enrich only when needed. This discovers possible schema, not the predicates actually used around an anchor or enumeration coverage.", "inputSchema": { "type": "object", "properties": { @@ -6371,7 +6371,7 @@ }, { "name": "vontology_concept_search", - "description": "Search Vontology concept names and descriptions, including predicates and types. Use as schema discovery when a represented entity or relationship is relevant but its exact concept, predicate, direction, or reified shape is not yet known. Begin with a small exact or substring name search; request descriptions, hierarchy paths, similarity, or two-pass search only when the initial lexical result leaves a material gap. This finds possible schema; it does not show which predicates are actually used around a known entity, so it cannot by itself establish entity-relative enumeration or cardinality coverage.", + "description": "Search Vontology names and descriptions for possible entities, predicates, and types. Start with exact or substring search and enrich only when needed. This discovers possible schema, not the predicates actually used around an anchor or enumeration coverage.", "inputSchema": { "type": "object", "properties": { diff --git a/src/backend/services/adaptive_turn_service.py b/src/backend/services/adaptive_turn_service.py index 383ec8052..c28e336fa 100644 --- a/src/backend/services/adaptive_turn_service.py +++ b/src/backend/services/adaptive_turn_service.py @@ -1417,26 +1417,24 @@ def _scope_message( "only after judging that it can produce the material work product and " "evidence; representedness is not a quality score.\n" "- Use each candidate's plan, effect, cost, and adequacy evidence. A " - "workflow is warranted when it adds needed composition, verification, " - "durability, governance, or recovery; otherwise prefer an adequate " - "observable and recoverable direct path.\n" + "workflow is warranted only when it adds needed composition, verification, " + "durability, governance, or recovery that a bounded direct call or small " + "composition cannot adequately provide. Several reads alone do not make " + "a workflow necessary.\n" "- When bounded read-only plans are each plausibly adequate, start with " "the least costly observable plan and escalate only if its returned " "evidence exposes a material gap. Do not launch a durable or multi-step " "workflow merely to hedge uncertainty that a direct read or small " "composition can test within the turn budget.\n" - "- Treat one schema-discovery read followed by one content-bearing read " - "as a small composition, not as a reason by itself to select a broader " - "workflow.\n" "- Match evidence coverage to the requested claim. A positive hit proves " - "existence, not completeness. For a requested list, count, broad category, " - "or negative claim, inspect enough relevant pages, directions, and " - "represented schema variants to support that scope, or state the bound; " - "stop once the evidence supports the requested work product.\n" - "- Reconcile quantitative summaries with the grounded items. Count only " - "items evidenced to satisfy the requested category; report related " - "artefacts or ambiguous candidates separately rather than including them " - "in the total.\n" + "existence, not completeness. Do not call a list, count, broad category, " + "or negative complete from one page, predicate, direction, or represented " + "form unless inspected evidence establishes that coverage; inspect the " + "missing scope or state the bound. Stop when the requested work product is " + "adequately supported.\n" + "- Reconcile summaries with grounded items. Deduplicate overlapping facts, " + "separate related artefacts or ambiguous candidates, and state status or " + "time qualifiers only when inspected evidence establishes them.\n" "- A matched workflow may expose visible component capabilities as " "simpler alternatives. Their presence does not prove equivalence: inspect " "the request and escalate when a direct result leaves a material gap.\n" diff --git a/src/backend/services/tool_metadata_service.py b/src/backend/services/tool_metadata_service.py index eef3dff66..f993f428c 100644 --- a/src/backend/services/tool_metadata_service.py +++ b/src/backend/services/tool_metadata_service.py @@ -217,30 +217,16 @@ class ToolRequiredObligationMetadata: "category": "vontology", "display_template": "Found {count} concepts for {query}", "description": ( - "Search Vontology concept names and descriptions, including " - "predicates and types. Use as schema discovery when a represented " - "entity or relationship is relevant but its exact concept, " - "predicate, direction, or reified shape is not yet known. Begin with " - "a small exact or substring name search; request descriptions, " - "hierarchy paths, similarity, or two-pass search only when the " - "initial lexical result leaves a material gap. This finds possible " - "schema; it does not show which predicates are actually used around " - "a known entity, so it cannot by itself establish entity-relative " - "enumeration or cardinality coverage." + "Search Vontology names and descriptions for possible entities, " + "predicates, and types. Start with exact or substring search and " + "enrich only when needed. This discovers possible schema, not the " + "predicates actually used around an anchor or enumeration coverage." ), "planner_hint": ( - "Schema discovery for represented knowledge. Use before a " - "relation-specific read when the relevant entity, predicate, type, " - "inverse direction, or reified relationship shape is uncertain; " - "then use the discovered schema with a relation-bearing lookup. Start " - "with a small exact or substring search without hierarchy expansion " - "or two-pass similarity; add richer search only if needed. Skip when " - "the exact schema is already known or the task is not about " - "represented knowledge. When the anchor entity is known and the work " - "product enumerates or quantifies a broad relationship category, " - "prefer small predicate incidence first: lexical schema search shows " - "possible predicates, not the predicates actually in use around that " - "entity." + "Use when the entity, predicate, type, inverse, or reified shape is " + "unknown, then make a relation-bearing read. For a broad category " + "around a known anchor, use predicate incidence instead: schema search " + "shows what is possible, not what is actually used there." ), "dispatch_surface_family": "knowledge_base", "evidence_surface_family": "knowledge_base", @@ -716,28 +702,13 @@ class ToolRequiredObligationMetadata: "target_concept_max_count": 2, "default_payload": {"include_concept_preview": False, "limit": 20}, "planner_hint": ( - "Use for entity-relative relationship lookup after resolving the anchor " - "concept. This is relation-bearing evidence, not mere inventory. Choose " - "the cheapest adequate query shape: use relation_kind='binary' for " - "entity-to-entity relations and request text relations or snippets only " - "when the user asks about text content. When the represented predicate " - "is unknown, retrieve schema candidates with vontology_concept_search or " - "inspect predicate incidence, then pass the returned exact predicate IDs " - "in predicate_filter. Begin with minimally enriched, predicate-filtered " - "evidence unless broad inventory is itself requested. " - "For an initial single-relationship question, start around limit 20, " - "omit inline previews, " - "and hydrate only selected concept IDs. Keep argument_index='any' when " - "the direction or stored object slot is materially unknown; a numeric " - "object index selects one exact stored slot, not every object-side " - "assertion. A positive hit proves that relation " - "exists, not that a requested list or count is complete. For a list, " - "count, broad relationship category, or negative claim around a known " - "anchor, inspect small predicate incidence across the materially " - "possible directions first when the predicates, directions, or " - "represented forms needed for that coverage are not already " - "established; then read the exact predicates actually in use. A " - "one-direction or lexical-predicate probe cannot establish that coverage." + "Use for content-bearing relationships after resolving the anchor. " + "Start predicate-filtered around limit 20 with previews off; hydrate " + "selected concepts only when needed. A hit proves existence, not list " + "or count completeness. If predicate, direction, or represented-form " + "coverage is not established, inspect small any-direction incidence " + "first, then read every fitting exact predicate. A numeric object index " + "selects one stored slot, not the whole object side." ), }, "find_concepts_by_name": { @@ -810,30 +781,14 @@ class ToolRequiredObligationMetadata: "limit": 12, }, "planner_hint": ( - "Use before a filtered entity-relative lookup when the represented " - "predicate, inverse form, or argument direction is not established. " - "Use it early for a requested list, count, broad relationship category, " - "or negative claim around a known entity when the predicates, " - "directions, or represented forms needed for that coverage are not " - "already established: it reports predicates actually in use, whereas " - "lexical schema search reports only possible predicates. " - "Start with one small binary page without previews, snippets, or " - "argument type counts. Use argument_index='any' in that one minimal " - "call when the direction or stored object slot is materially unknown; " - "a numeric object index selects one exact stored slot, not the whole " - "object side. Argument type counts aggregate neighbour types and do not " - "identify the related entities, so keep them off for entity enumeration " - "and enable them only when the requested work product needs a type " - "distribution. For an enumeration, treat every incidence row whose " - "predicate meaning and anchor role fit the requested relationship as a " - "coverage candidate, including inverse forms; read all such exact " - "predicate IDs and deduplicate overlapping facts rather than choosing " - "only the closest wording or preferred direction. When a matching row " - "grounds an event, claim, or other reified node instead of the requested " - "answer entity, inspect that node's represented role fillers before " - "including or excluding it. A positive hit proves " - "existence, not list completeness; do not " - "infer a complete list, count, or negative from a one-direction probe." + "Use before a filtered relation read when coverage is not established. " + "For a list, count, broad category, or negative, start one small binary " + "argument_index='any' page with previews, snippets, and type counts off. " + "Treat every semantically fitting row, including inverse forms, as a " + "coverage candidate; read all exact predicates, deduplicate overlaps, " + "and inspect role fillers when a matching row grounds a reified node. " + "Incidence identifies coverage candidates, not the answer entities; a " + "numeric object index selects only one stored slot." ), }, "get_concept_usage_profile": { diff --git a/tests/backend/test_adaptive_turn_service.py b/tests/backend/test_adaptive_turn_service.py index 1082c2a22..90a0b53ee 100644 --- a/tests/backend/test_adaptive_turn_service.py +++ b/tests/backend/test_adaptive_turn_service.py @@ -164,11 +164,11 @@ def test_scope_message_prefers_progressive_low_cost_read_plans() -> None: assert "start with the least costly observable plan" in message assert "escalate only if its returned evidence exposes a material gap" in message - assert "one schema-discovery read followed by one content-bearing read" in message + assert "Several reads alone do not make a workflow necessary" in message assert "merely to hedge uncertainty" in message assert "A positive hit proves existence, not completeness" in message - assert "stop once the evidence supports the requested work product" in message - assert "Count only items evidenced to satisfy the requested category" in message + assert "one page, predicate, direction, or represented form" in message + assert "state status or time qualifiers only when inspected evidence" in message def _gmail_gateway(handler: Any) -> InternalMCPGateway: @@ -3355,7 +3355,10 @@ def test_schema_discovery_metadata_is_retrievable_without_list_word_trigger( capability = positive["capabilities"][0] assert capability["query_match"] is True - assert "schema discovery" in capability["planner_hint"].lower() + planner_hint = capability["planner_hint"].lower() + assert "unknown" in planner_hint + assert "relation-bearing read" in planner_hint + assert "what is possible, not what is actually used" in planner_hint assert negative["capabilities"][0]["query_match"] is False finally: tool_metadata_service.invalidate_cache() diff --git a/tests/backend/test_tool_metadata_service.py b/tests/backend/test_tool_metadata_service.py index 867c2c3b1..db2b491a6 100644 --- a/tests/backend/test_tool_metadata_service.py +++ b/tests/backend/test_tool_metadata_service.py @@ -230,15 +230,10 @@ def test_vontology_concept_search_alias_inherits_schema_discovery_metadata( assert alias.planner_hint == canonical.planner_hint assert alias.operation_category == "read" assert alias.evidence_role == "search" - assert "schema discovery" in (alias.planner_hint or "").lower() - assert "relation-bearing lookup" in (alias.planner_hint or "") - assert "small exact or substring search" in (alias.planner_hint or "") - assert "without hierarchy expansion or two-pass similarity" in ( - alias.planner_hint or "" - ) - assert "possible predicates, not the predicates actually in use" in ( - alias.planner_hint or "" - ) + assert "possible schema" in (alias.description or "").lower() + assert "then make a relation-bearing read" in (alias.planner_hint or "") + assert "use predicate incidence instead" in (alias.planner_hint or "") + assert "possible, not what is actually used" in (alias.planner_hint or "") assert "requested work product is a list" not in ( alias.planner_hint or "" ) @@ -267,56 +262,30 @@ def test_relation_read_hints_start_bounded_without_losing_negative_completeness( "include_concept_preview": False, "limit": 12, } - assert "without previews, snippets, or argument type counts" in ( + assert "previews, snippets, and type counts off" in ( incidence.planner_hint or "" ) - assert "one minimal call when the direction or stored object slot" in ( - incidence.planner_hint or "" - ) - assert "exact predicate IDs" in (incidence.planner_hint or "") - assert "positive hit proves existence, not list completeness" in ( - incidence.planner_hint or "" - ) - assert "not already established" in (incidence.planner_hint or "") assert "argument_index='any'" in (incidence.planner_hint or "") - assert "not the whole object side" in (incidence.planner_hint or "") - assert "do not identify the related entities" in ( - incidence.planner_hint or "" - ) - assert "keep them off for entity enumeration" in ( - incidence.planner_hint or "" - ) - assert "every incidence row" in (incidence.planner_hint or "") + assert "every semantically fitting row" in (incidence.planner_hint or "") assert "including inverse forms" in (incidence.planner_hint or "") - assert "closest wording or preferred direction" in ( - incidence.planner_hint or "" - ) - assert "inspect that node's represented role fillers" in ( + assert "inspect role fillers" in (incidence.planner_hint or "") + assert "coverage candidates, not the answer entities" in ( incidence.planner_hint or "" ) - assert "one-direction" in (incidence.planner_hint or "") + assert "selects only one stored slot" in (incidence.planner_hint or "") assert relation_read.default_payload == { "include_concept_preview": False, "limit": 20, } - assert "argument_index='any'" in (relation_read.planner_hint or "") - assert "start around limit 20" in (relation_read.planner_hint or "") - assert "omit inline previews" in (relation_read.planner_hint or "") - assert "positive hit proves that relation exists" in ( - relation_read.planner_hint or "" - ) - assert "cannot establish that coverage" in ( - relation_read.planner_hint or "" - ) - assert "not already established" in (relation_read.planner_hint or "") - assert "not every object-side assertion" in ( + assert "predicate-filtered around limit 20" in ( relation_read.planner_hint or "" ) - assert "relation_kind='binary'" in (relation_read.planner_hint or "") - assert "vontology_concept_search" in (relation_read.planner_hint or "") - assert "unless broad inventory is itself requested" in ( + assert "A hit proves existence, not list or count completeness" in ( relation_read.planner_hint or "" ) + assert "small any-direction incidence" in (relation_read.planner_hint or "") + assert "every fitting exact predicate" in (relation_read.planner_hint or "") + assert "not the whole object side" in (relation_read.planner_hint or "") assert "small predicate-filtered relation read" in ( concept_fetch.planner_hint or "" ) From c601fd19f8a745b9b99d002e71f4403fedc22fdf Mon Sep 17 00:00:00 2001 From: witbrock Date: Sat, 1 Aug 2026 04:09:06 +0100 Subject: [PATCH 12/16] Improve predicate incidence discovery and paging --- .../integrations/internal_mcp/catalogue.py | 7 +- src/backend/mcp_server/mcp_stdio_server.py | 2 +- src/backend/mcp_server/vontology_mcp.json | 2 +- src/backend/services/adaptive_turn_service.py | 5 + .../services/concept_relation_service.py | 245 +++++++-- tests/backend/test_adaptive_turn_service.py | 1 + ...t_concept_relation_service_extent_index.py | 21 +- ...st_concept_relation_service_uncertainty.py | 1 + .../test_internal_mcp_catalogue_builds.py | 4 + .../test_predicate_incidence_page_first.py | 481 ++++++++++++++++++ 10 files changed, 716 insertions(+), 53 deletions(-) create mode 100644 tests/backend/test_predicate_incidence_page_first.py diff --git a/src/backend/integrations/internal_mcp/catalogue.py b/src/backend/integrations/internal_mcp/catalogue.py index e44c233b3..43539a397 100644 --- a/src/backend/integrations/internal_mcp/catalogue.py +++ b/src/backend/integrations/internal_mcp/catalogue.py @@ -533,7 +533,7 @@ def _get_predicate_incidence(**kwargs): relation_kind=kwargs.get("relation_kind"), scope=kwargs.get("scope"), include_text_snippets=bool(kwargs.get("include_text_snippets", False)), - include_concept_preview=bool(kwargs.get("include_concept_preview", True)), + include_concept_preview=bool(kwargs.get("include_concept_preview", False)), limit=kwargs.get("limit"), offset=kwargs.get("offset"), sort_by=kwargs.get("sort_by"), @@ -33973,8 +33973,9 @@ def _build_default_catalogue_core_definitions() -> List[MethodDefinition]: output_schema=_predicate_incidence_output_schema(), category="read", description=( - "Summarise which predicates are actually observed around a concept or across " - "instances of a type. The anchor entity goes in concept_id. Use before a " + "Discover predicates actually used around a known anchor or type when " + "relationship coverage is uncertain. The anchor entity goes in concept_id. " + "Use before a " "filtered relation read when a list, " "count, broad category, or negative lacks established predicate, " "direction, or represented-form coverage. Start with one small " diff --git a/src/backend/mcp_server/mcp_stdio_server.py b/src/backend/mcp_server/mcp_stdio_server.py index 3434a51cc..68dffdd83 100644 --- a/src/backend/mcp_server/mcp_stdio_server.py +++ b/src/backend/mcp_server/mcp_stdio_server.py @@ -2236,7 +2236,7 @@ async def _handle_get_predicate_incidence( scope=arguments.get("scope"), include_text_snippets=bool(arguments.get("include_text_snippets", False)), include_concept_preview=bool( - arguments.get("include_concept_preview", True) + arguments.get("include_concept_preview", False) ), limit=arguments.get("limit"), offset=arguments.get("offset"), diff --git a/src/backend/mcp_server/vontology_mcp.json b/src/backend/mcp_server/vontology_mcp.json index d8e4e3c5f..6a3bc2598 100644 --- a/src/backend/mcp_server/vontology_mcp.json +++ b/src/backend/mcp_server/vontology_mcp.json @@ -2320,7 +2320,7 @@ }, { "name": "get_predicate_incidence", - "description": "Summarise which predicates are actually observed around a concept or across instances of a type. The anchor entity goes in concept_id. Use before a filtered relation read when a list, count, broad category, or negative lacks established predicate, direction, or represented-form coverage. Start with one small argument_index='any' binary page; read every fitting predicate including inverse forms, deduplicate overlaps, and inspect role fillers for matching reified nodes. Incidence returns coverage candidates, not answer entities. Set role_expansion_mode='explicit' with represented node-type or role-predicate filters when the immediate neighbour is a reified/event/claim node whose other role fillers are the useful retrieval targets.", + "description": "Discover predicates actually used around a known anchor or type when relationship coverage is uncertain. The anchor entity goes in concept_id. Use before a filtered relation read when a list, count, broad category, or negative lacks established predicate, direction, or represented-form coverage. Start with one small argument_index='any' binary page; read every fitting predicate including inverse forms, deduplicate overlaps, and inspect role fillers for matching reified nodes. Incidence returns coverage candidates, not answer entities. Set role_expansion_mode='explicit' with represented node-type or role-predicate filters when the immediate neighbour is a reified/event/claim node whose other role fillers are the useful retrieval targets.", "inputSchema": { "type": "object", "properties": { diff --git a/src/backend/services/adaptive_turn_service.py b/src/backend/services/adaptive_turn_service.py index c28e336fa..ba7015a92 100644 --- a/src/backend/services/adaptive_turn_service.py +++ b/src/backend/services/adaptive_turn_service.py @@ -1435,6 +1435,11 @@ def _scope_message( "- Reconcile summaries with grounded items. Deduplicate overlapping facts, " "separate related artefacts or ambiguous candidates, and state status or " "time qualifiers only when inspected evidence establishes them.\n" + "- When a matching relation reaches a reified node or related artefact, " + "inspect its roles before including or excluding other participants. " + "Include a filler only when inspected represented evidence establishes it " + "in the requested semantic role. Co-participation alone is a qualified " + "candidate, not the requested relationship.\n" "- A matched workflow may expose visible component capabilities as " "simpler alternatives. Their presence does not prove equivalence: inspect " "the request and escalate when a direct result leaves a material gap.\n" diff --git a/src/backend/services/concept_relation_service.py b/src/backend/services/concept_relation_service.py index acf8f0c3b..32ce9e895 100644 --- a/src/backend/services/concept_relation_service.py +++ b/src/backend/services/concept_relation_service.py @@ -1130,7 +1130,7 @@ def get_predicate_incidence( relation_kind: Optional[str] = None, scope: Optional[str] = None, # Accepted for caller consistency; currently unused. include_text_snippets: bool = False, - include_concept_preview: bool = True, + include_concept_preview: bool = False, limit: Optional[int] = None, offset: Optional[int] = None, sort_by: Optional[str] = None, @@ -1195,6 +1195,8 @@ def get_predicate_incidence( role_expansion_depth=role_expansion_depth, max_sample_concepts_per_type=type_count_options.max_sample_concepts_per_type, ) + skeleton_type_count_options = PredicateIncidenceTypeCountOptions() + skeleton_role_expansion_options = PredicateIncidenceRoleExpansionOptions() if resolved_concept_id: hits, diagnostics = _collect_all_argument_hits_for_incidence( @@ -1208,25 +1210,32 @@ def get_predicate_incidence( uncertainty_mode=uncertainty_mode, uncertainty_statuses=uncertainty_statuses, ) - if type_count_options.include: - _attach_direct_type_ids_to_fast_incidence_hits({resolved_concept_id: hits}) predicate_rows = _aggregate_predicate_incidence_rows( hits=hits, - include_concept_preview=include_concept_preview, + # Count and sort without doing predicate/grounding preview, type, or + # role reads. Only the selected page is hydrated below. + include_concept_preview=False, preview_cache=preview_cache, anchor_concept_id=resolved_concept_id, mode="entity", type_membership_ids=None, - type_count_options=type_count_options, - role_expansion_options=role_expansion_options, + type_count_options=skeleton_type_count_options, + role_expansion_options=skeleton_role_expansion_options, ) sorted_rows = _sort_predicate_incidence_rows( predicate_rows, sort_by=resolved_sort_by, ) - paged_rows = sorted_rows[ - resolved_offset : resolved_offset + resolved_limit_value - ] + paged_rows = _hydrate_predicate_incidence_page( + sorted_rows[resolved_offset : resolved_offset + resolved_limit_value], + hits_by_anchor={resolved_concept_id: hits}, + mode="entity", + type_membership_ids=None, + include_concept_preview=include_concept_preview, + preview_cache=preview_cache, + type_count_options=type_count_options, + role_expansion_options=role_expansion_options, + ) return { "mode": "entity", "concept_id": resolved_concept_id, @@ -1311,9 +1320,7 @@ def get_predicate_incidence( if grouped_hits is not None else ("per_instance_relation_lookup") ) - if grouped_hits is not None and type_count_options.include: - _attach_direct_type_ids_to_fast_incidence_hits(grouped_hits) - + hits_by_instance: Dict[str, List[Dict[str, Any]]] = {} for instance_id in instance_ids: if grouped_hits is None: hits, _ = _collect_all_argument_hits_for_incidence( @@ -1329,27 +1336,41 @@ def get_predicate_incidence( ) else: hits = grouped_hits.get(instance_id, []) + hits_by_instance[instance_id] = hits _accumulate_predicate_incidence_rows( aggregated_by_predicate, hits=hits, - include_concept_preview=include_concept_preview, + # The complete type-wide skeleton must remain read-free apart from + # relation materialisation and access filtering. Enrich only the + # selected predicate page below. + include_concept_preview=False, preview_cache=preview_cache, anchor_concept_id=instance_id, mode="type", contributing_instance_id=instance_id, type_membership_ids=type_ids, - type_count_options=type_count_options, - role_expansion_options=role_expansion_options, + type_count_options=skeleton_type_count_options, + role_expansion_options=skeleton_role_expansion_options, ) + predicate_rows = [ + _finalise_predicate_incidence_row(row, mode="type") + for row in aggregated_by_predicate.values() + ] sorted_rows = _sort_predicate_incidence_rows( - [ - _finalise_predicate_incidence_row(row, mode="type") - for row in aggregated_by_predicate.values() - ], + predicate_rows, sort_by=resolved_sort_by, ) - paged_rows = sorted_rows[resolved_offset : resolved_offset + resolved_limit_value] + paged_rows = _hydrate_predicate_incidence_page( + sorted_rows[resolved_offset : resolved_offset + resolved_limit_value], + hits_by_anchor=hits_by_instance, + mode="type", + type_membership_ids=type_ids, + include_concept_preview=include_concept_preview, + preview_cache=preview_cache, + type_count_options=type_count_options, + role_expansion_options=role_expansion_options, + ) return { "mode": "type", "instance_of": resolved_instance_of, @@ -1414,6 +1435,112 @@ def _empty_payload(limit: Optional[int], offset: Optional[int]) -> Dict[str, Any } +def _hydrate_predicate_incidence_page( + skeleton_rows: Sequence[Dict[str, Any]], + *, + hits_by_anchor: Mapping[str, List[Dict[str, Any]]], + mode: str, + type_membership_ids: Optional[Sequence[str]], + include_concept_preview: bool, + preview_cache: Dict[str, Optional[Dict[str, Any]]], + type_count_options: PredicateIncidenceTypeCountOptions, + role_expansion_options: PredicateIncidenceRoleExpansionOptions, +) -> List[Dict[str, Any]]: + enrich_types_or_roles = ( + type_count_options.include + or role_expansion_options.mode == _ROLE_EXPANSION_MODE_EXPLICIT + ) + if not (include_concept_preview or enrich_types_or_roles): + return list(skeleton_rows) + + selected_ids = { + predicate_id + for row in skeleton_rows + if isinstance((predicate_id := row.get("predicate_concept_id")), str) + } + selected_hits_by_anchor = { + anchor_concept_id: [ + hit + for hit in hits + if isinstance(hit.get("predicate_concept_id"), str) + and str(hit["predicate_concept_id"]).strip() in selected_ids + ] + for anchor_concept_id, hits in hits_by_anchor.items() + } + if enrich_types_or_roles: + _attach_direct_type_ids_to_fast_incidence_hits(selected_hits_by_anchor) + + preview_ids: List[str] = [] + if include_concept_preview: + preview_ids.extend(selected_ids) + for row in skeleton_rows: + for sample in [ + *(row.get("sample_groundings") or []), + *(row.get("sample_instances") or []), + ]: + if isinstance(sample, Mapping): + preview_ids.append(sample.get("concept_id")) + if enrich_types_or_roles: + for hits in selected_hits_by_anchor.values(): + for hit in hits: + for field_name in ("source_type_ids", "target_type_ids"): + raw_type_ids = hit.get(field_name) + if isinstance(raw_type_ids, list): + preview_ids.extend(raw_type_ids) + _prime_concept_preview_cache( + preview_ids, + include_preview=True, + preview_cache=preview_cache, + ) + + hydrated_by_predicate: Dict[str, Dict[str, Any]] = {} + for anchor_concept_id, hits in selected_hits_by_anchor.items(): + _accumulate_predicate_incidence_rows( + hydrated_by_predicate, + hits=hits, + include_concept_preview=include_concept_preview, + preview_cache=preview_cache, + anchor_concept_id=anchor_concept_id, + mode=mode, + contributing_instance_id=(anchor_concept_id if mode == "type" else None), + type_membership_ids=type_membership_ids, + type_count_options=type_count_options, + role_expansion_options=role_expansion_options, + ) + hydrated_by_id = { + row["predicate_concept_id"]: _finalise_predicate_incidence_row( + row, + mode=mode, + ) + for row in hydrated_by_predicate.values() + } + if include_concept_preview and type_count_options.include: + sample_concepts = [ + sample + for row in hydrated_by_id.values() + for type_count in row.get("argument_type_counts", []) + for sample in type_count.get("sample_concepts", []) + if isinstance(type_count, Mapping) and isinstance(sample, dict) + ] + _prime_concept_preview_cache( + [sample.get("concept_id") for sample in sample_concepts], + include_preview=True, + preview_cache=preview_cache, + ) + for sample in sample_concepts: + preview = _resolve_concept_preview( + str(sample.get("concept_id") or ""), + True, + preview_cache, + ) + if isinstance(preview, Mapping): + sample.update(_compact_concept_sample(preview)) + return [ + hydrated_by_id.get(row.get("predicate_concept_id"), row) + for row in skeleton_rows + ] + + def _collect_all_argument_hits_for_incidence( *, concept_id: str, @@ -1672,12 +1799,14 @@ def _attach_direct_type_ids_to_fast_incidence_hits( grouped_hits: Mapping[str, List[Dict[str, Any]]], ) -> None: target_ids: List[str] = [] - for source_id, hits in grouped_hits.items(): + for anchor_id, hits in grouped_hits.items(): for hit in hits: + source_id = _normalise_concept_id(hit.get("source_concept_id")) target_id = _normalise_concept_id(hit.get("target_value")) - if not target_id or target_id == source_id: - continue - target_ids.append(target_id) + if source_id and source_id != anchor_id: + target_ids.append(source_id) + if target_id and target_id != anchor_id: + target_ids.append(target_id) target_ids = list(dict.fromkeys(target_ids)) if not target_ids: return @@ -1731,11 +1860,12 @@ def _attach_direct_type_ids_to_fast_incidence_hits( type_id for type_id in type_ids if type_id in accessible_type_ids ] - for source_id, hits in grouped_hits.items(): + for hits in grouped_hits.values(): for hit in hits: + source_id = _normalise_concept_id(hit.get("source_concept_id")) target_id = _normalise_concept_id(hit.get("target_value")) - if not target_id or target_id == source_id: - continue + if source_id in type_ids_by_target: + hit["source_type_ids"] = list(type_ids_by_target[source_id]) if target_id in type_ids_by_target: hit["target_type_ids"] = list(type_ids_by_target[target_id]) @@ -1886,19 +2016,19 @@ def _accumulate_predicate_incidence_rows( role_expansion_options=role_expansion_options, ) - if len(row["sample_groundings"]) < 4: - for grounding_key, grounding in _extract_predicate_incidence_groundings( - hit, - anchor_concept_id=anchor_concept_id, - include_concept_preview=include_concept_preview, - preview_cache=preview_cache, - ): - if grounding_key in row["_grounding_keys"]: - continue - row["_grounding_keys"].add(grounding_key) + for grounding_key, grounding in _extract_predicate_incidence_groundings( + hit, + anchor_concept_id=anchor_concept_id, + include_concept_preview=( + include_concept_preview and len(row["sample_groundings"]) < 4 + ), + preview_cache=preview_cache, + ): + if grounding_key in row["_grounding_keys"]: + continue + row["_grounding_keys"].add(grounding_key) + if len(row["sample_groundings"]) < 4: row["sample_groundings"].append(grounding) - if len(row["sample_groundings"]) >= 4: - break if mode == "type" and contributing_instance_id: row["_instance_ids"].add(contributing_instance_id) @@ -1962,16 +2092,32 @@ def _extract_predicate_incidence_argument_entries( source_concept_id = _normalise_concept_id(hit.get("source_concept_id")) if source_concept_id and source_concept_id != anchor_concept_id: + raw_source_type_ids = hit.get("source_type_ids") + source_type_ids: Optional[List[str]] = ( + [ + str(type_id).strip() + for type_id in raw_source_type_ids + if isinstance(type_id, str) and str(type_id).strip() + ] + if isinstance(raw_source_type_ids, list) + else None + ) entries.append( { "argument_index": _ARG_INDEX_SUBJECT, "argument_role": "subject", "concept_id": source_concept_id, - "concept_preview": _resolve_concept_preview( - source_concept_id, - True, - preview_cache, + "concept_preview": preview_cache.get(source_concept_id) + or ( + {"concept_id": source_concept_id, "type_ids": source_type_ids} + if source_type_ids is not None + else _resolve_concept_preview( + source_concept_id, + True, + preview_cache, + ) ), + "type_ids": source_type_ids, } ) @@ -1993,7 +2139,7 @@ def _extract_predicate_incidence_argument_entries( ) if not isinstance(resolved_preview, Mapping): if target_type_ids is not None: - resolved_preview = { + resolved_preview = preview_cache.get(target_concept_id) or { "concept_id": target_concept_id, "type_ids": list(target_type_ids), } @@ -2149,7 +2295,16 @@ def _accumulate_predicate_incidence_role_expansion( and frame_id not in frames ): continue - frame_type_ids = _resolve_accessible_concept_type_ids(frame_id) + raw_frame_type_ids = entry.get("type_ids") + frame_type_ids = ( + [ + str(type_id).strip() + for type_id in raw_frame_type_ids + if isinstance(type_id, str) and str(type_id).strip() + ] + if isinstance(raw_frame_type_ids, list) + else _resolve_accessible_concept_type_ids(frame_id) + ) if frame_type_ids is None: continue if allowed_node_types and not ( diff --git a/tests/backend/test_adaptive_turn_service.py b/tests/backend/test_adaptive_turn_service.py index 90a0b53ee..db73ab4cb 100644 --- a/tests/backend/test_adaptive_turn_service.py +++ b/tests/backend/test_adaptive_turn_service.py @@ -688,6 +688,7 @@ def test_plain_answer_gets_trusted_scope_and_generic_read_doorway() -> None: assert "#V#person" in system_message assert "#V#org" in system_message assert "all other writes are unavailable" in system_message + assert "Co-participation alone is a qualified candidate" in system_message assert { tool.name for tool in client.calls[0]["available_tools"] } == { diff --git a/tests/backend/test_concept_relation_service_extent_index.py b/tests/backend/test_concept_relation_service_extent_index.py index 826234d52..d8c87ff94 100644 --- a/tests/backend/test_concept_relation_service_extent_index.py +++ b/tests/backend/test_concept_relation_service_extent_index.py @@ -857,7 +857,7 @@ def test_predicate_incidence_materialises_incoming_relations_once( "targets": [target_id], "updated_at": None, } - for index in range(1_205) + for index in range(12_005) ] monkeypatch.setattr( @@ -884,6 +884,20 @@ def test_predicate_incidence_materialises_incoming_relations_once( lambda pipeline: aggregate_calls.append(pipeline) or incoming_rows ), ) + monkeypatch.setattr( + service, + "_load_accessible_preview_document", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("unselected incidence rows must not read previews") + ), + ) + monkeypatch.setattr( + service, + "_attach_direct_type_ids_to_fast_incidence_hits", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("default incidence must not hydrate argument types") + ), + ) payload = service.get_predicate_incidence( concept_id=target_id, @@ -896,5 +910,6 @@ def test_predicate_incidence_materialises_incoming_relations_once( assert len(aggregate_calls) == 1 assert payload["total_predicates"] == 1 assert payload["predicates"][0]["predicate_concept_id"] == "#V#related_to" - assert payload["predicates"][0]["relation_hit_count"] == 1_205 - assert payload["predicates"][0]["object_argument_hit_count"] == 1_205 + assert payload["predicates"][0]["relation_hit_count"] == 12_005 + assert payload["predicates"][0]["grounding_count"] == 12_005 + assert payload["predicates"][0]["object_argument_hit_count"] == 12_005 diff --git a/tests/backend/test_concept_relation_service_uncertainty.py b/tests/backend/test_concept_relation_service_uncertainty.py index 20da4ab4b..c9638f376 100644 --- a/tests/backend/test_concept_relation_service_uncertainty.py +++ b/tests/backend/test_concept_relation_service_uncertainty.py @@ -298,6 +298,7 @@ def test_relation_previews_and_predicate_incidence_groundings_include_type_ids() concept_id="#V#michael_witbrock", argument_index="subject", relation_kind="binary", + include_concept_preview=True, ) rows = incidence_payload.get("predicates") or [] author_row = next( diff --git a/tests/backend/test_internal_mcp_catalogue_builds.py b/tests/backend/test_internal_mcp_catalogue_builds.py index 0527f22a3..def77af3c 100644 --- a/tests/backend/test_internal_mcp_catalogue_builds.py +++ b/tests/backend/test_internal_mcp_catalogue_builds.py @@ -719,6 +719,10 @@ def test_predicate_incidence_input_schema_accepts_target_type_alias(): catalogue = build_default_catalogue() method = catalogue.get("get_predicate_incidence") + assert method.description.startswith( + "Discover predicates actually used around a known anchor or type when " + "relationship coverage is uncertain." + ) payload = { "target_type": "#V#scientific_publication", "relation_kind": "binary", diff --git a/tests/backend/test_predicate_incidence_page_first.py b/tests/backend/test_predicate_incidence_page_first.py new file mode 100644 index 000000000..df504a703 --- /dev/null +++ b/tests/backend/test_predicate_incidence_page_first.py @@ -0,0 +1,481 @@ +from __future__ import annotations + +import asyncio +import inspect +from typing import Any + +import pytest + + +def _hit( + predicate_id: str, + target_id: str, + *, + source_id: str = "#V#anchor", +) -> dict[str, Any]: + return { + "source_concept_id": source_id, + "predicate_concept_id": predicate_id, + "relation_kind": "binary", + "argument_indexes": [1], + "target_value": target_id, + "target_concept_preview": None, + "relation_metadata": { + "relation_id": f"struct::{source_id}::{predicate_id}::{target_id}" + }, + "access_granted": True, + "follow_up_actions": [], + "score": 1.0, + "is_asserted": True, + "relation_state": "asserted", + } + + +def _diagnostics() -> dict[str, Any]: + return { + "mode": "asserted_only", + "include_uncertain": False, + "statuses": None, + } + + +def test_selected_incidence_page_is_hydrated_only_after_skeleton_sort( + monkeypatch, +) -> None: + from src.backend.services import concept_relation_service as service + + hits = [ + _hit("#V#high", "#V#high_target_one"), + _hit("#V#high", "#V#high_target_two"), + _hit("#V#high", "#V#high_target_three"), + _hit("#V#high", "#V#high_target_four"), + _hit("#V#high", "#V#high_target_five"), + _hit("#V#low", "#V#low_target"), + ] + monkeypatch.setattr( + service, + "_collect_all_argument_hits_for_incidence", + lambda **_kwargs: (list(hits), _diagnostics()), + ) + + aggregate_calls: list[dict[str, Any]] = [] + original_aggregate = service._aggregate_predicate_incidence_rows + + def recording_aggregate(**kwargs): + aggregate_calls.append( + { + "predicates": { + hit.get("predicate_concept_id") for hit in kwargs["hits"] + }, + "include_concept_preview": kwargs["include_concept_preview"], + "include_type_counts": kwargs["type_count_options"].include, + } + ) + return original_aggregate(**kwargs) + + monkeypatch.setattr( + service, + "_aggregate_predicate_incidence_rows", + recording_aggregate, + ) + hydrated_predicates: list[set[str]] = [] + + def attach_types(grouped_hits): + selected = { + hit["predicate_concept_id"] + for grouped in grouped_hits.values() + for hit in grouped + } + hydrated_predicates.append(selected) + for grouped in grouped_hits.values(): + for hit in grouped: + hit["target_type_ids"] = [f"{hit['target_value']}_type"] + + monkeypatch.setattr( + service, + "_attach_direct_type_ids_to_fast_incidence_hits", + attach_types, + ) + + def prime_previews(concept_ids, *, include_preview, preview_cache): + assert include_preview is True + pending = list( + dict.fromkeys( + concept_id + for concept_id in concept_ids + if concept_id not in preview_cache + ) + ) + if len(pending) < 2: + return + for concept_id in pending: + preview_cache[concept_id] = { + "concept_id": concept_id, + "name": concept_id, + "kind": "individual", + } + + monkeypatch.setattr(service, "_prime_concept_preview_cache", prime_previews) + monkeypatch.setattr( + service, + "_load_accessible_preview_document", + lambda concept_id: { + "concept_id": concept_id, + "name": concept_id, + "kind": "individual", + }, + ) + + payload = service.get_predicate_incidence( + concept_id="#V#anchor", + relation_kind="binary", + include_concept_preview=True, + include_argument_type_counts=True, + limit=1, + ) + + assert [row["predicate_concept_id"] for row in payload["predicates"]] == ["#V#high"] + assert aggregate_calls == [ + { + "predicates": {"#V#high", "#V#low"}, + "include_concept_preview": False, + "include_type_counts": False, + } + ] + assert hydrated_predicates == [{"#V#high"}] + assert payload["predicates"][0]["predicate_preview"]["concept_id"] == "#V#high" + type_counts = { + row["type_concept_id"]: row + for row in payload["predicates"][0]["argument_type_counts"] + } + fifth_sample = type_counts["#V#high_target_five_type"]["sample_concepts"][0] + assert fifth_sample["name"] == "#V#high_target_five" + + +def test_selected_page_hydration_uses_normalised_predicate_ids(monkeypatch) -> None: + from src.backend.services import concept_relation_service as service + + monkeypatch.setattr( + service, + "_collect_all_argument_hits_for_incidence", + lambda **_kwargs: ( + [_hit(" #V#related_to ", "#V#target")], + _diagnostics(), + ), + ) + + def prime_previews(concept_ids, *, include_preview, preview_cache): + assert include_preview is True + for concept_id in concept_ids: + preview_cache[concept_id] = { + "concept_id": concept_id, + "name": concept_id, + "kind": "predicate", + } + + monkeypatch.setattr(service, "_prime_concept_preview_cache", prime_previews) + + payload = service.get_predicate_incidence( + concept_id="#V#anchor", + relation_kind="binary", + include_concept_preview=True, + limit=1, + ) + + row = payload["predicates"][0] + assert row["predicate_concept_id"] == "#V#related_to" + assert row["predicate_preview"]["concept_id"] == "#V#related_to" + + +@pytest.mark.parametrize( + "sort_by", + [ + "relation_hit_count", + "grounding_count", + "grounded_instance_count", + "predicate", + ], +) +def test_incidence_sort_and_offset_use_complete_skeleton( + monkeypatch, + sort_by: str, +) -> None: + from src.backend.services import concept_relation_service as service + + hits = [ + *[_hit("#V#alpha", f"#V#alpha_{index}") for index in range(5)], + *[_hit("#V#beta", f"#V#beta_{index}") for index in range(3)], + _hit("#V#gamma", "#V#gamma_0"), + ] + monkeypatch.setattr( + service, + "_collect_all_argument_hits_for_incidence", + lambda **_kwargs: (list(hits), _diagnostics()), + ) + + payload = service.get_predicate_incidence( + concept_id="#V#anchor", + relation_kind="binary", + sort_by=sort_by, + offset=1, + limit=1, + ) + + assert payload["total_predicates"] == 3 + assert payload["paging"] == { + "limit": 1, + "offset": 1, + "returned": 1, + "total_available": 3, + } + assert payload["predicates"][0]["predicate_concept_id"] == "#V#beta" + assert payload["predicates"][0]["relation_hit_count"] == 3 + + +def test_type_incidence_preserves_counts_while_hydrating_selected_page( + monkeypatch, +) -> None: + from src.backend.services import concept_relation_service as service + + grouped_hits = { + "#V#instance_one": [ + _hit("#V#high", "#V#shared", source_id="#V#instance_one"), + _hit("#V#low", "#V#other", source_id="#V#instance_one"), + ], + "#V#instance_two": [_hit("#V#high", "#V#shared", source_id="#V#instance_two")], + } + monkeypatch.setattr( + service, + "_resolve_type_incidence_type_ids", + lambda *_args, **_kwargs: ["#V#test_type"], + ) + monkeypatch.setattr( + service, + "_resolve_type_incidence_instance_ids", + lambda _type_ids: list(grouped_hits), + ) + monkeypatch.setattr( + service, + "_collect_type_subject_hits_for_incidence_fast", + lambda **_kwargs: {key: list(value) for key, value in grouped_hits.items()}, + ) + selected_for_types: list[set[str]] = [] + + def attach_types(selected): + selected_for_types.append( + {hit["predicate_concept_id"] for hits in selected.values() for hit in hits} + ) + for hits in selected.values(): + for hit in hits: + hit["target_type_ids"] = ["#V#shared_type"] + + monkeypatch.setattr( + service, + "_attach_direct_type_ids_to_fast_incidence_hits", + attach_types, + ) + monkeypatch.setattr( + service, + "_prime_concept_preview_cache", + lambda concept_ids, *, include_preview, preview_cache: preview_cache.update( + {concept_id: {"concept_id": concept_id} for concept_id in concept_ids} + ), + ) + + payload = service.get_predicate_incidence( + instance_of="#V#test_type", + direct_instances_only=True, + relation_kind="binary", + include_argument_type_counts=True, + limit=1, + ) + + row = payload["predicates"][0] + assert row["predicate_concept_id"] == "#V#high" + assert row["relation_hit_count"] == 2 + assert row["grounding_count"] == 1 + assert row["grounded_instance_count"] == 2 + assert selected_for_types == [{"#V#high"}] + + +def test_role_expansion_reads_only_selected_predicate_frames(monkeypatch) -> None: + from src.backend.services import concept_relation_service as service + + hits = [ + _hit("#V#high", "#V#high_frame_one"), + _hit("#V#high", "#V#high_frame_two"), + _hit("#V#low", "#V#low_frame"), + ] + monkeypatch.setattr( + service, + "_collect_all_argument_hits_for_incidence", + lambda **_kwargs: (list(hits), _diagnostics()), + ) + selected_for_types: list[set[str]] = [] + + def attach_types(selected): + selected_for_types.append( + { + hit["predicate_concept_id"] + for grouped in selected.values() + for hit in grouped + } + ) + for grouped in selected.values(): + for hit in grouped: + hit["target_type_ids"] = ["#V#frame_type"] + + monkeypatch.setattr( + service, + "_attach_direct_type_ids_to_fast_incidence_hits", + attach_types, + ) + monkeypatch.setattr( + service, + "_prime_concept_preview_cache", + lambda *_args, **_kwargs: None, + ) + loaded_frames: list[str] = [] + + def load_frame(concept_id, **_kwargs): + loaded_frames.append(concept_id) + return { + "concept_id": concept_id, + "relationships": {"#V#has_literal_role": ["represented value"]}, + } + + monkeypatch.setattr( + service, + "_load_accessible_relation_subject_document", + load_frame, + ) + + payload = service.get_predicate_incidence( + concept_id="#V#anchor", + relation_kind="binary", + role_expansion_mode="explicit", + role_node_type_filter=["#V#frame_type"], + limit=1, + ) + + assert payload["predicates"][0]["predicate_concept_id"] == "#V#high" + assert selected_for_types == [{"#V#high"}] + assert set(loaded_frames) == {"#V#high_frame_one", "#V#high_frame_two"} + assert "#V#low_frame" not in loaded_frames + + +def test_incoming_incidence_excludes_inaccessible_sources(monkeypatch) -> None: + from src.backend.services import concept_relation_service as service + + monkeypatch.setattr( + service, + "_load_accessible_relation_subject_document", + lambda *_args, **_kwargs: None, + ) + monkeypatch.setattr(service, "should_enforce_access_control", lambda: True) + monkeypatch.setattr( + service, + "filter_accessible_concept_ids", + lambda concept_ids: { + concept_id + for concept_id in concept_ids + if concept_id == "#V#visible_source" + }, + ) + monkeypatch.setattr( + service, + "query_relationship_extent_index", + lambda **_kwargs: ( + [ + { + "source_concept_id": "#V#visible_source", + "predicate_id": "#V#related_to", + "target_value": "#V#anchor", + "target_index": 0, + "source_updated_at": None, + }, + { + "source_concept_id": "#V#hidden_source", + "predicate_id": "#V#related_to", + "target_value": "#V#anchor", + "target_index": 0, + "source_updated_at": None, + }, + ], + 2, + ), + ) + + payload = service.get_predicate_incidence( + concept_id="#V#anchor", + argument_index="object", + predicate_filter=["#V#related_to"], + relation_kind="binary", + ) + + assert payload["total_predicates"] == 1 + assert payload["predicates"][0]["relation_hit_count"] == 1 + assert payload["predicates"][0]["grounding_count"] == 1 + assert payload["predicates"][0]["sample_groundings"] == [ + {"grounding_kind": "concept", "concept_id": "#V#visible_source"} + ] + + +def test_incidence_preview_default_matches_service_internal_mcp_stdio_and_metadata( + monkeypatch, +) -> None: + from src.backend.integrations.internal_mcp import catalogue + from src.backend.mcp_server import mcp_stdio_server + from src.backend.services import concept_relation_service as service + from src.backend.services import tool_metadata_service + + assert ( + inspect.signature(service.get_predicate_incidence) + .parameters["include_concept_preview"] + .default + is False + ) + + observed: list[bool] = [] + + def fake_get_predicate_incidence(**kwargs): + observed.append(kwargs["include_concept_preview"]) + return { + "mode": "entity", + "total_predicates": 0, + "predicates": [], + "paging": {}, + } + + monkeypatch.setattr( + service, "get_predicate_incidence", fake_get_predicate_incidence + ) + catalogue._get_predicate_incidence(concept_id="#V#anchor") + catalogue._get_predicate_incidence( + concept_id="#V#anchor", + include_concept_preview=True, + ) + monkeypatch.setattr( + mcp_stdio_server, + "get_predicate_incidence", + fake_get_predicate_incidence, + ) + asyncio.run( + mcp_stdio_server._handle_get_predicate_incidence({"concept_id": "#V#anchor"}) + ) + asyncio.run( + mcp_stdio_server._handle_get_predicate_incidence( + {"concept_id": "#V#anchor", "include_concept_preview": True} + ) + ) + + monkeypatch.setattr(tool_metadata_service, "_load_from_vontology", dict) + tool_metadata_service.invalidate_cache() + try: + metadata = tool_metadata_service.get_tool_metadata("get_predicate_incidence") + assert metadata.default_payload is not None + assert metadata.default_payload["include_concept_preview"] is False + finally: + tool_metadata_service.invalidate_cache() + + assert observed == [False, True, False, True] From c3f143a173465c2a09d7a3371715cfba4c441623 Mon Sep 17 00:00:00 2001 From: witbrock Date: Sat, 1 Aug 2026 08:00:57 +0100 Subject: [PATCH 13/16] Remove test authority and scope capability guidance --- .../integrations/internal_mcp/catalogue.py | 10 +- src/backend/mcp_server/vontology_mcp.json | 4 +- src/backend/server/routes/von_routes.py | 10 -- src/backend/services/adaptive_turn_service.py | 82 +++----------- .../agent_test_replay_mode_service.py | 57 ---------- src/backend/services/tool_metadata_service.py | 13 ++- tests/backend/test_adaptive_turn_service.py | 100 ++---------------- .../test_agent_test_replay_mode_service.py | 54 ---------- tests/backend/test_internal_mcp_jira_tools.py | 6 -- tests/backend/test_tool_metadata_service.py | 4 + .../test_von_generate_workflow_instances.py | 41 ------- 11 files changed, 45 insertions(+), 336 deletions(-) delete mode 100644 tests/backend/test_agent_test_replay_mode_service.py diff --git a/src/backend/integrations/internal_mcp/catalogue.py b/src/backend/integrations/internal_mcp/catalogue.py index 43539a397..046564799 100644 --- a/src/backend/integrations/internal_mcp/catalogue.py +++ b/src/backend/integrations/internal_mcp/catalogue.py @@ -33859,7 +33859,10 @@ def _build_default_catalogue_core_definitions() -> List[MethodDefinition]: "and pagination. A positive hit proves existence, not enumeration " "completeness. When predicate, direction, or represented-form coverage " "is uncertain, inspect predicate incidence first and then read every " - "fitting exact predicate. " + "fitting exact predicate. When a hit reaches a reified, event, claim, " + "or role node, inspect its represented role predicates before treating " + "another filler as the requested related entity; co-participation alone " + "does not establish that semantic role. " "Verified actors receive actor_effective results; " "payload-only identity is ignored and yields base_publication. " "Truncated actor overlays expose bounded recovery rather than a " @@ -33982,6 +33985,8 @@ def _build_default_catalogue_core_definitions() -> List[MethodDefinition]: "argument_index='any' binary page; read every fitting predicate including " "inverse forms, deduplicate overlaps, and inspect role fillers for matching " "reified nodes. Incidence returns coverage candidates, not answer entities. " + "A filler qualifies only when its represented role establishes the requested " + "relationship; co-participation alone does not. " "Set role_expansion_mode=" "'explicit' with represented node-type or role-predicate filters when " "the immediate neighbour is a reified/event/claim node whose other role " @@ -35568,9 +35573,6 @@ def _build_default_catalogue_external_integration_definitions() -> ( input_schema=_jira_update_issue_input_schema(), output_schema=jira_update_issue_output_schema, category="write", - ordinary_turn_excluded_reason="deployment_global_account", - ordinary_turn_fixed_arguments={"execute": False}, - ordinary_turn_effect=True, timeout_sec=20.0, description=( "Update a Jira issue with safety guardrails. Default dry_run=true (no mutation). " diff --git a/src/backend/mcp_server/vontology_mcp.json b/src/backend/mcp_server/vontology_mcp.json index 6a3bc2598..52cb813cd 100644 --- a/src/backend/mcp_server/vontology_mcp.json +++ b/src/backend/mcp_server/vontology_mcp.json @@ -2117,7 +2117,7 @@ }, { "name": "find_relations_with_argument", - "description": "Find relation assertions where a concept appears in one or more argument positions. The anchor entity must be supplied in concept_id; use argument_index to choose the relation slot instead of inventing subject/object payload fields. Supports exact graph matching and full-text text-relation matching with optional predicate/kind filters and pagination. A positive hit proves existence, not enumeration completeness. When predicate, direction, or represented-form coverage is uncertain, inspect predicate incidence first and then read every fitting exact predicate. Verified actors receive actor_effective results; payload-only identity is ignored and yields base_publication. Truncated actor overlays expose bounded recovery rather than a misleading offset continuation.", + "description": "Find relation assertions where a concept appears in one or more argument positions. The anchor entity must be supplied in concept_id; use argument_index to choose the relation slot instead of inventing subject/object payload fields. Supports exact graph matching and full-text text-relation matching with optional predicate/kind filters and pagination. A positive hit proves existence, not enumeration completeness. When predicate, direction, or represented-form coverage is uncertain, inspect predicate incidence first and then read every fitting exact predicate. When a hit reaches a reified, event, claim, or role node, inspect its represented role predicates before treating another filler as the requested related entity; co-participation alone does not establish that semantic role. Verified actors receive actor_effective results; payload-only identity is ignored and yields base_publication. Truncated actor overlays expose bounded recovery rather than a misleading offset continuation.", "inputSchema": { "type": "object", "properties": { @@ -2320,7 +2320,7 @@ }, { "name": "get_predicate_incidence", - "description": "Discover predicates actually used around a known anchor or type when relationship coverage is uncertain. The anchor entity goes in concept_id. Use before a filtered relation read when a list, count, broad category, or negative lacks established predicate, direction, or represented-form coverage. Start with one small argument_index='any' binary page; read every fitting predicate including inverse forms, deduplicate overlaps, and inspect role fillers for matching reified nodes. Incidence returns coverage candidates, not answer entities. Set role_expansion_mode='explicit' with represented node-type or role-predicate filters when the immediate neighbour is a reified/event/claim node whose other role fillers are the useful retrieval targets.", + "description": "Discover predicates actually used around a known anchor or type when relationship coverage is uncertain. The anchor entity goes in concept_id. Use before a filtered relation read when a list, count, broad category, or negative lacks established predicate, direction, or represented-form coverage. Start with one small argument_index='any' binary page; read every fitting predicate including inverse forms, deduplicate overlaps, and inspect role fillers for matching reified nodes. Incidence returns coverage candidates, not answer entities. A filler qualifies only when its represented role establishes the requested relationship; co-participation alone does not. Set role_expansion_mode='explicit' with represented node-type or role-predicate filters when the immediate neighbour is a reified/event/claim node whose other role fillers are the useful retrieval targets.", "inputSchema": { "type": "object", "properties": { diff --git a/src/backend/server/routes/von_routes.py b/src/backend/server/routes/von_routes.py index 3b453b4b0..cacf29b9b 100644 --- a/src/backend/server/routes/von_routes.py +++ b/src/backend/server/routes/von_routes.py @@ -55,9 +55,6 @@ from ...services.adaptive_turn_service import ( execute_adaptive_turn, ) -from ...services.agent_test_replay_mode_service import ( - resolve_agent_test_trusted_capability_names, -) from ...services.background_task_service import background_task_registry from ...services.workflow_payload_store import is_workflow_payload_blob_ref from ...services.live_request_load import ( @@ -11310,13 +11307,6 @@ def _progress_update(info: dict[str, Any]) -> None: user_namespace=user_namespace, user_concept_id=user_concept_id, org_concept_id=org_concept_id, - agent_test_trusted_capability_names=( - resolve_agent_test_trusted_capability_names( - user_concept_id=user_concept_id, - organisation_concept_id=org_concept_id, - namespace=user_namespace, - ) - ), trusted_argument_values=( {"gmail_profile": request_gmail_profile} if request_gmail_profile diff --git a/src/backend/services/adaptive_turn_service.py b/src/backend/services/adaptive_turn_service.py index ba7015a92..7b8c54002 100644 --- a/src/backend/services/adaptive_turn_service.py +++ b/src/backend/services/adaptive_turn_service.py @@ -1286,10 +1286,14 @@ def _tool_definitions() -> list[ToolDefinition]: description=( "Inspect the capabilities delegated to this turn. Non-exact " "discovery returns a complete unranked compact purpose index, " - "including semantically discovered represented workflows; judge " - "semantic adequacy yourself. Request an exact name to hydrate its " - "canonical argument schema. This is discovery, not a requirement " - "to use any particular capability." + "including semantically discovered represented workflows. Direct " + "tools, small compositions, and represented workflows are peers; " + "representedness does not rank a plan. Judge semantic adequacy " + "yourself. Prefer the smallest plan that can produce the requested " + "work product and evidence; use a workflow when its added composition, " + "verification, or recovery is materially needed. Request an exact " + "name to hydrate its canonical argument schema. This is discovery, " + "not a requirement to use any particular capability." ), input_schema={ "type": "object", @@ -1406,43 +1410,6 @@ def _scope_message( "all other writes are unavailable.\n" "- Remaining effect-capable window before the protected final-answer " f"reserve: {max(0.0, remaining_effect_capable_seconds):.3f} seconds.\n" - f"- {_CAPABILITY_TOOL_NAME} exposes the complete delegated catalogue " - "as an unranked compact purpose index without interpreting the user's " - "intent; judge semantic adequacy yourself, then request an exact name to " - "hydrate its canonical schema. Natural-language discovery also adds " - "actor-accessible executable represented workflows to the same index. " - "Detailed-page query labels are hints, never exclusions.\n" - "- Treat a direct call, a small composition, and a represented workflow " - "as capability plans in one decision space. Choose the least costly plan " - "only after judging that it can produce the material work product and " - "evidence; representedness is not a quality score.\n" - "- Use each candidate's plan, effect, cost, and adequacy evidence. A " - "workflow is warranted only when it adds needed composition, verification, " - "durability, governance, or recovery that a bounded direct call or small " - "composition cannot adequately provide. Several reads alone do not make " - "a workflow necessary.\n" - "- When bounded read-only plans are each plausibly adequate, start with " - "the least costly observable plan and escalate only if its returned " - "evidence exposes a material gap. Do not launch a durable or multi-step " - "workflow merely to hedge uncertainty that a direct read or small " - "composition can test within the turn budget.\n" - "- Match evidence coverage to the requested claim. A positive hit proves " - "existence, not completeness. Do not call a list, count, broad category, " - "or negative complete from one page, predicate, direction, or represented " - "form unless inspected evidence establishes that coverage; inspect the " - "missing scope or state the bound. Stop when the requested work product is " - "adequately supported.\n" - "- Reconcile summaries with grounded items. Deduplicate overlapping facts, " - "separate related artefacts or ambiguous candidates, and state status or " - "time qualifiers only when inspected evidence establishes them.\n" - "- When a matching relation reaches a reified node or related artefact, " - "inspect its roles before including or excluding other participants. " - "Include a filler only when inspected represented evidence establishes it " - "in the requested semantic role. Co-participation alone is a qualified " - "candidate, not the requested relationship.\n" - "- A matched workflow may expose visible component capabilities as " - "simpler alternatives. Their presence does not prove equivalence: inspect " - "the request and escalate when a direct result leaves a material gap.\n" f"- {_INVOKE_TOOL_NAME} invokes any named delegated capability.\n" f"- {_EVIDENCE_INDEX_TOOL_NAME} pages every evidence handle recorded " "for this turn.\n" @@ -1583,7 +1550,6 @@ def ordinary_turn_capability_delegation( *, user_concept_id: str | None, trusted_argument_values: Mapping[str, Any] | None = None, - agent_test_trusted_capability_names: Sequence[str] | None = None, ) -> tuple[str, ...]: """Project the capabilities authorised for an ordinary turn. @@ -1614,18 +1580,9 @@ def trusted_binding_is_present(binding_key: str) -> bool: and not is_unresolved_tool_argument_placeholder(value) ) - def trusted_bindings_are_present(metadata: Mapping[str, Any]) -> bool: - return all( - trusted_binding_is_present(str(binding_key)) - for binding_key in ( - metadata.get("ordinary_turn_trusted_argument_bindings") or {} - ).values() - ) - - methods = gateway.describe_methods() requested = [ name - for name, metadata in methods.items() + for name, metadata in gateway.describe_methods().items() if isinstance(metadata, Mapping) and ( metadata.get("category") == "read" @@ -1643,20 +1600,13 @@ def trusted_bindings_are_present(metadata: Mapping[str, Any]) -> bool: and metadata.get("ordinary_turn_public") is True ) ) - and trusted_bindings_are_present(metadata) + and all( + trusted_binding_is_present(str(binding_key)) + for binding_key in ( + metadata.get("ordinary_turn_trusted_argument_bindings") or {} + ).values() + ) ] - if authenticated: - for name in agent_test_trusted_capability_names or (): - metadata = methods.get(name) - if not isinstance(metadata, Mapping): - continue - if metadata.get("category") != "read" and not ( - metadata.get("category") == "write" - and metadata.get("ordinary_turn_effect") is True - ): - continue - if trusted_bindings_are_present(metadata): - requested.append(name) return _registered_capability_names(gateway, requested) @@ -2884,7 +2834,6 @@ def execute_adaptive_turn( user_concept_id: str | None = None, org_concept_id: str | None = None, trusted_argument_values: Mapping[str, Any] | None = None, - agent_test_trusted_capability_names: Sequence[str] | None = None, workflow_launch_inputs: Mapping[str, Any] | None = None, progress_tracker: Any = None, turn_id: str | None = None, @@ -2962,7 +2911,6 @@ def execute_adaptive_turn( gateway, user_concept_id=user_concept_id, trusted_argument_values=trusted_values, - agent_test_trusted_capability_names=agent_test_trusted_capability_names, ) delegated_lookup = {name.lower(): name for name in delegated_names} workflow_capabilities_by_name: dict[str, Any] = {} diff --git a/src/backend/services/agent_test_replay_mode_service.py b/src/backend/services/agent_test_replay_mode_service.py index daafa7db8..7e9084ddd 100644 --- a/src/backend/services/agent_test_replay_mode_service.py +++ b/src/backend/services/agent_test_replay_mode_service.py @@ -3,7 +3,6 @@ from __future__ import annotations import os -import re from typing import Any, Mapping @@ -11,21 +10,6 @@ AGENT_TEST_SELECTOR_REPLAY_MODE_FAST_PATH = "fast_path" AGENT_TEST_SELECTOR_REPLAY_MODE_REPRESENTED_LLM = "represented_selector_llm" AGENT_TEST_REAL_POSTCONDITION_CRITIC_ENV = "VON_AGENT_TEST_REAL_POSTCONDITION_CRITIC" -AGENT_TEST_TRUSTED_CAPABILITY_NAMES_ENV = ( - "VON_AGENT_TEST_TRUSTED_CAPABILITY_GRANTS" -) -AGENT_TEST_TRUSTED_CAPABILITY_USER_ENV = ( - "VON_AGENT_TEST_TRUSTED_CAPABILITY_GRANT_USER" -) -AGENT_TEST_TRUSTED_CAPABILITY_ORGANISATION_ENV = ( - "VON_AGENT_TEST_TRUSTED_CAPABILITY_GRANT_ORGANISATION" -) -AGENT_TEST_TRUSTED_CAPABILITY_NAMESPACE_ENV = ( - "VON_AGENT_TEST_TRUSTED_CAPABILITY_GRANT_NAMESPACE" -) -_AGENT_TEST_CAPABILITY_NAME_PATTERN = re.compile( - r"^[A-Za-z_][A-Za-z0-9_.:-]{0,127}$" -) def _clean_text(value: Any) -> str: @@ -48,47 +32,6 @@ def agent_test_real_postcondition_critic_enabled( } -def resolve_agent_test_trusted_capability_names( - *, - user_concept_id: str | None, - organisation_concept_id: str | None, - namespace: str | None, - environ: Mapping[str, str] | None = None, -) -> tuple[str, ...]: - """Resolve exact test capabilities only for the configured server scope.""" - - env = environ if environ is not None else os.environ - if _clean_text(env.get("VON_AGENT_TEST_INSTANCE")).lower() not in { - "1", - "true", - "yes", - "on", - }: - return () - - configured_scope = ( - _clean_text(env.get(AGENT_TEST_TRUSTED_CAPABILITY_USER_ENV)), - _clean_text(env.get(AGENT_TEST_TRUSTED_CAPABILITY_ORGANISATION_ENV)), - _clean_text(env.get(AGENT_TEST_TRUSTED_CAPABILITY_NAMESPACE_ENV)), - ) - server_scope = ( - _clean_text(user_concept_id), - _clean_text(organisation_concept_id), - _clean_text(namespace), - ) - if not all(configured_scope) or configured_scope != server_scope: - return () - - raw_names = _clean_text(env.get(AGENT_TEST_TRUSTED_CAPABILITY_NAMES_ENV)) - names = [part.strip() for part in raw_names.split(",")] - if not raw_names or any( - not name or _AGENT_TEST_CAPABILITY_NAME_PATTERN.fullmatch(name) is None - for name in names - ): - return () - return tuple(sorted(set(names))) - - def represented_postcondition_critic_enabled_for_runtime( *, agent_test_instance: bool, diff --git a/src/backend/services/tool_metadata_service.py b/src/backend/services/tool_metadata_service.py index f993f428c..d5acb5b70 100644 --- a/src/backend/services/tool_metadata_service.py +++ b/src/backend/services/tool_metadata_service.py @@ -707,8 +707,11 @@ class ToolRequiredObligationMetadata: "selected concepts only when needed. A hit proves existence, not list " "or count completeness. If predicate, direction, or represented-form " "coverage is not established, inspect small any-direction incidence " - "first, then read every fitting exact predicate. A numeric object index " - "selects one stored slot, not the whole object side." + "first, then read every fitting exact predicate. When a hit reaches a " + "reified, event, claim, or role node, inspect represented role predicates " + "before treating another filler as the requested entity; co-participation " + "alone does not establish that role. A numeric object index selects one " + "stored slot, not the whole object side." ), }, "find_concepts_by_name": { @@ -787,8 +790,10 @@ class ToolRequiredObligationMetadata: "Treat every semantically fitting row, including inverse forms, as a " "coverage candidate; read all exact predicates, deduplicate overlaps, " "and inspect role fillers when a matching row grounds a reified node. " - "Incidence identifies coverage candidates, not the answer entities; a " - "numeric object index selects only one stored slot." + "Incidence identifies coverage candidates, not the answer entities. A " + "filler qualifies only when its represented role establishes the requested " + "relationship; co-participation alone does not. A numeric object index " + "selects only one stored slot." ), }, "get_concept_usage_profile": { diff --git a/tests/backend/test_adaptive_turn_service.py b/tests/backend/test_adaptive_turn_service.py index db73ab4cb..257716547 100644 --- a/tests/backend/test_adaptive_turn_service.py +++ b/tests/backend/test_adaptive_turn_service.py @@ -151,7 +151,7 @@ def _gateway(handler: Any) -> InternalMCPGateway: ) -def test_scope_message_prefers_progressive_low_cost_read_plans() -> None: +def test_scope_message_contains_boundaries_not_selection_policy() -> None: message = _scope_message( TrustedTurnScope( user_concept_id="#V#person", @@ -162,13 +162,10 @@ def test_scope_message_prefers_progressive_low_cost_read_plans() -> None: final_synthesis=False, ) - assert "start with the least costly observable plan" in message - assert "escalate only if its returned evidence exposes a material gap" in message - assert "Several reads alone do not make a workflow necessary" in message - assert "merely to hedge uncertainty" in message - assert "A positive hit proves existence, not completeness" in message - assert "one page, predicate, direction, or represented form" in message - assert "state status or time qualifiers only when inspected evidence" in message + assert "Authenticated actor: #V#person" in message + assert "Effect boundary:" in message + assert "least costly" not in message + assert "representedness" not in message def _gmail_gateway(handler: Any) -> InternalMCPGateway: @@ -688,7 +685,6 @@ def test_plain_answer_gets_trusted_scope_and_generic_read_doorway() -> None: assert "#V#person" in system_message assert "#V#org" in system_message assert "all other writes are unavailable" in system_message - assert "Co-participation alone is a qualified candidate" in system_message assert { tool.name for tool in client.calls[0]["available_tools"] } == { @@ -702,6 +698,10 @@ def test_plain_answer_gets_trusted_scope_and_generic_read_doorway() -> None: for tool in client.calls[0]["available_tools"] if tool.name == "turn_capabilities" ) + assert "complete unranked compact purpose index" in catalogue_tool.description + assert "represented workflows are peers" in catalogue_tool.description + assert "representedness does not rank a plan" in catalogue_tool.description + assert "smallest plan that can produce" in catalogue_tool.description assert set(catalogue_tool.input_schema["properties"]) == { "query", "names", @@ -1180,88 +1180,6 @@ def test_ordinary_delegation_is_actor_capability_not_every_read_method() -> None ) == ("search_arxiv",) -def test_agent_test_exact_names_extend_only_registered_read_or_effect_capability() -> None: - catalogue = MethodCatalogue() - for definition in ( - MethodDefinition( - name="ordinary_read", - handler=lambda **_kwargs: {"success": True}, - input_schema=Schema(allow_unknown=True), - category="read", - ), - MethodDefinition( - name="deployment_read", - handler=lambda **_kwargs: {"success": True}, - input_schema=Schema(allow_unknown=True), - category="read", - ordinary_turn_excluded_reason="deployment_global_account", - ), - MethodDefinition( - name="unmarked_write", - handler=lambda **_kwargs: {"success": True}, - input_schema=Schema(allow_unknown=True), - category="write", - ), - MethodDefinition( - name="bounded_effect", - handler=lambda **_kwargs: {"success": True}, - input_schema=Schema(allow_unknown=True), - category="write", - ordinary_turn_excluded_reason="deployment_global_account", - ordinary_turn_effect=True, - ), - MethodDefinition( - name="bound_deployment_read", - handler=lambda **_kwargs: {"success": True}, - input_schema=Schema(allow_unknown=True), - category="read", - ordinary_turn_excluded_reason="deployment_global_account", - ordinary_turn_trusted_argument_bindings={"profile": "profile"}, - ), - ): - catalogue.register(definition) - gateway = InternalMCPGateway( - catalogue=catalogue, - transport=InternalMCPTransport(read_timeout_sec=1.0), - enabled=True, - ) - - default = ordinary_turn_capability_delegation( - gateway, - user_concept_id="#V#person", - ) - assert default == ("ordinary_read",) - assert ordinary_turn_capability_delegation( - gateway, - user_concept_id="#V#person", - agent_test_trusted_capability_names=(), - ) == default - - granted = ordinary_turn_capability_delegation( - gateway, - user_concept_id="#V#person", - agent_test_trusted_capability_names=( - "deployment_read", - "unmarked_write", - "bounded_effect", - "bound_deployment_read", - "unknown_capability", - ), - ) - assert granted == ("bounded_effect", "deployment_read", "ordinary_read") - assert ordinary_turn_capability_delegation( - gateway, - user_concept_id="#V#person", - trusted_argument_values={"profile": "represented-profile"}, - agent_test_trusted_capability_names=("bound_deployment_read",), - ) == ("bound_deployment_read", "ordinary_read") - assert ordinary_turn_capability_delegation( - gateway, - user_concept_id=None, - agent_test_trusted_capability_names=("deployment_read", "bounded_effect"), - ) == () - - def test_server_bound_capability_argument_is_not_model_visible() -> None: gateway = _gmail_gateway(lambda **_kwargs: {"success": True}) delegated = ordinary_turn_capability_delegation( diff --git a/tests/backend/test_agent_test_replay_mode_service.py b/tests/backend/test_agent_test_replay_mode_service.py deleted file mode 100644 index 44dc56d2c..000000000 --- a/tests/backend/test_agent_test_replay_mode_service.py +++ /dev/null @@ -1,54 +0,0 @@ -from __future__ import annotations - -from src.backend.services.agent_test_replay_mode_service import ( - resolve_agent_test_trusted_capability_names, -) - - -_TRUSTED_SCOPE = { - "user_concept_id": "#V#user", - "organisation_concept_id": "#V#org", - "namespace": "#V#user@org", -} - - -def _trusted_capability_environ() -> dict[str, str]: - return { - "VON_AGENT_TEST_INSTANCE": "1", - "VON_AGENT_TEST_TRUSTED_CAPABILITY_GRANTS": ( - "jira_update_issue,jira_get_issue,jira_get_issue" - ), - "VON_AGENT_TEST_TRUSTED_CAPABILITY_GRANT_USER": "#V#user", - "VON_AGENT_TEST_TRUSTED_CAPABILITY_GRANT_ORGANISATION": "#V#org", - "VON_AGENT_TEST_TRUSTED_CAPABILITY_GRANT_NAMESPACE": "#V#user@org", - } - - -def test_agent_test_trusted_capabilities_require_exact_server_scope() -> None: - assert resolve_agent_test_trusted_capability_names( - **_TRUSTED_SCOPE, - environ=_trusted_capability_environ(), - ) == ("jira_get_issue", "jira_update_issue") - - mismatches = [] - for key in _TRUSTED_SCOPE: - scope = dict(_TRUSTED_SCOPE) - scope[key] = scope[key] + "_other" - mismatches.append((scope, _trusted_capability_environ())) - missing_scope = _trusted_capability_environ() - missing_scope.pop("VON_AGENT_TEST_TRUSTED_CAPABILITY_GRANT_NAMESPACE") - mismatches.append((dict(_TRUSTED_SCOPE), missing_scope)) - outside_agent_test = _trusted_capability_environ() - outside_agent_test.pop("VON_AGENT_TEST_INSTANCE") - mismatches.append((dict(_TRUSTED_SCOPE), outside_agent_test)) - malformed_names = _trusted_capability_environ() - malformed_names["VON_AGENT_TEST_TRUSTED_CAPABILITY_GRANTS"] = ( - "jira_get_issue,not a capability" - ) - mismatches.append((dict(_TRUSTED_SCOPE), malformed_names)) - - for scope, environ in mismatches: - assert resolve_agent_test_trusted_capability_names( - **scope, - environ=environ, - ) == () diff --git a/tests/backend/test_internal_mcp_jira_tools.py b/tests/backend/test_internal_mcp_jira_tools.py index 56fe558fe..f66da5923 100644 --- a/tests/backend/test_internal_mcp_jira_tools.py +++ b/tests/backend/test_internal_mcp_jira_tools.py @@ -139,12 +139,6 @@ def test_jira_methods_registered_in_catalogue(): "fields", "expand", ) - update_definition = catalogue.get("jira_update_issue") - assert update_definition.ordinary_turn_excluded_reason == ( - "deployment_global_account" - ) - assert update_definition.ordinary_turn_effect is True - assert update_definition.ordinary_turn_fixed_arguments == {"execute": False} def test_jira_handlers_require_minimum_fields(): diff --git a/tests/backend/test_tool_metadata_service.py b/tests/backend/test_tool_metadata_service.py index db2b491a6..e27687898 100644 --- a/tests/backend/test_tool_metadata_service.py +++ b/tests/backend/test_tool_metadata_service.py @@ -272,6 +272,7 @@ def test_relation_read_hints_start_bounded_without_losing_negative_completeness( assert "coverage candidates, not the answer entities" in ( incidence.planner_hint or "" ) + assert "co-participation alone does not" in (incidence.planner_hint or "") assert "selects only one stored slot" in (incidence.planner_hint or "") assert relation_read.default_payload == { "include_concept_preview": False, @@ -285,6 +286,9 @@ def test_relation_read_hints_start_bounded_without_losing_negative_completeness( ) assert "small any-direction incidence" in (relation_read.planner_hint or "") assert "every fitting exact predicate" in (relation_read.planner_hint or "") + assert "co-participation alone does not" in ( + relation_read.planner_hint or "" + ) assert "not the whole object side" in (relation_read.planner_hint or "") assert "small predicate-filtered relation read" in ( concept_fetch.planner_hint or "" diff --git a/tests/backend/test_von_generate_workflow_instances.py b/tests/backend/test_von_generate_workflow_instances.py index 1ac607645..c7403d0d2 100644 --- a/tests/backend/test_von_generate_workflow_instances.py +++ b/tests/backend/test_von_generate_workflow_instances.py @@ -259,47 +259,6 @@ def test_generate_canonicalises_actor_scope_for_the_adaptive_turn( assert adaptive_call["user_namespace"] == "#V#michael_witbrock@sail_lab" -def test_generate_resolves_agent_test_capabilities_from_canonical_scope( - app: Flask, - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setenv("VON_AGENT_TEST_INSTANCE", "1") - monkeypatch.setenv( - "VON_AGENT_TEST_TRUSTED_CAPABILITY_GRANTS", - "jira_update_issue,jira_get_issue", - ) - monkeypatch.setenv( - "VON_AGENT_TEST_TRUSTED_CAPABILITY_GRANT_USER", - "#V#michael_witbrock", - ) - monkeypatch.setenv( - "VON_AGENT_TEST_TRUSTED_CAPABILITY_GRANT_ORGANISATION", - "#V#sail_lab", - ) - monkeypatch.setenv( - "VON_AGENT_TEST_TRUSTED_CAPABILITY_GRANT_NAMESPACE", - "#V#michael_witbrock@sail_lab", - ) - - response = app.test_client().post( - "/von/generate", - json={ - "prompt": "Read the authorised Jira issue.", - "user_id": "#V#spoofed_user", - "org_id": "#V#spoofed_org", - }, - ) - - assert response.status_code == 200 - adaptive_call = app.config["_ADAPTIVE_TURN_CALLS"][-1] - assert adaptive_call["agent_test_trusted_capability_names"] == ( - "jira_get_issue", - "jira_update_issue", - ) - assert adaptive_call["user_concept_id"] == "#V#michael_witbrock" - assert adaptive_call["org_concept_id"] == "#V#sail_lab" - - def test_generate_passes_authorised_workflow_inputs_to_adaptive_capabilities( app: Flask, ) -> None: From d81a56b65f5da1a65aa36b54e61ebe2bd22d2679 Mon Sep 17 00:00:00 2001 From: witbrock Date: Sat, 1 Aug 2026 09:13:00 +0100 Subject: [PATCH 14/16] Complete general capability discovery and relation coverage fix --- .../integrations/internal_mcp/catalogue.py | 31 +- src/backend/mcp_server/mcp_stdio_server.py | 2 +- src/backend/mcp_server/vontology_mcp.json | 4 +- src/backend/services/adaptive_turn_service.py | 188 +++++++++-- .../services/concept_relation_service.py | 223 +++++++------ .../relationship_extent_index_service.py | 49 ++- src/backend/services/tool_metadata_service.py | 8 +- tests/backend/test_adaptive_turn_service.py | 292 ++++++++++++++---- ...t_concept_relation_service_extent_index.py | 220 +++++++++++-- ...st_concept_relation_service_uncertainty.py | 86 +++++- ...internal_mcp_scoped_assertion_authority.py | 39 +++ .../test_relationship_extent_index_service.py | 16 + 12 files changed, 918 insertions(+), 240 deletions(-) diff --git a/src/backend/integrations/internal_mcp/catalogue.py b/src/backend/integrations/internal_mcp/catalogue.py index 046564799..d51fe17ed 100644 --- a/src/backend/integrations/internal_mcp/catalogue.py +++ b/src/backend/integrations/internal_mcp/catalogue.py @@ -323,6 +323,16 @@ def _annotate_bounded_relation_lookup( result = dict(payload) if not bool(result.get("total_hits_is_lower_bound")): return result + diagnostics = result.get("relation_query_diagnostics") + actor_overlay_truncated = bool( + isinstance(diagnostics, Mapping) + and ( + diagnostics.get("scoped_concept_query_truncated") + or diagnostics.get("actor_effective_text_query_truncated") + ) + ) + if not actor_overlay_truncated: + return result paging = dict(result.get("paging") or {}) paging.update( @@ -504,7 +514,7 @@ def _find_relations_with_argument(**kwargs): relation_kind=kwargs.get("relation_kind"), scope=kwargs.get("scope"), include_text_snippets=bool(kwargs.get("include_text_snippets", False)), - include_concept_preview=bool(kwargs.get("include_concept_preview", True)), + include_concept_preview=bool(kwargs.get("include_concept_preview", False)), limit=kwargs.get("limit"), offset=kwargs.get("offset"), sort_by=kwargs.get("sort_by"), @@ -9842,6 +9852,8 @@ def _predicate_incidence_output_schema() -> Schema: "type_ids_considered": (list, type(None)), "instance_count_considered": (int, type(None)), "direct_instances_only": (bool, type(None)), + "coverage_complete": (bool, type(None)), + "counts_are_lower_bounds": (bool, type(None)), "uncertainty_diagnostics": (dict, type(None)), }, allow_unknown=True, @@ -9853,7 +9865,9 @@ def _predicate_incidence_output_schema() -> Schema: "sample_groundings, optional argument_type_counts/untyped/literal/inaccessible " "argument counters, optional role_expansion summaries, and in type mode " "grounded_instance_count/sample_instances), plus role_expansions, paging metadata, " - "and optional typed/uncertainty diagnostics." + "and optional typed/uncertainty diagnostics. coverage_complete=false " + "and counts_are_lower_bounds=true report an unavailable coverage path " + "rather than presenting a partial incidence result as exhaustive." ), ) @@ -33859,7 +33873,11 @@ def _build_default_catalogue_core_definitions() -> List[MethodDefinition]: "and pagination. A positive hit proves existence, not enumeration " "completeness. When predicate, direction, or represented-form coverage " "is uncertain, inspect predicate incidence first and then read every " - "fitting exact predicate. When a hit reaches a reified, event, claim, " + "fitting exact predicate. For list, count, broad-category, or negative " + "claims, pass all semantically fitting predicate IDs together in one " + "predicate_filter read unless the incidence evidence distinguishes " + "their coverage; do not choose only the most obvious label. When a hit " + "reaches a reified, event, claim, " "or role node, inspect its represented role predicates before treating " "another filler as the requested related entity; co-participation alone " "does not establish that semantic role. " @@ -33982,8 +34000,11 @@ def _build_default_catalogue_core_definitions() -> List[MethodDefinition]: "filtered relation read when a list, " "count, broad category, or negative lacks established predicate, " "direction, or represented-form coverage. Start with one small " - "argument_index='any' binary page; read every fitting predicate including " - "inverse forms, deduplicate overlaps, and inspect role fillers for matching " + "argument_index='any' binary page. For list, count, broad-category, or " + "negative claims, pass every semantically fitting returned " + "predicate_concept_id, including inverse forms, together in one exact " + "predicate_filter read; do not choose only the most obvious label. " + "Deduplicate overlaps and inspect role fillers for matching " "reified nodes. Incidence returns coverage candidates, not answer entities. " "A filler qualifies only when its represented role establishes the requested " "relationship; co-participation alone does not. " diff --git a/src/backend/mcp_server/mcp_stdio_server.py b/src/backend/mcp_server/mcp_stdio_server.py index 68dffdd83..6a5b2c67f 100644 --- a/src/backend/mcp_server/mcp_stdio_server.py +++ b/src/backend/mcp_server/mcp_stdio_server.py @@ -2177,7 +2177,7 @@ async def _handle_find_relations_with_argument( arguments.get("include_text_snippets", False) ), include_concept_preview=bool( - arguments.get("include_concept_preview", True) + arguments.get("include_concept_preview", False) ), limit=arguments.get("limit"), offset=arguments.get("offset"), diff --git a/src/backend/mcp_server/vontology_mcp.json b/src/backend/mcp_server/vontology_mcp.json index 52cb813cd..cd7fb5e10 100644 --- a/src/backend/mcp_server/vontology_mcp.json +++ b/src/backend/mcp_server/vontology_mcp.json @@ -2117,7 +2117,7 @@ }, { "name": "find_relations_with_argument", - "description": "Find relation assertions where a concept appears in one or more argument positions. The anchor entity must be supplied in concept_id; use argument_index to choose the relation slot instead of inventing subject/object payload fields. Supports exact graph matching and full-text text-relation matching with optional predicate/kind filters and pagination. A positive hit proves existence, not enumeration completeness. When predicate, direction, or represented-form coverage is uncertain, inspect predicate incidence first and then read every fitting exact predicate. When a hit reaches a reified, event, claim, or role node, inspect its represented role predicates before treating another filler as the requested related entity; co-participation alone does not establish that semantic role. Verified actors receive actor_effective results; payload-only identity is ignored and yields base_publication. Truncated actor overlays expose bounded recovery rather than a misleading offset continuation.", + "description": "Find relation assertions where a concept appears in one or more argument positions. The anchor entity must be supplied in concept_id; use argument_index to choose the relation slot instead of inventing subject/object payload fields. Supports exact graph matching and full-text text-relation matching with optional predicate/kind filters and pagination. A positive hit proves existence, not enumeration completeness. When predicate, direction, or represented-form coverage is uncertain, inspect predicate incidence first and then read every fitting exact predicate. For list, count, broad-category, or negative claims, pass all semantically fitting predicate IDs together in one predicate_filter read unless the incidence evidence distinguishes their coverage; do not choose only the most obvious label. When a hit reaches a reified, event, claim, or role node, inspect its represented role predicates before treating another filler as the requested related entity; co-participation alone does not establish that semantic role. Verified actors receive actor_effective results; payload-only identity is ignored and yields base_publication. Truncated actor overlays expose bounded recovery rather than a misleading offset continuation.", "inputSchema": { "type": "object", "properties": { @@ -2320,7 +2320,7 @@ }, { "name": "get_predicate_incidence", - "description": "Discover predicates actually used around a known anchor or type when relationship coverage is uncertain. The anchor entity goes in concept_id. Use before a filtered relation read when a list, count, broad category, or negative lacks established predicate, direction, or represented-form coverage. Start with one small argument_index='any' binary page; read every fitting predicate including inverse forms, deduplicate overlaps, and inspect role fillers for matching reified nodes. Incidence returns coverage candidates, not answer entities. A filler qualifies only when its represented role establishes the requested relationship; co-participation alone does not. Set role_expansion_mode='explicit' with represented node-type or role-predicate filters when the immediate neighbour is a reified/event/claim node whose other role fillers are the useful retrieval targets.", + "description": "Discover predicates actually used around a known anchor or type when relationship coverage is uncertain. The anchor entity goes in concept_id. Use before a filtered relation read when a list, count, broad category, or negative lacks established predicate, direction, or represented-form coverage. Start with one small argument_index='any' binary page. For list, count, broad-category, or negative claims, pass every semantically fitting returned predicate_concept_id, including inverse forms, together in one exact predicate_filter read; do not choose only the most obvious label. Deduplicate overlaps and inspect role fillers for matching reified nodes. Incidence returns coverage candidates, not answer entities. A filler qualifies only when its represented role establishes the requested relationship; co-participation alone does not. Set role_expansion_mode='explicit' with represented node-type or role-predicate filters when the immediate neighbour is a reified/event/claim node whose other role fillers are the useful retrieval targets.", "inputSchema": { "type": "object", "properties": { diff --git a/src/backend/services/adaptive_turn_service.py b/src/backend/services/adaptive_turn_service.py index 7b8c54002..3cfd07d7f 100644 --- a/src/backend/services/adaptive_turn_service.py +++ b/src/backend/services/adaptive_turn_service.py @@ -357,6 +357,17 @@ def _compact_capability_selection_profiles( ) if adequacy_sources: compact_selection["adequacy_sources"] = adequacy_sources + existing_adequacy_sources = selection.get("adequacy_sources") + if ( + "adequacy_sources" not in compact_selection + and isinstance(existing_adequacy_sources, Sequence) + and not isinstance(existing_adequacy_sources, (str, bytes, bytearray)) + ): + compact_selection["adequacy_sources"] = [ + str(item) + for item in existing_adequacy_sources + if str(item).strip() + ] compact["selection"] = compact_selection return compact @@ -378,13 +389,10 @@ def _capability_purpose_index( first_sentence_end = re.search(r"[.!?](?=(?:\s+[A-Z#])|$)", purpose) if first_sentence_end is not None: purpose = purpose[: first_sentence_end.end()] - if len(purpose) > _CAPABILITY_PURPOSE_MAX_CHARS: - prefix = purpose[: _CAPABILITY_PURPOSE_MAX_CHARS - 3] - if not prefix.endswith(" ") and not purpose[len(prefix)].isspace(): - word_boundary = prefix.rfind(" ") - if word_boundary > 0: - prefix = prefix[:word_boundary] - purpose = prefix.rstrip() + "..." + purpose = _truncate_capability_purpose( + purpose, + max_chars=_CAPABILITY_PURPOSE_MAX_CHARS, + ) entry: dict[str, Any] = {"name": name, "purpose": purpose} plan_profile = capability.get("plan_profile") shape = ( @@ -413,6 +421,59 @@ def _capability_purpose_index( } +def _truncate_capability_purpose(purpose: str, *, max_chars: int) -> str: + if len(purpose) <= max_chars: + return purpose + if max_chars <= 3: + return purpose[:max_chars] + prefix = purpose[: max_chars - 3] + if not prefix.endswith(" ") and not purpose[len(prefix)].isspace(): + word_boundary = prefix.rfind(" ") + if word_boundary > 0: + prefix = prefix[:word_boundary] + return prefix.rstrip() + "..." + + +def _capability_purpose_index_with_limit( + purpose_index: Mapping[str, Any], + *, + max_chars: int, +) -> dict[str, Any]: + """Keep every purpose entry while compacting only its descriptive text.""" + + compact = dict(purpose_index) + entries = purpose_index.get("entries") + compact_entries: list[dict[str, Any]] = [] + if isinstance(entries, Sequence) and not isinstance( + entries, + (str, bytes, bytearray), + ): + for raw_entry in entries: + if not isinstance(raw_entry, Mapping): + continue + entry = dict(raw_entry) + purpose = str(entry.get("purpose") or "") + if purpose and max_chars > 0: + entry["purpose"] = _truncate_capability_purpose( + purpose, + max_chars=max_chars, + ) + else: + entry.pop("purpose", None) + compact_entries.append(entry) + compact.update( + { + "entries": compact_entries, + "purpose_projection": ( + "first_authored_sentence_model_context_compacted" + ), + "purpose_max_chars": max_chars, + "purpose_text_compacted_for_model_context": True, + } + ) + return compact + + def _capability_schema_reference( capability: Mapping[str, Any], *, @@ -468,6 +529,22 @@ def _capability_schema_reference( return compact +def _capability_discovery_reference( + capability: Mapping[str, Any], +) -> dict[str, Any]: + """Expose compact selection facts while deferring exact hydration.""" + + compact = { + key: capability.get(key) + for key in ("name", "query_match") + if key in capability + } + selection = _compact_capability_selection_profiles(capability).get("selection") + if isinstance(selection, Mapping): + compact["selection"] = dict(selection) + return compact + + def _capability_schema_focused_projection( capability: Mapping[str, Any], ) -> dict[str, Any]: @@ -510,8 +587,9 @@ def _bounded_capability_catalogue_output( raw_next_offset = value.get("next_offset") if not isinstance(raw_next_offset, int) or isinstance(raw_next_offset, bool): raw_next_offset = None + exact_name_page = value.get("catalogue_scope") == "requested_exact_names" exact_singleton_page = ( - value.get("catalogue_scope") == "requested_exact_names" + exact_name_page and value.get("total") == 1 and len(capabilities) == 1 and raw_next_offset is None @@ -556,7 +634,11 @@ def assemble( "omitted_page_entry_count": omitted_page_entry_count, } if omitted_page_entry_count or schema_reference_count: - projection["reason"] = "model_context_budget" + projection["reason"] = ( + "exact_name_hydration_required" + if not exact_name_page + else "model_context_budget" + ) return { **base, "next_offset": next_offset, @@ -596,9 +678,55 @@ def assemble( schema_reference_count=schema_reference_count, ) if len(_json_bytes(bounded)) > max_bytes: - return {} + try: + maximum_purpose_chars = max( + 0, + int(purpose_index.get("purpose_max_chars") or 0), + ) + except (TypeError, ValueError): + maximum_purpose_chars = 0 + best_fit: tuple[dict[str, Any], dict[str, Any]] | None = None + lower = 0 + upper = maximum_purpose_chars + while lower <= upper: + candidate_limit = (lower + upper) // 2 + candidate_base = { + **base, + "purpose_index": _capability_purpose_index_with_limit( + purpose_index, + max_chars=candidate_limit, + ), + } + base = candidate_base + candidate = assemble( + included, + full_schema_count=full_schema_count, + schema_reference_count=schema_reference_count, + ) + if len(_json_bytes(candidate)) <= max_bytes: + best_fit = candidate_base, candidate + lower = candidate_limit + 1 + else: + upper = candidate_limit - 1 + if best_fit is None: + return {} + base, bounded = best_fit for capability in capabilities: + if not exact_name_page: + reference = _capability_discovery_reference(capability) + reference_candidate = assemble( + [*included, reference], + full_schema_count=full_schema_count, + schema_reference_count=schema_reference_count + 1, + ) + if len(_json_bytes(reference_candidate)) > max_bytes: + break + included.append(reference) + schema_reference_count += 1 + bounded = reference_candidate + continue + full_candidate = assemble( [*included, capability], full_schema_count=full_schema_count + 1, @@ -1292,7 +1420,8 @@ def _tool_definitions() -> list[ToolDefinition]: "yourself. Prefer the smallest plan that can produce the requested " "work product and evidence; use a workflow when its added composition, " "verification, or recovery is materially needed. Request an exact " - "name to hydrate its canonical argument schema. This is discovery, " + "name to hydrate its canonical description, planner guidance, plan " + "and effect facts, and argument schema. This is discovery, " "not a requirement to use any particular capability." ), input_schema={ @@ -1335,7 +1464,9 @@ def _tool_definitions() -> list[ToolDefinition]: name=_EVIDENCE_TOOL_NAME, description=( "Read a selected bounded slice of a prior tool result by its " - "turn-scoped evidence handle. Select with a JSON pointer, a text " + "turn-scoped evidence handle. Evidence IDs are opaque: copy the " + "complete evidence_id exactly from the result or evidence index; " + "never reconstruct or shorten it. Select with a JSON pointer, a text " "query, an offset, or any combination. Repeated calls may inspect " "different portions; the raw result is not discarded. Omit " "json_pointer, use the RFC root pointer (an empty string), or use " @@ -1691,6 +1822,7 @@ def _capability_catalogue( *, workflow_capabilities: Sequence[Any] = (), workflow_discovery: Mapping[str, Any] | None = None, + capability_details_by_name: MutableMapping[str, dict[str, Any]] | None = None, ) -> dict[str, Any]: query = str(payload.get("query") or "").strip().lower() requested_names = payload.get("names") @@ -2262,8 +2394,18 @@ def workflow_sort_key(item: Mapping[str, Any]) -> tuple[Any, ...]: or item["selection"].get("frontier_status") != "declared_dominated" ) ) - page = selected[offset : offset + limit] - next_offset = offset + len(page) + full_page = selected[offset : offset + limit] + if capability_details_by_name is not None: + for capability in full_page: + capability_name = str(capability.get("name") or "").strip().lower() + if capability_name: + capability_details_by_name[capability_name] = dict(capability) + page = ( + full_page + if exact_names + else [_capability_discovery_reference(item) for item in full_page] + ) + next_offset = offset + len(full_page) return { "schema_version": _CAPABILITY_CATALOGUE_SCHEMA_VERSION, "success": True, @@ -4022,24 +4164,8 @@ def enter_answer_only(reason: str) -> None: workflow_capabilities_by_name.values() ), workflow_discovery=latest_workflow_discovery, + capability_details_by_name=catalogued_capabilities_by_name, ) - raw_catalogued_capabilities = output.get("capabilities") - if isinstance( - raw_catalogued_capabilities, Sequence - ) and not isinstance( - raw_catalogued_capabilities, - (str, bytes, bytearray), - ): - for raw_capability in raw_catalogued_capabilities: - if not isinstance(raw_capability, Mapping): - continue - catalogued_name = str( - raw_capability.get("name") or "" - ).strip() - if catalogued_name: - catalogued_capabilities_by_name[ - catalogued_name.lower() - ] = dict(raw_capability) raw_selection_policy = output.get("selection_policy") latest_capability_selection_policy = ( dict(raw_selection_policy) diff --git a/src/backend/services/concept_relation_service.py b/src/backend/services/concept_relation_service.py index 32ce9e895..47ebbe9b5 100644 --- a/src/backend/services/concept_relation_service.py +++ b/src/backend/services/concept_relation_service.py @@ -385,7 +385,7 @@ def find_relations_with_argument( preview_cache: Dict[str, Optional[Dict[str, Any]]] = {} hits: List[Dict[str, Any]] = [] - incoming_asserted_binary_hits: List[Dict[str, Any]] = [] + asserted_binary_hits: List[Dict[str, Any]] = [] scoped_concept_query_truncated = False actor_effective_text_query_truncated = False @@ -396,21 +396,6 @@ def find_relations_with_argument( if include_asserted_rows and include_structural and subject_doc: relationships = (subject_doc.get("relationships") or {}) if subject_doc else {} - _prime_concept_preview_cache( - [ - resolved_concept_id, - *( - target - for predicate_id, raw_targets in relationships.items() - if include_arg1 - and _predicate_matches_terms(predicate_id, predicate_terms) - for target in _normalise_relationship_targets(raw_targets) - if isinstance(target, str) and target.startswith("#V#") - ), - ], - include_preview=include_concept_preview, - preview_cache=preview_cache, - ) source_updated_at = ( _isoformat(subject_doc.get("updated_at")) if subject_doc else None ) @@ -420,11 +405,6 @@ def find_relations_with_argument( targets = _normalise_relationship_targets(raw_targets) if not targets: continue - source_preview = _resolve_concept_preview( - resolved_concept_id, - include_concept_preview, - preview_cache, - ) for target_index, target_value in enumerate(targets): matched_indexes = [_ARG_INDEX_SUBJECT] arg_target_index = _ARG_INDEX_FIRST_OBJECT + target_index @@ -432,31 +412,19 @@ def find_relations_with_argument( matched_indexes.append(arg_target_index) if not _argument_indexes_match(matched_indexes, argument_filter): continue - target_preview = None - if ( - include_concept_preview - and isinstance(target_value, str) - and target_value.startswith("#V#") - ): - target_preview = _resolve_concept_preview( - target_value, - True, - preview_cache, - ) hit = { "source_concept_id": resolved_concept_id, "predicate_concept_id": predicate_id, "relation_kind": "binary", "argument_indexes": matched_indexes, "target_value": target_value, - "target_concept_preview": target_preview, + "target_concept_preview": None, "relation_metadata": { "relation_id": f"struct::{resolved_concept_id}::{predicate_id}::{target_index}", "updated_at": source_updated_at, "match_type": "exact", }, - "access_granted": source_preview is not None - or not include_concept_preview, + "access_granted": not include_concept_preview, "follow_up_actions": _build_follow_up_actions( [target_value], exclude={resolved_concept_id}, @@ -465,9 +433,8 @@ def find_relations_with_argument( "is_asserted": True, "relation_state": "asserted", } - if include_concept_preview: - hit["source_concept_preview"] = source_preview hits.append(hit) + asserted_binary_hits.append(hit) if include_asserted_rows and include_structural and include_arg2_or_later: incoming_candidates: List[Tuple[str, Any, int, Any]] = [] @@ -519,7 +486,11 @@ def find_relations_with_argument( exact_index_filter = ( {"predicate_id": exact_predicate_ids[0]} if len(exact_predicate_ids) == 1 - else {} + else ( + {"predicate_ids": exact_predicate_ids} + if exact_predicate_ids + else {} + ) ) index_rows, index_total = query_relationship_extent_index( **exact_index_filter, @@ -569,11 +540,13 @@ def find_relations_with_argument( ), "bounded": bounded, "complete": complete, + "fallback_reason": bounded_index_diagnostics.get("reason"), **( { "stop_reason": bounded_index_diagnostics.get( "stop_reason" - ), + ) + or bounded_index_diagnostics.get("reason"), "has_more": bool( bounded_index_diagnostics.get("has_more") ), @@ -677,50 +650,13 @@ def find_relations_with_argument( else: incoming_asserted_binary_diagnostics.update( { - "path": "canonical_aggregation", + "path": "relationship_extent_index_unavailable", "fallback_reason": ("relationship_extent_index_unavailable"), + "bounded": False, + "complete": False, + "stop_reason": "relationship_extent_index_unavailable", } ) - incoming_pipeline = [ - {"$match": {"relationships": {"$type": "object"}}}, - { - "$project": { - "concept_id": 1, - "updated_at": 1, - "relationship_items": {"$objectToArray": "$relationships"}, - } - }, - {"$unwind": "$relationship_items"}, - { - "$project": { - "concept_id": 1, - "updated_at": 1, - "predicate": "$relationship_items.k", - "targets": "$relationship_items.v", - } - }, - {"$match": {"targets": resolved_concept_id}}, - ] - for row in ConceptsRepository.aggregate(incoming_pipeline): - incoming_asserted_binary_diagnostics["canonical_rows_returned"] += 1 - source_id = row.get("concept_id") - if not isinstance(source_id, str) or not source_id.strip(): - continue - source_id = source_id.strip() - targets = _normalise_relationship_targets(row.get("targets")) - if not targets: - continue - for target_index, target_value in enumerate(targets): - if target_value != resolved_concept_id: - continue - incoming_candidates.append( - ( - source_id, - row.get("predicate"), - target_index, - row.get("updated_at"), - ) - ) for ( source_id, @@ -755,7 +691,7 @@ def find_relations_with_argument( "relation_state": "asserted", } hits.append(hit) - incoming_asserted_binary_hits.append(hit) + asserted_binary_hits.append(hit) if ( context_view == "actor_effective" @@ -1032,34 +968,43 @@ def find_relations_with_argument( ) if include_concept_preview: paged_hit_ids = {id(hit) for hit in paged_hits} - paged_incoming_hits = [ - hit for hit in incoming_asserted_binary_hits if id(hit) in paged_hit_ids + paged_asserted_binary_hits = [ + hit for hit in asserted_binary_hits if id(hit) in paged_hit_ids ] - if paged_incoming_hits: + if paged_asserted_binary_hits: _prime_concept_preview_cache( [ - resolved_concept_id, *( - str(hit.get("source_concept_id") or "") - for hit in paged_incoming_hits + related_id + for hit in paged_asserted_binary_hits + for related_id in ( + str(hit.get("source_concept_id") or ""), + str(hit.get("target_value") or ""), + ) + if related_id.startswith("#V#") ), ], include_preview=True, preview_cache=preview_cache, ) - target_preview = _resolve_concept_preview( - resolved_concept_id, - True, - preview_cache, - ) - for hit in paged_incoming_hits: + for hit in paged_asserted_binary_hits: + source_id = str(hit.get("source_concept_id") or "") + target_value = str(hit.get("target_value") or "") source_preview = _resolve_concept_preview( - str(hit.get("source_concept_id") or ""), + source_id, True, preview_cache, ) hit["source_concept_preview"] = source_preview - hit["target_concept_preview"] = target_preview + hit["target_concept_preview"] = ( + _resolve_concept_preview( + target_value, + True, + preview_cache, + ) + if target_value.startswith("#V#") + else None + ) hit["access_granted"] = source_preview is not None counts_are_lower_bounds = bool( scoped_concept_query_truncated @@ -1239,6 +1184,12 @@ def get_predicate_incidence( return { "mode": "entity", "concept_id": resolved_concept_id, + "coverage_complete": bool(diagnostics.get("coverage_complete", True)), + **( + {"counts_are_lower_bounds": True} + if not diagnostics.get("coverage_complete", True) + else {} + ), "total_predicates": len(sorted_rows), "predicates": paged_rows, "role_expansions": _extract_predicate_incidence_role_expansions(paged_rows), @@ -1247,8 +1198,16 @@ def get_predicate_incidence( "offset": resolved_offset, "returned": len(paged_rows), "total_available": len(sorted_rows), + **( + {"total_available_is_lower_bound": True} + if not diagnostics.get("coverage_complete", True) + else {} + ), + }, + "uncertainty_diagnostics": { + key: diagnostics.get(key) + for key in ("mode", "include_uncertain", "statuses") }, - "uncertainty_diagnostics": diagnostics, "typed_predicate_incidence_diagnostics": ( _build_typed_predicate_incidence_diagnostics( type_count_options=type_count_options, @@ -1270,6 +1229,10 @@ def get_predicate_incidence( "include_text_snippets": bool(include_text_snippets), "include_concept_preview": bool(include_concept_preview), "include_argument_type_counts": type_count_options.include, + "coverage_complete": bool( + diagnostics.get("coverage_complete", True) + ), + "coverage_gaps": list(diagnostics.get("coverage_gaps") or []), "uncertainty_mode": diagnostics.get("mode"), "include_uncertain": diagnostics.get("include_uncertain"), "uncertainty_statuses": diagnostics.get("statuses"), @@ -1303,6 +1266,8 @@ def get_predicate_incidence( ) ), "statuses": _normalise_uncertainty_statuses(uncertainty_statuses) or None, + "coverage_complete": True, + "coverage_gaps": [], } grouped_hits = _collect_type_subject_hits_for_incidence_fast( @@ -1323,7 +1288,7 @@ def get_predicate_incidence( hits_by_instance: Dict[str, List[Dict[str, Any]]] = {} for instance_id in instance_ids: if grouped_hits is None: - hits, _ = _collect_all_argument_hits_for_incidence( + hits, instance_diagnostics = _collect_all_argument_hits_for_incidence( concept_id=instance_id, argument_index=argument_index, predicate_filter=predicate_filter, @@ -1334,6 +1299,16 @@ def get_predicate_incidence( uncertainty_mode=uncertainty_mode, uncertainty_statuses=uncertainty_statuses, ) + if not instance_diagnostics.get("coverage_complete", True): + diagnostics["coverage_complete"] = False + diagnostics["coverage_gaps"].extend( + { + **dict(gap), + "anchor_concept_id": instance_id, + } + for gap in instance_diagnostics.get("coverage_gaps", []) + if isinstance(gap, Mapping) + ) else: hits = grouped_hits.get(instance_id, []) hits_by_instance[instance_id] = hits @@ -1374,6 +1349,12 @@ def get_predicate_incidence( return { "mode": "type", "instance_of": resolved_instance_of, + "coverage_complete": bool(diagnostics.get("coverage_complete", True)), + **( + {"counts_are_lower_bounds": True} + if not diagnostics.get("coverage_complete", True) + else {} + ), "type_ids_considered": type_ids, "instance_count_considered": len(instance_ids), "direct_instances_only": bool(direct_instances_only), @@ -1385,8 +1366,16 @@ def get_predicate_incidence( "offset": resolved_offset, "returned": len(paged_rows), "total_available": len(sorted_rows), + **( + {"total_available_is_lower_bound": True} + if not diagnostics.get("coverage_complete", True) + else {} + ), + }, + "uncertainty_diagnostics": { + key: diagnostics.get(key) + for key in ("mode", "include_uncertain", "statuses") }, - "uncertainty_diagnostics": diagnostics, "typed_predicate_incidence_diagnostics": ( _build_typed_predicate_incidence_diagnostics( type_count_options=type_count_options, @@ -1411,6 +1400,8 @@ def get_predicate_incidence( "include_text_snippets": bool(include_text_snippets), "include_concept_preview": bool(include_concept_preview), "include_argument_type_counts": type_count_options.include, + "coverage_complete": bool(diagnostics.get("coverage_complete", True)), + "coverage_gaps": list(diagnostics.get("coverage_gaps") or []), "uncertainty_mode": diagnostics.get("mode"), "include_uncertain": diagnostics.get("include_uncertain"), "uncertainty_statuses": diagnostics.get("statuses"), @@ -1573,6 +1564,8 @@ def _collect_all_argument_hits_for_incidence( ) ), "statuses": _normalise_uncertainty_statuses(uncertainty_statuses) or None, + "coverage_complete": True, + "coverage_gaps": [], } argument_filter = _coerce_argument_index(argument_index) @@ -1608,7 +1601,34 @@ def _collect_all_argument_hits_for_incidence( _materialise_all_hits=True, ) if isinstance(payload.get("uncertainty_diagnostics"), dict): - diagnostics = dict(payload["uncertainty_diagnostics"]) + uncertainty_diagnostics = payload["uncertainty_diagnostics"] + for key in ("mode", "include_uncertain", "statuses"): + if key in uncertainty_diagnostics: + diagnostics[key] = uncertainty_diagnostics[key] + relation_diagnostics = payload.get("relation_query_diagnostics") + incoming_diagnostics = ( + relation_diagnostics.get("incoming_asserted_binary") + if isinstance(relation_diagnostics, Mapping) + else None + ) + if ( + isinstance(incoming_diagnostics, Mapping) + and incoming_diagnostics.get("requested") + and not incoming_diagnostics.get("complete", True) + ): + diagnostics["coverage_complete"] = False + diagnostics["coverage_gaps"].append( + { + "relation_kind": query_relation_kind, + "argument_index": query_argument_index, + "path": incoming_diagnostics.get("path"), + "reason": ( + incoming_diagnostics.get("stop_reason") + or incoming_diagnostics.get("fallback_reason") + or "incomplete_incoming_relation_coverage" + ), + } + ) batch_hits = payload.get("hits") if isinstance(batch_hits, list): all_hits.extend( @@ -1630,6 +1650,10 @@ def _collect_type_subject_hits_for_incidence_fast( uncertainty_statuses: Optional[Sequence[str]], ) -> Optional[Dict[str, List[Dict[str, Any]]]]: argument_filter = _coerce_argument_index(argument_index) + explicitly_all_arguments = bool( + isinstance(argument_index, str) + and argument_index.strip().lower() == "any" + ) relation_filter = _normalise_relation_kind_filter(relation_kind) mode_value, include_asserted_rows, include_uncertain_rows = ( _resolve_uncertainty_mode( @@ -1641,6 +1665,7 @@ def _collect_type_subject_hits_for_incidence_fast( not include_asserted_rows or include_uncertain_rows or _normalise_uncertainty_statuses(uncertainty_statuses) + or explicitly_all_arguments or argument_filter not in (None, _ARG_INDEX_SUBJECT) or relation_filter not in {"any", "binary", "text"} ): diff --git a/src/backend/services/relationship_extent_index_service.py b/src/backend/services/relationship_extent_index_service.py index 9856a3cf6..337da716b 100644 --- a/src/backend/services/relationship_extent_index_service.py +++ b/src/backend/services/relationship_extent_index_service.py @@ -674,6 +674,7 @@ def _flush_pending_docs(pending_docs: list[dict[str, Any]]) -> None: def _build_relationship_extent_index_query( *, predicate_id: str | None = None, + predicate_ids: Iterable[str] | None = None, target_value: str | None = None, target_values: Iterable[str] | None = None, source_concept_id: str | None = None, @@ -682,8 +683,41 @@ def _build_relationship_extent_index_query( query: dict[str, Any] = { "schema_version": RELATIONSHIP_EXTENT_INDEX_SCHEMA_VERSION, } - if predicate_id: - query["predicate_id"] = predicate_id + predicate_filter_supplied = bool(predicate_id) or predicate_ids is not None + raw_predicate_ids = ( + [predicate_ids] + if isinstance(predicate_ids, str) + else list(predicate_ids or []) + ) + resolved_predicate_ids = list( + dict.fromkeys( + [ + *( + [predicate_id.strip()] + if isinstance(predicate_id, str) and predicate_id.strip() + else [] + ), + *( + item.strip() + for item in raw_predicate_ids + if isinstance(item, str) and item.strip() + ), + ] + ) + ) + if exclude_structural_predicates and resolved_predicate_ids: + structural_predicates = get_relationship_kinds_set() + resolved_predicate_ids = [ + item for item in resolved_predicate_ids if item not in structural_predicates + ] + if predicate_filter_supplied and not resolved_predicate_ids: + return {}, False + if resolved_predicate_ids: + query["predicate_id"] = ( + resolved_predicate_ids[0] + if len(resolved_predicate_ids) == 1 + else {"$in": resolved_predicate_ids} + ) if target_value: query["target_value"] = target_value elif target_values is not None: @@ -695,19 +729,15 @@ def _build_relationship_extent_index_query( query["target_value"] = {"$in": values} if source_concept_id: query["source_concept_id"] = source_concept_id - if exclude_structural_predicates: - if predicate_id and predicate_id in get_relationship_kinds_set(): - return {}, False - predicate_filter: dict[str, Any] = {"$nin": list(get_relationship_kinds_set())} - if predicate_id: - predicate_filter["$eq"] = predicate_id - query["predicate_id"] = predicate_filter + if exclude_structural_predicates and not resolved_predicate_ids: + query["predicate_id"] = {"$nin": list(get_relationship_kinds_set())} return query, True def query_relationship_extent_index( *, predicate_id: str | None = None, + predicate_ids: Iterable[str] | None = None, target_value: str | None = None, target_values: Iterable[str] | None = None, source_concept_id: str | None = None, @@ -733,6 +763,7 @@ def query_relationship_extent_index( query, should_query = _build_relationship_extent_index_query( predicate_id=predicate_id, + predicate_ids=predicate_ids, target_value=target_value, target_values=target_values, source_concept_id=source_concept_id, diff --git a/src/backend/services/tool_metadata_service.py b/src/backend/services/tool_metadata_service.py index d5acb5b70..083a13410 100644 --- a/src/backend/services/tool_metadata_service.py +++ b/src/backend/services/tool_metadata_service.py @@ -707,7 +707,9 @@ class ToolRequiredObligationMetadata: "selected concepts only when needed. A hit proves existence, not list " "or count completeness. If predicate, direction, or represented-form " "coverage is not established, inspect small any-direction incidence " - "first, then read every fitting exact predicate. When a hit reaches a " + "first, then pass every fitting exact predicate together in one " + "predicate_filter read; do not select only the most obvious label. " + "When a hit reaches a " "reified, event, claim, or role node, inspect represented role predicates " "before treating another filler as the requested entity; co-participation " "alone does not establish that role. A numeric object index selects one " @@ -788,7 +790,9 @@ class ToolRequiredObligationMetadata: "For a list, count, broad category, or negative, start one small binary " "argument_index='any' page with previews, snippets, and type counts off. " "Treat every semantically fitting row, including inverse forms, as a " - "coverage candidate; read all exact predicates, deduplicate overlaps, " + "coverage candidate; pass all returned predicate IDs together in one " + "exact predicate_filter read, do not select only the most obvious label, " + "deduplicate overlaps, " "and inspect role fillers when a matching row grounds a reified node. " "Incidence identifies coverage candidates, not the answer entities. A " "filler qualifies only when its represented role establishes the requested " diff --git a/tests/backend/test_adaptive_turn_service.py b/tests/backend/test_adaptive_turn_service.py index 257716547..3f2ec8c97 100644 --- a/tests/backend/test_adaptive_turn_service.py +++ b/tests/backend/test_adaptive_turn_service.py @@ -3050,7 +3050,18 @@ def test_capability_query_ranks_without_eliminating_the_delegated_set( "public_web_search", "internal_record_search", ] - public_web, internal = web_query["capabilities"] + assert all( + set(item) == {"name", "query_match", "selection"} + for item in web_query["capabilities"] + ) + exact = _capability_catalogue( + gateway, + ("internal_record_search", "public_web_search"), + {"names": ["internal_record_search", "public_web_search"]}, + ) + exact_by_name = {item["name"]: item for item in exact["capabilities"]} + public_web = exact_by_name["public_web_search"] + internal = exact_by_name["internal_record_search"] assert public_web["surface_family"] == "web" assert public_web["external_surface"] is True assert internal["surface_family"] == "represented_records" @@ -3161,7 +3172,12 @@ def test_complete_purpose_index_keeps_zero_overlap_direct_tool_visible( {"query": "Which scholar mentors Michael?", "limit": 50}, workflow_capabilities=workflows, ) - assert len(_json_bytes(raw)) > 24_000 + assert len(_json_bytes(raw)) <= 24_000 + assert len(raw["capabilities"]) == 50 + assert all( + "description" not in item and "input_schema" not in item + for item in raw["capabilities"] + ) target_detail = next( item for item in raw["capabilities"] if item["name"] == target_name @@ -3274,7 +3290,12 @@ def test_schema_discovery_metadata_is_retrievable_without_list_word_trigger( capability = positive["capabilities"][0] assert capability["query_match"] is True - planner_hint = capability["planner_hint"].lower() + exact = _capability_catalogue( + gateway, + ("vontology_concept_search",), + {"names": ["vontology_concept_search"]}, + ) + planner_hint = exact["capabilities"][0]["planner_hint"].lower() assert "unknown" in planner_hint assert "relation-bearing read" in planner_hint assert "what is possible, not what is actually used" in planner_hint @@ -3313,7 +3334,15 @@ def fail_metadata(*_args, **_kwargs): assert result["total"] == 1 assert result["matched_total"] == 0 assert result["capabilities"][0]["name"] == "general_read" - assert result["capabilities"][0]["description"] == ( + assert result["purpose_index"]["entries"][0]["purpose"] == ( + "Read arbitrary general evidence." + ) + exact = _capability_catalogue( + gateway, + ("general_read",), + {"names": ["general_read"]}, + ) + assert exact["capabilities"][0]["description"] == ( "Read arbitrary general evidence." ) @@ -3396,15 +3425,27 @@ def test_capability_frontier_promotes_direct_components_without_hiding_workflow( "find_relations_with_argument", "students_index", ] - direct = result["capabilities"][0] - represented = result["capabilities"][1] + direct_reference = result["capabilities"][0] + exact = _capability_catalogue( + gateway, + ("fetch_concept", "find_relations_with_argument", "students_index"), + { + "names": [ + "fetch_concept", + "represented_workflow_entity_lookup", + ] + }, + workflow_capabilities=(workflow,), + ) + exact_by_name = {item["name"]: item for item in exact["capabilities"]} + direct = exact_by_name["fetch_concept"] + represented = exact_by_name["represented_workflow_entity_lookup"] assert direct["plan_profile"]["shape"] == "single_capability" assert represented["plan_profile"]["shape"] == "represented_workflow" assert represented["semantic_effect"] is False assert represented["effect_profile"]["operational_state_effect"] is True - assert any( - evidence["source"] == "declared_component_of_matched_workflow" - for evidence in direct["selection"]["adequacy_evidence"] + assert "declared_component_of_matched_workflow" in ( + direct_reference["selection"]["adequacy_sources"] ) assert result["frontier_total"] == 4 assert result["dominated_total"] == 0 @@ -3482,10 +3523,8 @@ def test_workflow_components_remain_visible_ahead_of_unrelated_tool_matches( "get_predicate_incidence", ] assert all( - any( - evidence["source"] == "declared_component_of_matched_workflow" - for evidence in item["selection"]["adequacy_evidence"] - ) + "declared_component_of_matched_workflow" + in item["selection"]["adequacy_sources"] for item in ( result["capabilities"][0], result["capabilities"][2], @@ -3570,9 +3609,15 @@ def test_descriptions_put_relevant_direct_plan_in_visible_frontier( "represented_workflow_entity_lookup", ] assert set(capability_names[2:]) == {"search_concepts", "generic_read"} - direct_candidate = result["capabilities"][0] + exact = _capability_catalogue( + gateway, + ("generic_read", "search_concepts", "find_relations_with_argument"), + {"names": ["find_relations_with_argument"]}, + workflow_capabilities=(workflow,), + ) + direct_candidate = exact["capabilities"][0] assert direct_candidate["plan_profile"]["cost_profile"]["durable_runtime"] is False - assert direct_candidate["selection"]["semantic_adequacy_owner"] == "adaptive_model" + assert result["selection_policy"]["semantic_adequacy_owner"] == "adaptive_model" def test_capability_frontier_does_not_prefer_unrelated_direct_tool( @@ -3694,9 +3739,9 @@ def test_capability_frontier_uses_catalogue_rarity_not_generic_routing_words( by_name[name]["selection"]["frontier_status"] == "outside_query_frontier" for name in generic_names ) - generic_evidence = by_name[generic_names[0]]["selection"]["adequacy_evidence"] - assert generic_evidence[0]["common_routing_term_count"] == 1 - assert generic_evidence[0]["informative_routing_term_count"] == 0 + assert by_name[generic_names[0]]["selection"]["adequacy_sources"] == [ + "literal_query_terms" + ] def test_declared_read_only_direct_equivalence_prunes_only_frontier( @@ -3781,6 +3826,76 @@ def test_declared_read_only_direct_equivalence_prunes_only_frontier( ) +def test_declared_direct_equivalence_does_not_prune_effectful_workflow( + monkeypatch, +) -> None: + from src.backend.services import tool_metadata_service + from src.backend.services.workflow_turn_capability_service import ( + WorkflowTurnCapability, + ) + + catalogue = MethodCatalogue() + catalogue.register( + MethodDefinition( + name="direct_lookup", + handler=lambda **_kwargs: {"success": True}, + input_schema=Schema(optional={"query": str}, allow_unknown=False), + category="read", + description="Look up the requested record.", + ) + ) + gateway = InternalMCPGateway( + catalogue=catalogue, + transport=InternalMCPTransport(read_timeout_sec=1.0), + enabled=True, + ) + monkeypatch.setattr( + tool_metadata_service, + "get_tool_description", + lambda _name, *, fallback_description=None: fallback_description, + ) + monkeypatch.setattr(tool_metadata_service, "get_tool_planner_hint", lambda _: None) + monkeypatch.setattr( + tool_metadata_service, + "get_tool_dispatch_surface_metadata", + lambda _: None, + ) + workflow = WorkflowTurnCapability( + name="represented_workflow_effectful_lookup", + workflow_id="#V#effectful_lookup_workflow", + display_name="Effectful lookup workflow", + description="Look up the record and publish a represented result.", + relevance_score=0.9, + input_schema={"type": "object", "properties": {}}, + component_capability_names=("direct_lookup",), + declared_component_count=1, + declared_step_count=2, + direct_equivalent_capability_names=("direct_lookup",), + semantic_effect=True, + semantic_effect_source="represented_workflow_declaration", + ) + + result = _capability_catalogue( + gateway, + ("direct_lookup",), + {"query": "look up and publish the requested record", "limit": 10}, + workflow_capabilities=(workflow,), + ) + + assert result["dominated_total"] == 0 + assert result["dominated_capabilities"] == [] + assert {item["name"] for item in result["capabilities"]} == { + "direct_lookup", + "represented_workflow_effectful_lookup", + } + purpose = next( + item + for item in result["purpose_index"]["entries"] + if item["name"] == "represented_workflow_effectful_lookup" + ) + assert purpose["semantic_effect"] is True + + def test_capability_page_budget_preserves_every_alternative_and_cursor( monkeypatch, ) -> None: @@ -3840,7 +3955,8 @@ def test_capability_page_budget_preserves_every_alternative_and_cursor( capability_names, {"query": "research", "offset": 0, "limit": 20}, ) - assert len(_json_bytes(first_raw_page)) > 24_000 + assert len(_json_bytes(first_raw_page)) <= 24_000 + assert len(first_raw_page["capabilities"]) == 20 paired = _bound_tool_results_for_model( [ @@ -3855,10 +3971,8 @@ def test_capability_page_budget_preserves_every_alternative_and_cursor( ) assert paired is not None assert all(item.output.get("capabilities") for item in paired) - assert all( - item.output["next_offset"] == len(item.output["capabilities"]) - for item in paired - ) + assert all(item.output["next_offset"] is None for item in paired) + assert all(len(item.output["capabilities"]) == 20 for item in paired) assert ( len( _json_bytes( @@ -3878,7 +3992,6 @@ def test_capability_page_budget_preserves_every_alternative_and_cursor( seen_names: list[str] = [] seen_entries: list[dict[str, Any]] = [] - schema_references: list[dict[str, Any]] = [] offset: int | None = 0 while offset is not None: raw_page = _capability_catalogue( @@ -3906,34 +4019,29 @@ def test_capability_page_budget_preserves_every_alternative_and_cursor( ] seen_names.extend(item["name"] for item in entries) seen_entries.extend(entries) - schema_references.extend( - item - for item in entries - if item.get("input_schema_omitted_for_model_context") is True - ) next_offset = bounded["next_offset"] if next_offset is not None: assert next_offset == offset + len(entries) offset = next_offset assert seen_names == capability_names - reference = schema_references[0] if schema_references else seen_entries[0] - assert reference["description"].startswith("Search research material.") - assert reference["surface_family"] == "research" - assert reference["evidence_surface_family"] == "research" - assert reference["external_surface"] is False - assert reference["server_bound_arguments"] == ["actor_id"] - if schema_references: - assert reference["input_schema_hydration"] == { - "tool": "turn_capabilities", - "arguments": { - "names": [reference["name"]], - "limit": 1, - }, - "purpose": "dedicated_exact_name_schema_page", - } - else: - assert "input_schema" in reference + reference = seen_entries[0] + assert reference["query_match"] is True + assert reference["selection"]["frontier_status"] == "candidate" + assert "description" not in reference + assert "input_schema" not in reference + purpose_entry = next( + item + for item in bounded["purpose_index"]["entries"] + if item["name"] == reference["name"] + ) + assert purpose_entry["purpose"].startswith("Search research material.") + assert purpose_entry["purpose"].endswith("...") + assert len(purpose_entry["purpose"]) <= 160 + assert bounded["purpose_index"]["exact_schema_hydration"] == { + "tool": "turn_capabilities", + "arguments": {"names": [""], "limit": 1}, + } exact_page = _capability_catalogue( gateway, @@ -3954,6 +4062,68 @@ def test_capability_page_budget_preserves_every_alternative_and_cursor( assert "input_schema" in exact_bounded[0].output["capabilities"][0] +def test_bounded_catalogue_compacts_purposes_without_erasing_candidates() -> None: + from src.backend.services.adaptive_turn_service import ( + _bounded_capability_catalogue_output, + _json_bytes, + ) + + entries = [ + { + "name": f"general_capability_{index:03d}", + "purpose": ( + "Perform an ordinary bounded task using represented evidence and " + "return the useful work product while preserving provenance, " + "recoverability, and a concise account of the observed result." + ), + } + for index in range(200) + ] + raw = { + "schema_version": "adaptive_turn_capability_catalogue.v1", + "success": True, + "delegation": "bounded_capabilities", + "total": len(entries), + "delegated_total": len(entries), + "catalogue_scope": "query_frontier", + "offset": 0, + "next_offset": None, + "purpose_index": { + "schema_version": "adaptive_turn_capability_purpose_index.v1", + "complete": True, + "ordering": "name_ascending_unranked", + "purpose_projection": "first_authored_sentence", + "purpose_max_chars": 160, + "exact_schema_hydration": { + "tool": "turn_capabilities", + "arguments": {"names": [""], "limit": 1}, + }, + "entries": entries, + }, + "capabilities": [ + { + "name": entry["name"], + "query_match": True, + "selection": {"frontier_status": "candidate"}, + } + for entry in entries[:50] + ], + } + + bounded = _bounded_capability_catalogue_output(raw, max_bytes=24_000) + + assert bounded + assert len(_json_bytes(bounded)) <= 24_000 + purpose_index = bounded["purpose_index"] + assert purpose_index["complete"] is True + assert len(purpose_index["entries"]) == len(entries) + assert purpose_index["purpose_text_compacted_for_model_context"] is True + assert purpose_index["purpose_max_chars"] < 160 + assert [item["name"] for item in purpose_index["entries"]] == [ + item["name"] for item in entries + ] + + def test_single_oversized_capability_remains_visible_as_schema_reference( monkeypatch, ) -> None: @@ -4118,23 +4288,18 @@ def test_bulky_capability_metadata_falls_back_without_hiding_the_schema( bounded = bounded_results[0].output assert len(_json_bytes(bounded)) <= 24_000 assert bounded["next_offset"] is None - assert bounded["capability_page_projection"]["returned"] == 1 - assert bounded["capability_page_projection"]["schema_reference_count"] == 1 + assert "capability_page_projection" not in bounded + assert len(bounded["capabilities"]) == 1 compact = bounded["capabilities"][0] assert compact["name"] == name - assert compact["capability_metadata_omitted_for_model_context"] is True - assert compact["input_schema_omitted_for_model_context"] is True - assert compact["input_schema_hydration"] == { - "tool": "turn_capabilities", - "arguments": { - "names": [name], - "limit": 1, - }, - "purpose": "dedicated_exact_name_schema_page", - } - assert compact["semantic_effect"] is False - assert compact["plan_profile"]["shape"] == "single_capability" assert compact["selection"]["frontier_status"] == "candidate" + assert "description" not in compact + assert "input_schema" not in compact + purpose = bounded["purpose_index"]["entries"][0] + assert purpose["name"] == name + assert purpose["purpose"].startswith("Read represented records.") + assert purpose["purpose"].endswith("...") + assert len(purpose["purpose"]) <= 160 exact = _capability_catalogue( gateway, @@ -5952,7 +6117,12 @@ def _execute_workflow(**kwargs: Any) -> dict[str, Any]: catalogue_result = client.calls[1]["tool_results"][0].output assert catalogue_result["represented_workflow_total"] == 1 - assert catalogue_result["capabilities"][0]["kind"] == ("represented_workflow") + workflow_purpose = next( + item + for item in catalogue_result["purpose_index"]["entries"] + if item["name"] == workflow_capability.name + ) + assert workflow_purpose["shape"] == "represented_workflow" assert ( catalogue_result["selection_policy"]["semantic_adequacy_owner"] == "adaptive_model" diff --git a/tests/backend/test_concept_relation_service_extent_index.py b/tests/backend/test_concept_relation_service_extent_index.py index d8c87ff94..cdd594b12 100644 --- a/tests/backend/test_concept_relation_service_extent_index.py +++ b/tests/backend/test_concept_relation_service_extent_index.py @@ -49,6 +49,80 @@ def test_relationship_target_visibility_is_batched_without_changing_shape( } +def test_relation_preview_default_is_false_only_on_model_facing_tools( + monkeypatch, +) -> None: + import asyncio + import inspect + + from src.backend.integrations.internal_mcp import catalogue + from src.backend.mcp_server import mcp_stdio_server + from src.backend.services import concept_relation_service as service + from src.backend.services import tool_metadata_service + + assert ( + inspect.signature(service.find_relations_with_argument) + .parameters["include_concept_preview"] + .default + is True + ) + observed: list[bool] = [] + + def fake_find_relations_with_argument(concept_id: str, **kwargs: Any): + observed.append(kwargs["include_concept_preview"]) + return { + "concept_id": concept_id, + "total_hits": 0, + "hits": [], + "paging": { + "limit": 20, + "offset": 0, + "returned": 0, + "total_available": 0, + }, + "relation_query_diagnostics": {}, + } + + monkeypatch.setattr( + service, + "find_relations_with_argument", + fake_find_relations_with_argument, + ) + catalogue._find_relations_with_argument(concept_id="#V#anchor") + catalogue._find_relations_with_argument( + concept_id="#V#anchor", + include_concept_preview=True, + ) + monkeypatch.setattr( + mcp_stdio_server, + "find_relations_with_argument", + fake_find_relations_with_argument, + ) + asyncio.run( + mcp_stdio_server._handle_find_relations_with_argument( + {"concept_id": "#V#anchor"} + ) + ) + asyncio.run( + mcp_stdio_server._handle_find_relations_with_argument( + {"concept_id": "#V#anchor", "include_concept_preview": True} + ) + ) + + monkeypatch.setattr(tool_metadata_service, "_load_from_vontology", dict) + tool_metadata_service.invalidate_cache() + try: + metadata = tool_metadata_service.get_tool_metadata( + "find_relations_with_argument" + ) + assert metadata.default_payload is not None + assert metadata.default_payload["include_concept_preview"] is False + finally: + tool_metadata_service.invalidate_cache() + + assert observed == [False, True, False, True] + + def test_incoming_asserted_binary_uses_extent_index_without_changing_results( monkeypatch, ) -> None: @@ -211,6 +285,7 @@ def test_relation_hits_include_source_previews_for_both_directions( from src.backend.services import concept_relation_service as service focal_id = "#V#focal" + index_queries: list[dict[str, Any]] = [] monkeypatch.setattr( service, "_load_accessible_relation_subject_document", @@ -219,10 +294,9 @@ def test_relation_hits_include_source_previews_for_both_directions( "relationships": {"#V#direct_relation": ["#V#outgoing_related"]}, }, ) - monkeypatch.setattr( - service, - "query_relationship_extent_index", - lambda **_kwargs: ( + def query_index(**kwargs: Any): + index_queries.append(kwargs) + return ( [ { "source_concept_id": "#V#incoming_related", @@ -233,8 +307,9 @@ def test_relation_hits_include_source_previews_for_both_directions( } ], 1, - ), - ) + ) + + monkeypatch.setattr(service, "query_relationship_extent_index", query_index) monkeypatch.setattr( service, "filter_accessible_concept_ids", @@ -289,13 +364,17 @@ def test_relation_hits_include_source_previews_for_both_directions( assert hits_by_source["#V#incoming_related"]["source_concept_preview"][ "type_ids" ] == ["#V#requested_type"] + assert index_queries[0]["predicate_ids"] == [ + "#V#direct_relation", + "#V#inverse_relation", + ] def test_outgoing_relation_previews_batch_uncached_targets(monkeypatch) -> None: from src.backend.services import concept_relation_service as service source_id = "#V#source" - target_ids = ("#V#target_a", "#V#target_b") + target_ids = ("#V#target_a", "#V#target_b", "#V#target_c") preview_queries: list[tuple[dict[str, Any], dict[str, int]]] = [] monkeypatch.setattr( service, @@ -340,18 +419,19 @@ def fake_find(query, projection): predicate_filter=["#V#supervises"], relation_kind="binary", include_concept_preview=True, - limit=10, + offset=1, + limit=1, ) assert len(preview_queries) == 1 assert preview_queries[0][0] == { - "concept_id": {"$in": [source_id, *target_ids]} + "concept_id": {"$in": [source_id, "#V#target_b"]} } - assert payload["total_hits"] == 2 - assert payload["paging"]["returned"] == 2 - assert { - hit["target_concept_preview"]["concept_id"] for hit in payload["hits"] - } == set(target_ids) + assert payload["total_hits"] == 3 + assert payload["paging"]["returned"] == 1 + assert payload["hits"][0]["target_concept_preview"]["concept_id"] == ( + "#V#target_b" + ) def test_incoming_relation_previews_batch_uncached_sources(monkeypatch) -> None: @@ -431,7 +511,7 @@ def fake_find(query, projection): assert len(preview_queries) == 1 assert preview_queries[0][0] == { "concept_id": { - "$in": [target_id, "#V#source_a", "#V#source_b"] + "$in": ["#V#source_a", target_id, "#V#source_b"] } } assert { @@ -518,7 +598,7 @@ def fake_find(query, projection): } assert len(preview_queries) == 1 assert preview_queries[0][0] == { - "concept_id": {"$in": [target_id, "#V#source_b"]} + "concept_id": {"$in": ["#V#source_b", target_id]} } assert payload["hits"][0]["source_concept_id"] == "#V#source_b" assert payload["hits"][0]["source_concept_preview"]["concept_id"] == ( @@ -621,7 +701,7 @@ def fake_find(query, projection, *, max_time_ms=None): } -def test_unresolved_predicate_label_preserves_general_canonical_fallback( +def test_unresolved_predicate_label_returns_typed_incomplete_without_full_scan( monkeypatch, ) -> None: from src.backend.services import concept_relation_service as service @@ -658,14 +738,20 @@ def test_unresolved_predicate_label_preserves_general_canonical_fallback( include_concept_preview=False, ) - assert len(aggregate_pipelines) == 1 + assert aggregate_pipelines == [] + assert payload["total_hits"] == 0 + assert payload["total_hits_is_lower_bound"] is True + assert payload["paging"]["total_available_is_lower_bound"] is True diagnostics = payload["relation_query_diagnostics"][ "incoming_asserted_binary" ] - assert diagnostics["path"] == "canonical_aggregation" + assert diagnostics["path"] == "relationship_extent_index_unavailable" assert diagnostics["fallback_reason"] == ( "relationship_extent_index_unavailable" ) + assert diagnostics["stop_reason"] == "relationship_extent_index_unavailable" + assert diagnostics["bounded"] is False + assert diagnostics["complete"] is False def test_empty_available_extent_index_does_not_trigger_canonical_fallback( @@ -843,7 +929,79 @@ def test_unfiltered_extent_timeout_does_not_start_canonical_full_scan( assert diagnostics["complete"] is False -def test_predicate_incidence_materialises_incoming_relations_once( +def test_predicate_incidence_materialises_large_ready_index_once( + monkeypatch, +) -> None: + from src.backend.services import concept_relation_service as service + + target_id = "#V#target" + index_calls: list[dict[str, Any]] = [] + indexed_rows = [ + { + "source_concept_id": f"#V#source_{index}", + "predicate_id": "#V#related_to", + "target_value": target_id, + "target_index": 0, + "source_updated_at": None, + } + for index in range(12_005) + ] + + monkeypatch.setattr( + service, + "_load_accessible_relation_subject_document", + lambda *_args, **_kwargs: None, + ) + + def query_index(**kwargs: Any): + index_calls.append(kwargs) + return indexed_rows, len(indexed_rows) + + monkeypatch.setattr(service, "query_relationship_extent_index", query_index) + monkeypatch.setattr( + service, + "filter_accessible_concept_ids", + lambda concept_ids: set(concept_ids), + ) + monkeypatch.setattr( + service.ConceptsRepository, + "aggregate", + staticmethod(_fail_if_aggregated), + ) + monkeypatch.setattr( + service, + "_load_accessible_preview_document", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("unselected incidence rows must not read previews") + ), + ) + monkeypatch.setattr( + service, + "_attach_direct_type_ids_to_fast_incidence_hits", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("default incidence must not hydrate argument types") + ), + ) + + payload = service.get_predicate_incidence( + concept_id=target_id, + argument_index="object", + relation_kind="binary", + include_concept_preview=False, + limit=20, + ) + + assert len(index_calls) == 1 + assert payload["coverage_complete"] is True + assert "counts_are_lower_bounds" not in payload + assert payload["total_predicates"] == 1 + assert payload["predicates"][0]["predicate_concept_id"] == "#V#related_to" + assert payload["predicates"][0]["relation_hit_count"] == 12_005 + assert payload["predicates"][0]["grounding_count"] == 12_005 + assert payload["predicates"][0]["object_argument_hit_count"] == 12_005 + + +def test_predicate_incidence_reports_incomplete_without_unindexed_full_scan( monkeypatch, ) -> None: from src.backend.services import concept_relation_service as service @@ -907,9 +1065,19 @@ def test_predicate_incidence_materialises_incoming_relations_once( limit=20, ) - assert len(aggregate_calls) == 1 - assert payload["total_predicates"] == 1 - assert payload["predicates"][0]["predicate_concept_id"] == "#V#related_to" - assert payload["predicates"][0]["relation_hit_count"] == 12_005 - assert payload["predicates"][0]["grounding_count"] == 12_005 - assert payload["predicates"][0]["object_argument_hit_count"] == 12_005 + assert aggregate_calls == [] + assert payload["coverage_complete"] is False + assert payload["counts_are_lower_bounds"] is True + assert payload["total_predicates"] == 0 + assert payload["predicates"] == [] + assert payload["paging"]["total_available_is_lower_bound"] is True + diagnostics = payload["predicate_incidence_query_diagnostics"] + assert diagnostics["coverage_complete"] is False + assert diagnostics["coverage_gaps"] == [ + { + "relation_kind": "binary", + "argument_index": "object", + "path": "relationship_extent_index_unavailable", + "reason": "relationship_extent_index_unavailable", + } + ] diff --git a/tests/backend/test_concept_relation_service_uncertainty.py b/tests/backend/test_concept_relation_service_uncertainty.py index c9638f376..93b680cc5 100644 --- a/tests/backend/test_concept_relation_service_uncertainty.py +++ b/tests/backend/test_concept_relation_service_uncertainty.py @@ -521,9 +521,11 @@ def fail_per_hit_type_resolution(*_args, **_kwargs): assert target_type_count["concept_count"] == 2 -def test_get_predicate_incidence_role_expands_reified_neighbour_roles() -> None: +def test_get_predicate_incidence_role_expands_reified_neighbour_roles( + monkeypatch, +) -> None: from src.backend.db.repositories.concepts_repository import ConceptsRepository - from src.backend.services.concept_relation_service import get_predicate_incidence + from src.backend.services import concept_relation_service as service ConceptsRepository.insert_one( {"concept_id": "#V#michael_witbrock", "relationships": {}} @@ -552,8 +554,24 @@ def test_get_predicate_incidence_role_expands_reified_neighbour_roles() -> None: "relationships": {"is_an_instance_of": ["#V#file_copy"]}, } ) + monkeypatch.setattr( + service, + "query_relationship_extent_index", + lambda **_kwargs: ( + [ + { + "source_concept_id": "#V#authorship_event_one", + "predicate_id": "#V#has_author", + "target_value": "#V#michael_witbrock", + "target_index": 0, + "source_updated_at": None, + } + ], + 1, + ), + ) - payload = get_predicate_incidence( + payload = service.get_predicate_incidence( concept_id="#V#michael_witbrock", argument_index="object", relation_kind="binary", @@ -596,7 +614,7 @@ def test_get_predicate_incidence_role_expands_reified_neighbour_roles() -> None: payload["role_expansions"][0]["anchor_predicate_concept_id"] == "#V#has_author" ) - auto_payload = get_predicate_incidence( + auto_payload = service.get_predicate_incidence( concept_id="#V#michael_witbrock", argument_index="object", relation_kind="binary", @@ -711,6 +729,66 @@ def fail_per_instance_lookup(*_args, **_kwargs): ) +def test_get_predicate_incidence_type_mode_explicit_any_includes_incoming( + monkeypatch, +) -> None: + from src.backend.db.repositories.concepts_repository import ConceptsRepository + from src.backend.services import concept_relation_service as service + + ConceptsRepository.insert_one( + { + "concept_id": "#V#alice_student", + "relationships": {"is_an_instance_of": ["#V#sail_student"]}, + } + ) + ConceptsRepository.insert_one( + { + "concept_id": "#V#bob_student", + "relationships": {"is_an_instance_of": ["#V#sail_student"]}, + } + ) + + def query_index(**kwargs): + target_id = kwargs.get("target_value") + if target_id != "#V#alice_student": + return [], 0 + return ( + [ + { + "source_concept_id": "#V#supervisor", + "predicate_id": "#V#supervises", + "target_value": target_id, + "target_index": 0, + "source_updated_at": None, + } + ], + 1, + ) + + monkeypatch.setattr(service, "query_relationship_extent_index", query_index) + monkeypatch.setattr( + service, + "filter_accessible_concept_ids", + lambda concept_ids: set(concept_ids), + ) + + payload = service.get_predicate_incidence( + instance_of="#V#sail_student", + direct_instances_only=True, + argument_index="any", + relation_kind="binary", + include_concept_preview=False, + ) + + rows = {row["predicate_concept_id"]: row for row in payload["predicates"]} + assert rows["#V#supervises"]["relation_hit_count"] == 1 + assert rows["#V#supervises"]["grounded_instance_count"] == 1 + assert ( + payload["predicate_incidence_query_diagnostics"]["retrieval_strategy"] + == "per_instance_relation_lookup" + ) + + def test_get_predicate_incidence_type_mode_batches_argument_type_counts( monkeypatch, ) -> None: diff --git a/tests/backend/test_internal_mcp_scoped_assertion_authority.py b/tests/backend/test_internal_mcp_scoped_assertion_authority.py index 44ca45746..506ff59fa 100644 --- a/tests/backend/test_internal_mcp_scoped_assertion_authority.py +++ b/tests/backend/test_internal_mcp_scoped_assertion_authority.py @@ -577,6 +577,45 @@ def truncated_lookup(**kwargs): } +def test_index_coverage_lower_bound_is_not_labelled_as_actor_overlay() -> None: + from src.backend.integrations.internal_mcp.catalogue import ( + _annotate_bounded_relation_lookup, + ) + + payload = { + "concept_id": "#V#subject", + "context_view": "base_publication", + "total_hits": 0, + "total_hits_is_lower_bound": True, + "hits": [], + "paging": { + "limit": 50, + "offset": 0, + "returned": 0, + "total_available": 0, + "total_available_is_lower_bound": True, + }, + "relation_query_diagnostics": { + "scoped_concept_query_truncated": False, + "actor_effective_text_query_truncated": False, + "incoming_asserted_binary": { + "path": "relationship_extent_index_unavailable", + "complete": False, + }, + }, + } + + result = _annotate_bounded_relation_lookup( + payload, + concept_id="#V#subject", + predicate_filter=None, + relation_kind="binary", + ) + + assert result == payload + assert "continuation" not in result + + def test_text_assertion_receipt_includes_derived_maintenance_schedule( monkeypatch: pytest.MonkeyPatch, ): diff --git a/tests/backend/test_relationship_extent_index_service.py b/tests/backend/test_relationship_extent_index_service.py index d7523ce21..c4a1d6f01 100644 --- a/tests/backend/test_relationship_extent_index_service.py +++ b/tests/backend/test_relationship_extent_index_service.py @@ -185,6 +185,22 @@ def test_relationship_extent_readiness_requires_explicit_complete_state( assert service.relationship_extent_index_ready() is True +def test_relationship_extent_query_batches_exact_predicates() -> None: + from src.backend.services import relationship_extent_index_service as service + + query, should_query = service._build_relationship_extent_index_query( + predicate_ids=["#V#supervises", "#V#co_supervises", "#V#supervises"], + target_value="#V#person", + ) + + assert should_query is True + assert query == { + "schema_version": service.RELATIONSHIP_EXTENT_INDEX_SCHEMA_VERSION, + "predicate_id": {"$in": ["#V#supervises", "#V#co_supervises"]}, + "target_value": "#V#person", + } + + def test_relationship_extent_rebuild_enforces_batch_size_for_dense_source( monkeypatch, ): From 656c70ad59bb3c97709aec3ef877bf29da285326 Mon Sep 17 00:00:00 2001 From: witbrock Date: Sat, 1 Aug 2026 09:44:15 +0100 Subject: [PATCH 15/16] Preserve equal capability guidance in model context --- .../integrations/internal_mcp/catalogue.py | 6 ++- src/backend/mcp_server/vontology_mcp.json | 2 +- src/backend/services/adaptive_turn_service.py | 21 +++++++-- src/backend/services/tool_metadata_service.py | 4 +- tests/backend/test_adaptive_turn_service.py | 45 ++++++++++++++++++- tests/backend/test_tool_metadata_service.py | 5 ++- 6 files changed, 72 insertions(+), 11 deletions(-) diff --git a/src/backend/integrations/internal_mcp/catalogue.py b/src/backend/integrations/internal_mcp/catalogue.py index d51fe17ed..753db047e 100644 --- a/src/backend/integrations/internal_mcp/catalogue.py +++ b/src/backend/integrations/internal_mcp/catalogue.py @@ -33999,8 +33999,10 @@ def _build_default_catalogue_core_definitions() -> List[MethodDefinition]: "Use before a " "filtered relation read when a list, " "count, broad category, or negative lacks established predicate, " - "direction, or represented-form coverage. Start with one small " - "argument_index='any' binary page. For list, count, broad-category, or " + "direction, or represented-form coverage. Start with one " + "argument_index='any' binary page using limit=20, " + "include_concept_preview=false, include_text_snippets=false, and " + "include_argument_type_counts=false. For list, count, broad-category, or " "negative claims, pass every semantically fitting returned " "predicate_concept_id, including inverse forms, together in one exact " "predicate_filter read; do not choose only the most obvious label. " diff --git a/src/backend/mcp_server/vontology_mcp.json b/src/backend/mcp_server/vontology_mcp.json index cd7fb5e10..b91bd72fe 100644 --- a/src/backend/mcp_server/vontology_mcp.json +++ b/src/backend/mcp_server/vontology_mcp.json @@ -2320,7 +2320,7 @@ }, { "name": "get_predicate_incidence", - "description": "Discover predicates actually used around a known anchor or type when relationship coverage is uncertain. The anchor entity goes in concept_id. Use before a filtered relation read when a list, count, broad category, or negative lacks established predicate, direction, or represented-form coverage. Start with one small argument_index='any' binary page. For list, count, broad-category, or negative claims, pass every semantically fitting returned predicate_concept_id, including inverse forms, together in one exact predicate_filter read; do not choose only the most obvious label. Deduplicate overlaps and inspect role fillers for matching reified nodes. Incidence returns coverage candidates, not answer entities. A filler qualifies only when its represented role establishes the requested relationship; co-participation alone does not. Set role_expansion_mode='explicit' with represented node-type or role-predicate filters when the immediate neighbour is a reified/event/claim node whose other role fillers are the useful retrieval targets.", + "description": "Discover predicates actually used around a known anchor or type when relationship coverage is uncertain. The anchor entity goes in concept_id. Use before a filtered relation read when a list, count, broad category, or negative lacks established predicate, direction, or represented-form coverage. Start with one argument_index='any' binary page using limit=20, include_concept_preview=false, include_text_snippets=false, and include_argument_type_counts=false. For list, count, broad-category, or negative claims, pass every semantically fitting returned predicate_concept_id, including inverse forms, together in one exact predicate_filter read; do not choose only the most obvious label. Deduplicate overlaps and inspect role fillers for matching reified nodes. Incidence returns coverage candidates, not answer entities. A filler qualifies only when its represented role establishes the requested relationship; co-participation alone does not. Set role_expansion_mode='explicit' with represented node-type or role-predicate filters when the immediate neighbour is a reified/event/claim node whose other role fillers are the useful retrieval targets.", "inputSchema": { "type": "object", "properties": { diff --git a/src/backend/services/adaptive_turn_service.py b/src/backend/services/adaptive_turn_service.py index 3cfd07d7f..f1a73c59a 100644 --- a/src/backend/services/adaptive_turn_service.py +++ b/src/backend/services/adaptive_turn_service.py @@ -415,7 +415,8 @@ def _capability_purpose_index( "truncation_marker": "...", "exact_schema_hydration": { "tool": _CAPABILITY_TOOL_NAME, - "arguments": {"names": [""], "limit": 1}, + "guidance": "Request all alternatives being compared in one names array.", + "arguments": {"names": [""], "limit": 50}, }, "entries": entries, } @@ -898,6 +899,17 @@ def _bound_tool_results_for_model( if not results: return [] + complete_batch = [ + { + "call_id": result.call_id, + "tool_name": result.tool_name, + "status": result.status, + "output": result.output, + } + for result in results + ] + if len(_json_bytes(complete_batch)) <= max_bytes: + return list(results) receipt_outputs = [_model_tool_output_receipt(result.output) for result in results] serialisable_shells = [ { @@ -1419,9 +1431,10 @@ def _tool_definitions() -> list[ToolDefinition]: "representedness does not rank a plan. Judge semantic adequacy " "yourself. Prefer the smallest plan that can produce the requested " "work product and evidence; use a workflow when its added composition, " - "verification, or recovery is materially needed. Request an exact " - "name to hydrate its canonical description, planner guidance, plan " - "and effect facts, and argument schema. This is discovery, " + "verification, or recovery is materially needed. Request the exact " + "names of all alternatives being compared together to hydrate their " + "canonical descriptions, planner guidance, plan and effect facts, and " + "argument schemas on equal footing. This is discovery, " "not a requirement to use any particular capability." ), input_schema={ diff --git a/src/backend/services/tool_metadata_service.py b/src/backend/services/tool_metadata_service.py index 083a13410..8f2f75398 100644 --- a/src/backend/services/tool_metadata_service.py +++ b/src/backend/services/tool_metadata_service.py @@ -788,7 +788,9 @@ class ToolRequiredObligationMetadata: "planner_hint": ( "Use before a filtered relation read when coverage is not established. " "For a list, count, broad category, or negative, start one small binary " - "argument_index='any' page with previews, snippets, and type counts off. " + "argument_index='any' page with limit=20, " + "include_concept_preview=false, include_text_snippets=false, and " + "include_argument_type_counts=false. " "Treat every semantically fitting row, including inverse forms, as a " "coverage candidate; pass all returned predicate IDs together in one " "exact predicate_filter read, do not select only the most obvious label, " diff --git a/tests/backend/test_adaptive_turn_service.py b/tests/backend/test_adaptive_turn_service.py index 3f2ec8c97..b62674ae4 100644 --- a/tests/backend/test_adaptive_turn_service.py +++ b/tests/backend/test_adaptive_turn_service.py @@ -702,6 +702,7 @@ def test_plain_answer_gets_trusted_scope_and_generic_read_doorway() -> None: assert "represented workflows are peers" in catalogue_tool.description assert "representedness does not rank a plan" in catalogue_tool.description assert "smallest plan that can produce" in catalogue_tool.description + assert "all alternatives being compared together" in catalogue_tool.description assert set(catalogue_tool.input_schema["properties"]) == { "query", "names", @@ -4040,7 +4041,8 @@ def test_capability_page_budget_preserves_every_alternative_and_cursor( assert len(purpose_entry["purpose"]) <= 160 assert bounded["purpose_index"]["exact_schema_hydration"] == { "tool": "turn_capabilities", - "arguments": {"names": [""], "limit": 1}, + "guidance": "Request all alternatives being compared in one names array.", + "arguments": {"names": [""], "limit": 50}, } exact_page = _capability_catalogue( @@ -4096,7 +4098,10 @@ def test_bounded_catalogue_compacts_purposes_without_erasing_candidates() -> Non "purpose_max_chars": 160, "exact_schema_hydration": { "tool": "turn_capabilities", - "arguments": {"names": [""], "limit": 1}, + "guidance": ( + "Request all alternatives being compared in one names array." + ), + "arguments": {"names": [""], "limit": 50}, }, "entries": entries, }, @@ -4622,6 +4627,42 @@ def test_tool_result_correlation_shell_overflow_has_no_oversized_fallback() -> N assert _bound_tool_results_for_model(results) is None +def test_tool_result_batch_preserves_full_outputs_when_aggregate_fits() -> None: + results = [ + ToolResult( + call_id=f"capability-{index}", + tool_name="turn_capabilities", + status="ok", + output={ + "name": f"capability-{index}", + "description": character * size, + "planner_hint": f"planner-{index}", + }, + ) + for index, (character, size) in enumerate( + (("a", 6_700), ("b", 5_100), ("c", 5_400), ("d", 3_300)) + ) + ] + complete_batch = [ + { + "call_id": result.call_id, + "tool_name": result.tool_name, + "status": result.status, + "output": result.output, + } + for result in results + ] + assert len(_json_bytes(complete_batch)) < 24_000 + + bounded = _bound_tool_results_for_model(results) + + assert bounded is not None + assert [result.output for result in bounded] == [ + result.output for result in results + ] + assert bounded[0].output["planner_hint"] == "planner-0" + + def test_bounded_effect_result_preserves_exact_partial_receipt() -> None: result = ToolResult( call_id="partial-effect", diff --git a/tests/backend/test_tool_metadata_service.py b/tests/backend/test_tool_metadata_service.py index e27687898..481e8d96a 100644 --- a/tests/backend/test_tool_metadata_service.py +++ b/tests/backend/test_tool_metadata_service.py @@ -262,7 +262,10 @@ def test_relation_read_hints_start_bounded_without_losing_negative_completeness( "include_concept_preview": False, "limit": 12, } - assert "previews, snippets, and type counts off" in ( + assert "limit=20" in (incidence.planner_hint or "") + assert "include_concept_preview=false" in (incidence.planner_hint or "") + assert "include_text_snippets=false" in (incidence.planner_hint or "") + assert "include_argument_type_counts=false" in ( incidence.planner_hint or "" ) assert "argument_index='any'" in (incidence.planner_hint or "") From 7e7dd30d9ef0974ba6de49815bd06e0c57dc6411 Mon Sep 17 00:00:00 2001 From: witbrock Date: Sat, 1 Aug 2026 10:07:31 +0100 Subject: [PATCH 16/16] Address relation test quality feedback --- tests/backend/test_concept_relation_service_extent_index.py | 2 +- tests/backend/test_concept_relation_service_uncertainty.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/backend/test_concept_relation_service_extent_index.py b/tests/backend/test_concept_relation_service_extent_index.py index cdd594b12..50832aa0d 100644 --- a/tests/backend/test_concept_relation_service_extent_index.py +++ b/tests/backend/test_concept_relation_service_extent_index.py @@ -961,7 +961,7 @@ def query_index(**kwargs: Any): monkeypatch.setattr( service, "filter_accessible_concept_ids", - lambda concept_ids: set(concept_ids), + set, ) monkeypatch.setattr( service.ConceptsRepository, diff --git a/tests/backend/test_concept_relation_service_uncertainty.py b/tests/backend/test_concept_relation_service_uncertainty.py index 93b680cc5..979ed126c 100644 --- a/tests/backend/test_concept_relation_service_uncertainty.py +++ b/tests/backend/test_concept_relation_service_uncertainty.py @@ -769,7 +769,7 @@ def query_index(**kwargs): monkeypatch.setattr( service, "filter_accessible_concept_ids", - lambda concept_ids: set(concept_ids), + set, ) payload = service.get_predicate_incidence(