Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 43 additions & 15 deletions pkg/agent/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ import (
)

type ContextBuilder struct {
workspace string
version string
skillsLoader *skills.SkillsLoader
memory *MemoryStore
tools *tools.ToolRegistry // Direct reference to tool registry
workspace string
sharedWorkspace string
version string
skillsLoader *skills.SkillsLoader
memory *MemoryStore
tools *tools.ToolRegistry // Direct reference to tool registry
}

func getGlobalConfigDir() string {
Expand All @@ -30,23 +31,42 @@ func getGlobalConfigDir() string {
return filepath.Join(home, ".picoclaw")
}

func resolveGlobalSkillsDir() string {
func defaultSharedWorkspaceDir() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, "sciclaw")
}

func resolveGlobalSkillsDir(sharedWorkspace string) string {
sharedRoot := strings.TrimSpace(sharedWorkspace)
if sharedRoot != "" {
sharedSkills := filepath.Join(sharedRoot, "skills")
if hasSkillsDir(sharedSkills) {
return sharedSkills
}
// Default to shared workspace path even if skills are missing;
// onboard/bootstrap should populate this location.
return sharedSkills
}

base := strings.TrimSpace(getGlobalConfigDir())
if base == "" {
return ""
}
workspaceSkills := filepath.Join(base, "workspace", "skills")
legacySkills := filepath.Join(base, "skills")

// Prefer the workspace baseline skills path used by onboarded installs.
// Legacy fallback only when shared workspace is unspecified.
if hasSkillsDir(workspaceSkills) {
return workspaceSkills
}
// Fall back to legacy global skills location if present.
if hasSkillsDir(legacySkills) {
return legacySkills
}
// Default to workspace-based path so future installs land in one place.

// Default to workspace-based legacy path when nothing is present.
return workspaceSkills
}

Expand All @@ -66,17 +86,25 @@ func hasSkillsDir(path string) bool {
return false
}

func NewContextBuilder(workspace string) *ContextBuilder {
func NewContextBuilder(workspace string, sharedWorkspace ...string) *ContextBuilder {
// builtin skills: skills directory in current project
// Use the skills/ directory under the current working directory
wd, _ := os.Getwd()
builtinSkillsDir := filepath.Join(wd, "skills")
globalSkillsDir := resolveGlobalSkillsDir()
sharedRoot := ""
if len(sharedWorkspace) > 0 {
sharedRoot = strings.TrimSpace(sharedWorkspace[0])
}
if sharedRoot == "" {
sharedRoot = defaultSharedWorkspaceDir()
}
globalSkillsDir := resolveGlobalSkillsDir(sharedRoot)

return &ContextBuilder{
workspace: workspace,
skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir),
memory: NewMemoryStore(workspace),
workspace: workspace,
sharedWorkspace: strings.TrimSpace(sharedRoot),
skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir),
memory: NewMemoryStore(workspace),
}
}

Expand Down Expand Up @@ -196,7 +224,7 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
}

primaryWorkspace := filepath.Clean(cb.workspace)
fallbackWorkspace := filepath.Clean(filepath.Join(getGlobalConfigDir(), "workspace"))
fallbackWorkspace := filepath.Clean(cb.sharedWorkspace)

