mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 18:15:34 +00:00
6-phase pure refactor — zero behavior change, all tests pass. Phase 1: DRY event payloads + metadata keys - BuildTaskEventPayload() helper with functional options replaces 24 inline constructions - 26 metadata string constants replace ~66 magic strings across dispatch/consumer Phase 2: Consumer handler decomposition - ConsumerDeps struct collapses 11-13 positional params to 3 per handler - Extract startTaskLockRenewal() and resolveTeamTaskOutcome() Phase 3: loop.go extraction (978→686 LOC) - enrichInputMedia(): media persistence, ref collection, tag enrichment - injectTeamTaskReminders(): leader/member task context injection - buildFilteredTools(): policy, bootstrap, channel, iteration filtering - collectRefsByKind() helper eliminates 3x copy-paste Phase 4: team_tasks_mutations split (630→318 LOC) - executeCreate extracted to team_tasks_create.go - resolveTeamAndTask() helper used by 13 execute* functions Phase 5: MCP LoadForAgent split (~90→~15 LOC body) - resolveServerCredentials() + connectAndFilter() helpers Phase 6: Comments + DI - 5 inaccurate comments fixed - ToolExecutor interface for dependency inversion (Loop.tools field) - FilterTools accepts ToolExecutor instead of concrete *Registry
213 lines
6.8 KiB
Go
213 lines
6.8 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
// broadcastTeamEvent sends a real-time event via the message bus for team activity visibility.
|
|
// Includes tenant_id from context for proper WS event filtering.
|
|
func (m *TeamToolManager) broadcastTeamEvent(ctx context.Context, name string, payload any) {
|
|
if m.msgBus == nil {
|
|
return
|
|
}
|
|
bus.BroadcastForTenant(m.msgBus, name, store.TenantIDFromContext(ctx), payload)
|
|
}
|
|
|
|
// resolveTeamRole returns the calling agent's role in the team.
|
|
// Unlike requireLead(), this does NOT bypass for teammate channel —
|
|
// workspace RBAC must respect actual roles even for teammate agents.
|
|
func (m *TeamToolManager) resolveTeamRole(ctx context.Context, team *store.TeamData, agentID uuid.UUID) (string, error) {
|
|
if agentID == team.LeadAgentID {
|
|
return store.TeamRoleLead, nil
|
|
}
|
|
members, err := m.cachedListMembers(ctx, team.ID, agentID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to resolve team role: %w", err)
|
|
}
|
|
for _, member := range members {
|
|
if member.AgentID == agentID {
|
|
return member.Role, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("agent is not a member of this team")
|
|
}
|
|
|
|
// agentDisplayName returns the display name for an agent key, falling back to empty string.
|
|
func (m *TeamToolManager) agentDisplayName(ctx context.Context, key string) string {
|
|
ag, err := m.cachedGetAgentByKey(ctx, key)
|
|
if err != nil || ag.DisplayName == "" {
|
|
return ""
|
|
}
|
|
return ag.DisplayName
|
|
}
|
|
|
|
// ============================================================
|
|
// TeamToolBackend exported wrappers (helpers layer)
|
|
// ============================================================
|
|
|
|
func (m *TeamToolManager) BroadcastTeamEvent(ctx context.Context, name string, payload any) {
|
|
m.broadcastTeamEvent(ctx, name, payload)
|
|
}
|
|
func (m *TeamToolManager) AgentDisplayName(ctx context.Context, key string) string {
|
|
return m.agentDisplayName(ctx, key)
|
|
}
|
|
func (m *TeamToolManager) FollowupDelayMinutes(team *store.TeamData) int {
|
|
return m.followupDelayMinutes(team)
|
|
}
|
|
func (m *TeamToolManager) FollowupMaxReminders(team *store.TeamData) int {
|
|
return m.followupMaxReminders(team)
|
|
}
|
|
|
|
// ============================================================
|
|
// Version helpers
|
|
// ============================================================
|
|
|
|
|
|
// ============================================================
|
|
// Follow-up settings helpers
|
|
// ============================================================
|
|
|
|
const (
|
|
defaultFollowupDelayMinutes = 30
|
|
defaultFollowupMaxReminders = 0 // 0 = unlimited
|
|
)
|
|
|
|
// followupDelayMinutes returns the team's followup_interval_minutes setting, or the default.
|
|
func (m *TeamToolManager) followupDelayMinutes(team *store.TeamData) int {
|
|
if team == nil || team.Settings == nil {
|
|
return defaultFollowupDelayMinutes
|
|
}
|
|
var settings map[string]any
|
|
if json.Unmarshal(team.Settings, &settings) != nil {
|
|
return defaultFollowupDelayMinutes
|
|
}
|
|
if v, ok := settings["followup_interval_minutes"].(float64); ok && v > 0 {
|
|
return int(v)
|
|
}
|
|
return defaultFollowupDelayMinutes
|
|
}
|
|
|
|
// followupMaxReminders returns the team's followup_max_reminders setting, or the default.
|
|
func (m *TeamToolManager) followupMaxReminders(team *store.TeamData) int {
|
|
if team == nil || team.Settings == nil {
|
|
return defaultFollowupMaxReminders
|
|
}
|
|
var settings map[string]any
|
|
if json.Unmarshal(team.Settings, &settings) != nil {
|
|
return defaultFollowupMaxReminders
|
|
}
|
|
if v, ok := settings["followup_max_reminders"].(float64); ok && v >= 0 {
|
|
return int(v)
|
|
}
|
|
return defaultFollowupMaxReminders
|
|
}
|
|
|
|
// ============================================================
|
|
// Escalation policy
|
|
// ============================================================
|
|
|
|
// EscalationResult indicates how an action should be handled.
|
|
type EscalationResult int
|
|
|
|
const (
|
|
EscalationNone EscalationResult = iota // no escalation configured
|
|
EscalationAuto // LLM chooses (currently: always review)
|
|
EscalationReview // create review task
|
|
EscalationReject // reject outright
|
|
)
|
|
|
|
// checkEscalation parses the team's escalation_mode and escalation_actions settings.
|
|
func (m *TeamToolManager) checkEscalation(team *store.TeamData, action string) EscalationResult {
|
|
if team == nil || team.Settings == nil {
|
|
return EscalationNone
|
|
}
|
|
var settings map[string]any
|
|
if err := json.Unmarshal(team.Settings, &settings); err != nil {
|
|
return EscalationNone
|
|
}
|
|
|
|
mode, _ := settings["escalation_mode"].(string)
|
|
if mode == "" {
|
|
return EscalationNone
|
|
}
|
|
|
|
// Check if action is in escalation_actions list.
|
|
actionsRaw, _ := settings["escalation_actions"].([]any)
|
|
if len(actionsRaw) > 0 {
|
|
found := false
|
|
for _, a := range actionsRaw {
|
|
if s, ok := a.(string); ok && s == action {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return EscalationNone
|
|
}
|
|
}
|
|
|
|
switch mode {
|
|
case "auto":
|
|
return EscalationAuto
|
|
case "review":
|
|
return EscalationReview
|
|
case "reject":
|
|
return EscalationReject
|
|
default:
|
|
return EscalationNone
|
|
}
|
|
}
|
|
|
|
// createEscalationTask creates an escalation task and broadcasts the event.
|
|
func (m *TeamToolManager) createEscalationTask(ctx context.Context, team *store.TeamData, agentID uuid.UUID, subject, description string) *Result {
|
|
task := &store.TeamTaskData{
|
|
TeamID: team.ID,
|
|
Subject: subject,
|
|
Description: description,
|
|
Status: store.TeamTaskStatusPending,
|
|
UserID: store.UserIDFromContext(ctx),
|
|
Channel: ToolChannelFromCtx(ctx),
|
|
TaskType: "escalation",
|
|
CreatedByAgentID: &agentID,
|
|
ChatID: ToolChatIDFromCtx(ctx),
|
|
}
|
|
if err := m.teamStore.CreateTask(ctx, task); err != nil {
|
|
return ErrorResult("failed to create escalation task: " + err.Error())
|
|
}
|
|
|
|
m.broadcastTeamEvent(ctx, protocol.EventTeamTaskCreated, BuildTaskEventPayload(
|
|
team.ID.String(), task.ID.String(),
|
|
store.TeamTaskStatusPending,
|
|
"", "",
|
|
WithSubject(subject),
|
|
WithContextInfo(ctx),
|
|
WithTimestamp(task.CreatedAt.UTC().Format("2006-01-02T15:04:05Z")),
|
|
))
|
|
|
|
// Notify channel if possible.
|
|
m.notifyChannelReview(task)
|
|
|
|
return NewResult(fmt.Sprintf("Action requires approval. Escalation task created: %s (id=%s). A human must approve before this action can proceed.", subject, task.Identifier))
|
|
}
|
|
|
|
// notifyChannelReview publishes an outbound message to the origin channel about a pending review.
|
|
func (m *TeamToolManager) notifyChannelReview(task *store.TeamTaskData) {
|
|
if m.msgBus == nil || task.Channel == "" || task.ChatID == "" {
|
|
return
|
|
}
|
|
content := fmt.Sprintf("🔔 Escalation: \"%s\" requires human review (task %s).", task.Subject, task.Identifier)
|
|
m.msgBus.PublishOutbound(bus.OutboundMessage{
|
|
Channel: task.Channel,
|
|
ChatID: task.ChatID,
|
|
Content: content,
|
|
})
|
|
}
|