Merge pull request #14 from yinwm/fix/tool-execution

Agent Memory System & Tool Execution Improvements
This commit is contained in:
lxowalle
2026-02-11 19:53:26 +08:00
committed by GitHub
10 changed files with 925 additions and 373 deletions

View File

@@ -102,7 +102,11 @@ func main() {
workspace := cfg.WorkspacePath() workspace := cfg.WorkspacePath()
installer := skills.NewSkillInstaller(workspace) installer := skills.NewSkillInstaller(workspace)
skillsLoader := skills.NewSkillsLoader(workspace, "") // 获取全局配置目录和内置 skills 目录
globalDir := filepath.Dir(getConfigPath())
globalSkillsDir := filepath.Join(globalDir, "skills")
builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills")
skillsLoader := skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir)
switch subcommand { switch subcommand {
case "list": case "list":
@@ -242,70 +246,6 @@ Information about user goes here.
- What the user wants to learn from AI - What the user wants to learn from AI
- Preferred interaction style - Preferred interaction style
- Areas of interest - Areas of interest
`,
"TOOLS.md": `# Available Tools
This document describes the tools available to picoclaw.
## File Operations
### Read Files
- Read file contents
- Supports text, markdown, code files
### Write Files
- Create new files
- Overwrite existing files
- Supports various formats
### List Directories
- List directory contents
- Recursive listing support
### Edit Files
- Make specific edits to files
- Line-by-line editing
- String replacement
## Web Tools
### Web Search
- Search the internet using search API
- Returns titles, URLs, snippets
- Optional: Requires API key for best results
### Web Fetch
- Fetch specific URLs
- Extract readable content
- Supports HTML, JSON, plain text
- Automatic content extraction
## Command Execution
### Shell Commands
- Execute any shell command
- Run in workspace directory
- Full shell access with timeout protection
## Messaging
### Send Messages
- Send messages to chat channels
- Supports Telegram, WhatsApp, Feishu
- Used for notifications and responses
## AI Capabilities
### Context Building
- Load system instructions from files
- Load skills dynamically
- Build conversation history
- Include timezone and other context
### Memory Management
- Long-term memory via MEMORY.md
- Daily notes via dated files
- Persistent across sessions
`, `,
"IDENTITY.md": `# Identity "IDENTITY.md": `# Identity
@@ -426,6 +366,9 @@ func agentCmd() {
args := os.Args[2:] args := os.Args[2:]
for i := 0; i < len(args); i++ { for i := 0; i < len(args); i++ {
switch args[i] { switch args[i] {
case "--debug", "-d":
logger.SetLevel(logger.DEBUG)
fmt.Println("🔍 Debug mode enabled")
case "-m", "--message": case "-m", "--message":
if i+1 < len(args) { if i+1 < len(args) {
message = args[i+1] message = args[i+1]
@@ -454,6 +397,15 @@ func agentCmd() {
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
// Print agent startup info (only for interactive mode)
startupInfo := agentLoop.GetStartupInfo()
logger.InfoCF("agent", "Agent initialized",
map[string]interface{}{
"tools_count": startupInfo["tools"].(map[string]interface{})["count"],
"skills_total": startupInfo["skills"].(map[string]interface{})["total"],
"skills_available": startupInfo["skills"].(map[string]interface{})["available"],
})
if message != "" { if message != "" {
ctx := context.Background() ctx := context.Background()
response, err := agentLoop.ProcessDirect(ctx, message, sessionKey) response, err := agentLoop.ProcessDirect(ctx, message, sessionKey)
@@ -555,6 +507,16 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
} }
func gatewayCmd() { func gatewayCmd() {
// Check for --debug flag
args := os.Args[2:]
for _, arg := range args {
if arg == "--debug" || arg == "-d" {
logger.SetLevel(logger.DEBUG)
fmt.Println("🔍 Debug mode enabled")
break
}
}
cfg, err := loadConfig() cfg, err := loadConfig()
if err != nil { if err != nil {
fmt.Printf("Error loading config: %v\n", err) fmt.Printf("Error loading config: %v\n", err)
@@ -570,6 +532,24 @@ func gatewayCmd() {
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
// Print agent startup info
fmt.Println("\n📦 Agent Status:")
startupInfo := agentLoop.GetStartupInfo()
toolsInfo := startupInfo["tools"].(map[string]interface{})
skillsInfo := startupInfo["skills"].(map[string]interface{})
fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"])
fmt.Printf(" • Skills: %d/%d available\n",
skillsInfo["available"],
skillsInfo["total"])
// Log to file as well
logger.InfoCF("agent", "Agent initialized",
map[string]interface{}{
"tools_count": toolsInfo["count"],
"skills_total": skillsInfo["total"],
"skills_available": skillsInfo["available"],
})
cronStorePath := filepath.Join(filepath.Dir(getConfigPath()), "cron", "jobs.json") cronStorePath := filepath.Join(filepath.Dir(getConfigPath()), "cron", "jobs.json")
cronService := cron.NewCronService(cronStorePath, nil) cronService := cron.NewCronService(cronStorePath, nil)
@@ -937,7 +917,11 @@ func skillsCmd() {
workspace := cfg.WorkspacePath() workspace := cfg.WorkspacePath()
installer := skills.NewSkillInstaller(workspace) installer := skills.NewSkillInstaller(workspace)
skillsLoader := skills.NewSkillsLoader(workspace, "") // 获取全局配置目录和内置 skills 目录
globalDir := filepath.Dir(getConfigPath())
globalSkillsDir := filepath.Join(globalDir, "skills")
builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills")
skillsLoader := skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir)
switch subcommand { switch subcommand {
case "list": case "list":
@@ -983,7 +967,7 @@ func skillsHelp() {
} }
func skillsListCmd(loader *skills.SkillsLoader) { func skillsListCmd(loader *skills.SkillsLoader) {
allSkills := loader.ListSkills(false) allSkills := loader.ListSkills()
if len(allSkills) == 0 { if len(allSkills) == 0 {
fmt.Println("No skills installed.") fmt.Println("No skills installed.")
@@ -993,17 +977,10 @@ func skillsListCmd(loader *skills.SkillsLoader) {
fmt.Println("\nInstalled Skills:") fmt.Println("\nInstalled Skills:")
fmt.Println("------------------") fmt.Println("------------------")
for _, skill := range allSkills { for _, skill := range allSkills {
status := "✓" fmt.Printf(" ✓ %s (%s)\n", skill.Name, skill.Source)
if !skill.Available {
status = "✗"
}
fmt.Printf(" %s %s (%s)\n", status, skill.Name, skill.Source)
if skill.Description != "" { if skill.Description != "" {
fmt.Printf(" %s\n", skill.Description) fmt.Printf(" %s\n", skill.Description)
} }
if !skill.Available {
fmt.Printf(" Missing: %s\n", skill.Missing)
}
} }
} }

View File

@@ -4,8 +4,11 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"strings"
"time" "time"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/skills" "github.com/sipeed/picoclaw/pkg/skills"
) )
@@ -13,54 +16,115 @@ import (
type ContextBuilder struct { type ContextBuilder struct {
workspace string workspace string
skillsLoader *skills.SkillsLoader skillsLoader *skills.SkillsLoader
memory *MemoryStore
toolsSummary func() []string // Function to get tool summaries dynamically
} }
func NewContextBuilder(workspace string) *ContextBuilder { func getGlobalConfigDir() string {
builtinSkillsDir := filepath.Join(filepath.Dir(workspace), "picoclaw", "skills") home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".picoclaw")
}
func NewContextBuilder(workspace string, toolsSummaryFunc func() []string) *ContextBuilder {
// builtin skills: 当前项目的 skills 目录
// 使用当前工作目录下的 skills/ 目录
wd, _ := os.Getwd()
builtinSkillsDir := filepath.Join(wd, "skills")
globalSkillsDir := filepath.Join(getGlobalConfigDir(), "skills")
return &ContextBuilder{ return &ContextBuilder{
workspace: workspace, workspace: workspace,
skillsLoader: skills.NewSkillsLoader(workspace, builtinSkillsDir), skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir),
memory: NewMemoryStore(workspace),
toolsSummary: toolsSummaryFunc,
} }
} }
func (cb *ContextBuilder) BuildSystemPrompt() string { func (cb *ContextBuilder) getIdentity() string {
now := time.Now().Format("2006-01-02 15:04 (Monday)") now := time.Now().Format("2006-01-02 15:04 (Monday)")
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace)) workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
runtime := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version())
// Build tools section dynamically
toolsSection := cb.buildToolsSection()
return fmt.Sprintf(`# picoclaw 🦞 return fmt.Sprintf(`# picoclaw 🦞
You are picoclaw, a helpful AI assistant. You have access to tools that allow you to: You are picoclaw, a helpful AI assistant.
- Read, write, and edit files
- Execute shell commands
- Search the web and fetch web pages
- Send messages to users on chat channels
- Spawn subagents for complex background tasks
## Current Time ## Current Time
%s %s
## Runtime
%s
## Workspace ## Workspace
Your workspace is at: %s Your workspace is at: %s
- Memory files: %s/memory/MEMORY.md - Memory: %s/memory/MEMORY.md
- Daily notes: %s/memory/2006-01-02.md - Daily Notes: %s/memory/YYYYMM/YYYYMMDD.md
- Custom skills: %s/skills/{skill-name}/SKILL.md - Skills: %s/skills/{skill-name}/SKILL.md
## Weather Information %s
When users ask about weather, use the web_fetch tool with wttr.in URLs:
- Current weather: https://wttr.in/{city}?format=j1
- Beijing: https://wttr.in/Beijing?format=j1
- Shanghai: https://wttr.in/Shanghai?format=j1
- New York: https://wttr.in/New_York?format=j1
- London: https://wttr.in/London?format=j1
- Tokyo: https://wttr.in/Tokyo?format=j1
IMPORTANT: When responding to direct questions or conversations, reply directly with your text response.
Only use the 'message' tool when you need to send a message to a specific chat channel (like WhatsApp).
For normal conversation, just respond with text - do not call the message tool.
Always be helpful, accurate, and concise. When using tools, explain what you're doing. Always be helpful, accurate, and concise. When using tools, explain what you're doing.
When remembering something, write to %s/memory/MEMORY.md`, When remembering something, write to %s/memory/MEMORY.md`,
now, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath) now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath)
}
func (cb *ContextBuilder) buildToolsSection() string {
if cb.toolsSummary == nil {
return ""
}
summaries := cb.toolsSummary()
if len(summaries) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString("## Available Tools\n\n")
sb.WriteString("You have access to the following tools:\n\n")
for _, s := range summaries {
sb.WriteString(s)
sb.WriteString("\n")
}
return sb.String()
}
func (cb *ContextBuilder) BuildSystemPrompt() string {
parts := []string{}
// Core identity section
parts = append(parts, cb.getIdentity())
// 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))
}
// 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")
} }
func (cb *ContextBuilder) LoadBootstrapFiles() string { func (cb *ContextBuilder) LoadBootstrapFiles() string {
@@ -68,9 +132,7 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
"AGENTS.md", "AGENTS.md",
"SOUL.md", "SOUL.md",
"USER.md", "USER.md",
"TOOLS.md",
"IDENTITY.md", "IDENTITY.md",
"MEMORY.md",
} }
var result string var result string
@@ -84,24 +146,33 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
return result return result
} }
func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary string, currentMessage string, media []string) []providers.Message { func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary string, currentMessage string, media []string, channel, chatID string) []providers.Message {
messages := []providers.Message{} messages := []providers.Message{}
systemPrompt := cb.BuildSystemPrompt() systemPrompt := cb.BuildSystemPrompt()
bootstrapContent := cb.LoadBootstrapFiles()
if bootstrapContent != "" { // Add Current Session info if provided
systemPrompt += "\n\n" + bootstrapContent if channel != "" && chatID != "" {
systemPrompt += fmt.Sprintf("\n\n## Current Session\nChannel: %s\nChat ID: %s", channel, chatID)
} }
skillsSummary := cb.skillsLoader.BuildSkillsSummary() // Log system prompt summary for debugging (debug mode only)
if skillsSummary != "" { logger.DebugCF("agent", "System prompt built",
systemPrompt += "\n\n## Available Skills\n\n" + skillsSummary map[string]interface{}{
} "total_chars": len(systemPrompt),
"total_lines": strings.Count(systemPrompt, "\n") + 1,
"section_count": strings.Count(systemPrompt, "\n\n---\n\n") + 1,
})
skillsContent := cb.loadSkills() // Log preview of system prompt (avoid logging huge content)
if skillsContent != "" { preview := systemPrompt
systemPrompt += "\n\n" + skillsContent if len(preview) > 500 {
preview = preview[:500] + "... (truncated)"
} }
logger.DebugCF("agent", "System prompt preview",
map[string]interface{}{
"preview": preview,
})
if summary != "" { if summary != "" {
systemPrompt += "\n\n## Summary of Previous Conversation\n\n" + summary systemPrompt += "\n\n## Summary of Previous Conversation\n\n" + summary
@@ -136,14 +207,13 @@ func (cb *ContextBuilder) AddAssistantMessage(messages []providers.Message, cont
Role: "assistant", Role: "assistant",
Content: content, Content: content,
} }
if len(toolCalls) > 0 { // Always add assistant message, whether or not it has tool calls
messages = append(messages, msg) messages = append(messages, msg)
}
return messages return messages
} }
func (cb *ContextBuilder) loadSkills() string { func (cb *ContextBuilder) loadSkills() string {
allSkills := cb.skillsLoader.ListSkills(true) allSkills := cb.skillsLoader.ListSkills()
if len(allSkills) == 0 { if len(allSkills) == 0 {
return "" return ""
} }
@@ -160,3 +230,17 @@ func (cb *ContextBuilder) loadSkills() string {
return "# Skill Definitions\n\n" + content return "# Skill Definitions\n\n" + content
} }
// GetSkillsInfo returns information about loaded skills.
func (cb *ContextBuilder) GetSkillsInfo() map[string]interface{} {
allSkills := cb.skillsLoader.ListSkills()
skillNames := make([]string, 0, len(allSkills))
for _, s := range allSkills {
skillNames = append(skillNames, s.Name)
}
return map[string]interface{}{
"total": len(allSkills),
"available": len(allSkills),
"names": skillNames,
}
}

View File

@@ -12,11 +12,11 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"sync" "strings"
"time"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/session"
"github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/tools"
@@ -27,16 +27,14 @@ type AgentLoop struct {
provider providers.LLMProvider provider providers.LLMProvider
workspace string workspace string
model string model string
contextWindow int
maxIterations int maxIterations int
sessions *session.SessionManager sessions *session.SessionManager
contextBuilder *ContextBuilder contextBuilder *ContextBuilder
tools *tools.ToolRegistry tools *tools.ToolRegistry
running bool running bool
summarizing sync.Map
} }
func NewAgentLoop(cfg *config.Config, bus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop { func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop {
workspace := cfg.WorkspacePath() workspace := cfg.WorkspacePath()
os.MkdirAll(workspace, 0755) os.MkdirAll(workspace, 0755)
@@ -50,20 +48,39 @@ func NewAgentLoop(cfg *config.Config, bus *bus.MessageBus, provider providers.LL
toolsRegistry.Register(tools.NewWebSearchTool(braveAPIKey, cfg.Tools.Web.Search.MaxResults)) toolsRegistry.Register(tools.NewWebSearchTool(braveAPIKey, cfg.Tools.Web.Search.MaxResults))
toolsRegistry.Register(tools.NewWebFetchTool(50000)) toolsRegistry.Register(tools.NewWebFetchTool(50000))
// Register message tool
messageTool := tools.NewMessageTool()
messageTool.SetSendCallback(func(channel, chatID, content string) error {
msgBus.PublishOutbound(bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
Content: content,
})
return nil
})
toolsRegistry.Register(messageTool)
// Register spawn tool
subagentManager := tools.NewSubagentManager(provider, workspace, msgBus)
spawnTool := tools.NewSpawnTool(subagentManager)
toolsRegistry.Register(spawnTool)
// Register edit file tool
editFileTool := tools.NewEditFileTool(workspace)
toolsRegistry.Register(editFileTool)
sessionsManager := session.NewSessionManager(filepath.Join(filepath.Dir(cfg.WorkspacePath()), "sessions")) sessionsManager := session.NewSessionManager(filepath.Join(filepath.Dir(cfg.WorkspacePath()), "sessions"))
return &AgentLoop{ return &AgentLoop{
bus: bus, bus: msgBus,
provider: provider, provider: provider,
workspace: workspace, workspace: workspace,
model: cfg.Agents.Defaults.Model, model: cfg.Agents.Defaults.Model,
contextWindow: cfg.Agents.Defaults.MaxTokens,
maxIterations: cfg.Agents.Defaults.MaxToolIterations, maxIterations: cfg.Agents.Defaults.MaxToolIterations,
sessions: sessionsManager, sessions: sessionsManager,
contextBuilder: NewContextBuilder(workspace), contextBuilder: NewContextBuilder(workspace, func() []string { return toolsRegistry.GetSummaries() }),
tools: toolsRegistry, tools: toolsRegistry,
running: false, running: false,
summarizing: sync.Map{},
} }
} }
@@ -115,6 +132,33 @@ func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey stri
} }
func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) { func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
// Add message preview to log
preview := truncate(msg.Content, 80)
logger.InfoCF("agent", fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, preview),
map[string]interface{}{
"channel": msg.Channel,
"chat_id": msg.ChatID,
"sender_id": msg.SenderID,
"session_key": msg.SessionKey,
})
// Route system messages to processSystemMessage
if msg.Channel == "system" {
return al.processSystemMessage(ctx, msg)
}
// Update tool contexts
if tool, ok := al.tools.Get("message"); ok {
if mt, ok := tool.(*tools.MessageTool); ok {
mt.SetContext(msg.Channel, msg.ChatID)
}
}
if tool, ok := al.tools.Get("spawn"); ok {
if st, ok := tool.(*tools.SpawnTool); ok {
st.SetContext(msg.Channel, msg.ChatID)
}
}
history := al.sessions.GetHistory(msg.SessionKey) history := al.sessions.GetHistory(msg.SessionKey)
summary := al.sessions.GetSummary(msg.SessionKey) summary := al.sessions.GetSummary(msg.SessionKey)
@@ -123,6 +167,199 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
summary, summary,
msg.Content, msg.Content,
nil, nil,
msg.Channel,
msg.ChatID,
)
iteration := 0
var finalContent string
for iteration < al.maxIterations {
iteration++
logger.DebugCF("agent", "LLM iteration",
map[string]interface{}{
"iteration": iteration,
"max": al.maxIterations,
})
toolDefs := al.tools.GetDefinitions()
providerToolDefs := make([]providers.ToolDefinition, 0, len(toolDefs))
for _, td := range toolDefs {
providerToolDefs = append(providerToolDefs, providers.ToolDefinition{
Type: td["type"].(string),
Function: providers.ToolFunctionDefinition{
Name: td["function"].(map[string]interface{})["name"].(string),
Description: td["function"].(map[string]interface{})["description"].(string),
Parameters: td["function"].(map[string]interface{})["parameters"].(map[string]interface{}),
},
})
}
// Log LLM request details
logger.DebugCF("agent", "LLM request",
map[string]interface{}{
"iteration": iteration,
"model": al.model,
"messages_count": len(messages),
"tools_count": len(providerToolDefs),
"max_tokens": 8192,
"temperature": 0.7,
"system_prompt_len": len(messages[0].Content),
})
// Log full messages (detailed)
logger.DebugCF("agent", "Full LLM request",
map[string]interface{}{
"iteration": iteration,
"messages_json": formatMessagesForLog(messages),
"tools_json": formatToolsForLog(providerToolDefs),
})
response, err := al.provider.Chat(ctx, messages, providerToolDefs, al.model, map[string]interface{}{
"max_tokens": 8192,
"temperature": 0.7,
})
if err != nil {
logger.ErrorCF("agent", "LLM call failed",
map[string]interface{}{
"iteration": iteration,
"error": err.Error(),
})
return "", fmt.Errorf("LLM call failed: %w", err)
}
if len(response.ToolCalls) == 0 {
finalContent = response.Content
logger.InfoCF("agent", "LLM response without tool calls (direct answer)",
map[string]interface{}{
"iteration": iteration,
"content_chars": len(finalContent),
})
break
}
toolNames := make([]string, 0, len(response.ToolCalls))
for _, tc := range response.ToolCalls {
toolNames = append(toolNames, tc.Name)
}
logger.InfoCF("agent", "LLM requested tool calls",
map[string]interface{}{
"tools": toolNames,
"count": len(toolNames),
"iteration": iteration,
})
assistantMsg := providers.Message{
Role: "assistant",
Content: response.Content,
}
for _, tc := range response.ToolCalls {
argumentsJSON, _ := json.Marshal(tc.Arguments)
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{
ID: tc.ID,
Type: "function",
Function: &providers.FunctionCall{
Name: tc.Name,
Arguments: string(argumentsJSON),
},
})
}
messages = append(messages, assistantMsg)
for _, tc := range response.ToolCalls {
// Log tool call with arguments preview
argsJSON, _ := json.Marshal(tc.Arguments)
argsPreview := truncate(string(argsJSON), 200)
logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview),
map[string]interface{}{
"tool": tc.Name,
"iteration": iteration,
})
result, err := al.tools.Execute(ctx, tc.Name, tc.Arguments)
if err != nil {
result = fmt.Sprintf("Error: %v", err)
}
toolResultMsg := providers.Message{
Role: "tool",
Content: result,
ToolCallID: tc.ID,
}
messages = append(messages, toolResultMsg)
}
}
if finalContent == "" {
finalContent = "I've completed processing but have no response to give."
}
al.sessions.AddMessage(msg.SessionKey, "user", msg.Content)
al.sessions.AddMessage(msg.SessionKey, "assistant", finalContent)
al.sessions.Save(al.sessions.GetOrCreate(msg.SessionKey))
// Log response preview
responsePreview := truncate(finalContent, 120)
logger.InfoCF("agent", fmt.Sprintf("Response to %s:%s: %s", msg.Channel, msg.SenderID, responsePreview),
map[string]interface{}{
"iterations": iteration,
"final_length": len(finalContent),
})
return finalContent, nil
}
func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
// Verify this is a system message
if msg.Channel != "system" {
return "", fmt.Errorf("processSystemMessage called with non-system message channel: %s", msg.Channel)
}
logger.InfoCF("agent", "Processing system message",
map[string]interface{}{
"sender_id": msg.SenderID,
"chat_id": msg.ChatID,
})
// Parse origin from chat_id (format: "channel:chat_id")
var originChannel, originChatID string
if idx := strings.Index(msg.ChatID, ":"); idx > 0 {
originChannel = msg.ChatID[:idx]
originChatID = msg.ChatID[idx+1:]
} else {
// Fallback
originChannel = "cli"
originChatID = msg.ChatID
}
// Use the origin session for context
sessionKey := fmt.Sprintf("%s:%s", originChannel, originChatID)
// Update tool contexts to original channel/chatID
if tool, ok := al.tools.Get("message"); ok {
if mt, ok := tool.(*tools.MessageTool); ok {
mt.SetContext(originChannel, originChatID)
}
}
if tool, ok := al.tools.Get("spawn"); ok {
if st, ok := tool.(*tools.SpawnTool); ok {
st.SetContext(originChannel, originChatID)
}
}
// Build messages with the announce content
history := al.sessions.GetHistory(sessionKey)
summary := al.sessions.GetSummary(sessionKey)
messages := al.contextBuilder.BuildMessages(
history,
summary,
msg.Content,
nil,
originChannel,
originChatID,
) )
iteration := 0 iteration := 0
@@ -144,12 +381,37 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
}) })
} }
// Log LLM request details
logger.DebugCF("agent", "LLM request",
map[string]interface{}{
"iteration": iteration,
"model": al.model,
"messages_count": len(messages),
"tools_count": len(providerToolDefs),
"max_tokens": 8192,
"temperature": 0.7,
"system_prompt_len": len(messages[0].Content),
})
// Log full messages (detailed)
logger.DebugCF("agent", "Full LLM request",
map[string]interface{}{
"iteration": iteration,
"messages_json": formatMessagesForLog(messages),
"tools_json": formatToolsForLog(providerToolDefs),
})
response, err := al.provider.Chat(ctx, messages, providerToolDefs, al.model, map[string]interface{}{ response, err := al.provider.Chat(ctx, messages, providerToolDefs, al.model, map[string]interface{}{
"max_tokens": 8192, "max_tokens": 8192,
"temperature": 0.7, "temperature": 0.7,
}) })
if err != nil { if err != nil {
logger.ErrorCF("agent", "LLM call failed in system message",
map[string]interface{}{
"iteration": iteration,
"error": err.Error(),
})
return "", fmt.Errorf("LLM call failed: %w", err) return "", fmt.Errorf("LLM call failed: %w", err)
} }
@@ -192,133 +454,112 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
} }
if finalContent == "" { if finalContent == "" {
finalContent = "I've completed processing but have no response to give." finalContent = "Background task completed."
} }
al.sessions.AddMessage(msg.SessionKey, "user", msg.Content) // Save to session with system message marker
al.sessions.AddMessage(msg.SessionKey, "assistant", finalContent) al.sessions.AddMessage(sessionKey, "user", fmt.Sprintf("[System: %s] %s", msg.SenderID, msg.Content))
al.sessions.AddMessage(sessionKey, "assistant", finalContent)
al.sessions.Save(al.sessions.GetOrCreate(sessionKey))
// Context compression logic logger.InfoCF("agent", "System message processing completed",
newHistory := al.sessions.GetHistory(msg.SessionKey) map[string]interface{}{
"iterations": iteration,
// Token Awareness (Dynamic) "final_length": len(finalContent),
// Trigger if history > 20 messages OR estimated tokens > 75% of context window })
tokenEstimate := al.estimateTokens(newHistory)
threshold := al.contextWindow * 75 / 100
if len(newHistory) > 20 || tokenEstimate > threshold {
if _, loading := al.summarizing.LoadOrStore(msg.SessionKey, true); !loading {
go func() {
defer al.summarizing.Delete(msg.SessionKey)
al.summarizeSession(msg.SessionKey)
}()
}
}
al.sessions.Save(al.sessions.GetOrCreate(msg.SessionKey))
return finalContent, nil return finalContent, nil
} }
func (al *AgentLoop) summarizeSession(sessionKey string) { // truncate returns a truncated version of s with at most maxLen characters.
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) // If the string is truncated, "..." is appended to indicate truncation.
defer cancel() // If the string fits within maxLen, it is returned unchanged.
func truncate(s string, maxLen int) string {
history := al.sessions.GetHistory(sessionKey) if len(s) <= maxLen {
summary := al.sessions.GetSummary(sessionKey) return s
}
// Keep last 4 messages for continuity // Reserve 3 chars for "..."
if len(history) <= 4 { if maxLen <= 3 {
return return s[:maxLen]
}
return s[:maxLen-3] + "..."
} }
toSummarize := history[:len(history)-4] // GetStartupInfo returns information about loaded tools and skills for logging.
func (al *AgentLoop) GetStartupInfo() map[string]interface{} {
info := make(map[string]interface{})
// Oversized Message Guard (Dynamic) // Tools info
// Skip messages larger than 50% of context window to prevent summarizer overflow. tools := al.tools.List()
maxMessageTokens := al.contextWindow / 2 info["tools"] = map[string]interface{}{
validMessages := make([]providers.Message, 0) "count": len(tools),
omitted := false "names": tools,
for _, m := range toSummarize {
if m.Role != "user" && m.Role != "assistant" {
continue
}
// Estimate tokens for this message
msgTokens := len(m.Content) / 4
if msgTokens > maxMessageTokens {
omitted = true
continue
}
validMessages = append(validMessages, m)
} }
if len(validMessages) == 0 { // Skills info
return info["skills"] = al.contextBuilder.GetSkillsInfo()
return info
} }
// Multi-Part Summarization // formatMessagesForLog formats messages for logging
// Split into two parts if history is significant func formatMessagesForLog(messages []providers.Message) string {
var finalSummary string if len(messages) == 0 {
if len(validMessages) > 10 { return "[]"
mid := len(validMessages) / 2
part1 := validMessages[:mid]
part2 := validMessages[mid:]
s1, _ := al.summarizeBatch(ctx, part1, "")
s2, _ := al.summarizeBatch(ctx, part2, "")
// Merge them
mergePrompt := fmt.Sprintf("Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", s1, s2)
resp, err := al.provider.Chat(ctx, []providers.Message{{Role: "user", Content: mergePrompt}}, nil, al.model, map[string]interface{}{
"max_tokens": 1024,
"temperature": 0.3,
})
if err == nil {
finalSummary = resp.Content
} else {
finalSummary = s1 + " " + s2
}
} else {
finalSummary, _ = al.summarizeBatch(ctx, validMessages, summary)
} }
if omitted && finalSummary != "" { var result string
finalSummary += "\n[Note: Some oversized messages were omitted from this summary for efficiency.]" result += "[\n"
for i, msg := range messages {
result += fmt.Sprintf(" [%d] Role: %s\n", i, msg.Role)
if msg.ToolCalls != nil && len(msg.ToolCalls) > 0 {
result += " ToolCalls:\n"
for _, tc := range msg.ToolCalls {
result += fmt.Sprintf(" - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name)
if tc.Function != nil {
result += fmt.Sprintf(" Arguments: %s\n", truncateString(tc.Function.Arguments, 200))
}
}
}
if msg.Content != "" {
content := truncateString(msg.Content, 200)
result += fmt.Sprintf(" Content: %s\n", content)
}
if msg.ToolCallID != "" {
result += fmt.Sprintf(" ToolCallID: %s\n", msg.ToolCallID)
}
result += "\n"
}
result += "]"
return result
} }
if finalSummary != "" { // formatToolsForLog formats tool definitions for logging
al.sessions.SetSummary(sessionKey, finalSummary) func formatToolsForLog(tools []providers.ToolDefinition) string {
al.sessions.TruncateHistory(sessionKey, 4) if len(tools) == 0 {
al.sessions.Save(al.sessions.GetOrCreate(sessionKey)) return "[]"
}
} }
func (al *AgentLoop) summarizeBatch(ctx context.Context, batch []providers.Message, existingSummary string) (string, error) { var result string
prompt := "Provide a concise summary of this conversation segment, preserving core context and key points.\n" result += "[\n"
if existingSummary != "" { for i, tool := range tools {
prompt += "Existing context: " + existingSummary + "\n" result += fmt.Sprintf(" [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name)
result += fmt.Sprintf(" Description: %s\n", tool.Function.Description)
if len(tool.Function.Parameters) > 0 {
result += fmt.Sprintf(" Parameters: %s\n", truncateString(fmt.Sprintf("%v", tool.Function.Parameters), 200))
} }
prompt += "\nCONVERSATION:\n" }
for _, m := range batch { result += "]"
prompt += fmt.Sprintf("%s: %s\n", m.Role, m.Content) return result
} }
response, err := al.provider.Chat(ctx, []providers.Message{{Role: "user", Content: prompt}}, nil, al.model, map[string]interface{}{ // truncateString truncates a string to max length
"max_tokens": 1024, func truncateString(s string, maxLen int) string {
"temperature": 0.3, if len(s) <= maxLen {
}) return s
if err != nil {
return "", err
} }
return response.Content, nil if maxLen <= 3 {
return s[:maxLen]
} }
return s[:maxLen-3] + "..."
func (al *AgentLoop) estimateTokens(messages []providers.Message) int {
total := 0
for _, m := range messages {
total += len(m.Content) / 4 // Simple heuristic: 4 chars per token
} }
return total
}

