diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 2bf9173d..38b1a6d2 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -10,286 +10,18 @@ import ( "sort" "strings" "sync" - "sync/atomic" "time" "github.com/google/uuid" - "github.com/nextlevelbuilder/goclaw/internal/bootstrap" - "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/providers" - "github.com/nextlevelbuilder/goclaw/internal/skills" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/tools" "github.com/nextlevelbuilder/goclaw/internal/tracing" "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) -// bootstrapAutoCleanupTurns is the number of user messages after which -// BOOTSTRAP.md is auto-removed if the LLM hasn't cleared it. -// Bootstrap typically completes in 2-3 conversation turns. -const bootstrapAutoCleanupTurns = 3 - -// EnsureUserFilesFunc seeds per-user context files on first chat (managed mode). -// Returns the effective workspace path (from user_agent_profiles) for caching. -type EnsureUserFilesFunc func(ctx context.Context, agentID uuid.UUID, userID, agentType, workspace, channel string) (effectiveWorkspace string, err error) - -// ContextFileLoaderFunc loads context files dynamically per-request (managed mode). -type ContextFileLoaderFunc func(ctx context.Context, agentID uuid.UUID, userID, agentType string) []bootstrap.ContextFile - -// BootstrapCleanupFunc removes BOOTSTRAP.md after a successful first run. -// Called automatically so the system doesn't rely on the LLM to delete it. -type BootstrapCleanupFunc func(ctx context.Context, agentID uuid.UUID, userID string) error - -// Loop is the agent execution loop for one agent instance. -// Think → Act → Observe cycle with tool execution. -type Loop struct { - id string - agentUUID uuid.UUID // set in managed mode for context propagation - agentType string // "open" or "predefined" (managed mode) - provider providers.Provider - model string - contextWindow int - maxIterations int - maxToolCalls int - workspace string - - eventPub bus.EventPublisher // currently unused by Loop; kept for future use - sessions store.SessionStore - tools *tools.Registry - toolPolicy *tools.PolicyEngine // optional: filters tools sent to LLM - agentToolPolicy *config.ToolPolicySpec // per-agent tool policy from DB (nil = no restrictions) - activeRuns atomic.Int32 // number of currently executing runs - - // Per-session summarization lock: prevents concurrent summarize goroutines for the same session. - summarizeMu sync.Map // sessionKey → *sync.Mutex - - // Bootstrap/persona context (loaded at startup, injected into system prompt) - ownerIDs []string - skillsLoader *skills.Loader - skillAllowList []string // nil = all, [] = none, ["x","y"] = filter - hasMemory bool - contextFiles []bootstrap.ContextFile - - // Per-user file seeding + dynamic context loading (managed mode) - ensureUserFiles EnsureUserFilesFunc - contextFileLoader ContextFileLoaderFunc - bootstrapCleanup BootstrapCleanupFunc - userWorkspaces sync.Map // userID → string (expanded workspace path from user_agent_profiles) - - // Compaction config (memory flush settings) - compactionCfg *config.CompactionConfig - - // Context pruning config (trim old tool results in-memory) - contextPruningCfg *config.ContextPruningConfig - - // Sandbox info - sandboxEnabled bool - sandboxContainerDir string - sandboxWorkspaceAccess string - - // Event callback for broadcasting agent events (run.started, chunk, tool.call, etc.) - onEvent func(event AgentEvent) - - // Tracing collector (nil in standalone mode) - traceCollector *tracing.Collector - - // Security: input scanning and message size limit - inputGuard *InputGuard - injectionAction string // "log", "warn" (default), "block", "off" - maxMessageChars int // 0 = use default (32000) - - // Global builtin tool settings (from builtin_tools table, managed mode) - builtinToolSettings tools.BuiltinToolSettings - - // Thinking level for extended thinking support - thinkingLevel string - - // Group writer cache for system prompt injection (managed mode) - groupWriterCache *store.GroupWriterCache - - // Team store for cross-session pending task detection (managed mode) - teamStore store.TeamStore -} - -// AgentEvent is emitted during agent execution for WS broadcasting. -type AgentEvent struct { - Type string `json:"type"` // "run.started", "run.completed", "run.failed", "chunk", "tool.call", "tool.result" - AgentID string `json:"agentId"` - RunID string `json:"runId"` - Payload interface{} `json:"payload,omitempty"` -} - -// LoopConfig configures a new Loop. -type LoopConfig struct { - ID string - Provider providers.Provider - Model string - ContextWindow int - MaxIterations int - MaxToolCalls int - Workspace string - Bus bus.EventPublisher - Sessions store.SessionStore - Tools *tools.Registry - ToolPolicy *tools.PolicyEngine // optional: filters tools sent to LLM - AgentToolPolicy *config.ToolPolicySpec // per-agent tool policy from DB (nil = no restrictions) - OnEvent func(AgentEvent) - - // Bootstrap/persona context - OwnerIDs []string - SkillsLoader *skills.Loader - SkillAllowList []string // nil = all, [] = none, ["x","y"] = filter - HasMemory bool - ContextFiles []bootstrap.ContextFile - - // Compaction config - CompactionCfg *config.CompactionConfig - - // Context pruning (trim old tool results to save context window) - ContextPruningCfg *config.ContextPruningConfig - - // Sandbox info (injected into system prompt) - SandboxEnabled bool - SandboxContainerDir string // e.g. "/workspace" - SandboxWorkspaceAccess string // "none", "ro", "rw" - - // Managed mode: agent UUID for context propagation to tools - AgentUUID uuid.UUID - AgentType string // "open" or "predefined" (managed mode) - - // Per-user file seeding + dynamic context loading (managed mode) - EnsureUserFiles EnsureUserFilesFunc - ContextFileLoader ContextFileLoaderFunc - BootstrapCleanup BootstrapCleanupFunc - - // Tracing collector (nil = no tracing) - TraceCollector *tracing.Collector - - // Security: input guard for injection detection, max message size - InputGuard *InputGuard // nil = auto-create when InjectionAction != "off" - InjectionAction string // "log", "warn" (default), "block", "off" - MaxMessageChars int // 0 = use default (32000) - - // Global builtin tool settings (from builtin_tools table, managed mode) - BuiltinToolSettings tools.BuiltinToolSettings - - // Thinking level: "off", "low", "medium", "high" (from agent other_config) - ThinkingLevel string - - // Group writer cache for system prompt injection (managed mode) - GroupWriterCache *store.GroupWriterCache - - // Team store for cross-session pending task detection (managed mode) - TeamStore store.TeamStore -} - -func NewLoop(cfg LoopConfig) *Loop { - if cfg.MaxIterations <= 0 { - cfg.MaxIterations = 20 - } - if cfg.ContextWindow <= 0 { - cfg.ContextWindow = 200000 - } - - // Normalize injection action (default: "warn") - action := cfg.InjectionAction - switch action { - case "log", "warn", "block", "off": - // valid - default: - action = "warn" - } - - // Auto-create InputGuard unless explicitly disabled - guard := cfg.InputGuard - if guard == nil && action != "off" { - guard = NewInputGuard() - } - - return &Loop{ - id: cfg.ID, - agentUUID: cfg.AgentUUID, - agentType: cfg.AgentType, - provider: cfg.Provider, - model: cfg.Model, - contextWindow: cfg.ContextWindow, - maxIterations: cfg.MaxIterations, - maxToolCalls: cfg.MaxToolCalls, - workspace: cfg.Workspace, - eventPub: cfg.Bus, - sessions: cfg.Sessions, - tools: cfg.Tools, - toolPolicy: cfg.ToolPolicy, - agentToolPolicy: cfg.AgentToolPolicy, - onEvent: cfg.OnEvent, - ownerIDs: cfg.OwnerIDs, - skillsLoader: cfg.SkillsLoader, - skillAllowList: cfg.SkillAllowList, - hasMemory: cfg.HasMemory, - contextFiles: cfg.ContextFiles, - ensureUserFiles: cfg.EnsureUserFiles, - contextFileLoader: cfg.ContextFileLoader, - bootstrapCleanup: cfg.BootstrapCleanup, - compactionCfg: cfg.CompactionCfg, - contextPruningCfg: cfg.ContextPruningCfg, - sandboxEnabled: cfg.SandboxEnabled, - sandboxContainerDir: cfg.SandboxContainerDir, - sandboxWorkspaceAccess: cfg.SandboxWorkspaceAccess, - traceCollector: cfg.TraceCollector, - inputGuard: guard, - injectionAction: action, - maxMessageChars: cfg.MaxMessageChars, - builtinToolSettings: cfg.BuiltinToolSettings, - thinkingLevel: cfg.ThinkingLevel, - groupWriterCache: cfg.GroupWriterCache, - teamStore: cfg.TeamStore, - } -} - -// RunRequest is the input for processing a message through the agent. -type RunRequest struct { - SessionKey string // composite key: agent:{agentId}:{channel}:{peerKind}:{chatId} - Message string // user message - Media []string // local file paths to images (already sanitized) - ForwardMedia []string // media paths to forward to output (not deleted, from delegation results) - Channel string // source channel - ChatID string // source chat ID - PeerKind string // "direct" or "group" (for session key building and tool context) - RunID string // unique run identifier - UserID string // external user ID (TEXT, free-form) for multi-tenant scoping - SenderID string // original individual sender ID (preserved in group chats for permission checks) - Stream bool // whether to stream response chunks - ExtraSystemPrompt string // optional: injected into system prompt (skills, subagent context, etc.) - SkillFilter []string // per-request skill override: nil=use agent default, []=no skills, ["x","y"]=whitelist - HistoryLimit int // max user turns to keep in context (0=unlimited, from channel config) - ToolAllow []string // per-group tool allow list (nil = no restriction, supports "group:xxx") - LocalKey string // composite key with topic/thread suffix for routing (e.g. "-100123:topic:42") - ParentTraceID uuid.UUID // if set, reuse parent trace instead of creating new (announce runs) - ParentRootSpanID uuid.UUID // if set, nest announce agent span under this parent span - TraceName string // override trace name (default: "chat ") - TraceTags []string // additional tags for the trace (e.g. "cron") - MaxIterations int // per-request override (0 = use agent default, must be lower) -} - -// RunResult is the output of a completed agent run. -type RunResult struct { - Content string `json:"content"` - RunID string `json:"runId"` - Iterations int `json:"iterations"` - Usage *providers.Usage `json:"usage,omitempty"` - Media []MediaResult `json:"media,omitempty"` // media files from tool results (MEDIA: prefix) - Deliverables []string `json:"deliverables,omitempty"` // actual content from tool outputs (for team task results) -} - -// MediaResult represents a media file produced by a tool during the agent run. -type MediaResult struct { - Path string `json:"path"` // local file path - ContentType string `json:"content_type,omitempty"` // MIME type - AsVoice bool `json:"as_voice,omitempty"` // send as voice message (Telegram OGG) -} - // Run processes a single message through the agent loop. // It blocks until completion and returns the final response. func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) { @@ -1122,191 +854,3 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) }, nil } -// compactMessagesInPlace summarizes the first ~70% of messages into a condensed -// summary, keeping the last ~30% intact. Operates purely on the local messages -// slice — no session state touched, no locks needed. -// Returns nil on failure (caller keeps original messages). -func (l *Loop) compactMessagesInPlace(ctx context.Context, messages []providers.Message) []providers.Message { - if len(messages) < 6 { - return nil - } - - // Resolve keepCount from compaction config (same defaults as maybeSummarize). - keepCount := 4 - if l.compactionCfg != nil && l.compactionCfg.KeepLastMessages > 0 { - keepCount = l.compactionCfg.KeepLastMessages - } - // Ensure we keep at least 30% of messages. - if minKeep := len(messages) * 3 / 10; minKeep > keepCount { - keepCount = minKeep - } - - splitIdx := len(messages) - keepCount - - // Walk backward from splitIdx to find a clean boundary — - // avoid splitting tool_use → tool_result pairs. - for splitIdx > 0 { - m := messages[splitIdx] - if m.Role == "tool" || (m.Role == "assistant" && len(m.ToolCalls) > 0) { - splitIdx-- - continue - } - break - } - if splitIdx <= 1 { - return nil - } - - // Build summary input (same pattern as maybeSummarize in loop_history.go). - toSummarize := messages[:splitIdx] - var sb strings.Builder - for _, m := range toSummarize { - switch m.Role { - case "user": - fmt.Fprintf(&sb, "user: %s\n", m.Content) - case "assistant": - fmt.Fprintf(&sb, "assistant: %s\n", SanitizeAssistantContent(m.Content)) - } - } - - sctx, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() - - resp, err := l.provider.Chat(sctx, providers.ChatRequest{ - Messages: []providers.Message{{ - Role: "user", - Content: "Provide a concise summary of this conversation, preserving key findings, data, and context:\n\n" + sb.String(), - }}, - Model: l.model, - Options: map[string]interface{}{"max_tokens": 1024, "temperature": 0.3}, - }) - if err != nil { - slog.Warn("mid_loop_compaction_failed", "agent", l.id, "error", err) - return nil - } - - summary := providers.Message{ - Role: "user", - Content: "[Summary of earlier conversation]\n" + SanitizeAssistantContent(resp.Content), - } - result := make([]providers.Message, 0, 1+keepCount) - result = append(result, summary) - result = append(result, messages[splitIdx:]...) - - slog.Info("mid_loop_compacted", - "agent", l.id, - "original_msgs", len(messages), - "summarized", splitIdx, - "kept", len(result)) - - return result -} - -// parseMediaResult extracts a MediaResult from a tool result string containing "MEDIA:" prefix. -// Handles formats: "MEDIA:/path/to/file" and "[[audio_as_voice]]\nMEDIA:/path/to/file". -// Returns nil if no MEDIA: prefix is found. -// -// IMPORTANT: Only matches "MEDIA:" at the start of the (trimmed) string to avoid false -// positives when tool output contains "MEDIA:" in arbitrary text (e.g. a web page -// mentioning a commit message like "return MEDIA: path from screenshot"). -func parseMediaResult(toolOutput string) *MediaResult { - s := toolOutput - asVoice := false - - // Check for [[audio_as_voice]] tag (TTS voice messages) - if strings.Contains(s, "[[audio_as_voice]]") { - asVoice = true - s = strings.ReplaceAll(s, "[[audio_as_voice]]", "") - } - - s = strings.TrimSpace(s) - - // Only match MEDIA: at the beginning of the string. - if !strings.HasPrefix(s, "MEDIA:") { - return nil - } - path := strings.TrimSpace(s[6:]) - if path == "" { - return nil - } - // Take only the first line (in case there's trailing text) - if nl := strings.IndexByte(path, '\n'); nl >= 0 { - path = strings.TrimSpace(path[:nl]) - } - - return &MediaResult{ - Path: path, - ContentType: mimeFromExt(filepath.Ext(path)), - AsVoice: asVoice, - } -} - -// mimeFromExt returns a MIME type for common media file extensions. -func mimeFromExt(ext string) string { - switch strings.ToLower(ext) { - case ".png": - return "image/png" - case ".jpg", ".jpeg": - return "image/jpeg" - case ".gif": - return "image/gif" - case ".webp": - return "image/webp" - case ".mp4": - return "video/mp4" - case ".ogg", ".opus": - return "audio/ogg" - case ".mp3": - return "audio/mpeg" - case ".wav": - return "audio/wav" - case ".txt": - return "text/plain" - case ".pdf": - return "application/pdf" - case ".csv": - return "text/csv" - case ".json": - return "application/json" - case ".html", ".htm": - return "text/html" - case ".xml": - return "application/xml" - case ".zip": - return "application/zip" - case ".doc", ".docx": - return "application/msword" - case ".xls", ".xlsx": - return "application/vnd.ms-excel" - default: - return "application/octet-stream" - } -} - -// sanitizePathSegment makes a userID safe for use as a directory name. -// Replaces colons, spaces, and other unsafe chars with underscores. -func sanitizePathSegment(s string) string { - var b strings.Builder - for _, r := range s { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { - b.WriteRune(r) - } else { - b.WriteByte('_') - } - } - return b.String() -} - -// InvalidateUserWorkspace clears the cached workspace for a user, -// forcing the next request to re-read from user_agent_profiles. -func (l *Loop) InvalidateUserWorkspace(userID string) { - l.userWorkspaces.Delete(userID) -} - -// ProviderName returns the name of this agent's LLM provider (e.g. "anthropic", "openai"). -func (l *Loop) ProviderName() string { - if l.provider == nil { - return "" - } - return l.provider.Name() -} diff --git a/internal/agent/loop_compact.go b/internal/agent/loop_compact.go new file mode 100644 index 00000000..dced5040 --- /dev/null +++ b/internal/agent/loop_compact.go @@ -0,0 +1,91 @@ +package agent + +import ( + "context" + "fmt" + "log/slog" + "strings" + "time" + + "github.com/nextlevelbuilder/goclaw/internal/providers" +) + +// compactMessagesInPlace summarizes the first ~70% of messages into a condensed +// summary, keeping the last ~30% intact. Operates purely on the local messages +// slice — no session state touched, no locks needed. +// Returns nil on failure (caller keeps original messages). +func (l *Loop) compactMessagesInPlace(ctx context.Context, messages []providers.Message) []providers.Message { + if len(messages) < 6 { + return nil + } + + // Resolve keepCount from compaction config (same defaults as maybeSummarize). + keepCount := 4 + if l.compactionCfg != nil && l.compactionCfg.KeepLastMessages > 0 { + keepCount = l.compactionCfg.KeepLastMessages + } + // Ensure we keep at least 30% of messages. + if minKeep := len(messages) * 3 / 10; minKeep > keepCount { + keepCount = minKeep + } + + splitIdx := len(messages) - keepCount + + // Walk backward from splitIdx to find a clean boundary — + // avoid splitting tool_use → tool_result pairs. + for splitIdx > 0 { + m := messages[splitIdx] + if m.Role == "tool" || (m.Role == "assistant" && len(m.ToolCalls) > 0) { + splitIdx-- + continue + } + break + } + if splitIdx <= 1 { + return nil + } + + // Build summary input (same pattern as maybeSummarize in loop_history.go). + toSummarize := messages[:splitIdx] + var sb strings.Builder + for _, m := range toSummarize { + switch m.Role { + case "user": + fmt.Fprintf(&sb, "user: %s\n", m.Content) + case "assistant": + fmt.Fprintf(&sb, "assistant: %s\n", SanitizeAssistantContent(m.Content)) + } + } + + sctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + resp, err := l.provider.Chat(sctx, providers.ChatRequest{ + Messages: []providers.Message{{ + Role: "user", + Content: "Provide a concise summary of this conversation, preserving key findings, data, and context:\n\n" + sb.String(), + }}, + Model: l.model, + Options: map[string]interface{}{"max_tokens": 1024, "temperature": 0.3}, + }) + if err != nil { + slog.Warn("mid_loop_compaction_failed", "agent", l.id, "error", err) + return nil + } + + summary := providers.Message{ + Role: "user", + Content: "[Summary of earlier conversation]\n" + SanitizeAssistantContent(resp.Content), + } + result := make([]providers.Message, 0, 1+keepCount) + result = append(result, summary) + result = append(result, messages[splitIdx:]...) + + slog.Info("mid_loop_compacted", + "agent", l.id, + "original_msgs", len(messages), + "summarized", splitIdx, + "kept", len(result)) + + return result +} diff --git a/internal/agent/loop_media.go b/internal/agent/loop_media.go new file mode 100644 index 00000000..a2a45451 --- /dev/null +++ b/internal/agent/loop_media.go @@ -0,0 +1,87 @@ +package agent + +import ( + "path/filepath" + "strings" +) + +// parseMediaResult extracts a MediaResult from a tool result string containing "MEDIA:" prefix. +// Handles formats: "MEDIA:/path/to/file" and "[[audio_as_voice]]\nMEDIA:/path/to/file". +// Returns nil if no MEDIA: prefix is found. +// +// IMPORTANT: Only matches "MEDIA:" at the start of the (trimmed) string to avoid false +// positives when tool output contains "MEDIA:" in arbitrary text (e.g. a web page +// mentioning a commit message like "return MEDIA: path from screenshot"). +func parseMediaResult(toolOutput string) *MediaResult { + s := toolOutput + asVoice := false + + // Check for [[audio_as_voice]] tag (TTS voice messages) + if strings.Contains(s, "[[audio_as_voice]]") { + asVoice = true + s = strings.ReplaceAll(s, "[[audio_as_voice]]", "") + } + + s = strings.TrimSpace(s) + + // Only match MEDIA: at the beginning of the string. + if !strings.HasPrefix(s, "MEDIA:") { + return nil + } + path := strings.TrimSpace(s[6:]) + if path == "" { + return nil + } + // Take only the first line (in case there's trailing text) + if nl := strings.IndexByte(path, '\n'); nl >= 0 { + path = strings.TrimSpace(path[:nl]) + } + + return &MediaResult{ + Path: path, + ContentType: mimeFromExt(filepath.Ext(path)), + AsVoice: asVoice, + } +} + +// mimeFromExt returns a MIME type for common media file extensions. +func mimeFromExt(ext string) string { + switch strings.ToLower(ext) { + case ".png": + return "image/png" + case ".jpg", ".jpeg": + return "image/jpeg" + case ".gif": + return "image/gif" + case ".webp": + return "image/webp" + case ".mp4": + return "video/mp4" + case ".ogg", ".opus": + return "audio/ogg" + case ".mp3": + return "audio/mpeg" + case ".wav": + return "audio/wav" + case ".txt": + return "text/plain" + case ".pdf": + return "application/pdf" + case ".csv": + return "text/csv" + case ".json": + return "application/json" + case ".html", ".htm": + return "text/html" + case ".xml": + return "application/xml" + case ".zip": + return "application/zip" + case ".doc", ".docx": + return "application/msword" + case ".xls", ".xlsx": + return "application/vnd.ms-excel" + default: + return "application/octet-stream" + } +} diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go new file mode 100644 index 00000000..cbb56b38 --- /dev/null +++ b/internal/agent/loop_types.go @@ -0,0 +1,282 @@ +package agent + +import ( + "context" + "sync" + "sync/atomic" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/bootstrap" + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/skills" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/tools" + "github.com/nextlevelbuilder/goclaw/internal/tracing" +) + +// bootstrapAutoCleanupTurns is the number of user messages after which +// BOOTSTRAP.md is auto-removed if the LLM hasn't cleared it. +// Bootstrap typically completes in 2-3 conversation turns. +const bootstrapAutoCleanupTurns = 3 + +// EnsureUserFilesFunc seeds per-user context files on first chat (managed mode). +// Returns the effective workspace path (from user_agent_profiles) for caching. +type EnsureUserFilesFunc func(ctx context.Context, agentID uuid.UUID, userID, agentType, workspace, channel string) (effectiveWorkspace string, err error) + +// ContextFileLoaderFunc loads context files dynamically per-request (managed mode). +type ContextFileLoaderFunc func(ctx context.Context, agentID uuid.UUID, userID, agentType string) []bootstrap.ContextFile + +// BootstrapCleanupFunc removes BOOTSTRAP.md after a successful first run. +// Called automatically so the system doesn't rely on the LLM to delete it. +type BootstrapCleanupFunc func(ctx context.Context, agentID uuid.UUID, userID string) error + +// Loop is the agent execution loop for one agent instance. +// Think → Act → Observe cycle with tool execution. +type Loop struct { + id string + agentUUID uuid.UUID // set in managed mode for context propagation + agentType string // "open" or "predefined" (managed mode) + provider providers.Provider + model string + contextWindow int + maxIterations int + maxToolCalls int + workspace string + + eventPub bus.EventPublisher // currently unused by Loop; kept for future use + sessions store.SessionStore + tools *tools.Registry + toolPolicy *tools.PolicyEngine // optional: filters tools sent to LLM + agentToolPolicy *config.ToolPolicySpec // per-agent tool policy from DB (nil = no restrictions) + activeRuns atomic.Int32 // number of currently executing runs + + // Per-session summarization lock: prevents concurrent summarize goroutines for the same session. + summarizeMu sync.Map // sessionKey → *sync.Mutex + + // Bootstrap/persona context (loaded at startup, injected into system prompt) + ownerIDs []string + skillsLoader *skills.Loader + skillAllowList []string // nil = all, [] = none, ["x","y"] = filter + hasMemory bool + contextFiles []bootstrap.ContextFile + + // Per-user file seeding + dynamic context loading (managed mode) + ensureUserFiles EnsureUserFilesFunc + contextFileLoader ContextFileLoaderFunc + bootstrapCleanup BootstrapCleanupFunc + userWorkspaces sync.Map // userID → string (expanded workspace path from user_agent_profiles) + + // Compaction config (memory flush settings) + compactionCfg *config.CompactionConfig + + // Context pruning config (trim old tool results in-memory) + contextPruningCfg *config.ContextPruningConfig + + // Sandbox info + sandboxEnabled bool + sandboxContainerDir string + sandboxWorkspaceAccess string + + // Event callback for broadcasting agent events (run.started, chunk, tool.call, etc.) + onEvent func(event AgentEvent) + + // Tracing collector (nil in standalone mode) + traceCollector *tracing.Collector + + // Security: input scanning and message size limit + inputGuard *InputGuard + injectionAction string // "log", "warn" (default), "block", "off" + maxMessageChars int // 0 = use default (32000) + + // Global builtin tool settings (from builtin_tools table, managed mode) + builtinToolSettings tools.BuiltinToolSettings + + // Thinking level for extended thinking support + thinkingLevel string + + // Group writer cache for system prompt injection (managed mode) + groupWriterCache *store.GroupWriterCache + + // Team store for cross-session pending task detection (managed mode) + teamStore store.TeamStore +} + +// AgentEvent is emitted during agent execution for WS broadcasting. +type AgentEvent struct { + Type string `json:"type"` // "run.started", "run.completed", "run.failed", "chunk", "tool.call", "tool.result" + AgentID string `json:"agentId"` + RunID string `json:"runId"` + Payload interface{} `json:"payload,omitempty"` +} + +// LoopConfig configures a new Loop. +type LoopConfig struct { + ID string + Provider providers.Provider + Model string + ContextWindow int + MaxIterations int + MaxToolCalls int + Workspace string + Bus bus.EventPublisher + Sessions store.SessionStore + Tools *tools.Registry + ToolPolicy *tools.PolicyEngine // optional: filters tools sent to LLM + AgentToolPolicy *config.ToolPolicySpec // per-agent tool policy from DB (nil = no restrictions) + OnEvent func(AgentEvent) + + // Bootstrap/persona context + OwnerIDs []string + SkillsLoader *skills.Loader + SkillAllowList []string // nil = all, [] = none, ["x","y"] = filter + HasMemory bool + ContextFiles []bootstrap.ContextFile + + // Compaction config + CompactionCfg *config.CompactionConfig + + // Context pruning (trim old tool results to save context window) + ContextPruningCfg *config.ContextPruningConfig + + // Sandbox info (injected into system prompt) + SandboxEnabled bool + SandboxContainerDir string // e.g. "/workspace" + SandboxWorkspaceAccess string // "none", "ro", "rw" + + // Managed mode: agent UUID for context propagation to tools + AgentUUID uuid.UUID + AgentType string // "open" or "predefined" (managed mode) + + // Per-user file seeding + dynamic context loading (managed mode) + EnsureUserFiles EnsureUserFilesFunc + ContextFileLoader ContextFileLoaderFunc + BootstrapCleanup BootstrapCleanupFunc + + // Tracing collector (nil = no tracing) + TraceCollector *tracing.Collector + + // Security: input guard for injection detection, max message size + InputGuard *InputGuard // nil = auto-create when InjectionAction != "off" + InjectionAction string // "log", "warn" (default), "block", "off" + MaxMessageChars int // 0 = use default (32000) + + // Global builtin tool settings (from builtin_tools table, managed mode) + BuiltinToolSettings tools.BuiltinToolSettings + + // Thinking level: "off", "low", "medium", "high" (from agent other_config) + ThinkingLevel string + + // Group writer cache for system prompt injection (managed mode) + GroupWriterCache *store.GroupWriterCache + + // Team store for cross-session pending task detection (managed mode) + TeamStore store.TeamStore +} + +func NewLoop(cfg LoopConfig) *Loop { + if cfg.MaxIterations <= 0 { + cfg.MaxIterations = 20 + } + if cfg.ContextWindow <= 0 { + cfg.ContextWindow = 200000 + } + + // Normalize injection action (default: "warn") + action := cfg.InjectionAction + switch action { + case "log", "warn", "block", "off": + // valid + default: + action = "warn" + } + + // Auto-create InputGuard unless explicitly disabled + guard := cfg.InputGuard + if guard == nil && action != "off" { + guard = NewInputGuard() + } + + return &Loop{ + id: cfg.ID, + agentUUID: cfg.AgentUUID, + agentType: cfg.AgentType, + provider: cfg.Provider, + model: cfg.Model, + contextWindow: cfg.ContextWindow, + maxIterations: cfg.MaxIterations, + maxToolCalls: cfg.MaxToolCalls, + workspace: cfg.Workspace, + eventPub: cfg.Bus, + sessions: cfg.Sessions, + tools: cfg.Tools, + toolPolicy: cfg.ToolPolicy, + agentToolPolicy: cfg.AgentToolPolicy, + onEvent: cfg.OnEvent, + ownerIDs: cfg.OwnerIDs, + skillsLoader: cfg.SkillsLoader, + skillAllowList: cfg.SkillAllowList, + hasMemory: cfg.HasMemory, + contextFiles: cfg.ContextFiles, + ensureUserFiles: cfg.EnsureUserFiles, + contextFileLoader: cfg.ContextFileLoader, + bootstrapCleanup: cfg.BootstrapCleanup, + compactionCfg: cfg.CompactionCfg, + contextPruningCfg: cfg.ContextPruningCfg, + sandboxEnabled: cfg.SandboxEnabled, + sandboxContainerDir: cfg.SandboxContainerDir, + sandboxWorkspaceAccess: cfg.SandboxWorkspaceAccess, + traceCollector: cfg.TraceCollector, + inputGuard: guard, + injectionAction: action, + maxMessageChars: cfg.MaxMessageChars, + builtinToolSettings: cfg.BuiltinToolSettings, + thinkingLevel: cfg.ThinkingLevel, + groupWriterCache: cfg.GroupWriterCache, + teamStore: cfg.TeamStore, + } +} + +// RunRequest is the input for processing a message through the agent. +type RunRequest struct { + SessionKey string // composite key: agent:{agentId}:{channel}:{peerKind}:{chatId} + Message string // user message + Media []string // local file paths to images (already sanitized) + ForwardMedia []string // media paths to forward to output (not deleted, from delegation results) + Channel string // source channel + ChatID string // source chat ID + PeerKind string // "direct" or "group" (for session key building and tool context) + RunID string // unique run identifier + UserID string // external user ID (TEXT, free-form) for multi-tenant scoping + SenderID string // original individual sender ID (preserved in group chats for permission checks) + Stream bool // whether to stream response chunks + ExtraSystemPrompt string // optional: injected into system prompt (skills, subagent context, etc.) + SkillFilter []string // per-request skill override: nil=use agent default, []=no skills, ["x","y"]=whitelist + HistoryLimit int // max user turns to keep in context (0=unlimited, from channel config) + ToolAllow []string // per-group tool allow list (nil = no restriction, supports "group:xxx") + LocalKey string // composite key with topic/thread suffix for routing (e.g. "-100123:topic:42") + ParentTraceID uuid.UUID // if set, reuse parent trace instead of creating new (announce runs) + ParentRootSpanID uuid.UUID // if set, nest announce agent span under this parent span + TraceName string // override trace name (default: "chat ") + TraceTags []string // additional tags for the trace (e.g. "cron") + MaxIterations int // per-request override (0 = use agent default, must be lower) +} + +// RunResult is the output of a completed agent run. +type RunResult struct { + Content string `json:"content"` + RunID string `json:"runId"` + Iterations int `json:"iterations"` + Usage *providers.Usage `json:"usage,omitempty"` + Media []MediaResult `json:"media,omitempty"` // media files from tool results (MEDIA: prefix) + Deliverables []string `json:"deliverables,omitempty"` // actual content from tool outputs (for team task results) +} + +// MediaResult represents a media file produced by a tool during the agent run. +type MediaResult struct { + Path string `json:"path"` // local file path + ContentType string `json:"content_type,omitempty"` // MIME type + AsVoice bool `json:"as_voice,omitempty"` // send as voice message (Telegram OGG) +} diff --git a/internal/agent/loop_utils.go b/internal/agent/loop_utils.go new file mode 100644 index 00000000..1acee2f8 --- /dev/null +++ b/internal/agent/loop_utils.go @@ -0,0 +1,31 @@ +package agent + +import "strings" + +// sanitizePathSegment makes a userID safe for use as a directory name. +// Replaces colons, spaces, and other unsafe chars with underscores. +func sanitizePathSegment(s string) string { + var b strings.Builder + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { + b.WriteRune(r) + } else { + b.WriteByte('_') + } + } + return b.String() +} + +// InvalidateUserWorkspace clears the cached workspace for a user, +// forcing the next request to re-read from user_agent_profiles. +func (l *Loop) InvalidateUserWorkspace(userID string) { + l.userWorkspaces.Delete(userID) +} + +// ProviderName returns the name of this agent's LLM provider (e.g. "anthropic", "openai"). +func (l *Loop) ProviderName() string { + if l.provider == nil { + return "" + } + return l.provider.Name() +} diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go index 62103da8..ce7dc9eb 100644 --- a/internal/agent/resolver.go +++ b/internal/agent/resolver.go @@ -371,173 +371,3 @@ func (r *Router) InvalidateAll() { slog.Debug("invalidated all agent caches") } -// filterManualLinks removes auto-created team links from delegation targets. -// Team members coordinate via team_tasks/team_message, not delegate. -func filterManualLinks(targets []store.AgentLinkData) []store.AgentLinkData { - var filtered []store.AgentLinkData - for _, t := range targets { - if t.TeamID == nil { - filtered = append(filtered, t) - } - } - return filtered -} - -// buildDelegateAgentsMD generates DELEGATION.md content listing available delegation targets. -func buildDelegateAgentsMD(targets []store.AgentLinkData) string { - var sb strings.Builder - sb.WriteString("# Agent Delegation\n\n") - sb.WriteString("Use `spawn` with the `agent` parameter to delegate tasks to other specialized agents.\n") - sb.WriteString("The agent list below is complete and authoritative — answer questions about available agents directly from it.\n") - sb.WriteString("Only delegate when you need to actually assign work, not to check who is available.\n\n") - sb.WriteString("## Available Agents\n") - - for _, t := range targets { - sb.WriteString(fmt.Sprintf("\n### %s", t.TargetAgentKey)) - if t.TargetDisplayName != "" { - sb.WriteString(fmt.Sprintf(" (%s)", t.TargetDisplayName)) - } - if t.TargetIsTeamLead && t.TargetTeamName != "" { - sb.WriteString(fmt.Sprintf(" [Team Lead: %s]", t.TargetTeamName)) - } - sb.WriteString("\n") - if t.TargetDescription != "" { - sb.WriteString(t.TargetDescription + "\n") - } - sb.WriteString(fmt.Sprintf("→ `spawn(agent=\"%s\", task=\"describe the task\")`\n", t.TargetAgentKey)) - } - - sb.WriteString("\n## When to Delegate\n\n") - sb.WriteString("- The task clearly falls under another agent's expertise\n") - sb.WriteString("- You lack the tools or knowledge to handle it well\n") - sb.WriteString("- The user explicitly asks to involve another agent\n") - - return sb.String() -} - -// buildDelegateSearchInstruction generates DELEGATION.md content that instructs the agent -// to use delegate_search tool instead of listing all targets (used when >15 targets). -func buildDelegateSearchInstruction(targetCount int) string { - return fmt.Sprintf(`# Agent Delegation - -You have the `+"`spawn`"+` tool (with `+"`agent`"+` parameter) and `+"`delegate_search`"+` tool available. -Do NOT look for delegation info on disk — it is provided here. - -You have access to %d specialized agents. To find the right one: - -1. `+"`delegate_search(query=\"your keywords\")`"+` — search agents by expertise -2. `+"`spawn(agent=\"agent-key\", task=\"describe the task\")`"+` — delegate the task - -Example: -- User asks about billing → `+"`delegate_search(query=\"billing payment\")`"+` → `+"`spawn(agent=\"billing-agent\", task=\"...\")`"+` - -Do NOT guess agent keys. Always search first. -`, targetCount) -} - -// buildTeamMD generates compact TEAM.md content for an agent that is part of a team. -// Kept minimal — tool descriptions already live in tool Parameters()/Description(). -func buildTeamMD(team *store.TeamData, members []store.TeamMemberData, selfID uuid.UUID) string { - var sb strings.Builder - sb.WriteString("# Team: " + team.Name + "\n") - if team.Description != "" { - sb.WriteString(team.Description + "\n") - } - - // Determine self role - selfRole := store.TeamRoleMember - for _, m := range members { - if m.AgentID == selfID { - selfRole = m.Role - break - } - } - sb.WriteString(fmt.Sprintf("Role: %s\n\n", selfRole)) - - // Members (including self) - sb.WriteString("## Members\n") - sb.WriteString("This is the complete and authoritative list of your team. Do NOT use tools to verify this.\n\n") - for _, m := range members { - if m.AgentID == selfID { - sb.WriteString(fmt.Sprintf("- **you** (%s)", m.Role)) - } else if m.DisplayName != "" { - sb.WriteString(fmt.Sprintf("- **%s** `%s` (%s)", m.DisplayName, m.AgentKey, m.Role)) - } else { - sb.WriteString(fmt.Sprintf("- **%s** (%s)", m.AgentKey, m.Role)) - } - if m.Frontmatter != "" { - sb.WriteString(": " + m.Frontmatter) - } - sb.WriteString("\n") - } - - // Workflow guidance - sb.WriteString("\n## Workflow\n\n") - if selfRole == store.TeamRoleLead { - sb.WriteString("**ONE delegation = ONE spawn call.** The system auto-creates a tracking task for each delegation.\n") - sb.WriteString("When delegating to multiple agents, call `spawn` once per agent.\n\n") - sb.WriteString("Example (2 agents):\n") - sb.WriteString("```\n") - sb.WriteString("spawn agent=artist, task=\"Create illustration for...\", label=\"Create illustration\"\n") - sb.WriteString("spawn agent=writer, task=\"Write caption for...\", label=\"Write caption\"\n") - sb.WriteString("```\n\n") - sb.WriteString("The `label` parameter sets the task title on the board (keep it short).\n") - sb.WriteString("Each task auto-completes when its delegation finishes.\n") - sb.WriteString("Do NOT respond with text before spawning — call all spawns first, then tell the user.\n\n") - sb.WriteString("When multiple delegations run in parallel, the system collects ALL results and delivers\n") - sb.WriteString("them to you in a single combined notification. Do NOT present partial results.\n\n") - sb.WriteString("Advanced: For dependency chains, use `team_tasks` action=create with blocked_by,\n") - sb.WriteString("then `spawn` with team_task_id=.\n\n") - sb.WriteString("## Orchestration Patterns\n\n") - sb.WriteString("You can orchestrate multiple rounds — not just one-shot parallel delegation:\n") - sb.WriteString("- **Sequential**: A finishes → review result → delegate to B with A's output as context\n") - sb.WriteString("- **Iterative**: A produces draft → delegate to B for review → delegate back to A with feedback\n") - sb.WriteString("- **Mixed**: A+B in parallel → review both → delegate to C combining their outputs\n\n") - sb.WriteString("After receiving delegation results, decide: present to user (if done) or continue orchestrating.\n\n") - sb.WriteString("**Communication**: When updating the user, distinguish between:\n") - sb.WriteString("- First delegation round → \"assigning to team\" / notifying who is working on what\n") - sb.WriteString("- Follow-up rounds (after receiving results) → \"updating tasks\" / sharing progress and next steps\n") - sb.WriteString("Never repeat the same announcement phrasing for follow-up delegations.\n\n") - sb.WriteString("`team_tasks` actions:\n") - sb.WriteString("- action=list → active tasks (pending/in_progress/blocked), no results shown\n") - sb.WriteString("- action=list, status=all → all tasks including completed\n") - sb.WriteString("- action=get, task_id= → full task detail with result\n") - sb.WriteString("- action=search, query= → search tasks by subject/description\n") - sb.WriteString("- action=complete, task_id=, result= → manually complete a task\n") - sb.WriteString("- action=cancel, task_id=, reason= → cancel a pending task that is no longer needed\n\n") - sb.WriteString("Use `team_message` to send updates to team members.\n\n") - sb.WriteString("For simple questions about team composition, answer directly from the member list above.\n") - } else { - sb.WriteString("As a member, when you receive a delegated task, just do the work.\n") - sb.WriteString("Task completion is handled automatically by the system.\n\n") - sb.WriteString("For long-running tasks, send progress updates to your lead:\n") - sb.WriteString("`team_message` action=send, to=, text=\n\n") - sb.WriteString("`team_tasks` actions:\n") - sb.WriteString("- action=list → check team task board (active tasks)\n") - sb.WriteString("- action=get, task_id= → read a completed task's full result\n") - sb.WriteString("- action=search, query= → search tasks\n\n") - sb.WriteString("Use `team_message` to send updates to your team lead.\n\n") - sb.WriteString("For simple questions about team composition, answer directly from the member list above.\n") - } - - return sb.String() -} - -// agentToolPolicyWithMCP injects "group:mcp" into the agent's alsoAllow list -// when MCP tools are loaded, ensuring the PolicyEngine doesn't block them. -func agentToolPolicyWithMCP(policy *config.ToolPolicySpec, hasMCP bool) *config.ToolPolicySpec { - if !hasMCP { - return policy - } - if policy == nil { - policy = &config.ToolPolicySpec{} - } - // Check if group:mcp is already present - for _, a := range policy.AlsoAllow { - if a == "group:mcp" { - return policy - } - } - policy.AlsoAllow = append(policy.AlsoAllow, "group:mcp") - return policy -} diff --git a/internal/agent/resolver_helpers.go b/internal/agent/resolver_helpers.go new file mode 100644 index 00000000..54b7a68e --- /dev/null +++ b/internal/agent/resolver_helpers.go @@ -0,0 +1,182 @@ +package agent + +import ( + "fmt" + "strings" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// filterManualLinks removes auto-created team links from delegation targets. +// Team members coordinate via team_tasks/team_message, not delegate. +func filterManualLinks(targets []store.AgentLinkData) []store.AgentLinkData { + var filtered []store.AgentLinkData + for _, t := range targets { + if t.TeamID == nil { + filtered = append(filtered, t) + } + } + return filtered +} + +// buildDelegateAgentsMD generates DELEGATION.md content listing available delegation targets. +func buildDelegateAgentsMD(targets []store.AgentLinkData) string { + var sb strings.Builder + sb.WriteString("# Agent Delegation\n\n") + sb.WriteString("Use `spawn` with the `agent` parameter to delegate tasks to other specialized agents.\n") + sb.WriteString("The agent list below is complete and authoritative — answer questions about available agents directly from it.\n") + sb.WriteString("Only delegate when you need to actually assign work, not to check who is available.\n\n") + sb.WriteString("## Available Agents\n") + + for _, t := range targets { + sb.WriteString(fmt.Sprintf("\n### %s", t.TargetAgentKey)) + if t.TargetDisplayName != "" { + sb.WriteString(fmt.Sprintf(" (%s)", t.TargetDisplayName)) + } + if t.TargetIsTeamLead && t.TargetTeamName != "" { + sb.WriteString(fmt.Sprintf(" [Team Lead: %s]", t.TargetTeamName)) + } + sb.WriteString("\n") + if t.TargetDescription != "" { + sb.WriteString(t.TargetDescription + "\n") + } + sb.WriteString(fmt.Sprintf("→ `spawn(agent=\"%s\", task=\"describe the task\")`\n", t.TargetAgentKey)) + } + + sb.WriteString("\n## When to Delegate\n\n") + sb.WriteString("- The task clearly falls under another agent's expertise\n") + sb.WriteString("- You lack the tools or knowledge to handle it well\n") + sb.WriteString("- The user explicitly asks to involve another agent\n") + + return sb.String() +} + +// buildDelegateSearchInstruction generates DELEGATION.md content that instructs the agent +// to use delegate_search tool instead of listing all targets (used when >15 targets). +func buildDelegateSearchInstruction(targetCount int) string { + return fmt.Sprintf(`# Agent Delegation + +You have the `+"`spawn`"+` tool (with `+"`agent`"+` parameter) and `+"`delegate_search`"+` tool available. +Do NOT look for delegation info on disk — it is provided here. + +You have access to %d specialized agents. To find the right one: + +1. `+"`delegate_search(query=\"your keywords\")`"+` — search agents by expertise +2. `+"`spawn(agent=\"agent-key\", task=\"describe the task\")`"+` — delegate the task + +Example: +- User asks about billing → `+"`delegate_search(query=\"billing payment\")`"+` → `+"`spawn(agent=\"billing-agent\", task=\"...\")`"+` + +Do NOT guess agent keys. Always search first. +`, targetCount) +} + +// buildTeamMD generates compact TEAM.md content for an agent that is part of a team. +// Kept minimal — tool descriptions already live in tool Parameters()/Description(). +func buildTeamMD(team *store.TeamData, members []store.TeamMemberData, selfID uuid.UUID) string { + var sb strings.Builder + sb.WriteString("# Team: " + team.Name + "\n") + if team.Description != "" { + sb.WriteString(team.Description + "\n") + } + + // Determine self role + selfRole := store.TeamRoleMember + for _, m := range members { + if m.AgentID == selfID { + selfRole = m.Role + break + } + } + sb.WriteString(fmt.Sprintf("Role: %s\n\n", selfRole)) + + // Members (including self) + sb.WriteString("## Members\n") + sb.WriteString("This is the complete and authoritative list of your team. Do NOT use tools to verify this.\n\n") + for _, m := range members { + if m.AgentID == selfID { + sb.WriteString(fmt.Sprintf("- **you** (%s)", m.Role)) + } else if m.DisplayName != "" { + sb.WriteString(fmt.Sprintf("- **%s** `%s` (%s)", m.DisplayName, m.AgentKey, m.Role)) + } else { + sb.WriteString(fmt.Sprintf("- **%s** (%s)", m.AgentKey, m.Role)) + } + if m.Frontmatter != "" { + sb.WriteString(": " + m.Frontmatter) + } + sb.WriteString("\n") + } + + // Workflow guidance + sb.WriteString("\n## Workflow\n\n") + if selfRole == store.TeamRoleLead { + sb.WriteString("**ONE delegation = ONE spawn call.** The system auto-creates a tracking task for each delegation.\n") + sb.WriteString("When delegating to multiple agents, call `spawn` once per agent.\n\n") + sb.WriteString("Example (2 agents):\n") + sb.WriteString("```\n") + sb.WriteString("spawn agent=artist, task=\"Create illustration for...\", label=\"Create illustration\"\n") + sb.WriteString("spawn agent=writer, task=\"Write caption for...\", label=\"Write caption\"\n") + sb.WriteString("```\n\n") + sb.WriteString("The `label` parameter sets the task title on the board (keep it short).\n") + sb.WriteString("Each task auto-completes when its delegation finishes.\n") + sb.WriteString("Do NOT respond with text before spawning — call all spawns first, then tell the user.\n\n") + sb.WriteString("When multiple delegations run in parallel, the system collects ALL results and delivers\n") + sb.WriteString("them to you in a single combined notification. Do NOT present partial results.\n\n") + sb.WriteString("Advanced: For dependency chains, use `team_tasks` action=create with blocked_by,\n") + sb.WriteString("then `spawn` with team_task_id=.\n\n") + sb.WriteString("## Orchestration Patterns\n\n") + sb.WriteString("You can orchestrate multiple rounds — not just one-shot parallel delegation:\n") + sb.WriteString("- **Sequential**: A finishes → review result → delegate to B with A's output as context\n") + sb.WriteString("- **Iterative**: A produces draft → delegate to B for review → delegate back to A with feedback\n") + sb.WriteString("- **Mixed**: A+B in parallel → review both → delegate to C combining their outputs\n\n") + sb.WriteString("After receiving delegation results, decide: present to user (if done) or continue orchestrating.\n\n") + sb.WriteString("**Communication**: When updating the user, distinguish between:\n") + sb.WriteString("- First delegation round → \"assigning to team\" / notifying who is working on what\n") + sb.WriteString("- Follow-up rounds (after receiving results) → \"updating tasks\" / sharing progress and next steps\n") + sb.WriteString("Never repeat the same announcement phrasing for follow-up delegations.\n\n") + sb.WriteString("`team_tasks` actions:\n") + sb.WriteString("- action=list → active tasks (pending/in_progress/blocked), no results shown\n") + sb.WriteString("- action=list, status=all → all tasks including completed\n") + sb.WriteString("- action=get, task_id= → full task detail with result\n") + sb.WriteString("- action=search, query= → search tasks by subject/description\n") + sb.WriteString("- action=complete, task_id=, result= → manually complete a task\n") + sb.WriteString("- action=cancel, task_id=, reason= → cancel a pending task that is no longer needed\n\n") + sb.WriteString("Use `team_message` to send updates to team members.\n\n") + sb.WriteString("For simple questions about team composition, answer directly from the member list above.\n") + } else { + sb.WriteString("As a member, when you receive a delegated task, just do the work.\n") + sb.WriteString("Task completion is handled automatically by the system.\n\n") + sb.WriteString("For long-running tasks, send progress updates to your lead:\n") + sb.WriteString("`team_message` action=send, to=, text=\n\n") + sb.WriteString("`team_tasks` actions:\n") + sb.WriteString("- action=list → check team task board (active tasks)\n") + sb.WriteString("- action=get, task_id= → read a completed task's full result\n") + sb.WriteString("- action=search, query= → search tasks\n\n") + sb.WriteString("Use `team_message` to send updates to your team lead.\n\n") + sb.WriteString("For simple questions about team composition, answer directly from the member list above.\n") + } + + return sb.String() +} + +// agentToolPolicyWithMCP injects "group:mcp" into the agent's alsoAllow list +// when MCP tools are loaded, ensuring the PolicyEngine doesn't block them. +func agentToolPolicyWithMCP(policy *config.ToolPolicySpec, hasMCP bool) *config.ToolPolicySpec { + if !hasMCP { + return policy + } + if policy == nil { + policy = &config.ToolPolicySpec{} + } + // Check if group:mcp is already present + for _, a := range policy.AlsoAllow { + if a == "group:mcp" { + return policy + } + } + policy.AlsoAllow = append(policy.AlsoAllow, "group:mcp") + return policy +} diff --git a/internal/gateway/methods/teams.go b/internal/gateway/methods/teams.go index 69787be7..8a192ae0 100644 --- a/internal/gateway/methods/teams.go +++ b/internal/gateway/methods/teams.go @@ -5,8 +5,6 @@ import ( "encoding/json" "log/slog" - "github.com/google/uuid" - "github.com/nextlevelbuilder/goclaw/internal/agent" "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/gateway" @@ -170,433 +168,3 @@ func (m *TeamsMethods) handleCreate(_ context.Context, client *gateway.Client, r "team": team, })) } - -// --- Get --- - -type teamsGetParams struct { - TeamID string `json:"teamId"` -} - -func (m *TeamsMethods) handleGet(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { - if m.teamStore == nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not available (standalone mode)")) - return - } - - var params teamsGetParams - if err := json.Unmarshal(req.Params, ¶ms); err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params")) - return - } - - if params.TeamID == "" { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId is required")) - return - } - - teamID, err := uuid.Parse(params.TeamID) - if err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId")) - return - } - - ctx := context.Background() - team, err := m.teamStore.GetTeam(ctx, teamID) - if err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) - return - } - - members, err := m.teamStore.ListMembers(ctx, teamID) - if err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) - return - } - - client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{ - "team": team, - "members": members, - })) -} - -// --- Delete --- - -type teamsDeleteParams struct { - TeamID string `json:"teamId"` -} - -func (m *TeamsMethods) handleDelete(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { - if m.teamStore == nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not available (standalone mode)")) - return - } - - var params teamsDeleteParams - if err := json.Unmarshal(req.Params, ¶ms); err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params")) - return - } - - if params.TeamID == "" { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId is required")) - return - } - - teamID, err := uuid.Parse(params.TeamID) - if err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId")) - return - } - - ctx := context.Background() - - // Fetch members before deleting for cache invalidation - members, _ := m.teamStore.ListMembers(ctx, teamID) - - if err := m.teamStore.DeleteTeam(ctx, teamID); err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to delete team: "+err.Error())) - return - } - - // Invalidate agent caches - if m.agentRouter != nil { - for _, member := range members { - m.agentRouter.InvalidateAgent(member.AgentKey) - } - } - - client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{"ok": true})) -} - -// --- Task List (admin view) --- - -type teamsTaskListParams struct { - TeamID string `json:"teamId"` -} - -func (m *TeamsMethods) handleTaskList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { - if m.teamStore == nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not available (standalone mode)")) - return - } - - var params teamsTaskListParams - if err := json.Unmarshal(req.Params, ¶ms); err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params")) - return - } - - if params.TeamID == "" { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId is required")) - return - } - - teamID, err := uuid.Parse(params.TeamID) - if err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId")) - return - } - - ctx := context.Background() - tasks, err := m.teamStore.ListTasks(ctx, teamID, "newest", store.TeamTaskFilterAll, "") - if err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) - return - } - - client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{ - "tasks": tasks, - "count": len(tasks), - })) -} - -// --- Add Member --- - -type teamsAddMemberParams struct { - TeamID string `json:"teamId"` - Agent string `json:"agent"` // agent key or UUID -} - -func (m *TeamsMethods) handleAddMember(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { - if m.teamStore == nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not available (standalone mode)")) - return - } - - var params teamsAddMemberParams - if err := json.Unmarshal(req.Params, ¶ms); err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params")) - return - } - if params.TeamID == "" || params.Agent == "" { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId and agent are required")) - return - } - - teamID, err := uuid.Parse(params.TeamID) - if err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId")) - return - } - - ctx := context.Background() - - // Validate team exists - team, err := m.teamStore.GetTeam(ctx, teamID) - if err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "team not found: "+err.Error())) - return - } - - // Resolve agent - ag, err := resolveAgentInfo(m.agentStore, params.Agent) - if err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agent: "+err.Error())) - return - } - - // Prevent adding lead again - if ag.ID == team.LeadAgentID { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agent is already the team lead")) - return - } - - // Add member - if err := m.teamStore.AddMember(ctx, teamID, ag.ID, store.TeamRoleMember); err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to add member: "+err.Error())) - return - } - - // Auto-create outbound link from lead to new member - if m.linkStore != nil { - leadAgent, err := m.agentStore.GetByID(ctx, team.LeadAgentID) - if err == nil { - m.autoCreateTeamLinks(ctx, teamID, leadAgent, []*store.AgentData{ag}, client.UserID()) - } - } - - // Invalidate caches for all team members - m.invalidateTeamCaches(ctx, teamID) - - client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{"ok": true})) -} - -// --- Remove Member --- - -type teamsRemoveMemberParams struct { - TeamID string `json:"teamId"` - AgentID string `json:"agentId"` // agent UUID -} - -func (m *TeamsMethods) handleRemoveMember(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { - if m.teamStore == nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not available (standalone mode)")) - return - } - - var params teamsRemoveMemberParams - if err := json.Unmarshal(req.Params, ¶ms); err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params")) - return - } - if params.TeamID == "" || params.AgentID == "" { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId and agentId are required")) - return - } - - teamID, err := uuid.Parse(params.TeamID) - if err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId")) - return - } - agentID, err := uuid.Parse(params.AgentID) - if err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid agentId")) - return - } - - ctx := context.Background() - - // Validate team exists and prevent removing the lead - team, err := m.teamStore.GetTeam(ctx, teamID) - if err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "team not found: "+err.Error())) - return - } - if agentID == team.LeadAgentID { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "cannot remove the team lead")) - return - } - - // Remove member - if err := m.teamStore.RemoveMember(ctx, teamID, agentID); err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to remove member: "+err.Error())) - return - } - - // Clean up team-specific links - if m.linkStore != nil { - if err := m.linkStore.DeleteTeamLinksForAgent(ctx, teamID, agentID); err != nil { - slog.Warn("teams.members.remove: failed to clean up links", "error", err) - } - } - - // Invalidate caches for all remaining members + removed agent - m.invalidateTeamCaches(ctx, teamID) - if m.agentRouter != nil { - ag, err := m.agentStore.GetByID(ctx, agentID) - if err == nil { - m.agentRouter.InvalidateAgent(ag.AgentKey) - } - } - - client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{"ok": true})) -} - -// invalidateTeamCaches invalidates agent caches for all members of a team -// and emits a pub/sub event for TeamToolManager cache invalidation. -func (m *TeamsMethods) invalidateTeamCaches(ctx context.Context, teamID uuid.UUID) { - if m.agentRouter != nil { - members, err := m.teamStore.ListMembers(ctx, teamID) - if err == nil { - for _, member := range members { - if member.AgentKey != "" { - m.agentRouter.InvalidateAgent(member.AgentKey) - } - } - } - } - m.emitTeamCacheInvalidate() -} - -// --- Update (settings) --- - -type teamsUpdateParams struct { - TeamID string `json:"teamId"` - Settings map[string]interface{} `json:"settings"` -} - -func (m *TeamsMethods) handleUpdate(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { - if m.teamStore == nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not available (standalone mode)")) - return - } - - var params teamsUpdateParams - if err := json.Unmarshal(req.Params, ¶ms); err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params")) - return - } - - if params.TeamID == "" { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId is required")) - return - } - - teamID, err := uuid.Parse(params.TeamID) - if err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId")) - return - } - - ctx := context.Background() - - // Validate team exists - if _, err := m.teamStore.GetTeam(ctx, teamID); err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "team not found: "+err.Error())) - return - } - - // Validate settings against teamAccessSettings schema (strip unknown fields) - type teamAccessSettings struct { - AllowUserIDs []string `json:"allow_user_ids"` - DenyUserIDs []string `json:"deny_user_ids"` - AllowChannels []string `json:"allow_channels"` - DenyChannels []string `json:"deny_channels"` - } - raw, _ := json.Marshal(params.Settings) - var access teamAccessSettings - if err := json.Unmarshal(raw, &access); err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid settings: "+err.Error())) - return - } - cleaned, _ := json.Marshal(access) - - updates := map[string]any{"settings": json.RawMessage(cleaned)} - if err := m.teamStore.UpdateTeam(ctx, teamID, updates); err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to update team: "+err.Error())) - return - } - - m.invalidateTeamCaches(ctx, teamID) - - client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{"ok": true})) -} - -// --- Known Users --- - -type teamsKnownUsersParams struct { - TeamID string `json:"teamId"` -} - -func (m *TeamsMethods) handleKnownUsers(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { - if m.teamStore == nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not available (standalone mode)")) - return - } - - var params teamsKnownUsersParams - if err := json.Unmarshal(req.Params, ¶ms); err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params")) - return - } - - if params.TeamID == "" { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId is required")) - return - } - - teamID, err := uuid.Parse(params.TeamID) - if err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId")) - return - } - - ctx := context.Background() - users, err := m.teamStore.KnownUserIDs(ctx, teamID, 100) - if err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) - return - } - - client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{ - "users": users, - })) -} - -// --- helpers --- - -// autoCreateTeamLinks creates outbound agent_links from lead to each member. -// Only the lead can delegate to members — members cannot delegate back to lead -// or to other members. Silently skips existing links (UNIQUE constraint). -func (m *TeamsMethods) autoCreateTeamLinks(ctx context.Context, teamID uuid.UUID, leadAgent *store.AgentData, members []*store.AgentData, createdBy string) { - for _, member := range members { - if member.ID == leadAgent.ID { - continue - } - link := &store.AgentLinkData{ - SourceAgentID: leadAgent.ID, - TargetAgentID: member.ID, - Direction: store.LinkDirectionOutbound, - TeamID: &teamID, - Description: "auto-created by team", - MaxConcurrent: 3, - Status: store.LinkStatusActive, - CreatedBy: createdBy, - } - if err := m.linkStore.CreateLink(ctx, link); err != nil { - slog.Debug("teams: auto-link already exists or failed", - "source", leadAgent.AgentKey, "target", member.AgentKey, "error", err) - } - } -} diff --git a/internal/gateway/methods/teams_crud.go b/internal/gateway/methods/teams_crud.go new file mode 100644 index 00000000..da278a7b --- /dev/null +++ b/internal/gateway/methods/teams_crud.go @@ -0,0 +1,272 @@ +package methods + +import ( + "context" + "encoding/json" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/gateway" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +// --- Get --- + +type teamsGetParams struct { + TeamID string `json:"teamId"` +} + +func (m *TeamsMethods) handleGet(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { + if m.teamStore == nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not available (standalone mode)")) + return + } + + var params teamsGetParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params")) + return + } + + if params.TeamID == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId is required")) + return + } + + teamID, err := uuid.Parse(params.TeamID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId")) + return + } + + ctx := context.Background() + team, err := m.teamStore.GetTeam(ctx, teamID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) + return + } + + members, err := m.teamStore.ListMembers(ctx, teamID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) + return + } + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{ + "team": team, + "members": members, + })) +} + +// --- Delete --- + +type teamsDeleteParams struct { + TeamID string `json:"teamId"` +} + +func (m *TeamsMethods) handleDelete(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { + if m.teamStore == nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not available (standalone mode)")) + return + } + + var params teamsDeleteParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params")) + return + } + + if params.TeamID == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId is required")) + return + } + + teamID, err := uuid.Parse(params.TeamID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId")) + return + } + + ctx := context.Background() + + // Fetch members before deleting for cache invalidation + members, _ := m.teamStore.ListMembers(ctx, teamID) + + if err := m.teamStore.DeleteTeam(ctx, teamID); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to delete team: "+err.Error())) + return + } + + // Invalidate agent caches + if m.agentRouter != nil { + for _, member := range members { + m.agentRouter.InvalidateAgent(member.AgentKey) + } + } + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{"ok": true})) +} + +// --- Task List (admin view) --- + +type teamsTaskListParams struct { + TeamID string `json:"teamId"` +} + +func (m *TeamsMethods) handleTaskList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { + if m.teamStore == nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not available (standalone mode)")) + return + } + + var params teamsTaskListParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params")) + return + } + + if params.TeamID == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId is required")) + return + } + + teamID, err := uuid.Parse(params.TeamID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId")) + return + } + + ctx := context.Background() + tasks, err := m.teamStore.ListTasks(ctx, teamID, "newest", store.TeamTaskFilterAll, "") + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) + return + } + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{ + "tasks": tasks, + "count": len(tasks), + })) +} + +// --- Update (settings) --- + +type teamsUpdateParams struct { + TeamID string `json:"teamId"` + Settings map[string]interface{} `json:"settings"` +} + +func (m *TeamsMethods) handleUpdate(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { + if m.teamStore == nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not available (standalone mode)")) + return + } + + var params teamsUpdateParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params")) + return + } + + if params.TeamID == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId is required")) + return + } + + teamID, err := uuid.Parse(params.TeamID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId")) + return + } + + ctx := context.Background() + + // Validate team exists + if _, err := m.teamStore.GetTeam(ctx, teamID); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "team not found: "+err.Error())) + return + } + + // Validate settings against teamAccessSettings schema (strip unknown fields) + type teamAccessSettings struct { + AllowUserIDs []string `json:"allow_user_ids"` + DenyUserIDs []string `json:"deny_user_ids"` + AllowChannels []string `json:"allow_channels"` + DenyChannels []string `json:"deny_channels"` + } + raw, _ := json.Marshal(params.Settings) + var access teamAccessSettings + if err := json.Unmarshal(raw, &access); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid settings: "+err.Error())) + return + } + cleaned, _ := json.Marshal(access) + + updates := map[string]any{"settings": json.RawMessage(cleaned)} + if err := m.teamStore.UpdateTeam(ctx, teamID, updates); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to update team: "+err.Error())) + return + } + + m.invalidateTeamCaches(ctx, teamID) + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{"ok": true})) +} + +// --- Known Users --- + +type teamsKnownUsersParams struct { + TeamID string `json:"teamId"` +} + +func (m *TeamsMethods) handleKnownUsers(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { + if m.teamStore == nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not available (standalone mode)")) + return + } + + var params teamsKnownUsersParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params")) + return + } + + if params.TeamID == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId is required")) + return + } + + teamID, err := uuid.Parse(params.TeamID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId")) + return + } + + ctx := context.Background() + users, err := m.teamStore.KnownUserIDs(ctx, teamID, 100) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) + return + } + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{ + "users": users, + })) +} + +// invalidateTeamCaches invalidates agent caches for all members of a team +// and emits a pub/sub event for TeamToolManager cache invalidation. +func (m *TeamsMethods) invalidateTeamCaches(ctx context.Context, teamID uuid.UUID) { + if m.agentRouter != nil { + members, err := m.teamStore.ListMembers(ctx, teamID) + if err == nil { + for _, member := range members { + if member.AgentKey != "" { + m.agentRouter.InvalidateAgent(member.AgentKey) + } + } + } + } + m.emitTeamCacheInvalidate() +} diff --git a/internal/gateway/methods/teams_members.go b/internal/gateway/methods/teams_members.go new file mode 100644 index 00000000..7697fcb0 --- /dev/null +++ b/internal/gateway/methods/teams_members.go @@ -0,0 +1,181 @@ +package methods + +import ( + "context" + "encoding/json" + "log/slog" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/gateway" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +// --- Add Member --- + +type teamsAddMemberParams struct { + TeamID string `json:"teamId"` + Agent string `json:"agent"` // agent key or UUID +} + +func (m *TeamsMethods) handleAddMember(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { + if m.teamStore == nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not available (standalone mode)")) + return + } + + var params teamsAddMemberParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params")) + return + } + if params.TeamID == "" || params.Agent == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId and agent are required")) + return + } + + teamID, err := uuid.Parse(params.TeamID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId")) + return + } + + ctx := context.Background() + + // Validate team exists + team, err := m.teamStore.GetTeam(ctx, teamID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "team not found: "+err.Error())) + return + } + + // Resolve agent + ag, err := resolveAgentInfo(m.agentStore, params.Agent) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agent: "+err.Error())) + return + } + + // Prevent adding lead again + if ag.ID == team.LeadAgentID { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agent is already the team lead")) + return + } + + // Add member + if err := m.teamStore.AddMember(ctx, teamID, ag.ID, store.TeamRoleMember); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to add member: "+err.Error())) + return + } + + // Auto-create outbound link from lead to new member + if m.linkStore != nil { + leadAgent, err := m.agentStore.GetByID(ctx, team.LeadAgentID) + if err == nil { + m.autoCreateTeamLinks(ctx, teamID, leadAgent, []*store.AgentData{ag}, client.UserID()) + } + } + + // Invalidate caches for all team members + m.invalidateTeamCaches(ctx, teamID) + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{"ok": true})) +} + +// --- Remove Member --- + +type teamsRemoveMemberParams struct { + TeamID string `json:"teamId"` + AgentID string `json:"agentId"` // agent UUID +} + +func (m *TeamsMethods) handleRemoveMember(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { + if m.teamStore == nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not available (standalone mode)")) + return + } + + var params teamsRemoveMemberParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params")) + return + } + if params.TeamID == "" || params.AgentID == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId and agentId are required")) + return + } + + teamID, err := uuid.Parse(params.TeamID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId")) + return + } + agentID, err := uuid.Parse(params.AgentID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid agentId")) + return + } + + ctx := context.Background() + + // Validate team exists and prevent removing the lead + team, err := m.teamStore.GetTeam(ctx, teamID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "team not found: "+err.Error())) + return + } + if agentID == team.LeadAgentID { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "cannot remove the team lead")) + return + } + + // Remove member + if err := m.teamStore.RemoveMember(ctx, teamID, agentID); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to remove member: "+err.Error())) + return + } + + // Clean up team-specific links + if m.linkStore != nil { + if err := m.linkStore.DeleteTeamLinksForAgent(ctx, teamID, agentID); err != nil { + slog.Warn("teams.members.remove: failed to clean up links", "error", err) + } + } + + // Invalidate caches for all remaining members + removed agent + m.invalidateTeamCaches(ctx, teamID) + if m.agentRouter != nil { + ag, err := m.agentStore.GetByID(ctx, agentID) + if err == nil { + m.agentRouter.InvalidateAgent(ag.AgentKey) + } + } + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{"ok": true})) +} + +// autoCreateTeamLinks creates outbound agent_links from lead to each member. +// Only the lead can delegate to members — members cannot delegate back to lead +// or to other members. Silently skips existing links (UNIQUE constraint). +func (m *TeamsMethods) autoCreateTeamLinks(ctx context.Context, teamID uuid.UUID, leadAgent *store.AgentData, members []*store.AgentData, createdBy string) { + for _, member := range members { + if member.ID == leadAgent.ID { + continue + } + link := &store.AgentLinkData{ + SourceAgentID: leadAgent.ID, + TargetAgentID: member.ID, + Direction: store.LinkDirectionOutbound, + TeamID: &teamID, + Description: "auto-created by team", + MaxConcurrent: 3, + Status: store.LinkStatusActive, + CreatedBy: createdBy, + } + if err := m.linkStore.CreateLink(ctx, link); err != nil { + slog.Debug("teams: auto-link already exists or failed", + "source", leadAgent.AgentKey, "target", member.AgentKey, "error", err) + } + } +} diff --git a/internal/store/pg/sessions.go b/internal/store/pg/sessions.go index ea135f85..21f900bf 100644 --- a/internal/store/pg/sessions.go +++ b/internal/store/pg/sessions.go @@ -3,7 +3,6 @@ package pg import ( "database/sql" "encoding/json" - "strings" "sync" "time" @@ -156,381 +155,3 @@ func (s *PGSessionStore) UpdateMetadata(key, model, provider, channel string) { } } } - -func (s *PGSessionStore) AccumulateTokens(key string, input, output int64) { - s.mu.Lock() - defer s.mu.Unlock() - if data, ok := s.cache[key]; ok { - data.InputTokens += input - data.OutputTokens += output - } -} - -func (s *PGSessionStore) IncrementCompaction(key string) { - s.mu.Lock() - defer s.mu.Unlock() - if data, ok := s.cache[key]; ok { - data.CompactionCount++ - } -} - -func (s *PGSessionStore) GetCompactionCount(key string) int { - s.mu.RLock() - defer s.mu.RUnlock() - if data, ok := s.cache[key]; ok { - return data.CompactionCount - } - return 0 -} - -func (s *PGSessionStore) GetMemoryFlushCompactionCount(key string) int { - s.mu.RLock() - defer s.mu.RUnlock() - if data, ok := s.cache[key]; ok { - return data.MemoryFlushCompactionCount - } - return -1 -} - -func (s *PGSessionStore) SetMemoryFlushDone(key string) { - s.mu.Lock() - defer s.mu.Unlock() - if data, ok := s.cache[key]; ok { - data.MemoryFlushCompactionCount = data.CompactionCount - data.MemoryFlushAt = time.Now().UnixMilli() - } -} - -func (s *PGSessionStore) SetSpawnInfo(key, spawnedBy string, depth int) { - s.mu.Lock() - defer s.mu.Unlock() - if data, ok := s.cache[key]; ok { - data.SpawnedBy = spawnedBy - data.SpawnDepth = depth - } -} - -func (s *PGSessionStore) SetContextWindow(key string, cw int) { - s.mu.Lock() - defer s.mu.Unlock() - if data, ok := s.cache[key]; ok { - data.ContextWindow = cw - } -} - -func (s *PGSessionStore) GetContextWindow(key string) int { - s.mu.RLock() - defer s.mu.RUnlock() - if data, ok := s.cache[key]; ok { - return data.ContextWindow - } - return 0 -} - -func (s *PGSessionStore) SetLastPromptTokens(key string, tokens, msgCount int) { - s.mu.Lock() - defer s.mu.Unlock() - if data, ok := s.cache[key]; ok { - data.LastPromptTokens = tokens - data.LastMessageCount = msgCount - } -} - -func (s *PGSessionStore) GetLastPromptTokens(key string) (int, int) { - s.mu.RLock() - defer s.mu.RUnlock() - if data, ok := s.cache[key]; ok { - return data.LastPromptTokens, data.LastMessageCount - } - return 0, 0 -} - -func (s *PGSessionStore) TruncateHistory(key string, keepLast int) { - s.mu.Lock() - defer s.mu.Unlock() - if data, ok := s.cache[key]; ok { - if keepLast <= 0 { - data.Messages = []providers.Message{} - } else if len(data.Messages) > keepLast { - data.Messages = data.Messages[len(data.Messages)-keepLast:] - } - data.Updated = time.Now() - } -} - -func (s *PGSessionStore) Reset(key string) { - s.mu.Lock() - defer s.mu.Unlock() - if data, ok := s.cache[key]; ok { - data.Messages = []providers.Message{} - data.Summary = "" - data.Updated = time.Now() - } -} - -func (s *PGSessionStore) Delete(key string) error { - s.mu.Lock() - delete(s.cache, key) - s.mu.Unlock() - - _, err := s.db.Exec("DELETE FROM sessions WHERE session_key = $1", key) - return err -} - -func (s *PGSessionStore) List(agentID string) []store.SessionInfo { - var rows *sql.Rows - var err error - if agentID != "" { - prefix := "agent:" + agentID + ":%" - rows, err = s.db.Query( - "SELECT session_key, messages, created_at, updated_at, label, channel FROM sessions WHERE session_key LIKE $1 ORDER BY updated_at DESC", prefix) - } else { - rows, err = s.db.Query( - "SELECT session_key, messages, created_at, updated_at, label, channel FROM sessions ORDER BY updated_at DESC") - } - if err != nil { - return nil - } - defer rows.Close() - - var result []store.SessionInfo - for rows.Next() { - var key string - var msgsJSON []byte - var createdAt, updatedAt time.Time - var label, channel *string - if err := rows.Scan(&key, &msgsJSON, &createdAt, &updatedAt, &label, &channel); err != nil { - continue - } - var msgs []providers.Message - json.Unmarshal(msgsJSON, &msgs) - result = append(result, store.SessionInfo{ - Key: key, - MessageCount: len(msgs), - Created: createdAt, - Updated: updatedAt, - Label: derefStr(label), - Channel: derefStr(channel), - }) - } - return result -} - -func (s *PGSessionStore) ListPaged(opts store.SessionListOpts) store.SessionListResult { - limit := opts.Limit - if limit <= 0 { - limit = 20 - } - offset := opts.Offset - if offset < 0 { - offset = 0 - } - - var where string - var whereArgs []interface{} - - if opts.AgentID != "" { - where = " WHERE session_key LIKE $1" - whereArgs = append(whereArgs, "agent:"+opts.AgentID+":%") - } - - // Count total - var total int - countQ := "SELECT COUNT(*) FROM sessions" + where - if err := s.db.QueryRow(countQ, whereArgs...).Scan(&total); err != nil { - return store.SessionListResult{Sessions: []store.SessionInfo{}, Total: 0} - } - - // Fetch page using jsonb_array_length to avoid loading full messages - var selectQ string - var selectArgs []interface{} - - if opts.AgentID != "" { - selectQ = `SELECT session_key, jsonb_array_length(messages), created_at, updated_at, label, channel - FROM sessions WHERE session_key LIKE $1 ORDER BY updated_at DESC LIMIT $2 OFFSET $3` - selectArgs = []interface{}{whereArgs[0], limit, offset} - } else { - selectQ = `SELECT session_key, jsonb_array_length(messages), created_at, updated_at, label, channel - FROM sessions ORDER BY updated_at DESC LIMIT $1 OFFSET $2` - selectArgs = []interface{}{limit, offset} - } - - rows, err := s.db.Query(selectQ, selectArgs...) - if err != nil { - return store.SessionListResult{Sessions: []store.SessionInfo{}, Total: total} - } - defer rows.Close() - - var result []store.SessionInfo - for rows.Next() { - var key string - var msgCount int - var createdAt, updatedAt time.Time - var label, channel *string - if err := rows.Scan(&key, &msgCount, &createdAt, &updatedAt, &label, &channel); err != nil { - continue - } - result = append(result, store.SessionInfo{ - Key: key, - MessageCount: msgCount, - Created: createdAt, - Updated: updatedAt, - Label: derefStr(label), - Channel: derefStr(channel), - }) - } - if result == nil { - result = []store.SessionInfo{} - } - return store.SessionListResult{Sessions: result, Total: total} -} - -func (s *PGSessionStore) Save(key string) error { - s.mu.RLock() - data, ok := s.cache[key] - if !ok { - s.mu.RUnlock() - return nil - } - // Snapshot - snapshot := *data - msgs := make([]providers.Message, len(data.Messages)) - copy(msgs, data.Messages) - snapshot.Messages = msgs - s.mu.RUnlock() - - msgsJSON, _ := json.Marshal(snapshot.Messages) - - _, err := s.db.Exec( - `UPDATE sessions SET - messages = $1, summary = $2, model = $3, provider = $4, channel = $5, - input_tokens = $6, output_tokens = $7, compaction_count = $8, - memory_flush_compaction_count = $9, memory_flush_at = $10, - label = $11, spawned_by = $12, spawn_depth = $13, - agent_id = $14, user_id = $15, updated_at = $16 - WHERE session_key = $17`, - msgsJSON, nilStr(snapshot.Summary), nilStr(snapshot.Model), nilStr(snapshot.Provider), nilStr(snapshot.Channel), - snapshot.InputTokens, snapshot.OutputTokens, snapshot.CompactionCount, - snapshot.MemoryFlushCompactionCount, snapshot.MemoryFlushAt, - nilStr(snapshot.Label), nilStr(snapshot.SpawnedBy), snapshot.SpawnDepth, - nilSessionUUID(snapshot.AgentUUID), nilStr(snapshot.UserID), snapshot.Updated, - key, - ) - return err -} - -func (s *PGSessionStore) LastUsedChannel(agentID string) (string, string) { - prefix := "agent:" + agentID + ":%" - var sessionKey string - err := s.db.QueryRow( - `SELECT session_key FROM sessions - WHERE session_key LIKE $1 - AND session_key NOT LIKE $2 - AND session_key NOT LIKE $3 - AND session_key NOT LIKE $4 - ORDER BY updated_at DESC LIMIT 1`, - prefix, - "agent:"+agentID+":cron:%", - "agent:"+agentID+":subagent:%", - "agent:"+agentID+":heartbeat:%", - ).Scan(&sessionKey) - if err != nil { - return "", "" - } - parts := strings.SplitN(sessionKey, ":", 5) - if len(parts) >= 5 { - return parts[2], parts[4] - } - return "", "" -} - -// --- helpers --- - -func (s *PGSessionStore) getOrInit(key string) *store.SessionData { - if data, ok := s.cache[key]; ok { - return data - } - - // Try loading from DB first to avoid overwriting existing messages - data := s.loadFromDB(key) - if data != nil { - s.cache[key] = data - return data - } - - // Not in DB — create new - now := time.Now() - data = &store.SessionData{ - Key: key, - Messages: []providers.Message{}, - Created: now, - Updated: now, - } - s.cache[key] = data - - msgsJSON, _ := json.Marshal([]providers.Message{}) - s.db.Exec( - `INSERT INTO sessions (id, session_key, messages, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5) ON CONFLICT (session_key) DO NOTHING`, - uuid.Must(uuid.NewV7()), key, msgsJSON, now, now, - ) - return data -} - -func (s *PGSessionStore) loadFromDB(key string) *store.SessionData { - var sessionKey string - var msgsJSON []byte - var summary, model, provider, channel, label, spawnedBy, userID *string - var agentID *uuid.UUID - var inputTokens, outputTokens int64 - var compactionCount, memoryFlushCompactionCount, spawnDepth int - var memoryFlushAt int64 - var createdAt, updatedAt time.Time - - err := s.db.QueryRow( - `SELECT session_key, messages, summary, model, provider, channel, - input_tokens, output_tokens, compaction_count, - memory_flush_compaction_count, memory_flush_at, - label, spawned_by, spawn_depth, agent_id, user_id, - created_at, updated_at - FROM sessions WHERE session_key = $1`, key, - ).Scan(&sessionKey, &msgsJSON, &summary, &model, &provider, &channel, - &inputTokens, &outputTokens, &compactionCount, - &memoryFlushCompactionCount, &memoryFlushAt, - &label, &spawnedBy, &spawnDepth, &agentID, &userID, - &createdAt, &updatedAt) - if err != nil { - return nil - } - - var msgs []providers.Message - json.Unmarshal(msgsJSON, &msgs) - - return &store.SessionData{ - Key: sessionKey, - Messages: msgs, - Summary: derefStr(summary), - Created: createdAt, - Updated: updatedAt, - AgentUUID: derefUUID(agentID), - UserID: derefStr(userID), - Model: derefStr(model), - Provider: derefStr(provider), - Channel: derefStr(channel), - InputTokens: inputTokens, - OutputTokens: outputTokens, - CompactionCount: compactionCount, - MemoryFlushCompactionCount: memoryFlushCompactionCount, - MemoryFlushAt: memoryFlushAt, - Label: derefStr(label), - SpawnedBy: derefStr(spawnedBy), - SpawnDepth: spawnDepth, - } -} - -func nilSessionUUID(u uuid.UUID) *uuid.UUID { - if u == uuid.Nil { - return nil - } - return &u -} diff --git a/internal/store/pg/sessions_list.go b/internal/store/pg/sessions_list.go new file mode 100644 index 00000000..ea299596 --- /dev/null +++ b/internal/store/pg/sessions_list.go @@ -0,0 +1,271 @@ +package pg + +import ( + "database/sql" + "encoding/json" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func (s *PGSessionStore) List(agentID string) []store.SessionInfo { + var rows *sql.Rows + var err error + if agentID != "" { + prefix := "agent:" + agentID + ":%" + rows, err = s.db.Query( + "SELECT session_key, messages, created_at, updated_at, label, channel FROM sessions WHERE session_key LIKE $1 ORDER BY updated_at DESC", prefix) + } else { + rows, err = s.db.Query( + "SELECT session_key, messages, created_at, updated_at, label, channel FROM sessions ORDER BY updated_at DESC") + } + if err != nil { + return nil + } + defer rows.Close() + + var result []store.SessionInfo + for rows.Next() { + var key string + var msgsJSON []byte + var createdAt, updatedAt time.Time + var label, channel *string + if err := rows.Scan(&key, &msgsJSON, &createdAt, &updatedAt, &label, &channel); err != nil { + continue + } + var msgs []providers.Message + json.Unmarshal(msgsJSON, &msgs) + result = append(result, store.SessionInfo{ + Key: key, + MessageCount: len(msgs), + Created: createdAt, + Updated: updatedAt, + Label: derefStr(label), + Channel: derefStr(channel), + }) + } + return result +} + +func (s *PGSessionStore) ListPaged(opts store.SessionListOpts) store.SessionListResult { + limit := opts.Limit + if limit <= 0 { + limit = 20 + } + offset := opts.Offset + if offset < 0 { + offset = 0 + } + + var where string + var whereArgs []interface{} + + if opts.AgentID != "" { + where = " WHERE session_key LIKE $1" + whereArgs = append(whereArgs, "agent:"+opts.AgentID+":%") + } + + // Count total + var total int + countQ := "SELECT COUNT(*) FROM sessions" + where + if err := s.db.QueryRow(countQ, whereArgs...).Scan(&total); err != nil { + return store.SessionListResult{Sessions: []store.SessionInfo{}, Total: 0} + } + + // Fetch page using jsonb_array_length to avoid loading full messages + var selectQ string + var selectArgs []interface{} + + if opts.AgentID != "" { + selectQ = `SELECT session_key, jsonb_array_length(messages), created_at, updated_at, label, channel + FROM sessions WHERE session_key LIKE $1 ORDER BY updated_at DESC LIMIT $2 OFFSET $3` + selectArgs = []interface{}{whereArgs[0], limit, offset} + } else { + selectQ = `SELECT session_key, jsonb_array_length(messages), created_at, updated_at, label, channel + FROM sessions ORDER BY updated_at DESC LIMIT $1 OFFSET $2` + selectArgs = []interface{}{limit, offset} + } + + rows, err := s.db.Query(selectQ, selectArgs...) + if err != nil { + return store.SessionListResult{Sessions: []store.SessionInfo{}, Total: total} + } + defer rows.Close() + + var result []store.SessionInfo + for rows.Next() { + var key string + var msgCount int + var createdAt, updatedAt time.Time + var label, channel *string + if err := rows.Scan(&key, &msgCount, &createdAt, &updatedAt, &label, &channel); err != nil { + continue + } + result = append(result, store.SessionInfo{ + Key: key, + MessageCount: msgCount, + Created: createdAt, + Updated: updatedAt, + Label: derefStr(label), + Channel: derefStr(channel), + }) + } + if result == nil { + result = []store.SessionInfo{} + } + return store.SessionListResult{Sessions: result, Total: total} +} + +func (s *PGSessionStore) Save(key string) error { + s.mu.RLock() + data, ok := s.cache[key] + if !ok { + s.mu.RUnlock() + return nil + } + // Snapshot + snapshot := *data + msgs := make([]providers.Message, len(data.Messages)) + copy(msgs, data.Messages) + snapshot.Messages = msgs + s.mu.RUnlock() + + msgsJSON, _ := json.Marshal(snapshot.Messages) + + _, err := s.db.Exec( + `UPDATE sessions SET + messages = $1, summary = $2, model = $3, provider = $4, channel = $5, + input_tokens = $6, output_tokens = $7, compaction_count = $8, + memory_flush_compaction_count = $9, memory_flush_at = $10, + label = $11, spawned_by = $12, spawn_depth = $13, + agent_id = $14, user_id = $15, updated_at = $16 + WHERE session_key = $17`, + msgsJSON, nilStr(snapshot.Summary), nilStr(snapshot.Model), nilStr(snapshot.Provider), nilStr(snapshot.Channel), + snapshot.InputTokens, snapshot.OutputTokens, snapshot.CompactionCount, + snapshot.MemoryFlushCompactionCount, snapshot.MemoryFlushAt, + nilStr(snapshot.Label), nilStr(snapshot.SpawnedBy), snapshot.SpawnDepth, + nilSessionUUID(snapshot.AgentUUID), nilStr(snapshot.UserID), snapshot.Updated, + key, + ) + return err +} + +func (s *PGSessionStore) LastUsedChannel(agentID string) (string, string) { + prefix := "agent:" + agentID + ":%" + var sessionKey string + err := s.db.QueryRow( + `SELECT session_key FROM sessions + WHERE session_key LIKE $1 + AND session_key NOT LIKE $2 + AND session_key NOT LIKE $3 + AND session_key NOT LIKE $4 + ORDER BY updated_at DESC LIMIT 1`, + prefix, + "agent:"+agentID+":cron:%", + "agent:"+agentID+":subagent:%", + "agent:"+agentID+":heartbeat:%", + ).Scan(&sessionKey) + if err != nil { + return "", "" + } + parts := strings.SplitN(sessionKey, ":", 5) + if len(parts) >= 5 { + return parts[2], parts[4] + } + return "", "" +} + +// --- helpers --- + +func (s *PGSessionStore) getOrInit(key string) *store.SessionData { + if data, ok := s.cache[key]; ok { + return data + } + + // Try loading from DB first to avoid overwriting existing messages + data := s.loadFromDB(key) + if data != nil { + s.cache[key] = data + return data + } + + // Not in DB — create new + now := time.Now() + data = &store.SessionData{ + Key: key, + Messages: []providers.Message{}, + Created: now, + Updated: now, + } + s.cache[key] = data + + msgsJSON, _ := json.Marshal([]providers.Message{}) + s.db.Exec( + `INSERT INTO sessions (id, session_key, messages, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5) ON CONFLICT (session_key) DO NOTHING`, + uuid.Must(uuid.NewV7()), key, msgsJSON, now, now, + ) + return data +} + +func (s *PGSessionStore) loadFromDB(key string) *store.SessionData { + var sessionKey string + var msgsJSON []byte + var summary, model, provider, channel, label, spawnedBy, userID *string + var agentID *uuid.UUID + var inputTokens, outputTokens int64 + var compactionCount, memoryFlushCompactionCount, spawnDepth int + var memoryFlushAt int64 + var createdAt, updatedAt time.Time + + err := s.db.QueryRow( + `SELECT session_key, messages, summary, model, provider, channel, + input_tokens, output_tokens, compaction_count, + memory_flush_compaction_count, memory_flush_at, + label, spawned_by, spawn_depth, agent_id, user_id, + created_at, updated_at + FROM sessions WHERE session_key = $1`, key, + ).Scan(&sessionKey, &msgsJSON, &summary, &model, &provider, &channel, + &inputTokens, &outputTokens, &compactionCount, + &memoryFlushCompactionCount, &memoryFlushAt, + &label, &spawnedBy, &spawnDepth, &agentID, &userID, + &createdAt, &updatedAt) + if err != nil { + return nil + } + + var msgs []providers.Message + json.Unmarshal(msgsJSON, &msgs) + + return &store.SessionData{ + Key: sessionKey, + Messages: msgs, + Summary: derefStr(summary), + Created: createdAt, + Updated: updatedAt, + AgentUUID: derefUUID(agentID), + UserID: derefStr(userID), + Model: derefStr(model), + Provider: derefStr(provider), + Channel: derefStr(channel), + InputTokens: inputTokens, + OutputTokens: outputTokens, + CompactionCount: compactionCount, + MemoryFlushCompactionCount: memoryFlushCompactionCount, + MemoryFlushAt: memoryFlushAt, + Label: derefStr(label), + SpawnedBy: derefStr(spawnedBy), + SpawnDepth: spawnDepth, + } +} + +func nilSessionUUID(u uuid.UUID) *uuid.UUID { + if u == uuid.Nil { + return nil + } + return &u +} diff --git a/internal/store/pg/sessions_ops.go b/internal/store/pg/sessions_ops.go new file mode 100644 index 00000000..9fb0c27d --- /dev/null +++ b/internal/store/pg/sessions_ops.go @@ -0,0 +1,39 @@ +package pg + +import ( + "time" + + "github.com/nextlevelbuilder/goclaw/internal/providers" +) + +func (s *PGSessionStore) TruncateHistory(key string, keepLast int) { + s.mu.Lock() + defer s.mu.Unlock() + if data, ok := s.cache[key]; ok { + if keepLast <= 0 { + data.Messages = []providers.Message{} + } else if len(data.Messages) > keepLast { + data.Messages = data.Messages[len(data.Messages)-keepLast:] + } + data.Updated = time.Now() + } +} + +func (s *PGSessionStore) Reset(key string) { + s.mu.Lock() + defer s.mu.Unlock() + if data, ok := s.cache[key]; ok { + data.Messages = []providers.Message{} + data.Summary = "" + data.Updated = time.Now() + } +} + +func (s *PGSessionStore) Delete(key string) error { + s.mu.Lock() + delete(s.cache, key) + s.mu.Unlock() + + _, err := s.db.Exec("DELETE FROM sessions WHERE session_key = $1", key) + return err +} diff --git a/internal/store/pg/sessions_tokens.go b/internal/store/pg/sessions_tokens.go new file mode 100644 index 00000000..2aace97c --- /dev/null +++ b/internal/store/pg/sessions_tokens.go @@ -0,0 +1,91 @@ +package pg + +import "time" + +func (s *PGSessionStore) AccumulateTokens(key string, input, output int64) { + s.mu.Lock() + defer s.mu.Unlock() + if data, ok := s.cache[key]; ok { + data.InputTokens += input + data.OutputTokens += output + } +} + +func (s *PGSessionStore) IncrementCompaction(key string) { + s.mu.Lock() + defer s.mu.Unlock() + if data, ok := s.cache[key]; ok { + data.CompactionCount++ + } +} + +func (s *PGSessionStore) GetCompactionCount(key string) int { + s.mu.RLock() + defer s.mu.RUnlock() + if data, ok := s.cache[key]; ok { + return data.CompactionCount + } + return 0 +} + +func (s *PGSessionStore) GetMemoryFlushCompactionCount(key string) int { + s.mu.RLock() + defer s.mu.RUnlock() + if data, ok := s.cache[key]; ok { + return data.MemoryFlushCompactionCount + } + return -1 +} + +func (s *PGSessionStore) SetMemoryFlushDone(key string) { + s.mu.Lock() + defer s.mu.Unlock() + if data, ok := s.cache[key]; ok { + data.MemoryFlushCompactionCount = data.CompactionCount + data.MemoryFlushAt = time.Now().UnixMilli() + } +} + +func (s *PGSessionStore) SetSpawnInfo(key, spawnedBy string, depth int) { + s.mu.Lock() + defer s.mu.Unlock() + if data, ok := s.cache[key]; ok { + data.SpawnedBy = spawnedBy + data.SpawnDepth = depth + } +} + +func (s *PGSessionStore) SetContextWindow(key string, cw int) { + s.mu.Lock() + defer s.mu.Unlock() + if data, ok := s.cache[key]; ok { + data.ContextWindow = cw + } +} + +func (s *PGSessionStore) GetContextWindow(key string) int { + s.mu.RLock() + defer s.mu.RUnlock() + if data, ok := s.cache[key]; ok { + return data.ContextWindow + } + return 0 +} + +func (s *PGSessionStore) SetLastPromptTokens(key string, tokens, msgCount int) { + s.mu.Lock() + defer s.mu.Unlock() + if data, ok := s.cache[key]; ok { + data.LastPromptTokens = tokens + data.LastMessageCount = msgCount + } +} + +func (s *PGSessionStore) GetLastPromptTokens(key string) (int, int) { + s.mu.RLock() + defer s.mu.RUnlock() + if data, ok := s.cache[key]; ok { + return data.LastPromptTokens, data.LastMessageCount + } + return 0, 0 +} diff --git a/internal/tools/delegate.go b/internal/tools/delegate.go index 6a4ac58a..bb5bb0d9 100644 --- a/internal/tools/delegate.go +++ b/internal/tools/delegate.go @@ -2,9 +2,6 @@ package tools import ( "context" - "fmt" - "log/slog" - "strings" "sync" "time" @@ -13,7 +10,6 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/hooks" "github.com/nextlevelbuilder/goclaw/internal/store" - "github.com/nextlevelbuilder/goclaw/internal/tracing" ) const defaultMaxDelegationLoad = 5 @@ -173,515 +169,3 @@ func (dm *DelegateManager) SetHookEngine(engine *hooks.Engine) { dm.hookEngine = engine } -// Delegate executes a synchronous delegation to another agent. -func (dm *DelegateManager) Delegate(ctx context.Context, opts DelegateOpts) (*DelegateResult, error) { - task, _, err := dm.prepareDelegation(ctx, opts, "sync") - if err != nil { - return nil, err - } - - dm.active.Store(task.ID, task) - defer func() { - now := time.Now() - task.CompletedAt = &now - dm.active.Delete(task.ID) - }() - - dm.injectDependencyResults(ctx, &opts) - message := buildDelegateMessage(opts) - dm.emitEvent("delegation.started", task) - slog.Info("delegation started", "id", task.ID, "target", opts.TargetAgentKey, "mode", "sync") - - // Propagate parent trace ID so the delegate trace links back. - // Clear senderID — delegations are system-initiated, the delegate agent - // should not inherit the caller's group writer permissions (the delegate - // agent has its own writer list, and would incorrectly deny writes). - delegateCtx := store.WithSenderID(ctx, "") - if parentTraceID := tracing.TraceIDFromContext(ctx); parentTraceID != uuid.Nil { - delegateCtx = tracing.WithDelegateParentTraceID(delegateCtx, parentTraceID) - } - - startTime := time.Now() - result, err := dm.runAgent(delegateCtx, opts.TargetAgentKey, dm.buildRunRequest(task, message)) - duration := time.Since(startTime) - if err != nil { - task.Status = "failed" - dm.emitEvent("delegation.failed", task) - dm.saveDelegationHistory(task, "", err, duration) - return nil, fmt.Errorf("delegation to %q failed: %w", opts.TargetAgentKey, err) - } - - // Apply quality gates before marking completed. - if result, err = dm.applyQualityGates(delegateCtx, task, opts, result); err != nil { - task.Status = "failed" - dm.emitEvent("delegation.failed", task) - dm.saveDelegationHistory(task, "", err, duration) - return nil, fmt.Errorf("delegation to %q failed quality gate: %w", opts.TargetAgentKey, err) - } - - task.Status = "completed" - dm.emitEvent("delegation.completed", task) - dm.trackCompleted(task) - dm.autoCompleteTeamTask(task, result.Content, result.Deliverables) - dm.saveDelegationHistory(task, result.Content, nil, duration) - slog.Info("delegation completed", "id", task.ID, "target", opts.TargetAgentKey, "iterations", result.Iterations) - - return &DelegateResult{Content: result.Content, Iterations: result.Iterations, DelegationID: task.ID, TeamTaskID: task.TeamTaskID.String(), MediaPaths: result.MediaPaths}, nil -} - -// DelegateAsync spawns a delegation in the background and announces the result back. -func (dm *DelegateManager) DelegateAsync(ctx context.Context, opts DelegateOpts) (*DelegateResult, error) { - task, _, err := dm.prepareDelegation(ctx, opts, "async") - if err != nil { - return nil, err - } - - taskCtx, taskCancel := context.WithCancel(context.Background()) - task.cancelFunc = taskCancel - dm.active.Store(task.ID, task) - - // Capture parent trace ID before goroutine (ctx.Background() loses it) - parentTraceID := tracing.TraceIDFromContext(ctx) - if parentTraceID != uuid.Nil { - taskCtx = tracing.WithDelegateParentTraceID(taskCtx, parentTraceID) - } - - dm.injectDependencyResults(ctx, &opts) - message := buildDelegateMessage(opts) - dm.emitEvent("delegation.started", task) - slog.Info("delegation started (async)", "id", task.ID, "target", opts.TargetAgentKey) - - runReq := dm.buildRunRequest(task, message) - - go func() { - defer func() { - now := time.Now() - task.CompletedAt = &now - dm.active.Delete(task.ID) - }() - - // Periodic progress notifications — tick every interval until runAgent returns - // or the delegation is cancelled. Listens on both progressDone (normal exit) - // and taskCtx.Done() (cancel/stopall) to avoid goroutine leaks. - progressDone := make(chan struct{}) - go func() { - ticker := time.NewTicker(defaultProgressInterval) - defer ticker.Stop() - for { - select { - case <-ticker.C: - dm.sendProgressNotification(task) - case <-progressDone: - return - case <-taskCtx.Done(): - return - } - } - }() - - startTime := time.Now() - result, runErr := dm.runAgent(taskCtx, opts.TargetAgentKey, runReq) - close(progressDone) - duration := time.Since(startTime) - - // Count sibling delegations still running (exclude self) - siblings := dm.ListActive(task.SourceAgentID) - siblingCount := 0 - for _, s := range siblings { - if s.ID != task.ID { - siblingCount++ - } - } - // Announce result to parent via message bus - if dm.msgBus != nil && task.OriginChannel != "" { - elapsed := time.Since(task.CreatedAt) - - if siblingCount > 0 { - // Intermediate completion: accumulate artifacts + result summary. - // The final announce includes all sibling results so the lead doesn't - // need to call team_tasks to aggregate. - arts := &DelegateArtifacts{} - if result != nil { - arts.Media = result.MediaPaths - arts.Results = []DelegateResultSummary{{ - AgentKey: task.TargetAgentKey, - Content: result.Content, - HasMedia: len(result.MediaPaths) > 0, - Deliverables: result.Deliverables, - }} - } else if runErr != nil { - arts.Results = []DelegateResultSummary{{ - AgentKey: task.TargetAgentKey, - Content: fmt.Sprintf("[failed] %s", runErr.Error()), - }} - } - if task.TeamTaskID != uuid.Nil { - arts.CompletedTaskIDs = []string{task.TeamTaskID.String()} - } - dm.accumulateArtifacts(task.SourceAgentID, arts) - slog.Info("delegation announce suppressed (siblings still running)", - "id", task.ID, "target", task.TargetAgentKey, "siblings", siblingCount) - } else { - // Last completion: clear progress dedup so next batch gets fresh notifications. - dm.progressSent.Delete(task.SourceAgentID.String() + ":" + task.OriginChatID) - - // Last completion: collect all accumulated artifacts + own result - artifacts := dm.collectArtifacts(task.SourceAgentID) - if result != nil { - artifacts.Media = append(artifacts.Media, result.MediaPaths...) - artifacts.Results = append(artifacts.Results, DelegateResultSummary{ - AgentKey: task.TargetAgentKey, - Content: result.Content, - HasMedia: len(result.MediaPaths) > 0, - Deliverables: result.Deliverables, - }) - } - if task.TeamTaskID != uuid.Nil { - artifacts.CompletedTaskIDs = append(artifacts.CompletedTaskIDs, task.TeamTaskID.String()) - } - - announceMeta := map[string]string{ - "origin_channel": task.OriginChannel, - "origin_peer_kind": task.OriginPeerKind, - "parent_agent": task.SourceAgentKey, - "delegation_id": task.ID, - "target_agent": task.TargetAgentKey, - "origin_trace_id": task.OriginTraceID.String(), - "origin_root_span_id": task.OriginRootSpanID.String(), - } - if task.OriginLocalKey != "" { - announceMeta["origin_local_key"] = task.OriginLocalKey - } - announceMsg := bus.InboundMessage{ - Channel: "system", - SenderID: fmt.Sprintf("delegate:%s", task.ID), - ChatID: task.OriginChatID, - Content: formatDelegateAnnounce(task, artifacts, runErr, elapsed), - UserID: task.UserID, - Metadata: announceMeta, - Media: artifacts.Media, - } - dm.msgBus.PublishInbound(announceMsg) - } - } - - if runErr != nil { - task.Status = "failed" - dm.emitEvent("delegation.failed", task) - dm.saveDelegationHistory(task, "", runErr, duration) - } else { - // Apply quality gates before marking completed. - if result, runErr = dm.applyQualityGates(taskCtx, task, opts, result); runErr != nil { - task.Status = "failed" - dm.emitEvent("delegation.failed", task) - dm.saveDelegationHistory(task, "", runErr, duration) - } else { - task.Status = "completed" - dm.emitEvent("delegation.completed", task) - dm.trackCompleted(task) - resultContent := "" - var deliverables []string - if result != nil { - resultContent = result.Content - deliverables = result.Deliverables - } - // Auto-complete the team task for EVERY delegation (not just the last one). - // Each delegation has its own TeamTaskID — the isLastDelegation guard - // is for announce batching only, not for task completion. - dm.autoCompleteTeamTask(task, resultContent, deliverables) - dm.saveDelegationHistory(task, resultContent, nil, duration) - } - } - slog.Info("delegation finished (async)", "id", task.ID, "target", task.TargetAgentKey, "status", task.Status) - }() - - return &DelegateResult{DelegationID: task.ID, TeamTaskID: task.TeamTaskID.String()}, nil -} - -// --- internal helpers --- - -func (dm *DelegateManager) prepareDelegation(ctx context.Context, opts DelegateOpts, mode string) (*DelegationTask, *store.AgentLinkData, error) { - sourceAgentID := store.AgentIDFromContext(ctx) - if sourceAgentID == uuid.Nil { - return nil, nil, fmt.Errorf("delegation requires managed mode (no agent ID in context)") - } - - sourceAgent, err := dm.agentStore.GetByID(ctx, sourceAgentID) - if err != nil { - return nil, nil, fmt.Errorf("source agent not found: %w", err) - } - - targetAgent, err := dm.agentStore.GetByKey(ctx, opts.TargetAgentKey) - if err != nil { - return nil, nil, fmt.Errorf("target agent %q not found", opts.TargetAgentKey) - } - - link, err := dm.linkStore.GetLinkBetween(ctx, sourceAgentID, targetAgent.ID) - if err != nil { - return nil, nil, fmt.Errorf("failed to check delegation permission: %w", err) - } - if link == nil { - return nil, nil, fmt.Errorf("no delegation link from this agent to %q. Available targets are listed in AGENTS.md", opts.TargetAgentKey) - } - - userID := store.UserIDFromContext(ctx) - if err := checkUserPermission(link.Settings, userID); err != nil { - return nil, nil, err - } - - // Resolve team once — used for task enforcement, validation, and access checks. - var team *store.TeamData - if dm.teamStore != nil { - team, _ = dm.teamStore.GetTeamForAgent(ctx, sourceAgentID) - } - - // Auto-create team task when team_task_id is omitted. - // This eliminates the two-step create→spawn dance that caused LLM hallucination - // (LLM would call create+spawn in parallel, hallucinating the task_id). - if team != nil && opts.TeamTaskID == uuid.Nil { - subject := opts.Label - if subject == "" { - subject = opts.Task - if len(subject) > 100 { - subject = subject[:100] + "..." - } - } - taskData := &store.TeamTaskData{ - TeamID: team.ID, - Subject: subject, - Description: opts.Task, - Status: store.TeamTaskStatusPending, - UserID: store.UserIDFromContext(ctx), - Channel: ToolChannelFromCtx(ctx), - } - if err := dm.teamStore.CreateTask(ctx, taskData); err != nil { - return nil, nil, fmt.Errorf("failed to auto-create team task: %w", err) - } - opts.TeamTaskID = taskData.ID - slog.Info("delegate: auto-created team task", - "task_id", taskData.ID, "subject", subject, "target", opts.TargetAgentKey) - } - - // Validate that team_task_id belongs to the agent's team (prevent cross-team task completion). - if dm.teamStore != nil && opts.TeamTaskID != uuid.Nil { - teamTask, err := dm.teamStore.GetTask(ctx, opts.TeamTaskID) - if err != nil { - return nil, nil, fmt.Errorf( - "team_task_id %s not found. Use team_tasks action=list to see available tasks, or omit team_task_id to auto-create.", - opts.TeamTaskID) - } - - // Guard: scope task to current user_id (prevent cross-group task leak). - // user_id is the GROUP composite ID (e.g. "group:telegram:-1003701523276"), NOT the sender. - // Delegate/system channels skip this check — they operate cross-context by design. - currentUserID := store.UserIDFromContext(ctx) - channel := ToolChannelFromCtx(ctx) - if channel != "delegate" && channel != "system" && - teamTask.UserID != "" && currentUserID != "" && teamTask.UserID != currentUserID { - return nil, nil, fmt.Errorf( - "team_task_id %s belongs to a different context. Omit team_task_id to auto-create a new task.", - opts.TeamTaskID) - } - - // Guard: reject completed/cancelled tasks — enforce "one task per delegation". - if teamTask.Status == store.TeamTaskStatusCompleted || teamTask.Status == "cancelled" { - ownerLabel := "another agent" - if teamTask.OwnerAgentKey != "" { - ownerLabel = teamTask.OwnerAgentKey - } - return nil, nil, fmt.Errorf( - "team_task_id %s is already %s (completed by %q). Omit team_task_id to auto-create a new task.", - opts.TeamTaskID, teamTask.Status, ownerLabel) - } - - if team != nil { - if teamTask.TeamID != team.ID { - return nil, nil, fmt.Errorf("team_task_id does not belong to your team") - } - userID := store.UserIDFromContext(ctx) - ch := ToolChannelFromCtx(ctx) - if err := checkTeamAccess(team.Settings, userID, ch); err != nil { - return nil, nil, fmt.Errorf("team access denied: %w", err) - } - } - - // Auto-populate task description from spawn prompt if empty. - // This ensures the task board has full context for audit/visibility - // without relying on the LLM to set description at task creation time. - if teamTask.Description == "" && opts.Task != "" { - _ = dm.teamStore.UpdateTask(ctx, opts.TeamTaskID, map[string]any{ - "description": opts.Task, - }) - } - - // Claim task early so status moves to in_progress immediately. - // This prevents the pending reminder from re-triggering spawns for - // tasks that are already running. The ClaimTask in autoCompleteTeamTask() - // will harmlessly fail (WHERE status='pending' won't match). - _ = dm.teamStore.ClaimTask(ctx, opts.TeamTaskID, targetAgent.ID, teamTask.TeamID) - } - - linkCount := dm.ActiveCountForLink(sourceAgentID, targetAgent.ID) - if link.MaxConcurrent > 0 && linkCount >= link.MaxConcurrent { - return nil, nil, fmt.Errorf("delegation link to %q is at capacity (%d/%d active). Try again later or handle the task yourself", - opts.TargetAgentKey, linkCount, link.MaxConcurrent) - } - - targetCount := dm.ActiveCountForTarget(targetAgent.ID) - maxLoad := parseMaxDelegationLoad(targetAgent.OtherConfig) - if targetCount >= maxLoad { - return nil, nil, fmt.Errorf("agent %q is at capacity (%d/%d active delegations). Either wait and retry, use a different agent, or handle the task yourself", - opts.TargetAgentKey, targetCount, maxLoad) - } - - channel := ToolChannelFromCtx(ctx) - chatID := ToolChatIDFromCtx(ctx) - peerKind := ToolPeerKindFromCtx(ctx) - localKey := ToolLocalKeyFromCtx(ctx) - - delegationID := uuid.NewString()[:12] - task := &DelegationTask{ - ID: delegationID, - SourceAgentID: sourceAgentID, - SourceAgentKey: sourceAgent.AgentKey, - TargetAgentID: targetAgent.ID, - TargetAgentKey: opts.TargetAgentKey, - TargetDisplayName: targetAgent.DisplayName, - UserID: userID, - Task: opts.Task, - Status: "running", - Mode: mode, - SessionKey: fmt.Sprintf("delegate:%s:%s:%s", - sourceAgentID.String()[:8], opts.TargetAgentKey, delegationID), - CreatedAt: time.Now(), - OriginChannel: channel, - OriginChatID: chatID, - OriginPeerKind: peerKind, - OriginLocalKey: localKey, - OriginTraceID: tracing.TraceIDFromContext(ctx), - OriginRootSpanID: tracing.ParentSpanIDFromContext(ctx), - TeamTaskID: opts.TeamTaskID, - } - - // Carry team_id from the link (for delegation history filtering by team) - if link.TeamID != nil { - task.TeamID = *link.TeamID - } - - return task, link, nil -} - -// injectDependencyResults fetches completed dependency results for a task's -// blocked_by prerequisites and prepends them to opts.Context. This ensures the -// delegate agent receives prior results without needing to search for them. -func (dm *DelegateManager) injectDependencyResults(ctx context.Context, opts *DelegateOpts) { - if dm.teamStore == nil || opts.TeamTaskID == uuid.Nil { - return - } - teamTask, err := dm.teamStore.GetTask(ctx, opts.TeamTaskID) - if err != nil || len(teamTask.BlockedBy) == 0 { - return - } - - var depContext []string - for _, depID := range teamTask.BlockedBy { - dep, err := dm.teamStore.GetTask(ctx, depID) - if err != nil || dep.Result == nil || *dep.Result == "" { - continue - } - result := *dep.Result - if len(result) > 8000 { - result = result[:8000] + "\n[...truncated]" - } - agentLabel := dep.OwnerAgentKey - if agentLabel == "" { - agentLabel = "unknown" - } - depContext = append(depContext, fmt.Sprintf( - "--- Result from dependency task %q (id=%s, by %s) ---\n%s", - dep.Subject, dep.ID, agentLabel, result)) - } - - if len(depContext) > 0 { - injected := strings.Join(depContext, "\n\n") - if opts.Context != "" { - opts.Context = injected + "\n\n" + opts.Context - } else { - opts.Context = injected - } - } -} - -// sendProgressNotification sends a grouped "still working" message listing all -// active delegations from the same source agent. Uses progressSent to dedup — -// concurrent tickers only send one notification per cycle, then release for next tick. -func (dm *DelegateManager) sendProgressNotification(task *DelegationTask) { - // Skip internal/delegate channels — only notify on real user-facing channels. - if dm.msgBus == nil || task.OriginChannel == "" || task.OriginChatID == "" || - task.OriginChannel == "delegate" || task.OriginChannel == "system" { - return - } - - // Dedup: one grouped notification per source agent per chat per tick cycle. - dedupKey := task.SourceAgentID.String() + ":" + task.OriginChatID - if _, loaded := dm.progressSent.LoadOrStore(dedupKey, true); loaded { - return - } - defer dm.progressSent.Delete(dedupKey) // release for next tick - - // Collect all active delegations from same source agent. - active := dm.ListActive(task.SourceAgentID) - if len(active) == 0 { - return - } - - var lines []string - for _, t := range active { - elapsed := time.Since(t.CreatedAt).Round(time.Second) - label := t.TargetAgentKey - if t.TargetDisplayName != "" { - label = fmt.Sprintf("%s (%s)", t.TargetDisplayName, t.TargetAgentKey) - } - lines = append(lines, fmt.Sprintf("- %s: %s", label, elapsed)) - } - - content := fmt.Sprintf("⏳ Your team is working on it...\n%s", strings.Join(lines, "\n")) - - dm.msgBus.PublishOutbound(bus.OutboundMessage{ - Channel: task.OriginChannel, - ChatID: task.OriginChatID, - Content: content, - Metadata: map[string]string{ - "local_key": task.OriginLocalKey, - "peer_kind": task.OriginPeerKind, - }, - }) -} - -func buildDelegateMessage(opts DelegateOpts) string { - if opts.Context != "" { - return fmt.Sprintf("[Additional Context]\n%s\n\n[Task]\n%s", opts.Context, opts.Task) - } - return opts.Task -} - - -func (dm *DelegateManager) buildRunRequest(task *DelegationTask, message string) DelegateRunRequest { - return DelegateRunRequest{ - SessionKey: task.SessionKey, - Message: message, - UserID: task.UserID, - Channel: "delegate", - ChatID: task.OriginChatID, - PeerKind: task.OriginPeerKind, - RunID: fmt.Sprintf("delegate-%s", task.ID), - Stream: false, - ExtraSystemPrompt: "[Delegation Context]\nYou are handling a delegated task from another agent.\n" + - "- Focus exclusively on the delegated task below.\n" + - "- Your complete response will be returned to the requesting agent.\n" + - "- Do NOT try to communicate with the end user directly.\n" + - "- Do NOT use your persona name or self-references (e.g. do not say your name). Write factual, neutral content.\n" + - "- Be concise and deliver actionable results.\n" + - "- IMPORTANT: If the delegated task falls outside your expertise scope (as defined in your SOUL.md), politely refuse and explain that this task is not within your domain. Do NOT attempt tasks outside your scope.", - } -} diff --git a/internal/tools/delegate_async.go b/internal/tools/delegate_async.go new file mode 100644 index 00000000..878a1123 --- /dev/null +++ b/internal/tools/delegate_async.go @@ -0,0 +1,182 @@ +package tools + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/tracing" +) + +// DelegateAsync spawns a delegation in the background and announces the result back. +func (dm *DelegateManager) DelegateAsync(ctx context.Context, opts DelegateOpts) (*DelegateResult, error) { + task, _, err := dm.prepareDelegation(ctx, opts, "async") + if err != nil { + return nil, err + } + + taskCtx, taskCancel := context.WithCancel(context.Background()) + task.cancelFunc = taskCancel + dm.active.Store(task.ID, task) + + // Capture parent trace ID before goroutine (ctx.Background() loses it) + parentTraceID := tracing.TraceIDFromContext(ctx) + if parentTraceID != uuid.Nil { + taskCtx = tracing.WithDelegateParentTraceID(taskCtx, parentTraceID) + } + + dm.injectDependencyResults(ctx, &opts) + message := buildDelegateMessage(opts) + dm.emitEvent("delegation.started", task) + slog.Info("delegation started (async)", "id", task.ID, "target", opts.TargetAgentKey) + + runReq := dm.buildRunRequest(task, message) + + go func() { + defer func() { + now := time.Now() + task.CompletedAt = &now + dm.active.Delete(task.ID) + }() + + // Periodic progress notifications — tick every interval until runAgent returns + // or the delegation is cancelled. Listens on both progressDone (normal exit) + // and taskCtx.Done() (cancel/stopall) to avoid goroutine leaks. + progressDone := make(chan struct{}) + go func() { + ticker := time.NewTicker(defaultProgressInterval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + dm.sendProgressNotification(task) + case <-progressDone: + return + case <-taskCtx.Done(): + return + } + } + }() + + startTime := time.Now() + result, runErr := dm.runAgent(taskCtx, opts.TargetAgentKey, runReq) + close(progressDone) + duration := time.Since(startTime) + + // Count sibling delegations still running (exclude self) + siblings := dm.ListActive(task.SourceAgentID) + siblingCount := 0 + for _, s := range siblings { + if s.ID != task.ID { + siblingCount++ + } + } + // Announce result to parent via message bus + if dm.msgBus != nil && task.OriginChannel != "" { + elapsed := time.Since(task.CreatedAt) + + if siblingCount > 0 { + // Intermediate completion: accumulate artifacts + result summary. + // The final announce includes all sibling results so the lead doesn't + // need to call team_tasks to aggregate. + arts := &DelegateArtifacts{} + if result != nil { + arts.Media = result.MediaPaths + arts.Results = []DelegateResultSummary{{ + AgentKey: task.TargetAgentKey, + Content: result.Content, + HasMedia: len(result.MediaPaths) > 0, + Deliverables: result.Deliverables, + }} + } else if runErr != nil { + arts.Results = []DelegateResultSummary{{ + AgentKey: task.TargetAgentKey, + Content: fmt.Sprintf("[failed] %s", runErr.Error()), + }} + } + if task.TeamTaskID != uuid.Nil { + arts.CompletedTaskIDs = []string{task.TeamTaskID.String()} + } + dm.accumulateArtifacts(task.SourceAgentID, arts) + slog.Info("delegation announce suppressed (siblings still running)", + "id", task.ID, "target", task.TargetAgentKey, "siblings", siblingCount) + } else { + // Last completion: clear progress dedup so next batch gets fresh notifications. + dm.progressSent.Delete(task.SourceAgentID.String() + ":" + task.OriginChatID) + + // Last completion: collect all accumulated artifacts + own result + artifacts := dm.collectArtifacts(task.SourceAgentID) + if result != nil { + artifacts.Media = append(artifacts.Media, result.MediaPaths...) + artifacts.Results = append(artifacts.Results, DelegateResultSummary{ + AgentKey: task.TargetAgentKey, + Content: result.Content, + HasMedia: len(result.MediaPaths) > 0, + Deliverables: result.Deliverables, + }) + } + if task.TeamTaskID != uuid.Nil { + artifacts.CompletedTaskIDs = append(artifacts.CompletedTaskIDs, task.TeamTaskID.String()) + } + + announceMeta := map[string]string{ + "origin_channel": task.OriginChannel, + "origin_peer_kind": task.OriginPeerKind, + "parent_agent": task.SourceAgentKey, + "delegation_id": task.ID, + "target_agent": task.TargetAgentKey, + "origin_trace_id": task.OriginTraceID.String(), + "origin_root_span_id": task.OriginRootSpanID.String(), + } + if task.OriginLocalKey != "" { + announceMeta["origin_local_key"] = task.OriginLocalKey + } + announceMsg := bus.InboundMessage{ + Channel: "system", + SenderID: fmt.Sprintf("delegate:%s", task.ID), + ChatID: task.OriginChatID, + Content: formatDelegateAnnounce(task, artifacts, runErr, elapsed), + UserID: task.UserID, + Metadata: announceMeta, + Media: artifacts.Media, + } + dm.msgBus.PublishInbound(announceMsg) + } + } + + if runErr != nil { + task.Status = "failed" + dm.emitEvent("delegation.failed", task) + dm.saveDelegationHistory(task, "", runErr, duration) + } else { + // Apply quality gates before marking completed. + if result, runErr = dm.applyQualityGates(taskCtx, task, opts, result); runErr != nil { + task.Status = "failed" + dm.emitEvent("delegation.failed", task) + dm.saveDelegationHistory(task, "", runErr, duration) + } else { + task.Status = "completed" + dm.emitEvent("delegation.completed", task) + dm.trackCompleted(task) + resultContent := "" + var deliverables []string + if result != nil { + resultContent = result.Content + deliverables = result.Deliverables + } + // Auto-complete the team task for EVERY delegation (not just the last one). + // Each delegation has its own TeamTaskID — the isLastDelegation guard + // is for announce batching only, not for task completion. + dm.autoCompleteTeamTask(task, resultContent, deliverables) + dm.saveDelegationHistory(task, resultContent, nil, duration) + } + } + slog.Info("delegation finished (async)", "id", task.ID, "target", task.TargetAgentKey, "status", task.Status) + }() + + return &DelegateResult{DelegationID: task.ID, TeamTaskID: task.TeamTaskID.String()}, nil +} diff --git a/internal/tools/delegate_prep.go b/internal/tools/delegate_prep.go new file mode 100644 index 00000000..294255a4 --- /dev/null +++ b/internal/tools/delegate_prep.go @@ -0,0 +1,300 @@ +package tools + +import ( + "context" + "fmt" + "log/slog" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/tracing" +) + +func (dm *DelegateManager) prepareDelegation(ctx context.Context, opts DelegateOpts, mode string) (*DelegationTask, *store.AgentLinkData, error) { + sourceAgentID := store.AgentIDFromContext(ctx) + if sourceAgentID == uuid.Nil { + return nil, nil, fmt.Errorf("delegation requires managed mode (no agent ID in context)") + } + + sourceAgent, err := dm.agentStore.GetByID(ctx, sourceAgentID) + if err != nil { + return nil, nil, fmt.Errorf("source agent not found: %w", err) + } + + targetAgent, err := dm.agentStore.GetByKey(ctx, opts.TargetAgentKey) + if err != nil { + return nil, nil, fmt.Errorf("target agent %q not found", opts.TargetAgentKey) + } + + link, err := dm.linkStore.GetLinkBetween(ctx, sourceAgentID, targetAgent.ID) + if err != nil { + return nil, nil, fmt.Errorf("failed to check delegation permission: %w", err) + } + if link == nil { + return nil, nil, fmt.Errorf("no delegation link from this agent to %q. Available targets are listed in AGENTS.md", opts.TargetAgentKey) + } + + userID := store.UserIDFromContext(ctx) + if err := checkUserPermission(link.Settings, userID); err != nil { + return nil, nil, err + } + + // Resolve team once — used for task enforcement, validation, and access checks. + var team *store.TeamData + if dm.teamStore != nil { + team, _ = dm.teamStore.GetTeamForAgent(ctx, sourceAgentID) + } + + // Auto-create team task when team_task_id is omitted. + // This eliminates the two-step create→spawn dance that caused LLM hallucination + // (LLM would call create+spawn in parallel, hallucinating the task_id). + if team != nil && opts.TeamTaskID == uuid.Nil { + subject := opts.Label + if subject == "" { + subject = opts.Task + if len(subject) > 100 { + subject = subject[:100] + "..." + } + } + taskData := &store.TeamTaskData{ + TeamID: team.ID, + Subject: subject, + Description: opts.Task, + Status: store.TeamTaskStatusPending, + UserID: store.UserIDFromContext(ctx), + Channel: ToolChannelFromCtx(ctx), + } + if err := dm.teamStore.CreateTask(ctx, taskData); err != nil { + return nil, nil, fmt.Errorf("failed to auto-create team task: %w", err) + } + opts.TeamTaskID = taskData.ID + slog.Info("delegate: auto-created team task", + "task_id", taskData.ID, "subject", subject, "target", opts.TargetAgentKey) + } + + // Validate that team_task_id belongs to the agent's team (prevent cross-team task completion). + if dm.teamStore != nil && opts.TeamTaskID != uuid.Nil { + teamTask, err := dm.teamStore.GetTask(ctx, opts.TeamTaskID) + if err != nil { + return nil, nil, fmt.Errorf( + "team_task_id %s not found. Use team_tasks action=list to see available tasks, or omit team_task_id to auto-create.", + opts.TeamTaskID) + } + + // Guard: scope task to current user_id (prevent cross-group task leak). + // user_id is the GROUP composite ID (e.g. "group:telegram:-1003701523276"), NOT the sender. + // Delegate/system channels skip this check — they operate cross-context by design. + currentUserID := store.UserIDFromContext(ctx) + channel := ToolChannelFromCtx(ctx) + if channel != "delegate" && channel != "system" && + teamTask.UserID != "" && currentUserID != "" && teamTask.UserID != currentUserID { + return nil, nil, fmt.Errorf( + "team_task_id %s belongs to a different context. Omit team_task_id to auto-create a new task.", + opts.TeamTaskID) + } + + // Guard: reject completed/cancelled tasks — enforce "one task per delegation". + if teamTask.Status == store.TeamTaskStatusCompleted || teamTask.Status == "cancelled" { + ownerLabel := "another agent" + if teamTask.OwnerAgentKey != "" { + ownerLabel = teamTask.OwnerAgentKey + } + return nil, nil, fmt.Errorf( + "team_task_id %s is already %s (completed by %q). Omit team_task_id to auto-create a new task.", + opts.TeamTaskID, teamTask.Status, ownerLabel) + } + + if team != nil { + if teamTask.TeamID != team.ID { + return nil, nil, fmt.Errorf("team_task_id does not belong to your team") + } + userID := store.UserIDFromContext(ctx) + ch := ToolChannelFromCtx(ctx) + if err := checkTeamAccess(team.Settings, userID, ch); err != nil { + return nil, nil, fmt.Errorf("team access denied: %w", err) + } + } + + // Auto-populate task description from spawn prompt if empty. + // This ensures the task board has full context for audit/visibility + // without relying on the LLM to set description at task creation time. + if teamTask.Description == "" && opts.Task != "" { + _ = dm.teamStore.UpdateTask(ctx, opts.TeamTaskID, map[string]any{ + "description": opts.Task, + }) + } + + // Claim task early so status moves to in_progress immediately. + // This prevents the pending reminder from re-triggering spawns for + // tasks that are already running. The ClaimTask in autoCompleteTeamTask() + // will harmlessly fail (WHERE status='pending' won't match). + _ = dm.teamStore.ClaimTask(ctx, opts.TeamTaskID, targetAgent.ID, teamTask.TeamID) + } + + linkCount := dm.ActiveCountForLink(sourceAgentID, targetAgent.ID) + if link.MaxConcurrent > 0 && linkCount >= link.MaxConcurrent { + return nil, nil, fmt.Errorf("delegation link to %q is at capacity (%d/%d active). Try again later or handle the task yourself", + opts.TargetAgentKey, linkCount, link.MaxConcurrent) + } + + targetCount := dm.ActiveCountForTarget(targetAgent.ID) + maxLoad := parseMaxDelegationLoad(targetAgent.OtherConfig) + if targetCount >= maxLoad { + return nil, nil, fmt.Errorf("agent %q is at capacity (%d/%d active delegations). Either wait and retry, use a different agent, or handle the task yourself", + opts.TargetAgentKey, targetCount, maxLoad) + } + + channel := ToolChannelFromCtx(ctx) + chatID := ToolChatIDFromCtx(ctx) + peerKind := ToolPeerKindFromCtx(ctx) + localKey := ToolLocalKeyFromCtx(ctx) + + delegationID := uuid.NewString()[:12] + task := &DelegationTask{ + ID: delegationID, + SourceAgentID: sourceAgentID, + SourceAgentKey: sourceAgent.AgentKey, + TargetAgentID: targetAgent.ID, + TargetAgentKey: opts.TargetAgentKey, + TargetDisplayName: targetAgent.DisplayName, + UserID: userID, + Task: opts.Task, + Status: "running", + Mode: mode, + SessionKey: fmt.Sprintf("delegate:%s:%s:%s", + sourceAgentID.String()[:8], opts.TargetAgentKey, delegationID), + CreatedAt: time.Now(), + OriginChannel: channel, + OriginChatID: chatID, + OriginPeerKind: peerKind, + OriginLocalKey: localKey, + OriginTraceID: tracing.TraceIDFromContext(ctx), + OriginRootSpanID: tracing.ParentSpanIDFromContext(ctx), + TeamTaskID: opts.TeamTaskID, + } + + // Carry team_id from the link (for delegation history filtering by team) + if link.TeamID != nil { + task.TeamID = *link.TeamID + } + + return task, link, nil +} + +// injectDependencyResults fetches completed dependency results for a task's +// blocked_by prerequisites and prepends them to opts.Context. This ensures the +// delegate agent receives prior results without needing to search for them. +func (dm *DelegateManager) injectDependencyResults(ctx context.Context, opts *DelegateOpts) { + if dm.teamStore == nil || opts.TeamTaskID == uuid.Nil { + return + } + teamTask, err := dm.teamStore.GetTask(ctx, opts.TeamTaskID) + if err != nil || len(teamTask.BlockedBy) == 0 { + return + } + + var depContext []string + for _, depID := range teamTask.BlockedBy { + dep, err := dm.teamStore.GetTask(ctx, depID) + if err != nil || dep.Result == nil || *dep.Result == "" { + continue + } + result := *dep.Result + if len(result) > 8000 { + result = result[:8000] + "\n[...truncated]" + } + agentLabel := dep.OwnerAgentKey + if agentLabel == "" { + agentLabel = "unknown" + } + depContext = append(depContext, fmt.Sprintf( + "--- Result from dependency task %q (id=%s, by %s) ---\n%s", + dep.Subject, dep.ID, agentLabel, result)) + } + + if len(depContext) > 0 { + injected := strings.Join(depContext, "\n\n") + if opts.Context != "" { + opts.Context = injected + "\n\n" + opts.Context + } else { + opts.Context = injected + } + } +} + +// sendProgressNotification sends a grouped "still working" message listing all +// active delegations from the same source agent. Uses progressSent to dedup — +// concurrent tickers only send one notification per cycle, then release for next tick. +func (dm *DelegateManager) sendProgressNotification(task *DelegationTask) { + // Skip internal/delegate channels — only notify on real user-facing channels. + if dm.msgBus == nil || task.OriginChannel == "" || task.OriginChatID == "" || + task.OriginChannel == "delegate" || task.OriginChannel == "system" { + return + } + + // Dedup: one grouped notification per source agent per chat per tick cycle. + dedupKey := task.SourceAgentID.String() + ":" + task.OriginChatID + if _, loaded := dm.progressSent.LoadOrStore(dedupKey, true); loaded { + return + } + defer dm.progressSent.Delete(dedupKey) // release for next tick + + // Collect all active delegations from same source agent. + active := dm.ListActive(task.SourceAgentID) + if len(active) == 0 { + return + } + + var lines []string + for _, t := range active { + elapsed := time.Since(t.CreatedAt).Round(time.Second) + label := t.TargetAgentKey + if t.TargetDisplayName != "" { + label = fmt.Sprintf("%s (%s)", t.TargetDisplayName, t.TargetAgentKey) + } + lines = append(lines, fmt.Sprintf("- %s: %s", label, elapsed)) + } + + content := fmt.Sprintf("⏳ Your team is working on it...\n%s", strings.Join(lines, "\n")) + + dm.msgBus.PublishOutbound(bus.OutboundMessage{ + Channel: task.OriginChannel, + ChatID: task.OriginChatID, + Content: content, + Metadata: map[string]string{ + "local_key": task.OriginLocalKey, + "peer_kind": task.OriginPeerKind, + }, + }) +} + +func buildDelegateMessage(opts DelegateOpts) string { + if opts.Context != "" { + return fmt.Sprintf("[Additional Context]\n%s\n\n[Task]\n%s", opts.Context, opts.Task) + } + return opts.Task +} + +func (dm *DelegateManager) buildRunRequest(task *DelegationTask, message string) DelegateRunRequest { + return DelegateRunRequest{ + SessionKey: task.SessionKey, + Message: message, + UserID: task.UserID, + Channel: "delegate", + ChatID: task.OriginChatID, + PeerKind: task.OriginPeerKind, + RunID: fmt.Sprintf("delegate-%s", task.ID), + Stream: false, + ExtraSystemPrompt: "[Delegation Context]\nYou are handling a delegated task from another agent.\n" + + "- Focus exclusively on the delegated task below.\n" + + "- Your complete response will be returned to the requesting agent.\n" + + "- Do NOT try to communicate with the end user directly.\n" + + "- Do NOT use your persona name or self-references (e.g. do not say your name). Write factual, neutral content.\n" + + "- Be concise and deliver actionable results.\n" + + "- IMPORTANT: If the delegated task falls outside your expertise scope (as defined in your SOUL.md), politely refuse and explain that this task is not within your domain. Do NOT attempt tasks outside your scope.", + } +} diff --git a/internal/tools/delegate_sync.go b/internal/tools/delegate_sync.go new file mode 100644 index 00000000..80584eff --- /dev/null +++ b/internal/tools/delegate_sync.go @@ -0,0 +1,69 @@ +package tools + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/tracing" +) + +// Delegate executes a synchronous delegation to another agent. +func (dm *DelegateManager) Delegate(ctx context.Context, opts DelegateOpts) (*DelegateResult, error) { + task, _, err := dm.prepareDelegation(ctx, opts, "sync") + if err != nil { + return nil, err + } + + dm.active.Store(task.ID, task) + defer func() { + now := time.Now() + task.CompletedAt = &now + dm.active.Delete(task.ID) + }() + + dm.injectDependencyResults(ctx, &opts) + message := buildDelegateMessage(opts) + dm.emitEvent("delegation.started", task) + slog.Info("delegation started", "id", task.ID, "target", opts.TargetAgentKey, "mode", "sync") + + // Propagate parent trace ID so the delegate trace links back. + // Clear senderID — delegations are system-initiated, the delegate agent + // should not inherit the caller's group writer permissions (the delegate + // agent has its own writer list, and would incorrectly deny writes). + delegateCtx := store.WithSenderID(ctx, "") + if parentTraceID := tracing.TraceIDFromContext(ctx); parentTraceID != uuid.Nil { + delegateCtx = tracing.WithDelegateParentTraceID(delegateCtx, parentTraceID) + } + + startTime := time.Now() + result, err := dm.runAgent(delegateCtx, opts.TargetAgentKey, dm.buildRunRequest(task, message)) + duration := time.Since(startTime) + if err != nil { + task.Status = "failed" + dm.emitEvent("delegation.failed", task) + dm.saveDelegationHistory(task, "", err, duration) + return nil, fmt.Errorf("delegation to %q failed: %w", opts.TargetAgentKey, err) + } + + // Apply quality gates before marking completed. + if result, err = dm.applyQualityGates(delegateCtx, task, opts, result); err != nil { + task.Status = "failed" + dm.emitEvent("delegation.failed", task) + dm.saveDelegationHistory(task, "", err, duration) + return nil, fmt.Errorf("delegation to %q failed quality gate: %w", opts.TargetAgentKey, err) + } + + task.Status = "completed" + dm.emitEvent("delegation.completed", task) + dm.trackCompleted(task) + dm.autoCompleteTeamTask(task, result.Content, result.Deliverables) + dm.saveDelegationHistory(task, result.Content, nil, duration) + slog.Info("delegation completed", "id", task.ID, "target", opts.TargetAgentKey, "iterations", result.Iterations) + + return &DelegateResult{Content: result.Content, Iterations: result.Iterations, DelegationID: task.ID, TeamTaskID: task.TeamTaskID.String(), MediaPaths: result.MediaPaths}, nil +} diff --git a/internal/tools/web_fetch_convert.go b/internal/tools/web_fetch_convert.go index c1eb3dda..501cf514 100644 --- a/internal/tools/web_fetch_convert.go +++ b/internal/tools/web_fetch_convert.go @@ -2,8 +2,6 @@ package tools import ( "encoding/json" - "fmt" - "regexp" "strings" "golang.org/x/net/html" @@ -57,7 +55,7 @@ var skipElements = map[atom.Atom]bool{ atom.Nav: true, atom.Footer: true, atom.Picture: true, - atom.Source: true, + atom.Source: true, } // Additional elements to skip in text mode only. @@ -176,397 +174,3 @@ func (c *converter) walkChildren(n *html.Node) { c.walk(child) } } - -func (c *converter) handleText(n *html.Node) { - text := n.Data - if c.inPre { - c.buf.WriteString(text) - return - } - text = collapseWhitespace(text) - if text == "" { - return - } - if text == " " && c.buf.Len() > 0 { - c.buf.WriteByte(' ') - return - } - c.buf.WriteString(text) -} - -func (c *converter) handleHeading(n *html.Node) { - c.ensureDoubleNewline() - if c.mode == modeMarkdown && len(n.Data) == 2 && n.Data[0] == 'h' { - level := int(n.Data[1] - '0') - for i := 0; i < level; i++ { - c.buf.WriteByte('#') - } - c.buf.WriteByte(' ') - } - c.walkChildren(n) - c.buf.WriteByte('\n') -} - -func (c *converter) handleParagraph(n *html.Node) { - c.ensureDoubleNewline() - c.walkChildren(n) - c.buf.WriteByte('\n') -} - -func (c *converter) handleLink(n *html.Node) { - href := getAttr(n, "href") - if c.mode == modeText || c.inLink || href == "" || strings.HasPrefix(href, "javascript:") { - c.walkChildren(n) - return - } - c.inLink = true - c.buf.WriteByte('[') - c.walkChildren(n) - c.buf.WriteString("](") - c.buf.WriteString(href) - c.buf.WriteByte(')') - c.inLink = false -} - -func (c *converter) handleImage(n *html.Node) { - alt := getAttr(n, "alt") - src := getAttr(n, "src") - if c.mode == modeMarkdown { - c.buf.WriteString("![") - c.buf.WriteString(alt) - c.buf.WriteByte(']') - if src != "" { - c.buf.WriteByte('(') - c.buf.WriteString(src) - c.buf.WriteByte(')') - } - } else if alt != "" { - c.buf.WriteString(alt) - } -} - -func (c *converter) handlePre(n *html.Node) { - c.ensureDoubleNewline() - if c.mode == modeMarkdown { - lang := "" - if code := findChild(n, atom.Code); code != nil { - cls := getAttr(code, "class") - for _, part := range strings.Fields(cls) { - if rest, ok := strings.CutPrefix(part, "language-"); ok { - lang = rest - break - } - if rest, ok := strings.CutPrefix(part, "lang-"); ok { - lang = rest - break - } - } - } - c.buf.WriteString("```") - c.buf.WriteString(lang) - c.buf.WriteByte('\n') - } - c.inPre = true - c.walkChildren(n) - c.inPre = false - if c.mode == modeMarkdown { - c.ensureNewline() - c.buf.WriteString("```\n") - } else { - c.buf.WriteByte('\n') - } -} - -func (c *converter) handleCode(n *html.Node) { - if c.inPre { - c.walkChildren(n) - return - } - if c.mode == modeMarkdown { - c.buf.WriteByte('`') - c.walkChildren(n) - c.buf.WriteByte('`') - } else { - c.walkChildren(n) - } -} - -func (c *converter) handleBlockquote(n *html.Node) { - c.ensureDoubleNewline() - if c.mode == modeMarkdown { - sub := &converter{mode: c.mode, inPre: c.inPre} - sub.walkChildren(n) - for i, line := range strings.Split(strings.TrimSpace(sub.buf.String()), "\n") { - if i > 0 { - c.buf.WriteByte('\n') - } - c.buf.WriteString("> ") - c.buf.WriteString(line) - } - c.buf.WriteByte('\n') - } else { - c.walkChildren(n) - } -} - -func (c *converter) handleStrong(n *html.Node) { - if c.mode == modeMarkdown { - c.buf.WriteString("**") - c.walkChildren(n) - c.buf.WriteString("**") - } else { - c.walkChildren(n) - } -} - -func (c *converter) handleEmphasis(n *html.Node) { - if c.mode == modeMarkdown { - c.buf.WriteByte('*') - c.walkChildren(n) - c.buf.WriteByte('*') - } else { - c.walkChildren(n) - } -} - -func (c *converter) handleList(n *html.Node) { - c.ensureNewline() - c.listDepth++ - c.listType = append(c.listType, n.DataAtom) - c.listIndex = append(c.listIndex, 0) - c.walkChildren(n) - c.listDepth-- - c.listType = c.listType[:len(c.listType)-1] - c.listIndex = c.listIndex[:len(c.listIndex)-1] - c.ensureNewline() -} - -func (c *converter) handleListItem(n *html.Node) { - c.ensureNewline() - indent := strings.Repeat(" ", max(0, c.listDepth-1)) - c.buf.WriteString(indent) - - if len(c.listType) > 0 && c.listType[len(c.listType)-1] == atom.Ol { - idx := len(c.listIndex) - 1 - c.listIndex[idx]++ - fmt.Fprintf(&c.buf, "%d. ", c.listIndex[idx]) - } else { - c.buf.WriteString("- ") - } - c.walkChildren(n) -} - -func (c *converter) handleTable(n *html.Node) { - c.ensureDoubleNewline() - rows := collectTableRows(n, c.mode) - if len(rows) == 0 { - return - } - colCount := 0 - for _, row := range rows { - if len(row) > colCount { - colCount = len(row) - } - } - if c.mode == modeMarkdown { - for i, row := range rows { - c.buf.WriteByte('|') - for j := 0; j < colCount; j++ { - cell := "" - if j < len(row) { - cell = row[j] - } - c.buf.WriteByte(' ') - c.buf.WriteString(cell) - c.buf.WriteString(" |") - } - c.buf.WriteByte('\n') - if i == 0 { - c.buf.WriteByte('|') - for j := 0; j < colCount; j++ { - c.buf.WriteString(" --- |") - } - c.buf.WriteByte('\n') - } - } - } else { - for _, row := range rows { - c.buf.WriteString(strings.Join(row, " | ")) - c.buf.WriteByte('\n') - } - } - c.buf.WriteByte('\n') -} - -func (c *converter) handleDefinitionTerm(n *html.Node) { - c.ensureDoubleNewline() - if c.mode == modeMarkdown { - c.buf.WriteString("**") - c.walkChildren(n) - c.buf.WriteString("**") - } else { - c.walkChildren(n) - } - c.buf.WriteByte('\n') -} - -func (c *converter) handleDefinitionDesc(n *html.Node) { - c.ensureNewline() - if c.mode == modeMarkdown { - c.buf.WriteString(": ") - } - c.walkChildren(n) - c.buf.WriteByte('\n') -} - -// --- helpers --- - -func getAttr(n *html.Node, key string) string { - for _, a := range n.Attr { - if a.Key == key { - return a.Val - } - } - return "" -} - -func findChild(n *html.Node, tag atom.Atom) *html.Node { - for c := n.FirstChild; c != nil; c = c.NextSibling { - if c.Type == html.ElementNode && c.DataAtom == tag { - return c - } - } - return nil -} - -func findBody(doc *html.Node) *html.Node { - var find func(*html.Node) *html.Node - find = func(n *html.Node) *html.Node { - if n.Type == html.ElementNode && n.DataAtom == atom.Body { - return n - } - for c := n.FirstChild; c != nil; c = c.NextSibling { - if found := find(c); found != nil { - return found - } - } - return nil - } - if body := find(doc); body != nil { - return body - } - return doc -} - -func collapseWhitespace(s string) string { - var buf strings.Builder - buf.Grow(len(s)) - inSpace := false - for _, r := range s { - if r == ' ' || r == '\t' || r == '\n' || r == '\r' || r == '\f' { - if !inSpace { - buf.WriteByte(' ') - inSpace = true - } - } else { - buf.WriteRune(r) - inSpace = false - } - } - return buf.String() -} - -func (c *converter) ensureNewline() { - if c.buf.Len() == 0 { - return - } - s := c.buf.String() - if s[len(s)-1] != '\n' { - c.buf.WriteByte('\n') - } -} - -func (c *converter) ensureDoubleNewline() { - if c.buf.Len() == 0 { - return - } - s := c.buf.String() - if len(s) >= 2 && s[len(s)-1] == '\n' && s[len(s)-2] == '\n' { - return - } - if s[len(s)-1] == '\n' { - c.buf.WriteByte('\n') - } else { - c.buf.WriteString("\n\n") - } -} - -// collectTableRows extracts rows from a table node. Each row is a slice of cell strings. -func collectTableRows(table *html.Node, mode convertMode) [][]string { - var rows [][]string - var findRows func(*html.Node) - findRows = func(n *html.Node) { - if n.Type == html.ElementNode && n.DataAtom == atom.Tr { - var cells []string - for td := n.FirstChild; td != nil; td = td.NextSibling { - if td.Type == html.ElementNode && (td.DataAtom == atom.Td || td.DataAtom == atom.Th) { - sub := &converter{mode: mode} - sub.walkChildren(td) - cells = append(cells, strings.TrimSpace(sub.buf.String())) - } - } - if len(cells) > 0 { - rows = append(rows, cells) - } - return - } - for c := n.FirstChild; c != nil; c = c.NextSibling { - findRows(c) - } - } - findRows(table) - return rows -} - -// --- output cleanup --- - -var reMultiNL = regexp.MustCompile(`\n{3,}`) - -func cleanOutput(s string) string { - s = reMultiNL.ReplaceAllString(s, "\n\n") - return strings.TrimSpace(s) -} - -func cleanTextOutput(s string) string { - lines := strings.Split(s, "\n") - var clean []string - for _, line := range lines { - line = strings.TrimRight(line, " \t") - clean = append(clean, line) - } - s = strings.Join(clean, "\n") - s = reMultiNL.ReplaceAllString(s, "\n\n") - return strings.TrimSpace(s) -} - -// stripTagsFallback is a last-resort fallback if the HTML parser fails. -var reStripTags = regexp.MustCompile(`<[^>]+>`) - -func stripTagsFallback(s string) string { - return strings.TrimSpace(reStripTags.ReplaceAllString(s, "")) -} - -// markdownToText strips markdown formatting for text mode. -func markdownToText(md string) string { - s := md - s = regexp.MustCompile(`(?m)^#{1,6}\s+`).ReplaceAllString(s, "") - s = strings.ReplaceAll(s, "**", "") - s = strings.ReplaceAll(s, "__", "") - s = regexp.MustCompile("`[^`]+`").ReplaceAllStringFunc(s, func(m string) string { - return strings.Trim(m, "`") - }) - s = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(s, "$1") - s = regexp.MustCompile(`!\[([^\]]*)\]\([^)]+\)`).ReplaceAllString(s, "$1") - s = reMultiNL.ReplaceAllString(s, "\n\n") - return strings.TrimSpace(s) -} diff --git a/internal/tools/web_fetch_convert_handlers.go b/internal/tools/web_fetch_convert_handlers.go new file mode 100644 index 00000000..2962a8af --- /dev/null +++ b/internal/tools/web_fetch_convert_handlers.go @@ -0,0 +1,251 @@ +package tools + +import ( + "fmt" + "strings" + + "golang.org/x/net/html" + "golang.org/x/net/html/atom" +) + +func (c *converter) handleText(n *html.Node) { + text := n.Data + if c.inPre { + c.buf.WriteString(text) + return + } + text = collapseWhitespace(text) + if text == "" { + return + } + if text == " " && c.buf.Len() > 0 { + c.buf.WriteByte(' ') + return + } + c.buf.WriteString(text) +} + +func (c *converter) handleHeading(n *html.Node) { + c.ensureDoubleNewline() + if c.mode == modeMarkdown && len(n.Data) == 2 && n.Data[0] == 'h' { + level := int(n.Data[1] - '0') + for i := 0; i < level; i++ { + c.buf.WriteByte('#') + } + c.buf.WriteByte(' ') + } + c.walkChildren(n) + c.buf.WriteByte('\n') +} + +func (c *converter) handleParagraph(n *html.Node) { + c.ensureDoubleNewline() + c.walkChildren(n) + c.buf.WriteByte('\n') +} + +func (c *converter) handleLink(n *html.Node) { + href := getAttr(n, "href") + if c.mode == modeText || c.inLink || href == "" || strings.HasPrefix(href, "javascript:") { + c.walkChildren(n) + return + } + c.inLink = true + c.buf.WriteByte('[') + c.walkChildren(n) + c.buf.WriteString("](") + c.buf.WriteString(href) + c.buf.WriteByte(')') + c.inLink = false +} + +func (c *converter) handleImage(n *html.Node) { + alt := getAttr(n, "alt") + src := getAttr(n, "src") + if c.mode == modeMarkdown { + c.buf.WriteString("![") + c.buf.WriteString(alt) + c.buf.WriteByte(']') + if src != "" { + c.buf.WriteByte('(') + c.buf.WriteString(src) + c.buf.WriteByte(')') + } + } else if alt != "" { + c.buf.WriteString(alt) + } +} + +func (c *converter) handlePre(n *html.Node) { + c.ensureDoubleNewline() + if c.mode == modeMarkdown { + lang := "" + if code := findChild(n, atom.Code); code != nil { + cls := getAttr(code, "class") + for _, part := range strings.Fields(cls) { + if rest, ok := strings.CutPrefix(part, "language-"); ok { + lang = rest + break + } + if rest, ok := strings.CutPrefix(part, "lang-"); ok { + lang = rest + break + } + } + } + c.buf.WriteString("```") + c.buf.WriteString(lang) + c.buf.WriteByte('\n') + } + c.inPre = true + c.walkChildren(n) + c.inPre = false + if c.mode == modeMarkdown { + c.ensureNewline() + c.buf.WriteString("```\n") + } else { + c.buf.WriteByte('\n') + } +} + +func (c *converter) handleCode(n *html.Node) { + if c.inPre { + c.walkChildren(n) + return + } + if c.mode == modeMarkdown { + c.buf.WriteByte('`') + c.walkChildren(n) + c.buf.WriteByte('`') + } else { + c.walkChildren(n) + } +} + +func (c *converter) handleBlockquote(n *html.Node) { + c.ensureDoubleNewline() + if c.mode == modeMarkdown { + sub := &converter{mode: c.mode, inPre: c.inPre} + sub.walkChildren(n) + for i, line := range strings.Split(strings.TrimSpace(sub.buf.String()), "\n") { + if i > 0 { + c.buf.WriteByte('\n') + } + c.buf.WriteString("> ") + c.buf.WriteString(line) + } + c.buf.WriteByte('\n') + } else { + c.walkChildren(n) + } +} + +func (c *converter) handleStrong(n *html.Node) { + if c.mode == modeMarkdown { + c.buf.WriteString("**") + c.walkChildren(n) + c.buf.WriteString("**") + } else { + c.walkChildren(n) + } +} + +func (c *converter) handleEmphasis(n *html.Node) { + if c.mode == modeMarkdown { + c.buf.WriteByte('*') + c.walkChildren(n) + c.buf.WriteByte('*') + } else { + c.walkChildren(n) + } +} + +func (c *converter) handleList(n *html.Node) { + c.ensureNewline() + c.listDepth++ + c.listType = append(c.listType, n.DataAtom) + c.listIndex = append(c.listIndex, 0) + c.walkChildren(n) + c.listDepth-- + c.listType = c.listType[:len(c.listType)-1] + c.listIndex = c.listIndex[:len(c.listIndex)-1] + c.ensureNewline() +} + +func (c *converter) handleListItem(n *html.Node) { + c.ensureNewline() + indent := strings.Repeat(" ", max(0, c.listDepth-1)) + c.buf.WriteString(indent) + + if len(c.listType) > 0 && c.listType[len(c.listType)-1] == atom.Ol { + idx := len(c.listIndex) - 1 + c.listIndex[idx]++ + fmt.Fprintf(&c.buf, "%d. ", c.listIndex[idx]) + } else { + c.buf.WriteString("- ") + } + c.walkChildren(n) +} + +func (c *converter) handleTable(n *html.Node) { + c.ensureDoubleNewline() + rows := collectTableRows(n, c.mode) + if len(rows) == 0 { + return + } + colCount := 0 + for _, row := range rows { + if len(row) > colCount { + colCount = len(row) + } + } + if c.mode == modeMarkdown { + for i, row := range rows { + c.buf.WriteByte('|') + for j := 0; j < colCount; j++ { + cell := "" + if j < len(row) { + cell = row[j] + } + c.buf.WriteByte(' ') + c.buf.WriteString(cell) + c.buf.WriteString(" |") + } + c.buf.WriteByte('\n') + if i == 0 { + c.buf.WriteByte('|') + for j := 0; j < colCount; j++ { + c.buf.WriteString(" --- |") + } + c.buf.WriteByte('\n') + } + } + } else { + for _, row := range rows { + c.buf.WriteString(strings.Join(row, " | ")) + c.buf.WriteByte('\n') + } + } + c.buf.WriteByte('\n') +} + +func (c *converter) handleDefinitionTerm(n *html.Node) { + c.ensureDoubleNewline() + if c.mode == modeMarkdown { + c.buf.WriteString("**") + c.walkChildren(n) + c.buf.WriteString("**") + } else { + c.walkChildren(n) + } + c.buf.WriteByte('\n') +} + +func (c *converter) handleDefinitionDesc(n *html.Node) { + c.ensureNewline() + if c.mode == modeMarkdown { + c.buf.WriteString(": ") + } + c.walkChildren(n) + c.buf.WriteByte('\n') +} diff --git a/internal/tools/web_fetch_convert_utils.go b/internal/tools/web_fetch_convert_utils.go new file mode 100644 index 00000000..d9f6194e --- /dev/null +++ b/internal/tools/web_fetch_convert_utils.go @@ -0,0 +1,159 @@ +package tools + +import ( + "regexp" + "strings" + + "golang.org/x/net/html" + "golang.org/x/net/html/atom" +) + +func getAttr(n *html.Node, key string) string { + for _, a := range n.Attr { + if a.Key == key { + return a.Val + } + } + return "" +} + +func findChild(n *html.Node, tag atom.Atom) *html.Node { + for c := n.FirstChild; c != nil; c = c.NextSibling { + if c.Type == html.ElementNode && c.DataAtom == tag { + return c + } + } + return nil +} + +func findBody(doc *html.Node) *html.Node { + var find func(*html.Node) *html.Node + find = func(n *html.Node) *html.Node { + if n.Type == html.ElementNode && n.DataAtom == atom.Body { + return n + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + if found := find(c); found != nil { + return found + } + } + return nil + } + if body := find(doc); body != nil { + return body + } + return doc +} + +func collapseWhitespace(s string) string { + var buf strings.Builder + buf.Grow(len(s)) + inSpace := false + for _, r := range s { + if r == ' ' || r == '\t' || r == '\n' || r == '\r' || r == '\f' { + if !inSpace { + buf.WriteByte(' ') + inSpace = true + } + } else { + buf.WriteRune(r) + inSpace = false + } + } + return buf.String() +} + +func (c *converter) ensureNewline() { + if c.buf.Len() == 0 { + return + } + s := c.buf.String() + if s[len(s)-1] != '\n' { + c.buf.WriteByte('\n') + } +} + +func (c *converter) ensureDoubleNewline() { + if c.buf.Len() == 0 { + return + } + s := c.buf.String() + if len(s) >= 2 && s[len(s)-1] == '\n' && s[len(s)-2] == '\n' { + return + } + if s[len(s)-1] == '\n' { + c.buf.WriteByte('\n') + } else { + c.buf.WriteString("\n\n") + } +} + +// collectTableRows extracts rows from a table node. Each row is a slice of cell strings. +func collectTableRows(table *html.Node, mode convertMode) [][]string { + var rows [][]string + var findRows func(*html.Node) + findRows = func(n *html.Node) { + if n.Type == html.ElementNode && n.DataAtom == atom.Tr { + var cells []string + for td := n.FirstChild; td != nil; td = td.NextSibling { + if td.Type == html.ElementNode && (td.DataAtom == atom.Td || td.DataAtom == atom.Th) { + sub := &converter{mode: mode} + sub.walkChildren(td) + cells = append(cells, strings.TrimSpace(sub.buf.String())) + } + } + if len(cells) > 0 { + rows = append(rows, cells) + } + return + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + findRows(c) + } + } + findRows(table) + return rows +} + +// --- output cleanup --- + +var reMultiNL = regexp.MustCompile(`\n{3,}`) + +func cleanOutput(s string) string { + s = reMultiNL.ReplaceAllString(s, "\n\n") + return strings.TrimSpace(s) +} + +func cleanTextOutput(s string) string { + lines := strings.Split(s, "\n") + var clean []string + for _, line := range lines { + line = strings.TrimRight(line, " \t") + clean = append(clean, line) + } + s = strings.Join(clean, "\n") + s = reMultiNL.ReplaceAllString(s, "\n\n") + return strings.TrimSpace(s) +} + +// stripTagsFallback is a last-resort fallback if the HTML parser fails. +var reStripTags = regexp.MustCompile(`<[^>]+>`) + +func stripTagsFallback(s string) string { + return strings.TrimSpace(reStripTags.ReplaceAllString(s, "")) +} + +// markdownToText strips markdown formatting for text mode. +func markdownToText(md string) string { + s := md + s = regexp.MustCompile(`(?m)^#{1,6}\s+`).ReplaceAllString(s, "") + s = strings.ReplaceAll(s, "**", "") + s = strings.ReplaceAll(s, "__", "") + s = regexp.MustCompile("`[^`]+`").ReplaceAllStringFunc(s, func(m string) string { + return strings.Trim(m, "`") + }) + s = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(s, "$1") + s = regexp.MustCompile(`!\[([^\]]*)\]\([^)]+\)`).ReplaceAllString(s, "$1") + s = reMultiNL.ReplaceAllString(s, "\n\n") + return strings.TrimSpace(s) +} diff --git a/pkg/browser/browser.go b/pkg/browser/browser.go index 703b0b99..bedcdaac 100644 --- a/pkg/browser/browser.go +++ b/pkg/browser/browser.go @@ -2,18 +2,12 @@ package browser import ( "context" - "encoding/json" "fmt" "log/slog" - "net" - "net/http" - "net/url" "sync" - "time" "github.com/go-rod/rod" "github.com/go-rod/rod/lib/launcher" - "github.com/go-rod/rod/lib/proto" ) // Manager handles the Chrome browser lifecycle and page management. @@ -158,448 +152,3 @@ func (m *Manager) Status() *StatusInfo { } return info } - -// ListTabs returns all open tabs. -func (m *Manager) ListTabs(ctx context.Context) ([]TabInfo, error) { - m.mu.Lock() - defer m.mu.Unlock() - - if m.browser == nil { - return nil, fmt.Errorf("browser not running") - } - - pages, err := m.browser.Pages() - if err != nil { - if m.remoteURL != "" { - if reconnErr := m.reconnectLocked(); reconnErr != nil { - return nil, fmt.Errorf("list pages: %w (reconnect also failed: %v)", err, reconnErr) - } - m.logger.Info("auto-reconnected to remote Chrome") - pages, err = m.browser.Pages() - if err != nil { - return nil, fmt.Errorf("list pages after reconnect: %w", err) - } - } else { - return nil, fmt.Errorf("list pages: %w", err) - } - } - - tabs := make([]TabInfo, 0, len(pages)) - for _, p := range pages { - info, err := p.Info() - if err != nil || info == nil { - continue - } - tid := string(p.TargetID) - m.pages[tid] = p - tabs = append(tabs, TabInfo{ - TargetID: tid, - URL: info.URL, - Title: info.Title, - }) - } - return tabs, nil -} - -// OpenTab opens a new tab with the given URL. -func (m *Manager) OpenTab(ctx context.Context, url string) (*TabInfo, error) { - m.mu.Lock() - defer m.mu.Unlock() - - if m.browser == nil { - return nil, fmt.Errorf("browser not running") - } - - page, err := m.browser.Page(proto.TargetCreateTarget{URL: url}) - if err != nil { - return nil, fmt.Errorf("open tab: %w", err) - } - - if err := page.WaitStable(300 * time.Millisecond); err != nil { - return nil, fmt.Errorf("wait stable: %w", err) - } - info, _ := page.Info() - tid := string(page.TargetID) - m.pages[tid] = page - - // Set up console listener - m.setupConsoleListener(page, tid) - - tab := &TabInfo{TargetID: tid, URL: url} - if info != nil { - tab.URL = info.URL - tab.Title = info.Title - } - return tab, nil -} - -// FocusTab activates a tab. -func (m *Manager) FocusTab(ctx context.Context, targetID string) error { - m.mu.Lock() - defer m.mu.Unlock() - - page, err := m.getPage(targetID) - if err != nil { - return err - } - - _, err = page.Activate() - return err -} - -// CloseTab closes a tab. -func (m *Manager) CloseTab(ctx context.Context, targetID string) error { - m.mu.Lock() - defer m.mu.Unlock() - - page, err := m.getPage(targetID) - if err != nil { - return err - } - - delete(m.pages, targetID) - delete(m.console, targetID) - return page.Close() -} - -// ConsoleMessages returns captured console messages for a tab. -func (m *Manager) ConsoleMessages(targetID string) []ConsoleMessage { - m.mu.Lock() - defer m.mu.Unlock() - - msgs := m.console[targetID] - if msgs == nil { - return []ConsoleMessage{} - } - - // Return copy and clear - result := make([]ConsoleMessage, len(msgs)) - copy(result, msgs) - m.console[targetID] = nil - return result -} - -// Snapshot takes an accessibility snapshot of a page. -func (m *Manager) Snapshot(ctx context.Context, targetID string, opts SnapshotOptions) (*SnapshotResult, error) { - m.mu.Lock() - page, err := m.getPage(targetID) - m.mu.Unlock() - - if err != nil { - return nil, err - } - - result, err := proto.AccessibilityGetFullAXTree{}.Call(page) - if err != nil { - return nil, fmt.Errorf("get AX tree: %w", err) - } - - snap := FormatSnapshot(result.Nodes, opts) - info, _ := page.Info() - snap.TargetID = targetID - if info != nil { - snap.URL = info.URL - snap.Title = info.Title - } - - // Cache refs - m.refs.Store(targetID, snap.Refs) - - return snap, nil -} - -// Screenshot captures a page screenshot as PNG bytes. -func (m *Manager) Screenshot(ctx context.Context, targetID string, fullPage bool) ([]byte, error) { - m.mu.Lock() - page, err := m.getPage(targetID) - m.mu.Unlock() - - if err != nil { - return nil, err - } - - if fullPage { - return page.Screenshot(fullPage, &proto.PageCaptureScreenshot{ - Format: proto.PageCaptureScreenshotFormatPng, - }) - } - return page.Screenshot(false, nil) -} - -// Navigate navigates a page to a URL. -func (m *Manager) Navigate(ctx context.Context, targetID, url string) error { - m.mu.Lock() - page, err := m.getPage(targetID) - m.mu.Unlock() - - if err != nil { - return err - } - - if err := page.Navigate(url); err != nil { - return fmt.Errorf("navigate: %w", err) - } - if err := page.WaitStable(300 * time.Millisecond); err != nil { - return fmt.Errorf("wait stable after navigate: %w", err) - } - return nil -} - -// Close shuts down the browser if running. -func (m *Manager) Close() error { - return m.Stop(context.Background()) -} - -// Refs returns the RefStore for external use (e.g. actions). -func (m *Manager) Refs() *RefStore { - return m.refs -} - -// reconnectLocked re-establishes the CDP connection to a remote Chrome. -// Must be called with m.mu held. Only works when remoteURL is set. -func (m *Manager) reconnectLocked() error { - m.browser = nil - m.pages = make(map[string]*rod.Page) - m.console = make(map[string][]ConsoleMessage) - m.refs = NewRefStore() - - controlURL, err := resolveRemoteCDP(m.remoteURL) - if err != nil { - return err - } - - b := rod.New().ControlURL(controlURL) - if err := b.Connect(); err != nil { - return err - } - m.browser = b - return nil -} - -// getPage looks up a page by targetID. If targetID is empty, returns the first available page. -// Must be called with m.mu held. If the connection is dead and remoteURL is set, -// it attempts one automatic reconnect. -func (m *Manager) getPage(targetID string) (*rod.Page, error) { - if m.browser == nil { - return nil, fmt.Errorf("browser not running") - } - - // If targetID specified, look in cache first - if targetID != "" { - if p, ok := m.pages[targetID]; ok { - return p, nil - } - } - - // Refresh page list from browser - pages, err := m.browser.Pages() - if err != nil { - // Connection dead — try auto-reconnect for remote Chrome - if m.remoteURL != "" { - if reconnErr := m.reconnectLocked(); reconnErr != nil { - return nil, fmt.Errorf("list pages: %w (reconnect also failed: %v)", err, reconnErr) - } - m.logger.Info("auto-reconnected to remote Chrome") - pages, err = m.browser.Pages() - if err != nil { - return nil, fmt.Errorf("list pages after reconnect: %w", err) - } - } else { - return nil, fmt.Errorf("list pages: %w", err) - } - } - - // Update cache - for _, p := range pages { - tid := string(p.TargetID) - m.pages[tid] = p - } - - if targetID != "" { - if p, ok := m.pages[targetID]; ok { - return p, nil - } - return nil, fmt.Errorf("tab not found: %s", targetID) - } - - // No targetID: return first page - if len(pages) == 0 { - return nil, fmt.Errorf("no tabs open") - } - return pages[0], nil -} - -// setupConsoleListener attaches a console message listener to a page via Rod's EachEvent. -func (m *Manager) setupConsoleListener(page *rod.Page, targetID string) { - go page.EachEvent(func(e *proto.RuntimeConsoleAPICalled) { - var text string - for _, arg := range e.Args { - s := arg.Value.String() - if s != "" && s != "null" { - text += s + " " - } - } - - level := "log" - switch e.Type { - case proto.RuntimeConsoleAPICalledTypeWarning: - level = "warn" - case proto.RuntimeConsoleAPICalledTypeError: - level = "error" - case proto.RuntimeConsoleAPICalledTypeInfo: - level = "info" - } - - m.mu.Lock() - msgs := m.console[targetID] - if len(msgs) >= 500 { - msgs = msgs[1:] - } - m.console[targetID] = append(msgs, ConsoleMessage{ - Level: level, - Text: text, - }) - m.mu.Unlock() - })() -} - -// resolveElement converts a RoleRef to a Rod Element via backendNodeID. -func (m *Manager) resolveElement(page *rod.Page, targetID, ref string) (*rod.Element, error) { - roleRef, ok := m.refs.Resolve(targetID, ref) - if !ok { - return nil, fmt.Errorf("unknown ref %q — take a new snapshot first", ref) - } - - if roleRef.BackendNodeID == 0 { - return nil, fmt.Errorf("no backendNodeID for ref %q", ref) - } - - backendID := proto.DOMBackendNodeID(roleRef.BackendNodeID) - resolved, err := proto.DOMResolveNode{BackendNodeID: backendID}.Call(page) - if err != nil { - return nil, fmt.Errorf("resolve DOM node for %q (backendNodeID=%d): %w", ref, roleRef.BackendNodeID, err) - } - - el, err := page.ElementFromObject(resolved.Object) - if err != nil { - return nil, fmt.Errorf("get element from object for %q: %w", ref, err) - } - - return el, nil -} - -// getPageAndResolve is a helper that locks, gets page, and resolves an element. -func (m *Manager) getPageAndResolve(targetID, ref string) (*rod.Page, *rod.Element, error) { - m.mu.Lock() - page, err := m.getPage(targetID) - m.mu.Unlock() - if err != nil { - return nil, nil, err - } - - // Ensure DOM is enabled for node resolution - _ = proto.DOMEnable{}.Call(page) - - el, err := m.resolveElement(page, targetID, NormalizeRef(ref)) - if err != nil { - return nil, nil, err - } - - return page, el, nil -} - -// waitStable waits for page to become stable (no network/DOM activity). -func waitStable(page *rod.Page) { - _ = page.WaitStable(300 * time.Millisecond) -} - -// resolveRemoteCDP queries a Chrome endpoint's /json/version to get the CDP -// WebSocket URL, resolving the hostname to an IP address. -// -// Chrome (M113+) rejects HTTP/WebSocket requests where the Host header is a -// hostname (not an IP or "localhost") to prevent DNS rebinding attacks. -// In Docker, the service name "chrome" is a hostname, so we resolve it to an -// IP address and use that for all connections. - -// cdpHTTPClient is used for /json/version queries with a reasonable timeout. -var cdpHTTPClient = &http.Client{Timeout: 10 * time.Second} - -func resolveRemoteCDP(remoteURL string) (string, error) { - parsed, err := url.Parse(remoteURL) - if err != nil { - return "", fmt.Errorf("parse remote URL: %w", err) - } - - host := parsed.Hostname() - port := parsed.Port() - if port == "" { - port = "9222" - } - - // Resolve hostname to IP — Chrome M113+ requires IP or "localhost" in - // the Host header to prevent DNS rebinding attacks. - ip, err := resolveToIPv4(host) - if err != nil { - return "", err - } - - // Query /json/version using the IP (so Host header is an IP). - versionURL := fmt.Sprintf("http://%s:%s/json/version", ip, port) - resp, err := cdpHTTPClient.Get(versionURL) //nolint:gosec // resolved from user-configured URL - if err != nil { - return "", fmt.Errorf("query /json/version at %s: %w", versionURL, err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("/json/version returned HTTP %d", resp.StatusCode) - } - - var ver struct { - WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"` - } - if err := json.NewDecoder(resp.Body).Decode(&ver); err != nil { - return "", fmt.Errorf("parse /json/version: %w", err) - } - if ver.WebSocketDebuggerURL == "" { - return "", fmt.Errorf("empty webSocketDebuggerUrl in /json/version response") - } - - // Replace host in returned URL with the resolved IP. - // Chrome returns ws://127.0.0.1/... but we need ws://:/... - wsURL, err := url.Parse(ver.WebSocketDebuggerURL) - if err != nil { - return "", fmt.Errorf("parse webSocketDebuggerUrl: %w", err) - } - wsURL.Host = net.JoinHostPort(ip, port) - return wsURL.String(), nil -} - -// resolveToIPv4 resolves a hostname to an IPv4 address. -// Chrome typically binds on 0.0.0.0 (IPv4), so we prefer IPv4 to avoid -// connection failures when DNS returns IPv6 addresses first. -// If the host is already an IP, it is returned as-is. -func resolveToIPv4(host string) (string, error) { - // Already an IP literal — return as-is. - if net.ParseIP(host) != nil { - return host, nil - } - - ips, err := net.LookupHost(host) - if err != nil { - return "", fmt.Errorf("resolve %s: %w", host, err) - } - - // Prefer IPv4. - for _, ip := range ips { - if parsed := net.ParseIP(ip); parsed != nil && parsed.To4() != nil { - return ip, nil - } - } - - // Fallback: return first address (could be IPv6). - if len(ips) > 0 { - return ips[0], nil - } - return "", fmt.Errorf("no addresses found for %s", host) -} diff --git a/pkg/browser/browser_page.go b/pkg/browser/browser_page.go new file mode 100644 index 00000000..2f5f429f --- /dev/null +++ b/pkg/browser/browser_page.go @@ -0,0 +1,85 @@ +package browser + +import ( + "context" + "fmt" + "time" + + "github.com/go-rod/rod/lib/proto" +) + +// Snapshot takes an accessibility snapshot of a page. +func (m *Manager) Snapshot(ctx context.Context, targetID string, opts SnapshotOptions) (*SnapshotResult, error) { + m.mu.Lock() + page, err := m.getPage(targetID) + m.mu.Unlock() + + if err != nil { + return nil, err + } + + result, err := proto.AccessibilityGetFullAXTree{}.Call(page) + if err != nil { + return nil, fmt.Errorf("get AX tree: %w", err) + } + + snap := FormatSnapshot(result.Nodes, opts) + info, _ := page.Info() + snap.TargetID = targetID + if info != nil { + snap.URL = info.URL + snap.Title = info.Title + } + + // Cache refs + m.refs.Store(targetID, snap.Refs) + + return snap, nil +} + +// Screenshot captures a page screenshot as PNG bytes. +func (m *Manager) Screenshot(ctx context.Context, targetID string, fullPage bool) ([]byte, error) { + m.mu.Lock() + page, err := m.getPage(targetID) + m.mu.Unlock() + + if err != nil { + return nil, err + } + + if fullPage { + return page.Screenshot(fullPage, &proto.PageCaptureScreenshot{ + Format: proto.PageCaptureScreenshotFormatPng, + }) + } + return page.Screenshot(false, nil) +} + +// Navigate navigates a page to a URL. +func (m *Manager) Navigate(ctx context.Context, targetID, url string) error { + m.mu.Lock() + page, err := m.getPage(targetID) + m.mu.Unlock() + + if err != nil { + return err + } + + if err := page.Navigate(url); err != nil { + return fmt.Errorf("navigate: %w", err) + } + if err := page.WaitStable(300 * time.Millisecond); err != nil { + return fmt.Errorf("wait stable after navigate: %w", err) + } + return nil +} + +// Close shuts down the browser if running. +func (m *Manager) Close() error { + return m.Stop(context.Background()) +} + +// Refs returns the RefStore for external use (e.g. actions). +func (m *Manager) Refs() *RefStore { + return m.refs +} diff --git a/pkg/browser/browser_remote.go b/pkg/browser/browser_remote.go new file mode 100644 index 00000000..394ad4b0 --- /dev/null +++ b/pkg/browser/browser_remote.go @@ -0,0 +1,262 @@ +package browser + +import ( + "encoding/json" + "fmt" + "net" + "net/http" + "net/url" + "time" + + "github.com/go-rod/rod" + "github.com/go-rod/rod/lib/proto" +) + +// reconnectLocked re-establishes the CDP connection to a remote Chrome. +// Must be called with m.mu held. Only works when remoteURL is set. +func (m *Manager) reconnectLocked() error { + m.browser = nil + m.pages = make(map[string]*rod.Page) + m.console = make(map[string][]ConsoleMessage) + m.refs = NewRefStore() + + controlURL, err := resolveRemoteCDP(m.remoteURL) + if err != nil { + return err + } + + b := rod.New().ControlURL(controlURL) + if err := b.Connect(); err != nil { + return err + } + m.browser = b + return nil +} + +// getPage looks up a page by targetID. If targetID is empty, returns the first available page. +// Must be called with m.mu held. If the connection is dead and remoteURL is set, +// it attempts one automatic reconnect. +func (m *Manager) getPage(targetID string) (*rod.Page, error) { + if m.browser == nil { + return nil, fmt.Errorf("browser not running") + } + + // If targetID specified, look in cache first + if targetID != "" { + if p, ok := m.pages[targetID]; ok { + return p, nil + } + } + + // Refresh page list from browser + pages, err := m.browser.Pages() + if err != nil { + // Connection dead — try auto-reconnect for remote Chrome + if m.remoteURL != "" { + if reconnErr := m.reconnectLocked(); reconnErr != nil { + return nil, fmt.Errorf("list pages: %w (reconnect also failed: %v)", err, reconnErr) + } + m.logger.Info("auto-reconnected to remote Chrome") + pages, err = m.browser.Pages() + if err != nil { + return nil, fmt.Errorf("list pages after reconnect: %w", err) + } + } else { + return nil, fmt.Errorf("list pages: %w", err) + } + } + + // Update cache + for _, p := range pages { + tid := string(p.TargetID) + m.pages[tid] = p + } + + if targetID != "" { + if p, ok := m.pages[targetID]; ok { + return p, nil + } + return nil, fmt.Errorf("tab not found: %s", targetID) + } + + // No targetID: return first page + if len(pages) == 0 { + return nil, fmt.Errorf("no tabs open") + } + return pages[0], nil +} + +// setupConsoleListener attaches a console message listener to a page via Rod's EachEvent. +func (m *Manager) setupConsoleListener(page *rod.Page, targetID string) { + go page.EachEvent(func(e *proto.RuntimeConsoleAPICalled) { + var text string + for _, arg := range e.Args { + s := arg.Value.String() + if s != "" && s != "null" { + text += s + " " + } + } + + level := "log" + switch e.Type { + case proto.RuntimeConsoleAPICalledTypeWarning: + level = "warn" + case proto.RuntimeConsoleAPICalledTypeError: + level = "error" + case proto.RuntimeConsoleAPICalledTypeInfo: + level = "info" + } + + m.mu.Lock() + msgs := m.console[targetID] + if len(msgs) >= 500 { + msgs = msgs[1:] + } + m.console[targetID] = append(msgs, ConsoleMessage{ + Level: level, + Text: text, + }) + m.mu.Unlock() + })() +} + +// resolveElement converts a RoleRef to a Rod Element via backendNodeID. +func (m *Manager) resolveElement(page *rod.Page, targetID, ref string) (*rod.Element, error) { + roleRef, ok := m.refs.Resolve(targetID, ref) + if !ok { + return nil, fmt.Errorf("unknown ref %q — take a new snapshot first", ref) + } + + if roleRef.BackendNodeID == 0 { + return nil, fmt.Errorf("no backendNodeID for ref %q", ref) + } + + backendID := proto.DOMBackendNodeID(roleRef.BackendNodeID) + resolved, err := proto.DOMResolveNode{BackendNodeID: backendID}.Call(page) + if err != nil { + return nil, fmt.Errorf("resolve DOM node for %q (backendNodeID=%d): %w", ref, roleRef.BackendNodeID, err) + } + + el, err := page.ElementFromObject(resolved.Object) + if err != nil { + return nil, fmt.Errorf("get element from object for %q: %w", ref, err) + } + + return el, nil +} + +// getPageAndResolve is a helper that locks, gets page, and resolves an element. +func (m *Manager) getPageAndResolve(targetID, ref string) (*rod.Page, *rod.Element, error) { + m.mu.Lock() + page, err := m.getPage(targetID) + m.mu.Unlock() + if err != nil { + return nil, nil, err + } + + // Ensure DOM is enabled for node resolution + _ = proto.DOMEnable{}.Call(page) + + el, err := m.resolveElement(page, targetID, NormalizeRef(ref)) + if err != nil { + return nil, nil, err + } + + return page, el, nil +} + +// waitStable waits for page to become stable (no network/DOM activity). +func waitStable(page *rod.Page) { + _ = page.WaitStable(300 * time.Millisecond) +} + +// resolveRemoteCDP queries a Chrome endpoint's /json/version to get the CDP +// WebSocket URL, resolving the hostname to an IP address. +// +// Chrome (M113+) rejects HTTP/WebSocket requests where the Host header is a +// hostname (not an IP or "localhost") to prevent DNS rebinding attacks. +// In Docker, the service name "chrome" is a hostname, so we resolve it to an +// IP address and use that for all connections. + +// cdpHTTPClient is used for /json/version queries with a reasonable timeout. +var cdpHTTPClient = &http.Client{Timeout: 10 * time.Second} + +func resolveRemoteCDP(remoteURL string) (string, error) { + parsed, err := url.Parse(remoteURL) + if err != nil { + return "", fmt.Errorf("parse remote URL: %w", err) + } + + host := parsed.Hostname() + port := parsed.Port() + if port == "" { + port = "9222" + } + + // Resolve hostname to IP — Chrome M113+ requires IP or "localhost" in + // the Host header to prevent DNS rebinding attacks. + ip, err := resolveToIPv4(host) + if err != nil { + return "", err + } + + // Query /json/version using the IP (so Host header is an IP). + versionURL := fmt.Sprintf("http://%s:%s/json/version", ip, port) + resp, err := cdpHTTPClient.Get(versionURL) //nolint:gosec // resolved from user-configured URL + if err != nil { + return "", fmt.Errorf("query /json/version at %s: %w", versionURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("/json/version returned HTTP %d", resp.StatusCode) + } + + var ver struct { + WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"` + } + if err := json.NewDecoder(resp.Body).Decode(&ver); err != nil { + return "", fmt.Errorf("parse /json/version: %w", err) + } + if ver.WebSocketDebuggerURL == "" { + return "", fmt.Errorf("empty webSocketDebuggerUrl in /json/version response") + } + + // Replace host in returned URL with the resolved IP. + // Chrome returns ws://127.0.0.1/... but we need ws://:/... + wsURL, err := url.Parse(ver.WebSocketDebuggerURL) + if err != nil { + return "", fmt.Errorf("parse webSocketDebuggerUrl: %w", err) + } + wsURL.Host = net.JoinHostPort(ip, port) + return wsURL.String(), nil +} + +// resolveToIPv4 resolves a hostname to an IPv4 address. +// Chrome typically binds on 0.0.0.0 (IPv4), so we prefer IPv4 to avoid +// connection failures when DNS returns IPv6 addresses first. +// If the host is already an IP, it is returned as-is. +func resolveToIPv4(host string) (string, error) { + // Already an IP literal — return as-is. + if net.ParseIP(host) != nil { + return host, nil + } + + ips, err := net.LookupHost(host) + if err != nil { + return "", fmt.Errorf("resolve %s: %w", host, err) + } + + // Prefer IPv4. + for _, ip := range ips { + if parsed := net.ParseIP(ip); parsed != nil && parsed.To4() != nil { + return ip, nil + } + } + + // Fallback: return first address (could be IPv6). + if len(ips) > 0 { + return ips[0], nil + } + return "", fmt.Errorf("no addresses found for %s", host) +} diff --git a/pkg/browser/browser_tabs.go b/pkg/browser/browser_tabs.go new file mode 100644 index 00000000..bc067fc2 --- /dev/null +++ b/pkg/browser/browser_tabs.go @@ -0,0 +1,129 @@ +package browser + +import ( + "context" + "fmt" + "time" + + "github.com/go-rod/rod/lib/proto" +) + +// ListTabs returns all open tabs. +func (m *Manager) ListTabs(ctx context.Context) ([]TabInfo, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.browser == nil { + return nil, fmt.Errorf("browser not running") + } + + pages, err := m.browser.Pages() + if err != nil { + if m.remoteURL != "" { + if reconnErr := m.reconnectLocked(); reconnErr != nil { + return nil, fmt.Errorf("list pages: %w (reconnect also failed: %v)", err, reconnErr) + } + m.logger.Info("auto-reconnected to remote Chrome") + pages, err = m.browser.Pages() + if err != nil { + return nil, fmt.Errorf("list pages after reconnect: %w", err) + } + } else { + return nil, fmt.Errorf("list pages: %w", err) + } + } + + tabs := make([]TabInfo, 0, len(pages)) + for _, p := range pages { + info, err := p.Info() + if err != nil || info == nil { + continue + } + tid := string(p.TargetID) + m.pages[tid] = p + tabs = append(tabs, TabInfo{ + TargetID: tid, + URL: info.URL, + Title: info.Title, + }) + } + return tabs, nil +} + +// OpenTab opens a new tab with the given URL. +func (m *Manager) OpenTab(ctx context.Context, url string) (*TabInfo, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.browser == nil { + return nil, fmt.Errorf("browser not running") + } + + page, err := m.browser.Page(proto.TargetCreateTarget{URL: url}) + if err != nil { + return nil, fmt.Errorf("open tab: %w", err) + } + + if err := page.WaitStable(300 * time.Millisecond); err != nil { + return nil, fmt.Errorf("wait stable: %w", err) + } + info, _ := page.Info() + tid := string(page.TargetID) + m.pages[tid] = page + + // Set up console listener + m.setupConsoleListener(page, tid) + + tab := &TabInfo{TargetID: tid, URL: url} + if info != nil { + tab.URL = info.URL + tab.Title = info.Title + } + return tab, nil +} + +// FocusTab activates a tab. +func (m *Manager) FocusTab(ctx context.Context, targetID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + page, err := m.getPage(targetID) + if err != nil { + return err + } + + _, err = page.Activate() + return err +} + +// CloseTab closes a tab. +func (m *Manager) CloseTab(ctx context.Context, targetID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + page, err := m.getPage(targetID) + if err != nil { + return err + } + + delete(m.pages, targetID) + delete(m.console, targetID) + return page.Close() +} + +// ConsoleMessages returns captured console messages for a tab. +func (m *Manager) ConsoleMessages(targetID string) []ConsoleMessage { + m.mu.Lock() + defer m.mu.Unlock() + + msgs := m.console[targetID] + if msgs == nil { + return []ConsoleMessage{} + } + + // Return copy and clear + result := make([]ConsoleMessage, len(msgs)) + copy(result, msgs) + m.console[targetID] = nil + return result +}