shikumi-tools-0.4.0.0: src/Shikumi/CodeExec/Session.hs
-- | Experimental, invocation-local data operations. No host execution capability.
module Shikumi.CodeExec.Session
( ContextStore,
contextStore,
contextMetadata,
SessionConfig (..),
defaultSessionConfig,
validateSessionConfig,
SessionState,
newSession,
operationCount,
subqueryAttempts,
observedChars,
SessionAction (..),
parseSessionAction,
SessionError (..),
SessionLimit (..),
SessionResult (..),
SessionObservation (..),
Truncation (..),
renderObservation,
stepSession,
recordObservation,
attemptedSubquery,
)
where
import Baikai (Model, emptyModel)
import Data.Aeson (FromJSON (..), ToJSON (..), Value (..), eitherDecodeStrict, object, withObject, (.:), (.=))
import Data.ByteString qualified as BS
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as M
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding (encodeUtf8)
import Shikumi.CodeExec.Prompt (encodeText)
newtype ContextStore = ContextStore (Map Text Text)
-- | Names and contents are immutable. Total size is checked by 'newSession'.
contextStore :: [(Text, Text)] -> Either SessionError ContextStore
contextStore xs
| any (not . validName . fst) xs = Left (SessionError "invalid document name")
| M.size m /= length xs = Left (SessionError "duplicate document name")
| otherwise = Right (ContextStore m)
where
m = M.fromList xs
contextMetadata :: ContextStore -> Value
contextMetadata (ContextStore m) = object ["documents" .= [object ["name" .= n, "length" .= T.length t] | (n, t) <- M.toList m]]
data SessionConfig = SessionConfig
{ maxContextChars :: !Int,
maxStoredChars :: !Int,
maxActionBytes :: !Int,
maxObservationChars :: !Int,
maxObservedChars :: !Int,
maxOperations :: !Int,
maxScanChars :: !Int,
maxMatches :: !Int,
maxSubqueries :: !Int,
maxSubqueryChars :: !Int,
subModel :: !Model
}
defaultSessionConfig :: SessionConfig
defaultSessionConfig = SessionConfig 4000000 16000 16000 4000 32000 40 1000000 100 8 8000 emptyModel
validateSessionConfig :: SessionConfig -> Either SessionError ()
validateSessionConfig c
| any (<= 0) [maxContextChars c, maxStoredChars c, maxActionBytes c, maxObservationChars c, maxObservedChars c, maxOperations c, maxScanChars c, maxMatches c, maxSubqueries c, maxSubqueryChars c] = Left (SessionError "all session limits must be positive")
| otherwise = Right ()
data SessionState = SessionState
{ sessionConfig :: !SessionConfig,
documents :: !ContextStore,
variables :: !(Map Text Value),
operationCount :: !Int,
reservedSubqueries :: !Int,
subqueryAttempts :: !Int,
observedChars :: !Int
}
newSession :: SessionConfig -> ContextStore -> Either SessionError SessionState
newSession c docs@(ContextStore m) = do
validateSessionConfig c
if sum [toInteger (T.length n) + toInteger (T.length t) | (n, t) <- M.toList m] > toInteger (maxContextChars c)
then Left (SessionError "context character limit exceeded (including names)")
else Right (SessionState c docs M.empty 0 0 0 0)
newtype SessionError = SessionError Text deriving stock (Eq, Show)
data SessionLimit = OuterIterations | Operations | Subqueries | RequestCharacters | ObservationCharacters
deriving stock (Eq, Show)
data SessionAction
= Describe Text
| Slice Text Int Int
| Find Text Text Int Int
| Store Text Value
| Load Text
| Submit Value
| Query Text
| QueryBatch [Text]
deriving stock (Eq, Show)
instance ToJSON SessionAction where
toJSON a = case a of
Describe n -> action "describe" ["name" .= n]
Slice n start count -> action "slice" ["name" .= n, "start" .= start, "count" .= count]
Find n needle start limit -> action "find" ["name" .= n, "needle" .= needle, "start" .= start, "limit" .= limit]
Store n v -> action "store" ["name" .= n, "value" .= v]
Load n -> action "load" ["name" .= n]
Submit v -> action "submit" ["value" .= v]
Query p -> action "query" ["prompt" .= p]
QueryBatch ps -> action "query_batch" ["prompts" .= ps]
where
action op fields = object (("op" .= (op :: Text)) : fields)
instance FromJSON SessionAction where
parseJSON = withObject "session action" $ \o -> do
op <- o .: "op"
case (op :: Text) of
"describe" -> Describe <$> o .: "name"
"slice" -> Slice <$> o .: "name" <*> o .: "start" <*> o .: "count"
"find" -> Find <$> o .: "name" <*> o .: "needle" <*> o .: "start" <*> o .: "limit"
"store" -> Store <$> o .: "name" <*> o .: "value"
"load" -> Load <$> o .: "name"
"submit" -> Submit <$> o .: "value"
"query" -> Query <$> o .: "prompt"
"query_batch" -> QueryBatch <$> o .: "prompts"
_ -> fail "unknown operation"
parseSessionAction :: SessionConfig -> Text -> Either SessionError SessionAction
parseSessionAction c raw
| BS.length (encodeUtf8 raw) > maxActionBytes c = Left (SessionError "action byte limit exceeded")
| otherwise = case eitherDecodeStrict (encodeUtf8 raw) of
Left _ -> Left (SessionError "invalid action JSON or fields")
Right a -> Right a
data Truncation = Truncation
{ originalChars :: !Int,
displayedChars :: !Int,
nextOffset :: !(Maybe Int)
}
deriving stock (Eq, Show)
data SessionObservation = SessionObservation
{ observationValue :: !Value,
observationError :: !(Maybe SessionError),
truncation :: !(Maybe Truncation)
}
deriving stock (Eq, Show)
renderObservation :: SessionObservation -> Text
renderObservation o =
encodeText $
object
[ "value" .= observationValue o,
"error" .= fmap (\(SessionError e) -> e) (observationError o),
"truncation" .= fmap (\t -> object ["originalChars" .= originalChars t, "displayedChars" .= displayedChars t, "nextOffset" .= nextOffset t]) (truncation o)
]
data SessionResult = Observed SessionObservation | RunQueries [Text] | Submitted Value
deriving stock (Eq, Show)
-- | Every action, including a parse error, consumes one operation. Rejected
-- actions preserve variables. Whole batches reserve slots before any dispatch.
stepSession :: SessionState -> Either SessionError SessionAction -> (SessionState, Either SessionLimit SessionResult)
stepSession s action
| operationCount s >= maxOperations c = (s, Left Operations)
| otherwise = case action of
Left e -> bad e
Right a | BS.length (encodeUtf8 (encodeText (toJSON a))) > maxActionBytes c -> bad (SessionError "action byte limit exceeded")
Right a -> case a of
Describe n -> doc n $ \t -> good (object ["name" .= n, "length" .= T.length t])
Slice n start count -> doc n $ \t ->
if start < 0 || start > T.length t || count < 0 || count > T.length t - start
then bad (SessionError "slice outside document")
else (s1, Right (Observed (sliceObservation n start count t (maxObservationChars c))))
Find n needle start limit -> doc n $ \t ->
if start < 0 || start > T.length t || limit <= 0 || limit > maxMatches c || T.null needle || T.length needle > maxScanChars c
then bad (SessionError "invalid find offset, needle or match limit")
else
let chunk = T.take (maxScanChars c) (T.drop start t)
(matches, resume) = search needle limit start chunk
end = start + T.length chunk
next = if resume < end then resume else if end < T.length t then max (start + 1) (end - T.length needle + 1) else end
in good (object ["matches" .= matches, "scannedEnd" .= end, "nextOffset" .= next, "complete" .= (next == T.length t)])
Store n v
| not (validName n) || M.member n dm -> bad (SessionError "reserved or invalid variable name")
| otherwise ->
let vs = M.insert n v (variables s)
in if sum [toInteger (T.length k) + toInteger (T.length (encodeText x)) | (k, x) <- M.toList vs] > toInteger (maxStoredChars c)
then bad (SessionError "stored value character limit exceeded")
else (s1 {variables = vs}, Right (Observed (ok (String "stored"))))
Load n -> maybe (bad (SessionError "unknown variable")) good (M.lookup n (variables s))
Submit v -> (s1, Right (Submitted v))
Query p -> queries [p]
QueryBatch ps -> queries ps
where
c = sessionConfig s
ContextStore dm = documents s
s1 = s {operationCount = operationCount s + 1}
bad e = (s1, Right (Observed (SessionObservation Null (Just e) Nothing)))
good v = (s1, Right (Observed (ok v)))
doc n f = maybe (bad (SessionError "unknown document")) f (M.lookup n dm)
queries ps
| null ps || any ((> maxSubqueryChars c) . T.length) ps = bad (SessionError "empty batch or oversized subquery")
| length ps > maxSubqueries c - reservedSubqueries s = (s1, Left Subqueries)
| otherwise = (s1 {reservedSubqueries = reservedSubqueries s + length ps}, Right (RunQueries ps))
ok :: Value -> SessionObservation
ok v = SessionObservation v Nothing Nothing
-- Keep source-relative offsets intact even when JSON escaping expands a slice.
sliceObservation :: Text -> Int -> Int -> Text -> Int -> SessionObservation
sliceObservation name start count source budget = fit (min count budget)
where
fit n =
let v = object ["name" .= name, "start" .= start, "end" .= (start + n), "text" .= T.take n (T.drop start source)]
tr = if n < count then Just (Truncation count n (Just (start + n))) else Nothing
o = SessionObservation v Nothing tr
in if n == 0 || T.length (renderObservation o) <= budget then o else fit (n `div` 2)
search :: Text -> Int -> Int -> Text -> ([Int], Int)
search needle = go
where
go 0 offset _ = ([], offset)
go remaining offset t =
let (before, after) = T.breakOn needle t
in if T.null after
then ([], offset + T.length t)
else
let found = offset + T.length before
(rest, end) = go (remaining - 1) (found + 1) (T.drop 1 after)
in (found : rest, end)
-- | Count actual logical LLM dispatch attempts, separately from batch reservations.
attemptedSubquery :: SessionState -> SessionState
attemptedSubquery s = s {subqueryAttempts = subqueryAttempts s + 1}
-- | Bound the complete serialized observation, including metadata and escaping.
-- Oversized non-slice values are shown as a JSON-text prefix, explicitly marked.
-- A limit too small even for metadata exhausts instead of hiding truncation.
recordObservation :: SessionState -> SessionObservation -> (SessionState, Either SessionLimit SessionObservation)
recordObservation s o
| size > maxObservationChars c || size > maxObservedChars c - observedChars s = (s, Left ObservationCharacters)
| otherwise = (s {observedChars = observedChars s + size}, Right bounded)
where
c = sessionConfig s
raw = encodeText (observationValue o)
bounded
| T.length (renderObservation o) <= maxObservationChars c = o
| truncation o /= Nothing = o
| otherwise = fit (min (T.length raw) (maxObservationChars c))
fit n =
let candidate = SessionObservation (String (T.take n raw)) (observationError o) (Just (Truncation (T.length raw) n Nothing))
in if n == 0 || T.length (renderObservation candidate) <= maxObservationChars c then candidate else fit (n `div` 2)
size = T.length (renderObservation bounded)
validName :: Text -> Bool
validName n = not (T.null n) && T.length n <= 128 && T.all (\x -> x >= 'a' && x <= 'z' || x >= 'A' && x <= 'Z' || x >= '0' && x <= '9' || x == '_' || x == '-') n && n `notElem` ["describe", "slice", "find", "store", "load", "submit", "query", "query_batch"]