161
pkg/agent/memory.go Normal file
View File

@@ -0,0 +1,161 @@
// 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
import (
"fmt"
"os"
"path/filepath"
"time"
)
// MemoryStore manages persistent memory for the agent.
// - Long-term memory: memory/MEMORY.md
// - Daily notes: memory/YYYYMM/YYYYMMDD.md
type MemoryStore struct {
workspace string
memoryDir string
memoryFile string
}
// 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, 0755)
return &MemoryStore{
workspace: workspace,
memoryDir: memoryDir,
memoryFile: memoryFile,
}
}
// 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
}
// ReadLongTerm reads the long-term memory (MEMORY.md).
// Returns empty string if the file doesn't exist.
func (ms *MemoryStore) ReadLongTerm() string {
if data, err := os.ReadFile(ms.memoryFile); err == nil {
return string(data)
}
return ""
}
// WriteLongTerm writes content to the long-term memory file (MEMORY.md).
func (ms *MemoryStore) WriteLongTerm(content string) error {
return os.WriteFile(ms.memoryFile, []byte(content), 0644)
}
// 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)
os.MkdirAll(monthDir, 0755)
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
}
return os.WriteFile(todayFile, []byte(newContent), 0644)
}
// GetRecentDailyNotes returns daily notes from the last N days.
// Contents are joined with "---" separator.
func (ms *MemoryStore) GetRecentDailyNotes(days int) string {
var notes []string
for i := 0; i < days; i++ {
date := time.Now().AddDate(0, 0, -i)
dateStr := date.Format("20060102") // YYYYMMDD
monthDir := dateStr[:6] // YYYYMM
filePath := filepath.Join(ms.memoryDir, monthDir, dateStr+".md")
if data, err := os.ReadFile(filePath); err == nil {
notes = append(notes, string(data))
}
}
if len(notes) == 0 {
return ""
}
// Join with separator
var result string
for i, note := range notes {
if i > 0 {
result += "\n\n---\n\n"
}
result += note
}
return result
}
// GetMemoryContext returns formatted memory context for the agent prompt.
// Includes long-term memory and recent daily notes.
func (ms *MemoryStore) GetMemoryContext() string {
var parts []string
// Long-term memory
longTerm := ms.ReadLongTerm()
if longTerm != "" {
parts = append(parts, "## Long-term Memory\n\n"+longTerm)
}
// Recent daily notes (last 3 days)
recentNotes := ms.GetRecentDailyNotes(3)
if recentNotes != "" {
parts = append(parts, "## Recent Daily Notes\n\n"+recentNotes)
}
if len(parts) == 0 {
return ""
}
// Join parts with separator
var result string
for i, part := range parts {
if i > 0 {
result += "\n\n---\n\n"
}
result += part
}
return fmt.Sprintf("# Memory\n\n%s", result)
}

