WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,502 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/Tencent/WeKnora/internal/accesspolicy"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/agent/skills"
|
||||
"github.com/Tencent/WeKnora/internal/agent/token"
|
||||
"github.com/Tencent/WeKnora/internal/application/repository"
|
||||
apperrors "github.com/Tencent/WeKnora/internal/errors"
|
||||
"github.com/Tencent/WeKnora/internal/im"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const MaxInstructionSkillBytes = 256 << 10
|
||||
const maxInstructionSkillTokens = 16000
|
||||
const maxInstructionSkillBindings = 32
|
||||
|
||||
// InstructionSkillService reuses the native catalog for non-executable rules.
|
||||
// Markdown imports are private to their creator; administrators may inspect
|
||||
// them. Native executable skills keep their existing workspace sharing policy.
|
||||
type InstructionSkillService struct {
|
||||
db *gorm.DB
|
||||
agents interfaces.CustomAgentService
|
||||
}
|
||||
|
||||
func NewInstructionSkillService(db *gorm.DB, agents interfaces.CustomAgentService) *InstructionSkillService {
|
||||
return &InstructionSkillService{db: db, agents: agents}
|
||||
}
|
||||
|
||||
type InstructionSkillView struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Version string `json:"version"`
|
||||
Instructions string `json:"instructions,omitempty"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
VersionSourceID string `json:"version_source_id,omitempty"`
|
||||
CreatorName string `json:"creator_name,omitempty"`
|
||||
InstructionOnly bool `json:"instruction_only"`
|
||||
Visibility string `json:"visibility"`
|
||||
Enabled bool `json:"enabled"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func instructionSkillView(ctx context.Context, row types.TenantSkillCatalogEntity, body bool) InstructionSkillView {
|
||||
row.EnsureInstructionDefaults()
|
||||
v := InstructionSkillView{ID: row.ID, Name: row.Name, Description: row.Description, Version: row.Version, CreatedBy: row.CreatedBy, InstructionOnly: true, UpdatedAt: row.UpdatedAt}
|
||||
v.Visibility, v.Enabled = row.Visibility, row.Enabled
|
||||
if snapshot := accesspolicy.FromContext(ctx); snapshot != nil {
|
||||
if global, managed := snapshot.Publications["skills:"+row.ID]; managed {
|
||||
v.Visibility = "private"
|
||||
if global {
|
||||
v.Visibility = "global"
|
||||
}
|
||||
}
|
||||
}
|
||||
v.VersionSourceID = row.VersionSourceID
|
||||
if body {
|
||||
v.Instructions = row.Instructions
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func instructionIdentity(ctx context.Context, write bool) (uint64, string, bool, error) {
|
||||
tenant, ok := types.TenantIDFromContext(ctx)
|
||||
user, userOK := types.UserIDFromContext(ctx)
|
||||
role := types.TenantRoleFromContext(ctx)
|
||||
floor := types.TenantRoleViewer
|
||||
if write {
|
||||
floor = types.TenantRoleContributor
|
||||
}
|
||||
if !ok || tenant == 0 || !userOK || user == "" || types.IsSyntheticUserID(user) || !role.HasPermission(floor) {
|
||||
return 0, "", false, apperrors.NewForbiddenError("没有技能操作权限")
|
||||
}
|
||||
return tenant, user, assetAdministrator(ctx), nil
|
||||
}
|
||||
|
||||
func (s *InstructionSkillService) List(ctx context.Context) ([]InstructionSkillView, error) {
|
||||
tenant, _, _, err := instructionIdentity(ctx, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rows []types.TenantSkillCatalogEntity
|
||||
q := s.db.WithContext(ctx).Where("tenant_id = ? AND instruction_only = ?", tenant, true)
|
||||
if err = q.Order("updated_at DESC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]InstructionSkillView, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if !instructionSkillReadable(ctx, &row) {
|
||||
continue
|
||||
}
|
||||
out = append(out, instructionSkillView(ctx, row, false))
|
||||
}
|
||||
s.fillCreatorNames(ctx, out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *InstructionSkillService) Get(ctx context.Context, id string) (*InstructionSkillView, error) {
|
||||
tenant, _, _, err := instructionIdentity(ctx, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var row types.TenantSkillCatalogEntity
|
||||
q := s.db.WithContext(ctx).Where("id = ? AND tenant_id = ? AND instruction_only = ?", id, tenant, true)
|
||||
if err = q.First(&row).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, apperrors.NewNotFoundError("技能不存在或无权访问")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !instructionSkillReadable(ctx, &row) {
|
||||
return nil, apperrors.NewNotFoundError("技能不存在或无权访问")
|
||||
}
|
||||
v := instructionSkillView(ctx, row, true)
|
||||
views := []InstructionSkillView{v}
|
||||
s.fillCreatorNames(ctx, views)
|
||||
v = views[0]
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
func (s *InstructionSkillService) fillCreatorNames(ctx context.Context, views []InstructionSkillView) {
|
||||
ids := make([]string, 0, len(views))
|
||||
for _, view := range views {
|
||||
if view.CreatedBy != "" {
|
||||
ids = append(ids, view.CreatedBy)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
var users []struct {
|
||||
ID string
|
||||
Username string
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Model(&types.User{}).Select("id, username").Where("id IN ?", ids).Find(&users).Error; err != nil {
|
||||
return
|
||||
}
|
||||
names := map[string]string{}
|
||||
for _, user := range users {
|
||||
names[user.ID] = user.Username
|
||||
}
|
||||
for i := range views {
|
||||
views[i].CreatorName = names[views[i].CreatedBy]
|
||||
}
|
||||
}
|
||||
|
||||
func parseInstructionMarkdown(filename, name, description string, content []byte) (*types.TenantSkillCatalogEntity, error) {
|
||||
if !strings.EqualFold(filepath.Ext(filename), ".md") || len(content) == 0 || len(content) > MaxInstructionSkillBytes || !utf8.Valid(content) || strings.ContainsRune(string(content), 0) {
|
||||
return nil, apperrors.NewBadRequestError("请上传不超过 256 KB 的 UTF-8 Markdown(.md)文件")
|
||||
}
|
||||
text := strings.TrimSpace(strings.TrimPrefix(string(content), "\ufeff"))
|
||||
body, version := text, ""
|
||||
if strings.HasPrefix(text, "---\n") || strings.HasPrefix(text, "---\r\n") {
|
||||
parsed, err := skills.ParseSkillFile(text)
|
||||
if err != nil {
|
||||
return nil, apperrors.NewBadRequestError("技能 Markdown 的头部格式无效:" + err.Error())
|
||||
}
|
||||
body = parsed.Instructions
|
||||
if strings.TrimSpace(name) == "" {
|
||||
name = parsed.Name
|
||||
}
|
||||
if strings.TrimSpace(description) == "" {
|
||||
description = parsed.Description
|
||||
}
|
||||
version, err = parseSkillBundleVersion(text)
|
||||
if err != nil {
|
||||
return nil, apperrors.NewBadRequestError("技能版本格式无效")
|
||||
}
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
name = strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename))
|
||||
}
|
||||
if name == "" || len([]rune(name)) > 100 || strings.IndexFunc(name, unicode.IsControl) >= 0 || strings.ContainsAny(name, `/\`) {
|
||||
return nil, apperrors.NewBadRequestError("技能名称必填、最多 100 字且不能包含路径或控制字符")
|
||||
}
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
return nil, apperrors.NewBadRequestError("技能正文不能为空")
|
||||
}
|
||||
if len([]rune(description)) > 1000 || len(version) > 64 {
|
||||
return nil, apperrors.NewBadRequestError("技能说明或版本过长")
|
||||
}
|
||||
estimator, err := token.NewEstimator()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if estimator.EstimateString(body) > maxInstructionSkillTokens {
|
||||
return nil, apperrors.NewBadRequestError("技能正文过长,请控制在约 16000 Token 以内")
|
||||
}
|
||||
return &types.TenantSkillCatalogEntity{Name: name, Description: strings.TrimSpace(description), Version: version, Instructions: body, InstructionOnly: true, BundleSHA256: skillArchiveSHA256(content)}, nil
|
||||
}
|
||||
|
||||
func (s *InstructionSkillService) Import(ctx context.Context, filename, name, description string, content []byte) (*InstructionSkillView, error) {
|
||||
tenant, user, _, err := instructionIdentity(ctx, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row, err := parseInstructionMarkdown(filename, name, description, content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row.ID, row.TenantID, row.CreatedBy = uuid.NewString(), tenant, user
|
||||
row.EnsureInstructionDefaults()
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var existing types.TenantSkillCatalogEntity
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("tenant_id = ? AND created_by = ? AND name = ? AND instruction_only = ? AND version_source_id = ''", tenant, user, row.Name, true).First(&existing).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return tx.Create(row).Error
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
row.ID, row.CreatedBy, row.CreatedAt = existing.ID, existing.CreatedBy, existing.CreatedAt
|
||||
existing.EnsureInstructionDefaults()
|
||||
row.Visibility, row.Enabled = existing.Visibility, existing.Enabled
|
||||
row.UpdatedAt = time.Now()
|
||||
return tx.Model(&existing).Updates(map[string]any{"description": row.Description, "version": row.Version, "instructions": row.Instructions, "bundle_sha256": row.BundleSHA256, "updated_at": row.UpdatedAt}).Error
|
||||
})
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return nil, apperrors.NewConflictError("同名技能已被创建,请刷新列表后重试")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
v := instructionSkillView(ctx, *row, true)
|
||||
views := []InstructionSkillView{v}
|
||||
s.fillCreatorNames(ctx, views)
|
||||
v = views[0]
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// Delete locks the definition before its references, matching Mount's lock
|
||||
// order. Detachment and deletion commit together; denied or failed deletions
|
||||
// leave every Agent configuration intact.
|
||||
func (s *InstructionSkillService) Delete(ctx context.Context, id string, detach bool) error {
|
||||
tenant, user, admin, err := instructionIdentity(ctx, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var skill types.TenantSkillCatalogEntity
|
||||
q := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND tenant_id = ? AND instruction_only = ?", id, tenant, true)
|
||||
if !admin {
|
||||
q = q.Where("created_by = ?", user)
|
||||
}
|
||||
if err := q.First(&skill).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return apperrors.NewNotFoundError("技能不存在或无权删除")
|
||||
}
|
||||
return err
|
||||
}
|
||||
// Read/lock tenant Agent rows in stable order. The skill lock prevents
|
||||
// new mount requests from racing this reference scan.
|
||||
var agents []types.CustomAgent
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("tenant_id = ?", tenant).Order("id").Find(&agents).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
ids := []string{}
|
||||
for _, a := range agents {
|
||||
if slices.Contains(a.Config.InstructionSkillIDs, id) {
|
||||
ids = append(ids, a.ID)
|
||||
}
|
||||
}
|
||||
if len(ids) > 0 && !detach {
|
||||
return apperrors.NewConflictError("技能已挂载到 Agent,可一键解绑全部关联后删除").WithDetails(map[string]any{"agent_ids": ids, "can_detach": true})
|
||||
}
|
||||
for _, a := range agents {
|
||||
if !slices.Contains(a.Config.InstructionSkillIDs, id) {
|
||||
continue
|
||||
}
|
||||
a.Config.InstructionSkillIDs = slices.DeleteFunc(slices.Clone(a.Config.InstructionSkillIDs), func(selected string) bool { return selected == id })
|
||||
if err := tx.Model(&a).Updates(map[string]any{"config": a.Config, "updated_at": time.Now()}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Delete(&skill).Error
|
||||
})
|
||||
}
|
||||
|
||||
type InstructionSkillMountRequest struct {
|
||||
AgentIDs []string `json:"agent_ids"`
|
||||
SkillIDs []string `json:"skill_ids"`
|
||||
Mode string `json:"mode"` // add (default), remove, replace
|
||||
}
|
||||
type InstructionSkillMountResult struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
SkillIDs []string `json:"instruction_skill_ids"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (s *InstructionSkillService) Mount(ctx context.Context, req InstructionSkillMountRequest) ([]InstructionSkillMountResult, error) {
|
||||
tenant, user, admin, err := instructionIdentity(ctx, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
agentIDs, skillIDs := dedupPreservingOrder(req.AgentIDs), dedupPreservingOrder(req.SkillIDs)
|
||||
if len(agentIDs) == 0 || len(agentIDs) > 100 || len(skillIDs) > maxInstructionSkillBindings {
|
||||
return nil, apperrors.NewBadRequestError("请选择 1–100 个 Agent,每个 Agent 最多挂载 32 个技能")
|
||||
}
|
||||
if req.Mode == "" {
|
||||
req.Mode = "add"
|
||||
}
|
||||
if req.Mode != "add" && req.Mode != "remove" && req.Mode != "replace" {
|
||||
return nil, apperrors.NewBadRequestError("挂载方式应为 add、remove 或 replace")
|
||||
}
|
||||
if req.Mode != "replace" && len(skillIDs) == 0 {
|
||||
return nil, apperrors.NewBadRequestError("请选择技能")
|
||||
}
|
||||
// Use the native service's visibility guard (including private digital-human
|
||||
// Agents) before acquiring row locks. The write ownership is rechecked below.
|
||||
for _, id := range agentIDs {
|
||||
a, err := s.agents.GetAgentByID(ctx, id)
|
||||
if err != nil || a == nil || a.TenantID != tenant || (!admin && a.CreatedBy != user) {
|
||||
return nil, apperrors.NewForbiddenError("所选 Agent 不存在或没有修改权限")
|
||||
}
|
||||
}
|
||||
slices.Sort(agentIDs)
|
||||
out := make([]InstructionSkillMountResult, 0, len(agentIDs))
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if req.Mode != "remove" {
|
||||
var rows []types.TenantSkillCatalogEntity
|
||||
q := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where("tenant_id = ? AND id IN ? AND instruction_only = ?", tenant, skillIDs, true)
|
||||
if err := q.Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(rows) != len(skillIDs) {
|
||||
return apperrors.NewNotFoundError("部分技能不存在或不属于当前工作空间")
|
||||
}
|
||||
for _, row := range rows {
|
||||
row.EnsureInstructionDefaults()
|
||||
if !row.Enabled || !instructionSkillReadable(ctx, &row) {
|
||||
return apperrors.NewForbiddenError("所选文字技能已停用或无权使用")
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, id := range agentIDs {
|
||||
// Native built-ins are virtual until first customized. Materialize
|
||||
// the tenant's default inside this transaction so bulk mounts stay
|
||||
// atomic and do not fail merely because no row existed yet.
|
||||
if types.IsBuiltinAgentID(id) {
|
||||
if !admin {
|
||||
return apperrors.NewForbiddenError("只有管理员可以修改内置 Agent")
|
||||
}
|
||||
builtin := types.GetBuiltinAgent(id, tenant)
|
||||
if builtin == nil {
|
||||
return apperrors.NewNotFoundError("内置 Agent 不存在")
|
||||
}
|
||||
builtin.EnsureDefaults()
|
||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(builtin).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var agent types.CustomAgent
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("tenant_id = ? AND id = ?", tenant, id).First(&agent).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if !admin && agent.CreatedBy != user {
|
||||
return apperrors.NewForbiddenError("只能为自己创建的 Agent 挂载技能")
|
||||
}
|
||||
selected := slices.Clone(agent.Config.InstructionSkillIDs)
|
||||
switch req.Mode {
|
||||
case "add":
|
||||
selected = dedupPreservingOrder(append(selected, skillIDs...))
|
||||
case "replace":
|
||||
selected = slices.Clone(skillIDs)
|
||||
case "remove":
|
||||
selected = slices.DeleteFunc(selected, func(id string) bool { return slices.Contains(skillIDs, id) })
|
||||
}
|
||||
if len(selected) > maxInstructionSkillBindings {
|
||||
return apperrors.NewBadRequestError("每个 Agent 最多挂载 32 个文字技能")
|
||||
}
|
||||
if selected == nil {
|
||||
selected = []string{}
|
||||
}
|
||||
agent.Config.InstructionSkillIDs = selected
|
||||
// Validate the binding while allowing owners to repair a disabled
|
||||
// Agent. Execution still checks its actual persisted enabled state.
|
||||
validationAgent := agent
|
||||
validationAgent.EnsureDefaults()
|
||||
validationAgent.Enabled = true
|
||||
// Removing bindings must also work when another remaining skill has
|
||||
// been revoked or deleted. Execution still validates every dependency.
|
||||
if req.Mode != "remove" {
|
||||
if _, err := ResolveAgentInstructionPrompt(ctx, repository.NewTenantSkillRepository(tx), &validationAgent); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
if err := tx.Model(&agent).Updates(map[string]any{"config": agent.Config, "updated_at": now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, InstructionSkillMountResult{AgentID: id, SkillIDs: selected, UpdatedAt: now})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type instructionCatalogReader interface {
|
||||
GetCatalog(context.Context, uint64, string) (*types.TenantSkillCatalogEntity, error)
|
||||
}
|
||||
|
||||
// ResolveAgentInstructionPrompt is shared by native QA and other generation
|
||||
// surfaces. Call it after the native Agent access check; returned text belongs
|
||||
// in the system context and never grants executable tools or sandbox access.
|
||||
func ResolveAgentInstructionPrompt(ctx context.Context, repo instructionCatalogReader, agent *types.CustomAgent) (string, error) {
|
||||
if agent == nil {
|
||||
return "", nil
|
||||
}
|
||||
agent.EnsureDefaults()
|
||||
if !agent.Enabled {
|
||||
return "", apperrors.NewForbiddenError("Agent 已停用或无权使用")
|
||||
}
|
||||
if strings.Contains(agent.Config.SystemPrompt, "<!-- weknora:digital-human:") {
|
||||
guard, ok := repo.(interface {
|
||||
CheckDigitalHumanAgentExecution(context.Context, *types.CustomAgent) error
|
||||
})
|
||||
if !ok {
|
||||
return "", apperrors.NewForbiddenError("数字人授权校验不可用,请稍后重试")
|
||||
}
|
||||
if err := guard.CheckDigitalHumanAgentExecution(ctx, agent); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
// Existing organization shares already authorize the Agent itself upstream.
|
||||
// Without instruction skills, preserve that native execution path.
|
||||
if len(agent.Config.InstructionSkillIDs) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
tenant, ok := types.TenantIDFromContext(ctx)
|
||||
if !ok || tenant == 0 || agent.TenantID != tenant {
|
||||
return "", apperrors.NewForbiddenError("文字技能不能跨工作空间读取")
|
||||
}
|
||||
user, userOK := types.UserIDFromContext(ctx)
|
||||
verifiedIM := im.IsVerifiedAgentExecution(ctx, agent)
|
||||
if !verifiedIM && !nativeAgentReadable(ctx, agent) && !nativeAgentExecutableDependency(ctx, agent) {
|
||||
return "", apperrors.NewForbiddenError("Agent 已停用或无权使用")
|
||||
}
|
||||
if !verifiedIM && (!userOK || user == "" || types.IsSyntheticUserID(user)) {
|
||||
return "", apperrors.NewForbiddenError("没有此 Agent 私有文字技能的使用权限")
|
||||
}
|
||||
ids := dedupPreservingOrder(agent.Config.InstructionSkillIDs)
|
||||
if repo == nil || len(ids) > maxInstructionSkillBindings {
|
||||
return "", apperrors.NewBadRequestError("Agent 的文字技能配置不可用")
|
||||
}
|
||||
var result strings.Builder
|
||||
result.WriteString("\n\n## 已挂载的文字技能\n按以下规则执行本次任务;这些规则不授予额外工具、脚本或数据访问权限。\n")
|
||||
for _, id := range ids {
|
||||
row, err := repo.GetCatalog(ctx, tenant, id)
|
||||
if err != nil {
|
||||
return "", apperrors.NewForbiddenError("已挂载的文字技能不可用,请重新选择技能")
|
||||
}
|
||||
if row == nil || !row.InstructionOnly || row.TenantID != tenant {
|
||||
return "", apperrors.NewBadRequestError("已挂载的文字技能已删除或无权使用,请重新选择技能")
|
||||
}
|
||||
row.EnsureInstructionDefaults()
|
||||
if !row.Enabled || (row.Visibility != "global" && row.Visibility != "private") {
|
||||
return "", apperrors.NewForbiddenError("已挂载的文字技能已停用或无权使用")
|
||||
}
|
||||
// Sharing an Agent permits executing its own private rules without
|
||||
// exposing the rules as independently readable or mountable resources.
|
||||
ownedDependency := row.CreatedBy != "" && row.CreatedBy == agent.CreatedBy
|
||||
if !verifiedIM && !ownedDependency && !instructionSkillReadable(ctx, row) {
|
||||
return "", apperrors.NewForbiddenError("已挂载私有文字技能的授权已撤销,请重新选择技能")
|
||||
}
|
||||
shared := accesspolicy.Readable(ctx, "skills", row.ID, false, false)
|
||||
if !shared && row.Visibility == "private" && !ownedDependency {
|
||||
return "", apperrors.NewForbiddenError("私有文字技能只能挂载到同一创建者的 Agent")
|
||||
}
|
||||
fmt.Fprintf(&result, "\n### %s\n%s\n", row.Name, row.Instructions)
|
||||
}
|
||||
estimator, err := token.NewEstimator()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if estimator.EstimateString(result.String()) > maxInstructionSkillTokens {
|
||||
return "", apperrors.NewBadRequestError("挂载技能的合计正文过长,请减少技能或缩短规则,系统不会自动截断技能")
|
||||
}
|
||||
return result.String(), nil
|
||||
}
|
||||
|
||||
func (s *InstructionSkillService) ResolveAgentInstructions(ctx context.Context, agent *types.CustomAgent) (string, error) {
|
||||
return ResolveAgentInstructionPrompt(ctx, repository.NewTenantSkillRepository(s.db), agent)
|
||||
}
|
||||
Reference in New Issue
Block a user