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
117 changes: 117 additions & 0 deletions application/single_app/functions_v2_agents_catalog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# functions_v2_agents_catalog.py
"""The Agents catalogue, shaped for the V2 interface.

The classic ``/agents`` page reads ``GET /api/agents/catalog``, which lists every
agent the user can reach in their personal, group and global scopes. The V2 page
lists only the agents the user may start a chat with. Governance can block a whole
scope or a single agent, and the chat picker already leaves those out, so the
catalogue does too rather than offering a Chat button that cannot work.

The classic endpoint keeps its behaviour because the admin Promoted Agents editor
reads it, and governance has no administrator bypass: filtering it there would
hide agents from the very control that promotes them.

Only the fields the page draws are sent. Assigned knowledge and the model
endpoint, deployment and provider stay on the server; the page shows the model
label. Instructions are sent only when the administrator shows them in the
details dialog, the same rule the classic endpoint applies.

It imports no Flask and no Azure client, so it can be exercised directly: the
route passes in the catalogue loader, the governance check, the usage and
promotion annotators, and the page configuration.
"""


# Every field the V2 page reads, and nothing else. ``instructions`` is handled
# separately because it depends on an administrator setting.
V2_CATALOG_AGENT_FIELDS = (
"id",
"name",
"display_name",
"description",
"agent_type",
"is_global",
"is_group",
"scope_type",
"scope_id",
"scope_name",
"group_id",
"group_name",
"tags",
"icon",
"model_label",
"actions_to_load",
"action_labels",
"catalog_key",
"usage_count",
"usage_count_30_days",
"usage_count_all_time",
"is_promoted_popular",
"promoted_popular_window",
"promoted_popular_rank",
"promoted_popular_order",
"promoted_popular_tag_enabled",
"promoted_popular_tag_label",
)

V2_CATALOG_INSTRUCTIONS_FIELD = "instructions"


def catalog_agent_governance_scope(agent):
"""Return the governance scope of a catalogue record, read as the bootstrap reads it."""
return str(agent.get("scope_type") or "").strip().lower() or "personal"


def project_v2_catalog_agent(agent, *, include_instructions):
"""Copy only the fields the V2 page draws from one catalogue record."""
projected = {field: agent[field] for field in V2_CATALOG_AGENT_FIELDS if field in agent}
if include_instructions and V2_CATALOG_INSTRUCTIONS_FIELD in agent:
projected[V2_CATALOG_INSTRUCTIONS_FIELD] = agent[V2_CATALOG_INSTRUCTIONS_FIELD]
return projected


def build_v2_agents_catalog_payload(
*,
load_catalog,
is_allowed,
apply_usage_counts,
apply_promotions,
page_config,
):
"""Return ``{"page": ..., "agents": [...]}`` for the V2 Agents page.

``load_catalog()``
Returns the accessible catalogue records.
``is_allowed(agent, scope)``
Whether governance lets the user chat with the agent. An error it raises
propagates, so the page fails closed rather than listing agents that were
never checked.
``apply_usage_counts(records)`` and ``apply_promotions(records)``
Annotate the records and return them.
``page_config``
The normalised display configuration from ``build_agents_page_config``. Its
``show_instructions_in_details`` decides whether instructions are sent, so the
flag the page reads and the redaction cannot disagree.

Governance runs first, so usage is counted and promotions are applied only to
agents the user can see: an administrator promoting an agent this user is
blocked from cannot put it back on this user's Popular tab.
"""
page = dict(page_config or {})
include_instructions = bool(page.get("show_instructions_in_details"))

allowed = [
agent
for agent in load_catalog() or []
if isinstance(agent, dict) and is_allowed(agent, catalog_agent_governance_scope(agent))
]
allowed = apply_usage_counts(allowed)
allowed = apply_promotions(allowed)

return {
"page": page,
"agents": [
project_v2_catalog_agent(agent, include_instructions=include_instructions)
for agent in allowed
],
}
52 changes: 51 additions & 1 deletion application/single_app/route_backend_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@
build_latest_features_payload,
build_user_latest_features_payload,
)
from functions_v2_agents_catalog import build_v2_agents_catalog_payload
from functions_workspace_context import (
WorkspaceContextError,
build_group_workspace_context,
Expand All @@ -204,10 +205,17 @@
_build_initial_chat_model_selection,
_is_chat_agent_allowed_by_governance,
)
# Shared with the server-rendered Agents page so both interfaces read the same
# title, hero colours, disclaimer and instructions setting.
from route_frontend_agents import build_agents_page_config
# Shared with the server-rendered admin page so both interfaces run the same
# connection tests rather than maintaining two lists of what can be tested.
from route_backend_settings import run_admin_settings_connection_test
from functions_agent_catalog import build_accessible_agent_catalog
from functions_agent_catalog import (
apply_agent_popular_promotions,
apply_agent_usage_counts,
build_accessible_agent_catalog,
)
from functions_ai_notice import get_ai_notice_config, is_ai_notice_dismissed
from functions_model_capabilities import resolve_model_vision_support
from functions_model_endpoint_providers import get_model_endpoint_provider_ui_options
Expand Down Expand Up @@ -942,6 +950,48 @@ def v2_support_latest_features():
)
return jsonify({"error": "Failed to load Latest Features."}), 500

