diff --git a/cmd/picoclaw/internal/agent/command.go b/cmd/picoclaw/internal/agent/command.go index 47262fc85..8fe683936 100644 --- a/cmd/picoclaw/internal/agent/command.go +++ b/cmd/picoclaw/internal/agent/command.go @@ -10,6 +10,11 @@ func NewAgentCommand() *cobra.Command { sessionKey string model string debug bool + // Workspace and config overrides + workspace string + configDir string + tools string + skills string ) cmd := &cobra.Command{ @@ -17,7 +22,8 @@ func NewAgentCommand() *cobra.Command { Short: "Interact with the agent directly", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - return agentCmd(message, sessionKey, model, debug) + return agentCmd(message, sessionKey, model, debug, + workspace, configDir, tools, skills) }, } @@ -26,5 +32,11 @@ func NewAgentCommand() *cobra.Command { cmd.Flags().StringVarP(&sessionKey, "session", "s", "cli:default", "Session key") cmd.Flags().StringVarP(&model, "model", "", "", "Model to use") + // Workspace and config overrides + cmd.Flags().StringVar(&workspace, "workspace", "", "Override agent workspace directory") + cmd.Flags().StringVar(&configDir, "config-dir", "", "Directory containing bootstrap files (AGENTS.md, IDENTITY.md, SOUL.md, USER.md) to copy into workspace") + cmd.Flags().StringVar(&tools, "tools", "", "Comma-separated tool allowlist (only these tools enabled)") + cmd.Flags().StringVar(&skills, "skills", "", "Comma-separated skill filter (only these skills loaded)") + return cmd } diff --git a/cmd/picoclaw/internal/agent/helpers.go b/cmd/picoclaw/internal/agent/helpers.go index f754abc65..6bf69be58 100644 --- a/cmd/picoclaw/internal/agent/helpers.go +++ b/cmd/picoclaw/internal/agent/helpers.go @@ -14,11 +14,13 @@ import ( "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/pkg/agent" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" ) -func agentCmd(message, sessionKey, model string, debug bool) error { +func agentCmd(message, sessionKey, model string, debug bool, + workspace, configDir, toolsFlag, skillsFlag string) error { if sessionKey == "" { sessionKey = "cli:default" } @@ -37,6 +39,30 @@ func agentCmd(message, sessionKey, model string, debug bool) error { cfg.Agents.Defaults.ModelName = model } + // Workspace override + if workspace != "" { + cfg.Agents.Defaults.Workspace = workspace + os.MkdirAll(workspace, 0o755) + } + + // Tool allowlist: disable all tools, then enable only the listed ones + if toolsFlag != "" { + toolList := strings.Split(toolsFlag, ",") + for i := range toolList { + toolList[i] = strings.TrimSpace(toolList[i]) + } + applyToolAllowlist(cfg, toolList) + } + + // Skills filter: inject into agent config so NewAgentInstance picks it up + if skillsFlag != "" { + skillList := strings.Split(skillsFlag, ",") + for i := range skillList { + skillList[i] = strings.TrimSpace(skillList[i]) + } + applySkillsFilter(cfg, skillList) + } + provider, modelID, err := providers.CreateProvider(cfg) if err != nil { return fmt.Errorf("error creating provider: %w", err) @@ -51,6 +77,11 @@ func agentCmd(message, sessionKey, model string, debug bool) error { defer msgBus.Close() agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) + // Copy bootstrap files from config-dir to workspace + if configDir != "" { + copyBootstrapFiles(configDir, cfg.Agents.Defaults.Workspace) + } + // Print agent startup info (only for interactive mode) startupInfo := agentLoop.GetStartupInfo() logger.InfoCF("agent", "Agent initialized", @@ -76,6 +107,65 @@ func agentCmd(message, sessionKey, model string, debug bool) error { return nil } +// applyToolAllowlist disables all tools, then enables only the listed ones. +func applyToolAllowlist(cfg *config.Config, allowed []string) { + allowSet := make(map[string]bool, len(allowed)) + for _, t := range allowed { + allowSet[t] = true + } + + cfg.Tools.ReadFile.Enabled = allowSet["read_file"] + cfg.Tools.WriteFile.Enabled = allowSet["write_file"] + cfg.Tools.EditFile.Enabled = allowSet["edit_file"] + cfg.Tools.AppendFile.Enabled = allowSet["append_file"] + cfg.Tools.ListDir.Enabled = allowSet["list_dir"] + cfg.Tools.Exec.Enabled = allowSet["exec"] + cfg.Tools.Spawn.Enabled = allowSet["spawn"] + cfg.Tools.Cron.Enabled = allowSet["cron"] + cfg.Tools.Web.Enabled = allowSet["web"] || allowSet["web_search"] + cfg.Tools.WebFetch.Enabled = allowSet["web_fetch"] + cfg.Tools.Skills.Enabled = allowSet["skills"] + cfg.Tools.FindSkills.Enabled = allowSet["find_skills"] + cfg.Tools.InstallSkill.Enabled = allowSet["install_skill"] + cfg.Tools.Subagent.Enabled = allowSet["subagent"] + cfg.Tools.Message.Enabled = allowSet["message"] + cfg.Tools.MCP.Enabled = allowSet["mcp"] + cfg.Tools.I2C.Enabled = allowSet["i2c"] + cfg.Tools.SPI.Enabled = allowSet["spi"] +} + +// applySkillsFilter injects a skills filter into the agent config. +func applySkillsFilter(cfg *config.Config, skills []string) { + if len(cfg.Agents.List) == 0 { + // Create an implicit main agent with skills filter + cfg.Agents.List = []config.AgentConfig{ + {ID: "main", Default: true, Skills: skills}, + } + } else { + // Apply to all agents + for i := range cfg.Agents.List { + cfg.Agents.List[i].Skills = skills + } + } +} + +// copyBootstrapFiles copies recognized bootstrap files (AGENTS.md, IDENTITY.md, +// SOUL.md, USER.md) from srcDir into the workspace directory. +func copyBootstrapFiles(srcDir, workspace string) { + bootstrapFiles := []string{"AGENTS.md", "IDENTITY.md", "SOUL.md", "USER.md"} + for _, filename := range bootstrapFiles { + srcPath := filepath.Join(srcDir, filename) + data, err := os.ReadFile(srcPath) + if err != nil { + continue // file not present in config-dir, skip + } + dstPath := filepath.Join(workspace, filename) + if err := os.WriteFile(dstPath, data, 0o644); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to write %s: %v\n", dstPath, err) + } + } +} + func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) { prompt := fmt.Sprintf("%s You: ", internal.Logo) diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 719b0cb6d..24ab3a58c 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -39,6 +39,54 @@ type ContextBuilder struct { // build time. This catches nested file creations/deletions/mtime changes // that may not update the top-level skill root directory mtime. skillFilesAtCache map[string]time.Time + + // skillsFilter limits which skills are included in the system prompt. + // When non-empty, only skills whose name matches an entry are included. + // ["*"] means include all skills. + skillsFilter []string +} + +// SetSkillsFilter sets the skills filter on the context builder. +// When non-empty, only skills matching these names are included in the system prompt. +// Use ["*"] to include all skills. +func (cb *ContextBuilder) SetSkillsFilter(filter []string) { + cb.skillsFilter = filter + cb.InvalidateCache() +} + +// buildFilteredSkillsSummary returns the skills summary, filtered by skillsFilter +// if one is set. When no filter is set, all skills are included. +func (cb *ContextBuilder) buildFilteredSkillsSummary() string { + if len(cb.skillsFilter) == 0 { + return cb.skillsLoader.BuildSkillsSummary() + } + + // Check for wildcard + for _, f := range cb.skillsFilter { + if f == "*" { + return cb.skillsLoader.BuildSkillsSummary() + } + } + + // Build filter set + filterSet := make(map[string]bool, len(cb.skillsFilter)) + for _, f := range cb.skillsFilter { + filterSet[f] = true + } + + allSkills := cb.skillsLoader.ListSkills() + var filtered []skills.SkillInfo + for _, s := range allSkills { + if filterSet[s.Name] { + filtered = append(filtered, s) + } + } + + if len(filtered) == 0 { + return "" + } + + return skills.FormatSkillsSummary(filtered) } func getGlobalConfigDir() string { @@ -107,7 +155,7 @@ func (cb *ContextBuilder) BuildSystemPrompt() string { } // Skills - show summary, AI can read full content with read_file tool - skillsSummary := cb.skillsLoader.BuildSkillsSummary() + skillsSummary := cb.buildFilteredSkillsSummary() if skillsSummary != "" { parts = append(parts, fmt.Sprintf(`# Skills diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 97cf0fa05..f6d036dfb 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -98,6 +98,7 @@ func NewAgentInstance( contextBuilder := NewContextBuilder(workspace) + // SkillsFilter will be applied after we know the agent config agentID := routing.DefaultAgentID agentName := "" var subagents *config.SubagentsConfig @@ -110,6 +111,11 @@ func NewAgentInstance( skillsFilter = agentCfg.Skills } + // Apply skills filter to context builder so only matching skills appear in the prompt + if len(skillsFilter) > 0 { + contextBuilder.SetSkillsFilter(skillsFilter) + } + maxIter := defaults.MaxToolIterations if maxIter == 0 { maxIter = 20 diff --git a/pkg/skills/loader.go b/pkg/skills/loader.go index 30d84635a..b9c666c35 100644 --- a/pkg/skills/loader.go +++ b/pkg/skills/loader.go @@ -191,14 +191,18 @@ func (sl *SkillsLoader) LoadSkillsForContext(skillNames []string) string { } func (sl *SkillsLoader) BuildSkillsSummary() string { - allSkills := sl.ListSkills() - if len(allSkills) == 0 { + return FormatSkillsSummary(sl.ListSkills()) +} + +// FormatSkillsSummary renders a list of SkillInfo entries as an XML summary. +func FormatSkillsSummary(skillList []SkillInfo) string { + if len(skillList) == 0 { return "" } var lines []string lines = append(lines, "") - for _, s := range allSkills { + for _, s := range skillList { escapedName := escapeXML(s.Name) escapedDesc := escapeXML(s.Description) escapedPath := escapeXML(s.Path)