diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 99ee3cfd..1f623925 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -25,6 +25,11 @@ type AgentConfig struct { StreamingEnabled bool DebugLogger tools.DebugLogger + // CoreTools overrides the default core tool set. If empty, core.AllTools() + // is used. This allows SDK users to provide a custom tool set (e.g. + // CodingTools or tools with a custom WorkDir). + CoreTools []fantasy.AgentTool + // ToolWrapper is an optional function that wraps the combined tool list // before it is passed to the Fantasy agent. Used by the extensions system // to intercept tool calls/results. @@ -93,8 +98,12 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) { return nil, fmt.Errorf("failed to create model provider: %v", err) } - // Register core tools (direct fantasy implementations, no MCP overhead) - coreTools := core.AllTools() + // Register core tools (direct fantasy implementations, no MCP overhead). + // Use caller-provided tools if set, otherwise default to all core tools. + coreTools := agentConfig.CoreTools + if len(coreTools) == 0 { + coreTools = core.AllTools() + } // Build the combined tool list: core tools + any external MCP tools allTools := make([]fantasy.AgentTool, len(coreTools)) diff --git a/internal/agent/factory.go b/internal/agent/factory.go index 0929d6bd..af1993c6 100644 --- a/internal/agent/factory.go +++ b/internal/agent/factory.go @@ -36,6 +36,9 @@ type AgentCreationOptions struct { SpinnerFunc SpinnerFunc // Function to show spinner (provided by caller) // DebugLogger is an optional logger for debugging MCP communications DebugLogger tools.DebugLogger // Optional debug logger + // CoreTools overrides the default core tool set. If empty, core.AllTools() + // is used. + CoreTools []fantasy.AgentTool // ToolWrapper wraps the combined tool list before Fantasy agent creation. ToolWrapper func([]fantasy.AgentTool) []fantasy.AgentTool // ExtraTools are additional tools to include (e.g. from extensions). @@ -53,6 +56,7 @@ func CreateAgent(ctx context.Context, opts *AgentCreationOptions) (*Agent, error MaxSteps: opts.MaxSteps, StreamingEnabled: opts.StreamingEnabled, DebugLogger: opts.DebugLogger, + CoreTools: opts.CoreTools, ToolWrapper: opts.ToolWrapper, ExtraTools: opts.ExtraTools, } diff --git a/internal/core/bash.go b/internal/core/bash.go index 4f9776d3..1a7996cb 100644 --- a/internal/core/bash.go +++ b/internal/core/bash.go @@ -35,7 +35,8 @@ type bashArgs struct { } // NewBashTool creates the bash core tool. -func NewBashTool() fantasy.AgentTool { +func NewBashTool(opts ...ToolOption) fantasy.AgentTool { + cfg := ApplyOptions(opts) return &coreTool{ info: fantasy.ToolInfo{ Name: "bash", @@ -52,11 +53,13 @@ func NewBashTool() fantasy.AgentTool { }, Required: []string{"command"}, }, - handler: executeBash, + handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) { + return executeBash(ctx, call, cfg.WorkDir) + }, } } -func executeBash(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) { +func executeBash(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) { var args bashArgs if err := parseArgs(call.Input, &args); err != nil { return fantasy.NewTextErrorResponse("command parameter is required"), nil @@ -83,6 +86,9 @@ func executeBash(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolRespon defer cancel() cmd := exec.CommandContext(cmdCtx, "bash", "-c", args.Command) + if workDir != "" { + cmd.Dir = workDir + } var stdout, stderr bytes.Buffer cmd.Stdout = &stdout diff --git a/internal/core/edit.go b/internal/core/edit.go index f36bade1..88e3af08 100644 --- a/internal/core/edit.go +++ b/internal/core/edit.go @@ -17,7 +17,8 @@ type editArgs struct { } // NewEditTool creates the edit core tool. -func NewEditTool() fantasy.AgentTool { +func NewEditTool(opts ...ToolOption) fantasy.AgentTool { + cfg := ApplyOptions(opts) return &coreTool{ info: fantasy.ToolInfo{ Name: "edit", @@ -38,11 +39,13 @@ func NewEditTool() fantasy.AgentTool { }, Required: []string{"path", "old_text", "new_text"}, }, - handler: executeEdit, + handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) { + return executeEdit(ctx, call, cfg.WorkDir) + }, } } -func executeEdit(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) { +func executeEdit(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) { var args editArgs if err := parseArgs(call.Input, &args); err != nil { return fantasy.NewTextErrorResponse("path, old_text, and new_text parameters are required"), nil @@ -51,7 +54,7 @@ func executeEdit(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolRespon return fantasy.NewTextErrorResponse("path parameter is required"), nil } - absPath, err := resolvePath(args.Path) + absPath, err := resolvePathWithWorkDir(args.Path, workDir) if err != nil { return fantasy.NewTextErrorResponse(fmt.Sprintf("invalid path: %v", err)), nil } diff --git a/internal/core/find.go b/internal/core/find.go index 75172fe5..a8be35e5 100644 --- a/internal/core/find.go +++ b/internal/core/find.go @@ -18,7 +18,8 @@ type findArgs struct { } // NewFindTool creates the find core tool. -func NewFindTool() fantasy.AgentTool { +func NewFindTool(opts ...ToolOption) fantasy.AgentTool { + cfg := ApplyOptions(opts) return &coreTool{ info: fantasy.ToolInfo{ Name: "find", @@ -39,11 +40,13 @@ func NewFindTool() fantasy.AgentTool { }, Required: []string{"pattern"}, }, - handler: executeFind, + handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) { + return executeFind(ctx, call, cfg.WorkDir) + }, } } -func executeFind(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) { +func executeFind(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) { var args findArgs if err := parseArgs(call.Input, &args); err != nil { return fantasy.NewTextErrorResponse("pattern parameter is required"), nil @@ -59,11 +62,13 @@ func executeFind(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolRespon searchPath := "." if args.Path != "" { - resolved, err := resolvePath(args.Path) + resolved, err := resolvePathWithWorkDir(args.Path, workDir) if err != nil { return fantasy.NewTextErrorResponse(fmt.Sprintf("invalid path: %v", err)), nil } searchPath = resolved + } else if workDir != "" { + searchPath = workDir } // Try fd first (faster, respects .gitignore by default) diff --git a/internal/core/grep.go b/internal/core/grep.go index b027d510..bf842198 100644 --- a/internal/core/grep.go +++ b/internal/core/grep.go @@ -22,7 +22,8 @@ type grepArgs struct { } // NewGrepTool creates the grep core tool. -func NewGrepTool() fantasy.AgentTool { +func NewGrepTool(opts ...ToolOption) fantasy.AgentTool { + cfg := ApplyOptions(opts) return &coreTool{ info: fantasy.ToolInfo{ Name: "grep", @@ -59,11 +60,13 @@ func NewGrepTool() fantasy.AgentTool { }, Required: []string{"pattern"}, }, - handler: executeGrep, + handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) { + return executeGrep(ctx, call, cfg.WorkDir) + }, } } -func executeGrep(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) { +func executeGrep(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) { var args grepArgs if err := parseArgs(call.Input, &args); err != nil { return fantasy.NewTextErrorResponse("pattern parameter is required"), nil @@ -79,11 +82,13 @@ func executeGrep(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolRespon searchPath := "." if args.Path != "" { - resolved, err := resolvePath(args.Path) + resolved, err := resolvePathWithWorkDir(args.Path, workDir) if err != nil { return fantasy.NewTextErrorResponse(fmt.Sprintf("invalid path: %v", err)), nil } searchPath = resolved + } else if workDir != "" { + searchPath = workDir } // Build ripgrep command diff --git a/internal/core/ls.go b/internal/core/ls.go index 95814384..429c94c2 100644 --- a/internal/core/ls.go +++ b/internal/core/ls.go @@ -16,7 +16,8 @@ type lsArgs struct { } // NewLsTool creates the ls core tool. -func NewLsTool() fantasy.AgentTool { +func NewLsTool(opts ...ToolOption) fantasy.AgentTool { + cfg := ApplyOptions(opts) return &coreTool{ info: fantasy.ToolInfo{ Name: "ls", @@ -33,11 +34,13 @@ func NewLsTool() fantasy.AgentTool { }, Required: []string{}, }, - handler: executeLs, + handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) { + return executeLs(ctx, call, cfg.WorkDir) + }, } } -func executeLs(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) { +func executeLs(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) { var args lsArgs _ = parseArgs(call.Input, &args) // optional args @@ -48,11 +51,13 @@ func executeLs(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse dirPath := "." if args.Path != "" { - resolved, err := resolvePath(args.Path) + resolved, err := resolvePathWithWorkDir(args.Path, workDir) if err != nil { return fantasy.NewTextErrorResponse(fmt.Sprintf("invalid path: %v", err)), nil } dirPath = resolved + } else if workDir != "" { + dirPath = workDir } info, err := os.Stat(dirPath) diff --git a/internal/core/read.go b/internal/core/read.go index 8888d0f0..81a02122 100644 --- a/internal/core/read.go +++ b/internal/core/read.go @@ -17,7 +17,8 @@ type readArgs struct { } // NewReadTool creates the read core tool. -func NewReadTool() fantasy.AgentTool { +func NewReadTool(opts ...ToolOption) fantasy.AgentTool { + cfg := ApplyOptions(opts) return &coreTool{ info: fantasy.ToolInfo{ Name: "read", @@ -38,11 +39,13 @@ func NewReadTool() fantasy.AgentTool { }, Required: []string{"path"}, }, - handler: executeRead, + handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) { + return executeRead(ctx, call, cfg.WorkDir) + }, } } -func executeRead(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) { +func executeRead(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) { var args readArgs if err := parseArgs(call.Input, &args); err != nil { return fantasy.NewTextErrorResponse("path parameter is required"), nil @@ -51,7 +54,7 @@ func executeRead(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolRespon return fantasy.NewTextErrorResponse("path parameter is required"), nil } - absPath, err := resolvePath(args.Path) + absPath, err := resolvePathWithWorkDir(args.Path, workDir) if err != nil { return fantasy.NewTextErrorResponse(fmt.Sprintf("invalid path: %v", err)), nil } @@ -131,14 +134,19 @@ func readDirectory(absPath string) (fantasy.ToolResponse, error) { return fantasy.NewTextResponse(tr.Content), nil } -// resolvePath resolves a path to an absolute path relative to cwd. -func resolvePath(path string) (string, error) { +// resolvePathWithWorkDir resolves a path to an absolute path relative to the +// given workDir. If workDir is empty, os.Getwd() is used. +func resolvePathWithWorkDir(path, workDir string) (string, error) { if filepath.IsAbs(path) { return filepath.Clean(path), nil } - cwd, err := os.Getwd() - if err != nil { - return "", fmt.Errorf("failed to get working directory: %w", err) + baseDir := workDir + if baseDir == "" { + var err error + baseDir, err = os.Getwd() + if err != nil { + return "", fmt.Errorf("failed to get working directory: %w", err) + } } - return filepath.Clean(filepath.Join(cwd, path)), nil + return filepath.Clean(filepath.Join(baseDir, path)), nil } diff --git a/internal/core/tools.go b/internal/core/tools.go index 8f894249..6336a616 100644 --- a/internal/core/tools.go +++ b/internal/core/tools.go @@ -12,6 +12,31 @@ import ( "charm.land/fantasy" ) +// ToolOption configures tool behavior. +type ToolOption func(*ToolConfig) + +// ToolConfig holds configuration for tool construction. +type ToolConfig struct { + WorkDir string +} + +// WithWorkDir sets the working directory for file-based tools. +// If empty, os.Getwd() is used at execution time. +func WithWorkDir(dir string) ToolOption { + return func(c *ToolConfig) { + c.WorkDir = dir + } +} + +// ApplyOptions applies the given ToolOptions to a ToolConfig and returns it. +func ApplyOptions(opts []ToolOption) ToolConfig { + var cfg ToolConfig + for _, o := range opts { + o(&cfg) + } + return cfg +} + // coreTool is the base implementation for all core tools. It implements // the fantasy.AgentTool interface with typed parameters and direct execution. type coreTool struct { @@ -41,35 +66,35 @@ func parseArgs(input string, target any) error { // CodingTools returns the default set of core tools for a coding agent: // bash, read, write, edit. This matches pi's codingTools collection. -func CodingTools() []fantasy.AgentTool { +func CodingTools(opts ...ToolOption) []fantasy.AgentTool { return []fantasy.AgentTool{ - NewBashTool(), - NewReadTool(), - NewWriteTool(), - NewEditTool(), + NewBashTool(opts...), + NewReadTool(opts...), + NewWriteTool(opts...), + NewEditTool(opts...), } } // ReadOnlyTools returns tools for read-only exploration: // read, grep, find, ls. This matches pi's readOnlyTools collection. -func ReadOnlyTools() []fantasy.AgentTool { +func ReadOnlyTools(opts ...ToolOption) []fantasy.AgentTool { return []fantasy.AgentTool{ - NewReadTool(), - NewGrepTool(), - NewFindTool(), - NewLsTool(), + NewReadTool(opts...), + NewGrepTool(opts...), + NewFindTool(opts...), + NewLsTool(opts...), } } // AllTools returns all available core tools. -func AllTools() []fantasy.AgentTool { +func AllTools(opts ...ToolOption) []fantasy.AgentTool { return []fantasy.AgentTool{ - NewBashTool(), - NewReadTool(), - NewWriteTool(), - NewEditTool(), - NewGrepTool(), - NewFindTool(), - NewLsTool(), + NewBashTool(opts...), + NewReadTool(opts...), + NewWriteTool(opts...), + NewEditTool(opts...), + NewGrepTool(opts...), + NewFindTool(opts...), + NewLsTool(opts...), } } diff --git a/internal/core/write.go b/internal/core/write.go index d2d26430..0c8071de 100644 --- a/internal/core/write.go +++ b/internal/core/write.go @@ -15,7 +15,8 @@ type writeArgs struct { } // NewWriteTool creates the write core tool. -func NewWriteTool() fantasy.AgentTool { +func NewWriteTool(opts ...ToolOption) fantasy.AgentTool { + cfg := ApplyOptions(opts) return &coreTool{ info: fantasy.ToolInfo{ Name: "write", @@ -32,11 +33,13 @@ func NewWriteTool() fantasy.AgentTool { }, Required: []string{"path", "content"}, }, - handler: executeWrite, + handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) { + return executeWrite(ctx, call, cfg.WorkDir) + }, } } -func executeWrite(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) { +func executeWrite(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) { var args writeArgs if err := parseArgs(call.Input, &args); err != nil { return fantasy.NewTextErrorResponse("path and content parameters are required"), nil @@ -45,7 +48,7 @@ func executeWrite(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolRespo return fantasy.NewTextErrorResponse("path parameter is required"), nil } - absPath, err := resolvePath(args.Path) + absPath, err := resolvePathWithWorkDir(args.Path, workDir) if err != nil { return fantasy.NewTextErrorResponse(fmt.Sprintf("invalid path: %v", err)), nil } diff --git a/pkg/kit/kit.go b/pkg/kit/kit.go index 0933eed2..c45fb462 100644 --- a/pkg/kit/kit.go +++ b/pkg/kit/kit.go @@ -31,6 +31,7 @@ type Options struct { MaxSteps int // Override max steps (0 = use default) Streaming bool // Enable streaming (default from config) Quiet bool // Suppress debug output + Tools []Tool // Custom tool set. If empty, AllTools() is used. } // New creates a Kit instance using the same initialization as the CLI. @@ -72,6 +73,7 @@ func New(ctx context.Context, opts *Options) (*Kit, error) { agentResult, err := SetupAgent(ctx, AgentSetupOptions{ MCPConfig: mcpConfig, Quiet: opts.Quiet, + CoreTools: opts.Tools, }) if err != nil { return nil, err @@ -178,6 +180,11 @@ func (m *Kit) GetModelString() string { return m.modelString } +// GetTools returns all tools available to the agent (core + MCP + extensions). +func (m *Kit) GetTools() []Tool { + return m.agent.GetTools() +} + // Close cleans up resources including MCP server connections and model resources. // Should be called when the Kit instance is no longer needed. Returns an // error if cleanup fails. diff --git a/pkg/kit/setup.go b/pkg/kit/setup.go index 5cee4161..49890f04 100644 --- a/pkg/kit/setup.go +++ b/pkg/kit/setup.go @@ -28,6 +28,9 @@ type AgentSetupOptions struct { UseBufferedLogger bool // Quiet suppresses output. Replaces the cmd package's quietFlag variable. Quiet bool + // CoreTools overrides the default core tool set. If empty, core.AllTools() + // is used. Allows SDK users to pass custom tools (e.g. with WithWorkDir). + CoreTools []fantasy.AgentTool } // AgentSetupResult bundles the created agent and any debug logger so the caller @@ -113,6 +116,7 @@ func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult, Quiet: opts.Quiet, SpinnerFunc: opts.SpinnerFunc, DebugLogger: debugLogger, + CoreTools: opts.CoreTools, ToolWrapper: extCreationOpts.toolWrapper, ExtraTools: extCreationOpts.extraTools, }) diff --git a/pkg/kit/tools.go b/pkg/kit/tools.go new file mode 100644 index 00000000..c47989ef --- /dev/null +++ b/pkg/kit/tools.go @@ -0,0 +1,53 @@ +package kit + +import ( + "charm.land/fantasy" + + "github.com/mark3labs/kit/internal/core" +) + +// Tool is the interface that all Kit tools implement. +type Tool = fantasy.AgentTool + +// ToolOption configures tool behavior. +type ToolOption = core.ToolOption + +// WithWorkDir sets the working directory for file-based tools. +// If empty, os.Getwd() is used at execution time. +var WithWorkDir = core.WithWorkDir + +// --- Individual tool constructors --- + +// NewReadTool creates a file-reading tool. +func NewReadTool(opts ...ToolOption) Tool { return core.NewReadTool(opts...) } + +// NewWriteTool creates a file-writing tool. +func NewWriteTool(opts ...ToolOption) Tool { return core.NewWriteTool(opts...) } + +// NewEditTool creates a surgical text-editing tool. +func NewEditTool(opts ...ToolOption) Tool { return core.NewEditTool(opts...) } + +// NewBashTool creates a bash command execution tool. +func NewBashTool(opts ...ToolOption) Tool { return core.NewBashTool(opts...) } + +// NewGrepTool creates a content search tool (uses ripgrep when available). +func NewGrepTool(opts ...ToolOption) Tool { return core.NewGrepTool(opts...) } + +// NewFindTool creates a file search tool (uses fd when available). +func NewFindTool(opts ...ToolOption) Tool { return core.NewFindTool(opts...) } + +// NewLsTool creates a directory listing tool. +func NewLsTool(opts ...ToolOption) Tool { return core.NewLsTool(opts...) } + +// --- Tool bundles --- + +// AllTools returns all available core tools. +func AllTools(opts ...ToolOption) []Tool { return core.AllTools(opts...) } + +// CodingTools returns the default set of core tools for a coding agent: +// bash, read, write, edit. +func CodingTools(opts ...ToolOption) []Tool { return core.CodingTools(opts...) } + +// ReadOnlyTools returns tools for read-only exploration: +// read, grep, find, ls. +func ReadOnlyTools(opts ...ToolOption) []Tool { return core.ReadOnlyTools(opts...) }