@bp.route("/api/v2/agents/catalog", methods=["GET"])
@swagger_route(security=get_auth_security())
@login_required
@user_required
@enabled_required("enable_semantic_kernel")
def v2_agents_catalog():
"""Return the agents this user may chat with, for the V2 Agents page.

The V2 counterpart of the classic ``/agents`` page, behind the same gates: an
application role and Semantic Kernel. Unlike the classic catalogue it leaves out
agents governance blocks for this user, as the chat picker does, so every card it
draws can start a chat. Usage counts and the administrator's Popular promotions
are applied after that filter, and the page configuration is sent alongside so
the SPA does not need a second request to draw the hero.
"""
user_id = get_current_user_id()
if not user_id:
return jsonify({"error": "User not authenticated"}), 401

try:
settings = get_settings()
payload = build_v2_agents_catalog_payload(
load_catalog=lambda: build_accessible_agent_catalog(user_id, settings=settings),
is_allowed=lambda agent, scope: _is_chat_agent_allowed_by_governance(
user_id, agent, scope
),
apply_usage_counts=apply_agent_usage_counts,
apply_promotions=lambda catalog: apply_agent_popular_promotions(
catalog, settings=settings
),
page_config=build_agents_page_config(sanitize_settings_for_user(settings)),
)
return jsonify(payload), 200
except Exception as exc:
log_event(
"[AGENTS_CATALOG] Failed to load the V2 agent catalogue.",
extra={"user_id": user_id, "error_type": type(exc).__name__},
level=logging.ERROR,
exceptionTraceback=True,
)
return jsonify({"error": "Failed to load agents."}), 500

