Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions examples/sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
18 changes: 15 additions & 3 deletions internal/acpserver/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
34 changes: 25 additions & 9 deletions internal/config/merger.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
19 changes: 18 additions & 1 deletion internal/extensions/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
107 changes: 65 additions & 42 deletions internal/kitsetup/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand All @@ -149,28 +159,35 @@ 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
}
}

// 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
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
Loading
Loading