packages feed

seihou-cli-0.6.0.0: src/Seihou/CLI/AgentConfig.hs

module Seihou.CLI.AgentConfig
  ( -- * Inputs
    AgentConfigInputs (..),
    baseAgentConfigInputs,
    AgentSettingFlags (..),
    noAgentSettingFlags,

    -- * Command identity
    AgentCommandName (..),
    agentCommandSegment,
    agentCommandLabel,
    allAgentCommands,

    -- * Config keys and environment variables
    agentProviderConfigKey,
    agentModelConfigKey,
    agentEffortConfigKey,
    agentTraceConfigKey,
    agentTracePathConfigKey,
    agentCommandProviderConfigKey,
    agentCommandModelConfigKey,
    agentCommandEffortConfigKey,
    agentCommandTraceConfigKey,
    agentProviderEnvVar,
    agentModelEnvVar,
    agentEffortEnvVar,
    agentTraceEnvVar,

    -- * Provenance
    AgentConfigSource (..),
    AgentField (..),
    ResolvedAgentField (..),
    agentConfigSourceLabel,

    -- * Resolution
    resolveAgentModelConfig,
    resolveAgentModelConfigFor,
    resolveTracePath,
    loadAgentModelConfig,
    loadAgentModelConfigFor,

    -- * Artifact-declared launch settings
    AgentLaunchDeclaration (..),
    noAgentLaunchDeclaration,
    agentLaunchDeclaration,
    validateAgentLaunchDeclaration,
    PendingAgentConfig (..),
    loadPendingAgentConfig,
    resolvePendingAgentConfig,
    resolvedAgentModelConfig,
    formatResolvedAgentProvenance,
    resolveDeclaredAgentConfig,

    -- * Whole-configuration inspection
    ResolvedCommandConfig (..),
    loadResolvedAgentConfig,
  )
where

import Baikai.ThinkingLevel (ThinkingLevel)
import Control.Applicative ((<|>))
import Data.Generics.Labels ()
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe, isJust)
import Data.Text qualified as T
import Seihou.CLI.AgentCompletion
  ( AgentModelConfig (..),
    AgentProvider (..),
    TraceSetting (..),
    defaultAgentModelConfig,
    defaultModelForProvider,
    effortFromText,
    effortToText,
    providerFromText,
    providerToText,
    traceFromText,
    traceToText,
  )
import Seihou.CLI.Shared (formatConfigError, logIO)
import Seihou.Core.Types (AgentLaunch (..), LogLevel)
import Seihou.Effect.ConfigReader (readGlobalConfig, readLocalConfig)
import Seihou.Effect.ConfigReaderInterp (runConfigReader)
import Seihou.Effect.Logger (logError, logInfo)
import Seihou.Prelude
import System.Environment (lookupEnv)
import System.Exit (exitFailure)

-- | All the raw material provider/model resolution draws on, in one record so
-- the pure resolver can be unit-tested without touching the filesystem or the
-- environment.
--
-- The two @cli*FromSubcommand@ flags record whether the (already combined)
-- winning CLI flag originated from the subcommand's own @--provider@/@--model@
-- (as opposed to the parent @seihou agent@ flag). They only affect the
-- provenance label reported for a CLI-sourced value; they never change which
-- value wins.
data AgentConfigInputs = AgentConfigInputs
  { cliProvider :: !(Maybe Text),
    cliModel :: !(Maybe Text),
    cliEffort :: !(Maybe Text),
    cliTrace :: !(Maybe Text),
    cliProviderFromSubcommand :: !Bool,
    cliModelFromSubcommand :: !Bool,
    cliEffortFromSubcommand :: !Bool,
    cliTraceFromSubcommand :: !Bool,
    envProvider :: !(Maybe Text),
    envModel :: !(Maybe Text),
    envEffort :: !(Maybe Text),
    envTrace :: !(Maybe Text),
    -- | Declared by the blueprint or prompt being run, when the command has
    -- one. Only populated once the artifact has been loaded; see
    -- 'resolvePendingAgentConfig'.
    declaredProvider :: !(Maybe Text),
    declaredModel :: !(Maybe Text),
    declaredEffort :: !(Maybe Text),
    -- | Reserved: no schema field feeds this yet. It exists so all four
    -- settings are structurally identical, making a future @launch.trace@ an
    -- insertion rather than a redesign.
    declaredTrace :: !(Maybe Text),
    localConfig :: !(Map Text Text),
    globalConfig :: !(Map Text Text)
  }
  deriving stock (Eq, Generic, Show)

