refactor: split 7 large Go files into smaller files per package

Pure cut-and-paste of functions/methods into separate files within the
same package — no logic changes. Reduces file sizes for readability.

- loop.go (1312→856) → loop_types.go, loop_compact.go, loop_media.go, loop_utils.go
- delegate.go (687→171) → delegate_sync.go, delegate_async.go, delegate_prep.go
- browser.go (605→154) → browser_tabs.go, browser_page.go, browser_remote.go
- teams.go (602→170) → teams_crud.go, teams_members.go
- web_fetch_convert.go (572→176) → web_fetch_convert_handlers.go, web_fetch_convert_utils.go
- resolver.go (543→373) → resolver_helpers.go
- sessions.go (536→157) → sessions_tokens.go, sessions_ops.go, sessions_list.go

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
viettranxandClaude Opus 4.6 committed 2026-03-05 10:47:06 +07:00
1 parent 043149bd43
commit 7d211fa796
25 files changed
+2965 -2801

No files matched your search

-456
View File
@@ -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 <agentID>")
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()
}
+91
View File
@@ -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
}
+87
View File
@@ -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"
}
}
+282
View File
@@ -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 <agentID>")
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)
}
+31
View File
@@ -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()
}
-170
View File
@@ -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=<the created 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=<id> → full task detail with result\n")
sb.WriteString("- action=search, query=<text> → search tasks by subject/description\n")
sb.WriteString("- action=complete, task_id=<id>, result=<summary> → manually complete a task\n")
sb.WriteString("- action=cancel, task_id=<id>, reason=<why> → 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=<lead_key>, text=<progress update>\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=<id> → read a completed task's full result\n")
sb.WriteString("- action=search, query=<text> → 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
}
+182
View File
@@ -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=<the created 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=<id> → full task detail with result\n")
sb.WriteString("- action=search, query=<text> → search tasks by subject/description\n")
sb.WriteString("- action=complete, task_id=<id>, result=<summary> → manually complete a task\n")
sb.WriteString("- action=cancel, task_id=<id>, reason=<why> → 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=<lead_key>, text=<progress update>\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=<id> → read a completed task's full result\n")
sb.WriteString("- action=search, query=<text> → 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
}
-432
View File
@@ -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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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)
}
}
}
+272
View File
@@ -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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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()
}
+181
View File
@@ -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, &params); 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, &params); 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)
}
}
}
-379
View File
@@ -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
}
+271
View File
@@ -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
}
+39
View File
@@ -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
}
+91
View File
@@ -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
}
-516
View File
@@ -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.",
}
}
+182
View File
@@ -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
}
+300
View File
@@ -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.",
}
}
+69
View File
@@ -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
}
+1 -397
View File
@@ -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)
}
@@ -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')
}
+159
View File
@@ -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)
}
-451
View File
@@ -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://<container-IP>:<port>/...
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)
}
+85
View File
@@ -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
}
+262
View File
@@ -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://<container-IP>:<port>/...
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)
}
+129
View File
@@ -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
}