diff --git a/README.md b/README.md index c5ce7f02..8637e4df 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/internal/agent/agent.go b/internal/agent/agent.go index c196fcd2..3d2d7eab 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -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 diff --git a/pkg/kit/README.md b/pkg/kit/README.md index 24c813a9..50d69a72 100644 --- a/pkg/kit/README.md +++ b/pkg/kit/README.md @@ -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 diff --git a/pkg/kit/context_files.go b/pkg/kit/context_files.go new file mode 100644 index 00000000..241b3d8a --- /dev/null +++ b/pkg/kit/context_files.go @@ -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: " 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 +} diff --git a/pkg/kit/kit.go b/pkg/kit/kit.go index 1df69b09..707d3bf5 100644 --- a/pkg/kit/kit.go +++ b/pkg/kit/kit.go @@ -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 } diff --git a/pkg/kit/runtime_skills_context_test.go b/pkg/kit/runtime_skills_context_test.go new file mode 100644 index 00000000..384f2e38 --- /dev/null +++ b/pkg/kit/runtime_skills_context_test.go @@ -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() +} diff --git a/pkg/kit/skills.go b/pkg/kit/skills.go index 4c153900..e07d42ed 100644 --- a/pkg/kit/skills.go +++ b/pkg/kit/skills.go @@ -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() +} diff --git a/www/pages/sdk/options.md b/www/pages/sdk/options.md index 00fe433e..9896b16d 100644 --- a/www/pages/sdk/options.md +++ b/www/pages/sdk/options.md @@ -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 | diff --git a/www/pages/sdk/overview.md b/www/pages/sdk/overview.md index 79a18c35..61e708eb 100644 --- a/www/pages/sdk/overview.md +++ b/www/pages/sdk/overview.md @@ -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: ` 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: