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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -1105,6 +1105,18 @@ 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.
Expand Down
20 changes: 17 additions & 3 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"

"github.com/spf13/viper"
"gopkg.in/yaml.v3"
Expand Down Expand Up @@ -554,7 +555,7 @@ func FilepathOr[T any](key string, value *T) error {
absPath = filepath.Join(home, absPath[2:])
}
if !filepath.IsAbs(absPath) {
base := configPath
base := GetConfigPath()
if base == "" {
fmt.Fprintf(os.Stderr, "unable to build relative path to config.")
os.Exit(1)
Expand All @@ -581,11 +582,24 @@ func FilepathOr[T any](key string, value *T) error {
return nil
}

var configPath string
var (
configPathMu sync.RWMutex
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.
// location is known. It is safe for concurrent use.
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 changes: 33 additions & 0 deletions internal/config/configpath_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
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)
}
}
84 changes: 76 additions & 8 deletions internal/skills/skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ package skills

import (
"bytes"
"errors"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"strings"

Expand Down Expand Up @@ -55,7 +58,14 @@ func LoadSkill(path string) (*Skill, error) {
abs = path
}

skill := &Skill{Path: abs}
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}

content := string(data)

Expand All @@ -70,7 +80,7 @@ func LoadSkill(path string) (*Skill, error) {
body = strings.TrimPrefix(body, "\n")

if err := yaml.Unmarshal([]byte(frontmatter), skill); err != nil {
return nil, fmt.Errorf("parsing frontmatter in %s: %w", path, err)
return nil, fmt.Errorf("parsing frontmatter in %s: %w", srcPath, err)
}
skill.Content = strings.TrimSpace(body)
} else {
Expand All @@ -83,12 +93,12 @@ func LoadSkill(path string) (*Skill, error) {

// Fallback: derive name from filename if frontmatter didn't set one.
if skill.Name == "" {
base := filepath.Base(path)
base := filepath.Base(srcPath)
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(path))
skill.Name = filepath.Base(filepath.Dir(srcPath))
}
}

Expand All @@ -113,7 +123,7 @@ func LoadSkillsFromDir(dir string) ([]*Skill, error) {
}

var skills []*Skill
var errs []string
var errs []error

for _, entry := range entries {
full := filepath.Join(dir, entry.Name())
Expand All @@ -123,7 +133,7 @@ func LoadSkillsFromDir(dir string) ([]*Skill, error) {
if ext == ".md" || ext == ".txt" {
s, err := LoadSkill(full)
if err != nil {
errs = append(errs, err.Error())
errs = append(errs, err)
continue
}
skills = append(skills, s)
Expand All @@ -140,7 +150,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.Error())
errs = append(errs, err)
continue
}
skills = append(skills, s)
Expand All @@ -150,7 +160,65 @@ func LoadSkillsFromDir(dir string) ([]*Skill, error) {
}

if len(errs) > 0 {
return skills, fmt.Errorf("some skills failed to load: %s", strings.Join(errs, "; "))
return skills, fmt.Errorf("some skills failed to load: %w", errors.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.
//
// 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
}
Expand Down
70 changes: 70 additions & 0 deletions internal/skills/skills_fs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
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
}
25 changes: 23 additions & 2 deletions pkg/kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,15 +364,28 @@ 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
- `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`

### 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
- `PromptResult(ctx, message)` - Send message and get full TurnResult (blocks
until end-of-turn; populates `TurnResult.Stream` in streaming mode)
- `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
Expand All @@ -384,7 +397,15 @@ 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

Expand Down
5 changes: 5 additions & 0 deletions pkg/kit/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,11 @@ 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()
Expand Down
14 changes: 13 additions & 1 deletion pkg/kit/compaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,20 @@ func (m *Kit) Compact(ctx context.Context, opts *CompactionOptions, customInstru
}

// compactInternal is the shared compaction implementation. The isAutomatic
// flag distinguishes auto-triggered compaction from manual /compact.
// 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.
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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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
Expand Down
Loading
Loading