From 82b7f0164034ca3d8265652ac7b84638677eff3f Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Wed, 15 Jul 2026 14:05:48 -0400 Subject: [PATCH] Add governed chat agent discovery --- .../agent_logging_chat_completion.py | 33 +- application/single_app/config.py | 2 +- .../single_app/functions_agent_catalog.py | 161 ++++- .../single_app/functions_agent_payload.py | 119 ++++ .../single_app/functions_chat_capabilities.py | 374 +++++++++++- .../functions_chat_capability_choices.py | 124 +++- .../functions_chat_orchestration.py | 8 +- .../single_app/functions_global_agents.py | 4 + .../single_app/functions_group_agents.py | 2 + .../single_app/functions_personal_agents.py | 6 + .../single_app/route_backend_agents.py | 29 +- application/single_app/route_backend_chats.py | 430 +++++++++++++- .../single_app/semantic_kernel_loader.py | 134 +++-- .../static/js/agent_modal_stepper.js | 123 +++- .../single_app/static/js/agents_common.js | 50 +- .../static/js/chat/chat-capability-choice.js | 47 +- .../static/json/schemas/agent.schema.json | 88 +++ .../single_app/templates/_agent_modal.html | 75 +++ .../features/CHAT_TURN_ORCHESTRATION.md | 48 +- docs/explanation/release_notes.md | 11 + .../test_agent_action_evidence_contract.py | 5 +- .../test_agent_stream_tool_retry_fallback.py | 7 +- .../test_agents_catalog_feature.py | 12 +- .../test_central_synthesis_contract.py | 9 +- .../test_chat_capability_choice_route.py | 548 +++++++++++++++++- .../test_chat_capability_discovery.py | 189 +++++- .../test_chat_governed_agent_discovery.py | 509 ++++++++++++++++ .../test_chat_turn_orchestration_plan.py | 4 +- ...test_agent_modal_orchestrator_discovery.py | 213 +++++++ ui_tests/test_chat_capability_choice_card.py | 214 ++++++- 30 files changed, 3428 insertions(+), 150 deletions(-) create mode 100644 functional_tests/test_chat_governed_agent_discovery.py create mode 100644 ui_tests/test_agent_modal_orchestrator_discovery.py diff --git a/application/single_app/agent_logging_chat_completion.py b/application/single_app/agent_logging_chat_completion.py index e4173ef27..d2d982aed 100644 --- a/application/single_app/agent_logging_chat_completion.py +++ b/application/single_app/agent_logging_chat_completion.py @@ -1,4 +1,4 @@ - +# agent_logging_chat_completion.py import json import logging from pydantic import Field @@ -15,6 +15,7 @@ class LoggingChatCompletionAgent(ChatCompletionAgent): deployment_name: str | None = Field(default=None) azure_endpoint: str | None = Field(default=None) api_version: str | None = Field(default=None) + orchestration_minimize_telemetry: bool = Field(default=False, exclude=True) def __init__(self, *args, display_name=None, default_agent=False, deployment_name=None, azure_endpoint=None, api_version=None, **kwargs): # Remove these from kwargs so the base class doesn't see them @@ -33,10 +34,11 @@ def __init__(self, *args, display_name=None, default_agent=False, deployment_nam def log_tool_execution(self, tool_name, arguments=None, result=None): """Manual method to log tool executions. Can be called by plugins.""" + minimize_telemetry = self.orchestration_minimize_telemetry tool_citation = { "tool_name": tool_name, - "function_arguments": str(arguments) if arguments else "", - "function_result": str(result)[:500] if result else "", + "function_arguments": "" if minimize_telemetry else str(arguments) if arguments else "", + "function_result": "" if minimize_telemetry else str(result)[:500] if result else "", "timestamp": datetime.datetime.utcnow().isoformat() } self.tool_invocations.append(tool_citation) @@ -136,11 +138,15 @@ async def invoke(self, *args, **kwargs): self.tool_invocations = [] # Log the prompt/messages before sending to LLM + minimize_telemetry = self.orchestration_minimize_telemetry + prompt_messages = args[0] if args and isinstance(args[0], (list, tuple)) else [] log_event( "[Logging Agent Request] Agent LLM prompt", extra={ "agent": self.name, - "prompt": [m.content[:30] for m in args[0]] if args else None + "prompt": None if minimize_telemetry else [m.content[:30] for m in prompt_messages], + "prompt_message_count": len(prompt_messages), + "telemetry_minimized": minimize_telemetry, } ) @@ -180,7 +186,13 @@ async def invoke(self, *args, **kwargs): extra={ "agent": self.name, "response_type": type(response).__name__, - "response_preview": str(response)[:100] if response else None + "response_preview": ( + None + if minimize_telemetry + else str(response)[:100] if response else None + ), + "response_length": len(str(response)) if response else 0, + "telemetry_minimized": minimize_telemetry, }, level=logging.DEBUG ) @@ -188,7 +200,8 @@ async def invoke(self, *args, **kwargs): # Store the response for analysis self._last_response = response # Simplified citation capture - primary citations come from plugin invocation logger - self._capture_tool_invocations_simplified(args, response) + if not minimize_telemetry: + self._capture_tool_invocations_simplified(args, response) return response finally: @@ -197,7 +210,13 @@ async def invoke(self, *args, **kwargs): "[Logging Agent Response][Usage] Agent LLM response", extra={ "agent": self.name, - "response": str(response)[:100] if response else None, + "response": ( + None + if minimize_telemetry + else str(response)[:100] if response else None + ), + "response_length": len(str(response)) if response else 0, + "telemetry_minimized": minimize_telemetry, "prompt_tokens": getattr(usage, "prompt_tokens", None), "completion_tokens": getattr(usage, "completion_tokens", None), "total_tokens": getattr(usage, "total_tokens", None), diff --git a/application/single_app/config.py b/application/single_app/config.py index 13be9cc4c..5a1029814 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -96,7 +96,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.250.066" +VERSION = "0.250.067" SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') SESSION_COOKIE_HTTPONLY = os.getenv('SESSION_COOKIE_HTTPONLY', 'true').lower() != 'false' diff --git a/application/single_app/functions_agent_catalog.py b/application/single_app/functions_agent_catalog.py index d70ca05e7..b3b2d66a6 100644 --- a/application/single_app/functions_agent_catalog.py +++ b/application/single_app/functions_agent_catalog.py @@ -10,10 +10,18 @@ from functions_assigned_knowledge import get_agent_assigned_knowledge from functions_global_actions import get_global_actions from functions_global_agents import get_global_agents -from functions_group import get_group_model_endpoints, get_user_groups +from functions_group import ( + check_group_status_allows_operation, + get_group_model_endpoints, + get_user_groups, +) from functions_group_actions import get_group_actions from functions_group_agents import get_group_agents -from functions_governance import filter_actions_by_action_type_access, filter_governed_global_actions_for_user +from functions_governance import ( + ensure_governance_access, + filter_actions_by_action_type_access, + filter_governed_global_actions_for_user, +) from functions_keyvault import SecretReturnType from functions_personal_actions import get_governed_personal_actions from functions_personal_agents import ensure_migration_complete, get_personal_agents @@ -272,6 +280,155 @@ def _should_include_global_agents(settings: Dict[str, Any]) -> bool: return bool(settings.get("enable_semantic_kernel", False)) +def _should_include_global_agents_for_discovery(settings: Dict[str, Any]) -> bool: + if not _should_include_global_agents(settings): + return False + return bool( + not settings.get("per_user_semantic_kernel", False) + or settings.get("merge_global_semantic_kernel_with_workspace", False) + ) + + +def _is_agent_currently_discoverable(agent: Dict[str, Any]) -> bool: + if not isinstance(agent, dict): + return False + if str(agent.get("agent_type") or "local").strip().lower() != "local": + return False + if not str(agent.get("created_at") or "").strip(): + return False + if any( + str(action_id or "").strip() + for action_id in (agent.get("actions_to_load") or []) + ): + return False + if agent.get("is_enabled") is False: + return False + if agent.get("hidden") is True or agent.get("is_hidden") is True: + return False + if str(agent.get("visibility") or "").strip().lower() == "hidden": + return False + if str(agent.get("status") or "").strip().lower() in {"disabled", "inactive", "hidden"}: + return False + discoverable = agent.get("discoverable_by_orchestrator", False) + if isinstance(discoverable, str): + discoverable = discoverable.strip().lower() == "true" + return bool(discoverable and isinstance(agent.get("orchestrator_descriptor"), dict)) + + +def _is_agent_allowed_by_governance(user_id: str, agent: Dict[str, Any], scope_type: str) -> bool: + try: + if scope_type == "global": + ensure_governance_access( + "governance_global_agents_usage", + user_id, + item_entity_type="global_agent", + item_id=str(agent.get("id") or agent.get("name") or ""), + ) + elif scope_type == "group": + ensure_governance_access("governance_group_agents", user_id) + else: + ensure_governance_access("governance_user_agents", user_id) + return True + except PermissionError: + return False + + +def _build_authorized_discovery_candidate( + agent: Dict[str, Any], + *, + scope_type: str, + scope_id: Optional[str], + scope_name: str, + user_id: str, +) -> Optional[Dict[str, Any]]: + if not _is_agent_currently_discoverable(agent): + return None + if not _is_agent_allowed_by_governance(user_id, agent, scope_type): + return None + + candidate = dict(agent) + candidate.update({ + "scope_type": scope_type, + "scope_id": scope_id, + "scope_name": scope_name, + "is_global": scope_type == "global", + "is_group": scope_type == "group", + "group_id": scope_id if scope_type == "group" else None, + "group_name": scope_name if scope_type == "group" else None, + }) + if scope_type == "personal": + candidate["user_id"] = user_id + candidate["catalog_key"] = build_agent_catalog_key(candidate) + return candidate if candidate["catalog_key"] else None + + +def build_authorized_agent_discovery_catalog( + user_id: str, + *, + settings: Optional[Dict[str, Any]] = None, + user_groups: Optional[Iterable[Dict[str, Any]]] = None, +) -> List[Dict[str, Any]]: + """Build a server-only canonical catalog after current auth and policy checks.""" + resolved_settings = settings or get_settings() + resolved_groups = list(user_groups) if user_groups is not None else get_user_groups(user_id) + catalog: List[Dict[str, Any]] = [] + + def append_candidate(agent, *, scope_type, scope_id, scope_name): + candidate = _build_authorized_discovery_candidate( + agent, + scope_type=scope_type, + scope_id=scope_id, + scope_name=scope_name, + user_id=user_id, + ) + if candidate: + catalog.append(candidate) + + if resolved_settings.get("allow_user_agents", False): + ensure_migration_complete(user_id) + for agent in get_personal_agents(user_id): + append_candidate( + agent, + scope_type="personal", + scope_id=user_id, + scope_name="Personal", + ) + + if _should_include_global_agents_for_discovery(resolved_settings): + for agent in get_global_agents(): + append_candidate( + agent, + scope_type="global", + scope_id=None, + scope_name="Global", + ) + + if ( + resolved_settings.get("enable_group_workspaces", False) + and resolved_settings.get("allow_group_agents", False) + ): + for group_doc in resolved_groups: + group_id = str((group_doc or {}).get("id") or "").strip() + if not group_id: + continue + group_chat_allowed, _ = check_group_status_allows_operation( + group_doc, + "chat", + ) + if not group_chat_allowed: + continue + group_name = str((group_doc or {}).get("name") or "Unnamed Group").strip() + for agent in get_group_agents(group_id): + append_candidate( + agent, + scope_type="group", + scope_id=group_id, + scope_name=group_name, + ) + + return catalog + + def build_accessible_agent_catalog( user_id: str, *, diff --git a/application/single_app/functions_agent_payload.py b/application/single_app/functions_agent_payload.py index 8770d8fce..fe917d569 100644 --- a/application/single_app/functions_agent_payload.py +++ b/application/single_app/functions_agent_payload.py @@ -2,6 +2,7 @@ """Utility helpers for normalizing agent payloads before validation and storage.""" from copy import deepcopy +import re from typing import Any, Dict, List from functions_icon_utils import normalize_icon_payload @@ -88,6 +89,12 @@ } _MAX_AGENT_TAGS = 20 _MAX_AGENT_TAG_LENGTH = 40 +_MAX_ORCHESTRATOR_DESCRIPTOR_ITEMS = 16 +_MAX_ORCHESTRATOR_DESCRIPTOR_ITEM_LENGTH = 64 +_ORCHESTRATOR_RISK_CLASSES = {"internal_read", "external_read"} +_ORCHESTRATOR_DATA_SENSITIVITY_CLASSES = {"public", "internal"} +_ORCHESTRATOR_COST_CLASSES = {"low", "standard"} +_ORCHESTRATOR_LATENCY_CLASSES = {"seconds", "minutes"} _FOUNDRY_FIELD_LENGTHS = { "agent_id": 128, "endpoint": 2048, @@ -257,6 +264,103 @@ def _coerce_tags(tags: Any) -> List[str]: return cleaned +def _coerce_bool(value: Any) -> bool: + if isinstance(value, str): + return value.strip().lower() == "true" + return bool(value) + + +def _coerce_orchestrator_descriptor_items(values: Any, field_name: str) -> List[str]: + if values in (None, ""): + return [] + if not isinstance(values, list): + raise AgentPayloadError(f"orchestrator_descriptor.{field_name} must be an array of strings.") + + cleaned: List[str] = [] + for value in values: + if not isinstance(value, str): + raise AgentPayloadError( + f"orchestrator_descriptor.{field_name} entries must be strings." + ) + normalized = re.sub( + r"[^a-z0-9_:-]+", + "_", + value.strip().lower(), + ).strip("_")[:_MAX_ORCHESTRATOR_DESCRIPTOR_ITEM_LENGTH] + if normalized and normalized not in cleaned: + cleaned.append(normalized) + if len(cleaned) > _MAX_ORCHESTRATOR_DESCRIPTOR_ITEMS: + raise AgentPayloadError( + f"orchestrator_descriptor.{field_name} supports at most " + f"{_MAX_ORCHESTRATOR_DESCRIPTOR_ITEMS} entries." + ) + return cleaned + + +def _normalize_orchestrator_descriptor(value: Any, *, discoverable: bool) -> Dict[str, Any]: + if value in (None, ""): + value = {} + if not isinstance(value, dict): + raise AgentPayloadError("orchestrator_descriptor must be an object.") + + capability_tags = _coerce_orchestrator_descriptor_items( + value.get("capability_tags"), + "capability_tags", + ) + evidence_types = _coerce_orchestrator_descriptor_items( + value.get("evidence_types"), + "evidence_types", + ) + read_only = _coerce_bool(value.get("read_only")) + external_data = _coerce_bool(value.get("external_data")) + risk_class = str(value.get("risk_class") or "").strip().lower() + data_sensitivity = str(value.get("data_sensitivity") or "").strip().lower() + latency_class = str(value.get("latency_class") or "").strip().lower() + cost_class = str(value.get("cost_class") or "").strip().lower() + + if discoverable and not read_only: + raise AgentPayloadError("Discoverable orchestrator agents must be read-only.") + + enum_values = ( + ("risk_class", risk_class, _ORCHESTRATOR_RISK_CLASSES), + ("data_sensitivity", data_sensitivity, _ORCHESTRATOR_DATA_SENSITIVITY_CLASSES), + ("latency_class", latency_class, _ORCHESTRATOR_LATENCY_CLASSES), + ("cost_class", cost_class, _ORCHESTRATOR_COST_CLASSES), + ) + for field_name, field_value, allowed_values in enum_values: + if field_value and field_value not in allowed_values: + raise AgentPayloadError( + f"orchestrator_descriptor.{field_name} is not allowed for discovery." + ) + + if discoverable: + if not capability_tags or not evidence_types: + raise AgentPayloadError( + "Discoverable orchestrator agents require capability tags and evidence types." + ) + if not all((risk_class, data_sensitivity, latency_class, cost_class)): + raise AgentPayloadError( + "Discoverable orchestrator agents require risk, sensitivity, latency, and cost classes." + ) + + normalized = { + "capability_tags": capability_tags, + "evidence_types": evidence_types, + "read_only": read_only, + "external_data": external_data, + "risk_class": risk_class, + "data_sensitivity": data_sensitivity, + "latency_class": latency_class, + "cost_class": cost_class, + } + if not discoverable and not any( + value not in (False, "", []) + for value in normalized.values() + ): + return {} + return normalized + + def _coerce_other_settings(settings: Any) -> Dict[str, Any]: if settings in (None, ""): return {} @@ -360,6 +464,21 @@ def sanitize_agent_payload(agent: Dict[str, Any]) -> Dict[str, Any]: sanitized["other_settings"] = _coerce_other_settings(sanitized.get("other_settings")) sanitized["actions_to_load"] = _coerce_actions(sanitized.get("actions_to_load")) sanitized["tags"] = _coerce_tags(sanitized.get("tags")) + sanitized["discoverable_by_orchestrator"] = _coerce_bool( + sanitized.get("discoverable_by_orchestrator") + ) + sanitized["orchestrator_descriptor"] = _normalize_orchestrator_descriptor( + sanitized.get("orchestrator_descriptor"), + discoverable=sanitized["discoverable_by_orchestrator"], + ) + if sanitized["discoverable_by_orchestrator"] and agent_type != "local": + raise AgentPayloadError( + "Orchestrator discovery currently supports local agents only." + ) + if sanitized["discoverable_by_orchestrator"] and sanitized["actions_to_load"]: + raise AgentPayloadError( + "Orchestrator discovery currently supports agents without attached actions only." + ) try: sanitized["icon"] = normalize_icon_payload(sanitized.get("icon"), field_name="icon") except ValueError as exc: diff --git a/application/single_app/functions_chat_capabilities.py b/application/single_app/functions_chat_capabilities.py index 81703a29d..e1dbb3c1b 100644 --- a/application/single_app/functions_chat_capabilities.py +++ b/application/single_app/functions_chat_capabilities.py @@ -1,6 +1,9 @@ # functions_chat_capabilities.py -"""Governed built-in capability discovery for chat orchestration.""" +"""Governed built-in and agent capability discovery for chat orchestration.""" +import copy +import hashlib +import hmac import re from collections.abc import Mapping @@ -9,8 +12,15 @@ CAPABILITY_INVENTORY_VERSION = 1 CAPABILITY_RECOMMENDATION_VERSION = 1 +AGENT_CAPABILITY_INVENTORY_VERSION = 1 CONTINUE_WITHOUT_CAPABILITIES_OPTION_ID = 'continue_without_capabilities' +AGENT_DISCOVERY_RISK_CLASSES = {'internal_read', 'external_read'} +AGENT_DISCOVERY_DATA_SENSITIVITY_CLASSES = {'public', 'internal'} +AGENT_DISCOVERY_COST_CLASSES = {'low', 'standard'} +AGENT_DISCOVERY_LATENCY_CLASSES = {'seconds', 'minutes'} +AGENT_DISCOVERY_SCOPE_CLASSES = {'personal', 'global', 'group'} + CAPABILITY_STATES = { 'selected', 'unselected', @@ -153,6 +163,16 @@ r'authoritative\s+sources?|source-intensive|due\s+diligence)\b', re.IGNORECASE, ) +ORGANIZATIONAL_KNOWLEDGE_PATTERN = re.compile( + r'\b(?:our|my|company|corporate|organization|organizational|internal|employee|employees|' + r'workplace|benefits?|human\s+resources|hr\b|handbook|intranet)\b', + re.IGNORECASE, +) +BUSINESS_SYSTEM_EVIDENCE_PATTERN = re.compile( + r'\b(?:business\s+system|crm|erp|hris|service\s*tickets?|incidents?|customer\s+records?|' + r'account\s+records?|inventory\s+system|case\s+records?|system\s+records?)\b', + re.IGNORECASE, +) REQUIREMENT_CAPABILITY_PREFERENCES = { 'current_public_information': ['web_search', 'deep_research'], @@ -179,6 +199,358 @@ def _bounded_reason(value): return normalized[:80] or None +def _normalize_agent_descriptor_identifiers(values, *, max_items=16): + if not isinstance(values, list): + return [] + normalized = [] + for value in values: + identifier = re.sub( + r'[^a-z0-9_:-]+', + '_', + str(value or '').strip().lower(), + ).strip('_')[:64] + if identifier and identifier not in normalized: + normalized.append(identifier) + if len(normalized) >= max_items: + break + return normalized + + +def _resolve_agent_scope_class(agent, catalog_key): + scope_class = str(agent.get('scope_type') or '').strip().lower() + if scope_class == 'enterprise': + scope_class = 'global' + if scope_class not in AGENT_DISCOVERY_SCOPE_CLASSES: + scope_class = str(catalog_key or '').partition(':')[0].strip().lower() + return scope_class if scope_class in AGENT_DISCOVERY_SCOPE_CLASSES else None + + +def _build_agent_discovery_reference( + catalog_key, + scope_class, + reference_secret, + identity_epoch=None, +): + secret = str(reference_secret or '').encode('utf-8') + if not secret: + return None + digest = hmac.new( + secret, + f"{catalog_key or ''}\n{identity_epoch or ''}".encode('utf-8'), + hashlib.sha256, + ).hexdigest()[:32] + return f'agent:{scope_class}:{digest}' + + +def build_governed_agent_capability_inventory(agents, *, reference_secret): + """Build safe descriptors from an already authorized canonical agent catalog.""" + entries = [] + seen_references = set() + for agent in agents or []: + if not isinstance(agent, Mapping): + continue + if not _coerce_bool(agent.get('discoverable_by_orchestrator'), default=False): + continue + descriptor = agent.get('orchestrator_descriptor') + if not isinstance(descriptor, Mapping): + continue + if not _coerce_bool(descriptor.get('read_only'), default=False): + continue + + risk_class = str(descriptor.get('risk_class') or '').strip().lower() + data_sensitivity = str(descriptor.get('data_sensitivity') or '').strip().lower() + cost_class = str(descriptor.get('cost_class') or '').strip().lower() + latency_class = str(descriptor.get('latency_class') or '').strip().lower() + if ( + risk_class not in AGENT_DISCOVERY_RISK_CLASSES + or data_sensitivity not in AGENT_DISCOVERY_DATA_SENSITIVITY_CLASSES + or cost_class not in AGENT_DISCOVERY_COST_CLASSES + or latency_class not in AGENT_DISCOVERY_LATENCY_CLASSES + ): + continue + + capability_tags = _normalize_agent_descriptor_identifiers( + descriptor.get('capability_tags') + ) + evidence_types = _normalize_agent_descriptor_identifiers( + descriptor.get('evidence_types') + ) + if not capability_tags or not evidence_types: + continue + + catalog_key = str(agent.get('catalog_key') or '').strip() + identity_epoch = str(agent.get('created_at') or '').strip() + scope_class = _resolve_agent_scope_class(agent, catalog_key) + option_reference = _build_agent_discovery_reference( + catalog_key, + scope_class, + reference_secret, + identity_epoch=identity_epoch, + ) + if not catalog_key or not identity_epoch or not scope_class or not option_reference: + continue + if option_reference in seen_references: + continue + seen_references.add(option_reference) + + label = ' '.join( + str( + agent.get('display_name') + or agent.get('displayName') + or agent.get('name') + or 'Specialized agent' + ).split() + )[:120] + entries.append({ + 'id': option_reference, + 'kind': 'agent', + 'label': label, + 'category': 'specialized_agent', + 'state': 'unselected', + 'scope': 'current_user', + 'scope_class': scope_class, + 'discoverable': True, + 'auto_use_allowed': False, + 'requires_user_choice': True, + 'read_only': True, + 'external_data': _coerce_bool(descriptor.get('external_data'), default=False), + 'risk_class': risk_class, + 'data_sensitivity': data_sensitivity, + 'cost_class': cost_class, + 'latency_class': latency_class, + 'capability_tags': capability_tags, + 'evidence_types': evidence_types, + }) + + return { + 'version': AGENT_CAPABILITY_INVENTORY_VERSION, + 'agents': entries, + } + + +def resolve_governed_agent_capability_reference(agents, option_id, *, reference_secret): + """Resolve one opaque option through the current governed canonical catalog.""" + normalized_option_id = str(option_id or '').strip() + if not normalized_option_id.startswith('agent:'): + return None + for agent in agents or []: + if not isinstance(agent, Mapping): + continue + inventory = build_governed_agent_capability_inventory( + [agent], + reference_secret=reference_secret, + ) + descriptor = next(iter(inventory.get('agents') or []), None) + if descriptor and descriptor.get('id') == normalized_option_id: + return dict(agent) + return None + + +def classify_agent_capability_requirements(user_message): + """Classify only explicit organizational or business-system evidence needs.""" + message = str(user_message or '').strip() + requirements = [] + if ORGANIZATIONAL_KNOWLEDGE_PATTERN.search(message): + requirements.append({ + 'id': 'specialized_organizational_knowledge', + 'reason_code': 'specialized_organizational_knowledge', + }) + if BUSINESS_SYSTEM_EVIDENCE_PATTERN.search(message): + requirements.append({ + 'id': 'business_system_evidence', + 'reason_code': 'business_system_evidence', + }) + return requirements + + +def _agent_descriptor_match_score(agent, user_message): + normalized_message = re.sub( + r'[^a-z0-9]+', + ' ', + str(user_message or '').strip().lower(), + ).strip() + message_terms = set(normalized_message.split()) + score = 0 + for identifier in ( + list(agent.get('capability_tags') or []) + + list(agent.get('evidence_types') or []) + ): + normalized_identifier = re.sub( + r'[^a-z0-9]+', + ' ', + str(identifier or '').strip().lower(), + ).strip() + if not normalized_identifier: + continue + if re.search(rf'\b{re.escape(normalized_identifier)}\b', normalized_message): + score += 4 + continue + score += sum( + 1 + for term in normalized_identifier.split() + if len(term) >= 4 and term in message_terms + ) + return score + + +def build_agent_capability_recommendation( + inventory, + user_message, + *, + selected_agent_present=False, + selected_capability_ids=None, +): + """Build at most one explicit-choice agent option from safe descriptors.""" + if selected_agent_present or not isinstance(inventory, Mapping): + return None + requirements = classify_agent_capability_requirements(user_message) + if not requirements: + return None + selected_capability_ids = { + str(capability_id or '').strip().lower() + for capability_id in (selected_capability_ids or []) + if str(capability_id or '').strip() + } + requirement_ids = {requirement['id'] for requirement in requirements} + if ( + 'specialized_organizational_knowledge' in requirement_ids + and selected_capability_ids.intersection({'workspace_search', 'analyze', 'compare'}) + ): + requirements = [ + requirement + for requirement in requirements + if requirement['id'] != 'specialized_organizational_knowledge' + ] + if not requirements: + return None + + ranked_candidates = [] + for agent in inventory.get('agents') or []: + if not isinstance(agent, Mapping): + continue + if not ( + agent.get('kind') == 'agent' + and agent.get('state') == 'unselected' + and agent.get('discoverable') is True + and agent.get('requires_user_choice') is True + and agent.get('read_only') is True + ): + continue + score = _agent_descriptor_match_score(agent, user_message) + if score > 0: + ranked_candidates.append((score, str(agent.get('id') or ''), agent)) + if not ranked_candidates: + return None + + _, _, agent = sorted( + ranked_candidates, + key=lambda item: (-item[0], item[1]), + )[0] + option = { + 'id': agent['id'], + 'kind': 'agent', + 'agent_ref': agent['id'], + 'capability_ids': [], + 'effective_capability_ids': [], + 'label': agent['label'], + 'category': agent['category'], + 'scope_class': agent['scope_class'], + 'latency_class': agent['latency_class'], + 'cost_class': agent['cost_class'], + 'external_data': agent['external_data'], + 'requires_user_choice': True, + 'read_only': True, + 'risk_class': agent['risk_class'], + 'data_sensitivity': agent['data_sensitivity'], + 'capability_tags': list(agent['capability_tags']), + 'evidence_types': list(agent['evidence_types']), + } + return { + 'version': CAPABILITY_RECOMMENDATION_VERSION, + 'status': 'pending', + 'requirement_ids': [requirement['id'] for requirement in requirements], + 'reason_codes': [requirement['reason_code'] for requirement in requirements], + 'recommended_option_id': agent['id'], + 'options': [ + option, + { + 'id': CONTINUE_WITHOUT_CAPABILITIES_OPTION_ID, + 'kind': 'continue', + 'capability_ids': [], + 'effective_capability_ids': [], + 'label': 'Continue without additional capabilities', + 'latency_class': 'immediate', + 'cost_class': 'none', + 'external_data': False, + 'requires_user_choice': True, + }, + ], + 'required_inputs': [], + } + + +def merge_capability_recommendations(primary, secondary): + """Merge built-in and agent options into one bounded Phase 8A proposal.""" + recommendations = [ + recommendation + for recommendation in (primary, secondary) + if isinstance(recommendation, Mapping) + ] + if not recommendations: + return None + if len(recommendations) == 1: + return copy.deepcopy(dict(recommendations[0])) + + options = [] + continue_option = None + seen_option_ids = set() + for recommendation in recommendations: + for option in recommendation.get('options') or []: + if not isinstance(option, Mapping): + continue + option_id = str(option.get('id') or '').strip() + if not option_id: + continue + if option_id == CONTINUE_WITHOUT_CAPABILITIES_OPTION_ID: + continue_option = continue_option or copy.deepcopy(dict(option)) + continue + if option_id in seen_option_ids or len(options) >= 11: + continue + seen_option_ids.add(option_id) + options.append(copy.deepcopy(dict(option))) + if continue_option: + options.append(continue_option) + + def merge_identifiers(field_name, max_items=16): + merged = [] + for recommendation in recommendations: + for value in recommendation.get(field_name) or []: + normalized = str(value or '').strip() + if normalized and normalized not in merged: + merged.append(normalized) + if len(merged) >= max_items: + return merged + return merged + + secondary_recommended_id = str( + recommendations[-1].get('recommended_option_id') or '' + ).strip() + recommended_option_id = ( + secondary_recommended_id + if secondary_recommended_id in seen_option_ids + else str(recommendations[0].get('recommended_option_id') or '').strip() + ) + return { + 'version': CAPABILITY_RECOMMENDATION_VERSION, + 'status': 'pending', + 'requirement_ids': merge_identifiers('requirement_ids'), + 'reason_codes': merge_identifiers('reason_codes'), + 'recommended_option_id': recommended_option_id, + 'options': options, + 'required_inputs': merge_identifiers('required_inputs'), + } + + def _normalize_governance_mode(value): normalized = str(value or 'recommend').strip().lower() return normalized if normalized in GOVERNANCE_MODES else 'blocked' diff --git a/application/single_app/functions_chat_capability_choices.py b/application/single_app/functions_chat_capability_choices.py index bc3fb2d6b..ea56b6fae 100644 --- a/application/single_app/functions_chat_capability_choices.py +++ b/application/single_app/functions_chat_capability_choices.py @@ -106,21 +106,43 @@ def _normalize_options(options): if option_id in seen_option_ids: raise CapabilityChoiceError('proposal option IDs must be unique', code='duplicate_option_id') seen_option_ids.add(option_id) + option_kind = str(raw_option.get('kind') or 'capability').strip().lower() + if option_id == CONTINUE_WITHOUT_CAPABILITIES_OPTION_ID: + option_kind = 'continue' + if option_kind not in {'capability', 'agent', 'continue'}: + raise CapabilityChoiceError('proposal option kind is invalid', code='invalid_option_kind') capability_ids = _normalize_identifiers(raw_option.get('capability_ids'), max_items=8) effective_ids = _normalize_identifiers( raw_option.get('effective_capability_ids') or capability_ids, max_items=8, ) - if option_id == CONTINUE_WITHOUT_CAPABILITIES_OPTION_ID: + agent_ref = str(raw_option.get('agent_ref') or '').strip() + if option_kind == 'continue': capability_ids = [] effective_ids = [] + agent_ref = '' + elif option_kind == 'agent': + if capability_ids or effective_ids: + raise CapabilityChoiceError( + 'agent options cannot name built-in capabilities', + code='invalid_agent_option_capability', + ) + if agent_ref != option_id or not re.fullmatch( + r'agent:(?:personal|global|group):[a-f0-9]{32}', + agent_ref, + ): + raise CapabilityChoiceError( + 'agent option reference is invalid', + code='invalid_agent_option_reference', + ) elif not capability_ids: raise CapabilityChoiceError( 'capability options must name at least one capability', code='missing_option_capability', ) - normalized_options.append({ + normalized_option = { 'id': option_id, + 'kind': option_kind, 'capability_ids': capability_ids, 'effective_capability_ids': effective_ids, 'label': ' '.join(str(raw_option.get('label') or option_id).split())[:120], @@ -135,7 +157,37 @@ def _normalize_options(options): raw_option.get('sensitive_input_types'), max_items=8, ), - }) + } + if option_kind == 'agent': + normalized_option.update({ + 'agent_ref': agent_ref, + 'category': str(raw_option.get('category') or 'specialized_agent').strip()[:40], + 'scope_class': str(raw_option.get('scope_class') or '').strip().lower()[:20], + 'read_only': raw_option.get('read_only') is True, + 'risk_class': str(raw_option.get('risk_class') or '').strip().lower()[:40], + 'data_sensitivity': str( + raw_option.get('data_sensitivity') or '' + ).strip().lower()[:40], + 'capability_tags': _normalize_identifiers( + raw_option.get('capability_tags'), + max_items=16, + ), + 'evidence_types': _normalize_identifiers( + raw_option.get('evidence_types'), + max_items=16, + ), + }) + if ( + normalized_option['scope_class'] not in {'personal', 'global', 'group'} + or normalized_option['read_only'] is not True + or not normalized_option['capability_tags'] + or not normalized_option['evidence_types'] + ): + raise CapabilityChoiceError( + 'agent option descriptor is incomplete', + code='invalid_agent_option_descriptor', + ) + normalized_options.append(normalized_option) if not normalized_options: raise CapabilityChoiceError('proposal options are required', code='missing_options') return normalized_options @@ -323,13 +375,15 @@ def apply_capability_choice_decision(proposal, option_id, *, actor_user_id, now= code='option_not_allowlisted', ) capability_ids = list(option.get('capability_ids') or []) - decision_status = 'declined' if not capability_ids else 'approved' + agent_ref = str(option.get('agent_ref') or '').strip() or None + decision_status = 'declined' if not capability_ids and not agent_ref else 'approved' updated['status'] = decision_status updated['decision'] = { 'option_id': normalized_option_id, 'status': decision_status, 'capability_ids': capability_ids, 'effective_capability_ids': list(option.get('effective_capability_ids') or capability_ids), + 'agent_ref': agent_ref, 'external_query_mode': option.get('external_query_mode') or 'minimized', 'sensitive_input_types': list(option.get('sensitive_input_types') or []), 'actor_user_id': actor_user_id, @@ -390,6 +444,68 @@ def revalidate_capability_choice(proposal, inventory): 'an approved capability is no longer discoverable', code='capability_policy_blocked', ) + agent_ref = str(decision.get('agent_ref') or '').strip() + if agent_ref: + agent_entries = ( + inventory.get('agents') + if isinstance(inventory, Mapping) + else None + ) + agent_entry = next( + ( + entry + for entry in (agent_entries or []) + if isinstance(entry, Mapping) and entry.get('id') == agent_ref + ), + None, + ) + if not agent_entry: + raise CapabilityChoiceError( + 'the approved agent is no longer in the governed catalog', + code='agent_missing', + ) + if not ( + agent_entry.get('state') == 'unselected' + and agent_entry.get('discoverable') is True + and agent_entry.get('requires_user_choice') is True + and agent_entry.get('read_only') is True + ): + raise CapabilityChoiceError( + 'the approved agent is no longer governed for discovery', + code='agent_policy_blocked', + ) + approved_option = get_capability_choice_option( + proposal, + decision.get('option_id'), + ) + if not approved_option or approved_option.get('agent_ref') != agent_ref: + raise CapabilityChoiceError( + 'the approved agent option is no longer valid', + code='agent_option_invalid', + ) + scalar_descriptor_fields = ( + 'scope_class', + 'read_only', + 'external_data', + 'risk_class', + 'data_sensitivity', + 'cost_class', + 'latency_class', + ) + list_descriptor_fields = ('capability_tags', 'evidence_types') + descriptor_changed = any( + approved_option.get(field_name) != agent_entry.get(field_name) + for field_name in scalar_descriptor_fields + ) or any( + list(approved_option.get(field_name) or []) + != list(agent_entry.get(field_name) or []) + for field_name in list_descriptor_fields + ) + if descriptor_changed: + raise CapabilityChoiceError( + 'the approved agent discovery policy has changed', + code='agent_policy_changed', + ) return True diff --git a/application/single_app/functions_chat_orchestration.py b/application/single_app/functions_chat_orchestration.py index 32a47b1f4..44a9df847 100644 --- a/application/single_app/functions_chat_orchestration.py +++ b/application/single_app/functions_chat_orchestration.py @@ -277,7 +277,13 @@ def capability_origin(capability_id): return origin if selected_agent_id: - selected_sources.append(_build_source('selected_agent', 'selection', {'agent_id': selected_agent_id})) + selected_sources.append( + _build_source( + 'selected_agent', + capability_origin('selected_agent'), + {'agent_id': selected_agent_id}, + ) + ) if selected_action_type and selected_action_type != 'none': selected_action_capability = ( 'compare' diff --git a/application/single_app/functions_global_agents.py b/application/single_app/functions_global_agents.py index 4b23e2058..3507d1ca2 100644 --- a/application/single_app/functions_global_agents.py +++ b/application/single_app/functions_global_agents.py @@ -129,6 +129,8 @@ def get_global_agents(include_disabled=False): agent.setdefault('model_provider', '') agent.setdefault('tags', []) agent.setdefault('icon', {}) + agent.setdefault('discoverable_by_orchestrator', False) + agent.setdefault('orchestrator_descriptor', {}) # Remove empty reasoning_effort to prevent validation errors if agent.get('reasoning_effort') == '': agent.pop('reasoning_effort', None) @@ -170,6 +172,8 @@ def get_global_agent(agent_id): agent.setdefault('model_provider', '') agent.setdefault('tags', []) agent.setdefault('icon', {}) + agent.setdefault('discoverable_by_orchestrator', False) + agent.setdefault('orchestrator_descriptor', {}) # Remove empty reasoning_effort to prevent validation errors if agent.get('reasoning_effort') == '': agent.pop('reasoning_effort', None) diff --git a/application/single_app/functions_group_agents.py b/application/single_app/functions_group_agents.py index 14321ca84..cc11304b9 100644 --- a/application/single_app/functions_group_agents.py +++ b/application/single_app/functions_group_agents.py @@ -241,6 +241,8 @@ def _clean_agent(agent: Dict[str, Any]) -> Dict[str, Any]: cleaned.setdefault("model_provider", "") cleaned.setdefault("tags", []) cleaned.setdefault("icon", {}) + cleaned.setdefault("discoverable_by_orchestrator", False) + cleaned.setdefault("orchestrator_descriptor", {}) # Remove empty reasoning_effort to prevent validation errors if cleaned.get("reasoning_effort") == "": cleaned.pop("reasoning_effort", None) diff --git a/application/single_app/functions_personal_agents.py b/application/single_app/functions_personal_agents.py index 8b177da0a..294a4ddf8 100644 --- a/application/single_app/functions_personal_agents.py +++ b/application/single_app/functions_personal_agents.py @@ -58,6 +58,8 @@ def get_personal_agents(user_id): cleaned_agent.setdefault('model_provider', '') cleaned_agent.setdefault('tags', []) cleaned_agent.setdefault('icon', {}) + cleaned_agent.setdefault('discoverable_by_orchestrator', False) + cleaned_agent.setdefault('orchestrator_descriptor', {}) # Remove empty reasoning_effort to prevent validation errors if cleaned_agent.get('reasoning_effort') == '': cleaned_agent.pop('reasoning_effort', None) @@ -101,6 +103,8 @@ def get_personal_agent(user_id, agent_id): cleaned_agent.setdefault('model_provider', '') cleaned_agent.setdefault('tags', []) cleaned_agent.setdefault('icon', {}) + cleaned_agent.setdefault('discoverable_by_orchestrator', False) + cleaned_agent.setdefault('orchestrator_descriptor', {}) # Remove empty reasoning_effort to prevent validation errors if cleaned_agent.get('reasoning_effort') == '': cleaned_agent.pop('reasoning_effort', None) @@ -217,6 +221,8 @@ def save_personal_agent(user_id, agent_data, actor_user_id=None): cleaned_result.setdefault('agent_type', 'local') cleaned_result.setdefault('tags', []) cleaned_result.setdefault('icon', {}) + cleaned_result.setdefault('discoverable_by_orchestrator', False) + cleaned_result.setdefault('orchestrator_descriptor', {}) bump_chat_bootstrap_user_cache_version(user_id, reason="personal_agent_saved") return cleaned_result diff --git a/application/single_app/route_backend_agents.py b/application/single_app/route_backend_agents.py index a8f7bdf93..30ba2ab9e 100644 --- a/application/single_app/route_backend_agents.py +++ b/application/single_app/route_backend_agents.py @@ -65,6 +65,25 @@ AGENT_INSTRUCTION_OUTPUT_TOKEN_LIMIT = 1400 +def _build_agent_log_summary(agent): + agent = agent if isinstance(agent, dict) else {} + descriptor = ( + agent.get('orchestrator_descriptor') + if isinstance(agent.get('orchestrator_descriptor'), dict) + else {} + ) + return { + 'agent_type': str(agent.get('agent_type') or 'local').strip().lower()[:40], + 'is_global': agent.get('is_global') is True, + 'is_group': agent.get('is_group') is True, + 'is_enabled': agent.get('is_enabled') is not False, + 'discoverable_by_orchestrator': agent.get('discoverable_by_orchestrator') is True, + 'action_count': len(agent.get('actions_to_load') or []), + 'capability_tag_count': len(descriptor.get('capability_tags') or []), + 'evidence_type_count': len(descriptor.get('evidence_types') or []), + } + + def _redact_catalog_agent_instructions(catalog): redacted_catalog = [] for agent in catalog or []: @@ -1645,11 +1664,11 @@ def add_agent(): return jsonify({'error': str(exc)}), 400 validation_error = validate_agent(cleaned_agent) if validation_error: - log_event("Add agent failed: validation error", level=logging.WARNING, extra={"action": "add", "agent": cleaned_agent, "error": validation_error}) + log_event("Add agent failed: validation error", level=logging.WARNING, extra={"action": "add", "agent_summary": _build_agent_log_summary(cleaned_agent), "error": validation_error}) return jsonify({'error': validation_error}), 400 # Prevent duplicate names (case-insensitive) if any(a['name'].lower() == cleaned_agent['name'].lower() for a in agents): - log_event("Add agent failed: duplicate name", level=logging.WARNING, extra={"action": "add", "agent": cleaned_agent}) + log_event("Add agent failed: duplicate name", level=logging.WARNING, extra={"action": "add", "agent_summary": _build_agent_log_summary(cleaned_agent)}) return jsonify({'error': 'Agent with this name already exists.'}), 400 # Assign a new GUID as id unless this is the default agent (which should have a static GUID) if not cleaned_agent.get('default_agent', False): @@ -1674,7 +1693,7 @@ def add_agent(): ) log_agent_creation(user_id=str(get_current_user_id()), agent_id=cleaned_agent.get('id', ''), agent_name=cleaned_agent.get('name', ''), agent_display_name=cleaned_agent.get('display_name', ''), scope='global') - log_event("Agent added", extra={"action": "add", "agent": {k: v for k, v in cleaned_agent.items() if k != 'id'}, "user": str(get_current_user_id())}) + log_event("Agent added", extra={"action": "add", "agent_summary": _build_agent_log_summary(cleaned_agent), "user": str(get_current_user_id())}) # --- HOT RELOAD TRIGGER --- setattr(builtins, "kernel_reload_needed", True) return jsonify({'success': True}) @@ -1772,11 +1791,11 @@ def edit_agent(agent_name): return jsonify({'error': str(exc)}), 400 validation_error = validate_agent(cleaned_agent) if validation_error: - log_event("Edit agent failed: validation error", level=logging.WARNING, extra={"action": "edit", "agent": cleaned_agent, "error": validation_error}) + log_event("Edit agent failed: validation error", level=logging.WARNING, extra={"action": "edit", "agent_summary": _build_agent_log_summary(cleaned_agent), "error": validation_error}) return jsonify({'error': validation_error}), 400 # --- Require at least one deployment field --- if not (cleaned_agent.get('azure_openai_gpt_deployment') or cleaned_agent.get('azure_agent_apim_gpt_deployment')): - log_event("Edit agent failed: missing deployment field", level=logging.WARNING, extra={"action": "edit", "agent": cleaned_agent}) + log_event("Edit agent failed: missing deployment field", level=logging.WARNING, extra={"action": "edit", "agent_summary": _build_agent_log_summary(cleaned_agent)}) return jsonify({'error': 'Agent must have either azure_openai_gpt_deployment or azure_agent_apim_gpt_deployment set.'}), 400 # Find the agent to update diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index 50426e352..86e210075 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -53,7 +53,7 @@ import threading from typing import Any, Dict, List, Mapping, Optional, Tuple from config import * -from flask import Response, copy_current_request_context, g, has_request_context, stream_with_context +from flask import Response, copy_current_request_context, current_app, g, has_request_context, stream_with_context from functions_authentication import * from functions_search import * from functions_service_health import ( @@ -91,7 +91,12 @@ URL_ACCESS_CONTEXT_CHAT, ) from functions_agents import get_agent_id_by_name -from functions_group import find_group_by_id, get_group_model_endpoints, get_user_role_in_group +from functions_group import ( + find_group_by_id, + get_group_model_endpoints, + get_user_role_in_group, + require_active_group, +) from functions_chat import * from functions_content import generate_embedding, generate_embeddings_batch from functions_assistant_table_exports import ( @@ -113,11 +118,17 @@ build_turn_orchestration_plan, ) from functions_chat_capabilities import ( + build_agent_capability_recommendation, + build_governed_agent_capability_inventory, build_governed_capability_inventory, + classify_agent_capability_requirements, classify_capability_requirements, match_governed_capabilities, + merge_capability_recommendations, normalize_capability_governance_modes, + resolve_governed_agent_capability_reference, ) +from functions_agent_catalog import build_authorized_agent_discovery_catalog from functions_chat_capability_choices import ( CapabilityChoiceError, add_sensitive_external_query_options, @@ -2003,6 +2014,24 @@ def _resolve_canonical_chat_agent(user_id, settings, requested_agent): if not isinstance(requested_agent, dict) or not requested_agent: return None + discovery_reference = str( + requested_agent.get('_orchestration_discovery_ref') or '' + ).strip() + if discovery_reference: + catalog = build_authorized_agent_discovery_catalog( + user_id, + settings=settings, + ) + canonical_agent = resolve_governed_agent_capability_reference( + catalog, + discovery_reference, + reference_secret=_get_agent_discovery_reference_secret(), + ) + if not canonical_agent: + return None + canonical_agent['_orchestration_discovery_ref'] = discovery_reference + return canonical_agent + requested_id = str(requested_agent.get('id') or '').strip() requested_name = str(requested_agent.get('name') or '').strip() if not requested_id and not requested_name: @@ -2110,18 +2139,69 @@ def _build_agent_selection_metadata(agent_info, assigned_knowledge_filters=None) } assigned_knowledge_enabled = bool(assigned_knowledge_filters) elif isinstance(agent_info, dict): - metadata = { - 'selected_agent': agent_info.get('name') or agent_info.get('selected_agent'), - 'agent_display_name': agent_info.get('display_name') or agent_info.get('agent_display_name'), - 'is_global': agent_info.get('is_global', False), - 'is_group': agent_info.get('is_group', False), - 'group_id': agent_info.get('group_id'), - 'group_name': agent_info.get('group_name'), - 'agent_id': agent_info.get('id') or agent_info.get('agent_id'), - 'agent_icon': agent_info.get('icon') or agent_info.get('agent_icon'), - 'agent_tags': agent_info.get('tags') or agent_info.get('agent_tags') or [], - 'catalog_key': agent_info.get('catalog_key'), - } + discovery_reference = str( + agent_info.get('_orchestration_discovery_ref') or '' + ).strip() + if discovery_reference: + descriptor = ( + agent_info.get('orchestrator_descriptor') + if isinstance(agent_info.get('orchestrator_descriptor'), Mapping) + else {} + ) + scope_class = str(agent_info.get('scope_type') or '').strip().lower() + if scope_class not in {'personal', 'global', 'group'}: + scope_class = ( + 'group' + if agent_info.get('is_group') + else 'global' + if agent_info.get('is_global') + else 'personal' + ) + display_name = str( + agent_info.get('display_name') + or agent_info.get('agent_display_name') + or agent_info.get('name') + or 'Specialized agent' + ).strip()[:120] + metadata = { + 'selected_agent': display_name, + 'agent_display_name': display_name, + 'is_global': scope_class == 'global', + 'is_group': scope_class == 'group', + 'group_id': None, + 'group_name': None, + 'agent_id': discovery_reference, + 'agent_icon': None, + 'agent_tags': list(descriptor.get('capability_tags') or []), + 'catalog_key': None, + 'selection_origin': 'discovery_approved', + 'scope_class': scope_class, + 'discovery_descriptor': { + 'read_only': descriptor.get('read_only') is True, + 'external_data': descriptor.get('external_data') is True, + 'risk_class': str(descriptor.get('risk_class') or '').strip()[:40], + 'data_sensitivity': str( + descriptor.get('data_sensitivity') or '' + ).strip()[:40], + 'latency_class': str(descriptor.get('latency_class') or '').strip()[:40], + 'cost_class': str(descriptor.get('cost_class') or '').strip()[:40], + 'capability_tags': list(descriptor.get('capability_tags') or [])[:16], + 'evidence_types': list(descriptor.get('evidence_types') or [])[:16], + }, + } + else: + metadata = { + 'selected_agent': agent_info.get('name') or agent_info.get('selected_agent'), + 'agent_display_name': agent_info.get('display_name') or agent_info.get('agent_display_name'), + 'is_global': agent_info.get('is_global', False), + 'is_group': agent_info.get('is_group', False), + 'group_id': agent_info.get('group_id'), + 'group_name': agent_info.get('group_name'), + 'agent_id': agent_info.get('id') or agent_info.get('agent_id'), + 'agent_icon': agent_info.get('icon') or agent_info.get('agent_icon'), + 'agent_tags': agent_info.get('tags') or agent_info.get('agent_tags') or [], + 'catalog_key': agent_info.get('catalog_key'), + } assigned_knowledge_enabled = bool( assigned_knowledge_filters or agent_info.get('assigned_knowledge_enabled') @@ -2633,6 +2713,52 @@ def _resolve_server_chat_capability_inventory( ) +def _get_agent_discovery_reference_secret(): + try: + configured_secret = current_app.config.get('SECRET_KEY') + except RuntimeError: + configured_secret = None + return configured_secret or SECRET_KEY + + +def _build_server_agent_discovery_inventory(*, settings, user_id): + try: + catalog = build_authorized_agent_discovery_catalog( + user_id, + settings=settings, + ) + inventory = build_governed_agent_capability_inventory( + catalog, + reference_secret=_get_agent_discovery_reference_secret(), + ) + return catalog, inventory + except Exception as exc: + log_event( + '[AgentDiscovery] Governed catalog unavailable', + extra={'error_type': type(exc).__name__}, + level=logging.WARNING, + ) + return [], {'version': 1, 'agents': []} + + +def _attach_governed_agent_inventory( + inventory, + *, + settings, + user_id, + selected_agent_present=False, +): + updated_inventory = copy.deepcopy(dict(inventory or {})) + agent_inventory = {'version': 1, 'agents': []} + if not selected_agent_present: + _, agent_inventory = _build_server_agent_discovery_inventory( + settings=settings, + user_id=user_id, + ) + updated_inventory['agents'] = copy.deepcopy(agent_inventory.get('agents') or []) + return updated_inventory + + def _build_server_capability_discovery( *, settings, @@ -2642,6 +2768,7 @@ def _build_server_capability_discovery( user_message, selected_capability_ids, authorized_document_count=0, + selected_agent_present=False, ): inventory = _resolve_server_chat_capability_inventory( settings=settings, @@ -2657,13 +2784,33 @@ def _build_server_capability_discovery( authorized_document_count=authorized_document_count, ) match = match_governed_capabilities(inventory, requirements) + agent_requirements = classify_agent_capability_requirements(user_message) + inventory = _attach_governed_agent_inventory( + inventory, + settings=settings, + user_id=user_id, + selected_agent_present=selected_agent_present, + ) + agent_inventory = { + 'version': 1, + 'agents': copy.deepcopy(inventory.get('agents') or []), + } + agent_recommendation = build_agent_capability_recommendation( + agent_inventory, + user_message, + selected_agent_present=selected_agent_present, + selected_capability_ids=selected_capability_ids, + ) recommendation = add_sensitive_external_query_options( - match.get('recommendation'), + merge_capability_recommendations( + match.get('recommendation'), + agent_recommendation, + ), user_message, ) return { 'inventory': inventory, - 'requirements': requirements, + 'requirements': requirements + agent_requirements, 'auto_capability_ids': list(match.get('auto_capability_ids') or []), 'recommendation': recommendation, } @@ -2966,6 +3113,12 @@ def _load_authorized_capability_proposal_context( selected_capability_ids=selected_capability_ids, authorized_document_count=len(authorized_documents) + len(authorized_task_documents), ) + agent_catalog, agent_inventory = _build_server_agent_discovery_inventory( + settings=settings, + user_id=user_id, + ) + refreshed_inventory = copy.deepcopy(refreshed_inventory) + refreshed_inventory['agents'] = copy.deepcopy(agent_inventory.get('agents') or []) provenance = ( proposal_metadata.get('capability_provenance') if isinstance(proposal_metadata.get('capability_provenance'), Mapping) @@ -2980,6 +3133,7 @@ def _load_authorized_capability_proposal_context( 'scope_context': scope_context, 'authorized_documents': authorized_documents + authorized_task_documents, 'inventory': refreshed_inventory, + 'agent_catalog': agent_catalog, 'provenance': copy.deepcopy(dict(provenance)), } @@ -3010,6 +3164,14 @@ def _claim_authorized_capability_resume( pending_effective_capability_ids = list( pending_decision.get('effective_capability_ids') or [] ) + pending_agent_ref = str(pending_decision.get('agent_ref') or '').strip() + canonical_discovered_agent = None + if pending_agent_ref: + canonical_discovered_agent = resolve_governed_agent_capability_reference( + context.get('agent_catalog') or [], + pending_agent_ref, + reference_secret=_get_agent_discovery_reference_secret(), + ) authorized_document_count = len(context.get('authorized_documents') or []) if 'analyze' in pending_effective_capability_ids and authorized_document_count < 1: raise CapabilityChoiceError( @@ -3097,6 +3259,21 @@ def _claim_authorized_capability_resume( context['resume_request'], effective_capability_ids, ) + agent_ref = str(decision.get('agent_ref') or '').strip() + if agent_ref: + canonical_discovered_agent = resolve_governed_agent_capability_reference( + context.get('agent_catalog') or [], + agent_ref, + reference_secret=_get_agent_discovery_reference_secret(), + ) + if not canonical_discovered_agent: + raise CapabilityChoiceError( + 'the approved agent is no longer in the governed catalog', + code='agent_missing', + ) + canonical_discovered_agent = copy.deepcopy(canonical_discovered_agent) + canonical_discovered_agent['_orchestration_discovery_ref'] = agent_ref + request_data['agent_info'] = canonical_discovered_agent external_query_mode = str(decision.get('external_query_mode') or 'minimized').strip().lower() if any( capability_id in {'web_search', 'url_access', 'deep_research'} @@ -3118,6 +3295,8 @@ def _claim_authorized_capability_resume( } for capability_id in effective_capability_ids: capability_origins[capability_id] = 'discovery_approved' + if agent_ref: + capability_origins['selected_agent'] = 'discovery_approved' request_data['_capability_resume_context'] = { 'proposal_id': proposal_id, 'parent_run_id': claimed_proposal.get('run_id'), @@ -3134,6 +3313,8 @@ def _claim_authorized_capability_resume( context['provenance'].get('proposed_capabilities') or claimed_proposal ), 'effective_capability_ids': effective_capability_ids, + 'agent_ref': agent_ref or None, + 'agent_origin': 'discovery_approved' if agent_ref else None, 'existing_user_message': copy.deepcopy(context['user_message_doc']), } context['proposal'] = claimed_proposal @@ -5776,6 +5957,58 @@ def build_grounded_image_central_synthesis_context(user_message, orchestration_p } +def build_agent_evidence_central_synthesis_context( + user_message, + orchestration_plan, + evidence_ledger, +): + """Build the existing central-finalizer handoff for approved agent evidence.""" + if not central_synthesis_is_ready(orchestration_plan, evidence_ledger): + return None + output_profile = ( + build_grounded_image_synthesis_profile() + if orchestration_plan.get('grounded_image_generation_requested') + else None + ) + synthesis_request = create_central_synthesis_request( + user_message, + orchestration_plan, + evidence_ledger, + output_profile=output_profile, + ) + return { + 'request': synthesis_request, + 'messages': build_central_synthesis_messages(synthesis_request), + } + + +def _requires_agent_evidence_collection(orchestration_plan, resume_context): + return bool( + orchestration_plan.get('grounded_image_generation_requested') + or ( + isinstance(resume_context, Mapping) + and resume_context.get('agent_origin') == 'discovery_approved' + ) + ) + + +def _build_discovered_agent_evidence_capability_metadata(agent_info): + descriptor = ( + agent_info.get('orchestrator_descriptor') + if isinstance(agent_info, Mapping) + and isinstance(agent_info.get('orchestrator_descriptor'), Mapping) + else {} + ) + return { + 'capability_tags': list(descriptor.get('capability_tags') or [])[:16], + 'evidence_types': list(descriptor.get('evidence_types') or [])[:16], + 'required_permissions': [], + 'uses_current_user_context': True, + 'returns_citations': True, + 'may_include_sensitive_data': False, + } + + def insert_system_message_after_existing_system_messages(conversation_history, system_message_content): """Insert a system message after existing system messages while avoiding duplicates.""" if not isinstance(conversation_history, list): @@ -11853,6 +12086,37 @@ def restore_agent_stream_retry_state(agent, retry_state): settings.function_choice_behavior = original_behavior +def apply_discovered_agent_invocation_policy(agent, resume_context): + """Disable inherited tools and verbose telemetry for an approved discovered agent.""" + if not ( + agent + and isinstance(resume_context, Mapping) + and resume_context.get('agent_origin') == 'discovery_approved' + ): + return None + policy_state = { + 'tool_state': apply_agent_stream_retry_mode(agent, 'disable_tools'), + 'minimize_telemetry': bool( + getattr(agent, 'orchestration_minimize_telemetry', False) + ), + } + if hasattr(agent, 'orchestration_minimize_telemetry'): + agent.orchestration_minimize_telemetry = True + return policy_state + + +def restore_discovered_agent_invocation_policy(agent, policy_state): + """Restore the selected-agent runtime after a governed discovered invocation.""" + if not agent or not policy_state: + return + restore_agent_stream_retry_state(agent, policy_state.get('tool_state')) + if hasattr(agent, 'orchestration_minimize_telemetry'): + agent.orchestration_minimize_telemetry = policy_state.get( + 'minimize_telemetry', + False, + ) + + def register_route_backend_chats(bp): def build_background_stream_response(event_generator_factory, stream_session=None): """Run SSE generation in background execution so it survives disconnects.""" @@ -12903,6 +13167,13 @@ def execute_document_action_chat_request( authorized_document_count=len(authorized_action_documents), ) ) + if not capability_resume_context: + action_capability_inventory = _attach_governed_agent_inventory( + action_capability_inventory, + settings=settings, + user_id=user_id, + selected_agent_present=_has_chat_agent_selection(request_agent_info), + ) rejected_selected_capabilities = _get_rejected_selected_capability_entries( action_capability_inventory, selected_builtin_capability_ids, @@ -14112,6 +14383,12 @@ def decide_chat_capability_proposal(proposal_id): conversation_id=conversation_id, proposal_id=proposal_id, ) + if option_id.startswith('agent:'): + resolve_governed_agent_capability_reference( + context.get('agent_catalog') or [], + option_id, + reference_secret=_get_agent_discovery_reference_secret(), + ) _, proposal, idempotent = persist_capability_decision( cosmos_messages_container, conversation_id=conversation_id, @@ -14174,7 +14451,11 @@ def decide_chat_capability_proposal(proposal_id): }, level=logging.WARNING, ) - status_code = 409 if exc.code in conflict_codes or exc.code.startswith('capability_') else 400 + status_code = 409 if ( + exc.code in conflict_codes + or exc.code.startswith('capability_') + or exc.code.startswith('agent_') + ) else 400 return jsonify({'error': str(exc), 'code': exc.code}), status_code except Exception as exc: log_event( @@ -14754,6 +15035,12 @@ def result_requires_message_reload(result: Any) -> bool: + len(auto_linked_chat_upload_document_ids) ), ) + compatibility_capability_inventory = _attach_governed_agent_inventory( + compatibility_capability_inventory, + settings=settings, + user_id=user_id, + selected_agent_present=_has_chat_agent_selection(request_agent_info), + ) compatibility_rejected_capabilities = _get_rejected_selected_capability_entries( compatibility_capability_inventory, compatibility_selected_capability_ids, @@ -17465,6 +17752,9 @@ def gpt_error(e): agent_scope_for_usage = 'personal' agent_group_id_for_usage = None agent_catalog_key_for_usage = None + agent_id_for_usage = getattr(selected_agent, 'id', None) if selected_agent else None + agent_name_for_usage = agent_name + agent_display_name_for_usage = agent_display_name if selected_agent: selection_metadata = user_metadata.get('agent_selection') if isinstance(user_metadata, dict) else None if isinstance(selection_metadata, dict): @@ -17476,6 +17766,16 @@ def gpt_error(e): agent_catalog_key_for_usage = selection_metadata.get('catalog_key') agent_icon = selection_metadata.get('agent_icon') agent_tags = selection_metadata.get('agent_tags') or [] + if selection_metadata.get('selection_origin') == 'discovery_approved': + agent_id_for_usage = selection_metadata.get('agent_id') + agent_name_for_usage = ( + selection_metadata.get('agent_display_name') + or selection_metadata.get('selected_agent') + or 'Specialized agent' + ) + agent_display_name_for_usage = agent_name_for_usage + agent_group_id_for_usage = None + agent_catalog_key_for_usage = None # assistant_message_id was generated earlier for thought tracking @@ -17586,9 +17886,9 @@ def gpt_error(e): if selected_agent and agent_name: log_agent_run( user_id=get_current_user_id(), - agent_id=getattr(selected_agent, 'id', None), - agent_name=agent_name, - agent_display_name=agent_display_name, + agent_id=agent_id_for_usage, + agent_name=agent_name_for_usage, + agent_display_name=agent_display_name_for_usage, scope=agent_scope_for_usage, group_id=agent_group_id_for_usage, conversation_id=conversation_id, @@ -18654,6 +18954,7 @@ def build_streaming_capability_usage(): user_message=user_message, selected_capability_ids=selected_builtin_capability_ids, authorized_document_count=len(discovery_document_ids), + selected_agent_present=_has_chat_agent_selection(request_agent_info), ) inventory_entries = capability_discovery.get('inventory', {}).get('capabilities', []) log_event( @@ -18720,7 +19021,11 @@ def build_streaming_capability_usage(): plan_kwargs = { 'conversation_id': conversation_id, - 'selected_agent': request_agent_info, + 'selected_agent': ( + {'id': capability_resume_context.get('agent_ref')} + if capability_resume_context and capability_resume_context.get('agent_ref') + else request_agent_info + ), 'selected_action': data.get('document_action'), 'selected_document_ids': requested_selected_document_ids, 'conversation_document_ids': auto_linked_chat_upload_document_ids, @@ -18801,6 +19106,8 @@ def build_streaming_capability_usage(): for capability_id in selected_builtin_capability_ids ] for capability_id, origin in capability_origins.items(): + if capability_id == 'selected_agent': + continue if not capability_id or any( entry['id'] == capability_id for entry in effective_capability_entries @@ -18811,6 +19118,13 @@ def build_streaming_capability_usage(): 'origin': origin, 'required': True, }) + if capability_resume_context and capability_resume_context.get('agent_ref'): + effective_capability_entries.append({ + 'id': capability_resume_context.get('agent_ref'), + 'kind': 'agent', + 'origin': 'discovery_approved', + 'required': True, + }) turn_capability_provenance = build_capability_provenance( selection_snapshot=turn_orchestration_plan.get('selection_snapshot'), capability_inventory=capability_discovery.get('inventory'), @@ -20595,6 +20909,16 @@ def persist_central_synthesis_state(status): if isinstance(selected_agent_metadata, dict): agent_icon_used = selected_agent_metadata.get('agent_icon') agent_tags_used = selected_agent_metadata.get('agent_tags') or [] + if ( + selected_agent_metadata.get('selection_origin') + == 'discovery_approved' + ): + agent_display_name_used = ( + selected_agent_metadata.get('agent_display_name') + or selected_agent_metadata.get('selected_agent') + or 'Specialized agent' + ) + agent_name_used = agent_display_name_used debug_print(f"--- Streaming from Agent: {agent_name_used} (model: {actual_model_used}) ---") else: debug_print(f"[Streaming] ⚠️ No agent selected, falling back to GPT") @@ -20644,17 +20968,25 @@ def persist_central_synthesis_state(status): for runtime_progress_event in drain_orchestration_runtime_progress(): yield runtime_progress_event - if ( - selected_agent - and turn_orchestration_plan.get('grounded_image_generation_requested') + if selected_agent and _requires_agent_evidence_collection( + turn_orchestration_plan, + capability_resume_context, ): + evidence_capability_metadata = ( + _build_discovered_agent_evidence_capability_metadata( + request_agent_info + ) + if capability_resume_context + and capability_resume_context.get('agent_origin') == 'discovery_approved' + else selected_agent + ) agent_evidence_task = build_agent_action_evidence_task( turn_orchestration_plan, turn_evidence_ledger, user_message, executor_type='selected_agent', executor_name=agent_display_name_used or agent_name_used, - capability_metadata=selected_agent, + capability_metadata=evidence_capability_metadata, authorization_context=getattr(g, 'authorized_chat_context', None), ) @@ -20875,6 +21207,8 @@ def finalize_cancelled_stream_response(): 'no image proposal was created.' ) if use_agent_streaming and selected_agent: + if hasattr(selected_agent, 'tool_invocations'): + selected_agent.tool_invocations = [] if ( selected_agent_runtime_node and selected_agent_runtime_node.status == 'pending' @@ -20944,6 +21278,12 @@ def finalize_cancelled_agent_stream_response(): agent_retry_plan = None retry_state = None + discovery_invocation_policy = ( + apply_discovered_agent_invocation_policy( + selected_agent, + capability_resume_context, + ) + ) try: for attempt_number in range(2): @@ -21101,6 +21441,10 @@ def finalize_cancelled_agent_stream_response(): return finally: restore_agent_stream_retry_state(selected_agent, retry_state) + restore_discovered_agent_invocation_policy( + selected_agent, + discovery_invocation_policy, + ) actual_model_used = ( getattr(selected_agent, 'last_run_model', None) @@ -21179,7 +21523,18 @@ def finalize_cancelled_agent_stream_response(): all_invocations = plugin_logger.get_recent_invocations() debug_print(f"[Agent Streaming] Total plugin invocations logged: {len(all_invocations)}") - plugin_invocations = plugin_logger.get_invocations_for_conversation(user_id, conversation_id) + discovered_agent_run = bool( + capability_resume_context + and capability_resume_context.get('agent_origin') == 'discovery_approved' + ) + plugin_invocations = ( + [] + if discovered_agent_run + else plugin_logger.get_invocations_for_conversation( + user_id, + conversation_id, + ) + ) debug_print(f"[Agent Streaming] Found {len(plugin_invocations)} plugin invocations for user {user_id}, conversation {conversation_id}") # If no invocations found, check if plugins were called at all @@ -21291,7 +21646,7 @@ def finalize_cancelled_agent_stream_response(): ) yield emit_thought('evidence_collection', evidence_status_message) user_metadata['evidence_ledger'] = turn_evidence_ledger - central_synthesis_context = build_grounded_image_central_synthesis_context( + central_synthesis_context = build_agent_evidence_central_synthesis_context( user_message, turn_orchestration_plan, turn_evidence_ledger, @@ -21300,7 +21655,7 @@ def finalize_cancelled_agent_stream_response(): user_message_doc['metadata'] = user_metadata cosmos_messages_container.upsert_item(user_message_doc) raise RuntimeError( - 'Grounded image evidence did not reach a terminal synthesis state.' + 'Agent evidence did not reach a terminal synthesis state.' ) persist_central_synthesis_state('pending') @@ -21716,6 +22071,9 @@ def finalize_cancelled_agent_stream_response(): agent_scope_for_usage = 'personal' agent_group_id_for_usage = None agent_catalog_key_for_usage = None + agent_id_for_usage = getattr(selected_agent, 'id', None) if selected_agent else None + agent_name_for_usage = agent_name_used + agent_display_name_for_usage = agent_display_name_used if isinstance(selected_agent_metadata, dict): if selected_agent_metadata.get('is_global'): agent_scope_for_usage = 'global' @@ -21723,11 +22081,21 @@ def finalize_cancelled_agent_stream_response(): agent_scope_for_usage = 'group' agent_group_id_for_usage = selected_agent_metadata.get('group_id') agent_catalog_key_for_usage = selected_agent_metadata.get('catalog_key') + if selected_agent_metadata.get('selection_origin') == 'discovery_approved': + agent_id_for_usage = selected_agent_metadata.get('agent_id') + agent_name_for_usage = ( + selected_agent_metadata.get('agent_display_name') + or selected_agent_metadata.get('selected_agent') + or 'Specialized agent' + ) + agent_display_name_for_usage = agent_name_for_usage + agent_group_id_for_usage = None + agent_catalog_key_for_usage = None log_agent_run( user_id=user_id, - agent_id=getattr(selected_agent, 'id', None) if selected_agent else None, - agent_name=agent_name_used, - agent_display_name=agent_display_name_used, + agent_id=agent_id_for_usage, + agent_name=agent_name_for_usage, + agent_display_name=agent_display_name_for_usage, scope=agent_scope_for_usage, group_id=agent_group_id_for_usage, conversation_id=conversation_id, diff --git a/application/single_app/semantic_kernel_loader.py b/application/single_app/semantic_kernel_loader.py index 8140b173e..4b594c9c8 100644 --- a/application/single_app/semantic_kernel_loader.py +++ b/application/single_app/semantic_kernel_loader.py @@ -113,6 +113,32 @@ from functions_agent_scope import find_agent_by_scope, is_selected_agent_scope_enabled import app_settings_cache + +def _build_agent_loader_log_summary(agent): + agent = agent if isinstance(agent, dict) else {} + return { + "agent_type": str(agent.get("agent_type") or "local").strip().lower()[:40], + "is_global": agent.get("is_global") is True, + "is_group": agent.get("is_group") is True, + "is_enabled": agent.get("is_enabled") is not False, + "discoverable_by_orchestrator": agent.get("discoverable_by_orchestrator") is True, + "action_count": len(agent.get("actions_to_load") or []), + } + + +def _build_agent_connection_log_summary(agent_config): + agent_config = agent_config if isinstance(agent_config, dict) else {} + return { + "endpoint_configured": bool(agent_config.get("endpoint")), + "credential_configured": bool( + agent_config.get("key") or agent_config.get("token_provider") + ), + "deployment_configured": bool(agent_config.get("deployment")), + "apim_enabled": agent_config.get("enable_agent_gpt_apim") is True, + "endpoint_protocol": resolve_agent_endpoint_protocol(agent_config), + } + + # Agent and Azure OpenAI chat service imports log_event("[SK Loader] Starting loader imports") try: @@ -1812,15 +1838,19 @@ def create_chat_completion_service(): f"[SK Loader] Registered Foundry agent: {agent_config['name']} ({mode_label})", { "agent_name": agent_config["name"], - "agent_id": agent_config.get("id"), "is_global": agent_config.get("is_global", False), + "is_group": agent_config.get("is_group", False), "agent_type": agent_type, }, level=logging.INFO, ) return kernel, agent_objs - log_event(f"[SK Loader] Agent config resolved for {agent_cfg.get('name')} - endpoint: {bool(agent_config.get('endpoint'))}, key: {bool(agent_config.get('key'))}, deployment: {agent_config.get('deployment')}, max_completion_tokens: {agent_config.get('max_completion_tokens')}", level=logging.INFO) + log_event( + f"[SK Loader] Agent config resolved for {agent_cfg.get('name')}", + extra=_build_agent_connection_log_summary(agent_config), + level=logging.INFO, + ) token_provider_present = bool(agent_config.get("token_provider")) has_auth = bool(agent_config.get("key")) or token_provider_present @@ -1830,37 +1860,21 @@ def create_chat_completion_service(): if apim_enabled: log_event( f"[SK Loader] Initializing APIM chat completion for agent: {agent_config['name']} ({mode_label})", - { - "aoai_endpoint": agent_config["endpoint"], - "aoai_key": f"{agent_config['key'][:3]}..." if agent_config["key"] else None, - "aoai_deployment": agent_config["deployment"], - "agent_name": agent_config["name"], - "endpoint_protocol": resolve_agent_endpoint_protocol(agent_config), - }, + _build_agent_connection_log_summary(agent_config), level=logging.INFO ) chat_service = create_chat_completion_service() else: log_event( f"[SK Loader] Initializing GPT Direct chat completion for agent: {agent_config['name']} ({mode_label})", - { - "aoai_endpoint": agent_config["endpoint"], - "aoai_key": f"{agent_config['key'][:3]}..." if agent_config["key"] else None, - "aoai_deployment": agent_config["deployment"], - "agent_name": agent_config["name"], - "endpoint_protocol": resolve_agent_endpoint_protocol(agent_config), - }, + _build_agent_connection_log_summary(agent_config), level=logging.INFO ) chat_service = create_chat_completion_service() if not chat_service: log_event( f"[SK Loader] Chat completion service could not be created for agent: {agent_config['name']} ({mode_label})", - { - "agent_name": agent_config["name"], - "aoai_endpoint": agent_config.get("endpoint"), - "aoai_deployment": agent_config.get("deployment"), - }, + _build_agent_connection_log_summary(agent_config), level=logging.ERROR, exceptionTraceback=True, ) @@ -1872,14 +1886,7 @@ def create_chat_completion_service(): kernel.add_service(chat_service) log_event( f"[SK Loader] Chat completion service registered for agent: {agent_config['name']} ({mode_label})", - { - "aoai_endpoint": agent_config["endpoint"], - "aoai_key": f"{agent_config['key'][:3]}..." if agent_config["key"] else None, - "aoai_deployment": agent_config["deployment"], - "agent_name": agent_config["name"], - "apim_enabled": agent_config.get("enable_agent_gpt_apim", False), - "endpoint_protocol": resolve_agent_endpoint_protocol(agent_config), - }, + _build_agent_connection_log_summary(agent_config), level=logging.INFO ) else: @@ -1892,13 +1899,7 @@ def create_chat_completion_service(): print(f" - deployment: {bool(agent_config.get('deployment'))}") log_event( f"[SK Loader] AzureChatCompletion or configuration not resolved for agent: {agent_config['name']} ({mode_label})", - { - "agent_name": agent_config["name"], - "aoai_endpoint": agent_config["endpoint"], - "aoai_key": f"{agent_config['key'][:3]}..." if agent_config["key"] else None, - "token_provider": token_provider_present, - "aoai_deployment": agent_config["deployment"], - }, + _build_agent_connection_log_summary(agent_config), level=logging.ERROR, exceptionTraceback=True ) @@ -1908,7 +1909,10 @@ def create_chat_completion_service(): print(f"[SK Loader] Creating LoggingChatCompletionAgent for {agent_config['name']}...") # Load agent-specific plugins into the kernel before creating the agent if agent_config.get("actions_to_load"): - print(f"[SK Loader] Loading agent-specific plugins: {agent_config['actions_to_load']}") + print( + "[SK Loader] Loading agent-specific plugins: " + f"count={len(agent_config['actions_to_load'])}" + ) # Determine plugin source based on agent scope agent_is_global = agent_config.get("is_global", False) agent_is_group = agent_config.get("is_group", False) @@ -1921,7 +1925,10 @@ def create_chat_completion_service(): resolved_user_id = get_current_user_id() group_id = agent_config.get("group_id") if agent_is_group else None - print(f"[SK Loader] Agent scope - is_global: {agent_is_global}, is_group: {agent_is_group}, plugin_mode: {plugin_mode}, group_id: {group_id}") + print( + f"[SK Loader] Agent scope - is_global: {agent_is_global}, " + f"is_group: {agent_is_group}, plugin_mode: {plugin_mode}" + ) load_agent_specific_plugins( kernel, agent_config["actions_to_load"], @@ -1998,10 +2005,7 @@ def create_chat_completion_service(): log_event( f"[SK Loader] ChatCompletionAgent initialized for agent: {agent_config['name']} ({mode_label})", { - "aoai_endpoint": agent_config["endpoint"], - "aoai_key": f"{agent_config['key'][:3]}..." if agent_config["key"] else None, - "aoai_deployment": agent_config["deployment"], - "agent_name": agent_config["name"], + **_build_agent_connection_log_summary(agent_config), "max_completion_tokens": agent_config.get("max_completion_tokens", -1), "agent_type": agent_type, }, @@ -2638,14 +2642,21 @@ def load_user_semantic_kernel(kernel: Kernel, settings, user_id: str, redis_clie agents_cfg = list(all_agents.values()) print(f"[SK Loader] After merging: {len(agents_cfg)} total agents") - debug_print(f"[SK Loader] Merged agents: {[(a.get('name'), a.get('is_global', False)) for a in agents_cfg]}") - log_event(f"[SK Loader] Merged global agents into per-user agents: {[a.get('name') for a in agents_cfg]}", level=logging.INFO) + debug_print(f"[SK Loader] Merged agent count: {len(agents_cfg)}") + log_event( + "[SK Loader] Merged global agents into per-user agents", + extra={"agent_count": len(agents_cfg)}, + level=logging.INFO, + ) log_event(f"[SK Loader] Found {len(agents_cfg)} agents for user '{user_id}'.", extra={ "user_id": user_id, "agents_count": len(agents_cfg), - "agents": agents_cfg + "agent_summaries": [ + _build_agent_loader_log_summary(agent) + for agent in agents_cfg + ], }, level=logging.INFO) # Ensure migration is complete (will migrate any remaining legacy data) @@ -2897,13 +2908,8 @@ def load_semantic_kernel(kernel: Kernel, settings): log_event( f"[SK Loader] Creating chat completion service {service_id} for agent: {agent_config['name']}", { - "aoai_endpoint": agent_config["endpoint"], - "aoai_key": f"{agent_config['key'][:3]}..." if agent_config["key"] else None, - "aoai_deployment": agent_config["deployment"], - "agent_name": agent_config["name"], - "actions_to_load": agent_config.get("actions_to_load", []), - "apim_enabled": settings.get("enable_gpt_apim", False), - "endpoint_protocol": resolve_agent_endpoint_protocol(agent_config), + **_build_agent_connection_log_summary(agent_config), + "action_count": len(agent_config.get("actions_to_load") or []), }, level=logging.INFO ) @@ -2951,12 +2957,8 @@ def load_semantic_kernel(kernel: Kernel, settings): log_event( f"[SK Loader] ChatCompletionAgent initialized for agent: {agent_config['name']}", { - "aoai_endpoint": agent_config["endpoint"], - "aoai_key": f"{agent_config['key'][:3]}..." if agent_config["key"] else None, - "aoai_deployment": agent_config["deployment"], - "agent_name": agent_config["name"], - "description": agent_obj.description, - "id": agent_obj.id + **_build_agent_connection_log_summary(agent_config), + "agent_type": str(agent_config.get("agent_type") or "local"), }, level=logging.INFO ) @@ -2995,13 +2997,8 @@ def load_semantic_kernel(kernel: Kernel, settings): log_event( f"[SK Loader] Creating chat completion service {service_id} for orchestrator agent: {orchestrator_config['name']}", { - "aoai_endpoint": orchestrator_config["endpoint"], - "aoai_key": f"{orchestrator_config['key'][:3]}..." if orchestrator_config["key"] else None, - "aoai_deployment": orchestrator_config["deployment"], - "agent_name": orchestrator_config["name"], + **_build_agent_connection_log_summary(orchestrator_config), "service_id": service_id or None, - "apim_enabled": settings.get("enable_gpt_apim", False), - "endpoint_protocol": resolve_agent_endpoint_protocol(orchestrator_config), }, level=logging.INFO ) @@ -3157,11 +3154,10 @@ def load_semantic_kernel(kernel: Kernel, settings): log_event( f"[SK Loader] Azure OpenAI chat completion service registered (kernel-only mode)", { - "aoai_endpoint": endpoint, - "aoai_key": f"{key[:3]}..." if key else None, - "aoai_deployment": deployment, - "agent_name": None, - "apim_enabled": apim_enabled + "endpoint_configured": bool(endpoint), + "credential_configured": bool(key), + "deployment_configured": bool(deployment), + "apim_enabled": apim_enabled, }, level=logging.INFO ) diff --git a/application/single_app/static/js/agent_modal_stepper.js b/application/single_app/static/js/agent_modal_stepper.js index aece466da..a0533341c 100644 --- a/application/single_app/static/js/agent_modal_stepper.js +++ b/application/single_app/static/js/agent_modal_stepper.js @@ -38,6 +38,21 @@ function normalizeHttpUrl(value) { } } +function normalizeOrchestratorDescriptorItems(value) { + const normalized = []; + String(value || '') + .split(',') + .map(item => item.trim().toLowerCase().replace(/[^a-z0-9_:-]+/g, '_').replace(/^_+|_+$/g, '')) + .filter(Boolean) + .forEach(item => { + const boundedItem = item.slice(0, 64); + if (!normalized.includes(boundedItem) && normalized.length < 16) { + normalized.push(boundedItem); + } + }); + return normalized; +} + const SIMPLECHAT_CAPABILITY_DEFINITIONS = [ { key: 'create_group', @@ -246,6 +261,7 @@ export class AgentModalStepper { const saveBtn = document.getElementById('agent-modal-save-btn'); const skipBtn = document.getElementById('agent-modal-skip'); const powerUserToggle = document.getElementById('agent-power-user-toggle'); + const orchestratorDiscoveryToggle = document.getElementById('agent-discoverable-by-orchestrator'); const agentTypeRadios = document.querySelectorAll('input[name="agent-type"]'); const draftInstructionsBtn = document.getElementById('agent-draft-instructions-btn'); @@ -264,6 +280,12 @@ export class AgentModalStepper { if (powerUserToggle) { powerUserToggle.addEventListener('change', (e) => this.togglePowerUserMode(e.target.checked)); } + if (orchestratorDiscoveryToggle) { + orchestratorDiscoveryToggle.addEventListener( + 'change', + () => this.syncOrchestratorDiscoveryControls() + ); + } if (draftInstructionsBtn) { draftInstructionsBtn.addEventListener('click', () => this.draftInstructions()); } @@ -355,6 +377,20 @@ export class AgentModalStepper { return JSON.parse(JSON.stringify(value || EMPTY_ASSIGNED_KNOWLEDGE)); } + syncOrchestratorDiscoveryControls() { + const toggle = document.getElementById('agent-discoverable-by-orchestrator'); + const controls = document.getElementById('agent-orchestrator-descriptor-controls'); + if (!toggle || !controls) { + return; + } + const enabled = toggle.checked; + controls.classList.toggle('d-none', !enabled); + toggle.setAttribute('aria-expanded', enabled ? 'true' : 'false'); + controls.querySelectorAll('input, select').forEach(control => { + control.disabled = !enabled; + }); + } + getAssignedKnowledgeAgentScope() { if (this.isAdmin) { return 'global'; @@ -1694,6 +1730,8 @@ export class AgentModalStepper { const instructionsInput = document.getElementById('agent-instructions'); const advancedFoundryNote = document.getElementById('agent-advanced-foundry-note'); const localAgentAdvancedSettings = document.getElementById('local-agent-advanced-settings'); + const orchestratorDiscoveryFieldset = document.getElementById('agent-orchestrator-discovery-fieldset'); + const orchestratorDiscoveryToggle = document.getElementById('agent-discoverable-by-orchestrator'); const foundryModeNote = document.getElementById('agent-foundry-mode-note'); const foundryFetchBtnLabel = document.getElementById('agent-foundry-fetch-btn-label'); const foundrySelectLabel = document.getElementById('agent-foundry-select-label'); @@ -1748,6 +1786,13 @@ export class AgentModalStepper { if (localAgentAdvancedSettings) { localAgentAdvancedSettings.classList.toggle('d-none', isFoundry); } + if (orchestratorDiscoveryFieldset) { + orchestratorDiscoveryFieldset.classList.toggle('d-none', isFoundry); + } + if (orchestratorDiscoveryToggle && isFoundry) { + orchestratorDiscoveryToggle.checked = false; + } + this.syncOrchestratorDiscoveryControls(); // Update helper text const helper = document.getElementById('agent-type-helper'); @@ -1926,6 +1971,7 @@ export class AgentModalStepper { this.actionsToSelect = null; // Clear any stored actions for new agent this.clearFields(); } + this.syncOrchestratorDiscoveryControls(); // Ensure generated name is populated for both new and existing agents this.updateGeneratedName(); @@ -2022,6 +2068,15 @@ export class AgentModalStepper { const additionalSettings = document.getElementById('agent-additional-settings'); const instructionBrief = document.getElementById('agent-instruction-brief'); const draftStatus = document.getElementById('agent-draft-instructions-status'); + const orchestratorDiscoveryToggle = document.getElementById('agent-discoverable-by-orchestrator'); + const orchestratorCapabilityTags = document.getElementById('agent-orchestrator-capability-tags'); + const orchestratorEvidenceTypes = document.getElementById('agent-orchestrator-evidence-types'); + const orchestratorReadOnly = document.getElementById('agent-orchestrator-read-only'); + const orchestratorExternalData = document.getElementById('agent-orchestrator-external-data'); + const orchestratorRiskClass = document.getElementById('agent-orchestrator-risk-class'); + const orchestratorDataSensitivity = document.getElementById('agent-orchestrator-data-sensitivity'); + const orchestratorLatencyClass = document.getElementById('agent-orchestrator-latency-class'); + const orchestratorCostClass = document.getElementById('agent-orchestrator-cost-class'); if (displayName) displayName.value = ''; if (generatedName) generatedName.value = ''; @@ -2053,12 +2108,22 @@ export class AgentModalStepper { if (additionalSettings) additionalSettings.value = '{}'; if (instructionBrief) instructionBrief.value = ''; if (draftStatus) draftStatus.textContent = ''; + if (orchestratorDiscoveryToggle) orchestratorDiscoveryToggle.checked = false; + if (orchestratorCapabilityTags) orchestratorCapabilityTags.value = ''; + if (orchestratorEvidenceTypes) orchestratorEvidenceTypes.value = ''; + if (orchestratorReadOnly) orchestratorReadOnly.checked = true; + if (orchestratorExternalData) orchestratorExternalData.checked = false; + if (orchestratorRiskClass) orchestratorRiskClass.value = 'internal_read'; + if (orchestratorDataSensitivity) orchestratorDataSensitivity.value = 'internal'; + if (orchestratorLatencyClass) orchestratorLatencyClass.value = 'seconds'; + if (orchestratorCostClass) orchestratorCostClass.value = 'standard'; const iconImageData = document.getElementById('agent-icon-image-data'); const iconImageFile = document.getElementById('agent-icon-image-file'); if (iconImageData) iconImageData.value = ''; if (iconImageFile) iconImageFile.value = ''; agentsCommon.setIconPayload(document, { kind: 'bootstrap', value: 'bi-robot' }); this.resetAssignedKnowledgeControls(); + this.syncOrchestratorDiscoveryControls(); // Clear any selected actions this.clearSelectedActions(); @@ -2393,6 +2458,7 @@ export class AgentModalStepper { if (agentsCommon && typeof agentsCommon.setAgentModalFields === 'function') { agentsCommon.setAgentModalFields(agent); } + this.syncOrchestratorDiscoveryControls(); this.setInstructionsValue(agent.instructions || ''); this.setAssignedKnowledgeControls(agent.other_settings?.[ASSIGNED_KNOWLEDGE_KEY]); @@ -2818,7 +2884,34 @@ export class AgentModalStepper { break; case 6: // Advanced - // Advanced settings validation would go here if needed + if (document.getElementById('agent-discoverable-by-orchestrator')?.checked) { + const capabilityTags = normalizeOrchestratorDescriptorItems( + document.getElementById('agent-orchestrator-capability-tags')?.value + ); + const evidenceTypes = normalizeOrchestratorDescriptorItems( + document.getElementById('agent-orchestrator-evidence-types')?.value + ); + const readOnly = document.getElementById('agent-orchestrator-read-only'); + if (capabilityTags.length === 0) { + this.showError('Add at least one orchestrator capability tag.'); + document.getElementById('agent-orchestrator-capability-tags')?.focus(); + return false; + } + if (evidenceTypes.length === 0) { + this.showError('Add at least one orchestrator evidence type.'); + document.getElementById('agent-orchestrator-evidence-types')?.focus(); + return false; + } + if (!readOnly?.checked) { + this.showError('Orchestrator discovery currently supports read-only agents only.'); + readOnly?.focus(); + return false; + } + if (this.getSelectedActionIds().length > 0) { + this.showError('Remove attached actions before enabling orchestrator discovery.'); + return false; + } + } break; case 7: // Summary @@ -3282,7 +3375,13 @@ export class AgentModalStepper { // Create badge content with global tag if needed if (isGlobal) { - badge.innerHTML = `${actionName} global`; + badge.textContent = actionName; + const globalBadge = document.createElement('small'); + globalBadge.className = 'badge bg-info text-dark ms-1'; + globalBadge.style.fontSize = '0.6em'; + globalBadge.textContent = 'global'; + badge.appendChild(document.createTextNode(' ')); + badge.appendChild(globalBadge); } else { badge.textContent = actionName; } @@ -4205,6 +4304,9 @@ export class AgentModalStepper { const modelId = modelIdInput?.value || selectedModelOption?.value || ''; const modelProvider = modelProviderInput?.value || selectedModelOption?.dataset?.provider || ''; const foundryAuthenticationType = 'delegated_user'; + const discoverableByOrchestrator = document.getElementById( + 'agent-discoverable-by-orchestrator' + )?.checked === true; const formData = { display_name: document.getElementById('agent-display-name')?.value || '', @@ -4225,7 +4327,22 @@ export class AgentModalStepper { agent_type: selectedAgentType, model_endpoint_id: modelEndpointId, model_id: modelId, - model_provider: modelProvider + model_provider: modelProvider, + discoverable_by_orchestrator: discoverableByOrchestrator, + orchestrator_descriptor: discoverableByOrchestrator ? { + capability_tags: normalizeOrchestratorDescriptorItems( + document.getElementById('agent-orchestrator-capability-tags')?.value + ), + evidence_types: normalizeOrchestratorDescriptorItems( + document.getElementById('agent-orchestrator-evidence-types')?.value + ), + read_only: document.getElementById('agent-orchestrator-read-only')?.checked === true, + external_data: document.getElementById('agent-orchestrator-external-data')?.checked === true, + risk_class: document.getElementById('agent-orchestrator-risk-class')?.value || 'internal_read', + data_sensitivity: document.getElementById('agent-orchestrator-data-sensitivity')?.value || 'internal', + latency_class: document.getElementById('agent-orchestrator-latency-class')?.value || 'seconds', + cost_class: document.getElementById('agent-orchestrator-cost-class')?.value || 'standard' + } : {} }; if (selectedAgentType === 'aifoundry') { diff --git a/application/single_app/static/js/agents_common.js b/application/single_app/static/js/agents_common.js index 169e4ca7c..1d51cb2b0 100644 --- a/application/single_app/static/js/agents_common.js +++ b/application/single_app/static/js/agents_common.js @@ -353,6 +353,28 @@ export function setAgentModalFields(agent, opts = {}) { agent.other_settings ? JSON.stringify(agent.other_settings, null, 2) : '{}' ); setValue('agent-max-completion-tokens', agent.max_completion_tokens || ''); + const orchestratorDescriptor = agent.orchestrator_descriptor && typeof agent.orchestrator_descriptor === 'object' + ? agent.orchestrator_descriptor + : {}; + setChecked('agent-discoverable-by-orchestrator', agent.discoverable_by_orchestrator === true); + setValue( + 'agent-orchestrator-capability-tags', + Array.isArray(orchestratorDescriptor.capability_tags) + ? orchestratorDescriptor.capability_tags.join(', ') + : '' + ); + setValue( + 'agent-orchestrator-evidence-types', + Array.isArray(orchestratorDescriptor.evidence_types) + ? orchestratorDescriptor.evidence_types.join(', ') + : '' + ); + setChecked('agent-orchestrator-read-only', orchestratorDescriptor.read_only !== false); + setChecked('agent-orchestrator-external-data', orchestratorDescriptor.external_data === true); + setValue('agent-orchestrator-risk-class', orchestratorDescriptor.risk_class || 'internal_read'); + setValue('agent-orchestrator-data-sensitivity', orchestratorDescriptor.data_sensitivity || 'internal'); + setValue('agent-orchestrator-latency-class', orchestratorDescriptor.latency_class || 'seconds'); + setValue('agent-orchestrator-cost-class', orchestratorDescriptor.cost_class || 'standard'); // Set reasoning effort if available const reasoningEffortSelect = root.getElementById('agent-reasoning-effort'); @@ -377,6 +399,20 @@ export function getAgentModalFields(opts = {}) { const el = root.getElementById(id); return el ? el.checked : false; }; + const getDescriptorItems = (id) => { + const values = []; + getValue(id) + .split(',') + .map(value => value.trim().toLowerCase().replace(/[^a-z0-9_:-]+/g, '_').replace(/^_+|_+$/g, '')) + .filter(Boolean) + .forEach(value => { + const boundedValue = value.slice(0, 64); + if (!values.includes(boundedValue) && values.length < 16) { + values.push(boundedValue); + } + }); + return values; + }; let additionalSettings = {}; try { const settingsRaw = getValue('agent-additional-settings'); @@ -401,6 +437,7 @@ export function getAgentModalFields(opts = {}) { }).filter(Boolean); } + const discoverableByOrchestrator = getChecked('agent-discoverable-by-orchestrator'); return { name: getValue('agent-name'), display_name: getValue('agent-display-name'), @@ -426,7 +463,18 @@ export function getAgentModalFields(opts = {}) { max_completion_tokens: parseInt(getValue('agent-max-completion-tokens')) || null, actions_to_load: actions_to_load, other_settings: additionalSettings, - agent_type: (opts.agent && opts.agent.agent_type) || 'local' + agent_type: (opts.agent && opts.agent.agent_type) || 'local', + discoverable_by_orchestrator: discoverableByOrchestrator, + orchestrator_descriptor: discoverableByOrchestrator ? { + capability_tags: getDescriptorItems('agent-orchestrator-capability-tags'), + evidence_types: getDescriptorItems('agent-orchestrator-evidence-types'), + read_only: getChecked('agent-orchestrator-read-only'), + external_data: getChecked('agent-orchestrator-external-data'), + risk_class: getValue('agent-orchestrator-risk-class') || 'internal_read', + data_sensitivity: getValue('agent-orchestrator-data-sensitivity') || 'internal', + latency_class: getValue('agent-orchestrator-latency-class') || 'seconds', + cost_class: getValue('agent-orchestrator-cost-class') || 'standard' + } : {} }; } /** diff --git a/application/single_app/static/js/chat/chat-capability-choice.js b/application/single_app/static/js/chat/chat-capability-choice.js index fa999cb40..9494d3ec6 100644 --- a/application/single_app/static/js/chat/chat-capability-choice.js +++ b/application/single_app/static/js/chat/chat-capability-choice.js @@ -11,6 +11,8 @@ const REASON_LABELS = Object.freeze({ multi_document_comparison: 'A document comparison could materially improve the result.', visual_output_materially_helpful: 'A visual output would materially help with this request.', multi_source_public_research: 'Multiple authoritative sources could materially improve confidence.', + specialized_organizational_knowledge: 'A specialized authorized agent could materially improve this answer.', + business_system_evidence: 'An authorized business-system agent could materially improve the evidence.', }); function normalizeIdentifier(value) { @@ -28,6 +30,9 @@ function normalizeProposal(metadata) { const options = Array.isArray(proposal.options) ? proposal.options.slice(0, MAX_OPTIONS).map(option => ({ id: normalizeIdentifier(option?.id), + kind: ['agent', 'capability', 'continue'].includes(normalizeIdentifier(option?.kind).toLowerCase()) + ? normalizeIdentifier(option?.kind).toLowerCase() + : 'capability', label: String(option?.label || '').trim().slice(0, 120), capabilityIds: Array.isArray(option?.capability_ids) ? option.capability_ids.map(normalizeIdentifier).filter(Boolean).slice(0, 8) @@ -35,6 +40,12 @@ function normalizeProposal(metadata) { latencyClass: normalizeIdentifier(option?.latency_class), costClass: normalizeIdentifier(option?.cost_class), externalData: option?.external_data === true, + scopeClass: ['personal', 'global', 'group'].includes( + normalizeIdentifier(option?.scope_class).toLowerCase() + ) ? normalizeIdentifier(option?.scope_class).toLowerCase() : '', + readOnly: option?.read_only === true, + riskClass: normalizeIdentifier(option?.risk_class), + dataSensitivity: normalizeIdentifier(option?.data_sensitivity), sensitiveInputTypes: Array.isArray(option?.sensitive_input_types) ? option.sensitive_input_types.map(normalizeIdentifier).filter(Boolean).slice(0, 8) : [], @@ -84,6 +95,21 @@ function createElement(tagName, className = '', text = '') { function appendOptionMeta(container, option) { const parts = []; + if (option.kind === 'agent') { + parts.push('Agent'); + if (option.scopeClass) { + parts.push(`Scope: ${option.scopeClass}`); + } + if (option.readOnly) { + parts.push('Read only'); + } + if (option.riskClass) { + parts.push(`Risk: ${option.riskClass.replaceAll('_', ' ')}`); + } + if (option.dataSensitivity) { + parts.push(`Data: ${option.dataSensitivity.replaceAll('_', ' ')}`); + } + } if (option.latencyClass) { parts.push(`Time: ${option.latencyClass}`); } @@ -93,7 +119,11 @@ function appendOptionMeta(container, option) { if (parts.length === 0) { return; } - container.appendChild(createElement('span', 'sc-capability-choice-option-meta', parts.join(' | '))); + const metadata = createElement('span', 'sc-capability-choice-option-meta', parts.join(' | ')); + if (option.kind === 'agent') { + metadata.dataset.testid = 'agent-option-meta'; + } + container.appendChild(metadata); } function getReasonText(proposal) { @@ -263,8 +293,10 @@ export function hydrateCapabilityChoice(messageElement, metadata, { onResume } = card.appendChild(header); card.appendChild(createElement('p', 'sc-capability-choice-reason', getReasonText(proposal))); - const hasExternalOption = proposal.options.some(option => option.externalData); - if (hasExternalOption) { + const hasExternalCapabilityOption = proposal.options.some( + option => option.externalData && option.kind !== 'agent' + ); + if (hasExternalCapabilityOption) { const notice = createElement( 'p', 'sc-capability-choice-notice', @@ -273,6 +305,15 @@ export function hydrateCapabilityChoice(messageElement, metadata, { onResume } = notice.dataset.testid = 'capability-external-data-notice'; card.appendChild(notice); } + if (proposal.options.some(option => option.externalData && option.kind === 'agent')) { + const agentExternalNotice = createElement( + 'p', + 'sc-capability-choice-notice', + 'A recommended agent may access external data under its saved governance policy.', + ); + agentExternalNotice.dataset.testid = 'agent-external-data-notice'; + card.appendChild(agentExternalNotice); + } if (proposal.options.some(option => option.sensitiveInputTypes.length > 0)) { const sensitiveNotice = createElement( 'p', diff --git a/application/single_app/static/json/schemas/agent.schema.json b/application/single_app/static/json/schemas/agent.schema.json index dd432f60b..724f88f36 100644 --- a/application/single_app/static/json/schemas/agent.schema.json +++ b/application/single_app/static/json/schemas/agent.schema.json @@ -108,6 +108,14 @@ "description": "False when the agent is hidden from runtime selection but retained for admin management.", "default": true }, + "discoverable_by_orchestrator": { + "type": "boolean", + "description": "Explicitly allow this agent to enter the governed orchestration discovery catalog.", + "default": false + }, + "orchestrator_descriptor": { + "$ref": "#/definitions/OrchestratorDescriptor" + }, "agent_type": { "type": "string", "enum": ["local", "aifoundry", "new_foundry", "foundry_workflow"], @@ -145,6 +153,24 @@ ], "title": "Agent", "allOf": [ + { + "if": { + "properties": { + "discoverable_by_orchestrator": { "const": true } + }, + "required": ["discoverable_by_orchestrator"] + }, + "then": { + "properties": { + "agent_type": { "const": "local" }, + "actions_to_load": { "maxItems": 0 }, + "orchestrator_descriptor": { + "$ref": "#/definitions/DiscoverableOrchestratorDescriptor" + } + }, + "required": ["orchestrator_descriptor"] + } + }, { "if": { "properties": { @@ -219,6 +245,68 @@ } ] }, + "OrchestratorDescriptor": { + "type": "object", + "additionalProperties": false, + "properties": { + "capability_tags": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9_:-]{1,64}$" + }, + "maxItems": 16 + }, + "evidence_types": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9_:-]{1,64}$" + }, + "maxItems": 16 + }, + "read_only": { "type": "boolean" }, + "external_data": { "type": "boolean" }, + "risk_class": { + "type": "string", + "enum": ["internal_read", "external_read"] + }, + "data_sensitivity": { + "type": "string", + "enum": ["public", "internal"] + }, + "latency_class": { + "type": "string", + "enum": ["seconds", "minutes"] + }, + "cost_class": { + "type": "string", + "enum": ["low", "standard"] + } + } + }, + "DiscoverableOrchestratorDescriptor": { + "allOf": [ + { "$ref": "#/definitions/OrchestratorDescriptor" }, + { + "required": [ + "capability_tags", + "evidence_types", + "read_only", + "external_data", + "risk_class", + "data_sensitivity", + "latency_class", + "cost_class" + ], + "properties": { + "capability_tags": { "minItems": 1 }, + "evidence_types": { "minItems": 1 }, + "read_only": { "const": true } + } + } + ] + }, "IconPayload": { "type": "object", "additionalProperties": false, diff --git a/application/single_app/templates/_agent_modal.html b/application/single_app/templates/_agent_modal.html index 8bc34bdcd..4b5059ead 100644 --- a/application/single_app/templates/_agent_modal.html +++ b/application/single_app/templates/_agent_modal.html @@ -692,6 +692,81 @@
Advanced Settings
+
+ Orchestrator Discovery +
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
diff --git a/docs/explanation/features/CHAT_TURN_ORCHESTRATION.md b/docs/explanation/features/CHAT_TURN_ORCHESTRATION.md index c0fd3c88d..b0c081f72 100644 --- a/docs/explanation/features/CHAT_TURN_ORCHESTRATION.md +++ b/docs/explanation/features/CHAT_TURN_ORCHESTRATION.md @@ -1,6 +1,6 @@ # Chat Turn Orchestration -Implemented in version: **0.250.066** +Implemented in version: **0.250.067** Associated issue: **[#1021](https://github.com/microsoft/simplechat/issues/1021)** ## Overview @@ -236,7 +236,7 @@ An inactive toolbar control is therefore not treated as a denial. Per-capability Common requirements are classified without a planning-model call. Initial classes cover current public information, current official/local rules, user-supplied URLs, workspace evidence, document analysis, multi-document comparison, explicit visual output, and multi-source public research. A recommendation is created only when an eligible capability can materially improve freshness, completeness, confidence, evidence quality, or requested output. -Simple timeless questions keep their direct one-step plan. Analyze requires at least one reauthorized document target, Compare requires at least two, URL Access requires a supplied URL, and Image is proposed only for explicit visual creation intent. Current local-law and official-record requests prefer Deep Research when available and retain Web Search as a bounded alternative. Unselected-agent discovery is intentionally deferred to Phase 8B; selected agents continue through canonical server resolution and the existing required-attempt contract. +Simple timeless questions keep their direct one-step plan. Analyze requires at least one reauthorized document target, Compare requires at least two, URL Access requires a supplied URL, and Image is proposed only for explicit visual creation intent. Current local-law and official-record requests prefer Deep Research when available and retain Web Search as a bounded alternative. Selected agents continue through canonical server resolution and the existing required-attempt contract; Phase 8B extends the same inventory and choice protocol to governed unselected agents. ### Durable Choice And Resume @@ -262,6 +262,32 @@ Discovered external retrieval preserves the existing current-message-only bounda The inline choice card renders server fields through DOM text APIs, provides one recommended option, alternatives, and a continue-without-capabilities path, associates external/sensitive notices with the workflow, uses `aria-live` for status, and maintains at least 44-pixel actions on mobile. +## Phase 8B Governed Agent Discovery And Recommendation + +Phase 8B extends the Phase 8A inventory, proposal, decision, and durable resume contracts to authorized unselected personal, global, and group agents. It does not add a second planner or approval route. Selected agents remain required attempts, and an inactive agent selector is not treated as a decline. + +Agent discovery is explicitly opt-in through `discoverable_by_orchestrator`, which defaults to `false` for existing and new records. An opted-in record must provide an allowlisted `orchestrator_descriptor` containing nonempty capability tags and evidence types, read-only state, external-data state, risk class, data-sensitivity class, latency class, and cost class. Invalid, incomplete, write-capable, sensitive, disabled, hidden, nonlocal runtime, or action-attached records fail closed. Foundry-backed agents and local agents with attached actions remain available through normal explicit selection but are excluded from Phase 8B discovery because their hidden tools or action arguments cannot yet meet the read-only telemetry boundary. + +The server builds the canonical discovery catalog only after current authorization and governance checks: + +- Personal records come only from the current user's partition and require personal-agent governance access. +- Global records require current global-agent feature and per-agent item-policy access, plus availability under the active global/per-user agent mode. +- Group records come only from the user's current memberships, require group-agent governance access, and exclude groups whose current status does not allow chat. + +Only a bounded safe projection enters orchestration metadata: opaque option reference, display label, scope class, capability tags, evidence types, read-only and external-data state, risk, sensitivity, latency, and cost. Raw instructions, internal IDs, group IDs/names, endpoints, keys, connector settings, action IDs/arguments, assigned-knowledge details, hidden tools, and inaccessible catalog entries remain in the server-only canonical catalog. Agent-management and invocation telemetry uses bounded summaries or the opaque reference for discovered runs rather than full canonical records. + +### Deterministic Agent Matching + +Initial matching recognizes explicit specialized organizational knowledge and business-system evidence requests. A safe descriptor must materially match the current message; the existence of an authorized agent alone is insufficient. At most one agent option is added to the existing Phase 8A proposal. Simple timeless questions stay direct, a selected agent suppresses all second-agent recommendations, and selected Workspace Search, Analyze, or Compare suppresses an organizational-agent recommendation when that selected capability already satisfies the requirement. + +Agent option IDs are server-authored HMAC references namespaced by personal, global, or group scope. The reference binds the canonical catalog key and immutable creation timestamp, so duplicate display names remain unambiguous and a deleted/recreated record cannot inherit an old approval. Browser requests still submit only conversation/proposal and persisted option IDs. + +### Agent Decision And Resume + +Agent decisions use the existing ETag-protected proposal transition and resume lease. At decision, claim, and final canonicalization, the server reauthorizes the personal conversation and exact source turn, rebuilds the current governed catalog, and resolves the approved opaque reference to one canonical record. Material descriptor changes invalidate the prior choice; deletion, disablement, hidden state, policy loss, group-membership revocation, inactive group status, or runtime-type changes remove the option entirely. + +After a successful claim, assigned knowledge is read from the refreshed canonical record and action constraints are checked again. Any newly attached action removes the agent from the governed catalog and invalidates the approval. The agent enters the existing selected-agent evidence/runtime node as a required `discovery_approved` source, returns evidence rather than a final answer, and hands the terminal ledger to the central response finalizer. Inherited kernel function choice is disabled at the agent, execution-setting, and service levels for that invocation; raw plugin invocation citations are not persisted. Loader and wrapper telemetry records only bounded scope/type booleans, counts, response lengths, and lifecycle state for the discovered run. Saved toolbar defaults are unchanged. Duplicate decisions, live resume claims, and process-loss reconciliation continue through the Phase 8A idempotency contract, so an approved agent is not invoked twice. + ## Security And Governance - Caller-supplied IDs are never treated as proof of authorization. @@ -297,6 +323,11 @@ The inline choice card renders server fields through DOM text APIs, provides one - A proposal approval never substitutes for collector or executor authorization; configuration and object access are checked again immediately before resume and at execution boundaries. - Browser decision payloads cannot add capabilities, run IDs, user IDs, document IDs, or scope IDs. Effective execution is reconstructed from the persisted allowlist. - Discovered external queries use current-message-only minimized text. Sensitive address use requires selection of the separately labeled sensitive-input option. +- Unselected agents default to undiscoverable and enter the catalog only after ownership/membership, group-status, feature, item-policy, enabled, visibility, runtime-type, attached-action, and safe-descriptor checks. +- Agent option references are opaque, scope-namespaced, bound to immutable stored identity, and resolved only through the current authorized server catalog. +- Agent decisions revalidate the exact persisted safe descriptor; material policy changes invalidate prior consent while canonical assigned knowledge and action constraints refresh at invocation. +- Discovered-agent proposal, message, and activity metadata omit canonical IDs, group identifiers/names, ordinary tags, catalog keys, instructions, endpoints, connector settings, action IDs, and hidden tools. +- Discovered-agent execution disables inherited kernel tools, omits raw plugin invocation citations, and suppresses prompt/response previews in runtime telemetry; normal explicitly selected-agent behavior is restored after the call. ## Dependencies @@ -310,6 +341,7 @@ The inline choice card renders server fields through DOM text APIs, provides one - Existing `ActiveConversationStreamSession` cancellation, replay, heartbeat, and reattach behavior. - Cosmos message ETags for idempotent capability decisions and resume leases. - Existing role-aware Web Search, URL Access, Deep Research, image-generation, document-action, and authorized document/scope checks. +- Existing personal/global/group agent stores, agent governance policies, group membership/status checks, assigned-knowledge reconstruction, and action-constraint validation. ## Testing And Validation @@ -395,11 +427,19 @@ Phase 8A capability coverage is in: - `functional_tests/test_chat_capability_choice_route.py` for conversation ownership, exact source-turn linkage, forged options, post-approval revocation, server request reconstruction, parent/child replay, and process-loss reconciliation. - `ui_tests/test_chat_capability_choice_card.py` for persisted rendering, keyboard interaction, external notices, exact decision/resume payloads, `aria-live`, desktop/mobile overflow, and 44-pixel controls. +Phase 8B agent coverage is in: + +- `functional_tests/test_chat_governed_agent_discovery.py` for opt-in defaults, authorization/governance filtering, inactive groups, local-runtime enforcement, duplicate names, opaque identity, deterministic matching, selected-capability suppression, safe serialization, policy changes, durable decisions, and required `discovery_approved` provenance. +- `functional_tests/test_chat_capability_choice_route.py` for forged payload rejection, exact source-turn authorization, canonical constraint refresh, policy/membership loss, duplicate decision and resume idempotency, final invocation reauthorization, safe message metadata, and process-loss reconciliation. +- `ui_tests/test_chat_capability_choice_card.py` for inert agent labels, safe scope/risk/sensitivity rendering, minimal browser payloads, pending/failed/invalidated/completed reconstruction, keyboard use, `aria-live`, 44-pixel actions, and desktop/mobile overflow. +- `ui_tests/test_agent_modal_orchestrator_discovery.py` for closed defaults, explicit opt-in, read-only validation, bounded descriptor serialization, edit reconstruction, keyboard accessibility, and responsive layout. + ## Known Limitations -Phase 8A adds durable built-in capability choice, with these deliberate boundaries: +Phase 8A and Phase 8B add durable built-in and governed local-agent choice, with these deliberate boundaries: -- Governed discovery of unselected agents is deferred to Phase 8B and will reuse this inventory/proposal contract. +- Generalized multi-agent recommendation remains out of scope. When an agent is already selected, Phase 8B does not recommend another agent. +- Foundry-backed agents and local agents with attached actions remain explicitly selectable but are not discoverable until hidden tools, action arguments, and runtime telemetry have a separately reviewed read-only governance contract. - Active runs and evidence ledgers are not stored in a durable queue or dedicated run store. Pending capability choices survive process loss, and persisted resumed assistants reconcile safely, but an in-flight model/tool operation still relies on existing stream execution and retry behavior. - Parallel runtime adapters must be request-context independent; the live chat route currently reconciles its existing authorized collectors sequentially. - Cancellation of synchronous model, agent, and document-action SDK calls is best effort. Pending finalization is prevented, but an in-flight non-cooperative call may return before its result can be discarded. diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 64ea54ff0..403aeb3a1 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,17 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](/explanation/features/) and [Fixes by Version](/explanation/fixes/). +### **(v0.250.067)** + +#### New Features + +* **Governed Chat Agent Discovery And Recommendation** + * Added explicit opt-in discovery for authorized unselected personal, global, and group local agents without attached actions, using bounded read-only descriptors and the existing Phase 8A capability-choice card, decision, and resume contracts. + * Catalog construction now filters current ownership, global policy, group membership/status, enabled/hidden state, runtime type, and safe policy before any agent can reach proposal metadata; duplicate names use opaque scope-namespaced references. + * Approved agents are canonically reauthorized at decision, resume, and invocation, refresh assigned knowledge, invalidate if actions are attached, and execute once as required `discovery_approved` evidence sources before central finalization without changing saved toolbar selection. + * Agent instructions, secrets, endpoints, connector settings, hidden tools, action arguments, canonical IDs, and inaccessible catalog details remain outside planner, proposal, browser, citation, and discovered-run telemetry surfaces; inherited tools are disabled for the approved evidence call. + * (Ref: microsoft/simplechat#1021, `functions_chat_capabilities.py`, `functions_agent_catalog.py`, `functions_chat_capability_choices.py`, `route_backend_chats.py`, `chat-capability-choice.js`, `CHAT_TURN_ORCHESTRATION.md`) + ### **(v0.250.066)** #### New Features diff --git a/functional_tests/test_agent_action_evidence_contract.py b/functional_tests/test_agent_action_evidence_contract.py index 4d4db1a02..844c8cb8c 100644 --- a/functional_tests/test_agent_action_evidence_contract.py +++ b/functional_tests/test_agent_action_evidence_contract.py @@ -2,7 +2,7 @@ # test_agent_action_evidence_contract.py """ Functional test for the generic agent/action evidence collection contract. -Version: 0.250.064 +Version: 0.250.067 Implemented in: 0.250.061 This test ensures selected agents and actions collect governed evidence into the @@ -318,6 +318,7 @@ def test_streaming_and_document_action_paths_apply_contract_before_persistence() assert 'evidence_status_message = build_agent_action_evidence_status_message(' in route_source assert "yield emit_thought('evidence_collection', evidence_status_message)" in route_source assert 'central_synthesis_context = build_grounded_image_central_synthesis_context(' in route_source + assert 'central_synthesis_context = build_agent_evidence_central_synthesis_context(' in route_source assert 'synthesis_response = gpt_client.chat.completions.create(**synthesis_params)' in route_source assert 'evidence_collection_task=agent_evidence_task' in route_source assert "'evidence_collection': action_evidence_task," in route_source @@ -334,7 +335,7 @@ def test_streaming_and_document_action_paths_apply_contract_before_persistence() apply_index, ) central_synthesis_index = route_source.index( - 'central_synthesis_context = build_grounded_image_central_synthesis_context(', + 'central_synthesis_context = build_agent_evidence_central_synthesis_context(', status_message_index, ) finalizer_index = route_source.index( diff --git a/functional_tests/test_agent_stream_tool_retry_fallback.py b/functional_tests/test_agent_stream_tool_retry_fallback.py index f5a4bc053..17dd28aad 100644 --- a/functional_tests/test_agent_stream_tool_retry_fallback.py +++ b/functional_tests/test_agent_stream_tool_retry_fallback.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 +# test_agent_stream_tool_retry_fallback.py """ Functional test for agent streaming tool fallback. -Version: 0.239.201 +Version: 0.250.067 Implemented in: 0.239.201 This test ensures Semantic Kernel agent streaming retries once with tool @@ -40,7 +41,9 @@ def test_agent_stream_retry_wiring() -> None: chats_path = ROOT / "application" / "single_app" / "route_backend_chats.py" assert_contains(chats_path, "candidate_retry_plan = classify_agent_stream_retry_mode(stream_error)") - assert_contains(chats_path, "if candidate_retry_plan and not accumulated_content and attempt_number == 0:") + assert_contains(chats_path, "and not accumulated_content") + assert_contains(chats_path, "and not executor_evidence_content") + assert_contains(chats_path, "and attempt_number == 0") assert_contains(chats_path, "retry_state = apply_agent_stream_retry_mode(") assert_contains(chats_path, "restore_agent_stream_retry_state(selected_agent, retry_state)") assert_contains(chats_path, "Retrying agent stream without tool calling") diff --git a/functional_tests/test_agents_catalog_feature.py b/functional_tests/test_agents_catalog_feature.py index 24ce41525..30c07ea48 100644 --- a/functional_tests/test_agents_catalog_feature.py +++ b/functional_tests/test_agents_catalog_feature.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 +# test_agents_catalog_feature.py """ Functional test for the Agents catalog page and agent icon/tag metadata. -Version: 0.242.072 -Implemented in: 0.242.061; updated in 0.242.064; 0.242.065; 0.242.066 +Version: 0.250.067 +Implemented in: 0.242.061; updated in 0.242.064; 0.242.065; 0.242.066; 0.250.067 This test ensures the global Agents page, shared catalog APIs, safe agent metadata, and chat handoff contract are present and regression-resistant. @@ -349,10 +350,15 @@ def normalize_promotion_tag_label(value): "functions_assigned_knowledge": {"get_agent_assigned_knowledge": lambda *args, **kwargs: {}}, "functions_global_actions": {"get_global_actions": lambda *args, **kwargs: []}, "functions_global_agents": {"get_global_agents": lambda *args, **kwargs: []}, - "functions_group": {"get_group_model_endpoints": lambda *args, **kwargs: [], "get_user_groups": lambda *args, **kwargs: []}, + "functions_group": { + "check_group_status_allows_operation": lambda *args, **kwargs: (True, ""), + "get_group_model_endpoints": lambda *args, **kwargs: [], + "get_user_groups": lambda *args, **kwargs: [], + }, "functions_group_actions": {"get_group_actions": lambda *args, **kwargs: []}, "functions_group_agents": {"get_group_agents": lambda *args, **kwargs: []}, "functions_governance": { + "ensure_governance_access": lambda *args, **kwargs: None, "filter_actions_by_action_type_access": lambda _user_id, actions, _feature_key, _scope: actions or [], "filter_governed_global_actions_for_user": lambda _user_id, actions: actions or [], }, diff --git a/functional_tests/test_central_synthesis_contract.py b/functional_tests/test_central_synthesis_contract.py index ea984dbe1..16097eba9 100644 --- a/functional_tests/test_central_synthesis_contract.py +++ b/functional_tests/test_central_synthesis_contract.py @@ -2,7 +2,7 @@ # test_central_synthesis_contract.py """ Functional test for the generic central synthesis contract. -Version: 0.250.064 +Version: 0.250.067 Implemented in: 0.250.062 This test ensures grounded image proposals are finalized only from completed, @@ -341,7 +341,10 @@ def test_streaming_route_centralizes_both_grounded_image_paths(): assert route_source.count( 'central_synthesis_context = build_grounded_image_central_synthesis_context(' - ) == 2 + ) == 1 + assert route_source.count( + 'central_synthesis_context = build_agent_evidence_central_synthesis_context(' + ) == 1 assert "conversation_history_for_api = central_synthesis_context['messages']" in route_source assert 'synthesis_response = gpt_client.chat.completions.create(**synthesis_params)' in route_source assert "set_evidence_ledger_status(turn_evidence_ledger, 'completed')" in route_source @@ -360,7 +363,7 @@ def test_streaming_route_centralizes_both_grounded_image_paths(): route_source.index('if agent_evidence_task:'), ) selected_agent_synthesis_index = route_source.index( - 'central_synthesis_context = build_grounded_image_central_synthesis_context(', + 'central_synthesis_context = build_agent_evidence_central_synthesis_context(', apply_index, ) finalizer_index = route_source.index( diff --git a/functional_tests/test_chat_capability_choice_route.py b/functional_tests/test_chat_capability_choice_route.py index 3d9a1743c..875debd44 100644 --- a/functional_tests/test_chat_capability_choice_route.py +++ b/functional_tests/test_chat_capability_choice_route.py @@ -2,8 +2,8 @@ # test_chat_capability_choice_route.py """ Functional test for authenticated chat capability decisions. -Version: 0.250.066 -Implemented in: 0.250.066 +Version: 0.250.067 +Implemented in: 0.250.067 This test ensures proposal decisions reauthorize the exact personal conversation and source turn, reject forged or stale choices, revalidate @@ -15,6 +15,7 @@ import sys from datetime import datetime, timedelta, timezone from pathlib import Path +from types import SimpleNamespace import pytest from flask import Flask @@ -26,7 +27,9 @@ sys.path.insert(0, str(SINGLE_APP_ROOT)) from functions_chat_capabilities import ( # noqa: E402 + build_agent_capability_recommendation, build_capability_recommendation, + build_governed_agent_capability_inventory, build_governed_capability_inventory, classify_capability_requirements, ) @@ -34,6 +37,12 @@ build_capability_choice_proposal, build_capability_provenance, ) +from functions_chat_orchestration import build_turn_orchestration_plan # noqa: E402 +from functions_evidence_ledger import ( # noqa: E402 + create_evidence_ledger_from_plan, + set_evidence_ledger_status, +) +import functions_agent_catalog # noqa: E402 class DummyNotFoundError(Exception): @@ -210,6 +219,133 @@ def _proposal_documents(*, expires_at=None): return user_message, assistant_message +def _governed_agent(agent_id='benefits-agent'): + return { + 'id': agent_id, + 'name': 'benefits_research', + 'display_name': 'Benefits Research', + 'created_at': '2026-07-15T12:00:00+00:00', + 'description': 'Authorized employee benefits research.', + 'instructions': 'Private canonical instructions.', + 'actions_to_load': [], + 'azure_openai_gpt_endpoint': 'https://private-endpoint.example.test', + 'azure_openai_gpt_key': 'private-agent-secret', + 'other_settings': { + 'assigned_knowledge': { + 'enabled': True, + 'document_ids': ['current-document'], + }, + 'connector': {'tenant': 'private-tenant'}, + 'hidden_tools': ['hidden_write_tool'], + }, + 'scope_type': 'personal', + 'scope_id': 'user-owner', + 'user_id': 'user-owner', + 'is_global': False, + 'is_group': False, + 'catalog_key': f'personal:user-owner:{agent_id}', + 'discoverable_by_orchestrator': True, + 'orchestrator_descriptor': { + 'capability_tags': ['benefits', 'policy_lookup'], + 'evidence_types': ['employee_benefits', 'policy_documents'], + 'read_only': True, + 'external_data': False, + 'risk_class': 'internal_read', + 'data_sensitivity': 'internal', + 'latency_class': 'seconds', + 'cost_class': 'standard', + }, + } + + +def _governed_group_agent(agent_id='group-benefits-agent'): + agent = _governed_agent(agent_id) + agent.update({ + 'scope_type': 'group', + 'scope_id': 'group-1', + 'user_id': None, + 'is_global': False, + 'is_group': True, + 'group_id': 'group-1', + 'group_name': 'Benefits Group', + 'catalog_key': f'group:group-1:{agent_id}', + }) + return agent + + +def _agent_proposal_documents(canonical_agent, *, reference_secret): + built_in_inventory = _inventory() + agent_inventory = build_governed_agent_capability_inventory( + [canonical_agent], + reference_secret=reference_secret, + ) + inventory = copy.deepcopy(built_in_inventory) + inventory['agents'] = copy.deepcopy(agent_inventory['agents']) + recommendation = build_agent_capability_recommendation( + agent_inventory, + 'Summarize our employee benefits policy.', + ) + proposal = build_capability_choice_proposal( + recommendation, + run_id='parent-run-1', + conversation_id='conversation-owner', + user_message_id='user-message-1', + assistant_message_id='proposal-1', + now=datetime.now(timezone.utc), + ) + provenance = build_capability_provenance( + selection_snapshot={ + 'conversation_id': 'conversation-owner', + 'selected_agent': None, + 'toggles': { + 'workspace_search': False, + 'web_search': False, + 'url_access': False, + 'source_review': False, + 'deep_research': False, + }, + }, + capability_inventory=inventory, + proposal=proposal, + effective_capabilities=[], + ) + user_message = { + 'id': 'user-message-1', + 'conversation_id': 'conversation-owner', + 'role': 'user', + 'content': 'Summarize our employee benefits policy.', + 'metadata': { + 'orchestration': {'run_id': 'parent-run-1'}, + 'capability_provenance': copy.deepcopy(provenance), + 'thread_info': {'thread_id': 'thread-1', 'previous_thread_id': None}, + }, + } + assistant_message = { + 'id': 'proposal-1', + 'conversation_id': 'conversation-owner', + 'role': 'assistant', + 'content': 'Choose how to continue.', + 'metadata': { + 'capability_proposal': proposal, + 'capability_provenance': provenance, + 'capability_resume_request': { + 'hybrid_search': False, + 'web_search_enabled': False, + 'url_access_enabled': False, + 'source_review_enabled': False, + 'deep_research_enabled': False, + 'selected_document_ids': [], + 'active_group_ids': [], + 'active_public_workspace_ids': [], + 'doc_scope': 'personal', + 'chat_type': 'user', + 'agent_info': None, + }, + }, + } + return user_message, assistant_message, proposal['recommended_option_id'] + + @pytest.fixture def capability_route_app(monkeypatch): monkeypatch.chdir(SINGLE_APP_ROOT) @@ -272,6 +408,36 @@ def capability_route_app(monkeypatch): return app +@pytest.fixture +def governed_agent_route_app(capability_route_app, monkeypatch): + state = capability_route_app.config['capability_route_state'] + route_backend_chats = state['route_module'] + agent_state = {'catalog': [_governed_agent()]} + user_message, proposal_message, option_id = _agent_proposal_documents( + agent_state['catalog'][0], + reference_secret=capability_route_app.secret_key, + ) + state['messages'].set_item(user_message) + state['messages'].set_item(proposal_message) + monkeypatch.setattr( + route_backend_chats, + 'build_authorized_agent_discovery_catalog', + lambda user_id, settings=None: [ + copy.deepcopy(agent) + for agent in agent_state['catalog'] + if functions_agent_catalog._is_agent_currently_discoverable(agent) + ], + ) + monkeypatch.setattr( + route_backend_chats, + '_get_agent_discovery_reference_secret', + lambda: capability_route_app.secret_key, + ) + state['agents'] = agent_state + state['agent_option_id'] = option_id + return capability_route_app + + def _decision(client, option_id='web_search', conversation_id='conversation-owner', **extra): return client.post( '/api/chat/capability-proposals/proposal-1/decision', @@ -552,5 +718,383 @@ def test_effective_capabilities_reconstruct_each_execution_mode(capability_route assert compare_request['document_action']['right_document_ids'] == ['document-2'] +def test_agent_decision_and_resume_refresh_canonical_constraints(governed_agent_route_app): + state = governed_agent_route_app.config['capability_route_state'] + route_backend_chats = state['route_module'] + option_id = state['agent_option_id'] + with governed_agent_route_app.test_client() as client: + first = _decision( + client, + option_id=option_id, + agent_info={'id': 'forged-agent', 'instructions': 'caller supplied'}, + ) + duplicate = _decision(client, option_id=option_id) + + assert first.status_code == 200 + assert duplicate.status_code == 200 + assert first.get_json()['idempotent'] is False + assert duplicate.get_json()['idempotent'] is True + + state['agents']['catalog'][0]['other_settings']['assigned_knowledge']['document_ids'] = [ + 'refreshed-document' + ] + context = route_backend_chats._claim_authorized_capability_resume( + settings={}, + user_id='user-owner', + user_email='owner@example.com', + user_roles=[], + conversation_id='conversation-owner', + proposal_id='proposal-1', + ) + request_agent = context['request_data']['agent_info'] + resume_context = context['request_data']['_capability_resume_context'] + public_metadata = route_backend_chats._build_agent_selection_metadata(request_agent) + + assert request_agent['id'] == 'benefits-agent' + assert request_agent['actions_to_load'] == [] + assert request_agent['other_settings']['assigned_knowledge']['document_ids'] == [ + 'refreshed-document' + ] + assert request_agent['_orchestration_discovery_ref'] == option_id + assert resume_context['agent_ref'] == option_id + assert resume_context['agent_origin'] == 'discovery_approved' + assert resume_context['capability_origins']['selected_agent'] == 'discovery_approved' + assert public_metadata['agent_id'] == option_id + assert public_metadata['catalog_key'] is None + assert public_metadata['selection_origin'] == 'discovery_approved' + + with pytest.raises(route_backend_chats.CapabilityChoiceError) as duplicate_claim: + route_backend_chats._claim_authorized_capability_resume( + settings={}, + user_id='user-owner', + user_email='owner@example.com', + user_roles=[], + conversation_id='conversation-owner', + proposal_id='proposal-1', + ) + assert duplicate_claim.value.code == 'resume_in_progress' + + +def test_agent_policy_change_blocks_decision_and_resume(governed_agent_route_app): + state = governed_agent_route_app.config['capability_route_state'] + option_id = state['agent_option_id'] + state['agents']['catalog'] = [] + with governed_agent_route_app.test_client() as client: + rejected = _decision(client, option_id=option_id) + assert rejected.status_code == 409 + assert rejected.get_json()['code'] == 'agent_missing' + + canonical_agent = _governed_agent() + user_message, proposal_message, option_id = _agent_proposal_documents( + canonical_agent, + reference_secret=governed_agent_route_app.secret_key, + ) + state['messages'].set_item(user_message) + state['messages'].set_item(proposal_message) + state['agents']['catalog'] = [canonical_agent] + with governed_agent_route_app.test_client() as client: + approved = _decision(client, option_id=option_id) + assert approved.status_code == 200 + + state['agents']['catalog'][0]['actions_to_load'] = ['newly-attached-action'] + route_backend_chats = state['route_module'] + with pytest.raises(route_backend_chats.CapabilityChoiceError) as revoked: + route_backend_chats._claim_authorized_capability_resume( + settings={}, + user_id='user-owner', + user_email='owner@example.com', + user_roles=[], + conversation_id='conversation-owner', + proposal_id='proposal-1', + ) + assert revoked.value.code == 'agent_missing' + stored = state['messages'].items[('conversation-owner', 'proposal-1')] + assert stored['metadata']['capability_proposal']['status'] == 'invalidated' + + +def test_group_membership_revocation_blocks_approved_agent_resume( + capability_route_app, + monkeypatch, +): + state = capability_route_app.config['capability_route_state'] + route_backend_chats = state['route_module'] + canonical_agent = _governed_group_agent() + membership = {'allowed': True} + user_message, proposal_message, option_id = _agent_proposal_documents( + canonical_agent, + reference_secret=capability_route_app.secret_key, + ) + state['messages'].set_item(user_message) + state['messages'].set_item(proposal_message) + monkeypatch.setattr( + route_backend_chats, + 'build_authorized_agent_discovery_catalog', + lambda user_id, settings=None: ( + [copy.deepcopy(canonical_agent)] + if membership['allowed'] + else [] + ), + ) + monkeypatch.setattr( + route_backend_chats, + '_get_agent_discovery_reference_secret', + lambda: capability_route_app.secret_key, + ) + + with capability_route_app.test_client() as client: + approved = _decision(client, option_id=option_id) + assert approved.status_code == 200 + + membership['allowed'] = False + with pytest.raises(route_backend_chats.CapabilityChoiceError) as revoked: + route_backend_chats._claim_authorized_capability_resume( + settings={}, + user_id='user-owner', + user_email='owner@example.com', + user_roles=[], + conversation_id='conversation-owner', + proposal_id='proposal-1', + ) + assert revoked.value.code == 'agent_missing' + stored = state['messages'].items[('conversation-owner', 'proposal-1')] + assert stored['metadata']['capability_proposal']['status'] == 'invalidated' + + +def test_agent_resume_reconciles_process_restart(governed_agent_route_app): + state = governed_agent_route_app.config['capability_route_state'] + route_backend_chats = state['route_module'] + with governed_agent_route_app.test_client() as client: + approved = _decision(client, option_id=state['agent_option_id']) + assert approved.status_code == 200 + + claimed = route_backend_chats._claim_authorized_capability_resume( + settings={}, + user_id='user-owner', + user_email='owner@example.com', + user_roles=[], + conversation_id='conversation-owner', + proposal_id='proposal-1', + ) + resume_context = claimed['request_data']['_capability_resume_context'] + state['messages'].set_item({ + 'id': 'resumed-agent-assistant-1', + 'conversation_id': 'conversation-owner', + 'role': 'assistant', + 'content': 'Completed by the approved agent.', + 'timestamp': datetime.now(timezone.utc).isoformat(), + 'metadata': { + 'capability_resume': { + 'proposal_id': 'proposal-1', + 'execution_id': resume_context['execution_id'], + }, + }, + }) + + reconciled = route_backend_chats._claim_authorized_capability_resume( + settings={}, + user_id='user-owner', + user_email='owner@example.com', + user_roles=[], + conversation_id='conversation-owner', + proposal_id='proposal-1', + ) + assert reconciled['already_completed'] is True + assert reconciled['proposal']['resume']['assistant_message_id'] == 'resumed-agent-assistant-1' + + +def test_initial_route_discovery_merges_safe_agent_option_and_suppresses_second_agent( + governed_agent_route_app, +): + state = governed_agent_route_app.config['capability_route_state'] + route_backend_chats = state['route_module'] + + discovery = route_backend_chats._build_server_capability_discovery( + settings={}, + user_id='user-owner', + user_email='owner@example.com', + user_roles=[], + user_message='Summarize our employee benefits policy.', + selected_capability_ids=[], + selected_agent_present=False, + ) + selected_agent_discovery = route_backend_chats._build_server_capability_discovery( + settings={}, + user_id='user-owner', + user_email='owner@example.com', + user_roles=[], + user_message='Summarize our employee benefits policy.', + selected_capability_ids=[], + selected_agent_present=True, + ) + + agent_options = [ + option + for option in discovery['recommendation']['options'] + if option.get('kind') == 'agent' + ] + assert len(agent_options) == 1 + assert agent_options[0]['id'] == state['agent_option_id'] + assert len(discovery['inventory']['agents']) == 1 + assert selected_agent_discovery['inventory']['agents'] == [] + assert selected_agent_discovery['recommendation'] is None + serialized = str({ + 'inventory': discovery['inventory'], + 'recommendation': discovery['recommendation'], + }) + assert 'Private canonical instructions' not in serialized + assert 'newly-attached-action' not in serialized + assert 'benefits-agent' not in serialized + assert 'private-endpoint' not in serialized + assert 'private-agent-secret' not in serialized + assert 'private-tenant' not in serialized + assert 'hidden_write_tool' not in serialized + + +def test_final_agent_canonicalizer_reauthorizes_exact_discovery_reference( + governed_agent_route_app, +): + state = governed_agent_route_app.config['capability_route_state'] + route_backend_chats = state['route_module'] + requested = { + 'id': 'forged-caller-agent', + 'name': 'forged_caller_agent', + '_orchestration_discovery_ref': state['agent_option_id'], + 'instructions': 'caller supplied instructions', + 'actions_to_load': ['caller_supplied_action'], + } + + resolved = route_backend_chats._resolve_canonical_chat_agent( + 'user-owner', + {}, + requested, + ) + assert resolved['id'] == 'benefits-agent' + assert resolved['instructions'] == 'Private canonical instructions.' + assert resolved['actions_to_load'] == [] + assert resolved['_orchestration_discovery_ref'] == state['agent_option_id'] + + state['agents']['catalog'] = [] + assert route_backend_chats._resolve_canonical_chat_agent( + 'user-owner', + {}, + requested, + ) is None + + +def test_discovered_agent_uses_evidence_and_response_finalizer_boundaries( + governed_agent_route_app, +): + state = governed_agent_route_app.config['capability_route_state'] + route_backend_chats = state['route_module'] + plan = build_turn_orchestration_plan( + 'Summarize our employee benefits policy.', + run_id='discovery-child-run', + conversation_id='conversation-owner', + selected_agent={'id': state['agent_option_id']}, + capability_origins={'selected_agent': 'discovery_approved'}, + ) + ledger = create_evidence_ledger_from_plan( + plan, + conversation_id='conversation-owner', + user_message_id='user-message-1', + ) + set_evidence_ledger_status(ledger, 'ready') + + assert route_backend_chats._requires_agent_evidence_collection( + plan, + {'agent_origin': 'discovery_approved'}, + ) is True + assert route_backend_chats._requires_agent_evidence_collection( + plan, + {'agent_origin': 'selection'}, + ) is False + + capability_metadata = ( + route_backend_chats._build_discovered_agent_evidence_capability_metadata( + state['agents']['catalog'][0] + ) + ) + assert capability_metadata == { + 'capability_tags': ['benefits', 'policy_lookup'], + 'evidence_types': ['employee_benefits', 'policy_documents'], + 'required_permissions': [], + 'uses_current_user_context': True, + 'returns_citations': True, + 'may_include_sensitive_data': False, + } + assert 'instructions' not in str(capability_metadata) + assert 'private-agent-secret' not in str(capability_metadata) + + synthesis = route_backend_chats.build_agent_evidence_central_synthesis_context( + 'Summarize our employee benefits policy.', + plan, + ledger, + ) + assert synthesis['request']['output_profile']['type'] == 'response' + assert synthesis['request']['policy']['executor_output_is_evidence_only'] is True + assert synthesis['messages'][0]['role'] == 'system' + + +def test_discovered_agent_invocation_disables_inherited_tools_and_restores_state( + governed_agent_route_app, +): + route_backend_chats = governed_agent_route_app.config[ + 'capability_route_state' + ]['route_module'] + execution_settings = SimpleNamespace(function_choice_behavior='execution-auto') + service_settings = SimpleNamespace(function_choice_behavior='service-auto') + agent = SimpleNamespace( + function_choice_behavior='agent-auto', + arguments=SimpleNamespace(execution_settings={'default': execution_settings}), + service=SimpleNamespace(prompt_execution_settings=service_settings), + orchestration_minimize_telemetry=False, + ) + + policy_state = route_backend_chats.apply_discovered_agent_invocation_policy( + agent, + {'agent_origin': 'discovery_approved'}, + ) + assert agent.function_choice_behavior is None + assert execution_settings.function_choice_behavior is None + assert service_settings.function_choice_behavior is None + assert agent.orchestration_minimize_telemetry is True + + route_backend_chats.restore_discovered_agent_invocation_policy( + agent, + policy_state, + ) + assert agent.function_choice_behavior == 'agent-auto' + assert execution_settings.function_choice_behavior == 'execution-auto' + assert service_settings.function_choice_behavior == 'service-auto' + assert agent.orchestration_minimize_telemetry is False + assert route_backend_chats.apply_discovered_agent_invocation_policy( + agent, + {'agent_origin': 'selection'}, + ) is None + + +def test_streaming_agent_fallback_citations_are_cleared_per_request(): + route_source = ( + SINGLE_APP_ROOT / 'route_backend_chats.py' + ).read_text(encoding='utf-8') + streaming_index = route_source.index( + 'if use_agent_streaming and selected_agent:' + ) + clear_index = route_source.index( + "if hasattr(selected_agent, 'tool_invocations'):", + streaming_index, + ) + invoke_index = route_source.index( + 'selected_agent.invoke_stream', + clear_index, + ) + assert clear_index < invoke_index + assert 'selected_agent.tool_invocations = []' in route_source[ + clear_index:invoke_index + ] + assert "== 'discovery_approved'" in route_source + assert 'agent_name_used = agent_display_name_used' in route_source + + if __name__ == '__main__': raise SystemExit(pytest.main([__file__, '-q'])) \ No newline at end of file diff --git a/functional_tests/test_chat_capability_discovery.py b/functional_tests/test_chat_capability_discovery.py index e293021c0..e6cdb9c53 100644 --- a/functional_tests/test_chat_capability_discovery.py +++ b/functional_tests/test_chat_capability_discovery.py @@ -2,8 +2,8 @@ # test_chat_capability_discovery.py """ Functional test for governed chat capability discovery and recommendation. -Version: 0.250.066 -Implemented in: 0.250.066 +Version: 0.250.067 +Implemented in: 0.250.067 This test ensures server-resolved capability states remain distinct and only authorized, available, discoverable capabilities can be recommended. @@ -17,9 +17,12 @@ SINGLE_APP_ROOT = REPO_ROOT / 'application' / 'single_app' sys.path.insert(0, str(SINGLE_APP_ROOT)) +from functions_agent_payload import AgentPayloadError, sanitize_agent_payload # noqa: E402 +from json_schema_validation import validate_agent # noqa: E402 from functions_chat_capabilities import ( # noqa: E402 CONTINUE_WITHOUT_CAPABILITIES_OPTION_ID, build_capability_recommendation, + build_governed_agent_capability_inventory, build_governed_capability_inventory, classify_capability_requirements, ) @@ -274,6 +277,186 @@ def test_blocked_and_unauthorized_capabilities_are_never_offered(): assert blocked_bundle_recommendation is None +def test_agent_inventory_defaults_closed_and_exposes_only_safe_descriptors(): + agents = [ + { + 'catalog_key': 'personal:user-1:benefits-agent', + 'created_at': '2026-07-15T12:00:00+00:00', + 'display_name': 'Benefits Research', + 'discoverable_by_orchestrator': True, + 'orchestrator_descriptor': { + 'capability_tags': ['benefits', 'policy_lookup'], + 'evidence_types': ['employee_benefits', 'policy_documents'], + 'read_only': True, + 'risk_class': 'internal_read', + 'data_sensitivity': 'internal', + 'latency_class': 'seconds', + 'cost_class': 'standard', + }, + 'instructions': 'SECRET instructions must never leave the canonical record.', + 'actions_to_load': ['hidden_action'], + 'assigned_knowledge': {'document_ids': ['private-document-id']}, + }, + { + 'catalog_key': 'global::default-closed', + 'created_at': '2026-07-15T12:00:00+00:00', + 'display_name': 'Default Closed', + 'orchestrator_descriptor': { + 'capability_tags': ['benefits'], + 'evidence_types': ['policy_documents'], + 'read_only': True, + 'risk_class': 'internal_read', + 'data_sensitivity': 'internal', + 'latency_class': 'seconds', + 'cost_class': 'low', + }, + }, + { + 'catalog_key': 'group:group-1:write-agent', + 'created_at': '2026-07-15T12:00:00+00:00', + 'display_name': 'Write Agent', + 'discoverable_by_orchestrator': True, + 'orchestrator_descriptor': { + 'capability_tags': ['benefits'], + 'evidence_types': ['policy_documents'], + 'read_only': False, + 'risk_class': 'consequential_write', + 'data_sensitivity': 'internal', + 'latency_class': 'seconds', + 'cost_class': 'standard', + }, + }, + ] + + inventory = build_governed_agent_capability_inventory( + agents, + reference_secret='phase-8b-test-secret', + ) + + assert inventory['version'] == 1 + assert len(inventory['agents']) == 1 + descriptor = inventory['agents'][0] + assert descriptor['id'].startswith('agent:personal:') + assert descriptor['label'] == 'Benefits Research' + assert descriptor['state'] == 'unselected' + assert descriptor['discoverable'] is True + assert descriptor['auto_use_allowed'] is False + assert descriptor['requires_user_choice'] is True + assert descriptor['capability_tags'] == ['benefits', 'policy_lookup'] + assert descriptor['evidence_types'] == ['employee_benefits', 'policy_documents'] + assert set(descriptor) == { + 'id', + 'kind', + 'label', + 'category', + 'state', + 'scope', + 'scope_class', + 'discoverable', + 'auto_use_allowed', + 'requires_user_choice', + 'read_only', + 'external_data', + 'risk_class', + 'data_sensitivity', + 'cost_class', + 'latency_class', + 'capability_tags', + 'evidence_types', + } + serialized = str(inventory) + assert 'SECRET instructions' not in serialized + assert 'hidden_action' not in serialized + assert 'private-document-id' not in serialized + assert 'default-closed' not in serialized + assert 'write-agent' not in serialized + + +def test_agent_discovery_policy_is_normalized_and_defaults_closed(): + base_payload = { + 'name': 'benefits_research', + 'display_name': 'Benefits Research', + 'description': 'Looks up employee benefits.', + 'instructions': 'Canonical instructions.', + 'actions_to_load': [], + 'other_settings': {}, + 'max_completion_tokens': -1, + } + + default_closed = sanitize_agent_payload(base_payload) + assert default_closed['discoverable_by_orchestrator'] is False + assert default_closed['orchestrator_descriptor'] == {} + + governed = sanitize_agent_payload({ + **base_payload, + 'id': '11111111-1111-4111-8111-111111111111', + 'discoverable_by_orchestrator': True, + 'orchestrator_descriptor': { + 'capability_tags': [' Benefits ', 'benefits', 'Policy Lookup!'], + 'evidence_types': ['Policy Documents'], + 'read_only': True, + 'external_data': False, + 'risk_class': 'internal_read', + 'data_sensitivity': 'internal', + 'latency_class': 'seconds', + 'cost_class': 'standard', + 'ignored_secret': 'must not survive normalization', + }, + }) + assert governed['discoverable_by_orchestrator'] is True + assert governed['orchestrator_descriptor'] == { + 'capability_tags': ['benefits', 'policy_lookup'], + 'evidence_types': ['policy_documents'], + 'read_only': True, + 'external_data': False, + 'risk_class': 'internal_read', + 'data_sensitivity': 'internal', + 'latency_class': 'seconds', + 'cost_class': 'standard', + } + assert validate_agent(governed) is None + + try: + sanitize_agent_payload({ + **base_payload, + 'discoverable_by_orchestrator': True, + 'orchestrator_descriptor': { + 'capability_tags': ['benefits'], + 'evidence_types': ['policy_documents'], + 'read_only': False, + 'risk_class': 'consequential_write', + 'data_sensitivity': 'internal', + 'latency_class': 'seconds', + 'cost_class': 'standard', + }, + }) + raise AssertionError('discoverable write-capable agents must be rejected') + except AgentPayloadError as exc: + assert 'read-only' in str(exc).lower() + + try: + sanitize_agent_payload({ + **base_payload, + 'agent_type': 'new_foundry', + 'discoverable_by_orchestrator': True, + 'orchestrator_descriptor': governed['orchestrator_descriptor'], + }) + raise AssertionError('Foundry agents must remain outside Phase 8B discovery') + except AgentPayloadError as exc: + assert 'local agents only' in str(exc).lower() + + try: + sanitize_agent_payload({ + **base_payload, + 'actions_to_load': ['hidden_action'], + 'discoverable_by_orchestrator': True, + 'orchestrator_descriptor': governed['orchestrator_descriptor'], + }) + raise AssertionError('agents with attached actions must remain undiscoverable') + except AgentPayloadError as exc: + assert 'without attached actions' in str(exc).lower() + + if __name__ == '__main__': tests = [ test_inventory_preserves_selection_and_governed_states, @@ -286,6 +469,8 @@ def test_blocked_and_unauthorized_capabilities_are_never_offered(): test_analyze_and_compare_require_authorized_targets, test_image_is_recommended_only_for_explicit_visual_output, test_blocked_and_unauthorized_capabilities_are_never_offered, + test_agent_inventory_defaults_closed_and_exposes_only_safe_descriptors, + test_agent_discovery_policy_is_normalized_and_defaults_closed, ] results = [] diff --git a/functional_tests/test_chat_governed_agent_discovery.py b/functional_tests/test_chat_governed_agent_discovery.py new file mode 100644 index 000000000..a702e5fcd --- /dev/null +++ b/functional_tests/test_chat_governed_agent_discovery.py @@ -0,0 +1,509 @@ +#!/usr/bin/env python3 +# test_chat_governed_agent_discovery.py +""" +Functional test for governed chat-agent discovery. +Version: 0.250.067 +Implemented in: 0.250.067 + +This test ensures personal, global, and group discovery catalogs are filtered +by current authorization and governance before safe descriptors are built. +""" + +import sys +from datetime import datetime, timezone +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SINGLE_APP_ROOT = REPO_ROOT / 'application' / 'single_app' +SEMANTIC_KERNEL_LOADER = SINGLE_APP_ROOT / 'semantic_kernel_loader.py' +AGENT_LOGGING_WRAPPER = SINGLE_APP_ROOT / 'agent_logging_chat_completion.py' +sys.path.insert(0, str(SINGLE_APP_ROOT)) + +import functions_agent_catalog # pyright: ignore[reportMissingImports] # noqa: E402 +from functions_chat_capabilities import ( # pyright: ignore[reportMissingImports] # noqa: E402 + CONTINUE_WITHOUT_CAPABILITIES_OPTION_ID, + build_agent_capability_recommendation, + build_capability_recommendation, + build_governed_agent_capability_inventory, + build_governed_capability_inventory, + classify_capability_requirements, + merge_capability_recommendations, + resolve_governed_agent_capability_reference, +) +from functions_chat_capability_choices import ( # pyright: ignore[reportMissingImports] # noqa: E402 + CapabilityChoiceError, + apply_capability_choice_decision, + build_capability_choice_proposal, + revalidate_capability_choice, +) +from functions_chat_orchestration import build_turn_orchestration_plan # pyright: ignore[reportMissingImports] # noqa: E402 + + +REFERENCE_SECRET = 'phase-8b-catalog-test-secret' +NOW = datetime(2026, 7, 15, 12, 0, tzinfo=timezone.utc) + + +def _agent(agent_id, display_name, **overrides): + agent = { + 'id': agent_id, + 'name': agent_id, + 'display_name': display_name, + 'created_at': '2026-07-15T12:00:00+00:00', + 'is_enabled': True, + 'discoverable_by_orchestrator': True, + 'orchestrator_descriptor': { + 'capability_tags': ['benefits', 'policy_lookup'], + 'evidence_types': ['employee_benefits', 'policy_documents'], + 'read_only': True, + 'external_data': False, + 'risk_class': 'internal_read', + 'data_sensitivity': 'internal', + 'latency_class': 'seconds', + 'cost_class': 'standard', + }, + 'instructions': f'private instructions for {agent_id}', + 'actions_to_load': [], + 'azure_openai_gpt_endpoint': 'https://private-endpoint.example.test', + 'azure_openai_gpt_key': 'private-agent-secret', + 'other_settings': { + 'connector': {'tenant': 'private-tenant'}, + 'hidden_tools': ['hidden_write_tool'], + }, + } + agent.update(overrides) + return agent + + +def _settings(**overrides): + settings = { + 'allow_user_agents': True, + 'enable_semantic_kernel': True, + 'per_user_semantic_kernel': False, + 'merge_global_semantic_kernel_with_workspace': False, + 'enable_group_workspaces': True, + 'allow_group_agents': True, + } + settings.update(overrides) + return settings + + +def test_catalog_filters_authorization_governance_and_availability(monkeypatch): + group_calls = [] + monkeypatch.setattr(functions_agent_catalog, 'ensure_migration_complete', lambda user_id: None) + monkeypatch.setattr( + functions_agent_catalog, + 'get_personal_agents', + lambda user_id: [ + _agent('personal-allowed', 'Benefits Research'), + _agent('personal-disabled', 'Disabled', is_enabled=False), + _agent('personal-hidden', 'Hidden', hidden=True), + _agent('personal-default-closed', 'Closed', discoverable_by_orchestrator=False), + _agent('personal-foundry', 'Foundry', agent_type='new_foundry'), + _agent('personal-action-agent', 'Action Agent', actions_to_load=['hidden_action']), + ], + ) + monkeypatch.setattr( + functions_agent_catalog, + 'get_global_agents', + lambda: [ + _agent('global-allowed', 'Benefits Research'), + _agent('global-policy-denied', 'Denied'), + ], + ) + monkeypatch.setattr( + functions_agent_catalog, + 'get_user_groups', + lambda user_id: [ + {'id': 'group-current', 'name': 'Current Group'}, + {'id': 'group-inactive', 'name': 'Inactive Group', 'status': 'inactive'}, + ], + ) + + def get_group_agents(group_id): + group_calls.append(group_id) + return [_agent('group-allowed', 'Benefits Research')] + + def ensure_governance_access(feature_key, user_id, **kwargs): + del feature_key, user_id + if kwargs.get('item_id') == 'global-policy-denied': + raise PermissionError('denied') + + monkeypatch.setattr(functions_agent_catalog, 'get_group_agents', get_group_agents) + monkeypatch.setattr( + functions_agent_catalog, + 'ensure_governance_access', + ensure_governance_access, + ) + + catalog = functions_agent_catalog.build_authorized_agent_discovery_catalog( + 'user-1', + settings=_settings(), + ) + + assert [agent['catalog_key'] for agent in catalog] == [ + 'personal:user-1:personal-allowed', + 'global:global:global-allowed', + 'group:group-current:group-allowed', + ] + assert group_calls == ['group-current'] + serialized = str(catalog) + assert 'global-policy-denied' not in serialized + assert 'personal-disabled' not in serialized + assert 'personal-hidden' not in serialized + assert 'personal-default-closed' not in serialized + assert 'personal-foundry' not in serialized + assert 'personal-action-agent' not in serialized + + +def test_revoked_group_membership_and_global_policy_remove_candidates(monkeypatch): + membership = {'groups': [{'id': 'group-current', 'name': 'Current Group'}]} + monkeypatch.setattr(functions_agent_catalog, 'ensure_migration_complete', lambda user_id: None) + monkeypatch.setattr(functions_agent_catalog, 'get_personal_agents', lambda user_id: []) + monkeypatch.setattr(functions_agent_catalog, 'get_global_agents', lambda: [_agent('global-1', 'Global')]) + monkeypatch.setattr( + functions_agent_catalog, + 'get_user_groups', + lambda user_id: list(membership['groups']), + ) + monkeypatch.setattr( + functions_agent_catalog, + 'get_group_agents', + lambda group_id: [_agent('group-1', 'Group')], + ) + monkeypatch.setattr( + functions_agent_catalog, + 'ensure_governance_access', + lambda *args, **kwargs: None, + ) + + initial = functions_agent_catalog.build_authorized_agent_discovery_catalog( + 'user-1', + settings=_settings(), + ) + membership['groups'] = [] + refreshed = functions_agent_catalog.build_authorized_agent_discovery_catalog( + 'user-1', + settings=_settings( + per_user_semantic_kernel=True, + merge_global_semantic_kernel_with_workspace=False, + ), + ) + + assert {agent['scope_type'] for agent in initial} == {'global', 'group'} + assert refreshed == [] + + +def test_duplicate_names_use_stable_opaque_references_and_canonical_resolution(): + canonical_agents = [ + { + **_agent('personal-agent-id', 'Benefits Research'), + 'scope_type': 'personal', + 'scope_id': 'user-1', + 'catalog_key': 'personal:user-1:personal-agent-id', + }, + { + **_agent('group-agent-id', 'Benefits Research'), + 'scope_type': 'group', + 'scope_id': 'group-1', + 'group_id': 'group-1', + 'catalog_key': 'group:group-1:group-agent-id', + }, + ] + + first_inventory = build_governed_agent_capability_inventory( + canonical_agents, + reference_secret=REFERENCE_SECRET, + ) + rebuilt_inventory = build_governed_agent_capability_inventory( + list(reversed(canonical_agents)), + reference_secret=REFERENCE_SECRET, + ) + references = [agent['id'] for agent in first_inventory['agents']] + + assert len(set(references)) == 2 + assert set(references) == { + agent['id'] for agent in rebuilt_inventory['agents'] + } + assert all('personal-agent-id' not in reference for reference in references) + assert all('group-agent-id' not in reference for reference in references) + assert all('group-1' not in reference for reference in references) + + approved_reference = references[1] + resolved = resolve_governed_agent_capability_reference( + canonical_agents, + approved_reference, + reference_secret=REFERENCE_SECRET, + ) + assert resolved['catalog_key'] == 'group:group-1:group-agent-id' + assert resolve_governed_agent_capability_reference( + canonical_agents, + 'agent:group:forged', + reference_secret=REFERENCE_SECRET, + ) is None + + original_identity = { + **canonical_agents[0], + 'created_at': '2026-07-15T12:00:00+00:00', + } + replacement_identity = { + **canonical_agents[0], + 'created_at': '2026-07-15T12:30:00+00:00', + } + original_reference = build_governed_agent_capability_inventory( + [original_identity], + reference_secret=REFERENCE_SECRET, + )['agents'][0]['id'] + replacement_reference = build_governed_agent_capability_inventory( + [replacement_identity], + reference_secret=REFERENCE_SECRET, + )['agents'][0]['id'] + assert original_reference != replacement_reference + + +def test_agent_matching_is_deterministic_bounded_and_suppressed_by_selection(): + canonical_agents = [ + { + **_agent('benefits-agent', 'Benefits Research'), + 'scope_type': 'personal', + 'scope_id': 'user-1', + 'catalog_key': 'personal:user-1:benefits-agent', + }, + { + **_agent( + 'service-agent', + 'Service Records', + orchestrator_descriptor={ + 'capability_tags': ['service_tickets', 'incident_lookup'], + 'evidence_types': ['business_system_records'], + 'read_only': True, + 'external_data': False, + 'risk_class': 'internal_read', + 'data_sensitivity': 'internal', + 'latency_class': 'seconds', + 'cost_class': 'low', + }, + ), + 'scope_type': 'global', + 'catalog_key': 'global:global:service-agent', + }, + ] + inventory = build_governed_agent_capability_inventory( + canonical_agents, + reference_secret=REFERENCE_SECRET, + ) + + recommendation = build_agent_capability_recommendation( + inventory, + 'Summarize our employee benefits policy.', + ) + + assert recommendation['recommended_option_id'].startswith('agent:personal:') + assert len(recommendation['options']) == 2 + assert recommendation['options'][0]['kind'] == 'agent' + assert recommendation['options'][1]['id'] == CONTINUE_WITHOUT_CAPABILITIES_OPTION_ID + assert recommendation['reason_codes'] == ['specialized_organizational_knowledge'] + assert build_agent_capability_recommendation( + inventory, + 'Explain recursion with a short example.', + ) is None + assert build_agent_capability_recommendation( + inventory, + 'Summarize our employee benefits policy.', + selected_agent_present=True, + ) is None + assert build_agent_capability_recommendation( + inventory, + 'Summarize our employee benefits policy.', + selected_capability_ids=['workspace_search'], + ) is None + + +def test_agent_and_builtin_options_share_one_recommendation_contract(): + resolved_capabilities = { + capability_id: { + 'enabled': True, + 'available': True, + 'authorized': True, + 'governance_mode': 'recommend', + } + for capability_id in ( + 'workspace_search', + 'analyze', + 'compare', + 'image', + 'web_search', + 'url_access', + 'deep_research', + ) + } + builtins = build_capability_recommendation( + build_governed_capability_inventory( + resolved_capabilities=resolved_capabilities, + ), + classify_capability_requirements( + 'What are the current updates to our employee benefits policy?' + ), + ) + agent_inventory = build_governed_agent_capability_inventory( + [{ + **_agent('benefits-agent', 'Benefits Research'), + 'scope_type': 'personal', + 'scope_id': 'user-1', + 'catalog_key': 'personal:user-1:benefits-agent', + }], + reference_secret=REFERENCE_SECRET, + ) + agents = build_agent_capability_recommendation( + agent_inventory, + 'What are the current updates to our employee benefits policy?', + ) + + combined = merge_capability_recommendations(builtins, agents) + option_ids = [option['id'] for option in combined['options']] + + assert option_ids[-1] == CONTINUE_WITHOUT_CAPABILITIES_OPTION_ID + assert option_ids.count(CONTINUE_WITHOUT_CAPABILITIES_OPTION_ID) == 1 + assert len([option for option in combined['options'] if option.get('kind') == 'agent']) == 1 + assert set(combined['requirement_ids']) == { + 'current_authoritative_sources', + 'specialized_organizational_knowledge', + } + serialized = str(combined) + assert 'private instructions' not in serialized + assert 'benefits-agent' not in serialized + assert 'private-endpoint' not in serialized + assert 'private-agent-secret' not in serialized + assert 'hidden_write_tool' not in serialized + assert 'private-tenant' not in serialized + + +def test_agent_option_uses_existing_durable_choice_and_revalidation_contract(): + agent_inventory = build_governed_agent_capability_inventory( + [{ + **_agent('benefits-agent', 'Benefits Research'), + 'scope_type': 'personal', + 'scope_id': 'user-1', + 'catalog_key': 'personal:user-1:benefits-agent', + }], + reference_secret=REFERENCE_SECRET, + ) + recommendation = build_agent_capability_recommendation( + agent_inventory, + 'Summarize our employee benefits policy.', + ) + proposal = build_capability_choice_proposal( + recommendation, + run_id='parent-run-1', + conversation_id='conversation-1', + user_message_id='user-message-1', + assistant_message_id='proposal-1', + now=NOW, + ) + agent_option = proposal['options'][0] + + assert agent_option['kind'] == 'agent' + assert agent_option['agent_ref'] == agent_option['id'] + assert agent_option['capability_ids'] == [] + assert set(agent_option) == { + 'id', + 'kind', + 'agent_ref', + 'capability_ids', + 'effective_capability_ids', + 'label', + 'category', + 'scope_class', + 'latency_class', + 'cost_class', + 'external_data', + 'requires_user_choice', + 'read_only', + 'risk_class', + 'data_sensitivity', + 'capability_tags', + 'evidence_types', + 'external_query_mode', + 'sensitive_input_types', + } + + approved, idempotent = apply_capability_choice_decision( + proposal, + agent_option['id'], + actor_user_id='user-1', + now=NOW, + ) + assert idempotent is False + assert approved['status'] == 'approved' + assert approved['decision']['capability_ids'] == [] + assert approved['decision']['agent_ref'] == agent_option['id'] + assert revalidate_capability_choice( + approved, + {'version': 1, 'capabilities': [], 'agents': agent_inventory['agents']}, + ) is True + + with pytest.raises(CapabilityChoiceError) as revoked: + revalidate_capability_choice( + approved, + {'version': 1, 'capabilities': [], 'agents': []}, + ) + assert revoked.value.code == 'agent_missing' + + changed_inventory = { + 'version': 1, + 'capabilities': [], + 'agents': [ + { + **agent_inventory['agents'][0], + 'external_data': True, + 'risk_class': 'external_read', + } + ], + } + with pytest.raises(CapabilityChoiceError) as changed_policy: + revalidate_capability_choice(approved, changed_inventory) + assert changed_policy.value.code == 'agent_policy_changed' + + +def test_approved_discovered_agent_is_a_required_existing_plan_source(): + plan = build_turn_orchestration_plan( + 'Summarize our employee benefits policy.', + conversation_id='conversation-1', + selected_agent={'id': 'agent:personal:2b25d558bf54aa330659b19e47c251ad'}, + capability_origins={'selected_agent': 'discovery_approved'}, + ) + selected_agent_source = next( + source for source in plan['sources'] if source['id'] == 'selected_agent' + ) + selected_agent_step = next( + step for step in plan['steps'] if step['capability'] == 'selected_agent' + ) + + assert selected_agent_source['origin'] == 'discovery_approved' + assert selected_agent_source['required'] is True + assert selected_agent_step['origin'] == 'discovery_approved' + assert selected_agent_step['required'] is True + assert 'approved_capability_discovery' in plan['reason_codes'] + + +def test_agent_loader_and_discovered_run_telemetry_are_minimized(): + loader_source = SEMANTIC_KERNEL_LOADER.read_text(encoding='utf-8') + wrapper_source = AGENT_LOGGING_WRAPPER.read_text(encoding='utf-8') + + assert '"agents": agents_cfg' not in loader_source + assert '"aoai_endpoint"' not in loader_source + assert '"aoai_key"' not in loader_source + assert '"actions_to_load": agent_config' not in loader_source + assert '_build_agent_loader_log_summary' in loader_source + assert '_build_agent_connection_log_summary' in loader_source + + assert 'orchestration_minimize_telemetry' in wrapper_source + assert '"prompt": None if minimize_telemetry' in wrapper_source + assert 'if not minimize_telemetry:' in wrapper_source + assert '"telemetry_minimized": minimize_telemetry' in wrapper_source + + +if __name__ == '__main__': + raise SystemExit(pytest.main([__file__, '-q'])) \ No newline at end of file diff --git a/functional_tests/test_chat_turn_orchestration_plan.py b/functional_tests/test_chat_turn_orchestration_plan.py index 7f167e39b..b5744a9bc 100644 --- a/functional_tests/test_chat_turn_orchestration_plan.py +++ b/functional_tests/test_chat_turn_orchestration_plan.py @@ -2,7 +2,7 @@ # test_chat_turn_orchestration_plan.py """ Functional test for the chat turn orchestration planning foundation. -Version: 0.250.066 +Version: 0.250.067 Implemented in: 0.250.058 This test ensures every turn receives a direct or coordinated plan, selected @@ -419,7 +419,7 @@ def test_streaming_chat_path_persists_and_applies_plan(): route_source = ROUTE_BACKEND_CHATS.read_text(encoding='utf-8') config_source = CONFIG_FILE.read_text(encoding='utf-8') - assert 'VERSION = "0.250.066"' in config_source + assert 'VERSION = "0.250.067"' in config_source assert 'turn_orchestration_plan = build_turn_orchestration_plan(' in route_source assert 'requested_action_document_ids = _normalize_conversation_task_document_ids(' in route_source assert 'requested_action_document_ids\n if requested_action_document_ids' in route_source diff --git a/ui_tests/test_agent_modal_orchestrator_discovery.py b/ui_tests/test_agent_modal_orchestrator_discovery.py new file mode 100644 index 000000000..0339591f2 --- /dev/null +++ b/ui_tests/test_agent_modal_orchestrator_discovery.py @@ -0,0 +1,213 @@ +# test_agent_modal_orchestrator_discovery.py +""" +UI test for governed orchestrator discovery settings in the agent modal. +Version: 0.250.067 +Implemented in: 0.250.067 + +This test ensures discovery defaults off, safe descriptors are validated and +serialized, existing settings reopen correctly, and controls remain accessible. +""" + +import os +from pathlib import Path + +import pytest + + +BASE_URL = os.getenv('SIMPLECHAT_UI_BASE_URL', '').rstrip('/') +STORAGE_STATE = os.getenv('SIMPLECHAT_UI_STORAGE_STATE', '') + + +@pytest.mark.ui +@pytest.mark.parametrize( + 'viewport', + [ + {'width': 1280, 'height': 900}, + {'width': 390, 'height': 844}, + ], +) +def test_agent_modal_governed_discovery_policy(viewport): + if not BASE_URL: + pytest.skip('Set SIMPLECHAT_UI_BASE_URL to run this UI test.') + if not STORAGE_STATE or not Path(STORAGE_STATE).exists(): + pytest.skip( + 'Set SIMPLECHAT_UI_STORAGE_STATE to a valid authenticated Playwright storage state file.' + ) + + from playwright.sync_api import expect, sync_playwright + + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + context = browser.new_context( + storage_state=STORAGE_STATE, + viewport=viewport, + ignore_https_errors=True, + ) + page = context.new_page() + try: + page.goto(f'{BASE_URL}/workspace', wait_until='domcontentloaded') + expect(page.locator('#agentModal')).to_be_attached() + page.wait_for_function( + '() => window.agentModalStepper && ' + 'typeof window.agentModalStepper.getAgentFormData === "function"' + ) + page.evaluate( + """ + () => { + window.agentModalStepper.showModal(); + } + """ + ) + expect(page.locator('#agentModal.show')).to_be_visible() + page.wait_for_timeout(200) + page.evaluate('() => window.agentModalStepper.goToStep(6)') + + toggle = page.locator('#agent-discoverable-by-orchestrator') + controls = page.locator('#agent-orchestrator-descriptor-controls') + expect(toggle).to_be_visible() + expect(toggle).not_to_be_checked() + expect(toggle).to_have_attribute('aria-expanded', 'false') + expect(controls).to_be_hidden() + assert page.locator('#agent-orchestrator-capability-tags').is_disabled() + + toggle.focus() + toggle.press('Space') + expect(toggle).to_be_checked() + expect(toggle).to_have_attribute('aria-expanded', 'true') + expect(controls).to_be_visible() + expect(page.locator('#agent-orchestrator-capability-tags')).to_be_enabled() + + page.locator('#agent-orchestrator-capability-tags').fill( + ' Benefits, benefits, Policy Lookup! ' + ) + page.locator('#agent-orchestrator-evidence-types').fill('Policy Documents') + page.locator('#agent-orchestrator-risk-class').select_option('internal_read') + page.locator('#agent-orchestrator-data-sensitivity').select_option('internal') + page.locator('#agent-orchestrator-latency-class').select_option('seconds') + page.locator('#agent-orchestrator-cost-class').select_option('standard') + page.locator('#agent-orchestrator-read-only').uncheck() + + assert page.evaluate( + '() => window.agentModalStepper.validateCurrentStep()' + ) is False + expect(page.locator('#agent-modal-error')).to_contain_text( + 'read-only agents only' + ) + + page.locator('#agent-orchestrator-read-only').check() + assert page.evaluate( + '() => window.agentModalStepper.validateCurrentStep()' + ) is True + payload = page.evaluate( + '() => window.agentModalStepper.getAgentFormData()' + ) + assert payload['discoverable_by_orchestrator'] is True + assert payload['orchestrator_descriptor'] == { + 'capability_tags': ['benefits', 'policy_lookup'], + 'evidence_types': ['policy_documents'], + 'read_only': True, + 'external_data': False, + 'risk_class': 'internal_read', + 'data_sensitivity': 'internal', + 'latency_class': 'seconds', + 'cost_class': 'standard', + } + + layout = page.locator('#agent-orchestrator-discovery-fieldset').evaluate( + """ + element => ({ + overflows: element.scrollWidth > element.clientWidth, + right: element.getBoundingClientRect().right, + viewportWidth: window.innerWidth, + }) + """ + ) + assert layout['overflows'] is False + assert layout['right'] <= layout['viewportWidth'] + 1 + + page.evaluate( + "() => window.agentModalStepper.handleAgentTypeChange('new_foundry')" + ) + expect(page.locator('#agent-orchestrator-discovery-fieldset')).to_be_hidden() + foundry_payload = page.evaluate( + '() => window.agentModalStepper.getAgentFormData()' + ) + assert foundry_payload['discoverable_by_orchestrator'] is False + assert foundry_payload['orchestrator_descriptor'] == {} + page.evaluate( + "() => window.agentModalStepper.handleAgentTypeChange('local')" + ) + page.evaluate( + """ + () => { + const actionCard = document.createElement('div'); + actionCard.id = 'phase8b-selected-action'; + actionCard.className = 'action-card border-primary'; + actionCard.dataset.actionId = 'action-1'; + actionCard.dataset.actionName = 'Action One'; + document.getElementById('agent-step-4').appendChild(actionCard); + } + """ + ) + toggle.check() + page.locator('#agent-orchestrator-capability-tags').fill('benefits') + page.locator('#agent-orchestrator-evidence-types').fill('policy_documents') + assert page.evaluate( + '() => window.agentModalStepper.validateCurrentStep()' + ) is False + expect(page.locator('#agent-modal-error')).to_contain_text( + 'Remove attached actions' + ) + page.locator('#phase8b-selected-action').evaluate('element => element.remove()') + + page.evaluate( + """ + agent => { + window.agentModalStepper.showModal(agent); + } + """, + { + 'id': 'agent-1', + 'name': 'benefits_research', + 'display_name': 'Benefits Research', + 'description': 'Benefits evidence.', + 'instructions': 'Private canonical instructions.', + 'actions_to_load': [], + 'other_settings': {}, + 'max_completion_tokens': -1, + 'agent_type': 'local', + 'discoverable_by_orchestrator': True, + 'orchestrator_descriptor': { + 'capability_tags': ['benefits'], + 'evidence_types': ['policy_documents'], + 'read_only': True, + 'external_data': True, + 'risk_class': 'external_read', + 'data_sensitivity': 'public', + 'latency_class': 'minutes', + 'cost_class': 'low', + }, + }, + ) + page.wait_for_timeout(200) + page.evaluate('() => window.agentModalStepper.goToStep(6)') + expect(toggle).to_be_checked() + expect(controls).to_be_visible() + expect(page.locator('#agent-orchestrator-capability-tags')).to_have_value('benefits') + expect(page.locator('#agent-orchestrator-evidence-types')).to_have_value( + 'policy_documents' + ) + expect(page.locator('#agent-orchestrator-external-data')).to_be_checked() + expect(page.locator('#agent-orchestrator-risk-class')).to_have_value( + 'external_read' + ) + expect(page.locator('#agent-orchestrator-data-sensitivity')).to_have_value( + 'public' + ) + expect(page.locator('#agent-orchestrator-latency-class')).to_have_value( + 'minutes' + ) + expect(page.locator('#agent-orchestrator-cost-class')).to_have_value('low') + finally: + context.close() + browser.close() \ No newline at end of file diff --git a/ui_tests/test_chat_capability_choice_card.py b/ui_tests/test_chat_capability_choice_card.py index b44b29e9a..e76f85262 100644 --- a/ui_tests/test_chat_capability_choice_card.py +++ b/ui_tests/test_chat_capability_choice_card.py @@ -1,8 +1,8 @@ # test_chat_capability_choice_card.py """ UI test for governed capability choice cards in chat. -Version: 0.250.066 -Implemented in: 0.250.066 +Version: 0.250.067 +Implemented in: 0.250.067 This test ensures persisted capability proposals hydrate on desktop and mobile, expose accessible notices and controls, submit only allowlisted identifiers, @@ -100,6 +100,81 @@ def _proposal_metadata(status='pending', resume_status='not_requested'): } +def _agent_proposal_metadata( + proposal_id, + *, + status='pending', + resume_status='not_requested', +): + created_at = datetime.now(timezone.utc) + agent_option_id = 'agent:group:0123456789abcdef0123456789abcdef' + decision = None + if status in {'approved', 'declined'}: + decision = { + 'option_id': agent_option_id, + 'status': status, + 'capability_ids': [], + 'effective_capability_ids': [], + 'agent_ref': agent_option_id, + } + return { + 'awaiting_user_choice': status == 'pending', + 'capability_proposal': { + 'version': 1, + 'proposal_id': proposal_id, + 'run_id': f'{proposal_id}-run', + 'conversation_id': 'ui-capability-conversation', + 'user_message_id': f'{proposal_id}-user', + 'assistant_message_id': proposal_id, + 'status': status, + 'requirement_ids': ['specialized_organizational_knowledge'], + 'reason_codes': ['specialized_organizational_knowledge'], + 'recommended_option_id': agent_option_id, + 'options': [ + { + 'id': agent_option_id, + 'kind': 'agent', + 'agent_ref': agent_option_id, + 'capability_ids': [], + 'effective_capability_ids': [], + 'label': 'Benefits ', + 'category': 'specialized_agent', + 'scope_class': 'group', + 'latency_class': 'seconds', + 'cost_class': 'standard', + 'external_data': True, + 'read_only': True, + 'risk_class': 'internal_read', + 'data_sensitivity': 'internal', + 'capability_tags': ['benefits', 'policy_lookup'], + 'evidence_types': ['policy_documents'], + }, + { + 'id': 'continue_without_capabilities', + 'kind': 'continue', + 'capability_ids': [], + 'effective_capability_ids': [], + 'label': 'Continue without additional capabilities', + 'latency_class': 'immediate', + 'cost_class': 'none', + 'external_data': False, + }, + ], + 'decision': decision, + 'resume': { + 'status': resume_status, + 'assistant_message_id': ( + f'{proposal_id}-completed-assistant' + if resume_status == 'completed' + else None + ), + }, + 'created_at': created_at.isoformat(), + 'expires_at': (created_at + timedelta(days=1)).isoformat(), + }, + } + + def _append_proposal_message(page, message_id, metadata): page.evaluate( r""" @@ -335,5 +410,140 @@ def handle_resume(route): 'This capability choice has expired.' ) + context.close() + browser.close() + + +@pytest.mark.ui +@pytest.mark.parametrize( + 'viewport', + [ + {'width': 1280, 'height': 900}, + {'width': 390, 'height': 844}, + ], +) +def test_governed_agent_choice_is_inert_minimal_and_refreshable(viewport): + from playwright.sync_api import expect, sync_playwright + + chat_url = _get_chat_test_url() + decision_requests = [] + resume_requests = [] + + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + context = _create_context(browser, viewport) + page = context.new_page() + + def handle_decision(route): + decision_requests.append(json.loads(route.request.post_data or '{}')) + route.fulfill( + status=200, + content_type='application/json', + body=json.dumps({ + 'success': True, + 'status': 'approved', + 'resume_status': 'pending', + 'resume_endpoint': '/api/chat/stream', + }), + ) + + def handle_resume(route): + resume_requests.append(json.loads(route.request.post_data or '{}')) + route.fulfill( + status=200, + content_type='text/event-stream', + body=( + 'data: ' + + json.dumps({ + 'done': True, + 'message_id': 'ui-agent-resumed-assistant', + 'full_content': 'Completed with governed agent evidence.', + }) + + '\n\n' + ), + ) + + page.route('**/api/chat/capability-proposals/*/decision', handle_decision) + page.route('**/api/chat/stream', handle_resume) + page.goto(chat_url, wait_until='domcontentloaded') + + proposal_id = 'ui-agent-proposal-pending' + _append_proposal_message( + page, + proposal_id, + _agent_proposal_metadata(proposal_id), + ) + card = page.locator( + f'[data-message-id="{proposal_id}"]' + ).get_by_test_id('capability-choice-card') + agent_button = card.get_by_role('button').filter(has_text='Benefits window.phase8bInjected === true') is False + + status_id = agent_button.get_attribute('aria-describedby') + assert status_id + expect(card.locator(f'#{status_id}')).to_have_attribute('aria-live', 'polite') + layout = card.evaluate( + """ + element => ({ + overflows: element.scrollWidth > element.clientWidth, + right: element.getBoundingClientRect().right, + viewportWidth: window.innerWidth, + buttonHeights: Array.from(element.querySelectorAll('button')).map( + button => button.getBoundingClientRect().height + ) + }) + """ + ) + assert layout['overflows'] is False + assert layout['right'] <= layout['viewportWidth'] + 1 + assert all(height >= 44 for height in layout['buttonHeights']) + + agent_button.focus() + agent_button.press('Enter') + expect(card.get_by_role('status')).to_contain_text('Completed with Benefits