diff --git a/internal/config/config.go b/internal/config/config.go index 99fb4c6a..1712a3fa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,6 +22,7 @@ type Config struct { OpenAI OpenAIConfig `yaml:"openai"` FOFA FofaConfig `yaml:"fofa,omitempty" json:"fofa,omitempty"` Agent AgentConfig `yaml:"agent"` + Hitl HitlConfig `yaml:"hitl,omitempty" json:"hitl,omitempty"` Security SecurityConfig `yaml:"security"` Database DatabaseConfig `yaml:"database"` Auth AuthConfig `yaml:"auth"` @@ -39,21 +40,21 @@ type Config struct { type MultiAgentConfig struct { Enabled bool `yaml:"enabled" json:"enabled"` RobotUseMultiAgent bool `yaml:"robot_use_multi_agent" json:"robot_use_multi_agent"` // 为 true 时钉钉/飞书/企微机器人走 Eino 多代理 - BatchUseMultiAgent bool `yaml:"batch_use_multi_agent" json:"batch_use_multi_agent"` // 为 true 时批量任务队列中每子任务走 Eino 多代理 + BatchUseMultiAgent bool `yaml:"batch_use_multi_agent" json:"batch_use_multi_agent"` // 为 true 时批量任务队列中每子任务走 Eino 多代理 // Orchestration 已弃用:保留仅兼容旧版 config.yaml;编排由聊天/WebShell 请求体 orchestration 决定,未传时按 deep。 Orchestration string `yaml:"orchestration,omitempty" json:"orchestration,omitempty"` MaxIteration int `yaml:"max_iteration" json:"max_iteration"` // 主代理 / 执行器最大推理轮次(Deep、Supervisor、plan_execute 的 Executor) // PlanExecuteLoopMaxIterations plan_execute 模式下 execute↔replan 外层循环上限;0 表示用 Eino 默认 10。 - PlanExecuteLoopMaxIterations int `yaml:"plan_execute_loop_max_iterations,omitempty" json:"plan_execute_loop_max_iterations,omitempty"` - SubAgentMaxIterations int `yaml:"sub_agent_max_iterations" json:"sub_agent_max_iterations"` - WithoutGeneralSubAgent bool `yaml:"without_general_sub_agent" json:"without_general_sub_agent"` - WithoutWriteTodos bool `yaml:"without_write_todos" json:"without_write_todos"` - OrchestratorInstruction string `yaml:"orchestrator_instruction" json:"orchestrator_instruction"` + PlanExecuteLoopMaxIterations int `yaml:"plan_execute_loop_max_iterations,omitempty" json:"plan_execute_loop_max_iterations,omitempty"` + SubAgentMaxIterations int `yaml:"sub_agent_max_iterations" json:"sub_agent_max_iterations"` + WithoutGeneralSubAgent bool `yaml:"without_general_sub_agent" json:"without_general_sub_agent"` + WithoutWriteTodos bool `yaml:"without_write_todos" json:"without_write_todos"` + OrchestratorInstruction string `yaml:"orchestrator_instruction" json:"orchestrator_instruction"` // OrchestratorInstructionPlanExecute plan_execute 主代理(规划侧)系统提示;非空且 agents/orchestrator-plan-execute.md 正文为空或未存在时生效。不与 Deep 的 orchestrator_instruction 混用。 OrchestratorInstructionPlanExecute string `yaml:"orchestrator_instruction_plan_execute,omitempty" json:"orchestrator_instruction_plan_execute,omitempty"` // OrchestratorInstructionSupervisor supervisor 主代理系统提示(transfer/exit 说明仍由运行追加);非空且 agents/orchestrator-supervisor.md 正文为空或未存在时生效。 - OrchestratorInstructionSupervisor string `yaml:"orchestrator_instruction_supervisor,omitempty" json:"orchestrator_instruction_supervisor,omitempty"` - SubAgents []MultiAgentSubConfig `yaml:"sub_agents" json:"sub_agents"` + OrchestratorInstructionSupervisor string `yaml:"orchestrator_instruction_supervisor,omitempty" json:"orchestrator_instruction_supervisor,omitempty"` + SubAgents []MultiAgentSubConfig `yaml:"sub_agents" json:"sub_agents"` // SubAgentUserContextMaxRunes caps the user-context supplement appended to task descriptions for sub-agents. // 0 (default) uses the built-in default of 2000 runes; negative value disables injection entirely. SubAgentUserContextMaxRunes int `yaml:"sub_agent_user_context_max_runes,omitempty" json:"sub_agent_user_context_max_runes,omitempty"` @@ -76,10 +77,10 @@ type MultiAgentEinoMiddlewareConfig struct { // PlantaskRelDir relative to skills_dir for per-conversation task boards (default .eino/plantask). PlantaskRelDir string `yaml:"plantask_rel_dir,omitempty" json:"plantask_rel_dir,omitempty"` // Reduction truncates/offloads large tool outputs (requires eino local backend for Write). - ReductionEnable bool `yaml:"reduction_enable,omitempty" json:"reduction_enable,omitempty"` - ReductionRootDir string `yaml:"reduction_root_dir,omitempty" json:"reduction_root_dir,omitempty"` // default: os temp + conversation id - ReductionClearExclude []string `yaml:"reduction_clear_exclude,omitempty" json:"reduction_clear_exclude,omitempty"` - ReductionSubAgents bool `yaml:"reduction_sub_agents,omitempty" json:"reduction_sub_agents,omitempty"` // also attach to sub-agents + ReductionEnable bool `yaml:"reduction_enable,omitempty" json:"reduction_enable,omitempty"` + ReductionRootDir string `yaml:"reduction_root_dir,omitempty" json:"reduction_root_dir,omitempty"` // default: os temp + conversation id + ReductionClearExclude []string `yaml:"reduction_clear_exclude,omitempty" json:"reduction_clear_exclude,omitempty"` + ReductionSubAgents bool `yaml:"reduction_sub_agents,omitempty" json:"reduction_sub_agents,omitempty"` // also attach to sub-agents // CheckpointDir when non-empty enables adk.Runner CheckPointStore (file-backed) for interrupt/resume persistence. CheckpointDir string `yaml:"checkpoint_dir,omitempty" json:"checkpoint_dir,omitempty"` // DeepOutputKey passed to deep.Config OutputKey (session final text); empty = off. @@ -130,8 +131,8 @@ type MultiAgentSubConfig struct { // MultiAgentPublic 返回给前端的精简信息(不含子代理指令全文)。 type MultiAgentPublic struct { - Enabled bool `json:"enabled"` - RobotUseMultiAgent bool `json:"robot_use_multi_agent"` + Enabled bool `json:"enabled"` + RobotUseMultiAgent bool `json:"robot_use_multi_agent"` BatchUseMultiAgent bool `json:"batch_use_multi_agent"` SubAgentCount int `json:"sub_agent_count"` Orchestration string `json:"orchestration,omitempty"` @@ -155,8 +156,8 @@ func NormalizeMultiAgentOrchestration(s string) string { type MultiAgentAPIUpdate struct { Enabled bool `json:"enabled"` RobotUseMultiAgent bool `json:"robot_use_multi_agent"` - BatchUseMultiAgent bool `json:"batch_use_multi_agent"` - PlanExecuteLoopMaxIterations *int `json:"plan_execute_loop_max_iterations,omitempty"` + BatchUseMultiAgent bool `json:"batch_use_multi_agent"` + PlanExecuteLoopMaxIterations *int `json:"plan_execute_loop_max_iterations,omitempty"` } // RobotsConfig 机器人配置(企业微信、钉钉、飞书等) @@ -244,6 +245,13 @@ type AgentConfig struct { SystemPromptPath string `yaml:"system_prompt_path,omitempty" json:"system_prompt_path,omitempty"` } +// HitlConfig 人机协同全局选项;与会话侧栏/API 中的白名单合并为并集后参与判定。 +// tool_whitelist 可在侧栏「应用」时合并写入 config.yaml 并立即生效;其他字段若仅改文件仍需重启。 +type HitlConfig struct { + // ToolWhitelist 全局免审批工具名(与每条会话配置的 sensitiveTools 语义相同:白名单内工具不触发 HITL)。 + ToolWhitelist []string `yaml:"tool_whitelist,omitempty" json:"tool_whitelist,omitempty"` +} + type AuthConfig struct { Password string `yaml:"password" json:"password"` SessionDurationHours int `yaml:"session_duration_hours" json:"session_duration_hours"` @@ -950,10 +958,10 @@ type RolesConfig struct { // RoleConfig 单个角色配置 type RoleConfig struct { - Name string `yaml:"name" json:"name"` // 角色名称 - Description string `yaml:"description" json:"description"` // 角色描述 - UserPrompt string `yaml:"user_prompt" json:"user_prompt"` // 用户提示词(追加到用户消息前) - Icon string `yaml:"icon,omitempty" json:"icon,omitempty"` // 角色图标(可选) + Name string `yaml:"name" json:"name"` // 角色名称 + Description string `yaml:"description" json:"description"` // 角色描述 + UserPrompt string `yaml:"user_prompt" json:"user_prompt"` // 用户提示词(追加到用户消息前) + Icon string `yaml:"icon,omitempty" json:"icon,omitempty"` // 角色图标(可选) Tools []string `yaml:"tools,omitempty" json:"tools,omitempty"` // 关联的工具列表(toolKey格式,如 "toolName" 或 "mcpName::toolName") MCPs []string `yaml:"mcps,omitempty" json:"mcps,omitempty"` // 向后兼容:关联的MCP服务器列表(已废弃,使用tools替代) Enabled bool `yaml:"enabled" json:"enabled"` // 是否启用 diff --git a/internal/multiagent/eino_adk_run_loop.go b/internal/multiagent/eino_adk_run_loop.go index 9541a33b..ff0e901a 100644 --- a/internal/multiagent/eino_adk_run_loop.go +++ b/internal/multiagent/eino_adk_run_loop.go @@ -230,6 +230,17 @@ attemptLoop: continue } if ev.Err != nil { + if errors.Is(ev.Err, context.DeadlineExceeded) { + flushAllPendingAsFailed(ev.Err) + if progress != nil { + progress("error", ev.Err.Error(), map[string]interface{}{ + "conversationId": conversationID, + "source": "eino", + "errorKind": "timeout", + }) + } + return nil, ev.Err + } // context.Canceled 是唯一应当直接终止编排的错误(用户关闭页面、主动停止等)。 if errors.Is(ev.Err, context.Canceled) { flushAllPendingAsFailed(ev.Err) diff --git a/internal/multiagent/eino_single_runner.go b/internal/multiagent/eino_single_runner.go index df7dd4d6..2f67ab58 100644 --- a/internal/multiagent/eino_single_runner.go +++ b/internal/multiagent/eino_single_runner.go @@ -159,6 +159,7 @@ func RunEinoSingleChatModelAgent( Tools: mainToolsForCfg, UnknownToolsHandler: einomcp.UnknownToolReminderHandler(), ToolCallMiddlewares: []compose.ToolMiddleware{ + {Invokable: hitlToolCallMiddleware()}, {Invokable: softRecoveryToolCallMiddleware()}, }, }, diff --git a/internal/multiagent/hitl_middleware.go b/internal/multiagent/hitl_middleware.go new file mode 100644 index 00000000..2167e1d8 --- /dev/null +++ b/internal/multiagent/hitl_middleware.go @@ -0,0 +1,81 @@ +package multiagent + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/cloudwego/eino/adk" + "github.com/cloudwego/eino/compose" +) + +type hitlInterceptorKey struct{} + +type HITLToolInterceptor func(ctx context.Context, toolName, arguments string) (string, error) + +type humanRejectError struct { + reason string +} + +func (e *humanRejectError) Error() string { + if strings.TrimSpace(e.reason) == "" { + return "rejected by user" + } + return "rejected by user: " + strings.TrimSpace(e.reason) +} + +func NewHumanRejectError(reason string) error { + return &humanRejectError{reason: strings.TrimSpace(reason)} +} + +func IsHumanRejectError(err error) bool { + var target *humanRejectError + return errors.As(err, &target) +} + +func WithHITLToolInterceptor(ctx context.Context, fn HITLToolInterceptor) context.Context { + if fn == nil { + return ctx + } + return context.WithValue(ctx, hitlInterceptorKey{}, fn) +} + +func hitlToolCallMiddleware() compose.InvokableToolMiddleware { + return func(next compose.InvokableToolEndpoint) compose.InvokableToolEndpoint { + return func(ctx context.Context, input *compose.ToolInput) (*compose.ToolOutput, error) { + if input != nil { + if fn, ok := ctx.Value(hitlInterceptorKey{}).(HITLToolInterceptor); ok && fn != nil { + edited, err := fn(ctx, input.Name, input.Arguments) + if err != nil { + if IsHumanRejectError(err) { + // Human rejection should be a soft tool result so the model can continue iterating. + msg := fmt.Sprintf("[HITL Reject] Tool '%s' was rejected by human reviewer. Reason: %s\nPlease adjust parameters/plan and continue without this call.", + input.Name, strings.TrimSpace(err.Error())) + // transfer_to_agent 在 Eino 中标记为 returnDirectly:工具成功后 ReAct 子图会直接 END, + // 并依赖真实工具内的 SendToolGenAction 触发移交。HITL 拒绝时不会执行真实工具, + // 若仍走 returnDirectly 分支,监督者会在无 Transfer 动作的情况下结束,模型不再迭代。 + if strings.EqualFold(strings.TrimSpace(input.Name), adk.TransferToAgentToolName) { + _ = compose.ProcessState[*adk.State](ctx, func(_ context.Context, st *adk.State) error { + if st == nil { + return nil + } + st.ReturnDirectlyToolCallID = "" + st.HasReturnDirectly = false + st.ReturnDirectlyEvent = nil + return nil + }) + } + return &compose.ToolOutput{Result: msg}, nil + } + return nil, err + } + if edited != "" { + input.Arguments = edited + } + } + } + return next(ctx, input) + } + } +} diff --git a/internal/multiagent/runner.go b/internal/multiagent/runner.go index f94c4303..09fc7ce0 100644 --- a/internal/multiagent/runner.go +++ b/internal/multiagent/runner.go @@ -268,6 +268,7 @@ func RunDeepAgent( Tools: subToolsForCfg, UnknownToolsHandler: einomcp.UnknownToolReminderHandler(), ToolCallMiddlewares: []compose.ToolMiddleware{ + {Invokable: hitlToolCallMiddleware()}, {Invokable: softRecoveryToolCallMiddleware()}, }, }, @@ -366,6 +367,7 @@ func RunDeepAgent( Tools: mainToolsForCfg, UnknownToolsHandler: einomcp.UnknownToolReminderHandler(), ToolCallMiddlewares: []compose.ToolMiddleware{ + {Invokable: hitlToolCallMiddleware()}, {Invokable: softRecoveryToolCallMiddleware()}, }, },