packages feed

shikumi-tools-0.4.0.0: src/Shikumi/CodeExec/RLM.hs

{-# LANGUAGE ScopedTypeVariables #-}

-- | Experimental bounded recursive language-model sessions. Documents stay out
-- of the prompt until explicitly inspected; subqueries have depth one.
module Shikumi.CodeExec.RLM
  ( RLMConfig (..),
    defaultRLMConfig,
    RLMOutcome (..),
    RLMReport (..),
    RLMAudit (..),
    rlm,
    rlmWithReport,
  )
where

import Baikai (emptyModel)
import Data.Aeson (Value (..), object, (.=))
import Data.Proxy (Proxy (..))
import Data.Text (Text)
import Data.Text qualified as T
import Effectful (Eff, (:>))
import Effectful.Error.Static (Error, throwError)
import Shikumi.Adapter (ToPrompt (toPrompt), responseText)
import Shikumi.CodeExec.Prompt (encodeText, simpleContext)
import Shikumi.CodeExec.Session
import Shikumi.Error (ShikumiError (..))
import Shikumi.LLM (LLM, complete)
import Shikumi.Program (Program, embed)
import Shikumi.Schema (FromModel, ToSchema, Validatable, parseOutput, toSchema)
import Shikumi.Signature (Signature, getInstruction)

data RLMConfig = RLMConfig
  { session :: !SessionConfig,
    maxOuterIterations :: !Int,
    -- | Sum of system and user text characters, checked before every call.
    maxRequestChars :: !Int
  }

defaultRLMConfig :: RLMConfig
defaultRLMConfig = RLMConfig defaultSessionConfig 40 64000

data RLMOutcome o = RLMSubmitted o | RLMExhausted SessionLimit
  deriving stock (Eq, Show)

data RLMAudit = RLMAudit
  { actionText :: !Text,
    actionTruncated :: !Bool,
    auditObservation :: !(Maybe SessionObservation)
  }
  deriving stock (Eq, Show)

data RLMReport = RLMReport
  { outerIterations :: !Int,
    operations :: !Int,
    subqueriesAttempted :: !Int,
    totalObservedChars :: !Int,
    audit :: ![RLMAudit],
    -- | Nothing means typed submission; Just identifies exhaustion.
    termination :: !(Maybe SessionLimit)
  }
  deriving stock (Eq, Show)

rlm :: (ToPrompt i, ToSchema o, FromModel o, Validatable o) => RLMConfig -> ContextStore -> Signature i o -> Program i o
rlm cfg docs sig = embed $ \i -> do
  (out, _) <- runRLM cfg docs sig i
  case out of
    RLMSubmitted o -> pure o
    RLMExhausted limit -> throwError (BudgetExceeded ("RLM session exhausted: " <> T.pack (show limit)))

rlmWithReport :: (ToPrompt i, ToSchema o, FromModel o, Validatable o) => RLMConfig -> ContextStore -> Signature i o -> Program i (RLMOutcome o, RLMReport)
rlmWithReport cfg docs sig = embed (runRLM cfg docs sig)

runRLM :: forall i o es. (LLM :> es, Error ShikumiError :> es, ToPrompt i, ToSchema o, FromModel o, Validatable o) => RLMConfig -> ContextStore -> Signature i o -> i -> Eff es (RLMOutcome o, RLMReport)
runRLM cfg docs sig input = do
  if maxOuterIterations cfg <= 0 || maxRequestChars cfg <= 0
    then throwError (ValidationFailure "RLM limits must be positive")
    else pure ()
  s <- either (\(SessionError e) -> throwError (ValidationFailure e)) pure (newSession sc docs)
  loop 0 s [] ""
  where
    sc = session cfg
    sys = getInstruction sig <> "\n" <> guide <> "\nSubmit value must match: " <> encodeText (toSchema (Proxy @o))
    initial = "Question:\n" <> toPrompt input <> "\nContext metadata:\n" <> encodeText (contextMetadata docs) <> "\nLimits:\n" <> limits
    limits =
      encodeText $
        object
          [ "outerIterations" .= maxOuterIterations cfg,
            "requestChars" .= maxRequestChars cfg,
            "contextChars" .= maxContextChars sc,
            "storedChars" .= maxStoredChars sc,
            "actionBytes" .= maxActionBytes sc,
            "observationChars" .= maxObservationChars sc,
            "observedChars" .= maxObservedChars sc,
            "operations" .= maxOperations sc,
            "scanChars" .= maxScanChars sc,
            "matches" .= maxMatches sc,
            "subqueries" .= maxSubqueries sc,
            "subqueryChars" .= maxSubqueryChars sc
          ]
    report n s rows term = RLMReport n (operationCount s) (subqueryAttempts s) (observedChars s) (reverse rows) term
    exhausted limit n s rows = pure (RLMExhausted limit, report n s rows (Just limit))
    fits system prompt = toInteger (T.length system) + toInteger (T.length prompt) <= toInteger (maxRequestChars cfg)
    loop n s rows history
      | n >= maxOuterIterations cfg = exhausted OuterIterations n s rows
      | operationCount s >= maxOperations sc = exhausted Operations n s rows
      | not (fits sys (initial <> history)) = exhausted RequestCharacters n s rows
      | otherwise = do
          let (ctx, opts) = simpleContext sys (initial <> history)
          raw <- responseText <$> complete emptyModel ctx opts
          let parsed = parseSessionAction sc raw
              clipped = case parsed of
                Right _ -> raw
                Left _ -> T.take (maxActionBytes sc `div` 4) raw
              row = RLMAudit clipped (clipped /= raw) Nothing
              (s1, result) = stepSession s parsed
              rows1 = row : rows
              history1 = history <> "\nAction: " <> clipped
              n1 = n + 1
          case result of
            Left limit -> exhausted limit n1 s1 rows1
            Right (Observed obs) -> continue n1 s1 rows1 history1 obs
            Right (Submitted value) -> case parseOutput (encodeText value) of
              Right o -> pure (RLMSubmitted o, report n1 s1 rows1 Nothing)
              Left _ -> continue n1 s1 rows1 history1 (SessionObservation Null (Just (SessionError "submission failed output schema or validation; correct value")) Nothing)
            Right (RunQueries prompts)
              | any (not . fits subSystem) prompts -> exhausted RequestCharacters n1 s1 rows1
              | otherwise -> queryAll n1 s1 rows1 history1 prompts
    continue n s rows history obs =
      case retain s rows history obs of
        (s1, rows1, _, Left limit) -> exhausted limit n s1 rows1
        (s1, rows1, history1, Right ()) -> loop n s1 rows1 history1
    retain s rows history obs =
      let (s1, result) = recordObservation s obs
       in case result of
            Left limit -> (s1, rows, history, Left limit)
            Right bounded ->
              let row = RLMAudit "" False (Just bounded)
               in (s1, row : rows, history <> "\nObservation: " <> renderObservation bounded, Right ())
    queryAll n s rows history [] = loop n s rows history
    queryAll n s rows history (p : ps) = do
      let s1 = attemptedSubquery s
          (ctx, opts) = simpleContext subSystem p
      -- Infrastructure errors propagate unchanged. Reserved slots are never
      -- refunded and no further batch member is dispatched after a failure.
      answer <- responseText <$> complete (subModel sc) ctx opts
      let obs = SessionObservation (String answer) Nothing Nothing
      case retain s1 rows history obs of
        (s2, rows2, _, Left limit) -> exhausted limit n s2 rows2
        (s2, rows2, history2, Right ()) -> queryAll n s2 rows2 history2 ps

subSystem :: Text
subSystem = "Answer the supplied question using only its supplied excerpts. You cannot invoke session operations."

guide :: Text
guide =
  T.unlines
    [ "Experimental document session. Return exactly one JSON action per turn, no fences.",
      "Actions: {op:describe,name}; {op:slice,name,start,count}; {op:find,name,needle,start,limit};",
      "{op:store,name,value}; {op:load,name}; {op:query,prompt}; {op:query_batch,prompts:[string]}; {op:submit,value}.",
      "Use quoted JSON keys and strings. Offsets/counts are Unicode characters, zero-based, half-open.",
      "Find is literal, returns overlapping match offsets and nextOffset for bounded continuation.",
      "Documents are immutable; variables persist privately. Names use ASCII letters, digits, underscore or hyphen, at most 128 characters; operation names are reserved.",
      "Subqueries run sequentially at depth one; their observations follow prompt order. Include excerpts explicitly.",
      "Observations may show marked prefixes. For slices, use nextOffset to continue. Generic truncated values are JSON-text prefixes.",
      "Errors consume operations. Correct invalid actions/submissions within the remaining limits. Submit performs no extra model call."
    ]