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)
|
||||
}
|
||||
|
||||
workspace := cfg.WorkspacePath()
|
||||
installer := skills.NewSkillInstaller(workspace)
|
||||
dataDir := cfg.DataPath()
|
||||
// 获取全局配置目录和内置 skills 目录
|
||||
globalDir := filepath.Dir(getConfigPath())
|
||||
globalSkillsDir := filepath.Join(globalDir, "skills")
|
||||
builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills")
|
||||
skillsLoader := skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir)
|
||||
skillsLoader := skills.NewSkillsLoader(dataDir, globalSkillsDir, builtinSkillsDir)
|
||||
|
||||
switch subcommand {
|
||||
case "list":
|
||||
skillsListCmd(skillsLoader)
|
||||
case "install":
|
||||
skillsInstallCmd(installer)
|
||||
case "remove", "uninstall":
|
||||
if len(os.Args) < 4 {
|
||||
fmt.Println("Usage: picoclaw skills remove <skill-name>")
|
||||
return
|
||||
}
|
||||
skillsRemoveCmd(installer, os.Args[3])
|
||||
skillsRemoveCmd(dataDir, os.Args[3])
|
||||
case "install-builtin":
|
||||
skillsInstallBuiltinCmd(workspace)
|
||||
skillsInstallBuiltinCmd(dataDir)
|
||||
case "list-builtin":
|
||||
skillsListBuiltinCmd()
|
||||
case "search":
|
||||
skillsSearchCmd(installer)
|
||||
case "show":
|
||||
if len(os.Args) < 4 {
|
||||
fmt.Println("Usage: picoclaw skills show <skill-name>")
|
||||
|
|
@ -237,7 +232,9 @@ func onboard() {
|
|||
}
|
||||
|
||||
workspace := cfg.WorkspacePath()
|
||||
createWorkspaceTemplates(workspace)
|
||||
dataDir := cfg.DataPath()
|
||||
os.MkdirAll(workspace, 0755)
|
||||
createWorkspaceTemplates(dataDir)
|
||||
|
||||
fmt.Printf("%s picoclaw is ready!\n", logo)
|
||||
fmt.Println("\nNext steps:")
|
||||
|
|
@ -562,10 +559,11 @@ func gatewayCmd() {
|
|||
})
|
||||
|
||||
// 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(
|
||||
cfg.WorkspacePath(),
|
||||
cfg.DataPath(),
|
||||
cfg.Heartbeat.Interval,
|
||||
cfg.Heartbeat.Enabled,
|
||||
)
|
||||
|
|
@ -647,7 +645,7 @@ func gatewayCmd() {
|
|||
}
|
||||
fmt.Println("✓ Heartbeat service started")
|
||||
|
||||
stateManager := state.NewManager(cfg.WorkspacePath())
|
||||
stateManager := state.NewManager(cfg.DataPath())
|
||||
deviceService := devices.NewService(devices.Config{
|
||||
Enabled: cfg.Devices.Enabled,
|
||||
MonitorUSB: cfg.Devices.MonitorUSB,
|
||||
|
|
@ -987,13 +985,13 @@ func getConfigPath() string {
|
|||
return filepath.Join(home, ".picoclaw", "config.json")
|
||||
}
|
||||
|
||||
func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool) *cron.CronService {
|
||||
cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
|
||||
func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, dataDir string, restrict bool) *cron.CronService {
|
||||
cronStorePath := filepath.Join(dataDir, "cron", "jobs.json")
|
||||
|
||||
// Create cron service
|
||||
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)
|
||||
agentLoop.RegisterTool(cronTool)
|
||||
|
||||
|
|
@ -1025,7 +1023,7 @@ func cronCmd() {
|
|||
return
|
||||
}
|
||||
|
||||
cronStorePath := filepath.Join(cfg.WorkspacePath(), "cron", "jobs.json")
|
||||
cronStorePath := filepath.Join(cfg.DataPath(), "cron", "jobs.json")
|
||||
|
||||
switch subcommand {
|
||||
case "list":
|
||||
|
|
@ -1227,16 +1225,16 @@ func cronEnableCmd(storePath string, disable bool) {
|
|||
func skillsHelp() {
|
||||
fmt.Println("\nSkills commands:")
|
||||
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(" list-builtin List available builtin skills")
|
||||
fmt.Println(" install-builtin Install all builtin skills to workspace")
|
||||
fmt.Println(" list-builtin List available builtin skills")
|
||||
fmt.Println(" remove <name> Remove installed skill")
|
||||
fmt.Println(" search Search available skills")
|
||||
fmt.Println(" show <name> Show skill details")
|
||||
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(" picoclaw skills list")
|
||||
fmt.Println(" picoclaw skills install sipeed/picoclaw-skills/weather")
|
||||
fmt.Println(" picoclaw skills install-builtin")
|
||||
fmt.Println(" picoclaw skills list-builtin")
|
||||
fmt.Println(" picoclaw skills remove weather")
|
||||
|
|
@ -1260,31 +1258,16 @@ func skillsListCmd(loader *skills.SkillsLoader) {
|
|||
}
|
||||
}
|
||||
|
||||
func skillsInstallCmd(installer *skills.SkillInstaller) {
|
||||
if len(os.Args) < 4 {
|
||||
fmt.Println("Usage: picoclaw skills install <github-repo>")
|
||||
fmt.Println("Example: picoclaw skills install sipeed/picoclaw-skills/weather")
|
||||
return
|
||||
}
|
||||
func skillsRemoveCmd(dataDir string, skillName string) {
|
||||
skillDir := filepath.Join(dataDir, "skills", skillName)
|
||||
|
||||
repo := os.Args[3]
|
||||
fmt.Printf("Installing skill from %s...\n", repo)
|
||||
|
||||
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)
|
||||
if _, err := os.Stat(skillDir); os.IsNotExist(err) {
|
||||
fmt.Printf("✗ Skill '%s' not found\n", skillName)
|
||||
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)
|
||||
|
||||
if err := installer.Uninstall(skillName); err != nil {
|
||||
if err := os.RemoveAll(skillDir); err != nil {
|
||||
fmt.Printf("✗ Failed to remove skill: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
|
@ -1292,9 +1275,9 @@ func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) {
|
|||
fmt.Printf("✓ Skill '%s' removed successfully!\n", skillName)
|
||||
}
|
||||
|
||||
func skillsInstallBuiltinCmd(workspace string) {
|
||||
func skillsInstallBuiltinCmd(dataDir string) {
|
||||
builtinSkillsDir := "./picoclaw/skills"
|
||||
workspaceSkillsDir := filepath.Join(workspace, "skills")
|
||||
skillsDir := filepath.Join(dataDir, "skills")
|
||||
|
||||
fmt.Printf("Copying builtin skills to workspace...\n")
|
||||
|
||||
|
|
@ -1307,19 +1290,19 @@ func skillsInstallBuiltinCmd(workspace string) {
|
|||
|
||||
for _, skillName := range skillsToInstall {
|
||||
builtinPath := filepath.Join(builtinSkillsDir, skillName)
|
||||
workspacePath := filepath.Join(workspaceSkillsDir, skillName)
|
||||
destPath := filepath.Join(skillsDir, skillName)
|
||||
|
||||
if _, err := os.Stat(builtinPath); err != nil {
|
||||
fmt.Printf("⊘ Builtin skill '%s' not found: %v\n", skillName, err)
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
content, ok := loader.LoadSkill(skillName)
|
||||
if !ok {
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@
|
|||
},
|
||||
"maixcam": {
|
||||
"enabled": false,
|
||||
"host": "0.0.0.0",
|
||||
"port": 18790,
|
||||
"host": "127.0.0.1",
|
||||
"port": 18792,
|
||||
"allow_from": []
|
||||
},
|
||||
"whatsapp": {
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
"enabled": false,
|
||||
"channel_secret": "YOUR_LINE_CHANNEL_SECRET",
|
||||
"channel_access_token": "YOUR_LINE_CHANNEL_ACCESS_TOKEN",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_host": "127.0.0.1",
|
||||
"webhook_port": 18791,
|
||||
"webhook_path": "/webhook/line",
|
||||
"allow_from": []
|
||||
|
|
@ -139,7 +139,11 @@
|
|||
"monitor_usb": true
|
||||
},
|
||||
"gateway": {
|
||||
"host": "0.0.0.0",
|
||||
"host": "127.0.0.1",
|
||||
"port": 18790
|
||||
},
|
||||
"rate_limits": {
|
||||
"max_tool_calls_per_minute": 60,
|
||||
"max_requests_per_minute": 30
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import (
|
|||
|
||||
type ContextBuilder struct {
|
||||
workspace string
|
||||
dataDir string
|
||||
skillsLoader *skills.SkillsLoader
|
||||
memory *MemoryStore
|
||||
tools *tools.ToolRegistry // Direct reference to tool registry
|
||||
|
|
@ -29,7 +30,7 @@ func getGlobalConfigDir() string {
|
|||
return filepath.Join(home, ".picoclaw")
|
||||
}
|
||||
|
||||
func NewContextBuilder(workspace string) *ContextBuilder {
|
||||
func NewContextBuilder(workspace string, dataDir string) *ContextBuilder {
|
||||
// builtin skills: skills directory in current project
|
||||
// Use the skills/ directory under the current working directory
|
||||
wd, _ := os.Getwd()
|
||||
|
|
@ -38,11 +39,22 @@ func NewContextBuilder(workspace string) *ContextBuilder {
|
|||
|
||||
return &ContextBuilder{
|
||||
workspace: workspace,
|
||||
skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir),
|
||||
memory: NewMemoryStore(workspace),
|
||||
dataDir: dataDir,
|
||||
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.
|
||||
func (cb *ContextBuilder) SetToolsRegistry(registry *tools.ToolRegistry) {
|
||||
cb.tools = registry
|
||||
|
|
@ -68,9 +80,6 @@ You are picoclaw, a helpful AI assistant.
|
|||
|
||||
## Workspace
|
||||
Your workspace is at: %s
|
||||
- Memory: %s/memory/MEMORY.md
|
||||
- Daily Notes: %s/memory/YYYYMM/YYYYMMDD.md
|
||||
- Skills: %s/skills/{skill-name}/SKILL.md
|
||||
|
||||
%s
|
||||
|
||||
|
|
@ -80,8 +89,12 @@ Your workspace is at: %s
|
|||
|
||||
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`,
|
||||
now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath)
|
||||
3. **Memory** - Use the memory tool to store and retrieve information.
|
||||
- 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 {
|
||||
|
|
@ -118,12 +131,12 @@ func (cb *ContextBuilder) BuildSystemPrompt() string {
|
|||
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()
|
||||
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.
|
||||
The following skills extend your capabilities. To use a skill, call the skill_read tool with the skill name.
|
||||
|
||||
%s`, skillsSummary))
|
||||
}
|
||||
|
|
@ -148,7 +161,7 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
|
|||
|
||||
var result string
|
||||
for _, filename := range bootstrapFiles {
|
||||
filePath := filepath.Join(cb.workspace, filename)
|
||||
filePath := filepath.Join(cb.dataDir, filename)
|
||||
if data, err := os.ReadFile(filePath); err == nil {
|
||||
result += fmt.Sprintf("## %s\n\n%s\n\n", filename, string(data))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ type AgentLoop struct {
|
|||
running atomic.Bool
|
||||
summarizing sync.Map // Tracks which sessions are currently being summarized
|
||||
channelManager *channels.Manager
|
||||
rateLimiter *rateLimiter
|
||||
}
|
||||
|
||||
// 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 {
|
||||
workspace := cfg.WorkspacePath()
|
||||
dataDir := cfg.DataPath()
|
||||
os.MkdirAll(workspace, 0755)
|
||||
os.MkdirAll(dataDir, 0755)
|
||||
|
||||
restrict := cfg.Agents.Defaults.RestrictToWorkspace
|
||||
|
||||
|
|
@ -133,15 +136,25 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
|
|||
subagentTool := tools.NewSubagentTool(subagentManager)
|
||||
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
|
||||
stateManager := state.NewManager(workspace)
|
||||
stateManager := state.NewManager(dataDir)
|
||||
|
||||
// Create context builder and set tools registry
|
||||
contextBuilder := NewContextBuilder(workspace)
|
||||
contextBuilder := NewContextBuilder(workspace, dataDir)
|
||||
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{
|
||||
bus: msgBus,
|
||||
provider: provider,
|
||||
|
|
@ -154,6 +167,7 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
|
|||
contextBuilder: contextBuilder,
|
||||
tools: toolsRegistry,
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
if response, handled := al.handleCommand(ctx, msg); handled {
|
||||
return response, nil
|
||||
|
|
@ -643,6 +667,23 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
|
|||
|
||||
// Execute tool calls
|
||||
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
|
||||
argsJSON, _ := json.Marshal(tc.Arguments)
|
||||
argsPreview := utils.Truncate(string(argsJSON), 200)
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ func TestRecordLastChannel(t *testing.T) {
|
|||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
DataDir: tmpDir,
|
||||
Model: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
|
|
@ -86,6 +87,7 @@ func TestRecordLastChatID(t *testing.T) {
|
|||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
DataDir: tmpDir,
|
||||
Model: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
|
|
@ -131,6 +133,7 @@ func TestNewAgentLoop_StateInitialized(t *testing.T) {
|
|||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
DataDir: tmpDir,
|
||||
Model: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
|
|
@ -167,6 +170,7 @@ func TestToolRegistry_ToolRegistration(t *testing.T) {
|
|||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
DataDir: tmpDir,
|
||||
Model: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
|
|
@ -213,6 +217,7 @@ func TestToolContext_Updates(t *testing.T) {
|
|||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
DataDir: tmpDir,
|
||||
Model: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
|
|
@ -244,6 +249,7 @@ func TestToolRegistry_GetDefinitions(t *testing.T) {
|
|||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
DataDir: tmpDir,
|
||||
Model: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
|
|
@ -288,6 +294,7 @@ func TestAgentLoop_GetStartupInfo(t *testing.T) {
|
|||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
DataDir: tmpDir,
|
||||
Model: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
|
|
@ -335,6 +342,7 @@ func TestCreateToolRegistry_ExecDisabled(t *testing.T) {
|
|||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
DataDir: tmpDir,
|
||||
Model: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
|
|
@ -374,6 +382,7 @@ func TestCreateToolRegistry_ExecEnabled(t *testing.T) {
|
|||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
DataDir: tmpDir,
|
||||
Model: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
|
|
@ -418,6 +427,7 @@ func TestCreateToolRegistry_I2CDisabled(t *testing.T) {
|
|||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
DataDir: tmpDir,
|
||||
Model: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
|
|
@ -455,6 +465,7 @@ func TestCreateToolRegistry_I2CEnabled(t *testing.T) {
|
|||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
DataDir: tmpDir,
|
||||
Model: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
|
|
@ -497,6 +508,7 @@ func TestCreateToolRegistry_SPIDisabled(t *testing.T) {
|
|||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
DataDir: tmpDir,
|
||||
Model: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
|
|
@ -534,6 +546,7 @@ func TestCreateToolRegistry_SPIEnabled(t *testing.T) {
|
|||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
DataDir: tmpDir,
|
||||
Model: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
|
|
@ -576,6 +589,7 @@ func TestAgentLoop_Stop(t *testing.T) {
|
|||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
DataDir: tmpDir,
|
||||
Model: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
|
|
@ -698,6 +712,7 @@ func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) {
|
|||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
DataDir: tmpDir,
|
||||
Model: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
|
|
@ -740,6 +755,7 @@ func TestToolResult_UserFacingToolDoesSendMessage(t *testing.T) {
|
|||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
DataDir: tmpDir,
|
||||
Model: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
|
|
@ -805,6 +821,7 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
|
|||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
DataDir: tmpDir,
|
||||
Model: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
|
|
|
|||
|
|
@ -17,22 +17,22 @@ import (
|
|||
// - Long-term memory: memory/MEMORY.md
|
||||
// - Daily notes: memory/YYYYMM/YYYYMMDD.md
|
||||
type MemoryStore struct {
|
||||
workspace string
|
||||
dataDir string
|
||||
memoryDir 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.
|
||||
func NewMemoryStore(workspace string) *MemoryStore {
|
||||
memoryDir := filepath.Join(workspace, "memory")
|
||||
func NewMemoryStore(dataDir string) *MemoryStore {
|
||||
memoryDir := filepath.Join(dataDir, "memory")
|
||||
memoryFile := filepath.Join(memoryDir, "MEMORY.md")
|
||||
|
||||
// Ensure memory directory exists
|
||||
os.MkdirAll(memoryDir, 0755)
|
||||
|
||||
return &MemoryStore{
|
||||
workspace: workspace,
|
||||
dataDir: dataDir,
|
||||
memoryDir: memoryDir,
|
||||
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 {
|
||||
Agents AgentsConfig `json:"agents"`
|
||||
Channels ChannelsConfig `json:"channels"`
|
||||
Providers ProvidersConfig `json:"providers"`
|
||||
Gateway GatewayConfig `json:"gateway"`
|
||||
Tools ToolsConfig `json:"tools"`
|
||||
Heartbeat HeartbeatConfig `json:"heartbeat"`
|
||||
Devices DevicesConfig `json:"devices"`
|
||||
mu sync.RWMutex
|
||||
Agents AgentsConfig `json:"agents"`
|
||||
Channels ChannelsConfig `json:"channels"`
|
||||
Providers ProvidersConfig `json:"providers"`
|
||||
Gateway GatewayConfig `json:"gateway"`
|
||||
Tools ToolsConfig `json:"tools"`
|
||||
Heartbeat HeartbeatConfig `json:"heartbeat"`
|
||||
Devices DevicesConfig `json:"devices"`
|
||||
RateLimits RateLimitsConfig `json:"rate_limits"`
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type AgentsConfig struct {
|
||||
|
|
@ -60,6 +61,7 @@ type AgentsConfig struct {
|
|||
|
||||
type AgentDefaults struct {
|
||||
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"`
|
||||
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
|
||||
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"`
|
||||
}
|
||||
|
||||
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 {
|
||||
Anthropic ProviderConfig `json:"anthropic"`
|
||||
OpenAI ProviderConfig `json:"openai"`
|
||||
|
|
@ -235,6 +242,7 @@ func DefaultConfig() *Config {
|
|||
Agents: AgentsConfig{
|
||||
Defaults: AgentDefaults{
|
||||
Workspace: "~/.picoclaw/workspace",
|
||||
DataDir: "~/.picoclaw/data",
|
||||
RestrictToWorkspace: true,
|
||||
Provider: "",
|
||||
Model: "glm-4.7",
|
||||
|
|
@ -269,8 +277,8 @@ func DefaultConfig() *Config {
|
|||
},
|
||||
MaixCam: MaixCamConfig{
|
||||
Enabled: false,
|
||||
Host: "0.0.0.0",
|
||||
Port: 18790,
|
||||
Host: "127.0.0.1",
|
||||
Port: 18792,
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
QQ: QQConfig{
|
||||
|
|
@ -295,7 +303,7 @@ func DefaultConfig() *Config {
|
|||
Enabled: false,
|
||||
ChannelSecret: "",
|
||||
ChannelAccessToken: "",
|
||||
WebhookHost: "0.0.0.0",
|
||||
WebhookHost: "127.0.0.1",
|
||||
WebhookPort: 18791,
|
||||
WebhookPath: "/webhook/line",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
|
|
@ -322,7 +330,7 @@ func DefaultConfig() *Config {
|
|||
ShengSuanYun: ProviderConfig{},
|
||||
},
|
||||
Gateway: GatewayConfig{
|
||||
Host: "0.0.0.0",
|
||||
Host: "127.0.0.1",
|
||||
Port: 18790,
|
||||
},
|
||||
Tools: ToolsConfig{
|
||||
|
|
@ -355,6 +363,10 @@ func DefaultConfig() *Config {
|
|||
Enabled: false,
|
||||
MonitorUSB: true,
|
||||
},
|
||||
RateLimits: RateLimitsConfig{
|
||||
MaxToolCallsPerMinute: 60,
|
||||
MaxRequestsPerMinute: 30,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -403,6 +415,12 @@ func (c *Config) WorkspacePath() string {
|
|||
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 {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ func TestDefaultConfig_Temperature(t *testing.T) {
|
|||
func TestDefaultConfig_Gateway(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
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
|
||||
func TestConfig_Complete(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
|
@ -218,7 +246,7 @@ func TestConfig_Complete(t *testing.T) {
|
|||
if cfg.Agents.Defaults.MaxToolIterations == 0 {
|
||||
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")
|
||||
}
|
||||
if cfg.Gateway.Port == 0 {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ type HeartbeatHandler func(prompt, channel, chatID string) *tools.ToolResult
|
|||
// HeartbeatService manages periodic heartbeat checks
|
||||
type HeartbeatService struct {
|
||||
workspace string
|
||||
dataDir string
|
||||
bus *bus.MessageBus
|
||||
state *state.Manager
|
||||
handler HeartbeatHandler
|
||||
|
|
@ -44,7 +45,7 @@ type HeartbeatService struct {
|
|||
}
|
||||
|
||||
// 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
|
||||
if intervalMinutes < minIntervalMinutes && intervalMinutes != 0 {
|
||||
intervalMinutes = minIntervalMinutes
|
||||
|
|
@ -56,9 +57,10 @@ func NewHeartbeatService(workspace string, intervalMinutes int, enabled bool) *H
|
|||
|
||||
return &HeartbeatService{
|
||||
workspace: workspace,
|
||||
dataDir: dataDir,
|
||||
interval: time.Duration(intervalMinutes) * time.Minute,
|
||||
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
|
||||
func (hs *HeartbeatService) buildPrompt() string {
|
||||
heartbeatPath := filepath.Join(hs.workspace, "HEARTBEAT.md")
|
||||
heartbeatPath := filepath.Join(hs.dataDir, "HEARTBEAT.md")
|
||||
|
||||
data, err := os.ReadFile(heartbeatPath)
|
||||
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
|
||||
func (hs *HeartbeatService) createDefaultHeartbeatTemplate() {
|
||||
heartbeatPath := filepath.Join(hs.workspace, "HEARTBEAT.md")
|
||||
heartbeatPath := filepath.Join(hs.dataDir, "HEARTBEAT.md")
|
||||
|
||||
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
|
||||
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)
|
||||
if err != nil {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ func TestExecuteHeartbeat_Async(t *testing.T) {
|
|||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
hs := NewHeartbeatService(tmpDir, 30, true)
|
||||
hs := NewHeartbeatService(tmpDir, tmpDir,30, true)
|
||||
hs.stopChan = make(chan struct{}) // Enable for testing
|
||||
|
||||
asyncCalled := false
|
||||
|
|
@ -54,7 +54,7 @@ func TestExecuteHeartbeat_Error(t *testing.T) {
|
|||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
hs := NewHeartbeatService(tmpDir, 30, true)
|
||||
hs := NewHeartbeatService(tmpDir, tmpDir,30, true)
|
||||
hs.stopChan = make(chan struct{}) // Enable for testing
|
||||
|
||||
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
||||
|
|
@ -92,7 +92,7 @@ func TestExecuteHeartbeat_Silent(t *testing.T) {
|
|||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
hs := NewHeartbeatService(tmpDir, 30, true)
|
||||
hs := NewHeartbeatService(tmpDir, tmpDir,30, true)
|
||||
hs.stopChan = make(chan struct{}) // Enable for testing
|
||||
|
||||
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
||||
|
|
@ -130,7 +130,7 @@ func TestHeartbeatService_StartStop(t *testing.T) {
|
|||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
hs := NewHeartbeatService(tmpDir, 1, true)
|
||||
hs := NewHeartbeatService(tmpDir, tmpDir,1, true)
|
||||
|
||||
err = hs.Start()
|
||||
if err != nil {
|
||||
|
|
@ -149,7 +149,7 @@ func TestHeartbeatService_Disabled(t *testing.T) {
|
|||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
hs := NewHeartbeatService(tmpDir, 1, false)
|
||||
hs := NewHeartbeatService(tmpDir, tmpDir,1, false)
|
||||
|
||||
if hs.enabled != false {
|
||||
t.Error("Expected service to be disabled")
|
||||
|
|
@ -166,7 +166,7 @@ func TestExecuteHeartbeat_NilResult(t *testing.T) {
|
|||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
hs := NewHeartbeatService(tmpDir, 30, true)
|
||||
hs := NewHeartbeatService(tmpDir, tmpDir,30, true)
|
||||
hs.stopChan = make(chan struct{}) // Enable for testing
|
||||
|
||||
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
||||
|
|
@ -188,7 +188,7 @@ func TestLogPath(t *testing.T) {
|
|||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
hs := NewHeartbeatService(tmpDir, 30, true)
|
||||
hs := NewHeartbeatService(tmpDir, tmpDir,30, true)
|
||||
|
||||
// Write a log entry
|
||||
hs.log("INFO", "Test log entry")
|
||||
|
|
@ -208,7 +208,7 @@ func TestHeartbeatFilePath(t *testing.T) {
|
|||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
hs := NewHeartbeatService(tmpDir, 30, true)
|
||||
hs := NewHeartbeatService(tmpDir, tmpDir,30, true)
|
||||
|
||||
// Trigger default template creation
|
||||
hs.buildPrompt()
|
||||
|
|
|
|||
|
|
@ -22,12 +22,13 @@ import (
|
|||
)
|
||||
|
||||
type HTTPProvider struct {
|
||||
apiKey string
|
||||
apiBase string
|
||||
httpClient *http.Client
|
||||
apiKey string
|
||||
apiBase string
|
||||
providerName string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
|
||||
func NewHTTPProvider(apiKey, apiBase, proxy, providerName string) *HTTPProvider {
|
||||
client := &http.Client{
|
||||
Timeout: 120 * time.Second,
|
||||
}
|
||||
|
|
@ -42,9 +43,10 @@ func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
|
|||
}
|
||||
|
||||
return &HTTPProvider{
|
||||
apiKey: apiKey,
|
||||
apiBase: strings.TrimRight(apiBase, "/"),
|
||||
httpClient: client,
|
||||
apiKey: apiKey,
|
||||
apiBase: strings.TrimRight(apiBase, "/"),
|
||||
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 {
|
||||
lowerModel := strings.ToLower(model)
|
||||
if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") {
|
||||
if p.providerName == "openai" || p.providerName == "zhipu" || p.providerName == "glm" {
|
||||
requestBody["max_completion_tokens"] = maxTokens
|
||||
} else {
|
||||
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 {
|
||||
lowerModel := strings.ToLower(model)
|
||||
// Kimi k2 models only support temperature=1
|
||||
if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") {
|
||||
requestBody["temperature"] = 1.0
|
||||
// OpenAI reasoning models (o1/o3/o4, gpt-5.x) and Kimi k2 only support temperature=1
|
||||
if p.providerName == "openai" || (strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2")) {
|
||||
// Don't send temperature; let the API use its default
|
||||
} else {
|
||||
requestBody["temperature"] = temperature
|
||||
}
|
||||
|
|
@ -374,6 +375,7 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
|
|||
apiKey = cfg.Providers.OpenAI.APIKey
|
||||
apiBase = cfg.Providers.OpenAI.APIBase
|
||||
proxy = cfg.Providers.OpenAI.Proxy
|
||||
providerName = "openai"
|
||||
if apiBase == "" {
|
||||
apiBase = "https://api.openai.com/v1"
|
||||
}
|
||||
|
|
@ -390,6 +392,7 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
|
|||
apiKey = cfg.Providers.Zhipu.APIKey
|
||||
apiBase = cfg.Providers.Zhipu.APIBase
|
||||
proxy = cfg.Providers.Zhipu.Proxy
|
||||
providerName = "zhipu"
|
||||
if apiBase == "" {
|
||||
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 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 {
|
||||
workspace string
|
||||
workspaceSkills string // workspace skills (项目级别)
|
||||
globalSkills string // 全局 skills (~/.picoclaw/skills)
|
||||
builtinSkills string // 内置 skills
|
||||
dataDir string
|
||||
dataSkills string // data dir skills
|
||||
globalSkills string // global skills (~/.picoclaw/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{
|
||||
workspace: workspace,
|
||||
workspaceSkills: filepath.Join(workspace, "skills"),
|
||||
globalSkills: globalSkills, // ~/.picoclaw/skills
|
||||
builtinSkills: builtinSkills,
|
||||
dataDir: dataDir,
|
||||
dataSkills: filepath.Join(dataDir, "skills"),
|
||||
globalSkills: globalSkills, // ~/.picoclaw/skills
|
||||
builtinSkills: builtinSkills,
|
||||
}
|
||||
}
|
||||
|
||||
func (sl *SkillsLoader) ListSkills() []SkillInfo {
|
||||
skills := make([]SkillInfo, 0)
|
||||
|
||||
if sl.workspaceSkills != "" {
|
||||
if dirs, err := os.ReadDir(sl.workspaceSkills); err == nil {
|
||||
if sl.dataSkills != "" {
|
||||
if dirs, err := os.ReadDir(sl.dataSkills); err == nil {
|
||||
for _, dir := range dirs {
|
||||
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 {
|
||||
info := SkillInfo{
|
||||
Name: dir.Name(),
|
||||
|
|
@ -87,7 +87,7 @@ func (sl *SkillsLoader) ListSkills() []SkillInfo {
|
|||
info.Name = metadata.Name
|
||||
}
|
||||
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
|
||||
}
|
||||
skills = append(skills, info)
|
||||
|
|
@ -181,8 +181,8 @@ func (sl *SkillsLoader) ListSkills() []SkillInfo {
|
|||
|
||||
func (sl *SkillsLoader) LoadSkill(name string) (string, bool) {
|
||||
// 1. 优先从 workspace skills 加载(项目级别)
|
||||
if sl.workspaceSkills != "" {
|
||||
skillFile := filepath.Join(sl.workspaceSkills, name, "SKILL.md")
|
||||
if sl.dataSkills != "" {
|
||||
skillFile := filepath.Join(sl.dataSkills, name, "SKILL.md")
|
||||
if content, err := os.ReadFile(skillFile); err == nil {
|
||||
return sl.stripFrontmatter(string(content)), true
|
||||
}
|
||||
|
|
@ -234,12 +234,10 @@ func (sl *SkillsLoader) BuildSkillsSummary() string {
|
|||
for _, s := range allSkills {
|
||||
escapedName := escapeXML(s.Name)
|
||||
escapedDesc := escapeXML(s.Description)
|
||||
escapedPath := escapeXML(s.Path)
|
||||
|
||||
lines = append(lines, fmt.Sprintf(" <skill>"))
|
||||
lines = append(lines, fmt.Sprintf(" <name>%s</name>", escapedName))
|
||||
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, " </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