packages feed

praxo-core-0.3.0: src/Praxo/Runtime.hs

module Praxo.Runtime
    ( MemoryRecord(..), MemoryStore, newMemoryStore, memoryPut, memorySearch
    , Event(..), Sink(..), newSink, multiSink, filterSink, emitSink, sinkKinds, sinkKindLists
    , streamKinds
    , newCheckpoint, registerCheckpoint, checkpointMarshal, checkpointUnmarshal
    , PathGrant(..), PathChecker(..), Decision(..), normalizePath, pathChecker, pathCheck, pathAllowed
    , ContextBudget(..), ContextMessage(..), estimateTokens, compactContext, messageWire
    ) where

import Data.Char (toLower)
import Data.List (intercalate, isInfixOf, sortBy)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Maybe (fromMaybe)
import Data.Ord (comparing)
import Praxo.Json (Json(..), asString, encodeJson, field, parseJson)
import System.Info (os)

data MemoryRecord = MemoryRecord
    { recordId :: String
    , recordFrame :: String
    , recordContent :: String
    , recordCreatedAt :: String
    } deriving (Eq, Show)

data MemoryStore = MemoryStore
    { storeRecords :: Map String [MemoryRecord]
    , storeSequence :: Int
    } deriving (Eq, Show)

newMemoryStore :: MemoryStore
newMemoryStore = MemoryStore Map.empty 0

memoryPut :: MemoryStore -> String -> MemoryRecord -> (MemoryStore, MemoryRecord)
memoryPut store session record =
    let (withId, nextSequence) =
            if null (recordId record)
                then (record { recordId = "mem-" ++ show (storeSequence store) ++ "-" ++ show (length current) }, storeSequence store + 1)
                else (record, storeSequence store)
        withTime = if null (recordCreatedAt withId) then withId { recordCreatedAt = show nextSequence } else withId
        current = Map.findWithDefault [] session (storeRecords store)
        updated = store { storeRecords = Map.insert session (current ++ [withTime]) (storeRecords store), storeSequence = nextSequence }
    in (updated, withTime)

memorySearch :: MemoryStore -> String -> String -> Int -> [MemoryRecord]
memorySearch store session query limit =
    let needle = map toLower query
        records = Map.findWithDefault [] session (storeRecords store)
        matches = filter (\record -> null needle || needle `isInfixOf` map toLower (recordContent record)) records
        ordered = sortBy (flip (comparing recordCreatedAt)) matches
    in if limit > 0 then take limit ordered else ordered

data Event = Event
    { eventKind :: String
    , eventSessionId :: String
    , eventRunId :: Maybe String
    , eventData :: Json
    } deriving (Eq, Show)

data Sink = RecordSink [String] | MultiSink [Sink] | FilterSink Sink [String] deriving (Eq, Show)

newSink :: Sink
newSink = RecordSink []

multiSink :: [Sink] -> Sink
multiSink = MultiSink

filterSink :: Sink -> [String] -> Sink
filterSink = FilterSink

emitSink :: Sink -> Event -> Sink
emitSink (MultiSink sinks) event = MultiSink (map (`emitSink` event) sinks)
emitSink (FilterSink target predicate) event
    | null predicate || eventKind event `elem` predicate = FilterSink (emitSink target event) predicate
    | otherwise = FilterSink target predicate
emitSink (RecordSink kinds) event = RecordSink (kinds ++ [eventKind event])

sinkKinds :: Sink -> [String]
sinkKinds (RecordSink kinds) = kinds
sinkKinds (FilterSink target _) = sinkKinds target
sinkKinds (MultiSink _) = []

sinkKindLists :: Sink -> [[String]]
sinkKindLists (MultiSink sinks) = map sinkKinds sinks
sinkKindLists sink = [sinkKinds sink]

streamKinds :: [String]
streamKinds = ["values", "updates", "messages", "tasks", "debug", "checkpoint"]

type CheckpointAllowlist = [String]

newCheckpoint :: CheckpointAllowlist
newCheckpoint = []

