Merge pull request #14 from yinwm/fix/tool-execution
Agent Memory System & Tool Execution Improvements
This commit is contained in:
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
if len(history) <= 4 {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
// Reserve 3 chars for "..."
|
||||||
toSummarize := history[:len(history)-4]
|
if maxLen <= 3 {
|
||||||
|
return s[:maxLen]
|
||||||
// Oversized Message Guard (Dynamic)
|
|
||||||
// Skip messages larger than 50% of context window to prevent summarizer overflow.
|
|
||||||
maxMessageTokens := al.contextWindow / 2
|
|
||||||
validMessages := make([]providers.Message, 0)
|
|
||||||
omitted := false
|
|
||||||
|
|
||||||
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 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Multi-Part Summarization
|
|
||||||
// Split into two parts if history is significant
|
|
||||||
var finalSummary string
|
|
||||||
if len(validMessages) > 10 {
|
|
||||||
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 != "" {
|
|
||||||
finalSummary += "\n[Note: Some oversized messages were omitted from this summary for efficiency.]"
|
|
||||||
}
|
|
||||||
|
|
||||||
if finalSummary != "" {
|
|
||||||
al.sessions.SetSummary(sessionKey, finalSummary)
|
|
||||||
al.sessions.TruncateHistory(sessionKey, 4)
|
|
||||||
al.sessions.Save(al.sessions.GetOrCreate(sessionKey))
|
|
||||||
}
|
}
|
||||||
|
return s[:maxLen-3] + "..."
|
||||||
}
|
}
|
||||||
|
|
||||||
func (al *AgentLoop) summarizeBatch(ctx context.Context, batch []providers.Message, existingSummary string) (string, error) {
|
// GetStartupInfo returns information about loaded tools and skills for logging.
|
||||||
prompt := "Provide a concise summary of this conversation segment, preserving core context and key points.\n"
|
func (al *AgentLoop) GetStartupInfo() map[string]interface{} {
|
||||||
if existingSummary != "" {
|
info := make(map[string]interface{})
|
||||||
prompt += "Existing context: " + existingSummary + "\n"
|
|
||||||
}
|
// Tools info
|
||||||
prompt += "\nCONVERSATION:\n"
|
tools := al.tools.List()
|
||||||
for _, m := range batch {
|
info["tools"] = map[string]interface{}{
|
||||||
prompt += fmt.Sprintf("%s: %s\n", m.Role, m.Content)
|
"count": len(tools),
|
||||||
|
"names": tools,
|
||||||
}
|
}
|
||||||
|
|
||||||
response, err := al.provider.Chat(ctx, []providers.Message{{Role: "user", Content: prompt}}, nil, al.model, map[string]interface{}{
|
// Skills info
|
||||||
"max_tokens": 1024,
|
info["skills"] = al.contextBuilder.GetSkillsInfo()
|
||||||
"temperature": 0.3,
|
|
||||||
})
|
return info
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return response.Content, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (al *AgentLoop) estimateTokens(messages []providers.Message) int {
|
// formatMessagesForLog formats messages for logging
|
||||||
total := 0
|
func formatMessagesForLog(messages []providers.Message) string {
|
||||||
for _, m := range messages {
|
if len(messages) == 0 {
|
||||||
total += len(m.Content) / 4 // Simple heuristic: 4 chars per token
|
return "[]"
|
||||||
}
|
}
|
||||||
return total
|
|
||||||
|
var result string
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// formatToolsForLog formats tool definitions for logging
|
||||||
|
func formatToolsForLog(tools []providers.ToolDefinition) string {
|
||||||
|
if len(tools) == 0 {
|
||||||
|
return "[]"
|
||||||
|
}
|
||||||
|
|
||||||
|
var result string
|
||||||
|
result += "[\n"
|
||||||
|
for i, tool := range tools {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result += "]"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateString truncates a string to max length
|
||||||
|
func truncateString(s string, maxLen int) string {
|
||||||
|
if len(s) <= maxLen {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if maxLen <= 3 {
|
||||||
|
return s[:maxLen]
|
||||||
|
}
|
||||||
|
return s[:maxLen-3] + "..."
|
||||||
|
}
|
||||||
|
|||||||
161
pkg/agent/memory.go
Normal file
161
pkg/agent/memory.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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, "&", "&")
|
s = strings.ReplaceAll(s, "&", "&")
|
||||||
s = strings.ReplaceAll(s, "<", "<")
|
s = strings.ReplaceAll(s, "<", "<")
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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*:`),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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{} {
|
||||||
|
|||||||
Reference in New Issue
Block a user