Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion application/single_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
VERSION = "0.241.006"
VERSION = "0.241.007"

SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')

Expand Down
16 changes: 15 additions & 1 deletion application/single_app/functions_agent_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,18 @@ def scope_matches(candidate):
if selected_agent_name:
return next((agent for agent in agents_cfg if agent.get("name") == selected_agent_name and scope_matches(agent)), None)

return None
return None


def is_selected_agent_scope_enabled(settings, selected_agent_data):
"""Return whether app settings allow the selected agent's scope."""
if not isinstance(selected_agent_data, dict):
return True

if selected_agent_data.get("is_group", False):
return bool((settings or {}).get("allow_group_agents", False))

if selected_agent_data.get("is_global", False):
return True

return bool((settings or {}).get("allow_user_agents", False))
36 changes: 23 additions & 13 deletions application/single_app/semantic_kernel_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
from functions_agent_payload import can_agent_use_default_multi_endpoint_model
from semantic_kernel_plugins.plugin_loader import discover_plugins
from semantic_kernel_plugins.openapi_plugin_factory import OpenApiPluginFactory
from functions_agent_scope import find_agent_by_scope
from functions_agent_scope import find_agent_by_scope, is_selected_agent_scope_enabled
import app_settings_cache

# Agent and Azure OpenAI chat service imports
Expand Down Expand Up @@ -1897,24 +1897,34 @@ def load_user_semantic_kernel(kernel: Kernel, settings, user_id: str, redis_clie

# Append selected group agent (if any) to the candidate list so downstream selection logic can resolve it
selected_agent_data = selected_agent if isinstance(selected_agent, dict) else {}
selected_agent_is_global = selected_agent_data.get('is_global', False)
selected_agent_is_group = selected_agent_data.get('is_group', False)
selected_agent_group_id = selected_agent_data.get('group_id')
conversation_group_id = getattr(g, "conversation_group_id", None)
allow_user_agents = settings.get('allow_user_agents', False)
allow_group_agents = settings.get('allow_group_agents', False)

if selected_agent_is_group and not allow_group_agents:
log_event(
"[SK Loader] Group agents are disabled; skipping group agent load.",
level=logging.WARNING
)
load_core_plugins_only(kernel, settings)
return kernel, None
if not selected_agent_is_group and not allow_user_agents:
log_event(
"[SK Loader] User agents are disabled; skipping personal agent load.",
level=logging.WARNING
)
if not is_selected_agent_scope_enabled(settings, selected_agent_data):
if selected_agent_is_group:
log_event(
"[SK Loader] Group agents are disabled; skipping group agent load.",
level=logging.WARNING,
extra={
'agent_name': selected_agent_data.get('name'),
'allow_group_agents': allow_group_agents,
'is_global': selected_agent_is_global,
}
)
else:
log_event(
"[SK Loader] User agents are disabled; skipping personal agent load.",
level=logging.WARNING,
extra={
'agent_name': selected_agent_data.get('name'),
'allow_user_agents': allow_user_agents,
'is_global': selected_agent_is_global,
}
)
load_core_plugins_only(kernel, settings)
return kernel, None

Expand Down
57 changes: 57 additions & 0 deletions docs/explanation/fixes/v0.241.007/GLOBAL_AGENT_SCOPE_GATE_FIX.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# GLOBAL_AGENT_SCOPE_GATE_FIX.md

## Global Agent Scope Gate Fix (v0.241.007)

Fixed/Implemented in version: **0.241.007**

### Issue Description

Per-user Semantic Kernel chats could silently fall back to the standard GPT model
when a user selected a global agent from the chat UI. The frontend showed no
error because the selection API accepted the agent and the streaming request
included that `agent_info`, but the backend still dropped into model-only mode.

### Root Cause Analysis

The per-user loader treated every non-group agent as a personal agent during the
scope gate check. When `allow_user_agents` was disabled and
`merge_global_semantic_kernel_with_workspace` was enabled, selected global agents
were blocked before the loader reached the global-agent merge and selection path.

### Technical Details

Files modified:
- `application/single_app/functions_agent_scope.py`
- `application/single_app/semantic_kernel_loader.py`
- `application/single_app/config.py`
- `functional_tests/test_global_agent_scope_gate.py`

Code changes summary:
- Added `is_selected_agent_scope_enabled()` to centralize scope gating for
personal, global, and group agent selections.
- Updated `load_user_semantic_kernel()` so global agents bypass the
`allow_user_agents` toggle while personal and group agent rules remain intact.
- Added regression coverage for the global-agent bypass, group-agent enforcement,
and loader wiring.

Testing approach:
- Added `functional_tests/test_global_agent_scope_gate.py` to validate the scope
helper behavior and confirm the per-user loader uses it.

Impact analysis:
- Global agents selected in per-user chat mode now remain on the agent invocation
path instead of silently reverting to model-only GPT routing.
- Personal and group scope restrictions continue to behave as configured.

### Validation

Before:
- The backend logged `Using agent from request` and then immediately logged
`User agents are disabled; skipping personal agent load.` for global agents.
- Requests fell back to `Loading core plugins only for model-only mode...`.

After:
- Global agent selections are no longer blocked by the personal-agent gate.
- Group selections still require `allow_group_agents`, and personal selections
still require `allow_user_agents`.
- The regression test protects the shared scope gate and its loader integration.
10 changes: 10 additions & 0 deletions docs/explanation/release_notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ This page tracks notable Simple Chat releases and organizes the detailed change

For feature-focused and fix-focused drill-downs by version, see [Features by Version](/explanation/features/) and [Fixes by Version](/explanation/fixes/).

### **(v0.241.007)**

#### Bug Fixes

* **Global Agent Scope Gate Fallback**
* Fixed per-user Semantic Kernel chats so selecting a global agent no longer silently falls back to the standard GPT model when personal agents are disabled for the tenant.
* The per-user loader now treats global, personal, and group agent scopes separately, allowing valid global-agent selections to continue through agent invocation while keeping personal and group scope toggles enforced as configured.
* Added regression coverage for the shared scope gate used by the per-user loader.
* (Ref: `semantic_kernel_loader.py`, `functions_agent_scope.py`, `test_global_agent_scope_gate.py`, global agent request routing)

### **(v0.241.006)**

#### Bug Fixes
Expand Down
118 changes: 118 additions & 0 deletions functional_tests/test_global_agent_scope_gate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# test_global_agent_scope_gate.py
"""
Functional test for global agent scope gating in per-user Semantic Kernel mode.
Version: 0.241.007
Implemented in: 0.241.007

This test ensures global agents remain eligible for loading even when personal
agent access is disabled, while personal and group scopes still respect their
own admin toggles.
"""

import os
import sys


repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
sys.path.append(repo_root)

from application.single_app.functions_agent_scope import is_selected_agent_scope_enabled


def read_file_text(file_path):
with open(file_path, "r", encoding="utf-8") as file:
return file.read()


def test_global_agents_bypass_personal_toggle():
"""Ensure global agents are not blocked by the personal-agent toggle."""
print("🔍 Validating global agent scope bypass...")

settings = {
"allow_user_agents": False,
"allow_group_agents": False,
}
global_agent = {
"name": "beta_occ_document_summarization_agent",
"is_global": True,
"is_group": False,
}
personal_agent = {
"name": "personal-agent",
"is_global": False,
"is_group": False,
}

assert is_selected_agent_scope_enabled(settings, global_agent) is True
assert is_selected_agent_scope_enabled(settings, personal_agent) is False

print("✅ Global agent scope bypass passed.")


def test_group_agents_still_require_group_toggle():
"""Ensure group agents still honor the group-agent toggle."""
print("🔍 Validating group agent scope enforcement...")

settings = {
"allow_user_agents": True,
"allow_group_agents": False,
}
group_agent = {
"name": "group-agent",
"is_global": False,
"is_group": True,
"group_id": "group-a",
}

assert is_selected_agent_scope_enabled(settings, group_agent) is False

settings["allow_group_agents"] = True
assert is_selected_agent_scope_enabled(settings, group_agent) is True

print("✅ Group agent scope enforcement passed.")


def test_loader_uses_scope_gate_helper():
"""Ensure the per-user loader uses the shared scope gate helper."""
print("🔍 Validating loader wiring for shared scope gate helper...")

loader_path = os.path.join(
repo_root, "application", "single_app", "semantic_kernel_loader.py"
)
loader_text = read_file_text(loader_path)

assert "is_selected_agent_scope_enabled(settings, selected_agent_data)" in loader_text, (
"Expected semantic kernel loader to use the shared selected-agent scope helper."
)

print("✅ Loader wiring for scope gate helper passed.")


def run_tests():
tests = [
test_global_agents_bypass_personal_toggle,
test_group_agents_still_require_group_toggle,
test_loader_uses_scope_gate_helper,
]
results = []

for test in tests:
print(f"\n🧪 Running {test.__name__}...")
try:
test()
print("✅ Test passed")
results.append(True)
except Exception as exc:
print(f"❌ Test failed: {exc}")
import traceback

traceback.print_exc()
results.append(False)

success = all(results)
print(f"\n📊 Results: {sum(results)}/{len(results)} tests passed")
return success


if __name__ == "__main__":
raise SystemExit(0 if run_tests() else 1)
Loading