diff --git a/internal/multiagent/hitl_middleware.go b/internal/multiagent/hitl_middleware.go index 235bb70b..b4cf23a3 100644 --- a/internal/multiagent/hitl_middleware.go +++ b/internal/multiagent/hitl_middleware.go @@ -3,7 +3,6 @@ package multiagent import ( "context" "errors" - "fmt" "strings" "github.com/cloudwego/eino/adk" @@ -75,8 +74,8 @@ func hitlInvokableToolCallMiddleware() compose.InvokableToolMiddleware { 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 reviewer. Reason: %s\nPlease adjust parameters/plan and continue without this call.", - input.Name, strings.TrimSpace(err.Error())) + // tool_search 须保持 JSON,否则 Eino toolsearch 中间件解析历史时会硬崩 ChatModel。 + msg := HitlRejectToolResult(input.Name, err.Error()) // transfer_to_agent 在 Eino 中标记为 returnDirectly:工具成功后 ReAct 子图会直接 END, // 并依赖真实工具内的 SendToolGenAction 触发移交。HITL 拒绝时不会执行真实工具, // 若仍走 returnDirectly 分支,监督者会在无 Transfer 动作的情况下结束,模型不再迭代。 @@ -103,8 +102,7 @@ func hitlStreamableToolCallMiddleware() compose.StreamableToolMiddleware { edited, err := fn(ctx, input.Name, input.Arguments) if err != nil { if IsHumanRejectError(err) { - msg := fmt.Sprintf("[HITL Reject] Tool '%s' was rejected by reviewer. Reason: %s\nPlease adjust parameters/plan and continue without this call.", - input.Name, strings.TrimSpace(err.Error())) + msg := HitlRejectToolResult(input.Name, err.Error()) hitlClearReturnDirectlyIfTransfer(ctx, input.Name) return &compose.StreamToolOutput{ Result: schema.StreamReaderFromArray([]string{msg}), diff --git a/internal/multiagent/hitl_toolsearch_compat.go b/internal/multiagent/hitl_toolsearch_compat.go new file mode 100644 index 00000000..b75fff00 --- /dev/null +++ b/internal/multiagent/hitl_toolsearch_compat.go @@ -0,0 +1,85 @@ +package multiagent + +import ( + "encoding/json" + "fmt" + "strings" +) + +const toolSearchToolName = "tool_search" + +// HitlExemptMetaTools 为编排/元工具:不直接执行攻击动作,但会阻塞 agent 控制流。 +// tool_search 必须免审批,否则其 HITL 拒绝结果与 Eino toolsearch 中间件不兼容(会硬崩 ChatModel)。 +var HitlExemptMetaTools = []string{ + toolSearchToolName, + "skill", + "task", + "write_todos", + "transfer_to_agent", + "exit", + "TaskCreate", + "TaskGet", + "TaskUpdate", + "TaskList", +} + +// IsToolSearchTool reports whether name is the Eino dynamictool tool_search meta-tool. +func IsToolSearchTool(name string) bool { + return strings.EqualFold(strings.TrimSpace(name), toolSearchToolName) +} + +// MergeHitlExemptMetaTools unions configured whitelist with built-in meta-tool exemptions. +func MergeHitlExemptMetaTools(configured []string) []string { + merged := make([]string, 0, len(configured)+len(HitlExemptMetaTools)) + seen := make(map[string]struct{}, len(configured)+len(HitlExemptMetaTools)) + add := func(name string) { + n := strings.ToLower(strings.TrimSpace(name)) + if n == "" { + return + } + if _, ok := seen[n]; ok { + return + } + seen[n] = struct{}{} + merged = append(merged, strings.TrimSpace(name)) + } + for _, t := range configured { + add(t) + } + for _, t := range HitlExemptMetaTools { + add(t) + } + return merged +} + +type toolSearchHitlRejectPayload struct { + SelectedTools []string `json:"selectedTools"` + HitlRejected bool `json:"_hitlRejected"` + Reason string `json:"reason"` +} + +// HitlRejectToolResult returns a tool result body safe for downstream consumers. +// tool_search must stay JSON-shaped so toolsearch.extractSelectedTools does not terminate the graph. +func HitlRejectToolResult(toolName, reason string) string { + reason = strings.TrimSpace(reason) + if !IsToolSearchTool(toolName) { + if reason == "" { + reason = "rejected by reviewer" + } + return fmt.Sprintf("[HITL Reject] Tool '%s' was rejected by reviewer. Reason: %s\nPlease adjust parameters/plan and continue without this call.", + strings.TrimSpace(toolName), reason) + } + payload := toolSearchHitlRejectPayload{ + SelectedTools: []string{}, + HitlRejected: true, + Reason: reason, + } + if payload.Reason == "" { + payload.Reason = "tool_search rejected by reviewer; no dynamic tools unlocked" + } + out, err := json.Marshal(payload) + if err != nil { + return `{"selectedTools":[],"_hitlRejected":true,"reason":"tool_search rejected by reviewer"}` + } + return string(out) +} diff --git a/internal/multiagent/hitl_toolsearch_compat_test.go b/internal/multiagent/hitl_toolsearch_compat_test.go new file mode 100644 index 00000000..4659bb74 --- /dev/null +++ b/internal/multiagent/hitl_toolsearch_compat_test.go @@ -0,0 +1,48 @@ +package multiagent + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestHitlRejectToolResult_toolSearchIsJSON(t *testing.T) { + raw := HitlRejectToolResult("tool_search", "rejected by user: timeout") + var payload toolSearchHitlRejectPayload + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(payload.SelectedTools) != 0 { + t.Fatalf("expected empty selectedTools, got %v", payload.SelectedTools) + } + if !payload.HitlRejected { + t.Fatal("expected _hitlRejected true") + } + if !strings.Contains(payload.Reason, "timeout") { + t.Fatalf("reason=%q", payload.Reason) + } +} + +func TestHitlRejectToolResult_otherToolKeepsLegacyText(t *testing.T) { + raw := HitlRejectToolResult("nmap", "too risky") + if strings.HasPrefix(raw, "{") { + t.Fatalf("expected legacy text, got %q", raw) + } + if !strings.HasPrefix(raw, "[HITL Reject]") { + t.Fatalf("expected [HITL Reject] prefix, got %q", raw) + } +} + +func TestMergeHitlExemptMetaTools_includesToolSearch(t *testing.T) { + merged := MergeHitlExemptMetaTools([]string{"read_file"}) + found := false + for _, name := range merged { + if IsToolSearchTool(name) { + found = true + break + } + } + if !found { + t.Fatalf("tool_search missing from %v", merged) + } +}