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 @@