feat: add rate limiting, memory/skill tools, and state management
- Add rate limiter for tool calls and requests (pkg/agent/ratelimit.go) - Add persistent memory tool (write/read long-term and daily notes) - Add skill tool for listing and reading skills - Add atomic state management (pkg/agent/state/) - Refactor skills loader to use dataDir instead of workspace - Remove skill installer (search/install from registry) - Add rate_limits config section - Update heartbeat service to use state manager for last channel - Refactor provider creation and model name handling Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
461585dbd5
commit
add20a4454
19 changed files with 819 additions and 330 deletions
|
|
@ -156,31 +156,26 @@ func main() {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
workspace := cfg.WorkspacePath()
|
dataDir := cfg.DataPath()
|
||||||
installer := skills.NewSkillInstaller(workspace)
|
|
||||||
// 获取全局配置目录和内置 skills 目录
|
// 获取全局配置目录和内置 skills 目录
|
||||||
globalDir := filepath.Dir(getConfigPath())
|
globalDir := filepath.Dir(getConfigPath())
|
||||||
globalSkillsDir := filepath.Join(globalDir, "skills")
|
globalSkillsDir := filepath.Join(globalDir, "skills")
|
||||||
builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills")
|
builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills")
|
||||||
skillsLoader := skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir)
|
skillsLoader := skills.NewSkillsLoader(dataDir, globalSkillsDir, builtinSkillsDir)
|
||||||
|
|
||||||
switch subcommand {
|
switch subcommand {
|
||||||
case "list":
|
case "list":
|
||||||
skillsListCmd(skillsLoader)
|
skillsListCmd(skillsLoader)
|
||||||
case "install":
|
|
||||||
skillsInstallCmd(installer)
|
|
||||||
case "remove", "uninstall":
|
case "remove", "uninstall":
|
||||||
if len(os.Args) < 4 {
|
if len(os.Args) < 4 {
|
||||||
fmt.Println("Usage: picoclaw skills remove <skill-name>")
|
fmt.Println("Usage: picoclaw skills remove <skill-name>")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
skillsRemoveCmd(installer, os.Args[3])
|
skillsRemoveCmd(dataDir, os.Args[3])
|
||||||
case "install-builtin":
|
case "install-builtin":
|
||||||
skillsInstallBuiltinCmd(workspace)
|
skillsInstallBuiltinCmd(dataDir)
|
||||||
case "list-builtin":
|
case "list-builtin":
|
||||||
skillsListBuiltinCmd()
|
skillsListBuiltinCmd()
|
||||||
case "search":
|
|
||||||
skillsSearchCmd(installer)
|
|
||||||
case "show":
|
case "show":
|
||||||
if len(os.Args) < 4 {
|
if len(os.Args) < 4 {
|
||||||
fmt.Println("Usage: picoclaw skills show <skill-name>")
|
fmt.Println("Usage: picoclaw skills show <skill-name>")
|
||||||
|
|
@ -237,7 +232,9 @@ func onboard() {
|
||||||
}
|
}
|
||||||
|
|
||||||
workspace := cfg.WorkspacePath()
|
workspace := cfg.WorkspacePath()
|
||||||
createWorkspaceTemplates(workspace)
|
dataDir := cfg.DataPath()
|
||||||
|
os.MkdirAll(workspace, 0755)
|
||||||
|
createWorkspaceTemplates(dataDir)
|
||||||
|
|
||||||
fmt.Printf("%s picoclaw is ready!\n", logo)
|
fmt.Printf("%s picoclaw is ready!\n", logo)
|
||||||
fmt.Println("\nNext steps:")
|
fmt.Println("\nNext steps:")
|
||||||
|
|
@ -562,10 +559,11 @@ func gatewayCmd() {
|
||||||
})
|
})
|
||||||
|
|
||||||
// Setup cron tool and service
|
// Setup cron tool and service
|
||||||
cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.Agents.Defaults.RestrictToWorkspace)
|
cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.DataPath(), cfg.Agents.Defaults.RestrictToWorkspace)
|
||||||
|
|
||||||
heartbeatService := heartbeat.NewHeartbeatService(
|
heartbeatService := heartbeat.NewHeartbeatService(
|
||||||
cfg.WorkspacePath(),
|
cfg.WorkspacePath(),
|
||||||
|
cfg.DataPath(),
|
||||||
cfg.Heartbeat.Interval,
|
cfg.Heartbeat.Interval,
|
||||||
cfg.Heartbeat.Enabled,
|
cfg.Heartbeat.Enabled,
|
||||||
)
|
)
|
||||||
|
|
@ -647,7 +645,7 @@ func gatewayCmd() {
|
||||||
}
|
}
|
||||||
fmt.Println("✓ Heartbeat service started")
|
fmt.Println("✓ Heartbeat service started")
|
||||||
|
|
||||||
stateManager := state.NewManager(cfg.WorkspacePath())
|
stateManager := state.NewManager(cfg.DataPath())
|
||||||
deviceService := devices.NewService(devices.Config{
|
deviceService := devices.NewService(devices.Config{
|
||||||
Enabled: cfg.Devices.Enabled,
|
Enabled: cfg.Devices.Enabled,
|
||||||
MonitorUSB: cfg.Devices.MonitorUSB,
|
MonitorUSB: cfg.Devices.MonitorUSB,
|
||||||
|
|
@ -987,13 +985,13 @@ func getConfigPath() string {
|
||||||
return filepath.Join(home, ".picoclaw", "config.json")
|
return filepath.Join(home, ".picoclaw", "config.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool) *cron.CronService {
|
func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, dataDir string, restrict bool) *cron.CronService {
|
||||||
cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
|
cronStorePath := filepath.Join(dataDir, "cron", "jobs.json")
|
||||||
|
|
||||||
// Create cron service
|
// Create cron service
|
||||||
cronService := cron.NewCronService(cronStorePath, nil)
|
cronService := cron.NewCronService(cronStorePath, nil)
|
||||||
|
|
||||||
// Create and register CronTool
|
// Create and register CronTool (workspace is for ExecTool sandboxing)
|
||||||
cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict)
|
cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict)
|
||||||
agentLoop.RegisterTool(cronTool)
|
agentLoop.RegisterTool(cronTool)
|
||||||
|
|
||||||
|
|
@ -1025,7 +1023,7 @@ func cronCmd() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cronStorePath := filepath.Join(cfg.WorkspacePath(), "cron", "jobs.json")
|
cronStorePath := filepath.Join(cfg.DataPath(), "cron", "jobs.json")
|
||||||
|
|
||||||
switch subcommand {
|
switch subcommand {
|
||||||
case "list":
|
case "list":
|
||||||
|
|
@ -1227,16 +1225,16 @@ func cronEnableCmd(storePath string, disable bool) {
|
||||||
func skillsHelp() {
|
func skillsHelp() {
|
||||||
fmt.Println("\nSkills commands:")
|
fmt.Println("\nSkills commands:")
|
||||||
fmt.Println(" list List installed skills")
|
fmt.Println(" list List installed skills")
|
||||||
fmt.Println(" install <repo> Install skill from GitHub")
|
fmt.Println(" install-builtin Install all builtin skills to workspace")
|
||||||
fmt.Println(" install-builtin Install all builtin skills to workspace")
|
fmt.Println(" list-builtin List available builtin skills")
|
||||||
fmt.Println(" list-builtin List available builtin skills")
|
|
||||||
fmt.Println(" remove <name> Remove installed skill")
|
fmt.Println(" remove <name> Remove installed skill")
|
||||||
fmt.Println(" search Search available skills")
|
|
||||||
fmt.Println(" show <name> Show skill details")
|
fmt.Println(" show <name> Show skill details")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
fmt.Println("To install custom skills, place SKILL.md files manually in:")
|
||||||
|
fmt.Println(" ~/.picoclaw/data/skills/<skill-name>/SKILL.md")
|
||||||
|
fmt.Println()
|
||||||
fmt.Println("Examples:")
|
fmt.Println("Examples:")
|
||||||
fmt.Println(" picoclaw skills list")
|
fmt.Println(" picoclaw skills list")
|
||||||
fmt.Println(" picoclaw skills install sipeed/picoclaw-skills/weather")
|
|
||||||
fmt.Println(" picoclaw skills install-builtin")
|
fmt.Println(" picoclaw skills install-builtin")
|
||||||
fmt.Println(" picoclaw skills list-builtin")
|
fmt.Println(" picoclaw skills list-builtin")
|
||||||
fmt.Println(" picoclaw skills remove weather")
|
fmt.Println(" picoclaw skills remove weather")
|
||||||
|
|
@ -1260,31 +1258,16 @@ func skillsListCmd(loader *skills.SkillsLoader) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func skillsInstallCmd(installer *skills.SkillInstaller) {
|
func skillsRemoveCmd(dataDir string, skillName string) {
|
||||||
if len(os.Args) < 4 {
|
skillDir := filepath.Join(dataDir, "skills", skillName)
|
||||||
fmt.Println("Usage: picoclaw skills install <github-repo>")
|
|
||||||
fmt.Println("Example: picoclaw skills install sipeed/picoclaw-skills/weather")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
repo := os.Args[3]
|
if _, err := os.Stat(skillDir); os.IsNotExist(err) {
|
||||||
fmt.Printf("Installing skill from %s...\n", repo)
|
fmt.Printf("✗ Skill '%s' not found\n", skillName)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
if err := installer.InstallFromGitHub(ctx, repo); err != nil {
|
|
||||||
fmt.Printf("✗ Failed to install skill: %v\n", err)
|
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("✓ Skill '%s' installed successfully!\n", filepath.Base(repo))
|
|
||||||
}
|
|
||||||
|
|
||||||
func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) {
|
|
||||||
fmt.Printf("Removing skill '%s'...\n", skillName)
|
fmt.Printf("Removing skill '%s'...\n", skillName)
|
||||||
|
if err := os.RemoveAll(skillDir); err != nil {
|
||||||
if err := installer.Uninstall(skillName); err != nil {
|
|
||||||
fmt.Printf("✗ Failed to remove skill: %v\n", err)
|
fmt.Printf("✗ Failed to remove skill: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
@ -1292,9 +1275,9 @@ func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) {
|
||||||
fmt.Printf("✓ Skill '%s' removed successfully!\n", skillName)
|
fmt.Printf("✓ Skill '%s' removed successfully!\n", skillName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func skillsInstallBuiltinCmd(workspace string) {
|
func skillsInstallBuiltinCmd(dataDir string) {
|
||||||
builtinSkillsDir := "./picoclaw/skills"
|
builtinSkillsDir := "./picoclaw/skills"
|
||||||
workspaceSkillsDir := filepath.Join(workspace, "skills")
|
skillsDir := filepath.Join(dataDir, "skills")
|
||||||
|
|
||||||
fmt.Printf("Copying builtin skills to workspace...\n")
|
fmt.Printf("Copying builtin skills to workspace...\n")
|
||||||
|
|
||||||
|
|
@ -1307,19 +1290,19 @@ func skillsInstallBuiltinCmd(workspace string) {
|
||||||
|
|
||||||
for _, skillName := range skillsToInstall {
|
for _, skillName := range skillsToInstall {
|
||||||
builtinPath := filepath.Join(builtinSkillsDir, skillName)
|
builtinPath := filepath.Join(builtinSkillsDir, skillName)
|
||||||
workspacePath := filepath.Join(workspaceSkillsDir, skillName)
|
destPath := filepath.Join(skillsDir, skillName)
|
||||||
|
|
||||||
if _, err := os.Stat(builtinPath); err != nil {
|
if _, err := os.Stat(builtinPath); err != nil {
|
||||||
fmt.Printf("⊘ Builtin skill '%s' not found: %v\n", skillName, err)
|
fmt.Printf("⊘ Builtin skill '%s' not found: %v\n", skillName, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.MkdirAll(workspacePath, 0755); err != nil {
|
if err := os.MkdirAll(destPath, 0755); err != nil {
|
||||||
fmt.Printf("✗ Failed to create directory for %s: %v\n", skillName, err)
|
fmt.Printf("✗ Failed to create directory for %s: %v\n", skillName, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := copyDirectory(builtinPath, workspacePath); err != nil {
|
if err := copyDirectory(builtinPath, destPath); err != nil {
|
||||||
fmt.Printf("✗ Failed to copy %s: %v\n", skillName, err)
|
fmt.Printf("✗ Failed to copy %s: %v\n", skillName, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1380,39 +1363,6 @@ func skillsListBuiltinCmd() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func skillsSearchCmd(installer *skills.SkillInstaller) {
|
|
||||||
fmt.Println("Searching for available skills...")
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
availableSkills, err := installer.ListAvailableSkills(ctx)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("✗ Failed to fetch skills list: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(availableSkills) == 0 {
|
|
||||||
fmt.Println("No skills available.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("\nAvailable Skills (%d):\n", len(availableSkills))
|
|
||||||
fmt.Println("--------------------")
|
|
||||||
for _, skill := range availableSkills {
|
|
||||||
fmt.Printf(" 📦 %s\n", skill.Name)
|
|
||||||
fmt.Printf(" %s\n", skill.Description)
|
|
||||||
fmt.Printf(" Repo: %s\n", skill.Repository)
|
|
||||||
if skill.Author != "" {
|
|
||||||
fmt.Printf(" Author: %s\n", skill.Author)
|
|
||||||
}
|
|
||||||
if len(skill.Tags) > 0 {
|
|
||||||
fmt.Printf(" Tags: %v\n", skill.Tags)
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func skillsShowCmd(loader *skills.SkillsLoader, skillName string) {
|
func skillsShowCmd(loader *skills.SkillsLoader, skillName string) {
|
||||||
content, ok := loader.LoadSkill(skillName)
|
content, ok := loader.LoadSkill(skillName)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@
|
||||||
},
|
},
|
||||||
"maixcam": {
|
"maixcam": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"host": "0.0.0.0",
|
"host": "127.0.0.1",
|
||||||
"port": 18790,
|
"port": 18792,
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
},
|
},
|
||||||
"whatsapp": {
|
"whatsapp": {
|
||||||
|
|
@ -56,7 +56,7 @@
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"channel_secret": "YOUR_LINE_CHANNEL_SECRET",
|
"channel_secret": "YOUR_LINE_CHANNEL_SECRET",
|
||||||
"channel_access_token": "YOUR_LINE_CHANNEL_ACCESS_TOKEN",
|
"channel_access_token": "YOUR_LINE_CHANNEL_ACCESS_TOKEN",
|
||||||
"webhook_host": "0.0.0.0",
|
"webhook_host": "127.0.0.1",
|
||||||
"webhook_port": 18791,
|
"webhook_port": 18791,
|
||||||
"webhook_path": "/webhook/line",
|
"webhook_path": "/webhook/line",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
|
@ -139,7 +139,11 @@
|
||||||
"monitor_usb": true
|
"monitor_usb": true
|
||||||
},
|
},
|
||||||
"gateway": {
|
"gateway": {
|
||||||
"host": "0.0.0.0",
|
"host": "127.0.0.1",
|
||||||
"port": 18790
|
"port": 18790
|
||||||
|
},
|
||||||
|
"rate_limits": {
|
||||||
|
"max_tool_calls_per_minute": 60,
|
||||||
|
"max_requests_per_minute": 30
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import (
|
||||||
|
|
||||||
type ContextBuilder struct {
|
type ContextBuilder struct {
|
||||||
workspace string
|
workspace string
|
||||||
|
dataDir string
|
||||||
skillsLoader *skills.SkillsLoader
|
skillsLoader *skills.SkillsLoader
|
||||||
memory *MemoryStore
|
memory *MemoryStore
|
||||||
tools *tools.ToolRegistry // Direct reference to tool registry
|
tools *tools.ToolRegistry // Direct reference to tool registry
|
||||||
|
|
@ -29,7 +30,7 @@ func getGlobalConfigDir() string {
|
||||||
return filepath.Join(home, ".picoclaw")
|
return filepath.Join(home, ".picoclaw")
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewContextBuilder(workspace string) *ContextBuilder {
|
func NewContextBuilder(workspace string, dataDir string) *ContextBuilder {
|
||||||
// builtin skills: skills directory in current project
|
// builtin skills: skills directory in current project
|
||||||
// Use the skills/ directory under the current working directory
|
// Use the skills/ directory under the current working directory
|
||||||
wd, _ := os.Getwd()
|
wd, _ := os.Getwd()
|
||||||
|
|
@ -38,11 +39,22 @@ func NewContextBuilder(workspace string) *ContextBuilder {
|
||||||
|
|
||||||
return &ContextBuilder{
|
return &ContextBuilder{
|
||||||
workspace: workspace,
|
workspace: workspace,
|
||||||
skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir),
|
dataDir: dataDir,
|
||||||
memory: NewMemoryStore(workspace),
|
skillsLoader: skills.NewSkillsLoader(dataDir, globalSkillsDir, builtinSkillsDir),
|
||||||
|
memory: NewMemoryStore(dataDir),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetMemory returns the memory store for tool registration.
|
||||||
|
func (cb *ContextBuilder) GetMemory() *MemoryStore {
|
||||||
|
return cb.memory
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSkillsLoader returns the skills loader for tool registration.
|
||||||
|
func (cb *ContextBuilder) GetSkillsLoader() *skills.SkillsLoader {
|
||||||
|
return cb.skillsLoader
|
||||||
|
}
|
||||||
|
|
||||||
// SetToolsRegistry sets the tools registry for dynamic tool summary generation.
|
// SetToolsRegistry sets the tools registry for dynamic tool summary generation.
|
||||||
func (cb *ContextBuilder) SetToolsRegistry(registry *tools.ToolRegistry) {
|
func (cb *ContextBuilder) SetToolsRegistry(registry *tools.ToolRegistry) {
|
||||||
cb.tools = registry
|
cb.tools = registry
|
||||||
|
|
@ -68,9 +80,6 @@ You are picoclaw, a helpful AI assistant.
|
||||||
|
|
||||||
## Workspace
|
## Workspace
|
||||||
Your workspace is at: %s
|
Your workspace is at: %s
|
||||||
- Memory: %s/memory/MEMORY.md
|
|
||||||
- Daily Notes: %s/memory/YYYYMM/YYYYMMDD.md
|
|
||||||
- Skills: %s/skills/{skill-name}/SKILL.md
|
|
||||||
|
|
||||||
%s
|
%s
|
||||||
|
|
||||||
|
|
@ -80,8 +89,12 @@ Your workspace is at: %s
|
||||||
|
|
||||||
2. **Be helpful and accurate** - When using tools, briefly explain what you're doing.
|
2. **Be helpful and accurate** - When using tools, briefly explain what you're doing.
|
||||||
|
|
||||||
3. **Memory** - When remembering something, write to %s/memory/MEMORY.md`,
|
3. **Memory** - Use the memory tool to store and retrieve information.
|
||||||
now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath)
|
- write_long_term: Save important, date-independent facts (user preferences, project info, permanent notes)
|
||||||
|
- append_daily: Record today's events and memos (diary-like daily entries)
|
||||||
|
- read_long_term: Read long-term memory
|
||||||
|
- read_daily: Read today's daily notes`,
|
||||||
|
now, runtime, workspacePath, toolsSection)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) buildToolsSection() string {
|
func (cb *ContextBuilder) buildToolsSection() string {
|
||||||
|
|
@ -118,12 +131,12 @@ func (cb *ContextBuilder) BuildSystemPrompt() string {
|
||||||
parts = append(parts, bootstrapContent)
|
parts = append(parts, bootstrapContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skills - show summary, AI can read full content with read_file tool
|
// Skills - show summary, AI can read full content with skill_read tool
|
||||||
skillsSummary := cb.skillsLoader.BuildSkillsSummary()
|
skillsSummary := cb.skillsLoader.BuildSkillsSummary()
|
||||||
if skillsSummary != "" {
|
if skillsSummary != "" {
|
||||||
parts = append(parts, fmt.Sprintf(`# Skills
|
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.
|
The following skills extend your capabilities. To use a skill, call the skill_read tool with the skill name.
|
||||||
|
|
||||||
%s`, skillsSummary))
|
%s`, skillsSummary))
|
||||||
}
|
}
|
||||||
|
|
@ -148,7 +161,7 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
|
||||||
|
|
||||||
var result string
|
var result string
|
||||||
for _, filename := range bootstrapFiles {
|
for _, filename := range bootstrapFiles {
|
||||||
filePath := filepath.Join(cb.workspace, filename)
|
filePath := filepath.Join(cb.dataDir, filename)
|
||||||
if data, err := os.ReadFile(filePath); err == nil {
|
if data, err := os.ReadFile(filePath); err == nil {
|
||||||
result += fmt.Sprintf("## %s\n\n%s\n\n", filename, string(data))
|
result += fmt.Sprintf("## %s\n\n%s\n\n", filename, string(data))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ type AgentLoop struct {
|
||||||
running atomic.Bool
|
running atomic.Bool
|
||||||
summarizing sync.Map // Tracks which sessions are currently being summarized
|
summarizing sync.Map // Tracks which sessions are currently being summarized
|
||||||
channelManager *channels.Manager
|
channelManager *channels.Manager
|
||||||
|
rateLimiter *rateLimiter
|
||||||
}
|
}
|
||||||
|
|
||||||
// processOptions configures how a message is processed
|
// processOptions configures how a message is processed
|
||||||
|
|
@ -112,7 +113,9 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg
|
||||||
|
|
||||||
func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop {
|
func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop {
|
||||||
workspace := cfg.WorkspacePath()
|
workspace := cfg.WorkspacePath()
|
||||||
|
dataDir := cfg.DataPath()
|
||||||
os.MkdirAll(workspace, 0755)
|
os.MkdirAll(workspace, 0755)
|
||||||
|
os.MkdirAll(dataDir, 0755)
|
||||||
|
|
||||||
restrict := cfg.Agents.Defaults.RestrictToWorkspace
|
restrict := cfg.Agents.Defaults.RestrictToWorkspace
|
||||||
|
|
||||||
|
|
@ -133,15 +136,25 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
|
||||||
subagentTool := tools.NewSubagentTool(subagentManager)
|
subagentTool := tools.NewSubagentTool(subagentManager)
|
||||||
toolsRegistry.Register(subagentTool)
|
toolsRegistry.Register(subagentTool)
|
||||||
|
|
||||||
sessionsManager := session.NewSessionManager(filepath.Join(workspace, "sessions"))
|
// Use dataDir for sessions and state (outside workspace for security)
|
||||||
|
sessionsManager := session.NewSessionManager(filepath.Join(dataDir, "sessions"))
|
||||||
|
|
||||||
// Create state manager for atomic state persistence
|
// Create state manager for atomic state persistence
|
||||||
stateManager := state.NewManager(workspace)
|
stateManager := state.NewManager(dataDir)
|
||||||
|
|
||||||
// Create context builder and set tools registry
|
// Create context builder and set tools registry
|
||||||
contextBuilder := NewContextBuilder(workspace)
|
contextBuilder := NewContextBuilder(workspace, dataDir)
|
||||||
contextBuilder.SetToolsRegistry(toolsRegistry)
|
contextBuilder.SetToolsRegistry(toolsRegistry)
|
||||||
|
|
||||||
|
// Register memory and skill tools (controlled access to dataDir)
|
||||||
|
memoryTool := tools.NewMemoryTool(contextBuilder.GetMemory())
|
||||||
|
toolsRegistry.Register(memoryTool)
|
||||||
|
subagentTools.Register(memoryTool)
|
||||||
|
|
||||||
|
skillTool := tools.NewSkillTool(contextBuilder.GetSkillsLoader())
|
||||||
|
toolsRegistry.Register(skillTool)
|
||||||
|
subagentTools.Register(skillTool)
|
||||||
|
|
||||||
return &AgentLoop{
|
return &AgentLoop{
|
||||||
bus: msgBus,
|
bus: msgBus,
|
||||||
provider: provider,
|
provider: provider,
|
||||||
|
|
@ -154,6 +167,7 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
|
||||||
contextBuilder: contextBuilder,
|
contextBuilder: contextBuilder,
|
||||||
tools: toolsRegistry,
|
tools: toolsRegistry,
|
||||||
summarizing: sync.Map{},
|
summarizing: sync.Map{},
|
||||||
|
rateLimiter: newRateLimiter(cfg.RateLimits.MaxToolCallsPerMinute, cfg.RateLimits.MaxRequestsPerMinute),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -275,6 +289,16 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
return al.processSystemMessage(ctx, msg)
|
return al.processSystemMessage(ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check request rate limit
|
||||||
|
if err := al.rateLimiter.checkRequest(); err != nil {
|
||||||
|
logger.WarnCF("agent", "Request rate limited",
|
||||||
|
map[string]interface{}{
|
||||||
|
"channel": msg.Channel,
|
||||||
|
"sender_id": msg.SenderID,
|
||||||
|
})
|
||||||
|
return fmt.Sprintf("Rate limited: %v. Please try again later.", err), nil
|
||||||
|
}
|
||||||
|
|
||||||
// Check for commands
|
// Check for commands
|
||||||
if response, handled := al.handleCommand(ctx, msg); handled {
|
if response, handled := al.handleCommand(ctx, msg); handled {
|
||||||
return response, nil
|
return response, nil
|
||||||
|
|
@ -643,6 +667,23 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
|
||||||
|
|
||||||
// Execute tool calls
|
// Execute tool calls
|
||||||
for _, tc := range response.ToolCalls {
|
for _, tc := range response.ToolCalls {
|
||||||
|
// Check tool call rate limit
|
||||||
|
if err := al.rateLimiter.checkToolCall(); err != nil {
|
||||||
|
logger.WarnCF("agent", "Tool call rate limited",
|
||||||
|
map[string]interface{}{
|
||||||
|
"tool": tc.Name,
|
||||||
|
"iteration": iteration,
|
||||||
|
})
|
||||||
|
toolResultMsg := providers.Message{
|
||||||
|
Role: "tool",
|
||||||
|
Content: fmt.Sprintf("Rate limited: %v", err),
|
||||||
|
ToolCallID: tc.ID,
|
||||||
|
}
|
||||||
|
messages = append(messages, toolResultMsg)
|
||||||
|
al.sessions.AddFullMessage(opts.SessionKey, toolResultMsg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Log tool call with arguments preview
|
// Log tool call with arguments preview
|
||||||
argsJSON, _ := json.Marshal(tc.Arguments)
|
argsJSON, _ := json.Marshal(tc.Arguments)
|
||||||
argsPreview := utils.Truncate(string(argsJSON), 200)
|
argsPreview := utils.Truncate(string(argsJSON), 200)
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ func TestRecordLastChannel(t *testing.T) {
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -86,6 +87,7 @@ func TestRecordLastChatID(t *testing.T) {
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -131,6 +133,7 @@ func TestNewAgentLoop_StateInitialized(t *testing.T) {
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -167,6 +170,7 @@ func TestToolRegistry_ToolRegistration(t *testing.T) {
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -213,6 +217,7 @@ func TestToolContext_Updates(t *testing.T) {
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -244,6 +249,7 @@ func TestToolRegistry_GetDefinitions(t *testing.T) {
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -288,6 +294,7 @@ func TestAgentLoop_GetStartupInfo(t *testing.T) {
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -335,6 +342,7 @@ func TestCreateToolRegistry_ExecDisabled(t *testing.T) {
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -374,6 +382,7 @@ func TestCreateToolRegistry_ExecEnabled(t *testing.T) {
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -418,6 +427,7 @@ func TestCreateToolRegistry_I2CDisabled(t *testing.T) {
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -455,6 +465,7 @@ func TestCreateToolRegistry_I2CEnabled(t *testing.T) {
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -497,6 +508,7 @@ func TestCreateToolRegistry_SPIDisabled(t *testing.T) {
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -534,6 +546,7 @@ func TestCreateToolRegistry_SPIEnabled(t *testing.T) {
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -576,6 +589,7 @@ func TestAgentLoop_Stop(t *testing.T) {
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -698,6 +712,7 @@ func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) {
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -740,6 +755,7 @@ func TestToolResult_UserFacingToolDoesSendMessage(t *testing.T) {
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
@ -805,6 +821,7 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
Workspace: tmpDir,
|
Workspace: tmpDir,
|
||||||
|
DataDir: tmpDir,
|
||||||
Model: "test-model",
|
Model: "test-model",
|
||||||
MaxTokens: 4096,
|
MaxTokens: 4096,
|
||||||
MaxToolIterations: 10,
|
MaxToolIterations: 10,
|
||||||
|
|
|
||||||
|
|
@ -17,22 +17,22 @@ import (
|
||||||
// - Long-term memory: memory/MEMORY.md
|
// - Long-term memory: memory/MEMORY.md
|
||||||
// - Daily notes: memory/YYYYMM/YYYYMMDD.md
|
// - Daily notes: memory/YYYYMM/YYYYMMDD.md
|
||||||
type MemoryStore struct {
|
type MemoryStore struct {
|
||||||
workspace string
|
dataDir string
|
||||||
memoryDir string
|
memoryDir string
|
||||||
memoryFile string
|
memoryFile string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMemoryStore creates a new MemoryStore with the given workspace path.
|
// NewMemoryStore creates a new MemoryStore with the given data directory path.
|
||||||
// It ensures the memory directory exists.
|
// It ensures the memory directory exists.
|
||||||
func NewMemoryStore(workspace string) *MemoryStore {
|
func NewMemoryStore(dataDir string) *MemoryStore {
|
||||||
memoryDir := filepath.Join(workspace, "memory")
|
memoryDir := filepath.Join(dataDir, "memory")
|
||||||
memoryFile := filepath.Join(memoryDir, "MEMORY.md")
|
memoryFile := filepath.Join(memoryDir, "MEMORY.md")
|
||||||
|
|
||||||
// Ensure memory directory exists
|
// Ensure memory directory exists
|
||||||
os.MkdirAll(memoryDir, 0755)
|
os.MkdirAll(memoryDir, 0755)
|
||||||
|
|
||||||
return &MemoryStore{
|
return &MemoryStore{
|
||||||
workspace: workspace,
|
dataDir: dataDir,
|
||||||
memoryDir: memoryDir,
|
memoryDir: memoryDir,
|
||||||
memoryFile: memoryFile,
|
memoryFile: memoryFile,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
84
pkg/agent/ratelimit.go
Normal file
84
pkg/agent/ratelimit.go
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rateLimiter provides simple sliding-window rate limiting for tool calls and requests.
|
||||||
|
type rateLimiter struct {
|
||||||
|
maxToolCallsPerMinute int
|
||||||
|
maxRequestsPerMinute int
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
toolCallTimes []time.Time
|
||||||
|
requestTimes []time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRateLimiter(maxToolCalls, maxRequests int) *rateLimiter {
|
||||||
|
return &rateLimiter{
|
||||||
|
maxToolCallsPerMinute: maxToolCalls,
|
||||||
|
maxRequestsPerMinute: maxRequests,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkToolCall checks if a tool call is allowed under the rate limit.
|
||||||
|
// Returns nil if allowed, error if rate limited.
|
||||||
|
func (rl *rateLimiter) checkToolCall() error {
|
||||||
|
if rl.maxToolCallsPerMinute <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rl.mu.Lock()
|
||||||
|
defer rl.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
cutoff := now.Add(-time.Minute)
|
||||||
|
|
||||||
|
// Remove expired entries
|
||||||
|
rl.toolCallTimes = pruneOld(rl.toolCallTimes, cutoff)
|
||||||
|
|
||||||
|
if len(rl.toolCallTimes) >= rl.maxToolCallsPerMinute {
|
||||||
|
return fmt.Errorf("tool call limit exceeded (%d/min)", rl.maxToolCallsPerMinute)
|
||||||
|
}
|
||||||
|
|
||||||
|
rl.toolCallTimes = append(rl.toolCallTimes, now)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkRequest checks if a request is allowed under the rate limit.
|
||||||
|
// Returns nil if allowed, error if rate limited.
|
||||||
|
func (rl *rateLimiter) checkRequest() error {
|
||||||
|
if rl.maxRequestsPerMinute <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rl.mu.Lock()
|
||||||
|
defer rl.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
cutoff := now.Add(-time.Minute)
|
||||||
|
|
||||||
|
// Remove expired entries
|
||||||
|
rl.requestTimes = pruneOld(rl.requestTimes, cutoff)
|
||||||
|
|
||||||
|
if len(rl.requestTimes) >= rl.maxRequestsPerMinute {
|
||||||
|
return fmt.Errorf("request limit exceeded (%d/min)", rl.maxRequestsPerMinute)
|
||||||
|
}
|
||||||
|
|
||||||
|
rl.requestTimes = append(rl.requestTimes, now)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneOld removes timestamps older than cutoff.
|
||||||
|
func pruneOld(times []time.Time, cutoff time.Time) []time.Time {
|
||||||
|
i := 0
|
||||||
|
for i < len(times) && times[i].Before(cutoff) {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if i == 0 {
|
||||||
|
return times
|
||||||
|
}
|
||||||
|
return times[i:]
|
||||||
|
}
|
||||||
5
pkg/agent/state/state.json
Normal file
5
pkg/agent/state/state.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"last_channel": "test:test-chat",
|
||||||
|
"last_chat_id": "test-chat-id-123",
|
||||||
|
"timestamp": "2026-02-17T10:56:02.805709569Z"
|
||||||
|
}
|
||||||
|
|
@ -44,14 +44,15 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Agents AgentsConfig `json:"agents"`
|
Agents AgentsConfig `json:"agents"`
|
||||||
Channels ChannelsConfig `json:"channels"`
|
Channels ChannelsConfig `json:"channels"`
|
||||||
Providers ProvidersConfig `json:"providers"`
|
Providers ProvidersConfig `json:"providers"`
|
||||||
Gateway GatewayConfig `json:"gateway"`
|
Gateway GatewayConfig `json:"gateway"`
|
||||||
Tools ToolsConfig `json:"tools"`
|
Tools ToolsConfig `json:"tools"`
|
||||||
Heartbeat HeartbeatConfig `json:"heartbeat"`
|
Heartbeat HeartbeatConfig `json:"heartbeat"`
|
||||||
Devices DevicesConfig `json:"devices"`
|
Devices DevicesConfig `json:"devices"`
|
||||||
mu sync.RWMutex
|
RateLimits RateLimitsConfig `json:"rate_limits"`
|
||||||
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentsConfig struct {
|
type AgentsConfig struct {
|
||||||
|
|
@ -60,6 +61,7 @@ type AgentsConfig struct {
|
||||||
|
|
||||||
type AgentDefaults struct {
|
type AgentDefaults struct {
|
||||||
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
|
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
|
||||||
|
DataDir string `json:"data_dir" env:"PICOCLAW_AGENTS_DEFAULTS_DATA_DIR"`
|
||||||
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
||||||
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
|
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
|
||||||
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
|
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
|
||||||
|
|
@ -166,6 +168,11 @@ type DevicesConfig struct {
|
||||||
MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"`
|
MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RateLimitsConfig struct {
|
||||||
|
MaxToolCallsPerMinute int `json:"max_tool_calls_per_minute" env:"PICOCLAW_RATE_LIMITS_MAX_TOOL_CALLS_PER_MINUTE"` // 0 = unlimited
|
||||||
|
MaxRequestsPerMinute int `json:"max_requests_per_minute" env:"PICOCLAW_RATE_LIMITS_MAX_REQUESTS_PER_MINUTE"` // 0 = unlimited
|
||||||
|
}
|
||||||
|
|
||||||
type ProvidersConfig struct {
|
type ProvidersConfig struct {
|
||||||
Anthropic ProviderConfig `json:"anthropic"`
|
Anthropic ProviderConfig `json:"anthropic"`
|
||||||
OpenAI ProviderConfig `json:"openai"`
|
OpenAI ProviderConfig `json:"openai"`
|
||||||
|
|
@ -235,6 +242,7 @@ func DefaultConfig() *Config {
|
||||||
Agents: AgentsConfig{
|
Agents: AgentsConfig{
|
||||||
Defaults: AgentDefaults{
|
Defaults: AgentDefaults{
|
||||||
Workspace: "~/.picoclaw/workspace",
|
Workspace: "~/.picoclaw/workspace",
|
||||||
|
DataDir: "~/.picoclaw/data",
|
||||||
RestrictToWorkspace: true,
|
RestrictToWorkspace: true,
|
||||||
Provider: "",
|
Provider: "",
|
||||||
Model: "glm-4.7",
|
Model: "glm-4.7",
|
||||||
|
|
@ -269,8 +277,8 @@ func DefaultConfig() *Config {
|
||||||
},
|
},
|
||||||
MaixCam: MaixCamConfig{
|
MaixCam: MaixCamConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
Host: "0.0.0.0",
|
Host: "127.0.0.1",
|
||||||
Port: 18790,
|
Port: 18792,
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
},
|
},
|
||||||
QQ: QQConfig{
|
QQ: QQConfig{
|
||||||
|
|
@ -295,7 +303,7 @@ func DefaultConfig() *Config {
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
ChannelSecret: "",
|
ChannelSecret: "",
|
||||||
ChannelAccessToken: "",
|
ChannelAccessToken: "",
|
||||||
WebhookHost: "0.0.0.0",
|
WebhookHost: "127.0.0.1",
|
||||||
WebhookPort: 18791,
|
WebhookPort: 18791,
|
||||||
WebhookPath: "/webhook/line",
|
WebhookPath: "/webhook/line",
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
|
|
@ -322,7 +330,7 @@ func DefaultConfig() *Config {
|
||||||
ShengSuanYun: ProviderConfig{},
|
ShengSuanYun: ProviderConfig{},
|
||||||
},
|
},
|
||||||
Gateway: GatewayConfig{
|
Gateway: GatewayConfig{
|
||||||
Host: "0.0.0.0",
|
Host: "127.0.0.1",
|
||||||
Port: 18790,
|
Port: 18790,
|
||||||
},
|
},
|
||||||
Tools: ToolsConfig{
|
Tools: ToolsConfig{
|
||||||
|
|
@ -355,6 +363,10 @@ func DefaultConfig() *Config {
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
MonitorUSB: true,
|
MonitorUSB: true,
|
||||||
},
|
},
|
||||||
|
RateLimits: RateLimitsConfig{
|
||||||
|
MaxToolCallsPerMinute: 60,
|
||||||
|
MaxRequestsPerMinute: 30,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -403,6 +415,12 @@ func (c *Config) WorkspacePath() string {
|
||||||
return expandHome(c.Agents.Defaults.Workspace)
|
return expandHome(c.Agents.Defaults.Workspace)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Config) DataPath() string {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return expandHome(c.Agents.Defaults.DataDir)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Config) GetAPIKey() string {
|
func (c *Config) GetAPIKey() string {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ func TestDefaultConfig_Temperature(t *testing.T) {
|
||||||
func TestDefaultConfig_Gateway(t *testing.T) {
|
func TestDefaultConfig_Gateway(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
if cfg.Gateway.Host != "0.0.0.0" {
|
if cfg.Gateway.Host != "127.0.0.1" {
|
||||||
t.Error("Gateway host should have default value")
|
t.Error("Gateway host should have default value")
|
||||||
}
|
}
|
||||||
if cfg.Gateway.Port == 0 {
|
if cfg.Gateway.Port == 0 {
|
||||||
|
|
@ -198,6 +198,34 @@ func TestSaveConfig_FilePermissions(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDefaultConfig_DataDir verifies data dir default value
|
||||||
|
func TestDefaultConfig_DataDir(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
|
if cfg.Agents.Defaults.DataDir == "" {
|
||||||
|
t.Error("DataDir should not be empty")
|
||||||
|
}
|
||||||
|
if cfg.Agents.Defaults.DataDir != "~/.picoclaw/data" {
|
||||||
|
t.Errorf("DataDir should be '~/.picoclaw/data', got '%s'", cfg.Agents.Defaults.DataDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfig_DataPath verifies DataPath expands home directory
|
||||||
|
func TestConfig_DataPath(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
|
path := cfg.DataPath()
|
||||||
|
if path == "" {
|
||||||
|
t.Error("DataPath should not be empty")
|
||||||
|
}
|
||||||
|
if path == "~/.picoclaw/data" {
|
||||||
|
t.Error("DataPath should expand ~ to home directory")
|
||||||
|
}
|
||||||
|
if path[0] == '~' {
|
||||||
|
t.Error("DataPath should not start with ~")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestConfig_Complete verifies all config fields are set
|
// TestConfig_Complete verifies all config fields are set
|
||||||
func TestConfig_Complete(t *testing.T) {
|
func TestConfig_Complete(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
@ -218,7 +246,7 @@ func TestConfig_Complete(t *testing.T) {
|
||||||
if cfg.Agents.Defaults.MaxToolIterations == 0 {
|
if cfg.Agents.Defaults.MaxToolIterations == 0 {
|
||||||
t.Error("MaxToolIterations should not be zero")
|
t.Error("MaxToolIterations should not be zero")
|
||||||
}
|
}
|
||||||
if cfg.Gateway.Host != "0.0.0.0" {
|
if cfg.Gateway.Host != "127.0.0.1" {
|
||||||
t.Error("Gateway host should have default value")
|
t.Error("Gateway host should have default value")
|
||||||
}
|
}
|
||||||
if cfg.Gateway.Port == 0 {
|
if cfg.Gateway.Port == 0 {
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ type HeartbeatHandler func(prompt, channel, chatID string) *tools.ToolResult
|
||||||
// HeartbeatService manages periodic heartbeat checks
|
// HeartbeatService manages periodic heartbeat checks
|
||||||
type HeartbeatService struct {
|
type HeartbeatService struct {
|
||||||
workspace string
|
workspace string
|
||||||
|
dataDir string
|
||||||
bus *bus.MessageBus
|
bus *bus.MessageBus
|
||||||
state *state.Manager
|
state *state.Manager
|
||||||
handler HeartbeatHandler
|
handler HeartbeatHandler
|
||||||
|
|
@ -44,7 +45,7 @@ type HeartbeatService struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHeartbeatService creates a new heartbeat service
|
// NewHeartbeatService creates a new heartbeat service
|
||||||
func NewHeartbeatService(workspace string, intervalMinutes int, enabled bool) *HeartbeatService {
|
func NewHeartbeatService(workspace string, dataDir string, intervalMinutes int, enabled bool) *HeartbeatService {
|
||||||
// Apply minimum interval
|
// Apply minimum interval
|
||||||
if intervalMinutes < minIntervalMinutes && intervalMinutes != 0 {
|
if intervalMinutes < minIntervalMinutes && intervalMinutes != 0 {
|
||||||
intervalMinutes = minIntervalMinutes
|
intervalMinutes = minIntervalMinutes
|
||||||
|
|
@ -56,9 +57,10 @@ func NewHeartbeatService(workspace string, intervalMinutes int, enabled bool) *H
|
||||||
|
|
||||||
return &HeartbeatService{
|
return &HeartbeatService{
|
||||||
workspace: workspace,
|
workspace: workspace,
|
||||||
|
dataDir: dataDir,
|
||||||
interval: time.Duration(intervalMinutes) * time.Minute,
|
interval: time.Duration(intervalMinutes) * time.Minute,
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
state: state.NewManager(workspace),
|
state: state.NewManager(dataDir),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -217,7 +219,7 @@ func (hs *HeartbeatService) executeHeartbeat() {
|
||||||
|
|
||||||
// buildPrompt builds the heartbeat prompt from HEARTBEAT.md
|
// buildPrompt builds the heartbeat prompt from HEARTBEAT.md
|
||||||
func (hs *HeartbeatService) buildPrompt() string {
|
func (hs *HeartbeatService) buildPrompt() string {
|
||||||
heartbeatPath := filepath.Join(hs.workspace, "HEARTBEAT.md")
|
heartbeatPath := filepath.Join(hs.dataDir, "HEARTBEAT.md")
|
||||||
|
|
||||||
data, err := os.ReadFile(heartbeatPath)
|
data, err := os.ReadFile(heartbeatPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -249,7 +251,7 @@ If there is nothing that requires attention, respond ONLY with: HEARTBEAT_OK
|
||||||
|
|
||||||
// createDefaultHeartbeatTemplate creates the default HEARTBEAT.md file
|
// createDefaultHeartbeatTemplate creates the default HEARTBEAT.md file
|
||||||
func (hs *HeartbeatService) createDefaultHeartbeatTemplate() {
|
func (hs *HeartbeatService) createDefaultHeartbeatTemplate() {
|
||||||
heartbeatPath := filepath.Join(hs.workspace, "HEARTBEAT.md")
|
heartbeatPath := filepath.Join(hs.dataDir, "HEARTBEAT.md")
|
||||||
|
|
||||||
defaultContent := `# Heartbeat Check List
|
defaultContent := `# Heartbeat Check List
|
||||||
|
|
||||||
|
|
@ -353,7 +355,7 @@ func (hs *HeartbeatService) logError(format string, args ...any) {
|
||||||
|
|
||||||
// log writes a message to the heartbeat log file
|
// log writes a message to the heartbeat log file
|
||||||
func (hs *HeartbeatService) log(level, format string, args ...any) {
|
func (hs *HeartbeatService) log(level, format string, args ...any) {
|
||||||
logFile := filepath.Join(hs.workspace, "heartbeat.log")
|
logFile := filepath.Join(hs.dataDir, "heartbeat.log")
|
||||||
f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ func TestExecuteHeartbeat_Async(t *testing.T) {
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
hs := NewHeartbeatService(tmpDir, 30, true)
|
hs := NewHeartbeatService(tmpDir, tmpDir,30, true)
|
||||||
hs.stopChan = make(chan struct{}) // Enable for testing
|
hs.stopChan = make(chan struct{}) // Enable for testing
|
||||||
|
|
||||||
asyncCalled := false
|
asyncCalled := false
|
||||||
|
|
@ -54,7 +54,7 @@ func TestExecuteHeartbeat_Error(t *testing.T) {
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
hs := NewHeartbeatService(tmpDir, 30, true)
|
hs := NewHeartbeatService(tmpDir, tmpDir,30, true)
|
||||||
hs.stopChan = make(chan struct{}) // Enable for testing
|
hs.stopChan = make(chan struct{}) // Enable for testing
|
||||||
|
|
||||||
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
||||||
|
|
@ -92,7 +92,7 @@ func TestExecuteHeartbeat_Silent(t *testing.T) {
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
hs := NewHeartbeatService(tmpDir, 30, true)
|
hs := NewHeartbeatService(tmpDir, tmpDir,30, true)
|
||||||
hs.stopChan = make(chan struct{}) // Enable for testing
|
hs.stopChan = make(chan struct{}) // Enable for testing
|
||||||
|
|
||||||
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
||||||
|
|
@ -130,7 +130,7 @@ func TestHeartbeatService_StartStop(t *testing.T) {
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
hs := NewHeartbeatService(tmpDir, 1, true)
|
hs := NewHeartbeatService(tmpDir, tmpDir,1, true)
|
||||||
|
|
||||||
err = hs.Start()
|
err = hs.Start()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -149,7 +149,7 @@ func TestHeartbeatService_Disabled(t *testing.T) {
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
hs := NewHeartbeatService(tmpDir, 1, false)
|
hs := NewHeartbeatService(tmpDir, tmpDir,1, false)
|
||||||
|
|
||||||
if hs.enabled != false {
|
if hs.enabled != false {
|
||||||
t.Error("Expected service to be disabled")
|
t.Error("Expected service to be disabled")
|
||||||
|
|
@ -166,7 +166,7 @@ func TestExecuteHeartbeat_NilResult(t *testing.T) {
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
hs := NewHeartbeatService(tmpDir, 30, true)
|
hs := NewHeartbeatService(tmpDir, tmpDir,30, true)
|
||||||
hs.stopChan = make(chan struct{}) // Enable for testing
|
hs.stopChan = make(chan struct{}) // Enable for testing
|
||||||
|
|
||||||
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
||||||
|
|
@ -188,7 +188,7 @@ func TestLogPath(t *testing.T) {
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
hs := NewHeartbeatService(tmpDir, 30, true)
|
hs := NewHeartbeatService(tmpDir, tmpDir,30, true)
|
||||||
|
|
||||||
// Write a log entry
|
// Write a log entry
|
||||||
hs.log("INFO", "Test log entry")
|
hs.log("INFO", "Test log entry")
|
||||||
|
|
@ -208,7 +208,7 @@ func TestHeartbeatFilePath(t *testing.T) {
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
hs := NewHeartbeatService(tmpDir, 30, true)
|
hs := NewHeartbeatService(tmpDir, tmpDir,30, true)
|
||||||
|
|
||||||
// Trigger default template creation
|
// Trigger default template creation
|
||||||
hs.buildPrompt()
|
hs.buildPrompt()
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,13 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type HTTPProvider struct {
|
type HTTPProvider struct {
|
||||||
apiKey string
|
apiKey string
|
||||||
apiBase string
|
apiBase string
|
||||||
httpClient *http.Client
|
providerName string
|
||||||
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
|
func NewHTTPProvider(apiKey, apiBase, proxy, providerName string) *HTTPProvider {
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: 120 * time.Second,
|
Timeout: 120 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
@ -42,9 +43,10 @@ func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
return &HTTPProvider{
|
return &HTTPProvider{
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
apiBase: strings.TrimRight(apiBase, "/"),
|
apiBase: strings.TrimRight(apiBase, "/"),
|
||||||
httpClient: client,
|
providerName: providerName,
|
||||||
|
httpClient: client,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,8 +74,7 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too
|
||||||
}
|
}
|
||||||
|
|
||||||
if maxTokens, ok := options["max_tokens"].(int); ok {
|
if maxTokens, ok := options["max_tokens"].(int); ok {
|
||||||
lowerModel := strings.ToLower(model)
|
if p.providerName == "openai" || p.providerName == "zhipu" || p.providerName == "glm" {
|
||||||
if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") {
|
|
||||||
requestBody["max_completion_tokens"] = maxTokens
|
requestBody["max_completion_tokens"] = maxTokens
|
||||||
} else {
|
} else {
|
||||||
requestBody["max_tokens"] = maxTokens
|
requestBody["max_tokens"] = maxTokens
|
||||||
|
|
@ -82,9 +83,9 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too
|
||||||
|
|
||||||
if temperature, ok := options["temperature"].(float64); ok {
|
if temperature, ok := options["temperature"].(float64); ok {
|
||||||
lowerModel := strings.ToLower(model)
|
lowerModel := strings.ToLower(model)
|
||||||
// Kimi k2 models only support temperature=1
|
// OpenAI reasoning models (o1/o3/o4, gpt-5.x) and Kimi k2 only support temperature=1
|
||||||
if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") {
|
if p.providerName == "openai" || (strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2")) {
|
||||||
requestBody["temperature"] = 1.0
|
// Don't send temperature; let the API use its default
|
||||||
} else {
|
} else {
|
||||||
requestBody["temperature"] = temperature
|
requestBody["temperature"] = temperature
|
||||||
}
|
}
|
||||||
|
|
@ -374,6 +375,7 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
|
||||||
apiKey = cfg.Providers.OpenAI.APIKey
|
apiKey = cfg.Providers.OpenAI.APIKey
|
||||||
apiBase = cfg.Providers.OpenAI.APIBase
|
apiBase = cfg.Providers.OpenAI.APIBase
|
||||||
proxy = cfg.Providers.OpenAI.Proxy
|
proxy = cfg.Providers.OpenAI.Proxy
|
||||||
|
providerName = "openai"
|
||||||
if apiBase == "" {
|
if apiBase == "" {
|
||||||
apiBase = "https://api.openai.com/v1"
|
apiBase = "https://api.openai.com/v1"
|
||||||
}
|
}
|
||||||
|
|
@ -390,6 +392,7 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
|
||||||
apiKey = cfg.Providers.Zhipu.APIKey
|
apiKey = cfg.Providers.Zhipu.APIKey
|
||||||
apiBase = cfg.Providers.Zhipu.APIBase
|
apiBase = cfg.Providers.Zhipu.APIBase
|
||||||
proxy = cfg.Providers.Zhipu.Proxy
|
proxy = cfg.Providers.Zhipu.Proxy
|
||||||
|
providerName = "zhipu"
|
||||||
if apiBase == "" {
|
if apiBase == "" {
|
||||||
apiBase = "https://open.bigmodel.cn/api/paas/v4"
|
apiBase = "https://open.bigmodel.cn/api/paas/v4"
|
||||||
}
|
}
|
||||||
|
|
@ -446,5 +449,5 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
|
||||||
return nil, fmt.Errorf("no API base configured for provider (model: %s)", model)
|
return nil, fmt.Errorf("no API base configured for provider (model: %s)", model)
|
||||||
}
|
}
|
||||||
|
|
||||||
return NewHTTPProvider(apiKey, apiBase, proxy), nil
|
return NewHTTPProvider(apiKey, apiBase, proxy, providerName), nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,171 +0,0 @@
|
||||||
package skills
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type SkillInstaller struct {
|
|
||||||
workspace string
|
|
||||||
}
|
|
||||||
|
|
||||||
type AvailableSkill struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Repository string `json:"repository"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Author string `json:"author"`
|
|
||||||
Tags []string `json:"tags"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type BuiltinSkill struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Path string `json:"path"`
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewSkillInstaller(workspace string) *SkillInstaller {
|
|
||||||
return &SkillInstaller{
|
|
||||||
workspace: workspace,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) error {
|
|
||||||
skillDir := filepath.Join(si.workspace, "skills", filepath.Base(repo))
|
|
||||||
|
|
||||||
if _, err := os.Stat(skillDir); err == nil {
|
|
||||||
return fmt.Errorf("skill '%s' already exists", filepath.Base(repo))
|
|
||||||
}
|
|
||||||
|
|
||||||
url := fmt.Sprintf("https://raw.githubusercontent.com/%s/main/SKILL.md", repo)
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 15 * time.Second}
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to fetch skill: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return fmt.Errorf("failed to fetch skill: HTTP %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.MkdirAll(skillDir, 0755); err != nil {
|
|
||||||
return fmt.Errorf("failed to create skill directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
skillPath := filepath.Join(skillDir, "SKILL.md")
|
|
||||||
if err := os.WriteFile(skillPath, body, 0644); err != nil {
|
|
||||||
return fmt.Errorf("failed to write skill file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (si *SkillInstaller) Uninstall(skillName string) error {
|
|
||||||
skillDir := filepath.Join(si.workspace, "skills", skillName)
|
|
||||||
|
|
||||||
if _, err := os.Stat(skillDir); os.IsNotExist(err) {
|
|
||||||
return fmt.Errorf("skill '%s' not found", skillName)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.RemoveAll(skillDir); err != nil {
|
|
||||||
return fmt.Errorf("failed to remove skill: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (si *SkillInstaller) ListAvailableSkills(ctx context.Context) ([]AvailableSkill, error) {
|
|
||||||
url := "https://raw.githubusercontent.com/sipeed/picoclaw-skills/main/skills.json"
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 15 * time.Second}
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to fetch skills list: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return nil, fmt.Errorf("failed to fetch skills list: HTTP %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var skills []AvailableSkill
|
|
||||||
if err := json.Unmarshal(body, &skills); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse skills list: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return skills, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (si *SkillInstaller) ListBuiltinSkills() []BuiltinSkill {
|
|
||||||
builtinSkillsDir := filepath.Join(filepath.Dir(si.workspace), "picoclaw", "skills")
|
|
||||||
|
|
||||||
entries, err := os.ReadDir(builtinSkillsDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var skills []BuiltinSkill
|
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.IsDir() {
|
|
||||||
_ = entry
|
|
||||||
skillName := entry.Name()
|
|
||||||
skillFile := filepath.Join(builtinSkillsDir, skillName, "SKILL.md")
|
|
||||||
|
|
||||||
data, err := os.ReadFile(skillFile)
|
|
||||||
description := ""
|
|
||||||
if err == nil {
|
|
||||||
content := string(data)
|
|
||||||
if idx := strings.Index(content, "\n"); idx > 0 {
|
|
||||||
firstLine := content[:idx]
|
|
||||||
if strings.Contains(firstLine, "description:") {
|
|
||||||
descLine := strings.Index(content[idx:], "\n")
|
|
||||||
if descLine > 0 {
|
|
||||||
description = strings.TrimSpace(content[idx+descLine : idx+descLine])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// skill := BuiltinSkill{
|
|
||||||
// Name: skillName,
|
|
||||||
// Path: description,
|
|
||||||
// Enabled: true,
|
|
||||||
// }
|
|
||||||
|
|
||||||
status := "✓"
|
|
||||||
fmt.Printf(" %s %s\n", status, entry.Name())
|
|
||||||
if description != "" {
|
|
||||||
fmt.Printf(" %s\n", description)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return skills
|
|
||||||
}
|
|
||||||
|
|
@ -52,29 +52,29 @@ func (info SkillInfo) validate() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
type SkillsLoader struct {
|
type SkillsLoader struct {
|
||||||
workspace string
|
dataDir string
|
||||||
workspaceSkills string // workspace skills (项目级别)
|
dataSkills string // data dir skills
|
||||||
globalSkills string // 全局 skills (~/.picoclaw/skills)
|
globalSkills string // global skills (~/.picoclaw/skills)
|
||||||
builtinSkills string // 内置 skills
|
builtinSkills string // builtin skills
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSkillsLoader(workspace string, globalSkills string, builtinSkills string) *SkillsLoader {
|
func NewSkillsLoader(dataDir string, globalSkills string, builtinSkills string) *SkillsLoader {
|
||||||
return &SkillsLoader{
|
return &SkillsLoader{
|
||||||
workspace: workspace,
|
dataDir: dataDir,
|
||||||
workspaceSkills: filepath.Join(workspace, "skills"),
|
dataSkills: filepath.Join(dataDir, "skills"),
|
||||||
globalSkills: globalSkills, // ~/.picoclaw/skills
|
globalSkills: globalSkills, // ~/.picoclaw/skills
|
||||||
builtinSkills: builtinSkills,
|
builtinSkills: builtinSkills,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sl *SkillsLoader) ListSkills() []SkillInfo {
|
func (sl *SkillsLoader) ListSkills() []SkillInfo {
|
||||||
skills := make([]SkillInfo, 0)
|
skills := make([]SkillInfo, 0)
|
||||||
|
|
||||||
if sl.workspaceSkills != "" {
|
if sl.dataSkills != "" {
|
||||||
if dirs, err := os.ReadDir(sl.workspaceSkills); err == nil {
|
if dirs, err := os.ReadDir(sl.dataSkills); err == nil {
|
||||||
for _, dir := range dirs {
|
for _, dir := range dirs {
|
||||||
if dir.IsDir() {
|
if dir.IsDir() {
|
||||||
skillFile := filepath.Join(sl.workspaceSkills, dir.Name(), "SKILL.md")
|
skillFile := filepath.Join(sl.dataSkills, dir.Name(), "SKILL.md")
|
||||||
if _, err := os.Stat(skillFile); err == nil {
|
if _, err := os.Stat(skillFile); err == nil {
|
||||||
info := SkillInfo{
|
info := SkillInfo{
|
||||||
Name: dir.Name(),
|
Name: dir.Name(),
|
||||||
|
|
@ -87,7 +87,7 @@ func (sl *SkillsLoader) ListSkills() []SkillInfo {
|
||||||
info.Name = metadata.Name
|
info.Name = metadata.Name
|
||||||
}
|
}
|
||||||
if err := info.validate(); err != nil {
|
if err := info.validate(); err != nil {
|
||||||
slog.Warn("invalid skill from workspace", "name", info.Name, "error", err)
|
slog.Warn("invalid skill from data dir", "name", info.Name, "error", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
skills = append(skills, info)
|
skills = append(skills, info)
|
||||||
|
|
@ -181,8 +181,8 @@ func (sl *SkillsLoader) ListSkills() []SkillInfo {
|
||||||
|
|
||||||
func (sl *SkillsLoader) LoadSkill(name string) (string, bool) {
|
func (sl *SkillsLoader) LoadSkill(name string) (string, bool) {
|
||||||
// 1. 优先从 workspace skills 加载(项目级别)
|
// 1. 优先从 workspace skills 加载(项目级别)
|
||||||
if sl.workspaceSkills != "" {
|
if sl.dataSkills != "" {
|
||||||
skillFile := filepath.Join(sl.workspaceSkills, name, "SKILL.md")
|
skillFile := filepath.Join(sl.dataSkills, name, "SKILL.md")
|
||||||
if content, err := os.ReadFile(skillFile); err == nil {
|
if content, err := os.ReadFile(skillFile); err == nil {
|
||||||
return sl.stripFrontmatter(string(content)), true
|
return sl.stripFrontmatter(string(content)), true
|
||||||
}
|
}
|
||||||
|
|
@ -234,12 +234,10 @@ func (sl *SkillsLoader) BuildSkillsSummary() string {
|
||||||
for _, s := range allSkills {
|
for _, s := range allSkills {
|
||||||
escapedName := escapeXML(s.Name)
|
escapedName := escapeXML(s.Name)
|
||||||
escapedDesc := escapeXML(s.Description)
|
escapedDesc := escapeXML(s.Description)
|
||||||
escapedPath := escapeXML(s.Path)
|
|
||||||
|
|
||||||
lines = append(lines, fmt.Sprintf(" <skill>"))
|
lines = append(lines, fmt.Sprintf(" <skill>"))
|
||||||
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(" <source>%s</source>", s.Source))
|
lines = append(lines, fmt.Sprintf(" <source>%s</source>", s.Source))
|
||||||
lines = append(lines, " </skill>")
|
lines = append(lines, " </skill>")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
95
pkg/tools/memory.go
Normal file
95
pkg/tools/memory.go
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MemoryWriter is the interface for memory operations.
|
||||||
|
// Implemented by agent.MemoryStore.
|
||||||
|
type MemoryWriter interface {
|
||||||
|
WriteLongTerm(content string) error
|
||||||
|
AppendToday(content string) error
|
||||||
|
ReadLongTerm() string
|
||||||
|
ReadToday() string
|
||||||
|
}
|
||||||
|
|
||||||
|
type MemoryTool struct {
|
||||||
|
writer MemoryWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMemoryTool(writer MemoryWriter) *MemoryTool {
|
||||||
|
return &MemoryTool{writer: writer}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MemoryTool) Name() string {
|
||||||
|
return "memory"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MemoryTool) Description() string {
|
||||||
|
return "Store and retrieve persistent memory. Actions: write_long_term, append_daily, read_long_term, read_daily"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MemoryTool) Parameters() map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"action": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "The memory action to perform",
|
||||||
|
"enum": []string{"write_long_term", "append_daily", "read_long_term", "read_daily"},
|
||||||
|
},
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Content to write (required for write_long_term and append_daily)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"action"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MemoryTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||||
|
action, ok := args["action"].(string)
|
||||||
|
if !ok {
|
||||||
|
return ErrorResult("action is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "write_long_term":
|
||||||
|
content, ok := args["content"].(string)
|
||||||
|
if !ok || content == "" {
|
||||||
|
return ErrorResult("content is required for write_long_term")
|
||||||
|
}
|
||||||
|
if err := t.writer.WriteLongTerm(content); err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("failed to write long-term memory: %v", err))
|
||||||
|
}
|
||||||
|
return SilentResult("Long-term memory updated successfully")
|
||||||
|
|
||||||
|
case "append_daily":
|
||||||
|
content, ok := args["content"].(string)
|
||||||
|
if !ok || content == "" {
|
||||||
|
return ErrorResult("content is required for append_daily")
|
||||||
|
}
|
||||||
|
if err := t.writer.AppendToday(content); err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("failed to append daily note: %v", err))
|
||||||
|
}
|
||||||
|
return SilentResult("Daily note appended successfully")
|
||||||
|
|
||||||
|
case "read_long_term":
|
||||||
|
content := t.writer.ReadLongTerm()
|
||||||
|
if content == "" {
|
||||||
|
return SilentResult("No long-term memory found")
|
||||||
|
}
|
||||||
|
return SilentResult(content)
|
||||||
|
|
||||||
|
case "read_daily":
|
||||||
|
content := t.writer.ReadToday()
|
||||||
|
if content == "" {
|
||||||
|
return SilentResult("No daily notes for today")
|
||||||
|
}
|
||||||
|
return SilentResult(content)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return ErrorResult(fmt.Sprintf("unknown action: %s", action))
|
||||||
|
}
|
||||||
|
}
|
||||||
162
pkg/tools/memory_test.go
Normal file
162
pkg/tools/memory_test.go
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockMemoryWriter struct {
|
||||||
|
longTerm string
|
||||||
|
daily string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockMemoryWriter) WriteLongTerm(content string) error {
|
||||||
|
m.longTerm = content
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockMemoryWriter) AppendToday(content string) error {
|
||||||
|
m.daily += content
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockMemoryWriter) ReadLongTerm() string {
|
||||||
|
return m.longTerm
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockMemoryWriter) ReadToday() string {
|
||||||
|
return m.daily
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryTool_WriteLongTerm(t *testing.T) {
|
||||||
|
w := &mockMemoryWriter{}
|
||||||
|
tool := NewMemoryTool(w)
|
||||||
|
|
||||||
|
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"action": "write_long_term",
|
||||||
|
"content": "User prefers dark mode",
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("unexpected error: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if w.longTerm != "User prefers dark mode" {
|
||||||
|
t.Errorf("expected 'User prefers dark mode', got '%s'", w.longTerm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryTool_WriteLongTerm_MissingContent(t *testing.T) {
|
||||||
|
w := &mockMemoryWriter{}
|
||||||
|
tool := NewMemoryTool(w)
|
||||||
|
|
||||||
|
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"action": "write_long_term",
|
||||||
|
})
|
||||||
|
|
||||||
|
if !result.IsError {
|
||||||
|
t.Error("expected error for missing content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryTool_AppendDaily(t *testing.T) {
|
||||||
|
w := &mockMemoryWriter{}
|
||||||
|
tool := NewMemoryTool(w)
|
||||||
|
|
||||||
|
tool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"action": "append_daily",
|
||||||
|
"content": "Met with team.",
|
||||||
|
})
|
||||||
|
tool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"action": "append_daily",
|
||||||
|
"content": " Discussed roadmap.",
|
||||||
|
})
|
||||||
|
|
||||||
|
if w.daily != "Met with team. Discussed roadmap." {
|
||||||
|
t.Errorf("expected appended content, got '%s'", w.daily)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryTool_ReadLongTerm(t *testing.T) {
|
||||||
|
w := &mockMemoryWriter{longTerm: "stored facts"}
|
||||||
|
tool := NewMemoryTool(w)
|
||||||
|
|
||||||
|
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"action": "read_long_term",
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("unexpected error: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if result.ForLLM != "stored facts" {
|
||||||
|
t.Errorf("expected 'stored facts', got '%s'", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryTool_ReadLongTerm_Empty(t *testing.T) {
|
||||||
|
w := &mockMemoryWriter{}
|
||||||
|
tool := NewMemoryTool(w)
|
||||||
|
|
||||||
|
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"action": "read_long_term",
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.IsError {
|
||||||
|
t.Error("should not error on empty memory")
|
||||||
|
}
|
||||||
|
if result.ForLLM != "No long-term memory found" {
|
||||||
|
t.Errorf("expected empty message, got '%s'", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryTool_ReadDaily(t *testing.T) {
|
||||||
|
w := &mockMemoryWriter{daily: "today's notes"}
|
||||||
|
tool := NewMemoryTool(w)
|
||||||
|
|
||||||
|
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"action": "read_daily",
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.ForLLM != "today's notes" {
|
||||||
|
t.Errorf("expected 'today's notes', got '%s'", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryTool_UnknownAction(t *testing.T) {
|
||||||
|
w := &mockMemoryWriter{}
|
||||||
|
tool := NewMemoryTool(w)
|
||||||
|
|
||||||
|
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"action": "delete_all",
|
||||||
|
})
|
||||||
|
|
||||||
|
if !result.IsError {
|
||||||
|
t.Error("expected error for unknown action")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryTool_MissingAction(t *testing.T) {
|
||||||
|
w := &mockMemoryWriter{}
|
||||||
|
tool := NewMemoryTool(w)
|
||||||
|
|
||||||
|
result := tool.Execute(context.Background(), map[string]interface{}{})
|
||||||
|
|
||||||
|
if !result.IsError {
|
||||||
|
t.Error("expected error for missing action")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryTool_NameAndDescription(t *testing.T) {
|
||||||
|
w := &mockMemoryWriter{}
|
||||||
|
tool := NewMemoryTool(w)
|
||||||
|
|
||||||
|
if tool.Name() != "memory" {
|
||||||
|
t.Errorf("expected name 'memory', got '%s'", tool.Name())
|
||||||
|
}
|
||||||
|
if tool.Description() == "" {
|
||||||
|
t.Error("description should not be empty")
|
||||||
|
}
|
||||||
|
params := tool.Parameters()
|
||||||
|
if params == nil {
|
||||||
|
t.Error("parameters should not be nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
77
pkg/tools/skills.go
Normal file
77
pkg/tools/skills.go
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SkillTool struct {
|
||||||
|
loader *skills.SkillsLoader
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSkillTool(loader *skills.SkillsLoader) *SkillTool {
|
||||||
|
return &SkillTool{loader: loader}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SkillTool) Name() string {
|
||||||
|
return "skill"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SkillTool) Description() string {
|
||||||
|
return "Read skill instructions. Actions: skill_list (list available skills), skill_read (read a skill's content)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SkillTool) Parameters() map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"action": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "The skill action to perform",
|
||||||
|
"enum": []string{"skill_list", "skill_read"},
|
||||||
|
},
|
||||||
|
"name": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Skill name (required for skill_read)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"action"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SkillTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||||
|
action, ok := args["action"].(string)
|
||||||
|
if !ok {
|
||||||
|
return ErrorResult("action is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "skill_list":
|
||||||
|
allSkills := t.loader.ListSkills()
|
||||||
|
if len(allSkills) == 0 {
|
||||||
|
return SilentResult("No skills available")
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, s := range allSkills {
|
||||||
|
sb.WriteString(fmt.Sprintf("- %s (%s): %s\n", s.Name, s.Source, s.Description))
|
||||||
|
}
|
||||||
|
return SilentResult(sb.String())
|
||||||
|
|
||||||
|
case "skill_read":
|
||||||
|
name, ok := args["name"].(string)
|
||||||
|
if !ok || name == "" {
|
||||||
|
return ErrorResult("name is required for skill_read")
|
||||||
|
}
|
||||||
|
content, found := t.loader.LoadSkill(name)
|
||||||
|
if !found {
|
||||||
|
return ErrorResult(fmt.Sprintf("skill %q not found", name))
|
||||||
|
}
|
||||||
|
return SilentResult(content)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return ErrorResult(fmt.Sprintf("unknown action: %s", action))
|
||||||
|
}
|
||||||
|
}
|
||||||
163
pkg/tools/skills_test.go
Normal file
163
pkg/tools/skills_test.go
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupSkillFixture(t *testing.T) (string, *skills.SkillsLoader) {
|
||||||
|
t.Helper()
|
||||||
|
tmpDir, err := os.MkdirTemp("", "skill-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a test skill
|
||||||
|
skillDir := filepath.Join(tmpDir, "skills", "test-skill")
|
||||||
|
os.MkdirAll(skillDir, 0755)
|
||||||
|
os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(`---
|
||||||
|
name: test-skill
|
||||||
|
description: "A test skill for unit testing"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Test Skill
|
||||||
|
|
||||||
|
This is the skill content.
|
||||||
|
`), 0644)
|
||||||
|
|
||||||
|
loader := skills.NewSkillsLoader(tmpDir, "", "")
|
||||||
|
return tmpDir, loader
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillTool_SkillList(t *testing.T) {
|
||||||
|
tmpDir, loader := setupSkillFixture(t)
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
tool := NewSkillTool(loader)
|
||||||
|
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"action": "skill_list",
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("unexpected error: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if result.ForLLM == "No skills available" {
|
||||||
|
t.Error("expected skills to be listed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillTool_SkillList_Empty(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "skill-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
loader := skills.NewSkillsLoader(tmpDir, "", "")
|
||||||
|
tool := NewSkillTool(loader)
|
||||||
|
|
||||||
|
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"action": "skill_list",
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.IsError {
|
||||||
|
t.Error("empty list should not be an error")
|
||||||
|
}
|
||||||
|
if result.ForLLM != "No skills available" {
|
||||||
|
t.Errorf("expected 'No skills available', got '%s'", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillTool_SkillRead(t *testing.T) {
|
||||||
|
tmpDir, loader := setupSkillFixture(t)
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
tool := NewSkillTool(loader)
|
||||||
|
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"action": "skill_read",
|
||||||
|
"name": "test-skill",
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("unexpected error: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if result.ForLLM == "" {
|
||||||
|
t.Error("expected skill content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillTool_SkillRead_NotFound(t *testing.T) {
|
||||||
|
tmpDir, loader := setupSkillFixture(t)
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
tool := NewSkillTool(loader)
|
||||||
|
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"action": "skill_read",
|
||||||
|
"name": "nonexistent",
|
||||||
|
})
|
||||||
|
|
||||||
|
if !result.IsError {
|
||||||
|
t.Error("expected error for nonexistent skill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillTool_SkillRead_MissingName(t *testing.T) {
|
||||||
|
tmpDir, loader := setupSkillFixture(t)
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
tool := NewSkillTool(loader)
|
||||||
|
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"action": "skill_read",
|
||||||
|
})
|
||||||
|
|
||||||
|
if !result.IsError {
|
||||||
|
t.Error("expected error for missing name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillTool_UnknownAction(t *testing.T) {
|
||||||
|
tmpDir, loader := setupSkillFixture(t)
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
tool := NewSkillTool(loader)
|
||||||
|
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"action": "skill_delete",
|
||||||
|
})
|
||||||
|
|
||||||
|
if !result.IsError {
|
||||||
|
t.Error("expected error for unknown action")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillTool_MissingAction(t *testing.T) {
|
||||||
|
tmpDir, loader := setupSkillFixture(t)
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
tool := NewSkillTool(loader)
|
||||||
|
result := tool.Execute(context.Background(), map[string]interface{}{})
|
||||||
|
|
||||||
|
if !result.IsError {
|
||||||
|
t.Error("expected error for missing action")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillTool_NameAndDescription(t *testing.T) {
|
||||||
|
tmpDir, loader := setupSkillFixture(t)
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
tool := NewSkillTool(loader)
|
||||||
|
|
||||||
|
if tool.Name() != "skill" {
|
||||||
|
t.Errorf("expected name 'skill', got '%s'", tool.Name())
|
||||||
|
}
|
||||||
|
if tool.Description() == "" {
|
||||||
|
t.Error("description should not be empty")
|
||||||
|
}
|
||||||
|
if tool.Parameters() == nil {
|
||||||
|
t.Error("parameters should not be nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue