mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 58b8aa043c | |||
| 41e34f30f8 | |||
| ce1d7afe83 |
@@ -756,6 +756,45 @@ host, _ := kit.New(ctx, &kit.Options{
|
||||
})
|
||||
```
|
||||
|
||||
### Runtime Skills & Context Files
|
||||
|
||||
For multi-tenant hosts (chatbots, per-user agents, web services), the SDK
|
||||
lets you swap skills and `AGENTS.md`-style context files **after** Kit
|
||||
construction. Every mutation recomposes the system prompt and applies it to
|
||||
the agent so the next turn picks up the new instructions — no restart needed.
|
||||
|
||||
```go
|
||||
// Programmatic skill (no file on disk required).
|
||||
host.AddSkill(&kit.Skill{
|
||||
Name: "polite-french",
|
||||
Description: "Respond in French and always greet the user.",
|
||||
Content: "Always reply in French. Open every response with 'Bonjour'.",
|
||||
})
|
||||
|
||||
// Or load one from disk.
|
||||
host.LoadAndAddSkill("/var/skills/refund-policy.md")
|
||||
|
||||
// Per-user AGENTS.md content pulled from a database.
|
||||
host.AddContextFileContent(
|
||||
fmt.Sprintf("session://%s/AGENTS.md", userID),
|
||||
rulesFromDB,
|
||||
)
|
||||
|
||||
// Tear down session-specific state on logout.
|
||||
host.RemoveSkill("polite-french")
|
||||
host.RemoveContextFile(fmt.Sprintf("session://%s/AGENTS.md", userID))
|
||||
|
||||
// Or replace the whole set atomically.
|
||||
host.SetSkills(activeSkillsForUser)
|
||||
host.SetContextFiles(activeContextForUser)
|
||||
```
|
||||
|
||||
Skills dedupe by `Name`, context files dedupe by `Path` (which can be any
|
||||
opaque identifier — it doesn't have to be a real filesystem path). All
|
||||
mutators and readers (`GetSkills`, `GetContextFiles`) are safe to call
|
||||
concurrently from multiple goroutines. See the [SDK overview docs](/sdk/overview#runtime-skills-and-context-files)
|
||||
for the full reference.
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Subagent Pattern
|
||||
|
||||
@@ -246,6 +246,12 @@ type Agent struct {
|
||||
mcpReady chan struct{}
|
||||
// mcpErr holds any error from background MCP loading.
|
||||
mcpErr error
|
||||
|
||||
// promptMu serializes runtime updates to systemPrompt and the
|
||||
// accompanying fantasy agent rebuild so concurrent SetSystemPrompt
|
||||
// callers (e.g. Kit.applyComposedSystemPrompt invoked from multiple
|
||||
// goroutines) don't race on a.systemPrompt / a.fantasyAgent.
|
||||
promptMu sync.Mutex
|
||||
}
|
||||
|
||||
// GenerateWithLoopResult contains the result and conversation history from an agent interaction.
|
||||
@@ -1318,6 +1324,24 @@ func (a *Agent) GetModel() fantasy.LanguageModel {
|
||||
return a.model
|
||||
}
|
||||
|
||||
// SetSystemPrompt updates the agent's system prompt and rebuilds the underlying
|
||||
// fantasy agent so subsequent turns use the new prompt. Safe to call while the
|
||||
// agent is idle; if invoked during an in-flight turn the new prompt takes
|
||||
// effect on the next LLM call.
|
||||
func (a *Agent) SetSystemPrompt(prompt string) {
|
||||
a.promptMu.Lock()
|
||||
defer a.promptMu.Unlock()
|
||||
a.systemPrompt = prompt
|
||||
a.rebuildFantasyAgent()
|
||||
}
|
||||
|
||||
// GetSystemPrompt returns the agent's current system prompt.
|
||||
func (a *Agent) GetSystemPrompt() string {
|
||||
a.promptMu.Lock()
|
||||
defer a.promptMu.Unlock()
|
||||
return a.systemPrompt
|
||||
}
|
||||
|
||||
// GetMaxTokens returns the effective max output tokens the agent currently
|
||||
// sends to the LLM provider, after per-model defaults, right-sizing, and any
|
||||
// Anthropic thinking-budget adjustments. Returns 0 when no ModelConfig is
|
||||
|
||||
@@ -241,6 +241,43 @@ response, _ := host.Prompt(ctx, "What's my name?")
|
||||
host.ClearSession()
|
||||
```
|
||||
|
||||
### Runtime Skills and Context Files
|
||||
|
||||
For multi-tenant chatbots, web services, or any host that needs per-user or
|
||||
per-session instructions, the SDK lets you add, remove, and replace skills and
|
||||
project context files (e.g. `AGENTS.md`) **after** Kit construction. Every
|
||||
mutation recomposes the system prompt and applies it to the agent so the next
|
||||
turn picks up the new instructions — no restart required.
|
||||
|
||||
```go
|
||||
// Add a programmatic skill (no file on disk required).
|
||||
host.AddSkill(&kit.Skill{
|
||||
Name: "polite-french",
|
||||
Description: "Respond in French and always greet the user.",
|
||||
Content: "Always reply in French. Open every response with 'Bonjour'.",
|
||||
})
|
||||
|
||||
// Or load one from disk.
|
||||
host.LoadAndAddSkill("/var/skills/refund-policy.md")
|
||||
|
||||
// Swap per-user AGENTS.md content fetched from your database.
|
||||
host.AddContextFileContent(
|
||||
fmt.Sprintf("session://%s/AGENTS.md", userID),
|
||||
rulesFromDB,
|
||||
)
|
||||
|
||||
// Tear down session-specific state when the user logs off.
|
||||
host.RemoveSkill("polite-french")
|
||||
host.RemoveContextFile(fmt.Sprintf("session://%s/AGENTS.md", userID))
|
||||
|
||||
// Or replace the whole set in one shot.
|
||||
host.SetSkills(activeSkillsForUser)
|
||||
host.SetContextFiles(activeContextForUser)
|
||||
```
|
||||
|
||||
Readers (`GetSkills`, `GetContextFiles`) return snapshots, and every mutator
|
||||
is safe to call concurrently from multiple goroutines.
|
||||
|
||||
## Re-exported Types
|
||||
|
||||
The SDK re-exports message/session/MCP types so you don't need direct internal imports. Agent-configuration types are Kit-owned (not aliases) and use only SDK types in their signatures, so consumers never need to import the underlying LLM-provider package.
|
||||
@@ -312,6 +349,9 @@ msg := kit.ConvertFromLLMMessage(lMsg) // LLMMessage → SDK Message
|
||||
- `ClearSession()` - Clear conversation history
|
||||
- `GetSessionPath()` - Get session file path
|
||||
- `GetSessionID()` - Get session UUID
|
||||
- `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
|
||||
- `Close()` - Clean up resources
|
||||
|
||||
### Options
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package kit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime context-file management (Issue #36)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Project context files (AGENTS.md and friends) are normally auto-discovered
|
||||
// during Kit.New() and injected into the system prompt. SDK consumers building
|
||||
// multi-tenant chatbots often need to swap context per user/session at runtime
|
||||
// without restarting the agent. The methods below provide that surface.
|
||||
//
|
||||
// Every mutation recomposes the system prompt and applies it to the underlying
|
||||
// agent so the next turn sees the updated project context.
|
||||
|
||||
// AddContextFile registers a project context file (e.g. an AGENTS.md
|
||||
// equivalent) on this Kit instance. The file does not need to exist on
|
||||
// disk — Path is treated as an opaque identifier used both for de-duplication
|
||||
// and for the "Instructions from: <Path>" header injected into the system
|
||||
// prompt. If a context file with the same Path is already loaded the new
|
||||
// content replaces it.
|
||||
//
|
||||
// Returns an error when cf is nil or has an empty Path. AddContextFile is
|
||||
// safe to call from any goroutine.
|
||||
func (m *Kit) AddContextFile(cf *ContextFile) error {
|
||||
if cf == nil {
|
||||
return fmt.Errorf("AddContextFile: context file is nil")
|
||||
}
|
||||
if cf.Path == "" {
|
||||
return fmt.Errorf("AddContextFile: context file path is required")
|
||||
}
|
||||
|
||||
// Take a defensive copy so later mutations by the caller don't race with
|
||||
// the agent reading the composed prompt.
|
||||
stored := &ContextFile{
|
||||
Path: cf.Path,
|
||||
Content: strings.TrimSpace(cf.Content),
|
||||
}
|
||||
|
||||
m.runtimeMu.Lock()
|
||||
replaced := false
|
||||
for i, existing := range m.contextFiles {
|
||||
if existing.Path == stored.Path {
|
||||
m.contextFiles[i] = stored
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
m.contextFiles = append(m.contextFiles, stored)
|
||||
}
|
||||
m.runtimeMu.Unlock()
|
||||
|
||||
m.applyComposedSystemPrompt()
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddContextFileContent is a convenience wrapper around [Kit.AddContextFile]
|
||||
// that builds the ContextFile from a path and inline content string. Use this
|
||||
// when the context originates from a database, API response, or any other
|
||||
// non-filesystem source.
|
||||
func (m *Kit) AddContextFileContent(path, content string) (*ContextFile, error) {
|
||||
cf := &ContextFile{Path: path, Content: content}
|
||||
if err := m.AddContextFile(cf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cf, nil
|
||||
}
|
||||
|
||||
// LoadAndAddContextFile reads a file from disk and registers it as a project
|
||||
// context file via [Kit.AddContextFile]. The absolute path is stored on the
|
||||
// resulting ContextFile.
|
||||
func (m *Kit) LoadAndAddContextFile(path string) (*ContextFile, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LoadAndAddContextFile: %w", err)
|
||||
}
|
||||
abs, absErr := filepath.Abs(path)
|
||||
if absErr != nil {
|
||||
abs = path
|
||||
}
|
||||
cf := &ContextFile{
|
||||
Path: abs,
|
||||
Content: strings.TrimSpace(string(data)),
|
||||
}
|
||||
if err := m.AddContextFile(cf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cf, nil
|
||||
}
|
||||
|
||||
// RemoveContextFile removes the context file with the given path and
|
||||
// recomposes the system prompt. Returns true when a matching file was found
|
||||
// and removed, false otherwise.
|
||||
func (m *Kit) RemoveContextFile(path string) bool {
|
||||
m.runtimeMu.Lock()
|
||||
found := false
|
||||
for i, cf := range m.contextFiles {
|
||||
if cf.Path == path {
|
||||
m.contextFiles = append(m.contextFiles[:i], m.contextFiles[i+1:]...)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
m.runtimeMu.Unlock()
|
||||
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
m.applyComposedSystemPrompt()
|
||||
return true
|
||||
}
|
||||
|
||||
// SetContextFiles replaces the active context-file set with the provided
|
||||
// slice. Pass nil or an empty slice to clear all context. The system prompt
|
||||
// is recomposed and applied. ContextFiles with empty Paths are rejected and
|
||||
// no mutation is performed.
|
||||
func (m *Kit) SetContextFiles(files []*ContextFile) error {
|
||||
// Validate first so a bad input doesn't partially mutate state.
|
||||
for i, cf := range files {
|
||||
if cf == nil {
|
||||
return fmt.Errorf("SetContextFiles: context file at index %d is nil", i)
|
||||
}
|
||||
if cf.Path == "" {
|
||||
return fmt.Errorf("SetContextFiles: context file at index %d has empty path", i)
|
||||
}
|
||||
}
|
||||
|
||||
// Defensive copies so caller-side mutation cannot race with composition.
|
||||
copied := make([]*ContextFile, len(files))
|
||||
for i, cf := range files {
|
||||
copied[i] = &ContextFile{
|
||||
Path: cf.Path,
|
||||
Content: strings.TrimSpace(cf.Content),
|
||||
}
|
||||
}
|
||||
|
||||
m.runtimeMu.Lock()
|
||||
m.contextFiles = copied
|
||||
m.runtimeMu.Unlock()
|
||||
|
||||
m.applyComposedSystemPrompt()
|
||||
return nil
|
||||
}
|
||||
+51
-8
@@ -61,6 +61,11 @@ type Kit struct {
|
||||
// systemPromptSource holds the raw configured value (file path or text)
|
||||
// when hasCustomSystemPrompt is true; empty when the built-in default is in use.
|
||||
systemPromptSource string
|
||||
// basePrompt holds the resolved base system prompt text (post file-load,
|
||||
// pre runtime-context composition) captured during New. Used by
|
||||
// RefreshSystemPrompt to recompose after skills/context-file mutations.
|
||||
// Protected by runtimeMu.
|
||||
basePrompt string
|
||||
|
||||
// Hook registries — interception layer (see hooks.go).
|
||||
beforeToolCall *hookRegistry[BeforeToolCallHook, BeforeToolCallResult]
|
||||
@@ -90,6 +95,12 @@ type Kit struct {
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// runtimeMu protects contextFiles and skills against concurrent runtime
|
||||
// mutations via AddSkill / RemoveSkill / AddContextFile etc. The fields
|
||||
// are read by composeSystemPrompt and several other accessors, so all
|
||||
// reads and writes after Kit construction must take this lock.
|
||||
runtimeMu sync.RWMutex
|
||||
|
||||
// steerCh is a buffered channel used to inject steering messages into
|
||||
// the running agent turn via the LLM library's PrepareStep. Created fresh for
|
||||
// each generate() call and set to nil when idle. Protected by steerMu.
|
||||
@@ -653,18 +664,25 @@ func (m *Kit) GetSystemPromptSource() string {
|
||||
// composeSystemPrompt takes a base system prompt and composes it with the
|
||||
// current runtime context: AGENTS.md content, skills metadata, and date/cwd.
|
||||
// This mirrors the composition done during Kit.New() initialization.
|
||||
// It acquires a read lock on runtimeMu while snapshotting contextFiles and
|
||||
// skills, so callers must not hold the write lock.
|
||||
func (m *Kit) composeSystemPrompt(basePrompt string) string {
|
||||
cwd, _ := os.Getwd()
|
||||
pb := skills.NewPromptBuilder(basePrompt)
|
||||
|
||||
m.runtimeMu.RLock()
|
||||
contextFiles := append([]*ContextFile(nil), m.contextFiles...)
|
||||
loadedSkills := append([]*skills.Skill(nil), m.skills...)
|
||||
m.runtimeMu.RUnlock()
|
||||
|
||||
// Inject AGENTS.md content as project context.
|
||||
for _, cf := range m.contextFiles {
|
||||
for _, cf := range contextFiles {
|
||||
pb.WithSection("", fmt.Sprintf("Instructions from: %s\n\n%s", cf.Path, cf.Content))
|
||||
}
|
||||
|
||||
// Inject skills metadata.
|
||||
if len(m.skills) > 0 {
|
||||
pb.WithSkills(m.skills)
|
||||
if len(loadedSkills) > 0 {
|
||||
pb.WithSkills(loadedSkills)
|
||||
}
|
||||
|
||||
// Append current date/time and working directory.
|
||||
@@ -1198,6 +1216,7 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
streaming bool
|
||||
hasCustomSystemPrompt bool
|
||||
systemPromptSource string
|
||||
capturedBasePrompt string
|
||||
)
|
||||
|
||||
if err := func() error {
|
||||
@@ -1349,6 +1368,10 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
|
||||
pb := skills.NewPromptBuilder(basePrompt)
|
||||
|
||||
// Capture the resolved base prompt so RefreshSystemPrompt can
|
||||
// recompose later after runtime skill/context-file mutations.
|
||||
capturedBasePrompt = basePrompt
|
||||
|
||||
// Inject AGENTS.md content as project context.
|
||||
for _, cf := range contextFiles {
|
||||
pb.WithSection("", fmt.Sprintf("Instructions from: %s\n\n%s", cf.Path, cf.Content))
|
||||
@@ -1534,6 +1557,7 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
mcpConfig: mcpConfig,
|
||||
hasCustomSystemPrompt: hasCustomSystemPrompt,
|
||||
systemPromptSource: systemPromptSource,
|
||||
basePrompt: capturedBasePrompt,
|
||||
beforeToolCall: beforeToolCall,
|
||||
afterToolResult: afterToolResult,
|
||||
beforeTurn: beforeTurn,
|
||||
@@ -1560,15 +1584,32 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// GetContextFiles returns the context files (e.g. AGENTS.md) loaded during
|
||||
// initialisation. Returns nil if no context files were found.
|
||||
// GetContextFiles returns the context files (e.g. AGENTS.md) currently active
|
||||
// on this Kit instance. The returned slice is a snapshot — mutating it does
|
||||
// not affect Kit state. Returns nil when no context files are loaded.
|
||||
func (m *Kit) GetContextFiles() []*ContextFile {
|
||||
return m.contextFiles
|
||||
m.runtimeMu.RLock()
|
||||
defer m.runtimeMu.RUnlock()
|
||||
if len(m.contextFiles) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]*ContextFile, len(m.contextFiles))
|
||||
copy(out, m.contextFiles)
|
||||
return out
|
||||
}
|
||||
|
||||
// GetSkills returns the skills loaded during initialisation.
|
||||
// GetSkills returns the skills currently active on this Kit instance. The
|
||||
// returned slice is a snapshot — mutating it does not affect Kit state.
|
||||
// Returns nil when no skills are loaded.
|
||||
func (m *Kit) GetSkills() []*Skill {
|
||||
return m.skills
|
||||
m.runtimeMu.RLock()
|
||||
defer m.runtimeMu.RUnlock()
|
||||
if len(m.skills) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]*Skill, len(m.skills))
|
||||
copy(out, m.skills)
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1613,12 +1654,14 @@ func (m *Kit) expandSkillCommand(prompt string) string {
|
||||
|
||||
// Find the skill by name.
|
||||
var skillPath string
|
||||
m.runtimeMu.RLock()
|
||||
for _, s := range m.skills {
|
||||
if s.Name == name {
|
||||
skillPath = s.Path
|
||||
break
|
||||
}
|
||||
}
|
||||
m.runtimeMu.RUnlock()
|
||||
if skillPath == "" {
|
||||
return prompt
|
||||
}
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
package kit
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/mark3labs/kit/internal/agent"
|
||||
"github.com/mark3labs/kit/internal/skills"
|
||||
)
|
||||
|
||||
// TestAddSkill_AddsAndDeduplicates verifies that AddSkill registers new skills
|
||||
// and that re-adding a skill with the same Name replaces the existing entry
|
||||
// rather than appending a duplicate. agent is nil in these tests; the method
|
||||
// must still mutate the in-memory state and tolerate the absent agent.
|
||||
func TestAddSkill_AddsAndDeduplicates(t *testing.T) {
|
||||
k := &Kit{basePrompt: "base"}
|
||||
|
||||
if err := k.AddSkill(&skills.Skill{Name: "alpha", Content: "first"}); err != nil {
|
||||
t.Fatalf("AddSkill alpha: %v", err)
|
||||
}
|
||||
if err := k.AddSkill(&skills.Skill{Name: "beta", Content: "second"}); err != nil {
|
||||
t.Fatalf("AddSkill beta: %v", err)
|
||||
}
|
||||
got := k.GetSkills()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 skills, got %d", len(got))
|
||||
}
|
||||
|
||||
// Re-adding alpha with new content must replace, not duplicate.
|
||||
if err := k.AddSkill(&skills.Skill{Name: "alpha", Content: "replaced"}); err != nil {
|
||||
t.Fatalf("AddSkill alpha replace: %v", err)
|
||||
}
|
||||
got = k.GetSkills()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 skills after replace, got %d", len(got))
|
||||
}
|
||||
for _, s := range got {
|
||||
if s.Name == "alpha" && s.Content != "replaced" {
|
||||
t.Errorf("alpha content = %q; want %q", s.Content, "replaced")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddSkill_Validation rejects nil skills and unnamed skills with errors
|
||||
// instead of corrupting state.
|
||||
func TestAddSkill_Validation(t *testing.T) {
|
||||
k := &Kit{}
|
||||
if err := k.AddSkill(nil); err == nil {
|
||||
t.Error("expected error for nil skill")
|
||||
}
|
||||
if err := k.AddSkill(&skills.Skill{Content: "x"}); err == nil {
|
||||
t.Error("expected error for unnamed skill")
|
||||
}
|
||||
if got := k.GetSkills(); got != nil {
|
||||
t.Errorf("skills list mutated after invalid AddSkill calls: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoveSkill verifies removal and the false return for misses.
|
||||
func TestRemoveSkill(t *testing.T) {
|
||||
k := &Kit{}
|
||||
_ = k.AddSkill(&skills.Skill{Name: "alpha"})
|
||||
_ = k.AddSkill(&skills.Skill{Name: "beta"})
|
||||
|
||||
if removed := k.RemoveSkill("missing"); removed {
|
||||
t.Error("RemoveSkill(missing) = true; want false")
|
||||
}
|
||||
if removed := k.RemoveSkill("alpha"); !removed {
|
||||
t.Error("RemoveSkill(alpha) = false; want true")
|
||||
}
|
||||
got := k.GetSkills()
|
||||
if len(got) != 1 || got[0].Name != "beta" {
|
||||
t.Errorf("remaining skills = %#v; want [beta]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetSkills replaces the entire set and validates input.
|
||||
func TestSetSkills(t *testing.T) {
|
||||
k := &Kit{}
|
||||
_ = k.AddSkill(&skills.Skill{Name: "alpha"})
|
||||
|
||||
err := k.SetSkills([]*skills.Skill{
|
||||
{Name: "one"},
|
||||
{Name: "two"},
|
||||
{Name: "three"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetSkills: %v", err)
|
||||
}
|
||||
if got := k.GetSkills(); len(got) != 3 {
|
||||
t.Errorf("expected 3 skills, got %d", len(got))
|
||||
}
|
||||
|
||||
// Invalid entry rejects the whole batch.
|
||||
bad := []*skills.Skill{{Name: "ok"}, nil}
|
||||
if err := k.SetSkills(bad); err == nil {
|
||||
t.Error("expected error when batch contains nil")
|
||||
}
|
||||
// State unchanged after rejected batch.
|
||||
if got := k.GetSkills(); len(got) != 3 {
|
||||
t.Errorf("skills mutated by rejected SetSkills batch: len=%d", len(got))
|
||||
}
|
||||
|
||||
// Empty slice clears.
|
||||
if err := k.SetSkills(nil); err != nil {
|
||||
t.Fatalf("SetSkills(nil): %v", err)
|
||||
}
|
||||
if got := k.GetSkills(); got != nil {
|
||||
t.Errorf("expected nil skills after clear; got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadAndAddSkill round-trips a skill file from disk.
|
||||
func TestLoadAndAddSkill(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "demo.md")
|
||||
body := "---\nname: demo\ndescription: demo skill\n---\nhello world"
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write skill file: %v", err)
|
||||
}
|
||||
|
||||
k := &Kit{}
|
||||
s, err := k.LoadAndAddSkill(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAndAddSkill: %v", err)
|
||||
}
|
||||
if s.Name != "demo" {
|
||||
t.Errorf("loaded skill Name = %q; want demo", s.Name)
|
||||
}
|
||||
if got := k.GetSkills(); len(got) != 1 {
|
||||
t.Errorf("expected 1 skill registered, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddContextFile_DeduplicatesByPath confirms identical paths replace
|
||||
// rather than duplicate.
|
||||
func TestAddContextFile_DeduplicatesByPath(t *testing.T) {
|
||||
k := &Kit{}
|
||||
if err := k.AddContextFile(&ContextFile{Path: "/a/AGENTS.md", Content: "v1"}); err != nil {
|
||||
t.Fatalf("AddContextFile: %v", err)
|
||||
}
|
||||
if err := k.AddContextFile(&ContextFile{Path: "/b/AGENTS.md", Content: "vB"}); err != nil {
|
||||
t.Fatalf("AddContextFile: %v", err)
|
||||
}
|
||||
if err := k.AddContextFile(&ContextFile{Path: "/a/AGENTS.md", Content: "v2"}); err != nil {
|
||||
t.Fatalf("AddContextFile replace: %v", err)
|
||||
}
|
||||
|
||||
got := k.GetContextFiles()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 context files, got %d", len(got))
|
||||
}
|
||||
for _, cf := range got {
|
||||
if cf.Path == "/a/AGENTS.md" && cf.Content != "v2" {
|
||||
t.Errorf("/a/AGENTS.md content = %q; want v2", cf.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddContextFile_Validation rejects nil and unpathed entries.
|
||||
func TestAddContextFile_Validation(t *testing.T) {
|
||||
k := &Kit{}
|
||||
if err := k.AddContextFile(nil); err == nil {
|
||||
t.Error("expected error for nil context file")
|
||||
}
|
||||
if err := k.AddContextFile(&ContextFile{Content: "x"}); err == nil {
|
||||
t.Error("expected error for empty path")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoveContextFile_Behavior verifies remove returns true on hit and
|
||||
// false on miss without mutating state on a miss.
|
||||
func TestRemoveContextFile_Behavior(t *testing.T) {
|
||||
k := &Kit{}
|
||||
_ = k.AddContextFile(&ContextFile{Path: "/a", Content: "x"})
|
||||
_ = k.AddContextFile(&ContextFile{Path: "/b", Content: "y"})
|
||||
|
||||
if removed := k.RemoveContextFile("/missing"); removed {
|
||||
t.Error("RemoveContextFile(missing) = true; want false")
|
||||
}
|
||||
if removed := k.RemoveContextFile("/a"); !removed {
|
||||
t.Error("RemoveContextFile(/a) = false; want true")
|
||||
}
|
||||
got := k.GetContextFiles()
|
||||
if len(got) != 1 || got[0].Path != "/b" {
|
||||
t.Errorf("remaining = %#v; want [/b]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetContextFiles replaces and validates batch input.
|
||||
func TestSetContextFiles(t *testing.T) {
|
||||
k := &Kit{}
|
||||
_ = k.AddContextFile(&ContextFile{Path: "/seed", Content: "old"})
|
||||
|
||||
err := k.SetContextFiles([]*ContextFile{
|
||||
{Path: "/x", Content: "x"},
|
||||
{Path: "/y", Content: "y"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetContextFiles: %v", err)
|
||||
}
|
||||
if got := k.GetContextFiles(); len(got) != 2 {
|
||||
t.Errorf("expected 2 context files, got %d", len(got))
|
||||
}
|
||||
|
||||
bad := []*ContextFile{{Path: "/ok"}, {Path: ""}}
|
||||
if err := k.SetContextFiles(bad); err == nil {
|
||||
t.Error("expected error for empty path in batch")
|
||||
}
|
||||
if got := k.GetContextFiles(); len(got) != 2 {
|
||||
t.Errorf("state mutated by rejected batch: len=%d", len(got))
|
||||
}
|
||||
|
||||
if err := k.SetContextFiles(nil); err != nil {
|
||||
t.Fatalf("SetContextFiles(nil): %v", err)
|
||||
}
|
||||
if got := k.GetContextFiles(); got != nil {
|
||||
t.Errorf("expected nil after clear; got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadAndAddContextFile reads from disk and registers the context file.
|
||||
func TestLoadAndAddContextFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "AGENTS.md")
|
||||
const content = "# Agent rules\nuse the new lint config"
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
|
||||
k := &Kit{}
|
||||
cf, err := k.LoadAndAddContextFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAndAddContextFile: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(cf.Path, "AGENTS.md") {
|
||||
t.Errorf("Path = %q; want suffix AGENTS.md", cf.Path)
|
||||
}
|
||||
if !strings.Contains(cf.Content, "use the new lint config") {
|
||||
t.Errorf("Content missing expected body: %q", cf.Content)
|
||||
}
|
||||
got := k.GetContextFiles()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 context file, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddContextFileContent registers an in-memory context blob.
|
||||
func TestAddContextFileContent(t *testing.T) {
|
||||
k := &Kit{}
|
||||
cf, err := k.AddContextFileContent("session://user-123/AGENTS.md", "always greet in French")
|
||||
if err != nil {
|
||||
t.Fatalf("AddContextFileContent: %v", err)
|
||||
}
|
||||
if cf.Path != "session://user-123/AGENTS.md" {
|
||||
t.Errorf("Path = %q", cf.Path)
|
||||
}
|
||||
if cf.Content != "always greet in French" {
|
||||
t.Errorf("Content = %q", cf.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// TestComposeSystemPrompt_IncludesSkillsAndContext verifies that runtime
|
||||
// mutations actually flow into the composed system prompt that the agent
|
||||
// would receive.
|
||||
func TestComposeSystemPrompt_IncludesSkillsAndContext(t *testing.T) {
|
||||
k := &Kit{basePrompt: "BASE-PROMPT-MARKER"}
|
||||
|
||||
if err := k.AddContextFile(&ContextFile{
|
||||
Path: "/proj/AGENTS.md",
|
||||
Content: "CTX-MARKER-OK",
|
||||
}); err != nil {
|
||||
t.Fatalf("AddContextFile: %v", err)
|
||||
}
|
||||
if err := k.AddSkill(&skills.Skill{
|
||||
Name: "greeter",
|
||||
Description: "SKILL-DESC-MARKER",
|
||||
Content: "do greetings",
|
||||
Path: "/skills/greeter.md",
|
||||
}); err != nil {
|
||||
t.Fatalf("AddSkill: %v", err)
|
||||
}
|
||||
|
||||
composed := k.composeSystemPrompt(k.basePrompt)
|
||||
for _, want := range []string{
|
||||
"BASE-PROMPT-MARKER",
|
||||
"CTX-MARKER-OK",
|
||||
"/proj/AGENTS.md",
|
||||
"greeter",
|
||||
"SKILL-DESC-MARKER",
|
||||
} {
|
||||
if !strings.Contains(composed, want) {
|
||||
t.Errorf("composed prompt missing %q\n--- composed ---\n%s", want, composed)
|
||||
}
|
||||
}
|
||||
|
||||
// Removing the skill should remove its marker from the next composition.
|
||||
k.RemoveSkill("greeter")
|
||||
composed = k.composeSystemPrompt(k.basePrompt)
|
||||
if strings.Contains(composed, "SKILL-DESC-MARKER") {
|
||||
t.Errorf("composed prompt still contains removed skill description:\n%s", composed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeMutations_AreThreadSafe stresses the mutation API from multiple
|
||||
// goroutines to surface data races under `go test -race`.
|
||||
func TestRuntimeMutations_AreThreadSafe(t *testing.T) {
|
||||
// Use a non-nil agent so applyComposedSystemPrompt actually invokes
|
||||
// agent.SetSystemPrompt (a no-op agent is fine — we only need the
|
||||
// systemPrompt mutation + fantasy rebuild path to run concurrently so
|
||||
// -race can observe any unsynchronized writes).
|
||||
k := &Kit{basePrompt: "base", agent: &agent.Agent{}}
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 8
|
||||
const iterations = 50
|
||||
|
||||
for g := range goroutines {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for range iterations {
|
||||
_ = k.AddSkill(&skills.Skill{
|
||||
Name: "skill",
|
||||
Content: "content",
|
||||
})
|
||||
_ = k.AddContextFile(&ContextFile{
|
||||
Path: "/shared/AGENTS.md",
|
||||
Content: "shared",
|
||||
})
|
||||
_ = k.GetSkills()
|
||||
_ = k.GetContextFiles()
|
||||
_ = k.composeSystemPrompt("base")
|
||||
k.RemoveSkill("skill")
|
||||
k.RemoveContextFile("/shared/AGENTS.md")
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
+138
-1
@@ -139,13 +139,150 @@ func (m *Kit) ClearSkillCache() {
|
||||
}
|
||||
|
||||
// ReloadSkills re-discovers skills from disk, replacing the current set.
|
||||
// This is called by file watchers when skill files change.
|
||||
// This is called by file watchers when skill files change. The system prompt
|
||||
// is recomposed and applied to the running agent so subsequent turns see the
|
||||
// new skill set.
|
||||
func (m *Kit) ReloadSkills() error {
|
||||
newSkills, err := loadSkills(m.opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reloading skills: %w", err)
|
||||
}
|
||||
m.runtimeMu.Lock()
|
||||
m.skills = newSkills
|
||||
m.runtimeMu.Unlock()
|
||||
m.ClearSkillCache()
|
||||
m.applyComposedSystemPrompt()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime skill management (Issue #36)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The methods below let SDK consumers (chatbot hosts, multi-tenant agents)
|
||||
// mutate the active skill set after Kit construction. Each mutation recomposes
|
||||
// the system prompt and applies it to the underlying agent so the LLM sees
|
||||
// the new skill metadata on its next turn.
|
||||
|
||||
// AddSkill registers a single skill on this Kit instance. The skill object
|
||||
// can be built programmatically (no file on disk required) — only Name and
|
||||
// Content are mandatory. If a skill with the same Name is already loaded the
|
||||
// new skill replaces it. Returns an error when skill is nil or has an empty
|
||||
// name.
|
||||
//
|
||||
// After mutation the system prompt is recomposed and applied to the running
|
||||
// agent so the next turn sees the updated skill metadata. AddSkill is safe to
|
||||
// call from any goroutine.
|
||||
func (m *Kit) AddSkill(skill *Skill) error {
|
||||
if skill == nil {
|
||||
return fmt.Errorf("AddSkill: skill is nil")
|
||||
}
|
||||
if skill.Name == "" {
|
||||
return fmt.Errorf("AddSkill: skill name is required")
|
||||
}
|
||||
|
||||
m.runtimeMu.Lock()
|
||||
replaced := false
|
||||
for i, s := range m.skills {
|
||||
if s.Name == skill.Name {
|
||||
m.skills[i] = skill
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
m.skills = append(m.skills, skill)
|
||||
}
|
||||
m.runtimeMu.Unlock()
|
||||
|
||||
m.ClearSkillCache()
|
||||
m.applyComposedSystemPrompt()
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAndAddSkill loads a skill from a filesystem path (single .md/.txt file)
|
||||
// and adds it via [Kit.AddSkill]. Returns the loaded skill on success.
|
||||
func (m *Kit) LoadAndAddSkill(path string) (*Skill, error) {
|
||||
s, err := skills.LoadSkill(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LoadAndAddSkill: %w", err)
|
||||
}
|
||||
if err := m.AddSkill(s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// RemoveSkill removes the named skill from this Kit instance and recomposes
|
||||
// the system prompt. Returns true when a skill with that name was found and
|
||||
// removed, false otherwise.
|
||||
func (m *Kit) RemoveSkill(name string) bool {
|
||||
m.runtimeMu.Lock()
|
||||
found := false
|
||||
for i, s := range m.skills {
|
||||
if s.Name == name {
|
||||
m.skills = append(m.skills[:i], m.skills[i+1:]...)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
m.runtimeMu.Unlock()
|
||||
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
m.ClearSkillCache()
|
||||
m.applyComposedSystemPrompt()
|
||||
return true
|
||||
}
|
||||
|
||||
// SetSkills replaces the active skill set with the provided slice. Pass nil
|
||||
// or an empty slice to remove all skills. The system prompt is recomposed and
|
||||
// applied. Skills with empty names are rejected and no mutation is performed.
|
||||
func (m *Kit) SetSkills(skillList []*Skill) error {
|
||||
// Validate first so a bad input doesn't partially mutate state.
|
||||
for i, s := range skillList {
|
||||
if s == nil {
|
||||
return fmt.Errorf("SetSkills: skill at index %d is nil", i)
|
||||
}
|
||||
if s.Name == "" {
|
||||
return fmt.Errorf("SetSkills: skill at index %d has empty name", i)
|
||||
}
|
||||
}
|
||||
|
||||
copied := make([]*Skill, len(skillList))
|
||||
copy(copied, skillList)
|
||||
|
||||
m.runtimeMu.Lock()
|
||||
m.skills = copied
|
||||
m.runtimeMu.Unlock()
|
||||
|
||||
m.ClearSkillCache()
|
||||
m.applyComposedSystemPrompt()
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyComposedSystemPrompt recomposes the system prompt from the captured
|
||||
// base prompt + current contextFiles + current skills + date/cwd, and pushes
|
||||
// the result onto the underlying agent. No-op when the agent is unset (i.e.
|
||||
// during construction).
|
||||
func (m *Kit) applyComposedSystemPrompt() {
|
||||
if m.agent == nil {
|
||||
return
|
||||
}
|
||||
m.runtimeMu.RLock()
|
||||
base := m.basePrompt
|
||||
m.runtimeMu.RUnlock()
|
||||
composed := m.composeSystemPrompt(base)
|
||||
m.agent.SetSystemPrompt(composed)
|
||||
}
|
||||
|
||||
// RefreshSystemPrompt manually recomposes the system prompt from the current
|
||||
// skills and context files and applies it to the agent. Call this after a
|
||||
// batch of low-level mutations or to force a re-render of the date/cwd
|
||||
// section. Most callers don't need to invoke this directly because
|
||||
// AddSkill, RemoveSkill, SetSkills, AddContextFile, RemoveContextFile, and
|
||||
// SetContextFiles all refresh automatically.
|
||||
func (m *Kit) RefreshSystemPrompt() {
|
||||
m.applyComposedSystemPrompt()
|
||||
}
|
||||
|
||||
@@ -160,6 +160,14 @@ when embedding Kit as a library.
|
||||
| `SkillsDir` | `string` | — | Override default skills directory |
|
||||
| `NoSkills` | `bool` | `false` | Disable skill loading entirely |
|
||||
|
||||
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` and
|
||||
`AddContextFile` / `AddContextFileContent` / `RemoveContextFile` /
|
||||
`SetContextFiles` methods on `*kit.Kit`. See
|
||||
[Runtime skills and context files](/sdk/overview#runtime-skills-and-context-files).
|
||||
|
||||
### Compaction & MCP
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|
||||
@@ -201,6 +201,66 @@ host, _ := kit.New(ctx, &kit.Options{
|
||||
n, _ := host.AddInProcessMCPServer(ctx, "docs", mcpSrv)
|
||||
```
|
||||
|
||||
## Runtime skills and context files
|
||||
|
||||
Kit auto-discovers skills and `AGENTS.md`-style context files during `New()`,
|
||||
but multi-tenant hosts (chatbots, web services, per-user agents) often need
|
||||
to swap these **after** construction. The runtime mutators below recompose
|
||||
the system prompt and apply it to the agent so the next turn picks up the
|
||||
updated instructions — no restart, no file shuffling.
|
||||
|
||||
```go
|
||||
// Add a programmatic skill — no file on disk required.
|
||||
host.AddSkill(&kit.Skill{
|
||||
Name: "polite-french",
|
||||
Description: "Respond in French and always greet the user.",
|
||||
Content: "Always reply in French. Open every response with 'Bonjour'.",
|
||||
})
|
||||
|
||||
// Or load one from disk.
|
||||
host.LoadAndAddSkill("/var/skills/refund-policy.md")
|
||||
|
||||
// Project context (AGENTS.md equivalents): inline content from a DB...
|
||||
host.AddContextFileContent(
|
||||
fmt.Sprintf("session://%s/AGENTS.md", userID),
|
||||
rulesFromDB,
|
||||
)
|
||||
// ...or load from disk.
|
||||
host.LoadAndAddContextFile("/etc/agents/tenant-acme.md")
|
||||
|
||||
// Remove individually when a session ends.
|
||||
host.RemoveSkill("polite-french")
|
||||
host.RemoveContextFile(fmt.Sprintf("session://%s/AGENTS.md", userID))
|
||||
|
||||
// Or replace the whole set in one call.
|
||||
host.SetSkills(activeSkillsForUser)
|
||||
host.SetContextFiles(activeContextForUser)
|
||||
|
||||
// Inspect current state (snapshot copies — safe to mutate).
|
||||
skills := host.GetSkills()
|
||||
ctxFiles := host.GetContextFiles()
|
||||
```
|
||||
|
||||
Key points:
|
||||
|
||||
- **Auto-refresh.** Every `Add*` / `Remove*` / `Set*` call recomposes the system
|
||||
prompt against the captured base prompt (preserving per-model overrides and
|
||||
`--system-prompt` resolution) and pushes the result onto the agent. Call
|
||||
`host.RefreshSystemPrompt()` only if you mutate state through a different
|
||||
path and need to force a re-render.
|
||||
- **Dedup keys.** Skills dedupe by `Name`; context files dedupe by `Path`.
|
||||
Re-adding the same key replaces the entry instead of appending a duplicate.
|
||||
- **Path is opaque.** `ContextFile.Path` does not have to point at a real file
|
||||
— it's only used for dedup and for the `Instructions from: <Path>` header
|
||||
injected into the prompt. URIs like `session://user-123/AGENTS.md` work fine.
|
||||
- **Thread safety.** All readers and mutators are safe to call concurrently
|
||||
from multiple goroutines; the underlying state is guarded by an internal
|
||||
`RWMutex`.
|
||||
- **Init-time options still apply.** `Options.Skills`, `Options.SkillsDir`,
|
||||
`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).
|
||||
|
||||
## MCP prompts and resources
|
||||
|
||||
Query prompts and resources exposed by connected MCP servers:
|
||||
|
||||
Reference in New Issue
Block a user