diff --git a/application/single_app/config.py b/application/single_app/config.py index 7cd617af2..b84e4bce5 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -97,7 +97,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.250.062" +VERSION = "0.250.064" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/functions_group_workflows.py b/application/single_app/functions_group_workflows.py index 8afd1ede0..c015c1c46 100644 --- a/application/single_app/functions_group_workflows.py +++ b/application/single_app/functions_group_workflows.py @@ -39,6 +39,8 @@ _normalize_document_action_config, _normalize_schedule, _normalize_text, + _normalize_workflow_error_handling, + _normalize_workflow_tasks, _strip_cosmos_metadata, _utc_now_iso, compute_next_run_at, @@ -381,7 +383,12 @@ def save_group_workflow(group_id, workflow_data, actor_user_id, user_info=None): workflow_name = _normalize_text(workflow_data.get('name'), 'Workflow name', required=True) description = _normalize_text(workflow_data.get('description'), 'Description') - task_prompt = _normalize_text(workflow_data.get('task_prompt'), 'Task prompt', required=True) + tasks = _normalize_workflow_tasks(workflow_data, existing_workflow=existing_workflow) + task_prompt = _normalize_text( + workflow_data.get('task_prompt') or (tasks[0].get('instructions') if tasks else ''), + 'Task prompt', + required=True, + ) runner_type = _normalize_text(workflow_data.get('runner_type'), 'Runner type', required=True).lower() if runner_type not in WORKFLOW_RUNNER_TYPES: raise ValueError('Runner type must be agent or model.') @@ -408,6 +415,16 @@ def save_group_workflow(group_id, workflow_data, actor_user_id, user_info=None): alert_priority = _normalize_alert_priority( workflow_data.get('alert_priority', (existing_workflow or {}).get('alert_priority', 'none')) ) + error_handling = _normalize_workflow_error_handling(workflow_data, existing_workflow=existing_workflow) + default_chat_capabilities_enabled = ( + (existing_workflow or {}).get('chat_capabilities_enabled', False) + if existing_workflow + else True + ) + chat_capabilities_enabled = _normalize_bool( + workflow_data.get('chat_capabilities_enabled', default_chat_capabilities_enabled), + default=default_chat_capabilities_enabled, + ) file_sync = _normalize_file_sync_config( actor_user_id, group_id, @@ -465,7 +482,10 @@ def save_group_workflow(group_id, workflow_data, actor_user_id, user_info=None): 'name': workflow_name, 'description': description, 'task_prompt': task_prompt, + 'tasks': tasks, + 'error_handling': error_handling, 'runner_type': runner_type, + 'chat_capabilities_enabled': chat_capabilities_enabled, 'trigger_type': trigger_type, 'is_enabled': is_enabled, 'url_access_enabled': url_access_enabled, diff --git a/application/single_app/functions_personal_workflows.py b/application/single_app/functions_personal_workflows.py index b95f1a820..bb0918e0f 100644 --- a/application/single_app/functions_personal_workflows.py +++ b/application/single_app/functions_personal_workflows.py @@ -45,6 +45,10 @@ WORKFLOW_FILE_SYNC_WAIT_MODES = {'complete', 'queued'} WORKFLOW_FILE_SYNC_CONTINUE_MODES = {'always', 'changed'} WORKFLOW_FILE_SYNC_MAX_SOURCES = 10 +WORKFLOW_ERROR_STRATEGIES = {'halt', 'continue'} +WORKFLOW_MAX_TASKS = 20 +WORKFLOW_TASK_INSTRUCTIONS_MAX_LENGTH = 12000 +WORKFLOW_TASK_NAME_MAX_LENGTH = 120 WORKFLOW_CONVERSATION_ACCESS_ERROR = 'Workflow conversation not found or access denied.' @@ -134,6 +138,79 @@ def _normalize_alert_priority(value): return normalized +def _normalize_workflow_tasks(workflow_data, existing_workflow=None): + workflow_data = workflow_data if isinstance(workflow_data, dict) else {} + existing_workflow = existing_workflow if isinstance(existing_workflow, dict) else {} + if 'tasks' not in workflow_data: + return list(existing_workflow.get('tasks') or []) if existing_workflow else [] + + raw_tasks = workflow_data.get('tasks') + if not isinstance(raw_tasks, list): + raise ValueError('Workflow tasks must be a list.') + if not raw_tasks: + raise ValueError('Add at least one workflow task.') + if len(raw_tasks) > WORKFLOW_MAX_TASKS: + raise ValueError(f'Workflows support up to {WORKFLOW_MAX_TASKS} tasks.') + + normalized_tasks = [] + seen_task_ids = set() + for index, raw_task in enumerate(raw_tasks): + if not isinstance(raw_task, dict): + raise ValueError(f'Workflow task {index + 1} is invalid.') + + task_type = _normalize_text(raw_task.get('type') or 'instructions', 'Task type').lower() + if task_type != 'instructions': + raise ValueError(f'Workflow task {index + 1} has an unsupported type.') + + task_id = _normalize_text(raw_task.get('id'), 'Task id') or str(uuid.uuid4()) + if task_id in seen_task_ids: + raise ValueError('Workflow task ids must be unique.') + seen_task_ids.add(task_id) + + name = _normalize_text(raw_task.get('name'), 'Task name') or f'Task {index + 1}' + if len(name) > WORKFLOW_TASK_NAME_MAX_LENGTH: + raise ValueError(f'Task name must be {WORKFLOW_TASK_NAME_MAX_LENGTH} characters or fewer.') + + instructions = _normalize_text(raw_task.get('instructions'), 'Task instructions', required=True) + if len(instructions) > WORKFLOW_TASK_INSTRUCTIONS_MAX_LENGTH: + raise ValueError( + f'Task instructions must be {WORKFLOW_TASK_INSTRUCTIONS_MAX_LENGTH} characters or fewer.' + ) + + normalized_tasks.append({ + 'id': task_id, + 'type': task_type, + 'name': name, + 'instructions': instructions, + 'order': index + 1, + }) + + return normalized_tasks + + +def _normalize_workflow_error_handling(workflow_data, existing_workflow=None): + workflow_data = workflow_data if isinstance(workflow_data, dict) else {} + existing_workflow = existing_workflow if isinstance(existing_workflow, dict) else {} + existing_config = existing_workflow.get('error_handling') if isinstance(existing_workflow.get('error_handling'), dict) else {} + raw_config = workflow_data.get('error_handling') if isinstance(workflow_data.get('error_handling'), dict) else existing_config + + strategy = _normalize_text(raw_config.get('strategy') or 'halt', 'Error strategy').lower() + if strategy not in WORKFLOW_ERROR_STRATEGIES: + raise ValueError('Error strategy must be halt or continue.') + + try: + retry_count = int(raw_config.get('retry_count', 0)) + except (TypeError, ValueError) as exc: + raise ValueError('Task retry count must be an integer.') from exc + if retry_count < 0 or retry_count > 5: + raise ValueError('Task retry count must be between 0 and 5.') + + return { + 'strategy': strategy, + 'retry_count': retry_count, + } + + def _normalize_document_action_config(workflow_data, existing_workflow=None, allow_empty_file_sync_targets=False): workflow_data = workflow_data if isinstance(workflow_data, dict) else {} existing_workflow = existing_workflow if isinstance(existing_workflow, dict) else {} @@ -518,7 +595,12 @@ def save_personal_workflow(user_id, workflow_data, actor_user_id=None): workflow_name = _normalize_text(workflow_data.get('name'), 'Workflow name', required=True) description = _normalize_text(workflow_data.get('description'), 'Description') - task_prompt = _normalize_text(workflow_data.get('task_prompt'), 'Task prompt', required=True) + tasks = _normalize_workflow_tasks(workflow_data, existing_workflow=existing_workflow) + task_prompt = _normalize_text( + workflow_data.get('task_prompt') or (tasks[0].get('instructions') if tasks else ''), + 'Task prompt', + required=True, + ) runner_type = _normalize_text(workflow_data.get('runner_type'), 'Runner type', required=True).lower() if runner_type not in WORKFLOW_RUNNER_TYPES: raise ValueError('Runner type must be agent or model.') @@ -545,6 +627,16 @@ def save_personal_workflow(user_id, workflow_data, actor_user_id=None): alert_priority = _normalize_alert_priority( workflow_data.get('alert_priority', (existing_workflow or {}).get('alert_priority', 'none')) ) + error_handling = _normalize_workflow_error_handling(workflow_data, existing_workflow=existing_workflow) + default_chat_capabilities_enabled = ( + (existing_workflow or {}).get('chat_capabilities_enabled', False) + if existing_workflow + else True + ) + chat_capabilities_enabled = _normalize_bool( + workflow_data.get('chat_capabilities_enabled', default_chat_capabilities_enabled), + default=default_chat_capabilities_enabled, + ) file_sync = _normalize_file_sync_config(user_id, workflow_data, existing_workflow=existing_workflow) allow_empty_file_sync_targets = bool(file_sync.get('enabled') and file_sync.get('use_changed_documents')) document_action = _normalize_document_action_config( @@ -593,7 +685,10 @@ def save_personal_workflow(user_id, workflow_data, actor_user_id=None): 'name': workflow_name, 'description': description, 'task_prompt': task_prompt, + 'tasks': tasks, + 'error_handling': error_handling, 'runner_type': runner_type, + 'chat_capabilities_enabled': chat_capabilities_enabled, 'trigger_type': trigger_type, 'is_enabled': is_enabled, 'url_access_enabled': url_access_enabled, diff --git a/application/single_app/functions_workflow_runner.py b/application/single_app/functions_workflow_runner.py index add9737a2..5c20a086e 100644 --- a/application/single_app/functions_workflow_runner.py +++ b/application/single_app/functions_workflow_runner.py @@ -24,6 +24,9 @@ from flask import Flask, g, has_request_context, session from openai import AzureOpenAI from semantic_kernel import Kernel +from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior +from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings +from semantic_kernel.contents.chat_history import ChatHistory from semantic_kernel.contents.chat_message_content import ChatMessageContent from collaboration_models import ( @@ -87,7 +90,10 @@ from model_endpoint_clients import ( MODEL_ENDPOINT_PROTOCOL_AZURE_OPENAI, ) -from functions_model_endpoint_runtime import build_model_endpoint_sync_chat_client +from functions_model_endpoint_runtime import ( + build_model_endpoint_sync_chat_client, + build_semantic_kernel_chat_service_for_model, +) from functions_notifications import create_workflow_priority_notification from functions_personal_workflows import ( get_personal_workflow, @@ -108,7 +114,11 @@ validate_url_access_request, ) from functions_thoughts import ThoughtTracker -from semantic_kernel_loader import load_user_semantic_kernel +from semantic_kernel_loader import ( + get_max_auto_invoke_attempts, + load_core_plugins_only, + load_user_semantic_kernel, +) from semantic_kernel_plugins.plugin_invocation_logger import get_plugin_logger, sanitize_plugin_invocation_value from semantic_kernel_plugins.plugin_invocation_thoughts import register_plugin_invocation_thought_callback @@ -122,6 +132,7 @@ TABULAR_DOCUMENT_EXTENSIONS = {'.csv', '.xls', '.xlsx', '.xlsm'} WORKFLOW_CONVERSATION_ACCESS_ERROR = 'Workflow conversation not found or access denied.' WORKFLOW_RUN_CANCELLED_MESSAGE = 'Workflow cancellation was requested.' +WORKFLOW_TASK_CONTEXT_MAX_CHARS = 12000 class WorkflowRunCancelledError(BaseException): @@ -4339,6 +4350,7 @@ def _apply_file_sync_context_to_workflow(workflow, file_sync_result): file_sync_context = _format_workflow_file_sync_context(file_sync_result) if file_sync_context: prepared_workflow['task_prompt'] = f"{workflow.get('task_prompt', '')}\n\n{file_sync_context}".strip() + prepared_workflow['file_sync_prompt_context'] = file_sync_context config = _get_workflow_file_sync_config(workflow) changed_document_ids = list(file_sync_result.get('changed_document_ids') or []) @@ -4819,7 +4831,7 @@ def _combine_per_document_analysis_results(document_results): } -def _execute_model_workflow(workflow, settings, run_id=None, thought_tracker=None, url_access_context=None): +def _execute_raw_model_workflow(workflow, settings, run_id=None, thought_tracker=None, url_access_context=None): _raise_if_workflow_run_cancelled(workflow, run_id) if thought_tracker and run_id: _add_workflow_activity_thought( @@ -4874,6 +4886,218 @@ def _execute_model_workflow(workflow, settings, run_id=None, thought_tracker=Non } +def _workflow_model_chat_capabilities_enabled(workflow): + value = (workflow or {}).get('chat_capabilities_enabled', False) + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in {'1', 'true', 'yes', 'on'} + return bool(value) + + +def _build_workflow_model_context(workflow, deployment_name, provider): + workflow = workflow if isinstance(workflow, dict) else {} + binding_summary = workflow.get('model_binding_summary') if isinstance(workflow.get('model_binding_summary'), dict) else {} + endpoint_id = str(workflow.get('model_endpoint_id') or binding_summary.get('endpoint_id') or '').strip() + model_id = str(workflow.get('model_id') or binding_summary.get('model_id') or '').strip() + model_context = { + 'user_id': str(workflow.get('user_id') or '').strip(), + 'model_deployment': str(deployment_name or '').strip(), + 'provider': str(provider or workflow.get('model_provider') or binding_summary.get('provider') or '').strip().lower(), + } + if endpoint_id: + model_context['endpoint_id'] = endpoint_id + if model_id: + model_context['model_id'] = model_id + + group_id = _get_workflow_group_id(workflow) + if group_id: + model_context['active_group_ids'] = [group_id] + + return model_context + + +@contextmanager +def _workflow_model_core_execution_context(workflow, conversation_id, run_id): + workflow = workflow if isinstance(workflow, dict) else {} + user_id = str(workflow.get('user_id') or '').strip() + group_id = _get_workflow_group_id(workflow) + context_values = { + 'conversation_id': str(conversation_id or '').strip(), + 'workflow_id': str(workflow.get('id') or '').strip(), + 'workflow_run_id': str(run_id or '').strip(), + 'conversation_group_id': group_id, + 'authorized_chat_context': None, + } + if group_id: + context_values['authorized_chat_context'] = { + 'user_id': user_id, + 'conversation_id': str(conversation_id or '').strip(), + 'active_group_ids': [group_id], + 'active_group_id': group_id, + 'active_public_workspace_ids': [], + 'active_public_workspace_id': None, + 'fact_memory_scope_id': group_id, + 'fact_memory_scope_type': 'group', + } + + with _ensure_execution_context(user_id): + previous_values = { + name: (hasattr(g, name), getattr(g, name, None)) + for name in context_values + } + for name, value in context_values.items(): + setattr(g, name, value) + + try: + yield + finally: + for name, (was_defined, value) in previous_values.items(): + if was_defined: + setattr(g, name, value) + elif hasattr(g, name): + delattr(g, name) + + +def _execute_model_workflow_with_core_capabilities( + workflow, + settings, + conversation_id='', + run_id=None, + thought_tracker=None, + url_access_context=None, +): + _raise_if_workflow_run_cancelled(workflow, run_id) + user_id = str(workflow.get('user_id') or '').strip() + if not user_id: + raise ValueError('Direct model workflows require an owning user.') + + if thought_tracker and run_id: + _add_workflow_activity_thought( + thought_tracker, + workflow, + run_id, + step_type='generation', + content='Starting direct model execution with core capabilities', + detail=None, + activity_key=f'generation:{run_id}', + kind='model_execution', + title='Model execution', + status='running', + ) + + _, deployment_name, provider = _resolve_model_workflow_client(workflow, settings) + model_context = _build_workflow_model_context(workflow, deployment_name, provider) + workflow_kernel_settings = get_workflow_kernel_settings(settings) + + with _workflow_model_core_execution_context(workflow, conversation_id, run_id): + plugin_logger = get_plugin_logger() + callback_key = None + if conversation_id: + plugin_logger.clear_invocations_for_conversation(user_id, conversation_id) + if thought_tracker and run_id and conversation_id: + callback_key = register_plugin_invocation_thought_callback( + plugin_logger, + thought_tracker, + user_id, + conversation_id, + actor_label='Workflow model', + ) + + try: + kernel = Kernel() + load_core_plugins_only(kernel, workflow_kernel_settings) + chat_service, _ = build_semantic_kernel_chat_service_for_model( + deployment_name, + settings, + service_id=f"workflow-model-{workflow.get('id') or 'default'}", + model_context=model_context, + ) + kernel.add_service(chat_service) + + chat_history = ChatHistory() + for message in _build_workflow_chat_messages( + workflow.get('task_prompt', ''), + url_access_context=url_access_context, + apply_generation_guidance=True, + ): + chat_history.add_message(message) + + execution_settings = PromptExecutionSettings() + execution_settings.function_choice_behavior = FunctionChoiceBehavior.Auto( + maximum_auto_invoke_attempts=get_max_auto_invoke_attempts(workflow_kernel_settings) + ) + completion_messages = _execute_cancelable_workflow_step( + workflow, + run_id, + lambda: asyncio.run( + chat_service.get_chat_message_contents( + chat_history, + execution_settings, + kernel=kernel, + ) + ), + ) + reply = _extract_message_text( + completion_messages[0].content if completion_messages else '' + ) + agent_citations = _build_agent_citations_from_invocations(user_id, conversation_id) + alert_targets = _collect_agent_alert_targets(user_id, conversation_id) + finally: + if callback_key: + plugin_logger.deregister_callbacks(callback_key) + + if thought_tracker and run_id: + _add_workflow_activity_thought( + thought_tracker, + workflow, + run_id, + step_type='generation', + content=f'Direct model execution completed with {deployment_name}', + detail=f'provider={provider}; core_capabilities=true', + activity_key=f'generation:{run_id}', + kind='model_execution', + title='Model execution', + status='completed', + ) + + return { + 'reply': reply, + 'model_deployment_name': deployment_name, + 'provider': provider, + 'agent_citations': agent_citations, + 'alert_targets': alert_targets, + 'core_capabilities_enabled': True, + } + + +def _execute_model_workflow( + workflow, + settings, + conversation_id='', + run_id=None, + thought_tracker=None, + url_access_context=None, +): + if _workflow_model_chat_capabilities_enabled(workflow): + return _execute_model_workflow_with_core_capabilities( + workflow, + settings, + conversation_id=conversation_id, + run_id=run_id, + thought_tracker=thought_tracker, + url_access_context=url_access_context, + ) + + return _execute_raw_model_workflow( + workflow, + settings, + run_id=run_id, + thought_tracker=thought_tracker, + url_access_context=url_access_context, + ) + + def _execute_document_analysis_workflow( workflow, settings, @@ -5731,6 +5955,405 @@ def _execute_agent_workflow(workflow, settings, conversation_id='', run_id=None, g.authorized_chat_context = previous_authorized_chat_context +def _truncate_workflow_task_context(value, max_chars=WORKFLOW_TASK_CONTEXT_MAX_CHARS): + normalized = str(value or '').strip() + if len(normalized) <= max_chars: + return normalized + + head_length = max_chars // 2 + tail_length = max_chars - head_length + return ( + f'{normalized[:head_length].rstrip()}\n\n' + '[Previous task output truncated]\n\n' + f'{normalized[-tail_length:].lstrip()}' + ) + + +def _build_workflow_task_execution_workflow(workflow, task, previous_reply='', include_document_action=False): + task = task if isinstance(task, dict) else {} + prepared_workflow = dict(workflow or {}) + task_instructions = str(task.get('instructions') or '').strip() + file_sync_context = str(workflow.get('file_sync_prompt_context') or '').strip() + if include_document_action and file_sync_context: + task_instructions = ( + f'{task_instructions}\n\n' + '[Workflow input context]\n' + f'{file_sync_context}' + ).strip() + previous_context = _truncate_workflow_task_context(previous_reply) + if previous_context: + task_instructions = ( + f'{task_instructions}\n\n' + '[Previous workflow task output]\n' + f'{previous_context}\n\n' + 'Use the previous task output as context. Complete only the current task instructions.' + ).strip() + + prepared_workflow['task_prompt'] = task_instructions + prepared_workflow['active_task'] = { + 'id': str(task.get('id') or '').strip(), + 'name': str(task.get('name') or '').strip(), + 'order': int(task.get('order') or 0), + } + if not include_document_action: + prepared_workflow['document_action'] = {'type': DOCUMENT_ACTION_TYPE_NONE} + prepared_workflow['analyze'] = build_analyze_config(prepared_workflow['document_action']) + return prepared_workflow + + +def _workflow_task_run_item_id(run_id, task_id): + return str(uuid.uuid5(uuid.NAMESPACE_URL, f'workflow-task:{run_id}:{task_id}')) + + +def _save_workflow_task_run_item( + workflow, + run_id, + task, + status, + attempt_count=0, + error='', + output_summary='', + created_at=None, +): + task = task if isinstance(task, dict) else {} + task_id = str(task.get('id') or '').strip() + now_iso = _utc_now_iso() + item = { + 'id': _workflow_task_run_item_id(run_id, task_id), + 'type': 'workflow_run_item', + 'item_type': 'task', + 'run_id': run_id, + 'user_id': str(workflow.get('user_id') or '').strip(), + 'workflow_id': workflow.get('id'), + 'group_id': _get_workflow_group_id(workflow) or None, + 'workflow_name': workflow.get('name'), + 'task_id': task_id, + 'task_type': str(task.get('type') or 'instructions').strip(), + 'task_order': int(task.get('order') or 0), + 'label': str(task.get('name') or f"Task {task.get('order') or ''}").strip(), + 'status': status, + 'attempt_count': int(attempt_count or 0), + 'error': str(error or '')[:2000], + 'output_summary': str(output_summary or '')[:4000], + 'created_at': created_at or now_iso, + 'updated_at': now_iso, + } + if status == 'running': + item['started_at'] = now_iso + if status in {'succeeded', 'failed', 'skipped', 'cancelled'}: + item['completed_at'] = now_iso + return _save_workflow_run_item_record(workflow, item) + + +def _execute_workflow_dispatch( + execution_workflow, + settings, + conversation_id, + run_id, + thought_tracker, + url_access_context, + file_sync_result=None, +): + document_action = _get_document_action_config(execution_workflow) + workflow_search_context = None + if document_action.get('type') == DOCUMENT_ACTION_TYPE_SEARCH: + workflow_search_context = _execute_cancelable_workflow_step( + execution_workflow, + run_id, + lambda: _prepare_workflow_search_context( + execution_workflow, + document_action, + settings, + thought_tracker=thought_tracker, + run_id=run_id, + ), + ) + execution_workflow = workflow_search_context.get('workflow') or execution_workflow + document_action = _get_document_action_config(execution_workflow) + + if document_action.get('type') in {DOCUMENT_ACTION_TYPE_ANALYZE, DOCUMENT_ACTION_TYPE_COMPARISON}: + _raise_if_workflow_run_cancelled(execution_workflow, run_id) + document_action = _resolve_recent_document_action_targets(execution_workflow, document_action, settings) + execution_workflow = _apply_runtime_document_action_config(execution_workflow, document_action) + run_item_callback = _build_run_item_activity_callback( + execution_workflow, + run_id, + file_sync_result=file_sync_result or {}, + ) + _initialize_document_run_items( + execution_workflow, + run_id, + document_action, + file_sync_result=file_sync_result or {}, + ) + execution_result = _execute_cancelable_workflow_step( + execution_workflow, + run_id, + lambda: _execute_document_action_workflow( + execution_workflow, + settings, + conversation_id=conversation_id, + run_id=run_id, + thought_tracker=thought_tracker, + external_activity_callback=run_item_callback, + url_access_context=url_access_context, + ), + ) + elif execution_workflow.get('runner_type') == 'agent': + execution_result = _execute_cancelable_workflow_step( + execution_workflow, + run_id, + lambda: _execute_agent_workflow( + execution_workflow, + settings, + conversation_id=conversation_id, + run_id=run_id, + thought_tracker=thought_tracker, + url_access_context=url_access_context, + ), + ) + else: + execution_result = _execute_cancelable_workflow_step( + execution_workflow, + run_id, + lambda: _execute_model_workflow( + execution_workflow, + settings, + conversation_id=conversation_id, + run_id=run_id, + thought_tracker=thought_tracker, + url_access_context=url_access_context, + ), + ) + + if workflow_search_context: + execution_result.update({ + 'hybrid_citations': workflow_search_context.get('citations') or [], + 'augmented': bool(workflow_search_context.get('citations')), + 'document_search': { + 'query': workflow_search_context.get('query'), + 'result_count': workflow_search_context.get('result_count', 0), + 'document_count': workflow_search_context.get('document_count', 0), + }, + }) + return execution_result + + +def _merge_workflow_task_execution_results(task_results): + task_results = list(task_results or []) + successful_results = [item for item in task_results if item.get('status') == 'succeeded'] + reply_sections = [] + for item in task_results: + task = item.get('task') if isinstance(item.get('task'), dict) else {} + task_name = str(task.get('name') or f"Task {task.get('order') or ''}").strip() + if item.get('status') == 'succeeded': + reply_text = str((item.get('result') or {}).get('reply') or '').strip() + reply_sections.extend([f'## {task_name}', '', reply_text or 'No response was generated.', '']) + else: + reply_sections.extend([ + f'## {task_name}', + '', + f"Task failed: {item.get('error') or 'Unknown error'}", + '', + ]) + + final_source = (successful_results[-1].get('result') or {}) if successful_results else {} + merged_result = dict(final_source) + merged_result['reply'] = '\n'.join(reply_sections).strip() + merged_result['task_results'] = [ + { + 'task_id': str((item.get('task') or {}).get('id') or '').strip(), + 'task_name': str((item.get('task') or {}).get('name') or '').strip(), + 'task_order': int((item.get('task') or {}).get('order') or 0), + 'status': item.get('status'), + 'attempt_count': int(item.get('attempt_count') or 0), + 'error': str(item.get('error') or ''), + 'response_preview': _build_response_preview((item.get('result') or {}).get('reply')), + } + for item in task_results + ] + merged_result['task_error_count'] = sum(1 for item in task_results if item.get('status') == 'failed') + merged_result['token_usage'] = _merge_token_usage_summaries([ + item.get('result') or {} + for item in successful_results + ]) + + for field in ( + 'agent_citations', + 'hybrid_citations', + 'web_search_citations', + 'generated_analysis_artifacts', + 'generated_tabular_outputs', + 'alert_targets', + ): + merged_result[field] = [ + value + for item in successful_results + for value in list((item.get('result') or {}).get(field) or []) + ] + merged_result['augmented'] = any( + bool((item.get('result') or {}).get('augmented')) + for item in successful_results + ) + return merged_result + + +def _execute_workflow_task_sequence( + workflow, + settings, + conversation_id, + run_id, + thought_tracker, + url_access_context, + file_sync_result=None, +): + tasks = list(workflow.get('tasks') or []) + error_handling = workflow.get('error_handling') if isinstance(workflow.get('error_handling'), dict) else {} + error_strategy = str(error_handling.get('strategy') or 'halt').strip().lower() + retry_count = max(0, min(5, int(error_handling.get('retry_count') or 0))) + previous_reply = '' + task_results = [] + + for task_index, raw_task in enumerate(tasks): + task = dict(raw_task or {}) + task['order'] = task_index + 1 + task_id = str(task.get('id') or f'task-{task_index + 1}').strip() + task['id'] = task_id + created_at = _utc_now_iso() + _save_workflow_task_run_item(workflow, run_id, task, 'queued', created_at=created_at) + if thought_tracker and run_id: + _add_workflow_activity_thought( + thought_tracker, + workflow, + run_id, + step_type='task', + content=f"Starting task {task_index + 1}: {task.get('name') or task_id}", + detail=None, + activity_key=f'task:{run_id}:{task_id}', + kind='workflow_task', + title=str(task.get('name') or f'Task {task_index + 1}'), + status='running', + ) + prepared_workflow = _build_workflow_task_execution_workflow( + workflow, + task, + previous_reply=previous_reply, + include_document_action=task_index == 0, + ) + + task_result = None + task_error = '' + attempt_count = 0 + for attempt_index in range(retry_count + 1): + attempt_count = attempt_index + 1 + _save_workflow_task_run_item( + workflow, + run_id, + task, + 'running', + attempt_count=attempt_count, + created_at=created_at, + ) + try: + task_result = _execute_workflow_dispatch( + prepared_workflow, + settings, + conversation_id, + run_id, + thought_tracker, + url_access_context, + file_sync_result=file_sync_result, + ) + task_error = '' + break + except Exception as exc: + task_error = str(exc) + if attempt_index >= retry_count: + break + if thought_tracker and run_id: + _add_workflow_activity_thought( + thought_tracker, + workflow, + run_id, + step_type='task', + content=f"Retrying task {task_index + 1}: {task.get('name') or task_id}", + detail=f'attempt={attempt_count + 1}', + activity_key=f'task:{run_id}:{task_id}', + kind='workflow_task', + title=str(task.get('name') or f'Task {task_index + 1}'), + status='running', + ) + + if task_result is not None: + _save_workflow_task_run_item( + workflow, + run_id, + task, + 'succeeded', + attempt_count=attempt_count, + output_summary=_build_response_preview(task_result.get('reply'), max_length=4000), + created_at=created_at, + ) + task_results.append({ + 'task': task, + 'status': 'succeeded', + 'attempt_count': attempt_count, + 'result': task_result, + 'error': '', + }) + if thought_tracker and run_id: + _add_workflow_activity_thought( + thought_tracker, + workflow, + run_id, + step_type='task', + content=f"Completed task {task_index + 1}: {task.get('name') or task_id}", + detail=f'attempts={attempt_count}', + activity_key=f'task:{run_id}:{task_id}', + kind='workflow_task', + title=str(task.get('name') or f'Task {task_index + 1}'), + status='completed', + ) + previous_reply = str(task_result.get('reply') or '').strip() + continue + + _save_workflow_task_run_item( + workflow, + run_id, + task, + 'failed', + attempt_count=attempt_count, + error=task_error, + created_at=created_at, + ) + task_results.append({ + 'task': task, + 'status': 'failed', + 'attempt_count': attempt_count, + 'result': {}, + 'error': task_error, + }) + if thought_tracker and run_id: + _add_workflow_activity_thought( + thought_tracker, + workflow, + run_id, + step_type='task', + content=f"Failed task {task_index + 1}: {task.get('name') or task_id}", + detail=task_error, + activity_key=f'task:{run_id}:{task_id}', + kind='workflow_task', + title=str(task.get('name') or f'Task {task_index + 1}'), + status='failed', + ) + if error_strategy != 'continue': + raise RuntimeError( + f"Workflow task '{task.get('name') or task_id}' failed after {attempt_count} attempt(s): {task_error}" + ) + + return _merge_workflow_task_execution_results(task_results) + + def _finalize_cancelled_workflow_run( workflow, run_record, @@ -5939,11 +6562,19 @@ def run_personal_workflow(workflow, trigger_source='manual', user_roles=None, ac status='running', ) + url_access_workflow = execution_workflow + if execution_workflow.get('tasks'): + url_access_workflow = dict(execution_workflow) + url_access_workflow['task_prompt'] = '\n\n'.join( + str(task.get('instructions') or '').strip() + for task in execution_workflow.get('tasks') or [] + if str(task.get('instructions') or '').strip() + ) url_access_context = _execute_cancelable_workflow_step( execution_workflow, run_id, lambda: _prepare_workflow_url_access_context( - execution_workflow, + url_access_workflow, settings, conversation.get('id'), run_id, @@ -5951,96 +6582,27 @@ def run_personal_workflow(workflow, trigger_source='manual', user_roles=None, ac user_roles=user_roles, ), ) - document_action = _get_document_action_config(execution_workflow) - workflow_search_context = None - if document_action.get('type') == DOCUMENT_ACTION_TYPE_SEARCH: - workflow_search_context = _execute_cancelable_workflow_step( - execution_workflow, - run_id, - lambda: _prepare_workflow_search_context( - execution_workflow, - document_action, - settings, - thought_tracker=thought_tracker, - run_id=run_id, - ), - ) - execution_workflow = workflow_search_context.get('workflow') or execution_workflow - document_action = _get_document_action_config(execution_workflow) - - if document_action.get('type') in {DOCUMENT_ACTION_TYPE_ANALYZE, DOCUMENT_ACTION_TYPE_COMPARISON}: - _raise_if_workflow_run_cancelled(execution_workflow, run_id) - document_action = _resolve_recent_document_action_targets(execution_workflow, document_action, settings) - execution_workflow = _apply_runtime_document_action_config(execution_workflow, document_action) - run_item_callback = _build_run_item_activity_callback( - execution_workflow, - run_id, - file_sync_result=file_sync_result or {}, - ) - _initialize_document_run_items( - execution_workflow, - run_id, - document_action, - file_sync_result=file_sync_result or {}, - ) - execution_result = _execute_cancelable_workflow_step( - execution_workflow, - run_id, - lambda: _execute_document_action_workflow( - execution_workflow, - settings, - conversation_id=conversation.get('id'), - run_id=run_id, - thought_tracker=thought_tracker, - external_activity_callback=run_item_callback, - url_access_context=url_access_context, - ), - ) - elif execution_workflow.get('runner_type') == 'agent': - execution_result = _execute_cancelable_workflow_step( + conversation_id = str(conversation.get('id') or '') + if execution_workflow.get('tasks'): + execution_result = _execute_workflow_task_sequence( execution_workflow, + settings, + conversation_id, run_id, - lambda: _execute_agent_workflow( - execution_workflow, - settings, - conversation_id=conversation.get('id'), - run_id=run_id, - thought_tracker=thought_tracker, - url_access_context=url_access_context, - ), + thought_tracker, + url_access_context, + file_sync_result=file_sync_result, ) - if workflow_search_context: - execution_result.update({ - 'hybrid_citations': workflow_search_context.get('citations') or [], - 'augmented': bool(workflow_search_context.get('citations')), - 'document_search': { - 'query': workflow_search_context.get('query'), - 'result_count': workflow_search_context.get('result_count', 0), - 'document_count': workflow_search_context.get('document_count', 0), - }, - }) else: - execution_result = _execute_cancelable_workflow_step( + execution_result = _execute_workflow_dispatch( execution_workflow, + settings, + conversation_id, run_id, - lambda: _execute_model_workflow( - execution_workflow, - settings, - run_id=run_id, - thought_tracker=thought_tracker, - url_access_context=url_access_context, - ), + thought_tracker, + url_access_context, + file_sync_result=file_sync_result, ) - if workflow_search_context: - execution_result.update({ - 'hybrid_citations': workflow_search_context.get('citations') or [], - 'augmented': bool(workflow_search_context.get('citations')), - 'document_search': { - 'query': workflow_search_context.get('query'), - 'result_count': workflow_search_context.get('result_count', 0), - 'document_count': workflow_search_context.get('document_count', 0), - }, - }) _raise_if_workflow_run_cancelled(execution_workflow, run_id) execution_result = _attach_workflow_url_access_result(execution_result, url_access_context) @@ -6088,6 +6650,8 @@ def run_personal_workflow(workflow, trigger_source='manual', user_roles=None, ac 'agent_name': execution_result.get('agent_name'), 'agent_display_name': execution_result.get('agent_display_name'), 'analysis_coverage': execution_result.get('analysis_coverage') or {}, + 'task_results': execution_result.get('task_results') or [], + 'task_error_count': int(execution_result.get('task_error_count') or 0), 'url_access': execution_result.get('url_access') or {}, 'source_review': execution_result.get('source_review') or {}, 'file_sync': file_sync_result or {}, diff --git a/application/single_app/static/css/workspace-responsive.css b/application/single_app/static/css/workspace-responsive.css index a853844e7..84ad0519f 100644 --- a/application/single_app/static/css/workspace-responsive.css +++ b/application/single_app/static/css/workspace-responsive.css @@ -253,6 +253,212 @@ text-align: center; } +.workflow-builder-body { + background: var(--bs-tertiary-bg, #f8f9fa); + display: flex; + flex-direction: column; + min-height: 32rem; +} + +.workflow-step-nav { + align-items: stretch; + background: var(--bs-body-bg, #fff); + border: 1px solid var(--bs-border-color, #dee2e6); + border-radius: 0.5rem; + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + margin-bottom: 1.25rem; + overflow: hidden; + order: 0; +} + +.workflow-step-nav__item { + align-items: center; + background: transparent; + border: 0; + border-right: 1px solid var(--bs-border-color, #dee2e6); + color: var(--bs-secondary-color, #6c757d); + display: flex; + font-size: 0.82rem; + font-weight: 600; + gap: 0.45rem; + justify-content: center; + min-height: 3rem; + padding: 0.65rem 0.5rem; +} + +.workflow-step-nav__item:last-child { + border-right: 0; +} + +.workflow-step-nav__item span { + align-items: center; + border: 1px solid currentColor; + border-radius: 50%; + display: inline-flex; + font-size: 0.72rem; + height: 1.45rem; + justify-content: center; + width: 1.45rem; +} + +.workflow-step-nav__item:hover, +.workflow-step-nav__item:focus-visible, +.workflow-step-nav__item.is-complete { + background: rgba(13, 110, 253, 0.06); + color: var(--bs-primary, #0d6efd); +} + +.workflow-step-nav__item.is-active { + background: rgba(13, 110, 253, 0.11); + box-shadow: inset 0 -2px 0 var(--bs-primary, #0d6efd); + color: var(--bs-primary, #0d6efd); +} + +.workflow-step-nav__item.is-active span, +.workflow-step-nav__item.is-complete span { + background: var(--bs-primary, #0d6efd); + border-color: var(--bs-primary, #0d6efd); + color: #fff; +} + +.workflow-step-block { + order: 10; +} + +.workflow-step-hidden { + display: none !important; +} + +#workflow-trigger-settings-card { + order: 1; +} + +#workflow-url-access-group { + order: 2; +} + +#workflow-file-sync-card { + order: 3; +} + +#workflow-review-summary-block { + order: 1; +} + +.workflow-task-list { + display: grid; + gap: 0.65rem; +} + +.workflow-task-item { + align-items: center; + background: var(--bs-body-bg, #fff); + border: 1px solid var(--bs-border-color, #dee2e6); + border-radius: 0.4rem; + display: grid; + gap: 0.75rem; + grid-template-columns: auto minmax(0, 1fr) auto; + padding: 0.75rem; +} + +.workflow-task-item.is-selected { + border-color: var(--bs-primary, #0d6efd); + box-shadow: 0 0 0 0.15rem rgba(13, 110, 253, 0.1); +} + +.workflow-task-item__number { + align-items: center; + background: var(--bs-tertiary-bg, #f8f9fa); + border: 1px solid var(--bs-border-color, #dee2e6); + border-radius: 0.3rem; + display: inline-flex; + font-size: 0.8rem; + font-weight: 700; + height: 2rem; + justify-content: center; + width: 2rem; +} + +.workflow-task-item.is-selected .workflow-task-item__number { + background: var(--bs-primary, #0d6efd); + border-color: var(--bs-primary, #0d6efd); + color: #fff; +} + +.workflow-task-item__content { + min-width: 0; +} + +.workflow-task-item__name { + font-size: 0.9rem; + font-weight: 600; + margin-bottom: 0.15rem; +} + +.workflow-task-item__preview { + color: var(--bs-secondary-color, #6c757d); + font-size: 0.78rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workflow-task-item__actions { + display: flex; + gap: 0.25rem; +} + +.workflow-task-item__actions .btn { + align-items: center; + display: inline-flex; + height: 2rem; + justify-content: center; + padding: 0; + width: 2rem; +} + +.workflow-task-editor { + background: var(--bs-body-bg, #fff); +} + +.workflow-retry-input { + max-width: 8rem; +} + +.workflow-review-summary { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.workflow-review-summary__item { + background: var(--bs-body-bg, #fff); + border: 1px solid var(--bs-border-color, #dee2e6); + border-radius: 0.4rem; + min-width: 0; + padding: 0.8rem; +} + +.workflow-review-summary__label { + color: var(--bs-secondary-color, #6c757d); + display: block; + font-size: 0.72rem; + font-weight: 700; + margin-bottom: 0.25rem; + text-transform: uppercase; +} + +.workflow-review-summary__value { + font-size: 0.9rem; + overflow-wrap: anywhere; +} + +[data-bs-theme="dark"] .workflow-step-nav__item.is-active, +[data-bs-theme="dark"] .workflow-step-nav__item.is-complete { + background: rgba(110, 168, 254, 0.16); +} + @media (max-width: 991.98px) { .workspace-page-header { flex-direction: column; @@ -286,6 +492,29 @@ flex: 1 1 100%; } + .workflow-step-nav { + display: flex; + overflow-x: auto; + } + + .workflow-step-nav__item { + flex: 0 0 auto; + min-width: 7.25rem; + } + + .workflow-review-summary { + grid-template-columns: 1fr; + } + + .workflow-task-item { + grid-template-columns: auto minmax(0, 1fr); + } + + .workflow-task-item__actions { + grid-column: 1 / -1; + justify-content: flex-end; + } + #documents-table, #group-documents-table, #prompts-table, diff --git a/application/single_app/static/js/workspace/workspace_workflows.js b/application/single_app/static/js/workspace/workspace_workflows.js index 6b23db4c4..8f37c2511 100644 --- a/application/single_app/static/js/workspace/workspace_workflows.js +++ b/application/single_app/static/js/workspace/workspace_workflows.js @@ -75,10 +75,18 @@ const workflowModal = workflowModalEl && window.bootstrap ? bootstrap.Modal.getO const workflowForm = document.getElementById("workflow-form"); const workflowModalLabel = document.getElementById("workflowModalLabel"); const workflowSaveBtn = document.getElementById("workflow-save-btn"); +const workflowStepDescription = document.getElementById("workflow-step-description"); +const workflowStepNavButtons = Array.from(document.querySelectorAll("[data-workflow-step-target]")); +const workflowStepBlocks = Array.from(document.querySelectorAll("[data-workflow-step]")); +const workflowStepBackBtn = document.getElementById("workflow-step-back-btn"); +const workflowStepNextBtn = document.getElementById("workflow-step-next-btn"); const workflowIdInput = document.getElementById("workflow-id"); const workflowNameInput = document.getElementById("workflow-name"); const workflowDescriptionInput = document.getElementById("workflow-description"); +const workflowTaskList = document.getElementById("workflow-task-list"); +const workflowAddTaskBtn = document.getElementById("workflow-add-task-btn"); +const workflowTaskNameInput = document.getElementById("workflow-task-name"); const workflowTaskBriefInput = document.getElementById("workflow-task-brief"); const workflowDraftInstructionsBtn = document.getElementById("workflow-draft-instructions-btn"); const workflowDraftInstructionsStatus = document.getElementById("workflow-draft-instructions-status"); @@ -111,6 +119,18 @@ const workflowFileSyncContinueModeSelect = document.getElementById("workflow-fil const workflowFileSyncUseChangedDocumentsToggle = document.getElementById("workflow-file-sync-use-changed-documents"); const workflowFileSyncHelp = document.getElementById("workflow-file-sync-help"); const workflowAlertPrioritySelect = document.getElementById("workflow-alert-priority"); +const workflowErrorStrategyInputs = Array.from(document.querySelectorAll('input[name="workflow-error-strategy"]')); +const workflowTaskRetryCountInput = document.getElementById("workflow-task-retry-count"); +const workflowReviewSummary = document.getElementById("workflow-review-summary"); +const WORKFLOW_STEPS = ["general", "trigger", "tasks", "reliability", "review"]; +const WORKFLOW_STEP_DESCRIPTIONS = { + general: "Name the workflow and choose its runner.", + trigger: "Choose when the workflow runs and configure optional inputs.", + tasks: "Build the ordered instruction sequence.", + reliability: "Choose retry and failure behavior.", + review: "Review the workflow and completion alert before saving.", +}; +const WORKFLOW_MAX_TASKS = 20; const DOCUMENT_ACTION_NONE = "none"; const DOCUMENT_ACTION_SEARCH = "search"; const DOCUMENT_ACTION_ANALYZE = "analyze"; @@ -169,6 +189,7 @@ const workflowAnalysisPublicWorkspaceIdsInput = document.getElementById("workflo const workflowAnalysisWindowUnitSelect = document.getElementById("workflow-analysis-window-unit"); const workflowAnalysisWindowSizeInput = document.getElementById("workflow-analysis-window-size"); const workflowAnalysisWindowPercentInput = document.getElementById("workflow-analysis-window-percent"); +const workflowAnalysisRetriesGroup = document.getElementById("workflow-analysis-retries-group"); const workflowAnalysisRetriesInput = document.getElementById("workflow-analysis-retries"); const workflowUseSelectedDocumentsBtn = document.getElementById("workflow-use-selected-documents-btn"); const workflowSelectedDocumentsSummary = document.getElementById("workflow-selected-documents-summary"); @@ -212,7 +233,10 @@ function getWorkflowDocumentActionMaxDocuments(actionType) { } function getDocumentActionDisplayLabel(actionType) { - if (actionType === DOCUMENT_ACTION_SEARCH || actionType === DOCUMENT_ACTION_NONE) { + if (actionType === DOCUMENT_ACTION_NONE) { + return "No document action"; + } + if (actionType === DOCUMENT_ACTION_SEARCH) { return "Search"; } if (actionType === DOCUMENT_ACTION_COMPARISON) { @@ -234,7 +258,10 @@ function getWorkflowUrlAccessMaxUrls() { } function getWorkflowPromptUrls() { - const promptText = normalizeText(workflowTaskPromptInput?.value); + syncActiveWorkflowTaskFromEditor(); + const promptText = workflowTasks.length + ? workflowTasks.map((task) => normalizeText(task.instructions)).filter(Boolean).join("\n") + : normalizeText(workflowTaskPromptInput?.value); if (!promptText) { return []; } @@ -265,7 +292,7 @@ function syncWorkflowDocumentActionTooltip() { const description = normalizeText( selectedOption?.dataset.actionDescription || selectedOption?.getAttribute("title") - || getDocumentActionDescription(normalizeText(workflowDocumentActionTypeSelect.value) || DOCUMENT_ACTION_SEARCH) + || getDocumentActionDescription(normalizeText(workflowDocumentActionTypeSelect.value) || DOCUMENT_ACTION_NONE) ); workflowDocumentActionTypeSelect.title = description; @@ -290,6 +317,9 @@ let workflowComparisonVersionLoadToken = 0; let workflowPickerDocumentIds = []; let workflowSavedComparisonTargetIds = []; let workflowSavedComparisonPreferredLeftId = ""; +let workflowTasks = []; +let activeWorkflowTaskId = ""; +let currentWorkflowStepIndex = 0; function normalizeText(value) { return String(value || "").trim(); @@ -312,6 +342,320 @@ function clearElementChildren(element) { } } +function createWorkflowTaskId() { + if (window.crypto?.randomUUID) { + return window.crypto.randomUUID(); + } + return `task-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function getActiveWorkflowTask() { + return workflowTasks.find((task) => task.id === activeWorkflowTaskId) || null; +} + +function syncActiveWorkflowTaskFromEditor() { + const activeTask = getActiveWorkflowTask(); + if (!activeTask) { + return; + } + activeTask.name = normalizeText(workflowTaskNameInput?.value); + activeTask.instructions = normalizeText(workflowTaskPromptInput?.value); +} + +function populateWorkflowTaskEditor() { + const activeTask = getActiveWorkflowTask(); + if (workflowTaskNameInput) { + workflowTaskNameInput.value = activeTask?.name || ""; + } + if (workflowTaskPromptInput) { + workflowTaskPromptInput.value = activeTask?.instructions || ""; + } + if (workflowTaskBriefInput) { + workflowTaskBriefInput.value = ""; + } + if (workflowDraftInstructionsStatus) { + workflowDraftInstructionsStatus.textContent = ""; + } +} + +function createWorkflowTaskActionButton(iconClass, label, handler, disabled = false) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "btn btn-sm btn-outline-secondary"; + button.title = label; + button.setAttribute("aria-label", label); + button.disabled = disabled; + const icon = document.createElement("i"); + icon.className = iconClass; + icon.setAttribute("aria-hidden", "true"); + button.appendChild(icon); + button.addEventListener("click", handler); + return button; +} + +function renderWorkflowTasks() { + if (!workflowTaskList) { + return; + } + clearElementChildren(workflowTaskList); + + workflowTasks.forEach((task, index) => { + task.order = index + 1; + const item = document.createElement("div"); + item.className = "workflow-task-item"; + item.classList.toggle("is-selected", task.id === activeWorkflowTaskId); + + const number = document.createElement("span"); + number.className = "workflow-task-item__number"; + number.textContent = String(index + 1); + + const content = document.createElement("div"); + content.className = "workflow-task-item__content"; + const name = document.createElement("div"); + name.className = "workflow-task-item__name"; + name.textContent = task.name || `Task ${index + 1}`; + const preview = document.createElement("div"); + preview.className = "workflow-task-item__preview"; + preview.textContent = task.instructions || "Add task instructions"; + content.append(name, preview); + + const actions = document.createElement("div"); + actions.className = "workflow-task-item__actions"; + actions.append( + createWorkflowTaskActionButton("bi bi-arrow-up", `Move ${name.textContent} up`, () => moveWorkflowTask(task.id, -1), index === 0), + createWorkflowTaskActionButton("bi bi-arrow-down", `Move ${name.textContent} down`, () => moveWorkflowTask(task.id, 1), index === workflowTasks.length - 1), + createWorkflowTaskActionButton("bi bi-pencil", `Edit ${name.textContent}`, () => selectWorkflowTask(task.id)), + createWorkflowTaskActionButton("bi bi-trash", `Delete ${name.textContent}`, () => removeWorkflowTask(task.id), workflowTasks.length === 1), + ); + item.append(number, content, actions); + workflowTaskList.appendChild(item); + }); +} + +function selectWorkflowTask(taskId) { + syncActiveWorkflowTaskFromEditor(); + if (!workflowTasks.some((task) => task.id === taskId)) { + return; + } + activeWorkflowTaskId = taskId; + populateWorkflowTaskEditor(); + renderWorkflowTasks(); + workflowTaskNameInput?.focus(); +} + +function addWorkflowTask() { + syncActiveWorkflowTaskFromEditor(); + if (workflowTasks.length >= WORKFLOW_MAX_TASKS) { + showToast(`Workflows support up to ${WORKFLOW_MAX_TASKS} tasks.`, "warning"); + return; + } + const task = { + id: createWorkflowTaskId(), + type: "instructions", + name: `Task ${workflowTasks.length + 1}`, + instructions: "", + order: workflowTasks.length + 1, + }; + workflowTasks.push(task); + activeWorkflowTaskId = task.id; + populateWorkflowTaskEditor(); + renderWorkflowTasks(); + workflowTaskNameInput?.focus(); +} + +function moveWorkflowTask(taskId, offset) { + syncActiveWorkflowTaskFromEditor(); + const currentIndex = workflowTasks.findIndex((task) => task.id === taskId); + const nextIndex = currentIndex + offset; + if (currentIndex < 0 || nextIndex < 0 || nextIndex >= workflowTasks.length) { + return; + } + const [task] = workflowTasks.splice(currentIndex, 1); + workflowTasks.splice(nextIndex, 0, task); + renderWorkflowTasks(); +} + +function removeWorkflowTask(taskId) { + if (workflowTasks.length === 1) { + showToast("A workflow requires at least one task.", "warning"); + return; + } + const taskIndex = workflowTasks.findIndex((task) => task.id === taskId); + if (taskIndex < 0) { + return; + } + workflowTasks.splice(taskIndex, 1); + const replacementTask = workflowTasks[Math.min(taskIndex, workflowTasks.length - 1)]; + activeWorkflowTaskId = replacementTask.id; + populateWorkflowTaskEditor(); + renderWorkflowTasks(); +} + +function initializeWorkflowTasks(workflow = null) { + const storedTasks = Array.isArray(workflow?.tasks) ? workflow.tasks : []; + workflowTasks = storedTasks.length + ? storedTasks.map((task, index) => ({ + id: normalizeText(task.id) || createWorkflowTaskId(), + type: "instructions", + name: normalizeText(task.name) || `Task ${index + 1}`, + instructions: normalizeText(task.instructions), + order: index + 1, + })) + : [{ + id: createWorkflowTaskId(), + type: "instructions", + name: "Task 1", + instructions: normalizeText(workflow?.task_prompt), + order: 1, + }]; + activeWorkflowTaskId = workflowTasks[0].id; + populateWorkflowTaskEditor(); + renderWorkflowTasks(); +} + +function getWorkflowErrorStrategy() { + return normalizeText(workflowErrorStrategyInputs.find((input) => input.checked)?.value) || "halt"; +} + +function addWorkflowReviewItem(labelText, valueText) { + if (!workflowReviewSummary) { + return; + } + const item = document.createElement("div"); + item.className = "workflow-review-summary__item"; + const label = document.createElement("span"); + label.className = "workflow-review-summary__label"; + label.textContent = labelText; + const value = document.createElement("div"); + value.className = "workflow-review-summary__value"; + value.textContent = valueText; + item.append(label, value); + workflowReviewSummary.appendChild(item); +} + +function renderWorkflowReview() { + if (!workflowReviewSummary) { + return; + } + syncActiveWorkflowTaskFromEditor(); + clearElementChildren(workflowReviewSummary); + const runnerLabel = workflowRunnerTypeSelect?.selectedOptions?.[0]?.textContent || "Direct Model"; + const triggerLabel = workflowTriggerTypeSelect?.selectedOptions?.[0]?.textContent || "Manual"; + const documentActionLabel = workflowDocumentActionTypeSelect?.selectedOptions?.[0]?.textContent || "No document action"; + const alertLabel = workflowAlertPrioritySelect?.selectedOptions?.[0]?.textContent || "No notification"; + const retryCount = Number(workflowTaskRetryCountInput?.value || 0); + const strategyLabel = getWorkflowErrorStrategy() === "continue" ? "Continue after failure" : "Stop after failure"; + + addWorkflowReviewItem("Workflow", normalizeText(workflowNameInput?.value) || "Untitled workflow"); + addWorkflowReviewItem("Runner", normalizeText(runnerLabel)); + addWorkflowReviewItem("Trigger", normalizeText(triggerLabel)); + addWorkflowReviewItem("Tasks", `${workflowTasks.length} ordered ${workflowTasks.length === 1 ? "task" : "tasks"}`); + addWorkflowReviewItem("Workspace documents", normalizeText(documentActionLabel)); + addWorkflowReviewItem("File Sync", workflowFileSyncEnabledToggle?.checked ? "Before each run" : "Not used"); + addWorkflowReviewItem("Failure handling", `${strategyLabel}; ${retryCount} ${retryCount === 1 ? "retry" : "retries"}`); + addWorkflowReviewItem("Completion alert", normalizeText(alertLabel)); +} + +function validateWorkflowTasks() { + syncActiveWorkflowTaskFromEditor(); + for (let index = 0; index < workflowTasks.length; index += 1) { + const task = workflowTasks[index]; + if (!normalizeText(task.name) || !normalizeText(task.instructions)) { + activeWorkflowTaskId = task.id; + populateWorkflowTaskEditor(); + renderWorkflowTasks(); + showToast(`Add a name and instructions for task ${index + 1}.`, "warning"); + (normalizeText(task.name) ? workflowTaskPromptInput : workflowTaskNameInput)?.focus(); + return false; + } + } + return true; +} + +function validateWorkflowStep(stepName) { + if (stepName === "general") { + if (!normalizeText(workflowNameInput?.value)) { + showToast("Workflow name is required.", "warning"); + workflowNameInput?.focus(); + return false; + } + if (normalizeText(workflowRunnerTypeSelect?.value) === "agent" && !getSelectedAgentOption()) { + showToast("Select an agent for this workflow.", "warning"); + workflowAgentSelect?.focus(); + return false; + } + } + if (stepName === "trigger") { + const triggerType = normalizeText(workflowTriggerTypeSelect?.value) || "manual"; + const fileSyncEnabled = Boolean(workflowFileSyncEnabledToggle?.checked) || triggerType === "file_sync"; + if (fileSyncEnabled && !getSelectedFileSyncSources().length) { + showToast("Select at least one File Sync source for this workflow.", "warning"); + workflowFileSyncSourcesSelect?.focus(); + return false; + } + } + if (stepName === "tasks") { + if (!validateWorkflowTasks()) { + return false; + } + try { + buildWorkflowPayload(); + } catch (error) { + showToast(escapeHtml(error.message || "Complete the task configuration before continuing."), "warning"); + return false; + } + } + if (stepName === "reliability") { + const retryCount = Number(workflowTaskRetryCountInput?.value || 0); + if (!Number.isInteger(retryCount) || retryCount < 0 || retryCount > 5) { + showToast("Task retry count must be between 0 and 5.", "warning"); + workflowTaskRetryCountInput?.focus(); + return false; + } + } + return true; +} + +function showWorkflowStep(stepIndex) { + currentWorkflowStepIndex = Math.max(0, Math.min(WORKFLOW_STEPS.length - 1, stepIndex)); + const stepName = WORKFLOW_STEPS[currentWorkflowStepIndex]; + workflowStepBlocks.forEach((block) => { + block.classList.toggle("workflow-step-hidden", block.dataset.workflowStep !== stepName); + }); + workflowStepNavButtons.forEach((button, index) => { + const isActive = index === currentWorkflowStepIndex; + button.classList.toggle("is-active", isActive); + button.classList.toggle("is-complete", index < currentWorkflowStepIndex); + if (isActive) { + button.setAttribute("aria-current", "step"); + } else { + button.removeAttribute("aria-current"); + } + }); + if (workflowStepDescription) { + workflowStepDescription.textContent = WORKFLOW_STEP_DESCRIPTIONS[stepName] || ""; + } + setElementVisibility(workflowStepBackBtn, currentWorkflowStepIndex > 0); + setElementVisibility(workflowStepNextBtn, currentWorkflowStepIndex < WORKFLOW_STEPS.length - 1); + setElementVisibility(workflowSaveBtn, currentWorkflowStepIndex === WORKFLOW_STEPS.length - 1); + if (stepName === "review") { + renderWorkflowReview(); + } + workflowModalEl?.querySelector(".modal-body")?.scrollTo({ top: 0, behavior: "smooth" }); +} + +function navigateWorkflowStep(targetIndex) { + if (targetIndex > currentWorkflowStepIndex) { + for (let index = currentWorkflowStepIndex; index < targetIndex; index += 1) { + if (!validateWorkflowStep(WORKFLOW_STEPS[index])) { + showWorkflowStep(index); + return; + } + } + } + showWorkflowStep(targetIndex); +} + function formatDateTime(value) { if (!value) { return ""; @@ -712,7 +1056,7 @@ function syncWorkflowPickerActionType() { return; } - const actionType = normalizeText(workflowDocumentActionTypeSelect.value) || DOCUMENT_ACTION_SEARCH; + const actionType = normalizeText(workflowDocumentActionTypeSelect.value) || DOCUMENT_ACTION_NONE; chatDocumentActionSelect.value = actionType; chatDocumentActionSelect.dispatchEvent(new Event("change", { bubbles: true })); } @@ -737,6 +1081,13 @@ async function initializeWorkflowDocumentPicker(documentAction = {}) { return; } + const actionType = normalizeText(documentAction.type || workflowDocumentActionTypeSelect?.value) || DOCUMENT_ACTION_NONE; + if (actionType === DOCUMENT_ACTION_NONE) { + setWorkflowPickerError(""); + setWorkflowPickerLoadingState(false); + return; + } + setWorkflowPickerError(""); setWorkflowPickerLoadingState(true); syncWorkflowPickerActionType(); @@ -1465,6 +1816,10 @@ function getDocumentActionConfig(workflow) { function getWorkflowDocumentActionSummary(workflow) { const config = getDocumentActionConfig(workflow); + if (config.type === DOCUMENT_ACTION_NONE) { + return "No document action"; + } + if (config.type === DOCUMENT_ACTION_SEARCH) { const documentCount = config.document_ids.length; if (config.target_mode === DOCUMENT_ANALYSIS_TARGET_RECENT) { @@ -1503,7 +1858,7 @@ function getWorkflowDocumentActionSummary(workflow) { return `Compare one source to ${rightCount || 0} ${rightCount === 1 ? "target" : "targets"}`; } - return "Search"; + return "No document action"; } function updateSelectedDocumentsSummary() { @@ -1529,14 +1884,16 @@ function updateWorkflowAnalysisTargetModeFields() { } function updateDocumentActionFields() { - const actionType = normalizeText(workflowDocumentActionTypeSelect?.value) || DOCUMENT_ACTION_SEARCH; + const actionType = normalizeText(workflowDocumentActionTypeSelect?.value) || DOCUMENT_ACTION_NONE; const hasDocumentAction = actionType !== DOCUMENT_ACTION_NONE; + const hasWindowedDocumentAction = [DOCUMENT_ACTION_ANALYZE, DOCUMENT_ACTION_COMPARISON].includes(actionType); const targetMode = normalizeText(workflowAnalysisTargetModeSelect?.value) || DOCUMENT_ANALYSIS_TARGET_SELECTED; const isRecentMode = targetMode === DOCUMENT_ANALYSIS_TARGET_RECENT; setElementVisibility(workflowDocumentTargetsFields, hasDocumentAction); setElementVisibility(workflowAnalysisTargetFields, hasDocumentAction); setElementVisibility(workflowAnalysisPerDocumentGroup, actionType === DOCUMENT_ACTION_ANALYZE); setElementVisibility(workflowComparisonTargetFields, actionType === DOCUMENT_ACTION_COMPARISON && !isRecentMode); + setElementVisibility(workflowAnalysisRetriesGroup, hasWindowedDocumentAction); syncWorkflowPickerActionType(); syncWorkflowDocumentActionTooltip(); updateWorkflowAnalysisTargetModeFields(); @@ -1567,7 +1924,10 @@ async function applySelectedWorkspaceDocumentsToWorkflow() { return; } - const actionType = normalizeText(workflowDocumentActionTypeSelect?.value) || DOCUMENT_ACTION_SEARCH; + const actionType = normalizeText(workflowDocumentActionTypeSelect?.value) || DOCUMENT_ACTION_NONE; + if (actionType === DOCUMENT_ACTION_NONE) { + return; + } const workflowMaxDocuments = getWorkflowDocumentActionMaxDocuments(actionType); const limitedSelectedIds = selectedIds.slice(0, workflowMaxDocuments); @@ -2277,6 +2637,7 @@ function resetWorkflowForm() { if (workflowTaskPromptInput) { workflowTaskPromptInput.value = ""; } + initializeWorkflowTasks(); if (workflowUrlAccessEnabledToggle) { workflowUrlAccessEnabledToggle.checked = false; } @@ -2314,8 +2675,14 @@ function resetWorkflowForm() { if (workflowAlertPrioritySelect) { workflowAlertPrioritySelect.value = "none"; } + workflowErrorStrategyInputs.forEach((input) => { + input.checked = input.value === "halt"; + }); + if (workflowTaskRetryCountInput) { + workflowTaskRetryCountInput.value = "0"; + } if (workflowDocumentActionTypeSelect) { - workflowDocumentActionTypeSelect.value = DOCUMENT_ACTION_SEARCH; + workflowDocumentActionTypeSelect.value = DOCUMENT_ACTION_NONE; } if (workflowAnalysisTargetModeSelect) { workflowAnalysisTargetModeSelect.value = DOCUMENT_ANALYSIS_TARGET_SELECTED; @@ -2377,6 +2744,7 @@ function resetWorkflowForm() { updateTriggerFields(); updateFileSyncFields(); updateDocumentActionFields(); + showWorkflowStep(0); } async function openWorkflowModal(workflow = null) { @@ -2421,6 +2789,15 @@ async function openWorkflowModal(workflow = null) { if (workflowAlertPrioritySelect) { workflowAlertPrioritySelect.value = normalizeText(workflow.alert_priority).toLowerCase() || "none"; } + const errorHandling = workflow.error_handling && typeof workflow.error_handling === "object" + ? workflow.error_handling + : {}; + workflowErrorStrategyInputs.forEach((input) => { + input.checked = input.value === (normalizeText(errorHandling.strategy) || "halt"); + }); + if (workflowTaskRetryCountInput) { + workflowTaskRetryCountInput.value = String(errorHandling.retry_count ?? 0); + } const fileSyncConfig = workflow.file_sync && typeof workflow.file_sync === "object" ? workflow.file_sync : {}; if (workflowFileSyncEnabledToggle) { workflowFileSyncEnabledToggle.checked = Boolean(fileSyncConfig.enabled); @@ -2497,6 +2874,7 @@ async function openWorkflowModal(workflow = null) { } } + initializeWorkflowTasks(workflow); const documentAction = workflow ? getDocumentActionConfig(workflow) : null; if (documentAction?.type === DOCUMENT_ACTION_COMPARISON) { const savedTargetIds = [documentAction.left_document_id, ...documentAction.right_document_ids].filter(Boolean); @@ -2513,14 +2891,23 @@ async function openWorkflowModal(workflow = null) { updateTriggerFields(); updateFileSyncFields(); updateDocumentActionFields(); + showWorkflowStep(0); workflowModal.show(); await initializeWorkflowDocumentPicker(documentAction || {}); } function buildWorkflowPayload() { + syncActiveWorkflowTaskFromEditor(); + const tasks = workflowTasks.map((task, index) => ({ + id: normalizeText(task.id) || createWorkflowTaskId(), + type: "instructions", + name: normalizeText(task.name), + instructions: normalizeText(task.instructions), + order: index + 1, + })); const runnerType = normalizeText(workflowRunnerTypeSelect?.value) || "model"; const triggerType = normalizeText(workflowTriggerTypeSelect?.value) || "manual"; - const documentActionType = normalizeText(workflowDocumentActionTypeSelect?.value) || DOCUMENT_ACTION_SEARCH; + const documentActionType = normalizeText(workflowDocumentActionTypeSelect?.value) || DOCUMENT_ACTION_NONE; const analysisTargetMode = normalizeText(workflowAnalysisTargetModeSelect?.value) === DOCUMENT_ANALYSIS_TARGET_RECENT ? DOCUMENT_ANALYSIS_TARGET_RECENT : DOCUMENT_ANALYSIS_TARGET_SELECTED; @@ -2551,9 +2938,17 @@ function buildWorkflowPayload() { id: normalizeText(workflowIdInput?.value), name: normalizeText(workflowNameInput?.value), description: normalizeText(workflowDescriptionInput?.value), - task_prompt: normalizeText(workflowTaskPromptInput?.value), + task_prompt: normalizeText(tasks[0]?.instructions), + tasks, + error_handling: { + strategy: getWorkflowErrorStrategy(), + retry_count: Number(workflowTaskRetryCountInput?.value || 0), + }, url_access_enabled: isWorkflowUrlAccessAvailable() ? Boolean(workflowUrlAccessEnabledToggle?.checked) : false, runner_type: runnerType, + chat_capabilities_enabled: currentEditingWorkflow + ? Boolean(currentEditingWorkflow.chat_capabilities_enabled) + : true, trigger_type: triggerType, alert_priority: normalizeText(workflowAlertPrioritySelect?.value).toLowerCase() || "none", is_enabled: ["interval", "file_sync"].includes(triggerType) ? Boolean(workflowEnabledToggle?.checked) : true, @@ -2611,8 +3006,15 @@ function buildWorkflowPayload() { if (!payload.name) { throw new Error("Workflow name is required."); } - if (!payload.task_prompt) { - throw new Error("Task prompt is required."); + if (!payload.tasks.length) { + throw new Error("Add at least one workflow task."); + } + const incompleteTaskIndex = payload.tasks.findIndex((task) => !task.name || !task.instructions); + if (incompleteTaskIndex >= 0) { + throw new Error(`Add a name and instructions for task ${incompleteTaskIndex + 1}.`); + } + if (!Number.isInteger(payload.error_handling.retry_count) || payload.error_handling.retry_count < 0 || payload.error_handling.retry_count > 5) { + throw new Error("Task retry count must be between 0 and 5."); } if (payload.url_access_enabled) { const promptUrls = getWorkflowPromptUrls(); @@ -3257,6 +3659,20 @@ function initializeWorkflowEvents() { workflowsGridView?.addEventListener("click", handleWorkflowGridClick); workflowsGridView?.addEventListener("keydown", handleWorkflowGridKeydown); workflowForm?.addEventListener("submit", saveWorkflow); + workflowAddTaskBtn?.addEventListener("click", addWorkflowTask); + workflowTaskNameInput?.addEventListener("input", () => { + syncActiveWorkflowTaskFromEditor(); + renderWorkflowTasks(); + }); + workflowTaskPromptInput?.addEventListener("input", () => { + syncActiveWorkflowTaskFromEditor(); + renderWorkflowTasks(); + }); + workflowStepBackBtn?.addEventListener("click", () => navigateWorkflowStep(currentWorkflowStepIndex - 1)); + workflowStepNextBtn?.addEventListener("click", () => navigateWorkflowStep(currentWorkflowStepIndex + 1)); + workflowStepNavButtons.forEach((button, index) => { + button.addEventListener("click", () => navigateWorkflowStep(index)); + }); workflowDraftInstructionsBtn?.addEventListener("click", draftWorkflowInstructions); workflowDeleteConfirmBtn?.addEventListener("click", deleteWorkflow); workflowHistoryBody?.addEventListener("click", (event) => { @@ -3289,6 +3705,7 @@ function initializeWorkflowEvents() { updateModelHelpText(); }); workflowModelSelect?.addEventListener("change", updateModelHelpText); + workflowAlertPrioritySelect?.addEventListener("change", renderWorkflowReview); workflowTriggerTypeSelect?.addEventListener("change", updateTriggerFields); workflowScheduleUnitSelect?.addEventListener("change", updateScheduleConstraints); workflowFileSyncEnabledToggle?.addEventListener("change", updateFileSyncFields); diff --git a/application/single_app/templates/group_workspaces.html b/application/single_app/templates/group_workspaces.html index 4ee592703..25990e6e5 100644 --- a/application/single_app/templates/group_workspaces.html +++ b/application/single_app/templates/group_workspaces.html @@ -2037,15 +2037,25 @@