You've already forked agentic-coding-workflow
361 lines
11 KiB
Go
361 lines
11 KiB
Go
package acp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
|
"github.com/yan1h/agent-coding-workflow/internal/infra/crypto"
|
|
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
|
)
|
|
|
|
type agentKindService struct {
|
|
repo Repository
|
|
enc *crypto.Encryptor
|
|
audit audit.Recorder
|
|
modelCheck ModelValidator // 校验 model_id 引用一个启用的 llm_model;可为 nil(跳过校验)
|
|
}
|
|
|
|
// ModelValidator 校验某 model_id 引用一个存在且启用的 llm_model。装配期由 app.go
|
|
// 注入一个委托给 chat 的 adapter,避免 acp 构造期直接依赖 chat。
|
|
type ModelValidator interface {
|
|
IsEnabledModel(ctx context.Context, modelID uuid.UUID) (bool, error)
|
|
}
|
|
|
|
// NewAgentKindService 构造 AgentKindService。
|
|
func NewAgentKindService(repo Repository, enc *crypto.Encryptor, rec audit.Recorder) AgentKindService {
|
|
return &agentKindService{repo: repo, enc: enc, audit: rec}
|
|
}
|
|
|
|
// SetModelValidator 注入 model 校验器(装配期回填)。
|
|
func (s *agentKindService) SetModelValidator(v ModelValidator) { s.modelCheck = v }
|
|
|
|
func (s *agentKindService) Create(ctx context.Context, c Caller, in CreateAgentKindInput) (*AgentKind, error) {
|
|
if !c.IsAdmin {
|
|
return nil, errs.New(errs.CodeForbidden, "admin only")
|
|
}
|
|
if in.Name == "" || in.DisplayName == "" || in.BinaryPath == "" {
|
|
return nil, errs.New(errs.CodeInvalidInput, "name / display_name / binary_path required")
|
|
}
|
|
clientType := in.ClientType
|
|
if clientType == "" {
|
|
clientType = ClientGeneric
|
|
}
|
|
if !clientType.Valid() {
|
|
return nil, errs.New(errs.CodeInvalidInput, "invalid client_type")
|
|
}
|
|
encrypted, err := encryptEnv(s.enc, in.Env)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
encryptedMCP, err := encryptMCPServers(s.enc, in.MCPServers)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
args := in.Args
|
|
if args == nil {
|
|
args = []string{}
|
|
}
|
|
allowlist := in.ToolAllowlist
|
|
if allowlist == nil {
|
|
allowlist = []string{}
|
|
}
|
|
if err := s.validateBudget(in.MaxCostUSD, in.MaxTokens, in.MaxWallClockSeconds); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.validateModel(ctx, in.ModelID); err != nil {
|
|
return nil, err
|
|
}
|
|
k := &AgentKind{
|
|
ID: uuid.New(), Name: in.Name, DisplayName: in.DisplayName, Description: in.Description,
|
|
BinaryPath: in.BinaryPath, Args: args, EncryptedEnv: encrypted, Enabled: in.Enabled,
|
|
ToolAllowlist: allowlist,
|
|
ClientType: clientType,
|
|
EncryptedMCPServers: encryptedMCP,
|
|
CreatedBy: c.UserID,
|
|
ModelID: in.ModelID,
|
|
MaxCostUSD: in.MaxCostUSD,
|
|
MaxTokens: in.MaxTokens,
|
|
MaxWallClockSeconds: in.MaxWallClockSeconds,
|
|
}
|
|
out, err := s.repo.CreateAgentKind(ctx, k)
|
|
if err != nil {
|
|
if IsUniqueViolation(err) {
|
|
return nil, errs.New(errs.CodeConflict, "agent kind name already exists")
|
|
}
|
|
return nil, err
|
|
}
|
|
s.recordAudit(ctx, c, "acp.agent_kind.create", out.ID.String(),
|
|
map[string]any{"name": out.Name, "binary_path": out.BinaryPath})
|
|
return out, nil
|
|
}
|
|
|
|
func (s *agentKindService) List(ctx context.Context, c Caller) ([]*AgentKind, error) {
|
|
if c.IsAdmin {
|
|
return s.repo.ListAgentKinds(ctx)
|
|
}
|
|
return s.repo.ListEnabledAgentKinds(ctx)
|
|
}
|
|
|
|
func (s *agentKindService) Get(ctx context.Context, c Caller, id uuid.UUID) (*AgentKind, error) {
|
|
k, err := s.repo.GetAgentKindByID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !c.IsAdmin && !k.Enabled {
|
|
return nil, errs.New(errs.CodeAcpAgentKindNotFound, "agent kind not found")
|
|
}
|
|
return k, nil
|
|
}
|
|
|
|
func (s *agentKindService) Update(ctx context.Context, c Caller, id uuid.UUID, in UpdateAgentKindInput) (*AgentKind, error) {
|
|
if !c.IsAdmin {
|
|
return nil, errs.New(errs.CodeForbidden, "admin only")
|
|
}
|
|
cur, err := s.repo.GetAgentKindByID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
changed := map[string]any{}
|
|
if in.DisplayName != nil && *in.DisplayName != cur.DisplayName {
|
|
cur.DisplayName = *in.DisplayName
|
|
changed["display_name"] = "<changed>"
|
|
}
|
|
if in.Description != nil && *in.Description != cur.Description {
|
|
cur.Description = *in.Description
|
|
changed["description"] = "<changed>"
|
|
}
|
|
if in.BinaryPath != nil && *in.BinaryPath != cur.BinaryPath {
|
|
cur.BinaryPath = *in.BinaryPath
|
|
changed["binary_path"] = "<changed>"
|
|
}
|
|
if in.Args != nil {
|
|
cur.Args = in.Args
|
|
changed["args"] = "<changed>"
|
|
}
|
|
if in.Env != nil {
|
|
encrypted, eerr := encryptEnv(s.enc, in.Env)
|
|
if eerr != nil {
|
|
return nil, eerr
|
|
}
|
|
cur.EncryptedEnv = encrypted
|
|
changed["env"] = "<changed>"
|
|
}
|
|
if in.ToolAllowlist != nil {
|
|
cur.ToolAllowlist = in.ToolAllowlist
|
|
changed["tool_allowlist"] = "<changed>"
|
|
}
|
|
if in.ClientType != nil && *in.ClientType != cur.ClientType {
|
|
if !in.ClientType.Valid() {
|
|
return nil, errs.New(errs.CodeInvalidInput, "invalid client_type")
|
|
}
|
|
cur.ClientType = *in.ClientType
|
|
changed["client_type"] = string(*in.ClientType)
|
|
}
|
|
if in.MCPServers != nil {
|
|
encryptedMCP, eerr := encryptMCPServers(s.enc, in.MCPServers)
|
|
if eerr != nil {
|
|
return nil, eerr
|
|
}
|
|
cur.EncryptedMCPServers = encryptedMCP
|
|
changed["mcp_servers"] = "<changed>"
|
|
}
|
|
if in.Enabled != nil && *in.Enabled != cur.Enabled {
|
|
cur.Enabled = *in.Enabled
|
|
changed["enabled"] = *in.Enabled
|
|
}
|
|
if in.SetModelID {
|
|
if err := s.validateModel(ctx, in.ModelID); err != nil {
|
|
return nil, err
|
|
}
|
|
cur.ModelID = in.ModelID
|
|
changed["model_id"] = "<changed>"
|
|
}
|
|
if in.SetMaxCostUSD {
|
|
if err := s.validateBudget(in.MaxCostUSD, nil, nil); err != nil {
|
|
return nil, err
|
|
}
|
|
cur.MaxCostUSD = in.MaxCostUSD
|
|
changed["max_cost_usd"] = "<changed>"
|
|
}
|
|
if in.SetMaxTokens {
|
|
if err := s.validateBudget(nil, in.MaxTokens, nil); err != nil {
|
|
return nil, err
|
|
}
|
|
cur.MaxTokens = in.MaxTokens
|
|
changed["max_tokens"] = "<changed>"
|
|
}
|
|
if in.SetMaxWallClock {
|
|
if err := s.validateBudget(nil, nil, in.MaxWallClockSeconds); err != nil {
|
|
return nil, err
|
|
}
|
|
cur.MaxWallClockSeconds = in.MaxWallClockSeconds
|
|
changed["max_wall_clock_seconds"] = "<changed>"
|
|
}
|
|
if len(changed) == 0 {
|
|
return cur, nil
|
|
}
|
|
out, err := s.repo.UpdateAgentKind(ctx, cur)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.recordAudit(ctx, c, "acp.agent_kind.update", out.ID.String(),
|
|
map[string]any{"changed_fields": changed})
|
|
return out, nil
|
|
}
|
|
|
|
func (s *agentKindService) Delete(ctx context.Context, c Caller, id uuid.UUID) error {
|
|
if !c.IsAdmin {
|
|
return errs.New(errs.CodeForbidden, "admin only")
|
|
}
|
|
cur, err := s.repo.GetAgentKindByID(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.repo.DeleteAgentKind(ctx, id); err != nil {
|
|
return err
|
|
}
|
|
s.recordAudit(ctx, c, "acp.agent_kind.delete", id.String(),
|
|
map[string]any{"name": cur.Name})
|
|
return nil
|
|
}
|
|
|
|
// validateBudget 校验预算上限:非空值必须为正。
|
|
func (s *agentKindService) validateBudget(maxCost *float64, maxTokens *int64, maxWall *int32) error {
|
|
if maxCost != nil && *maxCost <= 0 {
|
|
return errs.New(errs.CodeInvalidInput, "max_cost_usd must be positive")
|
|
}
|
|
if maxTokens != nil && *maxTokens <= 0 {
|
|
return errs.New(errs.CodeInvalidInput, "max_tokens must be positive")
|
|
}
|
|
if maxWall != nil && *maxWall <= 0 {
|
|
return errs.New(errs.CodeInvalidInput, "max_wall_clock_seconds must be positive")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateModel 校验 model_id 引用一个启用的 llm_model(modelCheck 为 nil 时跳过)。
|
|
func (s *agentKindService) validateModel(ctx context.Context, modelID *uuid.UUID) error {
|
|
if modelID == nil || s.modelCheck == nil {
|
|
return nil
|
|
}
|
|
ok, err := s.modelCheck.IsEnabledModel(ctx, *modelID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return errs.New(errs.CodeInvalidInput, "model_id does not reference an enabled model")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *agentKindService) recordAudit(ctx context.Context, c Caller, action, targetID string, meta map[string]any) {
|
|
if s.audit == nil {
|
|
return
|
|
}
|
|
uid := c.UserID
|
|
_ = s.audit.Record(context.WithoutCancel(ctx), audit.Entry{
|
|
UserID: &uid, Action: action, TargetType: "acp_agent_kind", TargetID: targetID, Metadata: meta,
|
|
})
|
|
}
|
|
|
|
// encryptEnv 把 map[string]string 序列化后加密。nil → 返回 nil(清空)。
|
|
func encryptEnv(enc *crypto.Encryptor, env map[string]string) ([]byte, error) {
|
|
if env == nil {
|
|
return nil, nil
|
|
}
|
|
if len(env) == 0 {
|
|
return enc.Encrypt([]byte(`{}`))
|
|
}
|
|
b, err := json.Marshal(env)
|
|
if err != nil {
|
|
return nil, errs.Wrap(err, errs.CodeInternal, "marshal env")
|
|
}
|
|
return enc.Encrypt(b)
|
|
}
|
|
|
|
// encryptMCPServers 校验并加密 MCP server 列表。nil → nil(不存储)。
|
|
func encryptMCPServers(enc *crypto.Encryptor, servers []McpServerSpec) ([]byte, error) {
|
|
if servers == nil {
|
|
return nil, nil
|
|
}
|
|
if err := validateMCPServers(servers); err != nil {
|
|
return nil, err
|
|
}
|
|
b, err := json.Marshal(servers)
|
|
if err != nil {
|
|
return nil, errs.Wrap(err, errs.CodeInternal, "marshal mcp servers")
|
|
}
|
|
return enc.Encrypt(b)
|
|
}
|
|
|
|
// decryptMCPServers 解密 MCP server 列表。空密文 → 空列表。
|
|
func decryptMCPServers(enc *crypto.Encryptor, encrypted []byte) ([]McpServerSpec, error) {
|
|
if len(encrypted) == 0 {
|
|
return []McpServerSpec{}, nil
|
|
}
|
|
plain, err := enc.Decrypt(encrypted)
|
|
if err != nil {
|
|
return nil, errs.Wrap(err, errs.CodeInternal, "decrypt mcp servers")
|
|
}
|
|
var out []McpServerSpec
|
|
if err := json.Unmarshal(plain, &out); err != nil {
|
|
return nil, errs.Wrap(err, errs.CodeInternal, "unmarshal mcp servers")
|
|
}
|
|
if out == nil {
|
|
out = []McpServerSpec{}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// validateMCPServers 校验条目:name 必填且不重复;stdio 需 command,http/sse 需 url。
|
|
func validateMCPServers(servers []McpServerSpec) error {
|
|
seen := map[string]bool{}
|
|
for _, sv := range servers {
|
|
if sv.Name == "" {
|
|
return errs.New(errs.CodeInvalidInput, "mcp server name required")
|
|
}
|
|
if sv.Name == AcwMcpServerName {
|
|
return errs.New(errs.CodeInvalidInput, "mcp server name '"+AcwMcpServerName+"' is reserved")
|
|
}
|
|
if seen[sv.Name] {
|
|
return errs.New(errs.CodeInvalidInput, "duplicate mcp server name: "+sv.Name)
|
|
}
|
|
seen[sv.Name] = true
|
|
switch sv.Type {
|
|
case McpStdio:
|
|
if sv.Command == "" {
|
|
return errs.New(errs.CodeInvalidInput, "mcp server "+sv.Name+": command required for stdio")
|
|
}
|
|
case McpHTTP, McpSSE:
|
|
if sv.URL == "" {
|
|
return errs.New(errs.CodeInvalidInput, "mcp server "+sv.Name+": url required for "+string(sv.Type))
|
|
}
|
|
default:
|
|
return errs.New(errs.CodeInvalidInput, "mcp server "+sv.Name+": invalid type")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// decryptEnv 解密并反序列化 env map。Part 2 的 supervisor.Spawn 用。
|
|
func decryptEnv(enc *crypto.Encryptor, encrypted []byte) (map[string]string, error) {
|
|
if len(encrypted) == 0 {
|
|
return map[string]string{}, nil
|
|
}
|
|
plain, err := enc.Decrypt(encrypted)
|
|
if err != nil {
|
|
return nil, errs.Wrap(err, errs.CodeInternal, "decrypt env")
|
|
}
|
|
var m map[string]string
|
|
if err := json.Unmarshal(plain, &m); err != nil {
|
|
return nil, errs.Wrap(err, errs.CodeInternal, "unmarshal env")
|
|
}
|
|
if m == nil {
|
|
m = map[string]string{}
|
|
}
|
|
return m, nil
|
|
}
|