packages feed

okf-cli-0.6.0.0: src/Okf/Cli/Agent/Config.hs

-- | Deciding which agent setting wins, and remembering why.
--
-- Each configurable field resolves through one ordered list of candidate
-- sources; the first candidate holding a present, non-blank value wins and
-- carries its label with it. Two independent axes generate the middle of that
-- list. /Scope/ distinguishes the project-local configuration file from the
-- user's global one. /Specificity/ distinguishes a per-command key
-- (@agent.assist.model@) from a shared-default key (@agent.model@). The rule
-- connecting them is that __scope dominates across scopes, and specificity
-- dominates within a scope__: any local value beats any global value, and
-- within one file the per-command key beats the shared default. The opposite
-- reading — a global per-command key beating a local shared default — is
-- equally coherent, which is why the rule is written down here and printed by
-- @okf config agent@.
--
-- Resolution returns provenance alongside every value, because that is what
-- lets @okf config agent@ answer "why this model?" in one line instead of
-- making a user simulate these rules in their head.
module Okf.Cli.Agent.Config
  ( AgentCommandName (..),
    agentCommandSegment,
    allAgentCommands,
    AgentField (..),
    agentFieldSegment,
    agentDefaultKey,
    agentCommandKey,
    agentFieldFlag,
    agentFieldEnvVar,
    AgentConfigSource (..),
    agentSourceLabel,
    ResolvedField (..),
    AgentOverrides (..),
    noAgentOverrides,
    ResolvedAgent (..),
    resolveAgent,
    renderAgentResolution,
    parseOkfProvider,
    parseOkfEffort,
    thinkingLevelOf,
  )
where

import Baikai.ThinkingLevel (ThinkingLevel (..))
import Data.Maybe (listToMaybe)
import Data.Text qualified as Text
import Okf.Cli.Config
  ( AgentFieldSettings (..),
    AgentSettings (..),
    OkfEffort (..),
    OkfProvider (..),
    agentSharedDefaults,
    renderOkfEffort,
    renderOkfProvider,
  )
import Okf.Prelude

-- | The commands okf can launch a model from. @okf assist@ is the only one
-- today; the type exists so that adding a second is one constructor rather than
-- a redesign, and so the exhaustiveness checker makes sure the key builders and
-- the inspection table are extended with it.
data AgentCommandName = AgentCmdAssist
  deriving stock (Eq, Show, Enum, Bounded)

agentCommandSegment :: AgentCommandName -> Text
agentCommandSegment AgentCmdAssist = "assist"

allAgentCommands :: [AgentCommandName]
allAgentCommands = [minBound .. maxBound]

-- | The settings an agent-launching command carries.
data AgentField
  = ProviderField
  | ModelField
  | EffortField
  | SystemPromptField
  deriving stock (Eq, Show, Enum, Bounded)

-- | The field's name as a configuration key segment.
agentFieldSegment :: AgentField -> Text
agentFieldSegment = \case
  ProviderField -> "provider"
  ModelField -> "model"
  EffortField -> "effort"
  SystemPromptField -> "systemPrompt"

-- | The shared-default key, e.g. @agent.model@.
agentDefaultKey :: AgentField -> Text
agentDefaultKey agentField = "agent." <> agentFieldSegment agentField

-- | The per-command key, e.g. @agent.assist.model@.
agentCommandKey :: AgentCommandName -> AgentField -> Text
agentCommandKey command agentField =
  "agent." <> agentCommandSegment command <> "." <> agentFieldSegment agentField

-- | The long flag name, without leading dashes. Note this is not the key
-- segment: @systemPrompt@ is spelled @--system-prompt@ on the command line.
agentFieldFlag :: AgentField -> Text
agentFieldFlag = \case
  ProviderField -> "provider"
  ModelField -> "model"
  EffortField -> "effort"
  SystemPromptField -> "system-prompt"

