diff --git a/README.md b/README.md index 9b371883..bf0acec5 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ A powerful, extensible AI coding agent CLI with multi-provider support, built-in - **Multi-Provider LLM Support**: Anthropic, OpenAI, Google Gemini, Ollama, Azure OpenAI, AWS Bedrock, OpenRouter, and more - **Built-in Core Tools**: bash (with interactive sudo password prompt), read, write, edit, grep, find, ls, subagent - no MCP overhead +- **Named Agents**: Reusable subagent presets defined in markdown with per-agent tool allowlists, advertised to the LLM for delegation - **Smart @ Attachments**: Binary files auto-detected via MIME type, MCP resources via `@mcp:server:uri` - **MCP Integration**: Connect external MCP servers for expanded capabilities - **Extension System**: Write custom tools, commands, widgets, and UI modifications in Go @@ -140,6 +141,9 @@ skill: # explicit skill files/dirs (disables auto-discovery) skills-dir: "" # scan this directory directly for skills (overrides auto-discovery) skill-disable: # hide skills from the model catalog by name (still usable via /skill:) - some-skill + +# Named agents +no-agents: false # set to true to disable named agent discovery (built-ins and definition files) ``` All of the above keys can also be set programmatically via the SDK @@ -223,6 +227,7 @@ mcpServers: --skills-dir Scan this directory directly for skills (overrides auto-discovery) --skill-disable Hide a skill from the model catalog by name (repeatable); still usable via /skill: --no-skills Disable skill loading (auto-discovery and explicit) +--no-agents Disable named agent discovery (built-ins and definition files) # Generation parameters --max-tokens Maximum tokens in response (default: 8192, auto-raised up to 32768 for models with larger known output limits) @@ -494,6 +499,50 @@ Placeholders inside fenced code blocks (```) and inline code spans are ignored. Disable templates with `--no-prompt-templates` or load a specific template with `--prompt-template `. +### Named Agents + +Define reusable subagent presets as markdown files. Named agents are advertised to the LLM in the `subagent` tool description, so the main agent can delegate tasks to the right specialist by name — with a preset system prompt, model, tool allowlist, temperature, and timeout. + +**Discovery locations** (highest to lowest precedence): + +1. `/.agents/agents/*.md` — project-local (cross-client convention) +2. `/.kit/agents/*.md` — project-local (Kit-specific) +3. `~/.config/kit/agents/*.md` — user-level (`$XDG_CONFIG_HOME` aware) +4. Built-in agents: `general` (full tool access) and `explore` (read-only: `read`, `grep`, `find`, `ls`) + +The filename (minus `.md`) is the agent name. Higher-precedence definitions override lower ones, so a project can replace — or disable — a built-in or user-level agent. + +**Example** (`.agents/agents/code-reviewer.md`): + +```markdown +--- +description: Reviews code for quality and best practices # required +model: anthropic/claude-sonnet-4 # optional model override +tools: [read, grep, find, ls] # optional tool allowlist +temperature: 0.1 # optional +timeout: 300 # optional, seconds +hidden: false # optional: resolvable but not advertised +disabled: false # optional: remove this agent (and anything it shadows) +--- +You are in code review mode. Focus on correctness, security, and +maintainability. Report findings with file paths and line references. +``` + +The LLM invokes it through the `subagent` tool's `agent` parameter; explicit `model` / `system_prompt` / `timeout_seconds` arguments override the agent's presets. An agent without a `tools:` list gets the default subagent tool set (everything except `subagent`, preventing recursion); with a `tools:` allowlist the subagent is restricted to exactly those tools. + +From the Go SDK: + +```go +agents := k.GetAgents() // discovered definitions (built-ins included) + +result, err := k.Subagent(ctx, kit.SubagentConfig{ + Prompt: "Map out the session persistence flow", + Agent: "explore", // preset prompt + read-only tools +}) +``` + +Disable discovery entirely with `--no-agents`, the `no-agents` config key (`.kit.yml`), `KIT_NO_AGENTS=true`, or `Options.NoAgents` in the SDK. + ## GitHub Integration Kit can run as an automated collaborator/reviewer inside GitHub Actions. The diff --git a/cmd/root.go b/cmd/root.go index c53a3f7b..a3c5bcf2 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -82,6 +82,9 @@ var ( skillsDir string skillsDisable []string + // Named agents control + noAgentsFlag bool + // TLS configuration tlsSkipVerify bool @@ -299,6 +302,8 @@ func init() { // Skills flags rootCmd.PersistentFlags(). BoolVar(&noSkillsFlag, "no-skills", false, "disable skill loading (auto-discovery and explicit)") + rootCmd.PersistentFlags(). + BoolVar(&noAgentsFlag, "no-agents", false, "disable named agent discovery (built-ins and .agents/agents, .kit/agents, ~/.config/kit/agents)") rootCmd.PersistentFlags(). StringSliceVar(&skillsPaths, "skill", nil, "load skill file or directory (repeatable)") rootCmd.PersistentFlags(). @@ -359,6 +364,7 @@ func init() { _ = viper.BindPFlag("prompt-template", rootCmd.PersistentFlags().Lookup("prompt-template")) _ = viper.BindPFlag("no-prompt-templates", rootCmd.PersistentFlags().Lookup("no-prompt-templates")) _ = viper.BindPFlag("no-skills", rootCmd.PersistentFlags().Lookup("no-skills")) + _ = viper.BindPFlag("no-agents", rootCmd.PersistentFlags().Lookup("no-agents")) _ = viper.BindPFlag("skill", rootCmd.PersistentFlags().Lookup("skill")) _ = viper.BindPFlag("skills-dir", rootCmd.PersistentFlags().Lookup("skills-dir")) _ = viper.BindPFlag("skill-disable", rootCmd.PersistentFlags().Lookup("skill-disable")) @@ -868,6 +874,7 @@ func runNormalMode(ctx context.Context) error { DisableCoreTools: viper.GetBool("no-core-tools"), CoreToolList: coreToolList, NoSkills: noSkillsFlag, + NoAgents: noAgentsFlag, Skills: skillsPaths, SkillsDir: skillsDir, SkillsDisable: skillsDisable, diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 87c62753..ebb0cea2 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -56,6 +56,11 @@ type AgentConfig struct { // Used by extensions to register custom tools. ExtraTools []fantasy.AgentTool + // NamedAgents lists discovered named agent definitions to advertise in + // the subagent tool description (see core.WithNamedAgents). Only + // consumed when core tools are built from CoreToolList. + NamedAgents []core.NamedAgentSpec + // OnMCPServerLoaded, if non-nil, is called when each MCP server finishes // loading (successfully or with error). The callback receives the server // name, tool count, and any error. Called from the background goroutine. @@ -315,7 +320,11 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) { coreTools = agentConfig.CoreTools } else { // Default: load all core tools - coreTools = core.ListedTools(agentConfig.CoreToolList) + var toolOpts []core.ToolOption + if len(agentConfig.NamedAgents) > 0 { + toolOpts = append(toolOpts, core.WithNamedAgents(agentConfig.NamedAgents...)) + } + coreTools = core.ListedTools(agentConfig.CoreToolList, toolOpts...) } // Build the initial tool list: core tools + extension tools (no MCP yet). diff --git a/internal/agent/factory.go b/internal/agent/factory.go index 7887db00..3a898f1d 100644 --- a/internal/agent/factory.go +++ b/internal/agent/factory.go @@ -7,6 +7,7 @@ import ( "charm.land/fantasy" "github.com/mark3labs/kit/internal/config" + "github.com/mark3labs/kit/internal/core" "github.com/mark3labs/kit/internal/models" "github.com/mark3labs/kit/internal/tools" ) @@ -53,6 +54,9 @@ type AgentCreationOptions struct { ToolWrapper func([]fantasy.AgentTool) []fantasy.AgentTool // ExtraTools are additional tools to include (e.g. from extensions). ExtraTools []fantasy.AgentTool + // NamedAgents lists discovered named agent definitions to advertise in + // the subagent tool description. + NamedAgents []core.NamedAgentSpec // OnMCPServerLoaded, if non-nil, is called when each MCP server finishes // loading (successfully or with error). Called from the background goroutine. OnMCPServerLoaded func(serverName string, toolCount int, err error) @@ -77,6 +81,7 @@ func CreateAgent(ctx context.Context, opts *AgentCreationOptions) (*Agent, error CoreToolList: opts.CoreToolList, ToolWrapper: opts.ToolWrapper, ExtraTools: opts.ExtraTools, + NamedAgents: opts.NamedAgents, OnMCPServerLoaded: opts.OnMCPServerLoaded, MCPTaskConfig: opts.MCPTaskConfig, } diff --git a/internal/agents/agents.go b/internal/agents/agents.go new file mode 100644 index 00000000..aa9dd406 --- /dev/null +++ b/internal/agents/agents.go @@ -0,0 +1,307 @@ +// Package agents provides discovery and parsing of named agent definitions. +// +// Named agents are reusable subagent presets defined in markdown files with +// YAML frontmatter. The filename (minus the .md extension) becomes the agent +// name, the frontmatter configures the agent, and the markdown body is used +// as the agent's system prompt. +// +// Discovery follows Kit's existing skills/prompts conventions: +// +// $XDG_CONFIG_HOME/kit/agents/ user-level agents (default ~/.config/kit/agents/) +// /.agents/agents/ project-local cross-client agents +// /.kit/agents/ project-local Kit agents +// +// Project-level agents take precedence over user-level agents, which take +// precedence over the built-in agents shipped with Kit. An agent with +// `disabled: true` removes it (and anything it shadows) from the final set. +package agents + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +// Source values identifying where an agent definition was discovered. +const ( + SourceBuiltin = "builtin" + SourceUser = "user" + SourceProject = "project" +) + +// Agent is a named, reusable subagent preset. +type Agent struct { + // Name identifies the agent. Derived from the definition filename + // (minus the .md extension) for file-based agents. + Name string + + // Description summarises what the agent does. Required — it is + // advertised to the LLM in the subagent tool description. + Description string + + // Model optionally overrides the spawning agent's model + // (e.g. "anthropic/claude-sonnet-4"). Empty inherits the parent model. + Model string + + // Tools is an optional allowlist of tool names available to the agent. + // Empty means the default subagent tool set (all tools except + // "subagent"). + Tools []string + + // Temperature optionally overrides the sampling temperature. + Temperature *float32 + + // Timeout optionally bounds the agent's execution time. Zero uses the + // subagent default. + Timeout time.Duration + + // Hidden excludes the agent from the subagent tool description while + // keeping it resolvable by name. + Hidden bool + + // Disabled removes the agent from the discovered set entirely. + Disabled bool + + // SystemPrompt is the agent's system prompt (the markdown body). + SystemPrompt string + + // FilePath is the on-disk path of the definition file. Empty for + // built-in agents. + FilePath string + + // Source records where the agent was discovered: "builtin", "user", + // or "project". + Source string +} + +// frontmatter mirrors the YAML frontmatter schema of an agent definition. +type frontmatter struct { + Description string `yaml:"description"` + Model string `yaml:"model"` + Tools []string `yaml:"tools"` + Temperature *float32 `yaml:"temperature"` + Timeout int `yaml:"timeout"` // seconds + Hidden bool `yaml:"hidden"` + Disabled bool `yaml:"disabled"` +} + +// Load parses a single agent definition file. The agent name is derived from +// the filename (minus the .md extension). A missing or empty description in +// the frontmatter is an error. +func Load(path string) (*Agent, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading agent file %s: %w", path, err) + } + + name := strings.TrimSuffix(filepath.Base(path), ".md") + fmRaw, body := splitFrontmatter(string(data)) + + var fm frontmatter + if fmRaw != "" { + if err := yaml.Unmarshal([]byte(fmRaw), &fm); err != nil { + return nil, fmt.Errorf("agent %s: parsing frontmatter: %w", name, err) + } + } + if strings.TrimSpace(fm.Description) == "" { + return nil, fmt.Errorf("agent %s: description is required in frontmatter", name) + } + + var timeout time.Duration + if fm.Timeout > 0 { + timeout = time.Duration(fm.Timeout) * time.Second + } + + return &Agent{ + Name: name, + Description: strings.TrimSpace(fm.Description), + Model: strings.TrimSpace(fm.Model), + Tools: fm.Tools, + Temperature: fm.Temperature, + Timeout: timeout, + Hidden: fm.Hidden, + Disabled: fm.Disabled, + SystemPrompt: strings.TrimSpace(body), + FilePath: path, + }, nil +} + +// LoadFromDir loads all agent definitions (*.md files) directly inside dir. +// A missing directory is not an error. Files that fail to parse are skipped; +// their errors are aggregated into the returned error alongside the +// successfully parsed agents. +func LoadFromDir(dir string) ([]*Agent, error) { + info, err := os.Stat(dir) + if err != nil || !info.IsDir() { + return nil, nil // directory doesn't exist — not an error + } + + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("reading agents directory %s: %w", dir, err) + } + + var loaded []*Agent + var errs []string + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") { + continue + } + a, err := Load(filepath.Join(dir, entry.Name())) + if err != nil { + errs = append(errs, err.Error()) + continue + } + loaded = append(loaded, a) + } + + if len(errs) > 0 { + return loaded, fmt.Errorf("some agents failed to load: %s", strings.Join(errs, "; ")) + } + return loaded, nil +} + +// GlobalDir returns the XDG-aligned global agents directory, respecting +// $XDG_CONFIG_HOME. Defaults to ~/.config/kit/agents/. Returns an empty +// string if the user's home directory cannot be resolved. +func GlobalDir() string { + base := os.Getenv("XDG_CONFIG_HOME") + if base == "" { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + base = filepath.Join(home, ".config") + } + return filepath.Join(base, "kit", "agents") +} + +// LoadAgents discovers named agents from the standard locations, applies +// precedence, and filters out disabled agents. cwd is the working directory +// for project-local discovery; if empty, the current working directory is +// used. +// +// Precedence (highest to lowest): +// +// 1. /.agents/agents/ (project, cross-client convention) +// 2. /.kit/agents/ (project, Kit-specific) +// 3. $XDG_CONFIG_HOME/kit/agents/ (user, default ~/.config/kit/agents/) +// 4. built-in agents +// +// When two definitions share a name, the higher-precedence one wins. An +// agent marked `disabled: true` is dropped after precedence resolution, so a +// project can disable a built-in or user-level agent by shadowing it. +// +// Per-file parse failures do not abort discovery: the aggregated error is +// returned alongside the successfully loaded agents. +func LoadAgents(cwd string) ([]*Agent, error) { + if cwd == "" { + cwd, _ = os.Getwd() + } + + type scope struct { + dir string + source string + } + scopes := []scope{ + {filepath.Join(cwd, ".agents", "agents"), SourceProject}, + {filepath.Join(cwd, ".kit", "agents"), SourceProject}, + } + if dir := GlobalDir(); dir != "" { + scopes = append(scopes, scope{dir, SourceUser}) + } + + var sets [][]*Agent + var errs []string + for _, s := range scopes { + loaded, err := LoadFromDir(s.dir) + if err != nil { + errs = append(errs, err.Error()) + } + for _, a := range loaded { + a.Source = s.source + } + sets = append(sets, loaded) + } + sets = append(sets, Builtins()) + + result := Merge(sets...) + + if len(errs) > 0 { + return result, fmt.Errorf("%s", strings.Join(errs, "; ")) + } + return result, nil +} + +// Merge combines agent sets ordered from highest to lowest precedence. The +// first definition of a name wins; later definitions of the same name are +// dropped. Agents marked Disabled are removed after precedence resolution, +// so a high-precedence disabled definition also removes anything it shadows. +func Merge(sets ...[]*Agent) []*Agent { + seen := make(map[string]struct{}) + var merged []*Agent + for _, set := range sets { + for _, a := range set { + if a == nil || a.Name == "" { + continue + } + if _, ok := seen[a.Name]; ok { + continue + } + seen[a.Name] = struct{}{} + merged = append(merged, a) + } + } + + result := merged[:0] + for _, a := range merged { + if a.Disabled { + continue + } + result = append(result, a) + } + return result +} + +// Builtins returns the built-in agents shipped with Kit. They can be +// overridden (or disabled) by user- or project-level definitions with the +// same name. +func Builtins() []*Agent { + return []*Agent{ + { + Name: "general", + Description: "General-purpose agent for researching complex questions and executing multi-step tasks", + Source: SourceBuiltin, + SystemPrompt: `You are a general-purpose agent handling a delegated task. Work autonomously: research, plan, and execute the task end to end without asking for clarification. When finished, report a single complete answer that includes concrete details (file paths, commands, findings) the delegating agent can act on directly.`, + }, + { + Name: "explore", + Description: "Read-only agent specialized for exploring codebases: finds files, searches code, and answers questions about structure and behavior", + Tools: []string{"read", "grep", "find", "ls"}, + Source: SourceBuiltin, + SystemPrompt: `You are a read-only codebase exploration agent. Find files, search code, and answer questions about the project's structure and behavior. You cannot modify anything: never attempt to write, edit, or execute state-changing commands. Report concise findings with concrete file paths and, where useful, line references.`, + }, + } +} + +// splitFrontmatter separates YAML frontmatter from the markdown body. +// Frontmatter must start on the first line with "---" and end with a "---" +// line. When no frontmatter is present, the whole content is the body. +func splitFrontmatter(content string) (fm string, body string) { + normalized := strings.ReplaceAll(content, "\r\n", "\n") + if !strings.HasPrefix(normalized, "---\n") { + return "", content + } + rest := normalized[len("---\n"):] + if before, after, ok := strings.Cut(rest, "\n---\n"); ok { + return before, after + } + if trimmed, ok := strings.CutSuffix(rest, "\n---"); ok { + return trimmed, "" + } + return "", content +} diff --git a/internal/agents/agents_test.go b/internal/agents/agents_test.go new file mode 100644 index 00000000..c9675e5e --- /dev/null +++ b/internal/agents/agents_test.go @@ -0,0 +1,267 @@ +package agents + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func writeAgent(t *testing.T, dir, name, content string) string { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } + return path +} + +func TestLoad_FullFrontmatter(t *testing.T) { + dir := t.TempDir() + path := writeAgent(t, dir, "code-reviewer.md", `--- +description: Reviews code for quality and best practices +model: anthropic/claude-sonnet-4 +tools: [read, grep, find, ls] +temperature: 0.1 +timeout: 300 +hidden: true +disabled: false +--- +You are in code review mode. Focus on correctness.`) + + a, err := Load(path) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + if a.Name != "code-reviewer" { + t.Errorf("Name = %q, want code-reviewer", a.Name) + } + if a.Description != "Reviews code for quality and best practices" { + t.Errorf("Description = %q", a.Description) + } + if a.Model != "anthropic/claude-sonnet-4" { + t.Errorf("Model = %q", a.Model) + } + if want := []string{"read", "grep", "find", "ls"}; len(a.Tools) != len(want) { + t.Errorf("Tools = %v, want %v", a.Tools, want) + } + if a.Temperature == nil || *a.Temperature != 0.1 { + t.Errorf("Temperature = %v, want 0.1", a.Temperature) + } + if a.Timeout != 300*time.Second { + t.Errorf("Timeout = %v, want 5m", a.Timeout) + } + if !a.Hidden { + t.Error("Hidden should be true") + } + if a.Disabled { + t.Error("Disabled should be false") + } + if a.SystemPrompt != "You are in code review mode. Focus on correctness." { + t.Errorf("SystemPrompt = %q", a.SystemPrompt) + } + if a.FilePath != path { + t.Errorf("FilePath = %q, want %q", a.FilePath, path) + } +} + +func TestLoad_MinimalFrontmatter(t *testing.T) { + dir := t.TempDir() + path := writeAgent(t, dir, "helper.md", "---\ndescription: Helps\n---\nBe helpful.") + + a, err := Load(path) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + if a.Model != "" || a.Tools != nil || a.Temperature != nil || a.Timeout != 0 || a.Hidden || a.Disabled { + t.Errorf("optional fields should be zero-valued: %+v", a) + } +} + +func TestLoad_MissingDescription(t *testing.T) { + dir := t.TempDir() + path := writeAgent(t, dir, "bad.md", "---\nmodel: foo/bar\n---\nBody.") + + if _, err := Load(path); err == nil { + t.Fatal("expected error for missing description") + } else if !strings.Contains(err.Error(), "description is required") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestLoad_NoFrontmatter(t *testing.T) { + dir := t.TempDir() + path := writeAgent(t, dir, "plain.md", "Just a body, no frontmatter.") + + if _, err := Load(path); err == nil { + t.Fatal("expected error for missing frontmatter/description") + } +} + +func TestSplitFrontmatter(t *testing.T) { + tests := []struct { + name string + content string + wantFM string + wantBody string + }{ + {"basic", "---\na: 1\n---\nbody", "a: 1", "body"}, + {"empty body", "---\na: 1\n---\n", "a: 1", ""}, + {"closing sep at EOF", "---\na: 1\n---", "a: 1", ""}, + {"no frontmatter", "body only", "", "body only"}, + {"crlf", "---\r\na: 1\r\n---\r\nbody", "a: 1", "body"}, + {"unclosed", "---\na: 1\nbody", "", "---\na: 1\nbody"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fm, body := splitFrontmatter(tt.content) + if fm != tt.wantFM { + t.Errorf("fm = %q, want %q", fm, tt.wantFM) + } + if body != tt.wantBody { + t.Errorf("body = %q, want %q", body, tt.wantBody) + } + }) + } +} + +func TestLoadFromDir(t *testing.T) { + dir := t.TempDir() + writeAgent(t, dir, "one.md", "---\ndescription: One\n---\nPrompt one.") + writeAgent(t, dir, "two.md", "---\ndescription: Two\n---\nPrompt two.") + writeAgent(t, dir, "broken.md", "---\nmodel: x\n---\nNo description.") + writeAgent(t, dir, "notes.txt", "not an agent") + if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil { + t.Fatal(err) + } + + loaded, err := LoadFromDir(dir) + if err == nil { + t.Error("expected aggregated error for broken.md") + } + if len(loaded) != 2 { + t.Fatalf("loaded %d agents, want 2", len(loaded)) + } +} + +func TestLoadFromDir_Missing(t *testing.T) { + loaded, err := LoadFromDir(filepath.Join(t.TempDir(), "does-not-exist")) + if err != nil { + t.Fatalf("missing dir should not error: %v", err) + } + if loaded != nil { + t.Errorf("expected nil agents, got %v", loaded) + } +} + +func TestMerge_PrecedenceAndDisabled(t *testing.T) { + high := []*Agent{ + {Name: "shared", Description: "high"}, + {Name: "off", Description: "disabled high", Disabled: true}, + } + low := []*Agent{ + {Name: "shared", Description: "low"}, + {Name: "off", Description: "shadowed low"}, + {Name: "solo", Description: "only low"}, + } + + merged := Merge(high, low) + + byName := map[string]*Agent{} + for _, a := range merged { + byName[a.Name] = a + } + if len(merged) != 2 { + t.Fatalf("merged %d agents, want 2: %v", len(merged), byName) + } + if byName["shared"] == nil || byName["shared"].Description != "high" { + t.Errorf("shared should resolve to high-precedence definition, got %+v", byName["shared"]) + } + // A high-precedence disabled agent removes the name entirely, including + // anything it shadows. + if byName["off"] != nil { + t.Error("disabled agent should be dropped") + } + if byName["solo"] == nil { + t.Error("non-colliding low-precedence agent should survive") + } +} + +func TestBuiltins(t *testing.T) { + builtins := Builtins() + byName := map[string]*Agent{} + for _, a := range builtins { + byName[a.Name] = a + if a.Description == "" { + t.Errorf("builtin %s has no description", a.Name) + } + if a.Source != SourceBuiltin { + t.Errorf("builtin %s has source %q", a.Name, a.Source) + } + } + if byName["general"] == nil { + t.Fatal("missing builtin: general") + } + if byName["explore"] == nil { + t.Fatal("missing builtin: explore") + } + if len(byName["general"].Tools) != 0 { + t.Errorf("general should have no tool restriction, got %v", byName["general"].Tools) + } + want := []string{"read", "grep", "find", "ls"} + got := byName["explore"].Tools + if len(got) != len(want) { + t.Fatalf("explore tools = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("explore tools = %v, want %v", got, want) + } + } +} + +func TestLoadAgents_Precedence(t *testing.T) { + cwd := t.TempDir() + // Isolate the user-level dir. + t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "config")) + userDir := filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "kit", "agents") + + // User-level agent, shadowed by a project one. + writeAgent(t, userDir, "reviewer.md", "---\ndescription: user reviewer\n---\nUser prompt.") + // Project agents in both project scopes; .agents/agents wins over .kit/agents. + writeAgent(t, filepath.Join(cwd, ".agents", "agents"), "reviewer.md", "---\ndescription: project reviewer\n---\nProject prompt.") + writeAgent(t, filepath.Join(cwd, ".kit", "agents"), "reviewer.md", "---\ndescription: kit-dir reviewer\n---\nKit prompt.") + // User-level only agent. + writeAgent(t, userDir, "tester.md", "---\ndescription: writes tests\n---\nTest prompt.") + // Project override disabling a builtin. + writeAgent(t, filepath.Join(cwd, ".agents", "agents"), "explore.md", "---\ndescription: off\ndisabled: true\n---\n") + + loaded, err := LoadAgents(cwd) + if err != nil { + t.Fatalf("LoadAgents failed: %v", err) + } + + byName := map[string]*Agent{} + for _, a := range loaded { + byName[a.Name] = a + } + + if r := byName["reviewer"]; r == nil || r.Description != "project reviewer" { + t.Errorf("reviewer should come from .agents/agents, got %+v", r) + } else if r.Source != SourceProject { + t.Errorf("reviewer source = %q, want project", r.Source) + } + if tst := byName["tester"]; tst == nil || tst.Source != SourceUser { + t.Errorf("tester should come from user scope, got %+v", tst) + } + if byName["explore"] != nil { + t.Error("disabled project override should remove builtin explore") + } + if byName["general"] == nil { + t.Error("builtin general should still be present") + } +} diff --git a/internal/core/subagent.go b/internal/core/subagent.go index 76ef8bc7..e84581d0 100644 --- a/internal/core/subagent.go +++ b/internal/core/subagent.go @@ -3,12 +3,16 @@ package core import ( "context" "fmt" + "strings" "time" "charm.land/fantasy" ) -const defaultSubagentTimeout = 5 * time.Minute +// maxSubagentTimeout caps the timeout an LLM can request via +// timeout_seconds. The 5-minute default (applied downstream when no timeout +// is requested and no named agent preset supplies one) lives in +// pkg/kit's Kit.Subagent. const maxSubagentTimeout = 30 * time.Minute // --------------------------------------------------------------------------- @@ -25,12 +29,30 @@ type SubagentSpawnResult struct { Elapsed time.Duration } +// SubagentSpawnRequest carries the parameters of an in-process subagent +// spawn from the subagent core tool to the parent Kit instance. +type SubagentSpawnRequest struct { + // ToolCallID is the LLM-assigned ID of the subagent tool call, + // enabling the parent to correlate subagent events. + ToolCallID string + // Prompt is the task for the subagent (required). + Prompt string + // Agent optionally names a discovered agent definition whose presets + // (model, system prompt, tools, timeout) apply as defaults. + Agent string + // Model optionally overrides the model. + Model string + // SystemPrompt optionally overrides the system prompt. + SystemPrompt string + // Timeout bounds execution. Zero means "unset": the spawner applies + // the named agent's timeout (if any) or the default. + Timeout time.Duration +} + // SubagentSpawnFunc is a callback that spawns an in-process subagent. The // parent Kit instance injects this into the context so the core tool can // call back without importing pkg/kit (which would create a cycle). -// The toolCallID parameter is the LLM-assigned ID of the subagent -// tool call, enabling the parent to correlate subagent events. -type SubagentSpawnFunc func(ctx context.Context, toolCallID, prompt, model, systemPrompt string, timeout time.Duration) (*SubagentSpawnResult, error) +type SubagentSpawnFunc func(ctx context.Context, req SubagentSpawnRequest) (*SubagentSpawnResult, error) type subagentCtxKey struct{} @@ -54,17 +76,26 @@ func getSubagentSpawner(ctx context.Context) SubagentSpawnFunc { type subagentArgs struct { Task string `json:"task"` + Agent string `json:"agent,omitempty"` Model string `json:"model,omitempty"` SystemPrompt string `json:"system_prompt,omitempty"` TimeoutSeconds int `json:"timeout_seconds,omitempty"` } -// NewSubagentTool creates the subagent core tool. -func NewSubagentTool(opts ...ToolOption) fantasy.AgentTool { - return &coreTool{ - info: fantasy.ToolInfo{ - Name: "subagent", - Description: `Spawn a subagent to perform a task autonomously. +// NamedAgentSpec summarises a named agent definition for advertisement in +// the subagent tool description. It is intentionally minimal so the core +// package does not depend on the agent discovery machinery. +type NamedAgentSpec struct { + // Name is the value the LLM passes in the "agent" parameter. + Name string + // Description summarises what the agent does. + Description string + // Tools lists the tool names the agent may use. Empty means the full + // default subagent tool set. + Tools []string +} + +const subagentBaseDescription = `Spawn a subagent to perform a task autonomously. The subagent runs as a separate in-process Kit instance with full tool access (except spawning further subagents). Use this to: @@ -78,12 +109,45 @@ consider breaking them into smaller focused subtasks. Example use cases: - "Research the authentication patterns in this codebase" - "Write unit tests for the UserService class" -- "Analyze the performance bottlenecks in the database queries"`, +- "Analyze the performance bottlenecks in the database queries"` + +// subagentDescription composes the tool description, appending the list of +// available named agents (and their tool access) when any are configured. +func subagentDescription(agents []NamedAgentSpec) string { + if len(agents) == 0 { + return subagentBaseDescription + } + var b strings.Builder + b.WriteString(subagentBaseDescription) + b.WriteString("\n\nAvailable named agents (pass the \"agent\" parameter to use one) and the tools they have access to:\n") + for _, a := range agents { + tools := "all tools" + if len(a.Tools) > 0 { + tools = strings.Join(a.Tools, ", ") + } + fmt.Fprintf(&b, "- %s: %s (tools: %s)\n", a.Name, a.Description, tools) + } + return strings.TrimRight(b.String(), "\n") +} + +// NewSubagentTool creates the subagent core tool. When named agents are +// configured via WithNamedAgents, they are advertised in the tool +// description so the LLM can delegate to the right specialist. +func NewSubagentTool(opts ...ToolOption) fantasy.AgentTool { + cfg := ApplyOptions(opts) + return &coreTool{ + info: fantasy.ToolInfo{ + Name: "subagent", + Description: subagentDescription(cfg.NamedAgents), Parameters: map[string]any{ "task": map[string]any{ "type": "string", "description": "The complete task description for the subagent to perform", }, + "agent": map[string]any{ + "type": "string", + "description": "Optional named agent to run the task with. Named agents provide preset system prompts, models, and tool restrictions. Explicit model/system_prompt/timeout_seconds arguments override the agent's presets.", + }, "model": map[string]any{ "type": "string", "description": "Optional model override. Empty string uses the current model.", @@ -115,8 +179,9 @@ func executeSubagent(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolRe return fantasy.NewTextErrorResponse("task parameter is required"), nil } - // Determine timeout. - timeout := defaultSubagentTimeout + // Determine timeout. Zero means "unset" so downstream resolution can + // apply a named agent's preset timeout (or the 5-minute default). + var timeout time.Duration if args.TimeoutSeconds > 0 { timeout = min(time.Duration(args.TimeoutSeconds)*time.Second, maxSubagentTimeout) } @@ -148,7 +213,14 @@ func executeSubagent(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolRe spawnCtx := context.WithoutCancel(valuesContext{parent: ctx}) // Spawn in-process subagent. - result, err := spawner(spawnCtx, call.ID, args.Task, args.Model, args.SystemPrompt, timeout) + result, err := spawner(spawnCtx, SubagentSpawnRequest{ + ToolCallID: call.ID, + Prompt: args.Task, + Agent: args.Agent, + Model: args.Model, + SystemPrompt: args.SystemPrompt, + Timeout: timeout, + }) if err != nil || result.Error != nil { spawnErr := err if spawnErr == nil { diff --git a/internal/core/subagent_named_test.go b/internal/core/subagent_named_test.go new file mode 100644 index 00000000..dac11c31 --- /dev/null +++ b/internal/core/subagent_named_test.go @@ -0,0 +1,114 @@ +package core + +import ( + "context" + "strings" + "testing" + "time" + + "charm.land/fantasy" +) + +func TestSubagentDescription_NoAgents(t *testing.T) { + if got := subagentDescription(nil); got != subagentBaseDescription { + t.Errorf("description without agents should equal the base description") + } +} + +func TestSubagentDescription_WithAgents(t *testing.T) { + desc := subagentDescription([]NamedAgentSpec{ + {Name: "explore", Description: "Read-only exploration", Tools: []string{"read", "grep"}}, + {Name: "general", Description: "General-purpose agent"}, + }) + + if !strings.HasPrefix(desc, subagentBaseDescription) { + t.Error("description should start with the base description") + } + if !strings.Contains(desc, "Available named agents") { + t.Error("description should advertise named agents") + } + if !strings.Contains(desc, "- explore: Read-only exploration (tools: read, grep)") { + t.Errorf("missing explore line in:\n%s", desc) + } + if !strings.Contains(desc, "- general: General-purpose agent (tools: all tools)") { + t.Errorf("missing general line in:\n%s", desc) + } +} + +func TestNewSubagentTool_AdvertisesNamedAgents(t *testing.T) { + tool := NewSubagentTool(WithNamedAgents(NamedAgentSpec{ + Name: "reviewer", Description: "Reviews code", Tools: []string{"read"}, + })) + + info := tool.Info() + if !strings.Contains(info.Description, "- reviewer: Reviews code (tools: read)") { + t.Errorf("tool description missing named agent:\n%s", info.Description) + } + if _, ok := info.Parameters["agent"]; !ok { + t.Error("subagent tool should expose an 'agent' parameter") + } +} + +func TestNewSubagentTool_DefaultHasAgentParam(t *testing.T) { + info := NewSubagentTool().Info() + if info.Description != subagentBaseDescription { + t.Error("default tool description should be the base description") + } + if _, ok := info.Parameters["agent"]; !ok { + t.Error("subagent tool should expose an 'agent' parameter even without named agents") + } +} + +func TestExecuteSubagent_PassesAgentToSpawner(t *testing.T) { + var captured SubagentSpawnRequest + spawner := SubagentSpawnFunc(func(ctx context.Context, req SubagentSpawnRequest) (*SubagentSpawnResult, error) { + captured = req + return &SubagentSpawnResult{Response: "done"}, nil + }) + ctx := WithSubagentSpawner(context.Background(), spawner) + + resp, err := executeSubagent(ctx, fantasy.ToolCall{ + ID: "tc42", + Input: `{"task":"look around","agent":"explore","timeout_seconds":60}`, + }) + if err != nil { + t.Fatalf("executeSubagent failed: %v", err) + } + if resp.IsError { + t.Fatalf("unexpected error response: %s", resp.Content) + } + + if captured.ToolCallID != "tc42" { + t.Errorf("ToolCallID = %q, want tc42", captured.ToolCallID) + } + if captured.Prompt != "look around" { + t.Errorf("Prompt = %q", captured.Prompt) + } + if captured.Agent != "explore" { + t.Errorf("Agent = %q, want explore", captured.Agent) + } + if captured.Timeout != 60*time.Second { + t.Errorf("Timeout = %v, want 60s", captured.Timeout) + } +} + +func TestExecuteSubagent_UnsetTimeoutIsZero(t *testing.T) { + // A zero timeout signals "unset" so downstream resolution can apply a + // named agent's preset timeout or the default. + var captured SubagentSpawnRequest + spawner := SubagentSpawnFunc(func(ctx context.Context, req SubagentSpawnRequest) (*SubagentSpawnResult, error) { + captured = req + return &SubagentSpawnResult{Response: "done"}, nil + }) + ctx := WithSubagentSpawner(context.Background(), spawner) + + if _, err := executeSubagent(ctx, fantasy.ToolCall{ID: "tc1", Input: `{"task":"t"}`}); err != nil { + t.Fatalf("executeSubagent failed: %v", err) + } + if captured.Timeout != 0 { + t.Errorf("Timeout = %v, want 0 (unset)", captured.Timeout) + } + if captured.Agent != "" { + t.Errorf("Agent = %q, want empty", captured.Agent) + } +} diff --git a/internal/core/subagent_test.go b/internal/core/subagent_test.go index 3f1913a4..04aac248 100644 --- a/internal/core/subagent_test.go +++ b/internal/core/subagent_test.go @@ -84,7 +84,7 @@ func TestSpawnContext_SurvivesDeadlineExceededParent(t *testing.T) { func TestSpawnContext_PreservesSpawnerValue(t *testing.T) { // Verify the subagent spawner callback survives context detachment. called := false - spawner := SubagentSpawnFunc(func(ctx context.Context, toolCallID, prompt, model, systemPrompt string, timeout time.Duration) (*SubagentSpawnResult, error) { + spawner := SubagentSpawnFunc(func(ctx context.Context, req SubagentSpawnRequest) (*SubagentSpawnResult, error) { called = true return &SubagentSpawnResult{Response: "ok"}, nil }) @@ -102,7 +102,7 @@ func TestSpawnContext_PreservesSpawnerValue(t *testing.T) { t.Fatal("spawner should be recoverable from detached context") } - result, err := recovered(spawnCtx, "tc1", "test task", "", "", time.Minute) + result, err := recovered(spawnCtx, SubagentSpawnRequest{ToolCallID: "tc1", Prompt: "test task", Timeout: time.Minute}) if err != nil { t.Fatalf("spawner call failed: %v", err) } diff --git a/internal/core/tools.go b/internal/core/tools.go index 662184f7..3f01e0df 100644 --- a/internal/core/tools.go +++ b/internal/core/tools.go @@ -20,6 +20,9 @@ type ToolOption func(*ToolConfig) // ToolConfig holds configuration for tool construction. type ToolConfig struct { WorkDir string + // NamedAgents lists discovered named agent definitions advertised in + // the subagent tool description. Only the subagent tool consumes this. + NamedAgents []NamedAgentSpec } // WithWorkDir sets the working directory for file-based tools. @@ -30,6 +33,14 @@ func WithWorkDir(dir string) ToolOption { } } +// WithNamedAgents advertises named agent definitions in the subagent tool +// description so the LLM can delegate tasks to them by name. +func WithNamedAgents(agents ...NamedAgentSpec) ToolOption { + return func(c *ToolConfig) { + c.NamedAgents = append(c.NamedAgents, agents...) + } +} + // ApplyOptions applies the given ToolOptions to a ToolConfig and returns it. func ApplyOptions(opts []ToolOption) ToolConfig { var cfg ToolConfig diff --git a/internal/kitsetup/setup.go b/internal/kitsetup/setup.go index 3c327047..9be02436 100644 --- a/internal/kitsetup/setup.go +++ b/internal/kitsetup/setup.go @@ -11,6 +11,7 @@ import ( "github.com/mark3labs/kit/internal/agent" "github.com/mark3labs/kit/internal/config" + "github.com/mark3labs/kit/internal/core" "github.com/mark3labs/kit/internal/extensions" "github.com/mark3labs/kit/internal/models" "github.com/mark3labs/kit/internal/tools" @@ -40,6 +41,9 @@ type AgentSetupOptions struct { // ExtraTools are additional tools added alongside core, MCP, and extension // tools. They do not replace the defaults — they extend them. ExtraTools []fantasy.AgentTool + // NamedAgents lists discovered named agent definitions to advertise in + // the subagent tool description. + NamedAgents []core.NamedAgentSpec // ToolWrapper is an optional function that wraps tools after extension // wrapping. Used by the SDK hook system. Both wrappers compose: // extension wrapper runs first (inner), then this wrapper (outer). @@ -258,6 +262,7 @@ func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult, CoreToolList: opts.CoreToolList, ToolWrapper: toolWrapper, ExtraTools: extraTools, + NamedAgents: opts.NamedAgents, OnMCPServerLoaded: opts.OnMCPServerLoaded, MCPTaskConfig: opts.MCPTaskConfig, }) diff --git a/pkg/kit/README.md b/pkg/kit/README.md index 9387f5c5..aae65cc2 100644 --- a/pkg/kit/README.md +++ b/pkg/kit/README.md @@ -396,6 +396,13 @@ msg := kit.ConvertFromLLMMessage(lMsg) // LLMMessage → SDK Message - `GetSessionPath()` - Get session file path - `GetSessionID()` - Get session UUID - `AddSkill(*Skill)` / `LoadAndAddSkill(path)` / `RemoveSkill(name)` / `SetSkills([])` - Manage skills at runtime +- `Subagent(ctx, SubagentConfig)` - Spawn an in-process child Kit instance; + set `SubagentConfig.Agent` to apply a named agent definition's presets +- `GetAgents()` / `GetAgent(name)` - Query named agent definitions discovered + at construction (built-ins plus `.agents/agents/` / `.kit/agents/` / + `~/.config/kit/agents/` files) +- `LoadAgentDefinitions(cwd)` - Standalone named-agent discovery without a Kit + instance - `AddContextFile(*ContextFile)` / `AddContextFileContent(path, content)` / `LoadAndAddContextFile(path)` / `RemoveContextFile(path)` / `SetContextFiles([])` - Manage AGENTS.md-style context files at runtime - `RefreshSystemPrompt()` - Re-apply the composed system prompt to the agent - `NewTool[T]` / `NewParallelTool[T]` - Create a typed custom tool @@ -423,6 +430,7 @@ Key `Options` fields for SDK usage: | `DisableCoreTools` | Use no core tools (0 tools, for chat-only) | | `CoreToolList` | List of core tools to include, if empty (default) use all. | | `NoSession` | Ephemeral mode (no session persistence) | +| `NoAgents` | Disable named agent discovery (built-ins and definition files) | | `SessionPath` | Open specific session file | | `Continue` | Resume most recent session | | `InProcessMCPServers` | Map of name → `*kit.MCPServer` for in-process MCP servers | diff --git a/pkg/kit/agents.go b/pkg/kit/agents.go new file mode 100644 index 00000000..92926e43 --- /dev/null +++ b/pkg/kit/agents.go @@ -0,0 +1,192 @@ +package kit + +import ( + "fmt" + "strings" + + "github.com/mark3labs/kit/internal/agents" + "github.com/mark3labs/kit/internal/core" +) + +// ==== Named Agent Types ==== + +// AgentDefinition is a named, reusable subagent preset discovered from +// markdown definition files or built into Kit. The definition's system +// prompt, model, tool allowlist, temperature, and timeout act as defaults +// when a subagent is spawned with [SubagentConfig].Agent set to its name. +// +// Definition files are markdown with YAML frontmatter. The filename (minus +// the .md extension) is the agent name, and the body is the system prompt: +// +// --- +// description: Reviews code for quality and best practices # required +// model: anthropic/claude-sonnet-4 # optional +// tools: [read, grep, find, ls] # optional allowlist +// temperature: 0.1 # optional +// timeout: 300 # optional, seconds +// hidden: false # optional +// disabled: false # optional +// --- +// You are in code review mode. Focus on... +type AgentDefinition = agents.Agent + +// ==== Named Agent Functions ==== + +// LoadAgentDefinitions discovers named agents from the standard locations, +// applies precedence, and filters out disabled agents: +// +// 1. /.agents/agents/ (project, cross-client convention) +// 2. /.kit/agents/ (project, Kit-specific) +// 3. $XDG_CONFIG_HOME/kit/agents/ (user, default ~/.config/kit/agents/) +// 4. Built-in agents ("general", "explore") +// +// Higher entries take precedence on name collisions. cwd is the working +// directory for project-local discovery; if empty, the current working +// directory is used. Per-file parse failures do not abort discovery — the +// aggregated error is returned alongside the successfully loaded agents. +func LoadAgentDefinitions(cwd string) ([]*AgentDefinition, error) { + return agents.LoadAgents(cwd) +} + +// GetAgents returns the named agent definitions discovered at construction +// time (including built-ins, excluding disabled ones). The returned slice is +// a deep-copied snapshot — mutating the returned definitions (including +// their Tools slices) does not affect Kit state. Returns nil when agent +// discovery was disabled via [Options].NoAgents. +func (m *Kit) GetAgents() []*AgentDefinition { + if len(m.namedAgents) == 0 { + return nil + } + out := make([]*AgentDefinition, len(m.namedAgents)) + for i, a := range m.namedAgents { + out[i] = cloneAgentDefinition(a) + } + return out +} + +// cloneAgentDefinition returns a deep copy of def, cloning the mutable +// Tools slice and Temperature pointer so callers cannot mutate shared Kit +// state through the copy. Returns nil for a nil input. +func cloneAgentDefinition(def *AgentDefinition) *AgentDefinition { + if def == nil { + return nil + } + cp := *def + if def.Tools != nil { + cp.Tools = append([]string(nil), def.Tools...) + } + if def.Temperature != nil { + t := *def.Temperature + cp.Temperature = &t + } + return &cp +} + +// GetAgent returns the named agent definition with the given name, or +// (nil, false) when no such agent was discovered. The returned definition +// is a deep copy — mutating it does not affect Kit state. +func (m *Kit) GetAgent(name string) (*AgentDefinition, bool) { + for _, a := range m.namedAgents { + if a.Name == name { + return cloneAgentDefinition(a), true + } + } + return nil, false +} + +// ==== Internal helpers ==== + +// namedAgentSpecs converts discovered agent definitions into the summaries +// advertised in the subagent tool description. Hidden agents are omitted — +// they stay resolvable by name but are not advertised to the LLM. +func namedAgentSpecs(defs []*AgentDefinition) []core.NamedAgentSpec { + var specs []core.NamedAgentSpec + for _, a := range defs { + if a.Hidden { + continue + } + specs = append(specs, core.NamedAgentSpec{ + Name: a.Name, + Description: a.Description, + Tools: a.Tools, + }) + } + return specs +} + +// resolveAgentDefinition looks up a named agent and merges its presets into +// cfg. Explicitly set scalar cfg fields (Model, SystemPrompt, Timeout, +// Temperature) win over the definition's values. Tools are handled +// differently: when the definition declares a tools allowlist, cfg.Tools is +// treated as the base set (defaulting to SubagentTools()) and intersected +// with the allowlist — it never widens beyond it. This is deliberate: the +// internal agent-loop spawner always passes the parent's inherited tools, +// and a full override there would let inherited tools bypass the allowlist. +// It reports whether the definition restricted the tool set (callers must +// then prevent the child from re-loading MCP servers, which would bypass +// the allowlist). +func (m *Kit) resolveAgentDefinition(cfg *SubagentConfig) (restricted bool, err error) { + def, ok := m.GetAgent(cfg.Agent) + if !ok { + return false, fmt.Errorf("unknown agent %q (available: %s)", + cfg.Agent, strings.Join(m.agentNames(), ", ")) + } + applyAgentDefinition(cfg, def) + if len(def.Tools) > 0 { + base := cfg.Tools + if base == nil { + base = SubagentTools() + } + cfg.Tools = filterToolsByName(base, def.Tools) + restricted = true + } + return restricted, nil +} + +// agentNames returns the names of all non-hidden discovered agents. +func (m *Kit) agentNames() []string { + var names []string + for _, a := range m.namedAgents { + if a.Hidden { + continue + } + names = append(names, a.Name) + } + return names +} + +// applyAgentDefinition merges a named agent definition's presets into cfg. +// Fields the caller already set (model, system prompt, timeout, temperature) +// are left untouched so explicit arguments override the definition. +func applyAgentDefinition(cfg *SubagentConfig, def *AgentDefinition) { + if cfg.Model == "" { + cfg.Model = def.Model + } + if cfg.SystemPrompt == "" { + cfg.SystemPrompt = def.SystemPrompt + } + if cfg.Timeout == 0 { + cfg.Timeout = def.Timeout + } + if cfg.Temperature == nil { + cfg.Temperature = def.Temperature + } +} + +// filterToolsByName returns the tools whose names appear in the allowlist, +// preserving order. Allowlisted names with no matching tool are ignored. +// The result is never nil so an empty allowlist match still counts as an +// explicit (empty) tool set rather than "use defaults". +func filterToolsByName(tools []Tool, names []string) []Tool { + allowed := make(map[string]struct{}, len(names)) + for _, n := range names { + allowed[n] = struct{}{} + } + filtered := make([]Tool, 0, len(names)) + for _, t := range tools { + if _, ok := allowed[t.Info().Name]; ok { + filtered = append(filtered, t) + } + } + return filtered +} diff --git a/pkg/kit/agents_test.go b/pkg/kit/agents_test.go new file mode 100644 index 00000000..2127a067 --- /dev/null +++ b/pkg/kit/agents_test.go @@ -0,0 +1,299 @@ +package kit + +import ( + "strings" + "testing" + "time" + + "github.com/mark3labs/kit/internal/agents" +) + +func TestApplyAgentDefinition_FillsDefaults(t *testing.T) { + temp := float32(0.2) + def := &AgentDefinition{ + Name: "reviewer", + Model: "anthropic/claude-sonnet-4", + SystemPrompt: "Review carefully.", + Timeout: 2 * time.Minute, + Temperature: &temp, + } + cfg := SubagentConfig{Prompt: "review this", Agent: "reviewer"} + + applyAgentDefinition(&cfg, def) + + if cfg.Model != "anthropic/claude-sonnet-4" { + t.Errorf("Model = %q", cfg.Model) + } + if cfg.SystemPrompt != "Review carefully." { + t.Errorf("SystemPrompt = %q", cfg.SystemPrompt) + } + if cfg.Timeout != 2*time.Minute { + t.Errorf("Timeout = %v", cfg.Timeout) + } + if cfg.Temperature == nil || *cfg.Temperature != 0.2 { + t.Errorf("Temperature = %v", cfg.Temperature) + } +} + +func TestApplyAgentDefinition_ExplicitArgsWin(t *testing.T) { + defTemp := float32(0.2) + cfgTemp := float32(0.9) + def := &AgentDefinition{ + Name: "reviewer", + Model: "anthropic/claude-sonnet-4", + SystemPrompt: "Review carefully.", + Timeout: 2 * time.Minute, + Temperature: &defTemp, + } + cfg := SubagentConfig{ + Prompt: "review this", + Agent: "reviewer", + Model: "openai/gpt-4o", + SystemPrompt: "Custom prompt.", + Timeout: time.Minute, + Temperature: &cfgTemp, + } + + applyAgentDefinition(&cfg, def) + + if cfg.Model != "openai/gpt-4o" { + t.Errorf("explicit model should win, got %q", cfg.Model) + } + if cfg.SystemPrompt != "Custom prompt." { + t.Errorf("explicit system prompt should win, got %q", cfg.SystemPrompt) + } + if cfg.Timeout != time.Minute { + t.Errorf("explicit timeout should win, got %v", cfg.Timeout) + } + if cfg.Temperature == nil || *cfg.Temperature != 0.9 { + t.Errorf("explicit temperature should win, got %v", cfg.Temperature) + } +} + +func TestFilterToolsByName(t *testing.T) { + tools := SubagentTools() + filtered := filterToolsByName(tools, []string{"read", "grep", "nonexistent"}) + + if len(filtered) != 2 { + t.Fatalf("filtered %d tools, want 2", len(filtered)) + } + names := map[string]bool{} + for _, tl := range filtered { + names[tl.Info().Name] = true + } + if !names["read"] || !names["grep"] { + t.Errorf("filtered set = %v, want read+grep", names) + } +} + +func TestFilterToolsByName_EmptyResultIsNotNil(t *testing.T) { + filtered := filterToolsByName(SubagentTools(), []string{"nonexistent"}) + if filtered == nil { + t.Fatal("empty allowlist match must return a non-nil slice (explicit empty tool set)") + } + if len(filtered) != 0 { + t.Errorf("filtered = %v, want empty", filtered) + } +} + +func TestNamedAgentSpecs_ExcludesHidden(t *testing.T) { + defs := []*AgentDefinition{ + {Name: "visible", Description: "shown", Tools: []string{"read"}}, + {Name: "secret", Description: "hidden", Hidden: true}, + } + + specs := namedAgentSpecs(defs) + + if len(specs) != 1 { + t.Fatalf("got %d specs, want 1", len(specs)) + } + if specs[0].Name != "visible" || specs[0].Description != "shown" { + t.Errorf("spec = %+v", specs[0]) + } + if len(specs[0].Tools) != 1 || specs[0].Tools[0] != "read" { + t.Errorf("spec tools = %v", specs[0].Tools) + } +} + +func TestResolveAgentDefinition_Unknown(t *testing.T) { + k := &Kit{namedAgents: []*AgentDefinition{ + {Name: "general", Description: "g"}, + {Name: "explore", Description: "e"}, + {Name: "secret", Description: "s", Hidden: true}, + }} + cfg := SubagentConfig{Prompt: "p", Agent: "nope"} + + _, err := k.resolveAgentDefinition(&cfg) + if err == nil { + t.Fatal("expected error for unknown agent") + } + msg := err.Error() + if !strings.Contains(msg, `"nope"`) { + t.Errorf("error should name the unknown agent: %v", err) + } + if !strings.Contains(msg, "general") || !strings.Contains(msg, "explore") { + t.Errorf("error should list available agents: %v", err) + } + if strings.Contains(msg, "secret") { + t.Errorf("error should not reveal hidden agents: %v", err) + } +} + +func TestResolveAgentDefinition_AppliesAllowlist(t *testing.T) { + k := &Kit{namedAgents: []*AgentDefinition{ + {Name: "explore", Description: "e", Tools: []string{"read", "grep", "find", "ls"}}, + }} + cfg := SubagentConfig{Prompt: "p", Agent: "explore"} + + restricted, err := k.resolveAgentDefinition(&cfg) + if err != nil { + t.Fatalf("resolve failed: %v", err) + } + if !restricted { + t.Error("allowlisted agent should report restricted=true") + } + if len(cfg.Tools) != 4 { + t.Fatalf("cfg.Tools has %d tools, want 4", len(cfg.Tools)) + } + for _, tl := range cfg.Tools { + switch tl.Info().Name { + case "read", "grep", "find", "ls": + default: + t.Errorf("unexpected tool %q in restricted set", tl.Info().Name) + } + } +} + +func TestResolveAgentDefinition_ExplicitToolsIntersectAllowlist(t *testing.T) { + // Explicit cfg.Tools is the base set: the agent allowlist narrows it + // and cfg.Tools can never widen access beyond the allowlist. This is + // deliberate — the internal spawner always passes inherited tools, and + // a full override would bypass the allowlist. + k := &Kit{namedAgents: []*AgentDefinition{ + {Name: "explore", Description: "e", Tools: []string{"read", "grep"}}, + }} + cfg := SubagentConfig{ + Prompt: "p", + Agent: "explore", + // Base set includes tools outside the allowlist (bash, write). + Tools: SubagentTools(), + } + + restricted, err := k.resolveAgentDefinition(&cfg) + if err != nil { + t.Fatalf("resolve failed: %v", err) + } + if !restricted { + t.Error("allowlisted agent should report restricted=true") + } + if len(cfg.Tools) != 2 { + t.Fatalf("cfg.Tools has %d tools, want 2 (intersection)", len(cfg.Tools)) + } + for _, tl := range cfg.Tools { + switch tl.Info().Name { + case "read", "grep": + default: + t.Errorf("tool %q escaped the allowlist", tl.Info().Name) + } + } +} + +func TestResolveAgentDefinition_HiddenIsResolvable(t *testing.T) { + k := &Kit{namedAgents: []*AgentDefinition{ + {Name: "secret", Description: "s", Hidden: true, SystemPrompt: "shh"}, + }} + cfg := SubagentConfig{Prompt: "p", Agent: "secret"} + + restricted, err := k.resolveAgentDefinition(&cfg) + if err != nil { + t.Fatalf("hidden agents must stay resolvable: %v", err) + } + if restricted { + t.Error("no allowlist, should not be restricted") + } + if cfg.SystemPrompt != "shh" { + t.Errorf("SystemPrompt = %q", cfg.SystemPrompt) + } +} + +func TestResolveAgentDefinition_NoAllowlistLeavesToolsNil(t *testing.T) { + k := &Kit{namedAgents: []*AgentDefinition{ + {Name: "general", Description: "g"}, + }} + cfg := SubagentConfig{Prompt: "p", Agent: "general"} + + restricted, err := k.resolveAgentDefinition(&cfg) + if err != nil { + t.Fatalf("resolve failed: %v", err) + } + if restricted { + t.Error("unrestricted agent should report restricted=false") + } + if cfg.Tools != nil { + t.Errorf("cfg.Tools should remain nil, got %d tools", len(cfg.Tools)) + } +} + +func TestKitGetAgents(t *testing.T) { + defs := []*AgentDefinition{ + {Name: "a", Description: "aa"}, + {Name: "b", Description: "bb"}, + } + k := &Kit{namedAgents: defs} + + got := k.GetAgents() + if len(got) != 2 { + t.Fatalf("GetAgents returned %d, want 2", len(got)) + } + // Snapshot semantics: mutating the returned slice must not affect Kit. + got[0] = nil + if k.namedAgents[0] == nil { + t.Error("GetAgents must return a copy") + } + + if a, ok := k.GetAgent("b"); !ok || a.Description != "bb" { + t.Errorf("GetAgent(b) = %+v, %v", a, ok) + } + if _, ok := k.GetAgent("missing"); ok { + t.Error("GetAgent(missing) should report false") + } + + empty := &Kit{} + if empty.GetAgents() != nil { + t.Error("GetAgents on empty Kit should return nil") + } +} + +func TestKitGetAgents_DeepCopy(t *testing.T) { + temp := float32(0.3) + k := &Kit{namedAgents: []*AgentDefinition{ + {Name: "a", Description: "aa", Tools: []string{"read", "grep"}, Temperature: &temp}, + }} + + got := k.GetAgents() + + // Mutating the returned definition's fields must not leak into Kit state. + got[0].Name = "mutated" + got[0].Tools[0] = "bash" + *got[0].Temperature = 0.9 + + orig := k.namedAgents[0] + if orig.Name != "a" { + t.Errorf("struct field mutation leaked: Name = %q", orig.Name) + } + if orig.Tools[0] != "read" { + t.Errorf("Tools slice mutation leaked: %v", orig.Tools) + } + if *orig.Temperature != 0.3 { + t.Errorf("Temperature pointer mutation leaked: %v", *orig.Temperature) + } +} + +func TestAgentDefinitionAliasCompatibility(t *testing.T) { + // AgentDefinition must remain an alias of the internal type so SDK + // consumers can round-trip values through LoadAgentDefinitions. + takeDef := func(d *AgentDefinition) string { return d.Name } + if got := takeDef(&agents.Agent{Name: "x", Description: "y"}); got != "x" { + t.Errorf("alias round-trip failed: %q", got) + } +} diff --git a/pkg/kit/kit.go b/pkg/kit/kit.go index 731833e7..da57391d 100644 --- a/pkg/kit/kit.go +++ b/pkg/kit/kit.go @@ -49,6 +49,7 @@ type Kit struct { compactionOpts *CompactionOptions contextFiles []*ContextFile skills []*skills.Skill + namedAgents []*AgentDefinition // named agent definitions discovered at construction extRunner *extensions.Runner bufferedLogger *tools.BufferedDebugLogger authHandler MCPAuthHandler // OAuth handler for remote MCP servers (may need Close) @@ -1190,6 +1191,12 @@ type Options struct { // (e.g. AGENTS.md) from the working directory. NoContextFiles bool + // NoAgents disables discovery of named agent definitions (built-ins and + // .agents/agents/ / .kit/agents/ / ~/.config/kit/agents/ files). When + // set, the subagent tool advertises no named agents and + // [SubagentConfig].Agent cannot resolve. + NoAgents bool + // MCPConfig provides a pre-loaded MCP configuration. When set, // LoadAndValidateConfig is skipped during Kit creation — avoiding // viper access entirely. This is set automatically for in-process @@ -1418,6 +1425,7 @@ func New(ctx context.Context, opts *Options) (*Kit, error) { cwd string contextFiles []*ContextFile loadedSkills []*Skill + namedAgents []*AgentDefinition mcpConfig *config.Config debug bool noExtensions bool @@ -1555,6 +1563,19 @@ func New(ctx context.Context, opts *Options) (*Kit, error) { applySkillDisableList(loadedSkills, disable) } + // Discover named agent definitions (built-ins + .agents/agents/, + // .kit/agents/, ~/.config/kit/agents/). They are advertised in the + // subagent tool description and resolvable via SubagentConfig.Agent. + // Per-file parse failures are non-fatal: usable agents still load and + // a warning is printed unless quiet. + if !opts.NoAgents && !v.GetBool("no-agents") { + var agErr error + namedAgents, agErr = LoadAgentDefinitions(cwd) + if agErr != nil && !opts.Quiet { + fmt.Fprintf(os.Stderr, "Warning: failed to load some agent definitions: %v\n", agErr) + } + } + // Always compose the system prompt with runtime context: base prompt + // AGENTS.md context + skills metadata + date/cwd. // @@ -1745,6 +1766,7 @@ func New(ctx context.Context, opts *Options) (*Kit, error) { CoreTools: opts.Tools, CoreToolList: toolList, ExtraTools: extraTools, + NamedAgents: namedAgentSpecs(namedAgents), ToolWrapper: hookToolWrapper(beforeToolCall, afterToolResult), ProviderConfig: providerConfig, Debug: debug, @@ -1826,6 +1848,7 @@ func New(ctx context.Context, opts *Options) (*Kit, error) { compactionOpts: opts.CompactionOptions, contextFiles: contextFiles, skills: loadedSkills, + namedAgents: namedAgents, extRunner: agentResult.ExtRunner, bufferedLogger: agentResult.BufferedLogger, authHandler: setupOpts.AuthHandler, @@ -2154,6 +2177,17 @@ type SubagentConfig struct { // Prompt is the task/instruction for the subagent (required). Prompt string + // Agent optionally names a discovered agent definition (see + // [Kit.GetAgents]) whose presets — model, system prompt, tool + // allowlist, temperature, and timeout — apply as defaults. Explicitly + // set scalar fields on this struct (Model, SystemPrompt, Timeout, + // Temperature) override the definition's values. Tools is the + // exception: when the definition declares a tool allowlist, Tools acts + // as the base set and is intersected with the allowlist — it can narrow + // the agent's tool access but never widen it. An unknown name is an + // error. + Agent string + // Model overrides the parent's model (e.g. "anthropic/claude-haiku-3-5-20241022"). // Empty string uses the parent's current model. Model string @@ -2170,6 +2204,10 @@ type SubagentConfig struct { // Pass m.GetToolsForSubagent() explicitly to opt into inheritance from // SDK call sites. // (The subagent tool is dropped to prevent infinite recursion.) + // + // When Agent names a definition with a tool allowlist, this set is + // intersected with that allowlist rather than overriding it — see the + // Agent field documentation. Tools []Tool // NoSession, when true, uses an in-memory ephemeral session. When false @@ -2180,6 +2218,10 @@ type SubagentConfig struct { // Timeout limits execution time. Zero means 5 minute default. Timeout time.Duration + // Temperature overrides the sampling temperature for the subagent. + // Nil inherits the parent's effective setting. + Temperature *float32 + // OnEvent, when set, receives all events from the subagent's event bus. // This enables the parent to stream subagent tool calls, text chunks, // etc. in real time. @@ -2279,6 +2321,20 @@ func (m *Kit) Subagent(ctx context.Context, cfg SubagentConfig) (*SubagentResult start := time.Now() + // Resolve a named agent definition: its model, system prompt, tool + // allowlist, temperature, and timeout act as defaults that explicitly + // set cfg fields override. agentRestricted records whether an allowlist + // was applied — the child must then not re-load MCP servers, which + // would add tools beyond the allowlist. + agentRestricted := false + if cfg.Agent != "" { + var err error + agentRestricted, err = m.resolveAgentDefinition(&cfg) + if err != nil { + return nil, err + } + } + // Default timeout. timeout := cfg.Timeout if timeout == 0 { @@ -2348,7 +2404,7 @@ func (m *Kit) Subagent(ctx context.Context, cfg SubagentConfig) (*SubagentResult // explicit empty config takes the "pre-loaded" branch in New() and loads // zero servers. childMCPConfig := m.mcpConfig - if cfg.Tools != nil && toolsIncludeMCP(tools, m.GetMCPToolNames()) { + if agentRestricted || (cfg.Tools != nil && toolsIncludeMCP(tools, m.GetMCPToolNames())) { if m.mcpConfig != nil { cp := *m.mcpConfig cp.MCPServers = nil @@ -2381,6 +2437,11 @@ func (m *Kit) Subagent(ctx context.Context, cfg SubagentConfig) (*SubagentResult // programmatic Options or runtime setters (e.g. SetThinkingLevel) would // otherwise be lost. inheritProviderConfig(childOpts, m.v) + // A per-subagent temperature (explicit or from a named agent definition) + // overrides whatever the parent's config store provided. + if cfg.Temperature != nil { + childOpts.Temperature = cfg.Temperature + } // Propagate the parent's MCP task configuration so a child subagent // invoking long-running MCP tools observes the same per-server modes, // timeouts, and progress callback as the parent. Without this, child @@ -2463,24 +2524,25 @@ func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent. // subagent core tool can create child Kit instances without // importing pkg/kit (which would create an import cycle). ctx = core.WithSubagentSpawner(ctx, func( - spawnCtx context.Context, toolCallID, prompt, model, systemPrompt string, timeout time.Duration, + spawnCtx context.Context, req core.SubagentSpawnRequest, ) (*core.SubagentSpawnResult, error) { // Build OnEvent: dispatch to per-tool-call listeners if any are // registered via SubscribeSubagent(). Listeners are cleaned up // after the subagent completes. var onEvent func(Event) - if listeners := m.getSubagentListenerSet(toolCallID); listeners != nil { + if listeners := m.getSubagentListenerSet(req.ToolCallID); listeners != nil { onEvent = listeners.emit } result, err := m.Subagent(spawnCtx, SubagentConfig{ - Prompt: prompt, - Model: model, - SystemPrompt: systemPrompt, - Timeout: timeout, + Prompt: req.Prompt, + Agent: req.Agent, + Model: req.Model, + SystemPrompt: req.SystemPrompt, + Timeout: req.Timeout, OnEvent: onEvent, Tools: m.GetToolsForSubagent(), }) - m.cleanupSubagentListeners(toolCallID) + m.cleanupSubagentListeners(req.ToolCallID) if result == nil { return &core.SubagentSpawnResult{Error: err}, err } diff --git a/skills/kit-sdk/SKILL.md b/skills/kit-sdk/SKILL.md index ece7f291..7455fafe 100644 --- a/skills/kit-sdk/SKILL.md +++ b/skills/kit-sdk/SKILL.md @@ -1071,6 +1071,32 @@ host.OnToolCall(func(e kit.ToolCallEvent) { }) ``` +### Named agents + +Named agents are reusable subagent presets discovered from markdown files +(`.agents/agents/*.md`, `.kit/agents/*.md`, `~/.config/kit/agents/*.md`) +plus the built-ins `general` and `explore`. The filename is the agent name; +YAML frontmatter sets `description` (required), `model`, `tools` (allowlist), +`temperature`, `timeout` (seconds), `hidden`, `disabled`; the body is the +system prompt. They are advertised in the subagent tool description so the +LLM can delegate by name. + +```go +defs := host.GetAgents() // discovered definitions (snapshot) +def, ok := host.GetAgent("explore") // lookup by name + +result, err := host.Subagent(ctx, kit.SubagentConfig{ + Prompt: "Map out the session persistence flow", + Agent: "explore", // preset prompt + read-only tool allowlist + // Explicit Model/SystemPrompt/Timeout/Temperature override the preset. +}) + +// Standalone discovery without a Kit instance: +defs, err := kit.LoadAgentDefinitions("") // "" = current working directory +``` + +Disable discovery with `--no-agents`, the `no-agents` config key, `KIT_NO_AGENTS=true`, or `kit.Options{NoAgents: true}`. + --- ## Extension API diff --git a/www/pages/advanced/subagents.md b/www/pages/advanced/subagents.md index b9467ae5..210a7bf0 100644 --- a/www/pages/advanced/subagents.md +++ b/www/pages/advanced/subagents.md @@ -39,6 +39,7 @@ Kit includes a built-in `subagent` tool that the LLM can use to delegate tasks t ``` subagent( task: "Analyze the test files and summarize coverage", + agent: "explore", // optional named agent model: "anthropic/claude-haiku-latest", // optional system_prompt: "You are a test analysis expert.", // optional timeout_seconds: 300 // optional, max 1800 @@ -47,6 +48,54 @@ subagent( Subagents run as separate in-process Kit instances with full tool access (except spawning further subagents, to prevent infinite recursion). They can run in parallel. +## Named agents + +Named agents are reusable subagent presets defined in markdown files. They are advertised in the `subagent` tool description, so the LLM can delegate to the right specialist by name — with a preset system prompt, model, tool allowlist, temperature, and timeout. + +### Definition files + +The filename (minus `.md`) is the agent name; YAML frontmatter configures it; the markdown body is the system prompt: + +```markdown +--- +description: Reviews code for quality and best practices # required +model: anthropic/claude-sonnet-4 # optional model override +tools: [read, grep, find, ls] # optional tool allowlist +temperature: 0.1 # optional +timeout: 300 # optional, seconds +hidden: false # optional: resolvable but not advertised +disabled: false # optional: remove this agent (and anything it shadows) +--- +You are in code review mode. Focus on correctness, security, and +maintainability. Report findings with file paths and line references. +``` + +### Discovery and precedence + +Definitions are discovered from (highest to lowest precedence): + +| Location | Scope | +|----------|-------| +| `/.agents/agents/*.md` | Project-local (cross-client convention) | +| `/.kit/agents/*.md` | Project-local (Kit-specific) | +| `~/.config/kit/agents/*.md` | User-level (`$XDG_CONFIG_HOME` aware) | +| Built-in | Ships with Kit | + +Higher-precedence definitions override lower ones by name, so a project can replace — or disable via `disabled: true` — a built-in or user-level agent. + +Two built-in agents ship with Kit: + +| Agent | Tools | Purpose | +|-------|-------|---------| +| `general` | all tools | General-purpose research and multi-step task execution | +| `explore` | `read`, `grep`, `find`, `ls` | Read-only codebase exploration | + +### Tool allowlists + +An agent without a `tools:` list gets the default subagent tool set (everything except `subagent`, preventing recursion). With a `tools:` allowlist, the subagent is restricted to exactly those tools — a read-only `explore`-style agent cannot edit files or run commands. Explicit `model` / `system_prompt` / `timeout_seconds` arguments in the tool call override the agent's presets. + +Disable named-agent discovery entirely with `--no-agents`, the `no-agents` config key, or `KIT_NO_AGENTS=true`. + ## Extension subagents Extensions can spawn subagents programmatically: @@ -158,13 +207,32 @@ The SDK provides in-process subagent spawning: ```go result, err := host.Subagent(ctx, kit.SubagentConfig{ - Task: "Summarize the changes in this PR", + Prompt: "Summarize the changes in this PR", Model: "anthropic/claude-haiku-latest", SystemPrompt: "You are a code reviewer.", Timeout: 5 * time.Minute, }) ``` +Set `Agent` to run the task with a [named agent](#named-agents)'s presets; explicitly set fields still win: + +```go +result, err := host.Subagent(ctx, kit.SubagentConfig{ + Prompt: "Map out the session persistence flow", + Agent: "explore", // preset prompt + read-only tool allowlist +}) +``` + +Inspect the discovered definitions: + +```go +defs := host.GetAgents() // snapshot of discovered definitions +def, ok := host.GetAgent("explore") // lookup by name + +// Standalone discovery without a Kit instance: +defs, err := kit.LoadAgentDefinitions("") // "" = current working directory +``` + ### Real-time subagent events Use `SubscribeSubagent` to receive real-time events from LLM-initiated subagents (i.e., when the model uses the `subagent` tool). Register inside an `OnToolCall` handler using the tool call ID: diff --git a/www/pages/cli/flags.md b/www/pages/cli/flags.md index 10b79c87..45998986 100644 --- a/www/pages/cli/flags.md +++ b/www/pages/cli/flags.md @@ -56,6 +56,7 @@ These flags control Kit's behavior. When a prompt is passed as a positional argu | `--skills-dir` | — | — | Scan this directory directly for skills (overrides auto-discovery) | | `--skill-disable` | — | — | Hide a skill from the model catalog by name (repeatable); still usable via `/skill:` | | `--no-skills` | — | `false` | Disable skill loading (auto-discovery and explicit) | +| `--no-agents` | — | `false` | Disable named agent discovery (built-ins and [definition files](/advanced/subagents#named-agents)) | ## Generation parameters diff --git a/www/pages/configuration.md b/www/pages/configuration.md index 3fd9ffbd..ae47395f 100644 --- a/www/pages/configuration.md +++ b/www/pages/configuration.md @@ -48,6 +48,7 @@ stream: true | `prompt-templates` | bool | `true` | Enable prompt template loading | | `prompt-template` | string | — | Specific template to load by name | | `no-skills` | bool | `false` | Disable skill loading (auto-discovery and explicit) | +| `no-agents` | bool | `false` | Disable named agent discovery ([built-ins and definition files](/advanced/subagents#named-agents)) | | `skill` | list | — | Explicit skill files or directories to load (disables auto-discovery) | | `skills-dir` | string | — | Scan this directory directly for skills (overrides auto-discovery; not treated as a parent of `.agents`/`.kit`) | | `skill-disable` | list | — | Skill names to hide from the model catalog (still usable via `/skill:`) | diff --git a/www/pages/index.md b/www/pages/index.md index 0badaa97..409de94b 100644 --- a/www/pages/index.md +++ b/www/pages/index.md @@ -14,6 +14,7 @@ A powerful, extensible AI coding agent CLI with multi-provider support, built-in - **Multi-Provider LLM Support** — Anthropic, OpenAI, Google Gemini, Ollama, Azure OpenAI, AWS Bedrock, OpenRouter, and more - **Built-in Core Tools** — bash (with interactive sudo password prompt), read, write, edit, grep, find, ls, subagent with no MCP overhead +- **Named Agents** — reusable subagent presets defined in markdown, with per-agent tool allowlists, advertised to the LLM for delegation - **Smart @ Attachments** — Binary files auto-detected via MIME type, MCP resources via `@mcp:server:uri` - **MCP Integration** — Connect external MCP servers for expanded capabilities (tools, prompts, and resources) - **Extension System** — Write custom tools, commands, widgets, and UI modifications in Go diff --git a/www/pages/sdk/options.md b/www/pages/sdk/options.md index d9187a29..20a5db2d 100644 --- a/www/pages/sdk/options.md +++ b/www/pages/sdk/options.md @@ -74,6 +74,7 @@ host, err := kit.New(ctx, &kit.Options{ // Feature toggles NoExtensions: true, // disable Yaegi extension loading NoContextFiles: true, // disable automatic AGENTS.md loading + NoAgents: true, // disable named agent discovery (.agents/agents/, .kit/agents/, ~/.config/kit/agents/, and built-ins) // Session (advanced) SessionManager: myCustomSession, // custom SessionManager implementation @@ -165,6 +166,7 @@ when embedding Kit as a library. | `DisableCoreTools` | `bool` | `false` | Use no core tools (0 tools, for chat-only) | | `NoExtensions` | `bool` | `false` | Disable Yaegi extension loading | | `NoContextFiles` | `bool` | `false` | Disable automatic AGENTS.md loading | +| `NoAgents` | `bool` | `false` | Disable named agent discovery (built-ins and `.agents/agents/` / `.kit/agents/` / `~/.config/kit/agents/` files); see [Subagents](/advanced/subagents#named-agents) | ### Skills & configuration diff --git a/www/pages/sdk/overview.md b/www/pages/sdk/overview.md index af99877f..ccf1bb4a 100644 --- a/www/pages/sdk/overview.md +++ b/www/pages/sdk/overview.md @@ -619,4 +619,19 @@ result, err := host.Subagent(ctx, kit.SubagentConfig{ }) ``` +Set `Agent` to a named agent definition (discovered from `.agents/agents/*.md`, +`.kit/agents/*.md`, `~/.config/kit/agents/*.md`, or the built-ins `general` / +`explore`) to apply its preset system prompt, model, tool allowlist, and +timeout: + +```go +result, err := host.Subagent(ctx, kit.SubagentConfig{ + Prompt: "Map out the session persistence flow", + Agent: "explore", // read-only preset +}) +``` + +See [Subagents](/advanced/subagents#named-agents) for definition file format +and discovery precedence. + See [Options](/sdk/options), [Callbacks](/sdk/callbacks), and [Sessions](/sdk/sessions) for more details.