From e65ab0dbc054c44ad7a6cc0db328982e1aa09fd3 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Tue, 6 Oct 2026 16:29:15 -0400 Subject: [PATCH 1/5] Add V2 admin global agents and actions; reset scroll on category change - Choosing an Admin Settings category, in the rail or the phone select, now opens it at its top and restarts the On this page index. - Global Agents and Global Actions sections list, create, edit, enable, disable and delete the organisation's agents and actions, and choose the default agent, using the same V2 editors workspaces use. Thirteen new admin-only routes under /api/v2/admin serve them through the shared editor engine, now with a global scope: masked secrets, global Key Vault names, conditional writes and case-insensitive name checks. - Templates start and publish global agents; Agent Template Approvals links to the approvals queue in a new tab. - Remove the Global agent delegation card. Call agent is an ordinary global action whose targets are global agents; the target picker now follows the editor's scope instead of always reading personal targets. - Admin Settings asks before opening a global editor would drop unsaved settings. - The classic global agent delete reads stored Key Vault references, so it removes credentials the V2 editor saved instead of failing. - Version 0.261.260. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../single_app/admin_settings_fields.py | 48 ++ application/single_app/admin_settings_nav.py | 4 + application/single_app/app.py | 2 + application/single_app/config.py | 2 +- .../single_app/functions_global_agents.py | 7 +- .../functions_global_editor_access.py | 228 +++++++ .../functions_workspace_authoring.py | 409 +++++++++++- .../single_app/route_backend_agents.py | 48 ++ .../single_app/route_backend_plugins.py | 76 +++ .../route_backend_v2_admin_agents_actions.py | 212 ++++++ .../templates/admin/_panes/agents.html | 2 +- application/v2_ui/src/App.tsx | 6 + .../admin/AgentTemplateApprovalsLink.tsx | 27 + .../components/admin/GlobalActionsManager.tsx | 178 +++++ .../components/admin/GlobalAgentsManager.tsx | 226 +++++++ .../ActionConfigurationFields.tsx | 4 +- .../CallAgentActionConfiguration.tsx | 14 +- .../McpActionConfiguration.tsx | 10 +- .../OpenApiActionConfiguration.tsx | 6 +- .../workspaceAgents/AgentActionPicker.tsx | 12 +- .../workspaceAgents/AgentKnowledgeFields.tsx | 4 +- .../workspaceAgents/AgentTemplatesPanel.tsx | 18 +- application/v2_ui/src/lib/actionWorkbench.ts | 134 +++- application/v2_ui/src/lib/agentWorkbench.ts | 131 +++- .../src/lib/workspaceActionConnectors.ts | 40 +- .../v2_ui/src/lib/workspaceActionServices.ts | 24 +- .../v2_ui/src/lib/workspaceActionTypes.ts | 36 ++ .../v2_ui/src/lib/workspaceAgentCommands.ts | 6 +- .../v2_ui/src/lib/workspaceAgentKnowledge.ts | 10 + .../v2_ui/src/lib/workspaceAgentTemplates.ts | 10 +- .../v2_ui/src/lib/workspaceAuthoring.ts | 45 +- .../v2_ui/src/lib/workspaceEditorDrafts.ts | 20 +- .../src/pages/AdminGlobalEditorPages.tsx | 57 ++ .../v2_ui/src/pages/AdminSettingsPage.tsx | 144 +++-- .../src/pages/workspace/ActionEditorPage.tsx | 19 +- .../src/pages/workspace/AgentEditorPage.tsx | 5 +- docs/_data/app_surface.yml | 6 +- docs/admin/agents-actions.md | 87 ++- .../features/AGENT_DELEGATION_ACTION.md | 8 +- .../features/V2_ADMIN_AGENTS_ACTIONS.md | 28 +- .../V2_ADMIN_GLOBAL_AGENTS_ACTIONS.md | 192 ++++++ .../V2_ADMIN_CATEGORY_SCROLL_RESET_FIX.md | 65 ++ docs/guides/call-another-agent.md | 10 +- .../test_route_blueprint_policy_inventory.py | 16 + .../test_v2_admin_actions_parity.py | 28 +- .../test_v2_admin_agents_parity.py | 11 +- .../test_v2_admin_global_editor_backend.py | 489 ++++++++++++++ .../test_v2_admin_global_editor_logic.mjs | 243 +++++++ ...pace_authoring_credential_compatibility.py | 68 +- ui_tests/fixtures/agent_delegation/.gitignore | 6 - .../fixtures/agent_delegation/harness.html | 13 - .../agent_delegation/harness_build.py | 45 -- .../agent_delegation/harness_entry.tsx | 32 - ui_tests/fixtures/v2_admin_global_editors.py | 610 ++++++++++++++++++ ui_tests/test_agent_delegation_v2.py | 261 -------- .../test_v2_admin_global_agents_actions.py | 470 ++++++++++++++ ui_tests/test_v2_workspace_authoring.py | 5 +- 57 files changed, 4346 insertions(+), 571 deletions(-) create mode 100644 application/single_app/functions_global_editor_access.py create mode 100644 application/single_app/route_backend_v2_admin_agents_actions.py create mode 100644 application/v2_ui/src/components/admin/AgentTemplateApprovalsLink.tsx create mode 100644 application/v2_ui/src/components/admin/GlobalActionsManager.tsx create mode 100644 application/v2_ui/src/components/admin/GlobalAgentsManager.tsx create mode 100644 application/v2_ui/src/pages/AdminGlobalEditorPages.tsx create mode 100644 docs/explanation/features/V2_ADMIN_GLOBAL_AGENTS_ACTIONS.md create mode 100644 docs/explanation/fixes/V2_ADMIN_CATEGORY_SCROLL_RESET_FIX.md create mode 100644 functional_tests/test_v2_admin_global_editor_backend.py create mode 100644 functional_tests/test_v2_admin_global_editor_logic.mjs delete mode 100644 ui_tests/fixtures/agent_delegation/.gitignore delete mode 100644 ui_tests/fixtures/agent_delegation/harness.html delete mode 100644 ui_tests/fixtures/agent_delegation/harness_build.py delete mode 100644 ui_tests/fixtures/agent_delegation/harness_entry.tsx create mode 100644 ui_tests/fixtures/v2_admin_global_editors.py delete mode 100644 ui_tests/test_agent_delegation_v2.py create mode 100644 ui_tests/test_v2_admin_global_agents_actions.py diff --git a/application/single_app/admin_settings_fields.py b/application/single_app/admin_settings_fields.py index 374c9632f..0c4abba27 100644 --- a/application/single_app/admin_settings_fields.py +++ b/application/single_app/admin_settings_fields.py @@ -4352,6 +4352,25 @@ "depends_on": {"key": "enable_semantic_kernel", "equals": True}, }, ], + # The organisation's agents themselves, rather than settings about them. The + # component reads and writes through the global editor routes + # (/api/v2/admin/agents) and the classic enable and default-agent routes, never + # the settings draft, so each change saves on its own. + "organization-agents-section": [ + { + "type": "component", + "component": "global-agents-manager", + "label": "Global Agents", + "help": ( + "Agents the organisation provides. Everyone uses the default agent while " + "Workspace Mode is off; with it on, these appear beside people's own agents " + "only when global agents are added to workspaces. Create one from scratch " + "or from an approved template, and disable one to keep it without offering " + "it." + ), + "depends_on": {"key": "enable_semantic_kernel", "equals": True}, + }, + ], # Rendered by V1 only while Workspace Mode is on, which ``ADMIN_NAV`` now # states as a section condition, so V2 hides the section on the same terms. "agent-toggles-card": [ @@ -4580,6 +4599,17 @@ "default": True, "depends_on": {"key": "agent_templates_allow_user_submission", "equals": True}, }, + { + # Reviewing submissions happens in the shared approvals queue, which + # the classic Approvals page serves; this only points there. + "type": "component", + "component": "agent-template-approvals-link", + "label": "Approvals queue", + "help": ( + "Review, approve and remove submitted templates in the approvals queue, " + "beside the other requests an administrator handles." + ), + }, ], # --- Actions ---------------------------------------------------------- # @@ -4933,6 +4963,24 @@ "group": "Managed elsewhere", }, ], + # The organisation's actions themselves. The built-in toggles that used to sit + # here live in core-plugin-toggles, so this section holds no setting at all: + # the component reads and writes through the global editor routes + # (/api/v2/admin/actions) and the classic enable route, never the settings draft. + "actions-config": [ + { + "type": "component", + "component": "global-actions-manager", + "label": "Global Actions", + "help": ( + "Connections to APIs, databases, MCP servers and other services that " + "global agents can call. Workspaces see them too when global agents and " + "actions are added to workspaces. Disable one to keep its configuration " + "without loading it." + ), + "depends_on": {"key": "enable_semantic_kernel", "equals": True}, + }, + ], # --- Inbound MCP ------------------------------------------------------ # # The whole card is gated by ENABLE_MCP_UI, an App Service application diff --git a/application/single_app/admin_settings_nav.py b/application/single_app/admin_settings_nav.py index f63b48806..aa07db31c 100644 --- a/application/single_app/admin_settings_nav.py +++ b/application/single_app/admin_settings_nav.py @@ -151,6 +151,10 @@ "icon": "bi-robot", "sections": [ {"id": "agents-config", "label": "Agent Runtime", "icon": "bi-robot"}, + # The organisation's own agents: created, edited and chosen as the + # default here. V1 draws this as the Global Agents table inside the + # Agent Runtime card; V2 gives it a section of its own. + {"id": "organization-agents-section", "label": "Global Agents", "icon": "bi-stars"}, {"id": "agent-toggles-card", "label": "Workspace Agent Permissions", "icon": "bi-person-gear", "condition": "per_user_semantic_kernel"}, {"id": "agents-page-customization-card", "label": "Agents Page", "icon": "bi-palette"}, {"id": "agent-template-approvals-section", "label": "Agent Template Approvals", "icon": "bi-layers", "condition": "enable_agent_template_gallery"}, diff --git a/application/single_app/app.py b/application/single_app/app.py index 10e4b599f..80dde76cf 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -87,6 +87,7 @@ from route_backend_group_prompts_scoped import * from route_backend_group_actions_scoped import * from route_backend_group_agents_scoped import * +from route_backend_v2_admin_agents_actions import register_route_backend_v2_admin_agents_actions from route_backend_group_identities_scoped import * from route_backend_group_endpoints_scoped import * from route_backend_group_file_sources_scoped import * @@ -1411,6 +1412,7 @@ def list_semantic_kernel_plugins(): # ------------------- API V2 UI Routes ------------------- register_route_blueprint('backend_v2', register_route_backend_v2, user_required_blueprint) register_route_blueprint('backend_v2_admin', register_route_backend_v2_admin, admin_required_blueprint) +register_route_blueprint('backend_v2_admin_agents_actions', register_route_backend_v2_admin_agents_actions, admin_required_blueprint) # ------------------- API Orchestration Routes ---------- register_route_blueprint('backend_orchestration', register_route_backend_orchestration, user_required_blueprint) diff --git a/application/single_app/config.py b/application/single_app/config.py index 670ab4808..3a890675e 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.259" +VERSION = "0.261.260" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/functions_global_agents.py b/application/single_app/functions_global_agents.py index 08502c652..be446f08b 100644 --- a/application/single_app/functions_global_agents.py +++ b/application/single_app/functions_global_agents.py @@ -291,7 +291,12 @@ def delete_global_agent(agent_id): try: user_id = get_current_user_id() print(f"Deleting global agent: {agent_id}") - agent_dict = get_global_agent(agent_id) + # Read the stored references, not the UI placeholders: a masked read rebuilds the + # classic secret name, which misses the fresh names the V2 editor saves under. + agent_dict = cosmos_global_agents_container.read_item( + item=agent_id, + partition_key=agent_id + ) keyvault_agent_delete_helper(agent_dict, agent_id, scope="global") cosmos_global_agents_container.delete_item( item=agent_id, diff --git a/application/single_app/functions_global_editor_access.py b/application/single_app/functions_global_editor_access.py new file mode 100644 index 000000000..c14b12ace --- /dev/null +++ b/application/single_app/functions_global_editor_access.py @@ -0,0 +1,228 @@ +# functions_global_editor_access.py +"""Orchestration for the V2 Admin Settings global agent and action editors. + +The classic ``/api/admin/agents`` and ``/api/admin/plugins`` routes save whole +documents addressed by name, with no revision. These routes serve the V2 agent and +action editors instead, so global records get the contract personal and group +records already have: reads answer with ``{record, revision, secret_paths, +read_only}``, writes accept ``{updates, expected_revision, clear_secret_paths, +removed_paths}``, and a write made against a stale revision is refused with nothing +written. Persistence, secret handling and the conditional writes are the shared +engine in ``functions_workspace_authoring``; the routes enforce the Admin role. + +Enabling, disabling and choosing the default agent stay on the classic routes, which +already update those fields without rewriting stored secret references. +""" + +import json +import logging +import re +from importlib import import_module + +from flask import jsonify, request +from werkzeug.exceptions import HTTPException + +from functions_appinsights import log_event +from functions_settings import get_settings, update_settings +from functions_workspace_authoring import ( + apply_global_action_write, + apply_global_agent_write, + build_global_agent_editor_options, + build_secret_reminder_defaults, + delete_global_editor_record, + editor_error_response, + editor_resource, + list_global_editor_records, + log_committed_global_editor_change, + project_editor_record, + read_global_editor_record, +) + + +INVALID_GLOBAL_RESOURCE_ID = re.compile(r"[/\\?#,\x00-\x1f\x7f]") + + +class GlobalEditorError(HTTPException): + """A stable, non-sensitive failure at the global editor boundary.""" + + def __init__(self, message, status_code): + super().__init__(description=message) + self.code = status_code + + +def _validate_identifier(value, label): + if ( + not isinstance(value, str) or not value or len(value) > 512 + or value != value.strip() or value in (".", "..") + or INVALID_GLOBAL_RESOURCE_ID.search(value) + ): + raise GlobalEditorError(f"Invalid {label}.", 400) + + +def global_editor_error_response(exc): + """Map boundary errors and shared editor-engine errors to HTTP responses.""" + if isinstance(exc, GlobalEditorError): + response = jsonify({"error": exc.description}) + response.headers["Cache-Control"] = "no-store" + return response, exc.code + return editor_error_response(exc) + + +def reject_query_parameters(): + """These routes take no query parameters; a stray one is refused, not ignored.""" + if request.args: + raise GlobalEditorError("This request does not accept query parameters.", 400) + + +def reject_request_body(): + if request.get_data(): + raise GlobalEditorError("This read does not accept a request body.", 400) + + +def read_json_body(): + """Parse a JSON object body, rejecting duplicate keys so a smuggled second value + cannot shadow a validated one.""" + def unique_fields(pairs): + payload = {} + for key, value in pairs: + if key in payload: + raise GlobalEditorError("Duplicate fields are not supported.", 400) + payload[key] = value + return payload + + if not request.is_json: + raise GlobalEditorError("A JSON object is required for this action.", 400) + try: + body = json.loads(request.get_data(), object_pairs_hook=unique_fields) + except (ValueError, UnicodeDecodeError) as error: + raise GlobalEditorError("Provide valid JSON with no duplicate fields.", 400) from error + if not isinstance(body, dict): + raise GlobalEditorError("A JSON object is required for this action.", 400) + return body + + +def _resource(record, kind): + # Global records are read-only everywhere else; here they are the administrator's own. + return editor_resource(record, kind, global_scope=True, read_only=False) + + +def selected_agent_name(settings): + """The default global agent, which settings store by name.""" + selected = settings.get("global_selected_agent") + if isinstance(selected, dict): + return str(selected.get("name") or "") + return selected if isinstance(selected, str) else "" + + +def _follow_default_agent_rename(existing, saved, settings): + """Keep the default agent selected when it is renamed. + + The selection is stored by name, so a rename would otherwise leave it naming an + agent that no longer exists. The agent is already saved when this runs; a failure + here is logged rather than reported as a failed save. + """ + old_name = existing.get("name") + new_name = saved.get("name") + selected = settings.get("global_selected_agent") + if old_name == new_name or not isinstance(selected, dict) or selected.get("name") != old_name: + return + try: + if not update_settings({"global_selected_agent": {**selected, "name": new_name}}): + raise RuntimeError("The settings update was not saved.") + except Exception as exc: + log_event( + "[GLOBAL_AGENTS] The renamed default agent could not be reselected.", + level=logging.WARNING, + extra={"agent_id": saved.get("id", ""), "error_type": type(exc).__name__}, + ) + + +def list_global_agents(): + settings = get_settings() + agents = [ + project_editor_record(record, "agents", global_scope=True) + for record in list_global_editor_records("agents") + ] + return {"agents": agents, "selected_agent_name": selected_agent_name(settings)}, 200 + + +def get_global_agent(agent_id): + _validate_identifier(agent_id, "agent identifier") + return _resource(read_global_editor_record("agents", agent_id), "agents"), 200 + + +def get_global_agent_options(): + settings = get_settings() + # Bound lazily so this module does not import the agent route blueprint at load. + route_agents = import_module("route_backend_agents") + endpoints = route_agents.build_combined_model_endpoints(settings) + return build_global_agent_editor_options(settings, endpoints), 200 + + +def create_global_agent(user_id, body, prepare): + settings = get_settings() + saved = apply_global_agent_write(user_id, None, body, prepare, settings) + log_committed_global_editor_change("agents", user_id, saved, "creation") + return _resource(saved, "agents"), 201 + + +def update_global_agent(user_id, agent_id, body, prepare): + _validate_identifier(agent_id, "agent identifier") + settings = get_settings() + existing = read_global_editor_record("agents", agent_id) + saved = apply_global_agent_write(user_id, existing, body, prepare, settings) + _follow_default_agent_rename(existing, saved, settings) + log_committed_global_editor_change("agents", user_id, saved, "update") + return _resource(saved, "agents"), 200 + + +def delete_global_agent_record(user_id, agent_id): + _validate_identifier(agent_id, "agent identifier") + settings = get_settings() + existing = read_global_editor_record("agents", agent_id) + delete_global_editor_record("agents", user_id, existing, settings) + log_committed_global_editor_change("agents", user_id, existing, "deletion") + return {"success": True}, 200 + + +def list_global_actions(): + actions = [ + project_editor_record(record, "actions", global_scope=True) + for record in list_global_editor_records("actions") + ] + return {"actions": actions}, 200 + + +def get_global_action(action_id): + _validate_identifier(action_id, "action identifier") + return _resource(read_global_editor_record("actions", action_id), "actions"), 200 + + +def get_global_action_options(): + """The tenant-level Key Vault reminder defaults the action editor shows.""" + return {"secret_reminders": build_secret_reminder_defaults(get_settings())}, 200 + + +def create_global_action(user_id, body, prepare): + settings = get_settings() + saved = apply_global_action_write(user_id, None, body, prepare, settings) + log_committed_global_editor_change("actions", user_id, saved, "creation") + return _resource(saved, "actions"), 201 + + +def update_global_action(user_id, action_id, body, prepare): + _validate_identifier(action_id, "action identifier") + settings = get_settings() + existing = read_global_editor_record("actions", action_id) + saved = apply_global_action_write(user_id, existing, body, prepare, settings) + log_committed_global_editor_change("actions", user_id, saved, "update") + return _resource(saved, "actions"), 200 + + +def delete_global_action_record(user_id, action_id): + _validate_identifier(action_id, "action identifier") + settings = get_settings() + existing = read_global_editor_record("actions", action_id) + delete_global_editor_record("actions", user_id, existing, settings) + log_committed_global_editor_change("actions", user_id, existing, "deletion") + return {"success": True}, 200 diff --git a/application/single_app/functions_workspace_authoring.py b/application/single_app/functions_workspace_authoring.py index a16fabfe2..62db0aa74 100644 --- a/application/single_app/functions_workspace_authoring.py +++ b/application/single_app/functions_workspace_authoring.py @@ -54,6 +54,16 @@ ("new_foundry", "New Foundry", "allow_group_new_foundry_agents"), ("foundry_workflow", "Foundry Workflow", "allow_group_new_foundry_agents"), ) +# Global agents may be any type. The classic admin agent editor offers all four, and +# the tenant-wide ``allow_*foundry_agents`` defaults have no administrator control, +# so gating on them would take Foundry agents away from the one surface that has +# always been able to create them. +_GLOBAL_AGENT_TYPES = ( + ("local", "Local agent", None), + ("aifoundry", "Azure AI Foundry", None), + ("new_foundry", "New Foundry", None), + ("foundry_workflow", "Foundry Workflow", None), +) _BUILTIN_ACTIONS = ( ("time", "Time", "enable_time_plugin"), ("fact_memory", "Fact memory", "enable_fact_memory_plugin"), @@ -107,6 +117,23 @@ "enable_url_access": False, **{flag: False for _, _, flag in _BUILTIN_ACTIONS}, } +# The global agent editor options: the same shape again, with no workspace +# permission in it, because a global agent is the organisation's and is authored +# only by an administrator. +_GLOBAL_OPTION_DEFAULTS = { + "enable_semantic_kernel": False, + "per_user_semantic_kernel": False, + "merge_global_semantic_kernel_with_workspace": False, + "enable_multi_model_endpoints": False, + "default_model_selection": {}, + "gpt_model": {}, + "enable_gpt_apim": False, + "azure_apim_gpt_deployment": "", + "enable_agent_template_gallery": False, + "enable_web_search": False, + "enable_url_access": False, + **{flag: False for _, _, flag in _BUILTIN_ACTIONS}, +} class WorkspaceAuthoringConflict(ValueError): @@ -471,7 +498,7 @@ def merge(before, changes, path=(), *, replace_objects=False): return result -def _secret_scope(kind, user_id, record, path, group_id=None): +def _secret_scope(kind, user_id, record, path, group_id=None, *, global_scope=False): """Resolve the Key Vault (scope_value, source, scope) for one credential path. Group resources store their credentials in the group's Key Vault namespace so a @@ -484,7 +511,15 @@ def _secret_scope(kind, user_id, record, path, group_id=None): so ``{agent_id}--agent--group--{name}`` names written by the legacy group route resolve, and so a group agent never mints a ``--user--`` secret the legacy ``scope="group"`` delete would leave behind (M4C §2.1). + + Global agents and actions are keyed by their own id in the ``global`` namespace, + which is how ``save_global_agent`` and ``save_global_action`` name them, so a + record saved by the classic admin editor can be edited here and the reverse. """ + if global_scope: + if kind == "agents": + return (record["id"], "agent", "global") + return (record["id"], "action" if path[0] == "auth" else "action-addset", "global") if kind == "agents": return (record["id"], "agent", "group" if group_id else "user") source = "action" if path[0] == "auth" else "action-addset" @@ -493,7 +528,7 @@ def _secret_scope(kind, user_id, record, path, group_id=None): return (user_id, source, "user") -def _legacy_editor_secret_reference(record, path, kind, user_id, settings, group_id=None): +def _legacy_editor_secret_reference(record, path, kind, user_id, settings, group_id=None, *, global_scope=False): """Recover a legacy stored trigger only from its owned, conventional Key Vault name.""" unavailable = "A stored secret is unavailable. Re-enter its value." if not settings.get("enable_key_vault_secret_storage") or not settings.get("key_vault_name"): @@ -518,7 +553,9 @@ def _legacy_editor_secret_reference(record, path, kind, user_id, settings, group secret_name = keyvault._build_plugin_additional_field_secret_name(name, f"mcp-custom-header-{path[2]}") if not secret_name: raise WorkspaceAuthoringValidation(unavailable) - scope_value, source, kv_scope = _secret_scope(kind, user_id, record, path, group_id) + scope_value, source, kv_scope = _secret_scope( + kind, user_id, record, path, group_id, global_scope=global_scope, + ) try: reference = keyvault.build_full_secret_name(secret_name, scope_value, source, kv_scope) value = keyvault.resolve_secret_reference_for_context( @@ -545,12 +582,14 @@ def _supported_storage_paths(record, kind): } -def _check_stored_references(record, kind, user_id, group_id=None): +def _check_stored_references(record, kind, user_id, group_id=None, *, global_scope=False): keyvault = import_module("functions_keyvault") for path in editor_secret_paths(record, kind): value = _get(record, path) if _is_reference(value): - scope_value, source, kv_scope = _secret_scope(kind, user_id, record, path, group_id) + scope_value, source, kv_scope = _secret_scope( + kind, user_id, record, path, group_id, global_scope=global_scope, + ) if not keyvault.secret_reference_matches_context( value, scope_value=scope_value, scope=kv_scope, allowed_sources={source}, ): @@ -576,10 +615,10 @@ def _cleanup_staged_secrets(references): ) -def _stage_editor_secrets(record, kind, user_id, settings, staged, group_id=None): +def _stage_editor_secrets(record, kind, user_id, settings, staged, group_id=None, *, global_scope=False): """Fresh names isolate failed CAS writes from every credential in a stored manifest.""" keyvault = import_module("functions_keyvault") - _check_stored_references(record, kind, user_id, group_id) + _check_stored_references(record, kind, user_id, group_id, global_scope=global_scope) if not settings.get("enable_key_vault_secret_storage", False): return if not settings.get("key_vault_name"): @@ -590,7 +629,9 @@ def _stage_editor_secrets(record, kind, user_id, settings, staged, group_id=None continue if not isinstance(value, str): raise WorkspaceAuthoringValidation("Credentials must be text values.") - scope_value, source, kv_scope = _secret_scope(kind, user_id, record, path, group_id) + scope_value, source, kv_scope = _secret_scope( + kind, user_id, record, path, group_id, global_scope=global_scope, + ) secret_name = f"editor-{uuid.uuid4().hex}" reference = keyvault.store_secret_in_key_vault( secret_name, value, scope_value, source=source, scope=kv_scope, @@ -603,14 +644,16 @@ def _stage_editor_secrets(record, kind, user_id, settings, staged, group_id=None _set(record, path, reference) -def _cleanup_replaced_secrets(record, kind, user_id, settings, group_id=None): +def _cleanup_replaced_secrets(record, kind, user_id, settings, group_id=None, *, global_scope=False): if not record or not settings.get("enable_key_vault_secret_storage") or not settings.get("key_vault_name"): return keyvault = import_module("functions_keyvault") references = set() for path in _supported_storage_paths(record, kind): value = _get(record, path, None) - scope_value, source, kv_scope = _secret_scope(kind, user_id, record, path, group_id) + scope_value, source, kv_scope = _secret_scope( + kind, user_id, record, path, group_id, global_scope=global_scope, + ) if isinstance(value, str) and keyvault.secret_reference_matches_context( value, scope_value=scope_value, scope=kv_scope, allowed_sources={source}, ): @@ -620,7 +663,11 @@ def _cleanup_replaced_secrets(record, kind, user_id, settings, group_id=None): try: # Legacy names may be shared after a classic rename. Never delete a reference # still used by another owned manifest (including the just-committed one). - if group_id: + if global_scope: + still_used = _container(kind, "global").query_items( + query="SELECT * FROM c", enable_cross_partition_query=True, + ) + elif group_id: still_used = _container(kind, "group").query_items( query="SELECT * FROM c WHERE c.group_id = @group_id", parameters=[{"name": "@group_id", "value": group_id}], partition_key=group_id, @@ -644,7 +691,7 @@ def _cleanup_replaced_secrets(record, kind, user_id, settings, group_id=None): ) -def _sync_editor_reminders(saved, kind, user_id, settings, group_id=None): +def _sync_editor_reminders(saved, kind, user_id, settings, group_id=None, *, global_scope=False): if kind != "actions" or not settings.get("enable_key_vault_secret_storage"): return saved metadata = saved.get("metadata") or {} @@ -652,15 +699,21 @@ def _sync_editor_reminders(saved, kind, user_id, settings, group_id=None): return saved keyvault = import_module("functions_keyvault") updated = deepcopy(saved) - partition = group_id or user_id - scope = "group" if group_id else "personal" + if global_scope: + # Global records are partitioned by their own id. + partition, scope = saved["id"], "global" + else: + partition = group_id or user_id + scope = "group" if group_id else "personal" try: # The main CAS already succeeded. In particular, a stale request cannot # update expiration on a previously stored credential or reminder inventory. for path in _supported_storage_paths(updated, kind): reference = _get(updated, path, None) if isinstance(reference, str) and keyvault.validate_secret_name_dynamic(reference): - scope_value, source, kv_scope = _secret_scope(kind, user_id, updated, path, group_id) + scope_value, source, kv_scope = _secret_scope( + kind, user_id, updated, path, group_id, global_scope=global_scope, + ) keyvault._sync_plugin_secret_reminder(updated, path, reference, scope_value, source, kv_scope) sync_status = (updated.get("metadata") or {}).get("key_vault_secret_reminder_sync") if sync_status is not None and sync_status != metadata.get("key_vault_secret_reminder_sync"): @@ -679,11 +732,17 @@ def _sync_editor_reminders(saved, kind, user_id, settings, group_id=None): return saved -def _invalidate_editor_catalog(kind, user_id, operation, group_id=None): +def _invalidate_editor_catalog(kind, user_id, operation, group_id=None, *, global_scope=False): builtins.kernel_reload_needed = True try: cache = import_module("functions_chat_bootstrap_cache") - if group_id: + if global_scope: + # A global agent or action reaches every user, exactly as the classic + # global saves invalidate it. + cache.bump_chat_bootstrap_global_cache_version( + reason=f"global_{'agent' if kind == 'agents' else 'action'}_{operation}", + ) + elif group_id: # Group resource changes affect every member, so invalidate globally, # exactly as the classic group action and agent saves do (M4C §2.2). cache.bump_chat_bootstrap_global_cache_version( @@ -1072,9 +1131,242 @@ def apply_group_agent_write(user_id, group_id, existing, body, prepare, settings return save_group_editor_record("agents", user_id, group_id, prepared, existing, settings) +# === Global (administrator) agent and action editor === +# Global agents and actions reuse the same engine once more. The administrator +# boundary is enforced by the routes; these helpers bind the global containers +# (partitioned by id), the global Key Vault namespace (keyed by the record id, as the +# classic global saves name it), the global catalogue invalidation, and the two +# rules the classic admin routes apply: a name is unique among global records of +# its kind ignoring case, and the selected default agent cannot be deleted. + +def _assert_global_record_access(record): + """A stored global record must not claim a group or personal owner.""" + if ( + record.get("is_group") + or record.get("group_id") + or record.get("scope") not in (None, "", "global") + ): + raise LookupError("This resource is unavailable.") + + +def read_global_editor_record(kind, record_id): + """Read one raw global record for the editor.""" + if not isinstance(record_id, str) or not record_id or any(char in record_id for char in "/\\?#"): + raise LookupError("This resource is unavailable.") + exceptions = import_module("azure.cosmos.exceptions") + try: + record = _container(kind, "global").read_item(item=record_id, partition_key=record_id) + except exceptions.CosmosResourceNotFoundError as exc: + raise LookupError("This resource is unavailable.") from exc + if record.get("id") != record_id: + raise LookupError("This resource is unavailable.") + _assert_global_record_access(record) + return deepcopy(record) + + +def list_global_editor_records(kind): + """Return every raw global record of a kind, enabled or not, for the admin list.""" + records = [] + for record in _container(kind, "global").query_items( + query="SELECT * FROM c", enable_cross_partition_query=True, + ): + try: + _assert_global_record_access(record) + except LookupError: + continue + records.append(record) + return records + + +def _assert_global_available_name(kind, record, existing): + """Refuse a name another global record of the kind already holds, ignoring case. + + The classic admin routes apply the same rule. An unchanged name is accepted even + when a legacy duplicate exists, so an edit is never blocked by data it did not + create. + """ + name = record.get("name") + if not isinstance(name, str) or not name.strip(): + raise WorkspaceAuthoringValidation("A name is required.") + if existing and name == existing.get("name"): + return + matches = _container(kind, "global").query_items( + query="SELECT c.id FROM c WHERE STRINGEQUALS(c.name, @name, true)", + parameters=[{"name": "@name", "value": name}], enable_cross_partition_query=True, + ) + if any(match.get("id") != (existing or {}).get("id") for match in matches): + label = "agent" if kind == "agents" else "action" + raise WorkspaceAuthoringValidation(f"A global {label} with this name already exists.") + + +def save_global_editor_record(kind, user_id, record, existing, settings): + """Save one prepared global manifest with a mandatory conditional write.""" + if existing: + try: + current = read_global_editor_record(kind, existing["id"]) + except LookupError as exc: + raise WorkspaceAuthoringConflict("This resource changed. Reload it before saving.") from exc + if not existing.get("_etag") or existing["_etag"] != current.get("_etag"): + raise WorkspaceAuthoringConflict("This resource changed. Reload it before saving.") + delegation = import_module("functions_agent_delegation") + if kind == "agents": + delegation.validate_agent_delegation_bindings( + record, user_id=user_id, scope_type="global", scope_id="global", + settings=settings, existing_agent=existing, + ) + else: + record = delegation.validate_agent_action_for_scope( + record, user_id=user_id, scope_type="global", scope_id="global", settings=settings, + ) + identities = import_module("functions_workspace_identities") + identities.validate_action_identity_reference( + record, identities.WORKSPACE_IDENTITY_SCOPE_GLOBAL, identities.WORKSPACE_IDENTITY_SCOPE_GLOBAL, + ) + result = { + key: deepcopy(value) for key, value in record.items() + if not key.startswith("_") and key not in _MANAGED_FIELDS + } + now = datetime.now(timezone.utc).isoformat() + result.update({ + "id": existing["id"] if existing else record["id"], + "created_at": (existing or {}).get("created_at", now), + "created_by": (existing or {}).get("created_by", user_id), + "modified_at": now, "modified_by": user_id, "updated_at": now, + # The stored shape the classic global saves write, which the runtime and the + # chat catalogue branch on. + "is_global": True, "is_group": False, + }) + if kind == "actions": + result.update({"scope": "global", "scope_id": "global"}) + result["is_enabled"] = bool(result.get("is_enabled", (existing or {}).get("is_enabled", True))) + staged = [] + write_started = False + exceptions = import_module("azure.cosmos.exceptions") + try: + _stage_editor_secrets(result, kind, user_id, settings, staged, global_scope=True) + container = _container(kind, "global") + write_started = True + if existing: + saved = container.replace_item( + item=existing["id"], body=result, etag=existing["_etag"], + match_condition=import_module("azure.core").MatchConditions.IfNotModified, + ) + else: + saved = container.create_item(body=result) + except Exception as exc: + conflict_response = isinstance(exc, exceptions.CosmosHttpResponseError) and ( + exc.status_code in (409, 412) or (existing is not None and exc.status_code == 404) + ) + if not write_started: + _cleanup_staged_secrets(staged) + if conflict_response: + raise WorkspaceAuthoringConflict("This resource changed. Reload it before saving.") from exc + raise + saved = _sync_editor_reminders(saved, kind, user_id, settings, global_scope=True) + _cleanup_replaced_secrets(existing, kind, user_id, settings, global_scope=True) + _invalidate_editor_catalog(kind, user_id, "saved", global_scope=True) + return saved + + +def delete_global_editor_record(kind, user_id, record, settings): + """Delete one global record with a mandatory conditional write.""" + _assert_global_record_access(record) + if not record.get("_etag"): + raise WorkspaceAuthoringConflict("Reload this resource before deleting it.") + if kind == "agents": + selected = settings.get("global_selected_agent") + if isinstance(selected, dict) and selected.get("name") == record.get("name"): + raise WorkspaceAuthoringValidation("Choose another default agent before deleting this agent.") + exceptions = import_module("azure.cosmos.exceptions") + try: + _container(kind, "global").delete_item( + item=record["id"], partition_key=record["id"], etag=record["_etag"], + match_condition=import_module("azure.core").MatchConditions.IfNotModified, + ) + except exceptions.CosmosHttpResponseError as exc: + if exc.status_code in (404, 412): + raise WorkspaceAuthoringConflict("This resource changed. Reload it before deleting.") from exc + raise + _cleanup_replaced_secrets(record, kind, user_id, settings, global_scope=True) + _invalidate_editor_catalog(kind, user_id, "deleted", global_scope=True) + + +def _prepared_or_error(prepared, error, kind): + if error: + status = error[1] if isinstance(error, tuple) else 400 + if status == 403: + raise PermissionError("This configuration is unavailable.") + raise WorkspaceAuthoringValidation( + "Invalid agent configuration." if kind == "agents" else "Invalid action configuration." + ) + return prepared + + +def apply_global_action_write(user_id, existing, body, prepare, settings): + """Merge editor intent, validate through ``prepare``, and persist one global action. + + Mirrors :func:`apply_group_action_write`: the server allocates a new action's id, + and stored secret placeholders resolve against the global Key Vault namespace. + ``prepare`` takes ``(user_id, plugin, settings, existing)``. + """ + merged = merge_editor_write( + existing or {}, body, "actions", creating=existing is None, + resolve_stored_placeholder=lambda record, path: _legacy_editor_secret_reference( + record, path, "actions", user_id, settings, global_scope=True, + ), + ) + if not existing: + merged["id"] = str(uuid.uuid4()) + merged.setdefault("displayName", merged.get("name", "")) + merged.setdefault("description", "") + proposed = deepcopy(merged) + prepared, error = prepare( + user_id, _editor_validation_input("actions", merged, existing), settings, existing, + ) + prepared = _prepared_or_error(prepared, error, "actions") + if existing: + prepared = _preserve_unedited_values(existing, proposed, prepared) + _assert_global_available_name("actions", prepared, existing) + return save_global_editor_record("actions", user_id, prepared, existing, settings) + + +def apply_global_agent_write(user_id, existing, body, prepare, settings): + """Merge editor intent, validate through ``prepare``, and persist one global agent. + + Mirrors :func:`apply_group_agent_write`: the id is a client-allocated UUID, and the + agent-type gate runs against the tenant-wide Foundry flags the classic admin editor + reads. ``prepare`` takes ``(user_id, agent, settings, existing)``. + """ + merged = merge_editor_write( + existing or {}, body, "agents", creating=existing is None, + resolve_stored_placeholder=lambda record, path: _legacy_editor_secret_reference( + record, path, "agents", user_id, settings, global_scope=True, + ), + ) + if not existing: + try: + merged["id"] = str(uuid.UUID(merged.get("id", ""))) + except (ValueError, TypeError, AttributeError) as exc: + raise WorkspaceAuthoringValidation("Allocate an agent ID before saving.") from exc + merged.setdefault("display_name", merged.get("name", "")) + merged.setdefault("description", "") + merged.setdefault("instructions", "") + _validate_agent_editor_changes(merged, existing, settings, user_id, scope="global") + proposed = deepcopy(merged) + prepared, error = prepare( + user_id, _editor_validation_input("agents", merged, existing), settings, existing, + ) + prepared = _prepared_or_error(prepared, error, "agents") + if existing: + prepared = _preserve_unedited_values(existing, proposed, prepared) + _assert_global_available_name("agents", prepared, existing) + return save_global_editor_record("agents", user_id, prepared, existing, settings) + + def _validate_agent_editor_changes(record, existing, settings, user_id, *, scope="personal"): is_group = scope == "group" - type_table = _GROUP_AGENT_TYPES if is_group else _AGENT_TYPES + is_global = scope == "global" + type_table = _GROUP_AGENT_TYPES if is_group else _GLOBAL_AGENT_TYPES if is_global else _AGENT_TYPES endpoint_flag = "allow_group_custom_endpoints" if is_group else "allow_user_custom_endpoints" endpoint_governance = "governance_group_endpoints" if is_group else "governance_user_endpoints" surface = "group" if is_group else "personal" @@ -1086,6 +1378,11 @@ def _validate_agent_editor_changes(record, existing, settings, user_id, *, scope raise PermissionError(f"This agent type is disabled for {surface} workspaces.") if kind != "local" and record.get("actions_to_load"): raise WorkspaceAuthoringValidation("Remove local actions explicitly before selecting a Foundry agent type.") + if is_global: + # An administrator may give a global agent its own model connection, as the + # classic admin editor always allowed; the workspace custom-endpoint + # permissions govern people's own agents, not the organisation's. + return if kind == "local": configured_connection = any( record.get(field) not in (None, "") @@ -1315,6 +1612,30 @@ def log_committed_group_editor_change(kind, user_id, group_id, stored, operation ) +def log_committed_global_editor_change(kind, user_id, stored, operation): + """Record a committed global editor write, mirroring the group logger. + + Called only after the conditional write has committed, so a refused write is never + logged, and a logging failure never fails the write. ``scope`` is ``"global"``, + the scope the classic admin routes record. + """ + activity = import_module("functions_activity_logging") + prefix = "agent" if kind == "agents" else "action" + event = getattr(activity, f"log_{prefix}_{operation}") + try: + event( + user_id=user_id, scope="global", + **{f"{prefix}_id": stored.get("id", ""), f"{prefix}_name": stored.get("name", "")}, + **({"agent_display_name": stored.get("display_name", "")} if prefix == "agent" and operation != "deletion" else {}), + **({"action_type": stored.get("type", "")} if prefix == "action" and operation != "deletion" else {}), + ) + except Exception as exc: + import_module("functions_appinsights").log_event( + "[WORKSPACE_ACTIVITY] Unable to record a committed editor change.", + level=logging.WARNING, extra={"error_type": type(exc).__name__}, + ) + + def build_secret_reminder_defaults(settings): """The tenant-level Key Vault reminder defaults both editor option builders share. @@ -1501,6 +1822,58 @@ def build_group_agent_editor_options(user_id, group_id, settings, model_endpoint } +def build_global_agent_editor_options(settings, model_endpoints): + """The global agent editor options, for an administrator. + + The personal and group shape, with what a global agent needs: every agent type is + offered, as the classic admin editor offers them all, every global model connection + is offered (an administrator manages them, so no per-user governance filter + applies), and template submission follows the gate the submit route applies to an + administrator. Secrets are masked and reminder defaults are shared with the other + builders. + """ + settings_module = import_module("functions_settings") + sanitized = settings_module.sanitize_settings_for_user({ + key: deepcopy(settings.get(key, default)) for key, default in _GLOBAL_OPTION_DEFAULTS.items() + }) + safe = {key: sanitized[key] for key in _GLOBAL_OPTION_DEFAULTS if key in sanitized} + model_endpoints = settings_module.sanitize_settings_for_user( + {"model_endpoints": filter_model_endpoints_by_capability(model_endpoints, preserve_empty=True)}, + ).get("model_endpoints", []) + # These names contain "key"/"secret", but their values are strictly UI flags/defaults. + reminder_options = build_secret_reminder_defaults(settings) + safe.update({ + "enable_key_vault_secret_storage": reminder_options["storage_enabled"], + "enable_key_vault_secret_expiration_reminders": reminder_options["reminders_enabled"], + "key_vault_secret_expiration_default_lead_days": reminder_options["lead_days"], + "key_vault_secret_expiration_default_contact_email": reminder_options["contact_email"], + "key_vault_secret_expiration_require_expiration": reminder_options["require_expiration"], + }) + templates_module = import_module("functions_agent_templates") + safe["agent_template_submission_allowed"] = templates_module.agent_template_submission_decision( + settings, True, + )[0] + endpoints = [deepcopy(endpoint) for endpoint in model_endpoints if endpoint.get("scope") == "global"] + for endpoint in endpoints: + for path in editor_secret_paths(endpoint, "agents"): + _set(endpoint, path, EDITOR_SECRET_MASK) + safe["enable_multi_model_endpoints"] = bool( + safe.get("enable_multi_model_endpoints") or any(endpoint.get("models") for endpoint in endpoints) + ) + return { + "agent_types": [ + {"value": value, "label": label, "enabled": True} + for value, label, _flag in _GLOBAL_AGENT_TYPES + ], + "settings": safe, + "model_endpoints": endpoints, + "builtin_actions": [ + {"id": value, "label": label} + for value, label, flag in _BUILTIN_ACTIONS if safe.get(flag) + ], + } + + def build_action_editor_types(discovered_types): """Enrich governed discovery with canonical local schemas, without example defaults.""" validation = import_module("json_schema_validation") diff --git a/application/single_app/route_backend_agents.py b/application/single_app/route_backend_agents.py index 5ec735f7e..da2823d88 100644 --- a/application/single_app/route_backend_agents.py +++ b/application/single_app/route_backend_agents.py @@ -1344,6 +1344,54 @@ def _prepare_group_agent_payload(user_id, group_id, agent, settings, existing): return cleaned_agent, None +def _prepare_global_agent_payload(user_id, agent, settings, existing): + """Clean, enrich and validate one global agent for the V2 admin editor routes. + + Applies the rules the classic ``/api/admin/agents`` routes apply: the payload is + sanitized and marked global, assigned knowledge resolves in global scope with the + administrator's access, the schema validates, and Call agent attachments validate + against the global scope. Returns ``(cleaned_agent, error_response)`` with exactly + one set; the signature matches ``apply_global_agent_write``. + """ + try: + cleaned_agent = sanitize_agent_payload(agent) + except AgentPayloadError as exc: + return None, (jsonify({'error': str(exc)}), 400) + + cleaned_agent['is_global'] = True + cleaned_agent['is_group'] = False + + try: + if existing is None or ( + cleaned_agent.get('other_settings', {}).get('assigned_knowledge') + != (existing.get('other_settings') or {}).get('assigned_knowledge') + ): + cleaned_agent = apply_assigned_knowledge_to_agent_payload( + cleaned_agent, + user_id=user_id, + agent_scope='global', + is_admin=True, + ) + except AssignedKnowledgeError as exc: + return None, (jsonify({'error': str(exc)}), 400) + + validation_error = validate_agent(cleaned_agent) + if validation_error: + return None, (jsonify({'error': f'Agent validation failed: {validation_error}'}), 400) + + try: + validate_agent_delegation_bindings( + cleaned_agent, user_id=user_id, scope_type='global', scope_id='global', + settings=settings, existing_agent=existing, + ) + except PermissionError: + return None, (jsonify({'error': 'You are not authorized to attach these agent actions.'}), 403) + except (ValueError, LookupError): + return None, (jsonify({'error': 'Invalid or unavailable Call agent action selection.'}), 400) + + return cleaned_agent, None + + def _create_personal_agent(user_id, payload): """Create one personal agent from an object body.""" try: diff --git a/application/single_app/route_backend_plugins.py b/application/single_app/route_backend_plugins.py index ee806d00a..c2681bf6d 100644 --- a/application/single_app/route_backend_plugins.py +++ b/application/single_app/route_backend_plugins.py @@ -1495,6 +1495,82 @@ def _prepare_group_action_payload(user_id, group_id, plugin, settings, existing) return plugin_to_save, None +def _prepare_global_action_payload(user_id, plugin, settings, existing): + """Clean, default and validate one global action for the V2 admin editor routes. + + Mirrors ``_prepare_group_action_payload`` with the checks the classic + ``/api/admin/plugins`` routes apply to a global action: the type must be one the + deployment provides (a legacy type only when editing a record that already has + it), the identity and the MCP destination policy resolve against the global + scope, and per-type workspace governance does not apply, because an administrator + authors the organisation's actions. Returns ``(plugin_to_save, error_response)`` + with exactly one set; the signature matches ``apply_global_action_write``. + """ + validate_legacy_action_update(plugin, existing) + plugin_to_save = dict(plugin) + if 'is_global' in plugin_to_save: + del plugin_to_save['is_global'] + if 'is_group' in plugin_to_save: + del plugin_to_save['is_group'] + + plugin_to_save.setdefault('name', '') + plugin_to_save.setdefault('displayName', plugin_to_save.get('name', '')) + plugin_to_save.setdefault('description', '') + plugin_to_save.setdefault('metadata', {}) + plugin_to_save.setdefault('additionalFields', {}) + + for field in PLUGIN_STORAGE_MANAGED_FIELDS: + if field == 'id': + continue + plugin_to_save.pop(field, None) + for field in ('execution_status', 'is_legacy', 'legacy_source', 'legacy_locator', 'runtime_user_id'): + plugin_to_save.pop(field, None) + + plugin_to_save.setdefault('endpoint', '') + _apply_plugin_runtime_defaults(plugin_to_save) + plugin_type = plugin_to_save['type'] + allowed_types = discover_plugin_types(include_legacy=existing is not None) + if allowed_types is not None and plugin_type not in allowed_types: + return None, (jsonify({'error': 'This action type is not available.'}), 400) + try: + _validate_action_identity_for_scope( + plugin_to_save, + WORKSPACE_IDENTITY_SCOPE_GLOBAL, + WORKSPACE_IDENTITY_SCOPE_GLOBAL, + ) + except (ValueError, LookupError, PermissionError): + return None, (jsonify({'error': 'Action identity configuration is invalid.'}), 400) + + if 'auth' not in plugin_to_save: + plugin_to_save['auth'] = {'type': 'identity'} + elif not isinstance(plugin_to_save['auth'], dict): + plugin_to_save['auth'] = {'type': 'identity'} + elif 'type' not in plugin_to_save['auth']: + plugin_to_save['auth']['type'] = 'identity' + + validation_error = validate_editor_action_manifest(plugin_to_save) + if validation_error: + return None, (jsonify({'error': f'Plugin validation failed: {validation_error}'}), 400) + is_valid, validation_errors = PluginHealthChecker.validate_plugin_manifest(plugin_to_save, plugin_type) + if not is_valid: + return None, (jsonify({'error': f'Plugin validation failed: {"; ".join(validation_errors)}'}), 400) + + try: + _enforce_mcp_destination_policy( + plugin_to_save, + WORKSPACE_IDENTITY_SCOPE_GLOBAL, + WORKSPACE_IDENTITY_SCOPE_GLOBAL, + operation='global_action_save', + user_id=user_id, + ) + except PermissionError: + return None, (jsonify({'error': 'MCP destination is not allowed by governance policy.'}), 403) + except ValueError: + return None, (jsonify({'error': 'MCP destination configuration is invalid.'}), 400) + + return plugin_to_save, None + + def _save_personal_action_or_error(user_id, plugin_to_save, legacy_locator=None): """Persist one personal action, mapping the storage failures onto HTTP responses.""" try: diff --git a/application/single_app/route_backend_v2_admin_agents_actions.py b/application/single_app/route_backend_v2_admin_agents_actions.py new file mode 100644 index 000000000..036b15318 --- /dev/null +++ b/application/single_app/route_backend_v2_admin_agents_actions.py @@ -0,0 +1,212 @@ +# route_backend_v2_admin_agents_actions.py +"""V2 Admin Settings routes for the global agent and action editors. + +These serve the same V2 agent and action editors personal and group workspaces use, +for the organisation's global agents and actions. Every route requires the Admin +role; the shared editor contract, conditional writes and secret handling are +described in ``functions_global_editor_access``. +""" + +from functools import wraps + +from flask import jsonify + +from functions_authentication import admin_required, get_current_user_id, login_required +from functions_global_editor_access import ( + create_global_action, + create_global_agent, + delete_global_action_record, + delete_global_agent_record, + get_global_action, + get_global_action_options, + get_global_agent, + get_global_agent_options, + global_editor_error_response, + list_global_actions, + list_global_agents, + read_json_body, + reject_query_parameters, + reject_request_body, + update_global_action, + update_global_agent, +) +from functions_workspace_authoring import build_action_editor_types +from route_backend_agents import _prepare_global_agent_payload +from route_backend_plugins import _prepare_global_action_payload, get_plugin_types +from swagger_wrapper import swagger_route, get_auth_security + + +def _global_editor_boundary(function): + @wraps(function) + def guarded(*args, **kwargs): + try: + return function(*args, **kwargs) + except Exception as error: # noqa: BLE001 - all mapped to stable HTTP responses + return global_editor_error_response(error) + return guarded + + +def _no_store(payload, status): + # Editor reads carry configuration, so they stay out of shared caches. + response = jsonify(payload) + response.headers['Cache-Control'] = 'no-store' + return response, status + + +def register_route_backend_v2_admin_agents_actions(bp): + """Register the global agent and action editor routes. + + - GET /api/v2/admin/agents + - POST /api/v2/admin/agents + - GET /api/v2/admin/agent-options + - GET /api/v2/admin/agents/ + - PATCH /api/v2/admin/agents/ + - DELETE /api/v2/admin/agents/ + - GET /api/v2/admin/actions + - POST /api/v2/admin/actions + - GET /api/v2/admin/actions/types + - GET /api/v2/admin/action-options + - GET /api/v2/admin/actions/ + - PATCH /api/v2/admin/actions/ + - DELETE /api/v2/admin/actions/ + """ + + @bp.route('/api/v2/admin/agents', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + @_global_editor_boundary + def v2_admin_global_agents_list(): + reject_query_parameters() + reject_request_body() + return _no_store(*list_global_agents()) + + @bp.route('/api/v2/admin/agents', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + @_global_editor_boundary + def v2_admin_global_agents_create(): + reject_query_parameters() + return _no_store(*create_global_agent( + get_current_user_id(), read_json_body(), _prepare_global_agent_payload, + )) + + @bp.route('/api/v2/admin/agent-options', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + @_global_editor_boundary + def v2_admin_global_agent_options(): + # Its own path segment so it can never collide with /agents/. + reject_query_parameters() + reject_request_body() + return _no_store(*get_global_agent_options()) + + @bp.route('/api/v2/admin/agents/', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + @_global_editor_boundary + def v2_admin_global_agent_read(agent_id): + reject_query_parameters() + reject_request_body() + return _no_store(*get_global_agent(agent_id)) + + @bp.route('/api/v2/admin/agents/', methods=['PATCH']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + @_global_editor_boundary + def v2_admin_global_agent_update(agent_id): + reject_query_parameters() + return _no_store(*update_global_agent( + get_current_user_id(), agent_id, read_json_body(), _prepare_global_agent_payload, + )) + + @bp.route('/api/v2/admin/agents/', methods=['DELETE']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + @_global_editor_boundary + def v2_admin_global_agent_delete(agent_id): + reject_query_parameters() + reject_request_body() + return _no_store(*delete_global_agent_record(get_current_user_id(), agent_id)) + + @bp.route('/api/v2/admin/actions', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + @_global_editor_boundary + def v2_admin_global_actions_list(): + reject_query_parameters() + reject_request_body() + return _no_store(*list_global_actions()) + + @bp.route('/api/v2/admin/actions', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + @_global_editor_boundary + def v2_admin_global_actions_create(): + reject_query_parameters() + return _no_store(*create_global_action( + get_current_user_id(), read_json_body(), _prepare_global_action_payload, + )) + + @bp.route('/api/v2/admin/actions/types', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + @_global_editor_boundary + def v2_admin_global_action_types(): + # Every type the deployment provides, as the classic admin catalogue offers: + # per-type workspace governance applies to people's own actions, not to the + # organisation's. + reject_query_parameters() + reject_request_body() + discovered = get_plugin_types() + return _no_store({'types': build_action_editor_types(discovered.get_json())}, 200) + + @bp.route('/api/v2/admin/action-options', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + @_global_editor_boundary + def v2_admin_global_action_options(): + # Its own path segment so it can never collide with /actions/. + reject_query_parameters() + reject_request_body() + return _no_store(*get_global_action_options()) + + @bp.route('/api/v2/admin/actions/', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + @_global_editor_boundary + def v2_admin_global_action_read(action_id): + reject_query_parameters() + reject_request_body() + return _no_store(*get_global_action(action_id)) + + @bp.route('/api/v2/admin/actions/', methods=['PATCH']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + @_global_editor_boundary + def v2_admin_global_action_update(action_id): + reject_query_parameters() + return _no_store(*update_global_action( + get_current_user_id(), action_id, read_json_body(), _prepare_global_action_payload, + )) + + @bp.route('/api/v2/admin/actions/', methods=['DELETE']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + @_global_editor_boundary + def v2_admin_global_action_delete(action_id): + reject_query_parameters() + reject_request_body() + return _no_store(*delete_global_action_record(get_current_user_id(), action_id)) diff --git a/application/single_app/templates/admin/_panes/agents.html b/application/single_app/templates/admin/_panes/agents.html index 86164afba..a9f814e19 100644 --- a/application/single_app/templates/admin/_panes/agents.html +++ b/application/single_app/templates/admin/_panes/agents.html @@ -207,7 +207,7 @@

