Compare commits

..

1 Commits

Author SHA1 Message Date
kit-agent dddf10bc36 feat(sdk): add Options.DebugLogger and WithDebugLogger option
Today the SDK exposes a DebugLogger interface (pkg/kit/types.go) but no
public path to install one — the only consumer of the field is the
unexported kit.AgentConfig.toInternal() method, which itself is not
reachable from outside the package. As a result, embedders that want to
forward Kit's low-level engine + MCP tool plumbing debug output into
their own logging system (slog, zap, charm/log, an in-app TUI panel,
etc.) have no option but the on/off Debug bool, which always installs
the built-in SimpleDebugLogger / BufferedDebugLogger.

This change closes that gap on the supported Options / functional-option
construction path:

- pkg/kit/kit.go: add Options.DebugLogger DebugLogger. When non-nil it
  is used directly and the Debug bool is ignored; the supplied logger's
  IsDebugEnabled() controls whether downstream code emits messages.
- pkg/kit/options.go: add WithDebugLogger(l DebugLogger) Option.
- internal/kitsetup/setup.go: add AgentSetupOptions.DebugLogger and
  switch SetupAgent's logger selection so the caller-supplied logger
  wins unconditionally; otherwise the existing Debug + UseBufferedLogger
  branch picks the built-in implementation. No behaviour change when
  DebugLogger is nil.
- pkg/kit/kit.go: wire opts.DebugLogger into setupOpts so the New()
  path threads it through.
- pkg/kit/viper_isolation_test.go: add TestWithDebugLoggerPlumbing and
  TestWithDebugLoggerNilClears covering the option-to-field contract
  and later-options-override semantics consistent with the other With*
  helpers.
- pkg/kit/README.md: list WithDebugLogger in the helper inventory.

Notes:
- kit.DebugLogger and tools.DebugLogger are structurally identical
  (LogDebug(string) / IsDebugEnabled() bool), so the SDK value flows
  into the internal field without a conversion.
- This is purely additive on the SDK surface and does not touch
  kit.AgentConfig — that field already carried a DebugLogger, but the
  AgentConfig path is unreachable from outside the package today.
