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
+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)