Agent Orchestration Settings

-
+

Global Agents

+ ); +} diff --git a/application/v2_ui/src/components/admin/GlobalActionsManager.tsx b/application/v2_ui/src/components/admin/GlobalActionsManager.tsx new file mode 100644 index 000000000..7c15e6c1e --- /dev/null +++ b/application/v2_ui/src/components/admin/GlobalActionsManager.tsx @@ -0,0 +1,178 @@ +// GlobalActionsManager.tsx +// The organisation's global actions, listed and managed from Admin Settings. +// +// Actions are edited in the same V2 action editor workspaces use, at /admin/actions/, +// which carries the connector catalogue, MCP preconfigurations and presets, connection +// tests and Key Vault handling. This list adds what the classic admin table offered around +// it: creating one, enabling or disabling one, and deleting one. Each change saves on its +// own rather than through the settings save bar, because these are records, not settings. + +import { useEffect, useMemo, useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { AlertCircle, Loader2, Plug, Plus, RefreshCw, Trash2 } from 'lucide-react'; +import { api } from '../../lib/apiClient'; +import { fetchGlobalActions, GLOBAL_ACTION_WORKBENCH } from '../../lib/actionWorkbench'; +import { actionTypeLabel } from '../../lib/workspaceActionLogic'; +import { GLOBAL_ACTIONS_BASE_PATH, type ActionConfiguration } from '../../lib/workspaceAuthoring'; +import { toast } from '../../stores/toastStore'; +import { GlassButton } from '../ui/primitives'; +import { ConfirmAction, Pill, SectionSearch } from '../workspace/primitives'; + +/** Search appears once a list is long enough to need it. */ +const SEARCH_THRESHOLD = 6; + +function actionLabel(action: ActionConfiguration): string { + return action.displayName || action.name || 'Untitled action'; +} + +function failure(cause: unknown, fallback: string): string { + return cause instanceof Error && cause.message ? cause.message : fallback; +} + +export function GlobalActionsManager({ help }: { help?: string }) { + const navigate = useNavigate(); + const [actions, setActions] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(null); + const [query, setQuery] = useState(''); + const [revision, setRevision] = useState(0); + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(null); + void fetchGlobalActions(controller.signal).then((items) => { + if (!controller.signal.aborted) setActions(items); + }).catch((cause: unknown) => { + if (!controller.signal.aborted) setError(failure(cause, 'Could not load the global actions.')); + }).finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [revision]); + + const reload = () => setRevision((value) => value + 1); + + const visible = useMemo(() => { + const needle = query.trim().toLowerCase(); + return [...(actions ?? [])] + .filter((action) => !needle || `${actionLabel(action)} ${action.name} ${action.description} ${actionTypeLabel(action.type)}` + .toLowerCase().includes(needle)) + .sort((left, right) => actionLabel(left).localeCompare(actionLabel(right))); + }, [actions, query]); + + /** Run one change, report it, and re-read the list so every row shows the stored state. */ + const change = async (key: string, work: () => Promise) => { + setBusy(key); + setError(null); + try { + toast.success(await work()); + reload(); + } catch (cause) { + setError(failure(cause, 'The change could not be saved.')); + } finally { + setBusy(null); + } + }; + + const toggleEnabled = (action: ActionConfiguration) => change(`enabled:${action.id}`, async () => { + const enable = action.is_enabled === false; + await api.patch(`/api/admin/plugins/${encodeURIComponent(action.name)}/enabled`, { is_enabled: enable }); + return `${actionLabel(action)} ${enable ? 'enabled' : 'disabled'}.`; + }); + + const remove = (action: ActionConfiguration) => change(`delete:${action.id}`, async () => { + await GLOBAL_ACTION_WORKBENCH.deleteAction(action); + return `${actionLabel(action)} deleted.`; + }); + + return ( +
+ {help ?

{help}

: null} + +
+ navigate(`${GLOBAL_ACTIONS_BASE_PATH}/new`)}> + + + +
+ + {(actions?.length ?? 0) >= SEARCH_THRESHOLD ? ( + + ) : null} + + {error ? ( +

+

+ ) : null} + + {loading && actions === null ? ( +

+

+ ) : null} + + {actions !== null && actions.length === 0 ? ( +

+ No global actions yet. Create one to connect global agents to an API, a database, an + MCP server or another agent. +

+ ) : null} + {actions !== null && actions.length > 0 && visible.length === 0 ? ( +

No global actions match “{query}”.

+ ) : null} + + {visible.length ? ( +
    + {visible.map((action) => { + const label = actionLabel(action); + const enabled = action.is_enabled !== false; + const editorPath = `${GLOBAL_ACTIONS_BASE_PATH}/${encodeURIComponent(action.id)}`; + return ( +
  • +
    +
    +
    + navigate(editorPath)}> + Edit + + void toggleEnabled(action)}> + {busy === `enabled:${action.id}` ? + } label={`Delete ${label}`} confirmLabel="Delete action" + busy={busy === `delete:${action.id}`} disabled={busy !== null} + onConfirm={() => void remove(action)} /> +
    +
  • + ); + })} +