2026-06-18 13:32:59 +03:00
46 changed files with 542 additions and 3029 deletions
+7 -15
View File
@@ -130,13 +130,11 @@ stream: true
thinking-level: off # off, none, minimal, low, medium, high
no-core-tools: false # set to true to disable all built-in core tools
# Skills — all keys are optional
# Skills — all three keys are optional
no-skills: false # set to true to disable all skill loading
skill: # explicit skill files/dirs (disables auto-discovery)
- /path/to/skill.md
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
skills-dir: "" # override project-local directory for auto-discovery
```
All of the above keys can also be set programmatically via the SDK
@@ -214,8 +212,7 @@ mcpServers:
# Skills
--skill Load skill file or directory (repeatable)
--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:
--skills-dir Override the project-local skills directory for auto-discovery
--no-skills Disable skill loading (auto-discovery and explicit)
# Generation parameters
@@ -694,10 +691,10 @@ host, err := kit.NewAgent(ctx,
Available options: `WithModel`, `WithSystemPrompt`, `WithStreaming`,
`WithMaxTokens`, `WithThinkingLevel`, `WithTools`, `WithExtraTools`,
`WithProviderAPIKey`, `WithProviderURL`, `WithConfigFile`, `WithDebug`,
`WithDebugLogger`, and `Ephemeral`. For advanced configuration not covered by
the helpers (custom MCP config, in-process MCP servers, session backends, MCP
task tuning) construct an `Options` value explicitly and call `kit.New`.
`WithProviderAPIKey`, `WithProviderURL`, `WithConfigFile`, `WithDebug`, and
`Ephemeral`. For advanced configuration not covered by the helpers (custom MCP
config, in-process MCP servers, session backends, MCP task tuning) construct an
`Options` value explicitly and call `kit.New`.
### Per-instance config isolation
@@ -893,11 +890,6 @@ host.AddContextFileContent(
host.RemoveSkill("polite-french")
host.RemoveContextFile(fmt.Sprintf("session://%s/AGENTS.md", userID))
// Hide a skill from the model catalog without unloading it (still usable
// via /skill:); EnableSkill reverses it.
host.DisableSkill("refund-policy")
host.EnableSkill("refund-policy")
// Or replace the whole set atomically.
host.SetSkills(activeSkillsForUser)
host.SetContextFiles(activeContextForUser)
+4 -10
View File
@@ -74,10 +74,9 @@ var (
extensionPaths []string
// Skills control
noSkillsFlag bool
skillsPaths []string
skillsDir string
skillsDisable []string
noSkillsFlag bool
skillsPaths []string
skillsDir string
// TLS configuration
tlsSkipVerify bool
@@ -295,9 +294,7 @@ func init() {
rootCmd.PersistentFlags().
StringSliceVar(&skillsPaths, "skill", nil, "load skill file or directory (repeatable)")
rootCmd.PersistentFlags().
StringVar(&skillsDir, "skills-dir", "", "scan this directory directly for skills (overrides auto-discovery)")
rootCmd.PersistentFlags().
StringSliceVar(&skillsDisable, "skill-disable", nil, "hide a skill from the model catalog by name (repeatable); still usable via /skill:")
StringVar(&skillsDir, "skills-dir", "", "override the project-local skills directory for auto-discovery")
flags := rootCmd.PersistentFlags()
flags.StringVar(&providerURL, "provider-url", "", "base URL for the provider API (applies to OpenAI, Anthropic, Ollama, and Google)")
@@ -352,7 +349,6 @@ func init() {
_ = viper.BindPFlag("no-skills", rootCmd.PersistentFlags().Lookup("no-skills"))
_ = 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"))
// Defaults are already set in flag definitions, no need to duplicate in viper
@@ -846,8 +842,6 @@ func runNormalMode(ctx context.Context) error {
NoSkills: noSkillsFlag,
Skills: skillsPaths,
SkillsDir: skillsDir,
SkillsDisable: skillsDisable,
SkillTrustPrompt: skillTrustPrompt(),
// This callback is called when each MCP server finishes loading.
// We use a closure that captures appInstancePtr which is set after
// app.New() is called below.
-52
View File
@@ -1,52 +0,0 @@
package cmd
import (
"bufio"
"fmt"
"os"
"strings"
"golang.org/x/term"
"github.com/mark3labs/kit/pkg/kit"
)
// skillTrustPrompt returns a callback that gates project-local skill loading
// on an interactive trust decision (issue #65, gap #8). Project-local skills
// are injected into the system prompt, so a freshly cloned untrusted repo
// could smuggle instructions into the agent. The prompt asks the user whether
// to trust the directory before any project skill is loaded.
//
// It returns nil — meaning "load without prompting" — when Kit is not running
// interactively (a non-TTY stdin, --quiet, or a non-interactive one-shot
// prompt), so scripted and piped invocations keep their existing behaviour.
func skillTrustPrompt() func(projectDir string, skillCount int) kit.TrustDecision {
// Only prompt for interactive terminal sessions.
if quietFlag || positionalPrompt != "" {
return nil
}
if !term.IsTerminal(int(os.Stdin.Fd())) {
return nil
}
return func(projectDir string, skillCount int) kit.TrustDecision {
noun := "skills"
if skillCount == 1 {
noun = "skill"
}
fmt.Printf("\nThis project provides %d %s under .agents/skills or .kit/skills:\n %s\n",
skillCount, noun, projectDir)
fmt.Print("Load them into the agent? [t]rust always / [o]nce / [s]kip (default skip): ")
reader := bufio.NewReader(os.Stdin)
line, _ := reader.ReadString('\n')
switch strings.ToLower(strings.TrimSpace(line)) {
case "t", "trust", "a", "always":
return kit.TrustProject
case "o", "once", "y", "yes":
return kit.TrustProjectOnce
default:
return kit.SkipProjectSkills
}
}
}
-12
View File
@@ -1105,18 +1105,6 @@ func (a *Agent) GetExtensionToolCount() int {
return len(a.extraTools)
}
// GetExtraTools returns the agent's current extra tools (e.g.
// extension-registered tools). The returned slice is a copy so callers can
// snapshot and later restore it via SetExtraTools.
func (a *Agent) GetExtraTools() []fantasy.AgentTool {
if len(a.extraTools) == 0 {
return nil
}
out := make([]fantasy.AgentTool, len(a.extraTools))
copy(out, a.extraTools)
return out
}
// SetExtraTools replaces the agent's extra tools (e.g. extension-registered
// tools) and rebuilds the internal agent with the updated tool list. The
// model, system prompt, and all other configuration are preserved.
-32
View File
@@ -389,30 +389,6 @@ func roleLabel(role fantasy.MessageRole) string {
}
}
// skillContentMarkers are substrings that identify a message carrying
// explicitly-activated skill content. Such messages are exempt from
// compaction pruning per the agentskills.io spec (issue #65, gap #7): an
// activated skill must remain in context verbatim instead of being folded
// into a lossy summary.
var skillContentMarkers = []string{"<skill ", "<skill>", "<skill_content"}
// isProtectedMessage reports whether msg carries explicitly-activated skill
// content that must survive compaction unchanged.
func isProtectedMessage(msg fantasy.Message) bool {
for _, part := range msg.Content {
tp, ok := part.(fantasy.TextPart)
if !ok {
continue
}
for _, marker := range skillContentMarkers {
if strings.Contains(tp.Text, marker) {
return true
}
}
}
return false
}
// serializeMessages converts a slice of fantasy messages into a plain-text
// representation suitable for sending to the summarisation LLM. Tool result
// text is truncated to maxToolResultChars to keep the summarisation request
@@ -542,14 +518,6 @@ func Compact(
newMessages := make([]fantasy.Message, 0, 1+len(recentMessages))
newMessages = append(newMessages, summaryMessage)
// Carry forward any explicitly-activated skill content from the
// summarised range verbatim — skill instructions must not be lost to
// compaction (issue #65, gap #7).
for _, msg := range oldMessages {
if isProtectedMessage(msg) {
newMessages = append(newMessages, msg)
}
}
newMessages = append(newMessages, recentMessages...)
compactedTokens := EstimateMessageTokens(newMessages)
-22
View File
@@ -439,25 +439,3 @@ func TestSortedKeys_Empty(t *testing.T) {
t.Errorf("sortedKeys(nil) = %v, want nil", got)
}
}
// ---------------------------------------------------------------------------
// Skill-content protection (issue #65, gap #7)
// ---------------------------------------------------------------------------
func TestIsProtectedMessage(t *testing.T) {
cases := []struct {
text string
want bool
}{
{`<skill name="foo" location="/x">body</skill>`, true},
{`<skill_content name="foo">body</skill_content>`, true},
{"just a normal message", false},
{"talking about skills in general", false},
}
for _, c := range cases {
msg := makeTextMessage(fantasy.MessageRoleUser, c.text)
if got := isProtectedMessage(msg); got != c.want {
t.Errorf("isProtectedMessage(%q) = %v, want %v", c.text, got, c.want)
}
}
}
+4 -33
View File
@@ -6,7 +6,6 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"github.com/spf13/viper"
"gopkg.in/yaml.v3"
@@ -499,22 +498,7 @@ mcpServers:
# no-skills: false # Set to true to disable all skill loading
# skill: # Explicit skill files/dirs (disables auto-discovery)
# - "/path/to/skill.md"
# skills-dir: "/path/to/skills" # 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"
#
# Skill files follow the agentskills.io spec. A SKILL.md frontmatter block
# supports these fields:
# name: my-skill # required
# description: Use when ... # required (basis for model discovery)
# license: MIT # optional SPDX identifier
# compatibility: claude-code, cursor # optional targeted-environment note
# allowed-tools: read, bash # optional (experimental) tool restriction
# disable-model-invocation: false # optional; true hides from the catalog
# metadata: # optional arbitrary key/value pairs
# author: you
# tags: [example] # Kit extension
# when: on-demand # Kit extension
# skills-dir: "/path/to/skills" # Override project-local directory for auto-discovery
# API Configuration (can also use environment variables)
# provider-api-key: "your-api-key" # API key for OpenAI, Anthropic, or Google
@@ -570,7 +554,7 @@ func FilepathOr[T any](key string, value *T) error {
absPath = filepath.Join(home, absPath[2:])
}
if !filepath.IsAbs(absPath) {
base := GetConfigPath()
base := configPath
if base == "" {
fmt.Fprintf(os.Stderr, "unable to build relative path to config.")
os.Exit(1)
@@ -597,24 +581,11 @@ func FilepathOr[T any](key string, value *T) error {
return nil
}
var (
configPathMu sync.RWMutex
configPath string
)
var configPath string
// SetConfigPath sets the configuration file path for resolving relative paths
// in configuration values. This should be called when the configuration file
// location is known. It is safe for concurrent use.
// location is known.
func SetConfigPath(path string) {
configPathMu.Lock()
defer configPathMu.Unlock()
configPath = path
}
// GetConfigPath returns the configuration file path previously set via
// SetConfigPath. It is safe for concurrent use.
func GetConfigPath() string {
configPathMu.RLock()
defer configPathMu.RUnlock()
return configPath
}
-33
View File
@@ -1,33 +0,0 @@
package config
import (
"sync"
"testing"
)
// TestConfigPathConcurrentAccess exercises the mutex guarding the package-level
// configPath global. Run with -race to detect the data race that motivated the
// guard (concurrent kit.New() calls discovering a .kit.yml).
func TestConfigPathConcurrentAccess(t *testing.T) {
t.Cleanup(func() { SetConfigPath("") })
const goroutines = 32
var wg sync.WaitGroup
wg.Add(goroutines * 2)
for range goroutines {
go func() {
defer wg.Done()
SetConfigPath("/tmp/kit.yml")
}()
go func() {
defer wg.Done()
_ = GetConfigPath()
}()
}
wg.Wait()
SetConfigPath("/tmp/final.yml")
if got := GetConfigPath(); got != "/tmp/final.yml" {
t.Fatalf("GetConfigPath() = %q, want /tmp/final.yml", got)
}
}
+2 -18
View File
@@ -777,8 +777,7 @@ type Context struct {
LoadSkillsFromDir func(dir string) SkillLoadResult
// DiscoverSkills finds skills in standard locations.
// Checks ~/.agents/skills/, ~/.config/kit/skills/, <project>/.agents/skills/,
// and <project>/.kit/skills/.
// Checks ~/.config/kit/skills/, .kit/skills/, .agents/skills/
DiscoverSkills func() SkillLoadResult
// InjectSkillAsContext sends a skill's content as a system message.
@@ -910,24 +909,9 @@ type Skill struct {
Content string
// Path is the absolute filesystem path.
Path string
// License is an optional SPDX license identifier (agentskills.io field).
License string
// Compatibility is an optional note describing targeted environments
// (agentskills.io field).
Compatibility string
// Metadata is an optional bag of arbitrary string key/value pairs
// (agentskills.io field).
Metadata map[string]string
// AllowedTools optionally restricts which tools the skill may use
// (experimental agentskills.io field).
AllowedTools string
// DisableModelInvocation hides the skill from the model-facing catalog
// while keeping it available via explicit activation (agentskills.io field).
DisableModelInvocation bool
// Tags are optional labels for categorization. Kit extension.
// Tags are optional labels for categorization.
Tags []string
// When controls automatic inclusion: "always", "on-demand", or file-glob.
// Kit extension.
When string
}
+1 -1
View File
@@ -50,7 +50,7 @@ func TestPromptBuilder_WithSkills(t *testing.T) {
if !strings.Contains(result, "<description>Write code</description>") {
t.Error("missing skill description in XML")
}
if !strings.Contains(result, "<location>/tmp/coding/SKILL.md</location>") {
if !strings.Contains(result, "<location>file:///tmp/coding/SKILL.md</location>") {
t.Error("missing skill location")
}
}
+54 -436
View File
@@ -2,173 +2,40 @@
//
// Skills are markdown instruction files with optional YAML frontmatter that
// provide domain-specific context, instructions, and workflows to the agent.
// They follow the cross-client agentskills.io discovery convention plus a
// Kit-native location:
// They follow a hierarchical discovery pattern similar to extensions:
//
// ~/.agents/skills/ user-level cross-client skills
// ~/.config/kit/skills/ user-level Kit skills ($XDG_CONFIG_HOME aware)
// <project>/.agents/skills/ project-local cross-client skills
// <project>/.kit/skills/ project-local Kit skills
// ~/.config/kit/skills/ global skills directory
// .kit/skills/ project-local skills directory
//
// Skills can be single .md/.txt files or subdirectories containing a SKILL.md
// file. Project-level skills take precedence over user-level skills when two
// skills share the same name.
// Skills can be single .md/.txt files or subdirectories containing a SKILL.md file.
package skills
import (
"bytes"
"encoding/xml"
"errors"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/charmbracelet/log"
"gopkg.in/yaml.v3"
)
// Skill represents a markdown-based instruction file that provides
// domain-specific context and workflows to the agent.
//
// The Name and Description fields are required by the agentskills.io
// specification. License, Compatibility, Metadata, and AllowedTools are
// optional spec fields. Tags and When are Kit-specific extensions that other
// clients ignore.
type Skill struct {
// Name is the human-readable identifier for this skill. Required.
// Name is the human-readable identifier for this skill.
Name string `yaml:"name" json:"name"`
// Description summarises what this skill provides and when to use it.
// Required by the spec — it is the sole basis on which the model decides
// whether a skill is relevant, so a skill without one is omitted from the
// catalog.
// Description summarises what this skill provides.
Description string `yaml:"description" json:"description"`
// Content is the full markdown body (after frontmatter).
Content string `yaml:"-" json:"content"`
// Path is the absolute filesystem path the skill was loaded from.
Path string `yaml:"-" json:"path"`
// License is an optional SPDX license identifier (spec field).
License string `yaml:"license,omitempty" json:"license,omitempty"`
// Compatibility is an optional free-form note describing the environments
// or clients the skill targets (spec field). The model can use it to adapt
// execution.
Compatibility string `yaml:"compatibility,omitempty" json:"compatibility,omitempty"`
// Metadata is an optional bag of arbitrary string key/value pairs (spec
// field) for client-specific annotations.
Metadata map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty"`
// AllowedTools optionally restricts which tools the skill may use. This is
// an experimental spec field carried for portability; Kit does not yet
// enforce it.
AllowedTools string `yaml:"allowed-tools,omitempty" json:"allowed_tools,omitempty"`
// DisableModelInvocation, when true, hides the skill from the
// model-facing catalog (spec field). The skill can still be activated
// explicitly via the /skill: slash command.
DisableModelInvocation bool `yaml:"disable-model-invocation,omitempty" json:"disable_model_invocation,omitempty"`
// Tags are optional labels for categorisation. Kit extension.
// Tags are optional labels for categorisation.
Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"`
// When controls automatic inclusion: "always", "on-demand", or a
// file-glob like "file:*.go". Empty defaults to "on-demand". Kit extension.
// file-glob like "file:*.go". Empty defaults to "on-demand".
When string `yaml:"when,omitempty" json:"when,omitempty"`
// project records whether the skill was discovered in a project-local
// scope. Used internally for name-collision precedence (project > user).
project bool `yaml:"-" json:"-"`
}
// Diagnostic describes a validation problem with a skill. Severity is either
// "error" (the skill cannot be used) or "warning" (the skill is usable but
// non-compliant).
type Diagnostic struct {
// Severity is "error" or "warning".
Severity string `json:"severity"`
// Field names the frontmatter field the diagnostic relates to, if any.
Field string `json:"field,omitempty"`
// Message is a human-readable description of the problem.
Message string `json:"message"`
}
// Validate checks the skill against the agentskills.io specification and
// returns a list of diagnostics. An empty slice means the skill is fully
// compliant. A missing description is reported as an error because the spec
// makes it required for discovery.
func (s *Skill) Validate() []Diagnostic {
var diags []Diagnostic
if strings.TrimSpace(s.Name) == "" {
diags = append(diags, Diagnostic{Severity: "error", Field: "name", Message: "name is required"})
}
if strings.TrimSpace(s.Description) == "" {
diags = append(diags, Diagnostic{
Severity: "error",
Field: "description",
Message: "description is required for skill discovery",
})
}
return diags
}
// hasError reports whether diags contains a diagnostic with "error" severity.
func hasError(diags []Diagnostic) bool {
for _, d := range diags {
if d.Severity == "error" {
return true
}
}
return false
}
// BaseDir returns the directory the skill was loaded from. Relative resources
// referenced by a skill (scripts/, references/, assets/) resolve against this
// directory.
func (s *Skill) BaseDir() string {
if s.Path == "" {
return ""
}
return filepath.Dir(s.Path)
}
// resourceDirs are the conventional subdirectories a skill may bundle.
var resourceDirs = []string{"scripts", "references", "assets"}
// maxResources caps how many bundled resources are enumerated to avoid
// flooding the prompt for skills with large asset trees.
const maxResources = 50
// Resources walks one level into the skill's scripts/, references/, and
// assets/ subdirectories and returns the relative paths of any files found
// (slash-separated, relative to BaseDir). The result is capped at 50 entries.
// It returns nil when the skill has no bundled resources or its Path is not a
// real on-disk file.
func (s *Skill) Resources() []string {
base := s.BaseDir()
if base == "" {
return nil
}
var out []string
for _, sub := range resourceDirs {
dir := filepath.Join(base, sub)
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, e := range entries {
if e.IsDir() {
continue
}
out = append(out, sub+"/"+e.Name())
if len(out) >= maxResources {
sort.Strings(out)
return out
}
}
}
sort.Strings(out)
return out
}
// frontmatterSep is the YAML frontmatter delimiter.
@@ -188,14 +55,7 @@ func LoadSkill(path string) (*Skill, error) {
abs = path
}
return parseSkill(data, path, abs)
}
// parseSkill parses skill bytes that originated from srcPath (used for error
// messages and name derivation) and records storePath as the skill's Path.
// It is shared by the os-backed and fs.FS-backed loaders.
func parseSkill(data []byte, srcPath, storePath string) (*Skill, error) {
skill := &Skill{Path: storePath}
skill := &Skill{Path: abs}
content := string(data)
@@ -209,8 +69,8 @@ func parseSkill(data []byte, srcPath, storePath string) (*Skill, error) {
// Strip an optional trailing newline right after the closing ---.
body = strings.TrimPrefix(body, "\n")
if err := unmarshalFrontmatter([]byte(frontmatter), skill); err != nil {
return nil, fmt.Errorf("parsing frontmatter in %s: %w", srcPath, err)
if err := yaml.Unmarshal([]byte(frontmatter), skill); err != nil {
return nil, fmt.Errorf("parsing frontmatter in %s: %w", path, err)
}
skill.Content = strings.TrimSpace(body)
} else {
@@ -223,69 +83,18 @@ func parseSkill(data []byte, srcPath, storePath string) (*Skill, error) {
// Fallback: derive name from filename if frontmatter didn't set one.
if skill.Name == "" {
base := filepath.Base(srcPath)
base := filepath.Base(path)
ext := filepath.Ext(base)
skill.Name = strings.TrimSuffix(base, ext)
// Convert SKILL → directory name for SKILL.md files.
if strings.EqualFold(skill.Name, "SKILL") || strings.EqualFold(skill.Name, "skill") {
skill.Name = filepath.Base(filepath.Dir(srcPath))
skill.Name = filepath.Base(filepath.Dir(path))
}
}
return skill, nil
}
// unquotedColonRe matches a YAML scalar line whose value contains an unquoted
// colon, e.g. `description: Use when: extracting tables`. This is the most
// common frontmatter authoring mistake in cross-client skills and breaks
// strict YAML parsing.
var unquotedColonRe = regexp.MustCompile(`^(\s*[A-Za-z0-9_-]+):[ \t]+([^'"\n].*:.*)$`)
// unmarshalFrontmatter unmarshals YAML frontmatter into skill, tolerating the
// common "unquoted colon in a scalar value" mistake (e.g.
// `description: Use when: …`). On a parse failure it quotes offending scalar
// values and retries once before giving up.
func unmarshalFrontmatter(frontmatter []byte, skill *Skill) error {
err := yaml.Unmarshal(frontmatter, skill)
if err == nil {
return nil
}
// Attempt a single recovery pass: quote scalar values that contain an
// unquoted colon, which is the dominant cross-client failure mode.
repaired, changed := repairUnquotedColons(string(frontmatter))
if !changed {
return err
}
if retryErr := yaml.Unmarshal([]byte(repaired), skill); retryErr != nil {
// The original error is more useful to the author.
return err
}
return nil
}
// repairUnquotedColons quotes scalar values containing an unquoted colon and
// reports whether any line was changed.
func repairUnquotedColons(frontmatter string) (string, bool) {
lines := strings.Split(frontmatter, "\n")
changed := false
for i, line := range lines {
m := unquotedColonRe.FindStringSubmatch(line)
if m == nil {
continue
}
key, value := m[1], strings.TrimRight(m[2], " \t")
// Escape embedded double quotes before wrapping.
value = strings.ReplaceAll(value, `"`, `\"`)
lines[i] = fmt.Sprintf(`%s: "%s"`, key, value)
changed = true
}
if !changed {
return frontmatter, false
}
return strings.Join(lines, "\n"), true
}
// LoadSkillsFromDir loads all skills from a single directory. It looks for:
// - *.md and *.txt files directly in dir
// - SKILL.md (case-insensitive) in immediate subdirectories
@@ -304,7 +113,7 @@ func LoadSkillsFromDir(dir string) ([]*Skill, error) {
}
var skills []*Skill
var errs []error
var errs []string
for _, entry := range entries {
full := filepath.Join(dir, entry.Name())
@@ -314,7 +123,7 @@ func LoadSkillsFromDir(dir string) ([]*Skill, error) {
if ext == ".md" || ext == ".txt" {
s, err := LoadSkill(full)
if err != nil {
errs = append(errs, err)
errs = append(errs, err.Error())
continue
}
skills = append(skills, s)
@@ -331,7 +140,7 @@ func LoadSkillsFromDir(dir string) ([]*Skill, error) {
if !se.IsDir() && strings.EqualFold(se.Name(), "SKILL.md") {
s, err := LoadSkill(filepath.Join(full, se.Name()))
if err != nil {
errs = append(errs, err)
errs = append(errs, err.Error())
continue
}
skills = append(skills, s)
@@ -341,204 +150,59 @@ func LoadSkillsFromDir(dir string) ([]*Skill, error) {
}
if len(errs) > 0 {
return skills, fmt.Errorf("some skills failed to load: %w", errors.Join(errs...))
return skills, fmt.Errorf("some skills failed to load: %s", strings.Join(errs, "; "))
}
return skills, nil
}
// LoadSkillsFromFS is the fs.FS-typed counterpart of LoadSkillsFromDir. It
// walks fsys starting at root (which may be "." or a subdirectory), finds
// *.md and *.txt files plus SKILL.md files in subdirectories, parses YAML
// frontmatter + markdown body, and returns the loaded skills.
// LoadSkills auto-discovers skills from standard directories:
// 1. Global: $XDG_CONFIG_HOME/kit/skills/ (default ~/.config/kit/skills/)
// 2. Project-local: <cwd>/.kit/skills/
//
// Because fs.FS has no notion of an absolute on-disk path, each loaded skill's
// Path is set to its slash-separated path within fsys. Files that fail to
// parse are skipped and reported via the returned error.
func LoadSkillsFromFS(fsys fs.FS, root string) ([]*Skill, error) {
if fsys == nil {
return nil, nil
}
if root == "" {
root = "."
}
var skills []*Skill
var errs []error
walkErr := fs.WalkDir(fsys, root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return nil // skip unreadable entries rather than aborting the walk
}
if d.IsDir() {
return nil
}
name := d.Name()
ext := strings.ToLower(path.Ext(name))
if ext != ".md" && ext != ".txt" {
return nil
}
// Top-level .md/.txt files, or SKILL.md anywhere.
isTopLevel := path.Dir(p) == root
if !isTopLevel && !strings.EqualFold(name, "SKILL.md") {
return nil
}
data, readErr := fs.ReadFile(fsys, p)
if readErr != nil {
errs = append(errs, fmt.Errorf("reading skill %s: %w", p, readErr))
return nil
}
s, parseErr := parseSkill(data, p, p)
if parseErr != nil {
errs = append(errs, parseErr)
return nil
}
skills = append(skills, s)
return nil
})
if walkErr != nil {
return skills, fmt.Errorf("walking skills fs at %s: %w", root, walkErr)
}
if len(errs) > 0 {
return skills, fmt.Errorf("some skills failed to load: %w", errors.Join(errs...))
}
return skills, nil
}
// LoadUserSkills discovers skills from the user-level scopes only:
//
// 1. ~/.agents/skills/ (cross-client convention)
// 2. $XDG_CONFIG_HOME/kit/skills/ (default ~/.config/kit/skills/)
//
// The returned skills are not yet validated or deduplicated; pass them through
// Combine together with project skills to produce the final catalog set.
func LoadUserSkills() []*Skill {
var loaded []*Skill
if home, err := os.UserHomeDir(); err == nil && home != "" {
dir := filepath.Join(home, ".agents", "skills")
ss, loadErr := LoadSkillsFromDir(dir)
if loadErr != nil {
// Missing directories are already swallowed by LoadSkillsFromDir,
// so a non-nil error here is genuine (permission denied, read
// failure, or a malformed skill file) and would otherwise yield a
// silently partial catalog.
log.Warn("failed to load some user skills", "dir", dir, "err", loadErr)
}
loaded = append(loaded, ss...)
}
if g := globalSkillsDir(); g != "" {
ss, loadErr := LoadSkillsFromDir(g)
if loadErr != nil {
log.Warn("failed to load some user skills", "dir", g, "err", loadErr)
}
loaded = append(loaded, ss...)
}
for _, s := range loaded {
s.project = false
}
return loaded
}
// LoadProjectSkills discovers skills from the project-local scopes only:
//
// 1. <cwd>/.agents/skills/ (cross-client convention)
// 2. <cwd>/.kit/skills/ (Kit-specific)
//
// Because project-local skills are injected into the system prompt, callers
// may wish to gate this on a trust check before including the result. The
// returned skills are not yet validated or deduplicated; pass them through
// Combine.
func LoadProjectSkills(cwd string) []*Skill {
// Skills from project-local directories take precedence (appended last).
// cwd is the working directory for project-local discovery; if empty the
// current working directory is used.
func LoadSkills(cwd string) ([]*Skill, error) {
if cwd == "" {
cwd, _ = os.Getwd()
}
var loaded []*Skill
for _, dir := range []string{
filepath.Join(cwd, ".agents", "skills"),
filepath.Join(cwd, ".kit", "skills"),
} {
ss, loadErr := LoadSkillsFromDir(dir)
if loadErr != nil {
log.Warn("failed to load some project skills", "dir", dir, "err", loadErr)
}
loaded = append(loaded, ss...)
}
for _, s := range loaded {
s.project = true
}
return loaded
}
// Combine validates and deduplicates the union of user-level and project-level
// skills. Skills missing a required description are skipped with a logged
// warning; when two skills share a Name the project-level one wins (also
// logged). User skills are considered before project skills so first-seen
// ordering is stable.
func Combine(user, project []*Skill) []*Skill {
combined := make([]*Skill, 0, len(user)+len(project))
combined = append(combined, user...)
combined = append(combined, project...)
return finalizeSkills(combined)
}
seen := make(map[string]bool)
var all []*Skill
// LoadSkills auto-discovers skills from the standard agentskills.io scopes:
//
// 1. User-level: ~/.agents/skills/ (cross-client convention)
// 2. User-level: $XDG_CONFIG_HOME/kit/skills/ (default ~/.config/kit/skills/)
// 3. Project-local: <cwd>/.agents/skills/ (cross-client convention)
// 4. Project-local: <cwd>/.kit/skills/ (Kit-specific)
//
// When two skills share the same Name, the project-level skill takes
// precedence over a user-level one and a warning is logged. cwd is the working
// directory for project-local discovery; if empty the current working
// directory is used.
func LoadSkills(cwd string) ([]*Skill, error) {
return Combine(LoadUserSkills(), LoadProjectSkills(cwd)), nil
}
// finalizeSkills applies validation (skipping skills missing a required
// description) and name-collision precedence (project overrides user). It
// preserves first-seen ordering for stable catalog output.
func finalizeSkills(loaded []*Skill) []*Skill {
byName := make(map[string]int) // name → index in result
var result []*Skill
for _, s := range loaded {
if diags := s.Validate(); hasError(diags) {
for _, d := range diags {
if d.Severity == "error" {
log.Warn("skipping skill: validation failed", "path", s.Path, "field", d.Field, "reason", d.Message)
}
addUnique := func(skills []*Skill) {
for _, s := range skills {
if !seen[s.Path] {
seen[s.Path] = true
all = append(all, s)
}
continue
}
if idx, ok := byName[s.Name]; ok {
existing := result[idx]
// Project-level skills override user-level skills.
if s.project && !existing.project {
log.Warn("skill name collision: project skill overrides user skill",
"name", s.Name, "project", s.Path, "user", existing.Path)
result[idx] = s
} else {
log.Warn("skill name collision: keeping earlier skill, ignoring duplicate",
"name", s.Name, "kept", existing.Path, "ignored", s.Path)
}
continue
}
byName[s.Name] = len(result)
result = append(result, s)
}
return result
// Global skills.
globalDir := globalSkillsDir()
if globalDir != "" {
global, _ := LoadSkillsFromDir(globalDir)
addUnique(global)
}
// Project-local skills: .agents/skills/ (standardized cross-tool convention).
agentsDir := filepath.Join(cwd, ".agents", "skills")
agentsSkills, _ := LoadSkillsFromDir(agentsDir)
addUnique(agentsSkills)
// Project-local skills: .kit/skills/ (kit-specific).
localDir := filepath.Join(cwd, ".kit", "skills")
local, _ := LoadSkillsFromDir(localDir)
addUnique(local)
return all, nil
}
// FormatForPrompt formats skills as metadata-only XML for inclusion in a
// system prompt. Only the name, description, and file location are included;
// the agent reads the full skill file on demand using the read tool. Skill
// fields are XML-escaped so that descriptions containing <, >, & or quotes
// produce valid markup. Skills with DisableModelInvocation set are omitted
// from the catalog (they remain available via the /skill: slash command).
// the agent reads the full skill file on demand using the read tool. This
func FormatForPrompt(skills []*Skill) string {
if len(skills) == 0 {
return ""
@@ -550,63 +214,17 @@ func FormatForPrompt(skills []*Skill) string {
buf.WriteString("When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md) and use that absolute path in tool commands.\n")
buf.WriteString("\n<available_skills>\n")
emitted := 0
for _, s := range skills {
if s.DisableModelInvocation {
continue
}
buf.WriteString(" <skill>\n")
fmt.Fprintf(&buf, " <name>%s</name>\n", escapeXML(s.Name))
fmt.Fprintf(&buf, " <name>%s</name>\n", s.Name)
if s.Description != "" {
fmt.Fprintf(&buf, " <description>%s</description>\n", escapeXML(s.Description))
fmt.Fprintf(&buf, " <description>%s</description>\n", s.Description)
}
if s.Compatibility != "" {
fmt.Fprintf(&buf, " <compatibility>%s</compatibility>\n", escapeXML(s.Compatibility))
}
fmt.Fprintf(&buf, " <location>%s</location>\n", escapeXML(s.Path))
fmt.Fprintf(&buf, " <location>file://%s</location>\n", s.Path)
buf.WriteString(" </skill>\n")
emitted++
}
buf.WriteString("</available_skills>")
if emitted == 0 {
return ""
}
return buf.String()
}
// escapeXML escapes a string for safe inclusion as XML text content.
func escapeXML(s string) string {
var buf bytes.Buffer
if err := xml.EscapeText(&buf, []byte(s)); err != nil {
return s
}
return buf.String()
}
// FormatResources renders a skill's bundled resources as a <skill_resources>
// block, or returns the empty string when the skill bundles no resources. It
// is used when a skill is explicitly activated so the model knows which files
// it can read without enumerating them itself.
func FormatResources(resources []string) string {
if len(resources) == 0 {
return ""
}
var buf bytes.Buffer
buf.WriteString("<skill_resources>\n")
limit := len(resources)
truncated := false
if limit > maxResources {
limit = maxResources
truncated = true
}
for _, r := range resources[:limit] {
fmt.Fprintf(&buf, " <file>%s</file>\n", escapeXML(r))
}
if truncated {
buf.WriteString(" <!-- (truncated) -->\n")
}
buf.WriteString("</skill_resources>")
return buf.String()
}
-70
View File
@@ -1,70 +0,0 @@
package skills
import (
"testing"
"testing/fstest"
)
func TestLoadSkillsFromFS(t *testing.T) {
fsys := fstest.MapFS{
"top.md": {Data: []byte("---\nname: top-skill\ndescription: a top level skill\n---\nbody here")},
"notes.txt": {Data: []byte("plain text skill")},
"deep/SKILL.md": {Data: []byte("---\nname: deep-skill\n---\ndeep body")},
"deep/other.md": {Data: []byte("ignored non-SKILL nested md")},
"ignore.json": {Data: []byte("{}")},
}
got, err := LoadSkillsFromFS(fsys, ".")
if err != nil {
t.Fatalf("LoadSkillsFromFS error: %v", err)
}
byName := map[string]*Skill{}
for _, s := range got {
byName[s.Name] = s
}
if _, ok := byName["top-skill"]; !ok {
t.Errorf("top-skill not loaded; got %v", names(got))
}
if _, ok := byName["notes"]; !ok {
t.Errorf("notes (txt) not loaded; got %v", names(got))
}
if _, ok := byName["deep-skill"]; !ok {
t.Errorf("deep SKILL.md not loaded; got %v", names(got))
}
if _, ok := byName["other"]; ok {
t.Errorf("nested non-SKILL .md should be ignored; got %v", names(got))
}
if len(got) != 3 {
t.Errorf("expected 3 skills, got %d: %v", len(got), names(got))
}
// Content/description parsed from frontmatter.
if s := byName["top-skill"]; s != nil {
if s.Description != "a top level skill" {
t.Errorf("description = %q", s.Description)
}
if s.Content != "body here" {
t.Errorf("content = %q", s.Content)
}
if s.Path != "top.md" {
t.Errorf("path = %q, want top.md", s.Path)
}
}
}
func TestLoadSkillsFromFSNil(t *testing.T) {
got, err := LoadSkillsFromFS(nil, ".")
if err != nil || got != nil {
t.Fatalf("nil fs should yield (nil, nil), got (%v, %v)", got, err)
}
}
func names(skills []*Skill) []string {
out := make([]string, 0, len(skills))
for _, s := range skills {
out = append(out, s.Name)
}
return out
}
+22 -204
View File
@@ -243,24 +243,15 @@ func TestLoadSkillsFromDir_CaseInsensitiveSKILLmd(t *testing.T) {
// LoadSkills (auto-discovery)
// ---------------------------------------------------------------------------
func writeSkill(t *testing.T, path, name, desc, body string) {
t.Helper()
content := "---\nname: " + name + "\ndescription: " + desc + "\n---\n" + body
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestLoadSkills_ProjectLocal(t *testing.T) {
dir := t.TempDir()
// Isolate user-level scopes so the host machine's skills don't leak in.
t.Setenv("XDG_CONFIG_HOME", filepath.Join(dir, "xdg"))
t.Setenv("HOME", filepath.Join(dir, "home"))
skillsDir := filepath.Join(dir, ".kit", "skills")
if err := os.MkdirAll(skillsDir, 0o755); err != nil {
t.Fatal(err)
}
writeSkill(t, filepath.Join(skillsDir, "local.md"), "local", "A local skill", "Local skill")
if err := os.WriteFile(filepath.Join(skillsDir, "local.md"), []byte("Local skill"), 0o644); err != nil {
t.Fatal(err)
}
skills, err := LoadSkills(dir)
if err != nil {
@@ -274,64 +265,37 @@ func TestLoadSkills_ProjectLocal(t *testing.T) {
}
}
// TestLoadSkills_SkipsMissingDescription verifies that a skill without a
// required description is skipped during auto-discovery (gap #2).
func TestLoadSkills_SkipsMissingDescription(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", filepath.Join(dir, "xdg"))
t.Setenv("HOME", filepath.Join(dir, "home"))
skillsDir := filepath.Join(dir, ".kit", "skills")
if err := os.MkdirAll(skillsDir, 0o755); err != nil {
t.Fatal(err)
}
// No description — should be skipped.
if err := os.WriteFile(filepath.Join(skillsDir, "nodesc.md"), []byte("Just a body"), 0o644); err != nil {
t.Fatal(err)
}
writeSkill(t, filepath.Join(skillsDir, "good.md"), "good", "Has a description", "Body")
skills, err := LoadSkills(dir)
if err != nil {
t.Fatal(err)
}
if len(skills) != 1 {
t.Fatalf("expected 1 skill (missing-description skipped), got %d", len(skills))
}
if skills[0].Name != "good" {
t.Errorf("Name = %q, want %q", skills[0].Name, "good")
}
}
// TestLoadSkills_NameCollisionPrecedence verifies project-level skills override
// user-level skills with the same name (gap #5).
func TestLoadSkills_NameCollisionPrecedence(t *testing.T) {
func TestLoadSkills_Deduplication(t *testing.T) {
dir := t.TempDir()
// Set XDG_CONFIG_HOME so the user-level skill lives under our temp dir.
// Set XDG_CONFIG_HOME to our temp dir so global and local overlap.
t.Setenv("XDG_CONFIG_HOME", dir)
t.Setenv("HOME", filepath.Join(dir, "home"))
userDir := filepath.Join(dir, "kit", "skills")
projectDir := filepath.Join(dir, ".kit", "skills")
if err := os.MkdirAll(userDir, 0o755); err != nil {
globalDir := filepath.Join(dir, "kit", "skills")
localDir := filepath.Join(dir, ".kit", "skills")
if err := os.MkdirAll(globalDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(projectDir, 0o755); err != nil {
if err := os.MkdirAll(localDir, 0o755); err != nil {
t.Fatal(err)
}
writeSkill(t, filepath.Join(userDir, "shared.md"), "shared", "User version", "USER")
writeSkill(t, filepath.Join(projectDir, "shared.md"), "shared", "Project version", "PROJECT")
// Same content in both directories but different paths — both should load.
if err := os.WriteFile(filepath.Join(globalDir, "shared.md"), []byte("Global version"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(localDir, "shared.md"), []byte("Local version"), 0o644); err != nil {
t.Fatal(err)
}
skills, err := LoadSkills(dir)
if err != nil {
t.Fatal(err)
}
if len(skills) != 1 {
t.Fatalf("expected 1 skill (deduped by name), got %d", len(skills))
}
if !strings.Contains(skills[0].Content, "PROJECT") {
t.Errorf("expected project version to win, got content %q", skills[0].Content)
// Different absolute paths = both loaded.
if len(skills) != 2 {
t.Fatalf("expected 2 skills (different paths), got %d", len(skills))
}
}
@@ -357,8 +321,8 @@ func TestFormatForPrompt_SingleSkill(t *testing.T) {
if !strings.Contains(result, "<description>A test</description>") {
t.Errorf("result should contain description in XML")
}
if !strings.Contains(result, "<location>/tmp/test-skill/SKILL.md</location>") {
t.Errorf("result should contain bare file location (no file:// prefix)")
if !strings.Contains(result, "<location>file:///tmp/test-skill/SKILL.md</location>") {
t.Errorf("result should contain file location")
}
if !strings.Contains(result, "<available_skills>") {
t.Errorf("result should contain available_skills root element")
@@ -388,149 +352,3 @@ func TestFormatForPrompt_MultipleSkills(t *testing.T) {
t.Error("missing preamble instructions")
}
}
// ---------------------------------------------------------------------------
// agentskills.io spec compliance (issue #65)
// ---------------------------------------------------------------------------
// TestFormatForPrompt_XMLEscaping verifies special characters in name and
// description are escaped so the catalog is valid XML (gap #1).
func TestFormatForPrompt_XMLEscaping(t *testing.T) {
skills := []*Skill{
{Name: "a&b", Description: "use when <tag> & \"quoted\"", Path: "/tmp/x"},
}
result := FormatForPrompt(skills)
if strings.Contains(result, "<tag>") {
t.Errorf("raw < should have been escaped, got: %q", result)
}
if !strings.Contains(result, "&lt;tag&gt;") {
t.Errorf("expected escaped <tag>, got: %q", result)
}
if !strings.Contains(result, "a&amp;b") {
t.Errorf("expected escaped ampersand in name, got: %q", result)
}
}
// TestFormatForPrompt_DisableModelInvocation verifies that a skill flagged
// disable-model-invocation is omitted from the catalog (gap #10).
func TestFormatForPrompt_DisableModelInvocation(t *testing.T) {
skills := []*Skill{
{Name: "visible", Description: "shown", Path: "/tmp/a"},
{Name: "hidden", Description: "not shown", Path: "/tmp/b", DisableModelInvocation: true},
}
result := FormatForPrompt(skills)
if !strings.Contains(result, "<name>visible</name>") {
t.Error("visible skill should be in catalog")
}
if strings.Contains(result, "<name>hidden</name>") {
t.Error("disable-model-invocation skill should be omitted from catalog")
}
}
// TestLoadSkill_NewSpecFields verifies the new frontmatter fields parse (gap #6).
func TestLoadSkill_NewSpecFields(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "spec.md")
content := `---
name: spec-skill
description: A spec-compliant skill
license: MIT
compatibility: claude-code, cursor
allowed-tools: read, bash
disable-model-invocation: true
metadata:
author: jane
version: "1.2"
---
Body.`
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
s, err := LoadSkill(path)
if err != nil {
t.Fatal(err)
}
if s.License != "MIT" {
t.Errorf("License = %q, want MIT", s.License)
}
if s.Compatibility != "claude-code, cursor" {
t.Errorf("Compatibility = %q", s.Compatibility)
}
if s.AllowedTools != "read, bash" {
t.Errorf("AllowedTools = %q", s.AllowedTools)
}
if !s.DisableModelInvocation {
t.Error("DisableModelInvocation should be true")
}
if s.Metadata["author"] != "jane" || s.Metadata["version"] != "1.2" {
t.Errorf("Metadata = %v", s.Metadata)
}
}
// TestLoadSkill_UnquotedColonFallback verifies the YAML repair fallback for
// the common `description: Use when: ...` mistake (gap #9).
func TestLoadSkill_UnquotedColonFallback(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "colon.md")
content := "---\nname: colon-skill\ndescription: Use when: extracting tables from PDFs\n---\nBody."
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
s, err := LoadSkill(path)
if err != nil {
t.Fatalf("expected unquoted-colon fallback to succeed, got error: %v", err)
}
if s.Name != "colon-skill" {
t.Errorf("Name = %q", s.Name)
}
if !strings.Contains(s.Description, "extracting tables") {
t.Errorf("Description = %q", s.Description)
}
}
// TestValidate verifies the Validate diagnostics (gaps #2, #15).
func TestValidate(t *testing.T) {
missing := &Skill{Name: "x"}
diags := missing.Validate()
if !hasError(diags) {
t.Error("expected an error diagnostic for missing description")
}
ok := &Skill{Name: "x", Description: "y"}
if len(ok.Validate()) != 0 {
t.Error("expected no diagnostics for a complete skill")
}
}
// TestSkillResources verifies bundled-resource enumeration (gaps #11, #15).
func TestSkillResources(t *testing.T) {
dir := t.TempDir()
skillDir := filepath.Join(dir, "my-skill")
if err := os.MkdirAll(filepath.Join(skillDir, "scripts"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(skillDir, "references"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillDir, "scripts", "run.py"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillDir, "references", "REF.md"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
s := &Skill{Name: "my-skill", Path: filepath.Join(skillDir, "SKILL.md")}
res := s.Resources()
if len(res) != 2 {
t.Fatalf("expected 2 resources, got %d: %v", len(res), res)
}
if s.BaseDir() != skillDir {
t.Errorf("BaseDir = %q, want %q", s.BaseDir(), skillDir)
}
formatted := FormatResources(res)
if !strings.Contains(formatted, "<file>references/REF.md</file>") {
t.Errorf("FormatResources output missing reference: %q", formatted)
}
if !strings.Contains(formatted, "<file>scripts/run.py</file>") {
t.Errorf("FormatResources output missing script: %q", formatted)
}
}
-142
View File
@@ -1,142 +0,0 @@
// Package skilltool provides the built-in activate_skill tool, a dedicated
// activation entry point for agentskills.io skills (issue #65, gaps #13/#14).
//
// While a skill can always be activated by reading its SKILL.md with the
// generic read tool, a dedicated tool offers an enum-constrained skill name
// (preventing hallucinated names), bundled-resource enumeration, and
// per-session deduplication so the same skill is not re-injected repeatedly.
package skilltool
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"sync"
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/skills"
)
// SkillProvider returns the skills currently available for activation. It is
// queried on every call so runtime skill mutations are reflected.
type SkillProvider func() []*skills.Skill
type activateArgs struct {
Name string `json:"name"`
}
// activateSkillTool implements fantasy.AgentTool.
type activateSkillTool struct {
info fantasy.ToolInfo
provider SkillProvider
providerOptions fantasy.ProviderOptions
mu sync.Mutex
activated map[string]bool // session-level dedup tracking
}
func (t *activateSkillTool) Info() fantasy.ToolInfo { return t.info }
func (t *activateSkillTool) ProviderOptions() fantasy.ProviderOptions { return t.providerOptions }
func (t *activateSkillTool) SetProviderOptions(opts fantasy.ProviderOptions) {
t.providerOptions = opts
}
// New builds the activate_skill tool. names is the initial set of skill names
// used to populate the enum constraint on the name parameter; provider is
// queried at call time to resolve the skill by name (so runtime additions
// resolve even if absent from the enum). Returns nil when no skill names are
// available.
func New(names []string, provider SkillProvider) fantasy.AgentTool {
if len(names) == 0 || provider == nil {
return nil
}
sorted := append([]string(nil), names...)
sort.Strings(sorted)
enum := make([]any, len(sorted))
for i, n := range sorted {
enum[i] = n
}
return &activateSkillTool{
info: fantasy.ToolInfo{
Name: "activate_skill",
Description: "Activate a skill by name to load its full instructions into context. " +
"Use this when a task matches a skill listed in <available_skills>. " +
"The skill body and a list of its bundled resources are returned.",
Parameters: map[string]any{
"name": map[string]any{
"type": "string",
"description": "The exact name of the skill to activate.",
"enum": enum,
},
},
Required: []string{"name"},
Parallel: false,
},
provider: provider,
activated: map[string]bool{},
}
}
func (t *activateSkillTool) Run(_ context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
var args activateArgs
if call.Input != "" && call.Input != "{}" {
if err := json.Unmarshal([]byte(call.Input), &args); err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("invalid arguments: %v", err)), nil
}
}
name := strings.TrimSpace(args.Name)
if name == "" {
return fantasy.NewTextErrorResponse("name is required"), nil
}
// Hold the lock across the whole activation so the dedup check and the
// subsequent mark are atomic — two concurrent calls cannot both pass the
// check and double-activate the same skill (gap #14). The skill is only
// marked activated on success, so a failed load can be retried.
t.mu.Lock()
defer t.mu.Unlock()
if t.activated[name] {
return fantasy.NewTextResponse(
fmt.Sprintf("Skill %q was already loaded earlier in this session.", name)), nil
}
// Resolve the skill path from the current provider snapshot. Skills with
// disable-model-invocation set are not activatable by the model (they
// remain available via the /skill: command), mirroring their exclusion
// from the catalog and the tool's name enum.
var path string
for _, s := range t.provider() {
if s.Name == name && !s.DisableModelInvocation {
path = s.Path
break
}
}
if path == "" {
return fantasy.NewTextErrorResponse(fmt.Sprintf("unknown skill %q", name)), nil
}
// Re-read the file for freshness, stripping frontmatter.
loaded, err := skills.LoadSkill(path)
if err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to load skill %q: %v", name, err)), nil
}
var buf strings.Builder
fmt.Fprintf(&buf, "<skill_content name=%q location=%q>\n", loaded.Name, loaded.Path)
fmt.Fprintf(&buf, "References are relative to %s.\n\n", loaded.BaseDir())
buf.WriteString(loaded.Content)
if res := skills.FormatResources(loaded.Resources()); res != "" {
buf.WriteString("\n\n")
buf.WriteString(res)
}
buf.WriteString("\n</skill_content>")
t.activated[name] = true
return fantasy.NewTextResponse(buf.String()), nil
}
-107
View File
@@ -1,107 +0,0 @@
package skilltool
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/skills"
)
func writeSkillFile(t *testing.T, dir, name string) *skills.Skill {
t.Helper()
skillDir := filepath.Join(dir, name)
if err := os.MkdirAll(filepath.Join(skillDir, "scripts"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillDir, "scripts", "run.py"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
path := filepath.Join(skillDir, "SKILL.md")
content := "---\nname: " + name + "\ndescription: A test skill\n---\nDo the thing."
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return &skills.Skill{Name: name, Description: "A test skill", Path: path}
}
func TestNew_NilWhenNoSkills(t *testing.T) {
if tool := New(nil, func() []*skills.Skill { return nil }); tool != nil {
t.Error("expected nil tool when no skill names provided")
}
}
func TestActivateSkill_LoadsAndDedups(t *testing.T) {
dir := t.TempDir()
s := writeSkillFile(t, dir, "extract")
provider := func() []*skills.Skill { return []*skills.Skill{s} }
tool := New([]string{"extract"}, provider)
if tool == nil {
t.Fatal("expected a tool")
}
// First activation loads content + resources.
resp, err := tool.Run(context.Background(), fantasy.ToolCall{Input: `{"name":"extract"}`})
if err != nil {
t.Fatal(err)
}
out := responseText(resp)
if !strings.Contains(out, "Do the thing.") {
t.Errorf("expected skill body, got: %q", out)
}
if !strings.Contains(out, "<skill_content name=\"extract\"") {
t.Errorf("expected skill_content wrapper, got: %q", out)
}
if !strings.Contains(out, "scripts/run.py") {
t.Errorf("expected enumerated resources, got: %q", out)
}
// Second activation is deduplicated.
resp2, err := tool.Run(context.Background(), fantasy.ToolCall{Input: `{"name":"extract"}`})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(responseText(resp2), "already loaded") {
t.Errorf("expected dedup message, got: %q", responseText(resp2))
}
}
func TestActivateSkill_UnknownSkill(t *testing.T) {
provider := func() []*skills.Skill { return nil }
tool := New([]string{"extract"}, provider)
resp, err := tool.Run(context.Background(), fantasy.ToolCall{Input: `{"name":"nope"}`})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(responseText(resp), "unknown skill") {
t.Errorf("expected unknown-skill error, got: %q", responseText(resp))
}
}
// TestActivateSkill_DisabledNotActivatable verifies a skill flagged
// disable-model-invocation cannot be activated through the model-facing tool.
func TestActivateSkill_DisabledNotActivatable(t *testing.T) {
dir := t.TempDir()
s := writeSkillFile(t, dir, "extract")
s.DisableModelInvocation = true
provider := func() []*skills.Skill { return []*skills.Skill{s} }
tool := New([]string{"extract"}, provider)
resp, err := tool.Run(context.Background(), fantasy.ToolCall{Input: `{"name":"extract"}`})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(responseText(resp), "unknown skill") {
t.Errorf("disabled skill should not be activatable, got: %q", responseText(resp))
}
}
// responseText extracts the text from a tool response.
func responseText(resp fantasy.ToolResponse) string {
return resp.Content
}
-153
View File
@@ -1,153 +0,0 @@
// Package trust manages a persisted allowlist of project directories that the
// user has marked as trusted for loading project-local skills.
//
// Project-local skills (discovered under <project>/.agents/skills/ and
// <project>/.kit/skills/) are injected into the system prompt. A freshly
// cloned, untrusted repository could therefore smuggle instructions into the
// agent the moment the user runs Kit inside it. To mitigate this prompt-
// injection vector, project-level skill loading can be gated on an explicit
// trust decision recorded here.
//
// The allowlist is stored as JSON at $XDG_CONFIG_HOME/kit/trusted-projects.json
// (default ~/.config/kit/trusted-projects.json).
package trust
import (
"encoding/json"
"os"
"path/filepath"
"sync"
)
// Decision is the outcome of a trust prompt.
type Decision int
const (
// Skip declines to load project skills this session and records nothing.
Skip Decision = iota
// Trust loads project skills this session and persists the directory.
Trust
// TrustOnce loads project skills this session without persisting.
TrustOnce
)
// storeFileName is the basename of the persisted allowlist.
const storeFileName = "trusted-projects.json"
// Store is a persisted set of trusted project directories. The zero value is
// not usable — construct one with Load.
type Store struct {
mu sync.Mutex
path string
trusted map[string]bool
}
// store mirrors the on-disk JSON layout.
type store struct {
Projects []string `json:"projects"`
}
// DefaultPath returns the path the trust allowlist is persisted to, respecting
// $XDG_CONFIG_HOME. Returns the empty string when no home directory can be
// determined.
func DefaultPath() 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", storeFileName)
}
// Load reads the trust allowlist from path. A missing file yields an empty
// store (not an error). Pass an empty path to use DefaultPath.
func Load(path string) (*Store, error) {
if path == "" {
path = DefaultPath()
}
s := &Store{path: path, trusted: map[string]bool{}}
if path == "" {
return s, nil
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return s, nil
}
return s, err
}
var raw store
if err := json.Unmarshal(data, &raw); err != nil {
return s, err
}
for _, p := range raw.Projects {
s.trusted[normalize(p)] = true
}
return s, nil
}
// IsTrusted reports whether dir has been marked trusted.
func (s *Store) IsTrusted(dir string) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.trusted[normalize(dir)]
}
// Trust records dir as trusted and persists the allowlist to disk.
func (s *Store) Trust(dir string) error {
s.mu.Lock()
s.trusted[normalize(dir)] = true
s.mu.Unlock()
return s.save()
}
// Untrust removes dir from the allowlist and persists the change.
func (s *Store) Untrust(dir string) error {
s.mu.Lock()
delete(s.trusted, normalize(dir))
s.mu.Unlock()
return s.save()
}
// save writes the allowlist to disk, creating parent directories as needed.
func (s *Store) save() error {
if s.path == "" {
return nil
}
s.mu.Lock()
projects := make([]string, 0, len(s.trusted))
for p := range s.trusted {
projects = append(projects, p)
}
s.mu.Unlock()
data, err := json.MarshalIndent(store{Projects: projects}, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return err
}
return os.WriteFile(s.path, data, 0o644)
}
// normalize resolves dir to an absolute, symlink-evaluated path for stable
// comparison. It falls back to the cleaned input when resolution fails.
func normalize(dir string) string {
if dir == "" {
return ""
}
abs, err := filepath.Abs(dir)
if err != nil {
return filepath.Clean(dir)
}
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
return resolved
}
return abs
}
-60
View File
@@ -1,60 +0,0 @@
package trust
import (
"path/filepath"
"testing"
)
func TestStore_TrustAndPersist(t *testing.T) {
dir := t.TempDir()
storePath := filepath.Join(dir, "trusted-projects.json")
project := filepath.Join(dir, "repo")
s, err := Load(storePath)
if err != nil {
t.Fatal(err)
}
if s.IsTrusted(project) {
t.Fatal("project should not be trusted initially")
}
if err := s.Trust(project); err != nil {
t.Fatal(err)
}
if !s.IsTrusted(project) {
t.Fatal("project should be trusted after Trust")
}
// Reload from disk to confirm persistence.
s2, err := Load(storePath)
if err != nil {
t.Fatal(err)
}
if !s2.IsTrusted(project) {
t.Fatal("trust decision should persist across reloads")
}
}
func TestStore_Untrust(t *testing.T) {
dir := t.TempDir()
storePath := filepath.Join(dir, "trusted-projects.json")
project := filepath.Join(dir, "repo")
s, _ := Load(storePath)
_ = s.Trust(project)
if err := s.Untrust(project); err != nil {
t.Fatal(err)
}
if s.IsTrusted(project) {
t.Fatal("project should not be trusted after Untrust")
}
}
func TestStore_MissingFileIsEmpty(t *testing.T) {
s, err := Load(filepath.Join(t.TempDir(), "does-not-exist.json"))
if err != nil {
t.Fatalf("missing file should not error: %v", err)
}
if s.IsTrusted("/anything") {
t.Fatal("empty store should trust nothing")
}
}
+4 -25
View File
@@ -330,6 +330,7 @@ kit.LLMFilePart // {Filename, Data []byte, MediaType}
// Agent configuration — concrete Kit-owned structs and function types.
// All fields use SDK types (e.g. `[]kit.Tool`), so consumers can construct
// these without importing any LLM-provider package.
kit.AgentConfig // Lower-level agent config — prefer Options unless you need direct control
kit.DebugLogger // Interface: LogDebug(string) / IsDebugEnabled() bool
kit.MCPTaskConfig // Task-aware MCP tools/call config (modes, polling, progress)
kit.ToolCallHandler // func(toolCallID, toolName, toolArgs string)
@@ -364,28 +365,15 @@ msg := kit.ConvertFromLLMMessage(lMsg) // LLMMessage → SDK Message
- `Option` - Functional option (`func(*Options)`) for `NewAgent`
- `Message` - Conversation message with typed content parts
- `Tool` - Agent tool interface
- `TurnResult` - Full result from a prompt including usage stats, captured
stream deltas (`Stream`), and any tool-driven halt (`FinalValue` /
`HaltedByTool`)
- `StreamEvent` / `StreamEventKind` - Ordered delta events captured in
`TurnResult.Stream`
- `ToolOutput` - Custom tool return value; set `Halt`/`FinalValue` to end the
agent loop and surface a typed result
- Provider-error sentinels - `ErrContextOverflow`, `ErrRateLimit`, `ErrAuth`,
`ErrProviderUnavailable`, `ErrInvalidRequest`; classify with
`ClassifyProviderError(err)` and match via `errors.Is`
- `TurnResult` - Full result from a prompt including usage stats
### Key Methods
- `New(ctx, opts)` - Create new Kit instance
- `NewAgent(ctx, ...Option)` - Create a Kit via functional options (streaming on by default)
- `Prompt(ctx, message)` - Send message and get response string
- `PromptResult(ctx, message)` - Send message and get full TurnResult (blocks
until end-of-turn; populates `TurnResult.Stream` in streaming mode)
- `PromptResult(ctx, message)` - Send message and get full TurnResult
- `PromptWithOptions(ctx, message, opts)` - Prompt with per-call options
(system message, model, thinking level, provider credentials, extra tools)
- `PromptResultWithOptions(ctx, message, opts)` - Per-call options variant that
returns the full TurnResult
- `Steer(ctx, instruction)` - System-level steering
- `FollowUp(ctx, text)` - Continue without new user input
- `SetModel(ctx, model)` - Switch model at runtime
@@ -397,15 +385,7 @@ msg := kit.ConvertFromLLMMessage(lMsg) // LLMMessage → SDK Message
- `AddSkill(*Skill)` / `LoadAndAddSkill(path)` / `RemoveSkill(name)` / `SetSkills([])` - Manage skills at runtime
- `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
- `NewRawTool(name, desc, schema, fn)` - Create a schema-driven tool when the
input shape isn't known at compile time (skill/MCP catalogs)
- `LoadSkillsFromFS(fsys, root)` - `fs.FS`-typed skill loader (embed.FS,
fstest.MapFS, per-tenant virtual filesystems)
- `CollapseBranch(fromID, toID, summary)` - Collapse a branch range into a
summary (works with any `SessionManager` via `AppendBranchSummary`)
- `Close()` - Clean up resources
- `CloseContext(ctx)` - Clean up resources with a shutdown deadline
### Options
@@ -424,8 +404,7 @@ Key `Options` fields for SDK usage:
| `SessionPath` | Open specific session file |
| `Continue` | Resume most recent session |
| `InProcessMCPServers` | Map of name → `*kit.MCPServer` for in-process MCP servers |
| `Debug` | Enable debug logging via the built-in console logger (ignored when `DebugLogger` is set) |
| `DebugLogger` | Custom `DebugLogger` implementation — routes engine + MCP debug output into your own logging system |
| `Debug` | Enable debug logging |
## Environment Variables
-5
View File
@@ -153,11 +153,6 @@ func (a *treeManagerAdapter) GetContextEntryIDs() []string {
return a.inner.GetContextEntryIDs()
}
// AppendBranchSummary implements SessionManager.
func (a *treeManagerAdapter) AppendBranchSummary(fromID, summary string) (string, error) {
return a.inner.AppendBranchSummary(fromID, summary)
}
// Close implements SessionManager.
func (a *treeManagerAdapter) Close() error {
return a.inner.Close()
+208
View File
@@ -0,0 +1,208 @@
package kit
import (
"context"
"errors"
"testing"
"time"
"github.com/mark3labs/kit/internal/agent"
)
// TestAgentConfigToInternal verifies that the SDK-side AgentConfig converts
// faithfully to the internal agent.AgentConfig representation, preserving
// every field consumed by the internal agent layer.
//
// Regression test for https://github.com/mark3labs/kit/issues/30.
func TestAgentConfigToInternal(t *testing.T) {
t.Run("nil receiver returns nil", func(t *testing.T) {
var c *AgentConfig
if got := c.toInternal(); got != nil {
t.Errorf("nil.toInternal() = %v, want nil", got)
}
})
t.Run("scalar fields round-trip", func(t *testing.T) {
c := &AgentConfig{
SystemPrompt: "sys",
MaxSteps: 7,
StreamingEnabled: true,
DisableCoreTools: true,
}
got := c.toInternal()
if got == nil {
t.Fatal("toInternal() = nil")
}
if got.SystemPrompt != "sys" {
t.Errorf("SystemPrompt = %q, want %q", got.SystemPrompt, "sys")
}
if got.MaxSteps != 7 {
t.Errorf("MaxSteps = %d, want 7", got.MaxSteps)
}
if !got.StreamingEnabled {
t.Error("StreamingEnabled = false, want true")
}
if !got.DisableCoreTools {
t.Error("DisableCoreTools = false, want true")
}
})
t.Run("tool slices propagate without conversion", func(t *testing.T) {
// Tool is a type alias for the underlying LLM-tool type, so the
// SDK []Tool and internal []fantasy.AgentTool slices share the
// same backing array after conversion.
tool := NewTool[struct{}]("noop", "noop", nil)
c := &AgentConfig{
CoreTools: []Tool{tool},
ExtraTools: []Tool{tool, tool},
}
got := c.toInternal()
if len(got.CoreTools) != 1 {
t.Errorf("CoreTools len = %d, want 1", len(got.CoreTools))
}
if len(got.ExtraTools) != 2 {
t.Errorf("ExtraTools len = %d, want 2", len(got.ExtraTools))
}
})
t.Run("tool wrapper is invoked through internal config", func(t *testing.T) {
called := false
c := &AgentConfig{
ToolWrapper: func(in []Tool) []Tool {
called = true
return in
},
}
got := c.toInternal()
if got.ToolWrapper == nil {
t.Fatal("internal ToolWrapper is nil")
}
_ = got.ToolWrapper(nil)
if !called {
t.Error("SDK ToolWrapper was not invoked through the internal config")
}
})
t.Run("OnMCPServerLoaded propagates", func(t *testing.T) {
var captured string
wantErr := errors.New("boom")
c := &AgentConfig{
OnMCPServerLoaded: func(name string, _ int, _ error) {
captured = name
},
}
got := c.toInternal()
got.OnMCPServerLoaded("svr", 3, wantErr)
if captured != "svr" {
t.Errorf("OnMCPServerLoaded captured = %q, want %q", captured, "svr")
}
})
t.Run("DebugLogger propagates", func(t *testing.T) {
dl := &fakeDebugLogger{enabled: true}
c := &AgentConfig{DebugLogger: dl}
got := c.toInternal()
if got.DebugLogger == nil {
t.Fatal("internal DebugLogger is nil")
}
if !got.DebugLogger.IsDebugEnabled() {
t.Error("IsDebugEnabled = false, want true")
}
got.DebugLogger.LogDebug("hello")
if len(dl.messages) != 1 || dl.messages[0] != "hello" {
t.Errorf("messages = %v, want [hello]", dl.messages)
}
})
t.Run("MCPTaskConfig propagates with mode + progress", func(t *testing.T) {
c := &AgentConfig{
MCPTaskConfig: MCPTaskConfig{
PerServerMode: map[string]MCPTaskMode{
"build-svr": MCPTaskModeAlways,
},
DefaultTTL: 30 * time.Second,
PollInterval: 250 * time.Millisecond,
MaxPollInterval: 2 * time.Second,
Timeout: 5 * time.Minute,
Progress: func(_ MCPTaskProgress) {},
},
}
got := c.toInternal()
if got.MCPTaskConfig.DefaultTTL != 30*time.Second {
t.Errorf("DefaultTTL = %v, want 30s", got.MCPTaskConfig.DefaultTTL)
}
if got.MCPTaskConfig.PollInterval != 250*time.Millisecond {
t.Errorf("PollInterval = %v, want 250ms", got.MCPTaskConfig.PollInterval)
}
if got.MCPTaskConfig.MaxPollInterval != 2*time.Second {
t.Errorf("MaxPollInterval = %v, want 2s", got.MCPTaskConfig.MaxPollInterval)
}
if got.MCPTaskConfig.Timeout != 5*time.Minute {
t.Errorf("Timeout = %v, want 5m", got.MCPTaskConfig.Timeout)
}
mode, ok := got.MCPTaskConfig.PerServerMode["build-svr"]
if !ok {
t.Fatal("PerServerMode missing 'build-svr'")
}
if string(mode) != string(MCPTaskModeAlways) {
t.Errorf("mode = %q, want %q", mode, MCPTaskModeAlways)
}
if got.MCPTaskConfig.Progress == nil {
t.Fatal("internal Progress handler is nil")
}
})
t.Run("auth and token store factories are wired", func(t *testing.T) {
auth := &fakeAuthHandler{}
tokenCalls := 0
var tokenServer string
factory := MCPTokenStoreFactory(func(server string) (MCPTokenStore, error) {
tokenCalls++
tokenServer = server
return nil, nil
})
c := &AgentConfig{
AuthHandler: auth,
TokenStoreFactory: factory,
}
got := c.toInternal()
if got.AuthHandler == nil {
t.Fatal("internal AuthHandler is nil")
}
if got.TokenStoreFactory == nil {
t.Fatal("internal TokenStoreFactory is nil")
}
_, _ = got.TokenStoreFactory("https://example.test")
if tokenCalls != 1 {
t.Errorf("token factory call count = %d, want 1", tokenCalls)
}
if tokenServer != "https://example.test" {
t.Errorf("token factory server arg = %q", tokenServer)
}
if got.AuthHandler.RedirectURI() != "redirect" {
t.Errorf("RedirectURI = %q, want %q", got.AuthHandler.RedirectURI(), "redirect")
}
})
// Compile-time check that the internal type is what we expect.
//nolint:staticcheck // QF1011: explicit type asserts the conversion target.
var _ *agent.AgentConfig = (&AgentConfig{}).toInternal()
}
// fakeAuthHandler implements both kit.MCPAuthHandler and the structurally
// identical tools.MCPAuthHandler used by the internal layer.
type fakeAuthHandler struct{}
func (f *fakeAuthHandler) RedirectURI() string { return "redirect" }
func (f *fakeAuthHandler) HandleAuth(_ context.Context, _ string, _ string) (string, error) {
return "", nil
}
// fakeDebugLogger implements kit.DebugLogger for tests.
type fakeDebugLogger struct {
enabled bool
messages []string
}
func (f *fakeDebugLogger) LogDebug(m string) { f.messages = append(f.messages, m) }
func (f *fakeDebugLogger) IsDebugEnabled() bool { return f.enabled }
+1 -13
View File
@@ -112,20 +112,8 @@ func (m *Kit) Compact(ctx context.Context, opts *CompactionOptions, customInstru
}
// compactInternal is the shared compaction implementation. The isAutomatic
// flag distinguishes user-triggered from auto-compaction for hooks/events.
// On failure it emits a CompactionEvent carrying the error so embedders can
// observe the failure path symmetrically with the success path.
// flag distinguishes auto-triggered compaction from manual /compact.
func (m *Kit) compactInternal(ctx context.Context, opts *CompactionOptions, customInstructions string, isAutomatic bool) (*CompactionResult, error) {
result, err := m.compactImpl(ctx, opts, customInstructions, isAutomatic)
if err != nil {
m.events.emit(CompactionEvent{Err: err})
}
return result, err
}
// compactImpl performs the actual compaction work. On success it emits a
// CompactionEvent via persistAndEmitCompaction.
func (m *Kit) compactImpl(ctx context.Context, opts *CompactionOptions, customInstructions string, isAutomatic bool) (*CompactionResult, error) {
if opts == nil {
if m.compactionOpts != nil {
opts = m.compactionOpts
-113
View File
@@ -1,113 +0,0 @@
package kit
import (
"errors"
"strings"
)
// Provider-error sentinels. Provider and turn execution paths wrap these via
// fmt.Errorf("%w: %s", …) so embedders can classify failures with errors.Is
// instead of brittle string matching. Use [ClassifyProviderError] to map an
// arbitrary provider error to one of these sentinels.
var (
// ErrContextOverflow indicates the request exceeded the model's maximum
// context window. Embedders typically respond by compacting and retrying.
ErrContextOverflow = errors.New("context window exceeded")
// ErrRateLimit indicates the provider throttled the request. Embedders
// typically respond by backing off and retrying.
ErrRateLimit = errors.New("rate limited by provider")
// ErrAuth indicates a credential / authorization failure.
ErrAuth = errors.New("provider authentication failed")
// ErrProviderUnavailable indicates a transient provider/upstream failure
// (5xx, network error, timeout).
ErrProviderUnavailable = errors.New("provider unavailable")
// ErrInvalidRequest indicates the request was structurally invalid and
// retrying will not help.
ErrInvalidRequest = errors.New("invalid request to provider")
)
// ClassifyProviderError inspects err and returns it wrapped with the matching
// provider-error sentinel ([ErrContextOverflow], [ErrRateLimit], [ErrAuth],
// [ErrProviderUnavailable], or [ErrInvalidRequest]) when the underlying cause
// can be recognized. The returned error satisfies errors.Is against both the
// sentinel and the original cause, so the full chain stays inspectable.
//
// When err is nil it returns nil. When the cause cannot be classified the
// original err is returned unchanged so callers never lose information.
//
// Classification is heuristic: it first honors any sentinel already present in
// the chain (so double-classification is idempotent), then falls back to
// matching common provider status codes and phrases in the error text.
func ClassifyProviderError(err error) error {
if err == nil {
return nil
}
// Already classified — keep as-is so the call is idempotent.
for _, sentinel := range []error{
ErrContextOverflow, ErrRateLimit, ErrAuth,
ErrProviderUnavailable, ErrInvalidRequest,
} {
if errors.Is(err, sentinel) {
return err
}
}
if sentinel := classifyProviderErrorText(err.Error()); sentinel != nil {
return wrapSentinel(sentinel, err)
}
return err
}
// wrapSentinel returns an error that satisfies errors.Is(_, sentinel) while
// keeping the original cause inspectable via errors.Is.
func wrapSentinel(sentinel, cause error) error {
return &sentinelError{sentinel: sentinel, cause: cause}
}
type sentinelError struct {
sentinel error
cause error
}
func (e *sentinelError) Error() string {
return e.sentinel.Error() + ": " + e.cause.Error()
}
// Unwrap returns both the sentinel and the cause so errors.Is matches the
// sentinel and the underlying error chain stays reachable.
func (e *sentinelError) Unwrap() []error {
return []error{e.sentinel, e.cause}
}
// classifyProviderErrorText returns the sentinel matching common provider
// error phrasings, or nil if none match.
func classifyProviderErrorText(msg string) error {
m := strings.ToLower(msg)
switch {
case containsAny(m, "context_length_exceeded", "context window", "maximum context length", "too many tokens", "prompt is too long"):
return ErrContextOverflow
case containsAny(m, "rate limit", "rate_limit", "too many requests", "status 429", "429"):
return ErrRateLimit
case containsAny(m, "unauthorized", "authentication", "invalid api key", "invalid_api_key", "permission denied", "status 401", "status 403", "401", "403"):
return ErrAuth
case containsAny(m, "status 500", "status 502", "status 503", "status 504", "internal server error", "bad gateway", "service unavailable", "gateway timeout", "timeout", "connection refused", "no such host", "eof"):
return ErrProviderUnavailable
case containsAny(m, "status 400", "invalid request", "bad request", "unprocessable"):
return ErrInvalidRequest
default:
return nil
}
}
func containsAny(s string, subs ...string) bool {
for _, sub := range subs {
if strings.Contains(s, sub) {
return true
}
}
return false
}
-64
View File
@@ -1,64 +0,0 @@
package kit_test
import (
"errors"
"fmt"
"testing"
"github.com/mark3labs/kit/pkg/kit"
)
func TestClassifyProviderError(t *testing.T) {
cases := []struct {
name string
in error
want error
}{
{"nil", nil, nil},
{"context overflow", errors.New("error: context_length_exceeded for this model"), kit.ErrContextOverflow},
{"context window phrase", errors.New("the prompt is too long for the context window"), kit.ErrContextOverflow},
{"rate limit", errors.New("HTTP status 429: rate limit exceeded"), kit.ErrRateLimit},
{"auth 401", errors.New("status 401 unauthorized"), kit.ErrAuth},
{"auth invalid key", errors.New("invalid api key provided"), kit.ErrAuth},
{"unavailable 503", errors.New("status 503 service unavailable"), kit.ErrProviderUnavailable},
{"invalid request", errors.New("status 400 bad request: malformed body"), kit.ErrInvalidRequest},
{"unclassified", errors.New("something totally unexpected"), nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := kit.ClassifyProviderError(tc.in)
if tc.in == nil {
if got != nil {
t.Fatalf("expected nil, got %v", got)
}
return
}
if tc.want == nil {
// Unclassified errors are returned unchanged.
if got.Error() != tc.in.Error() {
t.Fatalf("expected unchanged error, got %v", got)
}
return
}
if !errors.Is(got, tc.want) {
t.Fatalf("errors.Is(%v, %v) = false", got, tc.want)
}
// Original cause must remain reachable.
if !errors.Is(got, tc.in) {
t.Fatalf("original cause not preserved in %v", got)
}
})
}
}
func TestClassifyProviderErrorIdempotent(t *testing.T) {
wrapped := fmt.Errorf("%w: upstream detail", kit.ErrRateLimit)
got := kit.ClassifyProviderError(wrapped)
if got != wrapped {
t.Fatalf("already-classified error should be returned unchanged")
}
if !errors.Is(got, kit.ErrRateLimit) {
t.Fatalf("expected ErrRateLimit to remain")
}
}
+1 -8
View File
@@ -370,10 +370,7 @@ type StepUsageEvent struct {
// EventType implements Event.
func (e StepUsageEvent) EventType() EventType { return EventStepUsage }
// CompactionEvent fires after a compaction attempt. On success Err is nil and
// the summary/token/file fields are populated. On failure Err is non-nil and
// the remaining fields are zero-valued, so embedders can wire symmetric
// start/end lifecycle telemetry around both outcomes.
// CompactionEvent fires after a successful compaction.
type CompactionEvent struct {
Summary string
OriginalTokens int
@@ -381,10 +378,6 @@ type CompactionEvent struct {
MessagesRemoved int
ReadFiles []string
ModifiedFiles []string
// Err is non-nil when compaction failed. On the failure path the other
// fields are zero-valued.
Err error
}
// EventType implements Event.
+16 -323
View File
@@ -23,7 +23,6 @@ import (
"github.com/mark3labs/kit/internal/models"
"github.com/mark3labs/kit/internal/session"
"github.com/mark3labs/kit/internal/skills"
"github.com/mark3labs/kit/internal/skilltool"
"github.com/mark3labs/kit/internal/tools"
"github.com/spf13/viper"
@@ -116,11 +115,6 @@ type Kit struct {
steerMu sync.Mutex
steerCh chan agent.SteerMessage
leftoverSteer []agent.SteerMessage // unconsumed steer messages from the last turn
// promptOptsMu serializes per-call PromptOptions overrides that mutate
// shared agent state (model, thinking level, provider creds, extra tools)
// so the apply/restore window of one call never races another.
promptOptsMu sync.Mutex
}
// Subscribe registers an EventListener that will be called for every lifecycle
@@ -1010,24 +1004,9 @@ type Options struct {
// Skills
Skills []string // Explicit skill files/dirs to load (empty = auto-discover)
SkillsDir string // Direct skills directory to scan (overrides auto-discovery; scanned as-is)
SkillsDir string // Override default project-local skills directory
NoSkills bool // Disable skill loading entirely (auto-discovery and explicit)
// SkillsDisable names skills (by Name) to exclude from the model-facing
// catalog. Disabled skills remain available via the /skill: slash command.
SkillsDisable []string
// SkillTrustPrompt is an optional callback invoked the first time Kit
// auto-discovers project-local skills (under <project>/.agents/skills or
// <project>/.kit/skills) in a directory that is not yet on the trust
// allowlist. It receives the project directory and the number of skills
// found, and returns a TrustDecision controlling whether the skills load.
//
// When nil, project-local skills are loaded without prompting (historical
// behaviour). Directories trusted via TrustProject are persisted to
// ~/.config/kit/trusted-projects.json and not prompted again.
SkillTrustPrompt func(projectDir string, skillCount int) TrustDecision
// NoExtensions disables Yaegi extension loading entirely.
NoExtensions bool
@@ -1389,15 +1368,6 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
if err != nil {
return fmt.Errorf("failed to load skills: %w", err)
}
// Apply per-skill disable list (--skill-disable / skill-disable
// config key). Disabled skills stay loaded (so /skill: still
// works) but are hidden from the model-facing catalog.
disable := opts.SkillsDisable
if len(disable) == 0 {
disable = v.GetStringSlice("skill-disable")
}
applySkillDisableList(loadedSkills, disable)
}
// Always compose the system prompt with runtime context: base prompt +
@@ -1551,37 +1521,12 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
// Build agent setup options, pulling CLI-specific fields when available.
// Pass the pre-built ProviderConfig and scalar viper snapshots so
// SetupAgent doesn't need to re-read viper (which would require the lock).
// Register the dedicated activate_skill tool when at least one skill is
// loaded (issue #65, gaps #13/#14). The provider closure reads the live
// skill set from the Kit instance once it exists so runtime additions
// resolve; skillToolKit is assigned after construction below.
var skillToolKit *Kit
extraTools := opts.ExtraTools
if len(loadedSkills) > 0 {
names := make([]string, 0, len(loadedSkills))
for _, s := range loadedSkills {
if !s.DisableModelInvocation {
names = append(names, s.Name)
}
}
provider := func() []*skills.Skill {
if skillToolKit == nil {
return loadedSkills
}
return skillToolKit.GetSkills()
}
if t := skilltool.New(names, provider); t != nil {
extraTools = append(extraTools, t)
}
}
setupOpts := kitsetup.AgentSetupOptions{
MCPConfig: mcpConfig,
Quiet: opts.Quiet,
CoreTools: opts.Tools,
DisableCoreTools: disableCoreTools,
ExtraTools: extraTools,
ExtraTools: opts.ExtraTools,
ToolWrapper: hookToolWrapper(beforeToolCall, afterToolResult),
ProviderConfig: providerConfig,
Debug: debug,
@@ -1681,10 +1626,6 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
prepareStep: prepareStep,
}
// Point the activate_skill provider closure at the live Kit instance so it
// resolves skills mutated after construction.
skillToolKit = k
// Bridge extension events to SDK hooks.
if agentResult.ExtRunner != nil {
k.bridgeExtensions(agentResult.ExtRunner)
@@ -1795,14 +1736,6 @@ func (m *Kit) expandSkillCommand(prompt string) string {
fmt.Fprintf(&buf, "<skill name=%q location=%q>\n", loaded.Name, loaded.Path)
fmt.Fprintf(&buf, "References are relative to %s.\n\n", baseDir)
buf.WriteString(loaded.Content)
// Enumerate bundled resources (scripts/, references/, assets/) so the model
// knows what it can read without listing the directory itself.
if res := skills.FormatResources(loaded.Resources()); res != "" {
buf.WriteString("\n\n")
buf.WriteString(res)
}
buf.WriteString("\n</skill>")
args = strings.TrimSpace(args)
@@ -1819,33 +1752,18 @@ func (m *Kit) expandSkillCommand(prompt string) string {
// ---------------------------------------------------------------------------
// loadSkills loads skills based on Options. If explicit paths are provided
// they are loaded directly. If SkillsDir is set it is treated as a direct
// skills directory (scanned as-is, not as a parent of .agents/.kit). Otherwise
// auto-discovery runs against the standard scopes rooted at SessionDir.
// they are loaded directly; otherwise auto-discovery runs.
func loadSkills(opts *Options) ([]*skills.Skill, error) {
if len(opts.Skills) > 0 {
return loadExplicitSkills(opts.Skills)
}
// An explicit --skills-dir is a direct skills directory: scan it as-is
// rather than appending .agents/skills and .kit/skills beneath it.
if opts.SkillsDir != "" {
return skills.LoadSkillsFromDir(opts.SkillsDir)
}
// Auto-discover from the standard scopes rooted at the session directory.
// Project-local skills are injected into the system prompt, so they are
// gated on a trust decision when a SkillTrustPrompt is configured.
cwd := opts.SessionDir
// Auto-discover from standard directories.
cwd := opts.SkillsDir
if cwd == "" {
cwd, _ = os.Getwd()
cwd = opts.SessionDir
}
user := skills.LoadUserSkills()
project := skills.LoadProjectSkills(cwd)
if len(project) > 0 && !projectSkillsTrusted(opts, cwd, len(project)) {
project = nil
}
return skills.Combine(user, project), nil
return skills.LoadSkills(cwd)
}
// loadExplicitSkills loads skills from a list of explicit paths. Each path
@@ -1923,58 +1841,6 @@ type TurnResult struct {
// any tool call/result messages added during the agent loop.
// Each message carries role and plain-text content.
Messages []LLMMessage
// FinalValue is set when a tool returned a [ToolOutput] with Halt=true
// during the turn. The dynamic type is whatever the tool handler placed
// in [ToolOutput.FinalValue]. Nil when no tool halted the turn.
FinalValue any
// HaltedByTool is the name of the tool that returned Halt=true, or empty
// if the turn ended for any other reason.
HaltedByTool string
// Stream contains every delta event observed during the turn in emit
// order. It is populated regardless of streaming mode (in non-streaming
// mode it carries the coarse-grained events the provider reported).
// PromptResult and the other turn-returning entry points always block
// until end-of-turn, so Stream is complete when they return.
Stream []StreamEvent
}
// StreamEventKind classifies a [StreamEvent] captured during a turn.
type StreamEventKind string
// Stream event kinds captured in [TurnResult.Stream].
const (
StreamEventTextDelta StreamEventKind = "text_delta"
StreamEventReasoningStart StreamEventKind = "reasoning_start"
StreamEventReasoningDelta StreamEventKind = "reasoning_delta"
StreamEventReasoningEnd StreamEventKind = "reasoning_end"
StreamEventToolCallChunk StreamEventKind = "tool_call_chunk"
)
// StreamEvent is a single delta observed during a turn, captured in
// [TurnResult.Stream]. It lets embedders assert streamed ordering
// deterministically without re-implementing an OnMessageUpdate collector.
type StreamEvent struct {
// Kind classifies the event.
Kind StreamEventKind
// Text carries the assistant text for StreamEventTextDelta.
Text string
// Reasoning carries the reasoning text for StreamEventReasoningDelta.
Reasoning string
// ToolName is the tool name for StreamEventToolCallChunk.
ToolName string
// ToolID is the tool call ID for StreamEventToolCallChunk.
ToolID string
// Args carries the (accumulating) tool-call argument JSON for
// StreamEventToolCallChunk.
Args string
}
// ---------------------------------------------------------------------------
@@ -2215,9 +2081,6 @@ func (m *Kit) Subagent(ctx context.Context, cfg SubagentConfig) (*SubagentResult
// All prompt modes (Prompt, Steer, FollowUp, PromptWithOptions) share this
// single code path so callback wiring is never duplicated.
func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.GenerateWithLoopResult, error) {
// Capture the per-turn stream collector (set by runTurn) so streamed
// deltas are recorded into TurnResult.Stream in emit order.
collector := streamCollectorFromContext(ctx)
// Create a per-turn steer channel and attach it to the context so the
// agent's PrepareStep can inject steering messages between steps.
steerCh := make(chan agent.SteerMessage, 16)
@@ -2335,30 +2198,24 @@ func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.
i := strings.Index(remaining, thinkClose)
if i == -1 {
m.events.emit(ReasoningDeltaEvent{Delta: remaining})
collector.add(StreamEvent{Kind: StreamEventReasoningDelta, Reasoning: remaining})
return
}
if i > 0 {
m.events.emit(ReasoningDeltaEvent{Delta: remaining[:i]})
collector.add(StreamEvent{Kind: StreamEventReasoningDelta, Reasoning: remaining[:i]})
}
inThinkTag = false
m.events.emit(ReasoningCompleteEvent{})
collector.add(StreamEvent{Kind: StreamEventReasoningEnd})
remaining = remaining[i+len(thinkClose):]
} else {
i := strings.Index(remaining, thinkOpen)
if i == -1 {
m.events.emit(MessageUpdateEvent{Chunk: remaining})
collector.add(StreamEvent{Kind: StreamEventTextDelta, Text: remaining})
return
}
if i > 0 {
m.events.emit(MessageUpdateEvent{Chunk: remaining[:i]})
collector.add(StreamEvent{Kind: StreamEventTextDelta, Text: remaining[:i]})
}
inThinkTag = true
collector.add(StreamEvent{Kind: StreamEventReasoningStart})
remaining = remaining[i+len(thinkOpen):]
}
}
@@ -2366,11 +2223,9 @@ func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.
}(),
OnReasoningDelta: func(delta string) {
m.events.emit(ReasoningDeltaEvent{Delta: delta})
collector.add(StreamEvent{Kind: StreamEventReasoningDelta, Reasoning: delta})
},
OnReasoningComplete: func() {
m.events.emit(ReasoningCompleteEvent{})
collector.add(StreamEvent{Kind: StreamEventReasoningEnd})
},
OnToolOutput: func(toolCallID, toolName, chunk string, isStderr bool) {
m.events.emit(ToolOutputEvent{
@@ -2417,14 +2272,12 @@ func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.
ToolName: toolName,
ToolKind: toolKindFor(toolName),
})
collector.add(StreamEvent{Kind: StreamEventToolCallChunk, ToolID: toolCallID, ToolName: toolName})
},
OnToolCallDelta: func(toolCallID, delta string) {
m.events.emit(ToolCallDeltaEvent{
ToolCallID: toolCallID,
Delta: delta,
})
collector.add(StreamEvent{Kind: StreamEventToolCallChunk, ToolID: toolCallID, Args: delta})
},
OnToolCallEnd: func(toolCallID string) {
m.events.emit(ToolCallEndEvent{
@@ -2576,14 +2429,6 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr
sentCount := len(messages)
// Attach a per-turn stream collector and halt holder so generate's
// callbacks can capture delta events (TurnResult.Stream) and tools can
// signal loop termination (TurnResult.FinalValue / HaltedByTool).
collector := &streamCollector{}
holder := &haltHolder{}
ctx = context.WithValue(ctx, streamCollectorKey{}, collector)
ctx = context.WithValue(ctx, haltHolderKey{}, holder)
m.events.emit(TurnStartEvent{Prompt: promptLabel})
m.events.emit(MessageStartEvent{})
@@ -2606,7 +2451,7 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr
m.events.emit(TurnEndEvent{Error: err})
// Run AfterTurn hooks even on error.
m.afterTurn.run(AfterTurnHook{Error: err})
return nil, ClassifyProviderError(err)
return nil, err
}
responseText := result.FinalResponse.Content.Text()
@@ -2659,13 +2504,6 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr
turnResult.FinalUsage = &finalUsage
}
// Surface captured stream deltas and any tool-driven halt signal.
turnResult.Stream = collector.drain()
if halted, toolName, value := holder.snapshot(); halted {
turnResult.HaltedByTool = toolName
turnResult.FinalValue = value
}
return turnResult, nil
}
@@ -2807,158 +2645,28 @@ type PromptOptions struct {
// Use it to inject per-call instructions or context without permanently
// modifying the agent's system prompt.
SystemMessage string
// Model overrides the agent's configured model for this call only. Empty
// string means "use the agent's default". The previous model is restored
// after the call returns.
Model string
// ThinkingLevel overrides the agent's reasoning level for this call only
// (e.g. "off", "low", "medium", "high"). Empty string means "use the
// agent's default". The previous level is restored after the call.
ThinkingLevel string
// ExtraTools are added to the effective tool set for this call only and
// removed afterwards.
ExtraTools []Tool
// ProviderURL overrides the provider base URL for this call only. Useful
// for multi-tenant embedders that resolve endpoints per request. The
// previous value is restored after the call.
ProviderURL string
// ProviderAPIKey overrides the provider credential for this call only.
// The previous value is restored after the call.
ProviderAPIKey string
}
// applyPromptOptions applies the per-call overrides in opts to the shared
// agent state and returns a restore function that reverts every change. It
// holds promptOptsMu for the lifetime of the override window (the returned
// restore releases it), so concurrent option-driven prompts are serialized.
// On error nothing is changed and the lock is released.
func (m *Kit) applyPromptOptions(ctx context.Context, opts PromptOptions) (func(), error) {
needsModelRebuild := opts.Model != "" || opts.ThinkingLevel != "" ||
opts.ProviderURL != "" || opts.ProviderAPIKey != ""
if !needsModelRebuild && len(opts.ExtraTools) == 0 {
return func() {}, nil
}
m.promptOptsMu.Lock()
var restores []func()
restore := func() {
for i := len(restores) - 1; i >= 0; i-- {
restores[i]()
}
m.promptOptsMu.Unlock()
}
// Extra tools (additive) — restored by re-setting the prior slice.
if len(opts.ExtraTools) > 0 {
prev := m.agent.GetExtraTools()
merged := make([]Tool, 0, len(prev)+len(opts.ExtraTools))
merged = append(merged, prev...)
merged = append(merged, opts.ExtraTools...)
m.agent.SetExtraTools(merged)
restores = append(restores, func() { m.agent.SetExtraTools(prev) })
}
if needsModelRebuild {
prevModel := m.modelString
prevThinkingSet := m.v.IsSet("thinking-level")
prevThinking := m.v.GetString("thinking-level")
prevURLSet := m.v.IsSet("provider-url")
prevURL := m.v.GetString("provider-url")
prevKeySet := m.v.IsSet("provider-api-key")
prevKey := m.v.GetString("provider-api-key")
if opts.ThinkingLevel != "" {
m.v.Set("thinking-level", opts.ThinkingLevel)
}
if opts.ProviderURL != "" {
m.v.Set("provider-url", opts.ProviderURL)
}
if opts.ProviderAPIKey != "" {
m.v.Set("provider-api-key", opts.ProviderAPIKey)
}
targetModel := opts.Model
if targetModel == "" {
targetModel = prevModel
}
if err := m.SetModel(ctx, targetModel); err != nil {
// Revert config keys we may have set, then unwind prior restores.
restoreViperString(m.v, "thinking-level", prevThinking, prevThinkingSet)
restoreViperString(m.v, "provider-url", prevURL, prevURLSet)
restoreViperString(m.v, "provider-api-key", prevKey, prevKeySet)
restore()
return nil, err
}
restores = append(restores, func() {
restoreViperString(m.v, "thinking-level", prevThinking, prevThinkingSet)
restoreViperString(m.v, "provider-url", prevURL, prevURLSet)
restoreViperString(m.v, "provider-api-key", prevKey, prevKeySet)
// Use a fresh context: the rollback must complete even if the
// caller's ctx was canceled or expired during the call, otherwise
// the per-call model override would leak into subsequent calls.
_ = m.SetModel(context.Background(), prevModel)
})
}
return restore, nil
}
// restoreViperString restores a config key to its prior value, clearing it
// back to the unset state when it was not explicitly set before.
func restoreViperString(v *viper.Viper, key, prev string, wasSet bool) {
if wasSet {
v.Set(key, prev)
return
}
v.Set(key, "")
}
// PromptWithOptions sends a message with per-call configuration. It behaves
// like Prompt but applies the overrides in opts (system message, model,
// thinking level, provider credentials, extra tools) for this call only,
// restoring the agent's prior state afterwards.
// like Prompt but allows injecting an additional system message before the
// user prompt. Both messages are persisted to the session.
func (m *Kit) PromptWithOptions(ctx context.Context, msg string, opts PromptOptions) (string, error) {
result, err := m.PromptResultWithOptions(ctx, msg, opts)
if err != nil {
return "", err
}
return result.Response, nil
}
// PromptResultWithOptions is the [TurnResult]-returning counterpart of
// PromptWithOptions. Like all turn-returning entry points it blocks until
// end-of-turn, so the returned TurnResult (including TurnResult.Stream) is
// complete when it returns. Per-call overrides in opts are applied for this
// call only and the agent's prior state is restored before returning.
func (m *Kit) PromptResultWithOptions(ctx context.Context, msg string, opts PromptOptions) (*TurnResult, error) {
restore, err := m.applyPromptOptions(ctx, opts)
if err != nil {
return nil, err
}
defer restore()
var preMessages []fantasy.Message
if opts.SystemMessage != "" {
preMessages = append(preMessages, fantasy.NewSystemMessage(opts.SystemMessage))
}
preMessages = append(preMessages, fantasy.NewUserMessage(msg))
return m.runTurn(ctx, msg, msg, preMessages)
result, err := m.runTurn(ctx, msg, msg, preMessages)
if err != nil {
return "", err
}
return result.Response, nil
}
// PromptResult sends a message and returns the full turn result including
// usage statistics and conversation messages. Use this instead of Prompt()
// when you need more than just the response text.
//
// PromptResult blocks until end-of-turn regardless of whether streaming is
// enabled. When streaming is enabled, every delta observed during the turn is
// also captured in order in [TurnResult.Stream], so callers can assert
// streamed ordering deterministically without an OnMessageUpdate collector.
func (m *Kit) PromptResult(ctx context.Context, message string) (*TurnResult, error) {
return m.runTurn(ctx, message, message, []fantasy.Message{
fantasy.NewUserMessage(message),
@@ -3096,18 +2804,7 @@ func extractFileParts(msg fantasy.Message) []fantasy.FilePart {
// Close cleans up resources including MCP server connections, model resources,
// and the tree session file handle. Should be called when the Kit instance is
// no longer needed. Returns an error if cleanup fails.
//
// Close is equivalent to CloseContext(context.Background()). Use
// [Kit.CloseContext] when shutdown must be bounded by a deadline.
func (m *Kit) Close() error {
return m.CloseContext(context.Background())
}
// CloseContext is like [Kit.Close] but accepts a context so graceful shutdown
// can be bounded by a deadline or cancellation. The context is honored on a
// best-effort basis: if it is already done when CloseContext is called, the
// context error is returned after a best-effort cleanup pass.
func (m *Kit) CloseContext(ctx context.Context) error {
// Emit SessionShutdown for extensions.
if m.extRunner != nil && m.extRunner.HasHandlers(extensions.SessionShutdown) {
_, _ = m.extRunner.Emit(extensions.SessionShutdownEvent{})
@@ -3119,11 +2816,7 @@ func (m *Kit) CloseContext(ctx context.Context) error {
if closer, ok := m.authHandler.(interface{ Close() error }); ok {
_ = closer.Close()
}
err := m.agent.Close()
if ctxErr := ctx.Err(); ctxErr != nil && err == nil {
return ctxErr
}
return err
return m.agent.Close()
}
// Conversion helpers are defined in adapter.go.
+33 -5
View File
@@ -102,11 +102,10 @@ type MCPTaskProgressHandler func(MCPTaskProgress)
// are optional; the zero value disables progress callbacks and applies
// sensible polling defaults inside the engine.
//
// Most consumers configure these via the flat [Options] fields
// (`MCPTaskMode`, `MCPTaskTTL`, `MCPTaskPollInterval`,
// `MCPTaskMaxPollInterval`, `MCPTaskTimeout`, `MCPTaskProgress`). The
// MCPTaskConfig type itself is retained for downstream consumers that
// receive it on engine-facing call sites.
// For most consumers, the flat [Options] fields (`MCPTaskMode`,
// `MCPTaskTTL`, `MCPTaskPollInterval`, `MCPTaskMaxPollInterval`,
// `MCPTaskTimeout`, `MCPTaskProgress`) are the preferred entry point.
// MCPTaskConfig is exposed for the low-level [AgentConfig] path.
type MCPTaskConfig struct {
// PerServerMode overrides the per-server task mode resolved from
// [MCPServerConfig]. Keys are server names. Missing entries fall back
@@ -134,6 +133,35 @@ type MCPTaskConfig struct {
Progress MCPTaskProgressHandler
}
// toToolsConfig converts the SDK-level [MCPTaskConfig] to the internal
// tools-package representation. Keeps the dependency arrow internal-only.
func (c MCPTaskConfig) toToolsConfig() tools.MCPTaskConfig {
cfg := tools.MCPTaskConfig{
DefaultTTL: c.DefaultTTL,
PollInterval: c.PollInterval,
MaxPollInterval: c.MaxPollInterval,
Timeout: c.Timeout,
}
if len(c.PerServerMode) > 0 {
cfg.PerServerMode = make(map[string]tools.MCPTaskMode, len(c.PerServerMode))
for k, v := range c.PerServerMode {
cfg.PerServerMode[k] = tools.MCPTaskMode(v)
}
}
if c.Progress != nil {
h := c.Progress
cfg.Progress = func(p tools.MCPTaskProgress) {
h(MCPTaskProgress{
Server: p.Server,
TaskID: p.TaskID,
Status: MCPTaskStatus(p.Status),
Message: p.Message,
})
}
}
return cfg
}
// mcpTaskOptions carries SDK consumer configuration into the agent setup.
// Stored on Options as a single value so the public surface stays compact;
// individual fields are exposed via WithMCP* builder functions.
-102
View File
@@ -1,102 +0,0 @@
package kit_test
import (
"context"
"testing"
"charm.land/fantasy"
"github.com/mark3labs/kit/pkg/kit"
)
func TestNewRawTool(t *testing.T) {
schema := map[string]any{
"type": "object",
"properties": map[string]any{
"city": map[string]any{"type": "string", "description": "City name"},
},
"required": []any{"city"},
}
var gotArgs map[string]any
tool := kit.NewRawTool("get_weather", "Get weather", schema,
func(ctx context.Context, args map[string]any) (kit.ToolOutput, error) {
gotArgs = args
return kit.TextResult("72F in " + args["city"].(string)), nil
},
)
info := tool.Info()
if info.Name != "get_weather" {
t.Fatalf("name = %q", info.Name)
}
if info.Parameters["type"] != "object" {
t.Fatalf("schema not propagated: %#v", info.Parameters)
}
if len(info.Required) != 1 || info.Required[0] != "city" {
t.Fatalf("required not propagated: %#v", info.Required)
}
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call_1",
Input: `{"city":"Boston"}`,
})
if err != nil {
t.Fatalf("Run error: %v", err)
}
if resp.IsError {
t.Fatalf("unexpected error response: %q", resp.Content)
}
if resp.Content != "72F in Boston" {
t.Fatalf("content = %q", resp.Content)
}
if gotArgs["city"] != "Boston" {
t.Fatalf("args not decoded: %#v", gotArgs)
}
}
func TestNewRawToolInvalidArgs(t *testing.T) {
tool := kit.NewRawTool("t", "d", nil,
func(ctx context.Context, args map[string]any) (kit.ToolOutput, error) {
t.Fatal("handler should not be called for invalid args")
return kit.ToolOutput{}, nil
},
)
resp, err := tool.Run(context.Background(), fantasy.ToolCall{ID: "x", Input: `not json`})
if err != nil {
t.Fatalf("Run error: %v", err)
}
if !resp.IsError {
t.Fatalf("expected error response for invalid args")
}
}
// Contract: null / whitespace-padded-null inputs must hand the handler a
// non-nil empty map (not a nil map), so handlers can read or write keys
// without a nil-map panic. Inputs are normalised before reaching the handler.
func TestNewRawToolNullArgs(t *testing.T) {
for _, input := range []string{"null", " null ", "\tnull\n"} {
called := false
var gotNil bool
tool := kit.NewRawTool("t", "d", nil,
func(ctx context.Context, args map[string]any) (kit.ToolOutput, error) {
called = true
gotNil = args == nil
return kit.TextResult("ok"), nil
},
)
resp, err := tool.Run(context.Background(), fantasy.ToolCall{ID: "x", Input: input})
if err != nil {
t.Fatalf("input %q: Run error: %v", input, err)
}
if resp.IsError {
t.Fatalf("input %q: unexpected error response: %q", input, resp.Content)
}
if !called {
t.Fatalf("input %q: handler not called", input)
}
if gotNil {
t.Fatalf("input %q: args was nil, want non-nil empty map", input)
}
}
}
-11
View File
@@ -1,14 +1,9 @@
package kit
import (
"errors"
"time"
)
// ErrBranchSummaryNotSupported is returned by SessionManager implementations
// that do not support collapsing a branch range into a summary entry.
var ErrBranchSummaryNotSupported = errors.New("session manager does not support branch summaries")
// SessionManager defines the contract for conversation storage backends.
// Implementations can use files (default), databases, cloud storage, etc.
//
@@ -94,12 +89,6 @@ type SessionManager interface {
// determine which entries to summarize.
GetContextEntryIDs() []string
// AppendBranchSummary collapses the range from fromID to the current leaf
// on the active branch into a single summary entry and returns the new
// entry ID. It backs [Kit.CollapseBranch]. Managers that do not track
// branch summaries should return [ErrBranchSummaryNotSupported].
AppendBranchSummary(fromID, summary string) (entryID string, err error)
// Close releases resources (database connections, file handles, etc.).
Close() error
}
+9 -9
View File
@@ -217,19 +217,19 @@ func (m *Kit) SummarizeBranch(fromID, toID string) (string, error) {
// CollapseBranch replaces a branch range with a summary entry.
// Returns an error if the session is unavailable or the operation fails.
// Custom SessionManagers that do not support branch summaries surface
// [ErrBranchSummaryNotSupported].
//
// The branch is always collapsed from fromID to the current leaf; the toID
// parameter is currently unused (the underlying AppendBranchSummary primitive
// only supports collapsing to the leaf) and is retained for forward
// compatibility.
func (m *Kit) CollapseBranch(fromID, toID, summary string) error {
if m.session == nil {
return fmt.Errorf("no session available")
}
_, err := m.session.AppendBranchSummary(fromID, summary)
return err
// Note: This operation is not directly supported by SessionManager interface
// as it requires AppendBranchSummary which is TreeManager-specific.
// For custom SessionManagers, this would need to be implemented differently.
// For now, we try to use the underlying TreeManager if available.
if adapter, ok := m.session.(*treeManagerAdapter); ok {
_, err := adapter.inner.AppendBranchSummary(fromID, summary)
return err
}
return fmt.Errorf("CollapseBranch not supported by custom session manager")
}
// branchEntryToTreeNode converts a BranchEntry to a TreeNode.
+10 -137
View File
@@ -2,12 +2,10 @@ package kit
import (
"fmt"
"io/fs"
"os"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/internal/skills"
"github.com/mark3labs/kit/internal/trust"
)
// ==== Skills Types ====
@@ -38,28 +36,12 @@ func LoadSkillsFromDir(dir string) ([]*Skill, error) {
return skills.LoadSkillsFromDir(dir)
}
// LoadSkillsFromFS is the [fs.FS]-typed counterpart of [LoadSkillsFromDir].
// It walks fsys starting at root (which may be "." or a subdirectory), finds
// *.md/*.txt files and SKILL.md files in subdirectories, parses YAML
// frontmatter + markdown body, and returns the loaded skills. Use it when
// skill discovery is wrapped in an fs.FS abstraction (embed.FS distribution,
// fstest.MapFS tests, or per-tenant virtual filesystems).
//
// Each loaded skill's Path is its slash-separated path within fsys, since
// fs.FS has no notion of an absolute on-disk path.
func LoadSkillsFromFS(fsys fs.FS, root string) ([]*Skill, error) {
return skills.LoadSkillsFromFS(fsys, root)
}
// LoadSkills auto-discovers skills from standard directories:
// - User-level: ~/.agents/skills/ (cross-client convention)
// - User-level: $XDG_CONFIG_HOME/kit/skills/ (default ~/.config/kit/skills/)
// - Project-local: <cwd>/.agents/skills/ (cross-client convention)
// - Project-local: <cwd>/.kit/skills/ (Kit-specific)
// - Global: $XDG_CONFIG_HOME/kit/skills/ (default ~/.config/kit/skills/)
// - Project-local: <cwd>/.kit/skills/
//
// Project-level skills take precedence over user-level skills with the same
// name. cwd is the working directory for project-local discovery; if empty
// the current working directory is used.
// cwd is the working directory for project-local discovery; if empty the
// current working directory is used.
func LoadSkills(cwd string) ([]*Skill, error) {
return skills.LoadSkills(cwd)
}
@@ -131,17 +113,12 @@ func (m *Kit) LoadSkillsFromDirForExtension(dir string) extensions.SkillLoadResu
// convertSkill converts internal skill to extension-facing format.
func (m *Kit) convertSkill(s *skills.Skill) *extensions.Skill {
return &extensions.Skill{
Name: s.Name,
Description: s.Description,
Content: s.Content,
Path: s.Path,
License: s.License,
Compatibility: s.Compatibility,
Metadata: s.Metadata,
AllowedTools: s.AllowedTools,
DisableModelInvocation: s.DisableModelInvocation,
Tags: s.Tags,
When: s.When,
Name: s.Name,
Description: s.Description,
Content: s.Content,
Path: s.Path,
Tags: s.Tags,
When: s.When,
}
}
@@ -309,107 +286,3 @@ func (m *Kit) applyComposedSystemPrompt() {
func (m *Kit) RefreshSystemPrompt() {
m.applyComposedSystemPrompt()
}
// ---------------------------------------------------------------------------
// Per-skill disable (Issue #65, gap #10)
// ---------------------------------------------------------------------------
// applySkillDisableList sets DisableModelInvocation on every skill whose Name
// appears in names. Disabled skills remain loaded (so explicit /skill:
// activation still works) but are hidden from the model-facing catalog.
func applySkillDisableList(skillList []*skills.Skill, names []string) {
if len(names) == 0 {
return
}
disabled := make(map[string]bool, len(names))
for _, n := range names {
disabled[n] = true
}
for _, s := range skillList {
if disabled[s.Name] {
s.DisableModelInvocation = true
}
}
}
// DisableSkill hides the named skill from the model-facing catalog while
// keeping it loaded (so it can still be activated explicitly via /skill:).
// The system prompt is recomposed and applied. Returns true when a skill with
// that name was found.
func (m *Kit) DisableSkill(name string) bool {
return m.setSkillModelInvocation(name, true)
}
// EnableSkill re-exposes a previously disabled skill in the model-facing
// catalog. The system prompt is recomposed and applied. Returns true when a
// skill with that name was found.
func (m *Kit) EnableSkill(name string) bool {
return m.setSkillModelInvocation(name, false)
}
// setSkillModelInvocation toggles DisableModelInvocation on the named skill
// and refreshes the system prompt. Returns true when the skill was found.
func (m *Kit) setSkillModelInvocation(name string, disabled bool) bool {
m.runtimeMu.Lock()
found := false
for _, s := range m.skills {
if s.Name == name {
s.DisableModelInvocation = disabled
found = true
break
}
}
m.runtimeMu.Unlock()
if !found {
return false
}
m.ClearSkillCache()
m.applyComposedSystemPrompt()
return true
}
// ---------------------------------------------------------------------------
// Project-skill trust gate (Issue #65, gap #8)
// ---------------------------------------------------------------------------
// TrustDecision is the outcome of a project-skill trust prompt.
type TrustDecision = trust.Decision
// Trust-prompt outcomes. They mirror the trust package decisions.
const (
// SkipProjectSkills declines to load project skills this session.
SkipProjectSkills = trust.Skip
// TrustProject loads project skills and persists the directory as trusted.
TrustProject = trust.Trust
// TrustProjectOnce loads project skills this session without persisting.
TrustProjectOnce = trust.TrustOnce
)
// projectSkillsTrusted decides whether project-local skills discovered in dir
// should be loaded. When no SkillTrustPrompt is configured the directory is
// trusted by default (preserving historical behaviour). Otherwise a persisted
// allowlist is consulted first, then the prompt is invoked for an unknown
// directory and the decision is persisted when the user chooses TrustProject.
func projectSkillsTrusted(opts *Options, dir string, count int) bool {
if opts.SkillTrustPrompt == nil {
return true
}
store, err := trust.Load("")
if err == nil && store.IsTrusted(dir) {
return true
}
switch opts.SkillTrustPrompt(dir, count) {
case TrustProject:
if store != nil {
_ = store.Trust(dir)
}
return true
case TrustProjectOnce:
return true
default:
return false
}
}
-120
View File
@@ -1,120 +0,0 @@
package kit
import (
"os"
"path/filepath"
"testing"
)
func writeSkillFile(t *testing.T, path, name, desc string) {
t.Helper()
content := "---\nname: " + name + "\ndescription: " + desc + "\n---\nBody."
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
// TestLoadSkills_SkillsDirIsDirect verifies --skills-dir scans the directory
// directly rather than appending .agents/.kit beneath it (issue #65, gap #3).
func TestLoadSkills_SkillsDirIsDirect(t *testing.T) {
dir := t.TempDir()
writeSkillFile(t, filepath.Join(dir, "direct.md"), "direct", "A direct skill")
got, err := loadSkills(&Options{SkillsDir: dir})
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Name != "direct" {
t.Fatalf("expected 1 skill named 'direct', got %+v", got)
}
}
// TestApplySkillDisableList verifies the disable list hides a skill from the
// catalog (issue #65, gap #10).
func TestApplySkillDisableList(t *testing.T) {
dir := t.TempDir()
writeSkillFile(t, filepath.Join(dir, "a.md"), "a", "skill a")
writeSkillFile(t, filepath.Join(dir, "b.md"), "b", "skill b")
got, err := loadSkills(&Options{SkillsDir: dir})
if err != nil {
t.Fatal(err)
}
applySkillDisableList(got, []string{"a"})
var aDisabled, bDisabled bool
for _, s := range got {
switch s.Name {
case "a":
aDisabled = s.DisableModelInvocation
case "b":
bDisabled = s.DisableModelInvocation
}
}
if !aDisabled {
t.Error("skill 'a' should be disabled")
}
if bDisabled {
t.Error("skill 'b' should not be disabled")
}
}
// TestProjectSkillsTrust verifies the trust gate drops untrusted project
// skills and honours a Trust decision (issue #65, gap #8).
func TestProjectSkillsTrust(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", filepath.Join(dir, "xdg"))
t.Setenv("HOME", filepath.Join(dir, "home"))
projectDir := filepath.Join(dir, "repo")
skillsDir := filepath.Join(projectDir, ".agents", "skills")
if err := os.MkdirAll(skillsDir, 0o755); err != nil {
t.Fatal(err)
}
writeSkillFile(t, filepath.Join(skillsDir, "proj.md"), "proj", "project skill")
// Skip decision → no project skills loaded.
skipped, err := loadSkills(&Options{
SessionDir: projectDir,
SkillTrustPrompt: func(_ string, _ int) TrustDecision {
return SkipProjectSkills
},
})
if err != nil {
t.Fatal(err)
}
if len(skipped) != 0 {
t.Fatalf("expected 0 skills when trust is skipped, got %d", len(skipped))
}
// Trust decision → project skills loaded and directory persisted.
prompted := 0
trusted, err := loadSkills(&Options{
SessionDir: projectDir,
SkillTrustPrompt: func(_ string, _ int) TrustDecision {
prompted++
return TrustProject
},
})
if err != nil {
t.Fatal(err)
}
if len(trusted) != 1 {
t.Fatalf("expected 1 skill when trusted, got %d", len(trusted))
}
// A subsequent load should not prompt again (persisted trust).
again, err := loadSkills(&Options{
SessionDir: projectDir,
SkillTrustPrompt: func(_ string, _ int) TrustDecision {
t.Error("should not prompt for an already-trusted directory")
return SkipProjectSkills
},
})
if err != nil {
t.Fatal(err)
}
if len(again) != 1 {
t.Fatalf("expected 1 skill on trusted reload, got %d", len(again))
}
}
-15
View File
@@ -1,15 +0,0 @@
//go:build testing
package kit
import "github.com/mark3labs/kit/internal/config"
// ResetForTesting clears package-global state that survives across tests in
// the same binary. It is intended for test-binary teardown / between-test
// cleanup. Safe to call concurrently with no in-flight kit.New() calls.
//
// This function is only compiled under the "testing" build tag so it never
// ships in production binaries.
func ResetForTesting() {
config.SetConfigPath("")
}
-155
View File
@@ -1,12 +1,8 @@
package kit
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"charm.land/fantasy"
@@ -44,20 +40,6 @@ type ToolOutput struct {
// Metadata is optional opaque metadata attached to the response.
// It is not sent to the LLM but may be consumed by hooks or the UI.
Metadata any
// FinalValue, when Halt is true, is propagated to the turn's
// [TurnResult.FinalValue] so the caller can recover a typed result
// produced by the tool (e.g. a structured "finish" tool). The dynamic
// type is whatever the tool handler stored. Ignored when Halt is false.
FinalValue any
// Halt, when true, signals that the agent loop should terminate after
// this tool call. Content is still returned to the model for the current
// step, but [TurnResult.FinalValue] and [TurnResult.HaltedByTool] are
// populated so embedders building structured-result extraction patterns
// (model calls a finish(...) tool, the loop ends, the typed value is
// returned) no longer need a side-channel.
Halt bool
}
// TextResult creates a successful text [ToolOutput].
@@ -90,49 +72,6 @@ func MediaResult(content string, data []byte, mediaType string) ToolOutput {
// toolCallIDKey is the context key for the tool call ID.
type toolCallIDKey struct{}
// haltHolderKey is the context key for the per-turn halt holder. It is
// injected by runTurn so tool handlers created with [NewTool],
// [NewParallelTool], and [NewRawTool] can signal loop termination and carry a
// final value out to the [TurnResult] without an embedder-side side-channel.
type haltHolderKey struct{}
// haltHolder captures a Halt signal raised by a tool handler during a turn.
type haltHolder struct {
mu sync.Mutex
halted bool
toolName string
value any
}
func (h *haltHolder) set(toolName string, value any) {
h.mu.Lock()
defer h.mu.Unlock()
// First halt wins so the earliest finishing tool determines the result.
if h.halted {
return
}
h.halted = true
h.toolName = toolName
h.value = value
}
func (h *haltHolder) snapshot() (bool, string, any) {
h.mu.Lock()
defer h.mu.Unlock()
return h.halted, h.toolName, h.value
}
// recordHalt records a Halt signal from a tool handler onto the per-turn halt
// holder, if one is present in the context.
func recordHalt(ctx context.Context, toolName string, result ToolOutput) {
if !result.Halt {
return
}
if holder, ok := ctx.Value(haltHolderKey{}).(*haltHolder); ok && holder != nil {
holder.set(toolName, result.FinalValue)
}
}
// ToolCallIDFromContext extracts the tool call ID from the context.
// The call ID is set automatically by [NewTool] and [NewParallelTool]
// before invoking the handler. Returns an empty string if no ID is present.
@@ -205,7 +144,6 @@ func NewTool[TInput any](name, description string, fn func(ctx context.Context,
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
recordHalt(ctx, name, result)
return toolOutputToResponse(result), nil
},
)
@@ -222,104 +160,11 @@ func NewParallelTool[TInput any](name, description string, fn func(ctx context.C
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
recordHalt(ctx, name, result)
return toolOutputToResponse(result), nil
},
)
}
// rawToolInput is the decoded carrier used by [NewRawTool]. Using
// json.RawMessage lets the typed-tool machinery in fantasy generate a
// permissive object schema while we forward the raw arguments to the handler
// as a decoded map.
type rawToolInput = json.RawMessage
// NewRawTool is the schema-driven counterpart to [NewTool]. Use it when the
// tool's input shape isn't known at compile time — for example tools loaded
// from JSON Schema definitions in skill files or MCP server catalogs.
//
// schema must be a valid JSON Schema describing the tool's input object; it is
// advertised to the model as the tool's parameter schema. fn receives the
// decoded JSON arguments as a map and returns a [ToolOutput]. Like [NewTool],
// the tool call ID is injected into the context and can be retrieved with
// [ToolCallIDFromContext], and [ToolOutput.Halt] is honored.
//
// If the model sends arguments that are not a valid JSON object the call
// short-circuits with an error [ToolResponse] before fn is invoked.
func NewRawTool(
name, description string,
schema map[string]any,
fn func(ctx context.Context, args map[string]any) (ToolOutput, error),
) Tool {
tool := fantasy.NewAgentTool(name, description,
func(ctx context.Context, input rawToolInput, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
ctx = context.WithValue(ctx, toolCallIDKey{}, call.ID)
args := map[string]any{}
// Normalise whitespace before the null/empty guard so values like
// " null " or "\tnull\n" take the same skip-unmarshal path as the
// bare "null" and the handler always receives a non-nil empty map.
// (fantasy currently trims via its RawMessage decode, but this keeps
// the guard correct independent of that upstream behaviour.)
trimmed := bytes.TrimSpace(input)
if len(trimmed) > 0 && !bytes.Equal(trimmed, []byte("null")) {
if err := json.Unmarshal(trimmed, &args); err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("invalid arguments for tool %q: %v", name, err)), nil
}
}
result, err := fn(ctx, args)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
recordHalt(ctx, name, result)
return toolOutputToResponse(result), nil
},
)
// Override the auto-generated schema with the caller-supplied one so the
// model sees the real input shape instead of the permissive raw-message
// schema.
if len(schema) > 0 {
info := tool.Info()
info.Parameters = schema
info.Required = requiredFromSchema(schema)
tool = &schemaOverrideTool{AgentTool: tool, info: info}
}
return tool
}
// schemaOverrideTool wraps an [fantasy.AgentTool] to advertise a
// caller-supplied JSON Schema instead of the auto-generated one. Used by
// [NewRawTool].
type schemaOverrideTool struct {
fantasy.AgentTool
info fantasy.ToolInfo
}
// Info returns the tool info carrying the overridden parameter schema.
func (t *schemaOverrideTool) Info() fantasy.ToolInfo { return t.info }
// requiredFromSchema extracts the top-level "required" array from a JSON
// Schema object, tolerating both []string and []any element types.
func requiredFromSchema(schema map[string]any) []string {
raw, ok := schema["required"]
if !ok {
return nil
}
switch v := raw.(type) {
case []string:
return v
case []any:
out := make([]string, 0, len(v))
for _, e := range v {
if s, ok := e.(string); ok {
out = append(out, s)
}
}
return out
default:
return nil
}
}
// --- Individual tool constructors ---
// NewReadTool creates a file-reading tool.
-48
View File
@@ -1,48 +0,0 @@
package kit
import (
"context"
"sync"
)
// streamCollectorKey is the context key carrying the per-turn stream collector
// so the agent callbacks in generate can capture delta events into
// TurnResult.Stream without re-implementing an OnMessageUpdate handler.
type streamCollectorKey struct{}
// streamCollector accumulates StreamEvents observed during a single turn in
// emit order. It is safe for concurrent use because tool-call deltas and text
// deltas may be emitted from different goroutines.
type streamCollector struct {
mu sync.Mutex
events []StreamEvent
}
func (c *streamCollector) add(e StreamEvent) {
if c == nil {
return
}
c.mu.Lock()
c.events = append(c.events, e)
c.mu.Unlock()
}
func (c *streamCollector) drain() []StreamEvent {
if c == nil {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
if len(c.events) == 0 {
return nil
}
out := make([]StreamEvent, len(c.events))
copy(out, c.events)
return out
}
// streamCollectorFromContext returns the per-turn stream collector if present.
func streamCollectorFromContext(ctx context.Context) *streamCollector {
c, _ := ctx.Value(streamCollectorKey{}).(*streamCollector)
return c
}
-86
View File
@@ -1,86 +0,0 @@
package kit
import (
"context"
"testing"
)
func TestHaltHolderFirstWins(t *testing.T) {
h := &haltHolder{}
if halted, _, _ := h.snapshot(); halted {
t.Fatal("new holder should not be halted")
}
h.set("finish", 42)
h.set("other", 99) // ignored — first halt wins
halted, name, val := h.snapshot()
if !halted {
t.Fatal("holder should be halted")
}
if name != "finish" {
t.Fatalf("toolName = %q, want finish", name)
}
if v, ok := val.(int); !ok || v != 42 {
t.Fatalf("value = %#v, want 42", val)
}
}
func TestRecordHalt(t *testing.T) {
holder := &haltHolder{}
ctx := context.WithValue(context.Background(), haltHolderKey{}, holder)
// Non-halting output records nothing.
recordHalt(ctx, "noop", ToolOutput{Content: "ok"})
if halted, _, _ := holder.snapshot(); halted {
t.Fatal("non-halting output should not halt")
}
recordHalt(ctx, "finish", ToolOutput{Halt: true, FinalValue: "done"})
halted, name, val := holder.snapshot()
if !halted || name != "finish" || val != "done" {
t.Fatalf("halt not recorded: halted=%v name=%q val=%v", halted, name, val)
}
// Missing holder in context is a safe no-op.
recordHalt(context.Background(), "finish", ToolOutput{Halt: true})
}
func TestStreamCollector(t *testing.T) {
c := &streamCollector{}
if c.drain() != nil {
t.Fatal("empty collector should drain to nil")
}
c.add(StreamEvent{Kind: StreamEventTextDelta, Text: "A"})
c.add(StreamEvent{Kind: StreamEventTextDelta, Text: "B"})
c.add(StreamEvent{Kind: StreamEventToolCallChunk, ToolName: "x"})
out := c.drain()
if len(out) != 3 {
t.Fatalf("len = %d, want 3", len(out))
}
if out[0].Text != "A" || out[1].Text != "B" {
t.Fatalf("order not preserved: %#v", out)
}
if out[2].Kind != StreamEventToolCallChunk || out[2].ToolName != "x" {
t.Fatalf("tool chunk wrong: %#v", out[2])
}
}
// nil receiver collector (no per-turn collector attached) must be safe.
func TestStreamCollectorNil(t *testing.T) {
var c *streamCollector
c.add(StreamEvent{Kind: StreamEventTextDelta, Text: "x"}) // no panic
if c.drain() != nil {
t.Fatal("nil collector should drain to nil")
}
}
func TestStreamCollectorFromContext(t *testing.T) {
if streamCollectorFromContext(context.Background()) != nil {
t.Fatal("expected nil collector for bare context")
}
c := &streamCollector{}
ctx := context.WithValue(context.Background(), streamCollectorKey{}, c)
if streamCollectorFromContext(ctx) != c {
t.Fatal("collector not retrieved from context")
}
}
+108 -4
View File
@@ -5,11 +5,13 @@ import (
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/agent"
"github.com/mark3labs/kit/internal/compaction"
"github.com/mark3labs/kit/internal/config"
"github.com/mark3labs/kit/internal/message"
"github.com/mark3labs/kit/internal/models"
"github.com/mark3labs/kit/internal/session"
"github.com/mark3labs/kit/internal/tools"
"github.com/mark3labs/mcp-go/client/transport"
"github.com/mark3labs/mcp-go/server"
)
@@ -81,10 +83,9 @@ type MCPServerConfig = config.MCPServerConfig
// concurrent use.
//
// Most consumers do not need to provide one; pass [Options.Debug] = true
// (or use [WithDebug]) to install the built-in console logger. DebugLogger
// is the escape hatch for embedders that want to route debug output into
// their own logging system — install one via [Options.DebugLogger] or
// [WithDebugLogger].
// to use the default logger. DebugLogger is exposed for the low-level
// [AgentConfig] path and for embedders that want to route debug output
// into their own logging system.
type DebugLogger interface {
// LogDebug records a single debug message. Implementations may drop,
// buffer, or render the message however they choose.
@@ -94,6 +95,109 @@ type DebugLogger interface {
IsDebugEnabled() bool
}
// AgentConfig holds configuration options for constructing an agent at the
// SDK boundary. All fields use SDK-owned types, so consumers can populate
// this struct without importing any underlying LLM-provider package.
//
// For most use cases, prefer the high-level [New] entry point with
// [Options]. AgentConfig is exposed for advanced consumers that need
// direct access to the lower-level agent configuration shape.
type AgentConfig struct {
// ModelConfig holds the LLM provider configuration. A nil value means
// that the default provider/model resolution will be used.
ModelConfig *ProviderConfig
// MCPConfig describes any MCP servers whose tools should be loaded
// alongside core tools.
MCPConfig *Config
// SystemPrompt is the system prompt sent to the LLM.
SystemPrompt string
// MaxSteps caps the number of LLM iterations per turn. A value of
// zero means no cap is applied at this layer.
MaxSteps int
// StreamingEnabled controls whether the agent streams responses.
StreamingEnabled bool
// AuthHandler handles OAuth authorization for remote MCP servers.
// When nil, remote MCP servers requiring OAuth will fail to connect.
AuthHandler MCPAuthHandler
// TokenStoreFactory, if non-nil, creates a custom token store for each
// remote MCP server's OAuth tokens. When nil, the default file-based
// token store is used.
TokenStoreFactory MCPTokenStoreFactory
// CoreTools overrides the default core tool set. If empty, [AllTools]
// is used. Provide a custom tool set (e.g. [CodingTools] or tools
// built with a custom WorkDir) to scope agent capabilities.
CoreTools []Tool
// DisableCoreTools, when true, prevents loading any core tools.
// Combined with empty CoreTools this yields a chat-only agent with
// no built-in tools.
DisableCoreTools bool
// ExtraTools are additional tools loaded alongside core and MCP tools.
ExtraTools []Tool
// ToolWrapper, if non-nil, wraps the combined tool list before it is
// handed to the LLM. Used to intercept tool calls or results.
ToolWrapper func([]Tool) []Tool
// OnMCPServerLoaded, if non-nil, is invoked once for each MCP server
// when its tools have finished loading (or failed). Called from a
// background goroutine.
OnMCPServerLoaded func(serverName string, toolCount int, err error)
// DebugLogger receives low-level debug output from the engine and the
// MCP tool plumbing. Nil means no debug output is emitted at this
// layer (regardless of [Options.Debug], which feeds the higher-level
// [New] entry point). Pass an implementation here when wiring a custom
// logger through the lower-level AgentConfig path.
DebugLogger DebugLogger
// MCPTaskConfig configures task-aware MCP tools/call execution — mode
// overrides, polling intervals, timeouts, and the progress handler.
// The zero value preserves historical synchronous-only behaviour for
// any server that didn't advertise task support during initialize.
MCPTaskConfig MCPTaskConfig
}
// toInternal converts an AgentConfig to its internal representation.
// Slice and function fields convert without allocation because [Tool]
// is a type alias for the underlying LLM-tool type.
func (c *AgentConfig) toInternal() *agent.AgentConfig {
if c == nil {
return nil
}
out := &agent.AgentConfig{
ModelConfig: c.ModelConfig,
MCPConfig: c.MCPConfig,
SystemPrompt: c.SystemPrompt,
MaxSteps: c.MaxSteps,
StreamingEnabled: c.StreamingEnabled,
CoreTools: c.CoreTools,
DisableCoreTools: c.DisableCoreTools,
ExtraTools: c.ExtraTools,
ToolWrapper: c.ToolWrapper,
OnMCPServerLoaded: c.OnMCPServerLoaded,
}
if c.AuthHandler != nil {
out.AuthHandler = c.AuthHandler
}
if c.TokenStoreFactory != nil {
out.TokenStoreFactory = tools.TokenStoreFactory(c.TokenStoreFactory)
}
if c.DebugLogger != nil {
out.DebugLogger = c.DebugLogger
}
out.MCPTaskConfig = c.MCPTaskConfig.toToolsConfig()
return out
}
// ToolCallHandler is invoked when the LLM produces a tool call. It receives
// the call ID, tool name, and the JSON-encoded input arguments.
type ToolCallHandler func(toolCallID, toolName, toolArgs string)
+41 -35
View File
@@ -264,31 +264,30 @@ func TestConvertFromLLMMessage(t *testing.T) {
}
}
// TestOptionsNoFantasyImport verifies Options can be populated with the
// tool-related fields — Tools and ExtraTools — using only SDK-owned types.
// This test deliberately does not import "charm.land/fantasy"; the package
// compiling at all is the proof that the SDK no longer leaks the dependency
// name through the Options surface.
//
// Tool-call interception (formerly the AgentConfig.ToolWrapper escape hatch)
// is covered by the hook system — [Kit.OnBeforeToolCall] /
// [Kit.OnAfterToolResult] — whose hook payload types also use only
// SDK-owned identifiers; see hooks_test.go.
// TestAgentConfigNoFantasyImport verifies AgentConfig can be populated with
// every field — including CoreTools, ExtraTools, and ToolWrapper — using
// only SDK-owned types. This test deliberately does not import
// "charm.land/fantasy"; the package compiling at all is the proof that the
// SDK no longer leaks the dependency name through AgentConfig.
//
// Regression test for https://github.com/mark3labs/kit/issues/30.
func TestOptionsNoFantasyImport(t *testing.T) {
func TestAgentConfigNoFantasyImport(t *testing.T) {
myTool := kit.NewTool[struct{}]("noop", "does nothing", func(_ context.Context, _ struct{}) (kit.ToolOutput, error) {
return kit.TextResult("ok"), nil
})
streaming := true
cfg := kit.Options{
SystemPrompt: "you are a tester",
MaxSteps: 5,
Streaming: &streaming,
Tools: []kit.Tool{myTool},
ExtraTools: []kit.Tool{myTool},
DisableCoreTools: false,
wrapperCalled := false
cfg := kit.AgentConfig{
SystemPrompt: "you are a tester",
MaxSteps: 5,
StreamingEnabled: true,
CoreTools: []kit.Tool{myTool},
ExtraTools: []kit.Tool{myTool},
DisableCoreTools: false,
ToolWrapper: func(in []kit.Tool) []kit.Tool {
wrapperCalled = true
return in
},
OnMCPServerLoaded: func(_ string, _ int, _ error) {},
}
@@ -298,29 +297,36 @@ func TestOptionsNoFantasyImport(t *testing.T) {
if cfg.MaxSteps != 5 {
t.Errorf("MaxSteps = %d, want 5", cfg.MaxSteps)
}
if cfg.Streaming == nil || !*cfg.Streaming {
t.Error("Streaming = false/nil, want true")
if !cfg.StreamingEnabled {
t.Error("StreamingEnabled = false, want true")
}
if len(cfg.Tools) != 1 {
t.Errorf("Tools len = %d, want 1", len(cfg.Tools))
if len(cfg.CoreTools) != 1 {
t.Errorf("CoreTools len = %d, want 1", len(cfg.CoreTools))
}
if len(cfg.ExtraTools) != 1 {
t.Errorf("ExtraTools len = %d, want 1", len(cfg.ExtraTools))
}
// Exercise the wrapper to confirm the func type is usable.
out := cfg.ToolWrapper(cfg.CoreTools)
if !wrapperCalled {
t.Error("ToolWrapper was not invoked")
}
if len(out) != 1 {
t.Errorf("wrapped tool list len = %d, want 1", len(out))
}
}
// TestToolSliceSignature documents that the kit.Tool alias — used by every
// SDK tool-related surface (Options.Tools, Options.ExtraTools, WithTools,
// WithExtraTools, hook payloads) — is referenced under its SDK-owned name
// in user code, without any fantasy import.
func TestToolSliceSignature(t *testing.T) {
var tools []kit.Tool
tools = append(tools, kit.NewTool[struct{}]("noop", "",
func(_ context.Context, _ struct{}) (kit.ToolOutput, error) {
return kit.TextResult("ok"), nil
}))
if len(tools) != 1 {
t.Fatalf("unexpected tool slice length: %d", len(tools))
// TestAgentConfigToolWrapperSignature documents that AgentConfig.ToolWrapper
// uses kit.Tool (not the underlying provider type) in its signature.
func TestAgentConfigToolWrapperSignature(t *testing.T) {
//nolint:staticcheck // QF1011: explicit type asserts the SDK-side func signature.
var _ func([]kit.Tool) []kit.Tool = func(in []kit.Tool) []kit.Tool { return in }
cfg := kit.AgentConfig{
ToolWrapper: func(in []kit.Tool) []kit.Tool { return in },
}
if cfg.ToolWrapper == nil {
t.Fatal("ToolWrapper assignment failed")
}
}
-18
View File
@@ -93,21 +93,3 @@ result, err := host.PromptResult(ctx, "Count files")
fmt.Println(result.Response)
fmt.Println(result.Usage.TotalTokens)
```
`PromptResult` blocks until end-of-turn regardless of streaming mode. When
streaming is enabled, every delta observed during the turn is also captured in
order in `result.Stream` (`[]kit.StreamEvent`), so you can assert streamed
ordering deterministically without wiring an `OnMessageUpdate` collector:
```go
for _, ev := range result.Stream {
switch ev.Kind {
case kit.StreamEventTextDelta:
fmt.Print(ev.Text)
case kit.StreamEventReasoningDelta:
fmt.Print(ev.Reasoning)
case kit.StreamEventToolCallChunk:
fmt.Printf("[%s %s]", ev.ToolName, ev.Args)
}
}
```
+2 -49
View File
@@ -67,61 +67,14 @@ kit --skill path/to/skill.md "prompt"
# Load multiple skill files or directories (flag is repeatable)
kit --skill ./skill1.md --skill ./skill2.md "prompt"
# Scan a directory directly for skills (overrides auto-discovery)
# Load all skills from a custom directory instead of the default locations
kit --skills-dir /path/to/skills "prompt"
# Hide a skill from the model catalog by name (still usable via /skill:)
kit --skill-disable noisy-skill "prompt"
# Disable all skill loading (auto-discovery and explicit)
kit --no-skills "prompt"
```
Skills follow the [agentskills.io](https://agentskills.io/specification) convention. They are auto-discovered from four canonical scopes:
| Scope | Location |
|-------|----------|
| User-level (cross-client) | `~/.agents/skills/` |
| User-level (Kit) | `~/.config/kit/skills/` (honors `$XDG_CONFIG_HOME`) |
| Project-local (cross-client) | `<project>/.agents/skills/` |
| Project-local (Kit) | `<project>/.kit/skills/` |
When two skills share the same `name`, the project-level one takes precedence over the user-level one. Use `--skills-dir` to scan one directory directly instead (it is **not** treated as a parent of `.agents`/`.kit` — the directory itself is scanned). `--skill` loads files explicitly (which disables auto-discovery), and `--no-skills` suppresses all skill loading regardless of other flags.
Disabled skills (`--skill-disable`, the `skill-disable` config key, or `disable-model-invocation: true` in a skill's frontmatter) are hidden from the model-facing `<available_skills>` catalog but remain available for explicit activation via the `/skill:<name>` command.
### Skill frontmatter
A skill is a markdown file (`SKILL.md` in a directory, or a standalone `.md`/`.txt` file) with optional YAML frontmatter. Kit reads the full [agentskills.io](https://agentskills.io/specification) field set plus two Kit-specific extensions:
```yaml
---
name: pdf-extractor # required
description: Use when extracting tables from PDFs # required (drives model discovery)
license: MIT # optional, SPDX identifier
compatibility: claude-code, cursor # optional, targeted environments
allowed-tools: read, bash # optional (experimental) tool restriction
disable-model-invocation: false # optional; true hides from the catalog
metadata: # optional arbitrary key/value pairs
author: you
tags: [pdf, data] # Kit extension
when: on-demand # Kit extension
---
```
`name` and `description` are required — a skill missing its description is skipped with a logged warning, since the description is the sole basis on which the model decides relevance. Descriptions are XML-escaped before they enter the catalog, so characters like `<`, `>`, and `&` are safe. A skill directory may bundle `scripts/`, `references/`, and `assets/` subdirectories; when a skill is activated those files are enumerated in a `<skill_resources>` block so the model knows what it can read.
### Project trust prompt
Because project-local skills are injected into the system prompt, entering a repository that ships `.agents/skills/` or `.kit/skills/` for the first time prompts you to trust it before any project skill loads — a safeguard against a freshly cloned, untrusted repo smuggling instructions into the agent:
```
This project provides 2 skills under .agents/skills or .kit/skills:
/path/to/repo
Load them into the agent? [t]rust always / [o]nce / [s]kip (default skip):
```
Choosing **trust always** persists the directory to `~/.config/kit/trusted-projects.json` so you are not asked again. The prompt is skipped (skills load silently) in non-interactive runs — when a prompt is passed positionally, `--quiet` is set, or stdin is not a TTY.
Skills are auto-discovered from `~/.config/kit/skills/`, `.kit/skills/`, and `.agents/skills/` by default. Use `--skills-dir` to override the project-local search root, or `--skill` to load files explicitly (which disables auto-discovery). `--no-skills` suppresses all skill loading regardless of other flags.
## GitHub integration
+1 -2
View File
@@ -53,8 +53,7 @@ These flags control Kit's behavior. When a prompt is passed as a positional argu
| Flag | Short | Default | Description |
|------|-------|---------|-------------|
| `--skill` | — | — | Load skill file or directory (repeatable) |
| `--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:` |
| `--skills-dir` | — | — | Override the project-local skills directory for auto-discovery |
| `--no-skills` | — | `false` | Disable skill loading (auto-discovery and explicit) |
## Generation parameters
+1 -2
View File
@@ -49,8 +49,7 @@ stream: true
| `prompt-template` | string | — | Specific template to load by name |
| `no-skills` | bool | `false` | Disable skill loading (auto-discovery and explicit) |
| `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:`) |
| `skills-dir` | string | — | Override the project-local directory used for skill auto-discovery |
## Environment variables
+2 -5
View File
@@ -447,14 +447,11 @@ Load and inject skills dynamically at runtime:
```go
// Discover skills from standard locations
result := ctx.DiscoverSkills() // ext.SkillLoadResult{Skills, Error}
// Standard locations: ~/.agents/skills/, ~/.config/kit/skills/,
// <project>/.agents/skills/, <project>/.kit/skills/
// Standard locations: ~/.config/kit/skills/, .kit/skills/, .agents/skills/
// Load a specific skill file
skill, err := ctx.LoadSkill("/path/to/skill.md") // (*ext.Skill, error string)
// Spec fields: skill.Name, skill.Description, skill.License, skill.Compatibility,
// skill.Metadata, skill.AllowedTools, skill.DisableModelInvocation
// Plus content/path and Kit extensions: skill.Content, skill.Path, skill.Tags, skill.When
// skill.Name, skill.Description, skill.Content, skill.Tags, skill.When
// Load all skills from a directory
result := ctx.LoadSkillsFromDir("/path/to/skills") // ext.SkillLoadResult
+1 -19
View File
@@ -176,30 +176,12 @@ Lower values run first. First non-nil result wins.
| `SourceEvent` | `OnSource` | LLM referenced a source (e.g., web search) |
| `ErrorEvent` | `OnError` | Agent-level error during streaming |
| `RetryEvent` | `OnRetry` | LLM request retried after transient error |
| `CompactionEvent` | `OnCompaction` | Conversation compacted (fires on success **and** failure — check `Err`) |
| `CompactionEvent` | `OnCompaction` | Conversation compacted |
| `SteerConsumedEvent` | `OnSteerConsumed` | Steering messages injected into turn |
| `PasswordPromptEvent` | — | Sudo command needs password (respond via `ResponseCh`) |
> **Note:** `OnStreaming` is a deprecated alias for `OnMessageUpdate` and will be removed in a future release.
### Compaction telemetry
`CompactionEvent` fires after every compaction attempt. On success `Err` is
`nil` and the summary/token/file fields are populated; on failure `Err` is
non-nil and the rest are zero-valued. This lets you wire symmetric
start/end lifecycle telemetry without hand-rolling the failure path:
```go
host.OnCompaction(func(e kit.CompactionEvent) {
if e.Err != nil {
log.Printf("compaction failed: %v", e.Err)
return
}
log.Printf("compacted %d → %d tokens (%d messages removed)",
e.OriginalTokens, e.CompactedTokens, e.MessagesRemoved)
})
```
## Subagent event monitoring
Monitor real-time events from LLM-initiated subagents (when the model uses the `subagent` tool):
+6 -83
View File
@@ -31,7 +31,6 @@ host, err := kit.New(ctx, &kit.Options{
Streaming: ptrBool(true), // *bool: nil = unset (default true), &false = off
Quiet: true,
Debug: true,
DebugLogger: myLogger, // optional; overrides Debug + built-in logger when non-nil
// Generation parameters (override env/config/per-model defaults)
MaxTokens: 16384, // 0 = auto-resolve; non-zero suppresses right-sizing
@@ -65,10 +64,9 @@ host, err := kit.New(ctx, &kit.Options{
AutoCompact: true,
// Skills
Skills: []string{"/path/to/skill.md"},
SkillsDir: "/path/to/skills/",
SkillsDisable: []string{"noisy-skill"},
NoSkills: true,
Skills: []string{"/path/to/skill.md"},
SkillsDir: "/path/to/skills/",
NoSkills: true,
// Feature toggles
NoExtensions: true, // disable Yaegi extension loading
@@ -105,8 +103,7 @@ host, err := kit.New(ctx, &kit.Options{
| `MaxSteps` | `int` | `0` | Max agent steps (0 = unlimited) |
| `Streaming` | `*bool` | `nil` | Enable streaming output. `nil` leaves it to the precedence chain (env → config → default `true`); `&true`/`&false` forces it. Pointer so unset is distinct from explicit `false`. |
| `Quiet` | `bool` | `false` | Suppress output |
| `Debug` | `bool` | `false` | Enable debug logging via the built-in console / buffered logger. Ignored when `DebugLogger` is non-nil. |
| `DebugLogger` | `DebugLogger` | `nil` | Caller-supplied logger that receives low-level engine + MCP tool plumbing debug output. When non-nil this overrides `Debug` — the supplied logger's `IsDebugEnabled()` controls downstream emission. See [Custom debug logger](#custom-debug-logger). |
| `Debug` | `bool` | `false` | Enable debug logging |
### Generation parameters
@@ -171,48 +168,13 @@ when embedding Kit as a library.
|-------|------|---------|-------------|
| `SkipConfig` | `bool` | `false` | Skip `.kit.yml` file loading (viper defaults + env vars still apply) |
| `Skills` | `[]string` | — | Explicit skill files/dirs to load |
| `SkillsDir` | `string` | — | Scan this directory directly for skills (overrides auto-discovery; scanned as-is) |
| `SkillsDisable` | `[]string` | — | Skill names to hide from the model catalog (still usable via `/skill:`) |
| `SkillTrustPrompt` | `func(projectDir string, skillCount int) TrustDecision` | `nil` | Callback gating project-local skill loading on a trust decision (see below) |
| `SkillsDir` | `string` | — | Override default skills directory |
| `NoSkills` | `bool` | `false` | Disable skill loading entirely |
#### Project-skill trust gate
Project-local skills (under `<project>/.agents/skills/` or `<project>/.kit/skills/`)
are injected into the system prompt, so loading them from an untrusted, freshly
cloned repository is a prompt-injection vector. Set `SkillTrustPrompt` to gate
that first load on an explicit decision. When `nil` (the default), project
skills load without prompting — preserving historical behaviour.
```go
opts := &kit.Options{
SkillTrustPrompt: func(projectDir string, skillCount int) kit.TrustDecision {
// Consult your own UI / policy here.
if userApproves(projectDir, skillCount) {
return kit.TrustProject // load and persist the directory as trusted
}
return kit.SkipProjectSkills // do not load project skills
},
}
```
The callback returns one of three `TrustDecision` values:
| Decision | Effect |
|----------|--------|
| `kit.TrustProject` | Load project skills and persist `projectDir` to `~/.config/kit/trusted-projects.json` (not prompted again) |
| `kit.TrustProjectOnce` | Load project skills for this run only, without persisting |
| `kit.SkipProjectSkills` | Do not load project skills |
A directory already on the persisted allowlist is trusted without invoking the
callback. The Kit CLI wires this to an interactive terminal prompt automatically
for TTY sessions.
These fields only control the **initial** skill and context-file set picked
up by `New()`. To add, remove, or replace skills and `AGENTS.md`-style
context files at runtime (e.g. per user or per session), use the
`AddSkill` / `LoadAndAddSkill` / `RemoveSkill` / `SetSkills` /
`DisableSkill` / `EnableSkill` and
`AddSkill` / `LoadAndAddSkill` / `RemoveSkill` / `SetSkills` and
`AddContextFile` / `AddContextFileContent` / `RemoveContextFile` /
`SetContextFiles` methods on `*kit.Kit`. See
[Runtime skills and context files](/sdk/overview#runtime-skills-and-context-files).
@@ -384,45 +346,6 @@ loaded MCP server that advertises the corresponding capability.
Context cancellation also works end-to-end: cancelling the `ctx` passed to a
tool execution triggers a best-effort `tasks/cancel` before the call returns.
## Custom debug logger
Kit's engine and MCP tool plumbing emit low-level debug output through a
`DebugLogger` interface. By default, setting `Debug: true` (or calling
`WithDebug()`) installs the built-in console logger. To route the same output
into your application's logging system instead, provide a custom
implementation via `Options.DebugLogger` or `WithDebugLogger`.
```go
type DebugLogger interface {
LogDebug(message string)
IsDebugEnabled() bool
}
```
When `DebugLogger` is non-nil it takes precedence over `Debug` — the
supplied logger's `IsDebugEnabled()` reports whether downstream code should
bother formatting messages.
**Example: forward to `log/slog`:**
```go
import "log/slog"
type slogDebugLogger struct{ l *slog.Logger }
func (s *slogDebugLogger) LogDebug(m string) { s.l.Debug(m) }
func (s *slogDebugLogger) IsDebugEnabled() bool { return true }
host, _ := kit.NewAgent(ctx,
kit.WithModel("anthropic/claude-sonnet-4-5-20250929"),
kit.WithDebugLogger(&slogDebugLogger{l: slog.Default()}),
)
```
Implementations must be safe for concurrent use — messages can arrive
from the engine goroutine, MCP connection pool, and tool execution paths
simultaneously.
## Precedence
For any given generation or provider field, the effective value is resolved
+3 -166
View File
@@ -80,7 +80,6 @@ Available options:
| `WithProviderURL(string)` | `Options.ProviderURL` |
| `WithConfigFile(string)` | `Options.ConfigFile` |
| `WithDebug()` | `Options.Debug = true` |
| `WithDebugLogger(DebugLogger)` | `Options.DebugLogger` (route engine + MCP debug output into a custom logger; overrides `WithDebug` when set) |
| `Ephemeral()` | `Options.NoSession = true` |
Options are applied in order, so later options override earlier ones. `Option`
@@ -130,36 +129,12 @@ The SDK provides several prompt variants:
| Method | Description |
|--------|-------------|
| `Prompt(ctx, message)` | Simple prompt, returns response string |
| `PromptWithOptions(ctx, message, opts)` | With per-call options (model, tools, thinking level, provider creds) |
| `PromptWithOptions(ctx, message, opts)` | With per-call options |
| `PromptResult(ctx, message)` | Returns full `TurnResult` with usage stats |
| `PromptResultWithOptions(ctx, message, opts)` | Per-call options variant that returns the full `TurnResult` |
| `PromptResultWithFiles(ctx, message, files)` | Multimodal with file attachments |
| `Steer(ctx, instruction)` | System-level steering without user message |
| `FollowUp(ctx, text)` | Continue without new user input |
### Per-call overrides
`PromptOptions` scopes configuration to a **single call** and restores the
agent's prior state afterwards — no need to rebuild a `*Kit` per request. This
suits multi-tenant hosts that resolve the model, credentials, or tool set per
request:
```go
result, err := host.PromptResultWithOptions(ctx, "Summarise this ticket", kit.PromptOptions{
SystemMessage: "You are a concise triage assistant.", // prepended for this call
Model: "anthropic/claude-haiku-3-5-20241022", // overrides the default model
ThinkingLevel: "low", // "off" | "low" | "medium" | "high"
ExtraTools: []kit.Tool{lookupTool}, // added on top of the core set
ProviderURL: "https://proxy.tenant-a/v1", // per-tenant endpoint
ProviderAPIKey: tenantKey, // per-tenant credential
})
```
Every field is optional; a zero value means "use the agent's default." The
prior model, thinking level, provider credentials, and tool set are all
restored before the call returns, and concurrent option-driven prompts are
serialized so the apply/restore window of one call never races another.
## Custom tools
Create custom tools with `kit.NewTool`. The JSON schema is auto-generated from the input struct — no external dependencies required:
@@ -200,64 +175,6 @@ Binary data (images, audio, etc.) in `ToolOutput.Data` is automatically forwarde
Use `kit.NewParallelTool` for tools that are safe to run concurrently. Use `kit.ToolCallIDFromContext(ctx)` to retrieve the LLM-assigned call ID for logging or tracing.
### Schema-driven tools
When the tool's input shape isn't known at compile time — tools sourced from
JSON Schema definitions in skill files, MCP server catalogs, or user-supplied
definitions — use `kit.NewRawTool`. It takes a JSON Schema and a handler that
receives the decoded arguments as a `map[string]any`, so no Go input type is
required:
```go
schema := map[string]any{
"type": "object",
"properties": map[string]any{
"city": map[string]any{"type": "string", "description": "City name"},
},
"required": []any{"city"},
}
weatherTool := kit.NewRawTool("get_weather", "Get current weather for a city", schema,
func(ctx context.Context, args map[string]any) (kit.ToolOutput, error) {
return kit.TextResult("72°F, sunny in " + args["city"].(string)), nil
},
)
```
The `schema` is advertised to the model as the tool's parameter schema. If the
model sends arguments that aren't a valid JSON object, the call short-circuits
with an error result before your handler runs.
### Halting the agent loop
For structured-result patterns — the model calls a `finish(...)` tool with a
typed argument and the loop should terminate, returning that value to the
caller — set `Halt` and `FinalValue` on the returned `ToolOutput` instead of
smuggling the value out through a side-channel:
```go
finishTool := kit.NewTool("finish", "Return the final structured answer",
func(ctx context.Context, input AnswerInput) (kit.ToolOutput, error) {
return kit.ToolOutput{
Content: "done",
Halt: true, // terminate the agent loop after this call
FinalValue: input, // surfaced to the caller
}, nil
},
)
result, _ := host.PromptResult(ctx, "Extract the order details")
if result.HaltedByTool == "finish" {
answer := result.FinalValue.(AnswerInput) // the typed value your handler stored
_ = answer
}
```
`TurnResult.HaltedByTool` names the tool that halted the turn (empty if the
turn ended for any other reason), and `TurnResult.FinalValue` carries whatever
your handler placed in `ToolOutput.FinalValue`. `Halt`/`FinalValue` work with
`NewTool`, `NewParallelTool`, and `NewRawTool` alike.
## Generation & provider overrides
SDK consumers can configure generation parameters and provider endpoints
@@ -382,11 +299,6 @@ host.LoadAndAddContextFile("/etc/agents/tenant-acme.md")
host.RemoveSkill("polite-french")
host.RemoveContextFile(fmt.Sprintf("session://%s/AGENTS.md", userID))
// Hide a skill from the model-facing catalog without unloading it — it stays
// available for explicit /skill: activation. EnableSkill reverses this.
host.DisableSkill("refund-policy")
host.EnableSkill("refund-policy")
// Or replace the whole set in one call.
host.SetSkills(activeSkillsForUser)
host.SetContextFiles(activeContextForUser)
@@ -412,35 +324,9 @@ Key points:
from multiple goroutines; the underlying state is guarded by an internal
`RWMutex`.
- **Init-time options still apply.** `Options.Skills`, `Options.SkillsDir`,
`Options.SkillsDisable`, `Options.SkillTrustPrompt`, `Options.NoSkills`, and
`Options.NoContextFiles` continue to control the startup set; the runtime API
mutates from whatever state `New()` produced.
`Options.NoSkills`, and `Options.NoContextFiles` continue to control the
startup set; the runtime API mutates from whatever state `New()` produced.
See [SDK options](/sdk/options#skills--configuration).
- **Auto-discovery scopes.** When no explicit `Skills`/`SkillsDir` are given,
`New()` scans four [agentskills.io](https://agentskills.io/specification)
locations: `~/.agents/skills/`, `~/.config/kit/skills/`,
`<project>/.agents/skills/`, and `<project>/.kit/skills/`. Project-level
skills override user-level skills of the same `name`. Skills missing a
`description` are skipped with a logged warning, and a skill's
`disable-model-invocation: true` (or `Options.SkillsDisable`) hides it from
the catalog while keeping it available for explicit activation.
- **Skill helpers.** A `kit.Skill` exposes `BaseDir()` (its directory) and
`Resources()` (the files bundled under `scripts/`, `references/`, and
`assets/`), which power the `<skill_resources>` enumeration shown when a skill
is activated.
- **`fs.FS`-backed discovery.** The package-level loaders `kit.LoadSkill`,
`kit.LoadSkillsFromDir`, and `kit.LoadSkills` are path-string based;
`kit.LoadSkillsFromFS(fsys, root)` is the `fs.FS`-typed counterpart for
`embed.FS` distribution, `fstest.MapFS` tests, or per-tenant virtual
filesystems. Feed the result into `host.SetSkills(...)`:
```go
//go:embed skills
var skillsFS embed.FS
loaded, _ := kit.LoadSkillsFromFS(skillsFS, "skills")
host.SetSkills(loaded)
```
## MCP prompts and resources
@@ -496,55 +382,6 @@ if host.ShouldCompact() {
}
```
## Provider error classification
Provider failures are wrapped with exported sentinels so you can branch on the
failure category with `errors.Is` instead of string-matching the underlying
HTTP error. `PromptResult` / `Prompt` already return classified errors; you can
also classify any provider error yourself with `kit.ClassifyProviderError`:
```go
_, err := host.PromptResult(ctx, prompt)
switch {
case errors.Is(err, kit.ErrContextOverflow):
host.Compact(ctx, nil, "") // compact and retry
case errors.Is(err, kit.ErrRateLimit):
backoffAndRetry()
case errors.Is(err, kit.ErrAuth):
rePromptForKey()
case errors.Is(err, kit.ErrProviderUnavailable):
retryLater()
case errors.Is(err, kit.ErrInvalidRequest):
log.Printf("non-retryable: %v", err)
}
```
| Sentinel | Meaning |
|----------|---------|
| `kit.ErrContextOverflow` | Request exceeded the model's context window |
| `kit.ErrRateLimit` | Provider throttled the request |
| `kit.ErrAuth` | Credential / authorization failure |
| `kit.ErrProviderUnavailable` | Transient upstream failure (5xx, network, timeout) |
| `kit.ErrInvalidRequest` | Structurally invalid request — retrying won't help |
The original error stays reachable via `errors.Is`, so you never lose the
provider's detail message.
## Graceful shutdown
`Close()` releases MCP connections, model resources, and the session file
handle. When shutdown must be bounded by a deadline, use `CloseContext`:
```go
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := host.CloseContext(shutdownCtx); err != nil {
log.Printf("shutdown: %v", err)
}
```
`Close()` is equivalent to `CloseContext(context.Background())`.
## In-process subagents
Spawn child Kit instances without subprocess overhead:
+1 -7
View File
@@ -99,12 +99,6 @@ host, _ := kit.New(ctx, &kit.Options{
})
```
The interface requires methods for message storage, branching, compaction, branch summaries, extension data, and lifecycle management. See the [`SessionManager` interface definition](https://pkg.go.dev/github.com/mark3labs/kit/pkg/kit#SessionManager) for the complete method set.
The `AppendBranchSummary(fromID, summary)` method backs `host.CollapseBranch`,
which collapses a branch range into a single summary entry. Custom managers
that don't track branch summaries can return `kit.ErrBranchSummaryNotSupported`
from that method; `host.CollapseBranch` then surfaces the same sentinel so
callers can detect it with `errors.Is`.
The interface requires methods for message storage, branching, compaction, extension data, and lifecycle management. See the [SDK skill reference](https://github.com/mark3labs/kit) for the complete interface definition.
When using a custom `SessionManager`, the `SessionPath`, `Continue`, and `NoSession` options are ignored — your manager handles its own storage and session selection.