From c9e170b352e1a8cd93cf6bfbb121069b7731e468 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Tue, 28 Jul 2026 09:04:30 -0400 Subject: [PATCH] Add task-level workflow runners (#1084) --- application/single_app/config.py | 2 +- .../single_app/functions_group_workflows.py | 73 ++- .../functions_personal_workflows.py | 87 ++- .../single_app/functions_workflow_runner.py | 204 ++++++- .../js/workspace/workspace_workflows.js | 395 ++++++++++++- .../templates/group_workspaces.html | 36 +- .../single_app/templates/workspace.html | 36 +- .../features/GENERIC_WORKFLOW_AUTOMATION.md | 75 ++- docs/explanation/release_notes.md | 18 + .../test_generic_workflow_automation.py | 6 +- .../test_workflow_stepped_builder.py | 23 +- .../test_workflow_task_sequence.py | 538 +++++++++++++++++- .../test_workflow_document_action_modal.py | 156 ++++- 13 files changed, 1579 insertions(+), 70 deletions(-) diff --git a/application/single_app/config.py b/application/single_app/config.py index b84e4bce5..47dc23320 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.064" +VERSION = "0.250.065" 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 c015c1c46..1c54eca45 100644 --- a/application/single_app/functions_group_workflows.py +++ b/application/single_app/functions_group_workflows.py @@ -25,13 +25,14 @@ sanitize_file_sync_source, ) from functions_global_agents import get_global_agents -from functions_group import get_group_model_endpoints +from functions_group import assert_group_role, get_group_model_endpoints from functions_group_agents import get_group_agents from functions_personal_workflows import ( WORKFLOW_FILE_SYNC_CONTINUE_MODES, WORKFLOW_FILE_SYNC_MAX_SOURCES, WORKFLOW_FILE_SYNC_WAIT_MODES, WORKFLOW_RUNNER_TYPES, + WORKFLOW_TASK_RUNNER_TYPES, WORKFLOW_TRIGGER_TYPES, _build_default_model_summary, _normalize_alert_priority, @@ -219,8 +220,14 @@ def scope_matches(candidate): return None -def _normalize_selected_agent(group_id, settings, requested_agent): - candidates = _build_selectable_agents(group_id, settings, requested_agent=requested_agent) +def _normalize_selected_agent(group_id, settings, requested_agent, strict_permissions=False): + candidates = _build_selectable_agents( + group_id, + settings, + requested_agent=None if strict_permissions else requested_agent, + ) + if strict_permissions: + candidates = [candidate for candidate in candidates if candidate.get('is_enabled', True)] matched_agent = _find_matching_agent(candidates, requested_agent, group_id) if not matched_agent: raise ValueError('Select a valid group or merged global agent.') @@ -301,6 +308,55 @@ def _summarize_model_binding(candidates, endpoint_id, model_id): } +def normalize_group_workflow_task_runner(actor_user_id, group_id, requested_runner, settings=None): + """Resolve a task runner against the group's currently authorized options.""" + assert_group_role( + actor_user_id, + group_id, + allowed_roles=GROUP_WORKFLOW_MEMBER_ROLES, + ) + requested_runner = requested_runner if isinstance(requested_runner, dict) else {} + runner_type = _normalize_text(requested_runner.get('type') or 'inherit', 'Task runner type').lower() + if runner_type not in WORKFLOW_TASK_RUNNER_TYPES: + raise ValueError('Task runner type must be inherit, model, or agent.') + if runner_type == 'inherit': + return {'type': 'inherit'} + + settings = settings or get_settings() + if runner_type == 'agent': + if not settings.get('enable_semantic_kernel', False): + raise ValueError('Agents must be enabled before selecting a task agent.') + if not settings.get('allow_group_agents', False): + raise ValueError('Group agents must be enabled before selecting a task agent.') + selected_agent = _normalize_selected_agent( + group_id, + settings, + requested_runner.get('selected_agent'), + strict_permissions=True, + ) + return { + 'type': 'agent', + 'selected_agent': selected_agent, + } + + model_endpoint_id = _normalize_text(requested_runner.get('model_endpoint_id'), 'Task model endpoint') + model_id = _normalize_text(requested_runner.get('model_id'), 'Task model') + model_binding_summary = _summarize_model_binding( + _build_model_endpoint_candidates(group_id, settings), + model_endpoint_id, + model_id, + ) + if not model_binding_summary: + raise ValueError('Select a model endpoint and model for the task override.') + return { + 'type': 'model', + 'model_endpoint_id': model_endpoint_id, + 'model_id': model_id, + 'model_provider': str(model_binding_summary.get('provider') or '').strip().lower(), + 'model_binding_summary': model_binding_summary, + } + + def get_group_workflows(group_id): """Fetch all workflows for a group.""" try: @@ -383,7 +439,16 @@ 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') - tasks = _normalize_workflow_tasks(workflow_data, existing_workflow=existing_workflow) + tasks = _normalize_workflow_tasks( + workflow_data, + existing_workflow=existing_workflow, + task_runner_normalizer=lambda runner: normalize_group_workflow_task_runner( + actor_user_id, + group_id, + runner, + settings=settings, + ), + ) task_prompt = _normalize_text( workflow_data.get('task_prompt') or (tasks[0].get('instructions') if tasks else ''), 'Task prompt', diff --git a/application/single_app/functions_personal_workflows.py b/application/single_app/functions_personal_workflows.py index bb0918e0f..6034f4c2e 100644 --- a/application/single_app/functions_personal_workflows.py +++ b/application/single_app/functions_personal_workflows.py @@ -49,6 +49,7 @@ WORKFLOW_MAX_TASKS = 20 WORKFLOW_TASK_INSTRUCTIONS_MAX_LENGTH = 12000 WORKFLOW_TASK_NAME_MAX_LENGTH = 120 +WORKFLOW_TASK_RUNNER_TYPES = {'inherit', 'agent', 'model'} WORKFLOW_CONVERSATION_ACCESS_ERROR = 'Workflow conversation not found or access denied.' @@ -138,16 +139,16 @@ def _normalize_alert_priority(value): return normalized -def _normalize_workflow_tasks(workflow_data, existing_workflow=None): +def _normalize_workflow_tasks(workflow_data, existing_workflow=None, task_runner_normalizer=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') + tasks_supplied = 'tasks' in workflow_data + raw_tasks = workflow_data.get('tasks') if tasks_supplied else existing_workflow.get('tasks') or [] if not isinstance(raw_tasks, list): raise ValueError('Workflow tasks must be a list.') if not raw_tasks: + if not tasks_supplied: + return [] 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.') @@ -177,12 +178,24 @@ def _normalize_workflow_tasks(workflow_data, existing_workflow=None): f'Task instructions must be {WORKFLOW_TASK_INSTRUCTIONS_MAX_LENGTH} characters or fewer.' ) + raw_runner = raw_task.get('runner') if isinstance(raw_task.get('runner'), dict) else {} + runner_type = _normalize_text(raw_runner.get('type') or 'inherit', 'Task runner type').lower() + if runner_type not in WORKFLOW_TASK_RUNNER_TYPES: + raise ValueError(f'Workflow task {index + 1} has an unsupported runner type.') + if runner_type == 'inherit': + runner = {'type': 'inherit'} + elif callable(task_runner_normalizer): + runner = task_runner_normalizer(raw_runner) + else: + raise ValueError(f'Workflow task {index + 1} runner could not be authorized.') + normalized_tasks.append({ 'id': task_id, 'type': task_type, 'name': name, 'instructions': instructions, 'order': index + 1, + 'runner': runner, }) return normalized_tasks @@ -371,8 +384,14 @@ def scope_matches(candidate): return None -def _normalize_selected_agent(user_id, settings, requested_agent): - candidates = _build_selectable_agents(user_id, settings, requested_agent=requested_agent) +def _normalize_selected_agent(user_id, settings, requested_agent, strict_permissions=False): + candidates = _build_selectable_agents( + user_id, + settings, + requested_agent=None if strict_permissions else requested_agent, + ) + if strict_permissions: + candidates = [candidate for candidate in candidates if candidate.get('is_enabled', True)] matched_agent = _find_matching_agent(candidates, requested_agent) if not matched_agent: raise ValueError('Select a valid personal or merged global agent.') @@ -493,6 +512,50 @@ def _summarize_model_binding(candidates, endpoint_id, model_id): } +def normalize_personal_workflow_task_runner(user_id, requested_runner, settings=None): + """Resolve a task runner against the user's currently authorized options.""" + requested_runner = requested_runner if isinstance(requested_runner, dict) else {} + runner_type = _normalize_text(requested_runner.get('type') or 'inherit', 'Task runner type').lower() + if runner_type not in WORKFLOW_TASK_RUNNER_TYPES: + raise ValueError('Task runner type must be inherit, model, or agent.') + if runner_type == 'inherit': + return {'type': 'inherit'} + + settings = settings or get_settings() + if runner_type == 'agent': + if not settings.get('enable_semantic_kernel', False): + raise ValueError('Agents must be enabled before selecting a task agent.') + if not settings.get('allow_user_agents', False): + raise ValueError('User agents must be enabled before selecting a task agent.') + selected_agent = _normalize_selected_agent( + user_id, + settings, + requested_runner.get('selected_agent'), + strict_permissions=True, + ) + return { + 'type': 'agent', + 'selected_agent': selected_agent, + } + + model_endpoint_id = _normalize_text(requested_runner.get('model_endpoint_id'), 'Task model endpoint') + model_id = _normalize_text(requested_runner.get('model_id'), 'Task model') + model_binding_summary = _summarize_model_binding( + _build_model_endpoint_candidates(user_id, settings), + model_endpoint_id, + model_id, + ) + if not model_binding_summary: + raise ValueError('Select a model endpoint and model for the task override.') + return { + 'type': 'model', + 'model_endpoint_id': model_endpoint_id, + 'model_id': model_id, + 'model_provider': str(model_binding_summary.get('provider') or '').strip().lower(), + 'model_binding_summary': model_binding_summary, + } + + def compute_next_run_at(workflow, from_time=None): """Return the next scheduled run timestamp for a scheduled workflow.""" workflow = workflow if isinstance(workflow, dict) else {} @@ -595,7 +658,15 @@ 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') - tasks = _normalize_workflow_tasks(workflow_data, existing_workflow=existing_workflow) + tasks = _normalize_workflow_tasks( + workflow_data, + existing_workflow=existing_workflow, + task_runner_normalizer=lambda runner: normalize_personal_workflow_task_runner( + user_id, + runner, + settings=settings, + ), + ) task_prompt = _normalize_text( workflow_data.get('task_prompt') or (tasks[0].get('instructions') if tasks else ''), 'Task prompt', diff --git a/application/single_app/functions_workflow_runner.py b/application/single_app/functions_workflow_runner.py index 5c20a086e..c7104a8d6 100644 --- a/application/single_app/functions_workflow_runner.py +++ b/application/single_app/functions_workflow_runner.py @@ -78,6 +78,7 @@ get_group_workflow, get_group_workflow_run, list_group_workflow_run_items, + normalize_group_workflow_task_runner, save_group_workflow_run, save_group_workflow_run_item, ) @@ -99,6 +100,7 @@ get_personal_workflow, get_personal_workflow_run, list_personal_workflow_run_items, + normalize_personal_workflow_task_runner, save_personal_workflow_run, save_personal_workflow_run_item, ) @@ -5872,6 +5874,8 @@ def _execute_agent_workflow(workflow, settings, conversation_id='', run_id=None, requested_name = str(selected_agent.get('name') or '').strip() if requested_name: loaded_agent = agent_objs.get(requested_name) + if loaded_agent is None and workflow.get('task_runner_override'): + raise ValueError('The selected task agent is no longer available for workflow execution.') if loaded_agent is None: loaded_agent = next(iter(agent_objs.values())) @@ -6001,6 +6005,117 @@ def _build_workflow_task_execution_workflow(workflow, task, previous_reply='', i return prepared_workflow +def _get_workflow_task_requested_runner_mode(task): + task = task if isinstance(task, dict) else {} + runner = task.get('runner') if isinstance(task.get('runner'), dict) else {} + return str(runner.get('type') or 'inherit').strip().lower() or 'inherit' + + +def _build_workflow_task_runner_audit(workflow, requested_mode, normalized_runner, execution_result=None): + workflow = workflow if isinstance(workflow, dict) else {} + normalized_runner = normalized_runner if isinstance(normalized_runner, dict) else {'type': 'inherit'} + requested_mode = str(requested_mode or 'inherit').strip().lower() or 'inherit' + inherited = normalized_runner.get('type') == 'inherit' + resolved_type = str(workflow.get('runner_type') if inherited else normalized_runner.get('type') or '').strip().lower() + runner_source = workflow if inherited else normalized_runner + audit = { + 'requested_mode': requested_mode, + 'resolved_type': resolved_type, + } + + if resolved_type == 'model': + binding_summary = ( + runner_source.get('model_binding_summary') + if isinstance(runner_source.get('model_binding_summary'), dict) + else {} + ) + endpoint_id = str(runner_source.get('model_endpoint_id') or binding_summary.get('endpoint_id') or '').strip() + model_id = str(runner_source.get('model_id') or binding_summary.get('model_id') or '').strip() + if endpoint_id: + audit['model_endpoint_id'] = endpoint_id + if model_id: + audit['model_id'] = model_id + elif resolved_type == 'agent': + selected_agent = runner_source.get('selected_agent') if isinstance(runner_source.get('selected_agent'), dict) else {} + agent_id = str(selected_agent.get('id') or '').strip() + agent_name = str(selected_agent.get('name') or '').strip() + if agent_id: + audit['agent_id'] = agent_id + if agent_name: + audit['agent_name'] = agent_name + if selected_agent.get('is_global'): + audit['agent_scope'] = 'global' + elif selected_agent.get('is_group'): + audit['agent_scope'] = 'group' + agent_group_id = str(selected_agent.get('group_id') or '').strip() + if agent_group_id: + audit['agent_group_id'] = agent_group_id + else: + audit['agent_scope'] = 'personal' + + execution_result = execution_result if isinstance(execution_result, dict) else {} + model_deployment_name = str(execution_result.get('model_deployment_name') or '').strip() + provider = str(execution_result.get('provider') or '').strip().lower() + if model_deployment_name: + audit['model_deployment_name'] = model_deployment_name + if provider: + audit['provider'] = provider + return audit + + +def _resolve_workflow_task_runner(workflow, task, settings, actor_user_id=None): + workflow = workflow if isinstance(workflow, dict) else {} + task = task if isinstance(task, dict) else {} + requested_runner = task.get('runner') if isinstance(task.get('runner'), dict) else {'type': 'inherit'} + requested_mode = _get_workflow_task_requested_runner_mode(task) + group_id = _get_workflow_group_id(workflow) + if group_id: + current_actor_user_id = str(actor_user_id or '').strip() + if not current_actor_user_id: + raise ValueError('Group workflow task runner resolution requires an actor user id.') + normalized_runner = normalize_group_workflow_task_runner( + current_actor_user_id, + group_id, + requested_runner, + settings=settings, + ) + else: + normalized_runner = normalize_personal_workflow_task_runner( + str(workflow.get('user_id') or '').strip(), + requested_runner, + settings=settings, + ) + + execution_workflow = dict(workflow) + if normalized_runner.get('type') == 'model': + execution_workflow.update({ + 'runner_type': 'model', + 'task_runner_override': True, + 'selected_agent': {}, + 'model_endpoint_id': normalized_runner.get('model_endpoint_id') or '', + 'model_id': normalized_runner.get('model_id') or '', + 'model_provider': normalized_runner.get('model_provider') or '', + 'model_binding_summary': normalized_runner.get('model_binding_summary') or {}, + }) + elif normalized_runner.get('type') == 'agent': + execution_workflow.update({ + 'runner_type': 'agent', + 'task_runner_override': True, + 'selected_agent': normalized_runner.get('selected_agent') or {}, + 'model_endpoint_id': '', + 'model_id': '', + 'model_provider': '', + 'model_binding_summary': None, + }) + + runner_audit = _build_workflow_task_runner_audit( + workflow, + requested_mode, + normalized_runner, + ) + return execution_workflow, runner_audit + + def _workflow_task_run_item_id(run_id, task_id): return str(uuid.uuid5(uuid.NAMESPACE_URL, f'workflow-task:{run_id}:{task_id}')) @@ -6014,10 +6129,37 @@ def _save_workflow_task_run_item( error='', output_summary='', created_at=None, + runner_audit=None, + token_usage=None, ): task = task if isinstance(task, dict) else {} task_id = str(task.get('id') or '').strip() now_iso = _utc_now_iso() + output_preview = str(output_summary or '')[:4000] + allowed_runner_audit_fields = ( + 'requested_mode', + 'resolved_type', + 'model_endpoint_id', + 'model_id', + 'agent_id', + 'agent_name', + 'agent_scope', + 'agent_group_id', + 'model_deployment_name', + 'provider', + ) + runner_audit = runner_audit if isinstance(runner_audit, dict) else {} + safe_runner_audit = { + field: str(runner_audit.get(field) or '').strip()[:500] + for field in allowed_runner_audit_fields + if str(runner_audit.get(field) or '').strip() + } + token_usage = token_usage if isinstance(token_usage, dict) else {} + safe_token_usage = { + field: int(token_usage.get(field) or 0) + for field in ('prompt_tokens', 'completion_tokens', 'total_tokens', 'request_count') + if isinstance(token_usage.get(field), (int, float)) + } item = { 'id': _workflow_task_run_item_id(run_id, task_id), 'type': 'workflow_run_item', @@ -6034,12 +6176,15 @@ def _save_workflow_task_run_item( 'status': status, 'attempt_count': int(attempt_count or 0), 'error': str(error or '')[:2000], - 'output_summary': str(output_summary or '')[:4000], + 'output_summary': output_preview, + 'output_preview': output_preview, + 'runner': safe_runner_audit, + 'token_usage': safe_token_usage or None, 'created_at': created_at or now_iso, 'updated_at': now_iso, } - if status == 'running': - item['started_at'] = now_iso + if status in {'running', 'succeeded', 'failed', 'skipped', 'cancelled'}: + item['started_at'] = created_at or now_iso if status in {'succeeded', 'failed', 'skipped', 'cancelled'}: item['completed_at'] = now_iso return _save_workflow_run_item_record(workflow, item) @@ -6169,6 +6314,8 @@ def _merge_workflow_task_execution_results(task_results): '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')), + 'runner': dict(item.get('runner') or {}), + 'token_usage': _merge_token_usage_summaries([item.get('result') or {}]), } for item in task_results ] @@ -6206,6 +6353,7 @@ def _execute_workflow_task_sequence( thought_tracker, url_access_context, file_sync_result=None, + actor_user_id=None, ): tasks = list(workflow.get('tasks') or []) error_handling = workflow.get('error_handling') if isinstance(workflow.get('error_handling'), dict) else {} @@ -6220,7 +6368,17 @@ def _execute_workflow_task_sequence( 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) + runner_audit = { + 'requested_mode': _get_workflow_task_requested_runner_mode(task), + } + _save_workflow_task_run_item( + workflow, + run_id, + task, + 'queued', + created_at=created_at, + runner_audit=runner_audit, + ) if thought_tracker and run_id: _add_workflow_activity_thought( thought_tracker, @@ -6246,17 +6404,24 @@ def _execute_workflow_task_sequence( 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( + attempt_workflow, runner_audit = _resolve_workflow_task_runner( prepared_workflow, + task, + settings, + actor_user_id=actor_user_id, + ) + _save_workflow_task_run_item( + workflow, + run_id, + task, + 'running', + attempt_count=attempt_count, + created_at=created_at, + runner_audit=runner_audit, + ) + task_result = _execute_workflow_dispatch( + attempt_workflow, settings, conversation_id, run_id, @@ -6265,6 +6430,13 @@ def _execute_workflow_task_sequence( file_sync_result=file_sync_result, ) task_error = '' + runner_audit = dict(runner_audit) + model_deployment_name = str(task_result.get('model_deployment_name') or '').strip() + provider = str(task_result.get('provider') or '').strip().lower() + if model_deployment_name: + runner_audit['model_deployment_name'] = model_deployment_name + if provider: + runner_audit['provider'] = provider break except Exception as exc: task_error = str(exc) @@ -6293,6 +6465,8 @@ def _execute_workflow_task_sequence( attempt_count=attempt_count, output_summary=_build_response_preview(task_result.get('reply'), max_length=4000), created_at=created_at, + runner_audit=runner_audit, + token_usage=_merge_token_usage_summaries([task_result]), ) task_results.append({ 'task': task, @@ -6300,6 +6474,7 @@ def _execute_workflow_task_sequence( 'attempt_count': attempt_count, 'result': task_result, 'error': '', + 'runner': runner_audit, }) if thought_tracker and run_id: _add_workflow_activity_thought( @@ -6325,6 +6500,7 @@ def _execute_workflow_task_sequence( attempt_count=attempt_count, error=task_error, created_at=created_at, + runner_audit=runner_audit, ) task_results.append({ 'task': task, @@ -6332,6 +6508,7 @@ def _execute_workflow_task_sequence( 'attempt_count': attempt_count, 'result': {}, 'error': task_error, + 'runner': runner_audit, }) if thought_tracker and run_id: _add_workflow_activity_thought( @@ -6592,6 +6769,7 @@ def run_personal_workflow(workflow, trigger_source='manual', user_roles=None, ac thought_tracker, url_access_context, file_sync_result=file_sync_result, + actor_user_id=actor_user_id or user_id, ) else: execution_result = _execute_workflow_dispatch( diff --git a/application/single_app/static/js/workspace/workspace_workflows.js b/application/single_app/static/js/workspace/workspace_workflows.js index 8f37c2511..503e11e60 100644 --- a/application/single_app/static/js/workspace/workspace_workflows.js +++ b/application/single_app/static/js/workspace/workspace_workflows.js @@ -91,6 +91,14 @@ const workflowTaskBriefInput = document.getElementById("workflow-task-brief"); const workflowDraftInstructionsBtn = document.getElementById("workflow-draft-instructions-btn"); const workflowDraftInstructionsStatus = document.getElementById("workflow-draft-instructions-status"); const workflowTaskPromptInput = document.getElementById("workflow-task-prompt"); +const workflowTaskRunnerTypeSelect = document.getElementById("workflow-task-runner-type"); +const workflowTaskModelFields = document.getElementById("workflow-task-model-fields"); +const workflowTaskModelSourceSelect = document.getElementById("workflow-task-model-source"); +const workflowTaskModelEndpointSelect = document.getElementById("workflow-task-model-endpoint"); +const workflowTaskModelSelect = document.getElementById("workflow-task-model"); +const workflowTaskAgentFields = document.getElementById("workflow-task-agent-fields"); +const workflowTaskAgentSelect = document.getElementById("workflow-task-agent"); +const workflowTaskAgentHelp = document.getElementById("workflow-task-agent-help"); const workflowUrlAccessEnabledToggle = document.getElementById("workflow-url-access-enabled"); const workflowRunnerTypeSelect = document.getElementById("workflow-runner-type"); const workflowAgentFields = document.getElementById("workflow-agent-fields"); @@ -124,7 +132,7 @@ const workflowTaskRetryCountInput = document.getElementById("workflow-task-retry 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.", + general: "Name the workflow and choose its default runner.", trigger: "Choose when the workflow runs and configure optional inputs.", tasks: "Build the ordered instruction sequence.", reliability: "Choose retry and failure behavior.", @@ -353,6 +361,271 @@ function getActiveWorkflowTask() { return workflowTasks.find((task) => task.id === activeWorkflowTaskId) || null; } +function normalizeWorkflowTaskRunner(runner) { + const source = runner && typeof runner === "object" ? runner : {}; + const runnerType = ["model", "agent"].includes(normalizeText(source.type)) + ? normalizeText(source.type) + : "inherit"; + if (runnerType === "model") { + return { + type: "model", + model_endpoint_id: normalizeText(source.model_endpoint_id), + model_id: normalizeText(source.model_id), + }; + } + if (runnerType === "agent") { + const selectedAgent = source.selected_agent && typeof source.selected_agent === "object" + ? source.selected_agent + : {}; + return { + type: "agent", + selected_agent: { + id: normalizeText(selectedAgent.id), + name: normalizeText(selectedAgent.name), + display_name: normalizeText(selectedAgent.display_name), + is_global: Boolean(selectedAgent.is_global), + is_group: Boolean(selectedAgent.is_group), + group_id: normalizeText(selectedAgent.group_id), + }, + }; + } + return { type: "inherit" }; +} + +function serializeWorkflowTaskRunner(runner) { + const normalizedRunner = normalizeWorkflowTaskRunner(runner); + if (normalizedRunner.type === "model") { + return normalizedRunner; + } + if (normalizedRunner.type === "agent") { + return { + type: "agent", + selected_agent: { + id: normalizedRunner.selected_agent.id, + name: normalizedRunner.selected_agent.name, + is_global: normalizedRunner.selected_agent.is_global, + is_group: normalizedRunner.selected_agent.is_group, + group_id: normalizedRunner.selected_agent.group_id, + }, + }; + } + return { type: "inherit" }; +} + +function getAgentScopeLabel(agent) { + if (agent?.is_global) { + return "Global Agent"; + } + if (agent?.is_group) { + return "Group Agent"; + } + return "Personal Agent"; +} + +function getWorkflowDefaultRunnerSummary() { + if (normalizeText(workflowRunnerTypeSelect?.value) === "agent") { + const agent = getSelectedAgentOption(); + const label = normalizeText(agent?.display_name || agent?.name) || "Unavailable agent"; + return `${label} (${getAgentScopeLabel(agent)})`; + } + if (normalizeText(workflowModelSourceSelect?.value) === "custom") { + const endpoint = getSelectedEndpointOption(); + const modelId = normalizeText(workflowModelSelect?.value); + const model = endpoint?.models?.find((candidate) => normalizeText(candidate.id) === modelId); + if (endpoint && model) { + return `${getEndpointDisplayName(endpoint)} / ${getModelDisplayName(model)}`; + } + } + return "Direct Model (app default)"; +} + +function getWorkflowTaskRunnerSummary(task) { + const runner = normalizeWorkflowTaskRunner(task?.runner); + if (runner.type === "inherit") { + return `Workflow default: ${getWorkflowDefaultRunnerSummary()}`; + } + if (runner.type === "agent") { + const selectedAgent = runner.selected_agent; + const authorizedAgent = agentOptions.find( + (agent) => getAgentOptionKey(agent) === getAgentOptionKey(selectedAgent) + ); + const agent = authorizedAgent || selectedAgent; + const label = normalizeText(agent?.display_name || agent?.name || agent?.id) || "Unavailable agent"; + return `Agent: ${label} (${getAgentScopeLabel(agent)})${authorizedAgent ? "" : " - unavailable"}`; + } + + const endpoint = getCustomEndpointOptions().find( + (candidate) => normalizeText(candidate.id) === runner.model_endpoint_id + ); + const model = endpoint?.models?.find((candidate) => normalizeText(candidate.id) === runner.model_id); + if (endpoint && model) { + return `Direct Model: ${getEndpointDisplayName(endpoint)} / ${getModelDisplayName(model)}`; + } + const fallback = [runner.model_endpoint_id, runner.model_id].filter(Boolean).join(" / ") || "Not selected"; + return `Direct Model: ${fallback} - unavailable`; +} + +function getSelectedWorkflowTaskAgentOption() { + const selectedKey = normalizeText(workflowTaskAgentSelect?.value); + return agentOptions.find((agent) => getAgentOptionKey(agent) === selectedKey) || null; +} + +function populateWorkflowTaskAgentSelect(selectedAgent = null) { + if (!workflowTaskAgentSelect) { + return; + } + const selectedKey = selectedAgent ? getAgentOptionKey(selectedAgent) : ""; + const options = [...agentOptions].sort((left, right) => { + const leftLabel = normalizeText(left.display_name || left.name).toLowerCase(); + const rightLabel = normalizeText(right.display_name || right.name).toLowerCase(); + return leftLabel.localeCompare(rightLabel); + }); + clearElementChildren(workflowTaskAgentSelect); + options.forEach((agent) => { + const option = document.createElement("option"); + option.value = getAgentOptionKey(agent); + option.textContent = `${normalizeText(agent.display_name || agent.name) || "Unnamed Agent"} (${getAgentScopeLabel(agent)})`; + option.selected = option.value === selectedKey; + workflowTaskAgentSelect.appendChild(option); + }); + if (selectedAgent && !options.some((agent) => getAgentOptionKey(agent) === selectedKey)) { + const option = document.createElement("option"); + option.value = selectedKey; + option.textContent = `${normalizeText(selectedAgent.display_name || selectedAgent.name || selectedAgent.id) || "Current Agent"} (Unavailable)`; + option.selected = true; + option.disabled = true; + workflowTaskAgentSelect.appendChild(option); + } + if (!workflowTaskAgentSelect.options.length) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = "No agents available"; + workflowTaskAgentSelect.appendChild(option); + } + workflowTaskAgentSelect.disabled = options.length === 0; + if (workflowTaskAgentHelp) { + workflowTaskAgentHelp.textContent = options.length + ? workflowWorkspaceConfig.scope === "group" + ? "Choose an authorized group or merged global agent." + : "Choose an authorized personal or merged global agent." + : "No authorized agents are currently available."; + } +} + +function getWorkflowTaskModelEndpoints(source = "") { + const normalizedSource = normalizeText(source) || "global"; + return getCustomEndpointOptions().filter((endpoint) => ( + normalizedSource === "global" ? endpoint.scope === "global" : endpoint.scope !== "global" + )); +} + +function populateWorkflowTaskModelSourceSelect(selectedEndpointId = "") { + if (!workflowTaskModelSourceSelect) { + return "global"; + } + const allEndpoints = getCustomEndpointOptions(); + const selectedEndpoint = allEndpoints.find( + (endpoint) => normalizeText(endpoint.id) === normalizeText(selectedEndpointId) + ); + const globalOption = Array.from(workflowTaskModelSourceSelect.options).find((option) => option.value === "global"); + const workspaceOption = Array.from(workflowTaskModelSourceSelect.options).find((option) => option.value === "workspace"); + if (workspaceOption) { + workspaceOption.textContent = `${normalizeText(workflowWorkspaceConfig.workspaceEndpointLabel) || "Workspace"} endpoints`; + } + if (globalOption) { + globalOption.disabled = !allEndpoints.some((endpoint) => endpoint.scope === "global"); + } + if (workspaceOption) { + workspaceOption.disabled = !allEndpoints.some((endpoint) => endpoint.scope !== "global"); + } + + let source = selectedEndpoint + ? selectedEndpoint.scope === "global" ? "global" : "workspace" + : normalizeText(workflowTaskModelSourceSelect.value) || "global"; + if (!getWorkflowTaskModelEndpoints(source).length) { + source = getWorkflowTaskModelEndpoints("global").length ? "global" : "workspace"; + } + workflowTaskModelSourceSelect.value = source; + return source; +} + +function populateWorkflowTaskModelEndpointSelect(selectedEndpointId = "") { + if (!workflowTaskModelEndpointSelect) { + return ""; + } + const endpoints = getWorkflowTaskModelEndpoints(workflowTaskModelSourceSelect?.value); + clearElementChildren(workflowTaskModelEndpointSelect); + endpoints.forEach((endpoint, index) => { + const option = document.createElement("option"); + option.value = normalizeText(endpoint.id); + option.textContent = getEndpointDisplayName(endpoint); + option.selected = (selectedEndpointId && option.value === selectedEndpointId) || (!selectedEndpointId && index === 0); + workflowTaskModelEndpointSelect.appendChild(option); + }); + if (selectedEndpointId && !endpoints.some((endpoint) => normalizeText(endpoint.id) === selectedEndpointId)) { + const option = document.createElement("option"); + option.value = selectedEndpointId; + option.textContent = `${selectedEndpointId} (Unavailable)`; + option.selected = true; + option.disabled = true; + workflowTaskModelEndpointSelect.appendChild(option); + } + if (!workflowTaskModelEndpointSelect.options.length) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = "No endpoints available"; + workflowTaskModelEndpointSelect.appendChild(option); + } + workflowTaskModelEndpointSelect.disabled = endpoints.length === 0; + return normalizeText(workflowTaskModelEndpointSelect.value); +} + +function populateWorkflowTaskModelSelect(selectedModelId = "") { + if (!workflowTaskModelSelect) { + return; + } + const endpointId = normalizeText(workflowTaskModelEndpointSelect?.value); + const endpoint = getCustomEndpointOptions().find((candidate) => normalizeText(candidate.id) === endpointId); + const models = Array.isArray(endpoint?.models) ? endpoint.models : []; + clearElementChildren(workflowTaskModelSelect); + models.forEach((model, index) => { + const option = document.createElement("option"); + option.value = normalizeText(model.id); + option.textContent = getModelDisplayName(model); + option.selected = (selectedModelId && option.value === selectedModelId) || (!selectedModelId && index === 0); + workflowTaskModelSelect.appendChild(option); + }); + if (selectedModelId && !models.some((model) => normalizeText(model.id) === selectedModelId)) { + const option = document.createElement("option"); + option.value = selectedModelId; + option.textContent = `${selectedModelId} (Unavailable)`; + option.selected = true; + option.disabled = true; + workflowTaskModelSelect.appendChild(option); + } + if (!workflowTaskModelSelect.options.length) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = "No models available"; + workflowTaskModelSelect.appendChild(option); + } + workflowTaskModelSelect.disabled = models.length === 0; +} + +function updateWorkflowTaskRunnerFields(runner = null) { + const normalizedRunner = normalizeWorkflowTaskRunner(runner || getActiveWorkflowTask()?.runner); + const runnerType = normalizeText(workflowTaskRunnerTypeSelect?.value) || normalizedRunner.type; + setElementVisibility(workflowTaskModelFields, runnerType === "model"); + setElementVisibility(workflowTaskAgentFields, runnerType === "agent"); + if (runnerType === "model") { + populateWorkflowTaskModelSourceSelect(normalizedRunner.model_endpoint_id); + populateWorkflowTaskModelEndpointSelect(normalizedRunner.model_endpoint_id); + populateWorkflowTaskModelSelect(normalizedRunner.model_id); + } else if (runnerType === "agent") { + populateWorkflowTaskAgentSelect(normalizedRunner.selected_agent); + } +} + function syncActiveWorkflowTaskFromEditor() { const activeTask = getActiveWorkflowTask(); if (!activeTask) { @@ -360,6 +633,30 @@ function syncActiveWorkflowTaskFromEditor() { } activeTask.name = normalizeText(workflowTaskNameInput?.value); activeTask.instructions = normalizeText(workflowTaskPromptInput?.value); + const runnerType = normalizeText(workflowTaskRunnerTypeSelect?.value) || "inherit"; + if (runnerType === "model") { + activeTask.runner = { + type: "model", + model_endpoint_id: normalizeText(workflowTaskModelEndpointSelect?.value), + model_id: normalizeText(workflowTaskModelSelect?.value), + }; + } else if (runnerType === "agent") { + const existingRunner = normalizeWorkflowTaskRunner(activeTask.runner); + const selectedAgent = getSelectedWorkflowTaskAgentOption() || existingRunner.selected_agent || {}; + activeTask.runner = { + type: "agent", + selected_agent: { + id: normalizeText(selectedAgent.id), + name: normalizeText(selectedAgent.name), + display_name: normalizeText(selectedAgent.display_name), + is_global: Boolean(selectedAgent.is_global), + is_group: Boolean(selectedAgent.is_group), + group_id: normalizeText(selectedAgent.group_id), + }, + }; + } else { + activeTask.runner = { type: "inherit" }; + } } function populateWorkflowTaskEditor() { @@ -370,6 +667,11 @@ function populateWorkflowTaskEditor() { if (workflowTaskPromptInput) { workflowTaskPromptInput.value = activeTask?.instructions || ""; } + const runner = normalizeWorkflowTaskRunner(activeTask?.runner); + if (workflowTaskRunnerTypeSelect) { + workflowTaskRunnerTypeSelect.value = runner.type; + } + updateWorkflowTaskRunnerFields(runner); if (workflowTaskBriefInput) { workflowTaskBriefInput.value = ""; } @@ -417,7 +719,10 @@ function renderWorkflowTasks() { const preview = document.createElement("div"); preview.className = "workflow-task-item__preview"; preview.textContent = task.instructions || "Add task instructions"; - content.append(name, preview); + const runner = document.createElement("div"); + runner.className = "workflow-task-item__runner small text-muted mt-1"; + runner.textContent = getWorkflowTaskRunnerSummary(task); + content.append(name, preview, runner); const actions = document.createElement("div"); actions.className = "workflow-task-item__actions"; @@ -455,6 +760,7 @@ function addWorkflowTask() { name: `Task ${workflowTasks.length + 1}`, instructions: "", order: workflowTasks.length + 1, + runner: { type: "inherit" }, }; workflowTasks.push(task); activeWorkflowTaskId = task.id; @@ -500,6 +806,7 @@ function initializeWorkflowTasks(workflow = null) { name: normalizeText(task.name) || `Task ${index + 1}`, instructions: normalizeText(task.instructions), order: index + 1, + runner: normalizeWorkflowTaskRunner(task.runner), })) : [{ id: createWorkflowTaskId(), @@ -507,6 +814,7 @@ function initializeWorkflowTasks(workflow = null) { name: "Task 1", instructions: normalizeText(workflow?.task_prompt), order: 1, + runner: { type: "inherit" }, }]; activeWorkflowTaskId = workflowTasks[0].id; populateWorkflowTaskEditor(); @@ -547,13 +855,19 @@ function renderWorkflowReview() { const strategyLabel = getWorkflowErrorStrategy() === "continue" ? "Continue after failure" : "Stop after failure"; addWorkflowReviewItem("Workflow", normalizeText(workflowNameInput?.value) || "Untitled workflow"); - addWorkflowReviewItem("Runner", normalizeText(runnerLabel)); + addWorkflowReviewItem("Default 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)); + workflowTasks.forEach((task, index) => { + addWorkflowReviewItem( + `Task ${index + 1}`, + `${normalizeText(task.name) || `Task ${index + 1}`} - ${getWorkflowTaskRunnerSummary(task)}`, + ); + }); } function validateWorkflowTasks() { @@ -568,6 +882,31 @@ function validateWorkflowTasks() { (normalizeText(task.name) ? workflowTaskPromptInput : workflowTaskNameInput)?.focus(); return false; } + const runner = normalizeWorkflowTaskRunner(task.runner); + if (runner.type === "model") { + const endpoint = getCustomEndpointOptions().find( + (candidate) => normalizeText(candidate.id) === runner.model_endpoint_id + ); + const model = endpoint?.models?.find((candidate) => normalizeText(candidate.id) === runner.model_id); + if (!endpoint || !model) { + activeWorkflowTaskId = task.id; + populateWorkflowTaskEditor(); + renderWorkflowTasks(); + showToast(`Select an available endpoint and model for task ${index + 1}.`, "warning"); + workflowTaskModelEndpointSelect?.focus(); + return false; + } + } + if (runner.type === "agent" && !agentOptions.some( + (agent) => getAgentOptionKey(agent) === getAgentOptionKey(runner.selected_agent) + )) { + activeWorkflowTaskId = task.id; + populateWorkflowTaskEditor(); + renderWorkflowTasks(); + showToast(`Select an available agent for task ${index + 1}.`, "warning"); + workflowTaskAgentSelect?.focus(); + return false; + } } return true; } @@ -2130,7 +2469,7 @@ function getModelDisplayName(model) { } function getAgentOptionKey(agent) { - const scope = agent?.is_global ? "global" : "personal"; + const scope = agent?.is_global ? "global" : agent?.is_group ? "group" : "personal"; return `${scope}:${normalizeText(agent?.id || agent?.name)}`; } @@ -2904,6 +3243,7 @@ function buildWorkflowPayload() { name: normalizeText(task.name), instructions: normalizeText(task.instructions), order: index + 1, + runner: serializeWorkflowTaskRunner(task.runner), })); const runnerType = normalizeText(workflowRunnerTypeSelect?.value) || "model"; const triggerType = normalizeText(workflowTriggerTypeSelect?.value) || "manual"; @@ -3698,13 +4038,54 @@ function initializeWorkflowEvents() { resumeButton.disabled = false; }); }); - workflowRunnerTypeSelect?.addEventListener("change", updateRunnerFields); - workflowModelSourceSelect?.addEventListener("change", updateRunnerFields); + workflowTaskRunnerTypeSelect?.addEventListener("change", () => { + const activeTask = getActiveWorkflowTask(); + if (activeTask) { + activeTask.runner = { type: normalizeText(workflowTaskRunnerTypeSelect.value) || "inherit" }; + } + updateWorkflowTaskRunnerFields(activeTask?.runner); + syncActiveWorkflowTaskFromEditor(); + renderWorkflowTasks(); + }); + workflowTaskModelSourceSelect?.addEventListener("change", () => { + const endpointId = populateWorkflowTaskModelEndpointSelect(""); + populateWorkflowTaskModelSelect(""); + if (endpointId) { + syncActiveWorkflowTaskFromEditor(); + renderWorkflowTasks(); + } + }); + workflowTaskModelEndpointSelect?.addEventListener("change", () => { + populateWorkflowTaskModelSelect(""); + syncActiveWorkflowTaskFromEditor(); + renderWorkflowTasks(); + }); + workflowTaskModelSelect?.addEventListener("change", () => { + syncActiveWorkflowTaskFromEditor(); + renderWorkflowTasks(); + }); + workflowTaskAgentSelect?.addEventListener("change", () => { + syncActiveWorkflowTaskFromEditor(); + renderWorkflowTasks(); + }); + workflowRunnerTypeSelect?.addEventListener("change", () => { + updateRunnerFields(); + renderWorkflowTasks(); + }); + workflowAgentSelect?.addEventListener("change", renderWorkflowTasks); + workflowModelSourceSelect?.addEventListener("change", () => { + updateRunnerFields(); + renderWorkflowTasks(); + }); workflowModelEndpointSelect?.addEventListener("change", () => { populateModelSelect(normalizeText(workflowModelEndpointSelect.value), ""); updateModelHelpText(); + renderWorkflowTasks(); + }); + workflowModelSelect?.addEventListener("change", () => { + updateModelHelpText(); + renderWorkflowTasks(); }); - workflowModelSelect?.addEventListener("change", updateModelHelpText); workflowAlertPrioritySelect?.addEventListener("change", renderWorkflowReview); workflowTriggerTypeSelect?.addEventListener("change", updateTriggerFields); workflowScheduleUnitSelect?.addEventListener("change", updateScheduleConstraints); diff --git a/application/single_app/templates/group_workspaces.html b/application/single_app/templates/group_workspaces.html index 25990e6e5..c75f12b87 100644 --- a/application/single_app/templates/group_workspaces.html +++ b/application/single_app/templates/group_workspaces.html @@ -2042,7 +2042,7 @@
- + +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
Choose an authorized group or merged global agent.
+
diff --git a/application/single_app/templates/workspace.html b/application/single_app/templates/workspace.html index f621ea603..77c0bddb7 100644 --- a/application/single_app/templates/workspace.html +++ b/application/single_app/templates/workspace.html @@ -1438,7 +1438,7 @@ @@ -1457,7 +1457,7 @@
- + +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
Choose an authorized personal or merged global agent.
+
diff --git a/docs/explanation/features/GENERIC_WORKFLOW_AUTOMATION.md b/docs/explanation/features/GENERIC_WORKFLOW_AUTOMATION.md index 7fec43945..9ee5b8054 100644 --- a/docs/explanation/features/GENERIC_WORKFLOW_AUTOMATION.md +++ b/docs/explanation/features/GENERIC_WORKFLOW_AUTOMATION.md @@ -1,14 +1,17 @@ # Generic Workflow Automation Implemented in version: **0.250.063** -Enhanced in version: **0.250.064** +Enhanced in version: **0.250.065** +Task-level runner selection implemented in version: **0.250.065** -Related issue: #1082 +Related issues: #1082, #1084 ## Overview Workflows are repeatable AI automations. Their required configuration is a -workflow instruction and one runner: either a Direct Model or an Agent. +workflow instruction and a default runner: either a Direct Model or an Agent. +Each ordered task can inherit that default or select an authorized model or +agent override. Workspace documents, document actions, File Sync, URL access, alerts, and scheduling are optional capabilities that can be added when they are relevant to the task. @@ -16,7 +19,7 @@ to the task. Fixed/Implemented in version: **0.250.063** Related version update: -- `application/single_app/config.py` reports version `0.250.064`. +- `application/single_app/config.py` reports version `0.250.065`. Dependencies: - `application/single_app/functions_personal_workflows.py` @@ -41,11 +44,14 @@ Dependencies: without requiring document analysis. - Each run records its messages and result in the workflow conversation and retains the normal workflow run history. -- A workflow can contain up to 20 ordered instruction tasks. Every task uses the - selected workflow runner and receives a bounded copy of the previous task's - response as context. +- A workflow can contain up to 20 ordered instruction tasks. A task runner can + inherit the workflow default, use an authorized Direct Model override, or use + an authorized Agent override. Every task receives a bounded copy of the + previous task's response as context. - Global task error handling supports 0-5 retries and either stops the workflow after the final failed attempt or records the failure and continues. +- Tasks without a `runner` field behave as `inherit`, and workflows without a + `tasks` array retain the legacy single-dispatch path. New workflow definitions set `chat_capabilities_enabled` to `true`. Existing saved Direct Model workflows remain on their prior raw-completion behavior when @@ -71,6 +77,32 @@ The workflow runner establishes a scoped user, conversation, workflow, and group authorization context before loading tools. Group workflows retain their active group scope while core tools execute. +### Task runner authorization and dispatch + +Task runner configuration is validated when a workflow is saved and again +immediately before each task attempt executes: + +- Personal workflows resolve agents from the owning user's personal agents and + currently permitted merged global agents. Group workflows resolve agents from + the selected group and currently permitted merged global agents. +- Group execution revalidates the current actor's group membership and role at + the task boundary. +- Model overrides resolve only enabled endpoint and model IDs from current + global, personal, or group endpoint options. Stored summaries contain no + endpoint credentials or secrets. +- Agent names, labels, group IDs, and scope flags supplied by the browser are not + trusted. The saved task receives a normalized identity from the server-side + authorized option. +- Deleted, disabled, stale, cross-user, and cross-group runners fail the task. + The workflow's retry count and stop-or-continue strategy handle that failure. + +Execution creates a temporary workflow binding for model and agent overrides, +then passes it through the existing document/model/agent dispatch path. The +stored workflow default remains unchanged. Task run items record requested and +resolved runner modes, non-secret model or agent identifiers, execution model +deployment/provider, attempts, status, errors, timestamps, output preview, and +per-task token counts when available. + ### Document actions and File Sync The `No document action` option is the default for new personal and group @@ -99,12 +131,15 @@ enabled plugins. ## Usage Instructions 1. Open the Personal Workspace or a Group Workspace and select Workflows. -2. In `General`, provide a name and choose Direct Model or Agent. +2. In `General`, provide a name and choose the Default Runner: Direct Model or + Agent. 3. In `Trigger`, choose Manual, Interval, or File Sync behavior and configure optional URL or File Sync inputs. -4. In `Tasks`, add, edit, remove, or reorder instruction tasks. Leave - `Workspace documents` set to `No document action` for prompt-only or - agent-only automation, or choose Search, Analyze, or Compare for task one. +4. In `Tasks`, add, edit, remove, or reorder instruction tasks. For each task, + choose Workflow default, Direct Model, or Agent. Model and agent options are + limited to the current workspace's authorized choices. Leave `Workspace + documents` set to `No document action` for prompt-only or agent-only + automation, or choose Search, Analyze, or Compare for task one. 5. In `Reliability`, choose the task retry count and whether execution stops or continues after a failed task. 6. In `Review`, confirm the workflow and choose its completion alert priority. @@ -119,17 +154,19 @@ Functional coverage: - `functional_tests/test_workflow_model_core_capabilities.py` verifies saved model binding, kernel-based core capability invocation, default-model binding, and legacy Direct Model compatibility. -- `functional_tests/test_workflow_task_sequence.py` verifies ordered execution, - bounded result chaining, retries, continue-on-error behavior, and legacy - dispatch compatibility. +- `functional_tests/test_workflow_task_sequence.py` verifies task runner + normalization, personal/group authorization, disabled and deleted runner + revalidation, alternating model-agent-model execution, audit/token metadata, + retries, continue-on-error behavior, bounded chaining, and legacy dispatch. - `functional_tests/test_workflow_stepped_builder.py` verifies the shared - five-step modal, browser payload, safe task rendering, and existing route - reuse. + five-step modal, conditional runner controls, inherited runner payloads, safe + task summaries, and existing route reuse. UI coverage: -- `ui_tests/test_workflow_document_action_modal.py` verifies that a new - workflow navigates all five steps, builds an ordered task sequence, configures - error handling and alert priority, and preserves document action workflows. +- `ui_tests/test_workflow_document_action_modal.py` verifies conditional task + model/agent controls, task row and Review summaries, safe rendering of + untrusted labels, reorder persistence, desktop/mobile layout, and preserved + document action workflows. Validation should include the focused functional tests, JavaScript syntax validation, and the UI test against an authenticated `SIMPLECHAT_UI_BASE_URL` diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 137f29bf7..530e1bdec 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,24 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](/explanation/features/) and [Fixes by Version](/explanation/fixes/). +### **(v0.250.065)** + +#### New Features + +* **Task-Level Workflow Model and Agent Selection** + * Each ordered workflow task can now inherit the workflow's Default Runner or select its own authorized Direct Model or Agent. + * Task runners are normalized on save and revalidated before execution, including current personal/group/global agent scope, group membership, and enabled model endpoint/model availability. + * Unavailable runners follow the workflow's retry and stop-or-continue strategy, while task run items record non-secret runner audit details, execution deployment/provider, output preview, and token usage when available. + * Existing tasks without runner configuration inherit the workflow default, and workflows without task sequences retain the legacy execution path. + * (Ref: microsoft/simplechat#1084, `functions_personal_workflows.py`, `functions_group_workflows.py`, `functions_workflow_runner.py`) + +#### User Interface Enhancements + +* **Per-Task Runner Controls** + * Renamed the workflow-level Runner field to Default Runner and added Workflow default, Direct Model, and Agent selection to each task editor. + * Task rows and Review now show the resolved runner, with responsive conditional model/agent controls and text-safe rendering for endpoint, model, and agent labels. + * (Ref: microsoft/simplechat#1084, `workspace.html`, `group_workspaces.html`, `workspace_workflows.js`) + ### **(v0.250.064)** #### New Features diff --git a/functional_tests/test_generic_workflow_automation.py b/functional_tests/test_generic_workflow_automation.py index a29e77334..678082aa7 100644 --- a/functional_tests/test_generic_workflow_automation.py +++ b/functional_tests/test_generic_workflow_automation.py @@ -1,9 +1,9 @@ # test_generic_workflow_automation.py """ Functional test for generic workflow automation. -Version: 0.250.064 +Version: 0.250.065 Implemented in: 0.250.063 -Enhanced in: 0.250.064 +Enhanced in: 0.250.065 This test ensures a workflow requires instructions and a selected model or agent, while workspace document actions remain explicitly optional. @@ -13,7 +13,7 @@ ROOT = Path(__file__).resolve().parents[1] -EXPECTED_VERSION = "0.250.064" +EXPECTED_VERSION = "0.250.065" def read_text(relative_path: str) -> str: diff --git a/functional_tests/test_workflow_stepped_builder.py b/functional_tests/test_workflow_stepped_builder.py index 368085c9b..e03ff96e0 100644 --- a/functional_tests/test_workflow_stepped_builder.py +++ b/functional_tests/test_workflow_stepped_builder.py @@ -1,8 +1,9 @@ # test_workflow_stepped_builder.py """ Functional test for the stepped workflow builder. -Version: 0.250.064 +Version: 0.250.065 Implemented in: 0.250.064 +Enhanced in: 0.250.065 This test ensures personal and group workflow modals use the same five-step builder and submit ordered tasks and error handling through existing routes. @@ -12,7 +13,7 @@ ROOT = Path(__file__).resolve().parents[1] -EXPECTED_VERSION = "0.250.064" +EXPECTED_VERSION = "0.250.065" def read_text(relative_path: str) -> str: @@ -34,6 +35,13 @@ def test_personal_and_group_templates_share_five_step_builder() -> None: "workflow-task-list", "workflow-add-task-btn", "workflow-task-name", + "workflow-task-runner-type", + "workflow-task-model-fields", + "workflow-task-model-source", + "workflow-task-model-endpoint", + "workflow-task-model", + "workflow-task-agent-fields", + "workflow-task-agent", "workflow-error-strategy-halt", "workflow-error-strategy-continue", "workflow-task-retry-count", @@ -43,6 +51,8 @@ def test_personal_and_group_templates_share_five_step_builder() -> None: ): assert f'id="{element_id}"' in content assert "modal-dialog modal-xl modal-dialog-scrollable" in content + assert '' in content + assert '' in content assert "cdn.tailwindcss.com" not in content assert "fonts.googleapis.com" not in content @@ -60,6 +70,10 @@ def test_shared_browser_behavior_builds_safe_ordered_task_payload() -> None: "moveWorkflowTask", "removeWorkflowTask", "initializeWorkflowTasks", + "normalizeWorkflowTaskRunner", + "serializeWorkflowTaskRunner", + "getWorkflowTaskRunnerSummary", + "updateWorkflowTaskRunnerFields", "renderWorkflowReview", "validateWorkflowStep", "showWorkflowStep", @@ -67,9 +81,13 @@ def test_shared_browser_behavior_builds_safe_ordered_task_payload() -> None: ): assert f"function {function_name}(" in content assert "workflowTasks.map((task, index) => ({" in content + assert "runner: serializeWorkflowTaskRunner(task.runner)," in content assert "error_handling:" in content assert "workflow-review-summary__item" in content assert 'name.textContent = task.name || `Task ${index + 1}`;' in content + assert "runner.textContent = getWorkflowTaskRunnerSummary(task);" in content + assert 'addWorkflowReviewItem("Default Runner"' in content + assert "workflowTaskAgentSelect.innerHTML" not in content assert "item.innerHTML" not in content print("PASS: stepped workflow browser behavior contract") @@ -92,6 +110,7 @@ def test_existing_routes_persist_tasks_without_new_endpoints() -> None: assert "save_group_workflow(" in routes assert "/api/user/workflows//run" in routes assert "/api/group/workflows//run" in routes + assert "/task-runners" not in routes print("PASS: stepped workflow route and persistence contract") diff --git a/functional_tests/test_workflow_task_sequence.py b/functional_tests/test_workflow_task_sequence.py index f8b2fbb69..e689966b6 100644 --- a/functional_tests/test_workflow_task_sequence.py +++ b/functional_tests/test_workflow_task_sequence.py @@ -1,8 +1,9 @@ # test_workflow_task_sequence.py """ Functional test for ordered workflow task sequences. -Version: 0.250.064 +Version: 0.250.065 Implemented in: 0.250.064 +Enhanced in: 0.250.065 This test ensures workflow tasks are normalized in order, execute with bounded prior-task context, retry safely, and honor halt or continue error strategies. @@ -16,8 +17,9 @@ ROOT = Path(__file__).resolve().parents[1] APP_ROOT = ROOT / "application" / "single_app" STORE_FILE = APP_ROOT / "functions_personal_workflows.py" +GROUP_STORE_FILE = APP_ROOT / "functions_group_workflows.py" RUNNER_FILE = APP_ROOT / "functions_workflow_runner.py" -EXPECTED_VERSION = "0.250.064" +EXPECTED_VERSION = "0.250.065" def read_text(path: Path) -> str: @@ -50,11 +52,12 @@ def load_store_helpers() -> dict: "WORKFLOW_MAX_TASKS": 20, "WORKFLOW_TASK_INSTRUCTIONS_MAX_LENGTH": 12000, "WORKFLOW_TASK_NAME_MAX_LENGTH": 120, + "WORKFLOW_TASK_RUNNER_TYPES": {"inherit", "agent", "model"}, }, ) -def load_runner_helpers(dispatch): +def load_runner_helpers(dispatch, personal_runner_normalizer=None, group_runner_normalizer=None): saved_items = [] timestamps = iter(f"2026-07-27T00:00:{index:02d}+00:00" for index in range(60)) namespace = { @@ -67,6 +70,12 @@ def load_runner_helpers(dispatch): "_merge_token_usage_summaries": lambda results: { "total_tokens": sum(int((result.get("token_usage") or {}).get("total_tokens") or 0) for result in results) }, + "normalize_group_workflow_task_runner": group_runner_normalizer or ( + lambda *_args, **_kwargs: {"type": "inherit"} + ), + "normalize_personal_workflow_task_runner": personal_runner_normalizer or ( + lambda *_args, **_kwargs: {"type": "inherit"} + ), "_save_workflow_run_item_record": lambda workflow, item: saved_items.append(dict(item)) or item, "_utc_now_iso": lambda: next(timestamps), "build_analyze_config": lambda action: {"enabled": action.get("type") == "analyze"}, @@ -77,6 +86,9 @@ def load_runner_helpers(dispatch): { "_truncate_workflow_task_context", "_build_workflow_task_execution_workflow", + "_get_workflow_task_requested_runner_mode", + "_build_workflow_task_runner_audit", + "_resolve_workflow_task_runner", "_workflow_task_run_item_id", "_save_workflow_task_run_item", "_merge_workflow_task_execution_results", @@ -87,6 +99,67 @@ def load_runner_helpers(dispatch): return helpers, saved_items +def load_personal_task_runner_helpers(personal_agents, global_agents, settings, personal_endpoints=None): + return load_functions( + STORE_FILE, + { + "_normalize_text", + "_build_selectable_agents", + "_find_matching_agent", + "_normalize_selected_agent", + "_build_model_endpoint_candidates", + "_summarize_model_binding", + "normalize_personal_workflow_task_runner", + }, + { + "WORKFLOW_TASK_RUNNER_TYPES": {"inherit", "agent", "model"}, + "get_global_agents": lambda: list(global_agents), + "get_personal_agents": lambda _user_id: list(personal_agents), + "get_settings": lambda: settings, + "get_user_settings": lambda _user_id: { + "settings": {"personal_model_endpoints": list(personal_endpoints or [])} + }, + "normalize_model_endpoints": lambda endpoints: (list(endpoints), []), + }, + ) + + +def load_group_task_runner_helpers(group_agents, global_agents, settings, group_endpoints=None): + role_checks = [] + + def normalize_text(value, field_name, required=False): + normalized = str(value or "").strip() + if required and not normalized: + raise ValueError(f"{field_name} is required.") + return normalized + + helpers = load_functions( + GROUP_STORE_FILE, + { + "_build_selectable_agents", + "_find_matching_agent", + "_normalize_selected_agent", + "_build_model_endpoint_candidates", + "_summarize_model_binding", + "normalize_group_workflow_task_runner", + }, + { + "GROUP_WORKFLOW_MEMBER_ROLES": ("Owner", "Admin", "DocumentManager", "User"), + "WORKFLOW_TASK_RUNNER_TYPES": {"inherit", "agent", "model"}, + "_normalize_text": normalize_text, + "assert_group_role": lambda user_id, group_id, allowed_roles: role_checks.append( + (user_id, group_id, allowed_roles) + ), + "get_global_agents": lambda: list(global_agents), + "get_group_agents": lambda _group_id: list(group_agents), + "get_group_model_endpoints": lambda _group_id: list(group_endpoints or []), + "get_settings": lambda: settings, + "normalize_model_endpoints": lambda endpoints: (list(endpoints), []), + }, + ) + return helpers, role_checks + + def test_task_and_error_policy_normalization() -> None: """Normalize ordered instruction tasks and bounded error handling.""" print("Testing workflow task normalization...") @@ -104,8 +177,19 @@ def test_task_and_error_policy_normalization() -> None: assert [task["order"] for task in tasks] == [1, 2] assert [task["type"] for task in tasks] == ["instructions", "instructions"] + assert [task["runner"] for task in tasks] == [{"type": "inherit"}, {"type": "inherit"}] assert policy == {"strategy": "continue", "retry_count": 2} + existing_tasks = helpers["_normalize_workflow_tasks"]( + {}, + existing_workflow={ + "tasks": [ + {"id": "legacy", "name": "Legacy", "instructions": "Keep working."} + ] + }, + ) + assert existing_tasks[0]["runner"] == {"type": "inherit"} + try: helpers["_normalize_workflow_tasks"]({"tasks": []}) raise AssertionError("Expected an empty task list to be rejected.") @@ -115,6 +199,179 @@ def test_task_and_error_policy_normalization() -> None: print("PASS: workflow task normalization") +def test_personal_task_runner_normalization_and_authorization() -> None: + """Normalize server-authorized personal agents and model bindings without secrets.""" + print("Testing personal task runner authorization...") + settings = { + "enable_semantic_kernel": True, + "allow_user_agents": True, + "per_user_semantic_kernel": True, + "merge_global_semantic_kernel_with_workspace": False, + "allow_user_custom_endpoints": False, + "model_endpoints": [ + { + "id": "global-endpoint", + "name": "Server endpoint", + "provider": "aoai", + "enabled": True, + "connection": {"api_key": "must-not-persist"}, + "models": [ + {"id": "fast-model", "displayName": "Fast", "enabled": True}, + {"id": "disabled-model", "displayName": "Disabled", "enabled": False}, + ], + } + ], + } + helpers = load_personal_task_runner_helpers( + personal_agents=[ + {"id": "personal-agent", "name": "server_name", "display_name": "Server Name"}, + {"id": "disabled-agent", "name": "disabled", "is_enabled": False}, + ], + global_agents=[{"id": "global-agent", "name": "global_name", "is_global": True}], + settings=settings, + ) + + agent_runner = helpers["normalize_personal_workflow_task_runner"]( + "user-1", + { + "type": "agent", + "selected_agent": { + "id": "personal-agent", + "name": "browser_tampered_name", + "is_global": False, + }, + }, + settings=settings, + ) + assert agent_runner["selected_agent"]["name"] == "server_name" + assert agent_runner["selected_agent"]["is_global"] is False + + model_runner = helpers["normalize_personal_workflow_task_runner"]( + "user-1", + { + "type": "model", + "model_endpoint_id": "global-endpoint", + "model_id": "fast-model", + "model_provider": "browser-value", + }, + settings=settings, + ) + assert model_runner["model_provider"] == "aoai" + assert model_runner["model_binding_summary"]["label"] == "Global: Server endpoint / Fast" + assert "connection" not in model_runner["model_binding_summary"] + + for invalid_runner in ( + {"type": "agent", "selected_agent": {"id": "cross-user", "is_global": False}}, + {"type": "agent", "selected_agent": {"id": "disabled-agent", "is_global": False}}, + {"type": "agent", "selected_agent": {"id": "global-agent", "is_global": True}}, + {"type": "model", "model_endpoint_id": "missing", "model_id": "fast-model"}, + {"type": "model", "model_endpoint_id": "global-endpoint", "model_id": "disabled-model"}, + ): + try: + helpers["normalize_personal_workflow_task_runner"]( + "user-1", + invalid_runner, + settings=settings, + ) + raise AssertionError(f"Expected task runner rejection: {invalid_runner}") + except ValueError: + pass + + task_helpers = load_store_helpers() + normalized_tasks = task_helpers["_normalize_workflow_tasks"]( + { + "tasks": [ + { + "id": "extract", + "name": "Extract", + "instructions": "Extract facts.", + "runner": { + "type": "model", + "model_endpoint_id": "global-endpoint", + "model_id": "fast-model", + }, + } + ] + }, + task_runner_normalizer=lambda runner: helpers["normalize_personal_workflow_task_runner"]( + "user-1", + runner, + settings=settings, + ), + ) + assert normalized_tasks[0]["runner"] == model_runner + assert "'tasks': tasks" in read_text(STORE_FILE) + print("PASS: personal task runner authorization") + + +def test_group_task_agent_authorization() -> None: + """Normalize only agents belonging to the currently authorized group.""" + print("Testing group task runner authorization...") + settings = { + "enable_semantic_kernel": True, + "allow_group_agents": True, + "per_user_semantic_kernel": True, + "merge_global_semantic_kernel_with_workspace": False, + "allow_group_custom_endpoints": True, + "model_endpoints": [], + } + helpers, role_checks = load_group_task_runner_helpers( + group_agents=[{"id": "group-agent", "name": "group_server_name", "group_id": "group-1"}], + global_agents=[{"id": "global-agent", "name": "global_name", "is_global": True}], + settings=settings, + group_endpoints=[ + { + "id": "group-endpoint", + "name": "Group endpoint", + "provider": "openai", + "models": [{"id": "group-model", "displayName": "Group Model"}], + } + ], + ) + agent_runner = helpers["normalize_group_workflow_task_runner"]( + "member-1", + "group-1", + { + "type": "agent", + "selected_agent": { + "id": "group-agent", + "name": "tampered", + "is_global": False, + "is_group": True, + "group_id": "other-group", + }, + }, + settings=settings, + ) + assert agent_runner["selected_agent"]["name"] == "group_server_name" + assert agent_runner["selected_agent"]["group_id"] == "group-1" + assert role_checks[-1][0:2] == ("member-1", "group-1") + + model_runner = helpers["normalize_group_workflow_task_runner"]( + "member-1", + "group-1", + {"type": "model", "model_endpoint_id": "group-endpoint", "model_id": "group-model"}, + settings=settings, + ) + assert model_runner["model_binding_summary"]["scope"] == "group" + + for invalid_agent in ( + {"id": "cross-group-agent", "is_global": False, "is_group": True, "group_id": "other-group"}, + {"id": "global-agent", "is_global": True}, + ): + try: + helpers["normalize_group_workflow_task_runner"]( + "member-1", + "group-1", + {"type": "agent", "selected_agent": invalid_agent}, + settings=settings, + ) + raise AssertionError(f"Expected group task agent rejection: {invalid_agent}") + except ValueError: + pass + print("PASS: group task runner authorization") + + def test_ordered_tasks_chain_context_and_apply_documents_once() -> None: """Execute tasks in order and apply configured document input only to task one.""" print("Testing ordered task context chaining...") @@ -151,6 +408,7 @@ def dispatch(workflow, *_args, **_kwargs): ) assert [workflow["active_task"]["id"] for workflow in dispatched_workflows] == ["collect", "summarize"] + assert [workflow["runner_type"] for workflow in dispatched_workflows] == ["model", "model"] assert dispatched_workflows[0]["document_action"]["type"] == "search" assert "[Workflow input context]" in dispatched_workflows[0]["task_prompt"] assert "File Sync context for this workflow run." in dispatched_workflows[0]["task_prompt"] @@ -205,6 +463,275 @@ def dispatch(workflow, *_args, **_kwargs): print("PASS: workflow task retry and continue strategy") +def test_alternating_task_runners_and_audit_metadata() -> None: + """Dispatch model, agent, and model overrides with per-task audit metadata.""" + print("Testing alternating task runners and audit metadata...") + dispatched_workflows = [] + + def normalize_runner(_user_id, requested_runner, settings=None): + runner_type = requested_runner.get("type") or "inherit" + if runner_type == "inherit": + return {"type": "inherit"} + if runner_type == "agent": + return { + "type": "agent", + "selected_agent": { + "id": "research-agent", + "name": "server_research", + "is_global": False, + "is_group": False, + }, + } + return { + "type": "model", + "model_endpoint_id": requested_runner["model_endpoint_id"], + "model_id": requested_runner["model_id"], + "model_provider": "aoai", + "model_binding_summary": { + "endpoint_id": requested_runner["model_endpoint_id"], + "model_id": requested_runner["model_id"], + "provider": "aoai", + }, + } + + def dispatch(workflow, *_args, **_kwargs): + dispatched_workflows.append(dict(workflow)) + task_order = workflow["active_task"]["order"] + return { + "reply": f"Task {task_order} result", + "model_deployment_name": f"deployment-{task_order}", + "provider": "agent" if workflow["runner_type"] == "agent" else "aoai", + "token_usage": { + "prompt_tokens": task_order, + "completion_tokens": task_order + 1, + "total_tokens": (task_order * 2) + 1, + }, + } + + helpers, saved_items = load_runner_helpers( + dispatch, + personal_runner_normalizer=normalize_runner, + ) + result = helpers["_execute_workflow_task_sequence"]( + { + "id": "workflow-alternating", + "name": "Alternating", + "user_id": "user-1", + "runner_type": "model", + "model_endpoint_id": "default-endpoint", + "model_id": "default-model", + "document_action": {"type": "none"}, + "tasks": [ + { + "id": "extract", + "name": "Extract", + "instructions": "Extract.", + "runner": {"type": "model", "model_endpoint_id": "fast-endpoint", "model_id": "fast-model"}, + }, + { + "id": "research", + "name": "Research", + "instructions": "Research.", + "runner": {"type": "agent", "selected_agent": {"id": "browser-agent"}}, + }, + { + "id": "evaluate", + "name": "Evaluate", + "instructions": "Evaluate.", + "runner": {"type": "model", "model_endpoint_id": "reason-endpoint", "model_id": "reason-model"}, + }, + ], + "error_handling": {"strategy": "halt", "retry_count": 0}, + }, + {}, + "conversation-alternating", + "run-alternating", + None, + {}, + ) + + assert [workflow["runner_type"] for workflow in dispatched_workflows] == ["model", "agent", "model"] + assert dispatched_workflows[0]["model_id"] == "fast-model" + assert dispatched_workflows[1]["selected_agent"]["name"] == "server_research" + assert dispatched_workflows[2]["model_id"] == "reason-model" + succeeded_items = [item for item in saved_items if item["status"] == "succeeded"] + assert [item["runner"]["requested_mode"] for item in succeeded_items] == ["model", "agent", "model"] + assert [item["runner"]["resolved_type"] for item in succeeded_items] == ["model", "agent", "model"] + assert succeeded_items[1]["runner"]["agent_id"] == "research-agent" + assert succeeded_items[1]["runner"]["model_deployment_name"] == "deployment-2" + assert succeeded_items[2]["token_usage"]["total_tokens"] == 7 + assert result["token_usage"]["total_tokens"] == 15 + print("PASS: alternating task runners and audit metadata") + + +def test_unavailable_task_runner_retries_and_continues() -> None: + """Treat execution-time runner loss as a retryable task failure.""" + print("Testing unavailable task runner retry and continue behavior...") + resolution_attempts = {"missing": 0, "inherit": 0} + dispatched_task_ids = [] + + def normalize_runner(_user_id, requested_runner, settings=None): + runner_type = requested_runner.get("type") or "inherit" + resolution_attempts[runner_type] += 1 + if runner_type == "missing": + raise ValueError("The selected model endpoint is no longer available.") + return {"type": "inherit"} + + def dispatch(workflow, *_args, **_kwargs): + dispatched_task_ids.append(workflow["active_task"]["id"]) + return {"reply": "continued"} + + helpers, saved_items = load_runner_helpers( + dispatch, + personal_runner_normalizer=normalize_runner, + ) + result = helpers["_execute_workflow_task_sequence"]( + { + "id": "workflow-unavailable", + "name": "Unavailable", + "user_id": "user-1", + "runner_type": "model", + "document_action": {"type": "none"}, + "tasks": [ + { + "id": "missing", + "name": "Missing runner", + "instructions": "Fail runner resolution.", + "runner": {"type": "missing"}, + }, + { + "id": "later", + "name": "Later task", + "instructions": "Continue.", + }, + ], + "error_handling": {"strategy": "continue", "retry_count": 1}, + }, + {}, + "conversation-unavailable", + "run-unavailable", + None, + {}, + ) + + assert resolution_attempts == {"missing": 2, "inherit": 1} + assert dispatched_task_ids == ["later"] + assert [item["status"] for item in result["task_results"]] == ["failed", "succeeded"] + failed_item = next(item for item in saved_items if item["task_id"] == "missing" and item["status"] == "failed") + assert failed_item["attempt_count"] == 2 + assert failed_item["runner"]["requested_mode"] == "missing" + print("PASS: unavailable task runner retry and continue behavior") + + +def test_execution_revalidation_rejects_deleted_agent_and_disabled_model() -> None: + """Recheck stored task runners against current options before each dispatch.""" + print("Testing execution-time task runner revalidation...") + personal_agents = [ + {"id": "agent-before-delete", "name": "agent_before_delete", "display_name": "Agent"} + ] + settings = { + "enable_semantic_kernel": True, + "allow_user_agents": True, + "per_user_semantic_kernel": True, + "merge_global_semantic_kernel_with_workspace": False, + "allow_user_custom_endpoints": False, + "model_endpoints": [ + { + "id": "endpoint-before-disable", + "name": "Endpoint", + "provider": "aoai", + "enabled": True, + "models": [{"id": "model-before-disable", "displayName": "Model", "enabled": True}], + } + ], + } + authorization_helpers = load_personal_task_runner_helpers( + personal_agents=personal_agents, + global_agents=[], + settings=settings, + ) + saved_agent_runner = authorization_helpers["normalize_personal_workflow_task_runner"]( + "user-1", + { + "type": "agent", + "selected_agent": {"id": "agent-before-delete", "is_global": False}, + }, + settings=settings, + ) + saved_model_runner = authorization_helpers["normalize_personal_workflow_task_runner"]( + "user-1", + { + "type": "model", + "model_endpoint_id": "endpoint-before-disable", + "model_id": "model-before-disable", + }, + settings=settings, + ) + + personal_agents.clear() + settings["model_endpoints"][0]["enabled"] = False + dispatched_task_ids = [] + + def revalidate_runner(user_id, requested_runner, settings=None): + return authorization_helpers["normalize_personal_workflow_task_runner"]( + user_id, + requested_runner, + settings=execution_settings, + ) + + execution_settings = settings + + def dispatch(workflow, *_args, **_kwargs): + dispatched_task_ids.append(workflow["active_task"]["id"]) + return {"reply": "inherited task completed"} + + helpers, _saved_items = load_runner_helpers( + dispatch, + personal_runner_normalizer=revalidate_runner, + ) + result = helpers["_execute_workflow_task_sequence"]( + { + "id": "workflow-stale-runners", + "name": "Stale runners", + "user_id": "user-1", + "runner_type": "model", + "document_action": {"type": "none"}, + "tasks": [ + { + "id": "deleted-agent", + "name": "Deleted agent", + "instructions": "Use the deleted agent.", + "runner": saved_agent_runner, + }, + { + "id": "disabled-model", + "name": "Disabled model", + "instructions": "Use the disabled model.", + "runner": saved_model_runner, + }, + { + "id": "inherit", + "name": "Inherited runner", + "instructions": "Continue with the workflow default.", + "runner": {"type": "inherit"}, + }, + ], + "error_handling": {"strategy": "continue", "retry_count": 0}, + }, + execution_settings, + "conversation-stale-runners", + "run-stale-runners", + None, + {}, + ) + + assert [item["status"] for item in result["task_results"]] == ["failed", "failed", "succeeded"] + assert "valid personal" in result["task_results"][0]["error"] + assert "disabled" in result["task_results"][1]["error"] + assert dispatched_task_ids == ["inherit"] + print("PASS: execution-time task runner revalidation") + + def test_version_and_legacy_dispatch_contract() -> None: """Keep the version and legacy no-task dispatch branch explicit.""" config_content = read_text(APP_ROOT / "config.py") @@ -217,8 +744,13 @@ def test_version_and_legacy_dispatch_contract() -> None: def run_tests() -> bool: tests = [ test_task_and_error_policy_normalization, + test_personal_task_runner_normalization_and_authorization, + test_group_task_agent_authorization, test_ordered_tasks_chain_context_and_apply_documents_once, test_continue_strategy_retries_and_runs_later_tasks, + test_alternating_task_runners_and_audit_metadata, + test_unavailable_task_runner_retries_and_continues, + test_execution_revalidation_rejects_deleted_agent_and_disabled_model, test_version_and_legacy_dispatch_contract, ] results = [] diff --git a/ui_tests/test_workflow_document_action_modal.py b/ui_tests/test_workflow_document_action_modal.py index dd3d71106..e5e634d90 100644 --- a/ui_tests/test_workflow_document_action_modal.py +++ b/ui_tests/test_workflow_document_action_modal.py @@ -1,9 +1,9 @@ # test_workflow_document_action_modal.py """ UI test for workflow document action modal. -Version: 0.250.064 +Version: 0.250.065 Implemented in: 0.250.063 -Enhanced in: 0.250.064 +Enhanced in: 0.250.065 This test ensures the workflow modal supports generic no-document automation, uses Source/Target wording for comparison, and submits version-aware comparison @@ -74,10 +74,14 @@ def handler(route): page.route("**/api/user/workflows**", handler) -def _route_agent_api(page): +def _route_agent_api(page, agents=None): page.route( "**/api/user/agents", - lambda route: route.fulfill(status=200, content_type="application/json", body=json.dumps([])), + lambda route: route.fulfill( + status=200, + content_type="application/json", + body=json.dumps(list(agents or [])), + ), ) @@ -364,6 +368,150 @@ def test_workflow_document_action_modal_per_document_analysis(): assert saved_payload["document_action"]["analysis_mode"] == "per_document" assert saved_payload["analyze"]["enabled"] is True assert saved_payload["analyze"]["analysis_mode"] == "per_document" + finally: + context.close() + browser.close() + + +@pytest.mark.ui +def test_workflow_task_runner_controls_safe_summaries_and_mobile_layout(): + """Validate task runner overrides, safe labels, review output, payloads, and responsive layout.""" + _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": []} + malicious_agent_name = '' + malicious_endpoint_name = '' + + _route_workflow_api(page, workflow_state) + _route_agent_api(page, [ + { + "id": "research-agent", + "name": "server_research", + "display_name": malicious_agent_name, + "is_global": False, + "is_group": False, + } + ]) + _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." + page.evaluate( + """ + ([endpointName]) => { + window.taskRunnerXss = false; + window.globalModelEndpoints = [{ + id: "global-fast", + name: endpointName, + provider: "aoai", + enabled: true, + models: [{ id: "fast-model", displayName: "Fast model", enabled: true }], + }]; + window.workspaceModelEndpoints = [{ + id: "personal-reasoning", + name: "Reasoning endpoint", + provider: "openai", + enabled: true, + models: [{ id: "reasoning-model", displayName: "Reasoning model", enabled: true }], + }]; + } + """, + [malicious_endpoint_name], + ) + + _open_workflows_tab(page) + page.get_by_role("button", name="New Workflow").click() + expect(page.locator("#workflowModal")).to_be_visible() + expect(page.get_by_label("Default Runner")).to_have_value("model") + + _advance_workflow_builder_to_tasks( + page, + "Multi-agent publication", + "Extract the source facts.", + ) + expect(page.locator("#workflow-task-runner-type")).to_have_value("inherit") + expect(page.locator("#workflow-task-model-fields")).to_be_hidden() + expect(page.locator("#workflow-task-agent-fields")).to_be_hidden() + + page.fill("#workflow-task-name", "Extract facts") + page.select_option("#workflow-task-runner-type", "model") + expect(page.locator("#workflow-task-model-fields")).to_be_visible() + expect(page.locator("#workflow-task-agent-fields")).to_be_hidden() + page.select_option("#workflow-task-model-source", "global") + page.select_option("#workflow-task-model-endpoint", "global-fast") + page.select_option("#workflow-task-model", "fast-model") + + page.click("#workflow-add-task-btn") + page.fill("#workflow-task-name", "Research facts") + page.fill("#workflow-task-prompt", "Enrich the facts with authorized tools.") + page.select_option("#workflow-task-runner-type", "agent") + expect(page.locator("#workflow-task-agent-fields")).to_be_visible() + expect(page.locator("#workflow-task-model-fields")).to_be_hidden() + page.select_option("#workflow-task-agent", "personal:research-agent") + + page.click("#workflow-add-task-btn") + page.fill("#workflow-task-name", "Publish") + page.fill("#workflow-task-prompt", "Create the final artifact.") + expect(page.locator("#workflow-task-runner-type")).to_have_value("inherit") + expect(page.locator("#workflow-task-list .workflow-task-item")).to_have_count(3) + + page.get_by_role("button", name="Move Research facts up").click() + task_names = page.locator("#workflow-task-list .workflow-task-item__name").all_text_contents() + assert task_names == ["Research facts", "Extract facts", "Publish"] + expect(page.locator("#workflow-task-list")).to_contain_text("Workflow default: Direct Model") + expect(page.locator("#workflow-task-list")).to_contain_text(malicious_agent_name) + expect(page.locator("#workflow-task-list #task-runner-xss")).to_have_count(0) + expect(page.locator("#workflow-task-list #task-runner-endpoint-xss")).to_have_count(0) + assert page.evaluate("window.taskRunnerXss") is False + + _advance_workflow_builder_to_review(page) + review_summary = page.locator("#workflow-review-summary") + expect(review_summary).to_contain_text("Default Runner") + expect(review_summary).to_contain_text("Research facts - Agent") + expect(review_summary).to_contain_text("Extract facts - Direct Model") + expect(review_summary).to_contain_text("Publish - Workflow default") + + page.set_viewport_size({"width": 390, "height": 844}) + page.locator('[data-workflow-step-target="tasks"]').click() + page.get_by_role("button", name="Edit Extract facts").click() + expect(page.locator("#workflow-task-model-fields")).to_be_visible() + source_box = page.locator("#workflow-task-model-source").bounding_box() + endpoint_box = page.locator("#workflow-task-model-endpoint").bounding_box() + model_box = page.locator("#workflow-task-model").bounding_box() + assert source_box and endpoint_box and model_box + assert source_box["y"] < endpoint_box["y"] < model_box["y"] + for box in (source_box, endpoint_box, model_box): + assert box["x"] >= 0 + assert box["x"] + box["width"] <= 391 + + page.locator('[data-workflow-step-target="review"]').click() + page.click("#workflow-save-btn") + assert workflow_state["saved_payloads"], "Expected the workflow save handler to capture the modal payload." + saved_tasks = workflow_state["saved_payloads"][0]["tasks"] + assert [task["name"] for task in saved_tasks] == ["Research facts", "Extract facts", "Publish"] + assert [task["runner"]["type"] for task in saved_tasks] == ["agent", "model", "inherit"] + assert saved_tasks[0]["runner"]["selected_agent"] == { + "id": "research-agent", + "name": "server_research", + "is_global": False, + "is_group": False, + "group_id": "", + } + assert saved_tasks[1]["runner"] == { + "type": "model", + "model_endpoint_id": "global-fast", + "model_id": "fast-model", + } + assert page.evaluate("window.taskRunnerXss") is False finally: context.close() browser.close() \ No newline at end of file