+ ) : null} +
+ ); +} diff --git a/application/v2_ui/src/components/admin/GlobalAgentsManager.tsx b/application/v2_ui/src/components/admin/GlobalAgentsManager.tsx new file mode 100644 index 000000000..43340eb7b --- /dev/null +++ b/application/v2_ui/src/components/admin/GlobalAgentsManager.tsx @@ -0,0 +1,226 @@ +// GlobalAgentsManager.tsx +// The organisation's global agents, listed and managed from Admin Settings. +// +// The agents themselves are edited in the same V2 agent editor workspaces use, at +// /admin/agents/. This list carries what the classic admin table offered around it: +// creating one (from scratch or an approved template), enabling or disabling one, making +// one the default, and deleting one. Each change saves on its own rather than through the +// settings save bar, because these are records, not settings. + +import { useEffect, useMemo, useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { AlertCircle, Loader2, Plus, RefreshCw, Sparkles, Trash2 } from 'lucide-react'; +import { api } from '../../lib/apiClient'; +import { fetchGlobalAgentCatalog, GLOBAL_AGENT_WORKBENCH } from '../../lib/agentWorkbench'; +import { AGENT_TYPE_LABELS } from '../../lib/workspaceAgentAuthoring'; +import { GLOBAL_AGENTS_BASE_PATH, type AgentConfiguration } from '../../lib/workspaceAuthoring'; +import { toast } from '../../stores/toastStore'; +import { GlassButton } from '../ui/primitives'; +import { ConfirmAction, Pill, SectionSearch } from '../workspace/primitives'; +import { AgentIcon } from '../workspaceAgents/AgentIdentityFields'; + +/** Search appears once a list is long enough to need it. */ +const SEARCH_THRESHOLD = 6; + +interface EnabledResponse { + success?: boolean; + fallback_agent_name?: string | null; +} + +function agentLabel(agent: AgentConfiguration): string { + return agent.display_name || agent.name || 'Untitled agent'; +} + +function failure(cause: unknown, fallback: string): string { + return cause instanceof Error && cause.message ? cause.message : fallback; +} + +function modelSummary(agent: AgentConfiguration): string { + if (agent.agent_type && agent.agent_type !== 'local') return 'Foundry-managed model'; + return agent.model_id || agent.azure_openai_gpt_deployment || agent.azure_agent_apim_gpt_deployment + || 'Default model'; +} + +export function GlobalAgentsManager({ help, templatesEnabled }: { help?: string; templatesEnabled: boolean }) { + const navigate = useNavigate(); + const [agents, setAgents] = useState(null); + const [defaultName, setDefaultName] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(null); + const [query, setQuery] = useState(''); + const [revision, setRevision] = useState(0); + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(null); + void fetchGlobalAgentCatalog(controller.signal).then((catalog) => { + if (controller.signal.aborted) return; + setAgents(catalog.agents); + setDefaultName(catalog.selectedAgentName); + }).catch((cause: unknown) => { + if (!controller.signal.aborted) setError(failure(cause, 'Could not load the global agents.')); + }).finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [revision]); + + const reload = () => setRevision((value) => value + 1); + + const visible = useMemo(() => { + const needle = query.trim().toLowerCase(); + return [...(agents ?? [])] + .filter((agent) => !needle || `${agentLabel(agent)} ${agent.name} ${agent.description}`.toLowerCase().includes(needle)) + .sort((left, right) => agentLabel(left).localeCompare(agentLabel(right))); + }, [agents, query]); + + /** Run one change, report it, and re-read the list so every row shows the stored state. */ + const change = async (key: string, work: () => Promise) => { + setBusy(key); + setError(null); + try { + toast.success(await work()); + reload(); + } catch (cause) { + setError(failure(cause, 'The change could not be saved.')); + } finally { + setBusy(null); + } + }; + + const toggleEnabled = (agent: AgentConfiguration) => change(`enabled:${agent.id}`, async () => { + const enable = agent.is_enabled === false; + const result = await api.patch( + `/api/admin/agents/${encodeURIComponent(agent.name)}/enabled`, { is_enabled: enable }, + ); + const label = agentLabel(agent); + if (result.fallback_agent_name) { + // The route names the new default by its stored name; show the name people see. + const fallback = agents?.find((item) => item.name === result.fallback_agent_name); + return `${label} disabled. ${fallback ? agentLabel(fallback) : result.fallback_agent_name} is now the default agent.`; + } + return `${label} ${enable ? 'enabled' : 'disabled'}.`; + }); + + const makeDefault = (agent: AgentConfiguration) => change(`default:${agent.id}`, async () => { + await api.post('/api/admin/agents/selected_agent', { name: agent.name }); + return `${agentLabel(agent)} is now the default agent.`; + }); + + const remove = (agent: AgentConfiguration) => change(`delete:${agent.id}`, async () => { + await GLOBAL_AGENT_WORKBENCH.deleteAgent(agent); + return `${agentLabel(agent)} deleted.`; + }); + + return ( +
+ {help ?

{help}

: null} + +
+ navigate(`${GLOBAL_AGENTS_BASE_PATH}/new`)}> + + {templatesEnabled ? ( + navigate(`${GLOBAL_AGENTS_BASE_PATH}/new?templates=1`)}> + + ) : null} + + +
+ + {(agents?.length ?? 0) >= SEARCH_THRESHOLD ? ( + + ) : null} + + {error ? ( +

+

+ ) : null} + + {loading && agents === null ? ( +

+

+ ) : null} + + {agents !== null && agents.length === 0 ? ( +

+ No global agents yet. Create one, or start from an approved template, and it becomes + available as soon as it is saved. +

+ ) : null} + {agents !== null && agents.length > 0 && visible.length === 0 ? ( +

No global agents match “{query}”.

+ ) : null} + + {visible.length ? ( +
    + {visible.map((agent) => { + const label = agentLabel(agent); + const enabled = agent.is_enabled !== false; + const isDefault = Boolean(defaultName) && agent.name === defaultName; + const editorPath = `${GLOBAL_AGENTS_BASE_PATH}/${encodeURIComponent(agent.id)}`; + return ( +
  • +
    + +
    +

    + {label} +

    + {agent.description ? ( +

    {agent.description}

    + ) : null} +
    + {AGENT_TYPE_LABELS[agent.agent_type] ?? agent.agent_type} + {isDefault ? Default agent : null} + {!enabled ? Disabled : null} +
    +

    + {modelSummary(agent)}{' · '}{agent.actions_to_load?.length ?? 0} actions{' · '}{agent.name} +

    +
    +
    +
    + navigate(editorPath)}> + Edit + + {enabled && !isDefault ? ( + void makeDefault(agent)}> + Make default + + ) : null} + void toggleEnabled(agent)}> + {busy === `enabled:${agent.id}` ? + {isDefault ? ( + Choose another default to delete + ) : ( + } label={`Delete ${label}`} confirmLabel="Delete agent" + busy={busy === `delete:${agent.id}`} disabled={busy !== null} + onConfirm={() => void remove(agent)} /> + )} +
    +
  • + ); + })} +
