diff --git a/pkg/agent/context.go b/pkg/agent/context.go index ce571ec7b..f48b496a1 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -149,9 +149,7 @@ type ContextBuilder struct { orchestrationEnabled bool // set from AgentLoop when --orchestration flag is used // Cache for system prompt to avoid rebuilding on every call. - // This fixes issue #607: repeated reprocessing of the entire context. - // The cache auto-invalidates when workspace source files change (mtime check). systemPromptMutex sync.RWMutex @@ -176,13 +174,11 @@ func getGlobalConfigDir() string { if err != nil { return "" } - return filepath.Join(home, ".picoclaw") } func NewContextBuilder(workspace string) *ContextBuilder { // builtin skills: skills directory in current project - // Use the skills/ directory under the current working directory wd, _ := os.Getwd() @@ -282,13 +278,9 @@ You are picoclaw, %s. ## Workspace - Your workspace is at: %s - - Memory: %s/memory/MEMORY.md - - Daily Notes: %s/memory/YYYYMM/YYYYMMDD.md - - Skills: %s/skills/{skill-name}/SKILL.md @@ -299,12 +291,8 @@ Your workspace is at: %s ## Important Rules - - 1. **ALWAYS use tools** - When you need to perform an action (schedule reminders, send messages, execute commands, etc.), you MUST call the appropriate tool. Do NOT just say you'll do it or pretend to do it. - - 2. **Be helpful and accurate** - When using tools, briefly explain what you're doing. @@ -414,7 +402,6 @@ func (cb *ContextBuilder) BuildSystemPrompt() string { parts := []string{} // Core identity section - parts = append(parts, cb.getIdentity()) // Orchestration guidance — injected only when spawn tool is registered @@ -426,26 +413,18 @@ func (cb *ContextBuilder) BuildSystemPrompt() string { } // Bootstrap files - bootstrapContent := cb.LoadBootstrapFiles() - if bootstrapContent != "" { parts = append(parts, bootstrapContent) } // Skills - show summary, AI can read full content with read_file tool - skillsSummary := cb.skillsLoader.BuildSkillsSummary() - if skillsSummary != "" { parts = append(parts, fmt.Sprintf(`# Skills - - The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool. - - %s`, skillsSummary)) } @@ -464,75 +443,50 @@ The following skills extend your capabilities. To use a skill, read its SKILL.md } // Memory context - memoryContext := cb.memory.GetMemoryContext() - if memoryContext != "" { parts = append(parts, "# Memory\n\n"+memoryContext) } // Join with "---" separator - return strings.Join(parts, "\n\n---\n\n") } // BuildSystemPromptWithCache returns the cached system prompt if available - // and source files haven't changed, otherwise builds and caches it. - // Source file changes are detected via mtime checks (cheap stat calls). - func (cb *ContextBuilder) BuildSystemPromptWithCache() string { // Try read lock first — fast path when cache is valid - cb.systemPromptMutex.RLock() - if cb.cachedSystemPrompt != "" && !cb.sourceFilesChangedLocked() { result := cb.cachedSystemPrompt - cb.systemPromptMutex.RUnlock() - return result } - cb.systemPromptMutex.RUnlock() // Acquire write lock for building - cb.systemPromptMutex.Lock() - defer cb.systemPromptMutex.Unlock() // Double-check: another goroutine may have rebuilt while we waited - if cb.cachedSystemPrompt != "" && !cb.sourceFilesChangedLocked() { return cb.cachedSystemPrompt } // Snapshot the baseline (existence + max mtime) BEFORE building the prompt. - // This way cachedAt reflects the pre-build state: if a file is modified - // during BuildSystemPrompt, its new mtime will be > baseline.maxMtime, - // so the next sourceFilesChangedLocked check will correctly trigger a - // rebuild. The alternative (baseline after build) risks caching stale - // content with a too-new baseline, making the staleness invisible. - baseline := cb.buildCacheBaseline() - prompt := cb.BuildSystemPrompt() - cb.cachedSystemPrompt = prompt - cb.cachedAt = baseline.maxMtime - cb.existedAtCache = baseline.existed logger.DebugCF("agent", "System prompt cached", - map[string]any{ "length": len(prompt), }) @@ -541,20 +495,14 @@ func (cb *ContextBuilder) BuildSystemPromptWithCache() string { } // InvalidateCache clears the cached system prompt. - // Normally not needed because the cache auto-invalidates via mtime checks, - // but this is useful for tests or explicit reload commands. - func (cb *ContextBuilder) InvalidateCache() { cb.systemPromptMutex.Lock() - defer cb.systemPromptMutex.Unlock() cb.cachedSystemPrompt = "" - cb.cachedAt = time.Time{} - cb.existedAtCache = nil logger.DebugCF("agent", "System prompt cache invalidated", nil) @@ -607,9 +555,7 @@ func (cb *ContextBuilder) sourcePaths() []string { } // cacheBaseline holds the file existence snapshot and the latest observed - // mtime across all tracked paths. Used as the cache reference point. - type cacheBaseline struct { existed map[string]bool @@ -635,9 +581,7 @@ func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline { for _, p := range allPaths { info, err := os.Stat(p) - existed[p] = err == nil - if err == nil && info.ModTime().After(maxMtime) { maxMtime = info.ModTime() } @@ -660,17 +604,11 @@ func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline { }) // If no tracked files exist yet (empty workspace), maxMtime is zero. - // Use a very old non-zero time so that: - // 1. cachedAt.IsZero() won't trigger perpetual rebuilds. - // 2. Any real file created afterwards has mtime > cachedAt, so it - // will be detected by fileChangedSince (unlike time.Now() which - // could race with a file whose mtime <= Now). - if maxMtime.IsZero() { maxMtime = time.Unix(1, 0) } @@ -679,19 +617,12 @@ func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline { } // sourceFilesChangedLocked checks whether any workspace source file has been - // modified, created, or deleted since the cache was last built. - // - // IMPORTANT: The caller MUST hold at least a read lock on systemPromptMutex. - // Go's sync.RWMutex is not reentrant, so this function must NOT acquire the - // lock itself (it would deadlock when called from BuildSystemPromptWithCache - // which already holds RLock or Lock). - func (cb *ContextBuilder) sourceFilesChangedLocked() bool { if cb.cachedAt.IsZero() { return true @@ -737,55 +668,37 @@ func (cb *ContextBuilder) sourceFilesChangedLocked() bool { } // fileChangedSince returns true if a tracked source file has been modified, - // newly created, or deleted since the cache was built. - // - // Four cases: - // - existed at cache time, exists now -> check mtime - // - existed at cache time, gone now -> changed (deleted) - // - absent at cache time, exists now -> changed (created) - // - absent at cache time, gone now -> no change - func (cb *ContextBuilder) fileChangedSince(path string) bool { // Defensive: if existedAtCache was never initialized, treat as changed - // so the cache rebuilds rather than silently serving stale data. - if cb.existedAtCache == nil { return true } existedBefore := cb.existedAtCache[path] - info, err := os.Stat(path) - existsNow := err == nil if existedBefore != existsNow { return true // file was created or deleted } - if !existsNow { return false // didn't exist before, doesn't exist now } - return info.ModTime().After(cb.cachedAt) } // errWalkStop is a sentinel error used to stop filepath.WalkDir early. - // Using a dedicated error (instead of fs.SkipAll) makes the early-exit - // intent explicit and avoids the nilerr linter warning that would fire - // if the callback returned nil when its err parameter is non-nil. - var errWalkStop = errors.New("walk stop") // skillFilesModifiedSince recursively walks the skills directory and checks @@ -935,28 +848,18 @@ func (cb *ContextBuilder) ResolveBootstrapPaths() []BootstrapFileInfo { } // buildDynamicContext returns a short dynamic context string with per-request info. - // This changes every request (time, session) so it is NOT part of the cached prompt. - // LLM-side KV cache reuse is achieved by each provider adapter's native mechanism: - // - Anthropic: per-block cache_control (ephemeral) on the static SystemParts block - // - OpenAI / Codex: prompt_cache_key for prefix-based caching - // - // See: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching - // See: https://platform.openai.com/docs/guides/prompt-caching - func (cb *ContextBuilder) buildDynamicContext(channel, chatID string) string { now := time.Now().Format("2006-01-02 15:04 (Monday)") - rt := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version()) var sb strings.Builder - fmt.Fprintf(&sb, "## Current Time\n%s\n\n## Runtime\n%s", now, rt) if channel != "" && chatID != "" { @@ -968,97 +871,62 @@ func (cb *ContextBuilder) buildDynamicContext(channel, chatID string) string { func (cb *ContextBuilder) BuildMessages( history []providers.Message, - summary string, - currentMessage string, - media []string, - channel, chatID string, ) []providers.Message { messages := []providers.Message{} // The static part (identity, bootstrap, skills, memory) is cached locally to - // avoid repeated file I/O and string building on every call (fixes issue #607). - // Dynamic parts (time, session, summary) are appended per request. - // Everything is sent as a single system message for provider compatibility: - // - Anthropic adapter extracts messages[0] (Role=="system") and maps its content - // to the top-level "system" parameter in the Messages API request. A single - // contiguous system block makes this extraction straightforward. - // - Codex maps only the first system message to its instructions field. - // - OpenAI-compat passes messages through as-is. - staticPrompt := cb.BuildSystemPromptWithCache() // Build short dynamic context (time, runtime, session) — changes per request - dynamicCtx := cb.buildDynamicContext(channel, chatID) // Compose a single system message: static (cached) + dynamic + optional summary. - // Keeping all system content in one message ensures every provider adapter can - // extract it correctly (Anthropic adapter -> top-level system param, - // Codex -> instructions field). - // - // SystemParts carries the same content as structured blocks so that - // cache-aware adapters (Anthropic) can set per-block cache_control. - // The static block is marked "ephemeral" — its prefix hash is stable - // across requests, enabling LLM-side KV cache reuse. - stringParts := []string{staticPrompt, dynamicCtx} contentBlocks := []providers.ContentBlock{ {Type: "text", Text: staticPrompt, CacheControl: &providers.CacheControl{Type: "ephemeral"}}, - {Type: "text", Text: dynamicCtx}, } if summary != "" { summaryText := fmt.Sprintf( - "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+ - "for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n%s", - summary) - stringParts = append(stringParts, summaryText) - contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: summaryText}) } fullSystemPrompt := strings.Join(stringParts, "\n\n---\n\n") // Log system prompt summary for debugging (debug mode only). - // Read cachedSystemPrompt under lock to avoid a data race with - // concurrent InvalidateCache / BuildSystemPromptWithCache writes. - cb.systemPromptMutex.RLock() - isCached := cb.cachedSystemPrompt != "" - cb.systemPromptMutex.RUnlock() logger.DebugCF("agent", "System prompt built", - map[string]any{ "static_chars": len(staticPrompt), @@ -1080,7 +948,6 @@ func (cb *ContextBuilder) BuildMessages( } logger.DebugCF("agent", "System prompt preview", - map[string]any{ "preview": preview, }) @@ -1088,11 +955,8 @@ func (cb *ContextBuilder) BuildMessages( history = sanitizeHistoryForProvider(history) // Single system message containing all context — compatible with all providers. - // SystemParts enables cache-aware adapters to set per-block cache_control; - // Content is the concatenated fallback for adapters that don't read SystemParts. - messages = append(messages, providers.Message{ Role: "system", @@ -1102,11 +966,9 @@ func (cb *ContextBuilder) BuildMessages( }) // Add conversation history - messages = append(messages, history...) // Add current user message - if strings.TrimSpace(currentMessage) != "" { messages = append(messages, providers.Message{ Role: "user", @@ -1124,86 +986,58 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message } sanitized := make([]providers.Message, 0, len(history)) - for _, msg := range history { switch msg.Role { case "system": - // Drop system messages from history. BuildMessages always - // constructs its own single system message (static + dynamic + - // summary); extra system messages would break providers that - // only accept one (Anthropic, Codex). - logger.DebugCF("agent", "Dropping system message from history", map[string]any{}) - continue case "tool": - if len(sanitized) == 0 { logger.DebugCF("agent", "Dropping orphaned leading tool message", map[string]any{}) - continue } - // Walk backwards to find the nearest assistant message, - // skipping over any preceding tool messages (multi-tool-call case). - foundAssistant := false - for i := len(sanitized) - 1; i >= 0; i-- { if sanitized[i].Role == "tool" { continue } - if sanitized[i].Role == "assistant" && len(sanitized[i].ToolCalls) > 0 { foundAssistant = true } - break } - if !foundAssistant { logger.DebugCF("agent", "Dropping orphaned tool message", map[string]any{}) - continue } - sanitized = append(sanitized, msg) case "assistant": - if len(msg.ToolCalls) > 0 { if len(sanitized) == 0 { logger.DebugCF("agent", "Dropping assistant tool-call turn at history start", map[string]any{}) - continue } - prev := sanitized[len(sanitized)-1] - if prev.Role != "user" && prev.Role != "tool" { logger.DebugCF( - "agent", - "Dropping assistant tool-call turn with invalid predecessor", - map[string]any{"prev_role": prev.Role}, ) - continue } } - sanitized = append(sanitized, msg) default: - sanitized = append(sanitized, msg) } } @@ -1213,7 +1047,6 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message func (cb *ContextBuilder) AddToolResult( messages []providers.Message, - toolCallID, toolName, result string, ) []providers.Message { messages = append(messages, providers.Message{ @@ -1223,15 +1056,12 @@ func (cb *ContextBuilder) AddToolResult( ToolCallID: toolCallID, }) - return messages } func (cb *ContextBuilder) AddAssistantMessage( messages []providers.Message, - content string, - toolCalls []map[string]any, ) []providers.Message { msg := providers.Message{ @@ -1239,11 +1069,8 @@ func (cb *ContextBuilder) AddAssistantMessage( Content: content, } - // Always add assistant message, whether or not it has tool calls - messages = append(messages, msg) - return messages } @@ -1376,16 +1203,12 @@ func (cb *ContextBuilder) GetPlanTaskName() string { } // GetSkillsInfo returns information about loaded skills. - func (cb *ContextBuilder) GetSkillsInfo() map[string]any { allSkills := cb.skillsLoader.ListSkills() - skillNames := make([]string, 0, len(allSkills)) - for _, s := range allSkills { skillNames = append(skillNames, s.Name) } - return map[string]any{ "total": len(allSkills), diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index eed0439c3..64c36ad13 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -81,22 +81,16 @@ type AgentInstance struct { } // NewAgentInstance creates an agent instance from config. - func NewAgentInstance( agentCfg *config.AgentConfig, - defaults *config.AgentDefaults, - cfg *config.Config, - provider providers.LLMProvider, ) *AgentInstance { workspace := resolveAgentWorkspace(agentCfg, defaults) - os.MkdirAll(workspace, 0o755) model := resolveAgentModel(agentCfg, defaults) - fallbacks := resolveAgentFallbacks(agentCfg, defaults) restrict := defaults.RestrictToWorkspace @@ -154,20 +148,14 @@ func NewAgentInstance( contextBuilder := NewContextBuilder(workspace) agentID := routing.DefaultAgentID - agentName := "" - var subagents *config.SubagentsConfig - var skillsFilter []string if agentCfg != nil { agentID = routing.NormalizeAgentID(agentCfg.ID) - agentName = agentCfg.Name - subagents = agentCfg.Subagents - skillsFilter = agentCfg.Skills } @@ -182,7 +170,6 @@ func NewAgentInstance( } maxIter := defaults.MaxToolIterations - if maxIter == 0 { maxIter = 20 } @@ -194,42 +181,34 @@ func NewAgentInstance( } maxTokens := defaults.MaxTokens - if maxTokens == 0 { maxTokens = 8192 } temperature := 0.7 - if defaults.Temperature != nil { temperature = *defaults.Temperature } // Resolve fallback candidates - modelCfg := providers.ModelConfig{ Primary: model, Fallbacks: fallbacks, } - resolveFromModelList := func(raw string) (string, bool) { ensureProtocol := func(model string) string { model = strings.TrimSpace(model) - if model == "" { return "" } - if strings.Contains(model, "/") { return model } - return "openai/" + model } raw = strings.TrimSpace(raw) - if raw == "" { return "", false } @@ -241,17 +220,13 @@ func NewAgentInstance( for i := range cfg.ModelList { fullModel := strings.TrimSpace(cfg.ModelList[i].Model) - if fullModel == "" { continue } - if fullModel == raw { return ensureProtocol(fullModel), true } - _, modelID := providers.ExtractProtocol(fullModel) - if modelID == raw { return ensureProtocol(fullModel), true } @@ -333,7 +308,6 @@ func NewAgentInstance( } // resolveAgentWorkspace determines the workspace directory for an agent. - func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string { if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" { return expandHome(strings.TrimSpace(agentCfg.Workspace)) @@ -351,22 +325,18 @@ func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentD } // resolveAgentModel resolves the primary model for an agent. - func resolveAgentModel(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string { if agentCfg != nil && agentCfg.Model != nil && strings.TrimSpace(agentCfg.Model.Primary) != "" { return strings.TrimSpace(agentCfg.Model.Primary) } - return defaults.GetModelName() } // resolveAgentFallbacks resolves the fallback models for an agent. - func resolveAgentFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) []string { if agentCfg != nil && agentCfg.Model != nil && agentCfg.Model.Fallbacks != nil { return agentCfg.Model.Fallbacks } - return defaults.ModelFallbacks } @@ -507,16 +477,12 @@ func expandHome(path string) string { if path == "" { return path } - if path[0] == '~' { home, _ := os.UserHomeDir() - if len(path) > 1 && path[1] == '/' { return home + path[1:] } - return home } - return path } diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 3ee17863a..99b456752 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -1,11 +1,7 @@ // PicoClaw - Ultra-lightweight personal AI agent - // Inspired by and based on nanobot: https://github.com/HKUDS/nanobot - // License: MIT - // - // Copyright (c) 2026 PicoClaw contributors package agent @@ -92,7 +88,6 @@ type AgentLoop struct { } // processOptions configures how a message is processed - type processOptions struct { SessionKey string // Session identifier for history/context @@ -123,9 +118,7 @@ const defaultResponse = "I've completed processing but have no response to give. func NewAgentLoop( cfg *config.Config, - msgBus *bus.MessageBus, - provider providers.LLMProvider, enableStats ...bool, @@ -133,17 +126,12 @@ func NewAgentLoop( registry := NewAgentRegistry(cfg, provider) // Set up shared fallback chain - cooldown := providers.NewCooldownTracker() - fallbackChain := providers.NewFallbackChain(cooldown) // Create state manager using default agent's workspace for channel recording - defaultAgent := registry.GetDefaultAgent() - var stateManager *state.Manager - if defaultAgent != nil { stateManager = state.NewManager(defaultAgent.Workspace) } @@ -224,21 +212,16 @@ func (al *AgentLoop) SetHeartbeatThreadUpdater(fn func(int)) { } // registerSharedTools registers tools that are shared across all agents (web, message, spawn). - func registerSharedTools( cfg *config.Config, - msgBus *bus.MessageBus, - registry *AgentRegistry, - provider providers.LLMProvider, al *AgentLoop, ) { for _, agentID := range registry.ListAgentIDs() { agent, ok := registry.GetAgent(agentID) - if !ok { continue } @@ -469,9 +452,7 @@ func (al *AgentLoop) Run(ctx context.Context) error { for al.running.Load() { select { case <-ctx.Done(): - return nil - default: } @@ -738,41 +719,29 @@ func (al *AgentLoop) SetMediaStore(s media.MediaStore) { } // inferMediaType determines the media type ("image", "audio", "video", "file") - // from a filename and MIME content type. - func inferMediaType(filename, contentType string) string { ct := strings.ToLower(contentType) - fn := strings.ToLower(filename) if strings.HasPrefix(ct, "image/") { return "image" } - if strings.HasPrefix(ct, "audio/") || ct == "application/ogg" { return "audio" } - if strings.HasPrefix(ct, "video/") { return "video" } // Fallback: infer from extension - ext := filepath.Ext(fn) - switch ext { case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg": - return "image" - case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus": - return "audio" - case ".mp4", ".avi", ".mov", ".webm", ".mkv": - return "video" } @@ -780,26 +749,20 @@ func inferMediaType(filename, contentType string) string { } // RecordLastChannel records the last active channel for this workspace. - // This uses the atomic state save mechanism to prevent data loss on crash. - func (al *AgentLoop) RecordLastChannel(channel string) error { if al.state == nil { return nil } - return al.state.SetLastChannel(channel) } // RecordLastChatID records the last active chat ID for this workspace. - // This uses the atomic state save mechanism to prevent data loss on crash. - func (al *AgentLoop) RecordLastChatID(chatID string) error { if al.state == nil { return nil } - return al.state.SetLastChatID(chatID) } @@ -823,7 +786,6 @@ func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey stri func (al *AgentLoop) ProcessDirectWithChannel( ctx context.Context, - content, sessionKey, channel, chatID string, ) (string, error) { msg := bus.InboundMessage{ @@ -846,12 +808,10 @@ func (al *AgentLoop) ProcessDirectWithChannel( } // ProcessHeartbeat processes a heartbeat request without session history. - // Each heartbeat is independent and doesn't accumulate context. func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, chatID string) (string, error) { agent := al.registry.GetDefaultAgent() - if agent == nil { return "", fmt.Errorf("no default agent for heartbeat") } @@ -888,9 +848,7 @@ func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, cha func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) { // Add message preview to log (show full content for error messages) - var logContent string - if strings.Contains(msg.Content, "Error:") || strings.Contains(msg.Content, "error") { logContent = msg.Content // Full content for errors } else { @@ -971,7 +929,6 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) } // Route system messages to processSystemMessage - if msg.Channel == "system" { return al.processSystemMessage(ctx, msg) } @@ -1023,11 +980,9 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) }) agent, ok := al.registry.GetAgent(route.AgentID) - if !ok { agent = al.registry.GetDefaultAgent() } - if agent == nil { return "", fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID) } @@ -1385,7 +1340,6 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt if !constants.IsInternalChannel(opts.Channel) { channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID) - if err := al.RecordLastChannel(channelKey); err != nil { logger.WarnCF("agent", "Failed to record last channel", map[string]any{"error": err.Error()}) } @@ -1487,12 +1441,9 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt // 2. Build messages (skip history for heartbeat) var history []providers.Message - var summary string - if !opts.NoHistory { history = agent.Sessions.GetHistory(opts.SessionKey) - summary = agent.Sessions.GetSummary(opts.SessionKey) // Sanitize history to remove orphaned tool calls (from crashes/session collisions) @@ -1517,19 +1468,14 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt _ = agent.Sessions.Save(opts.SessionKey) } } - messages := agent.ContextBuilder.BuildMessages( - history, - summary, - opts.UserMessage, nil, opts.Channel, - opts.ChatID, ) @@ -1855,9 +1801,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt // 9. Log response responsePreview := utils.Truncate(finalContent, 120) - logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview), - map[string]any{ "agent_id": agent.ID, @@ -1875,21 +1819,16 @@ func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string if al.channelManager == nil { return "" } - if ch, ok := al.channelManager.GetChannel(channelName); ok { return ch.ReasoningChannelID() } - return "" } func (al *AgentLoop) runLLMIteration( ctx context.Context, - agent *AgentInstance, - messages []providers.Message, - opts processOptions, task *activeTask, @@ -1897,7 +1836,6 @@ func (al *AgentLoop) runLLMIteration( planSnapshot string, ) (string, int, error) { iteration := 0 - var finalContent string lastReminderIdx := -1 @@ -1952,7 +1890,6 @@ func (al *AgentLoop) runLLMIteration( } logger.DebugCF("agent", "LLM iteration", - map[string]any{ "agent_id": agent.ID, @@ -1962,7 +1899,6 @@ func (al *AgentLoop) runLLMIteration( }) // Build tool definitions - providerToolDefs := agent.Tools.ToProviderDefs() // Interview mode: strip tool definitions the LLM must not use, @@ -1974,9 +1910,7 @@ func (al *AgentLoop) runLLMIteration( } // Log LLM request details - logger.DebugCF("agent", "LLM request", - map[string]any{ "agent_id": agent.ID, @@ -1996,9 +1930,7 @@ func (al *AgentLoop) runLLMIteration( }) // Log full messages (detailed) - logger.DebugCF("agent", "Full LLM request", - map[string]any{ "iteration": iteration, @@ -2010,7 +1942,6 @@ func (al *AgentLoop) runLLMIteration( // Call LLM with fallback chain if candidates are configured. var response *providers.LLMResponse - var err error // Build onChunk callback for streaming preview. @@ -2169,11 +2100,9 @@ func (al *AgentLoop) runLLMIteration( return doCall(ctx, p, model) }, ) - if fbErr != nil { return nil, fbErr } - if fbResult.Provider != "" && len(fbResult.Attempts) > 0 { logger.InfoCF("agent", fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts", @@ -2181,7 +2110,6 @@ func (al *AgentLoop) runLLMIteration( map[string]any{"agent_id": agent.ID, "iteration": iteration}) } - return fbResult.Response, nil } @@ -2201,12 +2129,9 @@ func (al *AgentLoop) runLLMIteration( al.reporter().ReportStateChange(opts.SessionKey, orch.AgentStateWaiting, "") // Retry loop for context/token errors - maxRetries := 2 - for retry := 0; retry <= maxRetries; retry++ { response, err = callLLM() - if err == nil { break } @@ -2214,40 +2139,25 @@ func (al *AgentLoop) runLLMIteration( errMsg := strings.ToLower(err.Error()) // Check if this is a network/HTTP timeout — not a context window error. - isTimeoutError := errors.Is(err, context.DeadlineExceeded) || - strings.Contains(errMsg, "deadline exceeded") || - strings.Contains(errMsg, "client.timeout") || - strings.Contains(errMsg, "timed out") || - strings.Contains(errMsg, "timeout exceeded") // Detect real context window / token limit errors, excluding network timeouts. - isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") || - strings.Contains(errMsg, "context window") || - strings.Contains(errMsg, "maximum context length") || - strings.Contains(errMsg, "token limit") || - strings.Contains(errMsg, "too many tokens") || - strings.Contains(errMsg, "max_tokens") || - strings.Contains(errMsg, "invalidparameter") || - strings.Contains(errMsg, "prompt is too long") || - strings.Contains(errMsg, "request too large")) if isTimeoutError && retry < maxRetries { backoff := time.Duration(retry+1) * 5 * time.Second - logger.WarnCF("agent", "Timeout error, retrying after backoff", map[string]any{ "error": err.Error(), @@ -2255,9 +2165,7 @@ func (al *AgentLoop) runLLMIteration( "backoff": backoff.String(), }) - time.Sleep(backoff) - continue } @@ -2279,21 +2187,14 @@ func (al *AgentLoop) runLLMIteration( } al.forceCompression(agent, opts.SessionKey) - newHistory := agent.Sessions.GetHistory(opts.SessionKey) - newSummary := agent.Sessions.GetSummary(opts.SessionKey) - messages = agent.ContextBuilder.BuildMessages( - newHistory, newSummary, "", - nil, opts.Channel, opts.ChatID, ) - continue } - break } @@ -2317,7 +2218,6 @@ func (al *AgentLoop) runLLMIteration( if err != nil { logger.ErrorCF("agent", "LLM call failed", - map[string]any{ "agent_id": agent.ID, @@ -2325,7 +2225,6 @@ func (al *AgentLoop) runLLMIteration( "error": err.Error(), }) - return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err) } @@ -2347,7 +2246,6 @@ func (al *AgentLoop) runLLMIteration( go al.handleReasoning(ctx, response.Reasoning, opts.Channel, al.targetReasoningChannelID(opts.Channel)) logger.DebugCF("agent", "LLM response", - map[string]any{ "agent_id": agent.ID, @@ -2504,12 +2402,10 @@ func (al *AgentLoop) runLLMIteration( "content_chars": len(finalContent), }) - break } normalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls)) - for _, tc := range response.ToolCalls { normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc)) } @@ -2563,15 +2459,11 @@ func (al *AgentLoop) runLLMIteration( } // Log tool calls - toolNames := make([]string, 0, len(normalizedToolCalls)) - for _, tc := range normalizedToolCalls { toolNames = append(toolNames, tc.Name) } - logger.InfoCF("agent", "LLM requested tool calls", - map[string]any{ "agent_id": agent.ID, @@ -2685,7 +2577,6 @@ func (al *AgentLoop) runLLMIteration( } // Build assistant message with tool calls - assistantMsg := providers.Message{ Role: "assistant", @@ -2693,14 +2584,10 @@ func (al *AgentLoop) runLLMIteration( ReasoningContent: response.ReasoningContent, } - for _, tc := range normalizedToolCalls { // Copy ExtraContent to ensure thought_signature is persisted for Gemini 3 - extraContent := tc.ExtraContent - thoughtSignature := "" - if tc.Function != nil { thoughtSignature = tc.Function.ThoughtSignature } @@ -2709,7 +2596,6 @@ func (al *AgentLoop) runLLMIteration( ID: tc.ID, Type: "function", - Name: tc.Name, Arguments: tc.Arguments, @@ -2727,11 +2613,9 @@ func (al *AgentLoop) runLLMIteration( ThoughtSignature: thoughtSignature, }) } - messages = append(messages, assistantMsg) // Save assistant message with tool calls to session - agent.Sessions.AddFullMessage(opts.SessionKey, assistantMsg) // Execute tool calls @@ -2923,16 +2807,12 @@ func (al *AgentLoop) runLLMIteration( if al.mediaStore != nil { if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil { part.Filename = meta.Filename - part.ContentType = meta.ContentType - part.Type = inferMediaType(meta.Filename, meta.ContentType) } } - parts = append(parts, part) } - al.bus.PublishOutboundMedia(ctx, bus.OutboundMediaMessage{ Channel: opts.Channel, @@ -2963,11 +2843,9 @@ func (al *AgentLoop) runLLMIteration( ToolCallID: tc.ID, } - messages = append(messages, toolResultMsg) // Save tool result message to session - agent.Sessions.AddFullMessage(opts.SessionKey, toolResultMsg) } diff --git a/pkg/agent/memory.go b/pkg/agent/memory.go index 2d81bb398..f2483abe3 100644 --- a/pkg/agent/memory.go +++ b/pkg/agent/memory.go @@ -1,11 +1,7 @@ // PicoClaw - Ultra-lightweight personal AI agent - // Inspired by and based on nanobot: https://github.com/HKUDS/nanobot - // License: MIT - // - // Copyright (c) 2026 PicoClaw contributors package agent @@ -24,11 +20,8 @@ import ( ) // MemoryStore manages persistent memory for the agent. - // - Long-term memory: memory/MEMORY.md - // - Daily notes: memory/YYYYMM/YYYYMMDD.md - type MemoryStore struct { workspace string @@ -82,16 +75,12 @@ type parsedPlanState struct { } // NewMemoryStore creates a new MemoryStore with the given workspace path. - // It ensures the memory directory exists. - func NewMemoryStore(workspace string) *MemoryStore { memoryDir := filepath.Join(workspace, "memory") - memoryFile := filepath.Join(memoryDir, "MEMORY.md") // Ensure memory directory exists - os.MkdirAll(memoryDir, 0o755) return &MemoryStore{ @@ -104,14 +93,12 @@ func NewMemoryStore(workspace string) *MemoryStore { } // getTodayFile returns the path to today's daily note file (memory/YYYYMM/YYYYMMDD.md). - func (ms *MemoryStore) getTodayFile() string { today := time.Now().Format("20060102") // YYYYMMDD monthDir := today[:6] // YYYYMM filePath := filepath.Join(ms.memoryDir, monthDir, today+".md") - return filePath } @@ -318,10 +305,8 @@ func (ms *MemoryStore) ReadLongTerm() string { } // WriteLongTerm writes content to the long-term memory file (MEMORY.md). - func (ms *MemoryStore) WriteLongTerm(content string) error { // Use unified atomic write utility with explicit sync for flash storage reliability. - // Using 0o600 (owner read/write only) for secure default permissions. if err := fileutil.WriteFileAtomic(ms.memoryFile, []byte(content), 0o600); err != nil { @@ -346,71 +331,53 @@ func (ms *MemoryStore) ClearLongTerm() error { } // ReadToday reads today's daily note. - // Returns empty string if the file doesn't exist. - func (ms *MemoryStore) ReadToday() string { todayFile := ms.getTodayFile() - if data, err := os.ReadFile(todayFile); err == nil { return string(data) } - return "" } // AppendToday appends content to today's daily note. - // If the file doesn't exist, it creates a new file with a date header. - func (ms *MemoryStore) AppendToday(content string) error { todayFile := ms.getTodayFile() // Ensure month directory exists - monthDir := filepath.Dir(todayFile) - if err := os.MkdirAll(monthDir, 0o755); err != nil { return err } var existingContent string - if data, err := os.ReadFile(todayFile); err == nil { existingContent = string(data) } var newContent string - if existingContent == "" { // Add header for new day - header := fmt.Sprintf("# %s\n\n", time.Now().Format("2006-01-02")) - newContent = header + content } else { // Append to existing content - newContent = existingContent + "\n" + content } // Use unified atomic write utility with explicit sync for flash storage reliability. - return fileutil.WriteFileAtomic(todayFile, []byte(newContent), 0o600) } // GetRecentDailyNotes returns daily notes from the last N days. - // Contents are joined with "---" separator. - func (ms *MemoryStore) GetRecentDailyNotes(days int) string { var sb strings.Builder - first := true for i := range days { date := time.Now().AddDate(0, 0, -i) - dateStr := date.Format("20060102") // YYYYMMDD monthDir := dateStr[:6] // YYYYMM @@ -421,9 +388,7 @@ func (ms *MemoryStore) GetRecentDailyNotes(days int) string { if !first { sb.WriteString("\n\n---\n\n") } - sb.Write(data) - first = false } } diff --git a/pkg/agent/registry.go b/pkg/agent/registry.go index 300352331..3fb3f49ba 100644 --- a/pkg/agent/registry.go +++ b/pkg/agent/registry.go @@ -11,7 +11,6 @@ import ( ) // AgentRegistry manages multiple agent instances and routes messages to them. - type AgentRegistry struct { agents map[string]*AgentInstance @@ -21,10 +20,8 @@ type AgentRegistry struct { } // NewAgentRegistry creates a registry from config, instantiating all agents. - func NewAgentRegistry( cfg *config.Config, - provider providers.LLMProvider, ) *AgentRegistry { registry := &AgentRegistry{ @@ -34,31 +31,22 @@ func NewAgentRegistry( } agentConfigs := cfg.Agents.List - if len(agentConfigs) == 0 { implicitAgent := &config.AgentConfig{ ID: "main", Default: true, } - instance := NewAgentInstance(implicitAgent, &cfg.Agents.Defaults, cfg, provider) - registry.agents["main"] = instance - logger.InfoCF("agent", "Created implicit main agent (no agents.list configured)", nil) } else { for i := range agentConfigs { ac := &agentConfigs[i] - id := routing.NormalizeAgentID(ac.ID) - instance := NewAgentInstance(ac, &cfg.Agents.Defaults, cfg, provider) - registry.agents[id] = instance - logger.InfoCF("agent", "Registered agent", - map[string]any{ "agent_id": id, @@ -75,66 +63,48 @@ func NewAgentRegistry( } // GetAgent returns the agent instance for a given ID. - func (r *AgentRegistry) GetAgent(agentID string) (*AgentInstance, bool) { r.mu.RLock() - defer r.mu.RUnlock() - id := routing.NormalizeAgentID(agentID) - agent, ok := r.agents[id] - return agent, ok } // ResolveRoute determines which agent handles the message. - func (r *AgentRegistry) ResolveRoute(input routing.RouteInput) routing.ResolvedRoute { return r.resolver.ResolveRoute(input) } // ListAgentIDs returns all registered agent IDs. - func (r *AgentRegistry) ListAgentIDs() []string { r.mu.RLock() - defer r.mu.RUnlock() - ids := make([]string, 0, len(r.agents)) - for id := range r.agents { ids = append(ids, id) } - return ids } // CanSpawnSubagent checks if parentAgentID is allowed to spawn targetAgentID. - func (r *AgentRegistry) CanSpawnSubagent(parentAgentID, targetAgentID string) bool { parent, ok := r.GetAgent(parentAgentID) - if !ok { return false } - if parent.Subagents == nil || parent.Subagents.AllowAgents == nil { return false } - targetNorm := routing.NormalizeAgentID(targetAgentID) - for _, allowed := range parent.Subagents.AllowAgents { if allowed == "*" { return true } - if routing.NormalizeAgentID(allowed) == targetNorm { return true } } - return false } @@ -152,19 +122,14 @@ func (r *AgentRegistry) ForEachTool(name string, fn func(tools.Tool)) { } // GetDefaultAgent returns the default agent instance. - func (r *AgentRegistry) GetDefaultAgent() *AgentInstance { r.mu.RLock() - defer r.mu.RUnlock() - if agent, ok := r.agents["main"]; ok { return agent } - for _, agent := range r.agents { return agent } - return nil } diff --git a/pkg/providers/anthropic/provider.go b/pkg/providers/anthropic/provider.go index 5657bac50..7a17e92bf 100644 --- a/pkg/providers/anthropic/provider.go +++ b/pkg/providers/anthropic/provider.go @@ -329,7 +329,6 @@ func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam { case []string: tool.InputSchema.Required = append([]string(nil), req...) } - result = append(result, anthropic.ToolUnionParam{OfTool: &tool}) } return result diff --git a/pkg/session/manager.go b/pkg/session/manager.go index 708f96a96..77134ff7e 100644 --- a/pkg/session/manager.go +++ b/pkg/session/manager.go @@ -63,11 +63,9 @@ func NewSessionManager(storage string) *SessionManager { func (sm *SessionManager) GetOrCreate(key string) *Session { sm.mu.Lock() - defer sm.mu.Unlock() session, ok := sm.sessions[key] - if ok { return session } @@ -81,7 +79,6 @@ func (sm *SessionManager) GetOrCreate(key string) *Session { Updated: time.Now(), } - sm.sessions[key] = session return session @@ -96,16 +93,12 @@ func (sm *SessionManager) AddMessage(sessionKey, role, content string) { } // AddFullMessage adds a complete message with tool calls and tool call ID to the session. - // This is used to save the full conversation flow including tool calls and tool results. - func (sm *SessionManager) AddFullMessage(sessionKey string, msg providers.Message) { sm.mu.Lock() - defer sm.mu.Unlock() session, ok := sm.sessions[sessionKey] - if !ok { session = &Session{ Key: sessionKey, @@ -114,77 +107,61 @@ func (sm *SessionManager) AddFullMessage(sessionKey string, msg providers.Messag Created: time.Now(), } - sm.sessions[sessionKey] = session } session.Messages = append(session.Messages, msg) - session.Updated = time.Now() } func (sm *SessionManager) GetHistory(key string) []providers.Message { sm.mu.RLock() - defer sm.mu.RUnlock() session, ok := sm.sessions[key] - if !ok { return []providers.Message{} } history := make([]providers.Message, len(session.Messages)) - copy(history, session.Messages) - return history } func (sm *SessionManager) GetSummary(key string) string { sm.mu.RLock() - defer sm.mu.RUnlock() session, ok := sm.sessions[key] - if !ok { return "" } - return session.Summary } func (sm *SessionManager) SetSummary(key string, summary string) { sm.mu.Lock() - defer sm.mu.Unlock() session, ok := sm.sessions[key] - if ok { session.Summary = summary - session.Updated = time.Now() } } func (sm *SessionManager) TruncateHistory(key string, keepLast int) { sm.mu.Lock() - defer sm.mu.Unlock() session, ok := sm.sessions[key] - if !ok { return } if keepLast <= 0 { session.Messages = []providers.Message{} - session.Updated = time.Now() - return } @@ -193,7 +170,6 @@ func (sm *SessionManager) TruncateHistory(key string, keepLast int) { } session.Messages = session.Messages[len(session.Messages)-keepLast:] - session.Updated = time.Now() } @@ -234,14 +210,10 @@ func (sm *SessionManager) Save(key string) error { } // Snapshot under read lock, then perform slow file I/O after unlock. - sm.mu.RLock() - stored, ok := sm.sessions[key] - if !ok { sm.mu.RUnlock() - return nil } @@ -249,20 +221,15 @@ func (sm *SessionManager) Save(key string) error { Key: stored.Key, Summary: stored.Summary, - Created: stored.Created, - Updated: stored.Updated, } - if len(stored.Messages) > 0 { snapshot.Messages = make([]providers.Message, len(stored.Messages)) - copy(snapshot.Messages, stored.Messages) } else { snapshot.Messages = []providers.Message{} } - sm.mu.RUnlock() data, err := json.MarshalIndent(snapshot, "", " ") @@ -271,16 +238,13 @@ func (sm *SessionManager) Save(key string) error { } sessionPath := filepath.Join(sm.storage, filename+".json") - tmpFile, err := os.CreateTemp(sm.storage, "session-*.tmp") if err != nil { return err } tmpPath := tmpFile.Name() - cleanup := true - defer func() { if cleanup { _ = os.Remove(tmpPath) @@ -289,22 +253,17 @@ func (sm *SessionManager) Save(key string) error { if _, err := tmpFile.Write(data); err != nil { _ = tmpFile.Close() - return err } if err := tmpFile.Chmod(0o644); err != nil { _ = tmpFile.Close() - return err } - if err := tmpFile.Sync(); err != nil { _ = tmpFile.Close() - return err } - if err := tmpFile.Close(); err != nil { return err } @@ -312,9 +271,7 @@ func (sm *SessionManager) Save(key string) error { if err := os.Rename(tmpPath, sessionPath); err != nil { return err } - cleanup = false - return nil } @@ -334,14 +291,12 @@ func (sm *SessionManager) loadSessions() error { } sessionPath := filepath.Join(sm.storage, file.Name()) - data, err := os.ReadFile(sessionPath) if err != nil { continue } var session Session - if err := json.Unmarshal(data, &session); err != nil { continue } @@ -459,25 +414,17 @@ func SanitizeHistory(history []providers.Message) ([]providers.Message, int) { } // SetHistory updates the messages of a session. - func (sm *SessionManager) SetHistory(key string, history []providers.Message) { sm.mu.Lock() - defer sm.mu.Unlock() session, ok := sm.sessions[key] - if ok { // Create a deep copy to strictly isolate internal state - // from the caller's slice. - msgs := make([]providers.Message, len(history)) - copy(msgs, history) - session.Messages = msgs - session.Updated = time.Now() } } diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index 6489cba6e..a05570186 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -14,13 +14,11 @@ import ( ) // JobExecutor is the interface for executing cron jobs through the agent - type JobExecutor interface { ProcessDirectWithChannel(ctx context.Context, content, sessionKey, channel, chatID string) (string, error) } // CronTool provides scheduling capabilities for the agent - type CronTool struct { cronService *cron.CronService @@ -38,12 +36,9 @@ type CronTool struct { } // NewCronTool creates a new CronTool - // execTimeout: 0 means no timeout, >0 sets the timeout duration - func NewCronTool( cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool, - execTimeout time.Duration, config *config.Config, ) (*CronTool, error) { execTool, err := NewExecToolWithConfig(workspace, restrict, config) @@ -52,7 +47,6 @@ func NewCronTool( } execTool.SetTimeout(execTimeout) - return &CronTool{ cronService: cronService, @@ -65,23 +59,19 @@ func NewCronTool( } // Name returns the tool name - func (t *CronTool) Name() string { return "cron" } // Description returns the tool description - func (t *CronTool) Description() string { return "Schedule reminders, tasks, or system commands. IMPORTANT: When user asks to be reminded or scheduled, you MUST call this tool. Use 'at_seconds' for one-time reminders (e.g., 'remind me in 10 minutes' → at_seconds=600). Use 'every_seconds' ONLY for recurring tasks (e.g., 'every 2 hours' → every_seconds=7200). Use 'cron_expr' for complex recurring schedules. Use 'command' to execute shell commands directly." } // Parameters returns the tool parameters schema - func (t *CronTool) Parameters() map[string]any { return map[string]any{ "type": "object", - "properties": map[string]any{ "action": map[string]any{ "type": "string", @@ -90,13 +80,11 @@ func (t *CronTool) Parameters() map[string]any { "description": "Action to perform. Use 'add' when user wants to schedule a reminder or task.", }, - "message": map[string]any{ "type": "string", "description": "The reminder/task message to display when triggered. If 'command' is used, this describes what the command does.", }, - "command": map[string]any{ "type": "string", @@ -108,32 +96,27 @@ func (t *CronTool) Parameters() map[string]any { "description": "One-time reminder: seconds from now when to trigger (e.g., 600 for 10 minutes later). Use this for one-time reminders like 'remind me in 10 minutes'.", }, - "every_seconds": map[string]any{ "type": "integer", "description": "Recurring interval in seconds (e.g., 3600 for every hour). Use this ONLY for recurring tasks like 'every 2 hours' or 'daily reminder'.", }, - "cron_expr": map[string]any{ "type": "string", "description": "Cron expression for complex recurring schedules (e.g., '0 9 * * *' for daily at 9am). Use this for complex recurring schedules.", }, - "job_id": map[string]any{ "type": "string", "description": "Job ID (for remove/enable/disable)", }, - "deliver": map[string]any{ "type": "boolean", "description": "If true, send message directly to channel. If false, let agent process message (for complex tasks). Default: true", }, }, - "required": []string{"action"}, } } @@ -151,10 +134,8 @@ func (t *CronTool) SetContext(channel, chatID string) { } // Execute runs the tool with the given arguments - func (t *CronTool) Execute(ctx context.Context, args map[string]any) *ToolResult { action, ok := args["action"].(string) - if !ok { return ErrorResult("action is required") } @@ -165,23 +146,14 @@ func (t *CronTool) Execute(ctx context.Context, args map[string]any) *ToolResult return t.addJob(args) case "list": - return t.listJobs() - case "remove": - return t.removeJob(args) - case "enable": - return t.enableJob(args, true) - case "disable": - return t.enableJob(args, false) - default: - return ErrorResult(fmt.Sprintf("unknown action: %s", action)) } } @@ -200,7 +172,6 @@ func (t *CronTool) addJob(args map[string]any) *ToolResult { } message, ok := args["message"].(string) - if !ok || message == "" { return ErrorResult("message is required for add") } @@ -208,26 +179,19 @@ func (t *CronTool) addJob(args map[string]any) *ToolResult { var schedule cron.CronSchedule // Check for at_seconds (one-time), every_seconds (recurring), or cron_expr - atSeconds, hasAt := args["at_seconds"].(float64) - everySeconds, hasEvery := args["every_seconds"].(float64) - cronExpr, hasCron := args["cron_expr"].(string) // Priority: at_seconds > every_seconds > cron_expr - if hasAt { atMS := time.Now().UnixMilli() + int64(atSeconds)*1000 - schedule = cron.CronSchedule{ Kind: "at", - AtMS: &atMS, } } else if hasEvery { everyMS := int64(everySeconds) * 1000 - schedule = cron.CronSchedule{ Kind: "every", @@ -236,7 +200,6 @@ func (t *CronTool) addJob(args map[string]any) *ToolResult { } else if hasCron { schedule = cron.CronSchedule{ Kind: "cron", - Expr: cronExpr, } } else { @@ -244,9 +207,7 @@ func (t *CronTool) addJob(args map[string]any) *ToolResult { } // Read deliver parameter, default to true - deliver := true - if d, ok := args["deliver"].(bool); ok { deliver = d } @@ -266,21 +227,14 @@ func (t *CronTool) addJob(args map[string]any) *ToolResult { } // Truncate message for job name (max 30 chars) - messagePreview := utils.Truncate(message, 30) job, err := t.cronService.AddJob( - messagePreview, - schedule, - message, - deliver, - channel, - chatID, ) if err != nil { @@ -289,9 +243,7 @@ func (t *CronTool) addJob(args map[string]any) *ToolResult { if command != "" { job.Payload.Command = command - // Need to save the updated payload - t.cronService.UpdateJob(job) } @@ -311,7 +263,6 @@ func (t *CronTool) listJobs() *ToolResult { for _, j := range jobs { var scheduleInfo string - if j.Schedule.Kind == "every" && j.Schedule.EveryMS != nil { scheduleInfo = fmt.Sprintf("every %ds", *j.Schedule.EveryMS/1000) } else if j.Schedule.Kind == "cron" { @@ -330,7 +281,6 @@ func (t *CronTool) listJobs() *ToolResult { func (t *CronTool) removeJob(args map[string]any) *ToolResult { jobID, ok := args["job_id"].(string) - if !ok || jobID == "" { return ErrorResult("job_id is required for remove") } @@ -338,62 +288,49 @@ func (t *CronTool) removeJob(args map[string]any) *ToolResult { if t.cronService.RemoveJob(jobID) { return SilentResult(fmt.Sprintf("Cron job removed: %s", jobID)) } - return ErrorResult(fmt.Sprintf("Job %s not found", jobID)) } func (t *CronTool) enableJob(args map[string]any, enable bool) *ToolResult { jobID, ok := args["job_id"].(string) - if !ok || jobID == "" { return ErrorResult("job_id is required for enable/disable") } job := t.cronService.EnableJob(jobID, enable) - if job == nil { return ErrorResult(fmt.Sprintf("Job %s not found", jobID)) } status := "enabled" - if !enable { status = "disabled" } - return SilentResult(fmt.Sprintf("Cron job '%s' %s", job.Name, status)) } // ExecuteJob executes a cron job through the agent - func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { // Get channel/chatID from job payload - channel := job.Payload.Channel - chatID := job.Payload.To // Default values if not set - if channel == "" { channel = "cli" } - if chatID == "" { chatID = "direct" } // Execute command if present - if job.Payload.Command != "" { args := map[string]any{ "command": job.Payload.Command, } result := t.execTool.Execute(ctx, args) - var output string - if result.IsError { output = fmt.Sprintf("Error executing scheduled command: %s", result.ForLLM) } else { @@ -401,9 +338,7 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { } pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer pubCancel() - t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ Channel: channel, @@ -411,17 +346,13 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { Content: output, }) - return "ok" } // If deliver=true, send message directly without agent processing - if job.Payload.Deliver { pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer pubCancel() - t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ Channel: channel, @@ -429,26 +360,18 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { Content: job.Payload.Message, }) - return "ok" } // For deliver=false, process through agent (for complex tasks) - sessionKey := fmt.Sprintf("cron-%s", job.ID) // Call agent with job's message - response, err := t.executor.ProcessDirectWithChannel( - ctx, - job.Payload.Message, - sessionKey, - channel, - chatID, ) if err != nil { @@ -456,8 +379,6 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { } // Response is automatically sent via MessageBus by AgentLoop - _ = response // Will be sent by AgentLoop - return "ok" } diff --git a/pkg/tools/edit.go b/pkg/tools/edit.go index 7946fd2fa..20d561b52 100644 --- a/pkg/tools/edit.go +++ b/pkg/tools/edit.go @@ -9,9 +9,7 @@ import ( ) // EditFileTool edits a file by replacing old_text with new_text. - // The old_text must exist exactly in the file. - type EditFileTool struct { fs fileSystem } @@ -41,46 +39,39 @@ func (t *EditFileTool) Description() string { func (t *EditFileTool) Parameters() map[string]any { return map[string]any{ "type": "object", - "properties": map[string]any{ "path": map[string]any{ "type": "string", "description": "The file path to edit", }, - "old_text": map[string]any{ "type": "string", "description": "The exact text to find and replace", }, - "new_text": map[string]any{ "type": "string", "description": "The text to replace with", }, }, - "required": []string{"path", "old_text", "new_text"}, } } func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { path, ok := args["path"].(string) - if !ok { return ErrorResult("path is required") } oldText, ok := args["old_text"].(string) - if !ok { return ErrorResult("old_text is required") } newText, ok := args["new_text"].(string) - if !ok { return ErrorResult("new_text is required") } @@ -88,7 +79,6 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe if err := editFile(resolveFS(ctx, t.fs, path), path, oldText, newText); err != nil { return ErrorResult(err.Error()) } - return SilentResult(fmt.Sprintf("File edited: %s", path)) } @@ -119,34 +109,29 @@ func (t *AppendFileTool) Description() string { func (t *AppendFileTool) Parameters() map[string]any { return map[string]any{ "type": "object", - "properties": map[string]any{ "path": map[string]any{ "type": "string", "description": "The file path to append to", }, - "content": map[string]any{ "type": "string", "description": "The content to append", }, }, - "required": []string{"path", "content"}, } } func (t *AppendFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { path, ok := args["path"].(string) - if !ok { return ErrorResult("path is required") } content, ok := args["content"].(string) - if !ok { return ErrorResult("content is required") } @@ -154,14 +139,11 @@ func (t *AppendFileTool) Execute(ctx context.Context, args map[string]any) *Tool if err := appendFile(resolveFS(ctx, t.fs, path), path, content); err != nil { return ErrorResult(err.Error()) } - return SilentResult(fmt.Sprintf("Appended to %s", path)) } // editFile reads the file via sysFs, performs the replacement, and writes back. - // It uses a fileSystem interface, allowing the same logic for both restricted and unrestricted modes. - func editFile(sysFs fileSystem, path, oldText, newText string) error { content, err := sysFs.ReadFile(path) if err != nil { @@ -177,21 +159,17 @@ func editFile(sysFs fileSystem, path, oldText, newText string) error { } // appendFile reads the existing content (if any) via sysFs, appends new content, and writes back. - func appendFile(sysFs fileSystem, path, appendContent string) error { content, err := sysFs.ReadFile(path) - if err != nil && !errors.Is(err, fs.ErrNotExist) { return err } newContent := append(content, []byte(appendContent)...) - return sysFs.WriteFile(path, newContent) } // replaceEditContent handles the core logic of finding and replacing a single occurrence of oldText. - func replaceEditContent(content []byte, oldText, newText string) ([]byte, error) { contentStr := string(content) @@ -200,12 +178,10 @@ func replaceEditContent(content []byte, oldText, newText string) ([]byte, error) } count := strings.Count(contentStr, oldText) - if count > 1 { return nil, fmt.Errorf("old_text appears %d times. Please provide more context to make it unique", count) } newContent := strings.Replace(contentStr, oldText, newText, 1) - return []byte(newContent), nil } diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go index 7b53c5e73..b4e28020a 100644 --- a/pkg/tools/filesystem.go +++ b/pkg/tools/filesystem.go @@ -27,7 +27,6 @@ func validatePath(path, workspace string, restrict bool) (string, error) { } var absPath string - if filepath.IsAbs(path) { absPath = filepath.Clean(path) } else { @@ -43,9 +42,7 @@ func validatePath(path, workspace string, restrict bool) (string, error) { } var resolved string - workspaceReal := absWorkspace - if resolved, err = filepath.EvalSymlinks(absWorkspace); err == nil { workspaceReal = resolved } @@ -56,7 +53,6 @@ func validatePath(path, workspace string, restrict bool) (string, error) { } } else if os.IsNotExist(err) { var parentResolved string - if parentResolved, err = resolveExistingAncestor(filepath.Dir(absPath)); err == nil { if !isWithinWorkspace(parentResolved, workspaceReal) { return "", fmt.Errorf("access denied: symlink resolves outside workspace") @@ -79,7 +75,6 @@ func resolveExistingAncestor(path string) (string, error) { } else if !os.IsNotExist(err) { return "", err } - if filepath.Dir(current) == current { return "", os.ErrNotExist } @@ -88,7 +83,6 @@ func resolveExistingAncestor(path string) (string, error) { func isWithinWorkspace(candidate, workspace string) bool { rel, err := filepath.Rel(filepath.Clean(workspace), filepath.Clean(candidate)) - return err == nil && filepath.IsLocal(rel) } @@ -119,7 +113,6 @@ func (t *ReadFileTool) Description() string { func (t *ReadFileTool) Parameters() map[string]any { return map[string]any{ "type": "object", - "properties": map[string]any{ "path": map[string]any{ "type": "string", @@ -127,14 +120,12 @@ func (t *ReadFileTool) Parameters() map[string]any { "description": "Path to the file to read", }, }, - "required": []string{"path"}, } } func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { path, ok := args["path"].(string) - if !ok { return ErrorResult("path is required") } @@ -174,34 +165,29 @@ func (t *WriteFileTool) Description() string { func (t *WriteFileTool) Parameters() map[string]any { return map[string]any{ "type": "object", - "properties": map[string]any{ "path": map[string]any{ "type": "string", "description": "Path to the file to write", }, - "content": map[string]any{ "type": "string", "description": "Content to write to the file", }, }, - "required": []string{"path", "content"}, } } func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { path, ok := args["path"].(string) - if !ok { return ErrorResult("path is required") } content, ok := args["content"].(string) - if !ok { return ErrorResult("content is required") } @@ -240,7 +226,6 @@ func (t *ListDirTool) Description() string { func (t *ListDirTool) Parameters() map[string]any { return map[string]any{ "type": "object", - "properties": map[string]any{ "path": map[string]any{ "type": "string", @@ -248,14 +233,12 @@ func (t *ListDirTool) Parameters() map[string]any { "description": "Path to list", }, }, - "required": []string{"path"}, } } func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolResult { path, ok := args["path"].(string) - if !ok { path = "." } @@ -264,13 +247,11 @@ func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolRes if err != nil { return ErrorResult(err.Error()) } - return formatDirEntries(entries) } func formatDirEntries(entries []os.DirEntry) *ToolResult { var result strings.Builder - for _, entry := range entries { if entry.IsDir() { result.WriteString("DIR: ") @@ -282,24 +263,18 @@ func formatDirEntries(entries []os.DirEntry) *ToolResult { result.WriteByte('\n') } - return NewToolResult(result.String()) } // fileSystem abstracts reading, writing, and listing files, allowing both - // unrestricted (host filesystem) and sandbox (os.Root) implementations to share the same polymorphic interface. - type fileSystem interface { ReadFile(path string) ([]byte, error) - WriteFile(path string, data []byte) error - ReadDir(path string) ([]os.DirEntry, error) } // hostFs is an unrestricted fileReadWriter that operates directly on the host filesystem. - type hostFs struct{} func (h *hostFs) ReadFile(path string) ([]byte, error) { @@ -308,14 +283,11 @@ func (h *hostFs) ReadFile(path string) ([]byte, error) { if os.IsNotExist(err) { return nil, fmt.Errorf("failed to read file: file not found: %w", err) } - if os.IsPermission(err) { return nil, fmt.Errorf("failed to read file: access denied: %w", err) } - return nil, fmt.Errorf("failed to read file: %w", err) } - return content, nil } @@ -330,14 +302,11 @@ func (h *hostFs) ReadDir(path string) ([]os.DirEntry, error) { func (h *hostFs) WriteFile(path string, data []byte) error { // Use unified atomic write utility with explicit sync for flash storage reliability. - // Using 0o600 (owner read/write only) for secure default permissions. - return fileutil.WriteFileAtomic(path, data, 0o600) } // sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root. - type sandboxFs struct { workspace string } @@ -351,7 +320,6 @@ func (r *sandboxFs) execute(path string, fn func(root *os.Root, relPath string) if err != nil { return fmt.Errorf("failed to open workspace: %w", err) } - defer root.Close() relPath, err := getSafeRelPath(r.workspace, path) @@ -364,37 +332,28 @@ func (r *sandboxFs) execute(path string, fn func(root *os.Root, relPath string) func (r *sandboxFs) ReadFile(path string) ([]byte, error) { var content []byte - err := r.execute(path, func(root *os.Root, relPath string) error { fileContent, err := root.ReadFile(relPath) if err != nil { if os.IsNotExist(err) { return fmt.Errorf("failed to read file: file not found: %w", err) } - // os.Root returns "escapes from parent" for paths outside the root - if os.IsPermission(err) || strings.Contains(err.Error(), "escapes from parent") || - strings.Contains(err.Error(), "permission denied") { return fmt.Errorf("failed to read file: access denied: %w", err) } - return fmt.Errorf("failed to read file: %w", err) } - content = fileContent - return nil }) - return content, err } func (r *sandboxFs) WriteFile(path string, data []byte) error { return r.execute(path, func(root *os.Root, relPath string) error { dir := filepath.Dir(relPath) - if dir != "." && dir != "/" { if err := root.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("failed to create parent directories: %w", err) @@ -402,55 +361,42 @@ func (r *sandboxFs) WriteFile(path string, data []byte) error { } // Use atomic write pattern with explicit sync for flash storage reliability. - // Using 0o600 (owner read/write only) for secure default permissions. - tmpRelPath := fmt.Sprintf(".tmp-%d-%d", os.Getpid(), time.Now().UnixNano()) tmpFile, err := root.OpenFile(tmpRelPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if err != nil { root.Remove(tmpRelPath) - return fmt.Errorf("failed to open temp file: %w", err) } if _, err := tmpFile.Write(data); err != nil { tmpFile.Close() - root.Remove(tmpRelPath) - return fmt.Errorf("failed to write temp file: %w", err) } // CRITICAL: Force sync to storage medium before rename. - // This ensures data is physically written to disk, not just cached. - if err := tmpFile.Sync(); err != nil { tmpFile.Close() - root.Remove(tmpRelPath) - return fmt.Errorf("failed to sync temp file: %w", err) } if err := tmpFile.Close(); err != nil { root.Remove(tmpRelPath) - return fmt.Errorf("failed to close temp file: %w", err) } if err := root.Rename(tmpRelPath, relPath); err != nil { root.Remove(tmpRelPath) - return fmt.Errorf("failed to rename temp file over target: %w", err) } // Sync directory to ensure rename is durable - if dirFile, err := root.Open("."); err == nil { _ = dirFile.Sync() - dirFile.Close() } @@ -460,33 +406,26 @@ func (r *sandboxFs) WriteFile(path string, data []byte) error { func (r *sandboxFs) ReadDir(path string) ([]os.DirEntry, error) { var entries []os.DirEntry - err := r.execute(path, func(root *os.Root, relPath string) error { dirEntries, err := fs.ReadDir(root.FS(), relPath) if err != nil { return err } - entries = dirEntries - return nil }) - return entries, err } // Helper to get a safe relative path for os.Root usage - func getSafeRelPath(workspace, path string) (string, error) { if workspace == "" { return "", fmt.Errorf("workspace is not defined") } rel := filepath.Clean(path) - if filepath.IsAbs(rel) { var err error - rel, err = filepath.Rel(workspace, rel) if err != nil { return "", fmt.Errorf("failed to calculate relative path: %w", err) diff --git a/pkg/tools/i2c.go b/pkg/tools/i2c.go index d3d7ebe10..436285247 100644 --- a/pkg/tools/i2c.go +++ b/pkg/tools/i2c.go @@ -10,7 +10,6 @@ import ( ) // I2CTool provides I2C bus interaction for reading sensors and controlling peripherals. - type I2CTool struct{} func NewI2CTool() *I2CTool { @@ -28,7 +27,6 @@ func (t *I2CTool) Description() string { func (t *I2CTool) Parameters() map[string]any { return map[string]any{ "type": "object", - "properties": map[string]any{ "action": map[string]any{ "type": "string", @@ -37,25 +35,21 @@ func (t *I2CTool) Parameters() map[string]any { "description": "Action to perform: detect (list available I2C buses), scan (find devices on a bus), read (read bytes from a device), write (send bytes to a device)", }, - "bus": map[string]any{ "type": "string", "description": "I2C bus number (e.g. \"1\" for /dev/i2c-1). Required for scan/read/write.", }, - "address": map[string]any{ "type": "integer", "description": "7-bit I2C device address (0x03-0x77). Required for read/write.", }, - "register": map[string]any{ "type": "integer", "description": "Register address to read from or write to. If set, sends register byte before read/write.", }, - "data": map[string]any{ "type": "array", @@ -63,20 +57,17 @@ func (t *I2CTool) Parameters() map[string]any { "description": "Bytes to write (0-255 each). Required for write action.", }, - "length": map[string]any{ "type": "integer", "description": "Number of bytes to read (1-256). Default: 1. Used with read action.", }, - "confirm": map[string]any{ "type": "boolean", "description": "Must be true for write operations. Safety guard to prevent accidental writes.", }, }, - "required": []string{"action"}, } } @@ -87,36 +78,25 @@ func (t *I2CTool) Execute(ctx context.Context, args map[string]any) *ToolResult } action, ok := args["action"].(string) - if !ok { return ErrorResult("action is required") } switch action { case "detect": - return t.detect() - case "scan": - return t.scan(args) - case "read": - return t.readDevice(args) - case "write": - return t.writeDevice(args) - default: - return ErrorResult(fmt.Sprintf("unknown action: %s (valid: detect, scan, read, write)", action)) } } // detect lists available I2C buses by globbing /dev/i2c-* - func (t *I2CTool) detect() *ToolResult { matches, err := filepath.Glob("/dev/i2c-*") if err != nil { @@ -136,9 +116,7 @@ func (t *I2CTool) detect() *ToolResult { } buses := make([]busInfo, 0, len(matches)) - re := regexp.MustCompile(`/dev/i2c-(\d+)`) - for _, m := range matches { if sub := re.FindStringSubmatch(m); sub != nil { buses = append(buses, busInfo{Path: m, Bus: sub[1]}) @@ -146,62 +124,44 @@ func (t *I2CTool) detect() *ToolResult { } result, _ := json.MarshalIndent(buses, "", " ") - return SilentResult(fmt.Sprintf("Found %d I2C bus(es):\n%s", len(buses), string(result))) } // Helper functions for I2C operations (used by platform-specific implementations) // isValidBusID checks that a bus identifier is a simple number (prevents path injection) - // - //nolint:unused // Used by i2c_linux.go - func isValidBusID(id string) bool { matched, _ := regexp.MatchString(`^\d+$`, id) - return matched } // parseI2CAddress extracts and validates an I2C address from args - // - //nolint:unused // Used by i2c_linux.go - func parseI2CAddress(args map[string]any) (int, *ToolResult) { addrFloat, ok := args["address"].(float64) - if !ok { return 0, ErrorResult("address is required (e.g. 0x38 for AHT20)") } - addr := int(addrFloat) - if addr < 0x03 || addr > 0x77 { return 0, ErrorResult("address must be in valid 7-bit range (0x03-0x77)") } - return addr, nil } // parseI2CBus extracts and validates an I2C bus from args - // - //nolint:unused // Used by i2c_linux.go - func parseI2CBus(args map[string]any) (string, *ToolResult) { bus, ok := args["bus"].(string) - if !ok || bus == "" { return "", ErrorResult("bus is required (e.g. \"1\" for /dev/i2c-1)") } - if !isValidBusID(bus) { return "", ErrorResult("invalid bus identifier: must be a number (e.g. \"1\")") } - return bus, nil } diff --git a/pkg/tools/i2c_linux.go b/pkg/tools/i2c_linux.go index aa0c06eb7..483c5a15f 100644 --- a/pkg/tools/i2c_linux.go +++ b/pkg/tools/i2c_linux.go @@ -112,7 +112,6 @@ func (t *I2CTool) scan(args map[string]any) *ToolResult { } var found []deviceEntry - // Scan 0x08-0x77, skipping I2C reserved addresses 0x00-0x07 for addr := 0x08; addr <= 0x77; addr++ { // Set slave address — EBUSY means a kernel driver owns this address @@ -142,7 +141,6 @@ func (t *I2CTool) scan(args map[string]any) *ToolResult { "devices": found, "count": len(found), }, "", " ") - return SilentResult(fmt.Sprintf("Scan of %s:\n%s", devPath, string(result))) } @@ -213,7 +211,6 @@ func (t *I2CTool) readDevice(args map[string]any) *ToolResult { "hex": hexBytes, "length": n, }, "", " ") - return SilentResult(string(result)) } diff --git a/pkg/tools/message.go b/pkg/tools/message.go index 7efebd5ad..b86bed0e0 100644 --- a/pkg/tools/message.go +++ b/pkg/tools/message.go @@ -32,27 +32,23 @@ func (t *MessageTool) Description() string { func (t *MessageTool) Parameters() map[string]any { return map[string]any{ "type": "object", - "properties": map[string]any{ "content": map[string]any{ "type": "string", "description": "The message content to send", }, - "channel": map[string]any{ "type": "string", "description": "Optional: target channel (telegram, whatsapp, etc.)", }, - "chat_id": map[string]any{ "type": "string", "description": "Optional: target chat/user ID", }, }, - "required": []string{"content"}, } } @@ -66,7 +62,6 @@ func (t *MessageTool) SetContext(channel, chatID string) { } // HasSentInRound returns true if the message tool sent a message during the current round. - func (t *MessageTool) HasSentInRound() bool { return t.sentInRound } @@ -77,19 +72,16 @@ func (t *MessageTool) SetSendCallback(callback SendCallback) { func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolResult { content, ok := args["content"].(string) - if !ok { return &ToolResult{ForLLM: "content is required", IsError: true} } channel, _ := args["channel"].(string) - chatID, _ := args["chat_id"].(string) if channel == "" { channel = t.defaultChannel } - if chatID == "" { chatID = t.defaultChatID } @@ -115,10 +107,8 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes t.sentInRound = true // Silent: user already received the message directly - return &ToolResult{ ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID), - Silent: true, } } diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index 45672c2cb..b8cae5c8b 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -291,27 +291,22 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf if config != nil { execConfig := config.Tools.Exec - enableDenyPatterns := execConfig.EnableDenyPatterns if enableDenyPatterns { denyPatterns = append(denyPatterns, defaultDenyPatterns...) - if len(execConfig.CustomDenyPatterns) > 0 { fmt.Printf("Using custom deny patterns: %v\n", execConfig.CustomDenyPatterns) - for _, pattern := range execConfig.CustomDenyPatterns { re, err := regexp.Compile(pattern) if err != nil { return nil, fmt.Errorf("invalid custom deny pattern %q: %w", pattern, err) } - denyPatterns = append(denyPatterns, re) } } } else { // If deny patterns are disabled, we won't add any patterns, allowing all commands. - fmt.Println("Warning: deny patterns are disabled. All commands will be allowed.") } } else { @@ -350,14 +345,12 @@ func (t *ExecTool) Description() string { func (t *ExecTool) Parameters() map[string]any { return map[string]any{ "type": "object", - "properties": map[string]any{ "command": map[string]any{ "type": "string", "description": "The shell command to execute", }, - "working_dir": map[string]any{ "type": "string", @@ -420,7 +413,6 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult if err != nil { return ErrorResult("Command blocked by safety guard (" + err.Error() + ")") } - cwd = resolvedWD } else { cwd = wd @@ -429,7 +421,6 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult if cwd == "" { wd, err := os.Getwd() - if err == nil { cwd = wd } @@ -450,27 +441,21 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult func (t *ExecTool) executeSync(ctx context.Context, command, cwd string) *ToolResult { // timeout == 0 means no timeout - var cmdCtx context.Context - var cancel context.CancelFunc - if t.timeout > 0 { cmdCtx, cancel = context.WithTimeout(ctx, t.timeout) } else { cmdCtx, cancel = context.WithCancel(ctx) } - defer cancel() var cmd *exec.Cmd - if runtime.GOOS == "windows" { cmd = exec.CommandContext(cmdCtx, "powershell", "-NoProfile", "-NonInteractive", "-Command", command) } else { cmd = exec.CommandContext(cmdCtx, "sh", "-c", command) } - if cwd != "" { cmd.Dir = cwd } @@ -478,9 +463,7 @@ func (t *ExecTool) executeSync(ctx context.Context, command, cwd string) *ToolRe prepareCommandForTermination(cmd) var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr if err := cmd.Start(); err != nil { @@ -488,29 +471,21 @@ func (t *ExecTool) executeSync(ctx context.Context, command, cwd string) *ToolRe } done := make(chan error, 1) - go func() { done <- cmd.Wait() }() var err error - select { case err = <-done: - case <-cmdCtx.Done(): - _ = terminateProcessTree(cmd) - select { case err = <-done: - case <-time.After(2 * time.Second): - if cmd.Process != nil { _ = cmd.Process.Kill() } - err = <-done } } @@ -528,12 +503,10 @@ func (t *ExecTool) executeSync(ctx context.Context, command, cwd string) *ToolRe if err != nil { if errors.Is(cmdCtx.Err(), context.DeadlineExceeded) { msg := fmt.Sprintf("Command timed out after %v", t.timeout) - return &ToolResult{ ForLLM: msg, ForUser: msg, - IsError: true, } } @@ -548,7 +521,6 @@ func (t *ExecTool) executeSync(ctx context.Context, command, cwd string) *ToolRe } maxLen := 10000 - if len(output) > maxLen { output = output[:maxLen] + fmt.Sprintf("\n... (truncated, %d more chars)", len(output)-maxLen) } @@ -558,7 +530,6 @@ func (t *ExecTool) executeSync(ctx context.Context, command, cwd string) *ToolRe ForLLM: output, ForUser: output, - IsError: true, } } @@ -567,7 +538,6 @@ func (t *ExecTool) executeSync(ctx context.Context, command, cwd string) *ToolRe ForLLM: output, ForUser: output, - IsError: false, } } @@ -971,7 +941,6 @@ func (t *ExecTool) Shutdown() { func (t *ExecTool) guardCommand(command, cwd string) string { cmd := strings.TrimSpace(command) - lower := strings.ToLower(cmd) for _, pattern := range t.denyPatterns { diff --git a/pkg/tools/shell_process_unix.go b/pkg/tools/shell_process_unix.go index 5d1f52951..d9dc5b92e 100644 --- a/pkg/tools/shell_process_unix.go +++ b/pkg/tools/shell_process_unix.go @@ -35,7 +35,6 @@ func terminateProcessTree(cmd *exec.Cmd) error { // Fallback kill on the shell process itself. _ = cmd.Process.Kill() - return nil } diff --git a/pkg/tools/shell_process_windows.go b/pkg/tools/shell_process_windows.go index fbd28b0fa..fe23b5c96 100644 --- a/pkg/tools/shell_process_windows.go +++ b/pkg/tools/shell_process_windows.go @@ -17,14 +17,11 @@ func terminateProcessTree(cmd *exec.Cmd) error { } pid := cmd.Process.Pid - if pid <= 0 { return nil } _ = exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(pid)).Run() - _ = cmd.Process.Kill() - return nil } diff --git a/pkg/tools/skills_install.go b/pkg/tools/skills_install.go index 89c5dcfe0..f4bd62777 100644 --- a/pkg/tools/skills_install.go +++ b/pkg/tools/skills_install.go @@ -16,11 +16,8 @@ import ( ) // InstallSkillTool allows the LLM agent to install skills from registries. - // It shares the same RegistryManager that FindSkillsTool uses, - // so all registries configured in config are available for installation. - type InstallSkillTool struct { registryMgr *skills.RegistryManager @@ -30,11 +27,8 @@ type InstallSkillTool struct { } // NewInstallSkillTool creates a new InstallSkillTool. - // registryMgr is the shared registry manager (same instance as FindSkillsTool). - // workspace is the root workspace directory; skills install to {workspace}/skills/{slug}/. - func NewInstallSkillTool(registryMgr *skills.RegistryManager, workspace string) *InstallSkillTool { return &InstallSkillTool{ registryMgr: registryMgr, @@ -56,110 +50,86 @@ func (t *InstallSkillTool) Description() string { func (t *InstallSkillTool) Parameters() map[string]any { return map[string]any{ "type": "object", - "properties": map[string]any{ "slug": map[string]any{ "type": "string", "description": "The unique slug of the skill to install (e.g., 'github', 'docker-compose')", }, - "version": map[string]any{ "type": "string", "description": "Specific version to install (optional, defaults to latest)", }, - "registry": map[string]any{ "type": "string", "description": "Registry to install from (required, e.g., 'clawhub')", }, - "force": map[string]any{ "type": "boolean", "description": "Force reinstall if skill already exists (default false)", }, }, - "required": []string{"slug", "registry"}, } } func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *ToolResult { // Install lock to prevent concurrent directory operations. - // Ideally this should be done at a `slug` level, currently, its at a `workspace` level. - t.mu.Lock() - defer t.mu.Unlock() // Validate slug - slug, _ := args["slug"].(string) - if err := utils.ValidateSkillIdentifier(slug); err != nil { return ErrorResult(fmt.Sprintf("invalid slug %q: error: %s", slug, err.Error())) } // Validate registry - registryName, _ := args["registry"].(string) - if err := utils.ValidateSkillIdentifier(registryName); err != nil { return ErrorResult(fmt.Sprintf("invalid registry %q: error: %s", registryName, err.Error())) } version, _ := args["version"].(string) - force, _ := args["force"].(bool) // Check if already installed. - skillsDir := filepath.Join(t.workspace, "skills") - targetDir := filepath.Join(skillsDir, slug) if !force { if _, err := os.Stat(targetDir); err == nil { return ErrorResult( - fmt.Sprintf("skill %q already installed at %s. Use force=true to reinstall.", slug, targetDir), ) } } else { // Force: remove existing if present. - os.RemoveAll(targetDir) } // Resolve which registry to use. - registry := t.registryMgr.GetRegistry(registryName) - if registry == nil { return ErrorResult(fmt.Sprintf("registry %q not found", registryName)) } // Ensure skills directory exists. - if err := os.MkdirAll(skillsDir, 0o755); err != nil { return ErrorResult(fmt.Sprintf("failed to create skills directory: %v", err)) } // Download and install (handles metadata, version resolution, extraction). - result, err := registry.DownloadAndInstall(ctx, slug, version, targetDir) if err != nil { // Clean up partial install. - rmErr := os.RemoveAll(targetDir) - if rmErr != nil { logger.ErrorCF("tool", "Failed to remove partial install", - map[string]any{ "tool": "install_skill", @@ -168,18 +138,14 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To "error": rmErr.Error(), }) } - return ErrorResult(fmt.Sprintf("failed to install %q: %v", slug, err)) } // Moderation: block malware. - if result.IsMalwareBlocked { rmErr := os.RemoveAll(targetDir) - if rmErr != nil { logger.ErrorCF("tool", "Failed to remove partial install", - map[string]any{ "tool": "install_skill", @@ -188,15 +154,12 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To "error": rmErr.Error(), }) } - return ErrorResult(fmt.Sprintf("skill %q is flagged as malicious and cannot be installed", slug)) } // Write origin metadata. - if err := writeOriginMeta(targetDir, registry.Name(), slug, result.Version); err != nil { logger.ErrorCF("tool", "Failed to write origin metadata", - map[string]any{ "tool": "install_skill", @@ -210,33 +173,26 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To "version": result.Version, }) - _ = err } // Build result with moderation warning if suspicious. - var output string - if result.IsSuspicious { output = fmt.Sprintf("⚠️ Warning: skill %q is flagged as suspicious (may contain risky patterns).\n\n", slug) } - output += fmt.Sprintf("Successfully installed skill %q v%s from %s registry.\nLocation: %s\n", - slug, result.Version, registry.Name(), targetDir) if result.Summary != "" { output += fmt.Sprintf("Description: %s\n", result.Summary) } - output += "\nThe skill is now available and can be loaded in the current session." return SilentResult(output) } // originMeta tracks which registry a skill was installed from. - type originMeta struct { Version int `json:"version"` @@ -268,6 +224,5 @@ func writeOriginMeta(targetDir, registryName, slug, version string) error { } // Use unified atomic write utility with explicit sync for flash storage reliability. - return fileutil.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600) } diff --git a/pkg/tools/skills_search.go b/pkg/tools/skills_search.go index 48baff7b6..66fafd6ee 100644 --- a/pkg/tools/skills_search.go +++ b/pkg/tools/skills_search.go @@ -9,7 +9,6 @@ import ( ) // FindSkillsTool allows the LLM agent to search for installable skills from registries. - type FindSkillsTool struct { registryMgr *skills.RegistryManager @@ -17,11 +16,8 @@ type FindSkillsTool struct { } // NewFindSkillsTool creates a new FindSkillsTool. - // registryMgr is the shared registry manager (built from config in createToolRegistry). - // cache is the search cache for deduplicating similar queries. - func NewFindSkillsTool(registryMgr *skills.RegistryManager, cache *skills.SearchCache) *FindSkillsTool { return &FindSkillsTool{ registryMgr: registryMgr, @@ -41,14 +37,12 @@ func (t *FindSkillsTool) Description() string { func (t *FindSkillsTool) Parameters() map[string]any { return map[string]any{ "type": "object", - "properties": map[string]any{ "query": map[string]any{ "type": "string", "description": "Search query describing the desired skill capability (e.g., 'github integration', 'database management')", }, - "limit": map[string]any{ "type": "integer", @@ -59,32 +53,26 @@ func (t *FindSkillsTool) Parameters() map[string]any { "maximum": 20.0, }, }, - "required": []string{"query"}, } } func (t *FindSkillsTool) Execute(ctx context.Context, args map[string]any) *ToolResult { query, ok := args["query"].(string) - query = strings.ToLower(strings.TrimSpace(query)) - if !ok || query == "" { return ErrorResult("query is required and must be a non-empty string") } limit := 5 - if l, ok := args["limit"].(float64); ok { li := int(l) - if li >= 1 && li <= 20 { limit = li } } // Check cache first. - if t.cache != nil { if cached, hit := t.cache.Get(query); hit { return SilentResult(formatSearchResults(query, cached, true)) @@ -92,14 +80,12 @@ func (t *FindSkillsTool) Execute(ctx context.Context, args map[string]any) *Tool } // Search all registries. - results, err := t.registryMgr.SearchAll(ctx, query, limit) if err != nil { return ErrorResult(fmt.Sprintf("skill search failed: %v", err)) } // Cache the results. - if t.cache != nil && len(results) > 0 { t.cache.Put(query, results) } @@ -113,36 +99,27 @@ func formatSearchResults(query string, results []skills.SearchResult, cached boo } var sb strings.Builder - source := "" - if cached { source = " (cached)" } - sb.WriteString(fmt.Sprintf("Found %d skills for %q%s:\n\n", len(results), query, source)) for i, r := range results { sb.WriteString(fmt.Sprintf("%d. **%s**", i+1, r.Slug)) - if r.Version != "" { sb.WriteString(fmt.Sprintf(" v%s", r.Version)) } - sb.WriteString(fmt.Sprintf(" (score: %.3f, registry: %s)\n", r.Score, r.RegistryName)) - if r.DisplayName != "" && r.DisplayName != r.Slug { sb.WriteString(fmt.Sprintf(" Name: %s\n", r.DisplayName)) } - if r.Summary != "" { sb.WriteString(fmt.Sprintf(" %s\n", r.Summary)) } - sb.WriteString("\n") } sb.WriteString("Use install_skill with the slug to install a skill.") - return sb.String() } diff --git a/pkg/tools/spawn.go b/pkg/tools/spawn.go index af19ca86d..c31c4361f 100644 --- a/pkg/tools/spawn.go +++ b/pkg/tools/spawn.go @@ -45,20 +45,17 @@ func (t *SpawnTool) Description() string { func (t *SpawnTool) Parameters() map[string]any { return map[string]any{ "type": "object", - "properties": map[string]any{ "task": map[string]any{ "type": "string", "description": "The task for subagent to complete", }, - "label": map[string]any{ "type": "string", "description": "Optional short label for the task (for display)", }, - "agent_id": map[string]any{ "type": "string", @@ -73,7 +70,6 @@ func (t *SpawnTool) Parameters() map[string]any { "description": "Optional capability tier: scout (explore), analyst (analyze), coder (code), worker (build), coordinator (orchestrate)", }, }, - "required": []string{"task"}, } } @@ -90,7 +86,6 @@ func (t *SpawnTool) SetAllowlistChecker(check func(targetAgentID string) bool) { func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResult { task, ok := args["task"].(string) - if !ok || strings.TrimSpace(task) == "" { return ErrorResult( @@ -101,7 +96,6 @@ func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResul } label, _ := args["label"].(string) - agentID, _ := args["agent_id"].(string) preset, _ := args["preset"].(string) @@ -141,6 +135,5 @@ func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResul } // Return AsyncResult since the task runs in background - return AsyncResult(result) } diff --git a/pkg/tools/spi.go b/pkg/tools/spi.go index b8c9b3d74..4618ea424 100644 --- a/pkg/tools/spi.go +++ b/pkg/tools/spi.go @@ -10,7 +10,6 @@ import ( ) // SPITool provides SPI bus interaction for high-speed peripheral communication. - type SPITool struct{} func NewSPITool() *SPITool { @@ -28,7 +27,6 @@ func (t *SPITool) Description() string { func (t *SPITool) Parameters() map[string]any { return map[string]any{ "type": "object", - "properties": map[string]any{ "action": map[string]any{ "type": "string", @@ -37,31 +35,26 @@ func (t *SPITool) Parameters() map[string]any { "description": "Action to perform: list (find available SPI devices), transfer (full-duplex send/receive), read (receive bytes by sending zeros)", }, - "device": map[string]any{ "type": "string", "description": "SPI device identifier (e.g. \"2.0\" for /dev/spidev2.0). Required for transfer/read.", }, - "speed": map[string]any{ "type": "integer", "description": "SPI clock speed in Hz. Default: 1000000 (1 MHz).", }, - "mode": map[string]any{ "type": "integer", "description": "SPI mode (0-3). Default: 0. Mode sets CPOL and CPHA: 0=0,0 1=0,1 2=1,0 3=1,1.", }, - "bits": map[string]any{ "type": "integer", "description": "Bits per word. Default: 8.", }, - "data": map[string]any{ "type": "array", @@ -69,20 +62,17 @@ func (t *SPITool) Parameters() map[string]any { "description": "Bytes to send (0-255 each). Required for transfer action.", }, - "length": map[string]any{ "type": "integer", "description": "Number of bytes to read (1-4096). Required for read action.", }, - "confirm": map[string]any{ "type": "boolean", "description": "Must be true for transfer operations. Safety guard to prevent accidental writes.", }, }, - "required": []string{"action"}, } } @@ -93,32 +83,23 @@ func (t *SPITool) Execute(ctx context.Context, args map[string]any) *ToolResult } action, ok := args["action"].(string) - if !ok { return ErrorResult("action is required") } switch action { case "list": - return t.list() - case "transfer": - return t.transfer(args) - case "read": - return t.readDevice(args) - default: - return ErrorResult(fmt.Sprintf("unknown action: %s (valid: list, transfer, read)", action)) } } // list finds available SPI devices by globbing /dev/spidev* - func (t *SPITool) list() *ToolResult { matches, err := filepath.Glob("/dev/spidev*") if err != nil { @@ -138,9 +119,7 @@ func (t *SPITool) list() *ToolResult { } devices := make([]devInfo, 0, len(matches)) - re := regexp.MustCompile(`/dev/spidev(\d+\.\d+)`) - for _, m := range matches { if sub := re.FindStringSubmatch(m); sub != nil { devices = append(devices, devInfo{Path: m, Device: sub[1]}) @@ -148,58 +127,45 @@ func (t *SPITool) list() *ToolResult { } result, _ := json.MarshalIndent(devices, "", " ") - return SilentResult(fmt.Sprintf("Found %d SPI device(s):\n%s", len(devices), string(result))) } // Helper function for SPI operations (used by platform-specific implementations) // parseSPIArgs extracts and validates common SPI parameters - // - //nolint:unused // Used by spi_linux.go - func parseSPIArgs(args map[string]any) (device string, speed uint32, mode uint8, bits uint8, errMsg string) { dev, ok := args["device"].(string) - if !ok || dev == "" { return "", 0, 0, 0, "device is required (e.g. \"2.0\" for /dev/spidev2.0)" } - matched, _ := regexp.MatchString(`^\d+\.\d+$`, dev) - if !matched { return "", 0, 0, 0, "invalid device identifier: must be in format \"X.Y\" (e.g. \"2.0\")" } speed = 1000000 // default 1 MHz - if s, ok := args["speed"].(float64); ok { if s < 1 || s > 125000000 { return "", 0, 0, 0, "speed must be between 1 Hz and 125 MHz" } - speed = uint32(s) } mode = 0 - if m, ok := args["mode"].(float64); ok { if int(m) < 0 || int(m) > 3 { return "", 0, 0, 0, "mode must be 0-3" } - mode = uint8(m) } bits = 8 - if b, ok := args["bits"].(float64); ok { if int(b) < 1 || int(b) > 32 { return "", 0, 0, 0, "bits must be between 1 and 32" } - bits = uint8(b) } diff --git a/pkg/tools/spi_linux.go b/pkg/tools/spi_linux.go index 450407d0f..9a3ae8448 100644 --- a/pkg/tools/spi_linux.go +++ b/pkg/tools/spi_linux.go @@ -140,7 +140,6 @@ func (t *SPITool) transfer(args map[string]any) *ToolResult { "received": intBytes, "hex": hexBytes, }, "", " ") - return SilentResult(string(result)) } @@ -196,6 +195,5 @@ func (t *SPITool) readDevice(args map[string]any) *ToolResult { "hex": hexBytes, "length": len(rxBuf), }, "", " ") - return SilentResult(string(result)) } diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index 1926e2eb7..897609991 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -174,7 +174,6 @@ type SubagentManager struct { func NewSubagentManager( provider providers.LLMProvider, - defaultModel, workspace string, bus *bus.MessageBus, @@ -211,30 +210,20 @@ func NewSubagentManager( } // SetLLMOptions sets max tokens and temperature for subagent LLM calls. - func (sm *SubagentManager) SetLLMOptions(maxTokens int, temperature float64) { sm.mu.Lock() - defer sm.mu.Unlock() - sm.maxTokens = maxTokens - sm.hasMaxTokens = true - sm.temperature = temperature - sm.hasTemperature = true } // SetTools sets the tool registry for subagent execution. - // If not set, subagent will have access to the provided tools. - func (sm *SubagentManager) SetTools(tools *ToolRegistry) { sm.mu.Lock() - defer sm.mu.Unlock() - sm.tools = tools } @@ -251,12 +240,9 @@ func (sm *SubagentManager) SetSessionRecorder(r SessionRecorder, conductorSessio } // RegisterTool registers a tool for subagent execution. - func (sm *SubagentManager) RegisterTool(tool Tool) { sm.mu.Lock() - defer sm.mu.Unlock() - sm.tools.Register(tool) } @@ -268,11 +254,9 @@ func (sm *SubagentManager) Spawn( callback AsyncCallback, ) (string, error) { sm.mu.Lock() - defer sm.mu.Unlock() taskID := fmt.Sprintf("subagent-%d", sm.nextID) - sm.nextID++ subagentTask := &SubagentTask{ @@ -338,7 +322,6 @@ func (sm *SubagentManager) Spawn( if label != "" { return fmt.Sprintf("Spawned subagent '%s' for task: %s", label, task), nil } - return fmt.Sprintf("Spawned subagent for task: %s", task), nil } @@ -447,9 +430,7 @@ func (sm *SubagentManager) finishTask( callback AsyncCallback, ) { sm.mu.Lock() - var result *ToolResult - defer func() { sm.mu.Unlock() @@ -460,14 +441,12 @@ func (sm *SubagentManager) finishTask( if err != nil { task.Status = "failed" - task.Result = fmt.Sprintf("Error: %v", err) gcReason := "failed" if ctx.Err() != nil { task.Status = "canceled" - task.Result = "Task canceled during execution" gcReason = "canceled" @@ -492,7 +471,6 @@ func (sm *SubagentManager) finishTask( } } else { task.Status = "completed" - task.Result = loopResult.Content task.CompletedAt = time.Now().UnixMilli() @@ -523,14 +501,12 @@ func (sm *SubagentManager) finishTask( "Subagent '%s' completed (iterations: %d, tool calls: %d): %s", task.Label, - loopResult.Iterations, loopResult.ToolCalls, loopResult.Content, ), - ForUser: loopResult.Content, } } @@ -1006,34 +982,25 @@ func (sm *SubagentManager) CancelTask(taskID string) { func (sm *SubagentManager) GetTask(taskID string) (*SubagentTask, bool) { sm.mu.RLock() - defer sm.mu.RUnlock() - task, ok := sm.tasks[taskID] - return task, ok } func (sm *SubagentManager) ListTasks() []*SubagentTask { sm.mu.RLock() - defer sm.mu.RUnlock() tasks := make([]*SubagentTask, 0, len(sm.tasks)) - for _, task := range sm.tasks { tasks = append(tasks, task) } - return tasks } // SubagentTool executes a subagent task synchronously and returns the result. - // Unlike SpawnTool which runs tasks asynchronously, SubagentTool waits for completion - // and returns the result directly in the ToolResult. - type SubagentTool struct { manager *SubagentManager @@ -1063,21 +1030,18 @@ func (t *SubagentTool) Description() string { func (t *SubagentTool) Parameters() map[string]any { return map[string]any{ "type": "object", - "properties": map[string]any{ "task": map[string]any{ "type": "string", "description": "The task for subagent to complete", }, - "label": map[string]any{ "type": "string", "description": "Optional short label for the task (for display)", }, }, - "required": []string{"task"}, } } @@ -1090,7 +1054,6 @@ func (t *SubagentTool) SetContext(channel, chatID string) { func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolResult { task, ok := args["task"].(string) - if !ok { return ErrorResult( @@ -1108,14 +1071,12 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe } // Build messages for subagent - messages := []providers.Message{ { Role: "system", Content: "You are a subagent. Complete the given task independently and provide a clear, concise result.", }, - { Role: "user", @@ -1124,34 +1085,22 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe } // Use RunToolLoop to execute with tools (same as async SpawnTool) - sm := t.manager - sm.mu.RLock() - tools := sm.tools - maxIter := sm.maxIterations - maxTokens := sm.maxTokens - temperature := sm.temperature - hasMaxTokens := sm.hasMaxTokens - hasTemperature := sm.hasTemperature - sm.mu.RUnlock() var llmOptions map[string]any - if hasMaxTokens || hasTemperature { llmOptions = map[string]any{} - if hasMaxTokens { llmOptions["max_tokens"] = maxTokens } - if hasTemperature { llmOptions["temperature"] = temperature } @@ -1173,19 +1122,14 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe } // ForUser: Brief summary for user (truncated if too long) - userContent := loopResult.Content - maxUserLen := 500 - if len(userContent) > maxUserLen { userContent = userContent[:maxUserLen] + "..." } // ForLLM: Full execution details - labelStr := label - if labelStr == "" { labelStr = "(unnamed)" } diff --git a/pkg/tools/toolloop.go b/pkg/tools/toolloop.go index f463ceaf7..59d557b75 100644 --- a/pkg/tools/toolloop.go +++ b/pkg/tools/toolloop.go @@ -1,11 +1,7 @@ // PicoClaw - Ultra-lightweight personal AI agent - // Inspired by and based on nanobot: https://github.com/HKUDS/nanobot - // License: MIT - // - // Copyright (c) 2026 PicoClaw contributors package tools @@ -22,7 +18,6 @@ import ( ) // ToolLoopConfig configures the tool execution loop. - type ToolLoopConfig struct { Provider providers.LLMProvider @@ -48,7 +43,6 @@ type ToolLoopConfig struct { } // ToolLoopResult contains the result of running the tool loop. - type ToolLoopResult struct { Content string @@ -60,16 +54,11 @@ type ToolLoopResult struct { } // RunToolLoop executes the LLM + tool call iteration loop. - // This is the core agent logic that can be reused by both main agent and subagents. - func RunToolLoop( ctx context.Context, - config ToolLoopConfig, - messages []providers.Message, - channel, chatID string, ) (*ToolLoopResult, error) { reporter := config.Reporter @@ -90,7 +79,6 @@ func RunToolLoop( iteration++ logger.DebugCF("toolloop", "LLM iteration", - map[string]any{ "iteration": iteration, @@ -98,17 +86,13 @@ func RunToolLoop( }) // 1. Build tool definitions - var providerToolDefs []providers.ToolDefinition - if config.Tools != nil { providerToolDefs = config.Tools.ToProviderDefs() } // 2. Set default LLM options - llmOpts := config.LLMOptions - if llmOpts == nil { llmOpts = map[string]any{} } @@ -120,48 +104,37 @@ func RunToolLoop( response, err := config.Provider.Chat(ctx, messages, providerToolDefs, config.Model, llmOpts) if err != nil { logger.ErrorCF("toolloop", "LLM call failed", - map[string]any{ "iteration": iteration, "error": err.Error(), }) - return nil, fmt.Errorf("LLM call failed: %w", err) } // 4. If no tool calls, we're done - if len(response.ToolCalls) == 0 { finalContent = response.Content - logger.InfoCF("toolloop", "LLM response without tool calls (direct answer)", - map[string]any{ "iteration": iteration, "content_chars": len(finalContent), }) - break } normalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls)) - for _, tc := range response.ToolCalls { normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc)) } // 5. Log tool calls - toolNames := make([]string, 0, len(normalizedToolCalls)) - for _, tc := range normalizedToolCalls { toolNames = append(toolNames, tc.Name) } - logger.InfoCF("toolloop", "LLM requested tool calls", - map[string]any{ "tools": toolNames, @@ -171,13 +144,11 @@ func RunToolLoop( }) // 6. Build assistant message with tool calls - assistantMsg := providers.Message{ Role: "assistant", Content: response.Content, } - for _, tc := range normalizedToolCalls { assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{ ID: tc.ID, @@ -187,7 +158,6 @@ func RunToolLoop( Name: tc.Name, Arguments: tc.Arguments, - Function: &providers.FunctionCall{ Name: tc.Name, @@ -195,7 +165,6 @@ func RunToolLoop( }, }) } - messages = append(messages, assistantMsg) // 7. Execute tool calls (hook: toolcall per tool) diff --git a/pkg/tools/web.go b/pkg/tools/web.go index 7081424c2..9cc2e3166 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/web.go @@ -30,7 +30,6 @@ const ( ) // Pre-compiled regexes for HTML text extraction - var ( reScript = regexp.MustCompile(``) @@ -39,7 +38,6 @@ var ( reTags = regexp.MustCompile(`<[^>]+>`) reWhitespace = regexp.MustCompile(`[^\S\n]+`) - reBlankLines = regexp.MustCompile(`\n{3,}`) // DuckDuckGo result extraction @@ -50,11 +48,9 @@ var ( ) // createHTTPClient creates an HTTP client with optional proxy support - func createHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, error) { client := &http.Client{ Timeout: timeout, - Transport: &http.Transport{ MaxIdleConns: 10, @@ -71,26 +67,18 @@ func createHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, err if err != nil { return nil, fmt.Errorf("invalid proxy URL: %w", err) } - scheme := strings.ToLower(proxy.Scheme) - switch scheme { case "http", "https", "socks5", "socks5h": - default: - return nil, fmt.Errorf( - "unsupported proxy scheme %q (supported: http, https, socks5, socks5h)", - proxy.Scheme, ) } - if proxy.Host == "" { return nil, fmt.Errorf("invalid proxy URL: missing host") } - client.Transport.(*http.Transport).Proxy = http.ProxyURL(proxy) } else { client.Transport.(*http.Transport).Proxy = http.ProxyFromEnvironment @@ -151,7 +139,6 @@ type BraveSearchProvider struct { func (p *BraveSearchProvider) Search(ctx context.Context, query string, count int) (string, error) { searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d", - url.QueryEscape(query), count) req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) @@ -224,7 +211,6 @@ type TavilySearchProvider struct { func (p *TavilySearchProvider) Search(ctx context.Context, query string, count int) (string, error) { searchURL := p.baseURL - if searchURL == "" { searchURL = "https://api.tavily.com/search" } @@ -326,7 +312,6 @@ func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, cou if err != nil { return "", fmt.Errorf("request failed: %w", err) } - defer resp.Body.Close() body, err := io.ReadAll(resp.Body) @@ -339,15 +324,11 @@ func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, cou func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query string) (string, error) { // Simple regex based extraction for DDG HTML - // Strategy: Find all result containers or key anchors directly // Try finding the result links directly first, as they are the most critical - // Pattern: Title - // The previous regex was a bit strict. Let's make it more flexible for attributes order/content - matches := reDDGLink.FindAllStringSubmatch(html, count+5) if len(matches) == 0 { @@ -362,17 +343,13 @@ func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query for i := range maxItems { urlStr := matches[i][1] - title := stripTags(matches[i][2]) - title = strings.TrimSpace(title) // URL decoding if needed - if strings.Contains(urlStr, "uddg=") { if u, err := url.QueryUnescape(urlStr); err == nil { _, after, ok := strings.Cut(u, "uddg=") - if ok { urlStr = after } @@ -382,7 +359,6 @@ func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query snippet := "" // Attempt to attach snippet if available and index aligns - if i < len(snippetMatches) { snippet = stripTags(snippetMatches[i][1]) @@ -447,7 +423,6 @@ func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, cou } req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+p.apiKey) req.Header.Set("User-Agent", userAgent) @@ -456,7 +431,6 @@ func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, cou if err != nil { return "", fmt.Errorf("request failed: %w", err) } - defer resp.Body.Close() body, err := io.ReadAll(resp.Body) @@ -569,7 +543,6 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { if err != nil { return nil, fmt.Errorf("failed to create HTTP client for Tavily: %w", err) } - provider = &TavilySearchProvider{ apiKey: opts.TavilyAPIKey, @@ -590,7 +563,6 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { if err != nil { return nil, fmt.Errorf("failed to create HTTP client for DuckDuckGo: %w", err) } - provider = &DuckDuckGoSearchProvider{proxy: opts.Proxy, client: client} providerName = "duckduckgo" @@ -622,14 +594,12 @@ func (t *WebSearchTool) Description() string { func (t *WebSearchTool) Parameters() map[string]any { return map[string]any{ "type": "object", - "properties": map[string]any{ "query": map[string]any{ "type": "string", "description": "Search query", }, - "count": map[string]any{ "type": "integer", @@ -640,20 +610,17 @@ func (t *WebSearchTool) Parameters() map[string]any { "maximum": 10.0, }, }, - "required": []string{"query"}, } } func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolResult { query, ok := args["query"].(string) - if !ok { return ErrorResult("query is required") } count := t.maxResults - if c, ok := args["count"].(float64); ok { if int(c) > 0 && int(c) <= 10 { count = int(c) @@ -692,7 +659,6 @@ func NewWebFetchToolWithProxy(maxChars int, proxy string) (*WebFetchTool, error) if maxChars <= 0 { maxChars = defaultMaxChars } - client, err := createHTTPClient(proxy, fetchTimeout) if err != nil { return nil, fmt.Errorf("failed to create HTTP client for web fetch: %w", err) @@ -726,14 +692,12 @@ func (t *WebFetchTool) Description() string { func (t *WebFetchTool) Parameters() map[string]any { return map[string]any{ "type": "object", - "properties": map[string]any{ "url": map[string]any{ "type": "string", "description": "URL to fetch", }, - "maxChars": map[string]any{ "type": "integer", @@ -742,14 +706,12 @@ func (t *WebFetchTool) Parameters() map[string]any { "minimum": 100.0, }, }, - "required": []string{"url"}, } } func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolResult { urlStr, ok := args["url"].(string) - if !ok { return ErrorResult("url is required") } @@ -768,7 +730,6 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe } maxChars := t.maxChars - if mc, ok := args["maxChars"].(float64); ok { if int(mc) > 100 { maxChars = int(mc) @@ -781,7 +742,6 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe } req.Header.Set("User-Agent", userAgent) - resp, err := t.client.Do(req) if err != nil { return ErrorResult(fmt.Sprintf("request failed: %v", err)) @@ -802,12 +762,9 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe if strings.Contains(contentType, "application/json") { var jsonData any - if err := json.Unmarshal(body, &jsonData); err == nil { formatted, _ := json.MarshalIndent(jsonData, "", " ") - text = string(formatted) - extractor = "json" } else { text = bodyStr @@ -827,7 +784,6 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe } truncated := len(text) > maxChars - if truncated { text = text[:maxChars] } @@ -838,7 +794,6 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe "status": resp.StatusCode, "extractor": extractor, - "truncated": truncated, "length": len(text), @@ -852,13 +807,9 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe ForLLM: fmt.Sprintf( "Fetched %d bytes from %s (extractor: %s, truncated: %v)", - len(text), - urlStr, - extractor, - truncated, ), @@ -868,15 +819,12 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe func (t *WebFetchTool) extractText(htmlContent string) string { result := reScript.ReplaceAllLiteralString(htmlContent, "") - result = reStyle.ReplaceAllLiteralString(result, "") - result = reTags.ReplaceAllLiteralString(result, "") result = strings.TrimSpace(result) result = reWhitespace.ReplaceAllString(result, " ") - result = reBlankLines.ReplaceAllString(result, "\n\n") lines := strings.Split(result, "\n") @@ -885,7 +833,6 @@ func (t *WebFetchTool) extractText(htmlContent string) string { for _, line := range lines { line = strings.TrimSpace(line) - if line != "" { if sb.Len() > 0 { sb.WriteByte('\n')