feat: persist hitl default config

This commit is contained in:
temp
2026-08-24 19:45:07 +08:00
parent e0a2f01427
commit 21c6ad9bdf
12 changed files with 291 additions and 114 deletions
+2
View File
@@ -972,6 +972,8 @@ func setupRoutes(
protected.GET("/hitl/tool-whitelist", agentHandler.GetHITLGlobalToolWhitelist)
protected.PUT("/hitl/tool-whitelist", agentHandler.SetHITLGlobalToolWhitelist)
protected.POST("/hitl/tool-whitelist", agentHandler.MergeHITLGlobalToolWhitelist)
protected.GET("/hitl/default-config", agentHandler.GetHITLDefaultConfig)
protected.PUT("/hitl/default-config", agentHandler.UpdateHITLDefaultConfig)
protected.GET("/hitl/default-reviewer", agentHandler.GetHITLDefaultReviewer)
protected.PUT("/hitl/default-reviewer", agentHandler.UpdateHITLDefaultReviewer)
protected.GET("/hitl/audit-strategy", agentHandler.GetHITLAuditStrategy)
+28 -1
View File
@@ -1062,8 +1062,24 @@ type HitlConfig struct {
AuditAgentPromptReviewEdit string `yaml:"audit_agent_prompt_review_edit,omitempty" json:"audit_agent_prompt_review_edit,omitempty"`
// RetentionDays 已决策审计日志(hitl_interrupts 非 pending)保留天数;省略时默认 90;0 表示不自动清理。
RetentionDays *int `yaml:"retention_days,omitempty" json:"retention_days,omitempty"`
// DefaultReviewer 全局默认审批方(human | audit_agent);未选会话时切换会写入 config.yaml;新建会话无独立配置时沿用。
// DefaultMode 全局默认人机协同模式(off | approval | review_edit;新建会话无独立配置时沿用。
DefaultMode string `yaml:"default_mode,omitempty" json:"default_mode,omitempty"`
// DefaultReviewer 全局默认审批方(human | audit_agent);新建会话无独立配置时沿用。
DefaultReviewer string `yaml:"default_reviewer,omitempty" json:"default_reviewer,omitempty"`
// DefaultTimeoutSeconds 全局默认审批等待秒数;nil 表示使用前端历史默认 300 秒,0 表示不限时。
DefaultTimeoutSeconds *int `yaml:"default_timeout_seconds,omitempty" json:"default_timeout_seconds,omitempty"`
}
// EffectiveDefaultMode returns off, approval, or review_edit; omitted or unknown values default to off.
func (h HitlConfig) EffectiveDefaultMode() string {
switch strings.ToLower(strings.TrimSpace(h.DefaultMode)) {
case "feedback", "followup":
return "approval"
case "approval", "review_edit":
return strings.ToLower(strings.TrimSpace(h.DefaultMode))
default:
return "off"
}
}
// EffectiveDefaultReviewer returns human or audit_agent; omitted or unknown values default to human.
@@ -1076,6 +1092,17 @@ func (h HitlConfig) EffectiveDefaultReviewer() string {
}
}
// EffectiveDefaultTimeoutSeconds returns the default HITL approval timeout; nil defaults to 5 minutes.
func (h HitlConfig) EffectiveDefaultTimeoutSeconds() int {
if h.DefaultTimeoutSeconds == nil {
return 300
}
if *h.DefaultTimeoutSeconds < 0 {
return 0
}
return *h.DefaultTimeoutSeconds
}
// RetentionDaysEffective returns retention; 0 means keep forever; omitted defaults to 90.
func (h HitlConfig) RetentionDaysEffective() int {
if h.RetentionDays == nil {
+23
View File
@@ -95,6 +95,29 @@ func TestHitlAuditModelEffectiveFallsBackToMainConfig(t *testing.T) {
}
}
func TestHitlDefaultConfigEffectiveValues(t *testing.T) {
if got := (HitlConfig{}).EffectiveDefaultMode(); got != "off" {
t.Fatalf("empty default mode = %q, want off", got)
}
if got := (HitlConfig{DefaultMode: "review-edit"}).EffectiveDefaultMode(); got != "off" {
t.Fatalf("unknown default mode = %q, want off", got)
}
if got := (HitlConfig{DefaultMode: "review_edit"}).EffectiveDefaultMode(); got != "review_edit" {
t.Fatalf("review_edit default mode = %q, want review_edit", got)
}
if got := (HitlConfig{}).EffectiveDefaultTimeoutSeconds(); got != 300 {
t.Fatalf("empty default timeout = %d, want 300", got)
}
zero := 0
if got := (HitlConfig{DefaultTimeoutSeconds: &zero}).EffectiveDefaultTimeoutSeconds(); got != 0 {
t.Fatalf("zero default timeout = %d, want 0", got)
}
neg := -1
if got := (HitlConfig{DefaultTimeoutSeconds: &neg}).EffectiveDefaultTimeoutSeconds(); got != 0 {
t.Fatalf("negative default timeout = %d, want 0", got)
}
}
func TestLoadUsesAIDefaultChannelAsRuntimeOpenAI(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
+32 -2
View File
@@ -315,12 +315,13 @@ func (h *AgentHandler) SetHitlToolWhitelistSaver(s HitlToolWhitelistSaver) {
h.hitlWhitelistSaver = s
}
// HitlDefaultReviewerSaver 持久化全局默认审批方到 config.yaml。
// HitlDefaultReviewerSaver 持久化全局默认人机协同配置到 config.yaml。
type HitlDefaultReviewerSaver interface {
UpdateHitlDefaultConfig(mode, reviewer string, timeoutSeconds int) error
UpdateHitlDefaultReviewer(reviewer string) error
}
// SetHitlDefaultReviewerSaver 设置 HITL 默认审批方落盘。
// SetHitlDefaultReviewerSaver 设置 HITL 默认配置落盘。
func (h *AgentHandler) SetHitlDefaultReviewerSaver(s HitlDefaultReviewerSaver) {
h.hitlDefaultReviewerSaver = s
}
@@ -332,6 +333,35 @@ func (h *AgentHandler) hitlEffectiveDefaultReviewer() string {
return "human"
}
func (h *AgentHandler) hitlEffectiveDefaultMode() string {
if h != nil && h.config != nil {
return normalizeHitlDefaultMode(h.config.Hitl.EffectiveDefaultMode())
}
return "off"
}
func (h *AgentHandler) hitlEffectiveDefaultTimeoutSeconds() int {
if h != nil && h.config != nil {
timeout := h.config.Hitl.EffectiveDefaultTimeoutSeconds()
if timeout < 0 {
return 0
}
return timeout
}
return 300
}
func (h *AgentHandler) hitlEffectiveDefaultRequest() *HITLRequest {
mode := h.hitlEffectiveDefaultMode()
return &HITLRequest{
Enabled: mode != "off",
Mode: mode,
Reviewer: h.hitlEffectiveDefaultReviewer(),
SensitiveTools: []string{},
TimeoutSeconds: h.hitlEffectiveDefaultTimeoutSeconds(),
}
}
// HITLNeedsToolApproval 供 C2 危险任务门控:与会话侧人机协同及免审批白名单判定一致。
func (h *AgentHandler) HITLNeedsToolApproval(conversationID, toolName string) bool {
if h == nil || h.hitlManager == nil {
+30
View File
@@ -891,7 +891,14 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
if req.Hitl != nil {
h.config.Hitl.AuditModel = req.Hitl.AuditModel
h.config.Hitl.ToolWhitelist = mergeHitlToolWhitelistSlice(nil, req.Hitl.ToolWhitelist)
if strings.TrimSpace(req.Hitl.DefaultMode) != "" {
h.config.Hitl.DefaultMode = req.Hitl.EffectiveDefaultMode()
}
h.config.Hitl.DefaultReviewer = req.Hitl.EffectiveDefaultReviewer()
if req.Hitl.DefaultTimeoutSeconds != nil {
v := req.Hitl.EffectiveDefaultTimeoutSeconds()
h.config.Hitl.DefaultTimeoutSeconds = &v
}
h.config.Hitl.AuditAgentPrompt = strings.TrimSpace(req.Hitl.AuditAgentPrompt)
h.config.Hitl.AuditAgentPromptReviewEdit = strings.TrimSpace(req.Hitl.AuditAgentPromptReviewEdit)
if req.Hitl.RetentionDays != nil {
@@ -2141,12 +2148,35 @@ func updateHitlConfig(doc *yaml.Node, cfg config.HitlConfig) {
setStringInMap(auditModelNode, "model", cfg.AuditModel.Model)
// flow 样式 [a, b, c] 单行展示,工具多时比块序列省行数
setFlowStringSliceInMap(hitlNode, "tool_whitelist", cfg.ToolWhitelist)
setStringInMap(hitlNode, "default_mode", cfg.EffectiveDefaultMode())
setStringInMap(hitlNode, "default_reviewer", cfg.EffectiveDefaultReviewer())
setIntInMap(hitlNode, "default_timeout_seconds", cfg.EffectiveDefaultTimeoutSeconds())
setIntInMap(hitlNode, "retention_days", cfg.RetentionDaysEffective())
setStringInMap(hitlNode, "audit_agent_prompt", cfg.AuditAgentPrompt)
setStringInMap(hitlNode, "audit_agent_prompt_review_edit", cfg.AuditAgentPromptReviewEdit)
}
// UpdateHitlDefaultConfig 更新全局默认人机协同配置并写入 config.yaml。
func (h *ConfigHandler) UpdateHitlDefaultConfig(mode, reviewer string, timeoutSeconds int) error {
h.mu.Lock()
defer h.mu.Unlock()
h.config.Hitl.DefaultMode = config.HitlConfig{DefaultMode: mode}.EffectiveDefaultMode()
h.config.Hitl.DefaultReviewer = config.HitlConfig{DefaultReviewer: reviewer}.EffectiveDefaultReviewer()
if timeoutSeconds < 0 {
timeoutSeconds = 0
}
h.config.Hitl.DefaultTimeoutSeconds = &timeoutSeconds
if err := h.saveConfig(); err != nil {
return err
}
h.logger.Info("HITL 全局默认配置已写入配置文件",
zap.String("default_mode", h.config.Hitl.DefaultMode),
zap.String("default_reviewer", h.config.Hitl.DefaultReviewer),
zap.Int("default_timeout_seconds", timeoutSeconds),
)
return nil
}
// UpdateHitlDefaultReviewer 更新全局默认审批方并写入 config.yaml。
func (h *ConfigHandler) UpdateHitlDefaultReviewer(reviewer string) error {
h.mu.Lock()
+74 -8
View File
@@ -289,6 +289,18 @@ func normalizeHitlMode(mode string) string {
}
}
func normalizeHitlDefaultMode(mode string) string {
v := strings.ToLower(strings.TrimSpace(mode))
switch v {
case "feedback", "followup":
return "approval"
case "approval", "review_edit":
return v
default:
return "off"
}
}
func (m *HITLManager) ActivateConversation(conversationID string, req *HITLRequest) {
if req == nil || !req.Enabled {
m.DeactivateConversation(conversationID)
@@ -629,7 +641,7 @@ func (h *AgentHandler) loadHITLConversationConfig(conversationID string) (*HITLR
return nil, err
}
if !has {
cfg.Reviewer = h.hitlEffectiveDefaultReviewer()
return h.hitlEffectiveDefaultRequest(), nil
}
return cfg, nil
}
@@ -994,7 +1006,9 @@ func (h *AgentHandler) GetHITLConversationConfig(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"conversationId": conversationID,
"hitl": cfg,
"defaultMode": h.hitlEffectiveDefaultMode(),
"defaultReviewer": h.hitlEffectiveDefaultReviewer(),
"defaultTimeoutSeconds": h.hitlEffectiveDefaultTimeoutSeconds(),
"hitlGlobalToolWhitelist": h.hitlConfigGlobalToolWhitelist(),
})
}
@@ -1051,11 +1065,64 @@ type setHitlDefaultReviewerReq struct {
Reviewer string `json:"reviewer"`
}
type setHitlDefaultConfigReq struct {
Mode string `json:"mode"`
Reviewer string `json:"reviewer"`
TimeoutSeconds int `json:"timeoutSeconds"`
}
func (h *AgentHandler) hitlDefaultConfigResponse() gin.H {
return gin.H{
"defaultMode": h.hitlEffectiveDefaultMode(),
"defaultReviewer": h.hitlEffectiveDefaultReviewer(),
"defaultTimeoutSeconds": h.hitlEffectiveDefaultTimeoutSeconds(),
"hitlGlobalToolWhitelist": h.hitlConfigGlobalToolWhitelist(),
}
}
// GetHITLDefaultConfig 返回 config.yaml 中的全局默认人机协同配置。
func (h *AgentHandler) GetHITLDefaultConfig(c *gin.Context) {
c.JSON(http.StatusOK, h.hitlDefaultConfigResponse())
}
// UpdateHITLDefaultConfig 将全局默认人机协同配置写入 config.yaml。
func (h *AgentHandler) UpdateHITLDefaultConfig(c *gin.Context) {
if h.hitlDefaultReviewerSaver == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "HITL 配置持久化不可用"})
return
}
var req setHitlDefaultConfigReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
mode := normalizeHitlDefaultMode(req.Mode)
reviewer := normalizeHitlReviewer(req.Reviewer)
timeoutSeconds := req.TimeoutSeconds
if timeoutSeconds < 0 {
timeoutSeconds = 0
}
if err := h.hitlDefaultReviewerSaver.UpdateHitlDefaultConfig(mode, reviewer, timeoutSeconds); err != nil {
h.logger.Warn("写入 HITL 默认配置到 config.yaml 失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if h.config != nil {
h.config.Hitl.DefaultMode = mode
h.config.Hitl.DefaultReviewer = reviewer
h.config.Hitl.DefaultTimeoutSeconds = &timeoutSeconds
}
if h.audit != nil {
h.audit.RecordOK(c, "hitl", "default_config_update", "HITL 全局默认配置更新", "hitl_config", "default", nil)
}
out := h.hitlDefaultConfigResponse()
out["ok"] = true
c.JSON(http.StatusOK, out)
}
// GetHITLDefaultReviewer 返回 config.yaml 中的全局默认审批方。
func (h *AgentHandler) GetHITLDefaultReviewer(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"defaultReviewer": h.hitlEffectiveDefaultReviewer(),
})
c.JSON(http.StatusOK, h.hitlDefaultConfigResponse())
}
// UpdateHITLDefaultReviewer 将全局默认审批方写入 config.yaml(未选会话时切换审批方)。
@@ -1081,10 +1148,9 @@ func (h *AgentHandler) UpdateHITLDefaultReviewer(c *gin.Context) {
if h.audit != nil {
h.audit.RecordOK(c, "hitl", "default_reviewer_update", "HITL 全局默认审批方更新", "hitl_config", "default_reviewer", nil)
}
c.JSON(http.StatusOK, gin.H{
"ok": true,
"defaultReviewer": reviewer,
})
out := h.hitlDefaultConfigResponse()
out["ok"] = true
c.JSON(http.StatusOK, out)
}
// SetHITLGlobalToolWhitelist 整表替换 config.yaml 中的全局免审批工具白名单。
+1 -1
View File
@@ -217,7 +217,7 @@ func resourceAllowed(c *gin.Context, db *database.DB) bool {
return session.Scope == database.RBACScopeAll
case strings.HasPrefix(path, "/c2/profiles") && c.Request.Method != http.MethodGet:
return session.Scope == database.RBACScopeAll
case (strings.HasPrefix(path, "/hitl/tool-whitelist") || strings.HasPrefix(path, "/hitl/default-reviewer") || strings.HasPrefix(path, "/hitl/audit-strategy")) && c.Request.Method != http.MethodGet:
case (strings.HasPrefix(path, "/hitl/tool-whitelist") || strings.HasPrefix(path, "/hitl/default-config") || strings.HasPrefix(path, "/hitl/default-reviewer") || strings.HasPrefix(path, "/hitl/audit-strategy")) && c.Request.Method != http.MethodGet:
return session.Scope == database.RBACScopeAll
case isMutationMethod(c.Request.Method) && isProcessGlobalMutationPath(path):
// These definitions/configurations are shared by every user and do not