From 45c4aa7a42e1fc91cd7007f1405dfc4e56693a9c Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Mon, 27 Jul 2026 16:40:12 -0400 Subject: [PATCH 1/3] Generalize workflows for document-free automation Refs #1082 --- application/single_app/config.py | 2 +- .../single_app/functions_group_workflows.py | 10 + .../functions_personal_workflows.py | 10 + .../single_app/functions_workflow_runner.py | 229 +++++++++++++++- .../js/workspace/workspace_workflows.js | 39 ++- .../templates/group_workspaces.html | 12 +- .../single_app/templates/workspace.html | 12 +- .../features/GENERIC_WORKFLOW_AUTOMATION.md | 119 ++++++++ .../test_generic_workflow_automation.py | 100 +++++++ .../test_workflow_model_core_capabilities.py | 257 ++++++++++++++++++ .../test_workflow_document_action_modal.py | 63 ++++- 11 files changed, 826 insertions(+), 27 deletions(-) create mode 100644 docs/explanation/features/GENERIC_WORKFLOW_AUTOMATION.md create mode 100644 functional_tests/test_generic_workflow_automation.py create mode 100644 functional_tests/test_workflow_model_core_capabilities.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 7cd617af2..9f2465127 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.063" 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..07733bd4f 100644 --- a/application/single_app/functions_group_workflows.py +++ b/application/single_app/functions_group_workflows.py @@ -408,6 +408,15 @@ 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')) ) + 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, @@ -466,6 +475,7 @@ def save_group_workflow(group_id, workflow_data, actor_user_id, user_info=None): 'description': description, 'task_prompt': task_prompt, '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..2365c1b27 100644 --- a/application/single_app/functions_personal_workflows.py +++ b/application/single_app/functions_personal_workflows.py @@ -545,6 +545,15 @@ 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')) ) + 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( @@ -594,6 +603,7 @@ def save_personal_workflow(user_id, workflow_data, actor_user_id=None): 'description': description, 'task_prompt': task_prompt, '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..9a906cddf 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 @@ -4819,7 +4829,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 +4884,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, @@ -6026,6 +6248,7 @@ def run_personal_workflow(workflow, trigger_source='manual', user_roles=None, ac lambda: _execute_model_workflow( execution_workflow, settings, + conversation_id=str(conversation.get('id') or ''), run_id=run_id, thought_tracker=thought_tracker, url_access_context=url_access_context, diff --git a/application/single_app/static/js/workspace/workspace_workflows.js b/application/single_app/static/js/workspace/workspace_workflows.js index 6b23db4c4..a90ae374b 100644 --- a/application/single_app/static/js/workspace/workspace_workflows.js +++ b/application/single_app/static/js/workspace/workspace_workflows.js @@ -169,6 +169,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 +213,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) { @@ -265,7 +269,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; @@ -712,7 +716,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 +741,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 +1476,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 +1518,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 +1544,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 +1584,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); @@ -2315,7 +2335,7 @@ function resetWorkflowForm() { workflowAlertPrioritySelect.value = "none"; } if (workflowDocumentActionTypeSelect) { - workflowDocumentActionTypeSelect.value = DOCUMENT_ACTION_SEARCH; + workflowDocumentActionTypeSelect.value = DOCUMENT_ACTION_NONE; } if (workflowAnalysisTargetModeSelect) { workflowAnalysisTargetModeSelect.value = DOCUMENT_ANALYSIS_TARGET_SELECTED; @@ -2520,7 +2540,7 @@ async function openWorkflowModal(workflow = null) { function buildWorkflowPayload() { 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; @@ -2554,6 +2574,9 @@ function buildWorkflowPayload() { task_prompt: normalizeText(workflowTaskPromptInput?.value), 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, diff --git a/application/single_app/templates/group_workspaces.html b/application/single_app/templates/group_workspaces.html index 4ee592703..407e82324 100644 --- a/application/single_app/templates/group_workspaces.html +++ b/application/single_app/templates/group_workspaces.html @@ -2086,10 +2086,13 @@ {% endif %}
+
Workspace documents
+
Optional. Use a document action only when this workflow needs workspace document context.
- - + {% if settings.document_action_capabilities.analyze.enabled %} @@ -2099,12 +2102,12 @@ {% endif %}
-
+
-
Search selected or recent documents and use matching excerpts as workflow context.
+
Run the workflow instructions without workspace document context.
@@ -2145,6 +2148,7 @@
+ +
-
+
-
Search selected or recent documents and use matching excerpts as workflow context.
+
Run the workflow instructions without workspace document context.
@@ -1541,6 +1544,7 @@
", selector_start) + return template_content[selector_start:selector_end] + + +def test_generic_workflow_ui_defaults_to_no_document_action() -> None: + """New personal and group workflows must expose and default to no document action.""" + print("Testing generic workflow document-optional UI contract...") + + config_content = read_text("application/single_app/config.py") + feature_doc_content = read_text("docs/explanation/features/GENERIC_WORKFLOW_AUTOMATION.md") + workflow_js_content = read_text("application/single_app/static/js/workspace/workspace_workflows.js") + + assert f'VERSION = "{EXPECTED_VERSION}"' in config_content + assert "# Generic Workflow Automation" in feature_doc_content + assert f"Implemented in version: **{EXPECTED_VERSION}**" in feature_doc_content + assert "No document action" in feature_doc_content + assert "workflowDocumentActionTypeSelect.value = DOCUMENT_ACTION_NONE;" in workflow_js_content + assert "const documentActionType = normalizeText(workflowDocumentActionTypeSelect?.value) || DOCUMENT_ACTION_NONE;" in workflow_js_content + assert "if (actionType === DOCUMENT_ACTION_NONE) {" in workflow_js_content + assert "setWorkflowPickerLoadingState(false);" in workflow_js_content + assert "setElementVisibility(workflowAnalysisRetriesGroup, hasWindowedDocumentAction);" in workflow_js_content + + for template_path in ( + "application/single_app/templates/workspace.html", + "application/single_app/templates/group_workspaces.html", + ): + selector_content = get_document_action_selector(read_text(template_path)) + assert '" in selector_content + assert selector_content.index('value="none"') < selector_content.index('value="search"') + + print("PASS: generic workflow UI defaults to no document action") + + +def test_generic_workflow_runner_retains_direct_execution_paths() -> None: + """No-document workflows must retain both direct model and agent execution branches.""" + print("Testing generic workflow execution contract...") + + workflow_store_content = read_text("application/single_app/functions_personal_workflows.py") + group_workflow_store_content = read_text("application/single_app/functions_group_workflows.py") + workflow_runner_content = read_text("application/single_app/functions_workflow_runner.py") + workflow_js_content = read_text("application/single_app/static/js/workspace/workspace_workflows.js") + + assert "task_prompt = _normalize_text(workflow_data.get('task_prompt'), 'Task prompt', required=True)" in workflow_store_content + assert "runner_type = _normalize_text(workflow_data.get('runner_type'), 'Runner type', required=True).lower()" in workflow_store_content + assert "'chat_capabilities_enabled': chat_capabilities_enabled," in workflow_store_content + assert "'chat_capabilities_enabled': chat_capabilities_enabled," in group_workflow_store_content + assert "chat_capabilities_enabled: currentEditingWorkflow" in workflow_js_content + assert "elif execution_workflow.get('runner_type') == 'agent':" in workflow_runner_content + assert "lambda: _execute_agent_workflow(" in workflow_runner_content + assert "lambda: _execute_model_workflow(" in workflow_runner_content + + print("PASS: generic workflow execution retains direct model and agent paths") + + +def run_tests() -> bool: + tests = [ + test_generic_workflow_ui_defaults_to_no_document_action, + test_generic_workflow_runner_retains_direct_execution_paths, + ] + results = [] + + for test in tests: + print(f"Running {test.__name__}...") + try: + test() + results.append(True) + except Exception as exc: + print(f"FAIL: {exc}") + results.append(False) + + print(f"Results: {sum(results)}/{len(results)} tests passed") + return all(results) + + +if __name__ == "__main__": + raise SystemExit(0 if run_tests() else 1) \ No newline at end of file diff --git a/functional_tests/test_workflow_model_core_capabilities.py b/functional_tests/test_workflow_model_core_capabilities.py new file mode 100644 index 000000000..8b7678f82 --- /dev/null +++ b/functional_tests/test_workflow_model_core_capabilities.py @@ -0,0 +1,257 @@ +# test_workflow_model_core_capabilities.py +""" +Functional test for Direct Model workflow core capabilities. +Version: 0.250.063 +Implemented in: 0.250.063 + +This test ensures new Direct Model workflows bind their saved model selection +to a Semantic Kernel service and pass the kernel to auto-invoked core tools. +""" + +import ast +import asyncio +from contextlib import contextmanager +from pathlib import Path +from types import SimpleNamespace + +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 + + +ROOT = Path(__file__).resolve().parents[1] +RUNNER_FILE = ROOT / "application" / "single_app" / "functions_workflow_runner.py" + + +class FakeKernel: + """Minimal kernel double that captures the selected chat service.""" + + def __init__(self): + self.services = [] + + def add_service(self, service): + self.services.append(service) + + +class FakeChatService: + """Minimal asynchronous chat service double for the workflow adapter.""" + + def __init__(self): + self.call = None + + async def get_chat_message_contents(self, chat_history, settings, **kwargs): + self.call = { + "chat_history": chat_history, + "settings": settings, + "kwargs": kwargs, + } + return [SimpleNamespace(content="Core capability result")] + + +class FakePluginLogger: + """Captures conversation-scoped plugin logging interactions.""" + + def __init__(self): + self.cleared_conversations = [] + self.deregistered_keys = [] + + def clear_invocations_for_conversation(self, user_id, conversation_id): + self.cleared_conversations.append((user_id, conversation_id)) + + def deregister_callbacks(self, callback_key): + self.deregistered_keys.append(callback_key) + + +def load_model_core_helpers(): + """Load the isolated model-core helpers with deterministic dependencies.""" + parsed = ast.parse(RUNNER_FILE.read_text(encoding="utf-8"), filename=str(RUNNER_FILE)) + helper_names = { + "_workflow_model_chat_capabilities_enabled", + "_build_workflow_model_context", + "_workflow_model_core_execution_context", + "_execute_model_workflow_with_core_capabilities", + "_execute_model_workflow", + } + helper_nodes = [ + node + for node in parsed.body + if isinstance(node, ast.FunctionDef) and node.name in helper_names + ] + assert len(helper_nodes) == len(helper_names), "Expected all Direct Model core capability helpers." + + kernel = FakeKernel() + chat_service = FakeChatService() + plugin_logger = FakePluginLogger() + loaded_core_plugin_settings = [] + semantic_service_requests = [] + fake_g = SimpleNamespace(conversation_id="previous-conversation") + + @contextmanager + def ensure_execution_context(_user_id): + yield + + namespace = { + "asyncio": asyncio, + "ChatHistory": ChatHistory, + "FunctionChoiceBehavior": FunctionChoiceBehavior, + "Kernel": lambda: kernel, + "PromptExecutionSettings": PromptExecutionSettings, + "_add_workflow_activity_thought": lambda *args, **kwargs: None, + "_build_agent_citations_from_invocations": lambda user_id, conversation_id: [ + {"user_id": user_id, "conversation_id": conversation_id} + ], + "_build_workflow_chat_messages": lambda prompt, **kwargs: [ + {"role": "user", "content": prompt} + ], + "_collect_agent_alert_targets": lambda user_id, conversation_id: [ + {"user_id": user_id, "conversation_id": conversation_id} + ], + "_ensure_execution_context": ensure_execution_context, + "_execute_cancelable_workflow_step": lambda workflow, run_id, operation: operation(), + "_execute_raw_model_workflow": lambda *args, **kwargs: {"reply": "legacy"}, + "_extract_message_text": lambda message_content: str(message_content or ""), + "_get_workflow_group_id": lambda workflow: str(workflow.get("group_id") or ""), + "_raise_if_workflow_run_cancelled": lambda workflow, run_id: None, + "_resolve_model_workflow_client": lambda workflow, settings: ( + object(), + "workflow-deployment", + "aoai", + ), + "build_semantic_kernel_chat_service_for_model": lambda deployment_name, settings, **kwargs: ( + semantic_service_requests.append({ + "deployment_name": deployment_name, + "settings": settings, + **kwargs, + }) or chat_service, + "azure_openai", + ), + "contextmanager": contextmanager, + "g": fake_g, + "get_max_auto_invoke_attempts": lambda settings: int(settings["max_auto_invoke_attempts"]), + "get_plugin_logger": lambda: plugin_logger, + "get_workflow_kernel_settings": lambda settings: {"max_auto_invoke_attempts": 7}, + "load_core_plugins_only": lambda selected_kernel, settings: loaded_core_plugin_settings.append( + (selected_kernel, settings) + ), + "register_plugin_invocation_thought_callback": lambda *args, **kwargs: "callback-key", + } + module = ast.Module(body=helper_nodes, type_ignores=[]) + exec(compile(module, str(RUNNER_FILE), "exec"), namespace) + return namespace, kernel, chat_service, plugin_logger, loaded_core_plugin_settings, semantic_service_requests, fake_g + + +def test_direct_model_workflow_binds_core_capabilities_to_saved_model() -> None: + """Validate direct model core capability execution uses the selected model and kernel.""" + print("Testing Direct Model workflow core capability adapter...") + ( + helpers, + kernel, + chat_service, + plugin_logger, + loaded_core_plugin_settings, + semantic_service_requests, + fake_g, + ) = load_model_core_helpers() + + workflow = { + "id": "workflow-1", + "user_id": "user-1", + "group_id": "group-1", + "task_prompt": "Create a chart from the available tabular data.", + "model_endpoint_id": "endpoint-1", + "model_id": "model-1", + "model_provider": "aoai", + "chat_capabilities_enabled": True, + } + result = helpers["_execute_model_workflow"]( + workflow, + {"enable_semantic_kernel": True}, + conversation_id="workflow-conversation-1", + run_id="run-1", + ) + + assert result["reply"] == "Core capability result" + assert result["core_capabilities_enabled"] is True + assert result["agent_citations"] == [{"user_id": "user-1", "conversation_id": "workflow-conversation-1"}] + assert kernel.services == [chat_service] + assert loaded_core_plugin_settings == [(kernel, {"max_auto_invoke_attempts": 7})] + assert semantic_service_requests[0]["deployment_name"] == "workflow-deployment" + assert semantic_service_requests[0]["model_context"] == { + "user_id": "user-1", + "model_deployment": "workflow-deployment", + "provider": "aoai", + "endpoint_id": "endpoint-1", + "model_id": "model-1", + "active_group_ids": ["group-1"], + } + assert chat_service.call["kwargs"]["kernel"] is kernel + assert chat_service.call["settings"].function_choice_behavior is not None + assert plugin_logger.cleared_conversations == [("user-1", "workflow-conversation-1")] + assert fake_g.conversation_id == "previous-conversation" + assert not hasattr(fake_g, "workflow_id") + + print("PASS: Direct Model workflow uses core capabilities with its saved model") + + +def test_legacy_direct_model_workflow_retains_raw_completion() -> None: + """Legacy workflows without the new flag must keep their existing direct path.""" + print("Testing Direct Model workflow legacy compatibility...") + helpers, *_ = load_model_core_helpers() + + result = helpers["_execute_model_workflow"]( + {"user_id": "user-1", "chat_capabilities_enabled": False}, + {}, + ) + + assert result == {"reply": "legacy"} + print("PASS: legacy Direct Model workflow keeps raw completion") + + +def test_default_model_selection_context_retains_saved_endpoint() -> None: + """Default app model selections must retain their saved endpoint identity.""" + print("Testing default model selection context...") + helpers, *_ = load_model_core_helpers() + + model_context = helpers["_build_workflow_model_context"]( + { + "user_id": "user-1", + "model_binding_summary": { + "mode": "default_selection", + "endpoint_id": "default-endpoint-1", + "model_id": "default-model-1", + "provider": "aoai", + }, + }, + "default-deployment", + "aoai", + ) + + assert model_context["endpoint_id"] == "default-endpoint-1" + assert model_context["model_id"] == "default-model-1" + assert model_context["model_deployment"] == "default-deployment" + print("PASS: default model selection context retains saved endpoint") + + +def run_tests() -> bool: + tests = [ + test_direct_model_workflow_binds_core_capabilities_to_saved_model, + test_legacy_direct_model_workflow_retains_raw_completion, + test_default_model_selection_context_retains_saved_endpoint, + ] + results = [] + + for test in tests: + print(f"Running {test.__name__}...") + try: + test() + results.append(True) + except Exception as exc: + print(f"FAIL: {exc}") + results.append(False) + + print(f"Results: {sum(results)}/{len(results)} tests passed") + return all(results) + + +if __name__ == "__main__": + raise SystemExit(0 if run_tests() else 1) \ No newline at end of file diff --git a/ui_tests/test_workflow_document_action_modal.py b/ui_tests/test_workflow_document_action_modal.py index 9f6613e73..b8b1fead9 100644 --- a/ui_tests/test_workflow_document_action_modal.py +++ b/ui_tests/test_workflow_document_action_modal.py @@ -1,12 +1,12 @@ # test_workflow_document_action_modal.py """ UI test for workflow document action modal. -Version: 0.241.182 -Implemented in: 0.241.103 +Version: 0.250.063 +Implemented in: 0.250.063 -This test ensures the workflow modal exposes the renamed Search/Analyze/Compare -selector states, uses Source/Target wording, and submits version-aware -comparison payloads. It also validates the per-document Analyze mode payload. +This test ensures the workflow modal supports generic no-document automation, +uses Source/Target wording for comparison, and submits version-aware comparison +and per-document Analyze payloads. """ import json @@ -144,6 +144,55 @@ def _open_workflows_tab(page): expect(page.locator("#workflows-tab")).to_be_visible() +@pytest.mark.ui +def test_workflow_modal_saves_generic_automation_without_documents(): + """Validate a workflow can be saved with instructions and a runner only.""" + _require_ui_env() + playwright_sync = _require_playwright() + + with playwright_sync.sync_playwright() as playwright: + browser = playwright.chromium.launch() + context = browser.new_context( + storage_state=STORAGE_STATE, + viewport={"width": 1440, "height": 900}, + ) + page = context.new_page() + workflow_state = {"items": [], "saved_payloads": []} + + _route_workflow_api(page, workflow_state) + _route_agent_api(page) + _route_document_apis(page) + + try: + response = page.goto(f"{BASE_URL}/workspace", wait_until="networkidle") + assert response is not None and response.ok, "Expected /workspace to load successfully." + + _open_workflows_tab(page) + page.get_by_role("button", name="New Workflow").click() + expect(page.locator("#workflowModal")).to_be_visible() + + action_options = page.locator("#workflow-document-action-type option").all_text_contents() + assert action_options[:2] == ["No document action", "Search"] + expect(page.locator("#workflow-document-action-type")).to_have_value("none") + expect(page.locator("#workflow-document-targets-fields")).to_be_hidden() + expect(page.locator("#workflow-analysis-retries-group")).to_be_hidden() + + page.fill("#workflow-name", "Scheduled Status Summary") + page.fill("#workflow-task-prompt", "Summarize the current status and propose next steps.") + page.click("#workflow-save-btn") + + assert workflow_state["saved_payloads"], "Expected the workflow save handler to capture the modal payload." + saved_payload = workflow_state["saved_payloads"][0] + assert saved_payload["runner_type"] == "model" + assert saved_payload["chat_capabilities_enabled"] is True + assert saved_payload["document_action"]["type"] == "none" + assert saved_payload["document_action"]["document_ids"] == [] + assert saved_payload["analyze"]["enabled"] is False + finally: + context.close() + browser.close() + + @pytest.mark.ui def test_workflow_document_action_modal_comparison(): """Validate the workflow modal shows the updated action labels and saves compare payloads.""" @@ -173,10 +222,10 @@ def test_workflow_document_action_modal_comparison(): expect(page.locator("#workflowModal")).to_be_visible() action_options = page.locator("#workflow-document-action-type option").all_text_contents() - assert action_options[:3] == ["Search", "Analyze", "Compare"] + assert action_options[:4] == ["No document action", "Search", "Analyze", "Compare"] expect(page.locator("#workflow-document-action-type")).to_have_attribute( "title", - "Find relevant information with the normal prompt flow instead of binding the workflow to fixed document targets.", + "Run the workflow instructions without workspace document context.", ) page.fill("#workflow-name", "Compare Contract Baseline") From 79148c849ed5d3733429013b5fadb908c1439da5 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Mon, 27 Jul 2026 17:26:49 -0400 Subject: [PATCH 2/3] Add stepped workflow builder and task sequences Refs #1082 --- application/single_app/config.py | 2 +- .../single_app/functions_group_workflows.py | 12 +- .../functions_personal_workflows.py | 87 ++- .../single_app/functions_workflow_runner.py | 511 +++++++++++++++--- .../static/css/workspace-responsive.css | 229 ++++++++ .../js/workspace/workspace_workflows.js | 402 +++++++++++++- .../templates/group_workspaces.html | 85 ++- .../single_app/templates/workspace.html | 85 ++- .../features/GENERIC_WORKFLOW_AUTOMATION.md | 43 +- .../test_generic_workflow_automation.py | 11 +- .../test_workflow_model_core_capabilities.py | 3 +- .../test_workflow_stepped_builder.py | 119 ++++ .../test_workflow_task_sequence.py | 238 ++++++++ .../test_workflow_document_action_modal.py | 63 ++- 14 files changed, 1735 insertions(+), 155 deletions(-) create mode 100644 functional_tests/test_workflow_stepped_builder.py create mode 100644 functional_tests/test_workflow_task_sequence.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 9f2465127..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.063" +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 07733bd4f..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,7 @@ 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 @@ -474,6 +482,8 @@ 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, diff --git a/application/single_app/functions_personal_workflows.py b/application/single_app/functions_personal_workflows.py index 2365c1b27..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,7 @@ 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 @@ -602,6 +685,8 @@ 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, diff --git a/application/single_app/functions_workflow_runner.py b/application/single_app/functions_workflow_runner.py index 9a906cddf..5c20a086e 100644 --- a/application/single_app/functions_workflow_runner.py +++ b/application/single_app/functions_workflow_runner.py @@ -132,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): @@ -4349,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 []) @@ -5953,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, @@ -6161,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, @@ -6173,97 +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, - conversation_id=str(conversation.get('id') or ''), - 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) @@ -6311,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 a90ae374b..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"; @@ -238,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 []; } @@ -294,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(); @@ -316,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 ""; @@ -2297,6 +2637,7 @@ function resetWorkflowForm() { if (workflowTaskPromptInput) { workflowTaskPromptInput.value = ""; } + initializeWorkflowTasks(); if (workflowUrlAccessEnabledToggle) { workflowUrlAccessEnabledToggle.checked = false; } @@ -2334,6 +2675,12 @@ 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_NONE; } @@ -2397,6 +2744,7 @@ function resetWorkflowForm() { updateTriggerFields(); updateFileSyncFields(); updateDocumentActionFields(); + showWorkflowStep(0); } async function openWorkflowModal(workflow = null) { @@ -2441,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); @@ -2517,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); @@ -2533,11 +2891,20 @@ 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_NONE; @@ -2571,7 +2938,12 @@ 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 @@ -2634,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(); @@ -3280,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) => { @@ -3312,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 407e82324..25990e6e5 100644 --- a/application/single_app/templates/group_workspaces.html +++ b/application/single_app/templates/group_workspaces.html @@ -2037,15 +2037,25 @@