export tools and tool factories with WithWorkDir option (Plan 01)

- Add ToolOption/WithWorkDir functional options pattern to internal/core
- Update all 7 tool constructors to accept ...ToolOption and resolve
  paths relative to the configured working directory
- Create pkg/kit/tools.go with public exports: individual constructors,
  bundles (AllTools, CodingTools, ReadOnlyTools), and WithWorkDir
- Add CoreTools field to AgentConfig/AgentCreationOptions so callers
  can inject custom tool sets instead of hardcoding core.AllTools()
- Add Tools field to kit.Options and GetTools() to kit.Kit
- Fully backward compatible: no-arg calls use os.Getwd() as before
This commit is contained in:
Ed Zynda
2026-02-27 11:37:46 +03:00
parent 9e5cf9dd4c
commit d8f40039fe
13 changed files with 190 additions and 53 deletions
+11 -2
View File
@@ -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))
+4
View File
@@ -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,
}
+9 -3
View File
@@ -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
+7 -4
View File
@@ -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
}
+9 -4
View File
@@ -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)
+9 -4
View File
@@ -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
+9 -4
View File
@@ -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)
+18 -10
View File
@@ -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
}
+43 -18
View File
@@ -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...),
}
}
+7 -4
View File
@@ -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
}
+7
View File
@@ -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.
+4
View File
@@ -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,
})
+53
View File
@@ -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...) }