Skip to content
Open
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
36 changes: 35 additions & 1 deletion agent-manager-service/controllers/agent_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (

"github.com/wso2/ai-agent-management-platform/agent-manager-service/middleware/jwtassertion"
"github.com/wso2/ai-agent-management-platform/agent-manager-service/middleware/logger"
"github.com/wso2/ai-agent-management-platform/agent-manager-service/models"
"github.com/wso2/ai-agent-management-platform/agent-manager-service/services"
"github.com/wso2/ai-agent-management-platform/agent-manager-service/spec"
"github.com/wso2/ai-agent-management-platform/agent-manager-service/utils"
Expand Down Expand Up @@ -125,11 +126,44 @@ func (c *agentController) ListAgents(w http.ResponseWriter, r *http.Request) {
return
}

// Parse filter parameters
search := r.URL.Query().Get("search")
provisioningType := r.URL.Query().Get("provisioningType")
sortBy := r.URL.Query().Get("sortBy")
sortOrder := r.URL.Query().Get("sortOrder")

// Validate filter parameters
if !models.IsValidSortBy(sortBy) {
log.Error("ListAgents: invalid sortBy parameter", "sortBy", sortBy)
utils.WriteErrorResponse(w, http.StatusBadRequest, "Invalid sortBy parameter: must be 'name', 'createdAt', or 'updatedAt'")
return
}
if !models.IsValidSortOrder(sortOrder) {
log.Error("ListAgents: invalid sortOrder parameter", "sortOrder", sortOrder)
utils.WriteErrorResponse(w, http.StatusBadRequest, "Invalid sortOrder parameter: must be 'asc' or 'desc'")
return
}
if !models.IsValidProvisioningType(provisioningType) {
log.Error("ListAgents: invalid provisioningType parameter", "provisioningType", provisioningType)
utils.WriteErrorResponse(w, http.StatusBadRequest, "Invalid provisioningType parameter: must be 'internal' or 'external'")
return
}

// Build filter
filter := models.AgentFilter{
Search: search,
ProvisioningType: provisioningType,
SortBy: sortBy,
SortOrder: sortOrder,
Limit: limit,
Offset: offset,
}

// Extract user info from JWT token
tokenClaims := jwtassertion.GetTokenClaims(ctx)
userIdpId := tokenClaims.Sub

agents, total, err := c.agentService.ListAgents(ctx, userIdpId, orgName, projName, int32(limit), int32(offset))
agents, total, err := c.agentService.ListAgents(ctx, userIdpId, orgName, projName, filter)
if err != nil {
log.Error("ListAgents: failed to list agents", "error", err)
if errors.Is(err, utils.ErrOrganizationNotFound) {
Expand Down
68 changes: 68 additions & 0 deletions agent-manager-service/models/agent_filter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com).
//
// WSO2 LLC. licenses this file to you under the Apache License,
// Version 2.0 (the "License"); you may not use this file except
// in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.

package models

// Valid filter values as constants
const (
// Sort field options
SortByName = "name"
SortByCreatedAt = "createdAt"
SortByUpdatedAt = "updatedAt"

// Sort order options
SortOrderAsc = "asc"
SortOrderDesc = "desc"

// Provisioning type options
ProvisioningInternal = "internal"
ProvisioningExternal = "external"
)

// AgentFilter holds filter options for listing agents
type AgentFilter struct {
Search string // search in name, displayName, description
ProvisioningType string // "internal", "external"
SortBy string // "name", "createdAt", "updatedAt"
SortOrder string // "asc", "desc"
Limit int
Offset int
}
Comment on lines +35 to +43
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Consider adding validation or using constants for filter values.

The AgentFilter struct accepts string values for SortBy, SortOrder, and ProvisioningType without validation. Invalid values will be silently ignored or cause unexpected behavior in the repository layer.

Consider:

  1. Define constants for valid values (e.g., SortByName, SortByCreatedAt, SortOrderAsc, ProvisioningTypeInternal)
  2. Add a Validate() method to the AgentFilter type to check field values before database queries
  3. Return validation errors early in the controller layer

This will improve type safety and provide better error messages to API consumers.

🤖 Prompt for AI Agents
In agent-manager-service/models/agent_filter.go around lines 19 to 27, the
AgentFilter struct accepts free-form strings for SortBy, SortOrder, and
ProvisioningType which can lead to silent failures; define typed constants for
allowed values (e.g., SortByName, SortByCreatedAt, SortOrderAsc/Desc,
ProvisioningTypeInternal/External), add a Validate() method on AgentFilter that
checks these fields (and limits/offset bounds) returning a descriptive error
when a value is invalid, and update callers (controllers) to call Validate() and
return the validation error to clients before proceeding to repository queries.

⚠️ Potential issue | 🟠 Major

Naming inconsistency between default value and expected format.

The comment on line 23 documents SortBy values as camelCase ("name", "createdAt", "updatedAt"), but DefaultAgentFilter() at line 32 uses snake_case "created_at". The repository at lines 84-92 of agent-manager-service/repositories/agent.go validates against camelCase values and maps them to snake_case column names.

This mismatch will cause the default filter to skip the switch statement and fall through to "created_at" being used directly as the sort column, which works accidentally but is inconsistent with the documented API contract.

🔎 Apply this diff to align the default with the expected format:
 func DefaultAgentFilter() AgentFilter {
 	return AgentFilter{
-		SortBy:    "created_at",
+		SortBy:    "createdAt",
 		SortOrder: "desc",
 		Limit:     20,
 		Offset:    0,
 	}
 }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In agent-manager-service/models/agent_filter.go around lines 19 to 27 (and
DefaultAgentFilter at line 32), the SortBy default uses snake_case
("created_at") while the documented/validated values are camelCase ("name",
"createdAt", "updatedAt"); update the default SortBy to "createdAt" (or adjust
the comment/validation to match) so the default follows the API contract and
will be handled by the repository's switch that maps camelCase to snake_case
column names.


// DefaultAgentFilter returns filter with sensible defaults
func DefaultAgentFilter() AgentFilter {
return AgentFilter{
SortBy: SortByCreatedAt,
SortOrder: SortOrderDesc,
Limit: 20,
Offset: 0,
}
}

// IsValidSortBy checks if sortBy value is valid
func IsValidSortBy(sortBy string) bool {
return sortBy == "" || sortBy == SortByName || sortBy == SortByCreatedAt || sortBy == SortByUpdatedAt
}

// IsValidSortOrder checks if sortOrder value is valid
func IsValidSortOrder(sortOrder string) bool {
return sortOrder == "" || sortOrder == SortOrderAsc || sortOrder == SortOrderDesc
}

// IsValidProvisioningType checks if provisioningType value is valid
func IsValidProvisioningType(provisioningType string) bool {
return provisioningType == "" || provisioningType == ProvisioningInternal || provisioningType == ProvisioningExternal
}
58 changes: 58 additions & 0 deletions agent-manager-service/repositories/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (

type AgentRepository interface {
ListAgents(ctx context.Context, orgId uuid.UUID, projectId uuid.UUID) ([]*models.Agent, error)
ListAgentsWithFilter(ctx context.Context, orgId uuid.UUID, projectId uuid.UUID, filter models.AgentFilter) ([]*models.Agent, int64, error)
GetAgentByName(ctx context.Context, orgId uuid.UUID, projectId uuid.UUID, agentName string) (*models.Agent, error)
CreateAgent(ctx context.Context, agent *models.Agent) error
SoftDeleteAgentByName(ctx context.Context, orgId uuid.UUID, projectId uuid.UUID, agentName string) error
Expand Down Expand Up @@ -56,6 +57,63 @@ func (r *agentRepository) ListAgents(ctx context.Context, orgId uuid.UUID, proje
return agents, nil
}

func (r *agentRepository) ListAgentsWithFilter(ctx context.Context, orgId uuid.UUID, projectId uuid.UUID, filter models.AgentFilter) ([]*models.Agent, int64, error) {
var agents []*models.Agent
var total int64

query := db.DB(ctx).Model(&models.Agent{}).Where("org_id = ? AND project_id = ?", orgId, projectId)

// Apply search filter (case-insensitive search on name, display_name, description)
if filter.Search != "" {
searchPattern := "%" + filter.Search + "%"
query = query.Where("name ILIKE ? OR display_name ILIKE ? OR description ILIKE ?", searchPattern, searchPattern, searchPattern)
}

// Apply provisioning type filter
if filter.ProvisioningType != "" {
query = query.Where("provisioning_type = ?", filter.ProvisioningType)
}

// Get total count before pagination
if err := query.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("agentRepository.ListAgentsWithFilter count: %w", err)
}

// Apply sorting - map camelCase API values to snake_case DB columns
sortColumn := "created_at"
switch filter.SortBy {
case models.SortByName:
sortColumn = "name"
case models.SortByUpdatedAt:
sortColumn = "updated_at"
case models.SortByCreatedAt:
sortColumn = "created_at"
}

// Validate sortOrder to prevent SQL injection - only allow known values
sortOrder := "DESC"
if filter.SortOrder == models.SortOrderAsc {
sortOrder = "ASC"
}

query = query.Order(fmt.Sprintf("%s %s", sortColumn, sortOrder))

// Apply pagination
if filter.Limit > 0 {
query = query.Limit(filter.Limit)
}
if filter.Offset > 0 {
query = query.Offset(filter.Offset)
}

// Execute query with preload
if err := query.Preload("AgentDetails").Find(&agents).Error; err != nil {
return nil, 0, fmt.Errorf("agentRepository.ListAgentsWithFilter: %w", err)
}

return agents, total, nil
}

func (r *agentRepository) GetAgentByName(ctx context.Context, orgId uuid.UUID, projectId uuid.UUID, agentName string) (*models.Agent, error) {
var agent models.Agent
if err := db.DB(ctx).
Expand Down
38 changes: 13 additions & 25 deletions agent-manager-service/services/agent_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import (
)

type AgentManagerService interface {
ListAgents(ctx context.Context, userIdpId uuid.UUID, orgName string, projName string, limit int32, offset int32) ([]*models.AgentResponse, int32, error)
ListAgents(ctx context.Context, userIdpId uuid.UUID, orgName string, projName string, filter models.AgentFilter) ([]*models.AgentResponse, int32, error)
CreateAgent(ctx context.Context, userIdpId uuid.UUID, orgName string, projectName string, req *spec.CreateAgentRequest) error
BuildAgent(ctx context.Context, userIdpId uuid.UUID, orgName string, projectName string, agentName string, commitId string) (*models.BuildResponse, error)
DeleteAgent(ctx context.Context, userIdpId uuid.UUID, orgName string, projectName string, agentName string) error
Expand Down Expand Up @@ -126,8 +126,8 @@ func (s *agentManagerService) GetAgent(ctx context.Context, userIdpId uuid.UUID,
return s.convertManagedAgentToAgentResponse(ocAgentComponent, agent), nil
}

func (s *agentManagerService) ListAgents(ctx context.Context, userIdpId uuid.UUID, orgName string, projName string, limit int32, offset int32) ([]*models.AgentResponse, int32, error) {
s.logger.Info("Listing agents", "orgName", orgName, "projectName", projName, "limit", limit, "offset", offset, "userIdpId", userIdpId)
func (s *agentManagerService) ListAgents(ctx context.Context, userIdpId uuid.UUID, orgName string, projName string, filter models.AgentFilter) ([]*models.AgentResponse, int32, error) {
s.logger.Info("Listing agents", "orgName", orgName, "projectName", projName, "filter", filter, "userIdpId", userIdpId)
// Validate organization exists
org, err := s.OrganizationRepository.GetOrganizationByOrgName(ctx, userIdpId, orgName)
if err != nil {
Expand All @@ -146,34 +146,22 @@ func (s *agentManagerService) ListAgents(ctx context.Context, userIdpId uuid.UUI
s.logger.Error("Failed to find project", "projectName", projName, "orgId", org.ID, "error", err)
return nil, 0, fmt.Errorf("failed to find project %s: %w", projName, err)
}
// Fetch all agents from the database
agents, err := s.AgentRepository.ListAgents(ctx, org.ID, project.ID)

// Fetch agents with filter from the database
agents, total, err := s.AgentRepository.ListAgentsWithFilter(ctx, org.ID, project.ID, filter)
if err != nil {
s.logger.Error("Failed to list agents from repository", "orgId", org.ID, "projectId", project.ID, "error", err)
return nil, 0, fmt.Errorf("failed to list external agents: %w", err)
return nil, 0, fmt.Errorf("failed to list agents: %w", err)
}
var allAgents []*models.AgentResponse

// Convert to response format
var agentResponses []*models.AgentResponse
for _, agent := range agents {
allAgents = append(allAgents, s.convertToAgentListItem(agent, project.Name))
agentResponses = append(agentResponses, s.convertToAgentListItem(agent, project.Name))
}

// Calculate total count
total := int32(len(allAgents))

// Apply pagination
var paginatedAgents []*models.AgentResponse
if offset >= total {
// If offset is beyond available data, return empty slice
paginatedAgents = []*models.AgentResponse{}
} else {
endIndex := offset + limit
if endIndex > total {
endIndex = total
}
paginatedAgents = allAgents[offset:endIndex]
}
s.logger.Info("Listed agents successfully", "orgName", orgName, "projName", projName, "totalAgents", total, "returnedAgents", len(paginatedAgents))
return paginatedAgents, total, nil
s.logger.Info("Listed agents successfully", "orgName", orgName, "projName", projName, "totalAgents", total, "returnedAgents", len(agentResponses))
return agentResponses, int32(total), nil
}

func (s *agentManagerService) CreateAgent(ctx context.Context, userIdpId uuid.UUID, orgName string, projectName string, req *spec.CreateAgentRequest) error {
Expand Down
11 changes: 10 additions & 1 deletion console/workspaces/libs/types/src/api/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ export type ListAgentsPathParams = OrgProjPathParams;
export type CreateAgentPathParams = OrgProjPathParams;
export type GetAgentPathParams = AgentPathParams;
export type DeleteAgentPathParams = AgentPathParams;
export type ListAgentsQuery = ListQuery;

// Sort field options for agent list
export type AgentSortBy = 'name' | 'createdAt' | 'updatedAt';

export interface ListAgentsQuery extends ListQuery {
search?: string;
provisioningType?: ProvisioningType;
sortBy?: AgentSortBy;
sortOrder?: 'asc' | 'desc';
}


65 changes: 34 additions & 31 deletions console/workspaces/pages/overview/src/AgentsList/AgentsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,17 @@ export interface AgentWithHref extends AgentResponse {
export const AgentsList: React.FC = () => {
const theme = useTheme();
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [hoveredAgentId, setHoveredAgentId] = useState<string | null>(null);

// Debounce search input to reduce API calls
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(search);
}, 300);
return () => clearTimeout(timer);
}, [search]);

// Detect touch device for alternative interaction pattern
const isTouchDevice =
typeof window !== "undefined" &&
Expand All @@ -118,10 +127,10 @@ export const AgentsList: React.FC = () => {
error,
isRefetching,
refetch: refetchAgents,
} = useListAgents({
orgName: orgId,
projName: projectId,
});
} = useListAgents(
{ orgName: orgId, projName: projectId },
{ search: debouncedSearch || undefined }
);
const { mutate: deleteAgent, isPending: isDeletingAgent } = useDeleteAgent();
const { data: project, isLoading: isProjectLoading } = useGetProject({
orgName: orgId,
Expand Down Expand Up @@ -166,7 +175,8 @@ export const AgentsList: React.FC = () => {
projectId &&
!data?.agents?.length &&
!isLoading &&
!isRefetching
!isRefetching &&
!debouncedSearch // Don't redirect when searching with no results
) {
navigate(
generatePath(
Expand All @@ -176,34 +186,28 @@ export const AgentsList: React.FC = () => {
)
);
}
}, [orgId, projectId, data?.agents, isLoading, isRefetching, navigate]);
}, [orgId, projectId, data?.agents, isLoading, isRefetching, navigate, debouncedSearch]);

const agentsWithHref: AgentWithHref[] = useMemo(
() =>
data?.agents
?.filter(
(agent: AgentResponse) =>
agent.displayName.toLowerCase().includes(search.toLowerCase()) ||
agent.name.toLowerCase().includes(search.toLowerCase())
)
.map((agent) => ({
...agent,
href: generatePath(
getAgentPath(agent.provisioning.type === "internal"),
{
orgId: orgId ?? "",
projectId: agent.projectName,
agentId: agent.name,
}
),
id: agent.name,
agentInfo: {
name: agent.name,
displayName: agent.displayName,
description: agent.description,
},
})) ?? [],
[data?.agents, search, orgId]
data?.agents?.map((agent) => ({
...agent,
href: generatePath(
getAgentPath(agent.provisioning.type === "internal"),
{
orgId: orgId ?? "",
projectId: agent.projectName,
agentId: agent.name,
}
),
id: agent.name,
agentInfo: {
name: agent.name,
displayName: agent.displayName,
description: agent.description,
},
})) ?? [],
[data?.agents, orgId]
);

const columns = useMemo(
Expand Down Expand Up @@ -416,7 +420,6 @@ export const AgentsList: React.FC = () => {
size="small"
variant="outlined"
placeholder="Search agents"
disabled={!data?.agents?.length}
/>
</Box>
<Button
Expand Down