View File

@@ -4,7 +4,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
@@ -13,13 +12,6 @@ import (
type SkillMetadata struct { type SkillMetadata struct {
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
Always bool `json:"always"`
Requires *SkillRequirements `json:"requires,omitempty"`
}
type SkillRequirements struct {
Bins []string `json:"bins"`
Env []string `json:"env"`
} }
type SkillInfo struct { type SkillInfo struct {
@@ -27,25 +19,25 @@ type SkillInfo struct {
Path string `json:"path"` Path string `json:"path"`
Source string `json:"source"` Source string `json:"source"`
Description string `json:"description"` Description string `json:"description"`
Available bool `json:"available"`
Missing string `json:"missing,omitempty"`
} }
type SkillsLoader struct { type SkillsLoader struct {
workspace string workspace string
workspaceSkills string workspaceSkills string // workspace skills (项目级别)
builtinSkills string globalSkills string // 全局 skills (~/.picoclaw/skills)
builtinSkills string // 内置 skills
} }
func NewSkillsLoader(workspace string, builtinSkills string) *SkillsLoader { func NewSkillsLoader(workspace string, globalSkills string, builtinSkills string) *SkillsLoader {
return &SkillsLoader{ return &SkillsLoader{
workspace: workspace, workspace: workspace,
workspaceSkills: filepath.Join(workspace, "skills"), workspaceSkills: filepath.Join(workspace, "skills"),
globalSkills: globalSkills, // ~/.picoclaw/skills
builtinSkills: builtinSkills, builtinSkills: builtinSkills,
} }
} }
func (sl *SkillsLoader) ListSkills(filterUnavailable bool) []SkillInfo { func (sl *SkillsLoader) ListSkills() []SkillInfo {
skills := make([]SkillInfo, 0) skills := make([]SkillInfo, 0)
if sl.workspaceSkills != "" { if sl.workspaceSkills != "" {
@@ -62,12 +54,41 @@ func (sl *SkillsLoader) ListSkills(filterUnavailable bool) []SkillInfo {
metadata := sl.getSkillMetadata(skillFile) metadata := sl.getSkillMetadata(skillFile)
if metadata != nil { if metadata != nil {
info.Description = metadata.Description info.Description = metadata.Description
info.Available = sl.checkRequirements(metadata.Requires)
if !info.Available {
info.Missing = sl.getMissingRequirements(metadata.Requires)
} }
} else { skills = append(skills, info)
info.Available = true }
}
}
}
}
// 全局 skills (~/.picoclaw/skills) - 被 workspace skills 覆盖
if sl.globalSkills != "" {
if dirs, err := os.ReadDir(sl.globalSkills); err == nil {
for _, dir := range dirs {
if dir.IsDir() {
skillFile := filepath.Join(sl.globalSkills, dir.Name(), "SKILL.md")
if _, err := os.Stat(skillFile); err == nil {
// 检查是否已被 workspace skills 覆盖
exists := false
for _, s := range skills {
if s.Name == dir.Name() && s.Source == "workspace" {
exists = true
break
}
}
if exists {
continue
}
info := SkillInfo{
Name: dir.Name(),
Path: skillFile,
Source: "global",
}
metadata := sl.getSkillMetadata(skillFile)
if metadata != nil {
info.Description = metadata.Description
} }
skills = append(skills, info) skills = append(skills, info)
} }
@@ -82,9 +103,10 @@ func (sl *SkillsLoader) ListSkills(filterUnavailable bool) []SkillInfo {
if dir.IsDir() { if dir.IsDir() {
skillFile := filepath.Join(sl.builtinSkills, dir.Name(), "SKILL.md") skillFile := filepath.Join(sl.builtinSkills, dir.Name(), "SKILL.md")
if _, err := os.Stat(skillFile); err == nil { if _, err := os.Stat(skillFile); err == nil {
// 检查是否已被 workspace 或 global skills 覆盖
exists := false exists := false
for _, s := range skills { for _, s := range skills {
if s.Name == dir.Name() && s.Source == "workspace" { if s.Name == dir.Name() && (s.Source == "workspace" || s.Source == "global") {
exists = true exists = true
break break
} }
@@ -101,12 +123,6 @@ func (sl *SkillsLoader) ListSkills(filterUnavailable bool) []SkillInfo {
metadata := sl.getSkillMetadata(skillFile) metadata := sl.getSkillMetadata(skillFile)
if metadata != nil { if metadata != nil {
info.Description = metadata.Description info.Description = metadata.Description
info.Available = sl.checkRequirements(metadata.Requires)
if !info.Available {
info.Missing = sl.getMissingRequirements(metadata.Requires)
}
} else {
info.Available = true
} }
skills = append(skills, info) skills = append(skills, info)
} }
@@ -115,20 +131,11 @@ func (sl *SkillsLoader) ListSkills(filterUnavailable bool) []SkillInfo {
} }
} }
if filterUnavailable {
filtered := make([]SkillInfo, 0)
for _, s := range skills {
if s.Available {
filtered = append(filtered, s)
}
}
return filtered
}
return skills return skills
} }
func (sl *SkillsLoader) LoadSkill(name string) (string, bool) { func (sl *SkillsLoader) LoadSkill(name string) (string, bool) {
// 1. 优先从 workspace skills 加载(项目级别)
if sl.workspaceSkills != "" { if sl.workspaceSkills != "" {
skillFile := filepath.Join(sl.workspaceSkills, name, "SKILL.md") skillFile := filepath.Join(sl.workspaceSkills, name, "SKILL.md")
if content, err := os.ReadFile(skillFile); err == nil { if content, err := os.ReadFile(skillFile); err == nil {
@@ -136,6 +143,15 @@ func (sl *SkillsLoader) LoadSkill(name string) (string, bool) {
} }
} }
// 2. 其次从全局 skills 加载 (~/.picoclaw/skills)
if sl.globalSkills != "" {
skillFile := filepath.Join(sl.globalSkills, name, "SKILL.md")
if content, err := os.ReadFile(skillFile); err == nil {
return sl.stripFrontmatter(string(content)), true
}
}
// 3. 最后从内置 skills 加载
if sl.builtinSkills != "" { if sl.builtinSkills != "" {
skillFile := filepath.Join(sl.builtinSkills, name, "SKILL.md") skillFile := filepath.Join(sl.builtinSkills, name, "SKILL.md")
if content, err := os.ReadFile(skillFile); err == nil { if content, err := os.ReadFile(skillFile); err == nil {
@@ -163,7 +179,7 @@ func (sl *SkillsLoader) LoadSkillsForContext(skillNames []string) string {
} }
func (sl *SkillsLoader) BuildSkillsSummary() string { func (sl *SkillsLoader) BuildSkillsSummary() string {
allSkills := sl.ListSkills(false) allSkills := sl.ListSkills()
if len(allSkills) == 0 { if len(allSkills) == 0 {
return "" return ""
} }
@@ -175,21 +191,11 @@ func (sl *SkillsLoader) BuildSkillsSummary() string {
escapedDesc := escapeXML(s.Description) escapedDesc := escapeXML(s.Description)
escapedPath := escapeXML(s.Path) escapedPath := escapeXML(s.Path)
available := "true" lines = append(lines, fmt.Sprintf(" <skill>"))
if !s.Available {
available = "false"
}
lines = append(lines, fmt.Sprintf(" <skill available=\"%s\">", available))
lines = append(lines, fmt.Sprintf(" <name>%s</name>", escapedName)) lines = append(lines, fmt.Sprintf(" <name>%s</name>", escapedName))
lines = append(lines, fmt.Sprintf(" <description>%s</description>", escapedDesc)) lines = append(lines, fmt.Sprintf(" <description>%s</description>", escapedDesc))
lines = append(lines, fmt.Sprintf(" <location>%s</location>", escapedPath)) lines = append(lines, fmt.Sprintf(" <location>%s</location>", escapedPath))
lines = append(lines, fmt.Sprintf(" <source>%s</source>", s.Source))
if !s.Available && s.Missing != "" {
escapedMissing := escapeXML(s.Missing)
lines = append(lines, fmt.Sprintf(" <requires>%s</requires>", escapedMissing))
}
lines = append(lines, " </skill>") lines = append(lines, " </skill>")
} }
lines = append(lines, "</skills>") lines = append(lines, "</skills>")
@@ -197,18 +203,6 @@ func (sl *SkillsLoader) BuildSkillsSummary() string {
return strings.Join(lines, "\n") return strings.Join(lines, "\n")
} }
func (sl *SkillsLoader) GetAlwaysSkills() []string {
skills := sl.ListSkills(true)
var always []string
for _, s := range skills {
metadata := sl.getSkillMetadata(s.Path)
if metadata != nil && metadata.Always {
always = append(always, s.Name)
}
}
return always
}
func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata { func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata {
content, err := os.ReadFile(skillPath) content, err := os.ReadFile(skillPath)
if err != nil { if err != nil {
@@ -222,27 +216,54 @@ func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata {
} }
} }
var metadata struct { // Try JSON first (for backward compatibility)
var jsonMeta struct {
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
Always bool `json:"always"`
Requires *SkillRequirements `json:"requires"`
} }
if err := json.Unmarshal([]byte(frontmatter), &jsonMeta); err == nil {
if err := json.Unmarshal([]byte(frontmatter), &metadata); err != nil {
return nil
}
return &SkillMetadata{ return &SkillMetadata{
Name: metadata.Name, Name: jsonMeta.Name,
Description: metadata.Description, Description: jsonMeta.Description,
Always: metadata.Always,
Requires: metadata.Requires,
} }
} }
// Fall back to simple YAML parsing
yamlMeta := sl.parseSimpleYAML(frontmatter)
return &SkillMetadata{
Name: yamlMeta["name"],
Description: yamlMeta["description"],
}
}
// parseSimpleYAML parses simple key: value YAML format
// Example: name: github\n description: "..."
func (sl *SkillsLoader) parseSimpleYAML(content string) map[string]string {
result := make(map[string]string)
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
// Remove quotes if present
value = strings.Trim(value, "\"'")
result[key] = value
}
}
return result
}
func (sl *SkillsLoader) extractFrontmatter(content string) string { func (sl *SkillsLoader) extractFrontmatter(content string) string {
re := regexp.MustCompile(`^---\n(.*?)\n---`) // (?s) enables DOTALL mode so . matches newlines
// Match first ---, capture everything until next --- on its own line
re := regexp.MustCompile(`(?s)^---\n(.*)\n---`)
match := re.FindStringSubmatch(content) match := re.FindStringSubmatch(content)
if len(match) > 1 { if len(match) > 1 {
return match[1] return match[1]
@@ -255,49 +276,6 @@ func (sl *SkillsLoader) stripFrontmatter(content string) string {
return re.ReplaceAllString(content, "") return re.ReplaceAllString(content, "")
} }
func (sl *SkillsLoader) checkRequirements(requires *SkillRequirements) bool {
if requires == nil {
return true
}
for _, bin := range requires.Bins {
if _, err := exec.LookPath(bin); err != nil {
continue
} else {
return true
}
}
for _, env := range requires.Env {
if os.Getenv(env) == "" {
return false
}
}
return true
}
func (sl *SkillsLoader) getMissingRequirements(requires *SkillRequirements) string {
if requires == nil {
return ""
}
var missing []string
for _, bin := range requires.Bins {
if _, err := exec.LookPath(bin); err != nil {
missing = append(missing, fmt.Sprintf("CLI: %s", bin))
}
}
for _, env := range requires.Env {
if os.Getenv(env) == "" {
missing = append(missing, fmt.Sprintf("ENV: %s", env))
}
}
return strings.Join(missing, ", ")
}
func escapeXML(s string) string { func escapeXML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;") s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;") s = strings.ReplaceAll(s, "<", "&lt;")

View File

@@ -8,10 +8,17 @@ import (
"strings" "strings"
) )
type EditFileTool struct{} // EditFileTool edits a file by replacing old_text with new_text.
// The old_text must exist exactly in the file.
type EditFileTool struct {
allowedDir string // Optional directory restriction for security
}
func NewEditFileTool() *EditFileTool { // NewEditFileTool creates a new EditFileTool with optional directory restriction.
return &EditFileTool{} func NewEditFileTool(allowedDir string) *EditFileTool {
return &EditFileTool{
allowedDir: allowedDir,
}
} }
func (t *EditFileTool) Name() string { func (t *EditFileTool) Name() string {
@@ -59,13 +66,34 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]interface{})
return "", fmt.Errorf("new_text is required") return "", fmt.Errorf("new_text is required")
} }
filePath := filepath.Clean(path) // Resolve path and enforce directory restriction if configured
resolvedPath := path
if filepath.IsAbs(path) {
resolvedPath = filepath.Clean(path)
} else {
abs, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf("failed to resolve path: %w", err)
}
resolvedPath = abs
}
if _, err := os.Stat(filePath); os.IsNotExist(err) { // Check directory restriction
if t.allowedDir != "" {
allowedAbs, err := filepath.Abs(t.allowedDir)
if err != nil {
return "", fmt.Errorf("failed to resolve allowed directory: %w", err)
}
if !strings.HasPrefix(resolvedPath, allowedAbs) {
return "", fmt.Errorf("path %s is outside allowed directory %s", path, t.allowedDir)
}
}
if _, err := os.Stat(resolvedPath); os.IsNotExist(err) {
return "", fmt.Errorf("file not found: %s", path) return "", fmt.Errorf("file not found: %s", path)
} }
content, err := os.ReadFile(filePath) content, err := os.ReadFile(resolvedPath)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to read file: %w", err) return "", fmt.Errorf("failed to read file: %w", err)
} }
@@ -83,7 +111,7 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]interface{})
newContent := strings.Replace(contentStr, oldText, newText, 1) newContent := strings.Replace(contentStr, oldText, newText, 1)
if err := os.WriteFile(filePath, []byte(newContent), 0644); err != nil { if err := os.WriteFile(resolvedPath, []byte(newContent), 0644); err != nil {
return "", fmt.Errorf("failed to write file: %w", err) return "", fmt.Errorf("failed to write file: %w", err)
} }

View File

@@ -4,6 +4,9 @@ import (
"context" "context"
"fmt" "fmt"
"sync" "sync"
"time"
"github.com/sipeed/picoclaw/pkg/logger"
) )
type ToolRegistry struct { type ToolRegistry struct {
@@ -31,11 +34,42 @@ func (r *ToolRegistry) Get(name string) (Tool, bool) {
} }
func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string]interface{}) (string, error) { func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string]interface{}) (string, error) {
logger.InfoCF("tool", "Tool execution started",
map[string]interface{}{
"tool": name,
"args": args,
})
tool, ok := r.Get(name) tool, ok := r.Get(name)
if !ok { if !ok {
logger.ErrorCF("tool", "Tool not found",
map[string]interface{}{
"tool": name,
})
return "", fmt.Errorf("tool '%s' not found", name) return "", fmt.Errorf("tool '%s' not found", name)
} }
return tool.Execute(ctx, args)
start := time.Now()
result, err := tool.Execute(ctx, args)
duration := time.Since(start)
if err != nil {
logger.ErrorCF("tool", "Tool execution failed",
map[string]interface{}{
"tool": name,
"duration": duration.Milliseconds(),
"error": err.Error(),
})
} else {
logger.InfoCF("tool", "Tool execution completed",
map[string]interface{}{
"tool": name,
"duration_ms": duration.Milliseconds(),
"result_length": len(result),
})
}
return result, err
} }
func (r *ToolRegistry) GetDefinitions() []map[string]interface{} { func (r *ToolRegistry) GetDefinitions() []map[string]interface{} {
@@ -48,3 +82,35 @@ func (r *ToolRegistry) GetDefinitions() []map[string]interface{} {
} }
return definitions return definitions
} }
// List returns a list of all registered tool names.
func (r *ToolRegistry) List() []string {
r.mu.RLock()
defer r.mu.RUnlock()
names := make([]string, 0, len(r.tools))
for name := range r.tools {
names = append(names, name)
}
return names
}
// Count returns the number of registered tools.
func (r *ToolRegistry) Count() int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.tools)
}
// GetSummaries returns human-readable summaries of all registered tools.
// Returns a slice of "name - description" strings.
func (r *ToolRegistry) GetSummaries() []string {
r.mu.RLock()
defer r.mu.RUnlock()
summaries := make([]string, 0, len(r.tools))
for _, tool := range r.tools {
summaries = append(summaries, fmt.Sprintf("- `%s` - %s", tool.Name(), tool.Description()))
}
return summaries
}