var result strings.Builder
for _, filename := range bootstrapFiles {
Expand Down
4 changes: 2 additions & 2 deletions pkg/agent/context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ func TestLoadBootstrapFilesFallsBackToGlobalWorkspace(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

globalWorkspace := filepath.Join(home, ".picoclaw", "workspace")
globalWorkspace := filepath.Join(home, "sciclaw")
if err := os.MkdirAll(globalWorkspace, 0755); err != nil {
t.Fatalf("mkdir global workspace: %v", err)
}
Expand All @@ -75,7 +75,7 @@ func TestContextBuilderLoadsGlobalWorkspaceSkillsForRoutedWorkspace(t *testing.T
home := t.TempDir()
t.Setenv("HOME", home)

skillDir := filepath.Join(home, ".picoclaw", "workspace", "skills", "baseline")
skillDir := filepath.Join(home, "sciclaw", "skills", "baseline")
if err := os.MkdirAll(skillDir, 0755); err != nil {
t.Fatalf("mkdir skill dir: %v", err)
}
Expand Down
35 changes: 28 additions & 7 deletions pkg/agent/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,24 +71,44 @@ type processOptions struct {
// This is shared between main agent and subagents.
func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msgBus *bus.MessageBus) *tools.ToolRegistry {
registry := tools.NewToolRegistry()
sharedWorkspace := cfg.SharedWorkspacePath()
sharedReadOnly := cfg.Agents.Defaults.SharedWorkspaceReadOnly

// File system tools
registry.Register(tools.NewReadFileTool(workspace, restrict))
registry.Register(tools.NewWriteFileTool(workspace, restrict))
registry.Register(tools.NewListDirTool(workspace, restrict))
registry.Register(tools.NewEditFileTool(workspace, restrict))
registry.Register(tools.NewAppendFileTool(workspace, restrict))
readFileTool := tools.NewReadFileTool(workspace, restrict)
readFileTool.SetSharedWorkspacePolicy(sharedWorkspace, sharedReadOnly)
registry.Register(readFileTool)

writeFileTool := tools.NewWriteFileTool(workspace, restrict)
writeFileTool.SetSharedWorkspacePolicy(sharedWorkspace, sharedReadOnly)
registry.Register(writeFileTool)

listDirTool := tools.NewListDirTool(workspace, restrict)
listDirTool.SetSharedWorkspacePolicy(sharedWorkspace, sharedReadOnly)
registry.Register(listDirTool)

editFileTool := tools.NewEditFileTool(workspace, restrict)
editFileTool.SetSharedWorkspacePolicy(sharedWorkspace, sharedReadOnly)
registry.Register(editFileTool)

appendFileTool := tools.NewAppendFileTool(workspace, restrict)
appendFileTool.SetSharedWorkspacePolicy(sharedWorkspace, sharedReadOnly)
registry.Register(appendFileTool)

// Shell execution
execTool := tools.NewExecTool(workspace, restrict)
execTool.SetSharedWorkspacePolicy(sharedWorkspace, sharedReadOnly)
pubmedExportTool := tools.NewPubMedExportTool(workspace, restrict)
pubmedExportTool.SetSharedWorkspacePolicy(sharedWorkspace, sharedReadOnly)
if strings.TrimSpace(cfg.Tools.PubMed.APIKey) != "" {
execTool.SetExtraEnv(map[string]string{"NCBI_API_KEY": cfg.Tools.PubMed.APIKey})
pubmedExportTool.SetExtraEnv(map[string]string{"NCBI_API_KEY": cfg.Tools.PubMed.APIKey})
}
registry.Register(execTool)
registry.Register(pubmedExportTool)
registry.Register(tools.NewWordCountTool(workspace, restrict))
wordCountTool := tools.NewWordCountTool(workspace, restrict)
wordCountTool.SetSharedWorkspacePolicy(sharedWorkspace, sharedReadOnly)
registry.Register(wordCountTool)

if searchTool := tools.NewWebSearchTool(tools.WebSearchToolOptions{
BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
Expand All @@ -111,6 +131,7 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg
// Message tool - available to both agent and subagent
// Subagent uses it to communicate directly with user
messageTool := tools.NewMessageTool(workspace, restrict)
messageTool.SetSharedWorkspacePolicy(sharedWorkspace, sharedReadOnly)
messageTool.SetSendCallback(func(channel, chatID, content string, attachments []bus.OutboundAttachment) error {
msgBus.PublishOutbound(bus.OutboundMessage{
Channel: channel,
Expand Down Expand Up @@ -154,7 +175,7 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
stateManager := state.NewManager(workspace)

// Create context builder and set tools registry
contextBuilder := NewContextBuilder(workspace)
contextBuilder := NewContextBuilder(workspace, cfg.SharedWorkspacePath())
contextBuilder.SetToolsRegistry(toolsRegistry)
contextBuilder.SetVersion(Version)

Expand Down
40 changes: 25 additions & 15 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,16 @@ type AgentsConfig struct {
}

type AgentDefaults struct {
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
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"`
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
Temperature float64 `json:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
ReasoningEffort string `json:"reasoning_effort,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_REASONING_EFFORT"`
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
SharedWorkspace string `json:"shared_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_SHARED_WORKSPACE"`
SharedWorkspaceReadOnly bool `json:"shared_workspace_read_only" env:"PICOCLAW_AGENTS_DEFAULTS_SHARED_WORKSPACE_READ_ONLY"`
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
Temperature float64 `json:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
ReasoningEffort string `json:"reasoning_effort,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_REASONING_EFFORT"`
}

const (
Expand Down Expand Up @@ -236,13 +238,15 @@ func DefaultConfig() *Config {
Defaults: AgentDefaults{
// Keep config/auth under ~/.picoclaw for compatibility, but default the *workspace*
// to a visible directory for scientific users.
Workspace: "~/sciclaw",
RestrictToWorkspace: true,
Provider: "",
Model: "gpt-5.2",
MaxTokens: 8192,
Temperature: 0.7,
MaxToolIterations: 0, // 0 = no hard iteration cap
Workspace: "~/sciclaw",
RestrictToWorkspace: true,
SharedWorkspace: "~/sciclaw",
SharedWorkspaceReadOnly: true,
Provider: "",
Model: "gpt-5.2",
MaxTokens: 8192,
Temperature: 0.7,
MaxToolIterations: 0, // 0 = no hard iteration cap
},
},
Channels: ChannelsConfig{
Expand Down Expand Up @@ -408,6 +412,12 @@ func (c *Config) WorkspacePath() string {
return expandHome(c.Agents.Defaults.Workspace)
}

func (c *Config) SharedWorkspacePath() string {
c.mu.RLock()
defer c.mu.RUnlock()
return expandHome(c.Agents.Defaults.SharedWorkspace)
}

func (c *Config) GetAPIKey() string {
c.mu.RLock()
defer c.mu.RUnlock()
Expand Down
14 changes: 14 additions & 0 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)

Expand All @@ -25,6 +26,19 @@ func TestDefaultConfig_WorkspacePath(t *testing.T) {
if cfg.Agents.Defaults.Workspace == "" {
t.Error("Workspace should not be empty")
}
if cfg.Agents.Defaults.SharedWorkspace == "" {
t.Error("SharedWorkspace should not be empty")
}
if !cfg.Agents.Defaults.SharedWorkspaceReadOnly {
t.Error("SharedWorkspaceReadOnly should default to true")
}
}

func TestDefaultConfig_SharedWorkspacePath(t *testing.T) {
cfg := DefaultConfig()
if strings.TrimSpace(cfg.SharedWorkspacePath()) == "" {
t.Fatal("SharedWorkspacePath should not be empty")
}
}

// TestDefaultConfig_Model verifies model is set
Expand Down
26 changes: 20 additions & 6 deletions pkg/tools/edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ import (
// EditFileTool edits a file by replacing old_text with new_text.
// The old_text must exist exactly in the file.
type EditFileTool struct {
allowedDir string
restrict bool
allowedDir string
restrict bool
sharedWorkspace string
sharedWorkspaceReadOnly bool
}

// NewEditFileTool creates a new EditFileTool with optional directory restriction.
Expand All @@ -22,6 +24,11 @@ func NewEditFileTool(allowedDir string, restrict bool) *EditFileTool {
}
}

func (t *EditFileTool) SetSharedWorkspacePolicy(sharedWorkspace string, sharedWorkspaceReadOnly bool) {
t.sharedWorkspace = strings.TrimSpace(sharedWorkspace)
t.sharedWorkspaceReadOnly = sharedWorkspaceReadOnly
}

func (t *EditFileTool) Name() string {
return "edit_file"
}
Expand Down Expand Up @@ -67,7 +74,7 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]interface{})
return ErrorResult("new_text is required")
}

resolvedPath, err := validatePath(path, t.allowedDir, t.restrict)
resolvedPath, err := validatePathWithPolicy(path, t.allowedDir, t.restrict, AccessWrite, t.sharedWorkspace, t.sharedWorkspaceReadOnly)
if err != nil {
return ErrorResult(err.Error())
}
Expand Down Expand Up @@ -102,14 +109,21 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]interface{})
}

type AppendFileTool struct {
workspace string
restrict bool
workspace string
restrict bool
sharedWorkspace string
sharedWorkspaceReadOnly bool
}

func NewAppendFileTool(workspace string, restrict bool) *AppendFileTool {
return &AppendFileTool{workspace: workspace, restrict: restrict}
}

func (t *AppendFileTool) SetSharedWorkspacePolicy(sharedWorkspace string, sharedWorkspaceReadOnly bool) {
t.sharedWorkspace = strings.TrimSpace(sharedWorkspace)
t.sharedWorkspaceReadOnly = sharedWorkspaceReadOnly
}

func (t *AppendFileTool) Name() string {
return "append_file"
}
Expand Down Expand Up @@ -146,7 +160,7 @@ func (t *AppendFileTool) Execute(ctx context.Context, args map[string]interface{
return ErrorResult("content is required")
}

resolvedPath, err := validatePath(path, t.workspace, t.restrict)
resolvedPath, err := validatePathWithPolicy(path, t.workspace, t.restrict, AccessWrite, t.sharedWorkspace, t.sharedWorkspaceReadOnly)
if err != nil {
return ErrorResult(err.Error())
}
Expand Down
Loading
Loading