From 3a8ffc2104324d66a8534e2438cd6967626f63bc Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Thu, 9 Apr 2026 12:35:00 +0300 Subject: [PATCH] feat(models): add per-model system prompt support - Add systemPrompt field to GenerationParams and config structs - On init, replace default system prompt with per-model prompt when user hasn't explicitly set one (via flag, config, or SDK option) - On model switch, detect per-model prompt and compose it with AGENTS.md, skills, and date/cwd context - Fix viper.IsSet bug: BindPFlag causes IsSet to return true for unset flags, so compare against defaultSystemPrompt instead - Agent.SetModel now updates stored system prompt from config - Export LoadModelSettingsFromConfig, LoadSystemPromptValue, and LookupModelForSettings for use by Kit.SetModel - Add tests for prompt apply, precedence, file path, and modelSettings override --- internal/agent/agent.go | 15 +++- internal/config/config.go | 3 + internal/models/custom.go | 40 ++++++++- internal/models/custom_test.go | 115 +++++++++++++++++++++++++ internal/models/registry.go | 12 +++ pkg/kit/kit.go | 150 ++++++++++++++++++++++++++------- 6 files changed, 298 insertions(+), 37 deletions(-) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 8bbdca69..4a24a173 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -839,9 +839,11 @@ func (a *Agent) GetLoadedServerNames() []string { return a.toolManager.GetLoadedServerNames() } -// SetModel swaps the agent's LLM provider to a new model. The existing tools, -// system prompt, and configuration are preserved. The old provider is closed -// if it has a closer. Returns the previous model string for notification. +// SetModel swaps the agent's LLM provider to a new model. The existing tools +// and configuration are preserved. When the new model's ProviderConfig carries +// a system prompt (from per-model settings), it replaces the agent's stored +// prompt so the rebuilt fantasy agent uses it. The old provider is closed if +// it has a closer. func (a *Agent) SetModel(ctx context.Context, config *models.ProviderConfig) error { // Ensure MCP tools are loaded before rebuilding (SetModel may be called // before the first LLM call). @@ -868,6 +870,13 @@ func (a *Agent) SetModel(ctx context.Context, config *models.ProviderConfig) err a.skipMaxOutputTokens = providerResult.SkipMaxOutputTokens a.modelConfig = config + // Update system prompt when the config carries one (from per-model + // settings or the global config). This allows model-specific system + // prompts to take effect on model switch. + if config.SystemPrompt != "" { + a.systemPrompt = config.SystemPrompt + } + // Update provider type. if config.ModelString != "" { if p, _, err := models.ParseModelString(config.ModelString); err == nil { diff --git a/internal/config/config.go b/internal/config/config.go index 8a603b2e..3087727e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -169,6 +169,7 @@ type GenerationParams struct { PresencePenalty *float32 `json:"presencePenalty,omitempty" yaml:"presencePenalty,omitempty"` StopSequences []string `json:"stopSequences,omitempty" yaml:"stopSequences,omitempty"` ThinkingLevel string `json:"thinkingLevel,omitempty" yaml:"thinkingLevel,omitempty"` + SystemPrompt string `json:"systemPrompt,omitempty" yaml:"systemPrompt,omitempty"` } // CustomModelConfig defines a custom model that can be used with custom/custom @@ -417,6 +418,7 @@ mcpServers: # anthropic/claude-opus-4-6: # thinkingLevel: "high" # maxTokens: 16384 +# systemPrompt: "You are a deep reasoning assistant." # or a file path # API Configuration (can also use environment variables) # provider-api-key: "your-api-key" # API key for OpenAI, Anthropic, or Google @@ -439,6 +441,7 @@ mcpServers: # temperature: 0.8 # topP: 0.95 # topK: 40 +# systemPrompt: "You are a helpful local assistant." ` _, err = file.WriteString(content) diff --git a/internal/models/custom.go b/internal/models/custom.go index 56827f44..43f11c5d 100644 --- a/internal/models/custom.go +++ b/internal/models/custom.go @@ -2,6 +2,8 @@ package models import ( "log" + "os" + "strings" "github.com/spf13/viper" ) @@ -57,10 +59,10 @@ func modelConfigToModelInfo(modelID string, cfg CustomModelConfig) ModelInfo { return info } -// loadModelSettingsFromConfig loads per-model generation parameter overrides +// LoadModelSettingsFromConfig loads per-model generation parameter overrides // from the config file. Keys are "provider/model" strings. Returns nil if // no model settings are configured. -func loadModelSettingsFromConfig() map[string]*GenerationParams { +func LoadModelSettingsFromConfig() map[string]*GenerationParams { if !viper.IsSet("modelSettings") { return nil } @@ -119,6 +121,10 @@ func convertGenerationParams(cfg GenerationParamsConfig) *GenerationParams { p.ThinkingLevel = ParseThinkingLevel(cfg.ThinkingLevel) any = true } + if cfg.SystemPrompt != "" { + p.SystemPrompt = cfg.SystemPrompt + any = true + } if !any { return nil @@ -147,7 +153,7 @@ func ApplyModelSettings(config *ProviderConfig, modelInfo *ModelInfo) { var params *GenerationParams // First check modelSettings from config. - if settings := loadModelSettingsFromConfig(); settings != nil { + if settings := LoadModelSettingsFromConfig(); settings != nil { modelKey := provider + "/" + modelName if p, ok := settings[modelKey]; ok { params = p @@ -191,6 +197,32 @@ func ApplyModelSettings(config *ProviderConfig, modelInfo *ModelInfo) { if params.ThinkingLevel != "" && !isExplicitlySet("thinking-level") { config.ThinkingLevel = params.ThinkingLevel } + if params.SystemPrompt != "" && config.SystemPrompt == "" { + // Resolve file paths: if the value points to an existing file, read it. + // We check config.SystemPrompt == "" rather than isExplicitlySet because + // viper.BindPFlag causes IsSet to return true even for unset flags. + config.SystemPrompt = LoadSystemPromptValue(params.SystemPrompt) + } +} + +// LoadSystemPromptValue resolves a system prompt value that may be either +// inline text or a file path. If the value is a path to an existing file, +// its contents are read and returned. Otherwise the string is returned as-is. +// This mirrors config.LoadSystemPrompt but lives in the models package to +// avoid circular dependencies. +func LoadSystemPromptValue(input string) string { + if input == "" { + return "" + } + if info, err := os.Stat(input); err == nil && !info.IsDir() { + content, err := os.ReadFile(input) + if err != nil { + log.Printf("Warning: failed to read system prompt file %q: %v", input, err) + return input + } + return strings.TrimSpace(string(content)) + } + return input } // isExplicitlySet returns true when the user has explicitly set a config key @@ -223,6 +255,7 @@ type GenerationParams struct { PresencePenalty *float32 StopSequences []string ThinkingLevel ThinkingLevel + SystemPrompt string // Per-model system prompt (inline text or file path) } // CustomModelConfig defines a custom model configuration loaded from the config file. @@ -252,6 +285,7 @@ type GenerationParamsConfig struct { PresencePenalty *float32 `json:"presencePenalty,omitempty" yaml:"presencePenalty,omitempty"` StopSequences []string `json:"stopSequences,omitempty" yaml:"stopSequences,omitempty"` ThinkingLevel string `json:"thinkingLevel,omitempty" yaml:"thinkingLevel,omitempty"` + SystemPrompt string `json:"systemPrompt,omitempty" yaml:"systemPrompt,omitempty"` } // CostConfig defines the pricing for a custom model. diff --git a/internal/models/custom_test.go b/internal/models/custom_test.go index 1a81f467..ec3c19f8 100644 --- a/internal/models/custom_test.go +++ b/internal/models/custom_test.go @@ -1,6 +1,7 @@ package models import ( + "os" "testing" "github.com/spf13/viper" @@ -87,6 +88,16 @@ func TestConvertGenerationParams(t *testing.T) { t.Errorf("expected thinking level medium, got %v", p.ThinkingLevel) } }) + t.Run("system prompt only", func(t *testing.T) { + cfg := GenerationParamsConfig{SystemPrompt: "You are helpful."} + p := convertGenerationParams(cfg) + if p == nil { + t.Fatal("expected non-nil") + } + if p.SystemPrompt != "You are helpful." { + t.Errorf("expected system prompt, got %q", p.SystemPrompt) + } + }) } func TestModelConfigToModelInfoWithParams(t *testing.T) { @@ -304,4 +315,108 @@ func TestApplyModelSettings(t *testing.T) { t.Errorf("expected thinking level high, got %v", config.ThinkingLevel) } }) + + t.Run("system prompt applied from model params", func(t *testing.T) { + viper.Reset() + + modelInfo := &ModelInfo{ + ID: "test-model", + Params: &GenerationParams{ + SystemPrompt: "You are a coding assistant.", + }, + } + + config := &ProviderConfig{ + ModelString: "custom/test-model", + } + + ApplyModelSettings(config, modelInfo) + + if config.SystemPrompt != "You are a coding assistant." { + t.Errorf("expected system prompt to be set, got %q", config.SystemPrompt) + } + }) + + t.Run("explicit system prompt takes precedence", func(t *testing.T) { + viper.Reset() + + modelInfo := &ModelInfo{ + ID: "test-model", + Params: &GenerationParams{ + SystemPrompt: "Model-specific prompt", + }, + } + + config := &ProviderConfig{ + ModelString: "custom/test-model", + SystemPrompt: "Global prompt", + } + + ApplyModelSettings(config, modelInfo) + + // Global system prompt should NOT be overridden because config + // already has a non-empty SystemPrompt. + if config.SystemPrompt != "Global prompt" { + t.Errorf("expected global prompt preserved, got %q", config.SystemPrompt) + } + }) + + t.Run("system prompt from file path", func(t *testing.T) { + viper.Reset() + + // Create a temp file with a system prompt + tmpFile, err := os.CreateTemp("", "kit-test-prompt-*.txt") + if err != nil { + t.Fatal(err) + } + defer func() { _ = os.Remove(tmpFile.Name()) }() + if _, err := tmpFile.WriteString(" Prompt from file "); err != nil { + t.Fatal(err) + } + _ = tmpFile.Close() + + modelInfo := &ModelInfo{ + ID: "test-model", + Params: &GenerationParams{ + SystemPrompt: tmpFile.Name(), + }, + } + + config := &ProviderConfig{ + ModelString: "custom/test-model", + } + + ApplyModelSettings(config, modelInfo) + + if config.SystemPrompt != "Prompt from file" { + t.Errorf("expected trimmed file content, got %q", config.SystemPrompt) + } + }) + + t.Run("modelSettings system prompt overrides custom model params", func(t *testing.T) { + viper.Reset() + + viper.Set("modelSettings", map[string]any{ + "custom/test-model": map[string]any{ + "systemPrompt": "From modelSettings", + }, + }) + + modelInfo := &ModelInfo{ + ID: "test-model", + Params: &GenerationParams{ + SystemPrompt: "From custom model", + }, + } + + config := &ProviderConfig{ + ModelString: "custom/test-model", + } + + ApplyModelSettings(config, modelInfo) + + if config.SystemPrompt != "From modelSettings" { + t.Errorf("expected modelSettings prompt, got %q", config.SystemPrompt) + } + }) } diff --git a/internal/models/registry.go b/internal/models/registry.go index 283d27ab..2f78e178 100644 --- a/internal/models/registry.go +++ b/internal/models/registry.go @@ -241,6 +241,18 @@ func (r *ModelsRegistry) LookupModel(provider, modelID string) *ModelInfo { return &modelInfo } +// LookupModelForSettings is a convenience function that parses a +// "provider/model" string and looks up the ModelInfo in the global registry. +// Returns nil when the model string is invalid or the model is unknown. +// Used by Kit.SetModel to pre-apply per-model settings before CreateProvider. +func LookupModelForSettings(modelString string) *ModelInfo { + provider, modelName, err := ParseModelString(modelString) + if err != nil { + return nil + } + return GetGlobalRegistry().LookupModel(provider, modelName) +} + // getRequiredEnvVars returns the required environment variables for a provider. func (r *ModelsRegistry) getRequiredEnvVars(provider string) ([]string, error) { providerInfo, exists := r.providers[provider] diff --git a/pkg/kit/kit.go b/pkg/kit/kit.go index 3e756eeb..c2b2b2a2 100644 --- a/pkg/kit/kit.go +++ b/pkg/kit/kit.go @@ -51,6 +51,12 @@ type Kit struct { authHandler MCPAuthHandler // OAuth handler for remote MCP servers (may need Close) opts *Options // stored for reload operations (skills, etc.) + // 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 + // can replace the default prompt on model switch. + hasCustomSystemPrompt bool + // Hook registries — interception layer (see hooks.go). beforeToolCall *hookRegistry[BeforeToolCallHook, BeforeToolCallResult] afterToolResult *hookRegistry[AfterToolResultHook, AfterToolResultResult] @@ -221,9 +227,12 @@ func iterBranchMessages[T any](tm *session.TreeManager, fn func(*session.Message return results } -// SetModel changes the active model at runtime. The existing tools, system -// prompt, and session are preserved. The model string should be in -// "provider/model" format (e.g. "anthropic/claude-sonnet-4-5-20250929"). +// SetModel changes the active model at runtime. The existing tools and +// session are preserved. When the new model has a per-model system prompt +// (from modelSettings or customModels params), it is composed with the +// current AGENTS.md context and skills before being applied. +// The model string should be in "provider/model" format +// (e.g. "anthropic/claude-sonnet-4-5-20250929"). // Returns an error if the model string is invalid or the provider cannot // be created. func (m *Kit) SetModel(ctx context.Context, modelString string) error { @@ -274,6 +283,24 @@ func (m *Kit) SetModel(ctx context.Context, modelString string) error { cfg.PresencePenalty = &v } + // When the user hasn't set a custom global system prompt, check for a + // per-model system prompt. Pre-apply model settings to discover it, + // then compose with AGENTS.md context and skills if found. + if !m.hasCustomSystemPrompt { + // Temporarily clear the system prompt so ApplyModelSettings can + // detect that no explicit prompt is set and apply the per-model one. + cfg.SystemPrompt = "" + models.ApplyModelSettings(cfg, models.LookupModelForSettings(modelString)) + + if cfg.SystemPrompt != "" { + // Per-model system prompt found — compose with runtime context. + cfg.SystemPrompt = m.composeSystemPrompt(cfg.SystemPrompt) + } else { + // No per-model prompt — restore the global composed prompt. + cfg.SystemPrompt = systemPrompt + } + } + if err := m.agent.SetModel(ctx, cfg); err != nil { return err } @@ -290,6 +317,32 @@ func (m *Kit) SetModel(ctx context.Context, modelString string) error { return nil } +// composeSystemPrompt takes a base system prompt and composes it with the +// current runtime context: AGENTS.md content, skills metadata, and date/cwd. +// This mirrors the composition done during Kit.New() initialization. +func (m *Kit) composeSystemPrompt(basePrompt string) string { + cwd, _ := os.Getwd() + pb := skills.NewPromptBuilder(basePrompt) + + // Inject AGENTS.md content as project context. + for _, cf := range m.contextFiles { + pb.WithSection("", fmt.Sprintf("Instructions from: %s\n\n%s", cf.Path, cf.Content)) + } + + // Inject skills metadata. + if len(m.skills) > 0 { + pb.WithSkills(m.skills) + } + + // Append current date/time and working directory. + pb.WithSection("", fmt.Sprintf( + "Current date and time: %s\nCurrent working directory: %s", + time.Now().Format("Monday, January 2, 2006, 3:04:05 PM MST"), cwd, + )) + + return pb.Build() +} + // GetAvailableModels returns a list of known models from the registry. Each // entry includes provider, model ID, context limit, and whether the model // supports reasoning. This is an advisory list — models not in the registry @@ -596,16 +649,17 @@ func New(ctx context.Context, opts *Options) (*Kit, error) { // provider creation, session init) then runs outside the lock, allowing // parallel subagent spawns to proceed concurrently. var ( - providerConfig *models.ProviderConfig - modelString string - cwd string - contextFiles []*ContextFile - loadedSkills []*Skill - mcpConfig *config.Config - debug bool - noExtensions bool - maxSteps int - streaming bool + providerConfig *models.ProviderConfig + modelString string + cwd string + contextFiles []*ContextFile + loadedSkills []*Skill + mcpConfig *config.Config + debug bool + noExtensions bool + maxSteps int + streaming bool + hasCustomSystemPrompt bool ) if err := func() error { @@ -661,8 +715,41 @@ func New(ctx context.Context, opts *Options) (*Kit, error) { // Always compose the system prompt with runtime context: base prompt + // AGENTS.md context + skills metadata + date/cwd. + // + // If the configured model has a per-model system prompt (via + // modelSettings or customModels params) and the user hasn't + // explicitly set system-prompt, use the per-model prompt as the + // base instead of the global default. { basePrompt := viper.GetString("system-prompt") + + // Track whether the user explicitly configured a custom system + // prompt. When they haven't (basePrompt is the built-in default + // or empty), per-model system prompts can replace it on switch. + userSetSystemPrompt := basePrompt != "" && basePrompt != defaultSystemPrompt + hasCustomSystemPrompt = userSetSystemPrompt + + // Check for per-model system prompt override when no explicit + // global system-prompt was configured by the user. + if !userSetSystemPrompt { + modelStr := viper.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 { + perModelParams = ms[modelStr] + } + if perModelParams == nil && mi.Params != nil { + perModelParams = mi.Params + } + if perModelParams != nil && perModelParams.SystemPrompt != "" { + basePrompt = models.LoadSystemPromptValue(perModelParams.SystemPrompt) + } + } + } + } + pb := skills.NewPromptBuilder(basePrompt) // Inject AGENTS.md content as project context. @@ -788,24 +875,25 @@ func New(ctx context.Context, opts *Options) (*Kit, error) { } k := &Kit{ - agent: agentResult.Agent, - session: sessionManager, - modelString: modelString, - events: newEventBus(), - autoCompact: opts.AutoCompact, - compactionOpts: opts.CompactionOptions, - contextFiles: contextFiles, - skills: loadedSkills, - extRunner: agentResult.ExtRunner, - bufferedLogger: agentResult.BufferedLogger, - authHandler: setupOpts.AuthHandler, - opts: opts, - beforeToolCall: beforeToolCall, - afterToolResult: afterToolResult, - beforeTurn: beforeTurn, - afterTurn: afterTurn, - contextPrepare: contextPrepare, - beforeCompact: beforeCompact, + agent: agentResult.Agent, + session: sessionManager, + modelString: modelString, + events: newEventBus(), + autoCompact: opts.AutoCompact, + compactionOpts: opts.CompactionOptions, + contextFiles: contextFiles, + skills: loadedSkills, + extRunner: agentResult.ExtRunner, + bufferedLogger: agentResult.BufferedLogger, + authHandler: setupOpts.AuthHandler, + opts: opts, + hasCustomSystemPrompt: hasCustomSystemPrompt, + beforeToolCall: beforeToolCall, + afterToolResult: afterToolResult, + beforeTurn: beforeTurn, + afterTurn: afterTurn, + contextPrepare: contextPrepare, + beforeCompact: beforeCompact, } // Bridge extension events to SDK hooks.