-- | An 'AgentConfigInputs' with nothing set: no flags, no environment, empty
-- config maps. Handy as a base for tests and for callers that only populate a
-- few fields.
baseAgentConfigInputs :: AgentConfigInputs
baseAgentConfigInputs =
  AgentConfigInputs
    { cliProvider = Nothing,
      cliModel = Nothing,
      cliEffort = Nothing,
      cliTrace = Nothing,
      cliProviderFromSubcommand = False,
      cliModelFromSubcommand = False,
      cliEffortFromSubcommand = False,
      cliTraceFromSubcommand = False,
      envProvider = Nothing,
      envModel = Nothing,
      envEffort = Nothing,
      envTrace = Nothing,
      declaredProvider = Nothing,
      declaredModel = Nothing,
      declaredEffort = Nothing,
      declaredTrace = Nothing,
      localConfig = Map.empty,
      globalConfig = Map.empty
    }

-- | The four agent settings as supplied on one command-line tier — either the
-- parent @seihou agent@ command or the subcommand itself.
--
-- Grouping them keeps the loader signatures honest: four same-typed
-- @Maybe Text@ values in a row, twice over, are trivial to transpose by
-- accident, and the compiler would not notice.
data AgentSettingFlags = AgentSettingFlags
  { provider :: !(Maybe Text),
    model :: !(Maybe Text),
    effort :: !(Maybe Text),
    trace :: !(Maybe Text)
  }
  deriving stock (Eq, Generic, Show)

-- | No flags supplied on this tier.
noAgentSettingFlags :: AgentSettingFlags
noAgentSettingFlags =
  AgentSettingFlags
    { provider = Nothing,
      model = Nothing,
      effort = Nothing,
      trace = Nothing
    }