View File

@@ -25,9 +25,9 @@ func NewExecTool(workingDir string) *ExecTool {
regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`), regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`),
regexp.MustCompile(`\bdel\s+/[fq]\b`), regexp.MustCompile(`\bdel\s+/[fq]\b`),
regexp.MustCompile(`\brmdir\s+/s\b`), regexp.MustCompile(`\brmdir\s+/s\b`),
regexp.MustCompile(`\b(format|mkfs|diskpart)\b`), regexp.MustCompile(`\b(format|mkfs|diskpart)\b\s`), // Match disk wiping commands (must be followed by space/args)
regexp.MustCompile(`\bdd\s+if=`), regexp.MustCompile(`\bdd\s+if=`),
regexp.MustCompile(`>\s*/dev/sd`), regexp.MustCompile(`>\s*/dev/sd[a-z]\b`), // Block writes to disk devices (but allow /dev/null)
regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`), regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`),
regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`), regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`),
} }

View File

@@ -5,6 +5,9 @@ import (
"fmt" "fmt"
"sync" "sync"
"time" "time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/providers"
) )
type SubagentTask struct { type SubagentTask struct {
@@ -21,15 +24,17 @@ type SubagentTask struct {
type SubagentManager struct { type SubagentManager struct {
tasks map[string]*SubagentTask tasks map[string]*SubagentTask
mu sync.RWMutex mu sync.RWMutex
provider LLMProvider provider providers.LLMProvider
bus *bus.MessageBus
workspace string workspace string
nextID int nextID int
} }
func NewSubagentManager(provider LLMProvider, workspace string) *SubagentManager { func NewSubagentManager(provider providers.LLMProvider, workspace string, bus *bus.MessageBus) *SubagentManager {
return &SubagentManager{ return &SubagentManager{
tasks: make(map[string]*SubagentTask), tasks: make(map[string]*SubagentTask),
provider: provider, provider: provider,
bus: bus,
workspace: workspace, workspace: workspace,
nextID: 1, nextID: 1,
} }
@@ -65,7 +70,7 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask) {
task.Status = "running" task.Status = "running"
task.Created = time.Now().UnixMilli() task.Created = time.Now().UnixMilli()
messages := []Message{ messages := []providers.Message{
{ {
Role: "system", Role: "system",
Content: "You are a subagent. Complete the given task independently and report the result.", Content: "You are a subagent. Complete the given task independently and report the result.",
@@ -90,6 +95,18 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask) {
task.Status = "completed" task.Status = "completed"
task.Result = response.Content task.Result = response.Content
} }
// Send announce message back to main agent
if sm.bus != nil {
announceContent := fmt.Sprintf("Task '%s' completed.\n\nResult:\n%s", task.Label, task.Result)
sm.bus.PublishInbound(bus.InboundMessage{
Channel: "system",
SenderID: fmt.Sprintf("subagent:%s", task.ID),
// Format: "original_channel:original_chat_id" for routing back
ChatID: fmt.Sprintf("%s:%s", task.OriginChannel, task.OriginChatID),
Content: announceContent,
})
}
} }
func (sm *SubagentManager) GetTask(taskID string) (*SubagentTask, bool) { func (sm *SubagentManager) GetTask(taskID string) (*SubagentTask, bool) {

View File

@@ -36,7 +36,7 @@ func (t *WebSearchTool) Name() string {
} }
func (t *WebSearchTool) Description() string { func (t *WebSearchTool) Description() string {
return "Search the web. Returns titles, URLs, and snippets." return "Search the web for current information. Returns titles, URLs, and snippets from search results."
} }
func (t *WebSearchTool) Parameters() map[string]interface{} { func (t *WebSearchTool) Parameters() map[string]interface{} {