feat(kit): isolate viper config per Kit instance + add NewAgent (#42)

* 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
This commit is contained in:
Ed Zynda
2026-06-02 14:41:35 +03:00
committed by GitHub
parent 7e4708f511
commit 7a04bdfeba
18 changed files with 1032 additions and 259 deletions
+33 -1
View File
@@ -556,7 +556,7 @@ host, err := kit.New(ctx, &kit.Options{
SystemPrompt: "You are a helpful bot",
ConfigFile: "/path/to/config.yml",
MaxSteps: 10,
Streaming: true,
Streaming: ptr(true), // *bool: nil = unset (default true), &false = off
Quiet: true,
// Generation parameters (override env/config/per-model defaults)
@@ -603,6 +603,38 @@ are pointer types so explicit `0.0` is distinguishable from "leave alone"; a
non-zero `MaxTokens` suppresses automatic right-sizing the same way `--max-tokens`
does on the CLI.
### Functional options (`NewAgent`)
For simple programmatic setups, `kit.NewAgent` offers an ergonomic
functional-options front door over `kit.New`. Streaming is **enabled by
default**; pass `kit.WithStreaming(false)` to opt out.
```go
host, err := kit.NewAgent(ctx,
kit.WithModel("anthropic/claude-sonnet-4-5-20250929"),
kit.WithSystemPrompt("You are a helpful assistant."),
kit.WithMaxTokens(8192),
kit.WithThinkingLevel("medium"),
kit.Ephemeral(), // in-memory session, no persistence
)
```
Available options: `WithModel`, `WithSystemPrompt`, `WithStreaming`,
`WithMaxTokens`, `WithThinkingLevel`, `WithTools`, `WithExtraTools`,
`WithProviderAPIKey`, `WithProviderURL`, `WithConfigFile`, `WithDebug`, and
`Ephemeral`. For advanced configuration not covered by the helpers (custom MCP
config, in-process MCP servers, session backends, MCP task tuning) construct an
`Options` value explicitly and call `kit.New`.
### Per-instance config isolation
Each `kit.New` / `kit.NewAgent` call owns an **isolated configuration store**,
so constructing multiple Kit instances in the same process is safe: setting the
model, thinking level, or generation parameters on one never affects another,
and runtime mutators (`SetModel`, `SetThinkingLevel`) only touch the owning
instance. This makes subagent spawning and multi-Kit embedding race-free with
no external synchronization required.
### MCP OAuth (remote MCP servers)
When a remote MCP server returns 401, Kit runs the full OAuth flow (dynamic
+10
View File
@@ -42,4 +42,14 @@ defer host.Close()
response, err := host.Prompt(ctx, "Hello!")
```
Or use the functional-options constructor for quick setups (streaming defaults on):
```go
host, err := kit.NewAgent(ctx,
kit.WithModel("anthropic/claude-sonnet-4-5-20250929"),
kit.WithSystemPrompt("You are a helpful assistant."),
kit.Ephemeral(),
)
```
See the [SDK README](../../pkg/kit/README.md) for the full API reference.
+15 -3
View File
@@ -7,6 +7,7 @@ import (
"sync"
"github.com/charmbracelet/log"
"github.com/spf13/viper"
"github.com/mark3labs/kit/internal/extbridge"
"github.com/mark3labs/kit/internal/extensions"
@@ -38,10 +39,21 @@ func newSessionRegistry() *sessionRegistry {
// given working directory. The Kit-generated session ID is used as the ACP
// session ID so the mapping is 1:1.
func (r *sessionRegistry) create(ctx context.Context, cwd string) (*acpSession, error) {
// Each ACP session gets its own isolated config store (CLI is left nil) so
// per-session SetModel / SetThinkingLevel calls cannot race or bleed across
// the sessionRegistry. We seed the relevant root-command flag values from
// the process-global store (which cobra populated from flags) so launching
// `kit acp -m <model> [--thinking-level ...] [--provider-url ...]` is still
// honored; .kit.yml and KIT_* env vars are loaded per session by kit.New.
streamOn := true
kitInstance, err := kit.New(ctx, &kit.Options{
SessionDir: cwd,
Quiet: true,
Streaming: true,
SessionDir: cwd,
Quiet: true,
Streaming: &streamOn,
Model: viper.GetString("model"),
ThinkingLevel: viper.GetString("thinking-level"),
ProviderURL: viper.GetString("provider-url"),
ProviderAPIKey: viper.GetString("provider-api-key"),
})
if err != nil {
// Provide actionable guidance for provider auth errors, which are
+25 -9
View File
@@ -7,32 +7,48 @@ import (
"github.com/spf13/viper"
)
// LoadAndValidateConfig loads configuration from viper, fixes environment variable
// casing issues, and validates the configuration. Returns an error if loading or
// validation fails.
// LoadAndValidateConfig loads configuration from the process-global viper
// store, fixes environment variable casing issues, and validates the
// configuration. Returns an error if loading or validation fails.
//
// This is a convenience wrapper around [LoadAndValidateConfigFrom] using the
// shared global store; it is retained for the CLI and other callers that rely
// on viper's process-global state.
func LoadAndValidateConfig() (*Config, error) {
return LoadAndValidateConfigFrom(viper.GetViper())
}
// LoadAndValidateConfigFrom loads configuration from the supplied per-instance
// store, fixes environment variable casing issues, and validates the
// configuration. When v is nil, the process-global store is used. Threading an
// explicit store lets each Kit instance own an isolated configuration without
// clobbering other instances in the same process.
func LoadAndValidateConfigFrom(v *viper.Viper) (*Config, error) {
if v == nil {
v = viper.GetViper()
}
config := &Config{
MCPServers: make(map[string]MCPServerConfig),
}
if err := viper.Unmarshal(config); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %v", err)
if err := v.Unmarshal(config); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
// Fix environment variable case sensitivity issue
// Viper lowercases all keys, but we need to preserve the original case for environment variables
fixEnvironmentCase(config)
fixEnvironmentCase(v, config)
if err := config.Validate(); err != nil {
return nil, fmt.Errorf("invalid config: %v", err)
return nil, fmt.Errorf("invalid config: %w", err)
}
return config, nil
}
// fixEnvironmentCase fixes the case of environment variable keys that were lowercased by Viper
func fixEnvironmentCase(config *Config) {
func fixEnvironmentCase(v *viper.Viper, config *Config) {
// Get the raw config data from viper
rawConfig := viper.AllSettings()
rawConfig := v.AllSettings()
// Check if we have mcpServers in the raw config
if mcpServersRaw, ok := rawConfig["mcpservers"]; ok {
+18 -1
View File
@@ -98,9 +98,20 @@ type Runner struct {
disabledTools map[string]bool // nil = all tools enabled
customEventSubs map[string][]func(string) // inter-extension event bus
optionOverrides map[string]string // runtime option overrides
configStore *viper.Viper // per-instance config store (nil = global)
mu sync.RWMutex
}
// SetConfigStore sets the per-instance configuration store used by GetOption
// to resolve "options.<name>" config values. When unset (nil), GetOption falls
// back to the process-global viper store. Threading a per-Kit store keeps
// extension option resolution isolated between Kit instances.
func (r *Runner) SetConfigStore(v *viper.Viper) {
r.mu.Lock()
defer r.mu.Unlock()
r.configStore = v
}
// ShortcutEntry pairs a shortcut definition with its handler.
type ShortcutEntry struct {
Def ShortcutDef
@@ -872,7 +883,13 @@ func (r *Runner) GetOption(name string) string {
// 3. Viper config: options.<name>
configKey := "options." + name
if v := viper.GetString(configKey); v != "" {
r.mu.RLock()
store := r.configStore
r.mu.RUnlock()
if store == nil {
store = viper.GetViper()
}
if v := store.GetString(configKey); v != "" {
return v
}
+65 -42
View File
@@ -46,9 +46,9 @@ type AgentSetupOptions struct {
ToolWrapper func([]fantasy.AgentTool) []fantasy.AgentTool
// ProviderConfig, when non-nil, is used directly instead of calling
// BuildProviderConfig(). Callers that already hold viperInitMu can
// pre-build this and release the lock before calling SetupAgent, so the
// slow agent/MCP initialisation runs concurrently with other New() calls.
// BuildProviderConfig(). Callers (e.g. Kit.New) pre-build this from their
// per-instance config store and pass it here, so the slow agent/MCP
// initialisation can run without further config reads.
ProviderConfig *models.ProviderConfig
// Debug enables debug logging. When zero-value, viper is consulted.
// Only meaningful when ProviderConfig is also set.
@@ -75,6 +75,11 @@ type AgentSetupOptions struct {
// MCPTaskConfig configures task-augmented tools/call execution. The
// zero value preserves historical synchronous-only behaviour.
MCPTaskConfig tools.MCPTaskConfig
// Viper is the per-instance configuration store. When set, it is used for
// any fallback config reads (debug, no-extensions, max-steps, stream,
// extension paths) and is attached to the extension runner. When nil, the
// process-global viper store is used.
Viper *viper.Viper
}
// AgentSetupResult bundles the created agent and any debug logger so the caller
@@ -87,57 +92,62 @@ type AgentSetupResult struct {
ExtRunner *extensions.Runner
}
// BuildProviderConfig creates a *models.ProviderConfig from the current viper
// state. All entry points (root, script, SDK) converge through this function.
// BuildProviderConfig creates a *models.ProviderConfig from the supplied viper
// store (or the process-global store when v is nil). All entry points (root,
// script, SDK) converge through this function.
//
// Generation parameter pointers (Temperature, TopP, etc.) are only set when
// the user has explicitly configured them via CLI flag, environment variable,
// or global config file. This allows per-model defaults from modelSettings
// and customModels to fill in unset parameters downstream.
func BuildProviderConfig() (*models.ProviderConfig, string, error) {
systemPrompt, err := config.LoadSystemPrompt(viper.GetString("system-prompt"))
func BuildProviderConfig(v *viper.Viper) (*models.ProviderConfig, string, error) {
if v == nil {
v = viper.GetViper()
}
systemPrompt, err := config.LoadSystemPrompt(v.GetString("system-prompt"))
if err != nil {
return nil, "", fmt.Errorf("failed to load system prompt: %w", err)
}
numGPU := int32(viper.GetInt("num-gpu-layers"))
mainGPU := int32(viper.GetInt("main-gpu"))
numGPU := int32(v.GetInt("num-gpu-layers"))
mainGPU := int32(v.GetInt("main-gpu"))
cfg := &models.ProviderConfig{
ModelString: viper.GetString("model"),
ModelString: v.GetString("model"),
SystemPrompt: systemPrompt,
ProviderAPIKey: viper.GetString("provider-api-key"),
ProviderURL: viper.GetString("provider-url"),
MaxTokens: viper.GetInt("max-tokens"),
StopSequences: viper.GetStringSlice("stop-sequences"),
ProviderAPIKey: v.GetString("provider-api-key"),
ProviderURL: v.GetString("provider-url"),
MaxTokens: v.GetInt("max-tokens"),
StopSequences: v.GetStringSlice("stop-sequences"),
NumGPU: &numGPU,
MainGPU: &mainGPU,
TLSSkipVerify: viper.GetBool("tls-skip-verify"),
ThinkingLevel: models.ParseThinkingLevel(viper.GetString("thinking-level")),
TLSSkipVerify: v.GetBool("tls-skip-verify"),
ThinkingLevel: models.ParseThinkingLevel(v.GetString("thinking-level")),
ConfigStore: v,
}
// Only set generation parameter pointers when the user has explicitly
// provided a value. This leaves nil pointers for unset params, allowing
// per-model defaults (modelSettings / customModels params) to apply.
if viper.IsSet("temperature") {
v := float32(viper.GetFloat64("temperature"))
cfg.Temperature = &v
if v.IsSet("temperature") {
val := float32(v.GetFloat64("temperature"))
cfg.Temperature = &val
}
if viper.IsSet("top-p") {
v := float32(viper.GetFloat64("top-p"))
cfg.TopP = &v
if v.IsSet("top-p") {
val := float32(v.GetFloat64("top-p"))
cfg.TopP = &val
}
if viper.IsSet("top-k") {
v := int32(viper.GetInt("top-k"))
cfg.TopK = &v
if v.IsSet("top-k") {
val := int32(v.GetInt("top-k"))
cfg.TopK = &val
}
if viper.IsSet("frequency-penalty") {
v := float32(viper.GetFloat64("frequency-penalty"))
cfg.FrequencyPenalty = &v
if v.IsSet("frequency-penalty") {
val := float32(v.GetFloat64("frequency-penalty"))
cfg.FrequencyPenalty = &val
}
if viper.IsSet("presence-penalty") {
v := float32(viper.GetFloat64("presence-penalty"))
cfg.PresencePenalty = &v
if v.IsSet("presence-penalty") {
val := float32(v.GetFloat64("presence-penalty"))
cfg.PresencePenalty = &val
}
return cfg, systemPrompt, nil
@@ -149,14 +159,21 @@ func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult,
var modelConfig *models.ProviderConfig
var systemPrompt string
// Resolve the config store: prefer the per-instance store, falling back to
// the process-global store.
v := opts.Viper
if v == nil {
v = viper.GetViper()
}
if opts.ProviderConfig != nil {
// Pre-built config supplied by caller (e.g. Kit.New after releasing
// viperInitMu). Use it directly — no viper reads needed here.
// Pre-built config supplied by caller (e.g. Kit.New after building the
// per-instance store). Use it directly — no viper reads needed here.
modelConfig = opts.ProviderConfig
systemPrompt = modelConfig.SystemPrompt
} else {
var err error
modelConfig, systemPrompt, err = BuildProviderConfig()
modelConfig, systemPrompt, err = BuildProviderConfig(v)
if err != nil {
return nil, err
}
@@ -164,13 +181,13 @@ func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult,
// Resolve debug / no-extensions / max-steps / streaming: prefer explicit
// fields (set when ProviderConfig was pre-built) over viper fallback.
debugEnabled := opts.Debug || viper.GetBool("debug")
noExtensions := opts.NoExtensions || viper.GetBool("no-extensions")
debugEnabled := opts.Debug || v.GetBool("debug")
noExtensions := opts.NoExtensions || v.GetBool("no-extensions")
maxSteps := opts.MaxSteps
if maxSteps == 0 {
maxSteps = viper.GetInt("max-steps")
maxSteps = v.GetInt("max-steps")
}
streamingEnabled := opts.StreamingEnabled || viper.GetBool("stream")
streamingEnabled := opts.StreamingEnabled || v.GetBool("stream")
// Create the appropriate debug logger.
var debugLogger tools.DebugLogger
@@ -189,7 +206,7 @@ func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult,
var extCreationOpts extensionCreationOpts
if !noExtensions {
var extErr error
extRunner, extCreationOpts, extErr = loadExtensions()
extRunner, extCreationOpts, extErr = loadExtensions(v)
if extErr != nil {
fmt.Printf("Warning: Failed to load extensions: %v\n", extErr)
}
@@ -253,9 +270,14 @@ type extensionCreationOpts struct {
}
// loadExtensions discovers and loads Yaegi extensions, builds the runner,
// and returns the tool wrapper/extra tools.
func loadExtensions() (*extensions.Runner, extensionCreationOpts, error) {
extraPaths := viper.GetStringSlice("extension")
// and returns the tool wrapper/extra tools. The supplied store is used to
// resolve the "extension" config key and is attached to the runner so
// extension option lookups stay isolated to this Kit instance.
func loadExtensions(v *viper.Viper) (*extensions.Runner, extensionCreationOpts, error) {
if v == nil {
v = viper.GetViper()
}
extraPaths := v.GetStringSlice("extension")
loaded, err := extensions.LoadExtensions(extraPaths)
if err != nil {
return nil, extensionCreationOpts{}, err
@@ -266,6 +288,7 @@ func loadExtensions() (*extensions.Runner, extensionCreationOpts, error) {
}
runner := extensions.NewRunner(loaded)
runner.SetConfigStore(v)
wrapper := func(tools []fantasy.AgentTool) []fantasy.AgentTool {
return extensions.WrapToolsWithExtensions(tools, runner)
+51 -18
View File
@@ -10,14 +10,24 @@ import (
// loadCustomModelsFromConfig loads custom model definitions from the config file
// and returns them as a map of model ID -> ModelInfo. Returns nil if no custom
// models are configured.
// models are configured. Reads from the process-global viper store (the model
// registry is a process-global singleton).
func loadCustomModelsFromConfig() map[string]ModelInfo {
if !viper.IsSet("customModels") {
return loadCustomModelsFrom(viper.GetViper())
}
// loadCustomModelsFrom loads custom model definitions from the supplied store.
// When v is nil the process-global store is used.
func loadCustomModelsFrom(v *viper.Viper) map[string]ModelInfo {
if v == nil {
v = viper.GetViper()
}
if !v.IsSet("customModels") {
return nil
}
var customModels map[string]CustomModelConfig
if err := viper.UnmarshalKey("customModels", &customModels); err != nil {
if err := v.UnmarshalKey("customModels", &customModels); err != nil {
log.Printf("Warning: Failed to parse customModels: %v", err)
return nil
}
@@ -60,15 +70,26 @@ func modelConfigToModelInfo(modelID string, cfg CustomModelConfig) ModelInfo {
}
// LoadModelSettingsFromConfig loads per-model generation parameter overrides
// from the config file. Keys are "provider/model" strings. Returns nil if
// no model settings are configured.
// from the process-global viper store. Keys are "provider/model" strings.
// Returns nil if no model settings are configured.
func LoadModelSettingsFromConfig() map[string]*GenerationParams {
if !viper.IsSet("modelSettings") {
return LoadModelSettingsFrom(viper.GetViper())
}
// LoadModelSettingsFrom loads per-model generation parameter overrides from the
// supplied per-instance store. When v is nil the process-global store is used.
// Keys are "provider/model" strings. Returns nil if no model settings are
// configured.
func LoadModelSettingsFrom(v *viper.Viper) map[string]*GenerationParams {
if v == nil {
v = viper.GetViper()
}
if !v.IsSet("modelSettings") {
return nil
}
var settings map[string]GenerationParamsConfig
if err := viper.UnmarshalKey("modelSettings", &settings); err != nil {
if err := v.UnmarshalKey("modelSettings", &settings); err != nil {
log.Printf("Warning: Failed to parse modelSettings: %v", err)
return nil
}
@@ -148,12 +169,17 @@ func ApplyModelSettings(config *ProviderConfig, modelInfo *ModelInfo) {
return
}
// Resolve the config store: prefer the per-instance store carried on the
// ProviderConfig (set by BuildProviderConfig / Kit.New), falling back to
// the process-global store for callers that don't thread one through.
store := config.ConfigStore
// Collect model-level params: modelSettings override > custom model params.
// modelSettings takes priority because it's the more specific/intentional config.
var params *GenerationParams
// First check modelSettings from config.
if settings := LoadModelSettingsFromConfig(); settings != nil {
if settings := LoadModelSettingsFrom(store); settings != nil {
modelKey := provider + "/" + modelName
if p, ok := settings[modelKey]; ok {
params = p
@@ -173,28 +199,28 @@ func ApplyModelSettings(config *ProviderConfig, modelInfo *ModelInfo) {
// We check viper.IsSet() which returns true only when the key was
// set via CLI flag, environment variable, or config file global section.
if params.MaxTokens != nil && !isExplicitlySet("max-tokens") {
if params.MaxTokens != nil && !isExplicitlySet(store, "max-tokens") {
config.MaxTokens = *params.MaxTokens
}
if params.Temperature != nil && !isExplicitlySet("temperature") {
if params.Temperature != nil && !isExplicitlySet(store, "temperature") {
config.Temperature = params.Temperature
}
if params.TopP != nil && !isExplicitlySet("top-p") {
if params.TopP != nil && !isExplicitlySet(store, "top-p") {
config.TopP = params.TopP
}
if params.TopK != nil && !isExplicitlySet("top-k") {
if params.TopK != nil && !isExplicitlySet(store, "top-k") {
config.TopK = params.TopK
}
if params.FrequencyPenalty != nil && !isExplicitlySet("frequency-penalty") {
if params.FrequencyPenalty != nil && !isExplicitlySet(store, "frequency-penalty") {
config.FrequencyPenalty = params.FrequencyPenalty
}
if params.PresencePenalty != nil && !isExplicitlySet("presence-penalty") {
if params.PresencePenalty != nil && !isExplicitlySet(store, "presence-penalty") {
config.PresencePenalty = params.PresencePenalty
}
if len(params.StopSequences) > 0 && !isExplicitlySet("stop-sequences") {
if len(params.StopSequences) > 0 && !isExplicitlySet(store, "stop-sequences") {
config.StopSequences = params.StopSequences
}
if params.ThinkingLevel != "" && !isExplicitlySet("thinking-level") {
if params.ThinkingLevel != "" && !isExplicitlySet(store, "thinking-level") {
config.ThinkingLevel = params.ThinkingLevel
}
if params.SystemPrompt != "" && config.SystemPrompt == "" {
@@ -228,7 +254,14 @@ func LoadSystemPromptValue(input string) string {
// isExplicitlySet returns true when the user has explicitly set a config key
// via CLI flag, environment variable, or the global section of the config file.
// Model-level defaults should not override explicitly set values.
func isExplicitlySet(key string) bool {
//
// The check runs against the supplied per-instance store when non-nil,
// otherwise the process-global store. This keeps the "explicit vs unset"
// precedence contract per-Kit-instance once a store is threaded through.
func isExplicitlySet(v *viper.Viper, key string) bool {
if v == nil {
v = viper.GetViper()
}
// viper.IsSet returns true if the key has been set in any of the
// data stores (flag, env, config file, default). We need to check
// whether the value was set at the global config level (not just
@@ -239,7 +272,7 @@ func isExplicitlySet(key string) bool {
// file values. This means global config file values (e.g.
// temperature: 0.7 at the top level) will correctly take precedence
// over model-level defaults, which is the desired behavior.
return viper.IsSet(key)
return v.IsSet(key)
}
// GenerationParams holds per-model generation parameter defaults.
+9 -1
View File
@@ -25,6 +25,7 @@ import (
openaisdk "github.com/charmbracelet/openai-go"
"github.com/mark3labs/kit/internal/auth"
"github.com/spf13/viper"
)
const (
@@ -164,6 +165,13 @@ type ProviderConfig struct {
ThinkingLevel ThinkingLevel
DisableCaching bool // Opt-out: set to true to disable automatic prompt caching
// ConfigStore is the per-instance configuration store used to resolve
// "explicitly set" precedence checks (isExplicitlySet), per-model
// settings, and right-sizing. When nil, the process-global viper store is
// used. Threading a per-Kit store here keeps generation-parameter
// precedence isolated between Kit instances in the same process.
ConfigStore *viper.Viper
// ProgressReaderFunc, when set, wraps an io.Reader with progress display
// for long operations like Ollama model pulls. The returned io.ReadCloser
// must be closed when done. When nil, the raw reader is consumed directly
@@ -530,7 +538,7 @@ func rightSizeMaxTokens(config *ProviderConfig, modelInfo *ModelInfo) {
if modelInfo == nil || modelInfo.Limit.Output <= 0 {
return
}
if isExplicitlySet("max-tokens") {
if isExplicitlySet(config.ConfigStore, "max-tokens") {
return
}
target := min(modelInfo.Limit.Output, defaultRightSizeCap)
+33 -1
View File
@@ -49,6 +49,36 @@ The SDK behaves identically to the CLI:
- Respects all environment variables (`KIT_*`)
- Uses the same defaults as the CLI
Each `kit.New` / `kit.NewAgent` call owns an **isolated configuration store**,
so constructing multiple Kit instances in the same process is safe — setting
the model, thinking level, or generation parameters on one never affects
another, and runtime mutators (`SetModel`, `SetThinkingLevel`) only touch the
owning instance. This makes subagent spawning and multi-Kit embedding race-free
without external synchronization.
### Functional options (`NewAgent`)
For simple programmatic setups, `kit.NewAgent` is an ergonomic
functional-options front door over `kit.New`. Streaming is enabled by default;
pass `kit.WithStreaming(false)` to opt out.
```go
host, err := kit.NewAgent(ctx,
kit.WithModel("anthropic/claude-sonnet-4-5-20250929"),
kit.WithSystemPrompt("You are a helpful assistant."),
kit.WithMaxTokens(8192),
kit.WithThinkingLevel("medium"),
kit.Ephemeral(), // in-memory session, no persistence
)
```
Helpers: `WithModel`, `WithSystemPrompt`, `WithStreaming`, `WithMaxTokens`,
`WithThinkingLevel`, `WithTools`, `WithExtraTools`, `WithProviderAPIKey`,
`WithProviderURL`, `WithConfigFile`, `WithDebug`, and `Ephemeral`. `Option` is
a plain `func(*Options)`, so you can define your own. For fields without a
`With*` helper (`MCPConfig`, `InProcessMCPServers`, `SessionManager`, MCP task
tuning) construct an `Options` value and call `kit.New`.
### Options
You can override specific settings:
@@ -59,7 +89,7 @@ host, err := kit.New(ctx, &kit.Options{
SystemPrompt: "You are a helpful bot", // Override system prompt
ConfigFile: "/path/to/config.yml", // Use specific config file
MaxSteps: 10, // Override max steps
Streaming: true, // Enable streaming
Streaming: ptrBool(true), // *bool: nil = unset (default true), &false = off
Quiet: true, // Suppress debug output
// Session options
@@ -331,6 +361,7 @@ msg := kit.ConvertFromLLMMessage(lMsg) // LLMMessage → SDK Message
- `Kit` - Main SDK type
- `Options` - Configuration options
- `Option` - Functional option (`func(*Options)`) for `NewAgent`
- `Message` - Conversation message with typed content parts
- `Tool` - Agent tool interface
- `TurnResult` - Full result from a prompt including usage stats
@@ -338,6 +369,7 @@ msg := kit.ConvertFromLLMMessage(lMsg) // LLMMessage → SDK Message
### Key Methods
- `New(ctx, opts)` - Create new Kit instance
- `NewAgent(ctx, ...Option)` - Create a Kit via functional options (streaming on by default)
- `Prompt(ctx, message)` - Send message and get response string
- `PromptResult(ctx, message)` - Send message and get full TurnResult
- `PromptWithOptions(ctx, message, opts)` - Prompt with per-call options
+50 -23
View File
@@ -65,23 +65,46 @@ const sdkDefaultMaxTokens = 8192
// which returns models.ThinkingOff.
// - sampling params (temperature, top-p, top-k, frequency/presence-penalty):
// left as nil pointers so provider libraries apply their own defaults.
func setSDKDefaults() {
viper.SetDefault("model", "anthropic/claude-sonnet-4-5-20250929")
viper.SetDefault("system-prompt", defaultSystemPrompt)
viper.SetDefault("stream", true)
viper.SetDefault("num-gpu-layers", -1)
viper.SetDefault("main-gpu", 0)
func setSDKDefaults(v *viper.Viper) {
v.SetDefault("model", "anthropic/claude-sonnet-4-5-20250929")
v.SetDefault("system-prompt", defaultSystemPrompt)
v.SetDefault("stream", true)
v.SetDefault("num-gpu-layers", -1)
v.SetDefault("main-gpu", 0)
}
// InitConfig initializes the viper configuration system.
// InitConfig initializes the process-global viper configuration system.
// It searches for config files in standard locations and loads them with
// environment variable substitution.
//
// configFile: explicit config file path (empty = search defaults).
// debug: if true, print warnings about missing configs to stderr.
//
// This wraps [initConfig] using the process-global store and is retained for
// the CLI, which binds its flags to the global viper.
func InitConfig(configFile string, debug bool) error {
return initConfig(viper.GetViper(), configFile, debug)
}
// initConfig loads configuration into the supplied per-instance store. When v
// is nil the process-global store is used.
func initConfig(v *viper.Viper, configFile string, debug bool) error {
if v == nil {
v = viper.GetViper()
}
// Configure KIT_* environment overrides unconditionally, before any file
// is loaded, so that an explicit config file does not disable env support.
// Map hyphenated config keys (e.g. "max-tokens") to underscored env var
// names (e.g. KIT_MAX_TOKENS); without this AutomaticEnv looks for
// KIT_MAX-TOKENS and silently misses valid overrides. Precedence is
// resolved at read time, so calling these before ReadConfig is fine.
v.SetEnvPrefix("KIT")
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
v.AutomaticEnv()
if configFile != "" {
return LoadConfigWithEnvSubstitution(configFile)
return loadConfigWithEnvSubstitution(v, configFile)
}
// Ensure a config file exists (create default if none found).
@@ -97,15 +120,15 @@ func InitConfig(configFile string, debug bool) error {
}
// Current directory has higher priority than home directory.
viper.AddConfigPath(".")
viper.AddConfigPath(home)
v.AddConfigPath(".")
v.AddConfigPath(home)
configLoaded := false
viper.SetConfigName(".kit")
if err := viper.ReadInConfig(); err == nil {
configPath := viper.ConfigFileUsed()
if err := LoadConfigWithEnvSubstitution(configPath); err != nil {
v.SetConfigName(".kit")
if err := v.ReadInConfig(); err == nil {
configPath := v.ConfigFileUsed()
if err := loadConfigWithEnvSubstitution(v, configPath); err != nil {
if strings.Contains(err.Error(), "environment variable substitution failed") {
return fmt.Errorf("error reading config file '%s': %w", configPath, err)
}
@@ -118,17 +141,21 @@ func InitConfig(configFile string, debug bool) error {
fmt.Fprintf(os.Stderr, "No config file found in current directory or home directory\n")
}
viper.SetEnvPrefix("KIT")
// Map hyphenated config keys (e.g. "max-tokens") to underscored env
// var names (e.g. KIT_MAX_TOKENS). Without this, AutomaticEnv looks
// for KIT_MAX-TOKENS and silently misses valid env overrides.
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
viper.AutomaticEnv()
return nil
}
// LoadConfigWithEnvSubstitution loads a config file with ${ENV_VAR} expansion.
// LoadConfigWithEnvSubstitution loads a config file with ${ENV_VAR} expansion
// into the process-global viper store.
func LoadConfigWithEnvSubstitution(configPath string) error {
return loadConfigWithEnvSubstitution(viper.GetViper(), configPath)
}
// loadConfigWithEnvSubstitution loads a config file with ${ENV_VAR} expansion
// into the supplied per-instance store (or the global store when v is nil).
func loadConfigWithEnvSubstitution(v *viper.Viper, configPath string) error {
if v == nil {
v = viper.GetViper()
}
rawContent, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("failed to read config file: %w", err)
@@ -146,6 +173,6 @@ func LoadConfigWithEnvSubstitution(configPath string) error {
}
config.SetConfigPath(configPath)
viper.SetConfigType(configType)
return viper.ReadConfig(strings.NewReader(processedContent))
v.SetConfigType(configType)
return v.ReadConfig(strings.NewReader(processedContent))
}
+22
View File
@@ -0,0 +1,22 @@
package kit
// This file exposes a handful of internal accessors to the external kit_test
// package. Because it ends in _test.go it is only compiled during testing and
// is therefore not part of the public SDK surface.
// ConfigValueIsSetForTest reports whether key is explicitly set in this Kit's
// isolated configuration store. Used by tests to assert the tri-state
// precedence contract per-instance.
func (m *Kit) ConfigValueIsSetForTest(key string) bool { return m.v.IsSet(key) }
// ConfigStringForTest returns the string value of key from this Kit's isolated
// configuration store.
func (m *Kit) ConfigStringForTest(key string) string { return m.v.GetString(key) }
// ConfigFloatForTest returns the float64 value of key from this Kit's isolated
// configuration store.
func (m *Kit) ConfigFloatForTest(key string) float64 { return m.v.GetFloat64(key) }
// ConfigBoolForTest returns the bool value of key from this Kit's isolated
// configuration store.
func (m *Kit) ConfigBoolForTest(key string) bool { return m.v.GetBool(key) }
+193 -132
View File
@@ -53,6 +53,14 @@ type Kit struct {
opts *Options // stored for reload operations (skills, etc.)
mcpConfig *config.Config // loaded MCP/server config, shared with subagents
// v is this Kit instance's isolated configuration store. Each Kit owns its
// own *viper.Viper (constructed via viper.New) so that runtime config
// mutators (SetModel, SetThinkingLevel) and config reads do not clobber or
// observe state from other Kit instances in the same process. When the CLI
// constructs a Kit (Options.CLI != nil) this points at the process-global
// store so cobra flag bindings remain in effect.
v *viper.Viper
// hasCustomSystemPrompt is true when the user explicitly configured a
// system prompt (via --system-prompt flag, config file, or SDK option).
// When false, per-model system prompts from modelSettings/customModels
@@ -555,8 +563,8 @@ func (m *Kit) SetModel(ctx context.Context, modelString string) error {
// Build a provider config from current settings, overriding the model.
// Load system prompt properly (handles both file paths and inline content).
systemPrompt, _ := config.LoadSystemPrompt(viper.GetString("system-prompt"))
thinkingLevel := models.ParseThinkingLevel(viper.GetString("thinking-level"))
systemPrompt, _ := config.LoadSystemPrompt(m.v.GetString("system-prompt"))
thinkingLevel := models.ParseThinkingLevel(m.v.GetString("thinking-level"))
// Validate and adjust thinking level for the target model.
// Some models (e.g., OpenAI gpt-5.4) don't support "minimal" and require "none".
@@ -567,8 +575,8 @@ func (m *Kit) SetModel(ctx context.Context, modelString string) error {
if !models.IsValidThinkingLevelForModel(thinkingLevel, modelName) {
fallback := models.SuggestThinkingLevelFallback(thinkingLevel, modelName)
if fallback != models.ThinkingOff {
// Adjust the thinking level in viper so the change persists.
viper.Set("thinking-level", string(fallback))
// Adjust the thinking level in the instance store so the change persists.
m.v.Set("thinking-level", string(fallback))
thinkingLevel = fallback
}
}
@@ -580,35 +588,36 @@ func (m *Kit) SetModel(ctx context.Context, modelString string) error {
cfg := &models.ProviderConfig{
ModelString: modelString,
SystemPrompt: systemPrompt,
ProviderAPIKey: viper.GetString("provider-api-key"),
ProviderURL: viper.GetString("provider-url"),
MaxTokens: viper.GetInt("max-tokens"),
TLSSkipVerify: viper.GetBool("tls-skip-verify"),
ProviderAPIKey: m.v.GetString("provider-api-key"),
ProviderURL: m.v.GetString("provider-url"),
MaxTokens: m.v.GetInt("max-tokens"),
TLSSkipVerify: m.v.GetBool("tls-skip-verify"),
ThinkingLevel: thinkingLevel,
DisableCaching: false, // Caching enabled by default, works with thinking
ConfigStore: m.v,
}
// Only set generation parameter pointers when the user has explicitly
// provided a value. This leaves nil pointers for unset params, allowing
// per-model defaults (modelSettings / customModels params) to apply.
if viper.IsSet("temperature") {
v := float32(viper.GetFloat64("temperature"))
if m.v.IsSet("temperature") {
v := float32(m.v.GetFloat64("temperature"))
cfg.Temperature = &v
}
if viper.IsSet("top-p") {
v := float32(viper.GetFloat64("top-p"))
if m.v.IsSet("top-p") {
v := float32(m.v.GetFloat64("top-p"))
cfg.TopP = &v
}
if viper.IsSet("top-k") {
v := int32(viper.GetInt("top-k"))
if m.v.IsSet("top-k") {
v := int32(m.v.GetInt("top-k"))
cfg.TopK = &v
}
if viper.IsSet("frequency-penalty") {
v := float32(viper.GetFloat64("frequency-penalty"))
if m.v.IsSet("frequency-penalty") {
v := float32(m.v.GetFloat64("frequency-penalty"))
cfg.FrequencyPenalty = &v
}
if viper.IsSet("presence-penalty") {
v := float32(viper.GetFloat64("presence-penalty"))
if m.v.IsSet("presence-penalty") {
v := float32(m.v.GetFloat64("presence-penalty"))
cfg.PresencePenalty = &v
}
@@ -734,7 +743,7 @@ func (m *Kit) ReloadExtensions() error {
}
// Re-load from disk.
extraPaths := viper.GetStringSlice("extension")
extraPaths := m.v.GetStringSlice("extension")
loaded, err := extensions.LoadExtensions(extraPaths)
if err != nil {
return fmt.Errorf("reloading extensions: %w", err)
@@ -742,6 +751,7 @@ func (m *Kit) ReloadExtensions() error {
// Swap extensions on the runner (clears dynamic state).
m.extRunner.Reload(loaded)
m.extRunner.SetConfigStore(m.v)
// Update extension tools on the agent so the LLM sees changes.
if m.agent != nil {
@@ -780,7 +790,8 @@ func (m *Kit) ExecuteCompletion(ctx context.Context, req extensions.CompleteRequ
// Create a temporary provider for the requested model.
config := &models.ProviderConfig{
ModelString: req.Model,
TLSSkipVerify: viper.GetBool("tls-skip-verify"),
TLSSkipVerify: m.v.GetBool("tls-skip-verify"),
ConfigStore: m.v,
}
if req.MaxTokens > 0 {
config.MaxTokens = req.MaxTokens
@@ -866,37 +877,30 @@ func (m *Kit) ExecuteCompletion(ctx context.Context, req extensions.CompleteRequ
// prompts, configuration, and behavior settings. All fields are optional
// and will use CLI defaults if not specified.
//
// Global viper state warning:
// Options are applied by [New] via [viper.Set] calls against viper's
// process-global store. This store is shared with every downstream reader
// (e.g. [Kit.SetModel], [Kit.GetThinkingLevel], BuildProviderConfig, and
// any other code path that calls viper.Get*). Two consequences:
//
// 1. Kit instances are NOT isolated from each other within a single
// process. Values set by the second New() call overwrite the first,
// and any code that later reads viper will see the most recent Set.
// 2. Fields left at the zero value do NOT clear prior viper state; they
// simply skip the viper.Set. Callers that need a clean slate between
// constructions should invoke viper.Reset() (the test suite uses a
// private resetViper() helper that wraps it) before the next New().
//
// Recommended usage: create one Kit per process, or reset viper between
// constructions. Concurrent calls to New are serialized internally by
// [viperInitMu], but that mutex does not prevent later viper reads (from
// a different Kit) from observing mutated keys.
//
// TODO: refactor New to use a per-instance *viper.Viper (constructed via
// viper.New()) so each Kit owns its own isolated config store and Options
// no longer leak through the global singleton.
// Config isolation: each [New] / [NewAgent] call constructs its own isolated
// configuration store (via viper.New internally). Options are applied to that
// per-instance store, so two Kits constructed in the same process do NOT share
// or clobber each other's configuration. Runtime mutators ([Kit.SetModel],
// [Kit.SetThinkingLevel]) and config readers ([Kit.GetThinkingLevel]) operate
// only on the owning instance. Fields left at their zero value are simply not
// applied; they fall through to the precedence chain (env → .kit.yml →
// per-model defaults) resolved within the instance's own store.
type Options struct {
Model string // Override model (e.g., "anthropic/claude-sonnet-4-5-20250929")
SystemPrompt string // Override system prompt
ConfigFile string // Override config file path
MaxSteps int // Override max steps (0 = use default)
Streaming bool // Enable streaming (default from config)
Quiet bool // Suppress debug output
Tools []Tool // Custom tool set. If empty, AllTools() is used.
ExtraTools []Tool // Additional tools added alongside core/MCP/extension tools.
// Streaming enables or disables streaming output. It is a pointer so the
// SDK can distinguish "unset" (nil) from an explicit choice, mirroring the
// sampling-parameter fields below. nil leaves streaming to the precedence
// chain (env → .kit.yml → default true); a non-nil value forces it. Prefer
// [WithStreaming] for the functional-options API.
Streaming *bool
Quiet bool // Suppress debug output
Tools []Tool // Custom tool set. If empty, AllTools() is used.
ExtraTools []Tool // Additional tools added alongside core/MCP/extension tools.
// Generation parameters. These override the corresponding values from
// .kit.yml / KIT_* environment variables. Leaving a field at its
@@ -1169,40 +1173,40 @@ func InitTreeSession(opts *Options) (*session.TreeManager, error) {
return session.CreateTreeSession(sessionDir)
}
// viperInitMu serializes viper writes during [New]. Viper's global state
// is not thread-safe, so concurrent calls (e.g. parallel subagent spawns)
// must not overlap the Set/Get window. Note that this mutex only protects
// the construction window — it does not isolate long-lived Kit instances
// from each other. See the "Global viper state warning" on [Options].
var viperInitMu sync.Mutex
// New creates a Kit instance using the same initialization as the CLI.
// It loads configuration, initializes MCP servers, creates the LLM model, and
// sets up the agent for interaction. Returns an error if initialization fails.
//
// Global viper state warning: fields on [Options] are applied by calling
// [viper.Set] on viper's process-global store. As a result, two Kits
// constructed in the same process are NOT isolated: the second New
// overwrites viper keys set by the first, and any downstream reader
// (e.g. [Kit.SetModel], [Kit.GetThinkingLevel]) will observe the most
// recent value. Callers that need multiple independent Kits should call
// viper.Reset() between constructions, or avoid constructing more than
// one Kit per process. Writes during New are serialized by [viperInitMu].
// Config isolation: New constructs a per-instance configuration store (via
// viper.New internally) and applies [Options] to it. Two Kits constructed in
// the same process are therefore fully isolated — neither overwrites the
// other's model, thinking level, or generation parameters, and runtime
// mutators ([Kit.SetModel], [Kit.SetThinkingLevel]) only affect the owning
// instance. This makes subagent spawning and multi-Kit embedding safe without
// any external synchronization.
//
// TODO: refactor to use a per-call viper.New() instance so each Kit owns
// its own isolated config store and Options stop leaking through the
// global singleton.
// CLI integration: when Options.CLI is non-nil the Kit shares the
// process-global viper store instead of allocating a fresh one, so cobra flag
// bindings established by the CLI remain in effect. SDK callers leave
// Options.CLI nil and always get an isolated store.
//
// For an ergonomic functional-options front door, see [NewAgent].
func New(ctx context.Context, opts *Options) (*Kit, error) {
if opts == nil {
opts = &Options{}
}
// All viper writes (SetSDKDefaults, InitConfig, Set calls, system-prompt
// composition) happen under viperInitMu. We also call BuildProviderConfig
// here — it's fast (just reads) — so we can capture the full config
// snapshot before releasing the lock. The expensive work (MCP loading,
// provider creation, session init) then runs outside the lock, allowing
// parallel subagent spawns to proceed concurrently.
// Construct this Kit's configuration store. SDK callers get a fresh,
// isolated *viper.Viper so concurrent constructions never clobber each
// other. The CLI (Options.CLI != nil) shares the process-global store so
// its cobra flag bindings and pre-loaded config remain visible.
var v *viper.Viper
if opts.CLI != nil {
v = viper.GetViper()
} else {
v = viper.New()
}
var (
providerConfig *models.ProviderConfig
modelString string
@@ -1221,79 +1225,84 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
)
if err := func() error {
viperInitMu.Lock()
defer viperInitMu.Unlock()
// Set CLI-equivalent defaults on the instance store. When used as an
// SDK (without cobra), these defaults are not registered via flag bindings.
setSDKDefaults(v)
// Set CLI-equivalent defaults for viper. When used as an SDK (without
// cobra), these defaults are not registered via flag bindings.
setSDKDefaults()
// Initialize config (loads config files and env vars).
// Only initialize if not already done (e.g., by CLI's cobra.OnInitialize).
// Check if model is already set, which indicates config was loaded.
// Initialize config (loads config files and env vars) into the instance
// store. The CLI shares the process-global store, which cobra.OnInitialize
// has already populated, so re-running initConfig there is unnecessary;
// SDK callers get a fresh isolated store that must be loaded here.
// We key off opts.CLI (not a config value) because setSDKDefaults always
// seeds "model", which would otherwise mask an empty store.
// SkipConfig bypasses .kit.yml file loading (viper defaults and env vars still apply).
if !opts.SkipConfig && viper.GetString("model") == "" {
if err := InitConfig(opts.ConfigFile, false); err != nil {
if !opts.SkipConfig && opts.CLI == nil {
if err := initConfig(v, opts.ConfigFile, false); err != nil {
return fmt.Errorf("failed to initialize config: %w", err)
}
}
// Handle CLI debug mode.
if opts.Debug {
viper.Set("debug", true)
v.Set("debug", true)
}
// Override viper settings with options.
// Override instance settings with options.
if opts.Model != "" {
viper.Set("model", opts.Model)
v.Set("model", opts.Model)
}
if opts.SystemPrompt != "" {
viper.Set("system-prompt", opts.SystemPrompt)
v.Set("system-prompt", opts.SystemPrompt)
}
if opts.MaxSteps > 0 {
viper.Set("max-steps", opts.MaxSteps)
v.Set("max-steps", opts.MaxSteps)
}
// Only override streaming when the caller explicitly set it. Otherwise
// leave the precedence chain (env → config → default true) untouched so a
// zero-valued Options does not silently force stream=false.
if opts.Streaming != nil {
v.Set("stream", *opts.Streaming)
}
viper.Set("stream", opts.Streaming)
// Generation parameter overrides. Each Options field, when set,
// is pushed into viper here so the existing downstream code
// (BuildProviderConfig, SetModel, modelSettings lookups) picks
// it up uniformly. Pointer-typed sampling params use viper.Set
// only when non-nil so that nil means "leave provider/per-model
// default in place" (BuildProviderConfig keys off viper.IsSet).
// is pushed into the instance store here so the existing downstream
// code (BuildProviderConfig, SetModel, modelSettings lookups) picks
// it up uniformly. Pointer-typed sampling params use Set only when
// non-nil so that nil means "leave provider/per-model default in
// place" (BuildProviderConfig keys off IsSet).
if opts.MaxTokens > 0 {
viper.Set("max-tokens", opts.MaxTokens)
v.Set("max-tokens", opts.MaxTokens)
}
if opts.ThinkingLevel != "" {
viper.Set("thinking-level", opts.ThinkingLevel)
v.Set("thinking-level", opts.ThinkingLevel)
}
if opts.Temperature != nil {
viper.Set("temperature", *opts.Temperature)
v.Set("temperature", *opts.Temperature)
}
if opts.TopP != nil {
viper.Set("top-p", *opts.TopP)
v.Set("top-p", *opts.TopP)
}
if opts.TopK != nil {
viper.Set("top-k", *opts.TopK)
v.Set("top-k", *opts.TopK)
}
if opts.FrequencyPenalty != nil {
viper.Set("frequency-penalty", *opts.FrequencyPenalty)
v.Set("frequency-penalty", *opts.FrequencyPenalty)
}
if opts.PresencePenalty != nil {
viper.Set("presence-penalty", *opts.PresencePenalty)
v.Set("presence-penalty", *opts.PresencePenalty)
}
// Provider overrides. TLSSkipVerify only takes effect when true —
// callers wanting to force-disable should use the config file or
// env var instead.
if opts.ProviderAPIKey != "" {
viper.Set("provider-api-key", opts.ProviderAPIKey)
v.Set("provider-api-key", opts.ProviderAPIKey)
}
if opts.ProviderURL != "" {
viper.Set("provider-url", opts.ProviderURL)
v.Set("provider-url", opts.ProviderURL)
}
if opts.TLSSkipVerify {
viper.Set("tls-skip-verify", true)
v.Set("tls-skip-verify", true)
}
// Resolve working directory for context/skill discovery.
@@ -1324,7 +1333,7 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
// explicitly set system-prompt, use the per-model prompt as the
// base instead of the global default.
{
rawPromptInput := viper.GetString("system-prompt")
rawPromptInput := v.GetString("system-prompt")
// Resolve a file path to its content so PromptBuilder receives the
// actual prompt text rather than a literal path string. Without this,
@@ -1349,12 +1358,12 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
// Check for per-model system prompt override when no explicit
// global system-prompt was configured by the user.
if !userSetSystemPrompt {
modelStr := viper.GetString("model")
modelStr := v.GetString("model")
if modelStr != "" {
if mi := models.LookupModelForSettings(modelStr); mi != nil {
var perModelParams *models.GenerationParams
// modelSettings takes priority over custom model params.
if ms := models.LoadModelSettingsFromConfig(); ms != nil {
if ms := models.LoadModelSettingsFrom(v); ms != nil {
perModelParams = ms[modelStr]
}
if perModelParams == nil && mi.Params != nil {
@@ -1389,42 +1398,42 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
time.Now().Format("Monday, January 2, 2006, 3:04:05 PM MST"), cwd,
))
viper.Set("system-prompt", pb.Build())
v.Set("system-prompt", pb.Build())
}
// Snapshot all viper-derived values now, while the lock is held.
// BuildProviderConfig is fast (pure reads), so we do it here.
// Snapshot all instance-derived values now.
// BuildProviderConfig is fast (pure reads).
var pcErr error
providerConfig, _, pcErr = kitsetup.BuildProviderConfig()
providerConfig, _, pcErr = kitsetup.BuildProviderConfig(v)
if pcErr != nil {
return fmt.Errorf("failed to build provider config: %w", pcErr)
}
// SDK last-resort max-tokens floor. When nothing — Options, env,
// config, nor a per-model default — supplied a value, we land on
// zero here (viper.GetInt returns 0 for unset keys). Apply the
// SDK default directly on the struct rather than via viper so
// viper.IsSet("max-tokens") stays false: downstream right-sizing
// zero here (GetInt returns 0 for unset keys). Apply the
// SDK default directly on the struct rather than via the store so
// IsSet("max-tokens") stays false: downstream right-sizing
// can still raise this toward the model's known output ceiling,
// and per-model modelSettings[...].maxTokens can still win.
if providerConfig.MaxTokens == 0 && opts.MaxTokens == 0 {
providerConfig.MaxTokens = sdkDefaultMaxTokens
}
modelString = viper.GetString("model")
debug = viper.GetBool("debug")
noExtensions = opts.NoExtensions || viper.GetBool("no-extensions")
disableCoreTools = opts.DisableCoreTools || viper.GetBool("no-core-tools")
maxSteps = viper.GetInt("max-steps")
streaming = viper.GetBool("stream")
modelString = v.GetString("model")
debug = v.GetBool("debug")
noExtensions = opts.NoExtensions || v.GetBool("no-extensions")
disableCoreTools = opts.DisableCoreTools || v.GetBool("no-core-tools")
maxSteps = v.GetInt("max-steps")
streaming = v.GetBool("stream")
return nil
}(); err != nil {
return nil, err
}
// ---- viperInitMu released — heavy I/O below runs concurrently ----
// ---- config snapshot complete — heavy I/O below ----
// Load MCP configuration. Use pre-loaded config if provided directly,
// via CLI options, or load from viper as a last resort.
// via CLI options, or load from the instance store as a last resort.
if opts.MCPConfig != nil {
mcpConfig = opts.MCPConfig
} else if opts.CLI != nil && opts.CLI.MCPConfig != nil {
@@ -1432,7 +1441,7 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
}
if mcpConfig == nil {
var err error
mcpConfig, err = config.LoadAndValidateConfig()
mcpConfig, err = config.LoadAndValidateConfigFrom(v)
if err != nil {
return nil, fmt.Errorf("failed to load MCP config: %w", err)
}
@@ -1488,6 +1497,7 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
timeout: opts.MCPTaskTimeout,
progress: opts.MCPTaskProgress,
}.toToolsConfig(),
Viper: v,
}
// Set up OAuth handler for remote MCP servers. The SDK does not create
@@ -1557,6 +1567,7 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
authHandler: setupOpts.AuthHandler,
opts: opts,
mcpConfig: mcpConfig,
v: v,
hasCustomSystemPrompt: hasCustomSystemPrompt,
systemPromptSource: systemPromptSource,
basePrompt: capturedBasePrompt,
@@ -1836,6 +1847,50 @@ type SubagentResult struct {
Elapsed time.Duration
}
// inheritProviderConfig copies the parent's effective provider/runtime
// configuration from its isolated config store onto child Options. Used by
// Kit.Subagent so the child — which owns a separate store and re-loads only
// .kit.yml / KIT_* on its own — still observes provider credentials, the
// thinking level, and sampler/token overrides the parent acquired via
// programmatic Options or runtime setters (e.g. SetThinkingLevel).
//
// max-tokens and the sampling parameters are only propagated when the parent
// explicitly set them (IsSet), preserving the tri-state precedence so per-model
// defaults still apply on the child when the parent left them unset. A nil
// child or store is a no-op.
func inheritProviderConfig(child *Options, v *viper.Viper) {
if child == nil || v == nil {
return
}
child.ProviderAPIKey = v.GetString("provider-api-key")
child.ProviderURL = v.GetString("provider-url")
child.TLSSkipVerify = v.GetBool("tls-skip-verify")
child.ThinkingLevel = v.GetString("thinking-level")
if v.IsSet("max-tokens") {
child.MaxTokens = v.GetInt("max-tokens")
}
if v.IsSet("temperature") {
t := float32(v.GetFloat64("temperature"))
child.Temperature = &t
}
if v.IsSet("top-p") {
p := float32(v.GetFloat64("top-p"))
child.TopP = &p
}
if v.IsSet("top-k") {
k := int32(v.GetInt("top-k"))
child.TopK = &k
}
if v.IsSet("frequency-penalty") {
fp := float32(v.GetFloat64("frequency-penalty"))
child.FrequencyPenalty = &fp
}
if v.IsSet("presence-penalty") {
pp := float32(v.GetFloat64("presence-penalty"))
child.PresencePenalty = &pp
}
}
// Subagent spawns an in-process child Kit instance to perform a task. The
// child gets its own session, event bus, and agent loop but shares the
// parent's config (API keys, provider settings) and defaults to the parent's
@@ -1905,22 +1960,28 @@ func (m *Kit) Subagent(ctx context.Context, cfg SubagentConfig) (*SubagentResult
}
// Create child Kit instance. Pass the parent's loaded MCP config to
// avoid re-reading viper (which races with concurrent subagent spawns).
// Streaming must be explicitly enabled — Options.Streaming defaults to
// false, and New() unconditionally writes viper.Set("stream", opts.Streaming).
// Without this, the subagent would (a) pollute viper global state for
// other concurrent callers and (b) potentially hit provider-level
// differences (e.g. Anthropic non-streaming timeouts with extended
// thinking).
// avoid re-loading and re-validating config for the child.
// Streaming is enabled explicitly — without it, non-streaming can hit
// provider-level differences (e.g. Anthropic non-streaming timeouts with
// extended thinking). The child gets its own config store, so this does not
// affect any other concurrent caller.
streamOn := true
childOpts := &Options{
Model: model,
SystemPrompt: systemPrompt,
Tools: tools,
NoSession: cfg.NoSession,
Quiet: true,
Streaming: true,
Streaming: &streamOn,
MCPConfig: m.mcpConfig,
}
// Inherit the parent's effective provider/runtime configuration. Since #40
// each Kit owns an isolated config store, so the child's New() only re-loads
// .kit.yml / KIT_* on its own — values the parent picked up from
// programmatic Options or runtime setters (e.g. SetThinkingLevel) would
// otherwise be lost.
inheritProviderConfig(childOpts, m.v)
// Propagate the parent's MCP task configuration so a child subagent
// invoking long-running MCP tools observes the same per-server modes,
// timeouts, and progress callback as the parent. Without this, child
@@ -2129,7 +2190,7 @@ func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.
}
},
OnStepUsage: func(inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens int64) {
if viper.GetBool("debug") {
if m.v.GetBool("debug") {
log.Printf("DEBUG Kit.generate emitting StepUsageEvent: input=%d output=%d cacheRead=%d cacheCreate=%d",
inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens,
)
@@ -2625,7 +2686,7 @@ func (m *Kit) IsReasoningModel() bool {
// GetThinkingLevel returns the current thinking level.
func (m *Kit) GetThinkingLevel() string {
return viper.GetString("thinking-level")
return m.v.GetString("thinking-level")
}
// SetThinkingLevel changes the thinking level and recreates the agent with
@@ -2634,7 +2695,7 @@ func (m *Kit) GetThinkingLevel() string {
// With message-level caching, both thinking and caching work together.
// Caching reduces costs by 60-90% for repeated context.
func (m *Kit) SetThinkingLevel(ctx context.Context, level string) error {
viper.Set("thinking-level", level)
m.v.Set("thinking-level", level)
// Recreate agent with new thinking config by re-running SetModel
// with the same model string. SetModel rebuilds the provider and
// passes the updated viper config (including thinking-level).
+25 -24
View File
@@ -86,8 +86,8 @@ func TestNewWithGenerationOptions(t *testing.T) {
if got := host.MaxTokens(); got != want {
t.Errorf("Options.MaxTokens=%d did not propagate; Kit.MaxTokens()=%d", want, got)
}
if !viper.IsSet("max-tokens") {
t.Error("viper.IsSet(\"max-tokens\") should be true after MaxTokens override")
if !host.ConfigValueIsSetForTest("max-tokens") {
t.Error("max-tokens should be marked explicitly set on the instance store after MaxTokens override")
}
})
@@ -129,11 +129,11 @@ func TestNewWithGenerationOptions(t *testing.T) {
}
defer func() { _ = host.Close() }()
if !viper.IsSet("temperature") {
t.Fatal("viper.IsSet(\"temperature\") should be true after Temperature override")
if !host.ConfigValueIsSetForTest("temperature") {
t.Fatal("temperature should be marked explicitly set on the instance store after Temperature override")
}
if got := float32(viper.GetFloat64("temperature")); got != want {
t.Errorf("Options.Temperature=%v did not propagate; viper=%v", want, got)
if got := float32(host.ConfigFloatForTest("temperature")); got != want {
t.Errorf("Options.Temperature=%v did not propagate; instance store=%v", want, got)
}
})
}
@@ -185,8 +185,8 @@ func TestNewPreservesIsSetSemantics(t *testing.T) {
// from SDK-side SetDefault/Set calls — which is exactly what this
// test is guarding against.
for _, k := range checkKeys {
if viper.IsSet(k) {
t.Errorf("viper.IsSet(%q) == true when no Options field set it "+
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)
}
}
@@ -217,14 +217,14 @@ func TestNewWithProviderOptions(t *testing.T) {
}
defer func() { _ = host.Close() }()
if got := viper.GetString("provider-api-key"); got != apiKey {
t.Errorf("Options.ProviderAPIKey did not propagate to viper; got %q (len=%d)", got, len(got))
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 viper already holds a different
// provider-api-key value (as it would if a config file or earlier
// Set() call populated one), Options.ProviderAPIKey must win.
// 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()
@@ -242,15 +242,16 @@ func TestNewWithProviderOptions(t *testing.T) {
ProviderAPIKey: want,
})
// Creation may still fail if the model registry is strict, but
// we only care that the override reached viper before any
// provider handshake happened.
if host != nil {
defer func() { _ = host.Close() }()
// 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 := viper.GetString("provider-api-key"); got != want {
t.Errorf("Options.ProviderAPIKey did not override pre-existing viper value; got %q, want %q", got, want)
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)
}
})
@@ -270,7 +271,7 @@ func TestNewWithProviderOptions(t *testing.T) {
}
defer func() { _ = host.Close() }()
if got := viper.GetString("provider-url"); got != want {
if got := host.ConfigStringForTest("provider-url"); got != want {
t.Errorf("Options.ProviderURL did not propagate; got %q, want %q", got, want)
}
})
@@ -353,9 +354,9 @@ func TestNewSystemPromptFilePath(t *testing.T) {
t.Errorf("GetSystemPromptSource() = %q; want %q", got, want)
}
// The composed system prompt is written back to viper after PromptBuilder
// runs. It must contain the file's contents, not the file path.
composed := viper.GetString("system-prompt")
// 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)
}
@@ -392,7 +393,7 @@ func TestNewSystemPromptInline(t *testing.T) {
if got := host.GetSystemPromptSource(); got != inline {
t.Errorf("GetSystemPromptSource() = %q; want %q", got, inline)
}
if composed := viper.GetString("system-prompt"); !strings.Contains(composed, inline) {
if composed := host.ConfigStringForTest("system-prompt"); !strings.Contains(composed, inline) {
t.Errorf("composed system-prompt missing inline content; got %q", composed)
}
}
+81
View File
@@ -4,6 +4,8 @@ import (
"testing"
"time"
"github.com/spf13/viper"
"github.com/mark3labs/kit/internal/tools"
)
@@ -163,3 +165,82 @@ func TestSubagentPropagatesMCPTaskOptions(t *testing.T) {
inheritMCPTaskOptions(&Options{}, nil)
inheritMCPTaskOptions(nil, parent)
}
// TestInheritProviderConfig verifies that Kit.Subagent's provider/runtime
// config inheritance copies the parent's effective settings onto child
// Options, and that the tri-state (IsSet) keys are only propagated when the
// parent explicitly set them. Regression test for config loss after the
// per-instance viper store isolation (#40).
func TestInheritProviderConfig(t *testing.T) {
t.Run("explicit values propagate", func(t *testing.T) {
v := viper.New()
v.Set("provider-api-key", "sk-parent")
v.Set("provider-url", "https://proxy.internal/v1")
v.Set("tls-skip-verify", true)
v.Set("thinking-level", "high")
v.Set("max-tokens", 4321)
v.Set("temperature", 0.25)
v.Set("top-p", 0.9)
v.Set("top-k", 40)
v.Set("frequency-penalty", 0.1)
v.Set("presence-penalty", 0.2)
child := &Options{}
inheritProviderConfig(child, v)
if child.ProviderAPIKey != "sk-parent" {
t.Errorf("ProviderAPIKey = %q, want sk-parent", child.ProviderAPIKey)
}
if child.ProviderURL != "https://proxy.internal/v1" {
t.Errorf("ProviderURL = %q", child.ProviderURL)
}
if !child.TLSSkipVerify {
t.Error("TLSSkipVerify not propagated")
}
if child.ThinkingLevel != "high" {
t.Errorf("ThinkingLevel = %q, want high", child.ThinkingLevel)
}
if child.MaxTokens != 4321 {
t.Errorf("MaxTokens = %d, want 4321", child.MaxTokens)
}
if child.Temperature == nil || *child.Temperature != 0.25 {
t.Errorf("Temperature = %v, want 0.25", child.Temperature)
}
if child.TopP == nil || *child.TopP != 0.9 {
t.Errorf("TopP = %v, want 0.9", child.TopP)
}
if child.TopK == nil || *child.TopK != 40 {
t.Errorf("TopK = %v, want 40", child.TopK)
}
if child.FrequencyPenalty == nil || *child.FrequencyPenalty != 0.1 {
t.Errorf("FrequencyPenalty = %v, want 0.1", child.FrequencyPenalty)
}
if child.PresencePenalty == nil || *child.PresencePenalty != 0.2 {
t.Errorf("PresencePenalty = %v, want 0.2", child.PresencePenalty)
}
})
t.Run("unset tri-state keys stay unset", func(t *testing.T) {
// A store with no sampler / max-tokens keys must leave the child's
// pointers nil and MaxTokens zero so per-model defaults still apply.
v := viper.New()
child := &Options{}
inheritProviderConfig(child, v)
if child.MaxTokens != 0 {
t.Errorf("MaxTokens = %d, want 0 (unset)", child.MaxTokens)
}
if child.Temperature != nil || child.TopP != nil || child.TopK != nil ||
child.FrequencyPenalty != nil || child.PresencePenalty != nil {
t.Error("sampler pointers must stay nil when the parent did not set them")
}
if child.ThinkingLevel != "" {
t.Errorf("ThinkingLevel = %q, want empty", child.ThinkingLevel)
}
})
t.Run("nil child or store is a no-op", func(t *testing.T) {
inheritProviderConfig(nil, viper.New())
inheritProviderConfig(&Options{}, nil)
})
}
+88
View File
@@ -0,0 +1,88 @@
package kit
import "context"
// Option configures a [Kit] created via [NewAgent]. Options are applied in
// order to an [Options] value, so later options override earlier ones. The
// type is a plain func(*Options), so callers can define their own options
// without depending on any internal type.
type Option func(*Options)
// NewAgent creates a Kit using an ergonomic functional-options API. It is a
// thin, additive front door over [New]: the supplied options are applied to a
// fresh [Options] value which is then passed to [New]. For advanced
// configuration not covered by the With* helpers (MCPConfig,
// InProcessMCPServers, session backends, MCP task tuning, etc.) construct an
// [Options] explicitly and call [New].
//
// Streaming defaults to enabled. Pass WithStreaming(false) to disable it.
//
// Example:
//
// k, err := kit.NewAgent(ctx,
// kit.WithModel("anthropic/claude-sonnet-4-5-20250929"),
// kit.WithSystemPrompt("You are a helpful assistant."),
// kit.WithMaxTokens(8192),
// kit.Ephemeral(),
// )
func NewAgent(ctx context.Context, opts ...Option) (*Kit, error) {
// Streaming defaults to true for the ergonomic constructor — this is the
// natural expectation for interactive agents. WithStreaming(false) overrides it.
streamOn := true
o := &Options{Streaming: &streamOn}
for _, fn := range opts {
fn(o)
}
return New(ctx, o)
}
// WithModel sets the model in "provider/model" format
// (e.g. "anthropic/claude-sonnet-4-5-20250929").
func WithModel(m string) Option { return func(o *Options) { o.Model = m } }
// WithSystemPrompt sets the system prompt. The value may be inline text or a
// path to a file whose contents are loaded as the prompt.
func WithSystemPrompt(p string) Option { return func(o *Options) { o.SystemPrompt = p } }
// WithStreaming enables or disables streaming responses. [NewAgent] enables
// streaming by default, so pass WithStreaming(false) to opt out.
func WithStreaming(b bool) Option {
return func(o *Options) { o.Streaming = &b }
}
// WithMaxTokens sets the maximum output tokens per LLM response. A value of 0
// lets the precedence chain (env → config → per-model → SDK floor) resolve a
// value; a non-zero value pins it and suppresses automatic right-sizing.
func WithMaxTokens(n int) Option { return func(o *Options) { o.MaxTokens = n } }
// WithThinkingLevel sets the reasoning effort for models that support extended
// thinking. Valid values: "off", "none", "minimal", "low", "medium", "high".
// An empty string lets the precedence chain resolve a level.
func WithThinkingLevel(level string) Option { return func(o *Options) { o.ThinkingLevel = level } }
// WithTools sets the agent's tool set, replacing the default core tools. When
// no tools are provided the default set is used.
func WithTools(t ...Tool) Option { return func(o *Options) { o.Tools = t } }
// WithExtraTools adds tools alongside the core/MCP/extension tools rather than
// replacing them.
func WithExtraTools(t ...Tool) Option { return func(o *Options) { o.ExtraTools = t } }
// WithProviderAPIKey overrides the API key used to authenticate with the model
// provider.
func WithProviderAPIKey(key string) Option { return func(o *Options) { o.ProviderAPIKey = key } }
// WithProviderURL overrides the provider endpoint URL. Useful for
// OpenAI-compatible proxies (LiteLLM, vLLM, Azure OpenAI, etc.).
func WithProviderURL(url string) Option { return func(o *Options) { o.ProviderURL = url } }
// WithConfigFile sets an explicit config file path, overriding the default
// .kit.yml search.
func WithConfigFile(path string) Option { return func(o *Options) { o.ConfigFile = path } }
// WithDebug enables SDK debug logging.
func WithDebug() Option { return func(o *Options) { o.Debug = true } }
// Ephemeral configures an in-memory session with no persistence (equivalent to
// Options.NoSession = true).
func Ephemeral() Option { return func(o *Options) { o.NoSession = true } }
+232
View File
@@ -0,0 +1,232 @@
package kit_test
import (
"context"
"os"
"testing"
"github.com/mark3labs/kit/pkg/kit"
)
// TestOptionFunctionsPlumbing verifies that the functional options apply their
// values to the underlying Options struct. This does not create a provider, so
// it runs without API keys.
func TestOptionFunctionsPlumbing(t *testing.T) {
o := &kit.Options{}
opts := []kit.Option{
kit.WithModel("anthropic/claude-sonnet-4-5-20250929"),
kit.WithSystemPrompt("be terse"),
kit.WithMaxTokens(4321),
kit.WithThinkingLevel("high"),
kit.WithProviderAPIKey("sk-test"),
kit.WithProviderURL("https://example.test/v1"),
kit.WithConfigFile("/tmp/.kit.yml"),
kit.WithStreaming(false),
kit.WithDebug(),
kit.Ephemeral(),
}
for _, fn := range opts {
fn(o)
}
if o.Model != "anthropic/claude-sonnet-4-5-20250929" {
t.Errorf("WithModel: got %q", o.Model)
}
if o.SystemPrompt != "be terse" {
t.Errorf("WithSystemPrompt: got %q", o.SystemPrompt)
}
if o.MaxTokens != 4321 {
t.Errorf("WithMaxTokens: got %d", o.MaxTokens)
}
if o.ThinkingLevel != "high" {
t.Errorf("WithThinkingLevel: got %q", o.ThinkingLevel)
}
if o.ProviderAPIKey != "sk-test" {
t.Errorf("WithProviderAPIKey: got %q", o.ProviderAPIKey)
}
if o.ProviderURL != "https://example.test/v1" {
t.Errorf("WithProviderURL: got %q", o.ProviderURL)
}
if o.ConfigFile != "/tmp/.kit.yml" {
t.Errorf("WithConfigFile: got %q", o.ConfigFile)
}
if o.Streaming == nil {
t.Error("WithStreaming: expected Streaming to be set (non-nil)")
} else if *o.Streaming {
t.Error("WithStreaming(false): expected *Streaming=false")
}
if !o.Debug {
t.Error("WithDebug: expected Debug=true")
}
if !o.NoSession {
t.Error("Ephemeral: expected NoSession=true")
}
}
// TestOptionOrderingOverrides verifies later options override earlier ones.
func TestOptionOrderingOverrides(t *testing.T) {
o := &kit.Options{}
kit.WithModel("a/b")(o)
kit.WithModel("c/d")(o)
if o.Model != "c/d" {
t.Errorf("later WithModel should win; got %q", o.Model)
}
}
// TestKitConfigIsolation is the regression test for issue #40: two Kit
// instances constructed in the same process must own independent configuration
// stores. Setting the thinking level (or model) on one must not affect the
// other. Against the previous global-viper implementation this test fails
// because both Kits read and write the same process-global store.
func TestKitConfigIsolation(t *testing.T) {
if os.Getenv("ANTHROPIC_API_KEY") == "" {
t.Skip("Skipping test: ANTHROPIC_API_KEY not set")
}
ctx := context.Background()
a, err := kit.New(ctx, &kit.Options{
Model: "anthropic/claude-sonnet-4-5-20250929",
ThinkingLevel: "low",
Quiet: true,
NoSession: true,
NoExtensions: true,
})
if err != nil {
t.Fatalf("failed to create Kit A: %v", err)
}
defer func() { _ = a.Close() }()
b, err := kit.New(ctx, &kit.Options{
Model: "anthropic/claude-sonnet-4-5-20250929",
ThinkingLevel: "high",
Quiet: true,
NoSession: true,
NoExtensions: true,
})
if err != nil {
t.Fatalf("failed to create Kit B: %v", err)
}
defer func() { _ = b.Close() }()
// Each instance must retain its own configured thinking level. Under the
// old global-viper implementation, B's construction overwrote A's value.
if got := a.GetThinkingLevel(); got != "low" {
t.Errorf("Kit A thinking level = %q; want %q (config leaked from B)", got, "low")
}
if got := b.GetThinkingLevel(); got != "high" {
t.Errorf("Kit B thinking level = %q; want %q", got, "high")
}
// Mutating one at runtime must not bleed into the other.
if err := a.SetThinkingLevel(ctx, "medium"); err != nil {
t.Fatalf("SetThinkingLevel on A: %v", err)
}
if got := a.GetThinkingLevel(); got != "medium" {
t.Errorf("after SetThinkingLevel, Kit A = %q; want %q", got, "medium")
}
if got := b.GetThinkingLevel(); got != "high" {
t.Errorf("after mutating A, Kit B leaked to %q; want %q", got, "high")
}
}
// TestNewAgentDefaultsStreamingOn verifies that the ergonomic constructor
// enables streaming by default and applies functional options.
func TestNewAgentDefaultsStreamingOn(t *testing.T) {
if os.Getenv("ANTHROPIC_API_KEY") == "" {
t.Skip("Skipping test: ANTHROPIC_API_KEY not set")
}
ctx := context.Background()
k, err := kit.NewAgent(ctx,
kit.WithModel("anthropic/claude-sonnet-4-5-20250929"),
kit.WithMaxTokens(2048),
kit.Ephemeral(),
)
if err != nil {
t.Fatalf("NewAgent failed: %v", err)
}
defer func() { _ = k.Close() }()
if !k.ConfigValueIsSetForTest("max-tokens") {
t.Error("NewAgent did not propagate WithMaxTokens to the instance store")
}
if !k.ConfigBoolForTest("stream") {
t.Error("NewAgent should enable streaming by default")
}
}
// TestNewAgentStreamingOptOut verifies WithStreaming(false) disables the
// default-on streaming behaviour of NewAgent.
func TestNewAgentStreamingOptOut(t *testing.T) {
if os.Getenv("ANTHROPIC_API_KEY") == "" {
t.Skip("Skipping test: ANTHROPIC_API_KEY not set")
}
ctx := context.Background()
k, err := kit.NewAgent(ctx,
kit.WithModel("anthropic/claude-sonnet-4-5-20250929"),
kit.WithStreaming(false),
kit.Ephemeral(),
)
if err != nil {
t.Fatalf("NewAgent failed: %v", err)
}
defer func() { _ = k.Close() }()
if k.ConfigBoolForTest("stream") {
t.Error("WithStreaming(false) should disable streaming")
}
}
// TestNewZeroOptionsKeepsStreamingDefault is the regression test for the
// unconditional `v.Set("stream", opts.Streaming)` bug: a zero-valued Options
// (Streaming == nil) must NOT force stream=false. With Streaming unset,
// streaming resolves through the precedence chain, whose SDK default is true.
func TestNewZeroOptionsKeepsStreamingDefault(t *testing.T) {
if os.Getenv("ANTHROPIC_API_KEY") == "" {
t.Skip("Skipping test: ANTHROPIC_API_KEY not set")
}
ctx := context.Background()
k, err := kit.New(ctx, &kit.Options{
Model: "anthropic/claude-sonnet-4-5-20250929",
Quiet: true,
NoSession: true,
SkipConfig: true, // isolate from any ~/.kit.yml / env stream setting
})
if err != nil {
t.Fatalf("New failed: %v", err)
}
defer func() { _ = k.Close() }()
if !k.ConfigBoolForTest("stream") {
t.Error("zero-valued Options must not force stream=false; expected the default (true)")
}
}
// TestNewStreamingExplicitOptOut verifies that a raw Options can still disable
// streaming by setting Streaming to a pointer to false.
func TestNewStreamingExplicitOptOut(t *testing.T) {
if os.Getenv("ANTHROPIC_API_KEY") == "" {
t.Skip("Skipping test: ANTHROPIC_API_KEY not set")
}
streamOff := false
ctx := context.Background()
k, err := kit.New(ctx, &kit.Options{
Model: "anthropic/claude-sonnet-4-5-20250929",
Quiet: true,
NoSession: true,
SkipConfig: true,
Streaming: &streamOff,
})
if err != nil {
t.Fatalf("New failed: %v", err)
}
defer func() { _ = k.Close() }()
if k.ConfigBoolForTest("stream") {
t.Error("Streaming=&false should disable streaming")
}
}
+15 -4
View File
@@ -7,6 +7,16 @@ description: Configuration options for the Kit Go SDK.
Pass an `Options` struct to `kit.New()` to configure the Kit instance.
::: tip
For simple setups, `kit.NewAgent(ctx, ...Option)` provides functional-options
helpers (`WithModel`, `WithStreaming`, `Ephemeral`, ...) over the same `Options`
struct. See [Functional options](/sdk/overview#functional-options-newagent).
:::
Each `kit.New` / `kit.NewAgent` call owns an isolated configuration store, so
these options never leak between Kit instances in the same process. See
[Per-instance config isolation](/sdk/overview#per-instance-config-isolation).
## Full options reference
```go
@@ -18,7 +28,7 @@ host, err := kit.New(ctx, &kit.Options{
// Behavior
MaxSteps: 10,
Streaming: true,
Streaming: ptrBool(true), // *bool: nil = unset (default true), &false = off
Quiet: true,
Debug: true,
@@ -91,7 +101,7 @@ host, err := kit.New(ctx, &kit.Options{
| `SystemPrompt` | `string` | — | System prompt text or file path |
| `ConfigFile` | `string` | `~/.kit.yml` | Path to config file |
| `MaxSteps` | `int` | `0` | Max agent steps (0 = unlimited) |
| `Streaming` | `bool` | `true` | Enable streaming output |
| `Streaming` | `*bool` | `nil` | Enable streaming output. `nil` leaves it to the precedence chain (env → config → default `true`); `&true`/`&false` forces it. Pointer so unset is distinct from explicit `false`. |
| `Quiet` | `bool` | `false` | Suppress output |
| `Debug` | `bool` | `false` | Enable debug logging |
@@ -114,9 +124,10 @@ defaults for samplers).
| `FrequencyPenalty` | `*float32` | — | OpenAI-family frequency penalty. `nil` leaves provider default. |
| `PresencePenalty` | `*float32` | — | OpenAI-family presence penalty. `nil` leaves provider default. |
Pointer-typed samplers are populated via a tiny helper:
Pointer-typed fields (`Streaming` and the samplers) are populated via tiny helpers:
```go
func ptrBool(v bool) *bool { return &v }
func ptrFloat32(v float32) *float32 { return &v }
```
@@ -127,7 +138,7 @@ when embedding Kit as a library.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `ProviderAPIKey` | `string` | — | API key used to authenticate with the provider. `""` falls back to config / provider-specific env var (e.g. `ANTHROPIC_API_KEY`). When set, overrides any pre-existing viper state. |
| `ProviderAPIKey` | `string` | — | API key used to authenticate with the provider. `""` falls back to config / provider-specific env var (e.g. `ANTHROPIC_API_KEY`). When set, it takes precedence over config and env values on this instance's store. |
| `ProviderURL` | `string` | — | Override the provider endpoint (e.g. LiteLLM, vLLM, Azure OpenAI, internal proxy). `""` = provider default. |
| `TLSSkipVerify` | `bool` | `false` | Disable TLS certificate verification on the provider HTTP client. Only effective when `true`; to force-disable, use config file or env var instead. For self-signed dev certs only. |
+67
View File
@@ -45,6 +45,73 @@ func main() {
}
```
## Functional options (`NewAgent`)
For simple programmatic setups, `kit.NewAgent` offers an ergonomic
functional-options front door over `kit.New`. Streaming is **enabled by
default**; pass `kit.WithStreaming(false)` to opt out.
```go
host, err := kit.NewAgent(ctx,
kit.WithModel("anthropic/claude-sonnet-4-5-20250929"),
kit.WithSystemPrompt("You are a helpful assistant."),
kit.WithMaxTokens(8192),
kit.WithThinkingLevel("medium"),
kit.Ephemeral(), // in-memory session, no persistence
)
if err != nil {
log.Fatal(err)
}
defer host.Close()
```
Available options:
| Option | Sets |
|--------|------|
| `WithModel(string)` | `Options.Model` (provider/model format) |
| `WithSystemPrompt(string)` | `Options.SystemPrompt` (inline text or file path) |
| `WithStreaming(bool)` | `Options.Streaming` (default `true` under `NewAgent`) |
| `WithMaxTokens(int)` | `Options.MaxTokens` |
| `WithThinkingLevel(string)` | `Options.ThinkingLevel` |
| `WithTools(...Tool)` | `Options.Tools` (replaces the default set) |
| `WithExtraTools(...Tool)` | `Options.ExtraTools` (adds alongside defaults) |
| `WithProviderAPIKey(string)` | `Options.ProviderAPIKey` |
| `WithProviderURL(string)` | `Options.ProviderURL` |
| `WithConfigFile(string)` | `Options.ConfigFile` |
| `WithDebug()` | `Options.Debug = true` |
| `Ephemeral()` | `Options.NoSession = true` |
Options are applied in order, so later options override earlier ones. `Option`
is a plain `func(*Options)`, so you can define your own. For advanced
configuration not covered by the helpers (custom MCP config, in-process MCP
servers, session backends, MCP task tuning) construct an `Options` value
explicitly and call `kit.New`.
### When to use which
| Constructor | Use when |
|-------------|----------|
| `kit.NewAgent(ctx, ...Option)` | Quick programmatic setups; you only need the common fields. Streaming defaults on. |
| `kit.New(ctx, *Options)` | You need fields without a `With*` helper (`MCPConfig`, `InProcessMCPServers`, `SessionManager`, MCP task tuning, etc.), or you already hold an `Options` value. |
## Per-instance config isolation
Each `kit.New` / `kit.NewAgent` call owns an **isolated configuration store**,
so constructing multiple Kit instances in the same process is safe: setting the
model, thinking level, or generation parameters on one never affects another,
and runtime mutators (`SetModel`, `SetThinkingLevel`) only touch the owning
instance. This makes subagent spawning and multi-Kit embedding race-free with
no external synchronization required.
```go
a, _ := kit.NewAgent(ctx, kit.WithThinkingLevel("low"))
b, _ := kit.NewAgent(ctx, kit.WithThinkingLevel("high"))
a.SetThinkingLevel(ctx, "medium")
// a.GetThinkingLevel() == "medium"; b.GetThinkingLevel() is still "high"
```
## Multi-turn conversations
Conversations retain context automatically across calls: