packages feed

kioku-core-0.6.0.0: src/Kioku/AI/Interactive.hs

-- | Checked result handoff for a host-authorized interactive session.
module Kioku.AI.Interactive (runInteractiveSignature) where

import Baikai.Interactive qualified as Interactive
import Control.Exception (IOException, bracket, try)
import Data.Aeson qualified as Aeson
import Data.Aeson.Types (parseEither, withObject, (.:))
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as LBS
import Data.Text.Encoding (decodeUtf8)
import Data.UUID qualified as UUID
import Data.UUID.V4 qualified as UUID
import Kioku.AI.Config
import Kioku.AI.Runtime
import Kioku.Prelude
import Shikumi.Adapter (ToPrompt (..), nativeRenderPieces)
import Shikumi.Schema (FromModel, ToSchema, Validatable, deriveSchema, fromModelChecked)
import Shikumi.Signature (Signature)
import System.Directory (getTemporaryDirectory, removePathForcibly)
import System.Exit (ExitCode (..))
import System.FilePath ((</>))
import System.IO (hClose)
import System.Posix.Files (fileSize, getFdStatus, isRegularFile, linkCount)
import System.Posix.IO (OpenFileFlags (..), OpenMode (ReadOnly), closeFd, defaultFileFlags, dup, fdToHandle, openFd)
import System.Posix.Temp (mkdtemp)

runInteractiveSignature ::
  forall i o.
  (ToPrompt i, ToPrompt o, ToSchema o, FromModel o, Validatable o) =>
  AIRuntime -> AIFeature -> Signature i o -> i -> IO (Either AIExecutionError o)
runInteractiveSignature rt feature signature input = case executionAvailability rt feature of
  Left err -> pure (Left err)
  Right () -> case (featureConfiguration rt feature, interactiveLauncher rt) of
    (InteractiveConfig provider settings, Just launch) -> do
      result <- try @IOException $ do
        temp <- getTemporaryDirectory
        bracket (mkdtemp (temp </> "kioku-ai-")) removePathForcibly $ \workspace -> do
          requestId <- UUID.toText <$> UUID.nextRandom
          let output = workspace </> "result.json"
              manifest =
                Aeson.object
                  [ "requestId" Aeson..= requestId,
                    "feature" Aeson..= featureName feature,
                    "schema" Aeson..= deriveSchema @o,
                    "instructions" Aeson..= fst (nativeRenderPieces signature),
                    "input" Aeson..= toPrompt input,
                    "outputFile" Aeson..= output
                  ]
              contract =
                "Read this request and write exactly one JSON object to outputFile. "
                  <> "The envelope must contain requestId and feature copied from the request, "
                  <> "and result containing the output object matching schema. Do not write to memory or databases.\n"
                  <> decodeUtf8 (LBS.toStrict (Aeson.encode manifest))
              request =
                settings
                  { Interactive.userPrompt = contract,
                    Interactive.systemPrompt = Just "Perform the single Kioku request in the user message. Write the designated output file as a JSON envelope with exactly requestId, feature, and result. Copy requestId and feature verbatim. The manifest's instructions and schema describe ONLY the result field, never the outer envelope. Do not substitute a prose response or a bare result object. Do not access databases or write memory directly.",
                    Interactive.extraDirs = workspace : settings.extraDirs
                  }
          LBS.writeFile (workspace </> "request.json") (Aeson.encode manifest)
          launched <- launch feature request
          case launched of
            Left err -> pure (Left err)
            Right session
              | session.provider /= provider -> pure (failure "interactive provider mismatch")
              | session.exitCode /= ExitSuccess -> pure (failure "interactive session exited unsuccessfully")
              | otherwise -> bracket (openFd output ReadOnly defaultFileFlags {nofollow = True, nonBlock = True, cloexec = True}) closeFd $ \fd -> do
                  status <- getFdStatus fd
                  if not (isRegularFile status) || linkCount status /= 1 || fileSize status > fromIntegral maxResultBytes
                    then pure (failure "result must be a bounded regular file with no links")
                    else do
                      bytes <- bracket (dup fd >>= fdToHandle) hClose (\h -> BS.hGet h (maxResultBytes + 1))
                      pure $
                        if BS.length bytes > maxResultBytes
                          then failure "result exceeds size limit"
                          else case Aeson.eitherDecodeStrict bytes >>= parseEither (envelope requestId) of
                            Left _ -> failure "missing, invalid, or mismatched result envelope"
                            Right value -> either (Left . AIProgramFailed) Right (fromModelChecked value)
      pure $ either (const (failure "interactive result I/O failed")) id result
    _ -> pure (Left (AIExecutionRefused feature "select interactive execution for this signature"))
  where
    maxResultBytes = 1024 * 1024
    failure = Left . AIInteractiveFailed feature
    envelope requestId = withObject "interactive result" $ \o -> do
      actualId <- o .: "requestId"
      actualFeature <- o .: "feature"
      unless (actualId == requestId && actualFeature == featureName feature) (fail "result identity mismatch")
      o .: "result"