diff --git a/cmd/late/main.go b/cmd/late/main.go index bb16d27..8ba037b 100644 --- a/cmd/late/main.go +++ b/cmd/late/main.go @@ -85,47 +85,57 @@ func (p pluginInlineTool) CallString(args json.RawMessage) string { func main() { // Parse flags helpReq := flag.Bool("help", false, "Show this help and exit.") - systemPromptReq := flag.String("system-prompt", "", "Replace the built-in system prompt with this text.") - systemPromptFileReq := flag.String("system-prompt-file", "", "Replace the built-in system prompt with a file's contents (highest priority).") - useToolsReq := flag.Bool("use-tools", true, "Offer tools to the main agent at all.") - enableBashReq := flag.Bool("enable-bash", true, "Enable the bash tool.") - injectCWDReq := flag.Bool("inject-cwd", true, "Replace ${{CWD}} in the system prompt with the working directory.") - enableSubagentsReq := flag.Bool("enable-subagents", true, "Allow the agent to spawn subagents.") - gemmaThinkingReq := flag.Bool("gemma-thinking", false, "Prepend the Gemma <|think|> token to the system prompt.") - subagentMaxTurns := flag.Int("subagent-max-turns", 500, "Maximum turns per subagent.") + systemPromptReq := flag.String("system-prompt", "", "Replace the built-in system prompt with this text; config.json \"system-prompt\" applies unless the flag is passed") + systemPromptFileReq := flag.String("system-prompt-file", "", "Replace the built-in system prompt with a file's contents (highest priority); config.json \"system-prompt-file\" applies unless the flag is passed") + useToolsReq := flag.Bool("use-tools", true, "Offer tools to the main agent at all; config.json \"use-tools\" applies unless the flag is passed") + enableBashReq := flag.Bool("enable-bash", true, "Enable the bash tool (master switch; enabled_tools.bash provides per-tool granularity); config.json \"enable-bash\" applies unless the flag is passed") + injectCWDReq := flag.Bool("inject-cwd", true, "Replace ${{CWD}} in the system prompt with the working directory; config.json \"inject-cwd\" applies unless the flag is passed") + enableSubagentsReq := flag.Bool("enable-subagents", true, "Allow the agent to spawn subagents; config.json \"enable-subagents\" applies unless the flag is passed") + gemmaThinkingReq := flag.Bool("gemma-thinking", false, "Prepend the Gemma <|think|> token to the system prompt; config.json \"gemma-thinking\" applies unless the flag is passed") + subagentMaxTurns := flag.Int("subagent-max-turns", appconfig.DefaultSubagentMaxTurns, "Maximum turns per subagent (0 = unlimited); config.json \"subagent-max-turns\" applies unless the flag is passed") // LATE_MAX_STREAM_RETRIES optionally overrides the default retry budget - // for LLM stream errors; an explicit -max-stream-retries flag wins over it. + // for LLM stream errors; an explicit -max-stream-retries flag wins over + // it. Full precedence: flag > env > config.json "max-stream-retries" > + // executor.DefaultMaxStreamRetries (ResolveMaxStreamRetries). maxStreamRetriesDefault := executor.DefaultMaxStreamRetries + maxStreamRetriesEnvSet := false if v := os.Getenv("LATE_MAX_STREAM_RETRIES"); v != "" { if parsed, err := strconv.Atoi(v); err == nil { maxStreamRetriesDefault = parsed + maxStreamRetriesEnvSet = true } else { fmt.Fprintf(os.Stderr, "Warning: ignoring invalid LATE_MAX_STREAM_RETRIES %q: %v\n", v, err) } } - maxStreamRetries := flag.Int("max-stream-retries", maxStreamRetriesDefault, "Retries for LLM stream errors with backoff; 0 disables. Env: LATE_MAX_STREAM_RETRIES") + maxStreamRetries := flag.Int("max-stream-retries", maxStreamRetriesDefault, "Retries for LLM stream errors with backoff; 0 disables. Env: LATE_MAX_STREAM_RETRIES; config.json \"max-stream-retries\" (flag > env > config > default)") saveSubagentHistoriesReq := flag.Bool("save-subagent-histories", false, "Persist subagent histories to disk (overrides session and config).") - enableSqzReq := flag.Bool("enable-sqz", false, "Compress bash tool output with the external 'sqz' binary if available.") - appendSystemPromptReq := flag.String("append-system-prompt", "", "Append this text to the final system prompt.") + enableSqzReq := flag.Bool("enable-sqz", false, "Compress bash tool output with the external 'sqz' binary if available; config.json \"enable-sqz\" applies unless the flag is passed") + appendSystemPromptReq := flag.String("append-system-prompt", "", "Append this text to the final system prompt; config.json \"append-system-prompt\" applies unless the flag is passed") versionReq := flag.Bool("version", false, "Print the version and exit.") unsupervisedReq := flag.Bool("i-promise-i-have-backups-and-will-not-file-issues", false, "UNSUPPORTED: run every tool without user confirmation.") askForUserApprovalReq := flag.Bool("ask-for-user-approval", false, askForUserApprovalUsage) - enableImagesReq := flag.Bool("enable-images", false, "Force-enable image attachments even if the backend does not advertise vision support.") + enableImagesReq := flag.Bool("enable-images", false, "Force-enable image attachments even if the backend does not advertise vision support; config.json \"enable-images\" applies unless the flag is passed") continueReq := flag.Bool("continue", false, "Resume the most recently updated session, regardless of which project directory it was started in.") continueProjectReq := flag.Bool("continue-project", false, "Resume the most recently updated session for the current project (git repo root of the working directory, or the working directory outside a repo); mutually exclusive with -continue.") - showCWDReq := flag.Bool("show-cwd", true, "Show the git branch / working directory in the status bar.") + showCWDReq := flag.Bool("show-cwd", true, "Show the git branch / working directory in the status bar; config.json \"show-cwd\" applies unless the flag is passed") themeReq := flag.String("theme", "", "Plugin theme id ('plugin:name' or bare name); env: LATE_THEME.") promptReq := flag.String("prompt", "", "Start the agent immediately with this prompt.") - logitBiasReq := flag.String("logit-bias", "", "Main-agent token bias: JSON object or comma-separated TOKEN_ID:BIAS pairs.") - suppressThinkingWordsReq := flag.Bool("suppress-thinking-words", false, "Bias anti-overthinking tokens (requires the same model for main agent and subagents).") - subagentLogitBiasReq := flag.String("subagent-logit-bias", "", "Subagent token bias: JSON object or comma-separated TOKEN_ID:BIAS pairs.") + logitBiasReq := flag.String("logit-bias", "", "Main-agent token bias: JSON object or comma-separated TOKEN_ID:BIAS pairs; config.json \"logit-bias\" applies unless the flag is passed") + suppressThinkingWordsReq := flag.Bool("suppress-thinking-words", false, "Bias anti-overthinking tokens (requires the same model for main agent and subagents); config.json \"suppress-thinking-words\" applies unless the flag is passed") + subagentLogitBiasReq := flag.String("subagent-logit-bias", "", "Subagent token bias: JSON object or comma-separated TOKEN_ID:BIAS pairs; config.json \"subagent-logit-bias\" applies unless the flag is passed") flag.Usage = func() { writeHelp(os.Stderr, flag.CommandLine) } flag.Parse() - tool.SetSqzEnabled(*enableSqzReq) + // Record which flags were explicitly passed on the command line. The app + // config loads AFTER flag.Parse below, so flag.Visit (which reports only + // command-line-set flags) is the only reliable "explicit flag > config" + // precedence signal for every resolver of a CLI-equivalent setting + // (ResolveUseTools, ResolveEnableBash, ResolveShowCWD, ...). + explicitFlags := map[string]bool{} + flag.Visit(func(f *flag.Flag) { explicitFlags[f.Name] = true }) if *versionReq { fmt.Printf("late %s\n", common.Version) @@ -227,53 +237,11 @@ func main() { } } - // Determine system prompt - // Priority: --system-prompt-file > --system-prompt > LATE_SYSTEM_PROMPT env var - var systemPrompt string - - if *systemPromptFileReq != "" { - content, err := os.ReadFile(*systemPromptFileReq) - if err != nil { - fmt.Fprintf(os.Stderr, "Error reading system prompt file: %v\n", err) - os.Exit(1) - } - systemPrompt = string(content) - } else if *systemPromptReq != "" { - systemPrompt = *systemPromptReq - } else if envPrompt := os.Getenv("LATE_SYSTEM_PROMPT"); envPrompt != "" { - systemPrompt = envPrompt - } else { - content, _ := assets.PromptsFS.ReadFile("prompts/instruction-orchestrator.md") - systemPrompt = string(content) - } - - if *injectCWDReq { - cwd, err := os.Getwd() - if err == nil { - systemPrompt = common.ReplacePlaceholders(systemPrompt, map[string]string{ - "${{CWD}}": cwd, - }) - } - } - - if *gemmaThinkingReq { - systemPrompt = "<|think|>" + systemPrompt - } - - if !*enableBashReq { - systemPrompt = common.ReplacePlaceholders(systemPrompt, - map[string]string{ - "${{NOTICE}}": "Bash is disabled. You must not attempt to use execute any bash commands. Doing so will result in an error.", - }) - } - - if runtime.GOOS == "windows" { - systemPrompt += "\n\n## Platform Note\nYou are running on **Windows** and commands execute in **PowerShell**. Prefer PowerShell-native commands and syntax:\n- Prefer `Get-ChildItem` (or `dir`) for directory listing\n- Prefer `Get-Content` for reading files\n- Prefer `Remove-Item` for deleting files/directories\n- Prefer `Copy-Item` and `Move-Item` for copy/move operations\n- Prefer `New-Item -ItemType Directory` for explicit directory creation\n- Use PowerShell quoting/escaping rules and avoid Unix-only shell syntax\n- Do NOT use bash/sh-specific features unless explicitly required" - } - - if *appendSystemPromptReq != "" { - systemPrompt = systemPrompt + *appendSystemPromptReq - } + // The system prompt is assembled after appconfig.LoadConfig below: its + // inputs (--system-prompt/-file/-append, --inject-cwd, --gemma-thinking, + // --enable-bash) are CLI-equivalent settings resolved with the mandatory + // flag > config > default precedence, which needs the loaded config. + // Nothing between the flag block and LoadConfig consumes the prompt. // Sessions setup @@ -374,25 +342,132 @@ func main() { } } - // Parse explicit user logit bias overrides if provided - var explicitUserLogitBias map[string]int - if *logitBiasReq != "" { - parsed, err := client.ParseLogitBias(*logitBiasReq) + // ------------------------------------------------------------------ + // CLI-equivalent settings (flag > config > default). + // + // Every setting below mirrors a command-line flag one-to-one; its + // config.json key is the flag name in kebab-case. Each appconfig + // resolver implements the mandatory precedence — explicitly passed + // flag (explicitFlags, recorded from flag.Visit above) > config.json + // > built-in default — and returns an optional warning surfaced on + // stderr like every other invalid config entry. + // ------------------------------------------------------------------ + reportWarning := func(warning string) { + if warning != "" { + fmt.Fprintf(os.Stderr, "Warning: %s\n", warning) + } + } + + resolvedEnableSqz, _ := appconfig.ResolveEnableSqz(appConfig, explicitFlags["enable-sqz"], *enableSqzReq) + tool.SetSqzEnabled(resolvedEnableSqz) + + // System prompt group. Priority (identical to the flags): file > text > + // LATE_SYSTEM_PROMPT env > built-in; the append is always applied last. + resolvedSystemPrompt, _ := appconfig.ResolveSystemPrompt(appConfig, explicitFlags["system-prompt"], *systemPromptReq) + resolvedSystemPromptFile, _ := appconfig.ResolveSystemPromptFile(appConfig, explicitFlags["system-prompt-file"], *systemPromptFileReq) + resolvedAppendSystemPrompt, _ := appconfig.ResolveAppendSystemPrompt(appConfig, explicitFlags["append-system-prompt"], *appendSystemPromptReq) + resolvedInjectCWD, _ := appconfig.ResolveInjectCWD(appConfig, explicitFlags["inject-cwd"], *injectCWDReq) + resolvedGemmaThinking, _ := appconfig.ResolveGemmaThinking(appConfig, explicitFlags["gemma-thinking"], *gemmaThinkingReq) + resolvedEnableBash, _ := appconfig.ResolveEnableBash(appConfig, explicitFlags["enable-bash"], *enableBashReq) + + // Subagent group. + resolvedEnableSubagents, _ := appconfig.ResolveEnableSubagents(appConfig, explicitFlags["enable-subagents"], *enableSubagentsReq) + resolvedSubagentMaxTurns, subagentMaxTurnsWarning := appconfig.ResolveSubagentMaxTurns(appConfig, explicitFlags["subagent-max-turns"], *subagentMaxTurns) + reportWarning(subagentMaxTurnsWarning) + + // Streaming / model group. + resolvedMaxStreamRetries, maxStreamRetriesWarning := appconfig.ResolveMaxStreamRetries(appConfig, explicitFlags["max-stream-retries"], *maxStreamRetries, maxStreamRetriesEnvSet, maxStreamRetriesDefault) + reportWarning(maxStreamRetriesWarning) + resolvedEnableImages, _ := appconfig.ResolveEnableImages(appConfig, explicitFlags["enable-images"], *enableImagesReq) + resolvedSuppressThinkingWords, _ := appconfig.ResolveSuppressThinkingWords(appConfig, explicitFlags["suppress-thinking-words"], *suppressThinkingWordsReq) + resolvedLogitBias, _ := appconfig.ResolveLogitBias(appConfig, explicitFlags["logit-bias"], *logitBiasReq) + resolvedSubagentLogitBias, _ := appconfig.ResolveSubagentLogitBias(appConfig, explicitFlags["subagent-logit-bias"], *subagentLogitBiasReq) + + // Session / TUI group. + resolvedShowCWD, _ := appconfig.ResolveShowCWD(appConfig, explicitFlags["show-cwd"], *showCWDReq) + resolvedUseTools, _ := appconfig.ResolveUseTools(appConfig, explicitFlags["use-tools"], *useToolsReq) + + // Determine system prompt + // Priority: --system-prompt-file > --system-prompt > LATE_SYSTEM_PROMPT + // env var (each of the first two is itself resolved flag > config > + // default above, so a config file beats a config text and either flag + // beats either config entry). + var systemPrompt string + + if resolvedSystemPromptFile != "" { + content, err := os.ReadFile(resolvedSystemPromptFile) if err != nil { - fmt.Fprintf(os.Stderr, "Error parsing --logit-bias: %v\n", err) + fmt.Fprintf(os.Stderr, "Error reading system prompt file: %v\n", err) os.Exit(1) } - explicitUserLogitBias = parsed + systemPrompt = string(content) + } else if resolvedSystemPrompt != "" { + systemPrompt = resolvedSystemPrompt + } else if envPrompt := os.Getenv("LATE_SYSTEM_PROMPT"); envPrompt != "" { + systemPrompt = envPrompt + } else { + content, _ := assets.PromptsFS.ReadFile("prompts/instruction-orchestrator.md") + systemPrompt = string(content) + } + + if resolvedInjectCWD { + cwd, err := os.Getwd() + if err == nil { + systemPrompt = common.ReplacePlaceholders(systemPrompt, map[string]string{ + "${{CWD}}": cwd, + }) + } + } + + if resolvedGemmaThinking { + systemPrompt = "<|think|>" + systemPrompt + } + + if !resolvedEnableBash { + systemPrompt = common.ReplacePlaceholders(systemPrompt, + map[string]string{ + "${{NOTICE}}": "Bash is disabled. You must not attempt to use execute any bash commands. Doing so will result in an error.", + }) + } + + if runtime.GOOS == "windows" { + systemPrompt += "\n\n## Platform Note\nYou are running on **Windows** and commands execute in **PowerShell**. Prefer PowerShell-native commands and syntax:\n- Prefer `Get-ChildItem` (or `dir`) for directory listing\n- Prefer `Get-Content` for reading files\n- Prefer `Remove-Item` for deleting files/directories\n- Prefer `Copy-Item` and `Move-Item` for copy/move operations\n- Prefer `New-Item -ItemType Directory` for explicit directory creation\n- Use PowerShell quoting/escaping rules and avoid Unix-only shell syntax\n- Do NOT use bash/sh-specific features unless explicitly required" + } + + if resolvedAppendSystemPrompt != "" { + systemPrompt = systemPrompt + resolvedAppendSystemPrompt + } + + // Parse the resolved main-agent logit bias override if provided. A + // malformed FLAG value keeps the historical hard error (the user typed + // it on this very command line); a malformed CONFIG value warns and + // proceeds without a bias, like every other invalid config entry. + var explicitUserLogitBias map[string]int + if resolvedLogitBias != "" { + parsed, err := client.ParseLogitBias(resolvedLogitBias) + if err != nil { + if explicitFlags["logit-bias"] { + fmt.Fprintf(os.Stderr, "Error parsing --logit-bias: %v\n", err) + os.Exit(1) + } + fmt.Fprintf(os.Stderr, "Warning: ignoring invalid config.json logit-bias: %v\n", err) + } else { + explicitUserLogitBias = parsed + } } var explicitSubagentLogitBias map[string]int - if *subagentLogitBiasReq != "" { - parsed, err := client.ParseLogitBias(*subagentLogitBiasReq) + if resolvedSubagentLogitBias != "" { + parsed, err := client.ParseLogitBias(resolvedSubagentLogitBias) if err != nil { - fmt.Fprintf(os.Stderr, "Error parsing --subagent-logit-bias: %v\n", err) - os.Exit(1) + if explicitFlags["subagent-logit-bias"] { + fmt.Fprintf(os.Stderr, "Error parsing --subagent-logit-bias: %v\n", err) + os.Exit(1) + } + fmt.Fprintf(os.Stderr, "Warning: ignoring invalid config.json subagent-logit-bias: %v\n", err) + } else { + explicitSubagentLogitBias = parsed } - explicitSubagentLogitBias = parsed } // Resolve subagent history persistence opt-in @@ -426,7 +501,7 @@ func main() { BaseURL: resolvedOpenAIConfig.BaseURL, APIKey: resolvedOpenAIConfig.APIKey, Model: resolvedOpenAIConfig.Model, - EnableImages: *enableImagesReq, + EnableImages: resolvedEnableImages, LogitBias: explicitUserLogitBias, AppVersion: common.Version, } @@ -440,7 +515,7 @@ func main() { resolvedSubagentConfig := appconfig.ResolveSubagentSettings(appConfig, resolvedOpenAIConfig) // Validate --suppress-thinking-words: only allowed in homogeneous setups - if err := validateSuppressThinkingWords(*suppressThinkingWordsReq, resolvedClientConfig.Model, resolvedSubagentConfig.Model, appConfig); err != nil { + if err := validateSuppressThinkingWords(resolvedSuppressThinkingWords, resolvedClientConfig.Model, resolvedSubagentConfig.Model, appConfig); err != nil { fmt.Fprintf(os.Stderr, "Error: --suppress-thinking-words is currently only supported when orchestrator and subagents use the same model: %v\n", err) os.Exit(1) } @@ -457,14 +532,17 @@ func main() { BaseURL: resolvedSubagentConfig.BaseURL, APIKey: resolvedSubagentConfig.APIKey, Model: resolvedSubagentConfig.Model, - EnableImages: *enableImagesReq, + EnableImages: resolvedEnableImages, LogitBias: explicitSubagentLogitBias, AppVersion: common.Version, }) } // Flag overrides - if !*enableBashReq { + // The bash master switch ANDs with enabled_tools.bash (per-tool + // granularity): either being false disables the bash tool, exactly as + // the -enable-bash=false flag always has. + if !resolvedEnableBash { enabledTools["bash"] = false } @@ -480,7 +558,7 @@ func main() { mainTools["write_file"] = false mainTools["target_edit"] = false - sess := session.New(c, historyPath, history, systemPrompt, *useToolsReq) + sess := session.New(c, historyPath, history, systemPrompt, resolvedUseTools) if loadedSessionMeta != nil { sess.SetSubagentMetadata(loadedSessionMeta.SubagentSeq, loadedSessionMeta.SaveSubagentHistories) if loadedSessionMeta.WorkingDir != "" { @@ -588,7 +666,7 @@ func main() { if setting.Model == resolvedClientConfig.Model { bias = c.LogitBias() } - sess.SetClient(newModelClient(ctx, setting, *enableImagesReq, bias)) + sess.SetClient(newModelClient(ctx, setting, resolvedEnableImages, bias)) return nil } } @@ -662,7 +740,7 @@ func main() { resolvedSubagentConfig.Model != resolvedOpenAIConfig.Model { model.SubagentInfo = resolvedSubagentConfig.Model } - model.ShowCWD = *showCWDReq + model.ShowCWD = resolvedShowCWD model.LazyHistory = true pOpts := []tea.ProgramOption{ @@ -705,7 +783,7 @@ func main() { case appconfig.PermissionModeUnsupervised: ctx = context.WithValue(ctx, common.SkipConfirmationKey, true) } - ctx = context.WithValue(ctx, common.MaxStreamRetriesKey, *maxStreamRetries) + ctx = context.WithValue(ctx, common.MaxStreamRetriesKey, resolvedMaxStreamRetries) rootAgent.SetContext(ctx) // Set middlewares (see buildMiddlewares for ordering rationale). @@ -716,14 +794,14 @@ func main() { // Wait only in this background goroutine: the TUI remains usable while // connections and discovery finish, but --prompt needs their results. - runBootstrap(p, mcpClient, config, c, subagentClient, sess, enabledTools, pluginManager, toolSync, *suppressThinkingWordsReq, explicitUserLogitBias, explicitSubagentLogitBias) + runBootstrap(p, mcpClient, config, c, subagentClient, sess, enabledTools, pluginManager, toolSync, resolvedSuppressThinkingWords, explicitUserLogitBias, explicitSubagentLogitBias) if *promptReq != "" { p.Send(tui.StartPromptMsg(*promptReq)) } }() - if *enableSubagentsReq { + if resolvedEnableSubagents { runner := func(ctx context.Context, goal string, ctxFiles []string, agentType string) (string, error) { var currentSubagentClient *client.Client if appConfig != nil { @@ -736,7 +814,7 @@ func main() { BaseURL: setting.URL, APIKey: setting.Key, Model: setting.Model, - EnableImages: *enableImagesReq, + EnableImages: resolvedEnableImages, LogitBias: biasForSubagent, AppVersion: common.Version, }) @@ -747,7 +825,7 @@ func main() { currentSubagentClient = subagentClient } - child, err := agent.NewSubagentOrchestrator(currentSubagentClient, goal, ctxFiles, agentType, enabledTools, *injectCWDReq, *gemmaThinkingReq, *subagentMaxTurns, effectiveSessionID, saveSubagentHistories, rootAgent, p) + child, err := agent.NewSubagentOrchestrator(currentSubagentClient, goal, ctxFiles, agentType, enabledTools, resolvedInjectCWD, resolvedGemmaThinking, resolvedSubagentMaxTurns, effectiveSessionID, saveSubagentHistories, rootAgent, p) if err != nil { return "", err } diff --git a/internal/config/config.go b/internal/config/config.go index 4b0463f..024b948 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "runtime" + "time" ) const DefaultOpenAIBaseURL = "http://localhost:8080" @@ -20,6 +21,29 @@ const ( PermissionModeUnsupervised = "i-promise-i-have-backups-and-will-not-file-issues" ) +// DefaultBashTimeout is the default wall-clock budget for a single bash tool +// call, applied when neither the --bash-timeout flag nor the config.json +// "bash-timeout" entry provides a value. "0" (or negative) means unlimited. +const DefaultBashTimeout = 10 * time.Minute + +// DefaultSubagentIdleTimeout is the default "truly idle" notification +// threshold for the subagent idle watchdog, applied when neither the +// --subagent-idle-timeout flag nor the config.json "subagent-idle-timeout" +// entry provides a value. "0" means off. +const DefaultSubagentIdleTimeout = 15 * time.Minute + +// DefaultSubagentMaxTurns is the default maximum number of turns per +// subagent, applied when neither the --subagent-max-turns flag nor the +// config.json "subagent-max-turns" entry provides a value. 0 means +// unlimited. +const DefaultSubagentMaxTurns = 500 + +// DefaultMaxConcurrentLLMRequests is the default process-wide cap on +// concurrent in-flight LLM requests, applied when neither the +// --max-concurrent-llm-requests flag nor the config.json +// "max-concurrent-llm-requests" entry provides a value. 0 means unlimited. +const DefaultMaxConcurrentLLMRequests = 6 + type EnvLookup func(string) (string, bool) type OpenAISettings struct { @@ -76,6 +100,137 @@ type Config struct { // of the same names override it. PermissionMode string `json:"permission-mode,omitempty"` + // ---------------------------------------------------------------------- + // CLI-equivalent settings (flag > config > default) + // + // Every field in this section mirrors a command-line flag one-to-one: + // the JSON key is the flag name in kebab-case, exactly as the user + // passes it (`--option-name ` becomes "option-name": ). + // Resolution follows the mandatory precedence: an explicitly passed CLI + // flag (detected via flag.Visit in main, since config.json loads after + // flag.Parse) wins over the config entry, which wins over the built-in + // default. Each field's doc comment states its flag equivalent, its + // default, and its unset/zero semantics; the matching Resolve* function + // in this file implements the precedence and is the only supported way + // to read the setting. + // ---------------------------------------------------------------------- + + // SystemPrompt replaces the built-in system prompt with this text. + // Flag: --system-prompt. Empty (unset) keeps the built-in prompt. + // Priority (identical to the flags): system-prompt-file > + // system-prompt > LATE_SYSTEM_PROMPT env > built-in prompt. + SystemPrompt string `json:"system-prompt,omitempty"` + + // SystemPromptFile replaces the built-in system prompt with the + // contents of this file. Flag: --system-prompt-file. Empty (unset) + // keeps the built-in prompt. An unreadable path is a hard error, the + // same as for the flag. + SystemPromptFile string `json:"system-prompt-file,omitempty"` + + // AppendSystemPrompt is appended to the final system prompt (after any + // replacement above). Flag: --append-system-prompt. Empty (unset) + // appends nothing. + AppendSystemPrompt string `json:"append-system-prompt,omitempty"` + + // InjectCWD replaces ${{CWD}} in the system prompt with the working + // directory. Flag: --inject-cwd. Default true. *bool tri-state: nil + // (absent entry) = unset → default, so an explicit false is + // distinguishable from unset (mirrors ShowTodoPane). + InjectCWD *bool `json:"inject-cwd,omitempty"` + + // GemmaThinking prepends the Gemma <|think|> token to the system + // prompt. Flag: --gemma-thinking. Default false; a plain bool suffices + // because the default is false. + GemmaThinking bool `json:"gemma-thinking,omitempty"` + + // UseTools offers tools to the main agent at all. Flag: --use-tools. + // Default true. *bool tri-state: nil (absent entry) = unset → default. + UseTools *bool `json:"use-tools,omitempty"` + + // EnableBash enables the bash tool. Flag: --enable-bash. Default + // true. *bool tri-state: nil (absent entry) = unset → default. This is + // the MASTER switch: config.json enabled_tools.bash provides per-tool + // granularity and is ANDed with it — either being false disables the + // bash tool (see ResolveEnableBash). + EnableBash *bool `json:"enable-bash,omitempty"` + + // BashTimeout is the max wall-clock time for one bash tool call. + // Flag: --bash-timeout. Duration STRING parsed with + // time.ParseDuration (e.g. "10m", "1h30m"); empty (unset) means + // DefaultBashTimeout (10m); "0" (or negative) means unlimited. + BashTimeout string `json:"bash-timeout,omitempty"` + + // EnableSqz compresses bash tool output with the external 'sqz' binary + // when it is available. Flag: --enable-sqz. Default false. + EnableSqz bool `json:"enable-sqz,omitempty"` + + // EnableImages force-enables image attachments even when the backend + // does not advertise vision support. Flag: --enable-images. + // Default false. + EnableImages bool `json:"enable-images,omitempty"` + + // EnableSubagents allows the agent to spawn subagents. + // Flag: --enable-subagents. Default true. *bool tri-state: nil + // (absent entry) = unset → default. + EnableSubagents *bool `json:"enable-subagents,omitempty"` + + // SubagentMaxTurns is the maximum number of turns per subagent. + // Flag: --subagent-max-turns. Default DefaultSubagentMaxTurns (500). + // *int tri-state: nil (absent entry) = unset → default; 0 = unlimited + // (the executor treats maxTurns <= 0 as unbounded, exactly like the + // flag); a negative value is invalid, warns, and falls back to the + // default (see ResolveSubagentMaxTurns). + SubagentMaxTurns *int `json:"subagent-max-turns,omitempty"` + + // SubagentIdleTimeout notifies when a subagent has been truly idle (no + // stream progress, no in-flight tool, no nested spawn) for this long. + // Flag: --subagent-idle-timeout. Duration STRING parsed with + // time.ParseDuration; empty (unset) means DefaultSubagentIdleTimeout + // (15m); "0" means off. + SubagentIdleTimeout string `json:"subagent-idle-timeout,omitempty"` + + // SubagentIdleKillAfter kills a subagent that stays truly idle past + // this duration. Flag: --subagent-idle-kill-after. Duration STRING + // parsed with time.ParseDuration; empty (unset) means the built-in + // default (0 = notify only, never kill); "0" means notify only. + SubagentIdleKillAfter string `json:"subagent-idle-kill-after,omitempty"` + + // MaxStreamRetries is the retry budget for LLM stream errors; 0 + // disables retrying. Flag: --max-stream-retries. *int tri-state: nil + // (absent entry) = unset → the LATE_MAX_STREAM_RETRIES env value, or + // executor.DefaultMaxStreamRetries when the env is unset. + // Precedence: flag > env > config > default (see + // ResolveMaxStreamRetries). + MaxStreamRetries *int `json:"max-stream-retries,omitempty"` + + // MaxConcurrentLLMRequests is the process-wide cap on concurrent + // in-flight LLM requests across all agents and subagents. + // Flag: --max-concurrent-llm-requests. Default + // DefaultMaxConcurrentLLMRequests (6). *int tri-state: nil (absent + // entry) = unset → default; 0 = unlimited; negative is invalid, warns, + // and falls back to the default (see ResolveMaxConcurrentLLMRequests). + MaxConcurrentLLMRequests *int `json:"max-concurrent-llm-requests,omitempty"` + + // SuppressThinkingWords biases anti-overthinking tokens (requires the + // same model for the main agent and subagents). Flag: + // --suppress-thinking-words. Default false. + SuppressThinkingWords bool `json:"suppress-thinking-words,omitempty"` + + // LogitBias is the main-agent token bias, in the same format the + // --logit-bias flag accepts: a JSON object or comma-separated + // TOKEN_ID:BIAS pairs. Empty (unset) sends no bias. Parsed by the + // caller with client.ParseLogitBias; the resolver is a pass-through. + LogitBias string `json:"logit-bias,omitempty"` + + // SubagentLogitBias is the subagent token bias, in the same format the + // --subagent-logit-bias flag accepts. Empty (unset) sends no bias. + SubagentLogitBias string `json:"subagent-logit-bias,omitempty"` + + // ShowCWD shows the git branch / working directory in the status bar. + // Flag: --show-cwd. Default true. *bool tri-state: nil (absent entry) + // = unset → default. + ShowCWD *bool `json:"show-cwd,omitempty"` + // Legacy subagent fields for backward compatibility SubagentBaseURL string `json:"subagent_base_url,omitempty"` SubagentAPIKey string `json:"subagent_api_key,omitempty"` @@ -292,6 +447,334 @@ func ResolvePermissionMode(cfg *Config, askFlag, unsupervisedFlag bool) (mode st return PermissionModeAskForUserApproval, "", nil } +// ------------------------------------------------------------------------- +// CLI-equivalent setting resolvers (flag > config > default) +// +// Each resolver below implements the mandatory precedence for one setting +// that mirrors a command-line flag: an explicitly passed flag wins over the +// config.json entry, which wins over the built-in default. The caller (main) +// passes cliExplicit = true only when flag.Visit reported the flag on the +// command line — config loads after flag.Parse, so that is the only reliable +// explicit-flag signal — together with the parsed flag value. Every resolver +// returns the effective value plus an optional warning for the caller to +// surface; a nil cfg behaves like an absent entry. Boolean and plain-string +// settings have no invalid VALUE (a wrong-typed config.json entry fails the +// whole parse and is surfaced by the degraded-config guard), so their +// warning return is always empty. +// ------------------------------------------------------------------------- + +// resolveDurationString is the shared body of the duration-string resolvers +// (mirrors ResolveSubagentTimeout's parsing rules): raw is the config entry; +// empty means unset and the default applies; a value that parses via +// time.ParseDuration passes through as-is (callers define non-positive +// semantics — unlimited or off); an unparseable non-empty value warns and +// falls back to the default. key is the config.json key named in warnings. +func resolveDurationString(raw string, cliExplicit bool, cliValue, def time.Duration, key string) (time.Duration, string) { + if cliExplicit { + return cliValue, "" + } + if raw != "" { + if parsed, err := time.ParseDuration(raw); err == nil { + return parsed, "" + } + return def, fmt.Sprintf("ignoring invalid config.json %s %q; using default %s", key, raw, def) + } + return def, "" +} + +// ResolveSystemPrompt resolves the system-prompt replacement text +// (flag: --system-prompt). Precedence: explicitly passed flag (even empty, +// so -system-prompt="" can undo a config entry for one run) > config.json +// "system-prompt" > "" (keep the built-in prompt). +func ResolveSystemPrompt(cfg *Config, cliExplicit bool, cliValue string) (string, string) { + if cliExplicit { + return cliValue, "" + } + if cfg != nil && cfg.SystemPrompt != "" { + return cfg.SystemPrompt, "" + } + return "", "" +} + +// ResolveSystemPromptFile resolves the system-prompt replacement file path +// (flag: --system-prompt-file). Precedence: explicitly passed flag > +// config.json "system-prompt-file" > "" (no replacement). The caller reads +// the file and keeps the flag's hard-error behavior for an unreadable path. +func ResolveSystemPromptFile(cfg *Config, cliExplicit bool, cliValue string) (string, string) { + if cliExplicit { + return cliValue, "" + } + if cfg != nil && cfg.SystemPromptFile != "" { + return cfg.SystemPromptFile, "" + } + return "", "" +} + +// ResolveAppendSystemPrompt resolves the text appended to the final system +// prompt (flag: --append-system-prompt). Precedence: explicitly passed flag +// > config.json "append-system-prompt" > "" (append nothing). Appending +// always happens after the file/text replacement resolution — the same +// order the flags use. +func ResolveAppendSystemPrompt(cfg *Config, cliExplicit bool, cliValue string) (string, string) { + if cliExplicit { + return cliValue, "" + } + if cfg != nil && cfg.AppendSystemPrompt != "" { + return cfg.AppendSystemPrompt, "" + } + return "", "" +} + +// ResolveUseTools resolves whether tools are offered to the main agent at +// all (flag: --use-tools). Precedence: explicitly passed flag > +// config.json "use-tools" > true. The config entry is a *bool: nil (absent) +// = unset. +func ResolveUseTools(cfg *Config, cliExplicit bool, cliValue bool) (bool, string) { + if cliExplicit { + return cliValue, "" + } + if cfg != nil && cfg.UseTools != nil { + return *cfg.UseTools, "" + } + return true, "" +} + +// ResolveEnableBash resolves the bash tool's MASTER switch (flag: +// --enable-bash). Precedence: explicitly passed flag > config.json +// "enable-bash" > true. The config entry is a *bool: nil (absent) = unset. +// enabled_tools.bash in config.json provides per-tool granularity and is +// ANDed with this switch by the caller — either being false disables the +// bash tool, exactly as the flag's false always has. +func ResolveEnableBash(cfg *Config, cliExplicit bool, cliValue bool) (bool, string) { + if cliExplicit { + return cliValue, "" + } + if cfg != nil && cfg.EnableBash != nil { + return *cfg.EnableBash, "" + } + return true, "" +} + +// ResolveInjectCWD resolves whether ${{CWD}} is replaced with the working +// directory in the system prompt (flag: --inject-cwd). Precedence: +// explicitly passed flag > config.json "inject-cwd" > true. The config +// entry is a *bool: nil (absent) = unset. +func ResolveInjectCWD(cfg *Config, cliExplicit bool, cliValue bool) (bool, string) { + if cliExplicit { + return cliValue, "" + } + if cfg != nil && cfg.InjectCWD != nil { + return *cfg.InjectCWD, "" + } + return true, "" +} + +// ResolveEnableSubagents resolves whether the agent may spawn subagents +// (flag: --enable-subagents). Precedence: explicitly passed flag > +// config.json "enable-subagents" > true. The config entry is a *bool: nil +// (absent) = unset. +func ResolveEnableSubagents(cfg *Config, cliExplicit bool, cliValue bool) (bool, string) { + if cliExplicit { + return cliValue, "" + } + if cfg != nil && cfg.EnableSubagents != nil { + return *cfg.EnableSubagents, "" + } + return true, "" +} + +// ResolveShowCWD resolves whether the status bar shows the git branch / +// working directory (flag: --show-cwd). Precedence: explicitly passed flag > +// config.json "show-cwd" > true. The config entry is a *bool: nil (absent) +// = unset. +func ResolveShowCWD(cfg *Config, cliExplicit bool, cliValue bool) (bool, string) { + if cliExplicit { + return cliValue, "" + } + if cfg != nil && cfg.ShowCWD != nil { + return *cfg.ShowCWD, "" + } + return true, "" +} + +// ResolveGemmaThinking resolves whether the Gemma <|think|> token is +// prepended to the system prompt (flag: --gemma-thinking). Precedence: +// explicitly passed flag > config.json "gemma-thinking" > false. +func ResolveGemmaThinking(cfg *Config, cliExplicit bool, cliValue bool) (bool, string) { + if cliExplicit { + return cliValue, "" + } + return cfg != nil && cfg.GemmaThinking, "" +} + +// ResolveEnableSqz resolves whether bash tool output is compressed with the +// external 'sqz' binary when available (flag: --enable-sqz). Precedence: +// explicitly passed flag > config.json "enable-sqz" > false. +func ResolveEnableSqz(cfg *Config, cliExplicit bool, cliValue bool) (bool, string) { + if cliExplicit { + return cliValue, "" + } + return cfg != nil && cfg.EnableSqz, "" +} + +// ResolveEnableImages resolves whether image attachments are force-enabled +// even when the backend does not advertise vision support (flag: +// --enable-images). Precedence: explicitly passed flag > config.json +// "enable-images" > false. +func ResolveEnableImages(cfg *Config, cliExplicit bool, cliValue bool) (bool, string) { + if cliExplicit { + return cliValue, "" + } + return cfg != nil && cfg.EnableImages, "" +} + +// ResolveSuppressThinkingWords resolves whether anti-overthinking tokens +// are biased (flag: --suppress-thinking-words; requires the same model for +// the main agent and subagents, which the caller validates). Precedence: +// explicitly passed flag > config.json "suppress-thinking-words" > false. +func ResolveSuppressThinkingWords(cfg *Config, cliExplicit bool, cliValue bool) (bool, string) { + if cliExplicit { + return cliValue, "" + } + return cfg != nil && cfg.SuppressThinkingWords, "" +} + +// ResolveBashTimeout resolves the max wall-clock time for one bash tool +// call (flag: --bash-timeout). Precedence: explicitly passed flag > +// config.json "bash-timeout" > DefaultBashTimeout (10m). The config entry +// is a time.ParseDuration string ("10m", "1h30m"); "0" (or negative) +// passes through — the shell tool treats any non-positive timeout as +// unlimited, exactly as for the flag. +func ResolveBashTimeout(cfg *Config, cliExplicit bool, cliValue time.Duration) (time.Duration, string) { + var raw string + if cfg != nil { + raw = cfg.BashTimeout + } + return resolveDurationString(raw, cliExplicit, cliValue, DefaultBashTimeout, "bash-timeout") +} + +// ResolveSubagentIdleTimeout resolves the "truly idle" notification +// threshold of the subagent idle watchdog (flag: --subagent-idle-timeout). +// Precedence: explicitly passed flag > config.json "subagent-idle-timeout" > +// DefaultSubagentIdleTimeout (15m). The config entry is a +// time.ParseDuration string; "0" (or negative) passes through — the +// watchdog treats a non-positive threshold as off. +func ResolveSubagentIdleTimeout(cfg *Config, cliExplicit bool, cliValue time.Duration) (time.Duration, string) { + var raw string + if cfg != nil { + raw = cfg.SubagentIdleTimeout + } + return resolveDurationString(raw, cliExplicit, cliValue, DefaultSubagentIdleTimeout, "subagent-idle-timeout") +} + +// ResolveSubagentIdleKillAfter resolves the sustained-idle duration past +// which an idle subagent is killed (flag: --subagent-idle-kill-after). +// Precedence: explicitly passed flag > config.json +// "subagent-idle-kill-after" > 0 (notify only, never kill). The config +// entry is a time.ParseDuration string; "0" passes through as notify-only. +func ResolveSubagentIdleKillAfter(cfg *Config, cliExplicit bool, cliValue time.Duration) (time.Duration, string) { + var raw string + if cfg != nil { + raw = cfg.SubagentIdleKillAfter + } + return resolveDurationString(raw, cliExplicit, cliValue, 0, "subagent-idle-kill-after") +} + +// ResolveSubagentMaxTurns resolves the maximum number of turns per subagent +// (flag: --subagent-max-turns). Precedence: explicitly passed flag > +// config.json "subagent-max-turns" > DefaultSubagentMaxTurns (500). The +// config entry is a *int: nil (absent) = unset; 0 = unlimited (the +// executor treats maxTurns <= 0 as unbounded, exactly like the flag); a +// negative value is invalid, warns, and falls back to the default. +func ResolveSubagentMaxTurns(cfg *Config, cliExplicit bool, cliValue int) (int, string) { + if cliExplicit { + return cliValue, "" + } + if cfg != nil && cfg.SubagentMaxTurns != nil { + if v := *cfg.SubagentMaxTurns; v >= 0 { + return v, "" + } + return DefaultSubagentMaxTurns, + fmt.Sprintf("ignoring invalid config.json subagent-max-turns %d; using default %d", *cfg.SubagentMaxTurns, DefaultSubagentMaxTurns) + } + return DefaultSubagentMaxTurns, "" +} + +// ResolveMaxStreamRetries resolves the LLM stream-error retry budget (flag: +// --max-stream-retries; 0 disables). Precedence: explicitly passed flag > +// LATE_MAX_STREAM_RETRIES env > config.json "max-stream-retries" > +// executor.DefaultMaxStreamRetries. The caller passes the env layer +// pre-resolved: envSet is true only when the env var is present and parses +// as an integer (main warns and ignores an unparseable value), and envValue +// is then the parsed budget — executor.DefaultMaxStreamRetries when the +// env is unset. The config entry is a *int: nil (absent) = unset; 0 = +// disabled; a negative value is invalid, warns, and falls back to envValue. +func ResolveMaxStreamRetries(cfg *Config, cliExplicit bool, cliValue int, envSet bool, envValue int) (int, string) { + if cliExplicit { + return cliValue, "" + } + if envSet { + return envValue, "" + } + if cfg != nil && cfg.MaxStreamRetries != nil { + if v := *cfg.MaxStreamRetries; v >= 0 { + return v, "" + } + return envValue, + fmt.Sprintf("ignoring invalid config.json max-stream-retries %d; using %d", *cfg.MaxStreamRetries, envValue) + } + return envValue, "" +} + +// ResolveMaxConcurrentLLMRequests resolves the process-wide cap on +// concurrent in-flight LLM requests (flag: --max-concurrent-llm-requests). +// Precedence: explicitly passed flag > config.json +// "max-concurrent-llm-requests" > DefaultMaxConcurrentLLMRequests (6). The +// config entry is a *int: nil (absent) = unset; 0 = unlimited; a negative +// value is invalid, warns, and falls back to the default. +func ResolveMaxConcurrentLLMRequests(cfg *Config, cliExplicit bool, cliValue int) (int, string) { + if cliExplicit { + return cliValue, "" + } + if cfg != nil && cfg.MaxConcurrentLLMRequests != nil { + if v := *cfg.MaxConcurrentLLMRequests; v >= 0 { + return v, "" + } + return DefaultMaxConcurrentLLMRequests, + fmt.Sprintf("ignoring invalid config.json max-concurrent-llm-requests %d; using default %d", *cfg.MaxConcurrentLLMRequests, DefaultMaxConcurrentLLMRequests) + } + return DefaultMaxConcurrentLLMRequests, "" +} + +// ResolveLogitBias resolves the main-agent token bias string (flag: +// --logit-bias). Precedence: explicitly passed flag > config.json +// "logit-bias" > "" (no bias). The value is a pass-through in the same +// format the flag accepts (JSON object or comma-separated TOKEN_ID:BIAS +// pairs); the caller parses it with client.ParseLogitBias and decides the +// error handling per source. +func ResolveLogitBias(cfg *Config, cliExplicit bool, cliValue string) (string, string) { + if cliExplicit { + return cliValue, "" + } + if cfg != nil && cfg.LogitBias != "" { + return cfg.LogitBias, "" + } + return "", "" +} + +// ResolveSubagentLogitBias resolves the subagent token bias string (flag: +// --subagent-logit-bias). Precedence: explicitly passed flag > config.json +// "subagent-logit-bias" > "" (no bias). Pass-through, like ResolveLogitBias. +func ResolveSubagentLogitBias(cfg *Config, cliExplicit bool, cliValue string) (string, string) { + if cliExplicit { + return cliValue, "" + } + if cfg != nil && cfg.SubagentLogitBias != "" { + return cfg.SubagentLogitBias, "" + } + return "", "" +} + func nonEmptyEnv(lookup EnvLookup, key string) (string, bool) { if lookup == nil { return "", false diff --git a/internal/config/config_cli_settings_test.go b/internal/config/config_cli_settings_test.go new file mode 100644 index 0000000..da763e4 --- /dev/null +++ b/internal/config/config_cli_settings_test.go @@ -0,0 +1,739 @@ +package config + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + "time" +) + +// The tests in this file cover the CLI-equivalent settings: every field that +// mirrors a command-line flag one-to-one (kebab-case JSON key == flag name) +// and its Resolve* function implementing the mandatory precedence +// explicitly-passed flag > config.json entry > built-in default. + +func boolPtr(v bool) *bool { return &v } +func intPtr(v int) *int { return &v } + +// TestResolveSystemPrompt covers the string group: system-prompt, +// system-prompt-file, and append-system-prompt (all resolved identically). +func TestResolveSystemPrompt(t *testing.T) { + tests := []struct { + name string + cfg *Config + cliExplicit bool + cliValue string + want string + }{ + { + name: "flag explicit wins over config", + cfg: &Config{SystemPrompt: "from config"}, + cliExplicit: true, + cliValue: "from flag", + want: "from flag", + }, + { + // -system-prompt="" is explicit: it undoes a config entry for + // the run and falls back to the built-in prompt. + name: "flag explicitly empty wins over config", + cfg: &Config{SystemPrompt: "from config"}, + cliExplicit: true, + cliValue: "", + want: "", + }, + { + name: "config wins over empty default", + cfg: &Config{SystemPrompt: "from config"}, + want: "from config", + }, + { + name: "empty config entry uses default", + cfg: &Config{}, + want: "", + }, + { + name: "nil config uses default", + cfg: nil, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, warning := ResolveSystemPrompt(tt.cfg, tt.cliExplicit, tt.cliValue) + if got != tt.want || warning != "" { + t.Fatalf("ResolveSystemPrompt() = (%q, %q), want (%q, \"\")", got, warning, tt.want) + } + }) + } +} + +func TestResolveSystemPromptFile(t *testing.T) { + tests := []struct { + name string + cfg *Config + cliExplicit bool + cliValue string + want string + }{ + {"flag explicit wins over config", &Config{SystemPromptFile: "/cfg.md"}, true, "/flag.md", "/flag.md"}, + {"config wins over empty default", &Config{SystemPromptFile: "/cfg.md"}, false, "", "/cfg.md"}, + {"empty config uses default", &Config{}, false, "", ""}, + {"nil config uses default", nil, false, "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, warning := ResolveSystemPromptFile(tt.cfg, tt.cliExplicit, tt.cliValue) + if got != tt.want || warning != "" { + t.Fatalf("ResolveSystemPromptFile() = (%q, %q), want (%q, \"\")", got, warning, tt.want) + } + }) + } +} + +func TestResolveAppendSystemPrompt(t *testing.T) { + tests := []struct { + name string + cfg *Config + cliExplicit bool + cliValue string + want string + }{ + {"flag explicit wins over config", &Config{AppendSystemPrompt: "cfg"}, true, "flag", "flag"}, + {"config wins over empty default", &Config{AppendSystemPrompt: "cfg"}, false, "", "cfg"}, + {"empty config uses default", &Config{}, false, "", ""}, + {"nil config uses default", nil, false, "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, warning := ResolveAppendSystemPrompt(tt.cfg, tt.cliExplicit, tt.cliValue) + if got != tt.want || warning != "" { + t.Fatalf("ResolveAppendSystemPrompt() = (%q, %q), want (%q, \"\")", got, warning, tt.want) + } + }) + } +} + +// TestResolveDefaultTrueBools covers the *bool tri-state group whose flag +// default is true: use-tools, enable-bash, inject-cwd, enable-subagents, +// show-cwd. All five resolve with identical semantics; the shared table runs +// every case against each resolver via its field accessor. +func TestResolveDefaultTrueBools(t *testing.T) { + resolvers := map[string]struct { + resolve func(*Config, bool, bool) (bool, string) + field func(*Config) **bool + }{ + "use-tools": { + resolve: ResolveUseTools, + field: func(c *Config) **bool { return &c.UseTools }, + }, + "enable-bash": { + resolve: ResolveEnableBash, + field: func(c *Config) **bool { return &c.EnableBash }, + }, + "inject-cwd": { + resolve: ResolveInjectCWD, + field: func(c *Config) **bool { return &c.InjectCWD }, + }, + "enable-subagents": { + resolve: ResolveEnableSubagents, + field: func(c *Config) **bool { return &c.EnableSubagents }, + }, + "show-cwd": { + resolve: ResolveShowCWD, + field: func(c *Config) **bool { return &c.ShowCWD }, + }, + } + + for name, r := range resolvers { + t.Run(name, func(t *testing.T) { + tests := []struct { + name string + cfg *Config + cliExplicit bool + cliValue bool + want bool + }{ + { + name: "flag explicit true wins over config false", + cfg: withBoolField(r.field, false), + cliExplicit: true, + cliValue: true, + want: true, + }, + { + name: "flag explicit false wins over config true", + cfg: withBoolField(r.field, true), + cliExplicit: true, + cliValue: false, + want: false, + }, + { + name: "config explicit false is honored (not unset)", + cfg: withBoolField(r.field, false), + want: false, + }, + { + name: "config explicit true is honored", + cfg: withBoolField(r.field, true), + want: true, + }, + { + name: "absent config entry (nil pointer) uses default", + cfg: &Config{}, + want: true, + }, + { + name: "nil config uses default", + cfg: nil, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, warning := r.resolve(tt.cfg, tt.cliExplicit, tt.cliValue) + if got != tt.want || warning != "" { + t.Fatalf("resolve() = (%v, %q), want (%v, \"\")", got, warning, tt.want) + } + }) + } + }) + } +} + +// withBoolField returns a config whose named *bool field is set to v (an +// explicit entry — the tri-state "set" case, distinct from a nil/unset one). +func withBoolField(field func(*Config) **bool, v bool) *Config { + cfg := &Config{} + *field(cfg) = boolPtr(v) + return cfg +} + +// TestResolveDefaultFalseBools covers the plain-bool group whose flag default +// is false: gemma-thinking, enable-sqz, enable-images, +// suppress-thinking-words. +func TestResolveDefaultFalseBools(t *testing.T) { + resolvers := map[string]struct { + resolve func(*Config, bool, bool) (bool, string) + set func(*Config, bool) + }{ + "gemma-thinking": { + resolve: ResolveGemmaThinking, + set: func(c *Config, v bool) { c.GemmaThinking = v }, + }, + "enable-sqz": { + resolve: ResolveEnableSqz, + set: func(c *Config, v bool) { c.EnableSqz = v }, + }, + "enable-images": { + resolve: ResolveEnableImages, + set: func(c *Config, v bool) { c.EnableImages = v }, + }, + "suppress-thinking-words": { + resolve: ResolveSuppressThinkingWords, + set: func(c *Config, v bool) { c.SuppressThinkingWords = v }, + }, + } + + for name, r := range resolvers { + t.Run(name, func(t *testing.T) { + tests := []struct { + name string + cfg *Config + cliExplicit bool + cliValue bool + want bool + }{ + { + name: "flag explicit false wins over config true", + cfg: withPlainBool(r.set, true), + cliExplicit: true, + cliValue: false, + want: false, + }, + { + name: "flag explicit true wins over config false", + cfg: withPlainBool(r.set, false), + cliExplicit: true, + cliValue: true, + want: true, + }, + { + name: "config true honored", + cfg: withPlainBool(r.set, true), + want: true, + }, + { + // false = unset for a default-false plain bool. + name: "config false is the unset default", + cfg: withPlainBool(r.set, false), + want: false, + }, + { + name: "nil config defaults false", + cfg: nil, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, warning := r.resolve(tt.cfg, tt.cliExplicit, tt.cliValue) + if got != tt.want || warning != "" { + t.Fatalf("resolve() = (%v, %q), want (%v, \"\")", got, warning, tt.want) + } + }) + } + }) + } +} + +// withPlainBool returns a config with the named plain-bool field set to v. +func withPlainBool(set func(*Config, bool), v bool) *Config { + cfg := &Config{} + set(cfg, v) + return cfg +} + +// TestResolveDurationSettings covers the duration-string group: bash-timeout, +// subagent-idle-timeout, subagent-idle-kill-after. All mirror +// ResolveSubagentTimeout's parsing rules: empty = unset (default), a +// parseable value passes through (0/negative keep their per-setting +// semantics), an unparseable value warns and falls back to the default. +func TestResolveDurationSettings(t *testing.T) { + resolvers := map[string]struct { + resolve func(*Config, bool, time.Duration) (time.Duration, string) + set func(*Config, string) + def time.Duration + }{ + "bash-timeout": { + resolve: ResolveBashTimeout, + set: func(c *Config, v string) { c.BashTimeout = v }, + def: DefaultBashTimeout, + }, + "subagent-idle-timeout": { + resolve: ResolveSubagentIdleTimeout, + set: func(c *Config, v string) { c.SubagentIdleTimeout = v }, + def: DefaultSubagentIdleTimeout, + }, + "subagent-idle-kill-after": { + resolve: ResolveSubagentIdleKillAfter, + set: func(c *Config, v string) { c.SubagentIdleKillAfter = v }, + def: 0, + }, + } + + for name, r := range resolvers { + t.Run(name, func(t *testing.T) { + tests := []struct { + name string + raw string + cliExplicit bool + cliValue time.Duration + want time.Duration + wantWarning string + }{ + { + name: "flag explicit wins over config", + raw: "1h", + cliExplicit: true, + cliValue: 2 * time.Minute, + want: 2 * time.Minute, + }, + { + name: "config parses to the configured duration", + raw: "45m", + want: 45 * time.Minute, + }, + { + name: "config zero passes through (unlimited/off)", + raw: "0", + want: 0, + }, + { + name: "config negative passes through", + raw: "-5m", + want: -5 * time.Minute, + }, + { + name: "unparseable config warns and falls back to default", + raw: "garbage", + want: r.def, + wantWarning: `ignoring invalid config.json ` + name + ` "garbage"; using default`, + }, + { + name: "unitless config value warns and falls back to default", + raw: "5", + want: r.def, + wantWarning: `ignoring invalid config.json ` + name + ` "5"; using default`, + }, + { + name: "empty config entry uses default", + raw: "", + want: r.def, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &Config{} + r.set(cfg, tt.raw) + got, warning := r.resolve(cfg, tt.cliExplicit, tt.cliValue) + if got != tt.want { + t.Fatalf("resolve() = %v, want %v", got, tt.want) + } + if !strings.HasPrefix(warning, tt.wantWarning) { + t.Fatalf("resolve() warning = %q, want prefix %q", warning, tt.wantWarning) + } + }) + } + // nil config uses the default. + if got, warning := r.resolve(nil, false, 0); got != r.def || warning != "" { + t.Fatalf("resolve(nil) = (%v, %q), want (%v, \"\")", got, warning, r.def) + } + }) + } +} + +func TestResolveSubagentMaxTurns(t *testing.T) { + tests := []struct { + name string + cfg *Config + cliExplicit bool + cliValue int + want int + wantWarning string + }{ + { + name: "flag explicit wins over config", + cfg: &Config{SubagentMaxTurns: intPtr(42)}, + cliExplicit: true, + cliValue: 7, + want: 7, + }, + { + name: "explicit zero flag wins over config (unlimited)", + cfg: &Config{SubagentMaxTurns: intPtr(42)}, + cliExplicit: true, + cliValue: 0, + want: 0, + }, + { + name: "config set is honored", + cfg: &Config{SubagentMaxTurns: intPtr(42)}, + want: 42, + }, + { + name: "config zero means unlimited (passes through)", + cfg: &Config{SubagentMaxTurns: intPtr(0)}, + want: 0, + }, + { + name: "negative config warns and falls back to default", + cfg: &Config{SubagentMaxTurns: intPtr(-3)}, + want: DefaultSubagentMaxTurns, + wantWarning: "ignoring invalid config.json subagent-max-turns -3; using default 500", + }, + { + name: "nil entry uses default", + cfg: &Config{}, + want: DefaultSubagentMaxTurns, + }, + { + name: "nil config uses default", + cfg: nil, + want: DefaultSubagentMaxTurns, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, warning := ResolveSubagentMaxTurns(tt.cfg, tt.cliExplicit, tt.cliValue) + if got != tt.want { + t.Fatalf("ResolveSubagentMaxTurns() = %d, want %d", got, tt.want) + } + if warning != tt.wantWarning { + t.Fatalf("ResolveSubagentMaxTurns() warning = %q, want %q", warning, tt.wantWarning) + } + }) + } +} + +// TestResolveMaxStreamRetries pins the four-layer precedence: flag > env > +// config > default. The caller passes the env layer pre-resolved (envSet, +// envValue); envValue is executor.DefaultMaxStreamRetries when the env is +// unset. +func TestResolveMaxStreamRetries(t *testing.T) { + const envBudget = 3 // stands in for a parsed LATE_MAX_STREAM_RETRIES + const defBudget = 10 // stands in for executor.DefaultMaxStreamRetries + + tests := []struct { + name string + cfg *Config + cliExplicit bool + cliValue int + envSet bool + want int + wantWarning string + }{ + { + name: "flag explicit wins over env and config", + cfg: &Config{MaxStreamRetries: intPtr(9)}, + cliExplicit: true, + cliValue: 5, + envSet: true, + want: 5, + }, + { + name: "env wins over config", + cfg: &Config{MaxStreamRetries: intPtr(9)}, + envSet: true, + want: envBudget, + }, + { + name: "config wins when env unset", + cfg: &Config{MaxStreamRetries: intPtr(9)}, + want: 9, + }, + { + name: "config zero disables retries", + cfg: &Config{MaxStreamRetries: intPtr(0)}, + want: 0, + }, + { + name: "negative config warns and falls back to env/default value", + cfg: &Config{MaxStreamRetries: intPtr(-2)}, + want: defBudget, + wantWarning: "ignoring invalid config.json max-stream-retries -2; using 10", + }, + { + name: "nil entry falls back to env/default value", + cfg: &Config{}, + want: defBudget, + }, + { + name: "nil config falls back to env/default value", + cfg: nil, + want: defBudget, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // envValue mirrors main: the parsed env budget when the env is + // set, the executor default when it is unset. + envValue := defBudget + if tt.envSet { + envValue = envBudget + } + got, warning := ResolveMaxStreamRetries(tt.cfg, tt.cliExplicit, tt.cliValue, tt.envSet, envValue) + if got != tt.want { + t.Fatalf("ResolveMaxStreamRetries() = %d, want %d", got, tt.want) + } + if warning != tt.wantWarning { + t.Fatalf("ResolveMaxStreamRetries() warning = %q, want %q", warning, tt.wantWarning) + } + }) + } +} + +func TestResolveMaxConcurrentLLMRequests(t *testing.T) { + tests := []struct { + name string + cfg *Config + cliExplicit bool + cliValue int + want int + wantWarning string + }{ + { + name: "flag explicit wins over config", + cfg: &Config{MaxConcurrentLLMRequests: intPtr(2)}, + cliExplicit: true, + cliValue: 8, + want: 8, + }, + { + name: "config set is honored", + cfg: &Config{MaxConcurrentLLMRequests: intPtr(2)}, + want: 2, + }, + { + name: "config zero means unlimited (passes through)", + cfg: &Config{MaxConcurrentLLMRequests: intPtr(0)}, + want: 0, + }, + { + name: "negative config warns and falls back to default", + cfg: &Config{MaxConcurrentLLMRequests: intPtr(-1)}, + want: DefaultMaxConcurrentLLMRequests, + wantWarning: "ignoring invalid config.json max-concurrent-llm-requests -1; using default 6", + }, + { + name: "nil entry uses default", + cfg: &Config{}, + want: DefaultMaxConcurrentLLMRequests, + }, + { + name: "nil config uses default", + cfg: nil, + want: DefaultMaxConcurrentLLMRequests, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, warning := ResolveMaxConcurrentLLMRequests(tt.cfg, tt.cliExplicit, tt.cliValue) + if got != tt.want { + t.Fatalf("ResolveMaxConcurrentLLMRequests() = %d, want %d", got, tt.want) + } + if warning != tt.wantWarning { + t.Fatalf("ResolveMaxConcurrentLLMRequests() warning = %q, want %q", warning, tt.wantWarning) + } + }) + } +} + +// TestResolveLogitBias covers the pass-through bias strings: no format +// validation in the resolver (client.ParseLogitBias stays the caller's job). +func TestResolveLogitBias(t *testing.T) { + tests := []struct { + name string + cfg *Config + cliExplicit bool + cliValue string + want string + }{ + {"flag explicit wins over config", &Config{LogitBias: `{"5":-1}`}, true, "5:-1", "5:-1"}, + {"config wins over empty default", &Config{LogitBias: `{"5":-1}`}, false, "", `{"5":-1}`}, + {"empty config uses default", &Config{}, false, "", ""}, + {"nil config uses default", nil, false, "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, warning := ResolveLogitBias(tt.cfg, tt.cliExplicit, tt.cliValue) + if got != tt.want || warning != "" { + t.Fatalf("ResolveLogitBias() = (%q, %q), want (%q, \"\")", got, warning, tt.want) + } + }) + } +} + +func TestResolveSubagentLogitBias(t *testing.T) { + tests := []struct { + name string + cfg *Config + cliExplicit bool + cliValue string + want string + }{ + {"flag explicit wins over config", &Config{SubagentLogitBias: "5:-1"}, true, "5:2", "5:2"}, + {"config wins over empty default", &Config{SubagentLogitBias: "5:-1"}, false, "", "5:-1"}, + {"empty config uses default", &Config{}, false, "", ""}, + {"nil config uses default", nil, false, "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, warning := ResolveSubagentLogitBias(tt.cfg, tt.cliExplicit, tt.cliValue) + if got != tt.want || warning != "" { + t.Fatalf("ResolveSubagentLogitBias() = (%q, %q), want (%q, \"\")", got, warning, tt.want) + } + }) + } +} + +// TestConfig_CLIEquivalentKeysJSONRoundTrip guards the user's mapping rule — +// the JSON key of every CLI-equivalent setting is its flag name in kebab-case +// — in BOTH directions: +// +// 1. Unmarshal: a config.json containing every new kebab-case key populates +// the matching struct field (a typo'd tag would leave the field zero). +// 2. Marshal: a fully populated struct serializes each field under its +// kebab-case key. +func TestConfig_CLIEquivalentKeysJSONRoundTrip(t *testing.T) { + // Every kebab-case key, one per new CLI-equivalent setting. + jsonLiteral := `{ + "system-prompt": "sp", + "system-prompt-file": "/tmp/sp.md", + "append-system-prompt": "asp", + "inject-cwd": false, + "gemma-thinking": true, + "use-tools": false, + "enable-bash": false, + "bash-timeout": "90s", + "enable-sqz": true, + "enable-images": true, + "enable-subagents": false, + "subagent-max-turns": 42, + "subagent-idle-timeout": "5m", + "subagent-idle-kill-after": "1m", + "max-stream-retries": 3, + "max-concurrent-llm-requests": 2, + "suppress-thinking-words": true, + "logit-bias": "{\"5\":-1}", + "subagent-logit-bias": "5:2", + "show-cwd": false + }` + + var cfg Config + if err := json.Unmarshal([]byte(jsonLiteral), &cfg); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + checks := []struct { + name string + ok bool + want string + }{ + {"SystemPrompt", cfg.SystemPrompt == "sp", "sp"}, + {"SystemPromptFile", cfg.SystemPromptFile == "/tmp/sp.md", "/tmp/sp.md"}, + {"AppendSystemPrompt", cfg.AppendSystemPrompt == "asp", "asp"}, + {"InjectCWD", cfg.InjectCWD != nil && !*cfg.InjectCWD, "explicit false"}, + {"GemmaThinking", cfg.GemmaThinking, "true"}, + {"UseTools", cfg.UseTools != nil && !*cfg.UseTools, "explicit false"}, + {"EnableBash", cfg.EnableBash != nil && !*cfg.EnableBash, "explicit false"}, + {"BashTimeout", cfg.BashTimeout == "90s", "90s"}, + {"EnableSqz", cfg.EnableSqz, "true"}, + {"EnableImages", cfg.EnableImages, "true"}, + {"EnableSubagents", cfg.EnableSubagents != nil && !*cfg.EnableSubagents, "explicit false"}, + {"SubagentMaxTurns", cfg.SubagentMaxTurns != nil && *cfg.SubagentMaxTurns == 42, "42"}, + {"SubagentIdleTimeout", cfg.SubagentIdleTimeout == "5m", "5m"}, + {"SubagentIdleKillAfter", cfg.SubagentIdleKillAfter == "1m", "1m"}, + {"MaxStreamRetries", cfg.MaxStreamRetries != nil && *cfg.MaxStreamRetries == 3, "3"}, + {"MaxConcurrentLLMRequests", cfg.MaxConcurrentLLMRequests != nil && *cfg.MaxConcurrentLLMRequests == 2, "2"}, + {"SuppressThinkingWords", cfg.SuppressThinkingWords, "true"}, + {"LogitBias", cfg.LogitBias == `{"5":-1}`, `{"5":-1}`}, + {"SubagentLogitBias", cfg.SubagentLogitBias == "5:2", "5:2"}, + {"ShowCWD", cfg.ShowCWD != nil && !*cfg.ShowCWD, "explicit false"}, + } + for _, c := range checks { + if !c.ok { + t.Errorf("key for %s did not round-trip into the struct (want %s) — tag typo?", c.name, c.want) + } + } + + // Marshal direction: a fully populated struct must emit every kebab-case + // key, including the explicit zeros (a 0 *int, an explicit-false *bool) + // that omitempty must keep because the pointer is non-nil. + data, err := json.Marshal(&cfg) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + out := string(data) + for _, key := range []string{ + "system-prompt", "system-prompt-file", "append-system-prompt", + "inject-cwd", "gemma-thinking", "use-tools", "enable-bash", + "bash-timeout", "enable-sqz", "enable-images", "enable-subagents", + "subagent-max-turns", "subagent-idle-timeout", "subagent-idle-kill-after", + "max-stream-retries", "max-concurrent-llm-requests", + "suppress-thinking-words", "logit-bias", "subagent-logit-bias", "show-cwd", + } { + if !strings.Contains(out, `"`+key+`":`) { + t.Errorf("Marshal output missing kebab-case key %q: %s", key, out) + } + } + + // And the marshaled form unmarshals to an identical struct (full cycle). + var back Config + if err := json.Unmarshal(data, &back); err != nil { + t.Fatalf("re-Unmarshal() error = %v", err) + } + if !reflect.DeepEqual(cfg, back) { + t.Fatalf("round-trip changed the config:\nwant %#v\ngot %#v", cfg, back) + } +}