-- | Fold the parent and subcommand flag tiers into gathered inputs. The
-- subcommand's own flag wins over the parent @seihou agent@ flag; which tier
-- supplied the winner only affects the provenance label, never the value.
applyAgentSettingFlags :: AgentSettingFlags -> AgentSettingFlags -> AgentConfigInputs -> AgentConfigInputs
applyAgentSettingFlags parent command inputs =
  inputs
    & #cliProvider
    .~ (command ^. #provider <|> parent ^. #provider)
    & #cliModel
    .~ (command ^. #model <|> parent ^. #model)
    & #cliEffort
    .~ (command ^. #effort <|> parent ^. #effort)
    & #cliTrace
    .~ (command ^. #trace <|> parent ^. #trace)
    & #cliProviderFromSubcommand
    .~ isJust (command ^. #provider)
    & #cliModelFromSubcommand
    .~ isJust (command ^. #model)
    & #cliEffortFromSubcommand
    .~ isJust (command ^. #effort)
    & #cliTraceFromSubcommand
    .~ isJust (command ^. #trace)

-- | The agent-driven commands whose provider/model can be configured
-- independently. Each maps to a config-key segment (see 'agentCommandSegment').
data AgentCommandName
  = AgentCmdAssist
  | AgentCmdBootstrap
  | AgentCmdSetup
  | AgentCmdRun
  | AgentCmdMigrate
  | AgentCmdPromptRun
  deriving stock (Eq, Show, Enum, Bounded)

-- | The token used inside per-command config keys, e.g. @"assist"@ in
-- @agent.assist.model@.
agentCommandSegment :: AgentCommandName -> Text
agentCommandSegment AgentCmdAssist = "assist"
agentCommandSegment AgentCmdBootstrap = "bootstrap"
agentCommandSegment AgentCmdSetup = "setup"
agentCommandSegment AgentCmdRun = "run"
agentCommandSegment AgentCmdMigrate = "migrate"
agentCommandSegment AgentCmdPromptRun = "prompt-run"

-- | Human-facing label for display, e.g. @"prompt run"@ for the two-word
-- @seihou prompt run@ command.
agentCommandLabel :: AgentCommandName -> Text
agentCommandLabel AgentCmdPromptRun = "prompt run"
agentCommandLabel c = agentCommandSegment c

-- | Every configurable agent command, in display order.
allAgentCommands :: [AgentCommandName]
allAgentCommands = [minBound .. maxBound]

-- | The cross-command default provider key, @agent.provider@.
agentProviderConfigKey :: Text
agentProviderConfigKey = "agent.provider"

-- | The cross-command default model key, @agent.model@.
agentModelConfigKey :: Text
agentModelConfigKey = "agent.model"

-- | The cross-command default reasoning-effort key, @agent.effort@.
agentEffortConfigKey :: Text
agentEffortConfigKey = "agent.effort"

-- | The cross-command default trace-destination key, @agent.trace@.
agentTraceConfigKey :: Text
agentTraceConfigKey = "agent.trace"

-- | The trace file path key, @agent.tracePath@. Free-form (any path) and
-- deliberately not per-command: one project writes one trace file.
agentTracePathConfigKey :: Text
agentTracePathConfigKey = "agent.tracePath"

-- | The per-command provider key, e.g. @agent.assist.provider@.
agentCommandProviderConfigKey :: AgentCommandName -> Text
agentCommandProviderConfigKey c = "agent." <> agentCommandSegment c <> ".provider"

-- | The per-command model key, e.g. @agent.run.model@.
agentCommandModelConfigKey :: AgentCommandName -> Text
agentCommandModelConfigKey c = "agent." <> agentCommandSegment c <> ".model"

-- | The per-command reasoning-effort key, e.g. @agent.run.effort@.
agentCommandEffortConfigKey :: AgentCommandName -> Text
agentCommandEffortConfigKey c = "agent." <> agentCommandSegment c <> ".effort"

-- | The per-command trace-destination key, e.g. @agent.run.trace@.
agentCommandTraceConfigKey :: AgentCommandName -> Text
agentCommandTraceConfigKey c = "agent." <> agentCommandSegment c <> ".trace"

agentProviderEnvVar :: String
agentProviderEnvVar = "SEIHOU_AGENT_PROVIDER"

agentModelEnvVar :: String
agentModelEnvVar = "SEIHOU_AGENT_MODEL"

agentEffortEnvVar :: String
agentEffortEnvVar = "SEIHOU_AGENT_EFFORT"

agentTraceEnvVar :: String
agentTraceEnvVar = "SEIHOU_AGENT_TRACE"

-- | Which of the resolvable fields a value belongs to. Used only to build
-- provenance labels.
data AgentField = ProviderField | ModelField | EffortField | TraceField
  deriving stock (Eq, Show)

-- | Where a resolved value came from, highest precedence first.
data AgentConfigSource
  = -- | @--provider@/@--model@ on the subcommand.
    SourceCliSubcommand
  | -- | @--provider@/@--model@ on @seihou agent@.
    SourceCliParent
  | -- | @SEIHOU_AGENT_PROVIDER@/@SEIHOU_AGENT_MODEL@.
    SourceEnv
  | -- | The blueprint's or prompt's own @launch.<field>@ declaration.
    SourceArtifactDeclaration
  | -- | Local @agent.<command>.<field>@.
    SourceLocalCommand
  | -- | Local @agent.<field>@.
    SourceLocalDefault
  | -- | Global @agent.<command>.<field>@.
    SourceGlobalCommand
  | -- | Global @agent.<field>@.
    SourceGlobalDefault
  | -- | The hard-coded fallback (provider @claude-cli@, model unset).
    SourceBuiltinDefault
  deriving stock (Eq, Show)

-- | A resolved value paired with the source that supplied it.
data ResolvedAgentField a = ResolvedAgentField
  { value :: !a,
    source :: !AgentConfigSource
  }
  deriving stock (Eq, Generic, Show)

-- | A short human label describing where a value came from, suitable for
-- bracketed display. For config-file sources it names the concrete key that
-- won, e.g. @"local: agent.run.model"@ or @"global: agent.provider"@.
agentConfigSourceLabel :: AgentCommandName -> AgentField -> AgentConfigSource -> Text
agentConfigSourceLabel c field src =
  case src of
    SourceCliSubcommand -> "flag on subcommand"
    SourceCliParent -> "flag on `seihou agent`"
    SourceEnv -> "env: " <> T.pack (envVarName field)
    SourceArtifactDeclaration -> artifactKind c <> ": launch." <> fieldName field
    SourceLocalCommand -> "local: " <> commandKey field c
    SourceLocalDefault -> "local: " <> defaultKey field
    SourceGlobalCommand -> "global: " <> commandKey field c
    SourceGlobalDefault -> "global: " <> defaultKey field
    SourceBuiltinDefault -> "built-in default"

-- | Which kind of artifact declares launch settings for a command, so the
-- provenance label names the thing the user actually ran.
artifactKind :: AgentCommandName -> Text
artifactKind AgentCmdPromptRun = "prompt"
artifactKind _ = "blueprint"

fieldName :: AgentField -> Text
fieldName ProviderField = "provider"
fieldName ModelField = "model"
fieldName EffortField = "effort"
fieldName TraceField = "trace"

envVarName :: AgentField -> String
envVarName ProviderField = agentProviderEnvVar
envVarName ModelField = agentModelEnvVar
envVarName EffortField = agentEffortEnvVar
envVarName TraceField = agentTraceEnvVar

defaultKey :: AgentField -> Text
defaultKey ProviderField = agentProviderConfigKey
defaultKey ModelField = agentModelConfigKey
defaultKey EffortField = agentEffortConfigKey
defaultKey TraceField = agentTraceConfigKey

commandKey :: AgentField -> AgentCommandName -> Text
commandKey ProviderField = agentCommandProviderConfigKey
commandKey ModelField = agentCommandModelConfigKey
commandKey EffortField = agentCommandEffortConfigKey
commandKey TraceField = agentCommandTraceConfigKey

-- | The full result of resolving one command's provider and model, with
-- provenance, used by the @seihou agent config@ inspection command.
data ResolvedCommandConfig = ResolvedCommandConfig
  { command :: !AgentCommandName,
    provider :: !(ResolvedAgentField AgentProvider),
    model :: !(ResolvedAgentField (Maybe Text)),
    effort :: !(ResolvedAgentField (Maybe ThinkingLevel)),
    trace :: !(ResolvedAgentField TraceSetting),
    -- | The configured @agent.tracePath@, if any. Carried without provenance:
    -- it is free-form, has no CLI flag and no per-command variant, so there is
    -- no precedence story worth displaying.
    tracePath :: !(Maybe FilePath)
  }
  deriving stock (Eq, Generic, Show)

-- | Flat resolver, preserved for backward compatibility. It never consults the
-- per-command config keys, so a caller with only @agent.provider@/@agent.model@
-- set (or none) gets exactly the historical behavior.
resolveAgentModelConfig :: AgentConfigInputs -> Either Text AgentModelConfig
resolveAgentModelConfig inputs = do
  provider <-
    resolveProvider
      [ candidate (inputs ^. #cliProvider) SourceCliSubcommand,
        candidate (inputs ^. #envProvider) SourceEnv,
        candidate (Map.lookup agentProviderConfigKey (inputs ^. #localConfig)) SourceLocalDefault,
        candidate (Map.lookup agentProviderConfigKey (inputs ^. #globalConfig)) SourceGlobalDefault
      ]
  let modelField =
        applyProviderDefaultModel (provider ^. #value) $
          resolveModel
            [ candidate (inputs ^. #cliModel) SourceCliSubcommand,
              candidate (inputs ^. #envModel) SourceEnv,
              candidate (Map.lookup agentModelConfigKey (inputs ^. #localConfig)) SourceLocalDefault,
              candidate (Map.lookup agentModelConfigKey (inputs ^. #globalConfig)) SourceGlobalDefault
            ]
  pure
    AgentModelConfig
      { provider = provider ^. #value,
        model = modelField ^. #value,
        effort = Nothing,
        trace = TraceOff,
        tracePath = Nothing
      }

-- | Resolve the provider, model, and reasoning effort for a specific command,
-- honoring the full precedence chain including the per-command config tiers, and
-- reporting the source of each value.
--
-- Precedence, highest first: subcommand flag, parent @agent@ flag, environment
-- variable, the artifact's own @launch.<field>@ declaration, local
-- @agent.<command>.<field>@, local @agent.<field>@, global
-- @agent.<command>.<field>@, global @agent.<field>@, built-in default.
--
-- The declaration tier is only populated for commands that load an artifact
-- first; see 'resolvePendingAgentConfig'. For every other caller the
-- @declared*@ inputs are 'Nothing' and this behaves exactly as it did before
-- the tier existed.
resolveAgentModelConfigFor ::
  AgentCommandName ->
  AgentConfigInputs ->
  Either
    Text
    ( ResolvedAgentField AgentProvider,
      ResolvedAgentField (Maybe Text),
      ResolvedAgentField (Maybe ThinkingLevel),
      ResolvedAgentField TraceSetting
    )
resolveAgentModelConfigFor c inputs = do
  provider <-
    (\p -> ResolvedAgentField (p ^. #value) (p ^. #source))
      <$> resolveProvider (providerCandidates c inputs)
  let model = applyProviderDefaultModel (provider ^. #value) (resolveModel (modelCandidates c inputs))
  effort <- resolveEffort (effortCandidates c inputs)
  trace <- resolveTrace (traceCandidates c inputs)
  pure (provider, model, effort, trace)

-- | Resolve the trace file path: local @agent.tracePath@ beats global, and a
-- blank value counts as absent. There is no CLI flag and no per-command
-- variant — 'agentTracePathConfigKey' is free-form, so it stays outside the
-- validated four-value 'TraceSetting' vocabulary.
resolveTracePath :: AgentConfigInputs -> Maybe FilePath
resolveTracePath inputs =
  T.unpack . fst
    <$> firstNonBlankWithSource
      [ candidate (Map.lookup agentTracePathConfigKey (inputs ^. #localConfig)) SourceLocalDefault,
        candidate (Map.lookup agentTracePathConfigKey (inputs ^. #globalConfig)) SourceGlobalDefault
      ]

-- | When no model was configured (source is the built-in default), substitute
-- the provider's deterministic default so the two local CLI providers always
-- resolve to a concrete model instead of 'Nothing'. The source stays
-- 'SourceBuiltinDefault' — the value is a built-in, just a non-empty one.
applyProviderDefaultModel :: AgentProvider -> ResolvedAgentField (Maybe Text) -> ResolvedAgentField (Maybe Text)
applyProviderDefaultModel prov field =
  case field ^. #value of
    Just _ -> field
    Nothing -> case defaultModelForProvider prov of
      Just m -> (field & #value ?~ m)
      Nothing -> field

providerCandidates :: AgentCommandName -> AgentConfigInputs -> [(Maybe Text, AgentConfigSource)]
providerCandidates c inputs =
  [ candidate (inputs ^. #cliProvider) (cliSource (inputs ^. #cliProviderFromSubcommand)),
    candidate (inputs ^. #envProvider) SourceEnv,
    candidate (inputs ^. #declaredProvider) SourceArtifactDeclaration,
    candidate (Map.lookup (agentCommandProviderConfigKey c) (inputs ^. #localConfig)) SourceLocalCommand,
    candidate (Map.lookup agentProviderConfigKey (inputs ^. #localConfig)) SourceLocalDefault,
    candidate (Map.lookup (agentCommandProviderConfigKey c) (inputs ^. #globalConfig)) SourceGlobalCommand,
    candidate (Map.lookup agentProviderConfigKey (inputs ^. #globalConfig)) SourceGlobalDefault
  ]

modelCandidates :: AgentCommandName -> AgentConfigInputs -> [(Maybe Text, AgentConfigSource)]
modelCandidates c inputs =
  [ candidate (inputs ^. #cliModel) (cliSource (inputs ^. #cliModelFromSubcommand)),
    candidate (inputs ^. #envModel) SourceEnv,
    candidate (inputs ^. #declaredModel) SourceArtifactDeclaration,
    candidate (Map.lookup (agentCommandModelConfigKey c) (inputs ^. #localConfig)) SourceLocalCommand,
    candidate (Map.lookup agentModelConfigKey (inputs ^. #localConfig)) SourceLocalDefault,
    candidate (Map.lookup (agentCommandModelConfigKey c) (inputs ^. #globalConfig)) SourceGlobalCommand,
    candidate (Map.lookup agentModelConfigKey (inputs ^. #globalConfig)) SourceGlobalDefault
  ]

effortCandidates :: AgentCommandName -> AgentConfigInputs -> [(Maybe Text, AgentConfigSource)]
effortCandidates c inputs =
  [ candidate (inputs ^. #cliEffort) (cliSource (inputs ^. #cliEffortFromSubcommand)),
    candidate (inputs ^. #envEffort) SourceEnv,
    candidate (inputs ^. #declaredEffort) SourceArtifactDeclaration,
    candidate (Map.lookup (agentCommandEffortConfigKey c) (inputs ^. #localConfig)) SourceLocalCommand,
    candidate (Map.lookup agentEffortConfigKey (inputs ^. #localConfig)) SourceLocalDefault,
    candidate (Map.lookup (agentCommandEffortConfigKey c) (inputs ^. #globalConfig)) SourceGlobalCommand,
    candidate (Map.lookup agentEffortConfigKey (inputs ^. #globalConfig)) SourceGlobalDefault
  ]

traceCandidates :: AgentCommandName -> AgentConfigInputs -> [(Maybe Text, AgentConfigSource)]
traceCandidates c inputs =
  [ candidate (inputs ^. #cliTrace) (cliSource (inputs ^. #cliTraceFromSubcommand)),
    candidate (inputs ^. #envTrace) SourceEnv,
    candidate (inputs ^. #declaredTrace) SourceArtifactDeclaration,
    candidate (Map.lookup (agentCommandTraceConfigKey c) (inputs ^. #localConfig)) SourceLocalCommand,
    candidate (Map.lookup agentTraceConfigKey (inputs ^. #localConfig)) SourceLocalDefault,
    candidate (Map.lookup (agentCommandTraceConfigKey c) (inputs ^. #globalConfig)) SourceGlobalCommand,
    candidate (Map.lookup agentTraceConfigKey (inputs ^. #globalConfig)) SourceGlobalDefault
  ]

cliSource :: Bool -> AgentConfigSource
cliSource True = SourceCliSubcommand
cliSource False = SourceCliParent

-- | Resolve a provider from an ordered candidate list, parsing the winning text
-- and falling back to the built-in default provider when nothing is set.
resolveProvider :: [(Maybe Text, AgentConfigSource)] -> Either Text (ResolvedAgentField AgentProvider)
resolveProvider candidates =
  case firstNonBlankWithSource candidates of
    Just (txt, src) -> (\p -> ResolvedAgentField p src) <$> providerFromText txt
    Nothing -> Right (ResolvedAgentField (defaultAgentModelConfig ^. #provider) SourceBuiltinDefault)

-- | Resolve a model from an ordered candidate list. An unset model resolves to
-- 'Nothing' with source 'SourceBuiltinDefault', letting the provider pick.
resolveModel :: [(Maybe Text, AgentConfigSource)] -> ResolvedAgentField (Maybe Text)
resolveModel candidates =
  case firstNonBlankWithSource candidates of
    Just (txt, src) -> ResolvedAgentField (Just txt) src
    Nothing -> ResolvedAgentField Nothing SourceBuiltinDefault

-- | Resolve a reasoning effort from an ordered candidate list. The winning text
-- is parsed with 'effortFromText'; a parse failure returns 'Left'. An unset
-- effort resolves to 'Nothing' with source 'SourceBuiltinDefault', which leaves
-- the provider/CLI default untouched.
resolveEffort :: [(Maybe Text, AgentConfigSource)] -> Either Text (ResolvedAgentField (Maybe ThinkingLevel))
resolveEffort candidates =
  case firstNonBlankWithSource candidates of
    Just (txt, src) -> (\lvl -> ResolvedAgentField (Just lvl) src) <$> effortFromText txt
    Nothing -> Right (ResolvedAgentField Nothing SourceBuiltinDefault)

-- | Resolve a trace destination from an ordered candidate list. Unlike the
-- model and effort resolvers there is no \"unset\" state: an unconfigured trace
-- resolves to 'TraceOff' with source 'SourceBuiltinDefault', which emits
-- nothing.
resolveTrace :: [(Maybe Text, AgentConfigSource)] -> Either Text (ResolvedAgentField TraceSetting)
resolveTrace candidates =
  case firstNonBlankWithSource candidates of
    Just (txt, src) -> (\t -> ResolvedAgentField t src) <$> traceFromText txt
    Nothing -> Right (ResolvedAgentField TraceOff SourceBuiltinDefault)

candidate :: Maybe Text -> AgentConfigSource -> (Maybe Text, AgentConfigSource)
candidate value src = (value, src)

-- | The leftmost candidate whose value is present and non-blank (whitespace is
-- stripped, and @""@ counts as absent), together with its source.
firstNonBlankWithSource :: [(Maybe Text, AgentConfigSource)] -> Maybe (Text, AgentConfigSource)
firstNonBlankWithSource =
  foldr step Nothing
  where
    step (value, src) acc =
      case T.strip <$> value of
        Just "" -> acc
        Just stripped -> Just (stripped, src)
        Nothing -> acc

-- | Read the two environment variables and the local + global config, then run
-- the flat resolver. Preserved for backward compatibility.
loadAgentModelConfig :: Maybe Text -> Maybe Text -> IO (Either Text AgentModelConfig)
loadAgentModelConfig cliProvider cliModel = do
  inputsOrErr <-
    gatherAgentConfigInputs
      noAgentSettingFlags
      ( noAgentSettingFlags
          & #provider
          .~ cliProvider
          & #model
          .~ cliModel
      )
  pure (inputsOrErr >>= resolveAgentModelConfig)

-- | Read the environment and config, then resolve provider/model/effort for a
-- specific command, projecting away the provenance the command handler does not
-- need.
loadAgentModelConfigFor ::
  AgentCommandName ->
  -- | flags on the parent @seihou agent@ command
  AgentSettingFlags ->
  -- | flags on the subcommand itself
  AgentSettingFlags ->
  IO (Either Text AgentModelConfig)
loadAgentModelConfigFor c parentFlags commandFlags = do
  inputsOrErr <- gatherAgentConfigInputs parentFlags commandFlags
  pure $ do
    inputs <- inputsOrErr
    (provider, model, effort, trace) <- resolveAgentModelConfigFor c inputs
    pure
      AgentModelConfig
        { provider = provider ^. #value,
          model = model ^. #value,
          effort = effort ^. #value,
          trace = trace ^. #value,
          tracePath = resolveTracePath inputs
        }

-- | Resolve every configurable command from the real environment and config,
-- with no CLI flags, for the @seihou agent config@ inspection view.
loadResolvedAgentConfig :: IO (Either Text [ResolvedCommandConfig])
loadResolvedAgentConfig = do
  inputsOrErr <- gatherAgentConfigInputs noAgentSettingFlags noAgentSettingFlags
  pure $ do
    inputs <- inputsOrErr
    traverse (resolveOne inputs) allAgentCommands
  where
    resolveOne inputs c = do
      (provider, model, effort, trace) <- resolveAgentModelConfigFor c inputs
      pure
        ResolvedCommandConfig
          { command = c,
            provider = provider,
            model = model,
            effort = effort,
            trace = trace,
            tracePath = resolveTracePath inputs
          }

-- | Shared IO: read @SEIHOU_AGENT_*@ and the local + global config maps into an
-- 'AgentConfigInputs'. Any config read error surfaces as 'Left'.
gatherAgentConfigInputs ::
  -- | flags on the parent @seihou agent@ command
  AgentSettingFlags ->
  -- | flags on the subcommand itself
  AgentSettingFlags ->
  IO (Either Text AgentConfigInputs)
gatherAgentConfigInputs parentFlags commandFlags = do
  envProvider <- fmap T.pack <$> lookupEnv agentProviderEnvVar
  envModel <- fmap T.pack <$> lookupEnv agentModelEnvVar
  envEffort <- fmap T.pack <$> lookupEnv agentEffortEnvVar
  envTrace <- fmap T.pack <$> lookupEnv agentTraceEnvVar
  (localResult, globalResult) <- runEff $ runConfigReader $ do
    local <- readLocalConfig
    global <- readGlobalConfig
    pure (local, global)
  pure $ do
    local <- first formatConfigError localResult
    global <- first formatConfigError globalResult
    pure $
      applyAgentSettingFlags
        parentFlags
        commandFlags
        ( baseAgentConfigInputs
            & #envProvider
            .~ envProvider
            & #envModel
            .~ envModel
            & #envEffort
            .~ envEffort
            & #envTrace
            .~ envTrace
            & #localConfig
            .~ local
            & #globalConfig
            .~ global
        )

-- | The three launch fields the resolver understands, projected out of an
-- artifact's @launch@ record. @mode@ is deliberately absent: it is reserved
-- and no part of the resolution path.
data AgentLaunchDeclaration = AgentLaunchDeclaration
  { provider :: !(Maybe Text),
    model :: !(Maybe Text),
    effort :: !(Maybe Text)
  }
  deriving stock (Eq, Generic, Show)

-- | A declaration that states nothing, leaving every field to the user's
-- flags, environment, and config.
noAgentLaunchDeclaration :: AgentLaunchDeclaration
noAgentLaunchDeclaration =
  AgentLaunchDeclaration
    { provider = Nothing,
      model = Nothing,
      effort = Nothing
    }

-- | Project a decoded artifact's launch record into the resolver's declaration
-- tier. An artifact with no @launch@ record declares nothing.
agentLaunchDeclaration :: Maybe AgentLaunch -> AgentLaunchDeclaration
agentLaunchDeclaration Nothing = noAgentLaunchDeclaration
agentLaunchDeclaration (Just l) =
  AgentLaunchDeclaration
    { provider = l ^. #provider,
      model = l ^. #model,
      effort = l ^. #effort
    }

-- | Parse-check a declared launch record, returning one message per invalid
-- value. An empty list means the declaration is usable.
--
-- The model is not checked: it is free-form by design, since providers accept
-- aliases and custom model IDs.
validateAgentLaunchDeclaration :: AgentLaunchDeclaration -> [Text]
validateAgentLaunchDeclaration decl =
  check "launch.provider" providerFromText (decl ^. #provider)
    <> check "launch.effort" effortFromText (decl ^. #effort)
  where
    check :: Text -> (Text -> Either Text a) -> Maybe Text -> [Text]
    check key parse value =
      [ key <> ": " <> err
      | Just raw <- [value],
        not (T.null (T.strip raw)),
        Left err <- [parse (T.strip raw)]
      ]

-- | Everything needed to finish resolution later: the command identity plus
-- the flags, environment, and config already gathered. A handler holds one of
-- these while it discovers and decodes its artifact, then finishes with
-- 'resolvePendingAgentConfig'.
--
-- This two-phase shape exists because the artifact's declaration is only known
-- after the handler loads it, but resolution must still be a single pass over
-- one ordered precedence list.
data PendingAgentConfig = PendingAgentConfig
  { command :: !AgentCommandName,
    inputs :: !AgentConfigInputs
  }
  deriving stock (Eq, Generic, Show)

-- | Read the environment and config for a command whose artifact may declare
-- its own launch settings, stopping short of resolution.
loadPendingAgentConfig ::
  AgentCommandName ->
  -- | flags on the parent @seihou agent@ command
  AgentSettingFlags ->
  -- | flags on the subcommand itself
  AgentSettingFlags ->
  IO (Either Text PendingAgentConfig)
loadPendingAgentConfig c parentFlags commandFlags = do
  inputsOrErr <- gatherAgentConfigInputs parentFlags commandFlags
  pure (PendingAgentConfig c <$> inputsOrErr)

-- | Finish resolution by folding the artifact's declaration into the gathered
-- inputs and running the ordinary precedence chain.
resolvePendingAgentConfig ::
  PendingAgentConfig ->
  AgentLaunchDeclaration ->
  Either Text ResolvedCommandConfig
resolvePendingAgentConfig pending decl = do
  let inputs =
        ( (pending ^. #inputs)
            & #declaredProvider
            .~ decl
            ^. #provider
            & #declaredModel
            .~ decl
            ^. #model
            & #declaredEffort
            .~ decl
            ^. #effort
        )
  (provider, model, effort, trace) <- resolveAgentModelConfigFor (pending ^. #command) inputs
  pure
    ResolvedCommandConfig
      { command = pending ^. #command,
        provider = provider,
        model = model,
        effort = effort,
        trace = trace,
        tracePath = resolveTracePath inputs
      }

-- | Project a resolved command config down to what the launch layer needs,
-- discarding provenance.
resolvedAgentModelConfig :: ResolvedCommandConfig -> AgentModelConfig
resolvedAgentModelConfig rcc =
  AgentModelConfig
    { provider = rcc ^. #provider . #value,
      model = rcc ^. #model . #value,
      effort = rcc ^. #effort . #value,
      trace = rcc ^. #trace . #value,
      tracePath = rcc ^. #tracePath
    }

-- | A one-line provenance summary for a verbose log line, e.g.
--
-- > provider claude-cli [built-in default], model claude-sonnet-5 [blueprint: launch.model], effort max [blueprint: launch.effort]
formatResolvedAgentProvenance :: ResolvedCommandConfig -> Text
formatResolvedAgentProvenance rcc =
  T.intercalate
    ", "
    [ part "provider" (providerToText (rcc ^. #provider . #value)) ProviderField (rcc ^. #provider . #source),
      part "model" (fromMaybe "<provider default>" (rcc ^. #model . #value)) ModelField (rcc ^. #model . #source),
      part "effort" (maybe "<unset>" effortToText (rcc ^. #effort . #value)) EffortField (rcc ^. #effort . #source),
      part "trace" (traceToText (rcc ^. #trace . #value)) TraceField (rcc ^. #trace . #source)
    ]
  where
    part label value field src =
      label <> " " <> value <> " [" <> agentConfigSourceLabel (rcc ^. #command) field src <> "]"

-- | Finish resolution with the artifact's declaration, logging the resolved
-- provenance at verbose level and exiting with an actionable message when the
-- artifact declares an unusable value.
--
-- The label names the artifact in the error message, e.g.
-- @"blueprint 'payments-service'"@.
resolveDeclaredAgentConfig ::
  LogLevel ->
  Text ->
  PendingAgentConfig ->
  AgentLaunchDeclaration ->
  IO AgentModelConfig
resolveDeclaredAgentConfig level label pending decl =
  case resolvePendingAgentConfig pending decl of
    Left err -> do
      logIO level (logError $ "Invalid agent settings for " <> label <> ": " <> err)
      exitFailure
    Right resolved -> do
      logIO level (logInfo $ "Agent: " <> formatResolvedAgentProvenance resolved)
      pure (resolvedAgentModelConfig resolved)