registerCheckpoint :: CheckpointAllowlist -> String -> Either String CheckpointAllowlist
registerCheckpoint _ "" = Left "CHECKPOINT_ERROR"
registerCheckpoint allowlist kind = Right (allowlist ++ [kind | kind `notElem` allowlist])

checkpointMarshal :: CheckpointAllowlist -> String -> Json -> Either String String
checkpointMarshal allowlist kind value
    | kind `elem` allowlist = encodeJson (JObject [("type", JString kind), ("data", value)])
    | otherwise = Left "CHECKPOINT_TYPE_NOT_ALLOWED"

checkpointUnmarshal :: CheckpointAllowlist -> String -> Either String Json
checkpointUnmarshal allowlist raw = do
    envelope <- either (const (Left "CHECKPOINT_ERROR")) Right (parseJson raw)
    let kind = asString (field "type" envelope)
    if kind `elem` allowlist
        then Right (field "data" envelope)
        else Left "CHECKPOINT_TYPE_NOT_ALLOWED"

data PathGrant = PathGrant { grantRoot :: String, grantAccess :: String } deriving (Eq, Show)
newtype PathChecker = PathChecker [PathGrant] deriving (Eq, Show)
data Decision = Decision { decisionAllowed :: Bool, decisionReason :: String } deriving (Eq, Show)

normalizePath :: String -> String
normalizePath value =
    let path = map (\c -> if c == '\\' then '/' else c) value
        (prefix, body, absolute) =
            case path of
                drive : ':' : rest -> let isAbs = take 1 rest == "/" in (drive : ":" ++ if isAbs then "/" else "", rest, isAbs)
                '/' : _ -> ("/", path, True)
                _ -> ("", path, False)
        parts = reduce absolute (splitSlash body)
        result = prefix ++ intercalate "/" parts
    in if os == "mingw32" then map toLower result else result

splitSlash :: String -> [String]
splitSlash "" = [""]
splitSlash text = case break (== '/') text of
    (part, []) -> [part]
    (part, _ : rest) -> part : splitSlash rest

reduce :: Bool -> [String] -> [String]
reduce absolute = foldl step []
  where
    step acc "" = acc
    step acc "." = acc
    step acc ".."
        | null acc = if absolute then [] else [".."]
        | last acc == ".." = acc ++ [".."]
        | otherwise = init acc
    step acc part = acc ++ [part]

pathChecker :: [PathGrant] -> Either String PathChecker
pathChecker grants = PathChecker <$> mapM checkGrant grants
  where
    checkGrant grant
        | null (grantRoot grant) = Left "POLICY_ERROR"
        | grantAccess grant `notElem` ["read", "write", "readwrite"] = Left "POLICY_ERROR"
        | otherwise = Right grant { grantRoot = normalizePath (grantRoot grant) }

pathCheck :: PathChecker -> String -> String -> Either String Decision
pathCheck (PathChecker grants) target need
    | need `notElem` ["read", "write", "readwrite"] = Left "POLICY_ERROR"
    | null grants = Right (Decision False "no allowed roots")
    | any matches grants = Right (Decision True "allowed")
    | any (\grant -> within (grantRoot grant) resolved) grants = Right (Decision False "insufficient grant")
    | otherwise = Right (Decision False "outside roots")
  where
    resolved = normalizePath target
    matches grant = within (grantRoot grant) resolved && allows (grantAccess grant) need
    within root value = root == value || (root ++ "/") `isPrefixOf` value

allows :: String -> String -> Bool
allows "readwrite" _ = True
allows "read" "read" = True
allows "write" "write" = True
allows _ _ = False

pathAllowed :: PathChecker -> String -> String -> Bool
pathAllowed checker target need = case pathCheck checker target need of
    Right decision -> decisionAllowed decision
    Left _ -> False

isPrefixOf :: String -> String -> Bool
isPrefixOf prefix value = take (length prefix) value == prefix

data ContextBudget = ContextBudget { budgetMaxTokens :: Int, budgetKeepRecent :: Int } deriving (Eq, Show)
data ContextMessage = ContextMessage
    { messageRole :: String
    , messageContent :: String
    , messageBlocks :: [String]
    , messageCalls :: [(String, Json)]
    , messageToolId :: Maybe String
    } deriving (Eq, Show)