+ ) : null} +
+ ); +} diff --git a/application/v2_ui/src/components/workspaceActions/ActionConfigurationFields.tsx b/application/v2_ui/src/components/workspaceActions/ActionConfigurationFields.tsx index 3e07b6a83..ecdb7c083 100644 --- a/application/v2_ui/src/components/workspaceActions/ActionConfigurationFields.tsx +++ b/application/v2_ui/src/components/workspaceActions/ActionConfigurationFields.tsx @@ -15,7 +15,7 @@ import { import { nativeActionDefinition, sqlConnectionMethod, usesDirectBlobConnectionString } from '../../lib/workspaceActionRegistry'; import { EDITOR_SECRET_MASK, isRecord, type ActionTypeDefinition } from '../../lib/workspaceAuthoring'; import { testWorkspaceAction } from '../../lib/workspaceActionServices'; -import type { ActionConnectorProps, ActionFieldDescriptor } from '../../lib/workspaceActionTypes'; +import { connectorTestScope, type ActionConnectorProps, type ActionFieldDescriptor } from '../../lib/workspaceActionTypes'; export function ActionDescriptorField({ props, descriptor }: { props: ActionConnectorProps; descriptor: ActionFieldDescriptor }) { const id = useId(); @@ -141,7 +141,7 @@ export function ActionConfigurationFields(props: ActionConnectorProps & { defini Saving does not run this test.

void request.run('Testing connection…', (signal) => testWorkspaceAction(draft, props.original, definition, signal, props.groupScope))}> + onClick={() => void request.run('Testing connection…', (signal) => testWorkspaceAction(draft, props.original, definition, signal, connectorTestScope(props)))}> {request.busy || 'Test connection'} diff --git a/application/v2_ui/src/components/workspaceActions/CallAgentActionConfiguration.tsx b/application/v2_ui/src/components/workspaceActions/CallAgentActionConfiguration.tsx index 020e949e5..4704c3420 100644 --- a/application/v2_ui/src/components/workspaceActions/CallAgentActionConfiguration.tsx +++ b/application/v2_ui/src/components/workspaceActions/CallAgentActionConfiguration.tsx @@ -5,7 +5,8 @@ import { RefreshCw } from 'lucide-react'; import { GlassButton } from '../ui/primitives'; import { ActionField, ACTION_INPUT_CLASS } from './ActionFields'; import { - actionTarget, fetchAgentTargets, PERSONAL_DELEGATION_SCOPE, referenceKey, type AgentTargetCatalog, + actionTarget, fetchAgentTargets, GLOBAL_DELEGATION_SCOPE, PERSONAL_DELEGATION_SCOPE, referenceKey, + type AgentTargetCatalog, type DelegationScope, } from '../../lib/agentDelegation'; import { actionFieldError, withActionValue } from '../../lib/workspaceActionLogic'; import type { ActionConnectorProps } from '../../lib/workspaceActionTypes'; @@ -20,10 +21,17 @@ export function CallAgentActionConfiguration(props: ActionConnectorProps) { const [version, setVersion] = useState(0); const callback = useRef(props.onValidityChange); callback.current = props.onValidityChange; + // Targets come from the action's own workspace, because the server only accepts a target + // there (or a permitted global agent): a global action calls global agents, and a group + // action calls its group's agents, never the author's personal ones. + const scopeType = props.globalScope ? 'global' : props.groupScope ? 'group' : 'personal'; + const groupId = props.groupScope?.id ?? ''; useEffect(() => { const controller = new AbortController(); + const scope: DelegationScope = scopeType === 'global' ? GLOBAL_DELEGATION_SCOPE + : scopeType === 'group' ? { type: 'group', groupId } : PERSONAL_DELEGATION_SCOPE; setLoading(true); setError(null); - void fetchAgentTargets(PERSONAL_DELEGATION_SCOPE, controller.signal).then((result) => { + void fetchAgentTargets(scope, controller.signal).then((result) => { if (controller.signal.aborted) return; if (!result || !Array.isArray(result.targets)) throw new Error('The target catalogue returned an invalid response.'); setCatalogue(result); @@ -31,7 +39,7 @@ export function CallAgentActionConfiguration(props: ActionConnectorProps) { if (!controller.signal.aborted) setError(errorMessage(cause, 'Could not load permitted target agents.')); }).finally(() => { if (!controller.signal.aborted) setLoading(false); }); return () => controller.abort(); - }, [version]); + }, [version, scopeType, groupId]); const target = actionTarget(props.draft); const selectedKey = target ? referenceKey(target) : ''; const targets = catalogue?.targets ?? []; diff --git a/application/v2_ui/src/components/workspaceActions/McpActionConfiguration.tsx b/application/v2_ui/src/components/workspaceActions/McpActionConfiguration.tsx index d6f8bbf3c..e31a89319 100644 --- a/application/v2_ui/src/components/workspaceActions/McpActionConfiguration.tsx +++ b/application/v2_ui/src/components/workspaceActions/McpActionConfiguration.tsx @@ -9,7 +9,7 @@ import { } from './OpenApiActionConfiguration'; import { EDITOR_SECRET_MASK, pointerPart } from '../../lib/workspaceAuthoring'; import { actionHasStoredArraySecrets } from '../../lib/workspaceActionLogic'; -import type { ActionConnectorProps } from '../../lib/workspaceActionTypes'; +import { connectorTestScope, type ActionConnectorProps } from '../../lib/workspaceActionTypes'; import { allowedMcpAuthMethods, allowedMcpTransports, applyMcpPreconfiguration, applyMcpPreset, changeConnectorAuthMethod, connectorAuthMethod, connectorFeedback, connectorLines, connectorObject, @@ -40,7 +40,7 @@ function useMcpCatalogues(props: ActionConnectorProps, includePreconfigurations: if (!controller.signal.aborted) setPresetsError(error instanceof Error ? error.message : 'Could not load MCP presets.'); }); const loadPreconfigurations = includePreconfigurations - ? fetchMcpPreconfigurations(controller.signal).then((result) => { + ? fetchMcpPreconfigurations(controller.signal, props.globalScope ? 'global' : 'personal').then((result) => { if (controller.signal.aborted) return; setPreconfigurations(result); setPreconfigurationsError(null); @@ -219,7 +219,7 @@ export function McpActionConfiguration(props: ActionConnectorProps) { `${name} ${tool?.function_name || ''} ${tool?.description || ''}`.toLowerCase().includes(toolSearch.toLowerCase())); const blockedExecution = readOnly || Boolean(busy) || Object.keys(localErrors).length > 0; const doDiscovery = async () => { - const result = await run('Discovering MCP tools…', (signal) => discoverMcpAction(draft, original, signal, props.groupScope), + const result = await run('Discovering MCP tools…', (signal) => discoverMcpAction(draft, original, signal, connectorTestScope(props)), (current, response) => updateConnectorFields(current, { mcp_tools: mergeMcpTools(current.additionalFields.mcp_tools, response.tools, connectorStrings(current.additionalFields.allowed_tool_names)), }), @@ -455,9 +455,9 @@ export function McpActionConfiguration(props: ActionConnectorProps) {

The connection test initializes a server session and lists its tools. It does not invoke tools or save discovered metadata. Authentication is configured in the Authentication section.

void run('Validating MCP configuration…', (signal) => validateApiConnector(draft, original, 'mcp', signal, props.groupScope))}>Validate MCP configuration + onClick={() => void run('Validating MCP configuration…', (signal) => validateApiConnector(draft, original, 'mcp', signal, connectorTestScope(props)))}>Validate MCP configuration void run('Testing MCP connection…', (signal) => testApiConnector(draft, original, 'mcp', signal, props.groupScope))}>Test MCP connection + onClick={() => void run('Testing MCP connection…', (signal) => testApiConnector(draft, original, 'mcp', signal, connectorTestScope(props)))}>Test MCP connection {busy ?

{busy}

: null}
{readOnly ?

Provided actions are read-only. Discovery and connection testing are disabled.

: null} diff --git a/application/v2_ui/src/components/workspaceActions/OpenApiActionConfiguration.tsx b/application/v2_ui/src/components/workspaceActions/OpenApiActionConfiguration.tsx index 986cfd516..b92fa80ce 100644 --- a/application/v2_ui/src/components/workspaceActions/OpenApiActionConfiguration.tsx +++ b/application/v2_ui/src/components/workspaceActions/OpenApiActionConfiguration.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { GlassButton, GlassPanel } from '../ui/primitives'; import { ActionField, ActionSecretInput, ACTION_INPUT_CLASS } from './ActionFields'; -import type { ActionConnectorProps } from '../../lib/workspaceActionTypes'; +import { connectorTestScope, type ActionConnectorProps } from '../../lib/workspaceActionTypes'; import { EDITOR_SECRET_MASK, type ActionConfiguration } from '../../lib/workspaceAuthoring'; import { applyOpenApiSpecification, changeConnectorAuthMethod, changeOpenApiBasicCredential, @@ -327,11 +327,11 @@ export function OpenApiActionConfiguration(props: ActionConnectorProps) {

Validation checks the manifest without running it. Connection testing parses the specification and probes the authenticated base URL; it does not invoke an individual API operation.

void run('Validating configuration…', (signal) => validateApiConnector(draft, original, 'openapi', signal, props.groupScope))}> + onClick={() => void run('Validating configuration…', (signal) => validateApiConnector(draft, original, 'openapi', signal, connectorTestScope(props)))}> Validate OpenAPI configuration void run('Testing OpenAPI connection…', (signal) => testApiConnector(draft, original, 'openapi', signal, props.groupScope))}> + onClick={() => void run('Testing OpenAPI connection…', (signal) => testApiConnector(draft, original, 'openapi', signal, connectorTestScope(props)))}> Test OpenAPI connection {busy ?

{busy}

: null} diff --git a/application/v2_ui/src/components/workspaceAgents/AgentActionPicker.tsx b/application/v2_ui/src/components/workspaceAgents/AgentActionPicker.tsx index 1e7417974..7b89a2a23 100644 --- a/application/v2_ui/src/components/workspaceAgents/AgentActionPicker.tsx +++ b/application/v2_ui/src/components/workspaceAgents/AgentActionPicker.tsx @@ -15,7 +15,7 @@ import { AgentField, AgentNotice } from './AgentFields'; export function AgentActionPicker({ draft, setDraft, actions, targets, loading, error, targetError, ownerId, builtinActions, - canCreateActions, onRefresh, onNewAction, readOnly, + canCreateActions, onRefresh, onNewAction, readOnly, scopeKind = 'personal', }: { draft: AgentConfiguration; setDraft: Dispatch>; @@ -30,7 +30,10 @@ export function AgentActionPicker({ onRefresh: () => void; onNewAction: () => void; readOnly: boolean; + /** Which kind of agent is being edited. A global agent's actions are all the organisation's own. */ + scopeKind?: 'personal' | 'group' | 'global'; }) { + const globalScope = scopeKind === 'global'; const [query, setQuery] = useState(''); const [typeFilter, setTypeFilter] = useState(''); const [selectedOnly, setSelectedOnly] = useState(false); @@ -115,7 +118,8 @@ export function AgentActionPicker({ {label}{action.type === 'agent' ? 'Call agent' : action.type} - {action.is_global ? Provided : Personal} + {globalScope ? Global + : action.is_global ? Provided : Personal} {action.description} ID: {action.id} @@ -163,7 +167,9 @@ export function AgentActionPicker({ {builtinActions.length ? (

Enabled built-in tools

-

Provided by your administrator; these are not personal settings.

+

{globalScope + ? 'Available to every agent; turn them on or off under Built-in Actions in Admin Settings.' + : 'Provided by your administrator; these are not personal settings.'}

    {builtinActions.map((action) =>
  • {action.label}{action.description ? ` — ${action.description}` : ''}
  • )}
diff --git a/application/v2_ui/src/components/workspaceAgents/AgentKnowledgeFields.tsx b/application/v2_ui/src/components/workspaceAgents/AgentKnowledgeFields.tsx index 80691d104..99b3dfa93 100644 --- a/application/v2_ui/src/components/workspaceAgents/AgentKnowledgeFields.tsx +++ b/application/v2_ui/src/components/workspaceAgents/AgentKnowledgeFields.tsx @@ -93,7 +93,9 @@ export function AgentKnowledgeFields({ {inputError ? {inputError} : null}
Source workspaces -

Only your personal workspace and permitted public workspaces can be newly assigned. Removing a source does not silently remove stored document references.

+

{knowledgeScopes.includes('personal') || knowledgeScopes.includes('group') + ? 'Only your personal workspace and permitted public workspaces can be newly assigned.' + : 'Only public workspaces can be assigned, because a global agent answers everyone.'} Removing a source does not silently remove stored document references.

{sources.filter((source) => `${source.label} ${source.id}`.toLowerCase().includes(sourceQuery.toLowerCase())).map((source) => ( diff --git a/application/v2_ui/src/components/workspaceAgents/AgentTemplatesPanel.tsx b/application/v2_ui/src/components/workspaceAgents/AgentTemplatesPanel.tsx index 79dfc1c68..64d35f515 100644 --- a/application/v2_ui/src/components/workspaceAgents/AgentTemplatesPanel.tsx +++ b/application/v2_ui/src/components/workspaceAgents/AgentTemplatesPanel.tsx @@ -20,12 +20,16 @@ function templateSettingsPreview(template: AgentTemplate): string { } export function AgentTemplatesPanel({ - draft, setDraft, actions, options, isNew, dirty, readOnly, submissionAllowed, groupScope, + draft, setDraft, actions, options, isNew, dirty, readOnly, submissionAllowed, scope = 'personal', }: { draft: AgentConfiguration; setDraft: Dispatch>; actions: ActionConfiguration[]; options: AgentEditorOptions; isNew: boolean; dirty: boolean; readOnly: boolean; - submissionAllowed: boolean; groupScope: boolean; + submissionAllowed: boolean; + /** Which kind of agent this is. A global agent publishes straight to the gallery. */ + scope?: 'personal' | 'group' | 'global'; }) { + const groupScope = scope === 'group'; + const globalScope = scope === 'global'; const enabled = options.settings.enable_agent_template_gallery === true; const submissionsAllowed = enabled && submissionAllowed && !readOnly; const [templates, setTemplates] = useState([]); @@ -75,11 +79,11 @@ export function AgentTemplatesPanel({ setError(null); setNotice(null); try { - const payload = await api.post<{ template: AgentTemplate }>('/api/agent-templates', agentTemplateSubmission(draft), controller.signal); + const payload = await api.post<{ template: AgentTemplate }>('/api/agent-templates', agentTemplateSubmission(draft, globalScope ? 'global' : 'personal'), controller.signal); if (!payload.template) throw new Error('The template service returned an invalid response.'); if (!controller.signal.aborted) setNotice(payload.template.status === 'approved' ? 'Template published to the approved gallery.' - : groupScope ? 'Template submitted for review.' : 'Personal template submitted for review.'); + : groupScope || globalScope ? 'Template submitted for review.' : 'Personal template submitted for review.'); } catch (cause) { if (!controller.signal.aborted) setError(cause instanceof Error ? cause.message : 'Could not submit this template.'); } finally { @@ -96,7 +100,7 @@ export function AgentTemplatesPanel({
setRevision((value) => value + 1)}>Refresh templates {submissionsAllowed ? void submit()}> - {submitting ? 'Submitting template…' : groupScope ? 'Submit template' : 'Submit personal template'} + {submitting ? 'Submitting template…' : globalScope ? 'Publish as template' : groupScope ? 'Submit template' : 'Submit personal template'} : null}
{error ? {error} : null} @@ -130,7 +134,9 @@ export function AgentTemplatesPanel({
{!visible.length && !loading && !error ?

No approved templates match.

: null} {!isNew ?

Templates start new agents; applying one will not overwrite this saved agent.

: null} - {submissionsAllowed ?

Submission publishes a reusable recipe under the current gallery approval policy, not your connection credentials. {Object.keys(safeAgentTemplateSettings(draft.other_settings)).length ? 'Non-secret additional settings are included.' : ''}

: null} + {submissionsAllowed ?

{globalScope + ? 'Publishing adds a reusable recipe to the approved gallery straight away, not your connection credentials.' + : 'Submission publishes a reusable recipe under the current gallery approval policy, not your connection credentials.'} {Object.keys(safeAgentTemplateSettings(draft.other_settings)).length ? 'Non-secret additional settings are included.' : ''}

: null}
); } diff --git a/application/v2_ui/src/lib/actionWorkbench.ts b/application/v2_ui/src/lib/actionWorkbench.ts index 49f696102..d24d1496e 100644 --- a/application/v2_ui/src/lib/actionWorkbench.ts +++ b/application/v2_ui/src/lib/actionWorkbench.ts @@ -19,7 +19,7 @@ import { ApiError, api } from './apiClient'; import { - buildEditorWrite, isRecord, + buildEditorWrite, GLOBAL_ACTIONS_BASE_PATH, isRecord, withoutServerStampedFields, type ActionConfiguration, type ActionTypeDefinition, type AuthoringResource, } from './workspaceAuthoring'; import { @@ -29,13 +29,15 @@ import { import { fetchActionEditorHints, fetchActionIdentities, testWorkspaceAction, type ActionEditorHints, } from './workspaceActionServices'; -import type { ActionIdentity, ActionTestGroupScope } from './workspaceActionTypes'; +import type { ActionIdentity, ActionTestScope } from './workspaceActionTypes'; +import { GLOBAL_ACTION_TEST_SCOPE, type ActionTestGroupScope } from './workspaceActionTypes'; import type { EditorWorkspaceScope } from './workspaceEditorDrafts'; import { requireWorkspaceId, workspaceBasePath } from './workspaceContext'; export type ActionScope = | { kind: 'personal' } - | { kind: 'group'; id: string; name: string }; + | { kind: 'group'; id: string; name: string } + | { kind: 'global' }; export const ACTION_OPERATIONS = ['create', 'edit', 'delete', 'test'] as const; export type ActionOperation = typeof ACTION_OPERATIONS[number]; @@ -59,8 +61,8 @@ export interface ActionWorkbenchAdapter { basePath: string; /** Draft-cache partition, so a group A draft never restores into group B or personal. */ draftScope: EditorWorkspaceScope; - /** Group test scope threaded into connection tests; undefined for personal. */ - testScope?: ActionTestGroupScope; + /** The scope connector commands run in: a group's, or global; undefined for personal. */ + testScope?: ActionTestScope; supported: ReadonlySet; allows: (operation: ActionOperation, action?: ActionConfiguration) => boolean; listActions: (signal?: AbortSignal) => Promise; @@ -95,11 +97,13 @@ export function advertisedActionOperations(value: unknown): ReadonlySet(record: T) } /** - * Map the group `action-options` envelope onto ActionEditorHints. The group editor needs only the - * five tenant-level Key Vault reminder defaults; unlike the personal `/api/user/agent/settings` + * Map an `action-options` envelope onto ActionEditorHints. The group and global editors need only + * the five tenant-level Key Vault reminder defaults; unlike the personal `/api/user/agent/settings` * read it carries no personal flags and no personal model endpoints. The envelope is validated * strictly, so a drifted shape throws rather than silently rendering blank defaults, and `canAuthor` * comes from the adapter gate, never from a server flag on this response. */ -function groupEditorHints(value: unknown, canAuthor: boolean): ActionEditorHints { +function actionOptionsHints(value: unknown, canAuthor: boolean): ActionEditorHints { if (!isRecord(value) || !isRecord(value.secret_reminders)) { throw new Error('The workspace returned invalid action options. Reload before trying again.'); } @@ -264,7 +268,7 @@ export function createGroupActionWorkbench( fetchEditorHints: async (signal) => { const response = await api.get( `/api/groups/${encodeURIComponent(groupId)}/action-options`, signal); - return groupEditorHints(response, allows('create')); + return actionOptionsHints(response, allows('create')); }, fetchEditor: async (id, _providedScope, signal) => { const response = await api.get>(groupActionsUrl(groupId, id), signal); @@ -335,3 +339,103 @@ export function createGroupActionWorkbench( test: (draft, original, definition, signal) => testWorkspaceAction(draft, original, definition, signal, testScope), }; } + +const GLOBAL_ACTIONS_API = '/api/v2/admin/actions'; + +function globalActionUrl(actionId?: string): string { + return actionId ? `${GLOBAL_ACTIONS_API}/${encodeURIComponent(requireWorkspaceId(actionId))}` : GLOBAL_ACTIONS_API; +} + +/** + * Prove a returned action is the organisation's: marked global and claiming no group, as the + * server's global projection always is. Anything else is refused rather than rendered. + */ +function assertGlobalActionScope(action: ActionConfiguration, id?: string): void { + if (!action || typeof action.id !== 'string' || !action.id + || (id !== undefined && action.id !== id) + || action.is_global !== true || Boolean(action.group_id)) { + throw new Error('The action response is not a global action. Refresh and try again.'); + } +} + +/** The editor contract for a global action, which an administrator always edits. */ +function assertGlobalEditorResource( + response: AuthoringResource, id?: string, +): AuthoringResource { + if (!isRecord(response) || !isRecord(response.record) || response.read_only !== false + || typeof response.revision !== 'string' || !response.revision + || !Array.isArray(response.secret_paths) + || !response.secret_paths.every((path) => typeof path === 'string' && path.startsWith('/'))) { + throw new Error('Admin Settings returned an invalid action editor resource. Reload before trying again.'); + } + assertGlobalActionScope(response.record, id); + return response; +} + +/** The global actions, read for the editor and the Admin Settings list. */ +export async function fetchGlobalActions(signal?: AbortSignal): Promise { + const actions = actionsFromResponse(await api.get(GLOBAL_ACTIONS_API, signal)); + actions.forEach((action) => assertGlobalActionScope(action)); + return actions; +} + +/** + * The administrator's global actions, edited from Admin Settings with the same editor workspaces + * use. Every route it calls requires the Admin role. Identities are the global ones, and connector + * commands run in the global scope so stored credentials resolve from the global namespace. + */ +export const GLOBAL_ACTION_WORKBENCH: ActionWorkbenchAdapter = { + scope: { kind: 'global' }, + basePath: GLOBAL_ACTIONS_BASE_PATH, + draftScope: { kind: 'global' }, + testScope: GLOBAL_ACTION_TEST_SCOPE, + supported: new Set(ACTION_OPERATIONS), + allows: () => true, + listActions: (signal) => fetchGlobalActions(signal), + fetchTypes: async (signal) => { + const response = await api.get(`${GLOBAL_ACTIONS_API}/types`, signal); + if (!isRecord(response) || !Array.isArray(response.types) || !response.types.every(isRecord)) { + throw new Error('Admin Settings returned an invalid action type list.'); + } + return response.types as unknown as ActionTypeDefinition[]; + }, + fetchEditorHints: async (signal) => actionOptionsHints(await api.get('/api/v2/admin/action-options', signal), true), + fetchEditor: async (id, _providedScope, signal) => + assertGlobalEditorResource(await api.get>(globalActionUrl(id), signal), id), + save: async (draft, original) => { + const write = buildEditorWrite( + withoutServerStampedFields(draft), + original ? { ...original, record: withoutServerStampedFields(original.record) } : null, + ); + if (!original) delete write.updates.id; + const response = original + ? await api.patch>(globalActionUrl(original.record.id), write) + : await api.post>(GLOBAL_ACTIONS_API, write); + return assertGlobalEditorResource(response, original?.record.id); + }, + deleteAction: async (action) => { + await api.delete<{ success: boolean }>(globalActionUrl(action.id)); + }, + listIdentities: async (signal) => { + // The global identities an action may bind, exactly as the group reader filters its own: + // only identities the server marks usable for actions are offered, with no client default. + const response = await api.get('/api/admin/workspace-identities/global/identities', signal); + if (!isRecord(response) || !Array.isArray(response.identities)) { + throw new Error('The identity response was malformed. Refresh and try again.'); + } + return response.identities.filter(isRecord).filter((identity) => + Array.isArray(identity.usage_contexts) && identity.usage_contexts.includes('action'), + ).map((identity) => { + const credentials = isRecord(identity.credentials) ? identity.credentials : {}; + return { + id: String(identity.id ?? ''), + name: String(identity.name ?? ''), + auth_type: String(credentials.auth_type ?? ''), + description: identity.description ? String(identity.description) : undefined, + scope_type: 'global', + scope_id: 'global', + } satisfies ActionIdentity; + }); + }, + test: (draft, original, definition, signal) => testWorkspaceAction(draft, original, definition, signal, GLOBAL_ACTION_TEST_SCOPE), +}; diff --git a/application/v2_ui/src/lib/agentWorkbench.ts b/application/v2_ui/src/lib/agentWorkbench.ts index 3b912c2ae..dfa3eaec2 100644 --- a/application/v2_ui/src/lib/agentWorkbench.ts +++ b/application/v2_ui/src/lib/agentWorkbench.ts @@ -23,7 +23,7 @@ import { api } from './apiClient'; import { generateAgentId } from './workspaceApi'; import { - buildEditorWrite, isRecord, + buildEditorWrite, GLOBAL_ACTIONS_BASE_PATH, GLOBAL_AGENTS_BASE_PATH, isRecord, withoutServerStampedFields, type ActionConfiguration, type AgentConfiguration, type AgentEditorOptions, type AuthoringResource, type WorkspaceAgentType, } from './workspaceAuthoring'; @@ -34,22 +34,25 @@ import { fetchAuthoringAgents, saveAgentConfiguration, } from './workspaceAuthoringApi'; import { - fetchAgentKnowledgeCatalog, fetchGroupAgentKnowledgeCatalog, type AgentKnowledgeCatalog, + fetchAgentKnowledgeCatalog, fetchGlobalAgentKnowledgeCatalog, fetchGroupAgentKnowledgeCatalog, + type AgentKnowledgeCatalog, } from './workspaceAgentKnowledge'; import { - discoverAgentFoundryResources, draftAgentInstructions, PERSONAL_INSTRUCTION_SCOPE, type InstructionDraftScope, + discoverAgentFoundryResources, draftAgentInstructions, GLOBAL_INSTRUCTION_SCOPE, PERSONAL_INSTRUCTION_SCOPE, + type InstructionDraftScope, } from './workspaceAgentCommands'; import { - fetchAgentTargets, PERSONAL_DELEGATION_SCOPE, type AgentTargetCatalog, type DelegationScope, + fetchAgentTargets, GLOBAL_DELEGATION_SCOPE, PERSONAL_DELEGATION_SCOPE, type AgentTargetCatalog, type DelegationScope, } from './agentDelegation'; import type { AgentLinkScope } from './conversationUrl'; import type { EditorWorkspaceScope } from './workspaceEditorDrafts'; -import type { ActionWorkbenchAdapter } from './actionWorkbench'; +import { fetchGlobalActions, type ActionWorkbenchAdapter } from './actionWorkbench'; import { requireWorkspaceId, workspaceBasePath } from './workspaceContext'; export type AgentWorkbenchScope = | { kind: 'personal' } - | { kind: 'group'; id: string; name: string }; + | { kind: 'group'; id: string; name: string } + | { kind: 'global' }; export const AGENT_OPERATIONS = ['create', 'edit', 'delete'] as const; export type AgentOperation = typeof AGENT_OPERATIONS[number]; @@ -126,11 +129,13 @@ export function advertisedAgentOperations(value: unknown): ReadonlySet assertGroupAgentScope(agent, groupId)); return agents; }, - fetchOptions: async (signal) => assertGroupAgentOptions( + fetchOptions: async (signal) => assertAgentEditorOptions( await api.get(`/api/groups/${encodeURIComponent(groupId)}/agent-options`, signal), ), fetchEditor: async (id, _providedScope, signal) => { @@ -327,3 +332,101 @@ export function createGroupAgentWorkbench( discoverAgentFoundryResources(endpoint, type, signal, groupId), }; } + +const GLOBAL_AGENTS_API = '/api/v2/admin/agents'; + +function globalAgentUrl(agentId?: string): string { + return agentId ? `${GLOBAL_AGENTS_API}/${encodeURIComponent(requireWorkspaceId(agentId))}` : GLOBAL_AGENTS_API; +} + +/** + * Prove a returned agent is the organisation's: marked global and claiming no group, as the + * server's global projection always is. Anything else is refused rather than rendered. + */ +function assertGlobalAgentScope(agent: AgentConfiguration, id?: string): void { + if (!agent || typeof agent.id !== 'string' || !agent.id + || (id !== undefined && agent.id !== id) + || agent.is_global !== true || Boolean(agent.group_id)) { + throw new Error('The agent response is not a global agent. Refresh and try again.'); + } +} + +/** The editor contract for a global agent, which an administrator always edits. */ +function assertGlobalAgentEditorResource( + response: AuthoringResource, id?: string, +): AuthoringResource { + if (!isRecord(response) || !isRecord(response.record) || response.read_only !== false + || typeof response.revision !== 'string' || !response.revision + || !Array.isArray(response.secret_paths) + || !response.secret_paths.every((path) => typeof path === 'string' && path.startsWith('/'))) { + throw new Error('Admin Settings returned an invalid agent editor resource. Reload before trying again.'); + } + assertGlobalAgentScope(response.record, id); + return response; +} + +export interface GlobalAgentCatalog { + agents: AgentConfiguration[]; + /** The default agent, which settings store by name; empty when none is chosen. */ + selectedAgentName: string; +} + +/** The global agents and the default among them, for the editor and the Admin Settings list. */ +export async function fetchGlobalAgentCatalog(signal?: AbortSignal): Promise { + const response = await api.get(GLOBAL_AGENTS_API, signal); + const agents = agentsFromResponse(response); + agents.forEach((agent) => assertGlobalAgentScope(agent)); + const selected = isRecord(response) && typeof response.selected_agent_name === 'string' ? response.selected_agent_name : ''; + return { agents, selectedAgentName: selected }; +} + +/** + * The administrator's global agents, edited from Admin Settings with the same editor workspaces + * use. Every route it calls requires the Admin role. Knowledge is limited to public workspaces, + * which is all a global agent may hold; actions, Call agent targets and instruction drafting all + * resolve in the global scope; and a new action created from the editor returns to it. + */ +export const GLOBAL_AGENT_WORKBENCH: AgentWorkbenchAdapter = { + scope: { kind: 'global' }, + basePath: GLOBAL_AGENTS_BASE_PATH, + draftScope: { kind: 'global' }, + knowledgeScopes: ['public'], + instructionScope: GLOBAL_INSTRUCTION_SCOPE, + delegationScope: GLOBAL_DELEGATION_SCOPE, + actionsBasePath: GLOBAL_ACTIONS_BASE_PATH, + canCreateActions: true, + supported: new Set(AGENT_OPERATIONS), + allows: () => true, + // Whether people can reach a global agent depends on Workspace Mode and the merge setting, so + // the editor links nowhere it cannot be sure of. + canUseInChat: () => false, + chatScope: () => ({ kind: 'global' }), + // The classic admin editor has always let a global agent carry its own model connection. + allowsCustomEndpoints: () => true, + allowsTemplateSubmission: (settings) => settings.agent_template_submission_allowed === true, + listAgents: async (signal) => (await fetchGlobalAgentCatalog(signal)).agents, + fetchOptions: async (signal) => assertAgentEditorOptions(await api.get('/api/v2/admin/agent-options', signal)), + fetchEditor: async (id, _providedScope, signal) => + assertGlobalAgentEditorResource(await api.get>(globalAgentUrl(id), signal), id), + save: async (draft, original) => { + const record = original ? draft : { ...draft, id: await generateAgentId() }; + if (!record.id) throw new Error('Could not allocate an agent identifier. Try again.'); + const write = buildEditorWrite( + withoutServerStampedFields(record), + original ? { ...original, record: withoutServerStampedFields(original.record) } : null, + ); + const response = original + ? await api.patch>(globalAgentUrl(original.record.id), write) + : await api.post>(GLOBAL_AGENTS_API, write); + return assertGlobalAgentEditorResource(response, original?.record.id); + }, + deleteAgent: async (agent) => { + await api.delete<{ success: boolean }>(globalAgentUrl(agent.id)); + }, + fetchActions: (signal) => fetchGlobalActions(signal), + fetchTargets: (signal) => fetchAgentTargets(GLOBAL_DELEGATION_SCOPE, signal), + fetchKnowledge: (signal) => fetchGlobalAgentKnowledgeCatalog(signal), + draftInstructions: (draft, actions, catalog, signal) => + draftAgentInstructions(draft, actions, catalog, signal, GLOBAL_INSTRUCTION_SCOPE), + discoverFoundryResources: (endpoint, type, signal) => discoverAgentFoundryResources(endpoint, type, signal), +}; diff --git a/application/v2_ui/src/lib/workspaceActionConnectors.ts b/application/v2_ui/src/lib/workspaceActionConnectors.ts index a8bf07bcc..928d36812 100644 --- a/application/v2_ui/src/lib/workspaceActionConnectors.ts +++ b/application/v2_ui/src/lib/workspaceActionConnectors.ts @@ -5,7 +5,10 @@ import { buildEditorWrite, EDITOR_SECRET_MASK, isRecord, pointerPart, type ActionConfiguration, type AuthoringResource, } from './workspaceAuthoring'; -import type { ActionIdentity, ActionTestGroupScope } from './workspaceActionTypes'; +import { + actionTestScopeName, isGroupActionTestScope, + type ActionIdentity, type ActionTestScope, +} from './workspaceActionTypes'; export type ApiConnector = 'openapi' | 'mcp'; export type ConnectorResource = AuthoringResource | null; @@ -477,8 +480,12 @@ export async function fetchMcpPresets(signal?: AbortSignal): Promise('/api/plugins/mcp/presets', signal), 'presets'); } -export async function fetchMcpPreconfigurations(signal?: AbortSignal): Promise { - return parseMcpCatalogue(await api.get('/api/plugins/mcp/preconfigurations?scope=personal', signal), 'preconfigurations'); +/** + * The MCP preconfigurations offered to an action. Eligibility depends on the scope the action is + * saved in, so a global action reads the global list, which the server opens only to administrators. + */ +export async function fetchMcpPreconfigurations(signal?: AbortSignal, scope: 'personal' | 'global' = 'personal'): Promise { + return parseMcpCatalogue(await api.get(`/api/plugins/mcp/preconfigurations?scope=${scope}`, signal), 'preconfigurations'); } export function allowedMcpTransports(preset?: McpCatalogEntry): ConnectorOption[] { @@ -876,10 +883,15 @@ export function connectorFeedback(value: unknown): ConnectorFeedback { export function buildConnectorSupportPayload( draft: ActionConfiguration, original: ConnectorResource, kind: ApiConnector, - purpose: 'test' | 'discover' | 'validate' = 'test', groupScope?: ActionTestGroupScope, + purpose: 'test' | 'discover' | 'validate' = 'test', testScope?: ActionTestScope, ): Record { - // A group connector is testable only from its own group workspace, carrying action_scope 'group'. - if (original?.read_only || draft.is_global || (draft.is_group && !groupScope)) throw new Error('Provided actions are read-only and cannot be tested.'); + const scopeName = actionTestScopeName(testScope); + const groupScope = isGroupActionTestScope(testScope) ? testScope : undefined; + // A connector is testable only from its own scope: a group connector from its group workspace + // and a global connector from Admin Settings, each carrying its own action_scope. + if (original?.read_only || (draft.is_global && scopeName !== 'global') || (draft.is_group && !groupScope)) { + throw new Error('Provided actions are read-only and cannot be tested.'); + } if (original && !connectorText(original.record.id).trim()) throw new Error('Reload this action before testing: its stable owned ID is missing.'); const errors = { ...validateConnectorConfiguration(draft, kind), @@ -929,21 +941,21 @@ export function buildConnectorSupportPayload( return { ...manifest, ...(purpose !== 'validate' ? { - action_scope: groupScope ? 'group' : 'personal', + action_scope: scopeName, ...(groupScope ? { group_id: groupScope.id } : {}), - ...(original ? { plugin_context: { scope: groupScope ? 'group' : 'personal', id: original.record.id, name: original.record.name } } : {}), + ...(original ? { plugin_context: { scope: scopeName, id: original.record.id, name: original.record.name } } : {}), clear_secret_paths: [...clearPaths], } : {}), }; } -export function testApiConnector(draft: ActionConfiguration, original: ConnectorResource, kind: ApiConnector, signal?: AbortSignal, groupScope?: ActionTestGroupScope): Promise { - const payload = buildConnectorSupportPayload(draft, original, kind, 'test', groupScope); +export function testApiConnector(draft: ActionConfiguration, original: ConnectorResource, kind: ApiConnector, signal?: AbortSignal, testScope?: ActionTestScope): Promise { + const payload = buildConnectorSupportPayload(draft, original, kind, 'test', testScope); return api.post(`/api/plugins/test-${kind}-connection`, payload, signal); } -export function validateApiConnector(draft: ActionConfiguration, original: ConnectorResource, kind: ApiConnector, signal?: AbortSignal, groupScope?: ActionTestGroupScope): Promise { - return api.post('/api/plugins/validate', buildConnectorSupportPayload(draft, original, kind, 'validate', groupScope), signal); +export function validateApiConnector(draft: ActionConfiguration, original: ConnectorResource, kind: ApiConnector, signal?: AbortSignal, testScope?: ActionTestScope): Promise { + return api.post('/api/plugins/validate', buildConnectorSupportPayload(draft, original, kind, 'validate', testScope), signal); } export interface McpDiscoveryResult { @@ -958,8 +970,8 @@ export interface McpDiscoveryResult { mcp_operation_id?: string; } -export async function discoverMcpAction(draft: ActionConfiguration, original: ConnectorResource, signal?: AbortSignal, groupScope?: ActionTestGroupScope): Promise { - const result = await api.post('/api/plugins/mcp/discover', buildConnectorSupportPayload(draft, original, 'mcp', 'discover', groupScope), signal); +export async function discoverMcpAction(draft: ActionConfiguration, original: ConnectorResource, signal?: AbortSignal, testScope?: ActionTestScope): Promise { + const result = await api.post('/api/plugins/mcp/discover', buildConnectorSupportPayload(draft, original, 'mcp', 'discover', testScope), signal); if (!result || (result.success === true && (!Array.isArray(result.tools) || parseMcpTools(result.tools).length !== result.tools.length))) { throw new Error('The server returned an invalid MCP tool catalogue.'); diff --git a/application/v2_ui/src/lib/workspaceActionServices.ts b/application/v2_ui/src/lib/workspaceActionServices.ts index 6c00f9053..160ff48a5 100644 --- a/application/v2_ui/src/lib/workspaceActionServices.ts +++ b/application/v2_ui/src/lib/workspaceActionServices.ts @@ -11,9 +11,12 @@ import { actionAuthMethod, actionForSave, actionText, actionValueAt, validateActionDraft, } from './workspaceActionLogic'; import { nativeActionDefinition, sqlConnectionMethod } from './workspaceActionRegistry'; -import type { ActionIdentity, ActionTestGroupScope } from './workspaceActionTypes'; +import { + actionTestScopeName, isGroupActionTestScope, + type ActionIdentity, type ActionTestScope, +} from './workspaceActionTypes'; -export type { ActionTestGroupScope } from './workspaceActionTypes'; +export type { ActionTestGroupScope, ActionTestScope } from './workspaceActionTypes'; export interface ActionEditorHints { canAuthor: boolean; @@ -80,10 +83,13 @@ function translateActionSecrets( export function buildActionConnectionPayload( draft: ActionConfiguration, original: AuthoringResource | null, - groupScope?: ActionTestGroupScope, + testScope?: ActionTestScope, ): Record { - // A group action is testable only from its own group workspace, carrying action_scope 'group'. - if (draft.type === 'agent' || original?.read_only || draft.is_global || (draft.is_group && !groupScope)) { + const scopeName = actionTestScopeName(testScope); + const groupScope = isGroupActionTestScope(testScope) ? testScope : undefined; + // An action is testable only from its own scope: a group action from its group workspace and a + // global action from Admin Settings, each carrying its own action_scope. + if (draft.type === 'agent' || original?.read_only || (draft.is_global && scopeName !== 'global') || (draft.is_group && !groupScope)) { throw new Error('This action cannot be connection-tested from My Workspace.'); } if (original && !actionText(original.record.id).trim()) { @@ -141,7 +147,7 @@ export function buildActionConnectionPayload( manifest.auth = auth; manifest.additionalFields = fields; const context = original - ? { scope: groupScope ? 'group' : 'personal', id: original.record.id, name: original.record.name } + ? { scope: scopeName, id: original.record.id, name: original.record.name } : undefined; const contextKey = ['sql_query', 'sql_schema', 'cosmos_query', 'yamcs', 'rocksdb'].includes(draft.type) ? 'existing_plugin' : 'plugin_context'; @@ -156,7 +162,7 @@ export function buildActionConnectionPayload( } } const common = { - action_scope: groupScope ? 'group' : 'personal', identity_id: draft.identity_id ?? '', clear_secret_paths: clearPaths, + action_scope: scopeName, identity_id: draft.identity_id ?? '', clear_secret_paths: clearPaths, ...(groupScope ? { group_id: groupScope.id } : {}), ...(context ? { [contextKey]: context } : {}), }; @@ -186,14 +192,14 @@ export function buildActionConnectionPayload( export function testWorkspaceAction( draft: ActionConfiguration, original: AuthoringResource | null, - definition: ActionTypeDefinition, signal?: AbortSignal, groupScope?: ActionTestGroupScope, + definition: ActionTypeDefinition, signal?: AbortSignal, testScope?: ActionTestScope, ): Promise { const path = nativeActionDefinition(draft.type).testPath; if (!path) return Promise.reject(new Error('This connector does not expose a connection-test command.')); const errors = validateActionDraft(actionForSave(draft), definition, original); for (const field of ['/name', '/displayName', '/type']) delete errors[field]; if (Object.keys(errors).length) return Promise.reject(new Error(Object.values(errors).join(' '))); - return api.post(path, buildActionConnectionPayload(draft, original, groupScope), signal); + return api.post(path, buildActionConnectionPayload(draft, original, testScope), signal); } export function validateWorkspaceAction( diff --git a/application/v2_ui/src/lib/workspaceActionTypes.ts b/application/v2_ui/src/lib/workspaceActionTypes.ts index 271291a15..787869a88 100644 --- a/application/v2_ui/src/lib/workspaceActionTypes.ts +++ b/application/v2_ui/src/lib/workspaceActionTypes.ts @@ -22,6 +22,37 @@ export interface ActionTestGroupScope { name?: string; } +/** + * A global action's test, discovery and validation run in the global scope, which the server opens + * only to administrators and resolves stored credentials from the global Key Vault namespace. + */ +export interface ActionTestGlobalScope { + global: true; +} + +/** The scope a connector command runs in; absent for personal actions. */ +export type ActionTestScope = ActionTestGroupScope | ActionTestGlobalScope; + +export const GLOBAL_ACTION_TEST_SCOPE: ActionTestGlobalScope = { global: true }; + +export function isGlobalActionTestScope(scope?: ActionTestScope): scope is ActionTestGlobalScope { + return Boolean(scope && 'global' in scope && scope.global === true); +} + +export function isGroupActionTestScope(scope?: ActionTestScope): scope is ActionTestGroupScope { + return Boolean(scope && 'id' in scope && typeof scope.id === 'string'); +} + +/** The `action_scope` a connector payload names. */ +export function actionTestScopeName(scope?: ActionTestScope): 'personal' | 'group' | 'global' { + return isGlobalActionTestScope(scope) ? 'global' : isGroupActionTestScope(scope) ? 'group' : 'personal'; +} + +/** The scope a connector command should run in, from the editor's connector props. */ +export function connectorTestScope(props: Pick): ActionTestScope | undefined { + return props.groupScope ?? (props.globalScope ? GLOBAL_ACTION_TEST_SCOPE : undefined); +} + export interface ActionConnectorProps { draft: ActionConfiguration; original: AuthoringResource | null; @@ -40,6 +71,11 @@ export interface ActionConnectorProps { identitiesResolvable: boolean; /** Set when the action belongs to a group, so connector tests send the group test payload. */ groupScope?: ActionTestGroupScope; + /** + * Set when an administrator edits a global action, so connector commands run in the global + * scope and MCP preconfigurations are read for global actions. + */ + globalScope?: boolean; } export interface ActionFieldDescriptor { diff --git a/application/v2_ui/src/lib/workspaceAgentCommands.ts b/application/v2_ui/src/lib/workspaceAgentCommands.ts index aa8015582..d56f33eac 100644 --- a/application/v2_ui/src/lib/workspaceAgentCommands.ts +++ b/application/v2_ui/src/lib/workspaceAgentCommands.ts @@ -10,15 +10,17 @@ import { agentKnowledgeReference, type AgentKnowledgeCatalog } from './workspace /** * Which workspace an instruction drafting request speaks for. Personal keeps the historical * `agent_scope: 'user'` spelling with no group; a group draft names its group so the server drafts - * against the group's own actions and knowledge rather than the caller's personal ones. Personal + * against the group's own actions and knowledge rather than the caller's personal ones. A global + * draft speaks for the organisation, which the server allows only for an administrator. Personal * callers pass nothing and their request stays byte-identical. */ export interface InstructionDraftScope { - agentScope: 'user' | 'group'; + agentScope: 'user' | 'group' | 'global'; groupId?: string; } export const PERSONAL_INSTRUCTION_SCOPE: InstructionDraftScope = { agentScope: 'user' }; +export const GLOBAL_INSTRUCTION_SCOPE: InstructionDraftScope = { agentScope: 'global' }; export function agentInstructionRequest( draft: AgentConfiguration, actions: ActionConfiguration[], catalog: AgentKnowledgeCatalog | null, diff --git a/application/v2_ui/src/lib/workspaceAgentKnowledge.ts b/application/v2_ui/src/lib/workspaceAgentKnowledge.ts index a78b4380b..8610030b8 100644 --- a/application/v2_ui/src/lib/workspaceAgentKnowledge.ts +++ b/application/v2_ui/src/lib/workspaceAgentKnowledge.ts @@ -68,6 +68,16 @@ export async function fetchGroupAgentKnowledgeCatalog(groupId: string, signal?: ); } +/** + * The catalogue a global agent may draw on: public workspaces only, since a global agent answers + * everyone and can hold no person's or group's documents. The server opens it only to administrators. + */ +export async function fetchGlobalAgentKnowledgeCatalog(signal?: AbortSignal): Promise { + return validateAgentKnowledgeCatalog( + await api.get('/api/agents/assigned-knowledge/catalog?agent_scope=global', signal), + ); +} + function validateAgentKnowledgeCatalog(response: AgentKnowledgeCatalog): AgentKnowledgeCatalog { if (!response || !Array.isArray(response.sources) || !Array.isArray(response.documents) || !Array.isArray(response.tags)) { throw new Error('The assigned knowledge catalogue returned an invalid response.'); diff --git a/application/v2_ui/src/lib/workspaceAgentTemplates.ts b/application/v2_ui/src/lib/workspaceAgentTemplates.ts index b3336241d..6d4e23fae 100644 --- a/application/v2_ui/src/lib/workspaceAgentTemplates.ts +++ b/application/v2_ui/src/lib/workspaceAgentTemplates.ts @@ -67,7 +67,13 @@ export function agentDraftFromTemplate(template: AgentTemplate, actions: ActionC }; } -export function agentTemplateSubmission(draft: AgentConfiguration) { +/** + * The template the gallery stores for a draft. `sourceScope` records where it came from: an + * administrator's global agent is a `global` submission, which the template service publishes + * straight to the approved gallery; personal and group agents submit as `personal`, as the classic + * workspaces do. + */ +export function agentTemplateSubmission(draft: AgentConfiguration, sourceScope: 'personal' | 'global' = 'personal') { return { template: { title: draft.display_name.trim(), @@ -79,7 +85,7 @@ export function agentTemplateSubmission(draft: AgentConfiguration) { actions_to_load: draft.actions_to_load, tags: draft.tags ?? [], ...(draft.id ? { source_agent_id: draft.id } : {}), - source_scope: 'personal', + source_scope: sourceScope, }, }; } diff --git a/application/v2_ui/src/lib/workspaceAuthoring.ts b/application/v2_ui/src/lib/workspaceAuthoring.ts index 1e181e952..99acb0bc8 100644 --- a/application/v2_ui/src/lib/workspaceAuthoring.ts +++ b/application/v2_ui/src/lib/workspaceAuthoring.ts @@ -147,6 +147,19 @@ export function sameEditorValue(left: unknown, right: unknown): boolean { const MANAGED_FIELDS = new Set(['user_id', 'last_updated', 'modified_at', 'modified_by', 'created_at', 'created_by', 'is_global', 'is_group', 'group_id']); +/** + * Fields the server stamps on a stored record and never accepts back, in `updates` or + * `removed_paths`. Global records carry the ones the classic global saves write. The global editor + * adapters strip them from both sides of the diff, so no editor change can emit them. + */ +const SERVER_STAMPED_FIELDS = ['scope', 'scope_id', 'scope_type', 'updated_at', 'updated_by', 'owner_id', 'owner_user_id']; + +export function withoutServerStampedFields(record: T): T { + const clone = { ...(record as Record) }; + for (const field of SERVER_STAMPED_FIELDS) delete clone[field]; + return clone as T; +} + /** Array changes replace the array; object changes name only changed leaves. */ export function buildEditorWrite>( draft: T, @@ -211,6 +224,16 @@ function validPathIdentifier(segment: string): boolean { !/[/\\\u0000-\u001f\u007f]/.test(identifier); } +/** The return-path scope for global agents edited from Admin Settings. */ +export const GLOBAL_AGENT_RETURN_SCOPE = { kind: 'global' } as const; + +/** A group id, or the global scope; absent means personal. */ +export type AgentEditorReturnScope = string | typeof GLOBAL_AGENT_RETURN_SCOPE; + +/** The SPA routes the administrator's global agent and action editors live under. */ +export const GLOBAL_AGENTS_BASE_PATH = '/admin/agents'; +export const GLOBAL_ACTIONS_BASE_PATH = '/admin/actions'; + /** * Only a known agent editor can receive a just-created action. * @@ -218,16 +241,19 @@ function validPathIdentifier(segment: string): boolean { * callers and links are byte-identical. A group caller passes its group id, and only that group's * `/groups//agents/` path is accepted -- an action created for group A can never hand back * into group B, and a personal path is refused in group scope and the reverse. The group id is - * matched by its encoded spelling, the same spelling `workspaceBasePath` writes into the URL. + * matched by its encoded spelling, the same spelling `workspaceBasePath` writes into the URL. The + * global scope accepts only the Admin Settings agent editor, `/admin/agents/`. */ -export function agentEditorReturnPath(value: string | null, groupId?: string): string | null { +export function agentEditorReturnPath(value: string | null, scope?: AgentEditorReturnScope): string | null { if (!value) return null; let identifierSegment: string | undefined; - if (typeof groupId === 'string') { - const prefix = `/groups/${encodeURIComponent(groupId)}/agents/`; + if (typeof scope === 'string') { + const prefix = `/groups/${encodeURIComponent(scope)}/agents/`; if (!value.startsWith(prefix)) return null; const rest = value.slice(prefix.length); identifierSegment = /^[^/?#]+$/.test(rest) ? rest : undefined; + } else if (scope?.kind === 'global') { + identifierSegment = value.match(/^\/admin\/agents\/([^/?#]+)$/)?.[1]; } else { identifierSegment = value.match(/^\/workspace\/agents\/([^/?#]+)$/)?.[1]; } @@ -235,18 +261,19 @@ export function agentEditorReturnPath(value: string | null, groupId?: string): s } /** - * Whether a path is any agent editor, personal or group. Used only by the editor frame's + * Whether a path is any agent editor, personal, group or global. Used only by the editor frame's * navigation blocker, which recognises the agent<->action handoff without knowing the group id -- * the returnTo linkage the blocker also checks ties the two halves together. Stricter callers pass - * an explicit group id to `agentEditorReturnPath` instead. + * an explicit scope to `agentEditorReturnPath` instead. */ export function isAgentEditorPath(value: string | null): boolean { - if (agentEditorReturnPath(value)) return true; + if (agentEditorReturnPath(value) || agentEditorReturnPath(value, GLOBAL_AGENT_RETURN_SCOPE)) return true; const identifierSegment = value?.match(/^\/groups\/[^/?#]+\/agents\/([^/?#]+)$/)?.[1]; return Boolean(identifierSegment && validPathIdentifier(identifierSegment)); } -/** Whether a path is a new-action editor, personal or group. Used by the editor frame blocker. */ +/** Whether a path is a new-action editor, personal, group or global. Used by the editor frame blocker. */ export function isActionEditorNewPath(value: string): boolean { - return value === '/workspace/actions/new' || /^\/groups\/[^/?#]+\/actions\/new$/.test(value); + return value === '/workspace/actions/new' || value === `${GLOBAL_ACTIONS_BASE_PATH}/new` + || /^\/groups\/[^/?#]+\/actions\/new$/.test(value); } diff --git a/application/v2_ui/src/lib/workspaceEditorDrafts.ts b/application/v2_ui/src/lib/workspaceEditorDrafts.ts index 182c8501a..8b24aefac 100644 --- a/application/v2_ui/src/lib/workspaceEditorDrafts.ts +++ b/application/v2_ui/src/lib/workspaceEditorDrafts.ts @@ -5,9 +5,11 @@ import { useCallback, useState, type Dispatch, type SetStateAction } from 'react import { useBootstrapStore } from '../stores/bootstrapStore'; import { agentEditorReturnPath, + GLOBAL_AGENT_RETURN_SCOPE, sameEditorValue, type ActionConfiguration, type AgentConfiguration, + type AgentEditorReturnScope, type AuthoringResource, } from './workspaceAuthoring'; @@ -18,16 +20,25 @@ type EditorKind = 'agents' | 'actions'; * Which workspace a draft belongs to. Personal is the default and, to keep personal behaviour * byte-identical, contributes nothing to the cache key. A group scope adds its id, so a draft * opened in group A can never be restored into group B or into personal scope -- the isolation - * the created-action handoff needs too. + * the created-action handoff needs too. The global scope is the administrator's own partition for + * global agents and actions edited from Admin Settings. */ export type EditorWorkspaceScope = | { kind: 'personal' } - | { kind: 'group'; id: string }; + | { kind: 'group'; id: string } + | { kind: 'global' }; const PERSONAL_EDITOR_SCOPE: EditorWorkspaceScope = { kind: 'personal' }; function editorScopeSegments(scope: EditorWorkspaceScope): string[] { - return scope.kind === 'group' ? ['group', scope.id] : []; + if (scope.kind === 'group') return ['group', scope.id]; + return scope.kind === 'global' ? ['global'] : []; +} + +/** The return-path scope `agentEditorReturnPath` checks a handed-off action against. */ +function returnPathScope(scope: EditorWorkspaceScope): AgentEditorReturnScope | undefined { + if (scope.kind === 'group') return scope.id; + return scope.kind === 'global' ? GLOBAL_AGENT_RETURN_SCOPE : undefined; } interface DraftState { @@ -138,8 +149,7 @@ export function queueCreatedWorkspaceAction( action: ActionConfiguration, workspaceScope: EditorWorkspaceScope = PERSONAL_EDITOR_SCOPE, ): void { - const groupId = workspaceScope.kind === 'group' ? workspaceScope.id : undefined; - if (!agentEditorReturnPath(returnPath, groupId)) throw new Error('Invalid agent editor return path.'); + if (!agentEditorReturnPath(returnPath, returnPathScope(workspaceScope))) throw new Error('Invalid agent editor return path.'); createdActions.set(JSON.stringify([ownerKey(), ...editorScopeSegments(workspaceScope), returnPath]), action); syncDraftUnloadProtection(); } diff --git a/application/v2_ui/src/pages/AdminGlobalEditorPages.tsx b/application/v2_ui/src/pages/AdminGlobalEditorPages.tsx new file mode 100644 index 000000000..66463fe3d --- /dev/null +++ b/application/v2_ui/src/pages/AdminGlobalEditorPages.tsx @@ -0,0 +1,57 @@ +// AdminGlobalEditorPages.tsx +// The global agent and action editors, opened from Admin Settings. +// +// These are the same editors personal and group workspaces use, driven by the global +// workbench adapters, inside an Admin Settings page frame. Saving or going back returns to +// Admin Settings with the matching section in view. Only an administrator reaches them; +// anyone else is told why, and the server refuses every global editor route regardless. + +import type { ReactNode } from 'react'; +import { ShieldAlert } from 'lucide-react'; +import { PageHeader } from '../components/layout/PageHeader'; +import { GlassPanel } from '../components/ui/primitives'; +import { GLOBAL_ACTION_WORKBENCH } from '../lib/actionWorkbench'; +import { GLOBAL_AGENT_WORKBENCH } from '../lib/agentWorkbench'; +import { useBootstrapStore } from '../stores/bootstrapStore'; +import { ActionEditorPage } from './workspace/ActionEditorPage'; +import { AgentEditorPage } from './workspace/AgentEditorPage'; + +function AdminEditorFrame({ description, children }: { description: string; children: ReactNode }) { + const isAdmin = useBootstrapStore((state) => Boolean(state.data?.user?.is_admin)); + return ( + <> + + {isAdmin ? ( +
+
{children}
+
+ ) : ( +
+ + +
+ )} + + ); +} + +export function AdminAgentEditorPage() { + return ( + + + + ); +} + +export function AdminActionEditorPage() { + return ( + + + + ); +} diff --git a/application/v2_ui/src/pages/AdminSettingsPage.tsx b/application/v2_ui/src/pages/AdminSettingsPage.tsx index f5354af7b..09a218ffd 100644 --- a/application/v2_ui/src/pages/AdminSettingsPage.tsx +++ b/application/v2_ui/src/pages/AdminSettingsPage.tsx @@ -25,8 +25,9 @@ // saving per keystroke would mint a version per character. import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useBlocker } from 'react-router-dom'; import { clsx } from 'clsx'; -import { Loader2, Network, Search, ShieldAlert, TriangleAlert, type LucideIcon } from 'lucide-react'; +import { Loader2, Search, ShieldAlert, TriangleAlert, type LucideIcon } from 'lucide-react'; import { ApiError, api } from '../lib/apiClient'; import { useBootstrapStore } from '../stores/bootstrapStore'; import { PageHeader } from '../components/layout/PageHeader'; @@ -59,8 +60,9 @@ import { ModelSelectionPicker } from '../components/admin/ModelSelectionPicker'; import { OrchestrationCard } from '../components/admin/OrchestrationCard'; import { OrchestrationPlannerModelPicker } from '../components/admin/OrchestrationPlannerModelPicker'; import { PromotedAgentsEditor } from '../components/admin/PromotedAgentsEditor'; -import { AgentDelegationManager } from '../components/agents/AgentDelegationManager'; -import { GLOBAL_DELEGATION_SCOPE } from '../lib/agentDelegation'; +import { AgentTemplateApprovalsLink } from '../components/admin/AgentTemplateApprovalsLink'; +import { GlobalActionsManager } from '../components/admin/GlobalActionsManager'; +import { GlobalAgentsManager } from '../components/admin/GlobalAgentsManager'; import { SaveBar } from '../components/admin/SaveBar'; import { SecretField } from '../components/admin/SecretField'; import { SettingsSection } from '../components/admin/SettingsSection'; @@ -68,6 +70,7 @@ import { SettingsIndex, type SettingsIndexEntry } from '../components/admin/Sett import { agentSectionAppearances } from '../components/admin/agentSectionAppearance'; import { ALL_SETTINGS_ICON, resolveAdminNavIcon } from '../components/admin/adminSectionIcons'; import { SettingField } from '../components/admin/fields'; +import { WorkspaceLeavePrompt } from '../components/workspace/WorkspaceEditorFrame'; import { ClassificationBannerPreview, UserAgreementPreview, @@ -131,6 +134,9 @@ interface RenderedSection { /** Synthetic field definitions used to read a sibling's current value for a preview. */ const READ_ONLY_REF = (key: string): AdminField => ({ key, type: 'text', label: '' }); +/** The global agent and action editors, which open in place of this page. */ +const GLOBAL_EDITOR_PATH = /^\/admin\/(?:agents|actions)\/[^/]+$/; + /** * Associate each undeclared `enable_*` setting with a section. * @@ -212,7 +218,14 @@ function buildCapabilityIndex( }); } -export function AdminSettingsPage() { +/** + * The Admin Settings page. + * + * `focusSection` opens the page on one section's category with that section in view. The + * global agent and action editors return through it, at /admin/agents and /admin/actions, + * so finishing an edit lands back on the list it started from. + */ +export function AdminSettingsPage({ focusSection }: { focusSection?: string } = {}) { const isAdmin = useBootstrapStore((state) => Boolean(state.data?.user?.is_admin)); const bootstrapVersion = useBootstrapStore((state) => state.data?.version); @@ -234,7 +247,8 @@ export function AdminSettingsPage() { const [checkingForUpdates, setCheckingForUpdates] = useState(true); const [query, setQuery] = useState(''); const [activeGroup, setActiveGroup] = useState(null); - const [delegationDirty, setDelegationDirty] = useState(false); + /** Counts category choices, so each one restarts the page index at the top. */ + const [categoryVisit, setCategoryVisit] = useState(0); const [draft, setDraft] = useState({}); const [saving, setSaving] = useState(false); @@ -625,6 +639,19 @@ export function AdminSettingsPage() { setFieldWarnings({}); }, []); + // Opening a global agent or action editor replaces this page, and its unsaved settings + // with it. Ask first, as the editors themselves do when they are left with changes. + const unsavedSettings = dirtyKeys.length > 0 || saving; + const leaveForEditor = useBlocker( + ({ nextLocation }) => unsavedSettings && GLOBAL_EDITOR_PATH.test(nextLocation.pathname), + ); + useEffect(() => { + // A save that finishes while the question is open leaves nothing to lose. + if (leaveForEditor.state === 'blocked' && !unsavedSettings) { + leaveForEditor.proceed(); + } + }, [leaveForEditor, unsavedSettings]); + const save = useCallback(async () => { if (!Object.keys(draft).length) { return; @@ -706,6 +733,20 @@ export function AdminSettingsPage() { const readSibling = (key: string, fallback = '') => asString(readFieldValue(READ_ONLY_REF(key), settings, draft), fallback); + /** + * Show a category from its first section. + * + * Every category shares one scroll pane, so changing the filter alone kept the offset + * the previous category was left at: leaving Knowledge near its end opened Security + * near its end too. Choosing a category -- including the one already shown -- now + * starts at the top, and the index is restarted so it marks that first section. + */ + const selectCategory = useCallback((groupId: string | null) => { + setActiveGroup(groupId); + setCategoryVisit((visit) => visit + 1); + scrollRef.current?.scrollTo({ top: 0 }); + }, []); + /** * Move the page to a section, from a cross-reference elsewhere on it. * @@ -720,16 +761,27 @@ export function AdminSettingsPage() { if (!shown) { const target = sections.find((section) => section.sectionId === sectionId); const nextGroup = !target ? null : activeGroup && target.groupId !== activeGroup ? target.groupId : activeGroup; - if (nextGroup !== activeGroup && delegationDirty) { - // Changing category would unmount the delegation manager and its edits. - toast.error('Save or cancel Call agent changes before changing settings categories.'); - return; - } setQuery(''); setActiveGroup(nextGroup); } setPendingScroll(sectionId); - }, [visibleSections, sections, activeGroup, delegationDirty]); + }, [visibleSections, sections, activeGroup]); + + // Open a deep-linked section once its section exists: its own category, with it in view. + // Only once per link, so choosing another category afterwards is not undone. + const focusedSectionRef = useRef(null); + useEffect(() => { + if (!focusSection || focusedSectionRef.current === focusSection) { + return; + } + const target = sections.find((section) => section.sectionId === focusSection); + if (!target) { + return; + } + focusedSectionRef.current = focusSection; + setActiveGroup(target.groupId); + setPendingScroll(focusSection); + }, [focusSection, sections]); useEffect(() => { if (!pendingScroll) { @@ -1009,6 +1061,19 @@ export function AdminSettingsPage() { ); case 'global-identities-list': return ; + case 'global-agents-manager': + // The editor's template gallery reads the saved setting, so the shortcut does too. + return ( + + ); + case 'global-actions-manager': + return ; + case 'agent-template-approvals-link': + return ; case 'promoted-popular-agents': return ( section.capabilities.length > 0 && (!activeGroup || section.groupId === activeGroup), ); - const showDelegationManager = !loading && Boolean(data) && !error && - (activeGroup === 'agents-actions' || - (activeGroup === null && /call agent|delegation/i.test(query))); // The index needs at least two sections to be worth the width it takes. const showIndex = !loading && !error && indexEntries.length > 1; @@ -1239,9 +1301,8 @@ export function AdminSettingsPage() { - ))} +
+ - -
- {error ? -
{error}
-
: null} -
- {readOnly ? 'Configuration details' : 'Configuration'} - {sections.map((section) => section.id === 'advanced' ? ( -
- {section.label} - {section.description ?

{section.description}

: null} -
{section.content}
-
- ) : ( -
-

{section.label}

- {section.description ?

{section.description}

: null} -
{section.content}
-
- ))} -
+
+ +
+
+
+ {error ? ( + + + ) : null} +
+ {readOnly ? 'Configuration details' : 'Configuration'} + {sections.map((section) => section.id === 'advanced' ? ( +
+ + + + {section.label} + {section.description ? {section.description} : null} + + +
{section.content}
+
+ ) : ( + +
+ +
+

{section.label}

+ {section.description ?

{section.description}

: null} +
+
+
{section.content}
+
+ ))} +
+
{blocker.state === 'blocked' ? ( diff --git a/application/v2_ui/src/components/workspaceActions/ActionAdvancedFields.tsx b/application/v2_ui/src/components/workspaceActions/ActionAdvancedFields.tsx index 370fb9a28..a81757e0a 100644 --- a/application/v2_ui/src/components/workspaceActions/ActionAdvancedFields.tsx +++ b/application/v2_ui/src/components/workspaceActions/ActionAdvancedFields.tsx @@ -1,7 +1,7 @@ // ActionAdvancedFields.tsx import { useId } from 'react'; -import { Toggle } from '../ui/primitives'; +import { EditorDependents, EditorPanelFieldset, EditorRow, EditorSwitch } from '../workspace/EditorLayout'; import { ActionField, ActionJsonInput, ACTION_INPUT_CLASS } from './ActionFields'; import { ActionSchemaFields } from './ActionSchemaFields'; import { isRecord, type ActionTypeDefinition } from '../../lib/workspaceAuthoring'; @@ -26,30 +26,25 @@ export function ActionAdvancedFields(props: ActionConnectorProps & { const syncStates = isRecord(sync) ? Object.entries(sync) : []; return (
-
- + onChange((current) => ({ ...current, name: event.target.value }))} /> -
-

Action ID

-

{draft.id || 'Assigned when the action is saved.'}

-

The ID, not the display name, identifies this action in agent assignments.

-
+ +

{draft.id || 'Assigned when the action is saved.'}

+
{draft.type !== 'agent' ? ( -
- Secret expiration and reminders -

- Track expiration and send a rotation reminder for this action’s stored secrets. Tracking does not rotate credentials automatically. -

+ {hints ?

Key Vault storage is {hints.storageEnabled ? 'enabled' : 'disabled'}; reminder delivery is {hints.remindersEnabled ? 'enabled' : 'disabled'}. {hints.requireExpiration ? ' Your administrator requires expiration dates for tracked secrets.' : ''} @@ -57,7 +52,7 @@ export function ActionAdvancedFields(props: ActionConnectorProps & { {!hints?.storageEnabled && hints ?

This configuration is retained, but reminders for Key Vault secrets require enabled Key Vault storage and reminder delivery.

: null} - onChange((current) => { const existing = actionValueAt(current, reminderPath); @@ -71,43 +66,41 @@ export function ActionAdvancedFields(props: ActionConnectorProps & { } : {}), }); })} /> - {enabled ?
- + updateReminder('expires_on', event.target.value)} /> - updateReminder('contact_email', event.target.value)} /> - updateReminder('lead_days', event.target.value === '' ? undefined : event.target.valueAsNumber)} /> - + updateReminder('label', event.target.value)} /> -
- -