Feature Description
The public SDK package pkg/kit directly imports github.com/spf13/viper and uses viper's process-global config store throughout its implementation. The SDK should be decoupled from viper entirely — viper is a CLI concern, not an SDK concern.
The CLI (cmd/) can still use viper to source configuration from flags/env/config files, but it should pass those values into the SDK as plain Options struct fields. The SDK itself should have zero import of viper.
Current State (verified)
pkg/kit/kit.go line 28 imports viper directly, and there are ~92 references to viper.* calls inside the package. Examples:
// pkg/kit/kit.go
import (
...
"github.com/spf13/viper"
)
// Reads from global viper state:
systemPrompt, _ := config.LoadSystemPrompt(viper.GetString("system-prompt"))
thinkingLevel := models.ParseThinkingLevel(viper.GetString("thinking-level"))
extraPaths := viper.GetStringSlice("extension")
modelString = viper.GetString("model")
debug = viper.GetBool("debug")
// Writes to global viper state from New():
viper.Set("model", opts.Model)
viper.Set("system-prompt", opts.SystemPrompt)
viper.Set("max-steps", opts.MaxSteps)
viper.Set("stream", opts.Streaming)
pkg/kit/kit_test.go also imports viper and relies on a resetViper() helper between tests. The package even has self-aware comments acknowledging the problem:
// Global viper state warning:
// Options are applied by [New] via [viper.Set] calls against viper's
// process-global store...
//
// 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
Motivation / Use Case
- SDK contract hygiene — per
AGENTS.md, pkg/kit/ is the public API surface for external Go developers embedding Kit as a library. Internal dependencies (viper, fantasy, etc.) should not leak into the SDK's import graph. Forcing SDK consumers to transitively depend on viper is exactly the kind of leakage the SDK rules are meant to prevent.
- Global state hazards — viper's global store means two
kit.New() calls in the same process race each other. The existing viperInitMu mutex only serializes writes during construction; it does not prevent later reads from seeing clobbered state. Subagent spawning, tests, and any multi-Kit embedding scenario are all affected. The TODO comments in kit.go already flag this.
- Testability —
kit_test.go has to import viper and use a resetViper() helper between tests. An SDK consumer should never need to know viper exists.
- Embedding friction — anyone embedding Kit must reason about viper's global state even if they have their own config system.
Proposed Implementation
- Define a self-contained config struct inside
pkg/kit (expand Options or introduce an internal resolvedConfig struct) that holds every value currently read via viper.Get*.
- In
cmd/ (CLI layer), build that struct from viper and pass it to kit.New() via Options. This is the "viper → CLI → SDK" boundary described above — passing options through the CLI is fine; importing viper in the SDK is not.
- Replace every
viper.Get* call inside pkg/kit with a field access on the resolved config.
- Replace every
viper.Set call inside pkg/kit with a struct field write (many exist only so a later viper.Get in the same package can read it back).
- For state that needs to be mutable at runtime (e.g.
SetThinkingLevel at lines ~2626/2635), store it on the Kit instance, not viper.
- Remove the
github.com/spf13/viper import from pkg/kit/kit.go and verify with go list -deps ./pkg/kit | grep viper (should be empty).
- Drop
viperInitMu — no longer needed once global state is gone.
Acceptance: grep -r viper pkg/kit/ returns nothing (other than possibly historical comments), and pkg/kit no longer transitively depends on viper.
Handling Defaults
Removing viper means we lose viper.IsSet(), which the SDK currently uses as a tri-state signal. pkg/kit/config.go deliberately does not call viper.SetDefault for certain keys precisely because doing so would make IsSet() return true and suppress downstream defaults. We must preserve this tri-state explicitly in Go.
Each setting falls into one of three precedence tiers:
- Explicitly set by the caller (flag / env / config /
Options) → use that value.
- Unset, but has a package default (
model, system-prompt, stream, num-gpu-layers, main-gpu) → currently registered via viper.SetDefault. These become plain Go constants applied in a resolver.
- Unset, and must stay unset (
max-tokens, temperature, top-p, top-k, frequency/presence-penalty, thinking-level) → deliberately given no default so downstream logic still fires:
max-tokens: applied as a last-resort struct-level floor (sdkDefaultMaxTokens = 8192) on *models.ProviderConfig after BuildProviderConfig, so per-model defaults (ApplyModelSettings) and auto right-sizing (rightSizeMaxTokens) still run.
thinking-level: handled by models.ParseThinkingLevel("") → ThinkingOff.
- sampling params: left as
nil pointers so provider libraries apply their own defaults.
Mapping viper.IsSet() → dependency-free equivalent
Replace !viper.IsSet(key) with a nil pointer (or a small Option[T]{ v, set } generic). A nil pointer = "unset, let downstream default win"; non-nil = "explicit." This is the same pattern the sampling params (*Temperature, *TopP, ...) already use — extend it to every tier-3 setting. This also kills the global-state race that viperInitMu exists to paper over, since state becomes per-instance.
⚠️ Do NOT collapse the tri-state
stream, num-gpu-layers, main-gpu, max-tokens, and the sampling params must not be given collapsing zero-value defaults. MaxTokens: 0 / Streaming: false currently mean "resolve via chain," not "force this value." Naively moving these to plain struct fields would silently break per-model defaults and right-sizing.
Note: Streaming bool is an existing latent edge case — New() unconditionally does viper.Set("stream", opts.Streaming), so an SDK caller cannot currently leave it "unset" (false always wins). The refactor should make a deliberate choice here (honor the bool as-is, or switch to *bool) and document it.
Boundary
All flag/env/config-file precedence resolution stays in cmd/ (where viper lives legitimately). The CLI translates resolved values into Options; the SDK consumes a fully-resolved struct.
Ergonomic Constructor & Backward Compatibility
Does this break the API contract?
No, if done carefully. viper never appears in an exported signature — it's purely an implementation detail of kit.go function bodies. The public contract is New(ctx, *Options) (*Kit, error) plus the Options fields, both of which stay unchanged. New(ctx, *Options) is used throughout the README and examples/sdk/*, so it must keep working and should not be deprecated (it's the escape hatch for programmatic config, MCPConfig, InProcessMCPServers).
The only behavioral risk is the tier-3 tri-state described above — preserve the documented "0 = resolve via chain" conventions and it remains non-breaking (except the Streaming nuance noted above).
Add a functional-options front door
Layer an ergonomic constructor on top, purely additively:
// Existing low-level constructor stays as-is (backward compatible).
func New(ctx context.Context, opts *Options) (*Kit, error)
// New ergonomic variadic constructor.
func NewAgent(ctx context.Context, opts ...Option) (*Kit, error) {
o := &Options{}
for _, fn := range opts { fn(o) }
return New(ctx, o)
}
type Option func(*Options)
func WithModel(m string) Option { return func(o *Options) { o.Model = m } }
func WithSystemPrompt(p string) Option { return func(o *Options) { o.SystemPrompt = p } }
func WithStreaming(b bool) Option { return func(o *Options) { o.Streaming = b } }
func WithMaxTokens(n int) Option { return func(o *Options) { o.MaxTokens = n } }
func WithTools(t ...Tool) Option { return func(o *Options) { o.Tools = t } }
func Ephemeral() Option { return func(o *Options) { o.NoSession = true } }
Usage:
agent, err := kit.NewAgent(ctx,
kit.WithModel("anthropic/claude-sonnet-4-5"),
kit.WithStreaming(true),
kit.Ephemeral(),
)
Why this also solves the defaults problem
With functional options, "option not provided" IS the unset signal — there is no zero-vs-unset ambiguity at the struct level, because absent With* calls simply never touch the field. This is also the clean CLI→SDK bridge: the CLI reads viper and appends an option only when the key is set:
// in cmd/ — viper lives here, legitimately
var opts []kit.Option
if viper.IsSet("model") { opts = append(opts, kit.WithModel(viper.GetString("model"))) }
if viper.IsSet("max-tokens") { opts = append(opts, kit.WithMaxTokens(viper.GetInt("max-tokens"))) }
agent, _ := kit.NewAgent(ctx, opts...)
viper.IsSet() → "append option or not" is the natural tri-state bridge across the boundary, with zero viper in the SDK.
Recommendation
- Keep
New(ctx, *Options) (do not deprecate).
- Add
NewAgent(ctx, ...Option) as the ergonomic entry point with With* / Ephemeral() helpers.
Option is func(*Options) — no dependency leakage.
Feature Description
The public SDK package
pkg/kitdirectly importsgithub.com/spf13/viperand uses viper's process-global config store throughout its implementation. The SDK should be decoupled from viper entirely — viper is a CLI concern, not an SDK concern.The CLI (
cmd/) can still use viper to source configuration from flags/env/config files, but it should pass those values into the SDK as plainOptionsstruct fields. The SDK itself should have zero import of viper.Current State (verified)
pkg/kit/kit.goline 28 imports viper directly, and there are ~92 references toviper.*calls inside the package. Examples:pkg/kit/kit_test.goalso imports viper and relies on aresetViper()helper between tests. The package even has self-aware comments acknowledging the problem:Motivation / Use Case
AGENTS.md,pkg/kit/is the public API surface for external Go developers embedding Kit as a library. Internal dependencies (viper, fantasy, etc.) should not leak into the SDK's import graph. Forcing SDK consumers to transitively depend on viper is exactly the kind of leakage the SDK rules are meant to prevent.kit.New()calls in the same process race each other. The existingviperInitMumutex only serializes writes during construction; it does not prevent later reads from seeing clobbered state. Subagent spawning, tests, and any multi-Kit embedding scenario are all affected. The TODO comments inkit.goalready flag this.kit_test.gohas to import viper and use aresetViper()helper between tests. An SDK consumer should never need to know viper exists.Proposed Implementation
pkg/kit(expandOptionsor introduce an internalresolvedConfigstruct) that holds every value currently read viaviper.Get*.cmd/(CLI layer), build that struct from viper and pass it tokit.New()viaOptions. This is the "viper → CLI → SDK" boundary described above — passing options through the CLI is fine; importing viper in the SDK is not.viper.Get*call insidepkg/kitwith a field access on the resolved config.viper.Setcall insidepkg/kitwith a struct field write (many exist only so a laterviper.Getin the same package can read it back).SetThinkingLevelat lines ~2626/2635), store it on theKitinstance, not viper.github.com/spf13/viperimport frompkg/kit/kit.goand verify withgo list -deps ./pkg/kit | grep viper(should be empty).viperInitMu— no longer needed once global state is gone.Acceptance:
grep -r viper pkg/kit/returns nothing (other than possibly historical comments), andpkg/kitno longer transitively depends on viper.Handling Defaults
Removing viper means we lose
viper.IsSet(), which the SDK currently uses as a tri-state signal.pkg/kit/config.godeliberately does not callviper.SetDefaultfor certain keys precisely because doing so would makeIsSet()returntrueand suppress downstream defaults. We must preserve this tri-state explicitly in Go.Each setting falls into one of three precedence tiers:
Options) → use that value.model,system-prompt,stream,num-gpu-layers,main-gpu) → currently registered viaviper.SetDefault. These become plain Go constants applied in a resolver.max-tokens,temperature,top-p,top-k,frequency/presence-penalty,thinking-level) → deliberately given no default so downstream logic still fires:max-tokens: applied as a last-resort struct-level floor (sdkDefaultMaxTokens= 8192) on*models.ProviderConfigafterBuildProviderConfig, so per-model defaults (ApplyModelSettings) and auto right-sizing (rightSizeMaxTokens) still run.thinking-level: handled bymodels.ParseThinkingLevel("")→ThinkingOff.nilpointers so provider libraries apply their own defaults.Mapping
viper.IsSet()→ dependency-free equivalentReplace
!viper.IsSet(key)with anilpointer (or a smallOption[T]{ v, set }generic). Anilpointer = "unset, let downstream default win"; non-nil = "explicit." This is the same pattern the sampling params (*Temperature,*TopP, ...) already use — extend it to every tier-3 setting. This also kills the global-state race thatviperInitMuexists to paper over, since state becomes per-instance.stream,num-gpu-layers,main-gpu,max-tokens, and the sampling params must not be given collapsing zero-value defaults.MaxTokens: 0/Streaming: falsecurrently mean "resolve via chain," not "force this value." Naively moving these to plain struct fields would silently break per-model defaults and right-sizing.Boundary
All flag/env/config-file precedence resolution stays in
cmd/(where viper lives legitimately). The CLI translates resolved values intoOptions; the SDK consumes a fully-resolved struct.Ergonomic Constructor & Backward Compatibility
Does this break the API contract?
No, if done carefully. viper never appears in an exported signature — it's purely an implementation detail of
kit.gofunction bodies. The public contract isNew(ctx, *Options) (*Kit, error)plus theOptionsfields, both of which stay unchanged.New(ctx, *Options)is used throughout the README andexamples/sdk/*, so it must keep working and should not be deprecated (it's the escape hatch for programmatic config,MCPConfig,InProcessMCPServers).The only behavioral risk is the tier-3 tri-state described above — preserve the documented "0 = resolve via chain" conventions and it remains non-breaking (except the
Streamingnuance noted above).Add a functional-options front door
Layer an ergonomic constructor on top, purely additively:
Usage:
Why this also solves the defaults problem
With functional options, "option not provided" IS the unset signal — there is no zero-vs-unset ambiguity at the struct level, because absent
With*calls simply never touch the field. This is also the clean CLI→SDK bridge: the CLI reads viper and appends an option only when the key is set:viper.IsSet()→ "append option or not" is the natural tri-state bridge across the boundary, with zero viper in the SDK.Recommendation
New(ctx, *Options)(do not deprecate).NewAgent(ctx, ...Option)as the ergonomic entry point withWith*/Ephemeral()helpers.Optionisfunc(*Options)— no dependency leakage.