@bp.route("/api/v2/bootstrap", methods=["GET"])
@swagger_route(security=get_auth_security())
@login_required
Expand Down
14 changes: 2 additions & 12 deletions application/v2_ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { AdminActionEditorPage, AdminAgentEditorPage } from './pages/AdminGlobal
import { SettingsPage } from './pages/SettingsPage';
import { WorkspacePage } from './pages/workspace/WorkspacePage';
import { safeSameOriginUrl } from './lib/adminOperations';
import { PlaceholderPage } from './pages/PlaceholderPage';
import { AgentsCatalogPage } from './pages/AgentsCatalogPage';
import { GroupWorkspacePage } from './pages/GroupWorkspacePage';
import { GroupDirectoryPage } from './pages/GroupDirectoryPage';
import { PublicWorkspacePage } from './pages/PublicWorkspacePage';
Expand Down Expand Up @@ -290,17 +290,7 @@ export function App() {
<Route path="/approvals" element={<ApprovalsPage />} />
<Route path="/approvals/:category" element={<ApprovalsPage />} />
<Route path="/approvals/:category/:itemId" element={<ApprovalsPage />} />
<Route
path="/agents"
element={
<PlaceholderPage
title="Agents"
description="The agent catalogue has not been rebuilt in the V2 interface yet. Agents you can access are still selectable from the chat composer."
classicHref="/agents"
classicLabel="Open the agent catalogue"
/>
}
/>
<Route path="/agents" element={<AgentsCatalogPage />} />
<Route path="/groups" element={<GroupWorkspacePage />} />
<Route path="/groups/directory" element={<GroupDirectoryPage />} />
<Route path="/groups/:groupId" element={<GroupWorkspacePage />} />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// AgentCatalogParts.tsx

import { MessageSquare } from 'lucide-react';
import { Link } from 'react-router-dom';
import { agentScopeType } from '../../lib/adminAgents';
import {
agentLinkScope, catalogDisplayName, catalogScopeLabel, promotedBadgeLabel, type CatalogAgent,
} from '../../lib/agentCatalog';
import { chatHrefForAgent } from '../../lib/conversationUrl';
import { GlassButton } from '../ui/primitives';
import { Pill } from '../workspace/primitives';

export function AgentCatalogBadges({ agent }: { agent: CatalogAgent }) {
const promoted = promotedBadgeLabel(agent);
return (
<div className="flex min-w-0 flex-wrap gap-1.5 [&>span]:max-w-full [&>span]:break-words [&>span]:whitespace-normal">
<Pill tone={agentScopeType(agent) === 'personal' ? 'neutral' : 'accent'}>
{catalogScopeLabel(agent)}
</Pill>
{promoted ? <Pill tone="ok">{promoted}</Pill> : null}
</div>
);
}

export function AgentChatLink({ agent }: { agent: CatalogAgent }) {
const scope = agentLinkScope(agent);
const label = `Chat with ${catalogDisplayName(agent)}`;
if (!scope || !agent.id) {
return (
<GlassButton size="sm" variant="primary" disabled aria-label={label}
title="This agent is missing its identifier or group.">
<MessageSquare size={14} aria-hidden="true" />
Chat
</GlassButton>
);
}
return (
<Link to={chatHrefForAgent(agent.id, scope)} aria-label={label}
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-xl bg-accent px-3 text-sm font-medium text-on-accent transition-colors hover:bg-accent-hover">
<MessageSquare size={14} aria-hidden="true" />
Chat
</Link>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// AgentDetailsModal.tsx

import {
catalogAgentTypeLabel, catalogDisplayName, catalogUsageCount, normalizeCatalogText, type CatalogAgent,
} from '../../lib/agentCatalog';
import { Modal } from '../ui/Modal';
import { PlainMarkdown } from '../ui/PlainMarkdown';
import { GlassButton } from '../ui/primitives';
import { Pill } from '../workspace/primitives';
import { AgentIcon } from '../workspaceAgents/AgentIdentityFields';
import { AgentCatalogBadges, AgentChatLink } from './AgentCatalogParts';

export function AgentDetailsModal({
agent, showInstructions, onClose,
}: {
agent: CatalogAgent;
showInstructions: boolean;
onClose: () => void;
}) {
const actions = agent.action_labels ?? agent.actions_to_load ?? [];
const tags = agent.tags ?? [];
return (
<Modal title={`Details for ${catalogDisplayName(agent)}`} size="lg" onClose={onClose}
footer={<><GlassButton size="sm" onClick={onClose}>Close</GlassButton><AgentChatLink agent={agent} /></>}>
<div className="min-w-0 space-y-5 break-words">
<div className="flex items-start gap-3">
<AgentIcon icon={agent.icon} />
<div className="min-w-0 space-y-1.5">
<h3 className="text-lg font-semibold text-text-1">{catalogDisplayName(agent)}</h3>
{agent.name ? <p className="break-all font-mono text-xs text-text-3">{agent.name}</p> : null}
<AgentCatalogBadges agent={agent} />
</div>
</div>
<p className="whitespace-pre-wrap text-sm leading-relaxed text-text-2">
{agent.description?.trim() || 'No description available.'}
</p>
<dl className="grid gap-x-6 gap-y-3 text-sm sm:grid-cols-2">
<div>
<dt className="text-text-3">Agent type</dt>
<dd className="mt-0.5 text-text-1">{catalogAgentTypeLabel(agent)}</dd>
</div>
<div>
<dt className="text-text-3">Model</dt>
<dd className="mt-0.5 text-text-1">{normalizeCatalogText(agent.model_label) || 'Default'}</dd>
</div>
<div>
<dt className="text-text-3">Times used, all time</dt>
<dd className="mt-0.5 tabular-nums text-text-1">{catalogUsageCount(agent, 'all_time').toLocaleString()}</dd>
</div>
<div>
<dt className="text-text-3">Times used, last 30 days</dt>
<dd className="mt-0.5 tabular-nums text-text-1">{catalogUsageCount(agent, '30_days').toLocaleString()}</dd>
</div>
</dl>
<section aria-label="Actions">
<h3 className="mb-2 text-sm font-semibold text-text-1">Actions</h3>
{actions.length ? (
<ul className="list-inside list-disc space-y-1 text-sm text-text-2">
{actions.map((action, index) => <li key={`${index}:${action}`}>{action}</li>)}
</ul>
) : <p className="text-sm text-text-3">No actions assigned.</p>}
</section>
<section aria-label="Tags">
<h3 className="mb-2 text-sm font-semibold text-text-1">Tags</h3>
{tags.length ? (
<div className="flex flex-wrap gap-1.5 [&>span]:max-w-full [&>span]:break-words [&>span]:whitespace-normal">
{tags.map((tag, index) => <Pill key={`${index}:${tag}`}>{tag}</Pill>)}
</div>
) : <p className="text-sm text-text-3">No tags assigned.</p>}
</section>
{showInstructions && agent.instructions?.trim() ? (
<section aria-label="Instructions">
<h3 className="mb-2 text-sm font-semibold text-text-1">Instructions</h3>
<div className="min-w-0 max-w-full overflow-x-auto">
<PlainMarkdown content={agent.instructions} className="[&_p]:break-words" />
</div>
</section>
) : null}
</div>
</Modal>
);
}
2 changes: 1 addition & 1 deletion application/v2_ui/src/components/ui/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ export function Modal({
>
{banner ?? (
<div className="flex items-start justify-between gap-3 border-b border-edge px-4 py-3">
<div className="min-w-0">
<div className="min-w-0 [overflow-wrap:anywhere]">
<h2 className="text-sm font-semibold text-text-1">{title}</h2>
{description ? (
<p className="mt-0.5 text-xs text-text-3">{description}</p>
Expand Down
Loading
Loading