diff --git a/application/single_app/collaboration_models.py b/application/single_app/collaboration_models.py index 63e0faae..d9ac6a4e 100644 --- a/application/single_app/collaboration_models.py +++ b/application/single_app/collaboration_models.py @@ -5,6 +5,8 @@ from datetime import UTC, datetime, timedelta import uuid +from functions_chat_capability_choices import project_chat_metadata_for_client + COLLABORATION_KIND = 'collaborative' @@ -660,7 +662,7 @@ def build_collaboration_message_doc_from_legacy( collaboration_metadata = collaboration_message.get('metadata', {}) collaboration_message['metadata'] = { - **dict(legacy_metadata), + **project_chat_metadata_for_client(legacy_metadata), **collaboration_metadata, 'source_message_id': _clean_string(legacy_message.get('id')) or None, 'source_role': legacy_role or None, diff --git a/application/single_app/config.py b/application/single_app/config.py index 72d0dcc8..cc617ea0 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.075" +VERSION = "0.250.077" 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_chat_capabilities.py b/application/single_app/functions_chat_capabilities.py index cda80ac2..6d070e74 100644 --- a/application/single_app/functions_chat_capabilities.py +++ b/application/single_app/functions_chat_capabilities.py @@ -1198,7 +1198,45 @@ def get_capability_option_revalidation_error(option, inventory): """Return a bounded error when a built-in option no longer matches server policy.""" if not isinstance(option, Mapping) or not isinstance(inventory, Mapping): return 'capability_plan_invalid' - if str(option.get('kind') or 'capability').strip().lower() != 'capability': + option_kind = str(option.get('kind') or 'capability').strip().lower() + if option_kind == 'context': + entries_by_id = { + str(entry.get('id') or '').strip().lower(): entry + for entry in (inventory.get('capabilities') or []) + if isinstance(entry, Mapping) and str(entry.get('id') or '').strip() + } + rebuilt_option = _build_contextual_egress_option( + option.get('effective_capability_ids') or [], + entries_by_id, + inventory.get('version'), + ) + if not rebuilt_option: + return 'capability_plan_invalid' + expected_option_id = rebuilt_option['id'] + if str(option.get('id') or '').endswith('_with_sensitive_inputs'): + expected_option_id = f'{expected_option_id}_with_sensitive_inputs' + if not ( + option.get('external_query_mode') + == 'include_approved_sensitive_inputs' + and 'street_address' in (option.get('sensitive_input_types') or []) + ): + return 'capability_plan_policy_changed' + for field_name in ( + 'kind', + 'effective_capability_ids', + 'latency_class', + 'cost_class', + 'external_data', + 'read_only', + 'risk_class', + 'data_sensitivity', + ): + if option.get(field_name) != rebuilt_option.get(field_name): + return 'capability_plan_policy_changed' + if option.get('id') != expected_option_id: + return 'capability_plan_policy_changed' + return None + if option_kind != 'capability': return None option_id = str(option.get('id') or '').strip() approved_ids = [ @@ -1353,58 +1391,213 @@ def _recommended_capability_option(recommendation): ) +def _build_contextual_egress_option(effective_ids, entries_by_id, inventory_version): + normalized_ids = sorted({ + str(capability_id or '').strip().lower() + for capability_id in effective_ids or [] + if str(capability_id or '').strip().lower() in entries_by_id + }) + effective_entries = [entries_by_id[capability_id] for capability_id in normalized_ids] + if not effective_entries or not all( + entry.get('external_data') is True + and entry.get('read_only') is True + and entry.get('input_ready') is True + and entry.get('state') in {'selected', 'unselected'} + for entry in effective_entries + ): + return None + binding = { + 'kind': 'prior_user_goal_egress', + 'inventory_version': inventory_version, + 'capabilities': [ + { + 'id': entry.get('id'), + 'state': entry.get('state'), + 'risk_class': entry.get('risk_class'), + 'latency_class': entry.get('latency_class'), + 'cost_class': entry.get('cost_class'), + 'data_sensitivity': entry.get('data_sensitivity'), + } + for entry in effective_entries + ], + } + option_digest = hashlib.sha256( + json.dumps(binding, sort_keys=True, separators=(',', ':')).encode('utf-8') + ).hexdigest()[:32] + effective_id_set = set(normalized_ids) + if 'deep_research' in effective_id_set: + label = 'Research using the earlier request' + elif 'web_search' in effective_id_set: + label = 'Search the web using the earlier request' + else: + label = 'Use external sources from the earlier request' + risk_order = { + 'internal_read': 0, + 'external_read': 1, + } + return { + 'id': f'context:{option_digest}', + 'kind': 'context', + 'capability_ids': [], + 'effective_capability_ids': normalized_ids, + 'label': label, + 'latency_class': _planner_plan_aggregate_class( + effective_entries, + 'latency_class', + _PLANNER_PLAN_LATENCY_ORDER, + 'seconds', + ), + 'cost_class': _planner_plan_aggregate_class( + effective_entries, + 'cost_class', + _PLANNER_PLAN_COST_ORDER, + 'standard', + ), + 'external_data': True, + 'requires_user_choice': True, + 'read_only': True, + 'risk_class': _planner_plan_aggregate_class( + effective_entries, + 'risk_class', + risk_order, + 'external_read', + ), + 'data_sensitivity': ( + 'internal' + if any( + str(entry.get('risk_class') or '').strip().lower() + == 'internal_read' + for entry in effective_entries + ) + else 'public' + ), + } + + +def build_contextual_egress_recommendation( + validated_result, + inventory, + selection_snapshot=None, +): + """Require one choice when a prior goal would use baseline external retrieval.""" + if not ( + isinstance(validated_result, Mapping) + and validated_result.get('status') == 'valid' + and validated_result.get('decision') == 'direct' + and validated_result.get('prior_goal_included') is True + and isinstance(inventory, Mapping) + ): + return None + entries_by_id = { + str(entry.get('id') or '').strip().lower(): entry + for entry in inventory.get('capabilities') or [] + if isinstance(entry, Mapping) and str(entry.get('id') or '').strip() + } + try: + selected_ids = _selected_planner_capability_ids( + inventory, + selection_snapshot, + ) + automatic_root_ids = { + str(capability_id or '').strip().lower() + for capability_id in ( + selection_snapshot.get('auto_capability_ids') + if isinstance(selection_snapshot, Mapping) + else [] + ) or [] + if str(capability_id or '').strip() + } + automatic_ids = set(expand_governed_capability_baseline_ids( + inventory, + automatic_root_ids, + )) + except ValueError: + return None + external_effective_ids = { + capability_id + for capability_id in selected_ids | automatic_ids + if capability_id in entries_by_id + and entries_by_id[capability_id].get('external_data') is True + } + option = _build_contextual_egress_option( + external_effective_ids, + entries_by_id, + inventory.get('version'), + ) + if not option: + return None + reason_codes = [ + str(item.get('reason_code') or '').strip().lower() + for item in validated_result.get('requirements') or [] + if isinstance(item, Mapping) + and _bounded_reason(item.get('reason_code')) + ] + if not reason_codes: + reason_codes = ['public_source_retrieval'] + return { + 'version': CAPABILITY_RECOMMENDATION_VERSION, + 'status': 'pending', + 'source': 'planner', + 'requirement_ids': [ + str(item.get('id') or '').strip().lower() + for item in validated_result.get('requirements') or [] + if isinstance(item, Mapping) and str(item.get('id') or '').strip() + ], + 'reason_codes': list(dict.fromkeys(reason_codes)), + 'recommended_option_id': option['id'], + 'options': [ + option, + { + 'id': CONTINUE_WITHOUT_CAPABILITIES_OPTION_ID, + 'kind': 'continue', + 'capability_ids': [], + 'effective_capability_ids': [], + 'label': 'Continue without external retrieval', + 'latency_class': 'immediate', + 'cost_class': 'none', + 'external_data': False, + 'requires_user_choice': True, + }, + ], + 'required_inputs': [], + 'selected_capability_ids': sorted(selected_ids), + 'selected_context_labels': [ + entry['label'] + for entry in inventory.get('capabilities') or [] + if isinstance(entry, Mapping) + and entry.get('state') == 'selected' + and str(entry.get('label') or '').strip() + ], + } + + def arbitrate_planner_capability_recommendation( planner_recommendation, deterministic_recommendation, ): - """Prefer deterministic control when a planner plan omits its material source.""" - deterministic = ( - copy.deepcopy(dict(deterministic_recommendation)) - if isinstance(deterministic_recommendation, Mapping) - else None - ) + """Activate only a validated planner recommendation in Assist mode.""" + del deterministic_recommendation planner = ( copy.deepcopy(dict(planner_recommendation)) if isinstance(planner_recommendation, Mapping) else None ) if not planner: - return deterministic, { + return None, { 'activation_status': 'suppressed', - 'recommendation_source': 'deterministic' if deterministic else 'direct', + 'recommendation_source': 'direct', 'suppression_reason': 'planner_not_materialized', } planner_signature = _planner_option_signature( _recommended_capability_option(planner) ) - deterministic_signature = _planner_option_signature( - _recommended_capability_option(deterministic) - ) if not planner_signature: - return deterministic, { + return None, { 'activation_status': 'suppressed', - 'recommendation_source': 'deterministic' if deterministic else 'direct', + 'recommendation_source': 'direct', 'suppression_reason': 'planner_not_materialized', } - if deterministic_signature and not deterministic_signature.issubset(planner_signature): - return deterministic, { - 'activation_status': 'suppressed', - 'recommendation_source': 'deterministic', - 'suppression_reason': 'deterministic_conflict', - } - if deterministic_signature: - planner['options'] = [ - option - for option in planner.get('options') or [] - if ( - str(option.get('id') or '').strip() - == CONTINUE_WITHOUT_CAPABILITIES_OPTION_ID - or deterministic_signature.issubset( - _planner_option_signature(option) - ) - ) - ] return planner, { 'activation_status': 'materialized', 'recommendation_source': 'planner', diff --git a/application/single_app/functions_chat_capability_choices.py b/application/single_app/functions_chat_capability_choices.py index 314bac20..9558f5a0 100644 --- a/application/single_app/functions_chat_capability_choices.py +++ b/application/single_app/functions_chat_capability_choices.py @@ -2,6 +2,9 @@ """Durable capability proposal, decision, and resume contracts.""" import copy +import hashlib +import hmac +import json import re import uuid from collections.abc import Mapping @@ -17,8 +20,13 @@ ) -CAPABILITY_CHOICE_VERSION = 2 +CAPABILITY_CHOICE_VERSION = 3 CAPABILITY_PROVENANCE_VERSION = 2 +APPROVED_USER_TURN_GOAL_VERSION = 1 +MAX_APPROVED_GOAL_SOURCE_TURNS = 3 +MAX_APPROVED_GOAL_DISPLAY_CHARS = 240 +MAX_CLIENT_CLARIFICATION_OPTIONS = 6 +MAX_CLIENT_CLARIFICATION_OPTION_CHARS = 120 DEFAULT_CAPABILITY_CHOICE_TTL_SECONDS = 86400 MAX_CAPABILITY_CHOICE_TTL_SECONDS = 604800 CAPABILITY_RESUME_LEASE_SECONDS = 1800 @@ -54,6 +62,10 @@ re.IGNORECASE, ) +_CLIENT_PRIVATE_METADATA_KEYS = frozenset({ + 'capability_resume_request', +}) + class CapabilityChoiceError(ValueError): """Raised when a capability proposal or decision is invalid.""" @@ -114,6 +126,262 @@ def _normalize_labels(values, *, max_items=8): return normalized +def _goal_source_record(message, *, conversation_id): + if not isinstance(message, Mapping): + raise CapabilityChoiceError( + 'goal source message is invalid', + code='goal_source_invalid', + ) + message_id = _normalize_identifier(message.get('id'), 'source_user_message_id') + if ( + message.get('conversation_id') != conversation_id + or message.get('role') != 'user' + ): + raise CapabilityChoiceError( + 'goal source message is outside the authorized conversation', + code='goal_source_invalid', + ) + metadata = message.get('metadata') if isinstance(message.get('metadata'), Mapping) else {} + thread_info = ( + metadata.get('thread_info') + if isinstance(metadata.get('thread_info'), Mapping) + else {} + ) + if ( + metadata.get('is_deleted') is True + or metadata.get('masked') is True + or bool(metadata.get('masked_ranges') or []) + or metadata.get('is_generated_chat_artifact') is True + or thread_info.get('active_thread') is False + ): + raise CapabilityChoiceError( + 'goal source message is no longer active', + code='goal_source_inactive', + ) + thread_id = str(thread_info.get('thread_id') or '').strip() + if not thread_id: + raise CapabilityChoiceError( + 'goal source message has no active thread binding', + code='goal_source_thread_invalid', + ) + content = str(message.get('content') or '').strip() + if not content: + raise CapabilityChoiceError( + 'goal source message is empty', + code='goal_source_invalid', + ) + return { + 'message_id': message_id, + 'content_hash': hashlib.sha256(content.encode('utf-8')).hexdigest(), + 'thread_id': thread_id, + 'previous_thread_id': str( + thread_info.get('previous_thread_id') or '' + ).strip(), + 'thread_attempt': int(thread_info.get('thread_attempt') or 1), + }, content + + +def build_approved_user_turn_goal( + source_user_messages, + *, + conversation_id, + current_user_message_id, + display_summary=None, + approved_by_option_id=None, + approved_sensitive_input_types=None, +): + """Build a bounded server-owned goal from exact active user documents.""" + conversation_id = _normalize_identifier(conversation_id, 'conversation_id') + current_user_message_id = _normalize_identifier( + current_user_message_id, + 'current_user_message_id', + ) + raw_messages = ( + source_user_messages + if isinstance(source_user_messages, list) + else [] + ) + if not 1 <= len(raw_messages) <= MAX_APPROVED_GOAL_SOURCE_TURNS: + raise CapabilityChoiceError( + 'approved goal must contain one to three user turns', + code='goal_source_count_invalid', + ) + source_records = [] + source_contents = [] + for message in raw_messages: + source_record, content = _goal_source_record( + message, + conversation_id=conversation_id, + ) + if source_record['message_id'] in { + item['message_id'] + for item in source_records + }: + raise CapabilityChoiceError( + 'approved goal source turns must be unique', + code='goal_source_duplicate', + ) + source_records.append(source_record) + source_contents.append(content) + source_ids = [record['message_id'] for record in source_records] + if current_user_message_id not in source_ids: + raise CapabilityChoiceError( + 'approved goal must include the current user turn', + code='goal_current_turn_missing', + ) + combined_user_text = '\n'.join(source_contents) + contextual_query = ' '.join(combined_user_text.split())[:1000] + minimized = build_minimized_external_query( + combined_user_text, + approved_sensitive_input_types=approved_sensitive_input_types, + ) + prior_user_turn_included = any( + message_id != current_user_message_id + for message_id in source_ids + ) + if display_summary is None: + summary_source = next( + ( + content + for message_id, content in zip(source_ids, source_contents) + if message_id != current_user_message_id + ), + source_contents[-1], + ) + display_summary = ' '.join(summary_source.split()) + normalized_summary = ' '.join(str(display_summary or '').split())[ + :MAX_APPROVED_GOAL_DISPLAY_CHARS + ] + return { + 'version': APPROVED_USER_TURN_GOAL_VERSION, + 'source': 'approved_user_turn_goal', + 'conversation_id': conversation_id, + 'current_user_message_id': current_user_message_id, + 'source_user_message_ids': source_ids, + 'source_turn_lineage': source_records, + 'display_summary': normalized_summary, + 'contextual_query': contextual_query, + 'external_query': minimized['query'], + 'omitted_sensitive_input_types': list( + minimized['omitted_sensitive_input_types'] + ), + 'prior_user_turn_included': prior_user_turn_included, + 'assistant_text_included': False, + 'workspace_content_included': False, + 'approved_by_option_id': ( + _normalize_identifier(approved_by_option_id, 'approved_by_option_id') + if approved_by_option_id + else None + ), + } + + +def rebuild_approved_user_turn_goal( + stored_goal, + source_user_messages, + *, + approved_sensitive_input_types=None, +): + """Rebuild a stored goal from exact documents and reject changed lineage.""" + if not isinstance(stored_goal, Mapping) or ( + stored_goal.get('version') != APPROVED_USER_TURN_GOAL_VERSION + or stored_goal.get('source') != 'approved_user_turn_goal' + ): + raise CapabilityChoiceError( + 'approved goal metadata is invalid', + code='goal_metadata_invalid', + ) + rebuilt = build_approved_user_turn_goal( + source_user_messages, + conversation_id=stored_goal.get('conversation_id'), + current_user_message_id=stored_goal.get('current_user_message_id'), + display_summary=stored_goal.get('display_summary'), + approved_by_option_id=stored_goal.get('approved_by_option_id'), + approved_sensitive_input_types=approved_sensitive_input_types, + ) + expected_ids = list(stored_goal.get('source_user_message_ids') or []) + if rebuilt['source_user_message_ids'] != expected_ids: + raise CapabilityChoiceError( + 'approved goal source turns changed', + code='goal_source_changed', + ) + expected_lineage = stored_goal.get('source_turn_lineage') or [] + if len(expected_lineage) != len(rebuilt['source_turn_lineage']): + raise CapabilityChoiceError( + 'approved goal lineage changed', + code='goal_source_changed', + ) + for expected, actual in zip(expected_lineage, rebuilt['source_turn_lineage']): + expected_hash = str(expected.get('content_hash') or '') if isinstance(expected, Mapping) else '' + actual_hash = str(actual.get('content_hash') or '') + expected_binding = { + key: expected.get(key) + for key in ( + 'message_id', + 'thread_id', + 'previous_thread_id', + 'thread_attempt', + ) + } if isinstance(expected, Mapping) else {} + actual_binding = { + key: actual.get(key) + for key in expected_binding + } + if ( + not expected_hash + or not hmac.compare_digest(expected_hash, actual_hash) + or expected_binding != actual_binding + ): + raise CapabilityChoiceError( + 'approved goal source content or lineage changed', + code='goal_source_changed', + ) + return rebuilt + + +def project_chat_metadata_for_client(metadata): + """Remove server-only execution and contextual lineage from client metadata.""" + def project(value, key_name=None): + if key_name == 'chat_clarification' and isinstance(value, Mapping): + return { + 'version': value.get('version'), + 'code': str(value.get('code') or '')[:64], + 'question': str(value.get('question') or '')[:240], + 'status': str(value.get('status') or '')[:32], + 'options': [ + str(option or '')[:MAX_CLIENT_CLARIFICATION_OPTION_CHARS] + for option in value.get('options') or [] + if str(option or '').strip() + ][:MAX_CLIENT_CLARIFICATION_OPTIONS], + 'created_at': value.get('created_at'), + 'expires_at': value.get('expires_at'), + 'resolved_at': value.get('resolved_at'), + 'response_mode': value.get('response_mode'), + } + if key_name == 'clarification_response' and isinstance(value, Mapping): + return { + 'version': value.get('version'), + 'code': str(value.get('code') or '')[:64], + 'status': str(value.get('status') or '')[:32], + 'response_mode': str(value.get('response_mode') or '')[:32], + 'idempotent': value.get('idempotent') is True, + } + if isinstance(value, Mapping): + return { + str(key): project(item, str(key)) + for key, item in value.items() + if ( + not str(key).startswith('_') + and str(key) not in _CLIENT_PRIVATE_METADATA_KEYS + ) + } + if isinstance(value, list): + return [project(item) for item in value] + return copy.deepcopy(value) + + return project(metadata if isinstance(metadata, Mapping) else {}) + + def _normalize_options(options): normalized_options = [] seen_option_ids = set() @@ -127,7 +395,7 @@ def _normalize_options(options): 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'}: + if option_kind not in {'capability', 'agent', 'context', '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( @@ -153,6 +421,18 @@ def _normalize_options(options): 'agent option reference is invalid', code='invalid_agent_option_reference', ) + elif option_kind == 'context': + if ( + capability_ids + or not effective_ids + or agent_ref + or raw_option.get('external_data') is not True + or raw_option.get('read_only') is not True + ): + raise CapabilityChoiceError( + 'context options must bind read-only external capabilities', + code='invalid_context_option', + ) elif not capability_ids: raise CapabilityChoiceError( 'capability options must name at least one capability', @@ -186,6 +466,8 @@ def _normalize_options(options): max_items=8, ), } + if option_kind == 'context': + normalized_option['approval_scope'] = 'prior_user_goal_egress' if option_kind == 'agent': normalized_option.update({ 'agent_ref': agent_ref, @@ -319,7 +601,13 @@ def add_sensitive_external_query_options( if not isinstance(option, Mapping): continue normalized_option = dict(option) - if normalized_option.get('external_data') and normalized_option.get('capability_ids'): + if ( + normalized_option.get('external_data') + and ( + normalized_option.get('capability_ids') + or normalized_option.get('kind') == 'context' + ) + ): normalized_option['external_query_mode'] = 'minimized' updated_options.append(normalized_option) sensitive_option = dict(normalized_option) @@ -369,6 +657,8 @@ def build_capability_choice_proposal( conversation_id, user_message_id, assistant_message_id=None, + approved_user_turn_goal=None, + capability_inventory=None, now=None, ttl_seconds=DEFAULT_CAPABILITY_CHOICE_TTL_SECONDS, ): @@ -384,8 +674,58 @@ def build_capability_choice_proposal( normalized_ttl = DEFAULT_CAPABILITY_CHOICE_TTL_SECONDS normalized_ttl = max(60, min(normalized_ttl, MAX_CAPABILITY_CHOICE_TTL_SECONDS)) proposal_id = str(assistant_message_id or uuid.uuid4()) - options = _normalize_options(recommendation.get('options')) + private_goal = ( + copy.deepcopy(dict(approved_user_turn_goal)) + if isinstance(approved_user_turn_goal, Mapping) + else None + ) + raw_options = copy.deepcopy(list(recommendation.get('options') or [])) + if private_goal: + if ( + private_goal.get('version') != APPROVED_USER_TURN_GOAL_VERSION + or private_goal.get('source') != 'approved_user_turn_goal' + or private_goal.get('conversation_id') != conversation_id + or private_goal.get('current_user_message_id') != user_message_id + or private_goal.get('prior_user_turn_included') is not True + ): + raise CapabilityChoiceError( + 'approved prior-user goal is invalid for this proposal', + code='goal_metadata_invalid', + ) + for raw_option in raw_options: + if ( + isinstance(raw_option, Mapping) + and raw_option.get('external_data') is True + and raw_option.get('id') != CONTINUE_WITHOUT_CAPABILITIES_OPTION_ID + ): + raw_option['prior_goal_included'] = True + raw_option['goal_source_count'] = len( + private_goal.get('source_user_message_ids') or [] + ) + raw_option['goal_display_summary'] = private_goal.get( + 'display_summary' + ) + options = _normalize_options(raw_options) + if private_goal and not any( + option.get('kind') != 'continue' + for option in options + ): + raise CapabilityChoiceError( + 'contextual user goal requires an actionable option', + code='goal_action_option_missing', + ) option_ids = {option['id'] for option in options} + external_capability_ids = sorted({ + str(entry.get('id') or '').strip() + for entry in ( + capability_inventory.get('capabilities') + if isinstance(capability_inventory, Mapping) + else [] + ) or [] + if isinstance(entry, Mapping) + and entry.get('external_data') is True + and str(entry.get('id') or '').strip() + })[:8] recommended_option_id = _normalize_identifier( recommendation.get('recommended_option_id'), 'recommended_option_id', @@ -416,6 +756,22 @@ def build_capability_choice_proposal( 'sensitive_data_notice_required': bool( recommendation.get('sensitive_data_notice_required') ), + 'prior_goal_included': bool(private_goal), + 'goal_source_count': min( + len(private_goal.get('source_user_message_ids') or []) + if private_goal + else 0, + MAX_APPROVED_GOAL_SOURCE_TURNS, + ), + 'goal_display_summary': ( + str(private_goal.get('display_summary') or '')[ + :MAX_APPROVED_GOAL_DISPLAY_CHARS + ] + if private_goal + else '' + ), + '_external_capability_ids': external_capability_ids, + '_approved_user_turn_goal': private_goal, 'recommended_option_id': recommended_option_id, 'options': options, 'created_at': current_time.isoformat(), @@ -491,9 +847,13 @@ def apply_capability_choice_decision(proposal, option_id, *, actor_user_id, now= ) capability_ids = list(option.get('capability_ids') or []) agent_ref = str(option.get('agent_ref') or '').strip() or None - decision_status = 'declined' if not capability_ids and not agent_ref else 'approved' + decision_status = ( + 'declined' + if option.get('kind') == 'continue' + else 'approved' + ) updated['status'] = decision_status - updated['decision'] = { + decision = { 'option_id': normalized_option_id, 'status': decision_status, 'capability_ids': capability_ids, @@ -504,6 +864,25 @@ def apply_capability_choice_decision(proposal, option_id, *, actor_user_id, now= 'actor_user_id': actor_user_id, 'decided_at': current_time.isoformat(), } + if updated.get('prior_goal_included') is True: + decision.update({ + 'approval_scope': ( + 'prior_user_goal_egress_declined' + if decision_status == 'declined' + else option.get('approval_scope') or 'contextual_capability' + ), + 'contextual_goal_included': decision_status == 'approved', + 'prior_goal_included': bool( + decision_status == 'approved' + and option.get('external_data') is True + ), + 'goal_source_count': ( + updated.get('goal_source_count', 0) + if decision_status == 'approved' + else 0 + ), + }) + updated['decision'] = decision updated['resume'] = { 'status': 'pending', 'execution_id': None, @@ -514,6 +893,15 @@ def apply_capability_choice_decision(proposal, option_id, *, actor_user_id, now= 'completed_at': None, 'error_type': None, } + private_goal = updated.get('_approved_user_turn_goal') + if isinstance(private_goal, Mapping): + private_goal = copy.deepcopy(dict(private_goal)) + private_goal['approved_by_option_id'] = ( + normalized_option_id + if decision_status == 'approved' + else None + ) + updated['_approved_user_turn_goal'] = private_goal return updated, False @@ -558,6 +946,25 @@ def revalidate_capability_choice(proposal, inventory): 'the approved decision no longer matches its server-authored option', code='capability_decision_mismatch', ) + private_goal = proposal.get('_approved_user_turn_goal') + if decision.get('contextual_goal_included') is True: + if not ( + isinstance(private_goal, Mapping) + and private_goal.get('approved_by_option_id') + == decision.get('option_id') + ): + raise CapabilityChoiceError( + 'approved prior-user goal no longer matches the option decision', + code='goal_approval_mismatch', + ) + if ( + decision.get('prior_goal_included') is True + and approved_option.get('external_data') is not True + ): + raise CapabilityChoiceError( + 'approved external goal no longer matches an external option', + code='goal_approval_mismatch', + ) for capability_id in effective_capability_ids: entry = entries_by_id.get(capability_id) if not entry: @@ -857,6 +1264,78 @@ def require_entry(capability_id): return True +def build_decline_aware_execution_baseline( + proposal, + refreshed_inventory, + *, + selected_capability_ids=None, + prior_effective_capabilities=None, + automatic_capability_root_ids=None, + automatic_capability_effective_ids=None, +): + """Exclude only proposal-bound external capabilities after explicit decline.""" + decision = ( + proposal.get('decision') + if isinstance(proposal, Mapping) + and isinstance(proposal.get('decision'), Mapping) + else {} + ) + if decision.get('approval_scope') != 'prior_user_goal_egress_declined': + return { + 'selected_capability_ids': selected_capability_ids, + 'prior_effective_capabilities': prior_effective_capabilities, + 'automatic_capability_root_ids': automatic_capability_root_ids, + 'automatic_capability_effective_ids': ( + automatic_capability_effective_ids + ), + } + external_capability_ids = { + str(capability_id or '').strip() + for capability_id in proposal.get('_external_capability_ids') or [] + if str(capability_id or '').strip() + } + if not external_capability_ids: + external_capability_ids = { + str(entry.get('id') or '').strip() + for entry in ( + refreshed_inventory.get('capabilities') + if isinstance(refreshed_inventory, Mapping) + else [] + ) or [] + if isinstance(entry, Mapping) + and entry.get('external_data') is True + and str(entry.get('id') or '').strip() + } + + def filtered_ids(values): + if values is None: + return None + return [ + capability_id + for capability_id in values + if str(capability_id or '').strip() not in external_capability_ids + ] + + return { + 'selected_capability_ids': filtered_ids(selected_capability_ids), + 'prior_effective_capabilities': [ + item + for item in prior_effective_capabilities or [] + if not ( + isinstance(item, Mapping) + and str(item.get('id') or '').strip() + in external_capability_ids + ) + ], + 'automatic_capability_root_ids': filtered_ids( + automatic_capability_root_ids + ), + 'automatic_capability_effective_ids': filtered_ids( + automatic_capability_effective_ids + ), + } + + def revalidate_capability_execution_compatibility( proposal, *, diff --git a/application/single_app/functions_chat_capability_persistence.py b/application/single_app/functions_chat_capability_persistence.py index a5428717..c4f47471 100644 --- a/application/single_app/functions_chat_capability_persistence.py +++ b/application/single_app/functions_chat_capability_persistence.py @@ -10,6 +10,7 @@ from functions_chat_capability_choices import ( CapabilityChoiceError, apply_capability_choice_decision, + build_decline_aware_execution_baseline, capability_choice_is_expired, claim_capability_choice_resume, complete_capability_choice_resume, @@ -114,6 +115,7 @@ def persist_capability_decision( automatic_capability_effective_ids=None, selected_agent_present=False, baseline_error_code=None, + source_lineage_validator=None, now=None, ): """Persist one allowlisted decision using optimistic concurrency.""" @@ -147,18 +149,31 @@ def persist_capability_decision( now=now, ) try: - revalidate_capability_execution_baseline( + if callable(source_lineage_validator): + source_lineage_validator(updated) + validation_baseline = build_decline_aware_execution_baseline( + updated, refreshed_inventory, selected_capability_ids=selected_capability_ids, prior_effective_capabilities=prior_effective_capabilities, automatic_capability_root_ids=automatic_capability_root_ids, - automatic_capability_effective_ids=automatic_capability_effective_ids, + automatic_capability_effective_ids=( + automatic_capability_effective_ids + ), + ) + revalidate_capability_execution_baseline( + refreshed_inventory, + **validation_baseline, baseline_error_code=baseline_error_code, ) revalidate_capability_execution_compatibility( updated, - selected_capability_ids=selected_capability_ids, - prior_effective_capabilities=prior_effective_capabilities, + selected_capability_ids=validation_baseline[ + 'selected_capability_ids' + ], + prior_effective_capabilities=validation_baseline[ + 'prior_effective_capabilities' + ], selected_agent_present=selected_agent_present, ) revalidate_capability_choice(updated, refreshed_inventory) @@ -199,6 +214,7 @@ def persist_capability_resume_claim( automatic_capability_effective_ids=None, selected_agent_present=False, baseline_error_code=None, + source_lineage_validator=None, now=None, execution_id=None, child_run_id=None, @@ -229,18 +245,31 @@ def persist_capability_resume_claim( code='proposal_expired', ) try: - revalidate_capability_execution_baseline( + if callable(source_lineage_validator): + source_lineage_validator(proposal) + validation_baseline = build_decline_aware_execution_baseline( + proposal, refreshed_inventory, selected_capability_ids=selected_capability_ids, prior_effective_capabilities=prior_effective_capabilities, automatic_capability_root_ids=automatic_capability_root_ids, - automatic_capability_effective_ids=automatic_capability_effective_ids, + automatic_capability_effective_ids=( + automatic_capability_effective_ids + ), + ) + revalidate_capability_execution_baseline( + refreshed_inventory, + **validation_baseline, baseline_error_code=baseline_error_code, ) revalidate_capability_execution_compatibility( proposal, - selected_capability_ids=selected_capability_ids, - prior_effective_capabilities=prior_effective_capabilities, + selected_capability_ids=validation_baseline[ + 'selected_capability_ids' + ], + prior_effective_capabilities=validation_baseline[ + 'prior_effective_capabilities' + ], selected_agent_present=selected_agent_present, ) revalidate_capability_choice(proposal, refreshed_inventory) diff --git a/application/single_app/functions_chat_capability_planner.py b/application/single_app/functions_chat_capability_planner.py index 98ef6a5b..bb198125 100644 --- a/application/single_app/functions_chat_capability_planner.py +++ b/application/single_app/functions_chat_capability_planner.py @@ -7,10 +7,19 @@ from collections.abc import Mapping -CAPABILITY_PLANNER_CONTRACT_VERSION = 1 +CAPABILITY_PLANNER_CONTRACT_VERSION = 2 CAPABILITY_PLANNER_MODE = 'capability_planning' CAPABILITY_PLANNER_DECISIONS = frozenset({'direct', 'propose', 'clarify'}) CAPABILITY_PLANNER_CONFIDENCE_CLASSES = frozenset({'low', 'medium', 'high'}) +CAPABILITY_PLANNER_CLARIFICATION_CODES = frozenset({ + 'ambiguous_reference', + 'document_targets_required', + 'jurisdiction_required', + 'output_format_required', + 'source_scope_required', + 'target_entity_required', + 'time_range_required', +}) CAPABILITY_PLANNER_REASON_CODES = frozenset({ 'fresh_public_information', 'public_source_retrieval', @@ -33,6 +42,11 @@ MAX_PLANNER_REQUIREMENTS = 8 MAX_EVIDENCE_TYPES_PER_REQUIREMENT = 8 MAX_USER_REQUEST_CHARS = 16000 +MAX_DIALOGUE_CONTEXT_TURNS = 3 +MAX_PRIOR_DIALOGUE_TURNS = MAX_DIALOGUE_CONTEXT_TURNS - 1 +MAX_DIALOGUE_TURN_CHARS = 8000 +MAX_CLARIFICATION_OPTIONS = 6 +MAX_CLARIFICATION_OPTION_CHARS = 120 MAX_AVAILABLE_CAPABILITIES = 64 DEFAULT_PLANNER_TIMEOUT_MS = 10000 MAX_PLANNER_TIMEOUT_MS = 20000 @@ -56,27 +70,34 @@ 'For broad, exhaustive, or multi-year public archive collection, prefer Deep ' 'Research and optionally offer Web Search as a faster alternative. For one named ' 'or narrowly scoped current public item, prefer Web Search. ' - 'Prefer direct for simple timeless requests. Use additive plans only when sources are ' - 'complementary. Use clarify only when interpretations materially change the required ' - 'plan. Requirement IDs must use requirement_1, requirement_2, and so on. Candidate ' + 'The dialogue context contains only bounded user-authored turns with request-local ' + 'turn references. Select only supplied turn references and never invent message IDs. ' + 'The current turn must be selected unless the supplied structured clarification state ' + 'explicitly links the goal to an earlier turn. Prior-turn selection describes intent ' + 'only and never grants external-data approval. Prefer direct for simple timeless ' + 'requests. Use additive plans only when sources are complementary. Use clarify only ' + 'when ambiguity materially changes sources, scope, output, risk, or required input. ' + 'For clarify, choose one supplied clarification code and only option values explicitly ' + 'listed for that code. Requirement IDs must use requirement_1, requirement_2, and so on. Candidate ' 'plan IDs must use candidate_1, candidate_2, and so on. Candidate capability IDs ' 'must contain only unselected additions; never repeat selected mandates in a ' 'candidate. Copy capability IDs and evidence types exactly from the provided ' - 'inventory. For direct, return empty ' - 'requirements and candidate_plans with both nullable fields set to null. For clarify, ' - 'return no candidate plans, a null recommended_plan_id, and material_ambiguity as the ' - 'clarification_code. For propose, return at least one candidate plan, recommend one ' - 'returned candidate ID, and set clarification_code to null. Return the exact JSON ' + 'inventory. For direct, return empty requirements and candidate_plans, a null ' + 'recommended_plan_id, and a null clarification. For clarify, return no candidate ' + 'plans, a null recommended_plan_id, and one clarification object. For propose, return ' + 'at least one candidate plan, recommend one returned candidate ID, and set ' + 'clarification to null. Return the exact JSON ' 'schema with no prose, markup, or private reasoning.' ) _PLANNER_RESULT_FIELDS = frozenset({ 'version', 'decision', + 'goal_turn_refs', 'requirements', 'candidate_plans', 'recommended_plan_id', - 'clarification_code', + 'clarification', }) _PLANNER_REQUIREMENT_FIELDS = frozenset({ 'id', @@ -91,6 +112,7 @@ }) _REQUIREMENT_ID_PATTERN = re.compile(r'^requirement_[1-8]$') _CANDIDATE_ID_PATTERN = re.compile(r'^candidate_[1-6]$') +_TURN_REF_PATTERN = re.compile(r'^turn_[0-2]$') _SAFE_IDENTIFIER_PATTERN = re.compile(r'^[a-z0-9][a-z0-9_:-]{0,255}$') _SAFE_BUILTIN_CAPABILITY_CLASSES = frozenset({ 'analyze', @@ -112,7 +134,9 @@ 'invalid_candidate_plan', 'invalid_candidate_plans', 'invalid_capability_ids', + 'invalid_clarification', 'invalid_clarification_code', + 'invalid_clarification_options', 'invalid_decision_shape', 'invalid_evidence_types', 'invalid_json', @@ -122,6 +146,7 @@ 'invalid_response', 'invalid_result', 'invalid_result_type', + 'invalid_goal_turn_refs', 'ineligible_capability', 'model_unavailable', 'missing_field', @@ -134,13 +159,28 @@ 'transport_timeout', 'transport_unsupported', 'unknown_capability', + 'unknown_clarification_option', 'unknown_confidence', 'unknown_decision', 'unknown_evidence_type', 'unknown_field', + 'unknown_goal_turn_ref', 'unknown_reason_code', 'unknown_recommended_plan', 'unsupported_version', + 'current_goal_turn_required', + 'clarification_budget_exhausted', +}) +CAPABILITY_PLANNER_TRANSPORT_ERROR_CLASSES = frozenset({ + 'connection_error', + 'http_400_max_completion_tokens', + 'http_400_reasoning_effort', + 'http_400_response_format', + 'http_400_temperature', + 'http_4xx', + 'http_5xx', + 'other', + 'type_error', }) @@ -172,6 +212,85 @@ def _safe_identifier_list(values, *, max_items): return normalized +def _normalize_dialogue_context(user_request, prior_user_turns): + current_text = str(user_request or '').strip()[:MAX_USER_REQUEST_CHARS] + prior_texts = [] + raw_prior_turns = ( + prior_user_turns + if isinstance(prior_user_turns, list) + else [] + ) + for raw_turn in raw_prior_turns: + if isinstance(raw_turn, Mapping): + if str(raw_turn.get('role') or 'user').strip().lower() != 'user': + continue + text = str(raw_turn.get('text') or '').strip() + else: + text = str(raw_turn or '').strip() + if text: + prior_texts.append(text[:MAX_DIALOGUE_TURN_CHARS]) + dialogue_texts = prior_texts[-MAX_PRIOR_DIALOGUE_TURNS:] + [ + current_text[:MAX_DIALOGUE_TURN_CHARS] + ] + return [ + { + 'ref': f'turn_{index}', + 'role': 'user', + 'text': text, + } + for index, text in enumerate(dialogue_texts) + ] + + +def _normalize_structured_state(structured_state, available_refs): + if not isinstance(structured_state, Mapping): + return None + state_type = str(structured_state.get('type') or '').strip().lower() + status = str(structured_state.get('status') or '').strip().lower() + source_goal_ref = str( + structured_state.get('source_goal_ref') or '' + ).strip().lower() + code = str(structured_state.get('code') or '').strip().lower() + if ( + state_type not in {'capability_offer', 'clarification'} + or status not in {'unresolved', 'resolved'} + or source_goal_ref not in available_refs + ): + return None + normalized = { + 'type': state_type, + 'source_goal_ref': source_goal_ref, + 'status': status, + } + if state_type == 'clarification': + if code not in CAPABILITY_PLANNER_CLARIFICATION_CODES: + return None + normalized['code'] = code + return normalized + + +def _normalize_clarification_option_candidates(option_candidates): + if not isinstance(option_candidates, Mapping): + return {} + normalized = {} + for raw_code, raw_values in option_candidates.items(): + code = str(raw_code or '').strip().lower() + if code not in CAPABILITY_PLANNER_CLARIFICATION_CODES: + continue + values = [] + for raw_value in raw_values if isinstance(raw_values, list) else []: + value = ' '.join(str(raw_value or '').split())[ + :MAX_CLARIFICATION_OPTION_CHARS + ] + if value and value not in values: + values.append(value) + if len(values) >= MAX_CLARIFICATION_OPTIONS: + break + if values: + normalized[code] = values + return normalized + + def _project_capability(entry, *, kind): if not isinstance(entry, Mapping): return None @@ -224,6 +343,10 @@ def build_capability_planner_request( max_candidate_plans=DEFAULT_MAX_CANDIDATE_PLANS, max_capabilities_per_plan=DEFAULT_MAX_CAPABILITIES_PER_PLAN, additional_selected_mandate_ids=None, + prior_user_turns=None, + structured_state=None, + clarification_option_candidates=None, + clarification_budget_remaining=1, ): """Project one server-authorized inventory into the model-safe contract.""" inventory = capability_inventory if isinstance(capability_inventory, Mapping) else {} @@ -260,10 +383,30 @@ def build_capability_planner_request( if normalized_id and normalized_id not in selected_mandate_ids: selected_mandates.append({'id': normalized_id, 'required': True}) selected_mandate_ids.add(normalized_id) + user_request_text = str(user_request or '').strip()[:MAX_USER_REQUEST_CHARS] + dialogue_context = _normalize_dialogue_context( + user_request_text, + prior_user_turns, + ) + dialogue_refs = { + turn['ref'] + for turn in dialogue_context + } + normalized_structured_state = _normalize_structured_state( + structured_state, + dialogue_refs, + ) + normalized_clarification_candidates = ( + _normalize_clarification_option_candidates( + clarification_option_candidates + ) + ) return { 'version': CAPABILITY_PLANNER_CONTRACT_VERSION, 'mode': CAPABILITY_PLANNER_MODE, - 'user_request': str(user_request or '').strip()[:MAX_USER_REQUEST_CHARS], + 'user_request': user_request_text, + 'dialogue_context': dialogue_context, + 'structured_state': normalized_structured_state, 'selected_mandates': selected_mandates, 'available_capabilities': available_capabilities, 'policy': { @@ -282,6 +425,21 @@ def build_capability_planner_request( 'selected_capabilities_are_required': True, 'planner_may_execute': False, 'planner_may_grant_access': False, + 'max_goal_turn_refs': MAX_DIALOGUE_CONTEXT_TURNS, + 'current_turn_ref': dialogue_context[-1]['ref'], + 'prior_turn_egress_requires_choice': True, + 'clarification_budget_remaining': _bounded_int( + clarification_budget_remaining, + default=1, + minimum=0, + maximum=1, + ), + 'clarification_codes': sorted( + CAPABILITY_PLANNER_CLARIFICATION_CODES + ), + 'clarification_option_candidates': ( + normalized_clarification_candidates + ), }, } @@ -302,13 +460,28 @@ def capability_planner_is_eligible( or cancel_requested ): return False - return any( + unselected_discovery_available = any( isinstance(capability, Mapping) and capability.get('state') == 'unselected' and capability.get('discoverable') is True and capability.get('input_ready') is True for capability in request.get('available_capabilities') or [] ) + contextual_selected_external_available = ( + len(request.get('dialogue_context') or []) > 1 + and any( + isinstance(capability, Mapping) + and capability.get('state') == 'selected' + and capability.get('external_data') is True + and capability.get('read_only') is True + and capability.get('input_ready') is True + for capability in request.get('available_capabilities') or [] + ) + ) + return bool( + unselected_discovery_available + or contextual_selected_external_available + ) def capability_planner_shadow_is_eligible( @@ -479,6 +652,99 @@ def _normalize_candidates( return normalized, candidate_id_aliases, None +def _normalize_goal_turn_refs(goal_turn_refs, planner_request): + if not isinstance(goal_turn_refs, list) or not goal_turn_refs: + return None, 'invalid_goal_turn_refs' + request = planner_request if isinstance(planner_request, Mapping) else {} + dialogue_context = [ + turn + for turn in request.get('dialogue_context') or [] + if isinstance(turn, Mapping) + and turn.get('role') == 'user' + and _TURN_REF_PATTERN.fullmatch(str(turn.get('ref') or '')) + ] + request_order = [str(turn.get('ref')) for turn in dialogue_context] + allowed_refs = set(request_order) + max_refs = min(MAX_DIALOGUE_CONTEXT_TURNS, len(request_order)) + if len(goal_turn_refs) > max_refs: + return None, 'invalid_goal_turn_refs' + selected_refs = [] + for raw_ref in goal_turn_refs: + turn_ref = str(raw_ref or '').strip().lower() + if not _TURN_REF_PATTERN.fullmatch(turn_ref): + return None, 'invalid_goal_turn_refs' + if turn_ref not in allowed_refs: + return None, 'unknown_goal_turn_ref' + if turn_ref in selected_refs: + return None, 'invalid_goal_turn_refs' + selected_refs.append(turn_ref) + policy = request.get('policy') if isinstance(request.get('policy'), Mapping) else {} + current_turn_ref = str(policy.get('current_turn_ref') or '').strip().lower() + structured_state = ( + request.get('structured_state') + if isinstance(request.get('structured_state'), Mapping) + else {} + ) + clarification_linked = ( + structured_state.get('type') == 'clarification' + and structured_state.get('source_goal_ref') in selected_refs + ) + if current_turn_ref not in selected_refs and not clarification_linked: + return None, 'current_goal_turn_required' + selected_set = set(selected_refs) + return [ref for ref in request_order if ref in selected_set], None + + +def _normalize_clarification(clarification, planner_request): + if clarification is None: + return None, None + if not isinstance(clarification, Mapping): + return None, 'invalid_clarification' + if set(clarification) != {'code', 'option_values'}: + return None, 'invalid_clarification' + request = planner_request if isinstance(planner_request, Mapping) else {} + policy = request.get('policy') if isinstance(request.get('policy'), Mapping) else {} + if _bounded_int( + policy.get('clarification_budget_remaining'), + default=0, + minimum=0, + maximum=1, + ) < 1: + return None, 'clarification_budget_exhausted' + allowed_codes = { + str(code or '').strip().lower() + for code in policy.get('clarification_codes') or [] + if str(code or '').strip().lower() + in CAPABILITY_PLANNER_CLARIFICATION_CODES + } + code = str(clarification.get('code') or '').strip().lower() + if code not in allowed_codes: + return None, 'invalid_clarification_code' + option_values = clarification.get('option_values') + if not isinstance(option_values, list) or len(option_values) > MAX_CLARIFICATION_OPTIONS: + return None, 'invalid_clarification_options' + candidates_by_code = ( + policy.get('clarification_option_candidates') + if isinstance(policy.get('clarification_option_candidates'), Mapping) + else {} + ) + allowed_values = candidates_by_code.get(code) or [] + normalized_values = [] + for raw_value in option_values: + value = ' '.join(str(raw_value or '').split())[ + :MAX_CLARIFICATION_OPTION_CHARS + ] + if not value or value not in allowed_values: + return None, 'unknown_clarification_option' + if value in normalized_values: + return None, 'invalid_clarification_options' + normalized_values.append(value) + return { + 'code': code, + 'option_values': normalized_values, + }, None + + def validate_capability_planner_result(raw_result, planner_request): """Validate untrusted planner JSON against the exact request inventory.""" parsed, failure_code = _parse_result(raw_result) @@ -496,6 +762,12 @@ def validate_capability_planner_result(raw_result, planner_request): if decision not in CAPABILITY_PLANNER_DECISIONS: return _rejected_result('unknown_decision') request = planner_request if isinstance(planner_request, Mapping) else {} + goal_turn_refs, failure_code = _normalize_goal_turn_refs( + parsed.get('goal_turn_refs'), + request, + ) + if failure_code: + return _rejected_result(failure_code) policy = request.get('policy') if isinstance(request.get('policy'), Mapping) else {} max_candidates = _bounded_int( policy.get('max_candidate_plans'), @@ -552,12 +824,15 @@ def validate_capability_planner_result(raw_result, planner_request): recommended_plan_id = parsed.get('recommended_plan_id') if recommended_plan_id is not None: recommended_plan_id = str(recommended_plan_id).strip().lower() - clarification_code = parsed.get('clarification_code') - if clarification_code is not None: - clarification_code = str(clarification_code).strip().lower() + clarification, failure_code = _normalize_clarification( + parsed.get('clarification'), + request, + ) + if failure_code: + return _rejected_result(failure_code) if decision == 'propose': - if not candidates or clarification_code is not None: + if not candidates or clarification is not None: return _rejected_result('invalid_decision_shape') if recommended_plan_id not in candidate_aliases: return _rejected_result('unknown_recommended_plan') @@ -565,20 +840,33 @@ def validate_capability_planner_result(raw_result, planner_request): elif decision == 'clarify': if candidates or recommended_plan_id is not None: return _rejected_result('invalid_decision_shape') - if clarification_code != 'material_ambiguity': - return _rejected_result('invalid_clarification_code') + if clarification is None: + return _rejected_result('invalid_decision_shape') else: - if candidates or recommended_plan_id is not None or clarification_code is not None: + if candidates or recommended_plan_id is not None or clarification is not None: return _rejected_result('invalid_decision_shape') + policy = request.get('policy') if isinstance(request.get('policy'), Mapping) else {} + current_turn_ref = str(policy.get('current_turn_ref') or '').strip().lower() + return { 'version': CAPABILITY_PLANNER_CONTRACT_VERSION, 'status': 'valid', 'decision': decision, + 'goal_turn_refs': goal_turn_refs, + 'eligible_goal_turn_count': min( + len(request.get('dialogue_context') or []), + MAX_DIALOGUE_CONTEXT_TURNS, + ), + 'selected_goal_turn_count': len(goal_turn_refs), + 'prior_goal_included': any( + turn_ref != current_turn_ref + for turn_ref in goal_turn_refs + ), 'requirements': requirements, 'candidate_plans': candidates, 'recommended_plan_id': recommended_plan_id, - 'clarification_code': clarification_code, + 'clarification': clarification, 'fallback_used': False, } @@ -680,6 +968,48 @@ def _planner_result_json_schema(planner_request=None): if evidence_types else identifier_schema ) + dialogue_refs = [ + str(turn.get('ref') or '').strip() + for turn in request.get('dialogue_context') or [] + if isinstance(turn, Mapping) + and turn.get('role') == 'user' + and _TURN_REF_PATTERN.fullmatch(str(turn.get('ref') or '').strip()) + ] + clarification_budget_remaining = _bounded_int( + policy.get('clarification_budget_remaining'), + default=0, + minimum=0, + maximum=1, + ) + clarification_codes = sorted({ + str(code or '').strip().lower() + for code in policy.get('clarification_codes') or [] + if str(code or '').strip().lower() + in CAPABILITY_PLANNER_CLARIFICATION_CODES + }) + clarification_candidates = ( + policy.get('clarification_option_candidates') + if isinstance(policy.get('clarification_option_candidates'), Mapping) + else {} + ) + clarification_option_values = sorted({ + str(value) + for code in clarification_codes + for value in clarification_candidates.get(code) or [] + if str(value) + }) + clarification_option_schema = ( + {'type': 'string', 'enum': clarification_option_values} + if clarification_option_values + else identifier_schema + ) + allowed_decisions = set( + CAPABILITY_PLANNER_DECISIONS + if proposal_is_available + else CAPABILITY_PLANNER_DECISIONS - {'propose'} + ) + if not clarification_budget_remaining or not clarification_codes: + allowed_decisions.discard('clarify') return { 'name': 'chat_capability_planner_result', 'strict': True, @@ -690,10 +1020,20 @@ def _planner_result_json_schema(planner_request=None): 'version': {'type': 'integer', 'enum': [CAPABILITY_PLANNER_CONTRACT_VERSION]}, 'decision': { 'type': 'string', - 'enum': sorted( - CAPABILITY_PLANNER_DECISIONS - if proposal_is_available - else CAPABILITY_PLANNER_DECISIONS - {'propose'} + 'enum': sorted(allowed_decisions), + }, + 'goal_turn_refs': { + 'type': 'array', + 'minItems': 1, + 'maxItems': min( + MAX_DIALOGUE_CONTEXT_TURNS, + max(1, len(dialogue_refs)), + ), + 'uniqueItems': True, + 'items': ( + {'type': 'string', 'enum': dialogue_refs} + if dialogue_refs + else identifier_schema ), }, 'requirements': { @@ -755,9 +1095,29 @@ def _planner_result_json_schema(planner_request=None): else [{'type': 'null'}] ), }, - 'clarification_code': { + 'clarification': { 'anyOf': [ - {'type': 'string', 'enum': ['material_ambiguity']}, + { + 'type': 'object', + 'additionalProperties': False, + 'properties': { + 'code': { + 'type': 'string', + 'enum': clarification_codes, + }, + 'option_values': { + 'type': 'array', + 'maxItems': ( + MAX_CLARIFICATION_OPTIONS + if clarification_option_values + else 0 + ), + 'uniqueItems': True, + 'items': clarification_option_schema, + }, + }, + 'required': ['code', 'option_values'], + }, {'type': 'null'}, ], }, @@ -765,10 +1125,11 @@ def _planner_result_json_schema(planner_request=None): 'required': [ 'version', 'decision', + 'goal_turn_refs', 'requirements', 'candidate_plans', 'recommended_plan_id', - 'clarification_code', + 'clarification', ], }, } @@ -802,27 +1163,33 @@ def _model_request_variants( return [ { 'response_format': schema_format, - 'temperature': 0, 'max_completion_tokens': token_limit, + 'reasoning_effort': 'minimal', }, { 'response_format': schema_format, 'max_completion_tokens': token_limit, }, { - 'response_format': schema_format, - 'max_tokens': token_limit, + 'response_format': {'type': 'json_object'}, + 'max_completion_tokens': token_limit, + 'reasoning_effort': 'minimal', }, { 'response_format': {'type': 'json_object'}, 'max_completion_tokens': token_limit, }, + { + 'max_completion_tokens': token_limit, + 'reasoning_effort': 'minimal', + }, {'max_completion_tokens': token_limit}, - {'max_tokens': token_limit}, ] def _optional_parameter_is_unsupported(exc, request_variant): + if getattr(exc, 'status_code', None) != 400: + return False error_text = str(exc or '').strip().lower() field_markers = { 'max_tokens': ('max_tokens', 'max tokens'), @@ -837,6 +1204,10 @@ def _optional_parameter_is_unsupported(exc, request_variant): 'json schema', ), 'temperature': ('temperature',), + 'reasoning_effort': ( + 'reasoning_effort', + 'reasoning effort', + ), } optional_fields = { field_name @@ -860,6 +1231,48 @@ def _optional_parameter_is_unsupported(exc, request_variant): )) +def _transport_error_class(exc, request_variant): + """Return a bounded diagnostic class without provider text or request data.""" + error_text = str(exc or '').strip().lower() + status_code = getattr(exc, 'status_code', None) + for field_name in ( + 'response_format', + 'reasoning_effort', + 'max_completion_tokens', + 'temperature', + ): + if field_name not in request_variant: + continue + markers = (field_name, field_name.replace('_', ' ')) + if status_code == 400 and any(marker in error_text for marker in markers): + return f'http_400_{field_name}' + if isinstance(status_code, int): + if 400 <= status_code < 500: + return 'http_4xx' + if status_code >= 500: + return 'http_5xx' + error_name = type(exc).__name__.lower() + if isinstance(exc, TypeError): + return 'type_error' + if 'connection' in error_name or 'connection' in error_text: + return 'connection_error' + return 'other' + + +def _request_variant_can_fallback(exc, request_variant): + """Return whether a fast 400 can safely retry with fewer optional fields.""" + error_class = _transport_error_class(exc, request_variant) + if error_class == 'http_400_max_completion_tokens': + return False + if _optional_parameter_is_unsupported(exc, request_variant): + return True + return error_class in { + 'http_400_reasoning_effort', + 'http_400_response_format', + 'http_400_temperature', + } + + def _is_timeout_error(exc): error_name = type(exc).__name__.lower() error_text = str(exc or '').strip().lower() @@ -907,15 +1320,30 @@ def _extract_model_response(response): return (content, None) if content else ('', 'empty_response') -def _invocation_failure(status, failure_code, *, started_at): +def _invocation_failure( + status, + failure_code, + *, + started_at, + transport_error_class=None, + transport_variant_index=None, +): latency_ms = max(0, round((time.perf_counter() - started_at) * 1000)) - return { + failure = { 'version': CAPABILITY_PLANNER_CONTRACT_VERSION, 'status': status, 'failure_code': failure_code, 'latency_ms': min(latency_ms, MAX_PLANNER_TIMEOUT_MS), 'fallback_used': True, } + if transport_error_class in CAPABILITY_PLANNER_TRANSPORT_ERROR_CLASSES: + failure['transport_error_class'] = transport_error_class + if isinstance(transport_variant_index, int): + failure['transport_variant_index'] = max( + 0, + min(transport_variant_index, 8), + ) + return failure def _planner_transport_client(planner_client, runtime_protocol, timeout_seconds): @@ -1020,10 +1448,19 @@ def invoke_capability_planner( ) if ( variant_index + 1 < len(variants) - and _optional_parameter_is_unsupported(exc, request_variant) + and _request_variant_can_fallback(exc, request_variant) ): continue - return _invocation_failure('rejected', 'client_error', started_at=started_at) + return _invocation_failure( + 'rejected', + 'client_error', + started_at=started_at, + transport_error_class=_transport_error_class( + exc, + request_variant, + ), + transport_variant_index=variant_index, + ) if callable(cancel_requested) and cancel_requested(): return _invocation_failure('discarded', 'cancelled', started_at=started_at) @@ -1103,6 +1540,31 @@ def build_capability_planner_metadata(planner_result, *, mode='shadow'): decision = str(result.get('decision') or '').strip().lower() if status == 'valid' and decision in CAPABILITY_PLANNER_DECISIONS: metadata['decision'] = decision + metadata['eligible_goal_turn_count'] = _bounded_int( + result.get('eligible_goal_turn_count'), + default=1, + minimum=1, + maximum=MAX_DIALOGUE_CONTEXT_TURNS, + ) + metadata['selected_goal_turn_count'] = _bounded_int( + result.get('selected_goal_turn_count'), + default=1, + minimum=1, + maximum=MAX_DIALOGUE_CONTEXT_TURNS, + ) + metadata['prior_goal_included'] = bool( + result.get('prior_goal_included') + ) + clarification = ( + result.get('clarification') + if isinstance(result.get('clarification'), Mapping) + else {} + ) + clarification_code = str( + clarification.get('code') or '' + ).strip().lower() + if clarification_code in CAPABILITY_PLANNER_CLARIFICATION_CODES: + metadata['clarification_code'] = clarification_code recommended_plan_id = str(result.get('recommended_plan_id') or '').strip() recommended_plan = next( @@ -1133,6 +1595,18 @@ def build_capability_planner_metadata(planner_result, *, mode='shadow'): failure_code = str(result.get('failure_code') or '').strip().lower() if status != 'valid' and failure_code in CAPABILITY_PLANNER_FAILURE_CODES: metadata['failure_code'] = failure_code + transport_error_class = str( + result.get('transport_error_class') or '' + ).strip().lower() + if transport_error_class in CAPABILITY_PLANNER_TRANSPORT_ERROR_CLASSES: + metadata['transport_error_class'] = transport_error_class + if isinstance(result.get('transport_variant_index'), int): + metadata['transport_variant_index'] = _bounded_int( + result.get('transport_variant_index'), + default=0, + minimum=0, + maximum=8, + ) return metadata diff --git a/application/single_app/functions_chat_clarifications.py b/application/single_app/functions_chat_clarifications.py new file mode 100644 index 00000000..73cab06b --- /dev/null +++ b/application/single_app/functions_chat_clarifications.py @@ -0,0 +1,1064 @@ +# functions_chat_clarifications.py +"""Durable server-authored chat clarification checkpoints.""" + +import copy +import hashlib +import hmac +import re +import uuid +from collections.abc import Mapping +from datetime import datetime, timedelta, timezone + +from azure.core import MatchConditions + +from functions_chat_capability_choices import CapabilityChoiceError + + +CHAT_CLARIFICATION_VERSION = 1 +DEFAULT_CHAT_CLARIFICATION_TTL_SECONDS = 86400 +MAX_CHAT_CLARIFICATION_TTL_SECONDS = 604800 +MAX_CHAT_CLARIFICATION_OPTIONS = 6 +MAX_CHAT_CLARIFICATION_OPTION_CHARS = 120 +MAX_CONDITIONAL_WRITE_ATTEMPTS = 3 +CHAT_CLARIFICATION_RESPONSE_LEASE_SECONDS = 1800 +CHAT_CLARIFICATION_CODES = frozenset({ + 'ambiguous_reference', + 'document_targets_required', + 'jurisdiction_required', + 'output_format_required', + 'source_scope_required', + 'target_entity_required', + 'time_range_required', +}) +CHAT_CLARIFICATION_QUESTIONS = { + 'ambiguous_reference': 'What does the referenced item mean in this request?', + 'document_targets_required': 'Which documents should I use?', + 'jurisdiction_required': 'Which jurisdiction applies?', + 'output_format_required': 'What output format do you want?', + 'source_scope_required': 'Where should I look for this information?', + 'target_entity_required': 'Which person, organization, or item should I use?', + 'time_range_required': 'What time range should I use?', +} +DEFAULT_CLARIFICATION_OPTION_CANDIDATES = { + 'output_format_required': [ + 'Concise answer', + 'Detailed answer', + 'Table', + ], + 'source_scope_required': [ + 'My workspace', + 'Public web', + 'Both', + ], +} + + +class ChatClarificationError(CapabilityChoiceError): + """Raised when a durable clarification transition is invalid.""" + + +def _utc_now(): + return datetime.now(timezone.utc) + + +def _parse_timestamp(value): + normalized = str(value or '').strip() + if not normalized: + return None + try: + parsed = datetime.fromisoformat(normalized.replace('Z', '+00:00')) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _normalize_identifier(value, field_name): + normalized = str(value or '').strip() + if not normalized or len(normalized) > 200: + raise ChatClarificationError( + f'{field_name} is required', + code=f'invalid_{field_name}', + ) + return normalized + + +def _normalize_options(values): + normalized = [] + for raw_value in values if isinstance(values, list) else []: + value = ' '.join(str(raw_value or '').split())[ + :MAX_CHAT_CLARIFICATION_OPTION_CHARS + ] + if value and value not in normalized: + normalized.append(value) + if len(normalized) >= MAX_CHAT_CLARIFICATION_OPTIONS: + break + return normalized + + +def chat_clarification_response_matches(clarification, response_text): + """Return whether text matches the stored response hash.""" + if not isinstance(clarification, Mapping): + return False + normalized_response = ' '.join(str(response_text or '').split()) + stored_hash = str(clarification.get('_response_hash') or '') + if not normalized_response or not stored_hash: + return False + response_hash = hashlib.sha256( + normalized_response.encode('utf-8') + ).hexdigest() + return hmac.compare_digest(stored_hash, response_hash) + + +def validate_chat_clarification_retry( + clarification, + response_message, + *, + proposed_text, + now=None, +): + """Validate Retry/Edit before a clarification response is mutated.""" + if not ( + isinstance(clarification, Mapping) + and isinstance(response_message, Mapping) + ): + raise ChatClarificationError( + 'clarification retry state is invalid', + code='clarification_response_conflict', + ) + response_user_message_id = str( + response_message.get('id') or '' + ).strip() + if not ( + response_user_message_id + and response_message.get('role') == 'user' + and response_user_message_id + == str( + clarification.get('_response_user_message_id') or '' + ).strip() + and chat_clarification_response_matches( + clarification, + response_message.get('content'), + ) + ): + raise ChatClarificationError( + 'clarification response no longer matches its checkpoint', + code='clarification_response_conflict', + ) + status = str(clarification.get('status') or '').strip().lower() + if status == 'expired' or ( + status == 'resolving' + and clarification_is_expired(clarification, now=now) + ): + raise ChatClarificationError( + 'clarification is no longer available for retry', + code='clarification_expired', + ) + if not chat_clarification_response_matches( + clarification, + proposed_text, + ): + raise ChatClarificationError( + 'clarification retry does not match the stored response', + code='clarification_response_conflict', + ) + if status not in {'resolving', 'resolved'}: + raise ChatClarificationError( + 'clarification is not available for retry', + code='clarification_response_conflict', + ) + return { + 'mode': 'recover' if status == 'resolving' else 'replay', + 'response_user_message_id': response_user_message_id, + 'response_thread_id': str( + clarification.get('_response_thread_id') or '' + ).strip(), + 'child_run_id': str( + clarification.get('child_run_id') or '' + ).strip(), + } + + +def clarification_is_expired(clarification, *, now=None): + current_time = now or _utc_now() + if current_time.tzinfo is None: + current_time = current_time.replace(tzinfo=timezone.utc) + expires_at = _parse_timestamp( + clarification.get('expires_at') + if isinstance(clarification, Mapping) + else None + ) + return expires_at is None or current_time >= expires_at + + +def clarification_response_lease_is_active(clarification, *, now=None): + """Return whether one resolving clarification still owns a live lease.""" + if not ( + isinstance(clarification, Mapping) + and clarification.get('status') == 'resolving' + ): + return False + current_time = now or _utc_now() + if current_time.tzinfo is None: + current_time = current_time.replace(tzinfo=timezone.utc) + lease_expires_at = _parse_timestamp( + clarification.get('lease_expires_at') + ) + return bool(lease_expires_at and current_time < lease_expires_at) + + +def build_chat_clarification( + planner_clarification, + *, + parent_run_id, + conversation_id, + source_user_message_id, + source_thread_id, + assistant_message_id=None, + now=None, + ttl_seconds=DEFAULT_CHAT_CLARIFICATION_TTL_SECONDS, +): + """Build one server-authored clarification checkpoint from validated planner data.""" + if not isinstance(planner_clarification, Mapping): + raise ChatClarificationError( + 'planner clarification is required', + code='clarification_missing', + ) + code = str(planner_clarification.get('code') or '').strip().lower() + if code not in CHAT_CLARIFICATION_CODES: + raise ChatClarificationError( + 'clarification code is not supported', + code='invalid_clarification_code', + ) + options = _normalize_options(planner_clarification.get('option_values')) + current_time = now or _utc_now() + if current_time.tzinfo is None: + current_time = current_time.replace(tzinfo=timezone.utc) + try: + normalized_ttl = int(ttl_seconds) + except (TypeError, ValueError): + normalized_ttl = DEFAULT_CHAT_CLARIFICATION_TTL_SECONDS + normalized_ttl = max(60, min(normalized_ttl, MAX_CHAT_CLARIFICATION_TTL_SECONDS)) + clarification_id = str(assistant_message_id or uuid.uuid4()) + return { + 'version': CHAT_CLARIFICATION_VERSION, + 'clarification_id': clarification_id, + 'parent_run_id': _normalize_identifier(parent_run_id, 'parent_run_id'), + 'code': code, + 'question': CHAT_CLARIFICATION_QUESTIONS[code], + 'status': 'pending', + 'options': options, + 'clarification_budget_used': 1, + 'created_at': current_time.isoformat(), + 'expires_at': ( + current_time + timedelta(seconds=normalized_ttl) + ).isoformat(), + 'resolved_at': None, + 'response_mode': None, + 'child_run_id': None, + 'claimed_at': None, + 'lease_expires_at': None, + '_conversation_id': _normalize_identifier( + conversation_id, + 'conversation_id', + ), + '_source_user_message_id': _normalize_identifier( + source_user_message_id, + 'source_user_message_id', + ), + '_source_thread_id': _normalize_identifier( + source_thread_id, + 'source_thread_id', + ), + '_response_user_message_id': None, + '_response_thread_id': None, + '_response_hash': None, + } + + +def claim_chat_clarification_response( + clarification, + *, + response_user_message_id, + response_text, + child_run_id, + response_thread_id=None, + now=None, +): + """Claim one clarification response before planning begins.""" + if not isinstance(clarification, Mapping): + raise ChatClarificationError( + 'clarification metadata is invalid', + code='clarification_invalid', + ) + updated = copy.deepcopy(dict(clarification)) + response_user_message_id = _normalize_identifier( + response_user_message_id, + 'response_user_message_id', + ) + child_run_id = _normalize_identifier(child_run_id, 'child_run_id') + response_thread_id = _normalize_identifier( + response_thread_id or str(uuid.uuid4()), + 'response_thread_id', + ) + normalized_response = ' '.join(str(response_text or '').split()) + if not normalized_response: + raise ChatClarificationError( + 'clarification response is required', + code='clarification_response_missing', + ) + response_hash = hashlib.sha256( + normalized_response.encode('utf-8') + ).hexdigest() + status = str(updated.get('status') or '').strip().lower() + if status == 'resolved': + if ( + updated.get('_response_user_message_id') + == response_user_message_id + and chat_clarification_response_matches( + updated, + normalized_response, + ) + ): + return updated, True + raise ChatClarificationError( + 'clarification already has a different response', + code='clarification_response_conflict', + ) + current_time = now or _utc_now() + if current_time.tzinfo is None: + current_time = current_time.replace(tzinfo=timezone.utc) + if status == 'resolving': + stored_response_user_message_id = str( + updated.get('_response_user_message_id') or '' + ).strip() + if not chat_clarification_response_matches( + updated, + normalized_response, + ): + raise ChatClarificationError( + 'clarification already has a different response', + code='clarification_response_conflict', + ) + lease_expires_at = _parse_timestamp(updated.get('lease_expires_at')) + if lease_expires_at and current_time < lease_expires_at: + raise ChatClarificationError( + 'clarification response is already being processed', + code='clarification_response_in_progress', + ) + if stored_response_user_message_id != response_user_message_id: + raise ChatClarificationError( + 'clarification retry does not match the claimed response', + code='clarification_response_conflict', + ) + child_run_id = str(updated.get('child_run_id') or child_run_id) + response_thread_id = str( + updated.get('_response_thread_id') + or response_thread_id + ) + elif status != 'pending': + raise ChatClarificationError( + 'clarification is no longer pending', + code=f'clarification_{status or "invalid"}', + ) + if clarification_is_expired(updated, now=current_time): + raise ChatClarificationError( + 'clarification has expired', + code='clarification_expired', + ) + options = updated.get('options') if isinstance(updated.get('options'), list) else [] + updated['status'] = 'resolving' + updated['response_mode'] = ( + 'option' + if normalized_response in options + else 'free_text' + ) + updated['child_run_id'] = child_run_id + updated['claimed_at'] = current_time.isoformat() + updated['lease_expires_at'] = ( + current_time + timedelta( + seconds=CHAT_CLARIFICATION_RESPONSE_LEASE_SECONDS + ) + ).isoformat() + updated['_response_user_message_id'] = response_user_message_id + updated['_response_thread_id'] = response_thread_id + updated['_response_hash'] = response_hash + return updated, False + + +def complete_chat_clarification_response( + clarification, + *, + response_user_message_id, + child_run_id, + now=None, +): + """Complete the exact claimed response after its user turn is persisted.""" + updated = copy.deepcopy(dict(clarification or {})) + response_user_message_id = _normalize_identifier( + response_user_message_id, + 'response_user_message_id', + ) + child_run_id = _normalize_identifier(child_run_id, 'child_run_id') + if updated.get('status') == 'resolved': + if ( + updated.get('_response_user_message_id') == response_user_message_id + and updated.get('child_run_id') == child_run_id + ): + return updated, True + raise ChatClarificationError( + 'clarification already completed for another response', + code='clarification_response_conflict', + ) + if not ( + updated.get('status') == 'resolving' + and updated.get('_response_user_message_id') + == response_user_message_id + and updated.get('child_run_id') == child_run_id + ): + raise ChatClarificationError( + 'clarification response claim does not match', + code='clarification_response_claim_mismatch', + ) + current_time = now or _utc_now() + if current_time.tzinfo is None: + current_time = current_time.replace(tzinfo=timezone.utc) + updated['status'] = 'resolved' + updated['resolved_at'] = current_time.isoformat() + updated['lease_expires_at'] = None + return updated, False + + +def apply_chat_clarification_response( + clarification, + *, + response_user_message_id, + response_text, + child_run_id=None, + now=None, +): + """Resolve one pending clarification or return an idempotent replay.""" + resolved_child_run_id = child_run_id or str(uuid.uuid4()) + claimed, idempotent = claim_chat_clarification_response( + clarification, + response_user_message_id=response_user_message_id, + response_text=response_text, + child_run_id=resolved_child_run_id, + response_thread_id=None, + now=now, + ) + if idempotent: + return claimed, True + return complete_chat_clarification_response( + claimed, + response_user_message_id=claimed.get('_response_user_message_id'), + child_run_id=claimed.get('child_run_id'), + now=now, + ) + + +def expire_chat_clarification(clarification, *, now=None): + """Return one terminal expired clarification state.""" + updated = copy.deepcopy(dict(clarification or {})) + if updated.get('status') == 'resolved': + return updated, True + if updated.get('status') == 'expired': + return updated, True + current_time = now or _utc_now() + if current_time.tzinfo is None: + current_time = current_time.replace(tzinfo=timezone.utc) + updated['status'] = 'expired' + updated['expired_at'] = current_time.isoformat() + return updated, False + + +def invalidate_chat_clarification(clarification, *, reason, now=None): + """Terminalize a pending claim after source or authorization failure.""" + updated = copy.deepcopy(dict(clarification or {})) + if updated.get('status') in {'resolved', 'expired'}: + return updated, True + current_time = now or _utc_now() + if current_time.tzinfo is None: + current_time = current_time.replace(tzinfo=timezone.utc) + updated['status'] = 'expired' + updated['expired_at'] = current_time.isoformat() + updated['lease_expires_at'] = None + updated['invalidation_reason'] = normalize_clarification_error_type( + reason + ) + return updated, False + + +def read_chat_clarification_message(container, *, conversation_id, clarification_id): + """Read one exact assistant clarification from its authorized partition.""" + conversation_id = _normalize_identifier(conversation_id, 'conversation_id') + clarification_id = _normalize_identifier( + clarification_id, + 'clarification_id', + ) + message = container.read_item( + item=clarification_id, + partition_key=conversation_id, + ) + metadata = message.get('metadata') if isinstance(message.get('metadata'), Mapping) else {} + clarification = metadata.get('chat_clarification') + if ( + message.get('conversation_id') != conversation_id + or message.get('role') != 'assistant' + or not isinstance(clarification, Mapping) + or clarification.get('clarification_id') != clarification_id + or clarification.get('_conversation_id') != conversation_id + ): + raise ChatClarificationError( + 'clarification checkpoint is invalid', + code='clarification_invalid', + ) + return message, copy.deepcopy(dict(clarification)) + + +def find_pending_chat_clarification( + container, + *, + conversation_id, + source_thread_id, +): + """Find at most one pending checkpoint for an exact predecessor thread.""" + conversation_id = _normalize_identifier(conversation_id, 'conversation_id') + source_thread_id = _normalize_identifier( + source_thread_id, + 'source_thread_id', + ) + rows = list(container.query_items( + query=( + 'SELECT TOP 2 * FROM c ' + 'WHERE c.conversation_id = @conversation_id ' + 'AND c.role = "assistant" ' + 'AND IS_DEFINED(c.metadata.chat_clarification) ' + 'AND c.metadata.thread_info.thread_id = @source_thread_id ' + 'ORDER BY c.timestamp DESC' + ), + parameters=[ + {'name': '@conversation_id', 'value': conversation_id}, + {'name': '@source_thread_id', 'value': source_thread_id}, + ], + partition_key=conversation_id, + )) + valid_rows = [] + for message in rows: + metadata = message.get('metadata') if isinstance(message.get('metadata'), Mapping) else {} + clarification = metadata.get('chat_clarification') + if ( + message.get('conversation_id') == conversation_id + and message.get('role') == 'assistant' + and isinstance(clarification, Mapping) + and clarification.get('status') in { + 'pending', + 'resolving', + } + and clarification.get('_source_thread_id') == source_thread_id + ): + valid_rows.append((message, copy.deepcopy(dict(clarification)))) + if len(valid_rows) > 1: + raise ChatClarificationError( + 'multiple pending clarifications exist for this goal', + code='clarification_ambiguous', + ) + return valid_rows[0] if valid_rows else (None, None) + + +def find_latest_unresolved_chat_clarification( + container, + *, + conversation_id, +): + """Find one unresolved checkpoint when latest user lineage is malformed.""" + conversation_id = _normalize_identifier(conversation_id, 'conversation_id') + rows = list(container.query_items( + query=( + 'SELECT TOP 2 * FROM c ' + 'WHERE c.conversation_id = @conversation_id ' + 'AND c.role = "assistant" ' + 'AND IS_DEFINED(c.metadata.chat_clarification) ' + 'AND (c.metadata.chat_clarification.status = "pending" ' + 'OR c.metadata.chat_clarification.status = "resolving") ' + 'ORDER BY c.timestamp DESC' + ), + parameters=[ + {'name': '@conversation_id', 'value': conversation_id}, + ], + partition_key=conversation_id, + )) + valid_rows = [] + for message in rows: + metadata = ( + message.get('metadata') + if isinstance(message.get('metadata'), Mapping) + else {} + ) + clarification = metadata.get('chat_clarification') + if ( + message.get('conversation_id') == conversation_id + and message.get('role') == 'assistant' + and isinstance(clarification, Mapping) + and clarification.get('status') in {'pending', 'resolving'} + and clarification.get('clarification_id') == message.get('id') + and clarification.get('_conversation_id') == conversation_id + ): + valid_rows.append((message, copy.deepcopy(dict(clarification)))) + if len(valid_rows) > 1: + raise ChatClarificationError( + 'multiple unresolved clarifications exist for this conversation', + code='clarification_ambiguous', + ) + return valid_rows[0] if valid_rows else (None, None) + + +def validate_chat_clarification_source( + container, + *, + conversation_id, + clarification, +): + """Re-read and validate the exact active source user turn.""" + if not isinstance(clarification, Mapping): + raise ChatClarificationError( + 'clarification metadata is invalid', + code='clarification_invalid', + ) + source_user_message_id = _normalize_identifier( + clarification.get('_source_user_message_id'), + 'source_user_message_id', + ) + source_thread_id = _normalize_identifier( + clarification.get('_source_thread_id'), + 'source_thread_id', + ) + message = container.read_item( + item=source_user_message_id, + partition_key=conversation_id, + ) + metadata = message.get('metadata') if isinstance(message.get('metadata'), Mapping) else {} + thread_info = ( + metadata.get('thread_info') + if isinstance(metadata.get('thread_info'), Mapping) + else {} + ) + if not ( + message.get('conversation_id') == conversation_id + and message.get('role') == 'user' + and str(thread_info.get('thread_id') or '').strip() == source_thread_id + and thread_info.get('active_thread') is not False + and metadata.get('is_deleted') is not True + and metadata.get('masked') is not True + and not (metadata.get('masked_ranges') or []) + and metadata.get('is_generated_chat_artifact') is not True + ): + raise ChatClarificationError( + 'clarification source turn is no longer active', + code='clarification_source_invalid', + ) + return message + + +def _replace_clarification_message(container, message, clarification): + updated_message = copy.deepcopy(dict(message)) + metadata = ( + copy.deepcopy(dict(updated_message.get('metadata'))) + if isinstance(updated_message.get('metadata'), Mapping) + else {} + ) + metadata['chat_clarification'] = copy.deepcopy(dict(clarification)) + metadata['awaiting_user_clarification'] = ( + clarification.get('status') in {'pending', 'resolving'} + ) + updated_message['metadata'] = metadata + return container.replace_item( + item=updated_message['id'], + body=updated_message, + etag=message.get('_etag'), + match_condition=MatchConditions.IfNotModified, + ) + + +def _is_conditional_conflict(exc): + return getattr(exc, 'status_code', None) in {409, 412} + + +def persist_chat_clarification_expiry( + container, + *, + conversation_id, + clarification_id, + now=None, +): + """Persist one expired clarification using optimistic concurrency.""" + last_conflict = None + for _ in range(MAX_CONDITIONAL_WRITE_ATTEMPTS): + message, clarification = read_chat_clarification_message( + container, + conversation_id=conversation_id, + clarification_id=clarification_id, + ) + if clarification.get('status') == 'expired': + return message, clarification, True + if clarification.get('status') == 'resolved': + return message, clarification, True + if not clarification_is_expired(clarification, now=now): + return message, clarification, True + expired, _ = expire_chat_clarification(clarification, now=now) + try: + saved_message = _replace_clarification_message( + container, + message, + expired, + ) + return saved_message, expired, False + except Exception as exc: + if not _is_conditional_conflict(exc): + raise + last_conflict = exc + raise ChatClarificationError( + 'clarification changed while it was being expired', + code='clarification_expiry_write_conflict', + ) from last_conflict + + +def persist_chat_clarification_invalidation( + container, + *, + conversation_id, + clarification_id, + reason, + expected_response_user_message_id=None, + expected_child_run_id=None, + expected_claimed_at=None, + now=None, +): + """Persist one fail-closed clarification invalidation with an ETag.""" + last_conflict = None + for _ in range(MAX_CONDITIONAL_WRITE_ATTEMPTS): + message, clarification = read_chat_clarification_message( + container, + conversation_id=conversation_id, + clarification_id=clarification_id, + ) + claim_fence_requested = any( + value is not None + for value in ( + expected_response_user_message_id, + expected_child_run_id, + expected_claimed_at, + ) + ) + expected_claim = { + '_response_user_message_id': str( + expected_response_user_message_id or '' + ).strip(), + 'child_run_id': str(expected_child_run_id or '').strip(), + 'claimed_at': str(expected_claimed_at or '').strip(), + } + if claim_fence_requested and any( + str(clarification.get(field_name) or '').strip() + != expected_value + for field_name, expected_value in expected_claim.items() + ): + raise ChatClarificationError( + 'clarification claim changed before invalidation', + code='clarification_response_claim_mismatch', + ) + invalidated, idempotent = invalidate_chat_clarification( + clarification, + reason=reason, + now=now, + ) + if idempotent: + return message, invalidated, True + try: + saved_message = _replace_clarification_message( + container, + message, + invalidated, + ) + return saved_message, invalidated, False + except Exception as exc: + if not _is_conditional_conflict(exc): + raise + last_conflict = exc + raise ChatClarificationError( + 'clarification changed while it was being invalidated', + code='clarification_invalidation_write_conflict', + ) from last_conflict + + +def persist_chat_clarification_response_claim( + container, + *, + conversation_id, + clarification_id, + response_user_message_id, + response_text, + child_run_id, + response_thread_id=None, + source_validator=None, + expected_response_user_message_id=None, + expected_child_run_id=None, + expected_claimed_at=None, + now=None, +): + """Claim one clarification response using optimistic concurrency.""" + last_conflict = None + for _ in range(MAX_CONDITIONAL_WRITE_ATTEMPTS): + message, clarification = read_chat_clarification_message( + container, + conversation_id=conversation_id, + clarification_id=clarification_id, + ) + claim_fence_requested = any( + value is not None + for value in ( + expected_response_user_message_id, + expected_child_run_id, + expected_claimed_at, + ) + ) + if claim_fence_requested and any(( + str( + clarification.get('_response_user_message_id') or '' + ).strip() + != str(expected_response_user_message_id or '').strip(), + str(clarification.get('child_run_id') or '').strip() + != str(expected_child_run_id or '').strip(), + str(clarification.get('claimed_at') or '').strip() + != str(expected_claimed_at or '').strip(), + )): + raise ChatClarificationError( + 'clarification claim changed before recovery', + code='clarification_response_claim_mismatch', + ) + if ( + clarification.get('status') in {'pending', 'resolving'} + and clarification_is_expired(clarification, now=now) + ): + expired, _ = expire_chat_clarification( + clarification, + now=now, + ) + try: + _replace_clarification_message(container, message, expired) + except Exception as exc: + if _is_conditional_conflict(exc): + last_conflict = exc + continue + raise + raise ChatClarificationError( + 'clarification has expired', + code='clarification_expired', + ) + if callable(source_validator): + try: + source_validator(clarification) + except Exception as source_error: + invalidated, invalidation_idempotent = ( + invalidate_chat_clarification( + clarification, + reason='clarification_source_invalid', + now=now, + ) + ) + if not invalidation_idempotent: + try: + _replace_clarification_message( + container, + message, + invalidated, + ) + except Exception as exc: + if _is_conditional_conflict(exc): + last_conflict = exc + continue + raise + raise ChatClarificationError( + 'clarification source turn is no longer active', + code='clarification_source_invalid', + ) from source_error + claimed, idempotent = claim_chat_clarification_response( + clarification, + response_user_message_id=response_user_message_id, + response_text=response_text, + child_run_id=child_run_id, + response_thread_id=response_thread_id, + now=now, + ) + if idempotent: + return message, claimed, True + try: + saved_message = _replace_clarification_message( + container, + message, + claimed, + ) + return saved_message, claimed, False + except Exception as exc: + if not _is_conditional_conflict(exc): + raise + last_conflict = exc + raise ChatClarificationError( + 'clarification changed while the response was being claimed', + code='clarification_claim_write_conflict', + ) from last_conflict + + +def persist_chat_clarification_response_completion( + container, + *, + conversation_id, + clarification_id, + response_user_message_id, + child_run_id, + expected_claimed_at=None, + response_validator=None, + now=None, +): + """Complete one exact claimed response using optimistic concurrency.""" + last_conflict = None + for _ in range(MAX_CONDITIONAL_WRITE_ATTEMPTS): + message, clarification = read_chat_clarification_message( + container, + conversation_id=conversation_id, + clarification_id=clarification_id, + ) + if ( + expected_claimed_at is not None + and str(clarification.get('claimed_at') or '').strip() + != str(expected_claimed_at or '').strip() + ): + raise ChatClarificationError( + 'clarification claim changed before completion', + code='clarification_response_claim_mismatch', + ) + if callable(response_validator): + try: + response_validator(clarification) + except Exception as response_error: + invalidated, invalidation_idempotent = ( + invalidate_chat_clarification( + clarification, + reason='clarification_response_claim_mismatch', + now=now, + ) + ) + if not invalidation_idempotent: + try: + _replace_clarification_message( + container, + message, + invalidated, + ) + except Exception as exc: + if _is_conditional_conflict(exc): + last_conflict = exc + continue + raise + raise ChatClarificationError( + 'clarification response state is invalid', + code='clarification_response_claim_mismatch', + ) from response_error + completed, idempotent = complete_chat_clarification_response( + clarification, + response_user_message_id=response_user_message_id, + child_run_id=child_run_id, + now=now, + ) + if idempotent: + return message, completed, True + try: + saved_message = _replace_clarification_message( + container, + message, + completed, + ) + return saved_message, completed, False + except Exception as exc: + if not _is_conditional_conflict(exc): + raise + last_conflict = exc + raise ChatClarificationError( + 'clarification changed while the response was being completed', + code='clarification_completion_write_conflict', + ) from last_conflict + + +def persist_chat_clarification_response( + container, + *, + conversation_id, + clarification_id, + response_user_message_id, + response_text, + child_run_id=None, + source_validator=None, + now=None, +): + """Resolve one clarification with optimistic concurrency.""" + last_conflict = None + for _ in range(MAX_CONDITIONAL_WRITE_ATTEMPTS): + message, clarification = read_chat_clarification_message( + container, + conversation_id=conversation_id, + clarification_id=clarification_id, + ) + if ( + clarification.get('status') == 'pending' + and clarification_is_expired(clarification, now=now) + ): + expired, _ = expire_chat_clarification( + clarification, + now=now, + ) + try: + _replace_clarification_message(container, message, expired) + except Exception as exc: + if _is_conditional_conflict(exc): + last_conflict = exc + continue + raise + raise ChatClarificationError( + 'clarification has expired', + code='clarification_expired', + ) + if callable(source_validator): + source_validator(clarification) + updated, idempotent = apply_chat_clarification_response( + clarification, + response_user_message_id=response_user_message_id, + response_text=response_text, + child_run_id=child_run_id, + now=now, + ) + if idempotent: + return message, updated, True + try: + saved_message = _replace_clarification_message( + container, + message, + updated, + ) + return saved_message, updated, False + except Exception as exc: + if not _is_conditional_conflict(exc): + raise + last_conflict = exc + raise ChatClarificationError( + 'clarification changed while the response was being saved', + code='clarification_write_conflict', + ) from last_conflict + + +def normalize_clarification_error_type(value): + """Return one bounded safe failure code for persistence or evaluation.""" + return re.sub( + r'[^a-z0-9_]+', + '_', + str(value or 'clarification_failed').strip().lower(), + )[:120] diff --git a/application/single_app/functions_chat_contextual_goals.py b/application/single_app/functions_chat_contextual_goals.py new file mode 100644 index 00000000..8be114fe --- /dev/null +++ b/application/single_app/functions_chat_contextual_goals.py @@ -0,0 +1,274 @@ +# functions_chat_contextual_goals.py +"""Bounded authorized user-turn context for chat capability planning.""" + +from collections.abc import Mapping + +from functions_chat_capability_choices import CapabilityChoiceError + + +MAX_PRIOR_USER_TURNS = 2 +MAX_CONTEXT_TURN_CHARS = 8000 + + +def _active_user_turn(message, *, conversation_id): + if not isinstance(message, Mapping): + return False + metadata = message.get('metadata') if isinstance(message.get('metadata'), Mapping) else {} + thread_info = ( + metadata.get('thread_info') + if isinstance(metadata.get('thread_info'), Mapping) + else {} + ) + return bool( + message.get('conversation_id') == conversation_id + and message.get('role') == 'user' + and str(message.get('id') or '').strip() + and str(message.get('content') or '').strip() + and str(thread_info.get('thread_id') or '').strip() + and metadata.get('is_deleted') is not True + and metadata.get('masked') is not True + and not (metadata.get('masked_ranges') or []) + and metadata.get('is_generated_chat_artifact') is not True + and thread_info.get('active_thread') is not False + ) + + +def _read_active_user_turn_for_thread(container, conversation_id, thread_id): + rows = list(container.query_items( + query=( + 'SELECT TOP 2 * FROM c ' + 'WHERE c.conversation_id = @conversation_id ' + 'AND c.role = "user" ' + 'AND c.metadata.thread_info.thread_id = @thread_id ' + 'AND (NOT IS_DEFINED(c.metadata.is_deleted) OR c.metadata.is_deleted != true) ' + 'AND (NOT IS_DEFINED(c.metadata.masked) OR c.metadata.masked != true) ' + 'AND (NOT IS_DEFINED(c.metadata.masked_ranges) ' + 'OR ARRAY_LENGTH(c.metadata.masked_ranges) = 0) ' + 'AND (NOT IS_DEFINED(c.metadata.is_generated_chat_artifact) ' + 'OR c.metadata.is_generated_chat_artifact != true) ' + 'AND (NOT IS_DEFINED(c.metadata.thread_info.active_thread) ' + 'OR c.metadata.thread_info.active_thread != false)' + ), + parameters=[ + {'name': '@conversation_id', 'value': conversation_id}, + {'name': '@thread_id', 'value': thread_id}, + ], + partition_key=conversation_id, + )) + active_rows = [ + row + for row in rows + if _active_user_turn(row, conversation_id=conversation_id) + ] + if len(active_rows) > 1: + raise CapabilityChoiceError( + 'planning context thread has multiple active user attempts', + code='goal_source_thread_ambiguous', + ) + return active_rows[0] if active_rows else None + + +def load_bounded_prior_user_turns(container, *, conversation_id): + """Load at most two preceding active user turns from one authorized partition.""" + conversation_id = str(conversation_id or '').strip() + if not conversation_id: + raise CapabilityChoiceError( + 'conversation_id is required for planning context', + code='invalid_conversation_id', + ) + latest_rows = list(container.query_items( + query=( + 'SELECT TOP 2 * FROM c ' + 'WHERE c.conversation_id = @conversation_id ' + 'AND c.role = "user" ' + 'AND (NOT IS_DEFINED(c.metadata.is_deleted) OR c.metadata.is_deleted != true) ' + 'AND (NOT IS_DEFINED(c.metadata.masked) OR c.metadata.masked != true) ' + 'AND (NOT IS_DEFINED(c.metadata.masked_ranges) ' + 'OR ARRAY_LENGTH(c.metadata.masked_ranges) = 0) ' + 'AND (NOT IS_DEFINED(c.metadata.is_generated_chat_artifact) ' + 'OR c.metadata.is_generated_chat_artifact != true) ' + 'AND (NOT IS_DEFINED(c.metadata.thread_info.active_thread) ' + 'OR c.metadata.thread_info.active_thread != false) ' + 'ORDER BY c.timestamp DESC' + ), + parameters=[{'name': '@conversation_id', 'value': conversation_id}], + partition_key=conversation_id, + )) + latest_turn = next( + ( + row + for row in latest_rows + if _active_user_turn(row, conversation_id=conversation_id) + ), + None, + ) + latest_user_candidate_invalid = bool( + latest_rows + and ( + latest_turn is None + or str(latest_rows[0].get('id') or '').strip() + != str(latest_turn.get('id') or '').strip() + ) + ) + if latest_turn is None: + return { + 'prior_user_messages': [], + 'predecessor_thread_id': None, + 'latest_user_candidate_invalid': ( + latest_user_candidate_invalid + ), + } + + newest_first = [latest_turn] + seen_thread_ids = { + str(latest_turn['metadata']['thread_info']['thread_id']) + } + while len(newest_first) < MAX_PRIOR_USER_TURNS: + previous_thread_id = str( + newest_first[-1].get('metadata', {}).get('thread_info', {}).get( + 'previous_thread_id' + ) or '' + ).strip() + if not previous_thread_id: + break + if previous_thread_id in seen_thread_ids: + raise CapabilityChoiceError( + 'planning context thread lineage contains a cycle', + code='goal_source_thread_invalid', + ) + previous_turn = _read_active_user_turn_for_thread( + container, + conversation_id, + previous_thread_id, + ) + if previous_turn is None: + break + newest_first.append(previous_turn) + seen_thread_ids.add(previous_thread_id) + + return { + 'prior_user_messages': list(reversed(newest_first)), + 'predecessor_thread_id': str( + latest_turn.get('metadata', {}).get('thread_info', {}).get( + 'thread_id' + ) or '' + ).strip() or None, + 'latest_user_candidate_invalid': latest_user_candidate_invalid, + } + + +def planner_prior_user_turns(context_state): + """Project only bounded user text into the planner request builder.""" + state = context_state if isinstance(context_state, Mapping) else {} + return [ + { + 'role': 'user', + 'text': str(message.get('content') or '').strip()[ + :MAX_CONTEXT_TURN_CHARS + ], + } + for message in state.get('prior_user_messages') or [] + if isinstance(message, Mapping) + and str(message.get('content') or '').strip() + ][-MAX_PRIOR_USER_TURNS:] + + +def resolve_planner_goal_source_messages( + planner_request, + planner_result, + context_state, + current_user_message, +): + """Resolve validated opaque refs to exact request-local user documents.""" + request = planner_request if isinstance(planner_request, Mapping) else {} + result = planner_result if isinstance(planner_result, Mapping) else {} + state = context_state if isinstance(context_state, Mapping) else {} + dialogue_context = [ + turn + for turn in request.get('dialogue_context') or [] + if isinstance(turn, Mapping) and turn.get('role') == 'user' + ] + prior_message_count = max(0, len(dialogue_context) - 1) + available_prior_messages = [ + message + for message in state.get('prior_user_messages') or [] + if isinstance(message, Mapping) + ] + source_messages = ( + available_prior_messages[-prior_message_count:] + if prior_message_count + else [] + ) + source_messages.append(current_user_message) + if len(source_messages) != len(dialogue_context): + raise CapabilityChoiceError( + 'planner goal refs cannot be bound to exact user turns', + code='goal_source_binding_invalid', + ) + messages_by_ref = {} + for turn, message in zip(dialogue_context, source_messages): + turn_ref = str(turn.get('ref') or '').strip() + projected_text = str(turn.get('text') or '').strip() + exact_text = str(message.get('content') or '').strip()[ + :MAX_CONTEXT_TURN_CHARS + ] + if not turn_ref or projected_text != exact_text: + raise CapabilityChoiceError( + 'planner goal context changed before source binding', + code='goal_source_changed', + ) + messages_by_ref[turn_ref] = message + selected_messages = [] + for turn_ref in result.get('goal_turn_refs') or []: + source_message = messages_by_ref.get(str(turn_ref or '').strip()) + if source_message is None: + raise CapabilityChoiceError( + 'planner selected an unavailable goal ref', + code='unknown_goal_turn_ref', + ) + selected_messages.append(source_message) + structured_state = ( + request.get('structured_state') + if isinstance(request.get('structured_state'), Mapping) + else {} + ) + if ( + structured_state.get('type') == 'clarification' + and current_user_message not in selected_messages + ): + selected_messages.append(current_user_message) + if not selected_messages: + raise CapabilityChoiceError( + 'planner selected no goal source turns', + code='invalid_goal_turn_refs', + ) + return selected_messages + + +def read_exact_goal_source_messages(container, *, conversation_id, stored_goal): + """Read exact persisted source IDs from an already authorized conversation.""" + if not isinstance(stored_goal, Mapping): + raise CapabilityChoiceError( + 'approved goal metadata is missing', + code='goal_metadata_invalid', + ) + source_ids = list(stored_goal.get('source_user_message_ids') or []) + if not 1 <= len(source_ids) <= 3: + raise CapabilityChoiceError( + 'approved goal source count is invalid', + code='goal_source_count_invalid', + ) + messages = [] + for source_id in source_ids: + source_id = str(source_id or '').strip() + if not source_id: + raise CapabilityChoiceError( + 'approved goal source ID is invalid', + code='goal_source_invalid', + ) + message = container.read_item( + item=source_id, + partition_key=conversation_id, + ) + messages.append(message) + return messages diff --git a/application/single_app/functions_collaboration.py b/application/single_app/functions_collaboration.py index a3fcd46f..0870e3ac 100644 --- a/application/single_app/functions_collaboration.py +++ b/application/single_app/functions_collaboration.py @@ -36,6 +36,7 @@ ) from functions_activity_logging import log_chat_activity from functions_appinsights import log_event +from functions_chat_capability_choices import project_chat_metadata_for_client from functions_conversation_cache import bump_conversation_cache_version, invalidate_conversation_cache_for_item from functions_documents import sync_chat_upload_workspace_document_sharing_for_collaboration from functions_group import ( @@ -337,7 +338,8 @@ def build_collaboration_image_url(conversation_id, message_id): def serialize_collaboration_message(message_doc): - metadata = message_doc.get('metadata', {}) if isinstance(message_doc, dict) else {} + stored_metadata = message_doc.get('metadata', {}) if isinstance(message_doc, dict) else {} + metadata = project_chat_metadata_for_client(stored_metadata) display_role = _get_collaboration_display_role(message_doc) serialized_role = display_role if display_role in ('file', 'image') else message_doc.get('role') serialized_content = message_doc.get('content', '') @@ -695,8 +697,9 @@ def build_collaboration_message_metadata_payload(message_doc, conversation_doc): 'vision_analysis': message_doc.get('vision_analysis') or (source_message_doc or {}).get('vision_analysis'), } + projected_metadata = project_chat_metadata_for_client(merged_metadata) if str(message_doc.get('role') or '').strip().lower() != 'assistant': - return merged_metadata + return projected_metadata payload = deepcopy(source_message_doc or {}) payload.update({ @@ -721,7 +724,7 @@ def build_collaboration_message_metadata_payload(message_doc, conversation_doc): 'extracted_text': message_doc.get('extracted_text') or payload.get('extracted_text'), 'vision_analysis': message_doc.get('vision_analysis') or payload.get('vision_analysis'), }) - payload['metadata'] = merged_metadata + payload['metadata'] = projected_metadata return payload diff --git a/application/single_app/functions_orchestration_evaluation.py b/application/single_app/functions_orchestration_evaluation.py index 9a34eb75..bca547ee 100644 --- a/application/single_app/functions_orchestration_evaluation.py +++ b/application/single_app/functions_orchestration_evaluation.py @@ -6,6 +6,7 @@ from datetime import datetime, timezone from functions_chat_capability_planner import ( + CAPABILITY_PLANNER_CLARIFICATION_CODES, CAPABILITY_PLANNER_DECISIONS, CAPABILITY_PLANNER_FAILURE_CODES, CAPABILITY_PLANNER_REASON_CODES, @@ -49,6 +50,7 @@ 'succeeded', }) SAFE_RUN_STATUSES = frozenset({ + 'awaiting_user_clarification', 'awaiting_user_choice', 'cancelled', 'failed', @@ -98,7 +100,11 @@ 'web_search+workspace_search', 'workspace_search', }) -SAFE_PLANNER_ACTIVATION_STATUSES = frozenset({'materialized', 'suppressed'}) +SAFE_PLANNER_ACTIVATION_STATUSES = frozenset({ + 'clarification', + 'materialized', + 'suppressed', +}) SAFE_RECOMMENDATION_SOURCES = frozenset({'deterministic', 'direct', 'planner'}) SAFE_PLANNER_SUPPRESSION_REASONS = frozenset({ 'deterministic_conflict', @@ -122,6 +128,31 @@ 'lease', 'policy', }) +SAFE_PRIOR_GOAL_OUTCOMES = frozenset({ + 'approved', + 'declined', + 'invalidated', + 'not_applicable', + 'pending', +}) +SAFE_CLARIFICATION_LIFECYCLES = frozenset({ + 'created', + 'expired', + 'failed', + 'resolved', +}) +SAFE_CLARIFICATION_STATUSES = frozenset({ + 'expired', + 'failed', + 'pending', + 'resolving', + 'resolved', +}) +SAFE_CLARIFICATION_RESPONSE_MODES = frozenset({ + 'free_text', + 'none', + 'option', +}) def _field(value, name, default=None): @@ -256,6 +287,20 @@ def _planner_event_fields(run_id, metadata, *, provider_class, model_name): 'reason_codes': _safe_planner_reason_codes(summary.get('reason_codes')), 'latency_ms': _bounded_count(summary.get('latency_ms')), 'fallback_used': bool(summary.get('fallback_used')), + 'eligible_goal_turn_count': min( + _bounded_count(summary.get('eligible_goal_turn_count')), + 3, + ), + 'selected_goal_turn_count': min( + _bounded_count(summary.get('selected_goal_turn_count')), + 3, + ), + 'prior_goal_included': bool(summary.get('prior_goal_included')), + 'clarification_code': _safe_enum( + summary.get('clarification_code'), + CAPABILITY_PLANNER_CLARIFICATION_CODES, + fallback='none', + ), } @@ -309,6 +354,11 @@ def _capability_class(option): return 'unknown' if option.get('kind') == 'agent' or option.get('agent_ref'): return 'governed_agent' + if option.get('kind') == 'context': + effective_classes = _safe_planner_capability_classes( + option.get('effective_capability_ids') + ) + return effective_classes[0] if effective_classes else 'unknown' capability_ids = [ str(capability_id or '').strip() for capability_id in (option.get('capability_ids') or []) @@ -421,6 +471,18 @@ def build_recommendation_created_evaluation_event(proposal): 'sensitive_data_notice_required': bool( _field(proposal, 'sensitive_data_notice_required', False) ), + 'prior_goal_included': bool( + _field(proposal, 'prior_goal_included', False) + ), + 'goal_source_count': min( + _bounded_count(_field(proposal, 'goal_source_count', 0)), + 3, + ), + 'prior_goal_outcome': ( + 'pending' + if _field(proposal, 'prior_goal_included', False) + else 'not_applicable' + ), } @@ -452,6 +514,65 @@ def build_recommendation_decision_evaluation_event(proposal, *, idempotent=False decision.get('decided_at'), ), 'idempotent': bool(idempotent), + 'prior_goal_included': bool( + decision.get('prior_goal_included') + ), + 'goal_source_count': min( + _bounded_count(decision.get('goal_source_count')), + 3, + ), + 'prior_goal_outcome': _safe_enum( + ( + decision.get('status') + if _field(proposal, 'prior_goal_included', False) + else 'not_applicable' + ), + SAFE_PRIOR_GOAL_OUTCOMES, + fallback='not_applicable', + ), + } + + +def build_clarification_evaluation_event( + clarification, + *, + lifecycle, + idempotent=False, + error_code=None, +): + """Build one bounded clarification lifecycle event without text or IDs.""" + value = clarification if isinstance(clarification, Mapping) else {} + return { + **_base_event('orchestration_clarification_lifecycle'), + 'parent_run_correlation_id': _correlation_id( + value.get('parent_run_id') + ), + 'lifecycle': _safe_enum( + lifecycle, + SAFE_CLARIFICATION_LIFECYCLES, + ), + 'status': _safe_enum( + value.get('status'), + SAFE_CLARIFICATION_STATUSES, + ), + 'clarification_code': _safe_enum( + value.get('code'), + CAPABILITY_PLANNER_CLARIFICATION_CODES, + ), + 'option_count': min( + _bounded_count(len(value.get('options') or [])), + 6, + ), + 'response_mode': _safe_enum( + value.get('response_mode') or 'none', + SAFE_CLARIFICATION_RESPONSE_MODES, + ), + 'idempotent': bool(idempotent), + 'failure_class': ( + _revalidation_reason_class(error_code) + if error_code + else 'none' + ), } diff --git a/application/single_app/functions_orchestration_runtime.py b/application/single_app/functions_orchestration_runtime.py index 6ce1bf35..b5d3dcf9 100644 --- a/application/single_app/functions_orchestration_runtime.py +++ b/application/single_app/functions_orchestration_runtime.py @@ -41,6 +41,7 @@ 'pending', 'running', 'awaiting_user_choice', + 'awaiting_user_clarification', 'succeeded', 'partial', 'failed', diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index 2a3e47c7..8c9364ea 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -158,7 +158,7 @@ def bounded_int(key, *, minimum, maximum): source.get('chat_capability_planner_model_id') or '' ).strip()[:256] if model_source == 'configured' and not (model_endpoint_id and model_id): - mode = 'off' + model_source = 'same_as_chat' return { 'chat_capability_planner_mode': mode, diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index 70e36c81..0e996ca2 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -56,6 +56,7 @@ import threading from typing import Any, Dict, List, Mapping, Optional, Tuple from config import * +from azure.core import MatchConditions 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 * @@ -135,6 +136,7 @@ from functions_chat_capabilities import ( arbitrate_planner_capability_recommendation, build_agent_capability_recommendation, + build_contextual_egress_recommendation, build_governed_agent_capability_inventory, build_governed_capability_inventory, build_planner_capability_recommendation, @@ -151,16 +153,42 @@ from functions_chat_capability_choices import ( CapabilityChoiceError, add_sensitive_external_query_options, + build_decline_aware_execution_baseline, + build_approved_user_turn_goal, build_capability_choice_proposal, build_capability_provenance, build_capability_resume_origins, build_minimized_external_query, build_resumed_external_query, + project_chat_metadata_for_client, + rebuild_approved_user_turn_goal, revalidate_capability_choice, revalidate_capability_execution_baseline, revalidate_capability_execution_compatibility, resolve_external_retrieval_message, ) +from functions_chat_contextual_goals import ( + load_bounded_prior_user_turns, + planner_prior_user_turns, + read_exact_goal_source_messages, + resolve_planner_goal_source_messages, +) +from functions_chat_clarifications import ( + DEFAULT_CLARIFICATION_OPTION_CANDIDATES, + ChatClarificationError, + build_chat_clarification, + chat_clarification_response_matches, + clarification_is_expired, + clarification_response_lease_is_active, + find_latest_unresolved_chat_clarification, + find_pending_chat_clarification, + persist_chat_clarification_expiry, + persist_chat_clarification_invalidation, + persist_chat_clarification_response_claim, + persist_chat_clarification_response_completion, + read_chat_clarification_message, + validate_chat_clarification_source, +) from functions_chat_capability_persistence import ( persist_capability_invalidation, persist_capability_decision, @@ -190,6 +218,7 @@ set_evidence_ledger_status, ) from functions_orchestration_evaluation import ( + build_clarification_evaluation_event, build_planner_activation_evaluation_event, build_planner_completed_evaluation_event, build_planner_rejected_evaluation_event, @@ -414,6 +443,7 @@ def _build_capability_usage_metadata( workspace_search_used=False, workspace_search_result_count=0, document_action_type=DOCUMENT_ACTION_TYPE_NONE, + document_scope=None, selected_document_ids=None, active_group_ids=None, @@ -495,6 +525,7 @@ def _assigned_knowledge_allows_document_action(assigned_knowledge_filters, docum def _build_assigned_knowledge_search_args(assigned_knowledge_filters, *, query, user_id, top_n): return { + 'query': query, 'user_id': user_id, 'top_n': top_n, @@ -2979,31 +3010,45 @@ def _build_server_capability_discovery( user_email, user_roles, user_message, + capability_input_message=None, selected_capability_ids, authorized_document_count=0, selected_agent_present=False, + enable_deterministic_matching=True, ): inventory = _resolve_server_chat_capability_inventory( settings=settings, user_id=user_id, user_email=user_email, user_roles=user_roles, - user_message=user_message, + user_message=( + capability_input_message + if capability_input_message is not None + else user_message + ), selected_capability_ids=selected_capability_ids, authorized_document_count=authorized_document_count, ) - requirements = classify_capability_requirements( - user_message, - 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, ) + if not enable_deterministic_matching: + return { + 'inventory': inventory, + 'requirements': [], + 'auto_capability_ids': [], + 'recommendation': None, + } + + requirements = classify_capability_requirements( + user_message, + authorized_document_count=authorized_document_count, + ) + match = match_governed_capabilities(inventory, requirements) + agent_requirements = classify_agent_capability_requirements(user_message) agent_inventory = { 'version': 1, 'agents': copy.deepcopy(inventory.get('agents') or []), @@ -3046,7 +3091,17 @@ def _resolve_chat_capability_planner_runtime( same_chat_endpoint, ): """Resolve a planner model only from server-authoritative endpoint policy.""" - if planner_settings.get('chat_capability_planner_model_source') == 'same_as_chat': + configured_endpoint_id = str( + planner_settings.get('chat_capability_planner_model_endpoint_id') or '' + ).strip() + configured_model_id = str( + planner_settings.get('chat_capability_planner_model_id') or '' + ).strip() + if ( + planner_settings.get('chat_capability_planner_model_source') + == 'same_as_chat' + or not (configured_endpoint_id and configured_model_id) + ): return { 'client': same_chat_client, 'model': same_chat_model, @@ -3332,6 +3387,25 @@ def _load_authorized_capability_proposal_context( if isinstance(user_message_doc.get('metadata'), Mapping) else {} ) + user_thread_info = ( + user_metadata.get('thread_info') + if isinstance(user_metadata.get('thread_info'), Mapping) + else {} + ) + if ( + user_metadata.get('masked') is True + or bool(user_metadata.get('masked_ranges') or []) + or user_metadata.get('is_generated_chat_artifact') is True + or user_thread_info.get('active_thread') is False + ): + raise CapabilityChoiceError( + 'proposal source user message is no longer active', + code='proposal_user_message_invalid', + ) + approved_user_turn_goal = _rebuild_authorized_contextual_goal( + proposal, + conversation_id=conversation_id, + ) original_plan = ( user_metadata.get('orchestration') if isinstance(user_metadata.get('orchestration'), Mapping) @@ -3511,7 +3585,11 @@ def _load_authorized_capability_proposal_context( user_id=user_id, user_email=user_email, user_roles=user_roles, - user_message=resume_request['message'], + user_message=( + approved_user_turn_goal.get('contextual_query') + if isinstance(approved_user_turn_goal, Mapping) + else resume_request['message'] + ), selected_capability_ids=selected_capability_ids, authorized_document_count=len(authorized_documents) + len(authorized_task_documents), ) @@ -3554,179 +3632,1058 @@ def _load_authorized_capability_proposal_context( 'baseline_error_code': baseline_error_code, 'agent_catalog': agent_catalog, 'provenance': copy.deepcopy(dict(provenance)), + 'approved_user_turn_goal': approved_user_turn_goal, } -def _distinct_authorized_document_ids(documents): - return { - str(document.get('id') or document.get('document_id') or '').strip() - for document in documents or [] - if isinstance(document, Mapping) - and str(document.get('id') or document.get('document_id') or '').strip() - } +def _rebuild_authorized_contextual_goal( + proposal, + *, + conversation_id, +): + """Rebuild private prior-user goal lineage from exact partition reads.""" + if not isinstance(proposal, Mapping): + return None + stored_goal = proposal.get('_approved_user_turn_goal') + if not isinstance(stored_goal, Mapping): + return None + if stored_goal.get('conversation_id') != conversation_id: + raise CapabilityChoiceError( + 'approved goal does not belong to this conversation', + code='goal_conversation_mismatch', + ) + decision = ( + proposal.get('decision') + if isinstance(proposal.get('decision'), Mapping) + else {} + ) + rebuilt_goal = _rebuild_exact_contextual_goal( + stored_goal, + conversation_id=conversation_id, + approved_sensitive_input_types=( + decision.get('sensitive_input_types') or [] + ), + ) + if decision.get('contextual_goal_included') is True and ( + rebuilt_goal.get('approved_by_option_id') + != decision.get('option_id') + ): + raise CapabilityChoiceError( + 'approved goal no longer matches the persisted decision', + code='goal_approval_mismatch', + ) + return rebuilt_goal -_CAPABILITY_RESUME_CLAIM_OWNER = ContextVar( - 'capability_resume_claim_owner', - default=None, -) +def _rebuild_claimed_contextual_goal( + capability_resume_context, + *, + conversation_id, +): + """Rebuild one claimed contextual goal and invalidate it on drift.""" + resume_context = ( + capability_resume_context + if isinstance(capability_resume_context, Mapping) + else {} + ) + proposal = resume_context.get('_contextual_proposal') + if not isinstance(proposal, Mapping): + return None + try: + return _rebuild_authorized_contextual_goal( + proposal, + conversation_id=conversation_id, + ) + except CapabilityChoiceError as contextual_error: + persist_capability_invalidation( + cosmos_messages_container, + conversation_id=conversation_id, + proposal_id=resume_context.get('proposal_id'), + reason=contextual_error.code, + expected_execution_id=resume_context.get('execution_id'), + ) + raise -def _release_capability_resume_claim_context(resume_context, error_type): - """Release one exact claimed execution without disturbing a newer owner.""" - if not isinstance(resume_context, Mapping): - return False - conversation_id = str(resume_context.get('conversation_id') or '').strip() - proposal_id = str(resume_context.get('proposal_id') or '').strip() - execution_id = str(resume_context.get('execution_id') or '').strip() - if not (conversation_id and proposal_id and execution_id): - return False +def _rebuild_exact_contextual_goal( + stored_goal, + *, + conversation_id, + approved_sensitive_input_types=None, +): + """Rebuild one exact contextual goal from its authorized user documents.""" try: - persist_capability_resume_failure( + source_messages = read_exact_goal_source_messages( cosmos_messages_container, conversation_id=conversation_id, - proposal_id=proposal_id, - execution_id=execution_id, - error_type=error_type, + stored_goal=stored_goal, ) - return True - except Exception as release_error: - log_event( - '[CapabilityDiscovery] Pre-handoff resume release failed', - extra={ - 'conversation_id': conversation_id, - 'proposal_id': proposal_id, - 'error_type': type(release_error).__name__, + except CosmosResourceNotFoundError as exc: + raise CapabilityChoiceError( + 'approved goal source no longer exists', + code='goal_source_missing', + ) from exc + return rebuild_approved_user_turn_goal( + stored_goal, + source_messages, + approved_sensitive_input_types=approved_sensitive_input_types, + ) + + +def _find_persisted_clarification_child_output( + *, + conversation_id, + child_run_id, +): + """Find one persisted terminal output for an exact clarification child run.""" + normalized_conversation_id = str(conversation_id or '').strip() + normalized_child_run_id = str(child_run_id or '').strip() + if not normalized_conversation_id or not normalized_child_run_id: + return None + rows = list(cosmos_messages_container.query_items( + query=( + 'SELECT TOP 2 * FROM c ' + 'WHERE c.conversation_id = @conversation_id ' + 'AND (c.role = "assistant" OR c.role = "image" ' + 'OR c.role = "safety") ' + 'AND c.metadata.orchestration.run_id = @child_run_id' + ), + parameters=[ + { + 'name': '@conversation_id', + 'value': normalized_conversation_id, }, - level=logging.ERROR, - ) - return False + {'name': '@child_run_id', 'value': normalized_child_run_id}, + ], + partition_key=normalized_conversation_id, + )) + rows.sort( + key=lambda row: str(row.get('timestamp') or ''), + reverse=True, + ) + output = rows[0] if rows else None + output_run_id = str( + (output or {}).get('metadata', {}).get('orchestration', {}).get( + 'run_id' + ) or '' + ).strip() + return output if output_run_id == normalized_child_run_id else None -def _complete_correlated_capability_resume_output( - resume_context, - assistant_message_id, +def _finalize_stream_clarification_claim( *, - conversation_id=None, + conversation_id, + clarification, ): - """Keep durable output authoritative even when completion persistence fails.""" - if not isinstance(resume_context, Mapping): - return False - output_message_id = str(assistant_message_id or '').strip() - resolved_conversation_id = str( - conversation_id or resume_context.get('conversation_id') or '' + """Resolve output-backed claims or terminalize no-output stream claims.""" + if not isinstance(clarification, Mapping): + return None + clarification_id = str( + clarification.get('clarification_id') or '' ).strip() - proposal_id = str(resume_context.get('proposal_id') or '').strip() - execution_id = str(resume_context.get('execution_id') or '').strip() + response_user_message_id = str( + clarification.get('_response_user_message_id') or '' + ).strip() + child_run_id = str(clarification.get('child_run_id') or '').strip() + claimed_at = str(clarification.get('claimed_at') or '').strip() + if not all(( + clarification_id, + response_user_message_id, + child_run_id, + claimed_at, + )): + return None + _, current = read_chat_clarification_message( + cosmos_messages_container, + conversation_id=conversation_id, + clarification_id=clarification_id, + ) if not ( - output_message_id - and resolved_conversation_id - and proposal_id - and execution_id + current.get('_response_user_message_id') + == response_user_message_id + and current.get('child_run_id') == child_run_id + and str(current.get('claimed_at') or '').strip() == claimed_at ): - return False + raise ChatClarificationError( + 'clarification stream claim changed', + code='clarification_response_claim_mismatch', + ) + if current.get('status') in {'resolved', 'expired'}: + return current + if current.get('status') != 'resolving': + return current try: - persist_capability_resume_completion( + _validate_current_claimed_clarification_response( + conversation_id=conversation_id, + clarification=current, + ) + except ChatClarificationError as response_error: + persist_chat_clarification_invalidation( cosmos_messages_container, - conversation_id=resolved_conversation_id, - proposal_id=proposal_id, - execution_id=execution_id, - assistant_message_id=output_message_id, + conversation_id=conversation_id, + clarification_id=clarification_id, + reason='clarification_response_claim_mismatch', + expected_response_user_message_id=response_user_message_id, + expected_child_run_id=child_run_id, + expected_claimed_at=claimed_at, ) - except Exception as completion_error: - log_event( - '[CapabilityDiscovery] Correlated output completion deferred', - extra={ - 'conversation_id': resolved_conversation_id, - 'proposal_id': proposal_id, - 'error_type': type(completion_error).__name__, - }, - level=logging.ERROR, + raise response_error + child_output = _find_persisted_clarification_child_output( + conversation_id=conversation_id, + child_run_id=child_run_id, + ) + if child_output: + _, completed, _ = persist_chat_clarification_response_completion( + cosmos_messages_container, + conversation_id=conversation_id, + clarification_id=clarification_id, + response_user_message_id=response_user_message_id, + child_run_id=child_run_id, + expected_claimed_at=claimed_at, + response_validator=lambda claimed: ( + _validate_current_claimed_clarification_response( + conversation_id=conversation_id, + clarification=claimed, + ) + ), ) - return True + return completed + _, invalidated, _ = persist_chat_clarification_invalidation( + cosmos_messages_container, + conversation_id=conversation_id, + clarification_id=clarification_id, + reason='clarification_child_output_missing', + expected_response_user_message_id=response_user_message_id, + expected_child_run_id=child_run_id, + expected_claimed_at=claimed_at, + ) + return invalidated -def _claim_authorized_capability_resume_impl( - *, - settings, - user_id, - user_email, - user_roles, - conversation_id, - proposal_id, -): - """Claim one durable resume and return a server-authored execution request.""" - context = _load_authorized_capability_proposal_context( - settings=settings, - user_id=user_id, - user_email=user_email, - user_roles=user_roles, +def _load_latest_chat_clarification_context(*, conversation_id): + """Load the latest bounded user lineage and linked clarification state.""" + context_state = load_bounded_prior_user_turns( + cosmos_messages_container, conversation_id=conversation_id, - proposal_id=proposal_id, - ) - pending_decision = ( - context['proposal'].get('decision') - if isinstance(context['proposal'].get('decision'), Mapping) - else {} - ) - 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(), + prior_user_messages = [ + message + for message in context_state.get('prior_user_messages') or [] + if isinstance(message, Mapping) + ] + latest_user_message = prior_user_messages[-1] if prior_user_messages else None + clarification = None + predecessor_thread_id = context_state.get('predecessor_thread_id') + if predecessor_thread_id: + _, clarification = find_pending_chat_clarification( + cosmos_messages_container, + conversation_id=conversation_id, + source_thread_id=predecessor_thread_id, ) - authorized_document_count = len( - _distinct_authorized_document_ids(context.get('authorized_documents')) - ) - if 'analyze' in pending_effective_capability_ids and authorized_document_count < 1: - context['baseline_error_code'] = ( - context.get('baseline_error_code') or 'capability_input_unavailable' + if clarification is None and latest_user_message: + latest_metadata = ( + latest_user_message.get('metadata') + if isinstance(latest_user_message.get('metadata'), Mapping) + else {} ) - if 'compare' in pending_effective_capability_ids and authorized_document_count < 2: - context['baseline_error_code'] = ( - context.get('baseline_error_code') or 'capability_input_unavailable' + clarification_response = ( + latest_metadata.get('clarification_response') + if isinstance( + latest_metadata.get('clarification_response'), + Mapping, + ) + else {} ) - stored_resume = ( - context['proposal'].get('resume') - if isinstance(context['proposal'].get('resume'), Mapping) - else {} - ) - stored_execution_id = str(stored_resume.get('execution_id') or '').strip() - if stored_execution_id and stored_resume.get('status') in {'running', 'failed'}: - try: - completed_rows = list(cosmos_messages_container.query_items( - query=( - 'SELECT TOP 1 * FROM c ' - 'WHERE c.conversation_id = @conversation_id ' - 'AND (c.role = "assistant" OR c.role = "image" OR c.role = "safety") ' - 'AND c.metadata.capability_resume.proposal_id = @proposal_id ' - 'AND c.metadata.capability_resume.execution_id = @execution_id ' - 'ORDER BY c.timestamp DESC' - ), - parameters=[ - {'name': '@conversation_id', 'value': conversation_id}, - {'name': '@proposal_id', 'value': proposal_id}, - {'name': '@execution_id', 'value': stored_execution_id}, - ], - partition_key=conversation_id, - )) - completed_assistant = next( - ( - message - for message in completed_rows - if str(message.get('id') or '').strip() - ), - None, + clarification_id = str( + clarification_response.get('_clarification_id') or '' + ).strip() + if clarification_id: + _, clarification = read_chat_clarification_message( + cosmos_messages_container, + conversation_id=conversation_id, + clarification_id=clarification_id, ) - if completed_assistant: - _, completed_proposal, _ = persist_capability_resume_completion( - cosmos_messages_container, - conversation_id=conversation_id, + if clarification is None: + _, clarification = find_latest_unresolved_chat_clarification( + cosmos_messages_container, + conversation_id=conversation_id, + ) + return { + 'context_state': context_state, + 'latest_user_message': latest_user_message, + 'clarification': clarification, + } + + +def _invalidate_chat_clarification_checkpoint( + *, + conversation_id, + clarification, + reason, +): + """Invalidate pending state or one exact resolving claim generation.""" + if not isinstance(clarification, Mapping): + raise ChatClarificationError( + 'clarification metadata is invalid', + code='clarification_invalid', + ) + invalidation_kwargs = { + 'container': cosmos_messages_container, + 'conversation_id': conversation_id, + 'clarification_id': clarification.get('clarification_id'), + 'reason': reason, + } + if clarification.get('status') == 'resolving': + invalidation_kwargs.update({ + 'expected_response_user_message_id': ( + clarification.get('_response_user_message_id') + ), + 'expected_child_run_id': clarification.get('child_run_id'), + 'expected_claimed_at': clarification.get('claimed_at'), + }) + return persist_chat_clarification_invalidation(**invalidation_kwargs) + + +def _preflight_chat_clarification( + *, + conversation_id, + user_message=None, + retry_user_message_id=None, + allow_pending_response=False, + allow_exact_response_retry=False, + expected_clarification_id=None, +): + """Fail closed unless this request is an allowed clarification response.""" + normalized_retry_user_message_id = str( + retry_user_message_id or '' + ).strip() + context = None + if normalized_retry_user_message_id: + try: + targeted_response = cosmos_messages_container.read_item( + item=normalized_retry_user_message_id, + partition_key=conversation_id, + ) + except CosmosResourceNotFoundError: + targeted_response = None + targeted_metadata = ( + targeted_response.get('metadata') + if isinstance(targeted_response, Mapping) + and isinstance(targeted_response.get('metadata'), Mapping) + else {} + ) + targeted_clarification_response = ( + targeted_metadata.get('clarification_response') + if isinstance( + targeted_metadata.get('clarification_response'), + Mapping, + ) + else {} + ) + targeted_clarification_id = str( + targeted_clarification_response.get('_clarification_id') or '' + ).strip() + if targeted_clarification_id: + if not ( + targeted_response.get('conversation_id') == conversation_id + and targeted_response.get('role') == 'user' + and targeted_response.get('id') + == normalized_retry_user_message_id + ): + raise ChatClarificationError( + 'clarification retry response is invalid', + code='clarification_response_conflict', + ) + try: + _, targeted_clarification = read_chat_clarification_message( + cosmos_messages_container, + conversation_id=conversation_id, + clarification_id=targeted_clarification_id, + ) + except CosmosResourceNotFoundError as exc: + raise ChatClarificationError( + 'linked clarification is no longer available', + code='clarification_response_conflict', + ) from exc + context = { + 'context_state': { + 'prior_user_messages': [], + 'predecessor_thread_id': None, + }, + 'latest_user_message': targeted_response, + 'clarification': targeted_clarification, + } + if context is None: + try: + context = _load_latest_chat_clarification_context( + conversation_id=conversation_id, + ) + except CosmosResourceNotFoundError as exc: + raise ChatClarificationError( + 'clarification state is no longer available', + code='clarification_source_invalid', + ) from exc + normalized_expected_clarification_id = str( + expected_clarification_id or '' + ).strip() + if normalized_expected_clarification_id: + try: + _, expected_clarification = read_chat_clarification_message( + cosmos_messages_container, + conversation_id=conversation_id, + clarification_id=( + normalized_expected_clarification_id + ), + ) + except CosmosResourceNotFoundError as exc: + raise ChatClarificationError( + 'expected clarification is no longer available', + code='clarification_response_conflict', + ) from exc + expected_status = str( + expected_clarification.get('status') or '' + ).strip().lower() + allowed_expected_statuses = {'pending', 'resolving'} + if allow_exact_response_retry: + allowed_expected_statuses.update({'resolved', 'expired'}) + if expected_status not in allowed_expected_statuses: + raise ChatClarificationError( + 'expected clarification is no longer pending', + code='clarification_response_conflict', + ) + context = { + **context, + 'clarification': expected_clarification, + } + clarification = context.get('clarification') + if not isinstance(clarification, Mapping): + return { + **context, + 'exact_response_retry': False, + } + status = str(clarification.get('status') or '').strip().lower() + if ( + status in {'pending', 'resolving'} + and clarification_is_expired(clarification) + ): + persist_chat_clarification_expiry( + cosmos_messages_container, + conversation_id=conversation_id, + clarification_id=clarification.get('clarification_id'), + ) + raise ChatClarificationError( + 'clarification has expired', + code='clarification_expired', + ) + + def validate_source_or_invalidate(): + try: + validate_chat_clarification_source( + cosmos_messages_container, + conversation_id=conversation_id, + clarification=clarification, + ) + except (CosmosResourceNotFoundError, ChatClarificationError) as exc: + _invalidate_chat_clarification_checkpoint( + conversation_id=conversation_id, + clarification=clarification, + reason='clarification_source_invalid', + ) + raise ChatClarificationError( + 'clarification source turn is no longer active', + code='clarification_source_invalid', + ) from exc + + if status in {'pending', 'resolving'}: + validate_source_or_invalidate() + stored_response_user_message_id = str( + clarification.get('_response_user_message_id') or '' + ).strip() + if status == 'resolving': + response_thread_id = str( + clarification.get('_response_thread_id') or '' + ).strip() + source_thread_id = str( + clarification.get('_source_thread_id') or '' + ).strip() + try: + response_document = cosmos_messages_container.read_item( + item=stored_response_user_message_id, + partition_key=conversation_id, + ) + response_metadata = ( + response_document.get('metadata') + if isinstance(response_document.get('metadata'), Mapping) + else {} + ) + response_thread_info = ( + response_metadata.get('thread_info') + if isinstance(response_metadata.get('thread_info'), Mapping) + else {} + ) + if not ( + stored_response_user_message_id + and response_document.get('id') + == stored_response_user_message_id + and response_document.get('conversation_id') + == conversation_id + and response_document.get('role') == 'user' + and response_metadata.get('is_deleted') is not True + and response_metadata.get('masked') is not True + and not (response_metadata.get('masked_ranges') or []) + and response_metadata.get( + 'is_generated_chat_artifact' + ) is not True + and response_thread_info.get('active_thread') is not False + and str( + response_thread_info.get('thread_id') or '' + ).strip() == response_thread_id + and str( + response_thread_info.get('previous_thread_id') or '' + ).strip() == source_thread_id + and chat_clarification_response_matches( + clarification, + response_document.get('content'), + ) + ): + raise ChatClarificationError( + 'clarification response state is invalid', + code='clarification_response_claim_mismatch', + ) + except CosmosResourceNotFoundError as exc: + if clarification_response_lease_is_active(clarification): + raise ChatClarificationError( + 'clarification response is already being processed', + code='clarification_response_in_progress', + ) from exc + _invalidate_chat_clarification_checkpoint( + conversation_id=conversation_id, + clarification=clarification, + reason='clarification_response_claim_mismatch', + ) + raise ChatClarificationError( + 'clarification response state is invalid', + code='clarification_response_claim_mismatch', + ) from exc + except ChatClarificationError: + _invalidate_chat_clarification_checkpoint( + conversation_id=conversation_id, + clarification=clarification, + reason='clarification_response_claim_mismatch', + ) + raise + latest_user_message_id = str( + (context.get('latest_user_message') or {}).get('id') or '' + ).strip() + exact_response_retry = bool( + normalized_retry_user_message_id + and normalized_retry_user_message_id + == stored_response_user_message_id + == latest_user_message_id + and chat_clarification_response_matches( + clarification, + user_message, + ) + ) + targeted_response_retry = bool( + normalized_retry_user_message_id + and normalized_retry_user_message_id + == stored_response_user_message_id + ) + if targeted_response_retry and not exact_response_retry: + raise ChatClarificationError( + 'clarification retry does not match the stored response', + code=( + 'clarification_expired' + if status == 'expired' + else 'clarification_response_conflict' + ), + ) + if exact_response_retry and status not in {'pending', 'resolving'}: + validate_source_or_invalidate() + if exact_response_retry: + if status not in {'resolving', 'resolved'}: + raise ChatClarificationError( + 'clarification is no longer available for retry', + code=( + 'clarification_expired' + if status == 'expired' + else 'clarification_response_conflict' + ), + ) + if allow_exact_response_retry: + return { + **context, + 'exact_response_retry': True, + } + raise ChatClarificationError( + 'retry the clarification response through the normal chat stream', + code='clarification_pending', + ) + if ( + status == 'pending' + and allow_pending_response + and not normalized_retry_user_message_id + ): + return { + **context, + 'exact_response_retry': False, + } + if status in {'pending', 'resolving'}: + raise ChatClarificationError( + 'answer the pending clarification before starting another action', + code='clarification_pending', + ) + return { + **context, + 'exact_response_retry': False, + } + + +def _prepare_clarification_recovery_context(context_state, clarification): + """Remove the persisted response before retrying it as the current turn.""" + state = ( + copy.deepcopy(dict(context_state)) + if isinstance(context_state, Mapping) + else {} + ) + response_user_message_id = str( + (clarification or {}).get('_response_user_message_id') or '' + ).strip() + source_user_message_id = str( + (clarification or {}).get('_source_user_message_id') or '' + ).strip() + source_thread_id = str( + (clarification or {}).get('_source_thread_id') or '' + ).strip() + if not all(( + response_user_message_id, + source_user_message_id, + source_thread_id, + )): + raise ChatClarificationError( + 'clarification recovery linkage is incomplete', + code='clarification_response_claim_mismatch', + ) + prior_user_messages = [ + copy.deepcopy(message) + for message in state.get('prior_user_messages') or [] + if isinstance(message, Mapping) + ] + response_matches = [ + message + for message in prior_user_messages + if str(message.get('id') or '').strip() + == response_user_message_id + ] + retained_messages = [ + message + for message in prior_user_messages + if str(message.get('id') or '').strip() + != response_user_message_id + ] + source_matches = [ + message + for message in retained_messages + if str(message.get('id') or '').strip() + == source_user_message_id + ] + if len(response_matches) != 1 or len(source_matches) != 1: + raise ChatClarificationError( + 'clarification recovery context is invalid', + code='clarification_response_claim_mismatch', + ) + state['prior_user_messages'] = retained_messages + state['predecessor_thread_id'] = source_thread_id + return state + + +def _claimed_clarification_response_is_valid( + document, + clarification, + *, + conversation_id, +): + """Validate one exact durable clarification response user turn.""" + if not ( + isinstance(document, Mapping) + and isinstance(clarification, Mapping) + ): + return False + metadata = ( + document.get('metadata') + if isinstance(document.get('metadata'), Mapping) + else {} + ) + thread_info = ( + metadata.get('thread_info') + if isinstance(metadata.get('thread_info'), Mapping) + else {} + ) + return bool( + document.get('id') + == clarification.get('_response_user_message_id') + and document.get('conversation_id') == conversation_id + and document.get('role') == 'user' + and metadata.get('is_deleted') is not True + and metadata.get('masked') is not True + and not (metadata.get('masked_ranges') or []) + and metadata.get('is_generated_chat_artifact') is not True + and thread_info.get('active_thread') is not False + and str(thread_info.get('thread_id') or '').strip() + == str(clarification.get('_response_thread_id') or '').strip() + and str(thread_info.get('previous_thread_id') or '').strip() + == str(clarification.get('_source_thread_id') or '').strip() + and chat_clarification_response_matches( + clarification, + document.get('content'), + ) + ) + + +def _validate_current_claimed_clarification_response( + *, + conversation_id, + clarification, +): + """Read and validate the exact response owned by a clarification claim.""" + try: + response_document = cosmos_messages_container.read_item( + item=clarification.get('_response_user_message_id'), + partition_key=conversation_id, + ) + except CosmosResourceNotFoundError as exc: + raise ChatClarificationError( + 'clarification response state is invalid', + code='clarification_response_claim_mismatch', + ) from exc + if not _claimed_clarification_response_is_valid( + response_document, + clarification, + conversation_id=conversation_id, + ): + raise ChatClarificationError( + 'clarification response state is invalid', + code='clarification_response_claim_mismatch', + ) + return response_document + + +def _persist_claimed_clarification_response_metadata( + document, + clarification, + *, + conversation_id, + desired_metadata, +): + """Conditionally merge response metadata without overwriting user changes.""" + current_document = copy.deepcopy(dict(document or {})) + expected_response_user_message_id = str( + clarification.get('_response_user_message_id') or '' + ).strip() + expected_child_run_id = str( + clarification.get('child_run_id') or '' + ).strip() + expected_claimed_at = str( + clarification.get('claimed_at') or '' + ).strip() + for _ in range(3): + _, current_clarification = read_chat_clarification_message( + cosmos_messages_container, + conversation_id=conversation_id, + clarification_id=clarification.get('clarification_id'), + ) + if not ( + current_clarification.get('status') == 'resolving' + and current_clarification.get('_response_user_message_id') + == expected_response_user_message_id + and current_clarification.get('child_run_id') + == expected_child_run_id + and str(current_clarification.get('claimed_at') or '').strip() + == expected_claimed_at + ): + raise ChatClarificationError( + 'clarification claim changed during response persistence', + code='clarification_response_claim_mismatch', + ) + if not _claimed_clarification_response_is_valid( + current_document, + clarification, + conversation_id=conversation_id, + ): + persist_chat_clarification_invalidation( + cosmos_messages_container, + conversation_id=conversation_id, + clarification_id=clarification.get('clarification_id'), + reason='clarification_response_claim_mismatch', + expected_response_user_message_id=( + expected_response_user_message_id + ), + expected_child_run_id=expected_child_run_id, + expected_claimed_at=expected_claimed_at, + ) + raise ChatClarificationError( + 'clarification response state is invalid', + code='clarification_response_claim_mismatch', + ) + current_metadata = ( + copy.deepcopy(dict(current_document.get('metadata'))) + if isinstance(current_document.get('metadata'), Mapping) + else {} + ) + current_metadata.update(copy.deepcopy(dict(desired_metadata or {}))) + updated_document = copy.deepcopy(current_document) + updated_document['metadata'] = current_metadata + try: + return cosmos_messages_container.replace_item( + item=updated_document['id'], + body=updated_document, + etag=current_document.get('_etag'), + match_condition=MatchConditions.IfNotModified, + ) + except Exception as write_error: + write_status = getattr(write_error, 'status_code', None) + if ( + isinstance(write_error, CosmosResourceNotFoundError) + or write_status == 404 + ): + persist_chat_clarification_invalidation( + cosmos_messages_container, + conversation_id=conversation_id, + clarification_id=clarification.get( + 'clarification_id' + ), + reason='clarification_response_claim_mismatch', + expected_response_user_message_id=( + expected_response_user_message_id + ), + expected_child_run_id=expected_child_run_id, + expected_claimed_at=expected_claimed_at, + ) + raise ChatClarificationError( + 'clarification response state is invalid', + code='clarification_response_claim_mismatch', + ) from write_error + if write_status not in {409, 412}: + raise + try: + current_document = cosmos_messages_container.read_item( + item=updated_document['id'], + partition_key=conversation_id, + ) + except CosmosResourceNotFoundError as read_error: + persist_chat_clarification_invalidation( + cosmos_messages_container, + conversation_id=conversation_id, + clarification_id=clarification.get( + 'clarification_id' + ), + reason='clarification_response_claim_mismatch', + expected_response_user_message_id=( + expected_response_user_message_id + ), + expected_child_run_id=expected_child_run_id, + expected_claimed_at=expected_claimed_at, + ) + raise ChatClarificationError( + 'clarification response state is invalid', + code='clarification_response_claim_mismatch', + ) from read_error + persist_chat_clarification_invalidation( + cosmos_messages_container, + conversation_id=conversation_id, + clarification_id=clarification.get('clarification_id'), + reason='clarification_response_claim_mismatch', + expected_response_user_message_id=expected_response_user_message_id, + expected_child_run_id=expected_child_run_id, + expected_claimed_at=expected_claimed_at, + ) + raise ChatClarificationError( + 'clarification response changed during persistence', + code='clarification_response_claim_mismatch', + ) + + +def _distinct_authorized_document_ids(documents): + return { + str(document.get('id') or document.get('document_id') or '').strip() + for document in documents or [] + if isinstance(document, Mapping) + and str(document.get('id') or document.get('document_id') or '').strip() + } + + +_CAPABILITY_RESUME_CLAIM_OWNER = ContextVar( + 'capability_resume_claim_owner', + default=None, +) + + +def _release_capability_resume_claim_context(resume_context, error_type): + """Release one exact claimed execution without disturbing a newer owner.""" + if not isinstance(resume_context, Mapping): + return False + conversation_id = str(resume_context.get('conversation_id') or '').strip() + proposal_id = str(resume_context.get('proposal_id') or '').strip() + execution_id = str(resume_context.get('execution_id') or '').strip() + if not (conversation_id and proposal_id and execution_id): + return False + try: + persist_capability_resume_failure( + cosmos_messages_container, + conversation_id=conversation_id, + proposal_id=proposal_id, + execution_id=execution_id, + error_type=error_type, + ) + return True + except Exception as release_error: + log_event( + '[CapabilityDiscovery] Pre-handoff resume release failed', + extra={ + 'conversation_id': conversation_id, + 'proposal_id': proposal_id, + 'error_type': type(release_error).__name__, + }, + level=logging.ERROR, + ) + return False + + +def _complete_correlated_capability_resume_output( + resume_context, + assistant_message_id, + *, + conversation_id=None, +): + """Keep durable output authoritative even when completion persistence fails.""" + if not isinstance(resume_context, Mapping): + return False + output_message_id = str(assistant_message_id or '').strip() + resolved_conversation_id = str( + conversation_id or resume_context.get('conversation_id') or '' + ).strip() + proposal_id = str(resume_context.get('proposal_id') or '').strip() + execution_id = str(resume_context.get('execution_id') or '').strip() + if not ( + output_message_id + and resolved_conversation_id + and proposal_id + and execution_id + ): + return False + try: + persist_capability_resume_completion( + cosmos_messages_container, + conversation_id=resolved_conversation_id, + proposal_id=proposal_id, + execution_id=execution_id, + assistant_message_id=output_message_id, + ) + except Exception as completion_error: + log_event( + '[CapabilityDiscovery] Correlated output completion deferred', + extra={ + 'conversation_id': resolved_conversation_id, + 'proposal_id': proposal_id, + 'error_type': type(completion_error).__name__, + }, + level=logging.ERROR, + ) + return True + + +def _claim_authorized_capability_resume_impl( + *, + settings, + user_id, + user_email, + user_roles, + conversation_id, + proposal_id, +): + """Claim one durable resume and return a server-authored execution request.""" + try: + context = _load_authorized_capability_proposal_context( + settings=settings, + user_id=user_id, + user_email=user_email, + user_roles=user_roles, + conversation_id=conversation_id, + proposal_id=proposal_id, + ) + except CapabilityChoiceError as authorization_error: + if authorization_error.code.startswith('goal_'): + persist_capability_invalidation( + cosmos_messages_container, + conversation_id=conversation_id, + proposal_id=proposal_id, + reason=authorization_error.code, + ) + raise + pending_decision = ( + context['proposal'].get('decision') + if isinstance(context['proposal'].get('decision'), Mapping) + else {} + ) + 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( + _distinct_authorized_document_ids(context.get('authorized_documents')) + ) + if 'analyze' in pending_effective_capability_ids and authorized_document_count < 1: + context['baseline_error_code'] = ( + context.get('baseline_error_code') or 'capability_input_unavailable' + ) + if 'compare' in pending_effective_capability_ids and authorized_document_count < 2: + context['baseline_error_code'] = ( + context.get('baseline_error_code') or 'capability_input_unavailable' + ) + stored_resume = ( + context['proposal'].get('resume') + if isinstance(context['proposal'].get('resume'), Mapping) + else {} + ) + stored_execution_id = str(stored_resume.get('execution_id') or '').strip() + if stored_execution_id and stored_resume.get('status') in {'running', 'failed'}: + try: + completed_rows = list(cosmos_messages_container.query_items( + query=( + 'SELECT TOP 1 * FROM c ' + 'WHERE c.conversation_id = @conversation_id ' + 'AND (c.role = "assistant" OR c.role = "image" OR c.role = "safety") ' + 'AND c.metadata.capability_resume.proposal_id = @proposal_id ' + 'AND c.metadata.capability_resume.execution_id = @execution_id ' + 'ORDER BY c.timestamp DESC' + ), + parameters=[ + {'name': '@conversation_id', 'value': conversation_id}, + {'name': '@proposal_id', 'value': proposal_id}, + {'name': '@execution_id', 'value': stored_execution_id}, + ], + partition_key=conversation_id, + )) + completed_assistant = next( + ( + message + for message in completed_rows + if str(message.get('id') or '').strip() + ), + None, + ) + if completed_assistant: + _, completed_proposal, _ = persist_capability_resume_completion( + cosmos_messages_container, + conversation_id=conversation_id, proposal_id=proposal_id, execution_id=stored_execution_id, assistant_message_id=completed_assistant['id'], @@ -3763,6 +4720,12 @@ def _claim_authorized_capability_resume_impl( ), selected_agent_present=context.get('selected_agent_present') is True, baseline_error_code=context.get('baseline_error_code'), + source_lineage_validator=lambda proposal: ( + _rebuild_authorized_contextual_goal( + proposal, + conversation_id=conversation_id, + ) + ), ) if idempotent: context['proposal'] = claimed_proposal @@ -3800,27 +4763,38 @@ def _claim_authorized_capability_resume_impl( 'resume claim changed before execution authorization completed', code='resume_claim_mismatch', ) + execution_validation_baseline = build_decline_aware_execution_baseline( + execution_proposal, + execution_context['inventory'], + selected_capability_ids=execution_context.get( + 'selected_capability_ids' + ), + prior_effective_capabilities=( + execution_context['provenance'].get( + 'effective_capabilities' + ) or [] + ), + automatic_capability_root_ids=execution_context.get( + 'automatic_capability_root_ids' + ), + automatic_capability_effective_ids=execution_context.get( + 'automatic_capability_effective_ids' + ), + ) try: revalidate_capability_execution_baseline( execution_context['inventory'], - selected_capability_ids=execution_context.get('selected_capability_ids'), - prior_effective_capabilities=( - execution_context['provenance'].get('effective_capabilities') or [] - ), - automatic_capability_root_ids=execution_context.get( - 'automatic_capability_root_ids' - ), - automatic_capability_effective_ids=execution_context.get( - 'automatic_capability_effective_ids' - ), + **execution_validation_baseline, baseline_error_code=execution_context.get('baseline_error_code'), ) revalidate_capability_execution_compatibility( execution_proposal, - selected_capability_ids=execution_context.get('selected_capability_ids'), - prior_effective_capabilities=( - execution_context['provenance'].get('effective_capabilities') or [] - ), + selected_capability_ids=execution_validation_baseline[ + 'selected_capability_ids' + ], + prior_effective_capabilities=execution_validation_baseline[ + 'prior_effective_capabilities' + ], selected_agent_present=( execution_context.get('selected_agent_present') is True ), @@ -3848,13 +4822,19 @@ def _claim_authorized_capability_resume_impl( effective_capability_ids = list(decision.get('effective_capability_ids') or []) selected_effective_capability_ids = expand_governed_capability_baseline_ids( context['inventory'], - context.get('selected_capability_ids') or [], + execution_validation_baseline.get('selected_capability_ids') or [], + ) + automatic_root_capability_ids = execution_validation_baseline.get( + 'automatic_capability_root_ids' ) - automatic_root_capability_ids = context.get('automatic_capability_root_ids') if automatic_root_capability_ids is None: automatic_root_capability_ids = { str(item.get('id') or '').strip() - for item in (context['provenance'].get('effective_capabilities') or []) + for item in ( + execution_validation_baseline.get( + 'prior_effective_capabilities' + ) or [] + ) if isinstance(item, Mapping) and str(item.get('origin') or '').strip() == 'discovery_auto' and str(item.get('id') or '').strip() @@ -3866,10 +4846,29 @@ def _claim_authorized_capability_resume_impl( execution_effective_capability_ids = set(selected_effective_capability_ids) execution_effective_capability_ids.update(automatic_effective_capability_ids) execution_effective_capability_ids.update(effective_capability_ids) + contextual_egress_declined = ( + decision.get('approval_scope') + == 'prior_user_goal_egress_declined' + ) + if contextual_egress_declined: + external_capability_ids = set( + claimed_proposal.get('_external_capability_ids') or [] + ) + execution_effective_capability_ids.difference_update( + external_capability_ids + ) request_data = _apply_effective_capabilities_to_request( context['resume_request'], execution_effective_capability_ids, ) + if contextual_egress_declined: + request_data.update({ + 'web_search_enabled': False, + 'url_access_enabled': False, + 'source_review_enabled': False, + 'deep_research_enabled': False, + '_server_contextual_egress_declined': True, + }) if 'image' in effective_capability_ids: source_thread_info = ( context.get('user_message_doc', {}).get('metadata', {}).get( @@ -3898,14 +4897,34 @@ def _claim_authorized_capability_resume_impl( 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() - resumed_external_query = build_resumed_external_query( - request_data.get('message'), - execution_effective_capability_ids, - external_query_mode=external_query_mode, - approved_sensitive_input_types=decision.get('sensitive_input_types') or [], + approved_user_turn_goal = execution_context.get( + 'approved_user_turn_goal' ) + if ( + decision.get('prior_goal_included') is True + and isinstance(approved_user_turn_goal, Mapping) + ): + resumed_external_query = str( + approved_user_turn_goal.get('external_query') or '' + ).strip() + else: + resumed_external_query = build_resumed_external_query( + request_data.get('message'), + execution_effective_capability_ids, + external_query_mode=external_query_mode, + approved_sensitive_input_types=decision.get( + 'sensitive_input_types' + ) or [], + ) if resumed_external_query is not None: request_data['_server_external_query'] = resumed_external_query + if ( + decision.get('contextual_goal_included') is True + and isinstance(approved_user_turn_goal, Mapping) + ): + request_data['_server_contextual_goal_query'] = str( + approved_user_turn_goal.get('contextual_query') or '' + ).strip() capability_origins = build_capability_resume_origins( context['inventory'], @@ -3918,6 +4937,14 @@ def _claim_authorized_capability_resume_impl( ), approved_agent_ref=agent_ref, ) + capability_origins = { + capability_id: origin + for capability_id, origin in capability_origins.items() + if ( + capability_id == 'selected_agent' + or capability_id in execution_effective_capability_ids + ) + } capability_resume_context = { 'conversation_id': conversation_id, 'proposal_id': proposal_id, @@ -3931,10 +4958,14 @@ def _claim_authorized_capability_resume_impl( 'capability_origins': capability_origins, 'capability_inventory': copy.deepcopy(context['inventory']), 'decision': copy.deepcopy(decision), + '_contextual_proposal': copy.deepcopy(claimed_proposal), 'original_proposal': copy.deepcopy( context['provenance'].get('proposed_capabilities') or claimed_proposal ), 'effective_capability_ids': effective_capability_ids, + 'execution_effective_capability_ids': sorted( + execution_effective_capability_ids + ), 'automatic_capability_root_ids': copy.deepcopy( context.get('automatic_capability_root_ids') ), @@ -12676,8 +13707,7 @@ def resolve_streaming_multi_endpoint_gpt_config( ) debug_print( f"[Streaming][Model Resolution] Resolved {selection_source} multi-endpoint model | " - f"provider={provider} | endpoint_id={requested_endpoint_id} | model_id={model_cfg.get('id')} | " - f"deployment={deployment} | api_version={api_version} | protocol={runtime_protocol}" + f"provider_class={provider} | protocol={runtime_protocol}" ) return ( gpt_client, @@ -12964,7 +13994,9 @@ def normalize_terminal_chat_payload(payload): 'locked_contexts': payload.get('locked_contexts', []), 'analysis_coverage': payload.get('analysis_coverage', {}), 'document_action': payload.get('document_action', {}), - 'metadata': payload.get('metadata', {}), + 'metadata': project_chat_metadata_for_client( + payload.get('metadata', {}) + ), }) def _build_document_action_stream_content(event): @@ -13634,6 +14666,59 @@ def execute_document_action_chat_request( if conversation_id is not None: conversation_id = str(conversation_id).strip() or None + document_action_user_message = user_message + if conversation_id: + try: + _authorize_personal_conversation_access(user_id, conversation_id) + _preflight_chat_clarification( + conversation_id=conversation_id, + user_message=user_message, + retry_user_message_id=( + data.get('retry_user_message_id') + or data.get('edited_user_message_id') + ), + ) + except LookupError: + return {'error': 'Conversation not found'}, 404 + except PermissionError: + return {'error': 'Forbidden'}, 403 + except (CapabilityChoiceError, ChatClarificationError) as clarification_error: + return { + 'error': str(clarification_error), + 'code': clarification_error.code, + }, 409 + except Exception: + return { + 'error': 'Failed to validate pending clarification', + }, 500 + if ( + capability_resume_context + and isinstance( + capability_resume_context.get('_contextual_proposal'), + Mapping, + ) + ): + try: + document_action_contextual_goal = ( + _rebuild_claimed_contextual_goal( + capability_resume_context, + conversation_id=conversation_id, + ) + ) + document_action_user_message = str( + (document_action_contextual_goal or {}).get( + 'contextual_query' + ) or user_message + ).strip() + except CapabilityChoiceError as contextual_action_error: + return { + 'error': ( + 'The earlier request is no longer available. ' + 'Send a new message to continue.' + ), + 'code': contextual_action_error.code, + }, 409 + selected_document_id = data.get('selected_document_id') selected_document_ids = data.get('selected_document_ids', []) if not selected_document_ids and selected_document_id: @@ -13822,6 +14907,36 @@ def execute_document_action_chat_request( g.conversation_id = conversation_id _set_authorized_chat_request_context(user_id, conversation_id, action_scope_context) + if isinstance( + (capability_resume_context or {}).get('_contextual_proposal'), + Mapping, + ): + try: + document_action_contextual_goal = ( + _rebuild_claimed_contextual_goal( + capability_resume_context, + conversation_id=conversation_id, + ) + ) + document_action_user_message = str( + (document_action_contextual_goal or {}).get( + 'contextual_query' + ) or '' + ).strip() + if not document_action_user_message: + raise CapabilityChoiceError( + 'approved contextual goal could not be rebuilt', + code='goal_query_empty', + ) + except CapabilityChoiceError as contextual_action_error: + return { + 'error': ( + 'The earlier request is no longer available. ' + 'Send a new message to continue.' + ), + 'code': contextual_action_error.code, + }, 409 + assigned_knowledge_planned = bool( assigned_knowledge_filters and assigned_knowledge_filters.get('has_workspace_knowledge') @@ -13887,7 +15002,7 @@ def execute_document_action_chat_request( else {} ) turn_orchestration_plan = build_turn_orchestration_plan( - user_message, + document_action_user_message, run_id=( capability_resume_context.get('child_run_id') if capability_resume_context @@ -14021,7 +15136,7 @@ def execute_document_action_chat_request( action_evidence_task = build_agent_action_evidence_task( turn_orchestration_plan, turn_evidence_ledger, - user_message, + document_action_user_message, executor_type='selected_action', executor_name=normalized_action.get('type'), capability_metadata=normalized_action, @@ -14183,11 +15298,40 @@ def persist_cancelled_document_action_runtime(): assigned_knowledge_action_context = {} assigned_context_metadata = {} assigned_knowledge_context_citations = [] + if isinstance( + (capability_resume_context or {}).get('_contextual_proposal'), + Mapping, + ): + try: + document_action_contextual_goal = ( + _rebuild_claimed_contextual_goal( + capability_resume_context, + conversation_id=conversation_id, + ) + ) + document_action_user_message = str( + (document_action_contextual_goal or {}).get( + 'contextual_query' + ) or '' + ).strip() + if not document_action_user_message: + raise CapabilityChoiceError( + 'approved contextual goal could not be rebuilt', + code='goal_query_empty', + ) + except CapabilityChoiceError as contextual_action_error: + return { + 'error': ( + 'The earlier request is no longer available. ' + 'Send a new message to continue.' + ), + 'code': contextual_action_error.code, + }, 409 if assigned_knowledge_filters and assigned_knowledge_filters.get('has_workspace_knowledge'): try: assigned_knowledge_action_context = _build_assigned_knowledge_reference_context( assigned_knowledge_filters, - query=user_message, + query=document_action_user_message, user_id=user_id, top_n=ASSIGNED_KNOWLEDGE_CONTEXT_TOP_N, ) @@ -14240,8 +15384,38 @@ def persist_cancelled_document_action_runtime(): elif thought_tracker.enabled: thought_tracker.add_thought('search', assigned_context_thought) + if isinstance( + (capability_resume_context or {}).get('_contextual_proposal'), + Mapping, + ): + try: + document_action_contextual_goal = ( + _rebuild_claimed_contextual_goal( + capability_resume_context, + conversation_id=conversation_id, + ) + ) + document_action_user_message = str( + (document_action_contextual_goal or {}).get( + 'contextual_query' + ) or '' + ).strip() + if not document_action_user_message: + raise CapabilityChoiceError( + 'approved contextual goal could not be rebuilt', + code='goal_query_empty', + ) + except CapabilityChoiceError as contextual_action_error: + return { + 'error': ( + 'The earlier request is no longer available. ' + 'Send a new message to continue.' + ), + 'code': contextual_action_error.code, + }, 409 + workflow_task_prompt = _build_document_action_prompt_with_assigned_knowledge_context( - user_message, + document_action_user_message, assigned_knowledge_action_context.get('context_block'), normalized_action.get('type'), ) @@ -14285,6 +15459,28 @@ def persist_cancelled_document_action_runtime(): try: if document_action_cancel_requested(): return persist_cancelled_document_action_runtime() + if isinstance( + (capability_resume_context or {}).get( + '_contextual_proposal' + ), + Mapping, + ): + final_document_action_goal = ( + _rebuild_claimed_contextual_goal( + capability_resume_context, + conversation_id=conversation_id, + ) + ) + final_document_action_query = str( + (final_document_action_goal or {}).get( + 'contextual_query' + ) or '' + ).strip() + if final_document_action_query != document_action_user_message: + raise CapabilityChoiceError( + 'approved contextual goal changed before execution', + code='goal_source_changed', + ) debug_print( '[ChatDocumentAction] Executing action | ' f'user_id={user_id} | ' @@ -14303,6 +15499,14 @@ def persist_cancelled_document_action_runtime(): ) if document_action_cancel_requested(): return persist_cancelled_document_action_runtime() + except CapabilityChoiceError as contextual_action_error: + return { + 'error': ( + 'The earlier request is no longer available. ' + 'Send a new message to continue.' + ), + 'code': contextual_action_error.code, + }, 409 except Exception as exc: debug_print( '[ChatDocumentAction] Execution failed | ' @@ -14722,7 +15926,9 @@ def persist_cancelled_document_action_runtime(): 'analysis_coverage': execution_result.get('analysis_coverage') or {}, 'document_action': normalized_action, 'token_usage': execution_result.get('token_usage'), - 'metadata': assistant_doc.get('metadata', {}), + 'metadata': project_chat_metadata_for_client( + assistant_doc.get('metadata', {}) + ), }), 200 def execute_analyze_chat_request( @@ -14762,8 +15968,38 @@ def chat_document_action_stream_api(): capability_resume_proposal_id = str( data.get('capability_resume_proposal_id') or '' ).strip() + requested_conversation_id = str( + data.get('conversation_id') or '' + ).strip() or None + if requested_conversation_id: + try: + _authorize_personal_conversation_access( + user_id, + requested_conversation_id, + ) + _preflight_chat_clarification( + conversation_id=requested_conversation_id, + user_message=data.get('message'), + retry_user_message_id=( + data.get('retry_user_message_id') + or data.get('edited_user_message_id') + ), + ) + except LookupError: + return jsonify({'error': 'Conversation not found'}), 404 + except PermissionError: + return jsonify({'error': 'Forbidden'}), 403 + except (CapabilityChoiceError, ChatClarificationError) as clarification_error: + return jsonify({ + 'error': str(clarification_error), + 'code': clarification_error.code, + }), 409 + except Exception: + return jsonify({ + 'error': 'Failed to validate pending clarification', + }), 500 if capability_resume_proposal_id: - resume_conversation_id = str(data.get('conversation_id') or '').strip() + resume_conversation_id = requested_conversation_id if not resume_conversation_id: return jsonify({'error': 'conversation_id is required for capability resume'}), 400 current_user_info = get_current_user_info() or {} @@ -15006,6 +16242,30 @@ def chat_analyze_stream_api(): conversation_id = getattr(g, 'conversation_id', None) or data.get('conversation_id') if conversation_id is not None: conversation_id = str(conversation_id).strip() or None + if conversation_id: + try: + _authorize_personal_conversation_access(user_id, conversation_id) + _preflight_chat_clarification( + conversation_id=conversation_id, + user_message=data.get('message'), + retry_user_message_id=( + data.get('retry_user_message_id') + or data.get('edited_user_message_id') + ), + ) + except LookupError: + return jsonify({'error': 'Conversation not found'}), 404 + except PermissionError: + return jsonify({'error': 'Forbidden'}), 403 + except (CapabilityChoiceError, ChatClarificationError) as clarification_error: + return jsonify({ + 'error': str(clarification_error), + 'code': clarification_error.code, + }), 409 + except Exception: + return jsonify({ + 'error': 'Failed to validate pending clarification', + }), 500 if not conversation_id: conversation_id = str(uuid.uuid4()) data['conversation_id'] = conversation_id @@ -15070,6 +16330,19 @@ def generate_image_from_proposal(): return jsonify({'error': 'conversation_id is required'}), 400 conversation_item = _authorize_personal_conversation_access(user_id, conversation_id) + try: + _preflight_chat_clarification( + conversation_id=conversation_id, + user_message=( + data.get('prompt') + or (data.get('proposal') or {}).get('prompt') + ), + ) + except (CapabilityChoiceError, ChatClarificationError) as clarification_error: + return jsonify({ + 'error': str(clarification_error), + 'code': clarification_error.code, + }), 409 proposal_payload = data.get('proposal') if isinstance(data.get('proposal'), dict) else dict(data) if data.get('prompt'): @@ -15236,6 +16509,12 @@ def decide_chat_capability_proposal(proposal_id): ), selected_agent_present=context.get('selected_agent_present') is True, baseline_error_code=context.get('baseline_error_code'), + source_lineage_validator=lambda stored_proposal: ( + _rebuild_authorized_contextual_goal( + stored_proposal, + conversation_id=conversation_id, + ) + ), ) decision = proposal.get('decision') or {} effective_capability_ids = list(decision.get('effective_capability_ids') or []) @@ -15295,10 +16574,27 @@ def decide_chat_capability_proposal(proposal_id): ), level=logging.WARNING, ) + if exc.code.startswith('goal_'): + try: + persist_capability_invalidation( + cosmos_messages_container, + conversation_id=conversation_id, + proposal_id=proposal_id, + reason=exc.code, + ) + except Exception as invalidation_error: + log_event( + '[CapabilityPlanner] Contextual decision invalidation failed', + extra={ + 'error_type': type(invalidation_error).__name__, + }, + level=logging.ERROR, + ) status_code = 409 if ( exc.code in conflict_codes or exc.code.startswith('capability_') or exc.code.startswith('agent_') + or exc.code.startswith('goal_') ) else 400 return jsonify({'error': str(exc), 'code': exc.code}), status_code except Exception as exc: @@ -15360,6 +16656,33 @@ def chat_api(server_request_data=None, server_resume_context=None): conversation_id = getattr(g, 'conversation_id', None) or data.get('conversation_id') if conversation_id is not None: conversation_id = str(conversation_id).strip() or None + if conversation_id: + try: + _authorize_personal_conversation_access( + user_id, + conversation_id, + ) + _preflight_chat_clarification( + conversation_id=conversation_id, + user_message=user_message, + retry_user_message_id=( + data.get('retry_user_message_id') + or data.get('edited_user_message_id') + ), + ) + except LookupError: + return jsonify({'error': 'Conversation not found'}), 404 + except PermissionError: + return jsonify({'error': 'Forbidden'}), 403 + except (CapabilityChoiceError, ChatClarificationError) as clarification_error: + return jsonify({ + 'error': str(clarification_error), + 'code': clarification_error.code, + }), 409 + except Exception: + return jsonify({ + 'error': 'Failed to validate pending clarification', + }), 500 hybrid_search_enabled = data.get('hybrid_search') web_search_enabled = data.get('web_search_enabled') url_access_enabled = data.get('url_access_enabled') @@ -15625,24 +16948,45 @@ def result_requires_message_reload(result: Any) -> bool: assigned_knowledge_filters, ASSIGNED_KNOWLEDGE_WEB_SOURCE_MODE_DEEP_RESEARCH, ) - if assigned_knowledge_url_review_urls and not is_url_access_enabled_for_user( - settings, - user_roles=current_user_roles, + if ( + assigned_knowledge_url_review_urls + and data.get('_server_contextual_egress_declined') is not True + and not is_url_access_enabled_for_user( + settings, + user_roles=current_user_roles, + ) ): return jsonify({ 'error': 'This agent has assigned URL sources, but URL Access is not available for your account.' }), 403 - if assigned_knowledge_deep_research_urls and not source_review_allowed_for_user: + if ( + assigned_knowledge_deep_research_urls + and data.get('_server_contextual_egress_declined') is not True + and not source_review_allowed_for_user + ): return jsonify({ 'error': 'This agent has assigned Deep Research sources, but Deep Research is not available for your account.' }), 403 - if assigned_knowledge_url_review_urls or assigned_knowledge_deep_research_urls: + if ( + data.get('_server_contextual_egress_declined') is not True + and ( + assigned_knowledge_url_review_urls + or assigned_knowledge_deep_research_urls + ) + ): source_review_enabled = True if assigned_knowledge_deep_research_urls: deep_research_enabled = True g.assigned_knowledge_context = assigned_knowledge_filters g.assigned_knowledge_user_context_active = assigned_knowledge_user_context_active + if data.get('_server_contextual_egress_declined') is True: + web_search_enabled = False + url_access_enabled = False + source_review_enabled = False + deep_research_enabled = False + assigned_knowledge_url_review_urls = [] + assigned_knowledge_deep_research_urls = [] explicit_external_retrieval_requested = _is_explicit_external_retrieval_requested( web_search_enabled=web_search_enabled, url_access_enabled=url_access_enabled, @@ -15878,18 +17222,11 @@ def result_requires_message_reload(result: Any) -> bool: compatibility_resume_context = trusted_capability_resume_context compatibility_selected_capability_ids = ( - [ - capability.get('id') - for capability in ( - compatibility_resume_context.get( - 'capability_inventory', {} - ).get('capabilities', []) - if compatibility_resume_context - else [] - ) - if isinstance(capability, Mapping) - and capability.get('state') == 'selected' - ] + list( + compatibility_resume_context.get( + 'execution_effective_capability_ids' + ) or [] + ) if compatibility_resume_context else _get_selected_builtin_chat_capability_ids(data) ) @@ -15945,6 +17282,13 @@ def result_requires_message_reload(result: Any) -> bool: web_search_enabled = True source_review_enabled = True deep_research_enabled = True + if data.get('_server_contextual_egress_declined') is True: + web_search_enabled = False + url_access_enabled = False + source_review_enabled = False + deep_research_enabled = False + assigned_knowledge_url_review_urls = [] + assigned_knowledge_deep_research_urls = [] compatibility_selection_snapshot = { 'conversation_id': conversation_id, 'capability_ids': list(compatibility_selected_capability_ids), @@ -17482,6 +18826,56 @@ def record_tabular_post_processing_thought(thought_payload): detail=f"files={tabular_filenames_str}" ) + if data.get('_server_contextual_egress_declined') is True: + web_search_enabled = False + url_access_enabled = False + source_review_enabled = False + deep_research_enabled = False + assigned_knowledge_url_review_urls = [] + assigned_knowledge_deep_research_urls = [] + legacy_contextual_external_execution = bool( + capability_resume_context + and ( + web_search_enabled + or source_review_enabled + or deep_research_enabled + or url_access_enabled + ) + and ( + capability_resume_context.get('decision') or {} + ).get('prior_goal_included') is True + ) + if legacy_contextual_external_execution: + try: + legacy_pre_execution_goal = ( + _rebuild_claimed_contextual_goal( + capability_resume_context, + conversation_id=conversation_id, + ) + ) + rebuilt_external_query = str( + (legacy_pre_execution_goal or {}).get( + 'external_query' + ) or '' + ).strip() + if not rebuilt_external_query: + raise CapabilityChoiceError( + 'approved external goal could not be rebuilt', + code='goal_query_empty', + ) + external_retrieval_message = rebuilt_external_query + web_search_query_text = build_web_search_query_text( + rebuilt_external_query + ) + except CapabilityChoiceError as contextual_execution_error: + return jsonify({ + 'error': ( + 'The approved earlier request is no longer ' + 'available. Send a new message to continue.' + ), + 'code': contextual_execution_error.code, + }), 409 + if web_search_enabled: search_thought_label = 'deep_research' if deep_research_enabled else 'web_search' search_thought_text = "Planning Deep Research web searches" if deep_research_enabled else f"Searching the web for '{web_search_query_text[:50]}'" @@ -18968,7 +20362,9 @@ def gpt_error(e): 'source_review': compact_source_review_result_for_metadata(source_review_result), 'deep_research': deep_research_result, 'agent_citations': prepared_agent_citations, - 'metadata': assistant_doc.get('metadata', {}), + 'metadata': project_chat_metadata_for_client( + assistant_doc.get('metadata', {}) + ), 'reload_messages': reload_messages_required, 'kernel_fallback_notice': kernel_fallback_notice, 'thoughts_enabled': thought_tracker.enabled @@ -19037,6 +20433,72 @@ def chat_stream_api(): capability_resume_proposal_id = str( (data or {}).get('capability_resume_proposal_id') or '' ).strip() + retry_user_message_id = data.get('retry_user_message_id') or data.get('edited_user_message_id') + retry_thread_id = data.get('retry_thread_id') + retry_thread_attempt = data.get('retry_thread_attempt') + is_retry = bool(retry_user_message_id) + is_edit = bool(data.get('edited_user_message_id')) + compatibility_mode = bool(data.get('image_generation')) or is_retry + requested_conversation_id = str(data.get('conversation_id') or '').strip() or None + clarification_response_retry = False + + if requested_conversation_id: + try: + _authorize_personal_conversation_access( + user_id, + requested_conversation_id, + ) + clarification_preflight = _preflight_chat_clarification( + conversation_id=requested_conversation_id, + user_message=data.get('message'), + retry_user_message_id=retry_user_message_id, + allow_pending_response=( + not compatibility_mode + and not capability_resume_proposal_id + ), + allow_exact_response_retry=( + compatibility_mode + and not capability_resume_proposal_id + ), + ) + if clarification_preflight.get('exact_response_retry'): + clarification_response_retry = True + compatibility_mode = False + preflight_clarification = clarification_preflight.get( + 'clarification' + ) + if ( + isinstance(preflight_clarification, Mapping) + and ( + clarification_response_retry + or ( + preflight_clarification.get('status') == 'pending' + and not compatibility_mode + ) + ) + and not capability_resume_proposal_id + ): + data['_server_expected_clarification_id'] = str( + preflight_clarification.get('clarification_id') + or '' + ).strip() + except LookupError: + return jsonify({'error': 'Conversation not found'}), 404 + except PermissionError: + return jsonify({'error': 'Forbidden'}), 403 + except (CapabilityChoiceError, ChatClarificationError) as clarification_error: + return jsonify({ + 'error': str(clarification_error), + 'code': clarification_error.code, + }), 409 + except Exception as exc: + debug_print( + '[Streaming] Clarification preflight failed | ' + f'error_type={type(exc).__name__}' + ) + return jsonify({ + 'error': 'Failed to validate pending clarification', + }), 500 if capability_resume_proposal_id: resume_conversation_id = str((data or {}).get('conversation_id') or '').strip() if not resume_conversation_id: @@ -19090,38 +20552,6 @@ def chat_stream_api(): ) else 400 return jsonify({'error': str(exc), 'code': exc.code}), status_code - retry_user_message_id = data.get('retry_user_message_id') or data.get('edited_user_message_id') - retry_thread_id = data.get('retry_thread_id') - retry_thread_attempt = data.get('retry_thread_attempt') - is_retry = bool(retry_user_message_id) - is_edit = bool(data.get('edited_user_message_id')) - - compatibility_mode = bool(data.get('image_generation')) or is_retry - requested_conversation_id = str(data.get('conversation_id') or '').strip() or None - - if requested_conversation_id: - try: - _authorize_personal_conversation_access(user_id, requested_conversation_id) - except LookupError: - _release_capability_resume_claim_context( - trusted_capability_resume_context, - 'conversation_not_found', - ) - return jsonify({'error': 'Conversation not found'}), 404 - except PermissionError: - _release_capability_resume_claim_context( - trusted_capability_resume_context, - 'conversation_forbidden', - ) - return jsonify({'error': 'Forbidden'}), 403 - except Exception as exc: - _release_capability_resume_claim_context( - trusted_capability_resume_context, - type(exc).__name__, - ) - debug_print(f"[Streaming] Error authorizing conversation {requested_conversation_id}: {exc}") - return jsonify({'error': 'Failed to authorize conversation'}), 500 - try: initial_scope_context = _get_authorized_chat_scope_context( user_id, @@ -19224,7 +20654,9 @@ def normalize_legacy_chat_payload(payload): 'kernel_fallback_notice': payload.get('kernel_fallback_notice'), 'thoughts_enabled': payload.get('thoughts_enabled', False), 'blocked': payload.get('blocked', False), - 'metadata': payload.get('metadata', {}), + 'metadata': project_chat_metadata_for_client( + payload.get('metadata', {}) + ), }) def generate_compatibility_response(): @@ -19349,32 +20781,105 @@ def generate_compatibility_response(): def generate(publish_background_event=None): capability_resume_context = trusted_capability_resume_context resume_terminalized = capability_resume_context is None + clarification_terminalized = False def complete_stream_capability_resume(assistant_message_id): nonlocal resume_terminalized - if resume_terminalized or not capability_resume_context: - return - try: - persist_capability_resume_completion( - cosmos_messages_container, - conversation_id=finalized_conversation_id, - proposal_id=capability_resume_context.get('proposal_id'), - execution_id=capability_resume_context.get('execution_id'), - assistant_message_id=assistant_message_id, - ) - resume_terminalized = True - except Exception as resume_completion_error: - resume_terminalized = True - log_event( - '[CapabilityDiscovery] Resume completion persistence failed', - extra={ - 'conversation_id': finalized_conversation_id, - 'proposal_id': capability_resume_context.get('proposal_id'), - 'error_type': type(resume_completion_error).__name__, - }, - level=logging.ERROR, - exceptionTraceback=True, - ) + nonlocal clarification_terminalized + nonlocal resolved_chat_clarification + if ( + not clarification_terminalized + and isinstance(resolved_chat_clarification, Mapping) + and resolved_chat_clarification.get('status') == 'resolving' + ): + try: + persist_stream_user_message(user_metadata) + ( + _, + resolved_chat_clarification, + clarification_idempotent, + ) = persist_chat_clarification_response_completion( + cosmos_messages_container, + conversation_id=finalized_conversation_id, + clarification_id=( + resolved_chat_clarification.get( + 'clarification_id' + ) + ), + response_user_message_id=( + resolved_chat_clarification.get( + '_response_user_message_id' + ) + ), + child_run_id=resolved_chat_clarification.get( + 'child_run_id' + ), + expected_claimed_at=( + resolved_chat_clarification.get( + 'claimed_at' + ) + ), + response_validator=lambda claimed: ( + _validate_current_claimed_clarification_response( + conversation_id=( + finalized_conversation_id + ), + clarification=claimed, + ) + ), + ) + clarification_terminalized = True + log_event( + '[CapabilityPlanner] Clarification child completed', + extra=build_clarification_evaluation_event( + resolved_chat_clarification, + lifecycle='resolved', + idempotent=clarification_idempotent, + ), + ) + except ChatClarificationError as clarification_completion_error: + log_event( + '[CapabilityPlanner] Clarification completion rejected', + extra={ + 'error_type': type( + clarification_completion_error + ).__name__, + }, + level=logging.ERROR, + ) + raise + except Exception as clarification_completion_error: + log_event( + '[CapabilityPlanner] Clarification completion deferred', + extra={ + 'error_type': type( + clarification_completion_error + ).__name__, + }, + level=logging.ERROR, + ) + if not resume_terminalized and capability_resume_context: + try: + persist_capability_resume_completion( + cosmos_messages_container, + conversation_id=finalized_conversation_id, + proposal_id=capability_resume_context.get('proposal_id'), + execution_id=capability_resume_context.get('execution_id'), + assistant_message_id=assistant_message_id, + ) + resume_terminalized = True + except Exception as resume_completion_error: + resume_terminalized = True + log_event( + '[CapabilityDiscovery] Resume completion persistence failed', + extra={ + 'conversation_id': finalized_conversation_id, + 'proposal_id': capability_resume_context.get('proposal_id'), + 'error_type': type(resume_completion_error).__name__, + }, + level=logging.ERROR, + exceptionTraceback=True, + ) def fail_stream_capability_resume(error_type): nonlocal resume_terminalized @@ -19414,6 +20919,85 @@ def stream_cancel_requested(): # Extract request parameters (same as non-streaming endpoint) user_message = data.get('message', '') conversation_id = finalized_conversation_id + early_contextual_goal = None + early_contextual_proposal = ( + capability_resume_context.get('_contextual_proposal') + if capability_resume_context + else None + ) + if isinstance(early_contextual_proposal, Mapping): + try: + early_contextual_goal = ( + _rebuild_authorized_contextual_goal( + early_contextual_proposal, + conversation_id=conversation_id, + ) + ) + early_contextual_decision = ( + capability_resume_context.get('decision') or {} + ) + if early_contextual_decision.get( + 'contextual_goal_included' + ) is True: + contextual_query = str( + (early_contextual_goal or {}).get( + 'contextual_query' + ) or '' + ).strip() + if not contextual_query: + raise CapabilityChoiceError( + 'approved contextual goal could not be rebuilt', + code='goal_query_empty', + ) + data['_server_contextual_goal_query'] = contextual_query + if early_contextual_decision.get( + 'prior_goal_included' + ) is True: + external_query = str( + (early_contextual_goal or {}).get( + 'external_query' + ) or '' + ).strip() + if not external_query: + raise CapabilityChoiceError( + 'approved external goal could not be rebuilt', + code='goal_query_empty', + ) + data['_server_external_query'] = external_query + except Exception as contextual_source_error: + error_code = ( + contextual_source_error.code + if isinstance( + contextual_source_error, + CapabilityChoiceError, + ) + else 'goal_reconstruction_failed' + ) + persist_capability_invalidation( + cosmos_messages_container, + conversation_id=conversation_id, + proposal_id=capability_resume_context.get( + 'proposal_id' + ), + reason=error_code, + expected_execution_id=capability_resume_context.get( + 'execution_id' + ), + ) + resume_terminalized = True + log_event( + '[CapabilityPlanner] Contextual goal rejected at worker entry', + extra={'failure_code': error_code}, + level=logging.WARNING, + ) + yield f"data: {json.dumps({ + 'error': ( + 'The earlier request is no longer available. ' + 'Send a new message to continue.' + ), + 'code': error_code, + })}\n\n" + return hybrid_search_enabled = data.get('hybrid_search') web_search_enabled = data.get('web_search_enabled') url_access_enabled = data.get('url_access_enabled') @@ -19551,7 +21135,10 @@ def stream_cancel_requested(): scope_type = 'group' if chat_type == 'group' else 'user' # Initialize variables - search_query = user_message + search_query = str( + data.get('_server_contextual_goal_query') + or user_message + ).strip() external_retrieval_message = resolve_external_retrieval_message( data, user_message, @@ -19606,11 +21193,17 @@ def stream_cancel_requested(): requested_url_access_enabled = bool(url_access_enabled) requested_source_review_enabled = bool(source_review_enabled) requested_deep_research_enabled = bool(deep_research_enabled) - prompt_urls = extract_urls_from_text(user_message) + url_access_input_message = str( + data.get('_server_contextual_goal_query') + or user_message + ).strip() + prompt_urls = extract_urls_from_text( + url_access_input_message + ) url_access_requested = bool(url_access_enabled) if url_access_requested: url_access_validation = validate_url_access_request( - user_message, + url_access_input_message, settings, URL_ACCESS_CONTEXT_CHAT, user_roles=current_user_roles, @@ -19708,16 +21301,30 @@ def stream_cancel_requested(): assigned_knowledge_filters, ASSIGNED_KNOWLEDGE_WEB_SOURCE_MODE_DEEP_RESEARCH, ) - if assigned_knowledge_url_review_urls and not is_url_access_enabled_for_user( - settings, - user_roles=current_user_roles, + if ( + assigned_knowledge_url_review_urls + and data.get('_server_contextual_egress_declined') is not True + and not is_url_access_enabled_for_user( + settings, + user_roles=current_user_roles, + ) ): yield f"data: {json.dumps({'error': 'This agent has assigned URL sources, but URL Access is not available for your account.'})}\n\n" return - if assigned_knowledge_deep_research_urls and not source_review_allowed_for_user: + if ( + assigned_knowledge_deep_research_urls + and data.get('_server_contextual_egress_declined') is not True + and not source_review_allowed_for_user + ): yield f"data: {json.dumps({'error': 'This agent has assigned Deep Research sources, but Deep Research is not available for your account.'})}\n\n" return - if assigned_knowledge_url_review_urls or assigned_knowledge_deep_research_urls: + if ( + data.get('_server_contextual_egress_declined') is not True + and ( + assigned_knowledge_url_review_urls + or assigned_knowledge_deep_research_urls + ) + ): source_review_enabled = True if assigned_knowledge_deep_research_urls: deep_research_enabled = True @@ -19731,6 +21338,13 @@ def stream_cancel_requested(): f"public_workspaces={len(effective_active_public_workspace_ids)} | " f"tags={len(tags_filter)}" ) + if data.get('_server_contextual_egress_declined') is True: + web_search_enabled = False + url_access_enabled = False + source_review_enabled = False + deep_research_enabled = False + assigned_knowledge_url_review_urls = [] + assigned_knowledge_deep_research_urls = [] explicit_external_retrieval_requested = _is_explicit_external_retrieval_requested( web_search_enabled=web_search_enabled, url_access_enabled=url_access_enabled, @@ -19861,59 +21475,745 @@ def build_streaming_capability_usage(): credential, cognitive_services_scope ) - gpt_client = AzureOpenAI( - api_version=api_version, - azure_endpoint=endpoint, - azure_ad_token_provider=token_provider + gpt_client = AzureOpenAI( + api_version=api_version, + azure_endpoint=endpoint, + azure_ad_token_provider=token_provider + ) + else: + gpt_client = AzureOpenAI( + api_version=api_version, + azure_endpoint=endpoint, + api_key=settings.get('azure_openai_gpt_key') + ) + + if not gpt_client or not gpt_model: + yield f"data: {json.dumps({'error': 'Failed to initialize AI model'})}\n\n" + return + + tabular_model_context = build_model_endpoint_context( + provider=gpt_provider, + endpoint=gpt_endpoint, + auth=gpt_auth, + api_version=gpt_api_version, + endpoint_id=gpt_endpoint_id or frontend_model_endpoint_id, + model_id=gpt_model_id or frontend_model_id, + model_deployment=gpt_model, + user_id=user_id, + active_group_ids=active_group_ids, + ) + + debug_print( + "[Streaming] Initialized model client | " + f"model={gpt_model} | provider={gpt_provider or 'legacy'} | " + f"endpoint_id={frontend_model_endpoint_id or ''} | api_version={gpt_api_version or ''} | " + f"enable_gpt_apim={enable_gpt_apim}" + ) + + except Exception as e: + yield f"data: {json.dumps({'error': f'Model initialization failed: {str(e)}'})}\n\n" + return + + # Load or create conversation (simplified) + if is_new_stream_conversation: + conversation_item = _create_personal_conversation(user_id, conversation_id=conversation_id) + debug_print(f"[Streaming] Created new conversation {conversation_id}") + else: + try: + conversation_item = _authorize_personal_conversation_access(user_id, conversation_id) + debug_print(f"[Streaming] Loaded existing conversation {conversation_id}") + except LookupError: + yield f"data: {json.dumps({'error': 'Conversation not found'})}\n\n" + return + except PermissionError: + yield f"data: {json.dumps({'error': 'Forbidden'})}\n\n" + return + + capability_dialogue_context_state = { + 'prior_user_messages': [], + 'predecessor_thread_id': None, + } + pending_chat_clarification = None + if not capability_resume_context: + try: + expected_clarification_id = str( + data.get('_server_expected_clarification_id') + or '' + ).strip() + worker_clarification_preflight = ( + _preflight_chat_clarification( + conversation_id=conversation_id, + user_message=user_message, + retry_user_message_id=( + retry_user_message_id + if clarification_response_retry + else None + ), + allow_pending_response=( + not clarification_response_retry + ), + allow_exact_response_retry=( + clarification_response_retry + ), + expected_clarification_id=( + expected_clarification_id or None + ), + ) + ) + targeted_clarification = ( + worker_clarification_preflight.get( + 'clarification' + ) + if clarification_response_retry + else None + ) + targeted_response = ( + worker_clarification_preflight.get( + 'latest_user_message' + ) + if clarification_response_retry + else None + ) + if ( + isinstance(targeted_clarification, Mapping) + and targeted_clarification.get('status') + == 'resolved' + ): + log_event( + '[CapabilityPlanner] Clarification response reconciled', + extra=build_clarification_evaluation_event( + targeted_clarification, + lifecycle='resolved', + idempotent=True, + ), + ) + yield f"data: {json.dumps({ + 'done': True, + 'conversation_id': conversation_id, + 'full_content': '', + 'reload_messages': True, + 'clarification_replayed': True, + })}\n\n" + return + if ( + isinstance(targeted_clarification, Mapping) + and targeted_clarification.get('status') + == 'resolving' + and isinstance(targeted_response, Mapping) + ): + targeted_source = validate_chat_clarification_source( + cosmos_messages_container, + conversation_id=conversation_id, + clarification=targeted_clarification, + ) + capability_dialogue_context_state = { + 'prior_user_messages': [ + targeted_source, + targeted_response, + ], + 'predecessor_thread_id': str( + targeted_response.get('metadata', {}).get( + 'thread_info', {} + ).get('thread_id') or '' + ).strip() or None, + } + pending_chat_clarification = targeted_clarification + else: + capability_dialogue_context_state = ( + load_bounded_prior_user_turns( + cosmos_messages_container, + conversation_id=conversation_id, + ) + ) + predecessor_thread_id = ( + capability_dialogue_context_state.get( + 'predecessor_thread_id' + ) + ) + if pending_chat_clarification: + pass + elif expected_clarification_id: + ( + _, + pending_chat_clarification, + ) = read_chat_clarification_message( + cosmos_messages_container, + conversation_id=conversation_id, + clarification_id=expected_clarification_id, + ) + if pending_chat_clarification.get( + 'status' + ) not in {'pending', 'resolving'}: + raise ChatClarificationError( + 'clarification answer was already handled', + code='clarification_response_conflict', + ) + elif predecessor_thread_id: + ( + _, + pending_chat_clarification, + ) = find_pending_chat_clarification( + cosmos_messages_container, + conversation_id=conversation_id, + source_thread_id=predecessor_thread_id, + ) + if pending_chat_clarification and ( + clarification_is_expired( + pending_chat_clarification + ) + ): + persist_chat_clarification_expiry( + cosmos_messages_container, + conversation_id=conversation_id, + clarification_id=( + pending_chat_clarification.get( + 'clarification_id' + ) + ), + ) + log_event( + '[CapabilityPlanner] Clarification expired', + extra=build_clarification_evaluation_event( + pending_chat_clarification, + lifecycle='expired', + ), + ) + raise ChatClarificationError( + 'clarification has expired', + code='clarification_expired', + ) + elif pending_chat_clarification: + validate_chat_clarification_source( + cosmos_messages_container, + conversation_id=conversation_id, + clarification=pending_chat_clarification, + ) + if ( + pending_chat_clarification.get('status') + == 'resolving' + and _find_persisted_clarification_child_output( + conversation_id=conversation_id, + child_run_id=( + pending_chat_clarification.get( + 'child_run_id' + ) + ), + ) + ): + ( + _, + pending_chat_clarification, + _, + ) = persist_chat_clarification_response_completion( + cosmos_messages_container, + conversation_id=conversation_id, + clarification_id=( + pending_chat_clarification.get( + 'clarification_id' + ) + ), + response_user_message_id=( + pending_chat_clarification.get( + '_response_user_message_id' + ) + ), + child_run_id=( + pending_chat_clarification.get( + 'child_run_id' + ) + ), + expected_claimed_at=( + pending_chat_clarification.get( + 'claimed_at' + ) + ), + response_validator=lambda claimed: ( + _validate_current_claimed_clarification_response( + conversation_id=conversation_id, + clarification=claimed, + ) + ), + ) + if not pending_chat_clarification: + latest_user_message = next( + reversed( + capability_dialogue_context_state.get( + 'prior_user_messages' + ) or [] + ), + None, + ) + latest_user_metadata = ( + latest_user_message.get('metadata') + if isinstance(latest_user_message, Mapping) + and isinstance( + latest_user_message.get('metadata'), + Mapping, + ) + else {} ) - else: - gpt_client = AzureOpenAI( - api_version=api_version, - azure_endpoint=endpoint, - api_key=settings.get('azure_openai_gpt_key') + latest_clarification_response = ( + latest_user_metadata.get( + 'clarification_response' + ) + if isinstance( + latest_user_metadata.get( + 'clarification_response' + ), + Mapping, + ) + else {} ) - - if not gpt_client or not gpt_model: - yield f"data: {json.dumps({'error': 'Failed to initialize AI model'})}\n\n" + clarification_id = str( + latest_clarification_response.get( + '_clarification_id' + ) or '' + ).strip() + if ( + clarification_id + and clarification_response_retry + and str(retry_user_message_id or '').strip() + == str( + (latest_user_message or {}).get('id') + or '' + ).strip() + ): + ( + _, + retry_clarification, + ) = read_chat_clarification_message( + cosmos_messages_container, + conversation_id=conversation_id, + clarification_id=clarification_id, + ) + if chat_clarification_response_matches( + retry_clarification, + user_message, + ): + validate_chat_clarification_source( + cosmos_messages_container, + conversation_id=conversation_id, + clarification=retry_clarification, + ) + response_user_message_id = str( + retry_clarification.get( + '_response_user_message_id' + ) or '' + ).strip() + child_run_id = str( + retry_clarification.get( + 'child_run_id' + ) or '' + ).strip() + if ( + retry_clarification.get('status') + == 'resolving' + and response_user_message_id + == str( + latest_user_message.get('id') + or '' + ).strip() + and child_run_id + ): + child_output = ( + _find_persisted_clarification_child_output( + conversation_id=conversation_id, + child_run_id=child_run_id, + ) + ) + if child_output: + ( + _, + retry_clarification, + _, + ) = persist_chat_clarification_response_completion( + cosmos_messages_container, + conversation_id=conversation_id, + clarification_id=clarification_id, + response_user_message_id=( + response_user_message_id + ), + child_run_id=child_run_id, + expected_claimed_at=( + retry_clarification.get( + 'claimed_at' + ) + ), + response_validator=lambda claimed: ( + _validate_current_claimed_clarification_response( + conversation_id=( + conversation_id + ), + clarification=claimed, + ) + ), + ) + else: + pending_chat_clarification = ( + retry_clarification + ) + if retry_clarification.get('status') == 'resolved': + log_event( + '[CapabilityPlanner] Clarification response reconciled', + extra=build_clarification_evaluation_event( + retry_clarification, + lifecycle='resolved', + idempotent=True, + ), + ) + yield f"data: {json.dumps({ + 'done': True, + 'conversation_id': conversation_id, + 'full_content': '', + 'reload_messages': True, + 'clarification_replayed': True, + })}\n\n" + return + log_event( + '[CapabilityPlanner] Contextual planning context assembled', + extra={ + 'prior_user_turn_count': len( + capability_dialogue_context_state.get( + 'prior_user_messages' + ) or [] + ), + 'clarification_pending': bool( + pending_chat_clarification + ), + }, + ) + except (CapabilityChoiceError, ChatClarificationError) as context_error: + log_event( + '[CapabilityPlanner] Contextual planning context rejected', + extra={'failure_code': context_error.code}, + level=logging.WARNING, + ) + yield f"data: {json.dumps({ + 'error': str(context_error), + 'code': context_error.code, + })}\n\n" + return + except Exception as context_error: + log_event( + '[CapabilityPlanner] Contextual planning context unavailable', + extra={ + 'error_type': type(context_error).__name__, + }, + level=logging.WARNING, + ) + yield f"data: {json.dumps({ + 'error': 'Contextual planning state is unavailable', + 'code': 'contextual_planning_unavailable', + })}\n\n" return - tabular_model_context = build_model_endpoint_context( - provider=gpt_provider, - endpoint=gpt_endpoint, - auth=gpt_auth, - api_version=gpt_api_version, - endpoint_id=gpt_endpoint_id or frontend_model_endpoint_id, - model_id=gpt_model_id or frontend_model_id, - model_deployment=gpt_model, - user_id=user_id, - active_group_ids=active_group_ids, + preallocated_user_message_id = None + preallocated_user_thread_id = None + clarification_child_run_id = None + resolved_chat_clarification = None + clarification_response_idempotent = False + claimed_clarification_user_doc = None + if pending_chat_clarification: + recovering_clarification_response = ( + pending_chat_clarification.get('status') == 'resolving' ) - - debug_print( - "[Streaming] Initialized model client | " - f"model={gpt_model} | provider={gpt_provider or 'legacy'} | " - f"endpoint_id={frontend_model_endpoint_id or ''} | api_version={gpt_api_version or ''} | " - f"enable_gpt_apim={enable_gpt_apim}" + if ( + recovering_clarification_response + and not clarification_response_retry + ): + yield f"data: {json.dumps({ + 'error': ( + 'Clarification response is already being ' + 'processed' + ), + 'code': 'clarification_response_in_progress', + })}\n\n" + return + if recovering_clarification_response: + try: + capability_dialogue_context_state = ( + _prepare_clarification_recovery_context( + capability_dialogue_context_state, + pending_chat_clarification, + ) + ) + except ChatClarificationError as recovery_error: + _invalidate_chat_clarification_checkpoint( + conversation_id=conversation_id, + clarification=pending_chat_clarification, + reason=recovery_error.code, + ) + yield f"data: {json.dumps({ + 'error': str(recovery_error), + 'code': recovery_error.code, + })}\n\n" + return + preallocated_user_message_id = str( + pending_chat_clarification.get( + '_response_user_message_id' + ) or '' + ).strip() if recovering_clarification_response else ( + f"{conversation_id}_user_{int(time.time())}_" + f"{random.randint(1000,9999)}" ) - - except Exception as e: - yield f"data: {json.dumps({'error': f'Model initialization failed: {str(e)}'})}\n\n" - return - - # Load or create conversation (simplified) - if is_new_stream_conversation: - conversation_item = _create_personal_conversation(user_id, conversation_id=conversation_id) - debug_print(f"[Streaming] Created new conversation {conversation_id}") - else: + preallocated_user_thread_id = str( + pending_chat_clarification.get( + '_response_thread_id' + ) or '' + ).strip() if recovering_clarification_response else str( + uuid.uuid4() + ) + clarification_child_run_id = str( + pending_chat_clarification.get('child_run_id') or '' + ).strip() if recovering_clarification_response else str( + uuid.uuid4() + ) + if not all(( + preallocated_user_message_id, + preallocated_user_thread_id, + clarification_child_run_id, + )): + _invalidate_chat_clarification_checkpoint( + conversation_id=conversation_id, + clarification=pending_chat_clarification, + reason='clarification_response_claim_mismatch', + ) + yield f"data: {json.dumps({ + 'error': 'Clarification response state is incomplete', + 'code': 'clarification_response_claim_mismatch', + })}\n\n" + return try: - conversation_item = _authorize_personal_conversation_access(user_id, conversation_id) - debug_print(f"[Streaming] Loaded existing conversation {conversation_id}") - except LookupError: - yield f"data: {json.dumps({'error': 'Conversation not found'})}\n\n" + ( + _, + resolved_chat_clarification, + clarification_response_idempotent, + ) = persist_chat_clarification_response_claim( + cosmos_messages_container, + conversation_id=conversation_id, + clarification_id=pending_chat_clarification.get( + 'clarification_id' + ), + response_user_message_id=( + preallocated_user_message_id + ), + response_text=user_message, + child_run_id=clarification_child_run_id, + response_thread_id=preallocated_user_thread_id, + source_validator=lambda clarification: ( + validate_chat_clarification_source( + cosmos_messages_container, + conversation_id=conversation_id, + clarification=clarification, + ) + ), + expected_response_user_message_id=( + pending_chat_clarification.get( + '_response_user_message_id' + ) + if recovering_clarification_response + else None + ), + expected_child_run_id=( + pending_chat_clarification.get('child_run_id') + if recovering_clarification_response + else None + ), + expected_claimed_at=( + pending_chat_clarification.get('claimed_at') + if recovering_clarification_response + else None + ), + ) + except (CapabilityChoiceError, ChatClarificationError) as clarification_error: + log_event( + '[CapabilityPlanner] Clarification response rejected', + extra=build_clarification_evaluation_event( + pending_chat_clarification, + lifecycle='failed', + error_code=clarification_error.code, + ), + level=logging.WARNING, + ) + yield f"data: {json.dumps({ + 'error': str(clarification_error), + 'code': clarification_error.code, + })}\n\n" return - except PermissionError: - yield f"data: {json.dumps({'error': 'Forbidden'})}\n\n" + if clarification_response_idempotent: + log_event( + '[CapabilityPlanner] Clarification replayed', + extra=build_clarification_evaluation_event( + resolved_chat_clarification, + lifecycle='resolved', + idempotent=True, + ), + ) + yield f"data: {json.dumps({ + 'done': True, + 'conversation_id': conversation_id, + 'full_content': '', + 'reload_messages': True, + 'clarification_replayed': True, + })}\n\n" + return + preallocated_user_message_id = str( + resolved_chat_clarification.get( + '_response_user_message_id' + ) or preallocated_user_message_id + ) + clarification_child_run_id = str( + resolved_chat_clarification.get('child_run_id') + or clarification_child_run_id + ) + preallocated_user_thread_id = str( + resolved_chat_clarification.get( + '_response_thread_id' + ) or preallocated_user_thread_id + ) + pending_chat_clarification = resolved_chat_clarification + try: + claimed_clarification_user_doc = ( + cosmos_messages_container.read_item( + item=preallocated_user_message_id, + partition_key=conversation_id, + ) + ) + except CosmosResourceNotFoundError: + if recovering_clarification_response: + _invalidate_chat_clarification_checkpoint( + conversation_id=conversation_id, + clarification=resolved_chat_clarification, + reason=( + 'clarification_response_claim_mismatch' + ), + ) + yield f"data: {json.dumps({ + 'error': ( + 'Clarification response state is invalid' + ), + 'code': ( + 'clarification_response_claim_mismatch' + ), + })}\n\n" + return + claimed_clarification_user_doc = { + 'id': preallocated_user_message_id, + 'conversation_id': conversation_id, + 'role': 'user', + 'content': user_message, + 'timestamp': datetime.utcnow().isoformat(), + 'model_deployment_name': None, + 'metadata': {}, + } + claimed_response_metadata = ( + copy.deepcopy( + claimed_clarification_user_doc.get('metadata') + ) + if isinstance( + claimed_clarification_user_doc.get('metadata'), + Mapping, + ) + else {} + ) + claimed_response_thread_info = ( + claimed_response_metadata.get('thread_info') + if isinstance( + claimed_response_metadata.get('thread_info'), + Mapping, + ) + else {} + ) + if ( + recovering_clarification_response + and not _claimed_clarification_response_is_valid( + claimed_clarification_user_doc, + resolved_chat_clarification, + conversation_id=conversation_id, + ) + ): + _invalidate_chat_clarification_checkpoint( + conversation_id=conversation_id, + clarification=resolved_chat_clarification, + reason='clarification_response_claim_mismatch', + ) + yield f"data: {json.dumps({ + 'error': 'Clarification response state is invalid', + 'code': 'clarification_response_claim_mismatch', + })}\n\n" return + claimed_response_metadata['thread_info'] = { + 'thread_id': preallocated_user_thread_id, + 'previous_thread_id': ( + resolved_chat_clarification.get( + '_source_thread_id' + ) + ), + 'active_thread': True, + 'thread_attempt': 1, + } + claimed_response_metadata['clarification_response'] = { + 'version': resolved_chat_clarification.get('version'), + 'code': resolved_chat_clarification.get('code'), + 'status': resolved_chat_clarification.get('status'), + 'response_mode': resolved_chat_clarification.get( + 'response_mode' + ), + 'parent_run_id': resolved_chat_clarification.get( + 'parent_run_id' + ), + 'child_run_id': clarification_child_run_id, + 'idempotent': False, + '_clarification_id': ( + resolved_chat_clarification.get( + 'clarification_id' + ) + ), + } + claimed_clarification_user_doc['metadata'] = ( + claimed_response_metadata + ) + if recovering_clarification_response: + claimed_clarification_user_doc = ( + _persist_claimed_clarification_response_metadata( + claimed_clarification_user_doc, + resolved_chat_clarification, + conversation_id=conversation_id, + desired_metadata=( + claimed_response_metadata + ), + ) + ) + else: + cosmos_messages_container.upsert_item( + claimed_clarification_user_doc + ) + claimed_clarification_user_doc = ( + cosmos_messages_container.read_item( + item=preallocated_user_message_id, + partition_key=conversation_id, + ) + ) + + bounded_prior_user_turns = planner_prior_user_turns( + capability_dialogue_context_state + ) + capability_input_message = '\n'.join( + [ + str(turn.get('text') or '').strip() + for turn in bounded_prior_user_turns + if str(turn.get('text') or '').strip() + ] + + [user_message] + ).strip() + current_turn_urls = extract_urls_from_text(user_message) + contextual_input_urls = extract_urls_from_text( + capability_input_message + ) + prior_url_input_used = bool( + not current_turn_urls + and contextual_input_urls + ) auto_linked_chat_upload_document_ids = [] auto_merge_chat_upload_workspace_context = _should_auto_merge_chat_upload_workspace_context( @@ -20019,18 +22319,31 @@ def build_streaming_capability_usage(): ) if isinstance(capability, Mapping) and capability.get('state') == 'selected' + and capability.get('id') in set( + capability_resume_context.get( + 'execution_effective_capability_ids' + ) or [] + ) ] if capability_resume_context else _get_selected_builtin_chat_capability_ids(data) ) + planner_settings = normalize_chat_capability_planner_settings(settings) + capability_planner_mode = planner_settings.get( + 'chat_capability_planner_mode' + ) if capability_resume_context: capability_discovery = { 'inventory': copy.deepcopy( capability_resume_context.get('capability_inventory') or {} ), - 'requirements': classify_capability_requirements( - user_message, - authorized_document_count=len(discovery_document_ids), + 'requirements': ( + [] + if capability_planner_mode == 'assist' + else classify_capability_requirements( + user_message, + authorized_document_count=len(discovery_document_ids), + ) ), 'auto_capability_ids': [ capability_id @@ -20048,9 +22361,13 @@ def build_streaming_capability_usage(): user_email=current_user_email, user_roles=current_user_roles, user_message=user_message, + capability_input_message=capability_input_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), + enable_deterministic_matching=( + capability_planner_mode != 'assist' + ), ) inventory_entries = capability_discovery.get('inventory', {}).get('capabilities', []) log_event( @@ -20102,29 +22419,75 @@ def build_streaming_capability_usage(): capability_planner_shadow_comparison = None capability_planner_provider_class = 'other' capability_planner_model_name = '' - planner_settings = normalize_chat_capability_planner_settings(settings) - capability_planner_mode = planner_settings.get( - 'chat_capability_planner_mode' - ) + capability_planner_request = None + capability_planner_result = None + contextual_planner_activation = False + contextual_planner_goal_used = False + planner_clarification_activation = False + if capability_planner_mode == 'assist': + capability_recommendation = None + capability_discovery['auto_capability_ids'] = [] if ( capability_planner_mode in {'shadow', 'assist'} and not capability_resume_context and not stream_cancel_requested() ): - capability_planner_request = build_capability_planner_request( - user_message, - capability_discovery.get('inventory'), - max_candidate_plans=planner_settings.get( - 'chat_capability_planner_max_candidate_plans' - ), - max_capabilities_per_plan=planner_settings.get( - 'chat_capability_planner_max_capabilities_per_plan' - ), - additional_selected_mandate_ids=( - ['selected_agent'] - if _has_chat_agent_selection(request_agent_info) - else [] - ), + planner_structured_state = None + if pending_chat_clarification: + source_user_message_id = str( + pending_chat_clarification.get( + '_source_user_message_id' + ) or '' + ).strip() + source_goal_index = next( + ( + index + for index, message in enumerate( + capability_dialogue_context_state.get( + 'prior_user_messages' + ) or [] + ) + if str(message.get('id') or '').strip() + == source_user_message_id + ), + None, + ) + if source_goal_index is not None: + planner_structured_state = { + 'type': 'clarification', + 'source_goal_ref': f'turn_{source_goal_index}', + 'status': 'resolved', + 'code': pending_chat_clarification.get('code'), + } + def build_active_capability_planner_request(): + return build_capability_planner_request( + user_message, + capability_discovery.get('inventory'), + max_candidate_plans=planner_settings.get( + 'chat_capability_planner_max_candidate_plans' + ), + max_capabilities_per_plan=planner_settings.get( + 'chat_capability_planner_max_capabilities_per_plan' + ), + additional_selected_mandate_ids=( + ['selected_agent'] + if _has_chat_agent_selection( + request_agent_info + ) + else [] + ), + prior_user_turns=bounded_prior_user_turns, + structured_state=planner_structured_state, + clarification_option_candidates=( + DEFAULT_CLARIFICATION_OPTION_CANDIDATES + ), + clarification_budget_remaining=( + 0 if pending_chat_clarification else 1 + ), + ) + + capability_planner_request = ( + build_active_capability_planner_request() ) if capability_planner_is_eligible( planner_settings, @@ -20164,7 +22527,111 @@ def build_streaming_capability_usage(): capability_planner_model_name = ( capability_planner_runtime.get('model') or '' ) - capability_planner_shadow_result = invoke_capability_planner( + if pending_chat_clarification: + try: + exact_clarification_response = ( + cosmos_messages_container.read_item( + item=pending_chat_clarification.get( + '_response_user_message_id' + ), + partition_key=conversation_id, + ) + ) + if not _claimed_clarification_response_is_valid( + exact_clarification_response, + pending_chat_clarification, + conversation_id=conversation_id, + ): + raise ChatClarificationError( + 'clarification response state is invalid', + code=( + 'clarification_response_claim_mismatch' + ), + ) + exact_clarification_source = ( + validate_chat_clarification_source( + cosmos_messages_container, + conversation_id=conversation_id, + clarification=( + pending_chat_clarification + ), + ) + ) + clarification_source_id = str( + pending_chat_clarification.get( + '_source_user_message_id' + ) or '' + ).strip() + refreshed_prior_messages = [ + copy.deepcopy(message) + for message in ( + capability_dialogue_context_state.get( + 'prior_user_messages' + ) or [] + ) + if isinstance(message, Mapping) + ] + matching_source_indexes = [ + index + for index, message in enumerate( + refreshed_prior_messages + ) + if str(message.get('id') or '').strip() + == clarification_source_id + ] + if len(matching_source_indexes) != 1: + raise ChatClarificationError( + 'clarification source is not in the bounded context', + code='clarification_source_invalid', + ) + refreshed_prior_messages[ + matching_source_indexes[0] + ] = copy.deepcopy(exact_clarification_source) + capability_dialogue_context_state = { + **capability_dialogue_context_state, + 'prior_user_messages': ( + refreshed_prior_messages + ), + } + bounded_prior_user_turns = ( + planner_prior_user_turns( + capability_dialogue_context_state + ) + ) + capability_planner_request = ( + build_active_capability_planner_request() + ) + except Exception as clarification_source_error: + error_code = ( + clarification_source_error.code + if isinstance( + clarification_source_error, + ( + CapabilityChoiceError, + ChatClarificationError, + ), + ) + else 'clarification_source_invalid' + ) + _invalidate_chat_clarification_checkpoint( + conversation_id=conversation_id, + clarification=pending_chat_clarification, + reason=error_code, + ) + log_event( + '[CapabilityPlanner] Clarification source invalidated before planning', + extra={'failure_code': error_code}, + level=logging.WARNING, + ) + yield f"data: {json.dumps({ + 'error': ( + 'The clarification source is no ' + 'longer available. Send a new message.' + ), + 'code': error_code, + })}\n\n" + return + capability_planner_result = invoke_capability_planner( planner_client=capability_planner_runtime.get('client'), planner_model=capability_planner_model_name, planner_request=capability_planner_request, @@ -20177,6 +22644,29 @@ def build_streaming_capability_usage(): ), cancel_requested=stream_cancel_requested, ) + if capability_planner_result.get('status') != 'valid': + log_event( + '[CapabilityPlanner] Fast planner call rejected', + extra={ + 'failure_code': capability_planner_result.get( + 'failure_code' + ), + 'transport_error_class': ( + capability_planner_result.get( + 'transport_error_class' + ) + ), + 'transport_variant_index': ( + capability_planner_result.get( + 'transport_variant_index' + ) + ), + 'latency_ms': capability_planner_result.get( + 'latency_ms' + ), + }, + level=logging.WARNING, + ) if stream_cancel_requested(): cancel_reason = ( stream_session.get_cancel_reason() @@ -20191,48 +22681,117 @@ def build_streaming_capability_usage(): return capability_planner_shadow_metadata = ( build_capability_planner_metadata( - capability_planner_shadow_result, + capability_planner_result, mode=capability_planner_mode, ) ) capability_planner_shadow_comparison = ( compare_capability_planner_shadow( - capability_planner_shadow_result, + capability_planner_result, capability_recommendation, ) ) + contextual_planner_goal_used = bool( + capability_planner_result.get('status') == 'valid' + and capability_planner_result.get( + 'prior_goal_included' + ) is True + ) + planner_url_access_requested = any( + 'url_access' in ( + candidate.get('capability_ids') or [] + ) + for candidate in capability_planner_result.get( + 'candidate_plans' + ) or [] + if isinstance(candidate, Mapping) + ) + contextual_url_ref_missing = bool( + prior_url_input_used + and planner_url_access_requested + and not contextual_planner_goal_used + ) if capability_planner_mode == 'assist': + planner_selection_snapshot = { + 'selected_capability_ids': ( + selected_builtin_capability_ids + + ( + ['selected_agent'] + if _has_chat_agent_selection( + request_agent_info + ) + else [] + ) + ), + 'auto_capability_ids': list( + capability_discovery.get( + 'auto_capability_ids' + ) or [] + ), + } + planner_recommendation = ( + build_planner_capability_recommendation( + capability_planner_result, + capability_discovery.get('inventory'), + planner_selection_snapshot, + ) + or build_contextual_egress_recommendation( + capability_planner_result, + capability_discovery.get('inventory'), + planner_selection_snapshot, + ) + ) + if contextual_url_ref_missing: + planner_recommendation = None + selected_goal_refs = set( + capability_planner_result.get( + 'goal_turn_refs' + ) or [] + ) + contextual_query_input = '\n'.join( + str(turn.get('text') or '') + for turn in capability_planner_request.get( + 'dialogue_context' + ) or [] + if turn.get('ref') in selected_goal_refs + ) planner_recommendation = ( add_sensitive_external_query_options( - build_planner_capability_recommendation( - capability_planner_shadow_result, - capability_discovery.get('inventory'), - { - 'selected_capability_ids': ( - selected_builtin_capability_ids - + ( - ['selected_agent'] - if _has_chat_agent_selection(request_agent_info) - else [] - ) - ), - 'auto_capability_ids': list( - capability_discovery.get( - 'auto_capability_ids' - ) or [] - ), - }, - ), - user_message, + planner_recommendation, + contextual_query_input or user_message, max_actionable_options=3, ) ) - ( - capability_recommendation, - activation_summary, - ) = arbitrate_planner_capability_recommendation( - planner_recommendation, - capability_recommendation, + if ( + capability_planner_result.get('status') == 'valid' + and capability_planner_result.get('decision') + == 'clarify' + and not pending_chat_clarification + ): + capability_recommendation = None + planner_clarification_activation = True + activation_summary = { + 'activation_status': 'clarification', + 'recommendation_source': 'planner', + 'suppression_reason': None, + } + else: + ( + capability_recommendation, + activation_summary, + ) = arbitrate_planner_capability_recommendation( + planner_recommendation, + capability_recommendation, + ) + contextual_planner_activation = bool( + activation_summary.get('activation_status') + == 'materialized' + and capability_planner_result.get( + 'prior_goal_included' + ) is True + and activation_summary.get( + 'recommendation_source' + ) == 'planner' ) capability_planner_shadow_metadata.update( activation_summary @@ -20339,6 +22898,19 @@ def build_streaming_capability_usage(): **plan_kwargs, ) else: + clarification_plan_linkage = ( + { + 'run_id': clarification_child_run_id, + 'parent_run_id': pending_chat_clarification.get( + 'parent_run_id' + ), + } + if ( + clarification_child_run_id + and pending_chat_clarification + ) + else {} + ) inherited_selected_capability_ids = ( set(selected_effective_capability_ids) - set(selected_builtin_capability_ids) @@ -20352,21 +22924,35 @@ def build_streaming_capability_usage(): 'source_review_enabled': requested_source_review_enabled, 'deep_research_enabled': requested_deep_research_enabled, }) + submitted_plan_kwargs.update( + clarification_plan_linkage + ) submitted_plan = build_turn_orchestration_plan( user_message, **submitted_plan_kwargs, ) + reconciled_plan_kwargs = dict(plan_kwargs) + if pending_chat_clarification: + reconciled_plan_kwargs['parent_run_id'] = ( + pending_chat_clarification.get( + 'parent_run_id' + ) + ) turn_orchestration_plan = build_turn_orchestration_plan( user_message, run_id=submitted_plan.get('run_id'), capability_origins=capability_origins, selection_snapshot_override=submitted_plan.get('selection_snapshot'), - **plan_kwargs, + **reconciled_plan_kwargs, ) else: + direct_plan_kwargs = dict(plan_kwargs) + direct_plan_kwargs.update( + clarification_plan_linkage + ) turn_orchestration_plan = build_turn_orchestration_plan( user_message, - **plan_kwargs, + **direct_plan_kwargs, ) effective_capability_entries = [ @@ -20472,7 +23058,10 @@ def build_streaming_capability_usage(): user_message_id = ( str(capability_resume_context.get('user_message_id') or '').strip() if capability_resume_context - else f"{conversation_id}_user_{int(time.time())}_{random.randint(1000,9999)}" + else ( + preallocated_user_message_id + or f"{conversation_id}_user_{int(time.time())}_{random.randint(1000,9999)}" + ) ) turn_evidence_ledger = create_evidence_ledger_from_plan( turn_orchestration_plan, @@ -20665,26 +23254,19 @@ def build_streaming_capability_usage(): ) previous_thread_id = existing_thread_info.get('previous_thread_id') if not capability_resume_context: - try: - last_msg_query = f""" - SELECT TOP 1 c.metadata.thread_info.thread_id as thread_id - FROM c - WHERE c.conversation_id = '{conversation_id}' - ORDER BY c.timestamp DESC - """ - last_msgs = list(cosmos_messages_container.query_items( - query=last_msg_query, - partition_key=conversation_id - )) - if last_msgs: - previous_thread_id = last_msgs[0].get('thread_id') - except Exception as e: - debug_print(f"Error fetching last message for threading: {e}") + previous_thread_id = ( + capability_dialogue_context_state.get( + 'predecessor_thread_id' + ) + ) current_user_thread_id = ( str(existing_thread_info.get('thread_id') or '').strip() if capability_resume_context - else str(uuid.uuid4()) + else ( + preallocated_user_thread_id + or str(uuid.uuid4()) + ) ) if not current_user_thread_id: raise CapabilityChoiceError( @@ -20700,27 +23282,146 @@ def build_streaming_capability_usage(): 'active_thread': True, 'thread_attempt': 1 } + if resolved_chat_clarification: + user_metadata['clarification_response'] = { + 'version': resolved_chat_clarification.get('version'), + 'code': resolved_chat_clarification.get('code'), + 'status': resolved_chat_clarification.get('status'), + 'response_mode': resolved_chat_clarification.get( + 'response_mode' + ), + 'parent_run_id': resolved_chat_clarification.get( + 'parent_run_id' + ), + 'child_run_id': resolved_chat_clarification.get( + 'child_run_id' + ), + 'idempotent': False, + '_clarification_id': ( + resolved_chat_clarification.get( + 'clarification_id' + ) + ), + } user_message_doc = ( copy.deepcopy(capability_resume_context.get('existing_user_message')) if capability_resume_context - else { - 'id': user_message_id, - 'conversation_id': conversation_id, - 'role': 'user', - 'content': user_message, - 'timestamp': datetime.utcnow().isoformat(), - 'model_deployment_name': None, - } + else ( + copy.deepcopy(claimed_clarification_user_doc) + if isinstance(claimed_clarification_user_doc, Mapping) + else { + 'id': user_message_id, + 'conversation_id': conversation_id, + 'role': 'user', + 'content': user_message, + 'timestamp': datetime.utcnow().isoformat(), + 'model_deployment_name': None, + } + ) ) - user_message_doc['metadata'] = user_metadata - cosmos_messages_container.upsert_item(user_message_doc) + def persist_stream_user_message(metadata): + nonlocal user_message_doc + user_message_doc['metadata'] = metadata + if resolved_chat_clarification: + user_message_doc = ( + _persist_claimed_clarification_response_metadata( + user_message_doc, + resolved_chat_clarification, + conversation_id=conversation_id, + desired_metadata=metadata, + ) + ) + else: + cosmos_messages_container.upsert_item( + user_message_doc + ) + return user_message_doc + + persist_stream_user_message(user_metadata) debug_print( f"[Streaming] {'Updated resumed' if capability_resume_context else 'Saved'} user message " f"{user_message_id} | thread_id={current_user_thread_id} | previous_thread_id={previous_thread_id}" ) + if resolved_chat_clarification: + if ( + resolved_chat_clarification.get('child_run_id') + != turn_orchestration_plan.get('run_id') + ): + raise ChatClarificationError( + 'clarification child run linkage changed', + code='clarification_child_run_mismatch', + ) + user_metadata['clarification_response'] = { + 'version': resolved_chat_clarification.get('version'), + 'code': resolved_chat_clarification.get('code'), + 'status': resolved_chat_clarification.get('status'), + 'response_mode': resolved_chat_clarification.get( + 'response_mode' + ), + 'parent_run_id': resolved_chat_clarification.get( + 'parent_run_id' + ), + 'child_run_id': resolved_chat_clarification.get( + 'child_run_id' + ), + 'idempotent': clarification_response_idempotent, + '_clarification_id': ( + resolved_chat_clarification.get( + 'clarification_id' + ) + ), + } + persist_stream_user_message(user_metadata) + + approved_user_turn_goal = None + if ( + contextual_planner_goal_used + and capability_planner_request + and capability_planner_result + ): + try: + goal_source_messages = ( + resolve_planner_goal_source_messages( + capability_planner_request, + capability_planner_result, + capability_dialogue_context_state, + user_message_doc, + ) + ) + exact_goal_source_messages = [ + cosmos_messages_container.read_item( + item=str(source_message.get('id') or ''), + partition_key=conversation_id, + ) + for source_message in goal_source_messages + ] + approved_user_turn_goal = ( + build_approved_user_turn_goal( + exact_goal_source_messages, + conversation_id=conversation_id, + current_user_message_id=user_message_id, + ) + ) + search_query = str( + approved_user_turn_goal.get( + 'contextual_query' + ) or user_message + ).strip() + except Exception as goal_binding_error: + capability_recommendation = None + contextual_planner_activation = False + contextual_planner_goal_used = False + log_event( + '[CapabilityPlanner] Contextual goal binding rejected', + extra={ + 'error_type': type(goal_binding_error).__name__, + }, + level=logging.WARNING, + ) + # Log activity if not capability_resume_context: try: @@ -20943,6 +23644,9 @@ def record_and_publish_streaming_thought(thought_payload): 'parent_run_id': capability_resume_context.get('parent_run_id'), 'execution_id': capability_resume_context.get('execution_id'), } + safety_doc.setdefault('metadata', {})[ + 'orchestration' + ] = turn_orchestration_plan cosmos_messages_container.upsert_item(safety_doc) complete_stream_capability_resume(assistant_message_id) @@ -20965,17 +23669,110 @@ def record_and_publish_streaming_thought(thought_payload): 'web_search_citations': [], 'agent_citations': [], 'model_deployment_name': None, - 'metadata': safety_doc.get('metadata', {}), + 'metadata': project_chat_metadata_for_client( + safety_doc.get('metadata', {}) + ), 'thoughts_enabled': thought_tracker.enabled, }) yield f"data: {json.dumps(final_data)}\n\n" return + except ChatClarificationError: + raise except HttpResponseError as e: debug_print(f"[Content Safety Error - Streaming] {e}") except Exception as ex: debug_print(f"[Content Safety - Streaming] Unexpected error: {ex}") + if ( + planner_clarification_activation + and not capability_resume_context + and capability_planner_result + ): + turn_orchestration_run.status = 'awaiting_user_clarification' + turn_orchestration_run.started_at = datetime.utcnow().isoformat() + clarification = build_chat_clarification( + capability_planner_result.get('clarification'), + parent_run_id=turn_orchestration_plan.get('run_id'), + conversation_id=conversation_id, + source_user_message_id=user_message_id, + source_thread_id=current_user_thread_id, + assistant_message_id=assistant_message_id, + ttl_seconds=settings.get( + 'chat_capability_choice_ttl_seconds', + 86400, + ), + ) + user_metadata['orchestration_runtime'] = ( + turn_orchestration_run.to_metadata() + ) + user_metadata['clarification_requested'] = { + 'version': clarification.get('version'), + 'code': clarification.get('code'), + 'status': clarification.get('status'), + 'clarification_budget_used': clarification.get( + 'clarification_budget_used' + ), + } + persist_stream_user_message(user_metadata) + + clarification_assistant_doc = make_json_serializable({ + 'id': assistant_message_id, + 'conversation_id': conversation_id, + 'role': 'assistant', + 'content': clarification.get('question'), + 'timestamp': datetime.utcnow().isoformat(), + 'augmented': False, + 'hybrid_citations': [], + 'web_search_citations': [], + 'agent_citations': [], + 'model_deployment_name': None, + 'metadata': { + 'awaiting_user_clarification': True, + 'orchestration': turn_orchestration_plan, + 'orchestration_runtime': ( + turn_orchestration_run.to_metadata() + ), + 'chat_clarification': clarification, + 'thread_info': { + 'thread_id': user_thread_id, + 'previous_thread_id': user_previous_thread_id, + 'active_thread': True, + 'thread_attempt': assistant_thread_attempt, + }, + }, + }) + cosmos_messages_container.upsert_item( + clarification_assistant_doc + ) + conversation_item['last_updated'] = datetime.utcnow().isoformat() + cosmos_conversations_container.upsert_item(conversation_item) + invalidate_conversation_cache_for_item( + conversation_item, + reason='chat_clarification_created', + ) + log_event( + '[CapabilityPlanner] Clarification created', + extra=build_clarification_evaluation_event( + clarification, + lifecycle='created', + ), + ) + yield f"data: {json.dumps(make_json_serializable({ + 'done': True, + 'awaiting_user_clarification': True, + 'conversation_id': conversation_id, + 'conversation_title': conversation_item.get('title'), + 'message_id': assistant_message_id, + 'user_message_id': user_message_id, + 'full_content': clarification.get('question'), + 'metadata': project_chat_metadata_for_client( + clarification_assistant_doc.get('metadata', {}) + ), + 'thoughts_enabled': thought_tracker.enabled, + }))}\n\n" + return + if capability_recommendation and not capability_resume_context: turn_orchestration_run.status = 'awaiting_user_choice' turn_orchestration_run.started_at = datetime.utcnow().isoformat() @@ -20985,6 +23782,10 @@ def record_and_publish_streaming_thought(thought_payload): conversation_id=conversation_id, user_message_id=user_message_id, assistant_message_id=assistant_message_id, + approved_user_turn_goal=approved_user_turn_goal, + capability_inventory=capability_discovery.get( + 'inventory' + ), ttl_seconds=settings.get('chat_capability_choice_ttl_seconds', 86400), ) turn_capability_provenance = build_capability_provenance( @@ -20999,8 +23800,7 @@ def record_and_publish_streaming_thought(thought_payload): ) user_metadata['capability_provenance'] = turn_capability_provenance user_metadata['orchestration_runtime'] = turn_orchestration_run.to_metadata() - user_message_doc['metadata'] = user_metadata - cosmos_messages_container.upsert_item(user_message_doc) + persist_stream_user_message(user_metadata) proposal_content = ( 'An additional capability could materially improve this answer. ' @@ -21037,6 +23837,7 @@ def record_and_publish_streaming_thought(thought_payload): }, }) cosmos_messages_container.upsert_item(proposal_assistant_doc) + complete_stream_capability_resume(assistant_message_id) conversation_item['last_updated'] = datetime.utcnow().isoformat() cosmos_conversations_container.upsert_item(conversation_item) invalidate_conversation_cache_for_item( @@ -21056,11 +23857,89 @@ def record_and_publish_streaming_thought(thought_payload): 'message_id': assistant_message_id, 'user_message_id': user_message_id, 'full_content': proposal_content, - 'metadata': proposal_assistant_doc.get('metadata', {}), + 'metadata': project_chat_metadata_for_client( + proposal_assistant_doc.get('metadata', {}) + ), 'thoughts_enabled': thought_tracker.enabled, }))}\n\n" return + contextual_goal_for_execution = None + contextual_goal_proposal = ( + capability_resume_context.get('_contextual_proposal') + if capability_resume_context + else None + ) + contextual_goal_decision = ( + capability_resume_context.get('decision') + if capability_resume_context + and isinstance( + capability_resume_context.get('decision'), + Mapping, + ) + else {} + ) + stored_contextual_goal = ( + contextual_goal_proposal.get('_approved_user_turn_goal') + if isinstance(contextual_goal_proposal, Mapping) + else approved_user_turn_goal + ) + if isinstance(stored_contextual_goal, Mapping): + try: + contextual_goal_for_execution = ( + _rebuild_exact_contextual_goal( + stored_contextual_goal, + conversation_id=conversation_id, + approved_sensitive_input_types=( + contextual_goal_decision.get( + 'sensitive_input_types' + ) or [] + ), + ) + ) + search_query = str( + contextual_goal_for_execution.get( + 'contextual_query' + ) or user_message + ).strip() + except Exception as contextual_source_error: + error_code = ( + contextual_source_error.code + if isinstance( + contextual_source_error, + CapabilityChoiceError, + ) + else 'goal_reconstruction_failed' + ) + if capability_resume_context: + persist_capability_invalidation( + cosmos_messages_container, + conversation_id=conversation_id, + proposal_id=capability_resume_context.get( + 'proposal_id' + ), + reason=error_code, + expected_execution_id=( + capability_resume_context.get( + 'execution_id' + ) + ), + ) + resume_terminalized = True + log_event( + '[CapabilityPlanner] Contextual goal rejected before execution', + extra={'failure_code': error_code}, + level=logging.WARNING, + ) + yield f"data: {json.dumps({ + 'error': ( + 'The earlier request is no longer available. ' + 'Send a new message to continue.' + ), + 'code': error_code, + })}\n\n" + return + if not original_hybrid_search_enabled and not explicit_external_retrieval_requested: prior_grounded_document_refs = _normalize_prior_grounded_document_refs(conversation_item) if prior_grounded_document_refs: @@ -21168,8 +24047,7 @@ def record_and_publish_streaming_thought(thought_payload): 'document_count': len(effective_selected_document_ids), 'search_query': search_query, } - user_message_doc['metadata'] = user_metadata - cosmos_messages_container.upsert_item(user_message_doc) + persist_stream_user_message(user_metadata) else: yield emit_thought( 'history_context', @@ -21651,6 +24529,75 @@ def record_and_publish_streaming_thought(thought_payload): detail=f"files={tabular_filenames_str}" ) + contextual_external_execution = bool( + capability_resume_context + and ( + web_search_enabled + or source_review_enabled + or deep_research_enabled + or url_access_enabled + ) + and ( + capability_resume_context.get('decision') or {} + ).get('prior_goal_included') is True + ) + if contextual_external_execution: + try: + pre_execution_goal = _rebuild_authorized_contextual_goal( + capability_resume_context.get( + '_contextual_proposal' + ), + conversation_id=conversation_id, + ) + rebuilt_external_query = str( + (pre_execution_goal or {}).get( + 'external_query' + ) or '' + ).strip() + if not rebuilt_external_query: + raise CapabilityChoiceError( + 'approved external goal could not be rebuilt', + code='goal_query_empty', + ) + external_retrieval_message = rebuilt_external_query + web_search_query_text = build_web_search_query_text( + rebuilt_external_query + ) + except Exception as contextual_execution_error: + error_code = ( + contextual_execution_error.code + if isinstance( + contextual_execution_error, + CapabilityChoiceError, + ) + else 'goal_reconstruction_failed' + ) + persist_capability_invalidation( + cosmos_messages_container, + conversation_id=conversation_id, + proposal_id=capability_resume_context.get( + 'proposal_id' + ), + reason=error_code, + expected_execution_id=capability_resume_context.get( + 'execution_id' + ), + ) + resume_terminalized = True + log_event( + '[CapabilityPlanner] Contextual external goal invalidated', + extra={'failure_code': error_code}, + level=logging.WARNING, + ) + yield f"data: {json.dumps({ + 'error': ( + 'The approved earlier request is no longer ' + 'available. Send a new message to continue.' + ), + 'code': error_code, + })}\n\n" + return + if web_search_enabled: debug_print( f"[Streaming] Starting web search augmentation for conversation_id={conversation_id}" @@ -21813,8 +24760,7 @@ def record_and_publish_streaming_thought(thought_payload): deep_research_query_count=_deep_research_query_count(deep_research_query_plan, deep_research_web_search_runs), ) user_metadata['chat_context']['chat_type'] = message_chat_type - user_message_doc['metadata'] = user_metadata - cosmos_messages_container.upsert_item(user_message_doc) + persist_stream_user_message(user_metadata) # Prepare conversation history conversation_history_for_api = [] @@ -21878,8 +24824,7 @@ def record_and_publish_streaming_thought(thought_payload): ) user_metadata['evidence_ledger'] = turn_evidence_ledger user_metadata['orchestration_runtime'] = turn_orchestration_run.to_metadata() - user_message_doc['metadata'] = user_metadata - cosmos_messages_container.upsert_item(user_message_doc) + persist_stream_user_message(user_metadata) for runtime_progress_event in drain_orchestration_runtime_progress(): yield runtime_progress_event history_segments = build_conversation_history_segments( @@ -22174,8 +25119,7 @@ def persist_central_synthesis_state(status): user_metadata['evidence_ledger'] = turn_evidence_ledger user_metadata['orchestration_runtime'] = turn_orchestration_run.to_metadata() user_metadata['central_synthesis'] = central_synthesis_metadata - user_message_doc['metadata'] = user_metadata - cosmos_messages_container.upsert_item(user_message_doc) + persist_stream_user_message(user_metadata) return central_synthesis_metadata if enable_semantic_kernel and user_enable_agents: @@ -22352,8 +25296,7 @@ def finalize_cancelled_stream_response(): ) user_metadata['orchestration_runtime'] = turn_orchestration_run.to_metadata() user_metadata['evidence_ledger'] = turn_evidence_ledger - user_message_doc['metadata'] = user_metadata - cosmos_messages_container.upsert_item(user_message_doc) + persist_stream_user_message(user_metadata) if ( central_synthesis_metadata and central_synthesis_metadata.get('status') == 'pending' @@ -22748,8 +25691,7 @@ def finalize_cancelled_agent_stream_response(): ) user_metadata['evidence_ledger'] = turn_evidence_ledger user_metadata['orchestration_runtime'] = turn_orchestration_run.to_metadata() - user_message_doc['metadata'] = user_metadata - cosmos_messages_container.upsert_item(user_message_doc) + persist_stream_user_message(user_metadata) error_payload = {'error': f'Agent streaming failed: {str(stream_error)}'} if isinstance(stream_error, FoundryAgentUserAuthenticationRequired): auth_response = getattr(stream_error, 'auth_response', {}) or {} @@ -22976,8 +25918,7 @@ def finalize_cancelled_agent_stream_response(): turn_evidence_ledger, ) if not central_synthesis_context: - user_message_doc['metadata'] = user_metadata - cosmos_messages_container.upsert_item(user_message_doc) + persist_stream_user_message(user_metadata) raise RuntimeError( 'Agent evidence did not reach a terminal synthesis state.' ) @@ -23370,18 +26311,6 @@ def finalize_cancelled_agent_stream_response(): } }) cosmos_messages_container.upsert_item(assistant_doc) - if capability_resume_context: - complete_stream_capability_resume(assistant_message_id) - if resume_terminalized: - log_event( - '[CapabilityDiscovery] Orchestration resumed', - extra={ - 'conversation_id': conversation_id, - 'proposal_id': capability_resume_context.get('proposal_id'), - 'parent_run_id': capability_resume_context.get('parent_run_id'), - 'child_run_id': turn_orchestration_plan.get('run_id'), - }, - ) if use_agent_streaming and agent_name_used: agent_scope_for_usage = 'personal' agent_group_id_for_usage = None @@ -23458,28 +26387,49 @@ def finalize_cancelled_agent_stream_response(): conversation_item['last_updated'] = datetime.utcnow().isoformat() try: - user_message_doc = cosmos_messages_container.read_item( - item=user_message_id, - partition_key=conversation_id - ) - if 'metadata' in user_message_doc and 'model_selection' in user_message_doc['metadata']: - user_message_doc['metadata']['model_selection']['selected_model'] = final_model_used if use_agent_streaming else gpt_model - user_message_doc['metadata']['model_selection']['model_icon'] = gpt_model_icon + if resolved_chat_clarification: + final_user_metadata = copy.deepcopy(user_metadata) + else: + user_message_doc = cosmos_messages_container.read_item( + item=user_message_id, + partition_key=conversation_id + ) + final_user_metadata = copy.deepcopy( + user_message_doc.get('metadata') or {} + ) + if 'model_selection' in final_user_metadata: + final_user_metadata['model_selection']['selected_model'] = final_model_used if use_agent_streaming else gpt_model + final_user_metadata['model_selection']['model_icon'] = gpt_model_icon if selected_agent_metadata: - user_message_doc.setdefault('metadata', {})['agent_selection'] = selected_agent_metadata - user_message_doc.setdefault('metadata', {})['evidence_ledger'] = turn_evidence_ledger - user_message_doc.setdefault('metadata', {})['orchestration_runtime'] = ( + final_user_metadata['agent_selection'] = selected_agent_metadata + final_user_metadata['evidence_ledger'] = turn_evidence_ledger + final_user_metadata['orchestration_runtime'] = ( turn_orchestration_run.to_metadata() ) if central_synthesis_metadata: - user_message_doc.setdefault('metadata', {})['central_synthesis'] = central_synthesis_metadata - user_message_doc.setdefault('metadata', {})['capability_provenance'] = ( + final_user_metadata['central_synthesis'] = central_synthesis_metadata + final_user_metadata['capability_provenance'] = ( turn_capability_provenance ) - cosmos_messages_container.upsert_item(user_message_doc) + user_metadata = final_user_metadata + persist_stream_user_message(user_metadata) + except ChatClarificationError: + raise except Exception as e: debug_print(f"Warning: Could not update streaming user message metadata: {e}") + complete_stream_capability_resume(assistant_message_id) + if capability_resume_context and resume_terminalized: + log_event( + '[CapabilityDiscovery] Orchestration resumed', + extra={ + 'conversation_id': conversation_id, + 'proposal_id': capability_resume_context.get('proposal_id'), + 'parent_run_id': capability_resume_context.get('parent_run_id'), + 'child_run_id': turn_orchestration_plan.get('run_id'), + }, + ) + try: conversation_item = collect_conversation_metadata( user_message=user_message, @@ -23553,7 +26503,9 @@ def finalize_cancelled_agent_stream_response(): 'agent_name': agent_name_used if use_agent_streaming else None, 'agent_icon': agent_icon_used if use_agent_streaming else None, 'agent_tags': agent_tags_used if use_agent_streaming else [], - 'metadata': assistant_doc.get('metadata', {}), + 'metadata': project_chat_metadata_for_client( + assistant_doc.get('metadata', {}) + ), 'full_content': accumulated_content, 'thoughts_enabled': thought_tracker.enabled }) @@ -23579,8 +26531,7 @@ def finalize_cancelled_agent_stream_response(): ) user_metadata['orchestration_runtime'] = turn_orchestration_run.to_metadata() user_metadata['evidence_ledger'] = turn_evidence_ledger - user_message_doc['metadata'] = user_metadata - cosmos_messages_container.upsert_item(user_message_doc) + persist_stream_user_message(user_metadata) if ( central_synthesis_metadata @@ -23663,6 +26614,8 @@ def finalize_cancelled_agent_stream_response(): try: cosmos_messages_container.upsert_item(assistant_doc) complete_stream_capability_resume(assistant_message_id) + except ChatClarificationError: + raise except Exception as partial_output_error: log_event( '[CapabilityDiscovery] Partial resume output persistence failed', @@ -23681,7 +26634,7 @@ def finalize_cancelled_agent_stream_response(): partial_error_payload = { 'error': error_msg, 'partial_content': accumulated_content, - 'metadata': { + 'metadata': project_chat_metadata_for_client({ 'incomplete': True, 'error': error_msg, 'orchestration': turn_orchestration_plan, @@ -23689,7 +26642,7 @@ def finalize_cancelled_agent_stream_response(): 'evidence_ledger': turn_evidence_ledger, 'capability_provenance': turn_capability_provenance, 'central_synthesis': central_synthesis_metadata, - }, + }), } yield f"data: {json.dumps(partial_error_payload)}\n\n" @@ -23729,19 +26682,20 @@ def finalize_cancelled_agent_stream_response(): progress_callback=runtime_progress_callback, ) active_user_metadata = locals().get('user_metadata') - active_user_message_doc = locals().get('user_message_doc') active_evidence_ledger = locals().get('turn_evidence_ledger') + active_user_persistence = locals().get( + 'persist_stream_user_message' + ) if ( isinstance(active_user_metadata, dict) - and isinstance(active_user_message_doc, dict) + and callable(active_user_persistence) ): active_user_metadata['orchestration_runtime'] = ( active_runtime.to_metadata() ) if active_evidence_ledger is not None: active_user_metadata['evidence_ledger'] = active_evidence_ledger - active_user_message_doc['metadata'] = active_user_metadata - cosmos_messages_container.upsert_item(active_user_message_doc) + active_user_persistence(active_user_metadata) except Exception as runtime_cleanup_error: log_event( '[OrchestrationRuntime] Failed to close an incomplete stream run', @@ -23751,6 +26705,38 @@ def finalize_cancelled_agent_stream_response(): }, level=logging.ERROR, ) + active_clarification = locals().get( + 'resolved_chat_clarification' + ) + if ( + not clarification_terminalized + and isinstance(active_clarification, Mapping) + ): + try: + resolved_chat_clarification = ( + _finalize_stream_clarification_claim( + conversation_id=finalized_conversation_id, + clarification=active_clarification, + ) + ) + clarification_terminalized = bool( + isinstance( + resolved_chat_clarification, + Mapping, + ) + and resolved_chat_clarification.get('status') + in {'resolved', 'expired'} + ) + except Exception as clarification_cleanup_error: + log_event( + '[CapabilityPlanner] Clarification cleanup failed', + extra={ + 'error_type': type( + clarification_cleanup_error + ).__name__, + }, + level=logging.ERROR, + ) try: return build_background_stream_response( diff --git a/application/single_app/route_backend_conversations.py b/application/single_app/route_backend_conversations.py index d53fbb43..652224c9 100644 --- a/application/single_app/route_backend_conversations.py +++ b/application/single_app/route_backend_conversations.py @@ -8,6 +8,12 @@ from config import * from functions_appinsights import log_event from functions_authentication import * +from functions_chat_capability_choices import project_chat_metadata_for_client +from functions_chat_clarifications import ( + ChatClarificationError, + read_chat_clarification_message, + validate_chat_clarification_retry, +) from functions_collaboration import ( assert_user_can_view_collaboration_conversation, assert_user_can_participate_in_collaboration_conversation, @@ -64,6 +70,80 @@ from functions_thoughts import archive_thoughts_for_conversation, delete_thoughts_for_conversation from utils_cache import invalidate_personal_search_cache + +def _project_message_for_client(message): + if not isinstance(message, dict): + return message + projected = dict(message) + projected['metadata'] = project_chat_metadata_for_client( + projected.get('metadata') + ) + return projected + + +def _find_user_message_for_thread_attempt( + conversation_id, + thread_id, + thread_attempt, +): + """Read the exact user attempt for an authorized conversation thread.""" + rows = list(cosmos_messages_container.query_items( + query=( + 'SELECT * FROM c ' + 'WHERE c.conversation_id = @conversation_id ' + 'AND c.metadata.thread_info.thread_id = @thread_id ' + 'AND c.metadata.thread_info.thread_attempt = @thread_attempt ' + 'AND c.role = "user" ' + ), + parameters=[ + {'name': '@conversation_id', 'value': conversation_id}, + {'name': '@thread_id', 'value': thread_id}, + {'name': '@thread_attempt', 'value': thread_attempt}, + ], + partition_key=conversation_id, + )) + return rows[0] if len(rows) == 1 else None + + +def _validate_linked_clarification_retry( + conversation_id, + response_message, + proposed_text, +): + """Validate a clarification-linked response before thread mutation.""" + metadata = ( + response_message.get('metadata') + if isinstance(response_message, dict) + and isinstance(response_message.get('metadata'), dict) + else {} + ) + clarification_response = ( + metadata.get('clarification_response') + if isinstance(metadata.get('clarification_response'), dict) + else {} + ) + clarification_id = str( + clarification_response.get('_clarification_id') or '' + ).strip() + if not clarification_id: + return None + try: + _, clarification = read_chat_clarification_message( + cosmos_messages_container, + conversation_id=conversation_id, + clarification_id=clarification_id, + ) + except CosmosResourceNotFoundError as exc: + raise ChatClarificationError( + 'linked clarification is no longer available', + code='clarification_response_conflict', + ) from exc + return validate_chat_clarification_retry( + clarification, + response_message, + proposed_text=proposed_text, + ) + def normalize_chat_type(conversation_item): chat_type = conversation_item.get('chat_type') if chat_type: @@ -935,6 +1015,7 @@ def api_get_messages(): all_items, image_url_builder=lambda image_id: f"/api/image/{image_id}", ) + messages = [_project_message_for_client(message) for message in messages] return jsonify({'messages': messages}) except PermissionError: @@ -2531,6 +2612,57 @@ def retry_message(message_id): if not thread_id: return jsonify({'error': 'Message has no thread_id'}), 400 + + requested_thread_attempt = original_msg.get( + 'metadata', {} + ).get('thread_info', {}).get('thread_attempt', 1) + original_user_msg = _find_user_message_for_thread_attempt( + conversation_id, + thread_id, + requested_thread_attempt, + ) + if not original_user_msg: + return jsonify({'error': 'User message not found in thread'}), 404 + user_content = original_user_msg.get('content', '') + original_metadata = original_user_msg.get('metadata', {}) + original_thread_info = original_metadata.get('thread_info', {}) + clarification_retry = _validate_linked_clarification_retry( + conversation_id, + original_user_msg, + user_content, + ) + if clarification_retry: + chat_request = { + 'message': user_content, + 'conversation_id': conversation_id, + 'model_deployment': selected_model or original_metadata.get('model_selection', {}).get('selected_model'), + 'reasoning_effort': reasoning_effort or original_metadata.get('reasoning_effort'), + 'hybrid_search': original_metadata.get('document_search', {}).get('enabled', False), + 'selected_document_id': original_metadata.get('document_search', {}).get('document_id'), + 'doc_scope': original_metadata.get('document_search', {}).get('scope'), + 'top_n': original_metadata.get('document_search', {}).get('top_n'), + 'classifications': original_metadata.get('document_search', {}).get('classifications'), + 'image_generation': original_metadata.get('image_generation', {}).get('enabled', False), + 'active_group_id': original_metadata.get('chat_context', {}).get('group_id'), + 'active_public_workspace_id': original_metadata.get('chat_context', {}).get('public_workspace_id'), + 'chat_type': original_metadata.get('chat_context', {}).get('type', 'user'), + 'retry_user_message_id': clarification_retry['response_user_message_id'], + 'retry_thread_id': original_thread_info.get('thread_id'), + 'retry_thread_attempt': original_thread_info.get('thread_attempt', 1), + } + if agent_info: + chat_request['agent_info'] = agent_info + elif original_metadata.get('agent_selection'): + chat_request['agent_info'] = original_metadata.get('agent_selection') + return jsonify({ + 'success': True, + 'message': 'Clarification retry validated', + 'thread_id': original_thread_info.get('thread_id'), + 'new_attempt': original_thread_info.get('thread_attempt', 1), + 'user_message_id': clarification_retry['response_user_message_id'], + 'clarification_retry_mode': clarification_retry['mode'], + 'chat_request': chat_request, + }), 200 # Find current max thread_attempt for this thread_id attempt_query = f""" @@ -2574,29 +2706,6 @@ def retry_message(message_id): print(f" ✏️ Deactivated: {msg_id} (role={msg_role}, was_active={old_active}, now_active=False)") - # Find the original user message in this thread to get the content - # Get the FIRST user message in this thread (attempt=1) to ensure we get the original content - user_msg_query = f""" - SELECT * FROM c - WHERE c.conversation_id = '{conversation_id}' - AND c.metadata.thread_info.thread_id = '{thread_id}' - AND c.role = 'user' - ORDER BY c.metadata.thread_info.thread_attempt ASC - """ - user_msg_results = list(cosmos_messages_container.query_items( - query=user_msg_query, - partition_key=conversation_id - )) - - if not user_msg_results: - return jsonify({'error': 'User message not found in thread'}), 404 - - # Get the first user message (attempt 1) to get original content and metadata - original_user_msg = user_msg_results[0] - user_content = original_user_msg.get('content', '') - original_metadata = original_user_msg.get('metadata', {}) - original_thread_info = original_metadata.get('thread_info', {}) - print(f"🔍 Retry - Original user message: {original_user_msg.get('id')}") print(f"🔍 Retry - Original thread_id: {original_thread_info.get('thread_id')}") print(f"🔍 Retry - Original previous_thread_id: {original_thread_info.get('previous_thread_id')}") @@ -2686,6 +2795,8 @@ def retry_message(message_id): 'chat_request': chat_request }), 200 + except ChatClarificationError as e: + return jsonify({'error': str(e), 'code': e.code}), 409 except Exception as e: print(f"Error retrying message: {str(e)}") import traceback @@ -2755,6 +2866,64 @@ def edit_message(message_id): if not thread_id: return jsonify({'error': 'Message has no thread_id'}), 400 + + requested_thread_attempt = original_msg.get( + 'metadata', {} + ).get('thread_info', {}).get('thread_attempt', 1) + original_user_msg = _find_user_message_for_thread_attempt( + conversation_id, + thread_id, + requested_thread_attempt, + ) + if not original_user_msg: + return jsonify({'error': 'User message not found in thread'}), 404 + original_metadata = original_user_msg.get('metadata', {}) + original_thread_info = original_metadata.get('thread_info', {}) + clarification_retry = _validate_linked_clarification_retry( + conversation_id, + original_user_msg, + edited_content, + ) + if clarification_retry: + chat_request = { + 'message': edited_content, + 'conversation_id': conversation_id, + 'model_deployment': original_metadata.get('model_selection', {}).get('selected_model'), + 'reasoning_effort': original_metadata.get('reasoning_effort'), + 'hybrid_search': original_metadata.get('document_search', {}).get('enabled', False), + 'selected_document_id': original_metadata.get('document_search', {}).get('document_id'), + 'doc_scope': original_metadata.get('document_search', {}).get('scope'), + 'top_n': original_metadata.get('document_search', {}).get('top_n'), + 'classifications': original_metadata.get('document_search', {}).get('classifications'), + 'image_generation': original_metadata.get('image_generation', {}).get('enabled', False), + 'active_group_id': original_metadata.get('chat_context', {}).get('group_id'), + 'active_public_workspace_id': original_metadata.get('chat_context', {}).get('public_workspace_id'), + 'chat_type': original_metadata.get('chat_context', {}).get('type', 'user'), + 'edited_user_message_id': clarification_retry['response_user_message_id'], + 'retry_thread_id': original_thread_info.get('thread_id'), + 'retry_thread_attempt': original_thread_info.get('thread_attempt', 1), + } + if original_metadata.get('agent_selection'): + agent_selection = original_metadata.get('agent_selection') + chat_request['agent_info'] = { + 'name': agent_selection.get('selected_agent'), + 'display_name': agent_selection.get('agent_display_name'), + 'id': agent_selection.get('agent_id'), + 'is_global': agent_selection.get('is_global', False), + 'is_group': agent_selection.get('is_group', False), + 'group_id': agent_selection.get('group_id'), + 'group_name': agent_selection.get('group_name'), + } + return jsonify({ + 'success': True, + 'message': 'Clarification edit validated', + 'thread_id': original_thread_info.get('thread_id'), + 'new_attempt': original_thread_info.get('thread_attempt', 1), + 'user_message_id': clarification_retry['response_user_message_id'], + 'edited': False, + 'clarification_retry_mode': clarification_retry['mode'], + 'chat_request': chat_request, + }), 200 # Find current max thread_attempt for this thread_id attempt_query = f""" @@ -2798,27 +2967,6 @@ def edit_message(message_id): print(f" ✏️ Deactivated: {msg_id} (role={msg_role}, was_active={old_active}, now_active=False)") - # Get the FIRST user message in this thread (attempt=1) to get original metadata - user_msg_query = f""" - SELECT * FROM c - WHERE c.conversation_id = '{conversation_id}' - AND c.metadata.thread_info.thread_id = '{thread_id}' - AND c.role = 'user' - ORDER BY c.metadata.thread_info.thread_attempt ASC - """ - user_msg_results = list(cosmos_messages_container.query_items( - query=user_msg_query, - partition_key=conversation_id - )) - - if not user_msg_results: - return jsonify({'error': 'User message not found in thread'}), 404 - - # Get the first user message (attempt 1) to get original metadata - original_user_msg = user_msg_results[0] - original_metadata = original_user_msg.get('metadata', {}) - original_thread_info = original_metadata.get('thread_info', {}) - print(f"🔍 Edit - Original user message: {original_user_msg.get('id')}") print(f"🔍 Edit - Original thread_id: {original_thread_info.get('thread_id')}") print(f"🔍 Edit - Original previous_thread_id: {original_thread_info.get('previous_thread_id')}") @@ -2910,6 +3058,8 @@ def edit_message(message_id): 'chat_request': chat_request }), 200 + except ChatClarificationError as e: + return jsonify({'error': str(e), 'code': e.code}), 409 except Exception as e: print(f"Error editing message: {str(e)}") import traceback diff --git a/application/single_app/route_frontend_conversations.py b/application/single_app/route_frontend_conversations.py index 2997549c..abb10e84 100644 --- a/application/single_app/route_frontend_conversations.py +++ b/application/single_app/route_frontend_conversations.py @@ -22,6 +22,7 @@ from functions_authentication import * from functions_debug import debug_print from functions_chat import sort_messages_by_thread +from functions_chat_capability_choices import project_chat_metadata_for_client from functions_collaboration import ( assert_user_can_view_collaboration_conversation, build_collaboration_message_metadata_payload, @@ -53,6 +54,16 @@ def _authorize_frontend_personal_conversation_access(user_id, conversation_id): return conversation_item + +def _project_message_for_client(message): + if not isinstance(message, dict): + return message + projected = dict(message) + projected['metadata'] = project_chat_metadata_for_client( + projected.get('metadata') + ) + return projected + def register_route_frontend_conversations(bp): def _disable_response_caching(response): response.headers['Cache-Control'] = 'no-store, max-age=0' @@ -128,6 +139,7 @@ def view_conversation(conversation_id): messages = filter_assistant_artifact_items(messages) messages = hydrate_agent_citations_from_artifacts(messages, artifact_payload_map) messages = _refresh_azure_maps_message_payloads(messages) + messages = [_project_message_for_client(message) for message in messages] return render_template('chat.html', conversation_id=conversation_id, messages=messages) @bp.route('/conversation//messages', methods=['GET']) @@ -205,6 +217,7 @@ def get_conversation_messages(conversation_id): all_items, image_url_builder=lambda image_id: f"/api/image/{image_id}", ) + messages = [_project_message_for_client(message) for message in messages] # Remove file content for security for m in messages: @@ -401,11 +414,13 @@ def get_message_metadata(message_id): if message_role == 'user': # User messages - return nested metadata object - metadata = message.get('metadata', {}) + metadata = project_chat_metadata_for_client( + message.get('metadata', {}) + ) return jsonify(metadata) else: # Assistant, image, file messages - return full document - return jsonify(message) + return jsonify(_project_message_for_client(message)) except CosmosResourceNotFoundError: return jsonify({'error': 'Message not found'}), 404 diff --git a/application/single_app/static/css/chats.css b/application/single_app/static/css/chats.css index 918b811a..f8926cf4 100644 --- a/application/single_app/static/css/chats.css +++ b/application/single_app/static/css/chats.css @@ -315,6 +315,126 @@ line-height: 1.35; } +.sc-capability-choice-context-notice { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.6rem; + align-items: start; + padding: 0.65rem; + margin: 0 0 0.65rem; + border-left: 3px solid var(--bs-success); + background: rgba(var(--bs-success-rgb), 0.08); + border-radius: 4px; +} + +.sc-capability-choice-context-notice > i { + margin-top: 0.1rem; + color: var(--bs-success); +} + +.sc-capability-choice-context-copy { + display: flex; + flex-direction: column; + gap: 0.2rem; + min-width: 0; +} + +.sc-capability-choice-context-label { + font-size: 0.8rem; + font-weight: 700; +} + +.sc-capability-choice-context-summary { + font-size: 0.875rem; + font-weight: 600; + line-height: 1.4; + overflow-wrap: anywhere; +} + +.sc-capability-choice-context-detail { + color: var(--bs-secondary-color); + font-size: 0.78rem; + line-height: 1.4; +} + +.sc-chat-clarification-card { + width: min(100%, 34rem); + padding: 0.9rem; + margin-top: 0.85rem; + overflow-wrap: anywhere; + color: var(--bs-body-color); + background: color-mix(in srgb, var(--bs-body-bg) 94%, var(--bs-warning) 6%); + border: 1px solid rgba(var(--bs-warning-rgb), 0.42); + border-left: 4px solid var(--bs-warning); + border-radius: 6px; +} + +.sc-chat-clarification-card.is-resolved { + background: rgba(var(--bs-success-rgb), 0.06); + border-color: rgba(var(--bs-success-rgb), 0.35); + border-left-color: var(--bs-success); +} + +.sc-chat-clarification-card.is-expired { + background: var(--bs-tertiary-bg); + border-color: var(--bs-border-color); + border-left-color: var(--bs-secondary-color); +} + +.sc-chat-clarification-header { + display: flex; + gap: 0.55rem; + align-items: center; + margin-bottom: 0.45rem; +} + +.sc-chat-clarification-header > i { + color: var(--bs-warning-text-emphasis); + font-size: 1.1rem; +} + +.sc-chat-clarification-title { + margin: 0; + font-size: 1rem; + font-weight: 700; + line-height: 1.35; +} + +.sc-chat-clarification-question { + margin: 0 0 0.75rem; + font-size: 0.9rem; + line-height: 1.45; +} + +.sc-chat-clarification-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.sc-chat-clarification-option, +.sc-chat-clarification-free-text { + min-height: 44px; + max-width: 100%; + white-space: normal; +} + +.sc-chat-clarification-option { + flex: 1 1 9rem; +} + +.sc-chat-clarification-free-text { + flex: 1 1 100%; + text-decoration: none; +} + +.sc-chat-clarification-status { + min-height: 1.35rem; + margin-top: 0.55rem; + font-size: 0.82rem; + line-height: 1.35; +} + @media (max-width: 575.98px) { .sc-capability-choice-option-grid { grid-template-columns: 1fr; @@ -337,6 +457,16 @@ grid-column: 2; justify-content: space-between; } + + .sc-chat-clarification-actions { + flex-direction: column; + } + + .sc-chat-clarification-option, + .sc-chat-clarification-free-text { + flex-basis: auto; + width: 100%; + } } /* chats.css */ @@ -4633,4 +4763,8 @@ mark.search-highlight { [data-bs-theme="dark"] .orchestration-progress-step + .orchestration-progress-step { border-top-color: rgba(148, 163, 184, 0.2); +} + +.sc-chat-clarification-card.is-resolving { + border-left-color: var(--bs-primary); } \ No newline at end of file diff --git a/application/single_app/static/js/admin/admin_model_endpoints.js b/application/single_app/static/js/admin/admin_model_endpoints.js index d6c6e69a..c51cf5dc 100644 --- a/application/single_app/static/js/admin/admin_model_endpoints.js +++ b/application/single_app/static/js/admin/admin_model_endpoints.js @@ -245,6 +245,23 @@ function updateHiddenInput() { return; } endpointsInput.value = JSON.stringify(modelEndpoints || []); + const plannerEndpoints = (modelEndpoints || []).map(endpoint => ({ + id: endpoint?.id || '', + name: endpoint?.name || '', + displayName: endpoint?.displayName || '', + provider: endpoint?.provider || '', + enabled: endpoint?.enabled !== false, + models: (Array.isArray(endpoint?.models) ? endpoint.models : []).map(model => ({ + id: model?.id || '', + displayName: model?.displayName || '', + deploymentName: model?.deploymentName || '', + modelName: model?.modelName || '', + enabled: model?.enabled !== false, + })), + })); + document.dispatchEvent(new CustomEvent('model-endpoints-changed', { + detail: { endpoints: plannerEndpoints }, + })); } function normalizeDefaultModelSelection(selection) { diff --git a/application/single_app/static/js/admin/admin_settings.js b/application/single_app/static/js/admin/admin_settings.js index 6d63143d..70dda059 100644 --- a/application/single_app/static/js/admin/admin_settings.js +++ b/application/single_app/static/js/admin/admin_settings.js @@ -9708,21 +9708,93 @@ function setupCapabilityPlannerControls() { updateModeDescription(); const modelSource = document.getElementById('chat_capability_planner_model_source'); - const configuredModelFields = [ - document.getElementById('chat_capability_planner_model_endpoint_id'), - document.getElementById('chat_capability_planner_model_id'), - ].filter(Boolean); - const updateConfiguredModelFields = () => { - const usesConfiguredModel = modelSource?.value === 'configured'; - configuredModelFields.forEach(field => { - field.readOnly = !usesConfiguredModel; - field.setAttribute('aria-disabled', `${!usesConfiguredModel}`); - field.classList.toggle('bg-body-secondary', !usesConfiguredModel); + const endpointSelect = document.getElementById('chat_capability_planner_model_endpoint_id'); + const modelSelect = document.getElementById('chat_capability_planner_model_id'); + let globalEndpoints = []; + const savedEndpointId = endpointSelect?.dataset.selectedValue || ''; + const savedModelId = modelSelect?.dataset.selectedValue || ''; + + const endpointLabel = endpoint => { + const name = endpoint.name || endpoint.displayName || endpoint.id; + const provider = endpoint.provider ? ` (${endpoint.provider})` : ''; + return `${name}${provider}`; + }; + + const modelLabel = model => { + const name = model.displayName || model.deploymentName || model.modelName || model.id; + const detail = model.modelName && model.modelName !== name ? ` (${model.modelName})` : ''; + return `${name}${detail}`; + }; + + const populateEndpointOptions = preferredEndpointId => { + if (!endpointSelect) { + return; + } + endpointSelect.replaceChildren(new Option('Use selected chat model', '')); + globalEndpoints.forEach(endpoint => { + endpointSelect.add(new Option(endpointLabel(endpoint), endpoint.id)); }); + endpointSelect.value = globalEndpoints.some(endpoint => endpoint.id === preferredEndpointId) + ? preferredEndpointId + : ''; + }; + + const setGlobalEndpoints = (endpoints, preferredEndpointId, preferredModelId) => { + globalEndpoints = (Array.isArray(endpoints) ? endpoints : []) + .filter(endpoint => endpoint && endpoint.enabled !== false && endpoint.id); + populateEndpointOptions(preferredEndpointId); + updateConfiguredModelFields(preferredModelId); + }; + + const populateModelOptions = preferredModelId => { + if (!modelSelect) { + return; + } + const endpoint = globalEndpoints.find(candidate => candidate.id === endpointSelect?.value); + const enabledModels = Array.isArray(endpoint?.models) + ? endpoint.models.filter(model => model && model.enabled !== false && model.id) + : []; + const emptyLabel = endpoint + ? 'Select a model' + : 'Select a global endpoint first'; + modelSelect.replaceChildren(new Option(emptyLabel, '')); + enabledModels.forEach(model => { + modelSelect.add(new Option(modelLabel(model), model.id)); + }); + modelSelect.value = enabledModels.some(model => model.id === preferredModelId) + ? preferredModelId + : ''; + modelSelect.disabled = modelSource?.value !== 'configured' || !endpoint || enabledModels.length === 0; }; - modelSource?.addEventListener('change', updateConfiguredModelFields); - updateConfiguredModelFields(); + const updateConfiguredModelFields = preferredModelId => { + const usesConfiguredModel = modelSource?.value === 'configured'; + if (endpointSelect) { + endpointSelect.disabled = !usesConfiguredModel || globalEndpoints.length === 0; + endpointSelect.setAttribute('aria-disabled', `${endpointSelect.disabled}`); + endpointSelect.classList.toggle('bg-body-secondary', endpointSelect.disabled); + } + populateModelOptions(preferredModelId ?? modelSelect?.value ?? savedModelId); + if (modelSelect) { + modelSelect.setAttribute('aria-disabled', `${modelSelect.disabled}`); + modelSelect.classList.toggle('bg-body-secondary', modelSelect.disabled); + } + }; + + modelSource?.addEventListener('change', () => updateConfiguredModelFields()); + endpointSelect?.addEventListener('change', () => { + populateModelOptions(''); + markFormAsModified(); + }); + modelSelect?.addEventListener('change', markFormAsModified); + document.addEventListener('model-endpoints-changed', event => { + setGlobalEndpoints( + event.detail?.endpoints, + endpointSelect?.value ?? savedEndpointId, + modelSelect?.value ?? savedModelId, + ); + }); + setGlobalEndpoints(window.modelEndpoints, savedEndpointId, savedModelId); panel.querySelectorAll('.capability-planner-range').forEach(rangeInput => { const outputId = rangeInput.getAttribute('data-value-output'); 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 401d557d..02e56c49 100644 --- a/application/single_app/static/js/chat/chat-capability-choice.js +++ b/application/single_app/static/js/chat/chat-capability-choice.js @@ -78,7 +78,7 @@ 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()) + kind: ['agent', 'capability', 'context', 'continue'].includes(normalizeIdentifier(option?.kind).toLowerCase()) ? normalizeIdentifier(option?.kind).toLowerCase() : 'capability', label: String(option?.label || '').trim().slice(0, 120), @@ -121,6 +121,12 @@ function normalizeProposal(metadata) { .filter(Boolean) .slice(0, 8) : [], + priorGoalIncluded: proposal.prior_goal_included === true, + goalSourceCount: Math.max( + 0, + Math.min(Number.parseInt(proposal.goal_source_count, 10) || 0, 3), + ), + goalDisplaySummary: String(proposal.goal_display_summary || '').trim().slice(0, 240), options, decision: proposal.decision && typeof proposal.decision === 'object' ? { @@ -166,6 +172,9 @@ function getOptionCapabilityLabels(option) { } function getOptionDescription(option) { + if (option.kind === 'context') { + return 'Continue the earlier request with the listed external sources.'; + } if (option.kind === 'agent') { const scope = option.scopeClass ? `${normalizeClassLabel(option.scopeClass, {})} governed agent` @@ -188,6 +197,9 @@ function getOptionDescription(option) { } function getOptionIconClass(option) { + if (option.kind === 'context') { + return 'bi-chat-left-text'; + } if (option.kind === 'agent') { return 'bi-robot'; } @@ -287,6 +299,9 @@ function getCompactOptionDetail(option) { if (option.kind === 'agent') { return getOptionDescription(option); } + if (option.kind === 'context') { + return 'Earlier request approved for this turn'; + } return ''; } @@ -431,7 +446,14 @@ async function submitDecision(card, proposal, option, statusElement, onResume) { } } -function renderPendingOptions(card, proposal, actions, statusElement, onResume) { +function renderPendingOptions( + card, + proposal, + actions, + statusElement, + onResume, + disclosureId = '', +) { const optionGrid = createElement('div', 'sc-capability-choice-option-grid'); const selectableOptions = proposal.options.filter(option => option.id !== CONTINUE_OPTION_ID); const continueOption = proposal.options.find(option => option.id === CONTINUE_OPTION_ID); @@ -445,7 +467,10 @@ function renderPendingOptions(card, proposal, actions, statusElement, onResume) button.type = 'button'; button.dataset.optionId = option.id; button.dataset.testid = 'capability-option-card'; - button.setAttribute('aria-describedby', statusElement.id); + button.setAttribute( + 'aria-describedby', + [statusElement.id, disclosureId].filter(Boolean).join(' '), + ); button.setAttribute( 'aria-label', `${isRecommended ? 'Recommended: ' : ''}${option.label}`, @@ -494,7 +519,10 @@ function renderPendingOptions(card, proposal, actions, statusElement, onResume) ); continueButton.type = 'button'; continueButton.dataset.optionId = continueOption.id; - continueButton.setAttribute('aria-describedby', statusElement.id); + continueButton.setAttribute( + 'aria-describedby', + [statusElement.id, disclosureId].filter(Boolean).join(' '), + ); continueButton.appendChild(createElement('span', '', continueOption.label)); const arrow = createElement('i', 'bi bi-arrow-right ms-2'); arrow.setAttribute('aria-hidden', 'true'); @@ -578,11 +606,46 @@ export function hydrateCapabilityChoice(messageElement, metadata, { onResume } = const hasExternalCapabilityOption = proposal.options.some( option => option.externalData && option.kind !== 'agent' ); + let disclosureId = ''; + if (proposal.priorGoalIncluded) { + disclosureId = `capability-context-notice-${safeId}`; + const disclosure = createElement('div', 'sc-capability-choice-context-notice'); + disclosure.id = disclosureId; + disclosure.dataset.testid = 'capability-prior-goal-notice'; + const disclosureIcon = createElement('i', 'bi bi-chat-left-text'); + disclosureIcon.setAttribute('aria-hidden', 'true'); + disclosure.appendChild(disclosureIcon); + const disclosureCopy = createElement('div', 'sc-capability-choice-context-copy'); + disclosureCopy.appendChild(createElement( + 'span', + 'sc-capability-choice-context-label', + 'Continuing an earlier request', + )); + if (proposal.goalDisplaySummary) { + disclosureCopy.appendChild(createElement( + 'span', + 'sc-capability-choice-context-summary', + proposal.goalDisplaySummary, + )); + } + disclosureCopy.appendChild(createElement( + 'span', + 'sc-capability-choice-context-detail', + hasExternalCapabilityOption + ? 'External retrieval will use the earlier request together with this follow-up only after you choose an external option.' + : 'The earlier request will be used with this follow-up only for this turn.', + )); + disclosure.appendChild(disclosureCopy); + card.appendChild(disclosure); + } + if (hasExternalCapabilityOption) { const notice = createElement( 'p', 'sc-capability-choice-notice', - 'External options send only the current message query. Conversation history and workspace content are not included.', + proposal.priorGoalIncluded + ? 'Only the disclosed user-authored goal is included. Assistant responses and workspace content are not sent.' + : 'External options send only the current message query. Conversation history and workspace content are not included.', ); notice.dataset.testid = 'capability-external-data-notice'; card.appendChild(notice); @@ -616,7 +679,14 @@ export function hydrateCapabilityChoice(messageElement, metadata, { onResume } = if (proposal.status === 'pending' && proposal.expired) { updateStatus(statusElement, 'This capability choice has expired.', 'muted'); } else if (proposal.status === 'pending') { - renderPendingOptions(card, proposal, actions, statusElement, onResume); + renderPendingOptions( + card, + proposal, + actions, + statusElement, + onResume, + disclosureId, + ); } else if (proposal.status === 'approved' || proposal.status === 'declined') { compactSelectionRendered = renderResolvedAction(card, proposal, statusElement, onResume); } else { diff --git a/application/single_app/static/js/chat/chat-clarification.js b/application/single_app/static/js/chat/chat-clarification.js new file mode 100644 index 00000000..043c03e9 --- /dev/null +++ b/application/single_app/static/js/chat/chat-clarification.js @@ -0,0 +1,175 @@ +// chat-clarification.js + +const MAX_OPTIONS = 6; +const QUESTION_BY_CODE = Object.freeze({ + ambiguous_reference: 'What does the referenced item mean in this request?', + document_targets_required: 'Which documents should I use?', + jurisdiction_required: 'Which jurisdiction applies?', + output_format_required: 'What output format do you want?', + source_scope_required: 'Where should I look for this information?', + target_entity_required: 'Which person, organization, or item should I use?', + time_range_required: 'What time range should I use?', +}); + +function normalizeClarification(metadata) { + const clarification = metadata?.chat_clarification; + if (!clarification || typeof clarification !== 'object') { + return null; + } + const code = String(clarification.code || '').trim().toLowerCase(); + const question = QUESTION_BY_CODE[code]; + const status = String(clarification.status || '').trim().toLowerCase(); + if (!question || !['pending', 'resolving', 'resolved', 'expired', 'failed'].includes(status)) { + return null; + } + const options = Array.isArray(clarification.options) + ? clarification.options + .map(value => String(value || '').trim().slice(0, 120)) + .filter((value, index, values) => value && values.indexOf(value) === index) + .slice(0, MAX_OPTIONS) + : []; + const expiresAt = String(clarification.expires_at || '').trim(); + const expiresAtMilliseconds = Date.parse(expiresAt); + return { + code, + question, + status, + options, + expired: Number.isFinite(expiresAtMilliseconds) + && expiresAtMilliseconds <= Date.now(), + }; +} + +function createElement(tagName, className = '', text = '') { + const element = document.createElement(tagName); + if (className) { + element.className = className; + } + if (text) { + element.textContent = text; + } + return element; +} + +function setBusy(card, busy) { + card.dataset.submitting = busy ? 'true' : 'false'; + card.querySelectorAll('button').forEach(button => { + button.disabled = busy; + }); +} + +function updateStatus(statusElement, text, tone = 'muted') { + statusElement.className = `sc-chat-clarification-status text-${tone}`; + statusElement.textContent = text; +} + +export function hydrateChatClarification( + messageElement, + metadata, + { onSubmit, onFocusInput } = {}, +) { + if (!messageElement) { + return; + } + messageElement.querySelector('.sc-chat-clarification-card')?.remove(); + const clarification = normalizeClarification(metadata); + if (!clarification) { + return; + } + const messageText = messageElement.querySelector('.message-text'); + if (!messageText) { + return; + } + + const card = createElement('section', 'sc-chat-clarification-card'); + card.dataset.testid = 'chat-clarification-card'; + const title = createElement('h3', 'sc-chat-clarification-title', 'One detail needed'); + const statusElement = createElement( + 'div', + 'sc-chat-clarification-status text-muted', + ); + const titleId = `chat-clarification-title-${clarification.code}`; + const statusId = `chat-clarification-status-${clarification.code}`; + title.id = titleId; + statusElement.id = statusId; + statusElement.setAttribute('role', 'status'); + statusElement.setAttribute('aria-live', 'polite'); + card.setAttribute('aria-labelledby', titleId); + + const header = createElement('div', 'sc-chat-clarification-header'); + const icon = createElement('i', 'bi bi-question-circle'); + icon.setAttribute('aria-hidden', 'true'); + header.appendChild(icon); + header.appendChild(title); + card.appendChild(header); + card.appendChild(createElement( + 'p', + 'sc-chat-clarification-question', + clarification.question, + )); + + const isPending = clarification.status === 'pending' && !clarification.expired; + if (isPending) { + const actions = createElement('div', 'sc-chat-clarification-actions'); + clarification.options.forEach(option => { + const optionButton = createElement( + 'button', + 'btn btn-outline-primary sc-chat-clarification-option', + option, + ); + optionButton.type = 'button'; + optionButton.dataset.testid = 'chat-clarification-option'; + optionButton.setAttribute('aria-describedby', statusId); + optionButton.addEventListener('click', () => { + if (card.dataset.submitting === 'true') { + return; + } + setBusy(card, true); + updateStatus(statusElement, 'Sending your answer...', 'primary'); + const submitted = typeof onSubmit === 'function' + ? onSubmit(option) + : false; + if (submitted === false) { + setBusy(card, false); + updateStatus( + statusElement, + 'Your answer could not be sent. Type it in the message box.', + 'danger', + ); + } + }); + actions.appendChild(optionButton); + }); + + const freeTextButton = createElement( + 'button', + 'btn btn-link sc-chat-clarification-free-text', + 'Answer in the message box', + ); + freeTextButton.type = 'button'; + freeTextButton.setAttribute('aria-describedby', statusId); + freeTextButton.addEventListener('click', () => { + if (typeof onFocusInput === 'function') { + onFocusInput(); + } + }); + actions.appendChild(freeTextButton); + card.appendChild(actions); + updateStatus(statusElement, 'Waiting for your answer.'); + } else if (clarification.status === 'resolved') { + card.classList.add('is-resolved'); + updateStatus(statusElement, 'Answer saved.', 'success'); + } else if (clarification.status === 'resolving') { + card.classList.add('is-resolving'); + updateStatus(statusElement, 'Your answer is being processed.', 'primary'); + } else { + card.classList.add('is-expired'); + updateStatus( + statusElement, + 'This clarification has expired. Send a new message to continue.', + 'muted', + ); + } + card.appendChild(statusElement); + messageText.insertAdjacentElement('afterend', card); +} diff --git a/application/single_app/static/js/chat/chat-messages.js b/application/single_app/static/js/chat/chat-messages.js index 313c6347..d98b17ca 100644 --- a/application/single_app/static/js/chat/chat-messages.js +++ b/application/single_app/static/js/chat/chat-messages.js @@ -25,6 +25,7 @@ import { createThoughtsToggleHtml, attachThoughtsToggleListener } from './chat-t import { destroyInlineCharts, extractInlineChartBlocks, hydrateInlineCharts, injectInlineChartHtml, restoreInlineChartTokens } from './chat-inline-charts.js'; import { attachGeneratedImageProposalResults, extractInlineImageProposalBlocks, hydrateInlineImageProposals, injectInlineImageProposalHtml, restoreInlineImageProposalTokens } from './chat-inline-image-proposals.js'; import { hydrateCapabilityChoice } from './chat-capability-choice.js'; +import { hydrateChatClarification } from './chat-clarification.js'; import { renderInlineVideoGalleries } from './chat-inline-videos.js'; import { renderInlineImageGalleries } from './chat-inline-images.js'; import { renderInlineAzureMaps } from './chat-inline-maps.js'; @@ -4988,6 +4989,21 @@ export function appendMessage( } }), }); + hydrateChatClarification(messageDiv, fullMessageObject?.metadata || null, { + onSubmit: clarificationValue => { + if (!userInput || !String(clarificationValue || '').trim()) { + return false; + } + userInput.value = String(clarificationValue).trim(); + userInput.dispatchEvent(new Event('input', { bubbles: true })); + updateSendButtonVisibility(); + sendMessage(); + return true; + }, + onFocusInput: () => { + userInput?.focus(); + }, + }); // --- Attach Event Listeners specifically for AI message --- attachCodeBlockCopyButtons(messageDiv.querySelector(".message-text")); diff --git a/application/single_app/templates/admin_settings.html b/application/single_app/templates/admin_settings.html index 67d57dc9..7f8de72b 100644 --- a/application/single_app/templates/admin_settings.html +++ b/application/single_app/templates/admin_settings.html @@ -4136,25 +4136,31 @@
-
Use a configured model when planning should be independent of the model selected in chat.
+
Use a configured model when planning should be independent of the model selected in chat. If no complete global model is selected, planning uses the exact model selected for that chat turn.
- -
- + +
Choose an enabled endpoint from Admin Settings > AI Models.
- -
- + +
Only enabled models from the selected global endpoint are available.
diff --git a/docs/explanation/features/CHAT_CAPABILITY_MODEL_PLANNER.md b/docs/explanation/features/CHAT_CAPABILITY_MODEL_PLANNER.md index 0cabe5c5..07cb304d 100644 --- a/docs/explanation/features/CHAT_CAPABILITY_MODEL_PLANNER.md +++ b/docs/explanation/features/CHAT_CAPABILITY_MODEL_PLANNER.md @@ -12,6 +12,8 @@ Choice-card experience enhanced in version: **0.250.074** Resolved choice state compacted in version: **0.250.075** +Planner-first runtime corrected in version: **0.250.077** + Associated issue: **[#1021](https://github.com/microsoft/simplechat/issues/1021)** ## Overview @@ -23,10 +25,12 @@ or `clarify` result. The model never receives execution authority. Phase 10A introduced `off` and observational `shadow` modes. Phase 10B adds a conservative `assist` mode: a validated high-confidence `propose` result may -become one durable server-authored choice card. The existing deterministic -recommendation wins material conflicts, and planner failure always falls back -to deterministic or direct behavior. `clarify` remains observational until -Phase 10C. +become one durable server-authored choice card. In `assist`, the server builds +the governed inventory but does not run deterministic requirement classifiers, +create heuristic suggestions, or automatically activate discovered tools. One +fast model call owns the `direct`, `propose`, or `clarify` decision. Invalid or +failed planning continues directly with explicit user selections only. Phase +10C adds bounded prior-user-turn goals and one durable structured clarification. ## Dependencies @@ -42,10 +46,10 @@ The streaming path performs these operations in order: 1. Authorize the user, conversation, active scopes, selected controls, and input readiness. 2. Resolve the chat model through existing endpoint governance. -3. Build the safe capability inventory and deterministic control recommendation. +3. Build the safe capability inventory. In `shadow`, also build the unchanged deterministic control for comparison. 4. When eligible, invoke and strictly validate the planner. 5. In `shadow`, compare safe planner classes with the unchanged deterministic control. -6. In `assist`, materialize high-confidence candidates from the current server inventory and arbitrate them against the deterministic recommendation. +6. In `assist`, materialize high-confidence candidates from the current server inventory without heuristic arbitration or automatic discovery. 7. Persist at most one recommendation through the existing capability proposal contract, or continue directly when no material valid proposal remains. 8. Recursively expand selected, automatic, and approved bundles, then reauthorize every effective member at decision, resume, and immediately before child-run execution. 9. Continue through the existing runtime, evidence-ledger, and central-finalization paths. @@ -122,30 +126,24 @@ proposal completion write transiently fails. Wrappers do not downgrade that execution to retryable failure, and restart reconciliation may complete the same exact running or failed execution without accepting a newer claim. -Deterministic recommendations use the same recursive baseline semantics as -planner options. Selected bundle dependencies are treated as already effective -and cannot be offered again. Deterministic built-in options are rebound to the -fresh recursive closure and safe policy fields at decision and resume, so a -bundle member added, removed, or replaced invalidates the stored option. -Streaming, non-streaming, and document-action provenance all record the same -expanded selected closure while preserving only explicit roots in the immutable -selection snapshot. - -Material arbitration is deliberately conservative. A planner plan may augment -the deterministic recommendation only when it contains the deterministic -plan's effective capability set. If it omits or conflicts with that material -source, the deterministic recommendation remains authoritative. Low confidence, -invalid output, timeout, refusal, filtering, provider failure, materialization -failure, or persistence failure grants no new capability. +Deterministic recommendations retain the same recursive baseline semantics in +`off` and as the observational control in `shadow`. In `assist`, neither built-in +nor governed-agent heuristic classification runs. Explicit selections and their +dependencies remain authoritative mandates, but deterministic discovery cannot +create a choice or automatic capability. Low confidence, invalid output, +timeout, refusal, filtering, provider failure, materialization failure, or +persistence failure grants no new capability and continues directly. ## Request Contract -The version 1 request contains: +The version 2 request contains: - The current user request, bounded to 16,000 characters. +- At most two preceding active user turns, projected with opaque turn references and bounded text. - Selected capability IDs marked as required mandates. - At most 64 safe available built-in and governed-agent descriptors. - Server-owned candidate and per-plan limits. +- Bounded structured clarification state and allowlisted clarification options. - Literal policy flags stating that the planner cannot execute or grant access. Built-in descriptors contain only server-known planning classes such as state, @@ -162,14 +160,15 @@ artifacts, secrets, and inaccessible counts are forbidden. ## Result Validation -The version 1 result schema permits only: +The version 2 result schema permits only: - `version` - `decision` +- `goal_turn_refs` - `requirements` - `candidate_plans` - `recommended_plan_id` -- `clarification_code` +- `clarification` Requirements and candidates have fixed nested fields and use allowlisted reason, evidence, and confidence classes. Validation rejects malformed JSON, missing or @@ -193,21 +192,25 @@ repair can turn an unknown ID into a known capability. ## Provider And Timeout Behavior -The default model source is `same_as_chat`. `configured` mode accepts only a -server-saved global endpoint ID and model ID and resolves them through current -global endpoint and item governance. A personal or group endpoint with the same -ID cannot shadow the configured planner. Missing, disabled, inaccessible, or -unsupported configured models do not fall back to browser input or another -model. - -Azure OpenAI and compatible OpenAI providers prefer strict JSON schema and use a -bounded list of fallbacks only when the active optional request parameter is -explicitly unsupported. Anthropic-compatible providers receive the exact result -schema in a JSON-only prompt. SDK retries are disabled, and arbitrary model, -network, quota, or service failures are not retried. +The default model source is `same_as_chat`, which uses the exact endpoint client +and deployment selected for that chat turn. `configured` mode accepts only a +complete server-saved global endpoint ID and model ID pair and resolves it +through current global endpoint and item governance. A personal or group +endpoint with the same ID cannot shadow the configured planner. An incomplete +configured pair normalizes back to the exact selected chat model; a complete +but unavailable configured pair fails closed rather than choosing the first +model in a catalog or accepting browser-supplied connection data. + +Azure OpenAI and compatible OpenAI providers begin with strict JSON schema, +`max_completion_tokens`, and `reasoning_effort=minimal`. A bounded sequence may +remove unsupported reasoning or schema options, use JSON-object mode, or rely +on the prompt schema only when a specific optional field produces a safe HTTP +400 compatibility class. Anthropic-compatible providers receive the exact +result schema in a JSON-only prompt. SDK retries are disabled, and arbitrary +model, network, quota, authentication, or service failures are not retried. Every call passes its remaining deadline to the provider request itself. The -default is 5,000 milliseconds, persisted values are clamped from 250 to 10,000 +default is 10,000 milliseconds, persisted values are clamped from 250 to 20,000 milliseconds, all compatibility variants share that one wall-clock budget, and the Anthropic HTTP adapter splits the same bound across connect and read time in `requests.post`. Timeout, empty or malformed provider output, invalid JSON, @@ -232,12 +235,16 @@ Backend settings and defaults are: ``` Admin Settings exposes `Off`, `Shadow`, and `Assist` modes plus model source, -global endpoint/model IDs, timeout, completion budget, candidate count, and -per-plan capability limits. Server normalization accepts only +dependent global endpoint/model selectors, timeout, completion budget, +candidate count, and per-plan capability limits. The endpoint selector lists +enabled global endpoints, and the model selector lists enabled models from the +chosen endpoint while preserving unsaved selections across catalog edits. +Server normalization accepts only `off | shadow | assist`, clamps every numeric value to the documented bounds, -and forces incomplete configured-model selections back to `off`. The shipped -default is `assist`; administrators may select `off` as a kill switch or return -to `shadow` without altering already persisted proposals awaiting a decision. +and forces incomplete configured-model selections back to `same_as_chat` without +disabling the selected planner mode. The shipped default is `assist`; +administrators may select `off` as a kill switch or return to `shadow` without +altering already persisted proposals awaiting a decision. The Admin panel defines each mode and setting through visible descriptions and keyboard-accessible information tooltips: @@ -248,9 +255,9 @@ keyboard-accessible information tooltips: display proposals, or execute suggested capabilities. - `Off` skips the planner model call entirely. - `Same as selected chat model` plans with the model selected for each turn. - `Configured global model` instead requires the internal global endpoint ID - and model ID from the Admin model-endpoint catalog; these values are not URLs, - deployment labels, candidate plans, or capability IDs. + `Configured global model` instead uses dependent selectors backed by the + sanitized Admin model-endpoint catalog; endpoint URLs, credentials, and raw + settings never enter these controls. - Planner timeout uses a 1-20 second slider with 10 seconds recommended. - Completion budget uses a 64-1200 token slider with 600 recommended. It limits only the compact planner JSON, not the final response or tool output. @@ -300,8 +307,9 @@ deterministic evaluation rows, and the required archive, additive, direct, selected-mandate, clarify, and governed-agent scenarios. `functional_tests/test_chat_capability_planner_route.py` verifies route ordering, -off/resume/cancellation gates, server-owned model selection, deterministic -control isolation, and user-turn-only shadow metadata. +off/resume/cancellation gates, exact turn-model fallback, global-only configured +selection, planner-only Assist discovery, deterministic Shadow isolation, and +user-turn-only metadata. `functional_tests/test_phase10a_controlled_shadow_runner.py` validates the realistic controlled manifest, unavailable and input-not-ready filtering, @@ -312,8 +320,8 @@ clarification, prompt-injection, unavailable, unauthorized, and policy-blocked behavior. `functional_tests/test_phase10b_governed_additive_plan_activation.py` validates -assist normalization and eligibility, high-confidence activation, deterministic -conflict precedence, Workspace plus Web and selected/automatic additive cases, +assist normalization and eligibility, high-confidence planner-only activation, +Workspace plus Web and selected/automatic additive cases, Deep Research expansion, deterministic and planner selected/automatic bundle subtraction, root-to-closure drift, deterministic option rebinding, dependency policy drift, equivalent-plan collapse, governed agents, Image/agent exclusion, @@ -340,6 +348,13 @@ Run the combined deterministic gate with: No live or billable planner call is required by the test suite. +Version 0.250.077 was also probed through the exact server-resolved model +selected for a chat turn. The non-executing public-archive scenario returned a +valid `propose` result in 4.017 seconds, recommending Deep Research with Web +Search as the alternative. The endpoint used the bounded JSON-object +compatibility variant; no capability was executed and no secret, endpoint ID, +host, prompt, or raw provider response was printed or persisted. + After deterministic gates pass, run the opt-in controlled live matrix with a known test deployment: @@ -362,7 +377,7 @@ prohibited execution-surface imports, and p50/p95 latency of 1.67/2.65 seconds. ## Known Limitations - `shadow` remains observational; only `assist` can materialize a proposal. -- `clarify` is still observational and cannot create a conversational checkpoint until Phase 10C. -- Only the current user request is available. Prior-turn goal resolution and prior-user-text external queries are deferred to Phase 10C. +- `clarify` is limited to one durable allowlisted clarification checkpoint per goal. +- Context is limited to the current request and at most two preceding active user turns; prior-user-text external egress always requires a separate durable choice. - Planner activation is limited to read-only built-ins and Phase 8B governed agents. Consequential, write, action-attached, sensitive-by-policy, and over-budget tools remain prohibited. - Generalized document, presentation, data, export, and workflow finalizers remain Phase 11 work. \ No newline at end of file diff --git a/docs/explanation/features/CHAT_TURN_ORCHESTRATION.md b/docs/explanation/features/CHAT_TURN_ORCHESTRATION.md index efab2126..9de26baf 100644 --- a/docs/explanation/features/CHAT_TURN_ORCHESTRATION.md +++ b/docs/explanation/features/CHAT_TURN_ORCHESTRATION.md @@ -5,6 +5,8 @@ Updated for governed planner activation in version: **0.250.072** Updated planner administration in version: **0.250.073** Updated governed choice experience in version: **0.250.074** Updated resolved choice state in version: **0.250.075** +Updated contextual goals and clarification in version: **0.250.076** +Corrected planner-first Assist runtime in version: **0.250.077** Associated issue: **[#1021](https://github.com/microsoft/simplechat/issues/1021)** ## Overview @@ -329,9 +331,11 @@ documented in `docs/explanation/features/CHAT_ORCHESTRATION_EVALUATION_QUALITY_GATES.md`. Phase 10A adds model-assisted capability planning in non-executing shadow mode. Phase 10B activates validated high-confidence additive proposals through the -existing durable choice lifecycle. Phase 10C remains responsible for contextual -clarification and bounded prior-user-goal references. Generalized output types -move to Phase 11. +existing durable choice lifecycle. Phase 10C adds contextual clarification and +bounded prior-user-goal references. Version 0.250.077 makes Assist planner-first: +the model owns capability suggestions while the server retains inventory, +authorization, approval, execution, and evidence control. Generalized output +types move to Phase 11. ## Phase 10A Model-Assisted Planner Shadow Mode @@ -373,12 +377,15 @@ plan remains the only behavioral input. The planner uses the already resolved chat model by default. Administrators may configure a saved global endpoint/model pair; the server resolves it through global endpoint and item governance and ignores colliding personal or group -endpoint IDs. Browser model values are not consulted. Azure OpenAI uses strict -JSON schema when supported, OpenAI-style providers use bounded compatibility -variants, and Anthropic-compatible models receive the exact schema in JSON-only -prompting. Calls are non-streaming, tool-free, single-result, disable SDK retries, -and share one real transport deadline that defaults to five seconds and cannot -exceed ten seconds. +endpoint IDs. An incomplete configured pair falls back to the exact client and +deployment selected for the chat turn; it never chooses the first catalog model. +Browser connection values are not consulted. Azure OpenAI starts with strict +JSON schema, `max_completion_tokens`, and minimal reasoning. OpenAI-style +providers use bounded compatibility variants only for classified optional-field +HTTP 400 responses, and Anthropic-compatible models receive the exact schema in +JSON-only prompting. Calls are non-streaming, tool-free, single-result, disable +SDK retries, and share one real transport deadline that defaults to ten seconds +and cannot exceed twenty seconds. ### Shadow Metadata And Evaluation @@ -414,12 +421,17 @@ silently adding, removing, or replacing work in an approved turn. A selected dependency keeps `selection` origin while remaining part of the bound closure. Planner and deterministic recommendations use conservative precedence. -Submitted selections, explicit declines, current eligibility, and material -deterministic recommendations remain authoritative. A planner plan can add -complementary work only when it contains the deterministic effective set; -otherwise deterministic behavior wins. Planner timeout, invalid output, low -confidence, provider failure, bundle failure, or proposal persistence failure -executes no newly proposed capability. +This was the original Phase 10B arbitration contract. Version 0.250.077 changes +current Assist behavior: the server builds the governed inventory but skips +built-in and governed-agent heuristic requirement classification, deterministic +recommendation generation, and automatic capability discovery. The validated +planner is the only source of new suggestions. Submitted selections, explicit +declines, current eligibility, and server policy remain authoritative. Planner +timeout, invalid output, low confidence, provider failure, bundle failure, or +proposal persistence failure suggests and executes no newly proposed capability. +Off retains explicit selections and deterministic server behavior; Shadow may +still compute the deterministic recommendation solely as an observational +comparison control. Activated plans reuse the existing `awaiting_user_choice` assistant message, allowlisted option decision, conversation/source-turn authorization, ETag @@ -454,8 +466,9 @@ reconciliation completes only the same execution ID from running or failed state. Deterministic and planner recommendation paths both subtract the complete -selected and automatic closure. Stored deterministic built-in options are also -rebound to the current recursive closure before resume. Streaming, +selected and automatic closure where those paths remain active. Stored +deterministic built-in options are also rebound to the current recursive closure +before resume. Assist does not create new automatic discovery roots. Streaming, non-streaming, and document-action paths persist the same expanded selected members with `selection` origin while retaining only explicit roots in the selection snapshot. @@ -480,10 +493,65 @@ to compose a new bundle. Selecting a card immediately submits its one persisted option ID and resumes execution. A full-width secondary action continues without additional capabilities. External-data notices and submitted selections remain visible as context. Admin Settings exposes the three modes -and bounded planner runtime controls for staged rollout. After a choice is +and bounded planner runtime controls for staged rollout. Configured global model +selection uses an enabled endpoint dropdown and a dependent enabled-model +dropdown sourced from sanitized settings; unsaved valid selections survive +catalog refreshes. After a choice is saved, the expanded chooser condenses to one inert summary showing the selected plan, its included capabilities, and Saved, Running, Completed, or retry state. +## Phase 10C Contextual Goals And Structured Clarification + +Phase 10C lets the planner interpret a terse follow-up against a bounded set of +exact prior user goals. Planning context contains the current user message and +at most two preceding active user messages from the same thread lineage. The +server excludes assistant, system, tool, evidence, citation, workspace, +deleted, fully or partially masked, generated-artifact, inactive, and +cross-thread content. The planner receives only request-local `turn_0` through +`turn_2` references; canonical conversation and message IDs remain server-side. + +The planner may select supplied goal refs, but it cannot author executable +combined text. The server maps validated refs back to exact user documents, +binds their content hashes and thread lineage, and rebuilds the goal in +deterministic order. Internal retrieval can use the exact combined user goal +without crossing an external boundary. A proposal using any prior user turn for Web Search, Deep +Research, or another external source always creates one disclosed durable +choice, including when the external capability was already selected or +automatic. The browser still submits only one persisted option ID. Continuing +without external retrieval suppresses external baseline capabilities for that +turn without changing toolbar defaults. + +Approved contextual retrieval is reauthorized from the exact conversation +partition during proposal load, inside decision and resume ETag transitions, +after the resume lease is claimed, and immediately before the first external +call. Deleted, masked, changed, foreign, inactive, or missing source turns +invalidate the proposal. The outbound query is reconstructed and minimized +again from exact user documents; stored query text is audit context rather than +authorization. Assistant output, planner-composed text, tool/evidence content, +and workspace text cannot become the external query. Address-bearing variants +retain a separate explicit sensitive-input option. + +Validated planner `clarify` decisions map one of seven allowlisted ambiguity +codes to a fixed server-authored question. The assistant persists an +`awaiting_user_clarification` checkpoint and closes the stream. Options submit +through the normal chat path, while free text uses the normal composer. The +clarification response claims an ETag-protected lease before the next planner +call, so an identical concurrent replay cannot create a second child plan. The +claim completes only after the exact response user turn is persisted. A live +claim reconstructs as processing; an expired claim can reuse its child linkage, +and a persisted response turn reconciles completion after process loss. The +response retains exact parent/source/child linkage on the server and consumes +the one-clarification budget for the goal. Unknown codes/options, conflicting +responses, expiry, and source revocation fail closed. + +Client metadata projection removes exact source IDs, content hashes, stored +queries, trusted resume requests, clarification IDs, and run linkage before +history, metadata, initial-render, or SSE responses reach the browser. The +choice and clarification modules render all dynamic summaries and option text +through inert DOM APIs. Fixed-schema evaluation records only bounded turn +counts, booleans, safe codes/classes, hashes, latency, lifecycle states, and +outcomes. + ## Security And Governance - Caller-supplied IDs are never treated as proof of authorization. @@ -640,17 +708,26 @@ Phase 9 evaluation coverage is in: Phase 10A and 10B planner coverage is in: - `functional_tests/test_chat_capability_model_planner.py` for safe request projection, strict direct/propose/clarify validation, selected-mandate preservation, provider variants, transport timeout, cancellation, privacy, the 139-row deterministic evaluation dataset, and required semantic fixtures. -- `functional_tests/test_chat_capability_planner_route.py` for off/shadow eligibility, resume isolation, server-owned configured model selection, route ordering, deterministic control isolation, and user-turn-only metadata. +- `functional_tests/test_chat_capability_planner_route.py` for off/shadow eligibility, resume isolation, exact selected-chat fallback, global-only configured model selection, planner-only Assist discovery, route ordering, deterministic Shadow isolation, and user-turn-only metadata. - `functional_tests/test_phase9_orchestration_observability.py` for the four fixed planner event types and forbidden-value checks. -- `functional_tests/test_phase10b_governed_additive_plan_activation.py` for high-confidence activation, additive bundles, selected and automatic dependency expansion, origin separation, deterministic arbitration, exact plan binding, sensitive current-turn options, governed agents, Image/agent exclusion, failure closure, admin controls, and bounded telemetry. -- `ui_tests/test_chat_capability_choice_card.py` and `ui_tests/test_admin_capability_planner_settings.py` for additive choice rendering and staged rollout controls across desktop and mobile. -- `scripts/run_phase9_orchestration_quality_gates.py` for the combined Phase 9/10A/10B repeatable gate. +- `functional_tests/test_phase10b_governed_additive_plan_activation.py` for planner-owned high-confidence activation, additive bundles, selected and automatic dependency expansion, origin separation, exact plan binding, sensitive current-turn options, governed agents, Image/agent exclusion, failure closure, admin controls, and bounded telemetry. +- `ui_tests/test_chat_capability_choice_card.py` and `ui_tests/test_admin_capability_planner_settings.py` for additive choice rendering, dependent configured-model selectors, and staged rollout controls across desktop and mobile. +- `scripts/run_phase9_orchestration_quality_gates.py` for the combined Phase 9/10A/10B/10C repeatable gate. + +Phase 10C contextual-goal and clarification coverage is in: + +- `functional_tests/test_phase10c_contextual_goals.py` for bounded active user-turn assembly, opaque ref binding, masking/artifact exclusion, exact source rereads, and context-only external choices. +- `functional_tests/test_chat_capability_model_planner.py` for planner v2 goal refs, current-turn requirements, server-allowlisted clarification codes/options, and clarification budget enforcement. +- `functional_tests/test_chat_capability_choice_contract.py`, `functional_tests/test_chat_capability_choice_persistence.py`, and `functional_tests/test_chat_capability_choice_route.py` for source hashes/lineage, client projection, option-scoped approval, ETag validation, mutation/revocation failure, exact query reconstruction, and contextual decline. +- `functional_tests/test_chat_clarification_persistence.py` for fixed questions, option/free-text responses, expiry, ETag retries, duplicate/conflicting responses, and parent/child linkage. +- `functional_tests/test_phase9_orchestration_observability.py` for bounded context, prior-goal, and clarification lifecycle telemetry with forbidden-value checks. +- `ui_tests/test_chat_capability_choice_card.py` and `ui_tests/test_chat_clarification.py` for inert disclosures/options, keyboard and screen-reader state, refresh reconstruction, 44-pixel controls, and desktop/mobile overflow. ## Known Limitations -Phase 8A, Phase 8B, Phase 10A, and Phase 10B add durable governed choice and conservative additive model planning, with these deliberate boundaries: +Phase 8A, Phase 8B, Phase 10A, Phase 10B, and Phase 10C add durable governed choice, conservative additive model planning, bounded contextual goals, and one structured clarification, with these deliberate boundaries: -- `assist` can create one server-authored choice from a validated high-confidence proposal, but the model cannot execute capabilities, author policy fields, bypass deterministic conflict precedence, or create a clarification checkpoint. +- `assist` can create one server-authored choice or one server-templated clarification from a validated result, but the model cannot execute capabilities, author query/question text or policy fields, override explicit selections or server authorization, approve egress, or create an unbounded clarification loop. - Generalized multi-agent recommendation remains out of scope. When an agent is already selected, Phase 8B does not recommend another agent. - Foundry-backed agents and local agents with attached actions remain explicitly selectable but are not discoverable until hidden tools, action arguments, and runtime telemetry have a separately reviewed read-only governance contract. diff --git a/docs/explanation/fixes/PHASE_10C_PLANNER_FIRST_RUNTIME_FIX.md b/docs/explanation/fixes/PHASE_10C_PLANNER_FIRST_RUNTIME_FIX.md new file mode 100644 index 00000000..530bd24b --- /dev/null +++ b/docs/explanation/fixes/PHASE_10C_PLANNER_FIRST_RUNTIME_FIX.md @@ -0,0 +1,77 @@ +# Phase 10C Planner-First Runtime Fix + +Fixed in version: **0.250.077** + +Associated issue: **[#1021](https://github.com/microsoft/simplechat/issues/1021)** + +## Issue + +Assist could invoke the model planner, receive an opaque `client_error`, and +then continue through deterministic capability classification. The user saw a +direct or heuristic result instead of the intended short first model call and +governed capability choice. Incomplete administrator endpoint/model IDs also +disabled the planner rather than using the model selected for the chat turn. +The Admin panel exposed the global endpoint and model IDs as free-form text. + +## Root Cause + +The Azure/OpenAI request profile still included provider-sensitive optional +parameters without a bounded compatibility sequence or safe diagnostic class. +Assist cleared some deterministic output only after the classifiers and +automatic matching had already run. Model-source normalization treated an +incomplete configured pair as a reason to turn planning off, and the Admin UI +did not bind model choices to the selected enabled global endpoint. + +## Technical Changes + +- `application/single_app/functions_chat_capability_planner.py` + - Uses `max_completion_tokens` and minimal reasoning for the first fast call. + - Tries strict JSON schema first, then bounded optional-field and JSON-object + compatibility variants only for classified HTTP 400 rejections. + - Emits only bounded transport error classes and variant indexes; prompts, + provider messages, endpoints, credentials, and raw responses remain absent. +- `application/single_app/route_backend_chats.py` + - Uses the exact turn-selected client and deployment unless both configured + global IDs are complete. + - Builds the authorized inventory in Assist without invoking built-in or + governed-agent heuristic classifiers, creating automatic capability IDs, + or restoring a deterministic recommendation after planner failure. +- `application/single_app/functions_chat_capabilities.py` and + `application/single_app/functions_settings.py` + - Keep Assist activation planner-owned and normalize incomplete configured + model selection to `same_as_chat` while preserving the selected mode. +- `application/single_app/templates/admin_settings.html`, + `application/single_app/static/js/admin/admin_settings.js`, and + `application/single_app/static/js/admin/admin_model_endpoints.js` + - Replace raw ID inputs with dependent enabled global endpoint/model selects. + - Rebuild options with inert DOM APIs and preserve current unsaved selections + when the endpoint catalog emits an update. +- Planner, activation, route, and Admin UI tests cover exact model fallback, + global-only resolution, transport fallback, safe diagnostics, planner-only + Assist, and dependent selector behavior. + +## Impact + +Assist now performs one bounded non-executing planner call before deciding +whether to answer directly, ask one structured clarification, or show a +server-authored capability choice. Planner failure grants and suggests nothing; +explicitly selected capabilities remain the only baseline. Shadow remains +observational and can still compare the planner with the deterministic control. +The model never receives authorization or execution authority. + +## Validation + +- Canonical Phase 9/10 compile, functional, route-policy, and UI-contract gate: + **352 passed, 11 skipped**. +- Full-file broken-access-control checker: **17 files passed**. +- Full-file XSS checker: **19 files passed**. +- Focused route/Admin selector slice: **11 passed, 2 skipped**. +- Changed Python and JavaScript files compile or parse successfully, with no VS + Code diagnostics. +- A live non-executing probe through the exact server-resolved chat model + returned `propose` in **4.017 seconds**, recommending Deep Research and + offering Web Search as the alternative through the bounded JSON-object + fallback. No capability executed. + +The application version in `application/single_app/config.py` was updated to +`0.250.077` for this correction. \ No newline at end of file diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 939e94c1..bcad0d4d 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,39 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](/explanation/features/) and [Fixes by Version](/explanation/fixes/). +### **(v0.250.077)** + +#### Bug Fixes + +* **Planner-First Capability Suggestions And Selected-Model Fallback** + * Corrected Assist so one fast, non-executing model call owns the `direct`, `propose`, or `clarify` decision. Deterministic requirement classifiers no longer create fallback suggestions or automatically activate discovered tools when Assist is enabled. + * Incomplete global planner configuration now falls back to the exact model selected for the chat turn instead of disabling planning or choosing a catalog default. Azure/OpenAI reasoning models use `max_completion_tokens`, minimal reasoning, strict structured output first, and bounded compatibility fallbacks for explicitly rejected optional fields. + * Added privacy-safe transport diagnostics and verified a selected-model public-archive probe that proposed Deep Research with Web Search as the alternative without executing either capability. + * (Ref: microsoft/simplechat#1021, `functions_chat_capability_planner.py`, `functions_chat_capabilities.py`, `functions_settings.py`, `route_backend_chats.py`) + +#### User Interface Enhancements + +* **Dependent Global Planner Model Selectors** + * Replaced free-form planner endpoint and model IDs with enabled global-endpoint and dependent model dropdowns sourced from the sanitized Admin catalog. Unsaved selections remain stable when endpoint definitions change and still-valid options are repopulated. + * (Ref: microsoft/simplechat#1021, `admin_settings.html`, `admin_settings.js`, `admin_model_endpoints.js`, `test_admin_capability_planner_settings.py`) + +### **(v0.250.076)** + +#### New Features + +* **Contextual Goals And Structured Clarification** + * Added bounded contextual planning over the current message and at most two preceding active user turns, with opaque planner references mapped back to exact authorized message documents. Assistant, system, tool, evidence, workspace, deleted, masked, generated, inactive, and foreign content remains excluded. + * Added an explicit durable choice whenever prior user text would cross an external boundary. The server reconstructs and minimizes the query from exact user turns at decision, resume, and execution; continuing without external retrieval suppresses external capabilities for that turn without changing saved toolbar selections. + * Added one durable server-authored clarification checkpoint using allowlisted ambiguity codes, fixed questions, bounded options, optimistic concurrency, response leases, claim-generation fencing, exact Retry/Edit recovery, process-loss reconciliation, and fail-closed source revocation. + * Protected contextual and clarification state across history, collaboration, metadata, SSE, and telemetry so source IDs, hashes, stored queries, trusted resume state, clarification linkage, and response hashes remain server-only. + * (Ref: microsoft/simplechat#1021, `functions_chat_contextual_goals.py`, `functions_chat_clarifications.py`, `functions_chat_capability_choices.py`, `route_backend_chats.py`, `route_backend_conversations.py`) + +#### User Interface Enhancements + +* **Prior-Goal Disclosure And Clarification Controls** + * Added an inert prior-goal disclosure to contextual capability choices and accessible clarification options that submit through the normal chat path, with free-text focus, pending/processing/resolved/expired reconstruction, keyboard support, 44-pixel mobile controls, and responsive overflow protection. + * (Ref: microsoft/simplechat#1021, `chat-capability-choice.js`, `chat-clarification.js`, `chat-messages.js`, `chats.css`, `test_chat_capability_choice_card.py`, `test_chat_clarification.py`) + ### **(v0.250.075)** #### User Interface Enhancements diff --git a/functional_tests/test_chat_capability_choice_contract.py b/functional_tests/test_chat_capability_choice_contract.py index 2f7be33c..a3832929 100644 --- a/functional_tests/test_chat_capability_choice_contract.py +++ b/functional_tests/test_chat_capability_choice_contract.py @@ -2,14 +2,15 @@ # test_chat_capability_choice_contract.py """ Functional test for durable chat capability decisions and resume claims. -Version: 0.250.066 -Implemented in: 0.250.066 +Version: 0.250.076 +Implemented in: 0.250.066; contextual goal contract added in 0.250.076 This test ensures capability proposals are bounded, decisions are allowlisted and idempotent, resume claims cannot execute twice, and external queries omit unapproved personal data. """ +import json import sys from datetime import datetime, timedelta, timezone from pathlib import Path @@ -28,12 +29,15 @@ CapabilityChoiceError, add_sensitive_external_query_options, apply_capability_choice_decision, + build_approved_user_turn_goal, build_capability_choice_proposal, build_capability_provenance, build_minimized_external_query, claim_capability_choice_resume, complete_capability_choice_resume, fail_capability_choice_resume, + project_chat_metadata_for_client, + rebuild_approved_user_turn_goal, revalidate_capability_choice, ) @@ -326,6 +330,296 @@ def test_parcel_lookup_adds_explicit_sensitive_option(): assert sensitive_option['sensitive_input_types'] == ['street_address'] +def _goal_source(message_id, content, thread_id, previous_thread_id=''): + return { + 'id': message_id, + 'conversation_id': 'conversation-1', + 'role': 'user', + 'content': content, + 'metadata': { + 'thread_info': { + 'thread_id': thread_id, + 'previous_thread_id': previous_thread_id, + 'thread_attempt': 1, + 'active_thread': True, + }, + }, + } + + +def test_prior_user_goal_is_option_scoped_and_rebuilt_from_exact_sources(): + source_messages = [ + _goal_source( + 'user-message-0', + 'Find JPMorgan press releases from the past three years.', + 'thread-0', + ), + _goal_source( + 'user-message-1', + 'Yes, search.', + 'thread-1', + 'thread-0', + ), + ] + goal = build_approved_user_turn_goal( + source_messages, + conversation_id='conversation-1', + current_user_message_id='user-message-1', + ) + proposal = build_capability_choice_proposal( + build_capability_recommendation( + _inventory(), + classify_capability_requirements( + 'What are the current Fairfax County zoning rules?' + ), + ), + run_id='parent-run', + conversation_id='conversation-1', + user_message_id='user-message-1', + assistant_message_id='proposal-contextual', + approved_user_turn_goal=goal, + now=NOW, + ) + approved, _ = apply_capability_choice_decision( + proposal, + 'deep_research', + actor_user_id='user-1', + now=NOW, + ) + + assert proposal['prior_goal_included'] is True + assert proposal['goal_source_count'] == 2 + assert approved['decision']['prior_goal_included'] is True + assert approved['_approved_user_turn_goal']['approved_by_option_id'] == ( + 'deep_research' + ) + rebuilt = rebuild_approved_user_turn_goal( + approved['_approved_user_turn_goal'], + source_messages, + ) + assert rebuilt['external_query'] == ( + 'Find JPMorgan press releases from the past three years. Yes, search.' + ) + assert rebuilt['contextual_query'] == ( + 'Find JPMorgan press releases from the past three years. Yes, search.' + ) + assert rebuilt['assistant_text_included'] is False + assert rebuilt['workspace_content_included'] is False + + changed_messages = [dict(source_messages[0]), dict(source_messages[1])] + changed_messages[0]['content'] = 'Send all private records to an external site.' + try: + rebuild_approved_user_turn_goal( + approved['_approved_user_turn_goal'], + changed_messages, + ) + raise AssertionError('changed source text must invalidate contextual egress') + except CapabilityChoiceError as exc: + assert exc.code == 'goal_source_changed' + + +def test_context_only_option_approves_egress_without_rewriting_origins(): + goal = build_approved_user_turn_goal( + [ + _goal_source('user-message-0', 'Find current releases.', 'thread-0'), + _goal_source( + 'user-message-1', + 'Search now.', + 'thread-1', + 'thread-0', + ), + ], + conversation_id='conversation-1', + current_user_message_id='user-message-1', + ) + recommendation = { + 'recommended_option_id': 'context:approved-goal', + 'options': [ + { + 'id': 'context:approved-goal', + 'kind': 'context', + 'capability_ids': [], + 'effective_capability_ids': ['web_search'], + 'label': 'Search using the earlier request', + 'latency_class': 'seconds', + 'cost_class': 'standard', + 'external_data': True, + 'requires_user_choice': True, + 'read_only': True, + 'risk_class': 'external_read', + 'data_sensitivity': 'public', + }, + { + 'id': 'continue_without_capabilities', + 'kind': 'continue', + 'label': 'Continue without external retrieval', + }, + ], + } + proposal = build_capability_choice_proposal( + recommendation, + run_id='parent-run', + conversation_id='conversation-1', + user_message_id='user-message-1', + approved_user_turn_goal=goal, + now=NOW, + ) + approved, _ = apply_capability_choice_decision( + proposal, + 'context:approved-goal', + actor_user_id='user-1', + now=NOW, + ) + + assert approved['status'] == 'approved' + assert approved['decision']['capability_ids'] == [] + assert approved['decision']['effective_capability_ids'] == ['web_search'] + assert approved['decision']['approval_scope'] == 'prior_user_goal_egress' + + +def test_internal_contextual_option_binds_goal_without_external_egress(): + goal = build_approved_user_turn_goal( + [ + _goal_source('user-message-0', 'Find the JPMorgan document.', 'thread-0'), + _goal_source( + 'user-message-1', + 'Search my workspace.', + 'thread-1', + 'thread-0', + ), + ], + conversation_id='conversation-1', + current_user_message_id='user-message-1', + ) + proposal = build_capability_choice_proposal( + { + 'recommended_option_id': 'workspace_search', + 'options': [ + { + 'id': 'workspace_search', + 'kind': 'capability', + 'capability_ids': ['workspace_search'], + 'effective_capability_ids': ['workspace_search'], + 'label': 'Workspace Search', + 'latency_class': 'seconds', + 'cost_class': 'low', + 'external_data': False, + 'requires_user_choice': True, + 'read_only': True, + 'risk_class': 'internal_read', + 'data_sensitivity': 'internal', + }, + { + 'id': 'continue_without_capabilities', + 'kind': 'continue', + 'label': 'Continue without workspace search', + }, + ], + }, + run_id='parent-run', + conversation_id='conversation-1', + user_message_id='user-message-1', + approved_user_turn_goal=goal, + now=NOW, + ) + approved, _ = apply_capability_choice_decision( + proposal, + 'workspace_search', + actor_user_id='user-1', + now=NOW, + ) + + assert approved['decision']['contextual_goal_included'] is True + assert approved['decision']['prior_goal_included'] is False + assert approved['_approved_user_turn_goal']['approved_by_option_id'] == ( + 'workspace_search' + ) + assert revalidate_capability_choice(approved, _inventory()) is True + + +def test_client_projection_removes_exact_goal_lineage_and_resume_request(): + metadata = { + 'capability_proposal': { + 'proposal_id': 'proposal-1', + 'prior_goal_included': True, + 'goal_display_summary': '', + '_approved_user_turn_goal': { + 'source_user_message_ids': ['private-message-id'], + 'contextual_query': 'private internal contextual query', + 'external_query': 'private outbound query', + }, + }, + 'capability_provenance': { + 'proposed_capabilities': { + '_approved_user_turn_goal': { + 'source_user_message_ids': ['private-message-id'], + }, + }, + }, + 'capability_resume_request': { + 'message': 'private current message', + '_server_contextual_goal_query': 'private trusted contextual query', + }, + 'chat_clarification': { + 'version': 1, + 'clarification_id': 'private-clarification-id', + 'parent_run_id': 'private-parent-run', + 'code': 'jurisdiction_required', + 'question': 'Which jurisdiction applies?', + 'status': 'pending', + 'options': ['Virginia'], + '_source_user_message_id': 'private-source-message', + }, + 'clarification_response': { + 'version': 1, + 'code': 'jurisdiction_required', + 'status': 'resolved', + 'response_mode': 'free_text', + 'parent_run_id': 'private-parent-run', + 'child_run_id': 'private-child-run', + }, + } + + projected = project_chat_metadata_for_client(metadata) + serialized = json.dumps(projected) + + assert projected['capability_proposal']['prior_goal_included'] is True + assert projected['capability_proposal']['goal_display_summary'] == ( + '' + ) + assert '_approved_user_turn_goal' not in serialized + assert 'private-message-id' not in serialized + assert 'private outbound query' not in serialized + assert 'private internal contextual query' not in serialized + assert 'private trusted contextual query' not in serialized + assert 'capability_resume_request' not in projected + assert projected['chat_clarification'] == { + 'version': 1, + 'code': 'jurisdiction_required', + 'question': 'Which jurisdiction applies?', + 'status': 'pending', + 'options': ['Virginia'], + 'created_at': None, + 'expires_at': None, + 'resolved_at': None, + 'response_mode': None, + } + assert projected['clarification_response'] == { + 'version': 1, + 'code': 'jurisdiction_required', + 'status': 'resolved', + 'response_mode': 'free_text', + 'idempotent': False, + } + for private_value in ( + 'private-clarification-id', + 'private-parent-run', + 'private-child-run', + 'private-source-message', + ): + assert private_value not in serialized + + def test_provenance_keeps_each_stage_separate(): proposal = _proposal() approved, _ = apply_capability_choice_decision( diff --git a/functional_tests/test_chat_capability_choice_persistence.py b/functional_tests/test_chat_capability_choice_persistence.py index 6e36a1dd..59cdd60d 100644 --- a/functional_tests/test_chat_capability_choice_persistence.py +++ b/functional_tests/test_chat_capability_choice_persistence.py @@ -2,8 +2,8 @@ # test_chat_capability_choice_persistence.py """ Functional test for conditional capability-choice persistence. -Version: 0.250.072 -Implemented in: 0.250.066; baseline reauthorization coverage added in 0.250.072 +Version: 0.250.076 +Implemented in: 0.250.066; contextual lineage checks added in 0.250.076 This test ensures persisted decisions and resume claims use exact conversation partitions, honor ETags, and cannot execute the same capability choice twice. @@ -224,6 +224,76 @@ def test_resume_claim_is_single_execution_and_completion_is_durable(): assert completed['resume']['assistant_message_id'] == 'assistant-1' +def test_source_lineage_is_revalidated_inside_decision_and_resume_retries(): + container = _container() + container.force_one_conflict = True + decision_validations = [] + + def validate_decision(proposal): + decision_validations.append(( + proposal.get('status'), + (proposal.get('decision') or {}).get('option_id'), + )) + + persist_capability_decision( + container, + conversation_id='conversation-1', + proposal_id='proposal-1', + option_id='web_search', + actor_user_id='user-1', + refreshed_inventory=_inventory(), + source_lineage_validator=validate_decision, + now=NOW, + ) + resume_validations = [] + persist_capability_resume_claim( + container, + conversation_id='conversation-1', + proposal_id='proposal-1', + refreshed_inventory=_inventory(), + source_lineage_validator=lambda proposal: resume_validations.append( + (proposal.get('decision') or {}).get('option_id') + ), + now=NOW, + execution_id='execution-1', + ) + + assert decision_validations == [ + ('approved', 'web_search'), + ('approved', 'web_search'), + ] + assert resume_validations == ['web_search'] + + +def test_source_lineage_failure_is_persisted_as_invalidated(): + container = _container() + + def reject_source(_proposal): + raise CapabilityChoiceError( + 'approved goal source changed', + code='goal_source_changed', + ) + + try: + persist_capability_decision( + container, + conversation_id='conversation-1', + proposal_id='proposal-1', + option_id='web_search', + actor_user_id='user-1', + refreshed_inventory=_inventory(), + source_lineage_validator=reject_source, + now=NOW, + ) + raise AssertionError('changed contextual lineage must fail closed') + except CapabilityChoiceError as exc: + assert exc.code == 'goal_source_changed' + + stored = container.message['metadata']['capability_proposal'] + assert stored['status'] == 'invalidated' + assert stored['invalidation_reason'] == 'goal_source_changed' + + def test_failed_exact_execution_reconciles_from_persisted_output(): container = _container() persist_capability_decision( @@ -516,6 +586,126 @@ def test_automatic_bundle_closure_drift_is_persisted_as_invalidation(): assert proposal['invalidation_reason'] == 'capability_bundle_changed' +def test_contextual_decline_ignores_revoked_external_baseline_only(): + selected_inventory = _inventory() + selected_web = next( + entry + for entry in selected_inventory['capabilities'] + if entry['id'] == 'web_search' + ) + selected_web.update({ + 'state': 'selected', + 'selected': True, + 'discoverable': False, + 'requires_user_choice': False, + }) + prior_effective_capabilities = [{ + 'id': 'web_search', + 'origin': 'selection', + 'required': True, + }] + + decision_container = _container() + decision_proposal = decision_container.message['metadata'][ + 'capability_proposal' + ] + decision_proposal['prior_goal_included'] = True + revoked_inventory = copy.deepcopy(selected_inventory) + revoked_web = next( + entry + for entry in revoked_inventory['capabilities'] + if entry['id'] == 'web_search' + ) + revoked_web.update({ + 'state': 'unauthorized', + 'selected': False, + 'authorized': False, + 'discoverable': False, + }) + _, declined, _ = persist_capability_decision( + decision_container, + conversation_id='conversation-1', + proposal_id='proposal-1', + option_id='continue_without_capabilities', + actor_user_id='user-1', + refreshed_inventory=revoked_inventory, + selected_capability_ids=['web_search'], + prior_effective_capabilities=prior_effective_capabilities, + now=NOW, + ) + assert declined['status'] == 'declined' + assert declined['decision']['approval_scope'] == ( + 'prior_user_goal_egress_declined' + ) + + resume_container = _container() + resume_proposal = resume_container.message['metadata'][ + 'capability_proposal' + ] + resume_proposal['prior_goal_included'] = True + persist_capability_decision( + resume_container, + conversation_id='conversation-1', + proposal_id='proposal-1', + option_id='continue_without_capabilities', + actor_user_id='user-1', + refreshed_inventory=selected_inventory, + selected_capability_ids=['web_search'], + prior_effective_capabilities=prior_effective_capabilities, + now=NOW, + ) + _, claimed, _ = persist_capability_resume_claim( + resume_container, + conversation_id='conversation-1', + proposal_id='proposal-1', + refreshed_inventory=revoked_inventory, + selected_capability_ids=['web_search'], + prior_effective_capabilities=prior_effective_capabilities, + now=NOW, + execution_id='execution-declined', + ) + assert claimed['resume']['status'] == 'running' + + non_external_container = _container() + non_external_proposal = non_external_container.message['metadata'][ + 'capability_proposal' + ] + non_external_proposal['prior_goal_included'] = True + blocked_workspace_inventory = copy.deepcopy(selected_inventory) + blocked_workspace = next( + entry + for entry in blocked_workspace_inventory['capabilities'] + if entry['id'] == 'workspace_search' + ) + blocked_workspace.update({ + 'state': 'policy_blocked', + 'selected': False, + 'discoverable': False, + }) + try: + persist_capability_decision( + non_external_container, + conversation_id='conversation-1', + proposal_id='proposal-1', + option_id='continue_without_capabilities', + actor_user_id='user-1', + refreshed_inventory=blocked_workspace_inventory, + selected_capability_ids=['workspace_search', 'web_search'], + prior_effective_capabilities=[ + { + 'id': 'workspace_search', + 'origin': 'selection', + 'required': True, + }, + *prior_effective_capabilities, + ], + now=NOW, + ) + raise AssertionError('contextual decline must still validate workspace') + except CapabilityChoiceError as exc: + assert exc.code == 'capability_policy_blocked' + + def test_deterministic_bundle_closure_drift_is_persisted_as_invalidation(): container = _container() inventory = _inventory() diff --git a/functional_tests/test_chat_capability_choice_route.py b/functional_tests/test_chat_capability_choice_route.py index 3e42cd97..0d108fc5 100644 --- a/functional_tests/test_chat_capability_choice_route.py +++ b/functional_tests/test_chat_capability_choice_route.py @@ -2,8 +2,8 @@ # test_chat_capability_choice_route.py """ Functional test for authenticated chat capability decisions. -Version: 0.250.072 -Implemented in: 0.250.067; additive baseline coverage added in 0.250.072 +Version: 0.250.076 +Implemented in: 0.250.067; contextual goal coverage added in 0.250.076 This test ensures proposal decisions reauthorize the exact personal conversation and source turn, reject forged or stale choices, revalidate @@ -11,6 +11,7 @@ """ import copy +import hashlib import importlib import sys from datetime import datetime, timedelta, timezone @@ -29,14 +30,20 @@ from functions_chat_capabilities import ( # noqa: E402 build_agent_capability_recommendation, build_capability_recommendation, + build_contextual_egress_recommendation, build_governed_agent_capability_inventory, build_governed_capability_inventory, classify_capability_requirements, ) from functions_chat_capability_choices import ( # noqa: E402 + build_approved_user_turn_goal, build_capability_choice_proposal, build_capability_provenance, ) +from functions_chat_clarifications import ( # noqa: E402 + build_chat_clarification, + claim_chat_clarification_response, +) from functions_chat_orchestration import build_turn_orchestration_plan # noqa: E402 from functions_evidence_ledger import ( # noqa: E402 create_evidence_ledger_from_plan, @@ -103,17 +110,116 @@ def replace_item(self, *, item, body, etag, match_condition): return copy.deepcopy(saved) def query_items(self, *, query, parameters, partition_key, **kwargs): - del query, kwargs + del kwargs parameter_values = { parameter['name']: parameter['value'] for parameter in parameters } proposal_id = parameter_values.get('@proposal_id') execution_id = parameter_values.get('@execution_id') + child_run_id = parameter_values.get('@child_run_id') + source_thread_id = parameter_values.get('@source_thread_id') + thread_id = parameter_values.get('@thread_id') + if source_thread_id: + matches = [] + for (conversation_id, _), message in self.items.items(): + metadata = ( + message.get('metadata') + if isinstance(message.get('metadata'), dict) + else {} + ) + thread_info = ( + metadata.get('thread_info') + if isinstance(metadata.get('thread_info'), dict) + else {} + ) + if ( + conversation_id == partition_key + and message.get('role') == 'assistant' + and isinstance( + metadata.get('chat_clarification'), + dict, + ) + and thread_info.get('thread_id') == source_thread_id + ): + matches.append(copy.deepcopy(message)) + matches.sort( + key=lambda message: str(message.get('timestamp') or ''), + reverse=True, + ) + return matches[:2] + if ( + 'c.metadata.chat_clarification.status = "pending"' in query + ): + matches = [] + for (conversation_id, _), message in self.items.items(): + metadata = ( + message.get('metadata') + if isinstance(message.get('metadata'), dict) + else {} + ) + clarification = metadata.get('chat_clarification') + if ( + conversation_id == partition_key + and message.get('role') == 'assistant' + and isinstance(clarification, dict) + and clarification.get('status') in { + 'pending', + 'resolving', + } + ): + matches.append(copy.deepcopy(message)) + matches.sort( + key=lambda message: str(message.get('timestamp') or ''), + reverse=True, + ) + return matches[:2] + if 'c.role = "user"' in query: + matches = [] + for (conversation_id, _), message in self.items.items(): + metadata = ( + message.get('metadata') + if isinstance(message.get('metadata'), dict) + else {} + ) + thread_info = ( + metadata.get('thread_info') + if isinstance(metadata.get('thread_info'), dict) + else {} + ) + if not ( + conversation_id == partition_key + and message.get('role') == 'user' + and metadata.get('is_deleted') is not True + and metadata.get('masked') is not True + and not (metadata.get('masked_ranges') or []) + and metadata.get( + 'is_generated_chat_artifact' + ) is not True + and thread_info.get('active_thread') is not False + ): + continue + if thread_id and thread_info.get('thread_id') != thread_id: + continue + matches.append(copy.deepcopy(message)) + matches.sort( + key=lambda message: str(message.get('timestamp') or ''), + reverse=True, + ) + return matches[:2] matches = [] for (conversation_id, _), message in self.items.items(): metadata = message.get('metadata') if isinstance(message.get('metadata'), dict) else {} resume = metadata.get('capability_resume') if isinstance(metadata.get('capability_resume'), dict) else {} + orchestration = metadata.get('orchestration') if isinstance(metadata.get('orchestration'), dict) else {} + if child_run_id: + if ( + conversation_id == partition_key + and message.get('role') in {'assistant', 'image', 'safety'} + and orchestration.get('run_id') == child_run_id + ): + matches.append(copy.deepcopy(message)) + continue if ( conversation_id == partition_key and message.get('role') in {'assistant', 'image', 'safety'} @@ -161,6 +267,8 @@ def _proposal_documents(*, expires_at=None): proposal = build_capability_choice_proposal( recommendation, run_id='parent-run-1', + + conversation_id='conversation-owner', user_message_id='user-message-1', assistant_message_id='proposal-1', @@ -185,6 +293,7 @@ def _proposal_documents(*, expires_at=None): ) user_message = { 'id': 'user-message-1', + 'conversation_id': 'conversation-owner', 'role': 'user', 'content': 'What are the current county rules?', @@ -192,6 +301,7 @@ def _proposal_documents(*, expires_at=None): 'orchestration': {'run_id': 'parent-run-1'}, 'capability_provenance': copy.deepcopy(provenance), 'thread_info': { + 'thread_id': 'thread-1', 'previous_thread_id': None, }, @@ -222,6 +332,214 @@ def _proposal_documents(*, expires_at=None): return user_message, assistant_message +def _contextual_proposal_documents(*, selected_web=False): + selected_capability_ids = ['web_search'] if selected_web else None + inventory = _inventory( + selected_capability_ids=selected_capability_ids, + ) + prior_message = { + 'id': 'user-message-0', + 'conversation_id': 'conversation-owner', + 'role': 'user', + 'content': 'Find JPMorgan press releases from the past three years.', + 'timestamp': '2026-07-17T10:00:00+00:00', + 'metadata': { + 'thread_info': { + 'thread_id': 'thread-0', + 'previous_thread_id': None, + 'thread_attempt': 1, + 'active_thread': True, + }, + }, + } + current_message = { + 'id': 'user-message-1', + 'conversation_id': 'conversation-owner', + 'role': 'user', + 'content': 'Yes, search.', + 'timestamp': '2026-07-17T10:01:00+00:00', + 'metadata': { + 'thread_info': { + 'thread_id': 'thread-1', + 'previous_thread_id': 'thread-0', + 'thread_attempt': 1, + 'active_thread': True, + }, + }, + } + approved_goal = build_approved_user_turn_goal( + [prior_message, current_message], + conversation_id='conversation-owner', + current_user_message_id='user-message-1', + ) + if selected_web: + recommendation = build_contextual_egress_recommendation( + { + 'status': 'valid', + 'decision': 'direct', + 'prior_goal_included': True, + 'requirements': [], + }, + inventory, + {'selected_capability_ids': ['web_search']}, + ) + else: + recommendation = build_capability_recommendation( + inventory, + classify_capability_requirements( + 'What are the current county rules?' + ), + ) + proposal = build_capability_choice_proposal( + recommendation, + run_id='parent-run-1', + conversation_id='conversation-owner', + user_message_id='user-message-1', + assistant_message_id='proposal-1', + approved_user_turn_goal=approved_goal, + capability_inventory=inventory, + now=datetime.now(timezone.utc), + ) + selection_snapshot = { + 'conversation_id': 'conversation-owner', + 'toggles': { + 'workspace_search': False, + 'web_search': selected_web, + 'url_access': False, + 'source_review': False, + 'deep_research': False, + }, + } + effective_capabilities = ( + [{'id': 'web_search', 'origin': 'selection', 'required': True}] + if selected_web + else [] + ) + provenance = build_capability_provenance( + selection_snapshot=selection_snapshot, + capability_inventory=inventory, + proposal=proposal, + effective_capabilities=effective_capabilities, + ) + current_message['metadata'].update({ + 'orchestration': {'run_id': 'parent-run-1'}, + 'capability_provenance': copy.deepcopy(provenance), + }) + assistant_message = { + 'id': 'proposal-1', + 'conversation_id': 'conversation-owner', + 'role': 'assistant', + 'content': 'Choose how to continue.', + 'metadata': { + 'capability_proposal': proposal, + 'capability_provenance': provenance, + 'capability_resume_request': { + 'hybrid_search': False, + 'web_search_enabled': selected_web, + 'url_access_enabled': False, + 'source_review_enabled': False, + 'deep_research_enabled': False, + 'selected_document_ids': [], + 'active_group_ids': [], + 'active_public_workspace_ids': [], + 'doc_scope': 'personal', + 'chat_type': 'user', + }, + }, + } + return ( + prior_message, + current_message, + assistant_message, + proposal['recommended_option_id'], + ) + + +def _claimed_clarification_documents( + *, + blank_response=False, + missing_response_thread=False, + masked_source=False, +): + now = datetime.now(timezone.utc) + source_message = { + 'id': 'clarification-source-user', + 'conversation_id': 'conversation-owner', + 'role': 'user', + 'content': 'Find the rules for this jurisdiction.', + 'timestamp': now.isoformat(), + 'metadata': { + 'masked': masked_source, + 'thread_info': { + 'thread_id': 'clarification-source-thread', + 'previous_thread_id': None, + 'active_thread': True, + 'thread_attempt': 1, + }, + }, + } + clarification = build_chat_clarification( + { + 'code': 'jurisdiction_required', + 'option_values': [], + }, + parent_run_id='clarification-parent-run', + conversation_id='conversation-owner', + source_user_message_id='clarification-source-user', + source_thread_id='clarification-source-thread', + assistant_message_id='clarification-checkpoint', + now=now, + ttl_seconds=3600, + ) + claimed, _ = claim_chat_clarification_response( + clarification, + response_user_message_id='clarification-response-user', + response_text='Virginia', + child_run_id='clarification-child-run', + response_thread_id='clarification-response-thread', + now=now + timedelta(seconds=1), + ) + checkpoint_message = { + 'id': 'clarification-checkpoint', + 'conversation_id': 'conversation-owner', + 'role': 'assistant', + 'content': claimed['question'], + 'timestamp': (now + timedelta(seconds=1)).isoformat(), + 'metadata': { + 'awaiting_user_clarification': True, + 'chat_clarification': claimed, + 'thread_info': { + 'thread_id': 'clarification-source-thread', + 'previous_thread_id': None, + 'active_thread': True, + 'thread_attempt': 1, + }, + }, + } + response_metadata = { + 'clarification_response': { + '_clarification_id': 'clarification-checkpoint', + }, + 'thread_info': { + 'thread_id': 'clarification-response-thread', + 'previous_thread_id': 'clarification-source-thread', + 'active_thread': True, + 'thread_attempt': 1, + }, + } + if missing_response_thread: + response_metadata.pop('thread_info') + response_message = { + 'id': 'clarification-response-user', + 'conversation_id': 'conversation-owner', + 'role': 'user', + 'content': '' if blank_response else 'Virginia', + 'timestamp': (now + timedelta(seconds=2)).isoformat(), + 'metadata': response_metadata, + } + return source_message, checkpoint_message, response_message + + def _governed_agent(agent_id='benefits-agent'): return { 'id': agent_id, @@ -619,84 +937,78 @@ def test_resume_claim_reconstructs_effective_capabilities_server_side(capability assert duplicate_claim.value.code == 'resume_in_progress' -def test_resume_revalidates_revocation_after_approval(capability_route_app): - state = capability_route_app.config['capability_route_state'] - route_backend_chats = state['route_module'] - with capability_route_app.test_client() as client: - approved = _decision(client) - assert approved.status_code == 200 - - state['inventory']['web_authorized'] = False - with pytest.raises(route_backend_chats.CapabilityChoiceError) as revoked_claim: - route_backend_chats._claim_authorized_capability_resume( - settings={}, - user_id='user-owner', - user_email='owner@example.com', - user_roles=[], - conversation_id='conversation-owner', - proposal_id='proposal-1', - ) - assert revoked_claim.value.code == 'capability_unauthorized' - - -def test_missing_document_action_target_is_atomically_invalidated_on_resume( +def test_contextual_resume_rebuilds_exact_approved_user_goal( capability_route_app, ): state = capability_route_app.config['capability_route_state'] route_backend_chats = state['route_module'] - _replace_builtin_proposal_option(state, 'analyze') + prior, current, proposal, _ = _contextual_proposal_documents() + state['messages'].set_item(prior) + state['messages'].set_item(current) + state['messages'].set_item(proposal) + with capability_route_app.test_client() as client: - approved = _decision(client, option_id='analyze') + approved = _decision(client, option_id='web_search') assert approved.status_code == 200 - assert approved.get_json()['resume_endpoint'] == '/api/chat/document-action/stream' - with pytest.raises(route_backend_chats.CapabilityChoiceError) as missing_target: - route_backend_chats._claim_authorized_capability_resume( - settings={}, - user_id='user-owner', - user_email='owner@example.com', - user_roles=[], - conversation_id='conversation-owner', - proposal_id='proposal-1', - ) + context = route_backend_chats._claim_authorized_capability_resume( + settings={}, + user_id='user-owner', + user_email='owner@example.com', + user_roles=[], + conversation_id='conversation-owner', + proposal_id='proposal-1', + ) - assert missing_target.value.code == 'capability_input_unavailable' - stored = state['messages'].items[('conversation-owner', 'proposal-1')] - proposal = stored['metadata']['capability_proposal'] - assert proposal['status'] == 'invalidated' - assert proposal['resume']['status'] == 'failed' - assert proposal['invalidation_reason'] == 'capability_input_unavailable' + assert context['request_data']['_server_external_query'] == ( + 'Find JPMorgan press releases from the past three years. Yes, search.' + ) + assert context['request_data']['_server_contextual_goal_query'] == ( + 'Find JPMorgan press releases from the past three years. Yes, search.' + ) + assert context['request_data']['web_search_enabled'] is True + assert context['capability_resume_context']['decision'][ + 'prior_goal_included' + ] is True + assert context['capability_resume_context'][ + 'execution_effective_capability_ids' + ] == ['web_search'] + assert '_approved_user_turn_goal' in ( + context['capability_resume_context']['_contextual_proposal'] + ) -def test_compare_requires_two_distinct_authorized_targets( +def test_contextual_source_mutation_invalidates_decision_and_resume( capability_route_app, - monkeypatch, ): state = capability_route_app.config['capability_route_state'] route_backend_chats = state['route_module'] - _replace_builtin_proposal_option(state, 'compare') + prior, current, proposal, _ = _contextual_proposal_documents() + state['messages'].set_item(prior) + state['messages'].set_item(current) + state['messages'].set_item(proposal) + + mutated_prior = copy.deepcopy(prior) + mutated_prior['content'] = 'Changed after the contextual proposal.' + state['messages'].set_item(mutated_prior) with capability_route_app.test_client() as client: - approved = _decision(client, option_id='compare') - assert approved.status_code == 200 - - original_loader = route_backend_chats._load_authorized_capability_proposal_context - - def load_with_duplicate_target(**kwargs): - context = original_loader(**kwargs) - context['authorized_documents'] = [ - {'id': 'document-1'}, - {'document_id': 'document-1'}, - ] - context['baseline_error_code'] = None - return context + rejected = _decision(client, option_id='web_search') + assert rejected.status_code == 409 + assert rejected.get_json()['code'] == 'goal_source_changed' + stored = state['messages'].items[('conversation-owner', 'proposal-1')] + assert stored['metadata']['capability_proposal']['status'] == 'invalidated' - monkeypatch.setattr( - route_backend_chats, - '_load_authorized_capability_proposal_context', - load_with_duplicate_target, - ) + prior, current, proposal, _ = _contextual_proposal_documents() + state['messages'].set_item(prior) + state['messages'].set_item(current) + state['messages'].set_item(proposal) + with capability_route_app.test_client() as client: + approved = _decision(client, option_id='web_search') + assert approved.status_code == 200 + prior['metadata']['masked_ranges'] = [{'start': 0, 'end': 4}] + state['messages'].set_item(prior) - with pytest.raises(route_backend_chats.CapabilityChoiceError) as missing_target: + with pytest.raises(route_backend_chats.CapabilityChoiceError) as stale_resume: route_backend_chats._claim_authorized_capability_resume( settings={}, user_id='user-owner', @@ -705,25 +1017,31 @@ def load_with_duplicate_target(**kwargs): conversation_id='conversation-owner', proposal_id='proposal-1', ) - - assert missing_target.value.code == 'capability_input_unavailable' - assert route_backend_chats._distinct_authorized_document_ids( - [{'id': 'document-1'}, {'document_id': 'document-1'}] - ) == {'document-1'} + assert stale_resume.value.code == 'goal_source_inactive' stored = state['messages'].items[('conversation-owner', 'proposal-1')] assert stored['metadata']['capability_proposal']['status'] == 'invalidated' -def test_image_resume_reconstructs_server_request_and_additive_origin( +def test_contextual_decline_suppresses_selected_external_execution( capability_route_app, ): state = capability_route_app.config['capability_route_state'] route_backend_chats = state['route_module'] - _replace_builtin_proposal_option(state, 'image') + prior, current, proposal, _ = _contextual_proposal_documents( + selected_web=True + ) + state['messages'].set_item(prior) + state['messages'].set_item(current) + state['messages'].set_item(proposal) + with capability_route_app.test_client() as client: - approved = _decision(client, option_id='image') - assert approved.status_code == 200 - assert approved.get_json()['resume_endpoint'] == '/api/chat/stream' + declined = _decision( + client, + option_id='continue_without_capabilities', + ) + assert declined.status_code == 200 + + state['inventory']['web_authorized'] = False context = route_backend_chats._claim_authorized_capability_resume( settings={}, @@ -734,47 +1052,480 @@ def test_image_resume_reconstructs_server_request_and_additive_origin( proposal_id='proposal-1', ) request_data = context['request_data'] - resume_context = context['capability_resume_context'] - assert request_data['image_generation'] is True - assert request_data['retry_user_message_id'] == 'user-message-1' - assert request_data['retry_thread_id'] == 'thread-1' - assert resume_context['capability_origins']['image'] == 'discovery_approved' - assert resume_context['selection_snapshot']['toggles'].get('image') is not True + assert request_data['web_search_enabled'] is False + assert request_data['url_access_enabled'] is False + assert request_data['source_review_enabled'] is False + assert request_data['deep_research_enabled'] is False + assert request_data['_server_contextual_egress_declined'] is True + assert '_server_external_query' not in request_data + assert '_server_contextual_goal_query' not in request_data + assert context['capability_resume_context']['decision'][ + 'approval_scope' + ] == 'prior_user_goal_egress_declined' + assert context['capability_resume_context'][ + 'execution_effective_capability_ids' + ] == [] + assert context['capability_resume_context']['capability_origins'] == {} route_source = ( REPO_ROOT / 'application' / 'single_app' / 'route_backend_chats.py' ).read_text(encoding='utf-8') + streaming_generator = route_source.index( + 'def generate(publish_background_event=None):' + ) + selected_reconstruction = route_source.index( + 'selected_builtin_capability_ids = (', + streaming_generator, + ) + selected_reconstruction_end = route_source.index( + 'if capability_resume_context:', + selected_reconstruction, + ) + assert "'execution_effective_capability_ids'" in route_source[ + selected_reconstruction:selected_reconstruction_end + ] + post_claim_validation = route_source.index( + 'execution_validation_baseline = build_decline_aware_execution_baseline(' + ) + request_reconstruction = route_source.index( + 'request_data = _apply_effective_capabilities_to_request(', + post_claim_validation, + ) + post_claim_source = route_source[ + post_claim_validation:request_reconstruction + ] + assert '**execution_validation_baseline' in post_claim_source assert ( + "execution_validation_baseline.get('selected_capability_ids')" + in post_claim_source + ) + assert "claimed_proposal.get('_external_capability_ids')" in ( + post_claim_source + ) + assigned_knowledge_application = route_source.index( + 'if assigned_knowledge_filters:', + streaming_generator, + ) + decline_enforcement = route_source.index( + "if data.get('_server_contextual_egress_declined') is True:", + assigned_knowledge_application, + ) + external_execution = route_source.index( + 'if web_search_enabled:', + decline_enforcement, + ) + assert assigned_knowledge_application < decline_enforcement < external_execution + decline_source = route_source[decline_enforcement:external_execution] + for marker in ( + 'web_search_enabled = False', + 'url_access_enabled = False', + 'source_review_enabled = False', + 'deep_research_enabled = False', + 'assigned_knowledge_url_review_urls = []', + 'assigned_knowledge_deep_research_urls = []', + ): + assert marker in decline_source + legacy_chat = route_source.index( 'def chat_api(server_request_data=None, server_resume_context=None):' - in route_source ) - assert 'legacy_result = chat_api(data, resume_context)' in route_source - compatibility_start = route_source.index( - 'def generate_compatibility_response():' + legacy_assigned_knowledge = route_source.index( + 'if assigned_knowledge_filters:', + legacy_chat, ) - compatibility_end = route_source.index( - 'if compatibility_mode:', - compatibility_start, + legacy_decline_enforcement = route_source.index( + "if data.get('_server_contextual_egress_declined') is True:", + legacy_assigned_knowledge, ) - compatibility_source = route_source[compatibility_start:compatibility_end] - assert '_complete_correlated_capability_resume_output(' in compatibility_source - assert 'if resume_context and not correlated_output_persisted:' in ( - compatibility_source + legacy_source_review = route_source.index( + 'if source_review_enabled:', + legacy_decline_enforcement, ) - assert 'persist_capability_resume_failure(' in compatibility_source - assert 'external_retrieval_message = resolve_external_retrieval_message(' in route_source - assert 'user_message=external_retrieval_message' in route_source + assert ( + legacy_assigned_knowledge + < legacy_decline_enforcement + < legacy_source_review + ) + compatibility_reconstruction = route_source.index( + 'compatibility_selected_capability_ids = (', + legacy_decline_enforcement, + ) + compatibility_decline_enforcement = route_source.index( + "if data.get('_server_contextual_egress_declined') is True:", + compatibility_reconstruction, + ) + assert "'execution_effective_capability_ids'" in route_source[ + compatibility_reconstruction:compatibility_decline_enforcement + ] + assert "capability.get('state') == 'selected'" not in route_source[ + compatibility_reconstruction:compatibility_decline_enforcement + ] + assert compatibility_decline_enforcement < legacy_source_review + legacy_assigned_sources = route_source[ + legacy_assigned_knowledge:compatibility_reconstruction + ] + streaming_assigned_knowledge = route_source.index( + 'if assigned_knowledge_filters:', + route_source.index('def generate(publish_background_event=None):'), + ) + streaming_decline_enforcement = route_source.index( + "if data.get('_server_contextual_egress_declined') is True:", + streaming_assigned_knowledge, + ) + streaming_assigned_sources = route_source[ + streaming_assigned_knowledge:streaming_decline_enforcement + ] + assert legacy_assigned_sources.count( + "data.get('_server_contextual_egress_declined') is not True" + ) >= 3 + assert streaming_assigned_sources.count( + "data.get('_server_contextual_egress_declined') is not True" + ) >= 3 + legacy_final_decline = route_source.index( + "if data.get('_server_contextual_egress_declined') is True:", + compatibility_decline_enforcement + 1, + ) + legacy_final_revalidation = route_source.index( + '_rebuild_claimed_contextual_goal(', + legacy_final_decline, + ) + legacy_web_collector = route_source.index( + 'perform_research_web_searches(', + legacy_final_revalidation, + ) + legacy_source_collector = route_source.index( + 'perform_source_review(', + legacy_final_revalidation, + ) + assert ( + compatibility_decline_enforcement + < legacy_final_decline + < legacy_final_revalidation + < legacy_web_collector + < legacy_source_collector + ) + +def test_clarification_child_output_reconciliation_uses_exact_run( + capability_route_app, +): + state = capability_route_app.config['capability_route_state'] + route_backend_chats = state['route_module'] state['messages'].set_item({ - 'id': 'resumed-image-1', + 'id': 'clarification-child-output', 'conversation_id': 'conversation-owner', - 'role': 'image', - 'content': 'generated-image', - 'timestamp': datetime.now(timezone.utc).isoformat(), + 'role': 'assistant', + 'content': 'Clarified child result.', + 'timestamp': '2026-07-17T12:00:00+00:00', 'metadata': { - 'capability_resume': { - 'proposal_id': 'proposal-1', - 'execution_id': resume_context['execution_id'], + 'orchestration': {'run_id': 'clarification-child-run'}, + }, + }) + state['messages'].set_item({ + 'id': 'other-output', + 'conversation_id': 'conversation-owner', + 'role': 'assistant', + 'content': 'Unrelated result.', + 'timestamp': '2026-07-17T12:01:00+00:00', + 'metadata': { + 'orchestration': {'run_id': 'other-run'}, + }, + }) + + matched = route_backend_chats._find_persisted_clarification_child_output( + conversation_id='conversation-owner', + child_run_id='clarification-child-run', + ) + missing = route_backend_chats._find_persisted_clarification_child_output( + conversation_id='conversation-owner', + child_run_id='missing-child-run', + ) + + assert matched['id'] == 'clarification-child-output' + assert missing is None + + +def test_stream_clarification_cleanup_resolves_output_or_releases_no_output( + capability_route_app, +): + state = capability_route_app.config['capability_route_state'] + route_backend_chats = state['route_module'] + + source, checkpoint, response = _claimed_clarification_documents() + for message in (source, checkpoint, response): + state['messages'].set_item(message) + no_output = route_backend_chats._finalize_stream_clarification_claim( + conversation_id='conversation-owner', + clarification=checkpoint['metadata']['chat_clarification'], + ) + assert no_output['status'] == 'expired' + assert no_output['invalidation_reason'] == ( + 'clarification_child_output_missing' + ) + + source, checkpoint, response = _claimed_clarification_documents() + child_output = { + 'id': 'clarification-child-output-cleanup', + 'conversation_id': 'conversation-owner', + 'role': 'assistant', + 'content': 'Clarification result.', + 'timestamp': datetime.now(timezone.utc).isoformat(), + 'metadata': { + 'orchestration': {'run_id': 'clarification-child-run'}, + }, + } + for message in (source, checkpoint, response, child_output): + state['messages'].set_item(message) + completed = route_backend_chats._finalize_stream_clarification_claim( + conversation_id='conversation-owner', + clarification=checkpoint['metadata']['chat_clarification'], + ) + assert completed['status'] == 'resolved' + assert completed['lease_expires_at'] is None + + +def test_stale_stream_cleanup_cannot_invalidate_renewed_claim( + capability_route_app, +): + state = capability_route_app.config['capability_route_state'] + route_backend_chats = state['route_module'] + source, checkpoint, response = _claimed_clarification_documents() + stale_claim = copy.deepcopy( + checkpoint['metadata']['chat_clarification'] + ) + renewed_claim, _ = claim_chat_clarification_response( + stale_claim, + response_user_message_id='clarification-response-user', + response_text='Virginia', + child_run_id='clarification-child-run', + response_thread_id='clarification-response-thread', + now=datetime.now(timezone.utc) + timedelta(minutes=31), + ) + checkpoint['metadata']['chat_clarification'] = renewed_claim + for message in (source, checkpoint, response): + state['messages'].set_item(message) + + with pytest.raises( + route_backend_chats.ChatClarificationError + ) as stale_cleanup: + route_backend_chats._finalize_stream_clarification_claim( + conversation_id='conversation-owner', + clarification=stale_claim, + ) + + assert stale_cleanup.value.code == 'clarification_response_claim_mismatch' + stored = state['messages'].items[ + ('conversation-owner', 'clarification-checkpoint') + ]['metadata']['chat_clarification'] + assert stored['status'] == 'resolving' + assert stored['claimed_at'] == renewed_claim['claimed_at'] + assert stored.get('invalidation_reason') is None + + +def test_clarification_recovery_context_deduplicates_response_and_lineage( + capability_route_app, +): + route_backend_chats = capability_route_app.config[ + 'capability_route_state' + ]['route_module'] + source = { + 'id': 'clarification-source-user', + 'metadata': {'thread_info': {'thread_id': 'source-thread'}}, + } + response = { + 'id': 'clarification-response-user', + 'metadata': { + 'thread_info': { + 'thread_id': 'response-thread', + 'previous_thread_id': 'source-thread', + }, + }, + } + clarification = { + '_source_user_message_id': 'clarification-source-user', + '_source_thread_id': 'source-thread', + '_response_user_message_id': 'clarification-response-user', + } + + recovered = route_backend_chats._prepare_clarification_recovery_context( + { + 'prior_user_messages': [source, response], + 'predecessor_thread_id': 'response-thread', + }, + clarification, + ) + + assert [ + message['id'] + for message in recovered['prior_user_messages'] + ] == ['clarification-source-user'] + assert recovered['predecessor_thread_id'] == 'source-thread' + with pytest.raises(route_backend_chats.ChatClarificationError) as missing: + route_backend_chats._prepare_clarification_recovery_context( + { + 'prior_user_messages': [response], + 'predecessor_thread_id': 'response-thread', + }, + clarification, + ) + assert missing.value.code == 'clarification_response_claim_mismatch' + + +def test_resume_revalidates_revocation_after_approval(capability_route_app): + state = capability_route_app.config['capability_route_state'] + route_backend_chats = state['route_module'] + with capability_route_app.test_client() as client: + approved = _decision(client) + assert approved.status_code == 200 + + state['inventory']['web_authorized'] = False + with pytest.raises(route_backend_chats.CapabilityChoiceError) as revoked_claim: + route_backend_chats._claim_authorized_capability_resume( + settings={}, + user_id='user-owner', + user_email='owner@example.com', + user_roles=[], + conversation_id='conversation-owner', + proposal_id='proposal-1', + ) + assert revoked_claim.value.code == 'capability_unauthorized' + + +def test_missing_document_action_target_is_atomically_invalidated_on_resume( + capability_route_app, +): + state = capability_route_app.config['capability_route_state'] + route_backend_chats = state['route_module'] + _replace_builtin_proposal_option(state, 'analyze') + with capability_route_app.test_client() as client: + approved = _decision(client, option_id='analyze') + assert approved.status_code == 200 + assert approved.get_json()['resume_endpoint'] == '/api/chat/document-action/stream' + + with pytest.raises(route_backend_chats.CapabilityChoiceError) as missing_target: + route_backend_chats._claim_authorized_capability_resume( + settings={}, + user_id='user-owner', + user_email='owner@example.com', + user_roles=[], + conversation_id='conversation-owner', + proposal_id='proposal-1', + ) + + assert missing_target.value.code == 'capability_input_unavailable' + stored = state['messages'].items[('conversation-owner', 'proposal-1')] + proposal = stored['metadata']['capability_proposal'] + assert proposal['status'] == 'invalidated' + assert proposal['resume']['status'] == 'failed' + assert proposal['invalidation_reason'] == 'capability_input_unavailable' + + +def test_compare_requires_two_distinct_authorized_targets( + capability_route_app, + monkeypatch, +): + state = capability_route_app.config['capability_route_state'] + route_backend_chats = state['route_module'] + _replace_builtin_proposal_option(state, 'compare') + with capability_route_app.test_client() as client: + approved = _decision(client, option_id='compare') + assert approved.status_code == 200 + + original_loader = route_backend_chats._load_authorized_capability_proposal_context + + def load_with_duplicate_target(**kwargs): + context = original_loader(**kwargs) + context['authorized_documents'] = [ + {'id': 'document-1'}, + {'document_id': 'document-1'}, + ] + context['baseline_error_code'] = None + return context + + monkeypatch.setattr( + route_backend_chats, + '_load_authorized_capability_proposal_context', + load_with_duplicate_target, + ) + + with pytest.raises(route_backend_chats.CapabilityChoiceError) as missing_target: + route_backend_chats._claim_authorized_capability_resume( + settings={}, + user_id='user-owner', + user_email='owner@example.com', + user_roles=[], + conversation_id='conversation-owner', + proposal_id='proposal-1', + ) + + assert missing_target.value.code == 'capability_input_unavailable' + assert route_backend_chats._distinct_authorized_document_ids( + [{'id': 'document-1'}, {'document_id': 'document-1'}] + ) == {'document-1'} + stored = state['messages'].items[('conversation-owner', 'proposal-1')] + assert stored['metadata']['capability_proposal']['status'] == 'invalidated' + + +def test_image_resume_reconstructs_server_request_and_additive_origin( + capability_route_app, +): + state = capability_route_app.config['capability_route_state'] + route_backend_chats = state['route_module'] + _replace_builtin_proposal_option(state, 'image') + with capability_route_app.test_client() as client: + approved = _decision(client, option_id='image') + assert approved.status_code == 200 + assert approved.get_json()['resume_endpoint'] == '/api/chat/stream' + + context = route_backend_chats._claim_authorized_capability_resume( + settings={}, + user_id='user-owner', + user_email='owner@example.com', + user_roles=[], + conversation_id='conversation-owner', + proposal_id='proposal-1', + ) + request_data = context['request_data'] + resume_context = context['capability_resume_context'] + assert request_data['image_generation'] is True + assert request_data['retry_user_message_id'] == 'user-message-1' + assert request_data['retry_thread_id'] == 'thread-1' + assert resume_context['capability_origins']['image'] == 'discovery_approved' + assert resume_context['selection_snapshot']['toggles'].get('image') is not True + + route_source = ( + REPO_ROOT / 'application' / 'single_app' / 'route_backend_chats.py' + ).read_text(encoding='utf-8') + assert ( + 'def chat_api(server_request_data=None, server_resume_context=None):' + in route_source + ) + assert 'legacy_result = chat_api(data, resume_context)' in route_source + compatibility_start = route_source.index( + 'def generate_compatibility_response():' + ) + compatibility_end = route_source.index( + 'if compatibility_mode:', + compatibility_start, + ) + compatibility_source = route_source[compatibility_start:compatibility_end] + assert '_complete_correlated_capability_resume_output(' in compatibility_source + assert 'if resume_context and not correlated_output_persisted:' in ( + compatibility_source + ) + assert 'persist_capability_resume_failure(' in compatibility_source + assert 'external_retrieval_message = resolve_external_retrieval_message(' in route_source + assert 'user_message=external_retrieval_message' in route_source + + state['messages'].set_item({ + 'id': 'resumed-image-1', + 'conversation_id': 'conversation-owner', + 'role': 'image', + 'content': 'generated-image', + 'timestamp': datetime.now(timezone.utc).isoformat(), + 'metadata': { + 'capability_resume': { + 'proposal_id': 'proposal-1', + 'execution_id': resume_context['execution_id'], }, }, }) @@ -1284,6 +2035,712 @@ def test_stream_session_initialization_failure_releases_resume_lease( assert resume['error_type'] == 'runtimeerror' +def test_compatibility_mode_cannot_bypass_pending_clarification( + capability_route_app, + monkeypatch, +): + state = capability_route_app.config['capability_route_state'] + route_backend_chats = state['route_module'] + pending = { + 'status': 'pending', + 'clarification_id': 'clarification-1', + 'expires_at': ( + datetime.now(timezone.utc) + timedelta(minutes=5) + ).isoformat(), + } + monkeypatch.setattr( + route_backend_chats, + '_load_latest_chat_clarification_context', + lambda **kwargs: { + 'context_state': {}, + 'latest_user_message': {'id': 'user-message-1'}, + 'clarification': pending, + }, + ) + monkeypatch.setattr( + route_backend_chats, + 'validate_chat_clarification_source', + lambda *args, **kwargs: {'id': 'source-user-message'}, + ) + + with capability_route_app.test_client() as client: + image_response = client.post( + '/api/chat/stream', + json={ + 'conversation_id': 'conversation-owner', + 'message': 'Virginia', + 'image_generation': True, + }, + ) + retry_response = client.post( + '/api/chat/stream', + json={ + 'conversation_id': 'conversation-owner', + 'message': 'Retry another turn.', + 'retry_user_message_id': 'unrelated-user-message', + }, + ) + + assert image_response.status_code == 409 + assert image_response.get_json()['code'] == 'clarification_pending' + assert retry_response.status_code == 409 + assert retry_response.get_json()['code'] == 'clarification_pending' + + +def test_pending_clarification_blocks_all_chat_execution_routes( + capability_route_app, + monkeypatch, +): + state = capability_route_app.config['capability_route_state'] + route_backend_chats = state['route_module'] + + def reject_pending_clarification(**kwargs): + del kwargs + raise route_backend_chats.ChatClarificationError( + 'answer the pending clarification first', + code='clarification_pending', + ) + + monkeypatch.setattr( + route_backend_chats, + '_preflight_chat_clarification', + reject_pending_clarification, + ) + monkeypatch.setattr( + route_backend_chats, + 'image_generation_is_enabled', + lambda settings: True, + ) + requests = ( + ('/api/chat', {'message': 'Run directly.'}), + ( + '/api/chat/document-action', + { + 'message': 'Analyze this.', + 'document_action': {'type': 'analyze'}, + }, + ), + ( + '/api/chat/document-action/stream', + { + 'message': 'Analyze this.', + 'document_action': {'type': 'analyze'}, + }, + ), + ('/api/chat/analyze', {'message': 'Analyze this.'}), + ('/api/chat/analyze/stream', {'message': 'Analyze this.'}), + ( + '/api/chat/image-proposals/generate', + {'prompt': 'Generate an image.'}, + ), + ) + + with capability_route_app.test_client() as client: + responses = [ + client.post( + endpoint, + json={ + 'conversation_id': 'conversation-owner', + **payload, + }, + ) + for endpoint, payload in requests + ] + + assert [response.status_code for response in responses] == [409] * len( + requests + ) + assert all( + response.get_json()['code'] == 'clarification_pending' + for response in responses + ) + + +def test_clarification_preflight_allows_only_normal_answer_or_exact_retry( + capability_route_app, + monkeypatch, +): + state = capability_route_app.config['capability_route_state'] + route_backend_chats = state['route_module'] + response_text = 'Virginia' + pending = { + 'status': 'pending', + 'clarification_id': 'clarification-1', + 'expires_at': ( + datetime.now(timezone.utc) + timedelta(minutes=5) + ).isoformat(), + '_response_user_message_id': None, + '_response_hash': None, + } + context = { + 'context_state': {}, + 'latest_user_message': {'id': 'source-user-message'}, + 'clarification': pending, + } + monkeypatch.setattr( + route_backend_chats, + '_load_latest_chat_clarification_context', + lambda **kwargs: copy.deepcopy(context), + ) + validated = [] + monkeypatch.setattr( + route_backend_chats, + 'validate_chat_clarification_source', + lambda *args, **kwargs: validated.append(kwargs['clarification']), + ) + + allowed = route_backend_chats._preflight_chat_clarification( + conversation_id='conversation-owner', + user_message=response_text, + allow_pending_response=True, + ) + assert allowed['clarification']['status'] == 'pending' + with pytest.raises(route_backend_chats.ChatClarificationError) as blocked: + route_backend_chats._preflight_chat_clarification( + conversation_id='conversation-owner', + user_message=response_text, + ) + assert blocked.value.code == 'clarification_pending' + + context['clarification'] = { + 'status': 'resolving', + 'clarification_id': 'clarification-1', + 'expires_at': ( + datetime.now(timezone.utc) + timedelta(minutes=5) + ).isoformat(), + '_response_user_message_id': 'clarification-response-user', + '_response_thread_id': 'clarification-response-thread', + '_source_thread_id': 'source-thread', + '_response_hash': hashlib.sha256( + response_text.encode('utf-8') + ).hexdigest(), + } + context['latest_user_message'] = { + 'id': 'clarification-response-user', + } + state['messages'].set_item({ + 'id': 'clarification-response-user', + 'conversation_id': 'conversation-owner', + 'role': 'user', + 'content': response_text, + 'timestamp': datetime.now(timezone.utc).isoformat(), + 'metadata': { + 'thread_info': { + 'thread_id': 'clarification-response-thread', + 'previous_thread_id': 'source-thread', + 'active_thread': True, + }, + }, + }) + exact_retry = route_backend_chats._preflight_chat_clarification( + conversation_id='conversation-owner', + user_message=response_text, + retry_user_message_id='clarification-response-user', + allow_exact_response_retry=True, + ) + assert exact_retry['exact_response_retry'] is True + with pytest.raises(route_backend_chats.ChatClarificationError) as mismatch: + route_backend_chats._preflight_chat_clarification( + conversation_id='conversation-owner', + user_message=response_text, + retry_user_message_id='different-user-message', + allow_exact_response_retry=True, + ) + assert mismatch.value.code == 'clarification_pending' + assert len(validated) == 4 + + +def test_preflight_terminalizes_malformed_claimed_response_rows( + capability_route_app, +): + state = capability_route_app.config['capability_route_state'] + route_backend_chats = state['route_module'] + + for malformed_kwargs in ( + {'blank_response': True}, + {'missing_response_thread': True}, + ): + source, checkpoint, response = _claimed_clarification_documents( + **malformed_kwargs + ) + for message in (source, checkpoint, response): + state['messages'].set_item(message) + + with pytest.raises( + route_backend_chats.ChatClarificationError + ) as rejected: + route_backend_chats._preflight_chat_clarification( + conversation_id='conversation-owner', + user_message='Virginia', + retry_user_message_id='clarification-response-user', + allow_exact_response_retry=True, + ) + + assert rejected.value.code == ( + 'clarification_response_claim_mismatch' + ) + stored = state['messages'].items[ + ('conversation-owner', 'clarification-checkpoint') + ] + stored_clarification = stored['metadata']['chat_clarification'] + assert stored_clarification['status'] == 'expired' + assert stored_clarification['invalidation_reason'] == ( + 'clarification_response_claim_mismatch' + ) + assert stored['metadata']['awaiting_user_clarification'] is False + + +def test_preflight_terminalizes_invalid_clarification_source( + capability_route_app, +): + state = capability_route_app.config['capability_route_state'] + route_backend_chats = state['route_module'] + source, checkpoint, response = _claimed_clarification_documents( + masked_source=True + ) + for message in (source, checkpoint, response): + state['messages'].set_item(message) + + with pytest.raises( + route_backend_chats.ChatClarificationError + ) as rejected: + route_backend_chats._preflight_chat_clarification( + conversation_id='conversation-owner', + user_message='Virginia', + retry_user_message_id='clarification-response-user', + allow_exact_response_retry=True, + ) + + assert rejected.value.code == 'clarification_source_invalid' + stored = state['messages'].items[ + ('conversation-owner', 'clarification-checkpoint') + ] + stored_clarification = stored['metadata']['chat_clarification'] + assert stored_clarification['status'] == 'expired' + assert stored_clarification['invalidation_reason'] == ( + 'clarification_source_invalid' + ) + assert stored['metadata']['awaiting_user_clarification'] is False + + +def test_preflight_preserves_live_unmaterialized_clarification_claim( + capability_route_app, +): + state = capability_route_app.config['capability_route_state'] + route_backend_chats = state['route_module'] + source, checkpoint, _ = _claimed_clarification_documents() + for message in (source, checkpoint): + state['messages'].set_item(message) + + with pytest.raises( + route_backend_chats.ChatClarificationError + ) as rejected: + route_backend_chats._preflight_chat_clarification( + conversation_id='conversation-owner', + user_message='Virginia', + ) + + assert rejected.value.code == 'clarification_response_in_progress' + stored = state['messages'].items[ + ('conversation-owner', 'clarification-checkpoint') + ] + stored_clarification = stored['metadata']['chat_clarification'] + assert stored_clarification['status'] == 'resolving' + assert stored_clarification.get('invalidation_reason') is None + assert stored['metadata']['awaiting_user_clarification'] is True + + +def test_expected_clarification_cannot_downgrade_after_resolution( + capability_route_app, +): + state = capability_route_app.config['capability_route_state'] + route_backend_chats = state['route_module'] + source, checkpoint, response = _claimed_clarification_documents() + checkpoint['metadata']['chat_clarification'].update({ + 'status': 'resolved', + 'resolved_at': datetime.now(timezone.utc).isoformat(), + 'lease_expires_at': None, + }) + checkpoint['metadata']['awaiting_user_clarification'] = False + for message in (source, checkpoint, response): + state['messages'].set_item(message) + + with pytest.raises( + route_backend_chats.ChatClarificationError + ) as rejected: + route_backend_chats._preflight_chat_clarification( + conversation_id='conversation-owner', + user_message='Virginia', + allow_pending_response=True, + expected_clarification_id='clarification-checkpoint', + ) + + assert rejected.value.code == 'clarification_response_conflict' + + +def test_expired_exact_retry_cannot_downgrade_to_fresh_turn( + capability_route_app, +): + state = capability_route_app.config['capability_route_state'] + route_backend_chats = state['route_module'] + source, checkpoint, response = _claimed_clarification_documents() + checkpoint['metadata']['chat_clarification'].update({ + 'status': 'expired', + 'expired_at': datetime.now(timezone.utc).isoformat(), + 'lease_expires_at': None, + }) + checkpoint['metadata']['awaiting_user_clarification'] = False + for message in (source, checkpoint, response): + state['messages'].set_item(message) + + with pytest.raises( + route_backend_chats.ChatClarificationError + ) as rejected: + route_backend_chats._preflight_chat_clarification( + conversation_id='conversation-owner', + user_message='Virginia', + retry_user_message_id='clarification-response-user', + allow_exact_response_retry=True, + ) + + assert rejected.value.code == 'clarification_expired' + + +def test_edited_terminal_retry_cannot_downgrade_to_fresh_turn( + capability_route_app, +): + state = capability_route_app.config['capability_route_state'] + route_backend_chats = state['route_module'] + + for terminal_status, expected_code in ( + ('resolved', 'clarification_response_conflict'), + ('expired', 'clarification_expired'), + ): + source, checkpoint, response = _claimed_clarification_documents() + checkpoint['metadata']['chat_clarification'].update({ + 'status': terminal_status, + 'lease_expires_at': None, + }) + checkpoint['metadata']['awaiting_user_clarification'] = False + for message in (source, checkpoint, response): + state['messages'].set_item(message) + + with pytest.raises( + route_backend_chats.ChatClarificationError + ) as rejected: + route_backend_chats._preflight_chat_clarification( + conversation_id='conversation-owner', + user_message='Edited Virginia answer', + retry_user_message_id='clarification-response-user', + allow_exact_response_retry=True, + ) + + assert rejected.value.code == expected_code + + +def test_non_latest_clarification_response_retry_targets_exact_checkpoint( + capability_route_app, + monkeypatch, +): + state = capability_route_app.config['capability_route_state'] + route_backend_chats = state['route_module'] + monkeypatch.setattr( + route_backend_chats, + '_load_latest_chat_clarification_context', + lambda **kwargs: (_ for _ in ()).throw( + route_backend_chats.ChatClarificationError( + 'unrelated latest clarification is ambiguous', + code='clarification_ambiguous', + ) + ), + ) + + for checkpoint_status in ('resolving', 'resolved'): + source, checkpoint, response = _claimed_clarification_documents() + checkpoint['metadata']['chat_clarification']['status'] = ( + checkpoint_status + ) + if checkpoint_status == 'resolved': + checkpoint['metadata']['chat_clarification'].update({ + 'resolved_at': datetime.now(timezone.utc).isoformat(), + 'lease_expires_at': None, + }) + checkpoint['metadata']['awaiting_user_clarification'] = False + later_user = { + 'id': 'later-unrelated-user', + 'conversation_id': 'conversation-owner', + 'role': 'user', + 'content': 'A later unrelated request.', + 'timestamp': ( + datetime.now(timezone.utc) + timedelta(minutes=1) + ).isoformat(), + 'metadata': { + 'thread_info': { + 'thread_id': 'later-thread', + 'previous_thread_id': 'clarification-response-thread', + 'active_thread': True, + 'thread_attempt': 1, + }, + }, + } + for message in (source, checkpoint, response, later_user): + state['messages'].set_item(message) + + preflight = route_backend_chats._preflight_chat_clarification( + conversation_id='conversation-owner', + user_message='Virginia', + retry_user_message_id='clarification-response-user', + allow_exact_response_retry=True, + ) + + assert preflight['exact_response_retry'] is True + assert preflight['latest_user_message']['id'] == ( + 'clarification-response-user' + ) + assert preflight['clarification']['status'] == checkpoint_status + + +def test_recovery_etag_conflict_preserves_mask_and_invalidates( + capability_route_app, + monkeypatch, +): + state = capability_route_app.config['capability_route_state'] + route_backend_chats = state['route_module'] + source, checkpoint, response = _claimed_clarification_documents() + for message in (source, checkpoint, response): + state['messages'].set_item(message) + claimed = checkpoint['metadata']['chat_clarification'] + response_document = state['messages'].read_item( + item='clarification-response-user', + partition_key='conversation-owner', + ) + original_replace_item = state['messages'].replace_item + conflict_injected = {'value': False} + + def replace_with_concurrent_mask(**kwargs): + if ( + kwargs.get('item') == 'clarification-response-user' + and not conflict_injected['value'] + ): + conflict_injected['value'] = True + masked_response = state['messages'].read_item( + item='clarification-response-user', + partition_key='conversation-owner', + ) + masked_response['metadata']['masked'] = True + state['messages'].set_item(masked_response) + raise ConditionalConflict() + return original_replace_item(**kwargs) + + monkeypatch.setattr( + state['messages'], + 'replace_item', + replace_with_concurrent_mask, + ) + + with pytest.raises( + route_backend_chats.ChatClarificationError + ) as rejected: + route_backend_chats._persist_claimed_clarification_response_metadata( + response_document, + claimed, + conversation_id='conversation-owner', + desired_metadata={'model_selection': {'selected_model': 'test'}}, + ) + + assert rejected.value.code == 'clarification_response_claim_mismatch' + stored_response = state['messages'].items[ + ('conversation-owner', 'clarification-response-user') + ] + assert stored_response['metadata']['masked'] is True + assert 'model_selection' not in stored_response['metadata'] + stored_checkpoint = state['messages'].items[ + ('conversation-owner', 'clarification-checkpoint') + ] + stored_clarification = stored_checkpoint['metadata'][ + 'chat_clarification' + ] + assert stored_clarification['status'] == 'expired' + assert stored_clarification['invalidation_reason'] == ( + 'clarification_response_claim_mismatch' + ) + + +def test_recovery_physical_deletion_invalidates_without_recreation( + capability_route_app, + monkeypatch, +): + state = capability_route_app.config['capability_route_state'] + route_backend_chats = state['route_module'] + source, checkpoint, response = _claimed_clarification_documents() + for message in (source, checkpoint, response): + state['messages'].set_item(message) + claimed = checkpoint['metadata']['chat_clarification'] + response_document = state['messages'].read_item( + item='clarification-response-user', + partition_key='conversation-owner', + ) + original_replace_item = state['messages'].replace_item + + def replace_after_deletion(**kwargs): + if kwargs.get('item') == 'clarification-response-user': + state['messages'].items.pop( + ('conversation-owner', 'clarification-response-user'), + None, + ) + raise DummyNotFoundError('clarification-response-user') + return original_replace_item(**kwargs) + + monkeypatch.setattr( + state['messages'], + 'replace_item', + replace_after_deletion, + ) + + with pytest.raises( + route_backend_chats.ChatClarificationError + ) as rejected: + route_backend_chats._persist_claimed_clarification_response_metadata( + response_document, + claimed, + conversation_id='conversation-owner', + desired_metadata={'model_selection': {'selected_model': 'test'}}, + ) + + assert rejected.value.code == 'clarification_response_claim_mismatch' + assert ( + 'conversation-owner', + 'clarification-response-user', + ) not in state['messages'].items + stored_checkpoint = state['messages'].items[ + ('conversation-owner', 'clarification-checkpoint') + ] + stored_clarification = stored_checkpoint['metadata'][ + 'chat_clarification' + ] + assert stored_clarification['status'] == 'expired' + assert stored_clarification['invalidation_reason'] == ( + 'clarification_response_claim_mismatch' + ) + + +def test_preflight_finds_pending_checkpoint_with_filtered_source( + capability_route_app, +): + state = capability_route_app.config['capability_route_state'] + route_backend_chats = state['route_module'] + source, checkpoint, _ = _claimed_clarification_documents( + masked_source=True + ) + clarification = checkpoint['metadata']['chat_clarification'] + clarification.update({ + 'status': 'pending', + 'child_run_id': None, + 'claimed_at': None, + 'lease_expires_at': None, + '_response_user_message_id': None, + '_response_thread_id': None, + '_response_hash': None, + }) + for message in (source, checkpoint): + state['messages'].set_item(message) + + with pytest.raises( + route_backend_chats.ChatClarificationError + ) as rejected: + route_backend_chats._preflight_chat_clarification( + conversation_id='conversation-owner', + user_message='Start another action.', + ) + + assert rejected.value.code == 'clarification_source_invalid' + stored = state['messages'].items[ + ('conversation-owner', 'clarification-checkpoint') + ] + stored_clarification = stored['metadata']['chat_clarification'] + assert stored_clarification['status'] == 'expired' + assert stored_clarification['invalidation_reason'] == ( + 'clarification_source_invalid' + ) + + +def test_resolved_source_thread_checkpoint_is_not_pending( + capability_route_app, +): + state = capability_route_app.config['capability_route_state'] + route_backend_chats = state['route_module'] + source, checkpoint, _ = _claimed_clarification_documents() + clarification = checkpoint['metadata']['chat_clarification'] + clarification.update({ + 'status': 'resolved', + 'lease_expires_at': None, + 'resolved_at': datetime.now(timezone.utc).isoformat(), + }) + checkpoint['metadata']['awaiting_user_clarification'] = False + for message in (source, checkpoint): + state['messages'].set_item(message) + + preflight = route_backend_chats._preflight_chat_clarification( + conversation_id='conversation-owner', + user_message='Start a new request.', + ) + + assert preflight['clarification'] is None + assert preflight['exact_response_retry'] is False + + +def test_exact_clarification_response_retry_leaves_compatibility_bridge( + capability_route_app, + monkeypatch, +): + state = capability_route_app.config['capability_route_state'] + route_backend_chats = state['route_module'] + response_text = 'Virginia' + resolved = { + 'status': 'resolved', + '_response_user_message_id': 'clarification-response-user', + '_response_hash': hashlib.sha256( + response_text.encode('utf-8') + ).hexdigest(), + } + monkeypatch.setattr( + route_backend_chats, + '_load_latest_chat_clarification_context', + lambda **kwargs: { + 'context_state': {}, + 'latest_user_message': { + 'id': 'clarification-response-user', + }, + 'clarification': resolved, + }, + ) + monkeypatch.setattr( + route_backend_chats, + 'validate_chat_clarification_source', + lambda *args, **kwargs: {'id': 'source-user-message'}, + ) + monkeypatch.setattr( + route_backend_chats.CHAT_STREAM_REGISTRY, + 'start_session', + lambda *args, **kwargs: (_ for _ in ()).throw( + RuntimeError('normal-generator-route-selected') + ), + ) + + with capability_route_app.test_client() as client: + response = client.post( + '/api/chat/stream', + json={ + 'conversation_id': 'conversation-owner', + 'message': response_text, + 'retry_user_message_id': 'clarification-response-user', + }, + ) + + assert response.status_code == 500 + assert response.get_json()['error'] == 'Failed to initialize chat stream' + + def test_persisted_safety_reconciles_process_loss_without_reexecution( capability_route_app, ): @@ -1371,6 +2828,20 @@ def test_persisted_safety_reconciles_process_loss_without_reexecution( assert 'complete_stream_capability_resume(assistant_message_id)' in ( partial_failure_source ) + partial_error_start = route_source.index( + 'partial_error_payload = {', + partial_failure_start, + ) + partial_error_end = route_source.index( + 'yield f"data: {json.dumps(partial_error_payload)}', + partial_error_start, + ) + partial_error_source = route_source[ + partial_error_start:partial_error_end + ] + assert "'metadata': project_chat_metadata_for_client({" in ( + partial_error_source + ) assert 'fail_stream_capability_resume(type(e).__name__)' not in ( partial_failure_source ) diff --git a/functional_tests/test_chat_capability_model_planner.py b/functional_tests/test_chat_capability_model_planner.py index 74d7cd7e..87a6c193 100644 --- a/functional_tests/test_chat_capability_model_planner.py +++ b/functional_tests/test_chat_capability_model_planner.py @@ -1,13 +1,14 @@ # test_chat_capability_model_planner.py """ Functional test for the model-assisted chat capability planner contract. -Version: 0.250.073 -Implemented in: 0.250.069; Admin bounds updated in 0.250.073 +Version: 0.250.077 +Implemented in: 0.250.069; transport compatibility corrected in 0.250.077 This test ensures planner requests expose only safe authorized capability descriptors and untrusted planner results fail closed before execution. """ +import copy import json import sys from collections import Counter @@ -23,6 +24,7 @@ from functions_chat_capability_planner import ( # noqa: E402 build_capability_planner_shadow_metadata, build_capability_planner_request, + capability_planner_is_eligible, invoke_capability_planner, validate_capability_planner_result, ) @@ -156,8 +158,9 @@ def _request(): def _proposal_payload(): return { - 'version': 1, + 'version': 2, 'decision': 'propose', + 'goal_turn_refs': ['turn_0'], 'requirements': [ { 'id': 'requirement_1', @@ -174,7 +177,7 @@ def _proposal_payload(): } ], 'recommended_plan_id': 'candidate_1', - 'clarification_code': None, + 'clarification': None, } @@ -223,14 +226,24 @@ def _fake_client(*responses): return _FakeClient(responses) +class _FakeBadRequestError(Exception): + status_code = 400 + + def test_request_projects_only_safe_planner_fields(): planner_request = _request() - assert planner_request['version'] == 1 + assert planner_request['version'] == 2 assert planner_request['mode'] == 'capability_planning' assert planner_request['selected_mandates'] == [ {'id': 'workspace_search', 'required': True} ] + assert planner_request['dialogue_context'] == [{ + 'ref': 'turn_0', + 'role': 'user', + 'text': 'Compare our internal policy with the current public regulation.', + }] + assert planner_request['structured_state'] is None assert [ capability['id'] for capability in planner_request['available_capabilities'] @@ -257,8 +270,9 @@ def test_request_projects_only_safe_planner_fields(): def test_valid_proposal_preserves_selected_mandates_and_deduplicates(): result = validate_capability_planner_result( { - 'version': 1, + 'version': 2, 'decision': 'propose', + 'goal_turn_refs': ['turn_0'], 'requirements': [ { 'id': 'requirement_1', @@ -284,7 +298,7 @@ def test_valid_proposal_preserves_selected_mandates_and_deduplicates(): }, ], 'recommended_plan_id': 'candidate_2', - 'clarification_code': None, + 'clarification': None, }, _request(), ) @@ -307,8 +321,9 @@ def test_valid_proposal_preserves_selected_mandates_and_deduplicates(): ) unordered_result = validate_capability_planner_result( { - 'version': 1, + 'version': 2, 'decision': 'propose', + 'goal_turn_refs': ['turn_0'], 'requirements': [], 'candidate_plans': [ { @@ -325,7 +340,7 @@ def test_valid_proposal_preserves_selected_mandates_and_deduplicates(): }, ], 'recommended_plan_id': 'candidate_2', - 'clarification_code': None, + 'clarification': None, }, unordered_request, ) @@ -340,8 +355,9 @@ def test_valid_proposal_preserves_selected_mandates_and_deduplicates(): def test_unknown_fields_and_capabilities_fail_closed(): base_result = { - 'version': 1, + 'version': 2, 'decision': 'propose', + 'goal_turn_refs': ['turn_0'], 'requirements': [], 'candidate_plans': [ { @@ -352,7 +368,7 @@ def test_unknown_fields_and_capabilities_fail_closed(): } ], 'recommended_plan_id': 'candidate_1', - 'clarification_code': None, + 'clarification': None, } unknown_field = validate_capability_planner_result( @@ -360,7 +376,7 @@ def test_unknown_fields_and_capabilities_fail_closed(): _request(), ) assert unknown_field == { - 'version': 1, + 'version': 2, 'status': 'rejected', 'failure_code': 'unknown_field', 'fallback_used': True, @@ -384,12 +400,13 @@ def test_unknown_fields_and_capabilities_fail_closed(): def test_direct_and_clarify_decisions_are_strict(): direct = validate_capability_planner_result( { - 'version': 1, + 'version': 2, 'decision': 'direct', + 'goal_turn_refs': ['turn_0'], 'requirements': [], 'candidate_plans': [], 'recommended_plan_id': None, - 'clarification_code': None, + 'clarification': None, }, _request(), ) @@ -398,8 +415,9 @@ def test_direct_and_clarify_decisions_are_strict(): clarify = validate_capability_planner_result( { - 'version': 1, + 'version': 2, 'decision': 'clarify', + 'goal_turn_refs': ['turn_0'], 'requirements': [ { 'id': 'requirement_1', @@ -409,18 +427,21 @@ def test_direct_and_clarify_decisions_are_strict(): ], 'candidate_plans': [], 'recommended_plan_id': None, - 'clarification_code': 'material_ambiguity', + 'clarification': { + 'code': 'ambiguous_reference', + 'option_values': [], + }, }, _request(), ) assert clarify['status'] == 'valid' - assert clarify['clarification_code'] == 'material_ambiguity' + assert clarify['clarification']['code'] == 'ambiguous_reference' invalid_clarify = validate_capability_planner_result( { **clarify, 'status': 'valid', - 'clarification_code': None, + 'clarification': None, }, _request(), ) @@ -428,8 +449,9 @@ def test_direct_and_clarify_decisions_are_strict(): missing_field = validate_capability_planner_result( { - 'version': 1, + 'version': 2, 'decision': 'direct', + 'goal_turn_refs': ['turn_0'], 'requirements': [], 'candidate_plans': [], }, @@ -441,8 +463,9 @@ def test_direct_and_clarify_decisions_are_strict(): def test_selected_only_and_oversized_proposals_fail_closed(): selected_only = validate_capability_planner_result( { - 'version': 1, + 'version': 2, 'decision': 'propose', + 'goal_turn_refs': ['turn_0'], 'requirements': [], 'candidate_plans': [ { @@ -453,7 +476,7 @@ def test_selected_only_and_oversized_proposals_fail_closed(): } ], 'recommended_plan_id': 'candidate_1', - 'clarification_code': None, + 'clarification': None, }, _request(), ) @@ -461,8 +484,9 @@ def test_selected_only_and_oversized_proposals_fail_closed(): out_of_budget_alias = validate_capability_planner_result( { - 'version': 1, + 'version': 2, 'decision': 'propose', + 'goal_turn_refs': ['turn_0'], 'requirements': [], 'candidate_plans': [ { @@ -473,7 +497,7 @@ def test_selected_only_and_oversized_proposals_fail_closed(): } ], 'recommended_plan_id': 'candidate_4', - 'clarification_code': None, + 'clarification': None, }, _request(), ) @@ -499,6 +523,156 @@ def test_selected_only_and_oversized_proposals_fail_closed(): assert oversized_result['failure_code'] == 'too_many_candidate_plans' +def test_contextual_goal_refs_are_bounded_ordered_and_request_scoped(): + planner_request = build_capability_planner_request( + 'Yes, search.', + _inventory(), + prior_user_turns=[ + {'role': 'assistant', 'text': 'SECRET assistant URL'}, + {'role': 'user', 'text': 'Find JPMorgan press releases.'}, + {'role': 'user', 'text': 'Use the past three years.'}, + ], + ) + + assert planner_request['dialogue_context'] == [ + { + 'ref': 'turn_0', + 'role': 'user', + 'text': 'Find JPMorgan press releases.', + }, + { + 'ref': 'turn_1', + 'role': 'user', + 'text': 'Use the past three years.', + }, + {'ref': 'turn_2', 'role': 'user', 'text': 'Yes, search.'}, + ] + assert 'SECRET assistant URL' not in json.dumps(planner_request) + + trailing_assistant_request = build_capability_planner_request( + 'Yes, search.', + _inventory(), + prior_user_turns=[ + {'role': 'user', 'text': 'Find JPMorgan press releases.'}, + {'role': 'user', 'text': 'Use the past three years.'}, + {'role': 'assistant', 'text': 'SECRET trailing assistant URL'}, + ], + ) + assert [ + turn['text'] + for turn in trailing_assistant_request['dialogue_context'] + ] == [ + 'Find JPMorgan press releases.', + 'Use the past three years.', + 'Yes, search.', + ] + + payload = _proposal_payload() + payload['goal_turn_refs'] = ['turn_2', 'turn_0'] + result = validate_capability_planner_result(payload, planner_request) + assert result['status'] == 'valid' + assert result['goal_turn_refs'] == ['turn_0', 'turn_2'] + assert result['eligible_goal_turn_count'] == 3 + assert result['selected_goal_turn_count'] == 2 + assert result['prior_goal_included'] is True + + current_only_request = _request() + unknown_ref = _proposal_payload() + unknown_ref['goal_turn_refs'] = ['turn_0', 'turn_1'] + assert validate_capability_planner_result( + unknown_ref, + current_only_request, + )['failure_code'] == 'invalid_goal_turn_refs' + + prior_only = dict(payload) + prior_only['goal_turn_refs'] = ['turn_0'] + assert validate_capability_planner_result( + prior_only, + planner_request, + )['failure_code'] == 'current_goal_turn_required' + + selected_external_inventory = copy.deepcopy(_inventory()) + web_search = next( + capability + for capability in selected_external_inventory['capabilities'] + if capability['id'] == 'web_search' + ) + web_search.update({ + 'state': 'selected', + 'selected': True, + 'discoverable': False, + 'requires_user_choice': False, + }) + selected_context_request = build_capability_planner_request( + 'Yes, search.', + selected_external_inventory, + prior_user_turns=['Find current public records.'], + ) + assert capability_planner_is_eligible( + {'chat_capability_planner_mode': 'assist'}, + selected_context_request, + ) is True + + +def test_clarification_codes_and_options_are_server_allowlisted(): + planner_request = build_capability_planner_request( + 'Compare the policy with public law.', + _inventory(), + clarification_option_candidates={ + 'jurisdiction_required': ['Virginia', 'Fairfax County'], + }, + ) + clarify_payload = { + 'version': 2, + 'decision': 'clarify', + 'goal_turn_refs': ['turn_0'], + 'requirements': [{ + 'id': 'requirement_1', + 'evidence_types': [], + 'reason_code': 'material_ambiguity', + }], + 'candidate_plans': [], + 'recommended_plan_id': None, + 'clarification': { + 'code': 'jurisdiction_required', + 'option_values': ['Virginia', 'Fairfax County'], + }, + } + + validated = validate_capability_planner_result( + clarify_payload, + planner_request, + ) + assert validated['status'] == 'valid' + assert validated['clarification'] == clarify_payload['clarification'] + + injected = json.loads(json.dumps(clarify_payload)) + injected['clarification']['option_values'] = [''] + assert validate_capability_planner_result( + injected, + planner_request, + )['failure_code'] == 'unknown_clarification_option' + + unknown_code = json.loads(json.dumps(clarify_payload)) + unknown_code['clarification']['code'] = 'execute_arbitrary_tool' + assert validate_capability_planner_result( + unknown_code, + planner_request, + )['failure_code'] == 'invalid_clarification_code' + + exhausted_request = build_capability_planner_request( + 'Compare the policy with public law.', + _inventory(), + clarification_budget_remaining=0, + ) + exhausted_payload = json.loads(json.dumps(clarify_payload)) + exhausted_payload['clarification']['option_values'] = [] + assert validate_capability_planner_result( + exhausted_payload, + exhausted_request, + )['failure_code'] == 'clarification_budget_exhausted' + + def test_azure_invocation_uses_schema_and_transport_timeout(): client = _fake_client(_completion_response(_proposal_payload())) planner_request = _request() @@ -520,8 +694,8 @@ def test_azure_invocation_uses_schema_and_transport_timeout(): request_payload = client.requests[0] assert 0 < request_payload['timeout'] <= 5.0 assert request_payload['stream'] is False - assert request_payload['temperature'] == 0 assert request_payload['max_completion_tokens'] == 300 + assert request_payload['reasoning_effort'] == 'minimal' assert request_payload['response_format']['type'] == 'json_schema' result_schema = request_payload['response_format']['json_schema']['schema'] requirement_schema = result_schema['properties']['requirements']['items'] @@ -556,10 +730,10 @@ def test_azure_invocation_uses_schema_and_transport_timeout(): def test_protocol_fallback_is_bounded_and_arbitrary_failures_are_not_retried(): fallback_client = _fake_client( - TypeError("unexpected keyword argument 'response_format'"), - TypeError("unexpected keyword argument 'response_format'"), - TypeError("unsupported parameter: response_format"), - TypeError("unsupported parameter: response_format"), + _FakeBadRequestError("unexpected keyword argument 'response_format'"), + _FakeBadRequestError("unexpected keyword argument 'response_format'"), + _FakeBadRequestError("unsupported parameter: response_format"), + _FakeBadRequestError("unsupported parameter: response_format"), _completion_response(_proposal_payload()), ) fallback_result = invoke_capability_planner( @@ -573,6 +747,7 @@ def test_protocol_fallback_is_bounded_and_arbitrary_failures_are_not_retried(): assert fallback_client.options == [{'timeout': 10.0, 'max_retries': 0}] assert len(fallback_client.requests) == 5 assert 'response_format' not in fallback_client.requests[-1] + assert fallback_client.requests[-1]['reasoning_effort'] == 'minimal' failed_client = _fake_client(RuntimeError('quota exceeded')) failed_result = invoke_capability_planner( @@ -596,8 +771,38 @@ def test_protocol_fallback_is_bounded_and_arbitrary_failures_are_not_retried(): assert misleading_error_result['failure_code'] == 'client_error' assert len(misleading_error_client.requests) == 1 + local_type_error_client = _fake_client( + TypeError("unexpected keyword argument 'response_format'"), + _completion_response(_proposal_payload()), + ) + local_type_error_result = invoke_capability_planner( + planner_client=local_type_error_client, + planner_model='planner-model', + planner_request=_request(), + ) + assert local_type_error_result['failure_code'] == 'client_error' + assert local_type_error_result['transport_error_class'] == 'type_error' + assert len(local_type_error_client.requests) == 1 + + unsupported_completion_tokens_client = _fake_client( + _FakeBadRequestError( + 'unsupported parameter: max_completion_tokens' + ), + _completion_response(_proposal_payload()), + ) + unsupported_completion_tokens_result = invoke_capability_planner( + planner_client=unsupported_completion_tokens_client, + planner_model='planner-model', + planner_request=_request(), + ) + assert unsupported_completion_tokens_result['failure_code'] == 'client_error' + assert unsupported_completion_tokens_result[ + 'transport_error_class' + ] == 'http_400_max_completion_tokens' + assert len(unsupported_completion_tokens_client.requests) == 1 + reasoning_client = _fake_client( - TypeError('unsupported parameter: temperature'), + _FakeBadRequestError('unsupported parameter: reasoning_effort'), _completion_response(_proposal_payload()), ) reasoning_result = invoke_capability_planner( @@ -609,9 +814,35 @@ def test_protocol_fallback_is_bounded_and_arbitrary_failures_are_not_retried(): assert reasoning_result['response_format_class'] == 'json_schema' assert len(reasoning_client.requests) == 2 assert reasoning_client.requests[-1]['max_completion_tokens'] == 600 - assert 'temperature' not in reasoning_client.requests[-1] + assert 'reasoning_effort' not in reasoning_client.requests[-1] assert reasoning_client.requests[-1]['response_format']['type'] == 'json_schema' + schema_fallback_client = _fake_client( + _FakeBadRequestError('Invalid parameter: response_format json_schema'), + _FakeBadRequestError('Invalid parameter: response_format json_schema'), + _completion_response(_proposal_payload()), + ) + schema_fallback_result = invoke_capability_planner( + planner_client=schema_fallback_client, + planner_model='gpt-5.6-planner', + planner_request=_request(), + ) + assert schema_fallback_result['status'] == 'valid' + assert schema_fallback_result['response_format_class'] == 'json_object' + assert len(schema_fallback_client.requests) == 3 + + terminal_bad_request = invoke_capability_planner( + planner_client=_fake_client( + _FakeBadRequestError('Bad request unrelated to optional fields') + ), + planner_model='planner-model', + planner_request=_request(), + ) + assert terminal_bad_request['failure_code'] == 'client_error' + assert terminal_bad_request['transport_error_class'] == 'http_4xx' + assert terminal_bad_request['transport_variant_index'] == 0 + assert 'Bad request' not in json.dumps(terminal_bad_request) + unsupported_transport = SimpleNamespace(chat=reasoning_client.chat) unsupported_result = invoke_capability_planner( planner_client=unsupported_transport, @@ -623,7 +854,7 @@ def test_protocol_fallback_is_bounded_and_arbitrary_failures_are_not_retried(): def test_optional_parameter_fallbacks_share_one_wall_clock_deadline(): deadline_client = _fake_client( - TypeError("unexpected keyword argument 'response_format'"), + _FakeBadRequestError("unexpected keyword argument 'response_format'"), _completion_response(_proposal_payload()), ) with patch( @@ -643,7 +874,7 @@ def test_optional_parameter_fallbacks_share_one_wall_clock_deadline(): assert result['latency_ms'] == 4500 anthropic_client = _fake_client( - TypeError('unsupported parameter: temperature'), + _FakeBadRequestError('unsupported parameter: temperature'), _completion_response(_proposal_payload()), ) with patch( @@ -857,13 +1088,16 @@ def test_shadow_metadata_excludes_prompts_responses_and_opaque_references(): metadata = build_capability_planner_shadow_metadata(planner_result) assert metadata == { - 'version': 1, + 'version': 2, 'mode': 'shadow', 'status': 'valid', 'candidate_count': 1, 'latency_ms': 412, 'fallback_used': False, 'decision': 'propose', + 'eligible_goal_turn_count': 1, + 'selected_goal_turn_count': 1, + 'prior_goal_included': False, 'recommended_capability_classes': [ 'workspace_search', 'web_search', @@ -926,7 +1160,10 @@ def test_planner_settings_normalize_closed_and_stay_backend_only(): 'chat_capability_planner_model_source': 'configured', 'chat_capability_planner_model_endpoint_id': 'server-endpoint', }) - assert incomplete_configured['chat_capability_planner_mode'] == 'off' + assert incomplete_configured['chat_capability_planner_mode'] == 'shadow' + assert incomplete_configured[ + 'chat_capability_planner_model_source' + ] == 'same_as_chat' sanitized = sanitize_settings_for_user({ **bounded, @@ -1318,8 +1555,9 @@ def _scenario_result( 'reason_code': reason_code, }) return { - 'version': 1, + 'version': 2, 'decision': decision, + 'goal_turn_refs': ['turn_0'], 'requirements': requirements, 'candidate_plans': [ { @@ -1335,8 +1573,13 @@ def _scenario_result( ) in enumerate(candidates, start=1) ], 'recommended_plan_id': 'candidate_1' if candidates else None, - 'clarification_code': ( - 'material_ambiguity' if decision == 'clarify' else None + 'clarification': ( + { + 'code': 'ambiguous_reference', + 'option_values': [], + } + if decision == 'clarify' + else None ), } diff --git a/functional_tests/test_chat_capability_planner_route.py b/functional_tests/test_chat_capability_planner_route.py index a0668096..7b590310 100644 --- a/functional_tests/test_chat_capability_planner_route.py +++ b/functional_tests/test_chat_capability_planner_route.py @@ -1,11 +1,11 @@ # test_chat_capability_planner_route.py """ Functional test for chat capability planner route placement and isolation. -Version: 0.250.069 -Implemented in: 0.250.069 +Version: 0.250.077 +Implemented in: 0.250.069; planner-first Assist corrected in 0.250.077 -This test ensures shadow planning runs only on eligible new turns after safe -discovery and cannot alter deterministic recommendation or execution state. +This test ensures Shadow remains isolated while Assist builds only the safe +inventory before the planner and cannot use heuristic capability suggestions. """ import copy @@ -119,12 +119,17 @@ def test_streaming_route_orders_shadow_before_unchanged_deterministic_plan(): "capability_recommendation = capability_discovery.get('recommendation')", discovery_index, ) - request_index = route_source.index( - 'capability_planner_request = build_capability_planner_request(', + request_builder_index = route_source.index( + 'def build_active_capability_planner_request():', control_index, ) + request_index = route_source.index( + 'capability_planner_request = (\n' + ' build_active_capability_planner_request()', + request_builder_index, + ) invoke_index = route_source.index( - 'capability_planner_shadow_result = invoke_capability_planner(', + 'capability_planner_result = invoke_capability_planner(', request_index, ) compare_index = route_source.index( @@ -146,7 +151,9 @@ def test_streaming_route_orders_shadow_before_unchanged_deterministic_plan(): assert 'and not capability_resume_context' in route_source[ control_index:request_index ] - assert "['selected_agent']" in route_source[request_index:invoke_index] + assert "['selected_agent']" in route_source[ + request_builder_index:request_index + ] def test_shadow_metadata_is_user_turn_only_and_configured_model_is_server_owned(): @@ -171,6 +178,7 @@ def test_shadow_metadata_is_user_turn_only_and_configured_model_is_server_owned( def test_configured_planner_ignores_colliding_user_endpoint(monkeypatch): route_backend_chats = importlib.import_module('route_backend_chats') + diagnostic_messages = [] def endpoint(scope, deployment): return { @@ -213,6 +221,11 @@ def endpoint(scope, deployment): 'deployment': deployment_name, }, ) + monkeypatch.setattr( + route_backend_chats, + 'debug_print', + diagnostic_messages.append, + ) resolved = route_backend_chats.resolve_streaming_multi_endpoint_gpt_config( {'enable_multi_model_endpoints': True}, @@ -231,6 +244,116 @@ def endpoint(scope, deployment): assert resolved[1] == 'admin-controlled-deployment' assert resolved[6] == 'shared-endpoint-id' assert resolved[7] == 'planner-model-id' + diagnostics = ' '.join(diagnostic_messages) + for forbidden_value in ( + 'shared-endpoint-id', + 'planner-model-id', + 'admin-controlled-deployment', + 'https://global.example.test', + '2025-01-01-preview', + 'global-secret', + ): + assert forbidden_value not in diagnostics + assert 'provider_class=aoai' in diagnostics + assert 'protocol=azure_openai' in diagnostics + + +def test_planner_runtime_uses_exact_selected_chat_model_without_admin_selection(): + route_backend_chats = importlib.import_module('route_backend_chats') + selected_chat_client = object() + + runtime = route_backend_chats._resolve_chat_capability_planner_runtime( + settings={}, + planner_settings={ + 'chat_capability_planner_mode': 'assist', + 'chat_capability_planner_model_source': 'same_as_chat', + 'chat_capability_planner_model_endpoint_id': '', + 'chat_capability_planner_model_id': '', + }, + user_id='current-user', + active_group_ids=[], + same_chat_client=selected_chat_client, + same_chat_model='gpt-5.6-luna', + same_chat_provider='aoai', + same_chat_endpoint='https://selected.example.test', + ) + + assert runtime['client'] is selected_chat_client + assert runtime['model'] == 'gpt-5.6-luna' + assert runtime['runtime_protocol'] == 'azure_openai' + + +def test_planner_runtime_treats_incomplete_configured_ids_as_selected_chat_model(): + route_backend_chats = importlib.import_module('route_backend_chats') + selected_chat_client = object() + + runtime = route_backend_chats._resolve_chat_capability_planner_runtime( + settings={}, + planner_settings={ + 'chat_capability_planner_mode': 'assist', + 'chat_capability_planner_model_source': 'configured', + 'chat_capability_planner_model_endpoint_id': 'global-endpoint', + 'chat_capability_planner_model_id': '', + }, + user_id='current-user', + active_group_ids=[], + same_chat_client=selected_chat_client, + same_chat_model='gpt-5.6-luna', + same_chat_provider='aoai', + same_chat_endpoint='https://selected.example.test', + ) + + assert runtime['client'] is selected_chat_client + assert runtime['model'] == 'gpt-5.6-luna' + + +def test_assist_discovery_builds_inventory_without_heuristic_classification( + monkeypatch, +): + route_backend_chats = importlib.import_module('route_backend_chats') + inventory = _inventory() + + monkeypatch.setattr( + route_backend_chats, + '_resolve_server_chat_capability_inventory', + lambda **kwargs: copy.deepcopy(inventory), + ) + monkeypatch.setattr( + route_backend_chats, + '_attach_governed_agent_inventory', + lambda current_inventory, **kwargs: current_inventory, + ) + monkeypatch.setattr( + route_backend_chats, + 'classify_capability_requirements', + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError('Assist must not run the built-in heuristic classifier.') + ), + ) + monkeypatch.setattr( + route_backend_chats, + 'classify_agent_capability_requirements', + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError('Assist must not run the agent heuristic classifier.') + ), + ) + + discovery = route_backend_chats._build_server_capability_discovery( + settings={}, + user_id='current-user', + user_email='user@example.test', + user_roles=[], + user_message='Find current public evidence.', + selected_capability_ids=[], + enable_deterministic_matching=False, + ) + + assert discovery == { + 'inventory': inventory, + 'requirements': [], + 'auto_capability_ids': [], + 'recommendation': None, + } def test_planner_cancellation_precedes_plan_and_persistence(): @@ -238,7 +361,7 @@ def test_planner_cancellation_precedes_plan_and_persistence(): SINGLE_APP_ROOT / 'route_backend_chats.py' ).read_text(encoding='utf-8') invoke_index = route_source.index( - 'capability_planner_shadow_result = invoke_capability_planner(' + 'capability_planner_result = invoke_capability_planner(' ) cancel_index = route_source.index( 'if stream_cancel_requested():', @@ -249,10 +372,335 @@ def test_planner_cancellation_precedes_plan_and_persistence(): cancel_index, ) persist_index = route_source.index( - 'cosmos_messages_container.upsert_item(user_message_doc)', + 'persist_stream_user_message(user_metadata)', auto_index, ) assert invoke_index < cancel_index < auto_index < persist_index assert '_build_stream_cancel_event(' in route_source[cancel_index:auto_index] assert 'message_persisted=False' in route_source[cancel_index:auto_index] + + +def test_clarification_claim_precedes_planner_and_completion_follows_persistence(): + route_source = ( + SINGLE_APP_ROOT / 'route_backend_chats.py' + ).read_text(encoding='utf-8') + generator_start = route_source.index( + 'def generate(publish_background_event=None):' + ) + worker_clarification_preflight = route_source.index( + '_preflight_chat_clarification(', + generator_start, + ) + context_assembly = route_source.index( + 'load_bounded_prior_user_turns(', + worker_clarification_preflight, + ) + clarification_transition = route_source.index( + ') = persist_chat_clarification_response_claim(', + generator_start, + ) + planner_invocation = route_source.index( + 'capability_planner_result = invoke_capability_planner(', + clarification_transition, + ) + final_clarification_source_validation = route_source.rindex( + 'validate_chat_clarification_source(', + clarification_transition, + planner_invocation, + ) + final_clarification_request_rebuild = route_source.rindex( + 'build_active_capability_planner_request()', + clarification_transition, + planner_invocation, + ) + clarification_invalidation = route_source.index( + '_invalidate_chat_clarification_checkpoint(', + final_clarification_source_validation, + ) + claimed_response_persistence = route_source.index( + '_persist_claimed_clarification_response_metadata(', + clarification_transition, + ) + claimed_response_new_insert = route_source.index( + 'cosmos_messages_container.upsert_item(\n' + ' claimed_clarification_user_doc', + claimed_response_persistence, + ) + user_persistence = route_source.index( + 'persist_stream_user_message(user_metadata)', + planner_invocation, + ) + dispatcher_definition = route_source.index( + 'def persist_stream_user_message(metadata):', + planner_invocation, + ) + terminal_completion_helper = route_source.index( + 'def complete_stream_capability_resume(assistant_message_id):', + generator_start, + ) + clarification_completion = route_source.index( + ') = persist_chat_clarification_response_completion(', + terminal_completion_helper, + ) + first_terminal_output = route_source.index( + 'complete_stream_capability_resume(assistant_message_id)', + planner_invocation, + ) + + assert ( + terminal_completion_helper + < worker_clarification_preflight + < context_assembly + < clarification_transition + < claimed_response_persistence + < claimed_response_new_insert + < final_clarification_source_validation + < final_clarification_request_rebuild + < planner_invocation + < dispatcher_definition + < user_persistence + < first_terminal_output + ) + generator_end = route_source.index( + "@bp.route('/api/chat/stream/cancel/", + dispatcher_definition, + ) + assert 'upsert_item(user_message_doc)' not in route_source[ + dispatcher_definition:generator_end + ] + assert 'upsert_item(active_user_message_doc)' not in route_source[ + dispatcher_definition:generator_end + ] + worker_preflight_source = route_source[ + worker_clarification_preflight:context_assembly + ] + assert 'expected_clarification_id=(' in worker_preflight_source + targeted_worker_context = route_source.index( + "targeted_clarification = (", + worker_clarification_preflight, + ) + targeted_replay = route_source.index( + "targeted_clarification.get('status')\n" + " == 'resolved'", + targeted_worker_context, + ) + targeted_source = route_source.index( + 'targeted_source = validate_chat_clarification_source(', + targeted_replay, + ) + targeted_recovery = route_source.index( + 'pending_chat_clarification = targeted_clarification', + targeted_source, + ) + bounded_history_reload = route_source.index( + 'load_bounded_prior_user_turns(', + targeted_recovery, + ) + assert ( + worker_clarification_preflight + < targeted_worker_context + < targeted_replay + < targeted_source + < targeted_recovery + < bounded_history_reload + ) + targeted_context_source = route_source[ + targeted_source:bounded_history_reload + ] + assert "'prior_user_messages': [" in targeted_context_source + assert 'targeted_source,' in targeted_context_source + assert 'targeted_response,' in targeted_context_source + expected_checkpoint_read = route_source.index( + ') = read_chat_clarification_message(', + context_assembly, + ) + assert expected_checkpoint_read < clarification_transition + assert route_source.count( + 'persist_chat_clarification_response_claim(' + ) == 1 + assert clarification_invalidation < planner_invocation + claimed_response_read = route_source.index( + 'claimed_clarification_user_doc = (', + clarification_transition, + ) + claimed_response_validation = route_source.index( + '_claimed_clarification_response_is_valid(', + claimed_response_read, + ) + claimed_response_invalidation = route_source.index( + '_invalidate_chat_clarification_checkpoint(', + claimed_response_validation, + ) + assert ( + claimed_response_read + < claimed_response_validation + < claimed_response_invalidation + < planner_invocation + ) + assert route_source.count( + 'persist_chat_clarification_response_completion(' + ) == 4 + cleanup_helper = route_source.index( + 'def _finalize_stream_clarification_claim(' + ) + cleanup_completion = route_source.index( + 'persist_chat_clarification_response_completion(', + cleanup_helper, + ) + cleanup_invalidation = route_source.index( + 'persist_chat_clarification_invalidation(', + cleanup_completion, + ) + assert cleanup_helper < cleanup_completion < cleanup_invalidation + reconciliation_completion = route_source.index( + ') = persist_chat_clarification_response_completion(', + clarification_completion + 1, + ) + assert clarification_completion < reconciliation_completion < clarification_transition + assert "'clarification_replayed': True" in route_source[ + reconciliation_completion:clarification_transition + ] + legacy_start = route_source.index( + 'def chat_api(server_request_data=None, server_resume_context=None):' + ) + stream_route_start = route_source.index( + "@bp.route('/api/chat/stream'", + legacy_start, + ) + assert 'resolved_chat_clarification' not in route_source[ + legacy_start:stream_route_start + ] + streaming_pointer = route_source.index( + "'_clarification_id': (", + planner_invocation, + ) + assert streaming_pointer < user_persistence + document_action_start = route_source.index( + 'def execute_document_action_chat_request(' + ) + first_contextual_action_revalidation = route_source.index( + '_rebuild_claimed_contextual_goal(', + document_action_start, + ) + agent_resolution = route_source.index( + '_resolve_canonical_chat_agent(', + document_action_start, + ) + task_document_resolution = route_source.index( + '_resolve_conversation_task_documents(', + document_action_start, + ) + second_contextual_action_revalidation = route_source.index( + '_rebuild_claimed_contextual_goal(', + first_contextual_action_revalidation + 1, + ) + selected_document_resolution = route_source.index( + '_resolve_authorized_chat_selected_documents(', + document_action_start, + ) + third_contextual_action_revalidation = route_source.index( + '_rebuild_claimed_contextual_goal(', + second_contextual_action_revalidation + 1, + ) + assigned_knowledge_retrieval = route_source.index( + '_build_assigned_knowledge_reference_context(', + document_action_start, + ) + fourth_contextual_action_revalidation = route_source.index( + '_rebuild_claimed_contextual_goal(', + third_contextual_action_revalidation + 1, + ) + workflow_prompt_build = route_source.index( + '_build_document_action_prompt_with_assigned_knowledge_context(', + document_action_start, + ) + fifth_contextual_action_revalidation = route_source.index( + '_rebuild_claimed_contextual_goal(', + fourth_contextual_action_revalidation + 1, + ) + workflow_execution = route_source.index( + '_execute_document_action_workflow(', + document_action_start, + ) + assert ( + first_contextual_action_revalidation + < agent_resolution + < task_document_resolution + < second_contextual_action_revalidation + < selected_document_resolution + < third_contextual_action_revalidation + < assigned_knowledge_retrieval + < fourth_contextual_action_revalidation + < workflow_prompt_build + < fifth_contextual_action_revalidation + < workflow_execution + ) + compatibility_preflight = route_source.index( + '_preflight_chat_clarification(', + route_source.index("@bp.route('/api/chat/stream'"), + ) + compatibility_bridge = route_source.index( + 'if compatibility_mode:', + compatibility_preflight, + ) + assert compatibility_preflight < compatibility_bridge + assert 'clarification_response_retry' in route_source[ + compatibility_preflight:compatibility_bridge + ] + assert 'clarification_response_idempotent' in route_source[ + clarification_transition:planner_invocation + ] + assert "'clarification_replayed': True" in route_source[ + clarification_transition:planner_invocation + ] + worker_context_revalidation = route_source.index( + '_rebuild_authorized_contextual_goal(', + generator_start, + ) + model_initialization = route_source.index( + 'initialize_semantic_kernel(', + worker_context_revalidation, + ) + agent_resolution = route_source.index( + '_resolve_canonical_chat_agent(', + worker_context_revalidation, + ) + final_context_revalidation = route_source.index( + '_rebuild_exact_contextual_goal(', + worker_context_revalidation, + ) + assert worker_context_revalidation < model_initialization + assert worker_context_revalidation < agent_resolution + assert worker_context_revalidation < final_context_revalidation + normal_assistant_metadata = route_source.index( + "'agent_runtime': agent_runtime_metadata or None", + planner_invocation, + ) + normal_assistant_persistence = route_source.index( + 'cosmos_messages_container.upsert_item(assistant_doc)', + normal_assistant_metadata, + ) + final_response_metadata_persistence = route_source.index( + 'persist_stream_user_message(', + normal_assistant_persistence, + ) + normal_terminal_completion = route_source.index( + 'complete_stream_capability_resume(assistant_message_id)', + normal_assistant_persistence, + ) + capability_resume_logging_guard = route_source.index( + 'if capability_resume_context and resume_terminalized:', + normal_terminal_completion, + ) + assert ( + normal_assistant_persistence + < final_response_metadata_persistence + < normal_terminal_completion + < capability_resume_logging_guard + ) + assert 'except ChatClarificationError:\n raise' in ( + route_source[ + final_response_metadata_persistence:normal_terminal_completion + ] + ) diff --git a/functional_tests/test_chat_clarification_persistence.py b/functional_tests/test_chat_clarification_persistence.py new file mode 100644 index 00000000..02da08c9 --- /dev/null +++ b/functional_tests/test_chat_clarification_persistence.py @@ -0,0 +1,601 @@ +#!/usr/bin/env python3 +# test_chat_clarification_persistence.py +""" +Functional test for durable structured chat clarification. +Version: 0.250.076 +Implemented in: 0.250.076 + +This test ensures server-authored clarification checkpoints are allowlisted, +expire predictably, and resolve idempotently with optimistic concurrency. +""" + +import copy +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SINGLE_APP_ROOT = REPO_ROOT / 'application' / 'single_app' +sys.path.insert(0, str(SINGLE_APP_ROOT)) + +from functions_chat_clarifications import ( # noqa: E402 + CHAT_CLARIFICATION_QUESTIONS, + ChatClarificationError, + apply_chat_clarification_response, + build_chat_clarification, + claim_chat_clarification_response, + complete_chat_clarification_response, + persist_chat_clarification_expiry, + persist_chat_clarification_invalidation, + persist_chat_clarification_response_claim, + persist_chat_clarification_response_completion, + persist_chat_clarification_response, + read_chat_clarification_message, + validate_chat_clarification_retry, +) + + +NOW = datetime(2026, 7, 17, 12, 0, tzinfo=timezone.utc) + + +class _ConditionalConflict(Exception): + status_code = 412 + + +class _FakeContainer: + def __init__(self, message, *, conflicts=0): + self.message = copy.deepcopy(message) + self.conflicts = conflicts + self.replace_calls = 0 + + def read_item(self, *, item, partition_key): + assert item == self.message['id'] + assert partition_key == self.message['conversation_id'] + return copy.deepcopy(self.message) + + def replace_item(self, *, item, body, etag, match_condition): + assert item == self.message['id'] + assert etag == self.message['_etag'] + assert match_condition is not None + self.replace_calls += 1 + if self.conflicts: + self.conflicts -= 1 + raise _ConditionalConflict('etag conflict') + updated = copy.deepcopy(body) + updated['_etag'] = f'etag-{self.replace_calls + 1}' + self.message = updated + return copy.deepcopy(updated) + + +def _clarification( + code='jurisdiction_required', + options=None, + now=NOW, + ttl_seconds=300, +): + return build_chat_clarification( + { + 'code': code, + 'option_values': list(options or []), + }, + parent_run_id='parent-run-1', + conversation_id='conversation-1', + source_user_message_id='user-message-1', + source_thread_id='thread-1', + assistant_message_id='clarification-1', + now=now, + ttl_seconds=ttl_seconds, + ) + + +def _message(clarification): + return { + 'id': clarification['clarification_id'], + 'conversation_id': 'conversation-1', + 'role': 'assistant', + 'content': clarification['question'], + '_etag': 'etag-1', + 'metadata': { + 'awaiting_user_clarification': True, + 'chat_clarification': copy.deepcopy(clarification), + }, + } + + +def test_all_clarification_codes_use_fixed_server_questions(): + for code, question in CHAT_CLARIFICATION_QUESTIONS.items(): + clarification = _clarification(code) + assert clarification['code'] == code + assert clarification['question'] == question + assert clarification['status'] == 'pending' + assert clarification['clarification_budget_used'] == 1 + assert clarification['_source_user_message_id'] == 'user-message-1' + assert clarification['_source_thread_id'] == 'thread-1' + + try: + _clarification('execute_arbitrary_tool') + raise AssertionError('unknown clarification codes must fail closed') + except ChatClarificationError as exc: + assert exc.code == 'invalid_clarification_code' + + +def test_response_resolution_is_idempotent_and_conflicts_fail(): + clarification = _clarification( + 'source_scope_required', + ['My workspace', 'Public web', 'Both'], + ) + resolved, idempotent = apply_chat_clarification_response( + clarification, + response_user_message_id='user-message-2', + response_text='Both', + child_run_id='child-run-1', + now=NOW + timedelta(seconds=5), + ) + replayed, replay_idempotent = apply_chat_clarification_response( + resolved, + response_user_message_id='user-message-2', + response_text='Both', + child_run_id='child-run-1', + now=NOW + timedelta(seconds=6), + ) + + assert idempotent is False + assert replay_idempotent is True + assert replayed == resolved + assert resolved['status'] == 'resolved' + assert resolved['response_mode'] == 'option' + assert resolved['child_run_id'] == 'child-run-1' + assert resolved['_response_user_message_id'] == 'user-message-2' + assert resolved['_response_hash'] + assert 'Both' not in resolved.values() + + try: + apply_chat_clarification_response( + resolved, + response_user_message_id='different-user-message', + response_text='Both', + now=NOW + timedelta(seconds=6), + ) + raise AssertionError('same text from a new message is not an idempotent replay') + except ChatClarificationError as exc: + assert exc.code == 'clarification_response_conflict' + + +def test_retry_validator_preserves_exact_response_identity_only(): + claimed, _ = claim_chat_clarification_response( + _clarification(ttl_seconds=7200), + response_user_message_id='user-message-2', + response_text='Virginia', + child_run_id='child-run-1', + response_thread_id='thread-2', + now=NOW + timedelta(seconds=5), + ) + response_message = { + 'id': 'user-message-2', + 'role': 'user', + 'content': 'Virginia', + } + + recovery = validate_chat_clarification_retry( + claimed, + response_message, + proposed_text='Virginia', + now=NOW + timedelta(seconds=6), + ) + assert recovery == { + 'mode': 'recover', + 'response_user_message_id': 'user-message-2', + 'response_thread_id': 'thread-2', + 'child_run_id': 'child-run-1', + } + + resolved, _ = complete_chat_clarification_response( + claimed, + response_user_message_id='user-message-2', + child_run_id='child-run-1', + now=NOW + timedelta(seconds=6), + ) + assert validate_chat_clarification_retry( + resolved, + response_message, + proposed_text='Virginia', + now=NOW + timedelta(seconds=7), + )['mode'] == 'replay' + + for proposed_text, expected_code in ( + ('Maryland', 'clarification_response_conflict'), + ('Virginia', 'clarification_expired'), + ): + candidate = copy.deepcopy(resolved) + if expected_code == 'clarification_expired': + candidate['status'] = 'expired' + try: + validate_chat_clarification_retry( + candidate, + response_message, + proposed_text=proposed_text, + now=NOW + timedelta(seconds=8), + ) + raise AssertionError('invalid clarification retry must fail') + except ChatClarificationError as exc: + assert exc.code == expected_code + + try: + apply_chat_clarification_response( + resolved, + response_user_message_id='user-message-3', + response_text='Public web', + now=NOW + timedelta(seconds=7), + ) + raise AssertionError('conflicting clarification responses must fail') + except ChatClarificationError as exc: + assert exc.code == 'clarification_response_conflict' + + +def test_free_text_response_is_linked_without_persisting_answer(): + resolved, _ = apply_chat_clarification_response( + _clarification(), + response_user_message_id='user-message-2', + response_text='Virginia and Fairfax County', + now=NOW + timedelta(seconds=5), + ) + + assert resolved['response_mode'] == 'free_text' + assert 'Virginia and Fairfax County' not in str(resolved) + + +def test_response_lease_blocks_duplicates_and_reclaims_after_process_loss(): + claimed, idempotent = claim_chat_clarification_response( + _clarification(ttl_seconds=7200), + response_user_message_id='user-message-2', + response_text='Virginia', + child_run_id='child-run-1', + response_thread_id='thread-2', + now=NOW + timedelta(seconds=5), + ) + assert idempotent is False + assert claimed['status'] == 'resolving' + assert claimed['lease_expires_at'] + + try: + claim_chat_clarification_response( + claimed, + response_user_message_id='duplicate-user-message', + response_text='Virginia', + child_run_id='duplicate-child-run', + response_thread_id='duplicate-thread', + now=NOW + timedelta(seconds=6), + ) + raise AssertionError('a live clarification lease must block duplicate planning') + except ChatClarificationError as exc: + assert exc.code == 'clarification_response_in_progress' + + try: + claim_chat_clarification_response( + claimed, + response_user_message_id='recovered-user-message', + response_text='Virginia', + child_run_id='replacement-child-run', + response_thread_id='replacement-thread', + now=NOW + timedelta(minutes=31), + ) + raise AssertionError('lease recovery must require the exact response message') + except ChatClarificationError as exc: + assert exc.code == 'clarification_response_conflict' + + reclaimed, reclaim_idempotent = claim_chat_clarification_response( + claimed, + response_user_message_id='user-message-2', + response_text='Virginia', + child_run_id='replacement-child-run', + response_thread_id='replacement-thread', + now=NOW + timedelta(minutes=31), + ) + assert reclaim_idempotent is False + assert reclaimed['_response_user_message_id'] == 'user-message-2' + assert reclaimed['_response_thread_id'] == 'thread-2' + assert reclaimed['child_run_id'] == 'child-run-1' + + completed, completion_idempotent = complete_chat_clarification_response( + reclaimed, + response_user_message_id='user-message-2', + child_run_id='child-run-1', + now=NOW + timedelta(minutes=31, seconds=1), + ) + assert completion_idempotent is False + assert completed['status'] == 'resolved' + assert completed['lease_expires_at'] is None + + +def test_claim_and_completion_persistence_use_separate_etag_transitions(): + container = _FakeContainer(_message(_clarification()), conflicts=1) + _, claimed, claim_idempotent = persist_chat_clarification_response_claim( + container, + conversation_id='conversation-1', + clarification_id='clarification-1', + response_user_message_id='user-message-2', + response_text='Virginia', + child_run_id='child-run-1', + now=NOW + timedelta(seconds=5), + ) + _, completed, completion_idempotent = ( + persist_chat_clarification_response_completion( + container, + conversation_id='conversation-1', + clarification_id='clarification-1', + response_user_message_id='user-message-2', + child_run_id='child-run-1', + now=NOW + timedelta(seconds=6), + ) + ) + + assert claim_idempotent is False + assert completion_idempotent is False + assert claimed['status'] == 'resolving' + assert completed['status'] == 'resolved' + assert container.replace_calls == 3 + + +def test_completion_rejects_stale_generation_and_invalid_response(): + claimed, _ = claim_chat_clarification_response( + _clarification(ttl_seconds=7200), + response_user_message_id='user-message-2', + response_text='Virginia', + child_run_id='child-run-1', + response_thread_id='thread-2', + now=NOW + timedelta(seconds=5), + ) + stale_container = _FakeContainer(_message(claimed)) + try: + persist_chat_clarification_response_completion( + stale_container, + conversation_id='conversation-1', + clarification_id='clarification-1', + response_user_message_id='user-message-2', + child_run_id='child-run-1', + expected_claimed_at='different-claim-generation', + now=NOW + timedelta(seconds=6), + ) + raise AssertionError('stale completion must not resolve a renewed claim') + except ChatClarificationError as exc: + assert exc.code == 'clarification_response_claim_mismatch' + assert stale_container.message['metadata']['chat_clarification'][ + 'status' + ] == 'resolving' + + invalid_container = _FakeContainer(_message(claimed)) + + def reject_response(_clarification): + raise KeyError('response-user-message') + + try: + persist_chat_clarification_response_completion( + invalid_container, + conversation_id='conversation-1', + clarification_id='clarification-1', + response_user_message_id='user-message-2', + child_run_id='child-run-1', + expected_claimed_at=claimed['claimed_at'], + response_validator=reject_response, + now=NOW + timedelta(seconds=6), + ) + raise AssertionError('invalid response must terminalize completion') + except ChatClarificationError as exc: + assert exc.code == 'clarification_response_claim_mismatch' + invalidated = invalid_container.message['metadata']['chat_clarification'] + assert invalidated['status'] == 'expired' + assert invalidated['invalidation_reason'] == ( + 'clarification_response_claim_mismatch' + ) + + +def test_claim_source_failure_is_durably_invalidated(): + container = _FakeContainer(_message(_clarification()), conflicts=1) + + def reject_source(_clarification): + raise KeyError('source-user-message') + + try: + persist_chat_clarification_response_claim( + container, + conversation_id='conversation-1', + clarification_id='clarification-1', + response_user_message_id='user-message-2', + response_text='Virginia', + child_run_id='child-run-1', + source_validator=reject_source, + now=NOW + timedelta(seconds=5), + ) + raise AssertionError('invalid clarification source must fail closed') + except ChatClarificationError as exc: + assert exc.code == 'clarification_source_invalid' + + stored = container.message['metadata']['chat_clarification'] + assert stored['status'] == 'expired' + assert stored['invalidation_reason'] == 'clarification_source_invalid' + assert stored['lease_expires_at'] is None + assert container.message['metadata']['awaiting_user_clarification'] is False + assert container.replace_calls == 2 + + +def test_stale_recovery_claim_cannot_invalidate_renewed_generation(): + stale_claim, _ = claim_chat_clarification_response( + _clarification(ttl_seconds=7200), + response_user_message_id='user-message-2', + response_text='Virginia', + child_run_id='child-run-1', + response_thread_id='thread-2', + now=NOW + timedelta(seconds=5), + ) + renewed_claim, _ = claim_chat_clarification_response( + stale_claim, + response_user_message_id='user-message-2', + response_text='Virginia', + child_run_id='child-run-1', + response_thread_id='thread-2', + now=NOW + timedelta(minutes=31), + ) + container = _FakeContainer(_message(renewed_claim)) + source_validations = [] + + def reject_source(_clarification): + source_validations.append(True) + raise KeyError('source-user-message') + + try: + persist_chat_clarification_response_claim( + container, + conversation_id='conversation-1', + clarification_id='clarification-1', + response_user_message_id='user-message-2', + response_text='Virginia', + child_run_id='child-run-1', + response_thread_id='thread-2', + source_validator=reject_source, + expected_response_user_message_id=( + stale_claim['_response_user_message_id'] + ), + expected_child_run_id=stale_claim['child_run_id'], + expected_claimed_at=stale_claim['claimed_at'], + now=NOW + timedelta(minutes=31, seconds=1), + ) + raise AssertionError('stale recovery must not mutate renewed claim') + except ChatClarificationError as exc: + assert exc.code == 'clarification_response_claim_mismatch' + + assert source_validations == [] + stored = container.message['metadata']['chat_clarification'] + assert stored['status'] == 'resolving' + assert stored['claimed_at'] == renewed_claim['claimed_at'] + assert stored.get('invalidation_reason') is None + assert container.replace_calls == 0 + + +def test_persistence_retries_etag_and_same_response_replays_once(): + container = _FakeContainer(_message(_clarification()), conflicts=1) + validated_sources = [] + + def validate_source(clarification): + validated_sources.append(clarification['_source_user_message_id']) + + saved_message, resolved, idempotent = persist_chat_clarification_response( + container, + conversation_id='conversation-1', + clarification_id='clarification-1', + response_user_message_id='user-message-2', + response_text='Virginia', + child_run_id='child-run-1', + source_validator=validate_source, + now=NOW + timedelta(seconds=5), + ) + replay_message, replayed, replay_idempotent = ( + persist_chat_clarification_response( + container, + conversation_id='conversation-1', + clarification_id='clarification-1', + response_user_message_id='user-message-2', + response_text='Virginia', + child_run_id='child-run-1', + source_validator=validate_source, + now=NOW + timedelta(seconds=6), + ) + ) + + assert saved_message['metadata']['awaiting_user_clarification'] is False + assert resolved['status'] == 'resolved' + assert idempotent is False + assert replay_idempotent is True + assert replayed == resolved + assert replay_message['id'] == 'clarification-1' + assert container.replace_calls == 2 + assert validated_sources == [ + 'user-message-1', + 'user-message-1', + 'user-message-1', + ] + + +def test_expiry_is_persisted_and_blocks_response(): + clarification = _clarification(now=NOW - timedelta(minutes=10)) + container = _FakeContainer(_message(clarification)) + + try: + persist_chat_clarification_response( + container, + conversation_id='conversation-1', + clarification_id='clarification-1', + response_user_message_id='user-message-2', + response_text='Virginia', + now=NOW, + ) + raise AssertionError('expired clarification responses must fail') + except ChatClarificationError as exc: + assert exc.code == 'clarification_expired' + + _, persisted = read_chat_clarification_message( + container, + conversation_id='conversation-1', + clarification_id='clarification-1', + ) + assert persisted['status'] == 'expired' + assert container.message['metadata']['awaiting_user_clarification'] is False + + +def test_preflight_expiry_transition_is_idempotent(): + clarification = _clarification(now=NOW - timedelta(minutes=10)) + container = _FakeContainer(_message(clarification), conflicts=1) + + _, expired, idempotent = persist_chat_clarification_expiry( + container, + conversation_id='conversation-1', + clarification_id='clarification-1', + now=NOW, + ) + _, replayed, replay_idempotent = persist_chat_clarification_expiry( + container, + conversation_id='conversation-1', + clarification_id='clarification-1', + now=NOW + timedelta(seconds=1), + ) + + assert idempotent is False + assert replay_idempotent is True + assert replayed == expired + assert expired['status'] == 'expired' + assert container.message['metadata']['awaiting_user_clarification'] is False + + +def test_source_invalidation_terminalizes_a_live_response_claim(): + claimed, _ = claim_chat_clarification_response( + _clarification(ttl_seconds=7200), + response_user_message_id='user-message-2', + response_text='Virginia', + child_run_id='child-run-1', + response_thread_id='thread-2', + now=NOW + timedelta(seconds=5), + ) + container = _FakeContainer(_message(claimed), conflicts=1) + + _, invalidated, idempotent = persist_chat_clarification_invalidation( + container, + conversation_id='conversation-1', + clarification_id='clarification-1', + reason='clarification_source_invalid', + now=NOW + timedelta(seconds=6), + ) + _, replayed, replay_idempotent = persist_chat_clarification_invalidation( + container, + conversation_id='conversation-1', + clarification_id='clarification-1', + reason='clarification_source_invalid', + now=NOW + timedelta(seconds=7), + ) + + assert idempotent is False + assert replay_idempotent is True + assert replayed == invalidated + assert invalidated['status'] == 'expired' + assert invalidated['lease_expires_at'] is None + assert invalidated['invalidation_reason'] == 'clarification_source_invalid' + assert container.message['metadata']['awaiting_user_clarification'] is False diff --git a/functional_tests/test_image_proposal_approval_route.py b/functional_tests/test_image_proposal_approval_route.py index afa512f3..866f6ea9 100644 --- a/functional_tests/test_image_proposal_approval_route.py +++ b/functional_tests/test_image_proposal_approval_route.py @@ -68,6 +68,10 @@ def read_item(self, item=None, partition_key=None, *args, **kwargs): raise DummyNotFoundError(item_id) return copy.deepcopy(stored_item) + def query_items(self, *, query, parameters, partition_key, **kwargs): + del query, parameters, partition_key, kwargs + return [] + def set_item(self, item): self.items[(item['conversation_id'], item['id'])] = copy.deepcopy(item) diff --git a/functional_tests/test_orchestration_runtime.py b/functional_tests/test_orchestration_runtime.py index 4778c641..3b87af01 100644 --- a/functional_tests/test_orchestration_runtime.py +++ b/functional_tests/test_orchestration_runtime.py @@ -721,7 +721,7 @@ def test_chat_routes_persist_and_gate_request_scoped_runtime(): route_source.index('def finalize_cancelled_stream_response():'), ) cancel_persist_index = route_source.index( - 'cosmos_messages_container.upsert_item(user_message_doc)', + 'persist_stream_user_message(user_metadata)', cancel_runtime_index, ) stream_failure_index = route_source.index( @@ -729,7 +729,7 @@ def test_chat_routes_persist_and_gate_request_scoped_runtime(): cancel_persist_index, ) stream_failure_persist_index = route_source.index( - 'cosmos_messages_container.upsert_item(user_message_doc)', + 'persist_stream_user_message(user_metadata)', stream_failure_index, ) assert cancel_runtime_index < cancel_persist_index < stream_failure_index diff --git a/functional_tests/test_phase10b_governed_additive_plan_activation.py b/functional_tests/test_phase10b_governed_additive_plan_activation.py index ba109438..49f23184 100644 --- a/functional_tests/test_phase10b_governed_additive_plan_activation.py +++ b/functional_tests/test_phase10b_governed_additive_plan_activation.py @@ -1,8 +1,8 @@ # test_phase10b_governed_additive_plan_activation.py """Functional tests for Phase 10B governed additive plan activation. -Version: 0.250.073 -Implemented in: 0.250.072; Admin controls enhanced in 0.250.073 +Version: 0.250.077 +Implemented in: 0.250.072; planner-only Assist corrected in 0.250.077 This test ensures validated planner candidates become bounded server-owned capability options without rewriting selected mandates. @@ -836,7 +836,7 @@ def test_assist_mode_is_normalized_and_uses_new_turn_eligibility(): ) is False -def test_deterministic_material_conflict_wins_but_complementary_plan_activates(): +def test_validated_planner_recommendation_owns_assist_activation(): inventory = _build_inventory() planner_recommendation = build_planner_capability_recommendation( _planner_result([ @@ -878,15 +878,15 @@ def test_deterministic_material_conflict_wins_but_complementary_plan_activates() deterministic_deep_research, ) - assert selected == deterministic_deep_research + assert selected == planner_recommendation assert summary == { - 'activation_status': 'suppressed', - 'recommendation_source': 'deterministic', - 'suppression_reason': 'deterministic_conflict', + 'activation_status': 'materialized', + 'recommendation_source': 'planner', + 'suppression_reason': None, } -def test_every_planner_alternative_preserves_deterministic_material_source(): +def test_planner_alternatives_do_not_inherit_heuristic_material_sources(): planner_recommendation = build_planner_capability_recommendation( _planner_result([ { @@ -924,11 +924,14 @@ def test_every_planner_alternative_preserves_deterministic_material_source(): if option['id'] != 'continue_without_capabilities' ] assert summary['activation_status'] == 'materialized' - assert len(actionable_options) == 1 - assert actionable_options[0]['effective_capability_ids'] == [ - 'web_search', - 'workspace_search', - ] + assert len(actionable_options) == 2 + assert { + tuple(option['effective_capability_ids']) + for option in actionable_options + } == { + ('web_search', 'workspace_search'), + ('web_search',), + } def test_governed_agent_candidate_reuses_server_owned_opaque_reference(): @@ -1001,7 +1004,7 @@ def test_plan_binding_revalidates_exact_bundle_and_policy_state(): ) approved = _approved_planner_proposal(recommendation) - assert approved['version'] == 2 + assert approved['version'] == 3 assert approved['recommendation_source'] == 'planner' assert revalidate_capability_choice(approved, inventory) is True diff --git a/functional_tests/test_phase10c_collaboration_metadata_projection.py b/functional_tests/test_phase10c_collaboration_metadata_projection.py new file mode 100644 index 00000000..70d588b3 --- /dev/null +++ b/functional_tests/test_phase10c_collaboration_metadata_projection.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +# test_phase10c_collaboration_metadata_projection.py +""" +Functional test for Phase 10C collaboration metadata projection. +Version: 0.250.076 +Implemented in: 0.250.076 + +This test ensures contextual-goal and clarification authorization lineage never +enters collaboration storage or collaborator-facing message payloads. +""" + +import json +import sys +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SINGLE_APP_ROOT = REPO_ROOT / 'application' / 'single_app' +sys.path.insert(0, str(SINGLE_APP_ROOT)) + +from collaboration_models import ( # noqa: E402 + build_collaboration_message_doc_from_legacy, +) +from functions_collaboration import ( # noqa: E402 + build_collaboration_message_metadata_payload, + serialize_collaboration_message, +) + + +PRIVATE_VALUES = ( + 'private-source-message-id', + 'private-content-hash', + 'private internal contextual query', + 'private external query', + 'private-response-hash', + 'private trusted resume message', + 'private-clarification-id', + 'private-parent-run', + 'private-child-run', +) + + +def _private_metadata(): + return { + 'capability_proposal': { + 'proposal_id': 'safe-proposal-id', + 'prior_goal_included': True, + 'goal_source_count': 2, + 'goal_display_summary': 'Earlier public-record request', + '_approved_user_turn_goal': { + 'source_user_message_ids': ['private-source-message-id'], + 'source_turn_lineage': [{ + 'message_id': 'private-source-message-id', + 'content_hash': 'private-content-hash', + }], + 'contextual_query': 'private internal contextual query', + 'external_query': 'private external query', + }, + }, + 'capability_resume_request': { + 'message': 'private trusted resume message', + }, + 'chat_clarification': { + 'version': 1, + 'clarification_id': 'private-clarification-id', + 'parent_run_id': 'private-parent-run', + 'code': 'jurisdiction_required', + 'question': 'Which jurisdiction applies?', + 'status': 'resolving', + 'options': ['Virginia'], + '_source_user_message_id': 'private-source-message-id', + '_response_hash': 'private-response-hash', + 'child_run_id': 'private-child-run', + }, + 'clarification_response': { + 'version': 1, + 'code': 'jurisdiction_required', + 'status': 'resolved', + 'response_mode': 'option', + 'parent_run_id': 'private-parent-run', + 'child_run_id': 'private-child-run', + '_clarification_id': 'private-clarification-id', + }, + } + + +def _assert_projected(payload): + serialized = json.dumps(payload, sort_keys=True) + for private_value in PRIVATE_VALUES: + assert private_value not in serialized + assert '_approved_user_turn_goal' not in serialized + assert 'capability_resume_request' not in serialized + assert payload['capability_proposal']['prior_goal_included'] is True + assert payload['chat_clarification']['code'] == 'jurisdiction_required' + assert payload['chat_clarification']['question'] == ( + 'Which jurisdiction applies?' + ) + assert payload['clarification_response'] == { + 'version': 1, + 'code': 'jurisdiction_required', + 'status': 'resolved', + 'response_mode': 'option', + 'idempotent': False, + } + + +def test_legacy_conversion_strips_private_contextual_metadata_before_storage(): + converted = build_collaboration_message_doc_from_legacy( + conversation_id='collaboration-1', + legacy_message={ + 'id': 'legacy-assistant-1', + 'conversation_id': 'personal-1', + 'role': 'assistant', + 'content': 'Choose how to continue.', + 'timestamp': '2026-07-17T12:00:00+00:00', + 'metadata': _private_metadata(), + }, + default_sender_user={ + 'userId': 'owner-1', + 'displayName': 'Owner', + 'email': 'owner@example.com', + }, + ) + + _assert_projected(converted['metadata']) + + +def test_collaboration_serializers_project_existing_private_rows(): + message = { + 'id': 'collaboration-message-1', + 'conversation_id': 'collaboration-1', + 'role': 'assistant', + 'message_kind': 'assistant_response', + 'content': 'Choose how to continue.', + 'timestamp': '2026-07-17T12:00:00+00:00', + 'metadata': { + **_private_metadata(), + 'sender': { + 'user_id': 'assistant', + 'display_name': 'AI', + 'email': '', + }, + }, + } + serialized = serialize_collaboration_message(message) + _assert_projected(serialized['metadata']) + + metadata_payload = build_collaboration_message_metadata_payload( + message, + { + 'id': 'collaboration-1', + 'title': 'Shared conversation', + 'conversation_kind': 'collaborative', + 'chat_type': 'personal_multi_user', + 'participant_count': 2, + 'participants': [], + }, + ) + _assert_projected(metadata_payload['metadata']) diff --git a/functional_tests/test_phase10c_contextual_goals.py b/functional_tests/test_phase10c_contextual_goals.py new file mode 100644 index 00000000..f45056b6 --- /dev/null +++ b/functional_tests/test_phase10c_contextual_goals.py @@ -0,0 +1,619 @@ +#!/usr/bin/env python3 +# test_phase10c_contextual_goals.py +""" +Functional test for Phase 10C bounded contextual goals. +Version: 0.250.076 +Implemented in: 0.250.076 + +This test ensures planning context contains only bounded active user turns, +opaque refs bind to exact documents, and prior-turn external egress remains a +server-authored choice even when retrieval is already selected. +""" + +import copy +import importlib +import sys +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SINGLE_APP_ROOT = REPO_ROOT / 'application' / 'single_app' +sys.path.insert(0, str(SINGLE_APP_ROOT)) + +from functions_chat_capabilities import ( # noqa: E402 + build_contextual_egress_recommendation, + build_governed_capability_inventory, + get_capability_option_revalidation_error, +) +from functions_chat_capability_choices import ( # noqa: E402 + CapabilityChoiceError, + add_sensitive_external_query_options, +) +from functions_chat_contextual_goals import ( # noqa: E402 + load_bounded_prior_user_turns, + planner_prior_user_turns, + read_exact_goal_source_messages, + resolve_planner_goal_source_messages, +) + + +def _message( + message_id, + role, + content, + timestamp, + thread_id, + previous_thread_id='', + *, + active=True, + deleted=False, + masked=False, + masked_ranges=None, + generated_artifact=False, + attempt=1, +): + return { + 'id': message_id, + 'conversation_id': 'conversation-1', + 'role': role, + 'content': content, + 'timestamp': timestamp, + 'metadata': { + 'is_deleted': deleted, + 'masked': masked, + 'masked_ranges': list(masked_ranges or []), + 'is_generated_chat_artifact': generated_artifact, + 'thread_info': { + 'thread_id': thread_id, + 'previous_thread_id': previous_thread_id, + 'thread_attempt': attempt, + 'active_thread': active, + }, + }, + } + + +class _FakeContainer: + def __init__(self, messages): + self.messages = { + message['id']: copy.deepcopy(message) + for message in messages + } + self.query_calls = [] + self.read_calls = [] + + @staticmethod + def _is_active_user(message): + metadata = message.get('metadata') or {} + thread_info = metadata.get('thread_info') or {} + return bool( + message.get('conversation_id') == 'conversation-1' + and message.get('role') == 'user' + and metadata.get('is_deleted') is not True + and metadata.get('masked') is not True + and not (metadata.get('masked_ranges') or []) + and metadata.get('is_generated_chat_artifact') is not True + and thread_info.get('active_thread') is not False + ) + + def query_items(self, *, query, parameters, partition_key): + self.query_calls.append({ + 'query': query, + 'parameters': copy.deepcopy(parameters), + 'partition_key': partition_key, + }) + assert partition_key == 'conversation-1' + parameter_map = { + parameter['name']: parameter['value'] + for parameter in parameters + } + assert parameter_map['@conversation_id'] == 'conversation-1' + candidates = [ + copy.deepcopy(message) + for message in self.messages.values() + if self._is_active_user(message) + ] + thread_id = parameter_map.get('@thread_id') + if thread_id: + candidates = [ + message + for message in candidates + if str( + message.get('metadata', {}).get( + 'thread_info', {} + ).get('thread_id') or '' + ) == thread_id + ] + candidates.sort( + key=lambda message: ( + message['metadata']['thread_info']['thread_attempt'], + message['timestamp'], + ), + reverse=True, + ) + return candidates[:2] + candidates.sort(key=lambda message: message['timestamp'], reverse=True) + return candidates[:2] + + def read_item(self, *, item, partition_key): + self.read_calls.append((item, partition_key)) + assert partition_key == 'conversation-1' + return copy.deepcopy(self.messages[item]) + + +def _context_messages(): + return [ + _message( + 'user-0', + 'user', + 'Find JPMorgan press releases.', + '2026-07-17T10:00:00+00:00', + 'thread-0', + ), + _message( + 'assistant-0', + 'assistant', + 'SECRET assistant URL https://private.example', + '2026-07-17T10:00:01+00:00', + 'thread-0', + ), + _message( + 'user-1-inactive', + 'user', + 'Use an inactive edit.', + '2026-07-17T10:01:00+00:00', + 'thread-1', + 'thread-0', + active=False, + attempt=1, + ), + _message( + 'user-1', + 'user', + 'Use the past three years.', + '2026-07-17T10:01:01+00:00', + 'thread-1', + 'thread-0', + attempt=2, + ), + _message( + 'user-deleted', + 'user', + 'SECRET deleted turn.', + '2026-07-17T10:02:00+00:00', + 'thread-deleted', + 'thread-1', + deleted=True, + ), + _message( + 'user-partially-masked', + 'user', + 'SECRET partially masked turn.', + '2026-07-17T10:02:01+00:00', + 'thread-partial', + 'thread-1', + masked_ranges=[{'start': 0, 'end': 6}], + ), + _message( + 'user-generated-artifact', + 'user', + 'SECRET generated artifact.', + '2026-07-17T10:02:02+00:00', + 'thread-artifact', + 'thread-1', + generated_artifact=True, + ), + ] + + +def test_context_loader_follows_two_active_user_hops_only(): + container = _FakeContainer(_context_messages()) + + state = load_bounded_prior_user_turns( + container, + conversation_id='conversation-1', + ) + + assert [ + message['id'] + for message in state['prior_user_messages'] + ] == ['user-0', 'user-1'] + assert state['predecessor_thread_id'] == 'thread-1' + assert planner_prior_user_turns(state) == [ + {'role': 'user', 'text': 'Find JPMorgan press releases.'}, + {'role': 'user', 'text': 'Use the past three years.'}, + ] + assert len(container.query_calls) == 2 + assert all( + call['partition_key'] == 'conversation-1' + and '@conversation_id' in { + parameter['name'] + for parameter in call['parameters'] + } + and 'c.role = "user"' in call['query'] + for call in container.query_calls + ) + + +def test_context_loader_falls_back_past_one_malformed_latest_response(): + for malformed_response in ( + _message( + 'user-response-empty', + 'user', + '', + '2026-07-17T10:02:00+00:00', + 'thread-response', + 'thread-1', + ), + { + **_message( + 'user-response-no-thread', + 'user', + 'Virginia', + '2026-07-17T10:02:00+00:00', + 'thread-response', + 'thread-1', + ), + 'metadata': {}, + }, + ): + container = _FakeContainer([ + *_context_messages(), + malformed_response, + ]) + + state = load_bounded_prior_user_turns( + container, + conversation_id='conversation-1', + ) + + assert [ + message['id'] + for message in state['prior_user_messages'] + ] == ['user-0', 'user-1'] + assert state['predecessor_thread_id'] == 'thread-1' + assert 'SELECT TOP 2 * FROM c' in container.query_calls[0]['query'] + + +def test_context_loader_rejects_ambiguous_active_attempts(): + messages = _context_messages() + messages.append(_message( + 'user-0-conflict', + 'user', + 'Conflicting active attempt.', + '2026-07-17T10:00:02+00:00', + 'thread-0', + attempt=2, + )) + container = _FakeContainer(messages) + + try: + load_bounded_prior_user_turns( + container, + conversation_id='conversation-1', + ) + raise AssertionError('ambiguous active attempts must fail closed') + except CapabilityChoiceError as exc: + assert exc.code == 'goal_source_thread_ambiguous' + + +def test_opaque_goal_refs_bind_to_exact_request_local_documents(): + container = _FakeContainer(_context_messages()) + state = load_bounded_prior_user_turns( + container, + conversation_id='conversation-1', + ) + current_message = _message( + 'user-2', + 'user', + 'Yes, search.', + '2026-07-17T10:03:00+00:00', + 'thread-2', + 'thread-1', + ) + planner_request = { + 'dialogue_context': [ + {'ref': 'turn_0', 'role': 'user', 'text': 'Find JPMorgan press releases.'}, + {'ref': 'turn_1', 'role': 'user', 'text': 'Use the past three years.'}, + {'ref': 'turn_2', 'role': 'user', 'text': 'Yes, search.'}, + ], + } + planner_result = { + 'goal_turn_refs': ['turn_0', 'turn_2'], + } + + resolved = resolve_planner_goal_source_messages( + planner_request, + planner_result, + state, + current_message, + ) + + assert [message['id'] for message in resolved] == ['user-0', 'user-2'] + stored_goal = { + 'source_user_message_ids': ['user-0', 'user-2'], + } + container.messages['user-2'] = current_message + reread = read_exact_goal_source_messages( + container, + conversation_id='conversation-1', + stored_goal=stored_goal, + ) + assert [message['id'] for message in reread] == ['user-0', 'user-2'] + assert container.read_calls == [ + ('user-0', 'conversation-1'), + ('user-2', 'conversation-1'), + ] + + +def test_clarification_linked_prior_ref_retains_exact_response_turn(): + container = _FakeContainer(_context_messages()) + state = load_bounded_prior_user_turns( + container, + conversation_id='conversation-1', + ) + current_message = _message( + 'user-2', + 'user', + 'Virginia and Fairfax County', + '2026-07-17T10:03:00+00:00', + 'thread-2', + 'thread-1', + ) + resolved = resolve_planner_goal_source_messages( + { + 'dialogue_context': [ + { + 'ref': 'turn_0', + 'role': 'user', + 'text': 'Find JPMorgan press releases.', + }, + { + 'ref': 'turn_1', + 'role': 'user', + 'text': 'Use the past three years.', + }, + { + 'ref': 'turn_2', + 'role': 'user', + 'text': 'Virginia and Fairfax County', + }, + ], + 'structured_state': { + 'type': 'clarification', + 'source_goal_ref': 'turn_0', + 'status': 'resolved', + 'code': 'jurisdiction_required', + }, + }, + {'goal_turn_refs': ['turn_0']}, + state, + current_message, + ) + + assert [message['id'] for message in resolved] == ['user-0', 'user-2'] + + +def _selected_web_inventory(): + resolved = { + capability_id: { + 'enabled': True, + 'available': True, + 'authorized': True, + 'governance_mode': 'recommend', + } + for capability_id in ( + 'workspace_search', + 'analyze', + 'compare', + 'image', + 'web_search', + 'url_access', + 'deep_research', + ) + } + return build_governed_capability_inventory( + selected_capability_ids=['web_search'], + resolved_capabilities=resolved, + ) + + +def test_selected_external_capability_gets_context_only_egress_choice(): + inventory = _selected_web_inventory() + recommendation = build_contextual_egress_recommendation( + { + 'status': 'valid', + 'decision': 'direct', + 'prior_goal_included': True, + 'requirements': [], + }, + inventory, + {'selected_capability_ids': ['web_search']}, + ) + + option = recommendation['options'][0] + assert option['kind'] == 'context' + assert option['capability_ids'] == [] + assert option['effective_capability_ids'] == ['web_search'] + assert option['external_data'] is True + assert option['id'].startswith('context:') + assert get_capability_option_revalidation_error(option, inventory) is None + + changed_option = copy.deepcopy(option) + changed_option['cost_class'] = 'none' + assert get_capability_option_revalidation_error( + changed_option, + inventory, + ) == 'capability_plan_policy_changed' + + sensitive = add_sensitive_external_query_options( + recommendation, + 'Search parcel records for 1234 Main Street, Fairfax VA 22030.', + ) + sensitive_option = next( + candidate + for candidate in sensitive['options'] + if candidate['id'].endswith('_with_sensitive_inputs') + ) + assert sensitive_option['kind'] == 'context' + assert sensitive_option['sensitive_input_types'] == ['street_address'] + assert get_capability_option_revalidation_error( + sensitive_option, + inventory, + ) is None + + +def test_route_wiring_separates_contextual_and_external_queries_and_projects_metadata(): + chat_route = (SINGLE_APP_ROOT / 'route_backend_chats.py').read_text( + encoding='utf-8' + ) + backend_conversations = ( + SINGLE_APP_ROOT / 'route_backend_conversations.py' + ).read_text(encoding='utf-8') + frontend_conversations = ( + SINGLE_APP_ROOT / 'route_frontend_conversations.py' + ).read_text(encoding='utf-8') + + assert "request_data['_server_contextual_goal_query']" in chat_route + assert "request_data['_server_external_query']" in chat_route + assert "data.get('_server_contextual_goal_query')" in chat_route + assert "approved_user_turn_goal.get('contextual_query')" in chat_route + assert "approved_user_turn_goal.get('external_query')" in chat_route + assert 'contextual_external_execution = bool(' in chat_route + assert 'contextual_url_ref_missing = bool(' in chat_route + assert 'if contextual_url_ref_missing:' in chat_route + assert "'execution_effective_capability_ids'" in chat_route + assert chat_route.index('contextual_external_execution = bool(') < ( + chat_route.index('if web_search_enabled:', chat_route.index( + 'contextual_external_execution = bool(' + )) + ) + assert chat_route.count('project_chat_metadata_for_client(') >= 8 + for raw_terminal_metadata in ( + "'metadata': assistant_doc.get('metadata', {}),", + "'metadata': payload.get('metadata', {}),", + "'metadata': safety_doc.get('metadata', {}),", + ): + assert raw_terminal_metadata not in chat_route + assert backend_conversations.count('_project_message_for_client(') >= 2 + assert frontend_conversations.count('_project_message_for_client(') >= 3 + assert 'project_chat_metadata_for_client(' in frontend_conversations + context_source = ( + SINGLE_APP_ROOT / 'functions_chat_contextual_goals.py' + ).read_text(encoding='utf-8') + exact_thread_query = context_source[ + context_source.index('def _read_active_user_turn_for_thread('): + context_source.index('def load_bounded_prior_user_turns(') + ] + assert 'ORDER BY' not in exact_thread_query + child_output_helper = chat_route[ + chat_route.index('def _find_persisted_clarification_child_output('): + chat_route.index('def _distinct_authorized_document_ids(') + ] + assert 'ORDER BY' not in child_output_helper + retry_route = backend_conversations[ + backend_conversations.index('def retry_message(message_id):'): + backend_conversations.index('def edit_message(message_id):') + ] + edit_route = backend_conversations[ + backend_conversations.index('def edit_message(message_id):'): + backend_conversations.index('def switch_attempt(message_id):') + ] + for route_source, request_id_field in ( + (retry_route, "'retry_user_message_id'"), + (edit_route, "'edited_user_message_id'"), + ): + validation_index = route_source.index( + '_validate_linked_clarification_retry(' + ) + deactivation_index = route_source.index( + "msg['metadata']['thread_info']['active_thread'] = False" + ) + clone_index = route_source.index( + 'new_user_message_id = ' + ) + assert validation_index < deactivation_index < clone_index + validated_branch = route_source[validation_index:deactivation_index] + assert request_id_field in validated_branch + assert "clarification_retry['response_user_message_id']" in ( + validated_branch + ) + + +def test_url_access_readiness_can_use_bounded_prior_user_url(monkeypatch): + route_backend_chats = importlib.import_module('route_backend_chats') + monkeypatch.setattr( + route_backend_chats, + 'normalize_capability_governance_modes', + lambda settings: { + capability_id: 'recommend' + for capability_id in ( + 'workspace_search', + 'analyze', + 'compare', + 'image', + 'web_search', + 'url_access', + 'deep_research', + ) + }, + ) + monkeypatch.setattr( + route_backend_chats, + 'get_enabled_document_action_types', + lambda settings=None: [], + ) + monkeypatch.setattr( + route_backend_chats, + 'is_url_access_enabled_for_user', + lambda *args, **kwargs: True, + ) + monkeypatch.setattr( + route_backend_chats, + 'is_source_review_enabled_for_user', + lambda *args, **kwargs: False, + ) + monkeypatch.setattr( + route_backend_chats, + 'image_generation_is_enabled', + lambda settings: False, + ) + monkeypatch.setattr( + route_backend_chats, + '_web_search_capability_is_configured', + lambda settings: False, + ) + + inventory = route_backend_chats._resolve_server_chat_capability_inventory( + settings={'enable_url_access': True}, + user_id='user-1', + user_email='user@example.com', + user_roles=[], + user_message=( + 'Review https://example.com/authorized-source\nReview that source.' + ), + selected_capability_ids=[], + ) + current_only_inventory = ( + route_backend_chats._resolve_server_chat_capability_inventory( + settings={'enable_url_access': True}, + user_id='user-1', + user_email='user@example.com', + user_roles=[], + user_message='Review that source.', + selected_capability_ids=[], + ) + ) + + url_access = next( + entry + for entry in inventory['capabilities'] + if entry['id'] == 'url_access' + ) + current_only_url_access = next( + entry + for entry in current_only_inventory['capabilities'] + if entry['id'] == 'url_access' + ) + assert url_access['input_ready'] is True + assert current_only_url_access['input_ready'] is False diff --git a/functional_tests/test_phase9_orchestration_observability.py b/functional_tests/test_phase9_orchestration_observability.py index 6a6e790d..1861f57a 100644 --- a/functional_tests/test_phase9_orchestration_observability.py +++ b/functional_tests/test_phase9_orchestration_observability.py @@ -2,8 +2,8 @@ # test_phase9_orchestration_observability.py """ Functional test for Phase 9 orchestration observability and privacy. -Version: 0.250.069 -Implemented in: 0.250.068 +Version: 0.250.076 +Implemented in: 0.250.068; contextual lifecycle added in 0.250.076 This test ensures evaluation events expose only bounded aggregate run, recommendation, latency, and citation-yield fields without private payloads. @@ -20,6 +20,7 @@ sys.path.insert(0, str(SINGLE_APP_ROOT)) from functions_orchestration_evaluation import ( # noqa: E402 + build_clarification_evaluation_event, build_orchestration_run_evaluation_event, build_planner_completed_evaluation_event, build_planner_rejected_evaluation_event, @@ -217,6 +218,10 @@ def test_planner_events_use_fixed_privacy_safe_dimensions(): 'latency_ms': 412, 'fallback_used': False, 'raw_response': 'private evidence text', + 'eligible_goal_turn_count': 999, + 'selected_goal_turn_count': 2, + 'prior_goal_included': True, + 'clarification_code': 'jurisdiction_required', } completed = build_planner_completed_evaluation_event( 'private-planner-run-id', @@ -243,6 +248,10 @@ def test_planner_events_use_fixed_privacy_safe_dimensions(): assert completed['model_class'] == 'other' assert completed['capability_classes'] == ['web_search', 'governed_agent'] assert completed['reason_codes'] == ['public_source_archive_research'] + assert completed['eligible_goal_turn_count'] == 3 + assert completed['selected_goal_turn_count'] == 2 + assert completed['prior_goal_included'] is True + assert completed['clarification_code'] == 'jurisdiction_required' assert compared['event_type'] == 'orchestration_planner_shadow_compared' assert compared['agreement_category'] == 'decision_disagreement' _assert_private_values_absent(completed) @@ -286,5 +295,32 @@ def test_planner_rejection_and_timeout_events_expose_only_bounded_failures(): _assert_private_values_absent(timed_out) +def test_clarification_event_excludes_question_answer_and_source_lineage(): + event = build_clarification_evaluation_event( + { + 'parent_run_id': 'private-planner-run-id', + 'clarification_id': 'canonical-agent-private-id', + 'code': 'jurisdiction_required', + 'question': 'private prompt text', + 'status': 'resolved', + 'options': ['private-group-name', 'Virginia'], + 'response_mode': 'free_text', + '_source_user_message_id': 'canonical-agent-private-id', + '_response_hash': 'private-secret-value', + }, + lifecycle='resolved', + idempotent=True, + ) + + assert event['event_type'] == 'orchestration_clarification_lifecycle' + assert event['parent_run_correlation_id'] != 'private-planner-run-id' + assert event['lifecycle'] == 'resolved' + assert event['clarification_code'] == 'jurisdiction_required' + assert event['option_count'] == 2 + assert event['response_mode'] == 'free_text' + assert event['idempotent'] is True + _assert_private_values_absent(event) + + if __name__ == '__main__': raise SystemExit(0) \ No newline at end of file diff --git a/scripts/run_phase9_orchestration_quality_gates.py b/scripts/run_phase9_orchestration_quality_gates.py index cb345f27..c1881d32 100644 --- a/scripts/run_phase9_orchestration_quality_gates.py +++ b/scripts/run_phase9_orchestration_quality_gates.py @@ -1,5 +1,5 @@ # run_phase9_orchestration_quality_gates.py -"""Run deterministic Phase 9/10A/10B and optional live orchestration gates.""" +"""Run deterministic Phase 9/10A/10B/10C and optional live orchestration gates.""" import argparse import os @@ -10,26 +10,36 @@ REPO_ROOT = Path(__file__).resolve().parents[1] COMPILE_TARGETS = ( + 'application/single_app/collaboration_models.py', + 'application/single_app/functions_collaboration.py', 'application/single_app/functions_chat_capability_planner.py', 'application/single_app/functions_chat_capabilities.py', 'application/single_app/functions_chat_capability_choices.py', 'application/single_app/functions_chat_capability_persistence.py', + 'application/single_app/functions_chat_clarifications.py', + 'application/single_app/functions_chat_contextual_goals.py', 'application/single_app/functions_orchestration_evaluation.py', 'application/single_app/functions_orchestration_runtime.py', 'application/single_app/functions_settings.py', 'application/single_app/model_endpoint_clients.py', 'application/single_app/route_backend_chats.py', + 'application/single_app/route_backend_conversations.py', + 'application/single_app/route_frontend_conversations.py', 'application/single_app/route_frontend_admin_settings.py', 'scripts/run_phase10a_controlled_shadow.py', ) BROWSER_XSS_TARGETS = ( 'application/single_app/static/js/chat/chat-capability-choice.js', + 'application/single_app/static/js/chat/chat-clarification.js', ) AUTOMATED_TEST_TARGETS = ( 'functional_tests/test_chat_capability_model_planner.py', 'functional_tests/test_chat_capability_planner_route.py', 'functional_tests/test_phase10a_controlled_shadow_runner.py', 'functional_tests/test_phase10b_governed_additive_plan_activation.py', + 'functional_tests/test_phase10c_contextual_goals.py', + 'functional_tests/test_phase10c_collaboration_metadata_projection.py', + 'functional_tests/test_chat_clarification_persistence.py', 'functional_tests/test_phase9_orchestration_golden_scenarios.py', 'functional_tests/test_phase9_orchestration_observability.py', 'functional_tests/test_chat_turn_orchestration_plan.py', @@ -65,6 +75,7 @@ 'functional_tests/route_tests/test_route_unauthenticated_policy_contract.py', 'functional_tests/route_tests/test_route_policy_test_coverage.py', 'ui_tests/test_chat_capability_choice_card.py', + 'ui_tests/test_chat_clarification.py', 'ui_tests/test_admin_capability_planner_settings.py', 'ui_tests/test_phase9_orchestration_live_smoke.py', ) @@ -111,7 +122,7 @@ def _validate_live_environment(environment): def build_argument_parser(): parser = argparse.ArgumentParser( - description='Run Phase 9/10A/10B orchestration compile, functional, security, UI-contract, and optional live-smoke gates.', + description='Run Phase 9/10A/10B/10C orchestration compile, functional, security, UI-contract, and optional live-smoke gates.', ) parser.add_argument( '--live-smoke', diff --git a/ui_tests/test_admin_capability_planner_settings.py b/ui_tests/test_admin_capability_planner_settings.py index 678e187f..73c8417e 100644 --- a/ui_tests/test_admin_capability_planner_settings.py +++ b/ui_tests/test_admin_capability_planner_settings.py @@ -1,11 +1,11 @@ # test_admin_capability_planner_settings.py """Azure Playwright UI tests for governed capability planner settings. -Version: 0.250.073 -Implemented in: 0.250.072; enhanced controls added in 0.250.073 +Version: 0.250.077 +Implemented in: 0.250.072; dependent model selectors added in 0.250.077 This test verifies that administrators can select off, shadow, or assist mode -and understand and edit bounded planner controls without layout overflow. +and choose a valid global endpoint/model pair without layout overflow. """ import os @@ -31,10 +31,21 @@ def test_capability_planner_control_source_contract(): 'admin', 'admin_settings.js', ) + endpoint_script_path = os.path.join( + repo_root, + 'application', + 'single_app', + 'static', + 'js', + 'admin', + 'admin_model_endpoints.js', + ) with open(template_path, encoding='utf-8') as template_file: template_source = template_file.read() with open(script_path, encoding='utf-8') as script_file: script_source = script_file.read() + with open(endpoint_script_path, encoding='utf-8') as endpoint_script_file: + endpoint_script_source = endpoint_script_file.read() planner_section = template_source.split( 'id="chat-capability-planner-section"', @@ -51,10 +62,28 @@ def test_capability_planner_control_source_contract(): assert 'range(1, 7)' in planner_section assert 'id="chat_capability_planner_max_capabilities_per_plan"' in planner_section assert 'min="1" max="8"' in planner_section + assert '