0.11.4: 旗舰版第五轮完善(智能体节点任务下发/个人智能体安全策略)
- 节点任务:管理端向节点池下发(Prompt/HTTP/MCP/Skill/数字员工/自定义), 指定节点或池路由,认领 SKIP LOCKED + 15 分钟租约,认领令牌防重放上报, 失败 30s×次数退避重入队,达上限 failed,支持取消/重试,完成与失败站内信。 - 个人智能体安全策略:auto_approve_tools 跳过个人调用审批门; rate_limit_multiplier 按 (tool,user) 独立窗口放宽个人限流(全局额度不受影响)。 - 修复存量缺陷:/v1/agent/nodes/ 未挂 publicMux,节点心跳/认领端点在部署 拓扑下不可达。 - 迁移 000046;任务全链路集成测试连真实库通过,HTTP 端到端验证 (下发→认领→伪造令牌拒绝→上报→succeeded,列表不泄露认领令牌); 25 包测试通过,前后端构建通过。
This commit is contained in:
+116
-1
@@ -43,7 +43,13 @@ func NewHTTPHandler(store *Store, identityService *identity.Service) *HTTPHandle
|
||||
h.mux.HandleFunc("PUT /api/v1/admin/agent-nodes/{id}", h.update)
|
||||
h.mux.HandleFunc("DELETE /api/v1/admin/agent-nodes/{id}", h.delete)
|
||||
h.mux.HandleFunc("POST /api/v1/admin/agent-nodes/{id}/rotate-token", h.rotateToken)
|
||||
h.mux.HandleFunc("POST /api/v1/agent/nodes/{code}/heartbeat", h.heartbeat)
|
||||
h.mux.HandleFunc("GET /api/v1/admin/agent-tasks", h.listTasks)
|
||||
h.mux.HandleFunc("POST /api/v1/admin/agent-tasks", h.createTask)
|
||||
h.mux.HandleFunc("POST /api/v1/admin/agent-tasks/{id}/cancel", h.cancelTask)
|
||||
h.mux.HandleFunc("POST /api/v1/admin/agent-tasks/{id}/retry", h.retryTask)
|
||||
h.mux.HandleFunc("POST /v1/agent/nodes/{code}/heartbeat", h.heartbeat)
|
||||
h.mux.HandleFunc("POST /v1/agent/nodes/{code}/tasks/claim", h.claimTask)
|
||||
h.mux.HandleFunc("POST /v1/agent/nodes/{code}/tasks/{id}/complete", h.completeTask)
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -216,3 +222,112 @@ func writeHeartbeatError(w http.ResponseWriter, err error) {
|
||||
apiresponse.Error(w, http.StatusInternalServerError, "节点心跳处理失败")
|
||||
}
|
||||
}
|
||||
|
||||
// --- 节点任务下发/认领/上报 ---
|
||||
|
||||
func (h *HTTPHandler) listTasks(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.require(w, r, identity.PermissionAgentNodeRead); !ok {
|
||||
return
|
||||
}
|
||||
items, err := h.store.ListTasks(r.Context(), r.URL.Query().Get("status"))
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, items)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) createTask(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := h.require(w, r, identity.PermissionAgentNodeManage)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
TaskType string `json:"task_type"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
PoolType string `json:"pool_type"`
|
||||
PoolCode string `json:"pool_code"`
|
||||
NodeID *string `json:"node_id"`
|
||||
MaxAttempts int `json:"max_attempts"`
|
||||
}
|
||||
if !decodeJSON(w, r, &input) {
|
||||
return
|
||||
}
|
||||
item, err := h.store.CreateTask(r.Context(), TaskInput{TaskType: input.TaskType, Payload: input.Payload, PoolType: input.PoolType, PoolCode: input.PoolCode, NodeID: input.NodeID, MaxAttempts: input.MaxAttempts}, actor.ID)
|
||||
if err != nil {
|
||||
writeTaskError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, item)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) cancelTask(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.require(w, r, identity.PermissionAgentNodeManage); !ok {
|
||||
return
|
||||
}
|
||||
if err := h.store.CancelTask(r.Context(), r.PathValue("id")); err != nil {
|
||||
writeTaskError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, map[string]bool{"cancelled": true})
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) retryTask(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.require(w, r, identity.PermissionAgentNodeManage); !ok {
|
||||
return
|
||||
}
|
||||
if err := h.store.RetryTask(r.Context(), r.PathValue("id")); err != nil {
|
||||
writeTaskError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, map[string]bool{"retried": true})
|
||||
}
|
||||
|
||||
// claimTask 节点拉取下一个可执行任务(池路由 + SKIP LOCKED 认领)。
|
||||
func (h *HTTPHandler) claimTask(w http.ResponseWriter, r *http.Request) {
|
||||
item, err := h.store.ClaimTask(r.Context(), r.PathValue("code"), r.Header.Get("X-Agent-Token"))
|
||||
if err != nil {
|
||||
writeTaskError(w, err)
|
||||
return
|
||||
}
|
||||
if item.ID == "" {
|
||||
apiresponse.OK(w, map[string]any{"task": nil})
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, map[string]any{"task": item})
|
||||
}
|
||||
|
||||
// completeTask 节点上报任务结果;claim_token 匹配才生效。
|
||||
func (h *HTTPHandler) completeTask(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
ClaimToken string `json:"claim_token"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if !decodeJSON(w, r, &input) {
|
||||
return
|
||||
}
|
||||
item, err := h.store.CompleteTask(r.Context(), r.PathValue("code"), r.Header.Get("X-Agent-Token"), r.PathValue("id"), input.ClaimToken, input.Result, input.Error)
|
||||
if err != nil {
|
||||
writeTaskError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, item)
|
||||
}
|
||||
|
||||
func writeTaskError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrTaskUnauthorized), errors.Is(err, ErrInvalidToken):
|
||||
apiresponse.Error(w, http.StatusUnauthorized, "节点令牌无效或节点已停用")
|
||||
case errors.Is(err, ErrTaskNotFound), errors.Is(err, ErrNotFound):
|
||||
apiresponse.Error(w, http.StatusNotFound, "任务不存在")
|
||||
case errors.Is(err, ErrTaskConflict):
|
||||
apiresponse.Error(w, http.StatusConflict, "任务状态不允许该操作(可能已被认领或已结束)")
|
||||
case errors.Is(err, ErrTaskInvalid), errors.Is(err, ErrInvalidInput):
|
||||
apiresponse.Error(w, http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, ErrStore):
|
||||
apiresponse.Error(w, http.StatusServiceUnavailable, "任务服务暂不可用")
|
||||
default:
|
||||
apiresponse.Error(w, http.StatusInternalServerError, "任务处理失败")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
platformid "aigateway.local/core/internal/platform/id"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Task 是一条下发给节点池的任务。
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
TaskType string `json:"task_type"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
PoolType string `json:"pool_type"`
|
||||
PoolCode string `json:"pool_code"`
|
||||
NodeID *string `json:"node_id,omitempty"`
|
||||
NodeCode string `json:"node_code,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Attempts int `json:"attempts"`
|
||||
MaxAttempts int `json:"max_attempts"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// ClaimToken 是认领时签发的上报凭证,仅认领响应返回,列表/详情不回传。
|
||||
ClaimToken string `json:"claim_token,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ClaimedAt *time.Time `json:"claimed_at,omitempty"`
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||
AvailableAt time.Time `json:"available_at"`
|
||||
}
|
||||
|
||||
// TaskInput 是创建任务的输入。
|
||||
type TaskInput struct {
|
||||
TaskType string
|
||||
Payload json.RawMessage
|
||||
PoolType string
|
||||
PoolCode string
|
||||
NodeID *string
|
||||
MaxAttempts int
|
||||
}
|
||||
|
||||
var (
|
||||
ErrTaskNotFound = errors.New("agent task not found")
|
||||
ErrTaskConflict = errors.New("agent task state conflict")
|
||||
ErrTaskInvalid = errors.New("agent task input invalid")
|
||||
ErrTaskUnauthorized = errors.New("agent node token invalid")
|
||||
)
|
||||
|
||||
var taskTypes = map[string]bool{"prompt": true, "http": true, "mcp_invoke": true, "skill_run": true, "digital_employee": true, "custom": true}
|
||||
|
||||
const taskSelect = `SELECT t.id::text,t.task_type,t.payload,t.pool_type,t.pool_code,t.node_id::text,coalesce(n.code,''),t.status,t.attempts,t.max_attempts,t.result,t.error,t.created_at,t.claimed_at,t.finished_at,t.available_at
|
||||
FROM gateway.agent_tasks t LEFT JOIN gateway.agent_nodes n ON n.id=t.node_id`
|
||||
|
||||
func scanTask(row pgx.Row) (Task, error) {
|
||||
var item Task
|
||||
var nodeID *string
|
||||
err := row.Scan(&item.ID, &item.TaskType, &item.Payload, &item.PoolType, &item.PoolCode, &nodeID, &item.NodeCode, &item.Status, &item.Attempts, &item.MaxAttempts, &item.Result, &item.Error, &item.CreatedAt, &item.ClaimedAt, &item.FinishedAt, &item.AvailableAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Task{}, ErrTaskNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
item.NodeID = nodeID
|
||||
item.Payload = normalizeJSON(item.Payload)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// CreateTask 下发任务到节点池(queued)。
|
||||
func (s *Store) CreateTask(ctx context.Context, input TaskInput, actorID string) (Task, error) {
|
||||
if s == nil || s.pool == nil {
|
||||
return Task{}, ErrStore
|
||||
}
|
||||
input.TaskType = strings.TrimSpace(input.TaskType)
|
||||
input.PoolType = strings.ToLower(strings.TrimSpace(input.PoolType))
|
||||
input.PoolCode = strings.TrimSpace(input.PoolCode)
|
||||
if !taskTypes[input.TaskType] {
|
||||
return Task{}, fmt.Errorf("%w: task type must be one of prompt/http/mcp_invoke/skill_run/digital_employee/custom", ErrTaskInvalid)
|
||||
}
|
||||
if len(input.Payload) == 0 || !json.Valid(input.Payload) {
|
||||
return Task{}, fmt.Errorf("%w: payload must be a JSON object", ErrTaskInvalid)
|
||||
}
|
||||
var object map[string]any
|
||||
if json.Unmarshal(input.Payload, &object) != nil || object == nil {
|
||||
return Task{}, fmt.Errorf("%w: payload must be a JSON object", ErrTaskInvalid)
|
||||
}
|
||||
if input.PoolType == "" {
|
||||
input.PoolType = "private"
|
||||
}
|
||||
if input.PoolType != "public" && input.PoolType != "private" {
|
||||
return Task{}, fmt.Errorf("%w: pool type is invalid", ErrTaskInvalid)
|
||||
}
|
||||
if input.PoolCode == "" {
|
||||
input.PoolCode = "default"
|
||||
}
|
||||
if len(input.PoolCode) > 64 {
|
||||
return Task{}, fmt.Errorf("%w: pool code is invalid", ErrTaskInvalid)
|
||||
}
|
||||
if input.MaxAttempts < 1 || input.MaxAttempts > 10 {
|
||||
input.MaxAttempts = 3
|
||||
}
|
||||
if input.NodeID != nil && strings.TrimSpace(*input.NodeID) != "" {
|
||||
var exists bool
|
||||
if err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway.agent_nodes WHERE id=$1)`, *input.NodeID).Scan(&exists); err != nil {
|
||||
return Task{}, fmt.Errorf("%w: %v", ErrStore, err)
|
||||
}
|
||||
if !exists {
|
||||
return Task{}, fmt.Errorf("%w: target node does not exist", ErrTaskInvalid)
|
||||
}
|
||||
} else {
|
||||
input.NodeID = nil
|
||||
}
|
||||
id, err := platformid.NewUUID()
|
||||
if err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
eventID, err := platformid.NewUUID()
|
||||
if err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return Task{}, fmt.Errorf("%w: %v", ErrStore, err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
_, err = tx.Exec(ctx, `INSERT INTO gateway.agent_tasks(id,task_type,payload,pool_type,pool_code,node_id,max_attempts,created_by) VALUES($1,$2,$3,$4,$5,nullif($6,'')::uuid,$7,nullif($8,'')::uuid)`, id, input.TaskType, input.Payload, input.PoolType, input.PoolCode, input.NodeID, input.MaxAttempts, actorID)
|
||||
if err != nil {
|
||||
return Task{}, fmt.Errorf("%w: %v", ErrStore, err)
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{"task_id": id, "task_type": input.TaskType, "pool_type": input.PoolType, "pool_code": input.PoolCode})
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO gateway.outbox_events(event_id,event_type,event_version,aggregate_type,aggregate_id,payload) VALUES($1,'agent_task.created',1,'agent_task',$2,$3)`, eventID, id, payload); err != nil {
|
||||
return Task{}, fmt.Errorf("%w: %v", ErrStore, err)
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return Task{}, fmt.Errorf("%w: %v", ErrStore, err)
|
||||
}
|
||||
return s.GetTask(ctx, id)
|
||||
}
|
||||
|
||||
// ListTasks 返回任务列表,可按状态过滤。
|
||||
func (s *Store) ListTasks(ctx context.Context, status string) ([]Task, error) {
|
||||
if s == nil || s.pool == nil {
|
||||
return nil, ErrStore
|
||||
}
|
||||
where, args := " WHERE true", []any{}
|
||||
if status != "" {
|
||||
args = append(args, status)
|
||||
where = " WHERE t.status=$1"
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, taskSelect+where+` ORDER BY t.created_at DESC LIMIT 200`, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrStore, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Task{}
|
||||
for rows.Next() {
|
||||
item, err := scanTask(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
// GetTask 按 ID 返回任务。
|
||||
func (s *Store) GetTask(ctx context.Context, id string) (Task, error) {
|
||||
if s == nil || s.pool == nil {
|
||||
return Task{}, ErrStore
|
||||
}
|
||||
return scanTask(s.pool.QueryRow(ctx, taskSelect+` WHERE t.id=$1`, strings.TrimSpace(id)))
|
||||
}
|
||||
|
||||
// CancelTask 取消排队中的任务(已认领/运行中不可取消)。
|
||||
func (s *Store) CancelTask(ctx context.Context, id string) error {
|
||||
if s == nil || s.pool == nil {
|
||||
return ErrStore
|
||||
}
|
||||
tag, err := s.pool.Exec(ctx, `UPDATE gateway.agent_tasks SET status='cancelled',finished_at=clock_timestamp() WHERE id=$1 AND status='queued'`, strings.TrimSpace(id))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrStore, err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrTaskConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RetryTask 把失败任务重新入队(重置失败状态与退避)。
|
||||
func (s *Store) RetryTask(ctx context.Context, id string) error {
|
||||
if s == nil || s.pool == nil {
|
||||
return ErrStore
|
||||
}
|
||||
tag, err := s.pool.Exec(ctx, `UPDATE gateway.agent_tasks SET status='queued',error='',available_at=clock_timestamp(),finished_at=NULL,claimed_at=NULL WHERE id=$1 AND status='failed'`, strings.TrimSpace(id))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrStore, err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrTaskConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// authenticateNode 校验节点令牌(与心跳同一套恒定时间比较),返回节点行。
|
||||
func (s *Store) authenticateNode(ctx context.Context, code, token string) (Node, error) {
|
||||
code = strings.ToLower(strings.TrimSpace(code))
|
||||
token = strings.TrimSpace(token)
|
||||
if !nodeCodePattern.MatchString(code) || token == "" || len(token) > 512 {
|
||||
return Node{}, ErrTaskUnauthorized
|
||||
}
|
||||
var storedHash []byte
|
||||
err := s.pool.QueryRow(ctx, `SELECT token_hash FROM gateway.agent_nodes WHERE code=$1 AND enabled`, code).Scan(&storedHash)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Node{}, ErrTaskUnauthorized
|
||||
}
|
||||
if err != nil {
|
||||
return Node{}, fmt.Errorf("%w: %v", ErrStore, err)
|
||||
}
|
||||
digest := sha256.Sum256([]byte(token))
|
||||
if subtle.ConstantTimeCompare(digest[:], storedHash) != 1 {
|
||||
return Node{}, ErrTaskUnauthorized
|
||||
}
|
||||
var id string
|
||||
if err := s.pool.QueryRow(ctx, `SELECT id::text FROM gateway.agent_nodes WHERE code=$1 AND enabled`, code).Scan(&id); err != nil {
|
||||
return Node{}, fmt.Errorf("%w: %v", ErrStore, err)
|
||||
}
|
||||
return s.Get(ctx, id)
|
||||
}
|
||||
|
||||
// ClaimTask 节点认领下一个可用任务:目标节点(或所在池)中最早排队且到期的任务,
|
||||
// SKIP LOCKED 保证多节点并发不重复认领。返回空 Task 表示当前无任务。
|
||||
func (s *Store) ClaimTask(ctx context.Context, code, token string) (Task, error) {
|
||||
if s == nil || s.pool == nil {
|
||||
return Task{}, ErrStore
|
||||
}
|
||||
node, err := s.authenticateNode(ctx, code, token)
|
||||
if err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
claim, err := platformid.NewUUID()
|
||||
if err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
var item Task
|
||||
err = s.pool.QueryRow(ctx, `UPDATE gateway.agent_tasks SET status='claimed',claim_token=$1,claimed_at=clock_timestamp(),available_at=clock_timestamp()+interval '15 minutes'
|
||||
WHERE id = (
|
||||
SELECT t.id FROM gateway.agent_tasks t
|
||||
WHERE t.status='queued' AND t.available_at<=clock_timestamp()
|
||||
AND (t.node_id IS NULL OR t.node_id=$2)
|
||||
AND (t.node_id IS NOT NULL OR (t.pool_type=$3 AND t.pool_code=$4))
|
||||
ORDER BY t.created_at LIMIT 1 FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
RETURNING id::text,task_type,payload,pool_type,pool_code,node_id::text,status,attempts,max_attempts,result,error,created_at,claimed_at,finished_at,available_at,claim_token`, claim, node.ID, node.PoolType, node.PoolCode).Scan(
|
||||
&item.ID, &item.TaskType, &item.Payload, &item.PoolType, &item.PoolCode, &item.NodeID, &item.Status, &item.Attempts, &item.MaxAttempts, &item.Result, &item.Error, &item.CreatedAt, &item.ClaimedAt, &item.FinishedAt, &item.AvailableAt, &item.ClaimToken)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Task{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Task{}, fmt.Errorf("%w: %v", ErrStore, err)
|
||||
}
|
||||
item.Payload = normalizeJSON(item.Payload)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// CompleteTask 节点上报任务结果;claim_token 匹配才生效(防重放/防串扰)。
|
||||
// 失败且未达最大重试次数时按退避重新入队。
|
||||
func (s *Store) CompleteTask(ctx context.Context, code, token, taskID, claimToken string, result json.RawMessage, taskError string) (Task, error) {
|
||||
if s == nil || s.pool == nil {
|
||||
return Task{}, ErrStore
|
||||
}
|
||||
if _, err := s.authenticateNode(ctx, code, token); err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
taskID = strings.TrimSpace(taskID)
|
||||
claimToken = strings.TrimSpace(claimToken)
|
||||
if taskID == "" || claimToken == "" || len(taskError) > 4000 {
|
||||
return Task{}, ErrTaskInvalid
|
||||
}
|
||||
if len(result) > 0 && !json.Valid(result) {
|
||||
return Task{}, fmt.Errorf("%w: result must be valid JSON", ErrTaskInvalid)
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return Task{}, fmt.Errorf("%w: %v", ErrStore, err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
var status, currentError string
|
||||
var attempts, maxAttempts int
|
||||
err = tx.QueryRow(ctx, `SELECT status,error,attempts,max_attempts FROM gateway.agent_tasks WHERE id=$1 AND claim_token=$2`, taskID, claimToken).Scan(&status, ¤tError, &attempts, &maxAttempts)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Task{}, ErrTaskConflict
|
||||
}
|
||||
if err != nil {
|
||||
return Task{}, fmt.Errorf("%w: %v", ErrStore, err)
|
||||
}
|
||||
if status != "claimed" && status != "running" {
|
||||
return Task{}, ErrTaskConflict
|
||||
}
|
||||
attempts++
|
||||
nextStatus := "succeeded"
|
||||
if taskError != "" {
|
||||
nextStatus = "failed"
|
||||
if attempts < maxAttempts {
|
||||
nextStatus = "queued"
|
||||
}
|
||||
}
|
||||
// 失败退避:30s * 已尝试次数。
|
||||
backoff := 30 * time.Second * time.Duration(attempts)
|
||||
_, err = tx.Exec(ctx, `UPDATE gateway.agent_tasks SET status=$2::text,attempts=$3::int,result=$4::jsonb,error=$5::text,claim_token=NULL,finished_at=CASE WHEN $2::text IN ('succeeded','failed','cancelled') THEN clock_timestamp() ELSE NULL END,available_at=CASE WHEN $2::text='queued' THEN clock_timestamp()+$6::interval ELSE available_at END WHERE id=$1::uuid`,
|
||||
taskID, nextStatus, attempts, normalizeJSON(result), taskError, backoff)
|
||||
if err != nil {
|
||||
return Task{}, fmt.Errorf("%w: %v", ErrStore, err)
|
||||
}
|
||||
eventID, _ := platformid.NewUUID()
|
||||
eventType := "agent_task.completed"
|
||||
if nextStatus == "failed" {
|
||||
eventType = "agent_task.failed"
|
||||
} else if nextStatus == "queued" {
|
||||
eventType = "agent_task.retry"
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{"task_id": taskID, "status": nextStatus, "attempts": attempts, "error": taskError})
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO gateway.outbox_events(event_id,event_type,event_version,aggregate_type,aggregate_id,payload) VALUES($1,$2,1,'agent_task',$3,$4)`, eventID, eventType, taskID, payload); err != nil {
|
||||
return Task{}, fmt.Errorf("%w: %v", ErrStore, err)
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return Task{}, fmt.Errorf("%w: %v", ErrStore, err)
|
||||
}
|
||||
return s.GetTask(ctx, taskID)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package agentnode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"aigateway.local/core/internal/platform/config"
|
||||
"aigateway.local/core/internal/platform/database"
|
||||
)
|
||||
|
||||
// TestAgentTaskPostgreSQLLifecycle 模拟一个节点跑通任务全链路:
|
||||
// 下发 → 心跳上线 → 认领(SKIP LOCKED) → 上报成功 → 状态校验;
|
||||
// 以及失败重试入队与并发认领互斥。
|
||||
func TestAgentTaskPostgreSQLLifecycle(t *testing.T) {
|
||||
databaseURL := os.Getenv("AGENT_NODE_TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("AGENT_NODE_TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := database.Open(ctx, config.Database{URL: databaseURL, MaxConns: 8, MinConns: 0})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM gateway.agent_nodes WHERE code='task-node-integration'`)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM gateway.agent_tasks WHERE payload->>'tag'='integration'`)
|
||||
defer pool.Exec(ctx, `DELETE FROM gateway.agent_nodes WHERE code='task-node-integration'`)
|
||||
defer pool.Exec(ctx, `DELETE FROM gateway.agent_tasks WHERE payload->>'tag'='integration'`)
|
||||
store := NewStore(pool)
|
||||
|
||||
// 1. 登记节点并心跳上线。
|
||||
node, token, err := store.Create(ctx, CreateInput{Code: "task-node-integration", Name: "Task Node", PoolType: "private", PoolCode: "test", Enabled: true}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.Heartbeat(ctx, node.Code, token, net.ParseIP("192.0.2.11"), HeartbeatInput{Version: "test", Capabilities: map[string]any{"tool_exec": true}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 2. 下发两条任务(同池)。
|
||||
payload, _ := json.Marshal(map[string]any{"tag": "integration", "seq": 1})
|
||||
first, err := store.CreateTask(ctx, TaskInput{TaskType: "prompt", Payload: payload, PoolType: "private", PoolCode: "test", MaxAttempts: 2}, "")
|
||||
if err != nil || first.Status != "queued" {
|
||||
t.Fatalf("create task=%+v err=%v", first, err)
|
||||
}
|
||||
payload2, _ := json.Marshal(map[string]any{"tag": "integration", "seq": 2})
|
||||
second, err := store.CreateTask(ctx, TaskInput{TaskType: "http", Payload: payload2, PoolType: "private", PoolCode: "test", MaxAttempts: 2}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 3. 节点认领:两条任务按创建顺序各被认领一次。
|
||||
claimed1, err := store.ClaimTask(ctx, node.Code, token)
|
||||
if err != nil || claimed1.ID != first.ID || claimed1.Status != "claimed" {
|
||||
t.Fatalf("claim1 task=%+v err=%v", claimed1, err)
|
||||
}
|
||||
claimed2, err := store.ClaimTask(ctx, node.Code, token)
|
||||
if err != nil || claimed2.ID != second.ID {
|
||||
t.Fatalf("claim2 task=%+v err=%v", claimed2, err)
|
||||
}
|
||||
if empty, err := store.ClaimTask(ctx, node.Code, token); err != nil || empty.ID != "" {
|
||||
t.Fatalf("third claim should be empty, got %+v err=%v", empty, err)
|
||||
}
|
||||
|
||||
// 4. 错误 claim_token 上报必须失败(防串扰)。
|
||||
badResult, _ := json.Marshal(map[string]string{"ok": "true"})
|
||||
if _, err := store.CompleteTask(ctx, node.Code, token, first.ID, "forged-token", badResult, ""); err == nil {
|
||||
t.Fatal("complete with forged claim token should fail")
|
||||
}
|
||||
|
||||
// 5. 成功上报第一条。
|
||||
done, err := store.CompleteTask(ctx, node.Code, token, first.ID, claimed1.ClaimToken, badResult, "")
|
||||
if err != nil || done.Status != "succeeded" || done.Attempts != 1 {
|
||||
t.Fatalf("complete task=%+v err=%v", done, err)
|
||||
}
|
||||
|
||||
// 6. 第二条上报失败 → attempts<max 应回队重试。
|
||||
if _, err := store.CompleteTask(ctx, node.Code, token, second.ID, claimed2.ClaimToken, nil, "node crashed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
retried, err := store.GetTask(ctx, second.ID)
|
||||
if err != nil || retried.Status != "queued" || retried.Attempts != 1 || retried.AvailableAt.After(time.Now().Add(2*time.Minute)) {
|
||||
t.Fatalf("retry task=%+v err=%v", retried, err)
|
||||
}
|
||||
|
||||
// 7. 失败任务按退避延迟可认领:拨回 available_at 后重新认领,再次失败
|
||||
// 达 max_attempts 后标记 failed。
|
||||
if _, err := pool.Exec(ctx, `UPDATE gateway.agent_tasks SET available_at=clock_timestamp()-interval '1 minute' WHERE id=$1`, second.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
claimed3, err := store.ClaimTask(ctx, node.Code, token)
|
||||
if err != nil || claimed3.ID != second.ID {
|
||||
t.Fatalf("reclaim task=%+v err=%v", claimed3, err)
|
||||
}
|
||||
if _, err := store.CompleteTask(ctx, node.Code, token, second.ID, claimed3.ClaimToken, nil, "node crashed again"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failed, err := store.GetTask(ctx, second.ID)
|
||||
if err != nil || failed.Status != "failed" || failed.Attempts != 2 {
|
||||
t.Fatalf("failed task=%+v err=%v", failed, err)
|
||||
}
|
||||
|
||||
// 8. 取消已结束任务应冲突;重试失败任务应重新入队。
|
||||
if err := store.CancelTask(ctx, first.ID); err == nil {
|
||||
t.Fatal("cancel succeeded task should conflict")
|
||||
}
|
||||
if err := store.RetryTask(ctx, second.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if retriedAgain, err := store.GetTask(ctx, second.ID); err != nil || retriedAgain.Status != "queued" {
|
||||
t.Fatalf("retry again task=%+v err=%v", retriedAgain, err)
|
||||
}
|
||||
}
|
||||
@@ -489,6 +489,7 @@ func adminMenus(account Account) []map[string]any {
|
||||
}
|
||||
if HasPermission(account, PermissionAgentNodeRead) || HasPermission(account, PermissionAgentNodeManage) {
|
||||
securityChildren = append(securityChildren, map[string]any{"name": "AgentNodes", "path": "agent-nodes", "component": "/gateway/agent-nodes", "meta": map[string]any{"title": "智能体节点"}})
|
||||
securityChildren = append(securityChildren, map[string]any{"name": "AgentTasks", "path": "agent-tasks", "component": "/gateway/agent-tasks", "meta": map[string]any{"title": "节点任务"}})
|
||||
}
|
||||
if len(securityChildren) > 0 {
|
||||
menus = append(menus, map[string]any{"name": "Security", "path": "/security", "component": "/index/index", "meta": map[string]any{"title": "安全与审计", "icon": "ri:shield-check-line"}, "children": securityChildren})
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package workbench
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"aigateway.local/core/internal/identity"
|
||||
"aigateway.local/core/internal/platform/apiresponse"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// AgentPolicy 是个人智能体安全策略。
|
||||
type AgentPolicy struct {
|
||||
AutoApproveTools bool `json:"auto_approve_tools"`
|
||||
RateLimitMultiplier int `json:"rate_limit_multiplier"`
|
||||
}
|
||||
|
||||
// AgentPolicyService 管理个人智能体安全策略。
|
||||
type AgentPolicyService struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewAgentPolicyService(pool *pgxpool.Pool) *AgentPolicyService {
|
||||
return &AgentPolicyService{pool: pool}
|
||||
}
|
||||
|
||||
// Get 返回用户的策略(未配置时返回默认值)。
|
||||
func (s *AgentPolicyService) Get(ctx context.Context, portalUserID string) (AgentPolicy, error) {
|
||||
var policy AgentPolicy
|
||||
if s == nil || s.pool == nil {
|
||||
return policy, errors.New("安全策略服务不可用")
|
||||
}
|
||||
err := s.pool.QueryRow(ctx, `SELECT auto_approve_tools,rate_limit_multiplier FROM gateway.portal_agent_policies WHERE portal_user_id=$1`, portalUserID).Scan(&policy.AutoApproveTools, &policy.RateLimitMultiplier)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
policy.RateLimitMultiplier = 1
|
||||
return policy, nil
|
||||
}
|
||||
if err != nil {
|
||||
return policy, err
|
||||
}
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
// Set 更新用户的策略。
|
||||
func (s *AgentPolicyService) Set(ctx context.Context, portalUserID string, policy AgentPolicy) error {
|
||||
if s == nil || s.pool == nil {
|
||||
return errors.New("安全策略服务不可用")
|
||||
}
|
||||
if policy.RateLimitMultiplier < 1 || policy.RateLimitMultiplier > 100 {
|
||||
return errors.New("限流倍数必须在 1-100 之间")
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO gateway.portal_agent_policies(portal_user_id,auto_approve_tools,rate_limit_multiplier) VALUES($1,$2,$3)
|
||||
ON CONFLICT(portal_user_id) DO UPDATE SET auto_approve_tools=$2,rate_limit_multiplier=$3,updated_at=clock_timestamp()`,
|
||||
portalUserID, policy.AutoApproveTools, policy.RateLimitMultiplier)
|
||||
return err
|
||||
}
|
||||
|
||||
// AgentPolicyHTTPHandler 门户个人智能体安全策略。
|
||||
type AgentPolicyHTTPHandler struct {
|
||||
service *AgentPolicyService
|
||||
identity *identity.Service
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
func NewAgentPolicyHTTPHandler(service *AgentPolicyService, identityService *identity.Service) *AgentPolicyHTTPHandler {
|
||||
h := &AgentPolicyHTTPHandler{service: service, identity: identityService, mux: http.NewServeMux()}
|
||||
h.mux.HandleFunc("GET /api/v1/portal/agent-policy", h.get)
|
||||
h.mux.HandleFunc("PUT /api/v1/portal/agent-policy", h.put)
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *AgentPolicyHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (h *AgentPolicyHTTPHandler) account(w http.ResponseWriter, r *http.Request) (identity.Account, bool) {
|
||||
account, err := h.identity.Authenticate(r.Context(), identity.KindPortal, r.Header.Get("Authorization"))
|
||||
if err != nil {
|
||||
apiresponse.Error(w, http.StatusUnauthorized, "登录状态无效或已过期")
|
||||
return identity.Account{}, false
|
||||
}
|
||||
return account, true
|
||||
}
|
||||
|
||||
func (h *AgentPolicyHTTPHandler) get(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := h.account(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
policy, err := h.service.Get(r.Context(), a.ID)
|
||||
if err != nil {
|
||||
apiresponse.Error(w, http.StatusServiceUnavailable, "安全策略查询失败")
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, policy)
|
||||
}
|
||||
|
||||
func (h *AgentPolicyHTTPHandler) put(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := h.account(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
AutoApproveTools bool `json:"auto_approve_tools"`
|
||||
RateLimitMultiplier int `json:"rate_limit_multiplier"`
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if decoder.Decode(&input) != nil {
|
||||
apiresponse.Error(w, http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
if err := h.service.Set(r.Context(), a.ID, AgentPolicy{AutoApproveTools: input.AutoApproveTools, RateLimitMultiplier: input.RateLimitMultiplier}); err != nil {
|
||||
apiresponse.Error(w, http.StatusBadRequest, strings.TrimSpace(err.Error()))
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, map[string]bool{"saved": true})
|
||||
}
|
||||
@@ -132,6 +132,10 @@ func inboxPlan(eventType string, payload json.RawMessage) []inboxDraft {
|
||||
return []inboxDraft{{RecipientKind: "portal", Category: "approval", Title: "资源申请已处理", Body: "您的资源权限申请(" + payloadValue(payload, "resource_type") + " " + payloadValue(payload, "resource_code") + ")已被" + text, Link: "/portal/requests", UserID: payloadValue(payload, "portal_user_id")}}
|
||||
case "tool_approval.requested":
|
||||
return []inboxDraft{{RecipientKind: "admin", Category: "approval", Title: "工具使用待审批", Body: "工具 " + payloadValue(payload, "tool_code") + " 首次被调用,需审批后才能使用", Link: "/system/approvals", AllAdmins: true}}
|
||||
case "agent_task.completed":
|
||||
return []inboxDraft{{RecipientKind: "admin", Category: "task_result", Title: "节点任务已完成", Body: "节点任务(" + payloadValue(payload, "task_id") + ")执行成功", Link: "/security/agent-tasks", AllAdmins: true}}
|
||||
case "agent_task.failed":
|
||||
return []inboxDraft{{RecipientKind: "admin", Category: "task_result", Title: "节点任务执行失败", Body: "节点任务(" + payloadValue(payload, "task_id") + ")执行失败: " + payloadValue(payload, "error"), Link: "/security/agent-tasks", AllAdmins: true}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+53
-11
@@ -217,12 +217,19 @@ var ErrToolRateLimited = errors.New("工具调用频率超限,请稍后重试"
|
||||
|
||||
// enforceGovernance 在工具执行前做治理校验:审批标记 + 调用频率上限。
|
||||
// 审批缺失时自动发起一次申请(每工具至多一个待审项);限流用固定窗口原子
|
||||
// upsert,多实例共享同一额度。返回 (allowed, err)。
|
||||
func (s *ToolService) enforceGovernance(ctx context.Context, tool Tool) (bool, error) {
|
||||
// upsert,多实例共享同一额度。apiKeyID 对应的门户用户若配置了个人智能体
|
||||
// 安全策略:auto_approve_tools 跳过审批门,rate_limit_multiplier 按倍数
|
||||
// 放宽个人限流(个人窗口独立计数)。返回 (allowed, err)。
|
||||
func (s *ToolService) enforceGovernance(ctx context.Context, tool Tool, apiKeyID string) (bool, error) {
|
||||
if s == nil || s.assets == nil || s.assets.pool == nil {
|
||||
return false, errors.New("工具服务不可用")
|
||||
}
|
||||
if tool.ApprovalRequired {
|
||||
// 解析调用者的个人策略(仅门户运行时凭据可能命中)。
|
||||
portalUserID, policy, err := s.personalPolicy(ctx, apiKeyID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if tool.ApprovalRequired && !policy.AutoApproveTools {
|
||||
var approved bool
|
||||
if err := s.assets.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway.tool_approval_requests WHERE tool_id=$1 AND status='approved')`, tool.ID).Scan(&approved); err != nil {
|
||||
return false, err
|
||||
@@ -258,22 +265,56 @@ func (s *ToolService) enforceGovernance(ctx context.Context, tool Tool) (bool, e
|
||||
return false, fmt.Errorf("%w: %s(已自动发起审批)", ErrToolApprovalRequired, tool.Name)
|
||||
}
|
||||
}
|
||||
if tool.RateLimitRPM > 0 {
|
||||
limit := tool.RateLimitRPM
|
||||
if portalUserID != "" && policy.RateLimitMultiplier > 1 {
|
||||
limit = tool.RateLimitRPM * policy.RateLimitMultiplier
|
||||
}
|
||||
if limit > 0 {
|
||||
userKey := portalUserID
|
||||
if userKey == "" {
|
||||
userKey = personalPolicySentinel
|
||||
}
|
||||
var count int64
|
||||
err := s.assets.pool.QueryRow(ctx, `INSERT INTO gateway.tool_rate_usage(tool_id,window_start,call_count)
|
||||
VALUES($1,date_trunc('minute',clock_timestamp()),1)
|
||||
ON CONFLICT (tool_id,window_start) DO UPDATE SET call_count=gateway.tool_rate_usage.call_count+1
|
||||
RETURNING call_count`, tool.ID).Scan(&count)
|
||||
err := s.assets.pool.QueryRow(ctx, `INSERT INTO gateway.tool_rate_usage(tool_id,portal_user_id,window_start,call_count)
|
||||
VALUES($1,$2,date_trunc('minute',clock_timestamp()),1)
|
||||
ON CONFLICT (tool_id,portal_user_id,window_start) DO UPDATE SET call_count=gateway.tool_rate_usage.call_count+1
|
||||
RETURNING call_count`, tool.ID, userKey).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if count > int64(tool.RateLimitRPM) {
|
||||
if count > int64(limit) {
|
||||
return false, ErrToolRateLimited
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// personalPolicySentinel 是非个人调用的限流窗口占位键(全零 UUID)。
|
||||
const personalPolicySentinel = "00000000-0000-0000-0000-000000000000"
|
||||
|
||||
// personalPolicy 解析 API Key 对应的门户用户及其个人智能体安全策略。
|
||||
// 非门户 Key 返回空 userID 与默认策略(不跳过审批、倍数 1)。
|
||||
func (s *ToolService) personalPolicy(ctx context.Context, apiKeyID string) (string, AgentPolicy, error) {
|
||||
if strings.TrimSpace(apiKeyID) == "" {
|
||||
return "", AgentPolicy{RateLimitMultiplier: 1}, nil
|
||||
}
|
||||
var portalUserID *string
|
||||
err := s.assets.pool.QueryRow(ctx, `SELECT portal_user_id::text FROM gateway.api_keys WHERE id=$1`, apiKeyID).Scan(&portalUserID)
|
||||
if err != nil || portalUserID == nil || *portalUserID == "" {
|
||||
return "", AgentPolicy{RateLimitMultiplier: 1}, nil
|
||||
}
|
||||
var policy AgentPolicy
|
||||
err = s.assets.pool.QueryRow(ctx, `SELECT auto_approve_tools,rate_limit_multiplier FROM gateway.portal_agent_policies WHERE portal_user_id=$1`, *portalUserID).Scan(&policy.AutoApproveTools, &policy.RateLimitMultiplier)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
policy.RateLimitMultiplier = 1
|
||||
return *portalUserID, policy, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", AgentPolicy{}, err
|
||||
}
|
||||
return *portalUserID, policy, nil
|
||||
}
|
||||
|
||||
// ListApprovalRequests 返回工具审批申请(含工具信息)。
|
||||
func (s *ToolService) ListApprovalRequests(ctx context.Context, status string) ([]map[string]any, error) {
|
||||
if s == nil || s.assets == nil || s.assets.pool == nil {
|
||||
@@ -351,8 +392,9 @@ func (s *ToolService) Execute(ctx context.Context, tool Tool, input map[string]a
|
||||
if err = validateToolInput(tool.InputSchema, input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 治理校验:审批标记 + 频率上限。被拒时不算一次成功调用(但会记 tool_runs 失败)。
|
||||
if allowed, governanceErr := s.enforceGovernance(ctx, tool); !allowed {
|
||||
// 治理校验:审批标记 + 频率上限(个人策略可跳过审批/放宽限流)。被拒时
|
||||
// 不算一次成功调用(但会记 tool_runs 失败)。
|
||||
if allowed, governanceErr := s.enforceGovernance(ctx, tool, apiKeyID); !allowed {
|
||||
return nil, governanceErr
|
||||
}
|
||||
headers, err := s.headers(tool)
|
||||
|
||||
Reference in New Issue
Block a user