diff --git a/application/single_app/agent_logging_chat_completion.py b/application/single_app/agent_logging_chat_completion.py index e4173ef2..d2d982ae 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 13be9cc4..5a102981 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 d70ca05e..b3b2d66a 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 8770d8fc..fe917d56 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 81703a29..e1dbb3c1 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 bc3fb2d6..ea56b6fa 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 32a47b1f..44a9df84 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 4b23e205..3507d1ca 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 14321ca8..cc11304b 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 8b177da0..294a4ddf 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 a8f7bdf9..30ba2ab9 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 50426e35..86e21007 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 8140b173..4b594c9c 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 aece466d..a0533341 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 169e4ca7..1d51cb2b 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 fa999cb4..9494d3ec 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 dd432f60..724f88f3 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 8bc34bdc..4b5059ea 100644 --- a/application/single_app/templates/_agent_modal.html +++ b/application/single_app/templates/_agent_modal.html @@ -692,6 +692,81 @@