-- | The environment variable, which is deliberately cross-command: an
-- environment variable is already a coarse, session-wide override, and a
-- per-command variable for every field would multiply the surface area for a
-- case the per-command configuration keys already serve better.
agentFieldEnvVar :: AgentField -> String
agentFieldEnvVar = \case
  ProviderField -> "OKF_AGENT_PROVIDER"
  ModelField -> "OKF_AGENT_MODEL"
  EffortField -> "OKF_AGENT_EFFORT"
  SystemPromptField -> "OKF_AGENT_SYSTEM_PROMPT"

-- | Where a resolved value came from, highest precedence first.
--
-- There is no "flag on the parent command" tier: @assist@ is a top-level
-- command with no parent that carries agent flags. If an @okf agent …@ group is
-- ever introduced, add a @SourceCliParent@ constructor between 'SourceCliFlag'
-- and 'SourceEnvVar'.
data AgentConfigSource
  = SourceCliFlag
  | SourceEnvVar
  | SourceLocalCommand
  | SourceLocalDefault
  | SourceGlobalCommand
  | SourceGlobalDefault
  | SourceBuiltinDefault
  deriving stock (Eq, Show)

-- | The label @okf config agent@ prints for a source.
agentSourceLabel :: AgentCommandName -> AgentField -> AgentConfigSource -> Text
agentSourceLabel command agentField = \case
  SourceCliFlag -> "--" <> agentFieldFlag agentField <> " flag"
  SourceEnvVar -> "env: " <> Text.pack (agentFieldEnvVar agentField)
  SourceLocalCommand -> "local: " <> agentCommandKey command agentField
  SourceLocalDefault -> "local: " <> agentDefaultKey agentField
  SourceGlobalCommand -> "global: " <> agentCommandKey command agentField
  SourceGlobalDefault -> "global: " <> agentDefaultKey agentField
  SourceBuiltinDefault -> "built-in default"

data ResolvedField a = ResolvedField
  { resolvedValue :: a,
    resolvedSource :: AgentConfigSource
  }
  deriving stock (Eq, Show)

-- | Values supplied by a layer above the configuration files. The caller has
-- already parsed and validated the text, so the resolver never sees a raw
-- string it might have to reject.
data AgentOverrides = AgentOverrides
  { provider :: !(Maybe OkfProvider),
    model :: !(Maybe Text),
    effort :: !(Maybe OkfEffort),
    systemPrompt :: !(Maybe Text)
  }
  deriving stock (Eq, Show)

noAgentOverrides :: AgentOverrides
noAgentOverrides =
  AgentOverrides
    { provider = Nothing,
      model = Nothing,
      effort = Nothing,
      systemPrompt = Nothing
    }

-- | What okf will actually use, and where each value came from.
--
-- 'provider' has a value in every case because a launcher has to pick one, so
-- it is a bare 'ResolvedField'. The other three are 'Maybe', where 'Nothing'
-- means unset and no source claimed it — which renders no flag at all, so an
-- unconfigured okf produces the command line it has always produced.
data ResolvedAgent = ResolvedAgent
  { provider :: !(ResolvedField OkfProvider),
    model :: !(Maybe (ResolvedField Text)),
    effort :: !(Maybe (ResolvedField OkfEffort)),
    systemPrompt :: !(Maybe (ResolvedField Text))
  }
  deriving stock (Eq, Show)

resolveAgent ::
  AgentCommandName ->
  -- | From command-line flags.
  AgentOverrides ->
  -- | From environment variables, already parsed.
  AgentOverrides ->
  -- | Local scope.
  Maybe AgentSettings ->
  -- | Global scope.
  Maybe AgentSettings ->
  ResolvedAgent
