mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
7a04bdfeba
* feat(kit): isolate viper config per Kit instance + add NewAgent (#40) - Give each kit.New()/NewAgent() call an isolated *viper.Viper store so multiple Kit instances in one process no longer clobber each other's config; runtime mutators (SetModel, SetThinkingLevel) touch only the owning instance, making subagent spawning and multi-Kit embedding race-free - Thread the per-instance store through internal/config, internal/models (ProviderConfig.ConfigStore), internal/kitsetup, and the extension runner, with a nil -> process-global fallback so the CLI is unaffected - Share the global store when Options.CLI != nil to preserve cobra flag bindings (also opted in for internal/acpserver) - Remove viperInitMu; preserve the tri-state IsSet precedence contract and sdkDefaultMaxTokens floor - Add ergonomic NewAgent + functional options (WithModel, WithStreaming, Ephemeral, etc.); NewAgent defaults streaming on, opt out via WithStreaming(false). New(ctx, *Options) behavior is unchanged - Add config-isolation regression test and NewAgent/option coverage; document NewAgent and per-instance isolation in README Fixes #40 * docs(sdk): document NewAgent options and per-instance config isolation - Add "Functional options (NewAgent)" and "Per-instance config isolation" sections to the docs site SDK overview, with an options table and a "when to use which" constructor comparison - Cross-reference NewAgent from the SDK options page and correct the now per-instance ProviderAPIKey precedence wording - Document NewAgent + With* helpers and config isolation in pkg/kit/README and list NewAgent/Option in the API reference - Show the NewAgent constructor in the SDK examples getting-started snippet * fix(kit): correct config loading and isolate ACP sessions - Isolate each ACP session's config store instead of sharing the global viper, preventing per-session SetModel/SetThinkingLevel races; seed the root-command flag values (model, thinking-level, provider URL/key) so `kit acp -m <model>` is still honored - Run initConfig for isolated SDK stores by gating on opts.CLI instead of v.GetString("model"), which setSDKDefaults always populates and thus skipped .kit.yml / KIT_* loading for SDK callers - Configure KIT_* env overrides unconditionally in initConfig so passing an explicit config file no longer disables environment variable support - Wrap config unmarshal/validate errors with %w to preserve the error chain * fix(kit): make Options.Streaming a *bool to honor unset - Change Options.Streaming from bool to *bool so a zero-valued Options no longer forces stream=false; New only sets the key when non-nil, letting streaming resolve through the precedence chain (env -> config -> default true). This also fixes the CLI path, which never set the field - Mirror the existing sampling-parameter pointer pattern instead of adding a separate StreamingSet sentinel, keeping Options internally consistent - Update WithStreaming/NewAgent, subagent, and ACP callers to the pointer form; add regression tests for the nil-default and explicit opt-out paths - Update SDK docs (README, pkg/kit/README, options page) with the ptrBool helper and *bool semantics * fix(kit): inherit parent provider config in subagents - Copy the parent's effective provider/runtime config (API key, URL, TLS, thinking level, max-tokens, samplers) onto child Options in Kit.Subagent. After the per-instance viper isolation, the child's isolated store only re-loaded .kit.yml / KIT_*, silently dropping config the parent set via programmatic Options or runtime setters like SetThinkingLevel - Preserve the IsSet tri-state for max-tokens and samplers so per-model defaults still apply on the child when the parent left them unset - Add TestInheritProviderConfig covering propagation, unset keys, and nil-safety
400 lines
12 KiB
Go
400 lines
12 KiB
Go
package kit_test
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/spf13/viper"
|
|
|
|
kit "github.com/mark3labs/kit/pkg/kit"
|
|
)
|
|
|
|
func TestNew(t *testing.T) {
|
|
if os.Getenv("ANTHROPIC_API_KEY") == "" {
|
|
t.Skip("Skipping test: ANTHROPIC_API_KEY not set")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Test default initialization
|
|
opts := &kit.Options{
|
|
Model: "anthropic/claude-sonnet-4-5-20250929",
|
|
}
|
|
host, err := kit.New(ctx, opts)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create Kit with defaults: %v", err)
|
|
}
|
|
defer func() { _ = host.Close() }()
|
|
|
|
if host.GetModelString() == "" {
|
|
t.Error("Model string should not be empty")
|
|
}
|
|
}
|
|
|
|
func TestNewWithOptions(t *testing.T) {
|
|
if os.Getenv("ANTHROPIC_API_KEY") == "" {
|
|
t.Skip("Skipping test: ANTHROPIC_API_KEY not set")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
opts := &kit.Options{
|
|
Model: "anthropic/claude-sonnet-4-5-20250929",
|
|
MaxSteps: 5,
|
|
Quiet: true,
|
|
}
|
|
|
|
host, err := kit.New(ctx, opts)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create Kit with options: %v", err)
|
|
}
|
|
defer func() { _ = host.Close() }()
|
|
|
|
if host.GetModelString() != opts.Model {
|
|
t.Errorf("Expected model %s, got %s", opts.Model, host.GetModelString())
|
|
}
|
|
}
|
|
|
|
// TestNewWithGenerationOptions verifies that the SDK-only generation
|
|
// parameter overrides on Options propagate all the way through to the
|
|
// agent without requiring any viper.Set workarounds in caller code.
|
|
func TestNewWithGenerationOptions(t *testing.T) {
|
|
if os.Getenv("ANTHROPIC_API_KEY") == "" {
|
|
t.Skip("Skipping test: ANTHROPIC_API_KEY not set")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// MaxTokens override — keep ThinkingLevel off so Anthropic's thinking
|
|
// budget doesn't auto-bump MaxTokens above what we configured.
|
|
t.Run("MaxTokens", func(t *testing.T) {
|
|
defer resetViper()
|
|
|
|
const want = 12345
|
|
host, err := kit.New(ctx, &kit.Options{
|
|
Model: "anthropic/claude-sonnet-4-5-20250929",
|
|
Quiet: true,
|
|
MaxTokens: want,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Failed to create Kit: %v", err)
|
|
}
|
|
defer func() { _ = host.Close() }()
|
|
|
|
if got := host.MaxTokens(); got != want {
|
|
t.Errorf("Options.MaxTokens=%d did not propagate; Kit.MaxTokens()=%d", want, got)
|
|
}
|
|
if !host.ConfigValueIsSetForTest("max-tokens") {
|
|
t.Error("max-tokens should be marked explicitly set on the instance store after MaxTokens override")
|
|
}
|
|
})
|
|
|
|
// ThinkingLevel override — verified via the public getter, which
|
|
// reads back the configured (not provider-derived) level.
|
|
t.Run("ThinkingLevel", func(t *testing.T) {
|
|
defer resetViper()
|
|
|
|
const want = "high"
|
|
host, err := kit.New(ctx, &kit.Options{
|
|
Model: "anthropic/claude-sonnet-4-5-20250929",
|
|
Quiet: true,
|
|
ThinkingLevel: want,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Failed to create Kit: %v", err)
|
|
}
|
|
defer func() { _ = host.Close() }()
|
|
|
|
if got := host.GetThinkingLevel(); got != want {
|
|
t.Errorf("Options.ThinkingLevel=%q did not propagate; Kit.GetThinkingLevel()=%q", want, got)
|
|
}
|
|
})
|
|
|
|
// Temperature override — pointer semantics let callers distinguish
|
|
// "explicitly 0.0" from "unset", which we assert by pushing a distinct
|
|
// value and reading it back off viper's merged state.
|
|
t.Run("Temperature", func(t *testing.T) {
|
|
defer resetViper()
|
|
|
|
want := float32(0.12345)
|
|
host, err := kit.New(ctx, &kit.Options{
|
|
Model: "anthropic/claude-sonnet-4-5-20250929",
|
|
Quiet: true,
|
|
Temperature: &want,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Failed to create Kit: %v", err)
|
|
}
|
|
defer func() { _ = host.Close() }()
|
|
|
|
if !host.ConfigValueIsSetForTest("temperature") {
|
|
t.Fatal("temperature should be marked explicitly set on the instance store after Temperature override")
|
|
}
|
|
if got := float32(host.ConfigFloatForTest("temperature")); got != want {
|
|
t.Errorf("Options.Temperature=%v did not propagate; instance store=%v", want, got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestNewPreservesIsSetSemantics verifies that creating a Kit WITHOUT
|
|
// populating the generation-param Options fields does NOT mark those
|
|
// keys as explicitly set in viper. This is the precedence contract
|
|
// that per-model defaults (ApplyModelSettings) and right-sizing
|
|
// (rightSizeMaxTokens) rely on.
|
|
//
|
|
// Previously setSDKDefaults() used viper.SetDefault() for every param,
|
|
// which caused viper.IsSet() to return true for all of them — silently
|
|
// suppressing per-model defaults and pinning max-tokens at 4096 even
|
|
// on models with much larger output limits.
|
|
func TestNewPreservesIsSetSemantics(t *testing.T) {
|
|
if os.Getenv("ANTHROPIC_API_KEY") == "" {
|
|
t.Skip("Skipping test: ANTHROPIC_API_KEY not set")
|
|
}
|
|
|
|
defer resetViper()
|
|
|
|
ctx := context.Background()
|
|
host, err := kit.New(ctx, &kit.Options{
|
|
Model: "anthropic/claude-sonnet-4-5-20250929",
|
|
Quiet: true,
|
|
NoSession: true,
|
|
SkipConfig: true, // isolate from any ~/.kit.yml values
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Failed to create Kit: %v", err)
|
|
}
|
|
defer func() { _ = host.Close() }()
|
|
|
|
// These keys must remain "unset" from viper's perspective so the
|
|
// downstream isExplicitlySet() checks allow per-model defaults to
|
|
// take effect.
|
|
checkKeys := []string{
|
|
"max-tokens",
|
|
"temperature",
|
|
"top-p",
|
|
"top-k",
|
|
"frequency-penalty",
|
|
"presence-penalty",
|
|
"thinking-level",
|
|
}
|
|
|
|
// With SkipConfig: true, InitConfig() is not invoked, so viper has
|
|
// no env-var bindings registered. Any IsSet() here would come purely
|
|
// from SDK-side SetDefault/Set calls — which is exactly what this
|
|
// test is guarding against.
|
|
for _, k := range checkKeys {
|
|
if host.ConfigValueIsSetForTest(k) {
|
|
t.Errorf("instance store reports %q explicitly set when no Options field set it "+
|
|
"(SDK defaults must not corrupt IsSet semantics)", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestNewWithProviderOptions verifies that programmatic provider overrides
|
|
// (API key, URL) take effect without env vars or config files, and that
|
|
// Options.ProviderAPIKey *wins* over any pre-existing viper state.
|
|
func TestNewWithProviderOptions(t *testing.T) {
|
|
if os.Getenv("ANTHROPIC_API_KEY") == "" {
|
|
t.Skip("Skipping test: ANTHROPIC_API_KEY not set")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
t.Run("succeeds with API key from Options", func(t *testing.T) {
|
|
defer resetViper()
|
|
|
|
apiKey := os.Getenv("ANTHROPIC_API_KEY")
|
|
host, err := kit.New(ctx, &kit.Options{
|
|
Model: "anthropic/claude-sonnet-4-5-20250929",
|
|
Quiet: true,
|
|
NoSession: true,
|
|
ProviderAPIKey: apiKey,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Failed to create Kit with ProviderAPIKey option: %v", err)
|
|
}
|
|
defer func() { _ = host.Close() }()
|
|
|
|
if got := host.ConfigStringForTest("provider-api-key"); got != apiKey {
|
|
t.Errorf("Options.ProviderAPIKey did not propagate to the instance store; got %q (len=%d)", got, len(got))
|
|
}
|
|
})
|
|
|
|
// Override precedence: even when the process-global store already holds a
|
|
// different provider-api-key value, Options.ProviderAPIKey must win on the
|
|
// Kit's isolated store.
|
|
t.Run("Options override beats pre-existing viper state", func(t *testing.T) {
|
|
defer resetViper()
|
|
|
|
viper.Set("provider-api-key", "sk-config-file-placeholder")
|
|
|
|
want := "sk-from-options-override"
|
|
// Use an OpenAI-flavored model so the validation path accepts
|
|
// the placeholder without attempting a real Anthropic handshake.
|
|
host, err := kit.New(ctx, &kit.Options{
|
|
Model: "openai/gpt-4o-mini",
|
|
Quiet: true,
|
|
NoSession: true,
|
|
NoExtensions: true,
|
|
DisableCoreTools: true,
|
|
ProviderAPIKey: want,
|
|
})
|
|
// Creation may still fail if the model registry is strict, but
|
|
// we only care that the override reached the instance store before
|
|
// any provider handshake happened.
|
|
if host == nil {
|
|
t.Fatalf("expected a Kit instance to inspect; got nil (err=%v)", err)
|
|
}
|
|
defer func() { _ = host.Close() }()
|
|
_ = err
|
|
|
|
if got := host.ConfigStringForTest("provider-api-key"); got != want {
|
|
t.Errorf("Options.ProviderAPIKey did not override pre-existing value on the instance store; got %q, want %q", got, want)
|
|
}
|
|
})
|
|
|
|
// ProviderURL override must also reach viper.
|
|
t.Run("ProviderURL propagates", func(t *testing.T) {
|
|
defer resetViper()
|
|
|
|
const want = "https://custom.example.com/v1"
|
|
host, err := kit.New(ctx, &kit.Options{
|
|
Model: "anthropic/claude-sonnet-4-5-20250929",
|
|
Quiet: true,
|
|
NoSession: true,
|
|
ProviderURL: want,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Failed to create Kit with ProviderURL option: %v", err)
|
|
}
|
|
defer func() { _ = host.Close() }()
|
|
|
|
if got := host.ConfigStringForTest("provider-url"); got != want {
|
|
t.Errorf("Options.ProviderURL did not propagate; got %q, want %q", got, want)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSessionManagement(t *testing.T) {
|
|
if os.Getenv("ANTHROPIC_API_KEY") == "" {
|
|
t.Skip("Skipping test: ANTHROPIC_API_KEY not set")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
host, err := kit.New(ctx, &kit.Options{Quiet: true, NoSession: true})
|
|
if err != nil {
|
|
t.Fatalf("Failed to create Kit: %v", err)
|
|
}
|
|
defer func() { _ = host.Close() }()
|
|
|
|
// Tree session should be configured.
|
|
ts := host.GetTreeSession()
|
|
if ts == nil {
|
|
t.Fatal("Expected tree session to be configured")
|
|
}
|
|
|
|
// Test clear session resets leaf.
|
|
host.ClearSession()
|
|
|
|
// Verify session info accessors.
|
|
if id := host.GetSessionID(); id == "" {
|
|
t.Error("Expected non-empty session ID")
|
|
}
|
|
}
|
|
|
|
// resetViper wipes viper's global state so a test case doesn't leak
|
|
// viper.Set() calls into the next one. Used via defer in subtests.
|
|
func resetViper() { viper.Reset() }
|
|
|
|
// TestNewSystemPromptFilePath is a regression test for issue #25.
|
|
//
|
|
// When Options.SystemPrompt (or the --system-prompt flag / config entry) is a
|
|
// file path, Kit must resolve the path to its file contents *before* the
|
|
// PromptBuilder composes the runtime context. Previously the path string
|
|
// itself was used verbatim as the base prompt, so the LLM received the path —
|
|
// not the prompt — as its system message.
|
|
func TestNewSystemPromptFilePath(t *testing.T) {
|
|
if os.Getenv("ANTHROPIC_API_KEY") == "" {
|
|
t.Skip("Skipping test: ANTHROPIC_API_KEY not set")
|
|
}
|
|
defer resetViper()
|
|
|
|
const promptContent = "You are a strict regression-test persona. Marker: KIT-25-OK"
|
|
|
|
tmpFile, err := os.CreateTemp(t.TempDir(), "kit-system-prompt-*.md")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp prompt file: %v", err)
|
|
}
|
|
if _, err := tmpFile.WriteString(promptContent); err != nil {
|
|
t.Fatalf("failed to write temp prompt file: %v", err)
|
|
}
|
|
if err := tmpFile.Close(); err != nil {
|
|
t.Fatalf("failed to close temp prompt file: %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
host, err := kit.New(ctx, &kit.Options{
|
|
Model: "anthropic/claude-sonnet-4-5-20250929",
|
|
SystemPrompt: tmpFile.Name(),
|
|
Quiet: true,
|
|
NoSession: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Failed to create Kit with system-prompt file: %v", err)
|
|
}
|
|
defer func() { _ = host.Close() }()
|
|
|
|
if !host.HasCustomSystemPrompt() {
|
|
t.Error("HasCustomSystemPrompt() = false; want true when --system-prompt is set")
|
|
}
|
|
if got, want := host.GetSystemPromptSource(), tmpFile.Name(); got != want {
|
|
t.Errorf("GetSystemPromptSource() = %q; want %q", got, want)
|
|
}
|
|
|
|
// The composed system prompt is written back to the instance store after
|
|
// PromptBuilder runs. It must contain the file's contents, not the file path.
|
|
composed := host.ConfigStringForTest("system-prompt")
|
|
if !strings.Contains(composed, promptContent) {
|
|
t.Errorf("composed system-prompt does not contain file contents\n composed = %q\n want substring = %q", composed, promptContent)
|
|
}
|
|
if strings.TrimSpace(composed) == tmpFile.Name() {
|
|
t.Errorf("composed system-prompt is the file path verbatim (%q); LoadSystemPrompt was not applied before PromptBuilder", composed)
|
|
}
|
|
}
|
|
|
|
// TestNewSystemPromptInline confirms that inline system-prompt strings still
|
|
// flow through unchanged after the file-path resolution change.
|
|
func TestNewSystemPromptInline(t *testing.T) {
|
|
if os.Getenv("ANTHROPIC_API_KEY") == "" {
|
|
t.Skip("Skipping test: ANTHROPIC_API_KEY not set")
|
|
}
|
|
defer resetViper()
|
|
|
|
const inline = "You are a concise inline-prompt persona."
|
|
|
|
ctx := context.Background()
|
|
host, err := kit.New(ctx, &kit.Options{
|
|
Model: "anthropic/claude-sonnet-4-5-20250929",
|
|
SystemPrompt: inline,
|
|
Quiet: true,
|
|
NoSession: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Failed to create Kit with inline system-prompt: %v", err)
|
|
}
|
|
defer func() { _ = host.Close() }()
|
|
|
|
if !host.HasCustomSystemPrompt() {
|
|
t.Error("HasCustomSystemPrompt() = false; want true for inline prompt")
|
|
}
|
|
if got := host.GetSystemPromptSource(); got != inline {
|
|
t.Errorf("GetSystemPromptSource() = %q; want %q", got, inline)
|
|
}
|
|
if composed := host.ConfigStringForTest("system-prompt"); !strings.Contains(composed, inline) {
|
|
t.Errorf("composed system-prompt missing inline content; got %q", composed)
|
|
}
|
|
}
|