Files
Enterprise-Knowledge-Base/internal/application/service/agent_share.go
T
wizardchen b8c45b173e fix(agent-share): don't require rerank model for wiki-only agents
The share-time validation forced every agent bound to a knowledge base to
have a rerank model configured, which blocked Wiki-type agents
(`wiki_search` / `wiki_read_page` / …) from being shared even though they
never invoke the reranker at runtime.

Align the check with the runtime logic in `session_agent_qa.go`: only
require a rerank model when the agent's allowed_tools include
`knowledge_search` (or rely on the default tool set, which contains it).
Wiki-only agents can now be shared without a rerank model; hybrid / RAG
agents still enforce the requirement.

Also update the handler error message to reflect the new rule.
2026-04-24 10:21:05 +08:00

494 lines
16 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"time"
"github.com/Tencent/WeKnora/internal/agent/tools"
"github.com/Tencent/WeKnora/internal/application/repository"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/google/uuid"
)
var (
ErrAgentShareNotFound = errors.New("agent share not found")
ErrAgentSharePermission = errors.New("permission denied for this share operation")
ErrAgentNotFoundForShare = errors.New("agent not found")
ErrNotAgentOwner = errors.New("only agent owner can share")
ErrOrgRoleCannotShareAgent = errors.New("only editors and admins can share agents to this organization")
ErrAgentNotConfigured = errors.New("agent is not fully configured (missing required chat model, or rerank model when the knowledge_search tool is enabled)")
)
// agentRequiresRerankModel returns true when the agent's configured tools
// will actually invoke the reranker at runtime. This mirrors the runtime
// check in session_agent_qa.go: only `knowledge_search` uses the reranker.
// Wiki-first agents (wiki_search / wiki_read_page / …) never call it and
// therefore don't need a rerank model configured, even when knowledge bases
// are attached.
//
// When AllowedTools is empty the runtime falls back to
// tools.DefaultAllowedTools(), which includes knowledge_search, so we treat
// that case as requiring the reranker.
func agentRequiresRerankModel(agent *types.CustomAgent) bool {
if agent == nil {
return false
}
allowed := agent.Config.AllowedTools
if len(allowed) == 0 {
allowed = tools.DefaultAllowedTools()
}
for _, t := range allowed {
if t == tools.ToolKnowledgeSearch {
return true
}
}
return false
}
// agentShareService implements AgentShareService interface
type agentShareService struct {
shareRepo interfaces.AgentShareRepository
disabledRepo interfaces.TenantDisabledSharedAgentRepository
orgRepo interfaces.OrganizationRepository
agentRepo interfaces.CustomAgentRepository
userRepo interfaces.UserRepository
}
// NewAgentShareService creates a new agent share service
func NewAgentShareService(
shareRepo interfaces.AgentShareRepository,
disabledRepo interfaces.TenantDisabledSharedAgentRepository,
orgRepo interfaces.OrganizationRepository,
agentRepo interfaces.CustomAgentRepository,
userRepo interfaces.UserRepository,
) interfaces.AgentShareService {
return &agentShareService{
shareRepo: shareRepo,
disabledRepo: disabledRepo,
orgRepo: orgRepo,
agentRepo: agentRepo,
userRepo: userRepo,
}
}
// ShareAgent shares an agent to an organization
func (s *agentShareService) ShareAgent(ctx context.Context, agentID string, orgID string, userID string, tenantID uint64, permission types.OrgMemberRole) (*types.AgentShare, error) {
logger.Infof(ctx, "Sharing agent %s to organization %s", agentID, orgID)
agent, err := s.agentRepo.GetAgentByID(ctx, agentID, tenantID)
if err != nil || agent == nil {
return nil, ErrAgentNotFoundForShare
}
if agent.TenantID != tenantID {
return nil, ErrNotAgentOwner
}
// Require agent to be fully configured before sharing (same rules as for
// conversation — see session_agent_qa.go). Rerank model is only required
// when the `knowledge_search` tool is enabled; Wiki-only agents
// (wiki_search / wiki_read_page) don't call the reranker at runtime.
if agent.Config.ModelID == "" {
return nil, ErrAgentNotConfigured
}
if agentRequiresRerankModel(agent) && agent.Config.RerankModelID == "" {
return nil, ErrAgentNotConfigured
}
_, err = s.orgRepo.GetByID(ctx, orgID)
if err != nil {
if errors.Is(err, repository.ErrOrganizationNotFound) {
return nil, ErrOrgNotFound
}
return nil, err
}
member, err := s.orgRepo.GetMember(ctx, orgID, userID)
if err != nil {
if errors.Is(err, repository.ErrOrgMemberNotFound) {
return nil, ErrUserNotInOrg
}
return nil, err
}
if !member.Role.HasPermission(types.OrgRoleEditor) {
return nil, ErrOrgRoleCannotShareAgent
}
// 智能体共享仅支持只读,不支持可编辑
permission = types.OrgRoleViewer
share := &types.AgentShare{
ID: uuid.New().String(),
AgentID: agentID,
OrganizationID: orgID,
SharedByUserID: userID,
SourceTenantID: tenantID,
Permission: permission,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.shareRepo.Create(ctx, share); err != nil {
if errors.Is(err, repository.ErrAgentShareAlreadyExists) {
existing, err := s.shareRepo.GetByAgentAndOrg(ctx, agentID, orgID)
if err != nil {
return nil, err
}
existing.Permission = types.OrgRoleViewer
existing.UpdatedAt = time.Now()
if err := s.shareRepo.Update(ctx, existing); err != nil {
return nil, err
}
return existing, nil
}
return nil, err
}
logger.Infof(ctx, "Agent %s shared successfully to organization %s", agentID, orgID)
return share, nil
}
// RemoveShare removes an agent share
func (s *agentShareService) RemoveShare(ctx context.Context, shareID string, userID string) error {
share, err := s.shareRepo.GetByID(ctx, shareID)
if err != nil {
if errors.Is(err, repository.ErrAgentShareNotFound) {
return ErrAgentShareNotFound
}
return err
}
if share.SharedByUserID == userID {
return s.shareRepo.Delete(ctx, shareID)
}
member, err := s.orgRepo.GetMember(ctx, share.OrganizationID, userID)
if err == nil && member.Role == types.OrgRoleAdmin {
return s.shareRepo.Delete(ctx, shareID)
}
return ErrAgentSharePermission
}
// ListSharesByAgent lists all shares for an agent
func (s *agentShareService) ListSharesByAgent(ctx context.Context, agentID string) ([]*types.AgentShare, error) {
return s.shareRepo.ListByAgent(ctx, agentID)
}
// ListSharesByOrganization lists all agent shares for an organization
func (s *agentShareService) ListSharesByOrganization(ctx context.Context, orgID string) ([]*types.AgentShare, error) {
return s.shareRepo.ListByOrganization(ctx, orgID)
}
// ListSharedAgents lists agents shared to the user through organizations, deduplicated by agent ID (keep highest permission)
func (s *agentShareService) ListSharedAgents(ctx context.Context, userID string, currentTenantID uint64) ([]*types.SharedAgentInfo, error) {
shares, err := s.shareRepo.ListSharedAgentsForUser(ctx, userID)
if err != nil {
return nil, err
}
agentInfoMap := make(map[string]*types.SharedAgentInfo)
for _, share := range shares {
if share.SourceTenantID == currentTenantID {
continue
}
if share.Agent == nil {
continue
}
member, err := s.orgRepo.GetMember(ctx, share.OrganizationID, userID)
if err != nil {
continue
}
effectivePermission := share.Permission
if !member.Role.HasPermission(share.Permission) {
effectivePermission = member.Role
}
info := &types.SharedAgentInfo{
Agent: share.Agent,
ShareID: share.ID,
OrganizationID: share.OrganizationID,
OrgName: "",
Permission: effectivePermission,
SourceTenantID: share.SourceTenantID,
SharedAt: share.CreatedAt,
SharedByUserID: share.SharedByUserID,
}
if share.Organization != nil {
info.OrgName = share.Organization.Name
}
if share.SharedByUserID != "" {
if u, err := s.userRepo.GetUserByID(ctx, share.SharedByUserID); err == nil && u != nil {
info.SharedByUsername = u.Username
}
}
key := fmt.Sprintf("%s_%d", share.AgentID, share.SourceTenantID)
existing, exists := agentInfoMap[key]
if !exists {
agentInfoMap[key] = info
} else if effectivePermission.HasPermission(existing.Permission) && effectivePermission != existing.Permission {
agentInfoMap[key] = info
}
}
result := make([]*types.SharedAgentInfo, 0, len(agentInfoMap))
for _, info := range agentInfoMap {
result = append(result, info)
}
// Set DisabledByMe from tenant_disabled_shared_agents for current tenant
disabledList, err := s.disabledRepo.ListByTenantID(ctx, currentTenantID)
if err != nil {
return result, nil // non-fatal: return list without DisabledByMe
}
disabledSet := make(map[string]bool)
for _, d := range disabledList {
disabledSet[fmt.Sprintf("%s_%d", d.AgentID, d.SourceTenantID)] = true
}
for _, info := range result {
if info.Agent != nil {
key := fmt.Sprintf("%s_%d", info.Agent.ID, info.SourceTenantID)
info.DisabledByMe = disabledSet[key]
}
}
return result, nil
}
// ListSharedAgentsInOrganization returns all agents shared to the given organization (including those shared by the current tenant), for list-page display when a space is selected.
func (s *agentShareService) ListSharedAgentsInOrganization(ctx context.Context, orgID string, userID string, currentTenantID uint64) ([]*types.OrganizationSharedAgentItem, error) {
member, err := s.orgRepo.GetMember(ctx, orgID, userID)
if err != nil {
if errors.Is(err, repository.ErrOrgMemberNotFound) {
return nil, ErrUserNotInOrg
}
return nil, err
}
shares, err := s.shareRepo.ListByOrganization(ctx, orgID)
if err != nil {
return nil, err
}
result := make([]*types.OrganizationSharedAgentItem, 0, len(shares))
for _, share := range shares {
if share.Agent == nil {
continue
}
effectivePermission := share.Permission
if !member.Role.HasPermission(share.Permission) {
effectivePermission = member.Role
}
orgName := ""
if share.Organization != nil {
orgName = share.Organization.Name
}
info := &types.SharedAgentInfo{
Agent: share.Agent,
ShareID: share.ID,
OrganizationID: share.OrganizationID,
OrgName: orgName,
Permission: effectivePermission,
SourceTenantID: share.SourceTenantID,
SharedAt: share.CreatedAt,
SharedByUserID: share.SharedByUserID,
}
if share.SharedByUserID != "" {
if u, err := s.userRepo.GetUserByID(ctx, share.SharedByUserID); err == nil && u != nil {
info.SharedByUsername = u.Username
}
}
item := &types.OrganizationSharedAgentItem{
SharedAgentInfo: *info,
IsMine: share.SourceTenantID == currentTenantID,
}
result = append(result, item)
}
// Set DisabledByMe for entries shared by others (mine entries stay false)
disabledList, err := s.disabledRepo.ListByTenantID(ctx, currentTenantID)
if err == nil {
disabledSet := make(map[string]bool)
for _, d := range disabledList {
disabledSet[fmt.Sprintf("%s_%d", d.AgentID, d.SourceTenantID)] = true
}
for _, item := range result {
if item.Agent != nil && !item.IsMine {
key := fmt.Sprintf("%s_%d", item.Agent.ID, item.SourceTenantID)
item.DisabledByMe = disabledSet[key]
}
}
}
return result, nil
}
// ListSharedAgentsInOrganizations returns per-org agent lists (batch); only orgs where user is member.
func (s *agentShareService) ListSharedAgentsInOrganizations(ctx context.Context, orgIDs []string, userID string, currentTenantID uint64) (map[string][]*types.OrganizationSharedAgentItem, error) {
out := make(map[string][]*types.OrganizationSharedAgentItem)
if len(orgIDs) == 0 {
return out, nil
}
members, err := s.orgRepo.ListMembersByUserForOrgs(ctx, userID, orgIDs)
if err != nil {
return nil, err
}
shares, err := s.shareRepo.ListByOrganizations(ctx, orgIDs)
if err != nil {
return nil, err
}
byOrg := make(map[string][]*types.AgentShare)
for _, share := range shares {
if share != nil && members[share.OrganizationID] != nil {
byOrg[share.OrganizationID] = append(byOrg[share.OrganizationID], share)
}
}
disabledSet := make(map[string]bool)
if disabledList, err := s.disabledRepo.ListByTenantID(ctx, currentTenantID); err == nil {
for _, d := range disabledList {
disabledSet[fmt.Sprintf("%s_%d", d.AgentID, d.SourceTenantID)] = true
}
}
for orgID, list := range byOrg {
member := members[orgID]
result := make([]*types.OrganizationSharedAgentItem, 0, len(list))
for _, share := range list {
if share.Agent == nil {
continue
}
effectivePermission := share.Permission
if !member.Role.HasPermission(share.Permission) {
effectivePermission = member.Role
}
orgName := ""
if share.Organization != nil {
orgName = share.Organization.Name
}
info := &types.SharedAgentInfo{
Agent: share.Agent,
ShareID: share.ID,
OrganizationID: share.OrganizationID,
OrgName: orgName,
Permission: effectivePermission,
SourceTenantID: share.SourceTenantID,
SharedAt: share.CreatedAt,
SharedByUserID: share.SharedByUserID,
}
if share.SharedByUserID != "" {
if u, err := s.userRepo.GetUserByID(ctx, share.SharedByUserID); err == nil && u != nil {
info.SharedByUsername = u.Username
}
}
item := &types.OrganizationSharedAgentItem{
SharedAgentInfo: *info,
IsMine: share.SourceTenantID == currentTenantID,
}
if item.Agent != nil && !item.IsMine {
item.DisabledByMe = disabledSet[fmt.Sprintf("%s_%d", item.Agent.ID, item.SourceTenantID)]
}
result = append(result, item)
}
out[orgID] = result
}
return out, nil
}
// CountByOrganizations returns share counts per organization (for list sidebar); excludes deleted agents
func (s *agentShareService) CountByOrganizations(ctx context.Context, orgIDs []string) (map[string]int64, error) {
return s.shareRepo.CountByOrganizations(ctx, orgIDs)
}
// SetSharedAgentDisabledByMe adds or removes (tenantID, agentID, sourceTenantID) from tenant_disabled_shared_agents.
func (s *agentShareService) SetSharedAgentDisabledByMe(ctx context.Context, tenantID uint64, agentID string, sourceTenantID uint64, disabled bool) error {
if disabled {
return s.disabledRepo.Add(ctx, tenantID, agentID, sourceTenantID)
}
return s.disabledRepo.Remove(ctx, tenantID, agentID, sourceTenantID)
}
// GetSharedAgentForUser returns the shared agent by agentID if the user has access; source tenant is resolved from the user's share. One share lookup + one agent lookup.
func (s *agentShareService) GetSharedAgentForUser(ctx context.Context, userID string, currentTenantID uint64, agentID string) (*types.CustomAgent, error) {
if agentID == "" {
return nil, ErrAgentShareNotFound
}
share, err := s.shareRepo.GetShareByAgentIDForUser(ctx, userID, agentID, currentTenantID)
if err != nil {
if errors.Is(err, repository.ErrAgentShareNotFound) {
return nil, ErrAgentSharePermission
}
return nil, err
}
agent, err := s.agentRepo.GetAgentByID(ctx, agentID, share.SourceTenantID)
if err != nil {
if errors.Is(err, repository.ErrCustomAgentNotFound) {
return nil, ErrAgentNotFoundForShare
}
return nil, err
}
return agent, nil
}
// UserCanAccessKBViaSomeSharedAgent returns true if the user has at least one shared agent that can access the given KB (used when opening KB detail from space list without agent_id).
func (s *agentShareService) UserCanAccessKBViaSomeSharedAgent(ctx context.Context, userID string, currentTenantID uint64, kb *types.KnowledgeBase) (bool, error) {
if kb == nil || kb.ID == "" {
return false, nil
}
list, err := s.ListSharedAgents(ctx, userID, currentTenantID)
if err != nil || len(list) == 0 {
return false, err
}
for _, info := range list {
if info.Agent == nil {
continue
}
agent := info.Agent
if agent.TenantID != kb.TenantID {
continue
}
mode := agent.Config.KBSelectionMode
if mode == "none" {
continue
}
if mode == "all" {
return true, nil
}
if mode == "selected" {
for _, id := range agent.Config.KnowledgeBases {
if id == kb.ID {
return true, nil
}
}
}
}
return false, nil
}
// GetShare gets an agent share by ID
func (s *agentShareService) GetShare(ctx context.Context, shareID string) (*types.AgentShare, error) {
share, err := s.shareRepo.GetByID(ctx, shareID)
if err != nil {
if errors.Is(err, repository.ErrAgentShareNotFound) {
return nil, ErrAgentShareNotFound
}
return nil, err
}
return share, nil
}
// GetShareByAgentAndOrg gets an agent share by agent ID and organization ID
func (s *agentShareService) GetShareByAgentAndOrg(ctx context.Context, agentID string, orgID string) (*types.AgentShare, error) {
share, err := s.shareRepo.GetByAgentAndOrg(ctx, agentID, orgID)
if err != nil {
if errors.Is(err, repository.ErrAgentShareNotFound) {
return nil, ErrAgentShareNotFound
}
return nil, err
}
return share, nil
}
// GetShareByAgentIDForUser returns one share for the given agentID that the user can access (user in org), excluding source_tenant_id == excludeTenantID.
func (s *agentShareService) GetShareByAgentIDForUser(ctx context.Context, userID, agentID string, excludeTenantID uint64) (*types.AgentShare, error) {
return s.shareRepo.GetShareByAgentIDForUser(ctx, userID, agentID, excludeTenantID)
}