estimateTokens :: [ContextMessage] -> Int
estimateTokens = sum . map messageTokens
  where
    messageTokens message = 4
        + length (messageContent message) `div` 4
        + length (fromMaybe "" (messageToolId message)) `div` 4
        + sum [4 + length block `div` 4 | block <- messageBlocks message]
        + sum [4 + length name `div` 4 + length (render arguments) `div` 4 | (name, arguments) <- messageCalls message]
    render value = either (const "") id (encodeJson value)

compactContext :: ContextBudget -> [ContextMessage] -> ([ContextMessage], Bool)
compactContext budget messages
    | budgetMaxTokens budget <= 0 || estimateTokens messages <= budgetMaxTokens budget = (messages, True)
    | otherwise =
        let (system, rest) = partitionSystem messages
            system' = filter (not . isCompaction . messageContent) system
        in case fitSystem system' (budgetMaxTokens budget) of
            Nothing -> ([], False)
            Just fitted ->
                let keep = if budgetKeepRecent budget > 0 then budgetKeepRecent budget else 4
                    start = alignStart rest (max (length rest - keep) 0)
                    marker = ContextMessage "system" ("[context compacted: " ++ show start ++ " older messages omitted]") [] [] Nothing
                    kept = drop start rest
                    output = fitted ++ (if start > 0 then [marker] else []) ++ kept
                in shrink output kept fitted marker start (budgetMaxTokens budget)
  where
    isCompaction value = take 19 value == "[context compacted:"

partitionSystem :: [ContextMessage] -> ([ContextMessage], [ContextMessage])
partitionSystem messages = (filter isSystem messages, filter (not . isSystem) messages)
  where isSystem message = messageRole message == "system"

fitSystem :: [ContextMessage] -> Int -> Maybe [ContextMessage]
fitSystem [] _ = Just []
fitSystem system maxTokens
    | estimateTokens system <= maxTokens = Just system
    | otherwise =
        case system of
            (first:rest) ->
                let trimmed = first { messageContent = truncateToTokens (messageContent first) (maxTokens - 4) }
                    candidate = trimmed : rest
                in if estimateTokens candidate <= maxTokens then Just candidate else Nothing

truncateToTokens :: String -> Int -> String
truncateToTokens text tokens
    | tokens <= 0 = ""
    | length text <= limit = text
    | limit <= 16 = take limit text
    | otherwise = take (limit - 16) text ++ "\n[truncated]"
  where limit = tokens * 4

alignStart :: [ContextMessage] -> Int -> Int
alignStart _ 0 = 0
alignStart rest start
    | messageRole (rest !! start) == "tool" = alignStart rest (start - 1)
    | otherwise = start

shrink :: [ContextMessage] -> [ContextMessage] -> [ContextMessage] -> ContextMessage -> Int -> Int -> ([ContextMessage], Bool)
shrink output kept system marker start maxTokens
    | estimateTokens output > maxTokens && length kept > 1 =
        if messageRole (kept !! 1) == "tool"
            then (output, False)
            else
                let kept' = drop 1 kept
                    output' = system ++ (if start > 0 then [marker] else []) ++ kept'
                in shrink output' kept' system marker start maxTokens
    | otherwise = (output, estimateTokens output <= maxTokens)

messageWire :: ContextMessage -> Json
messageWire message =
    let base = [("role", JString (messageRole message))]
        content = if not (null (messageContent message)) || null (messageBlocks message) && null (messageCalls message) && messageToolId message == Nothing
            then [("content", JString (messageContent message))]
            else []
        blocks = if null (messageBlocks message) then [] else [("contentBlocks", JArray [JObject [("type", JString "text"), ("text", JString block)] | block <- messageBlocks message])]
        calls = if null (messageCalls message) then [] else [("toolCalls", JArray [JObject [("id", JString ""), ("name", JString name), ("arguments", arguments)] | (name, arguments) <- messageCalls message])]
        toolId = maybe [] (\value -> [("toolId", JString value)]) (messageToolId message)
    in JObject (base ++ content ++ blocks ++ calls ++ toolId)