resolveAgent command flags env local global =
  ResolvedAgent
    { provider =
        fromMaybe (ResolvedField ProviderClaude SourceBuiltinDefault) $
          resolveField overrideProvider fieldProvider id,
      model = resolveField overrideModel fieldModel (>>= nonBlank),
      effort = resolveField overrideEffort fieldEffort id,
      systemPrompt = resolveField overrideSystemPrompt fieldSystemPrompt (>>= nonBlank)
    }
  where
    resolveField ::
      (AgentOverrides -> Maybe a) ->
      (AgentFieldSettings -> Maybe a) ->
      (Maybe a -> Maybe a) ->
      Maybe (ResolvedField a)
    resolveField fromOverrides fromBlock normalize =
      firstCandidate
        [ (normalize (fromOverrides flags), SourceCliFlag),
          (normalize (fromOverrides env), SourceEnvVar),
          (normalize (fromBlock . commandBlock command =<< local), SourceLocalCommand),
          (normalize (fromBlock . agentSharedDefaults =<< local), SourceLocalDefault),
          (normalize (fromBlock . commandBlock command =<< global), SourceGlobalCommand),
          (normalize (fromBlock . agentSharedDefaults =<< global), SourceGlobalDefault)
        ]

-- | The table behind @okf config agent@: one block per command, one row per
-- field, each row carrying the value okf resolved and the key or flag that
-- supplied it.
--
-- The precedence legend is printed unconditionally. The whole point of the
-- command is that the rules live next to the output that obeys them.
renderAgentResolution :: [(AgentCommandName, ResolvedAgent)] -> Text
renderAgentResolution entries =
  Text.unlines (concatMap renderBlock blocks <> [""] <> precedenceLegend)
  where
    blocks = [(command, agentResolutionRows command resolved) | (command, resolved) <- entries]
    allRows = concatMap snd blocks
    commandWidth = widest (map (agentCommandSegment . fst) blocks)
    fieldWidth = widest [name | (name, _, _) <- allRows]
    valueWidth = widest [value | (_, value, _) <- allRows]
    widest = foldr (max . Text.length) 0

    renderBlock (command, rows) =
      [ "  "
          <> pad commandWidth (if rowIndex == (0 :: Int) then agentCommandSegment command else "")
          <> "  "
          <> pad fieldWidth name
          <> "  "
          <> pad valueWidth value
          <> "  ["
          <> source
          <> "]"
      | (rowIndex, (name, value, source)) <- zip [0 ..] rows
      ]

    pad width text = text <> Text.replicate (max 0 (width - Text.length text)) " "

-- | One @(field, value, source label)@ row per configurable field.
--
-- A field no source claimed reads @(unset)@ and is attributed to the built-in
-- default, because that is what okf will use. Values are flattened to one line
-- so the table stays a table; a multi-line system prompt is shown with its line
-- breaks collapsed to spaces.
agentResolutionRows :: AgentCommandName -> ResolvedAgent -> [(Text, Text, Text)]
agentResolutionRows command ResolvedAgent {provider, model, effort, systemPrompt} =
  [ describe ProviderField (Just (rendered renderOkfProvider provider)),
    describe ModelField (rendered id <$> model),
    describe EffortField (rendered renderOkfEffort <$> effort),
    describe SystemPromptField (rendered id <$> systemPrompt)
  ]
  where
    rendered render ResolvedField {resolvedValue, resolvedSource} =
      ResolvedField {resolvedValue = oneLine (render resolvedValue), resolvedSource}

    describe agentField = \case
      Nothing ->
        ( agentFieldSegment agentField,
          "(unset)",
          agentSourceLabel command agentField SourceBuiltinDefault
        )
      Just ResolvedField {resolvedValue, resolvedSource} ->
        ( agentFieldSegment agentField,
          resolvedValue,
          agentSourceLabel command agentField resolvedSource
        )

    oneLine = Text.unwords . Text.words

precedenceLegend :: [Text]
precedenceLegend =
  [ "Precedence, highest first:",
    "  1. --provider / --model / --effort / --system-prompt flag on the subcommand",
    "  2. OKF_AGENT_PROVIDER / OKF_AGENT_MODEL / OKF_AGENT_EFFORT / OKF_AGENT_SYSTEM_PROMPT",
    "  3. local scope   agent.<command>.<field>",
    "  4. local scope   agent.<field>",
    "  5. global scope  agent.<command>.<field>",
    "  6. global scope  agent.<field>",
    "  7. built-in default"
  ]

-- | Walk the candidates and keep the first that holds a value, with its label.
firstCandidate :: [(Maybe a, AgentConfigSource)] -> Maybe (ResolvedField a)
firstCandidate candidates =
  listToMaybe [ResolvedField value source | (Just value, source) <- candidates]

-- | A key set to @"  "@ names no model, so treat it as absent rather than as a
-- model literally named two spaces. Only the text-valued fields need this;
-- 'OkfProvider' and 'OkfEffort' are closed enumerations by the time they reach
-- the resolver.
nonBlank :: Text -> Maybe Text
nonBlank raw =
  let stripped = Text.strip raw
   in if Text.null stripped then Nothing else Just stripped

commandBlock :: AgentCommandName -> AgentSettings -> AgentFieldSettings
commandBlock AgentCmdAssist AgentSettings {assist} = assist

fieldProvider :: AgentFieldSettings -> Maybe OkfProvider
fieldProvider AgentFieldSettings {provider} = provider

fieldModel :: AgentFieldSettings -> Maybe Text
fieldModel AgentFieldSettings {model} = model

fieldEffort :: AgentFieldSettings -> Maybe OkfEffort
fieldEffort AgentFieldSettings {effort} = effort

fieldSystemPrompt :: AgentFieldSettings -> Maybe Text
fieldSystemPrompt AgentFieldSettings {systemPrompt} = systemPrompt

overrideProvider :: AgentOverrides -> Maybe OkfProvider
overrideProvider AgentOverrides {provider} = provider

overrideModel :: AgentOverrides -> Maybe Text
overrideModel AgentOverrides {model} = model

overrideEffort :: AgentOverrides -> Maybe OkfEffort
overrideEffort AgentOverrides {effort} = effort

overrideSystemPrompt :: AgentOverrides -> Maybe Text
overrideSystemPrompt AgentOverrides {systemPrompt} = systemPrompt

-- | Parse a provider name, case-insensitively. The message names every value
-- okf accepts, because a user who spelled one wrong needs the list, not a
-- restatement of what they typed.
parseOkfProvider :: Text -> Either Text OkfProvider
parseOkfProvider raw = case Text.toLower (Text.strip raw) of
  "claude" -> Right ProviderClaude
  "codex" -> Right ProviderCodex
  _ ->
    Left
      ( "unknown provider "
          <> Text.pack (show raw)
          <> "; expected one of: claude, codex"
      )

parseOkfEffort :: Text -> Either Text OkfEffort
parseOkfEffort raw =
  case lookup (Text.toLower (Text.strip raw)) table of
    Just level -> Right level
    Nothing ->
      Left
        ( "unknown effort "
            <> Text.pack (show raw)
            <> "; expected one of: "
            <> Text.intercalate ", " (map fst table)
        )
  where
    table = [(renderOkfEffort level, level) | level <- [minBound .. maxBound]]

-- | The one place okf's configuration vocabulary meets Baikai's. It lives here
-- rather than in "Okf.Cli.Config" so configuration decoding stays free of
-- Baikai types, and a malformed level fails during Dhall decoding with a typed
-- error naming the allowed constructors.
thinkingLevelOf :: OkfEffort -> ThinkingLevel
thinkingLevelOf = \case
  EffortMinimal -> ThinkingMinimal
  EffortLow -> ThinkingLow
  EffortMedium -> ThinkingMedium
  EffortHigh -> ThinkingHigh
  EffortXHigh -> ThinkingXHigh
  EffortMax -> ThinkingMax