packages feed

shikumi-tools 0.3.0.3 → 0.4.0.0

raw patch · 31 files changed

+2593/−213 lines, 31 filesdep +shikumi-cachedep +shikumi-testingdep ~baikaidep ~http-client-tlsdep ~shikumiPVP ok

version bump matches the API change (PVP)

Dependencies added: shikumi-cache, shikumi-testing

Dependency ranges changed: baikai, http-client-tls, shikumi, shikumi-tools

API changes (from Hackage documentation)

+ Shikumi.Agent.History: Exchange :: !AssistantPayload -> ![(ToolCall, ToolOutput)] -> !Maybe Text -> HistoryEntry
+ Shikumi.Agent.History: HistoryError :: Text -> HistoryError
+ Shikumi.Agent.History: UserTurn :: !UserPayload -> HistoryEntry
+ Shikumi.Agent.History: appendExchange :: AssistantPayload -> [(ToolCall, ToolOutput)] -> Maybe Text -> Bool -> ReActSession -> Either HistoryError ReActSession
+ Shikumi.Agent.History: appendUser :: Text -> ReActSession -> Either HistoryError ReActSession
+ Shikumi.Agent.History: auditHistory :: ReActSession -> [HistoryEntry]
+ Shikumi.Agent.History: bindSessionOrigin :: Maybe RequestOrigin -> ReActSession -> ReActSession
+ Shikumi.Agent.History: compactSession :: Int -> Text -> ReActSession -> Either HistoryError ReActSession
+ Shikumi.Agent.History: compactionSafe :: Int -> ReActSession -> Bool
+ Shikumi.Agent.History: data HistoryEntry
+ Shikumi.Agent.History: data ReActSession
+ Shikumi.Agent.History: decodeSession :: Value -> Either HistoryError ReActSession
+ Shikumi.Agent.History: encodeSession :: ReActSession -> Value
+ Shikumi.Agent.History: entryMessages :: Text -> HistoryEntry -> [Message]
+ Shikumi.Agent.History: instance GHC.Classes.Eq Shikumi.Agent.History.HistoryEntry
+ Shikumi.Agent.History: instance GHC.Classes.Eq Shikumi.Agent.History.HistoryError
+ Shikumi.Agent.History: instance GHC.Classes.Eq Shikumi.Agent.History.ReActSession
+ Shikumi.Agent.History: instance GHC.Internal.Show.Show Shikumi.Agent.History.HistoryEntry
+ Shikumi.Agent.History: instance GHC.Internal.Show.Show Shikumi.Agent.History.HistoryError
+ Shikumi.Agent.History: instance GHC.Internal.Show.Show Shikumi.Agent.History.ReActSession
+ Shikumi.Agent.History: newSession :: Text -> Value -> Value -> Text -> Either HistoryError ReActSession
+ Shikumi.Agent.History: newtype HistoryError
+ Shikumi.Agent.History: parsePromptActions :: Text -> Either HistoryError [(Text, Value)]
+ Shikumi.Agent.History: promptEntries :: ReActSession -> [HistoryEntry]
+ Shikumi.Agent.History: promptMessages :: ReActSession -> [Message]
+ Shikumi.Agent.History: protectSession :: Context -> ReActSession -> ReActSession
+ Shikumi.Agent.History: renderSessionSummaryInput :: ReActSession -> Text
+ Shikumi.Agent.History: renderSummaryEntry :: HistoryEntry -> Text
+ Shikumi.Agent.History: restartSessionFromSummary :: Text -> ReActSession -> Either HistoryError ReActSession
+ Shikumi.Agent.History: sessionCompactedThrough :: ReActSession -> Int
+ Shikumi.Agent.History: sessionFingerprint :: ReActSession -> Value
+ Shikumi.Agent.History: sessionFinished :: ReActSession -> Bool
+ Shikumi.Agent.History: sessionIterations :: ReActSession -> Int
+ Shikumi.Agent.History: sessionOrigin :: ReActSession -> Maybe RequestOrigin
+ Shikumi.Agent.History: sessionPrefix :: ReActSession -> Maybe Value
+ Shikumi.Agent.History: sessionProtocol :: ReActSession -> Text
+ Shikumi.Agent.History: sessionTools :: ReActSession -> Value
+ Shikumi.Agent.History: sessionTurns :: ReActSession -> Int
+ Shikumi.Agent.History: validateCalls :: [Text] -> [ToolCall] -> Either HistoryError ()
+ Shikumi.Agent.History: validateSession :: ReActSession -> Either HistoryError ()
+ Shikumi.Agent.ReAct: SessionFinished :: o -> ReActSession -> SessionResult o
+ Shikumi.Agent.ReAct: SessionPaused :: ReActSession -> SessionResult o
+ Shikumi.Agent.ReAct: advanceSession :: forall i o (es :: [Effect]). (ToSchema o, FromModel o, Validatable o, LLM :> es, Error ShikumiError :> es) => Signature i o -> ToolRegistry -> ReActConfig -> ReActSession -> Eff es (SessionResult o)
+ Shikumi.Agent.ReAct: continueSession :: forall i o (es :: [Effect]). (ToPrompt i, ToSchema o, Error ShikumiError :> es) => Signature i o -> ToolRegistry -> ReActConfig -> i -> ReActSession -> Eff es ReActSession
+ Shikumi.Agent.ReAct: data SessionResult o
+ Shikumi.Agent.ReAct: finalToolName :: Text
+ Shikumi.Agent.ReAct: instance GHC.Classes.Eq o => GHC.Classes.Eq (Shikumi.Agent.ReAct.SessionResult o)
+ Shikumi.Agent.ReAct: instance GHC.Internal.Show.Show o => GHC.Internal.Show.Show (Shikumi.Agent.ReAct.SessionResult o)
+ Shikumi.Agent.ReAct: runSession :: forall o (es :: [Effect]) i. (ToSchema o, FromModel o, Validatable o, LLM :> es, Error ShikumiError :> es) => Signature i o -> ToolRegistry -> ReActConfig -> ReActSession -> Eff es (SessionResult o)
+ Shikumi.Agent.ReAct: startSession :: forall i o (es :: [Effect]). (ToPrompt i, ToSchema o, Error ShikumiError :> es) => Signature i o -> ToolRegistry -> ReActConfig -> i -> Eff es ReActSession
+ Shikumi.Agent.ReAct: startSessionWithModel :: forall i o (es :: [Effect]). (ToPrompt i, ToSchema o, Error ShikumiError :> es) => Model -> Signature i o -> ToolRegistry -> ReActConfig -> i -> Eff es ReActSession
+ Shikumi.CodeExec.RLM: RLMAudit :: !Text -> !Bool -> !Maybe SessionObservation -> RLMAudit
+ Shikumi.CodeExec.RLM: RLMConfig :: !SessionConfig -> !Int -> !Int -> RLMConfig
+ Shikumi.CodeExec.RLM: RLMExhausted :: SessionLimit -> RLMOutcome o
+ Shikumi.CodeExec.RLM: RLMReport :: !Int -> !Int -> !Int -> !Int -> ![RLMAudit] -> !Maybe SessionLimit -> RLMReport
+ Shikumi.CodeExec.RLM: RLMSubmitted :: o -> RLMOutcome o
+ Shikumi.CodeExec.RLM: [actionText] :: RLMAudit -> !Text
+ Shikumi.CodeExec.RLM: [actionTruncated] :: RLMAudit -> !Bool
+ Shikumi.CodeExec.RLM: [auditObservation] :: RLMAudit -> !Maybe SessionObservation
+ Shikumi.CodeExec.RLM: [audit] :: RLMReport -> ![RLMAudit]
+ Shikumi.CodeExec.RLM: [maxOuterIterations] :: RLMConfig -> !Int
+ Shikumi.CodeExec.RLM: [maxRequestChars] :: RLMConfig -> !Int
+ Shikumi.CodeExec.RLM: [operations] :: RLMReport -> !Int
+ Shikumi.CodeExec.RLM: [outerIterations] :: RLMReport -> !Int
+ Shikumi.CodeExec.RLM: [session] :: RLMConfig -> !SessionConfig
+ Shikumi.CodeExec.RLM: [subqueriesAttempted] :: RLMReport -> !Int
+ Shikumi.CodeExec.RLM: [termination] :: RLMReport -> !Maybe SessionLimit
+ Shikumi.CodeExec.RLM: [totalObservedChars] :: RLMReport -> !Int
+ Shikumi.CodeExec.RLM: data RLMAudit
+ Shikumi.CodeExec.RLM: data RLMConfig
+ Shikumi.CodeExec.RLM: data RLMOutcome o
+ Shikumi.CodeExec.RLM: data RLMReport
+ Shikumi.CodeExec.RLM: defaultRLMConfig :: RLMConfig
+ Shikumi.CodeExec.RLM: instance GHC.Classes.Eq Shikumi.CodeExec.RLM.RLMAudit
+ Shikumi.CodeExec.RLM: instance GHC.Classes.Eq Shikumi.CodeExec.RLM.RLMReport
+ Shikumi.CodeExec.RLM: instance GHC.Classes.Eq o => GHC.Classes.Eq (Shikumi.CodeExec.RLM.RLMOutcome o)
+ Shikumi.CodeExec.RLM: instance GHC.Internal.Show.Show Shikumi.CodeExec.RLM.RLMAudit
+ Shikumi.CodeExec.RLM: instance GHC.Internal.Show.Show Shikumi.CodeExec.RLM.RLMReport
+ Shikumi.CodeExec.RLM: instance GHC.Internal.Show.Show o => GHC.Internal.Show.Show (Shikumi.CodeExec.RLM.RLMOutcome o)
+ Shikumi.CodeExec.RLM: rlm :: (ToPrompt i, ToSchema o, FromModel o, Validatable o) => RLMConfig -> ContextStore -> Signature i o -> Program i o
+ Shikumi.CodeExec.RLM: rlmWithReport :: (ToPrompt i, ToSchema o, FromModel o, Validatable o) => RLMConfig -> ContextStore -> Signature i o -> Program i (RLMOutcome o, RLMReport)
+ Shikumi.CodeExec.Session: Describe :: Text -> SessionAction
+ Shikumi.CodeExec.Session: Find :: Text -> Text -> Int -> Int -> SessionAction
+ Shikumi.CodeExec.Session: Load :: Text -> SessionAction
+ Shikumi.CodeExec.Session: ObservationCharacters :: SessionLimit
+ Shikumi.CodeExec.Session: Observed :: SessionObservation -> SessionResult
+ Shikumi.CodeExec.Session: Operations :: SessionLimit
+ Shikumi.CodeExec.Session: OuterIterations :: SessionLimit
+ Shikumi.CodeExec.Session: Query :: Text -> SessionAction
+ Shikumi.CodeExec.Session: QueryBatch :: [Text] -> SessionAction
+ Shikumi.CodeExec.Session: RequestCharacters :: SessionLimit
+ Shikumi.CodeExec.Session: RunQueries :: [Text] -> SessionResult
+ Shikumi.CodeExec.Session: SessionConfig :: !Int -> !Int -> !Int -> !Int -> !Int -> !Int -> !Int -> !Int -> !Int -> !Int -> !Model -> SessionConfig
+ Shikumi.CodeExec.Session: SessionError :: Text -> SessionError
+ Shikumi.CodeExec.Session: SessionObservation :: !Value -> !Maybe SessionError -> !Maybe Truncation -> SessionObservation
+ Shikumi.CodeExec.Session: Slice :: Text -> Int -> Int -> SessionAction
+ Shikumi.CodeExec.Session: Store :: Text -> Value -> SessionAction
+ Shikumi.CodeExec.Session: Submit :: Value -> SessionAction
+ Shikumi.CodeExec.Session: Submitted :: Value -> SessionResult
+ Shikumi.CodeExec.Session: Subqueries :: SessionLimit
+ Shikumi.CodeExec.Session: Truncation :: !Int -> !Int -> !Maybe Int -> Truncation
+ Shikumi.CodeExec.Session: [displayedChars] :: Truncation -> !Int
+ Shikumi.CodeExec.Session: [maxActionBytes] :: SessionConfig -> !Int
+ Shikumi.CodeExec.Session: [maxContextChars] :: SessionConfig -> !Int
+ Shikumi.CodeExec.Session: [maxMatches] :: SessionConfig -> !Int
+ Shikumi.CodeExec.Session: [maxObservationChars] :: SessionConfig -> !Int
+ Shikumi.CodeExec.Session: [maxObservedChars] :: SessionConfig -> !Int
+ Shikumi.CodeExec.Session: [maxOperations] :: SessionConfig -> !Int
+ Shikumi.CodeExec.Session: [maxScanChars] :: SessionConfig -> !Int
+ Shikumi.CodeExec.Session: [maxStoredChars] :: SessionConfig -> !Int
+ Shikumi.CodeExec.Session: [maxSubqueries] :: SessionConfig -> !Int
+ Shikumi.CodeExec.Session: [maxSubqueryChars] :: SessionConfig -> !Int
+ Shikumi.CodeExec.Session: [nextOffset] :: Truncation -> !Maybe Int
+ Shikumi.CodeExec.Session: [observationError] :: SessionObservation -> !Maybe SessionError
+ Shikumi.CodeExec.Session: [observationValue] :: SessionObservation -> !Value
+ Shikumi.CodeExec.Session: [originalChars] :: Truncation -> !Int
+ Shikumi.CodeExec.Session: [subModel] :: SessionConfig -> !Model
+ Shikumi.CodeExec.Session: [truncation] :: SessionObservation -> !Maybe Truncation
+ Shikumi.CodeExec.Session: attemptedSubquery :: SessionState -> SessionState
+ Shikumi.CodeExec.Session: contextMetadata :: ContextStore -> Value
+ Shikumi.CodeExec.Session: contextStore :: [(Text, Text)] -> Either SessionError ContextStore
+ Shikumi.CodeExec.Session: data ContextStore
+ Shikumi.CodeExec.Session: data SessionAction
+ Shikumi.CodeExec.Session: data SessionConfig
+ Shikumi.CodeExec.Session: data SessionLimit
+ Shikumi.CodeExec.Session: data SessionObservation
+ Shikumi.CodeExec.Session: data SessionResult
+ Shikumi.CodeExec.Session: data SessionState
+ Shikumi.CodeExec.Session: data Truncation
+ Shikumi.CodeExec.Session: defaultSessionConfig :: SessionConfig
+ Shikumi.CodeExec.Session: instance Data.Aeson.Types.FromJSON.FromJSON Shikumi.CodeExec.Session.SessionAction
+ Shikumi.CodeExec.Session: instance Data.Aeson.Types.ToJSON.ToJSON Shikumi.CodeExec.Session.SessionAction
+ Shikumi.CodeExec.Session: instance GHC.Classes.Eq Shikumi.CodeExec.Session.SessionAction
+ Shikumi.CodeExec.Session: instance GHC.Classes.Eq Shikumi.CodeExec.Session.SessionError
+ Shikumi.CodeExec.Session: instance GHC.Classes.Eq Shikumi.CodeExec.Session.SessionLimit
+ Shikumi.CodeExec.Session: instance GHC.Classes.Eq Shikumi.CodeExec.Session.SessionObservation
+ Shikumi.CodeExec.Session: instance GHC.Classes.Eq Shikumi.CodeExec.Session.SessionResult
+ Shikumi.CodeExec.Session: instance GHC.Classes.Eq Shikumi.CodeExec.Session.Truncation
+ Shikumi.CodeExec.Session: instance GHC.Internal.Show.Show Shikumi.CodeExec.Session.SessionAction
+ Shikumi.CodeExec.Session: instance GHC.Internal.Show.Show Shikumi.CodeExec.Session.SessionError
+ Shikumi.CodeExec.Session: instance GHC.Internal.Show.Show Shikumi.CodeExec.Session.SessionLimit
+ Shikumi.CodeExec.Session: instance GHC.Internal.Show.Show Shikumi.CodeExec.Session.SessionObservation
+ Shikumi.CodeExec.Session: instance GHC.Internal.Show.Show Shikumi.CodeExec.Session.SessionResult
+ Shikumi.CodeExec.Session: instance GHC.Internal.Show.Show Shikumi.CodeExec.Session.Truncation
+ Shikumi.CodeExec.Session: newSession :: SessionConfig -> ContextStore -> Either SessionError SessionState
+ Shikumi.CodeExec.Session: newtype SessionError
+ Shikumi.CodeExec.Session: observedChars :: SessionState -> Int
+ Shikumi.CodeExec.Session: operationCount :: SessionState -> Int
+ Shikumi.CodeExec.Session: parseSessionAction :: SessionConfig -> Text -> Either SessionError SessionAction
+ Shikumi.CodeExec.Session: recordObservation :: SessionState -> SessionObservation -> (SessionState, Either SessionLimit SessionObservation)
+ Shikumi.CodeExec.Session: renderObservation :: SessionObservation -> Text
+ Shikumi.CodeExec.Session: stepSession :: SessionState -> Either SessionError SessionAction -> (SessionState, Either SessionLimit SessionResult)
+ Shikumi.CodeExec.Session: subqueryAttempts :: SessionState -> Int
+ Shikumi.CodeExec.Session: validateSessionConfig :: SessionConfig -> Either SessionError ()
+ Shikumi.Tool: [DynTool] :: Text -> Text -> Value -> (forall (es :: [Effect]). (LLM :> es, Error ShikumiError :> es) => Value -> Eff es (Either ToolError ToolOutput)) -> SomeTool
+ Shikumi.Tool: mkDynTool :: Text -> Text -> Value -> (forall (es :: [Effect]). (LLM :> es, Error ShikumiError :> es) => Value -> Eff es (Either ToolError ToolOutput)) -> SomeTool
+ Shikumi.Tool: runErasedOutput :: forall (es :: [Effect]). (LLM :> es, Error ShikumiError :> es) => SomeTool -> Value -> Eff es (Either ToolError ToolOutput)
+ Shikumi.Tool: runToolCallOutput :: forall (es :: [Effect]). (LLM :> es, Error ShikumiError :> es) => ToolRegistry -> ToolCall -> Eff es (Either ToolError ToolOutput)
+ Shikumi.Tool.Output: ToolOutput :: !ToolResult -> !Maybe Value -> ![Value] -> ToolOutput
+ Shikumi.Tool.Output: [extensionBlocks] :: ToolOutput -> ![Value]
+ Shikumi.Tool.Output: [result] :: ToolOutput -> !ToolResult
+ Shikumi.Tool.Output: [structuredContent] :: ToolOutput -> !Maybe Value
+ Shikumi.Tool.Output: data ToolOutput
+ Shikumi.Tool.Output: instance GHC.Classes.Eq Shikumi.Tool.Output.ToolOutput
+ Shikumi.Tool.Output: instance GHC.Internal.Show.Show Shikumi.Tool.Output.ToolOutput
+ Shikumi.Tool.Output: renderToolOutput :: ToolOutput -> Text
+ Shikumi.Tool.Output: textToolOutput :: Text -> ToolOutput
+ Shikumi.Tool.Output: toolOutputMessage :: ToolCall -> ToolOutput -> Message

Files

CHANGELOG.md view
@@ -2,6 +2,32 @@  ## Unreleased +## 0.4.0.0 — 2026-09-08++- Raise the internal `shikumi` bound to `^>=0.4.0.0`, and give the test suite's `shikumi-cache` dependency an explicit `^>=0.2.0.0` bound where it previously had none.++- Verify released Responses wire schemas, exact opaque checkpoint replay, call IDs, origin rejection, terminal errors, retries, cache observations and server cleanup with loopback integration tests.++- Write version-2 reasoning-safe checkpoints while reading version 1. Add safe summary rendering, explicit-model startup and pure restart; defer unsafe compaction and reject incompatible continuation before dispatch. Public API additions require a PVP review at release.++- Preserve structured refusals in defensive ReAct response checks and use shared error rendering for tool observations; partial refused tool calls never dispatch.++- Upgrade the dependency on `mori://shinzui/baikai/packages/baikai` to `>=0.7.0.0 && <0.8`. Preserve cost basis and usage availability in agent checkpoints, including exact rational costs and legacy checkpoint decoding.++### Added++- Rich `ToolOutput` results and runtime-schema dynamic tool registration, with+  compatibility text projections.+- Completed-turn ReAct sessions with original native call IDs, validated final+  submission, versioned checkpoints, continuation, and whole-exchange compaction.+  Legacy ReAct extraction and CodeAct trajectory APIs remain unchanged.++### Changed++- Widened the `http-client-tls` upper bound to admit 0.4. The web tool uses+  the unchanged `tlsManagerSettings` API, and 0.4 is the compatible line for+  the TLS 2.x / Crypton 1.1 cohort selected by baikai 0.6.+ ## 0.3.0.3 — 2026-08-29  ### Changed
shikumi-tools.cabal view
@@ -1,6 +1,6 @@ cabal-version:   3.4 name:            shikumi-tools-version:         0.3.0.3+version:         0.4.0.0 synopsis:   Typed tools and ReAct agents for shikumi LM programs (EP-11) @@ -43,22 +43,26 @@   import:          common-options   hs-source-dirs:  src   exposed-modules:+    Shikumi.Agent.History     Shikumi.Agent.ReAct     Shikumi.CodeExec.CodeAct     Shikumi.CodeExec.Interpreter     Shikumi.CodeExec.ProgramOfThought     Shikumi.CodeExec.Prompt+    Shikumi.CodeExec.RLM+    Shikumi.CodeExec.Session     Shikumi.Tool     Shikumi.Tool.Builtin     Shikumi.Tool.Builtin.Fs     Shikumi.Tool.Builtin.Shell     Shikumi.Tool.Builtin.Web     Shikumi.Tool.Env+    Shikumi.Tool.Output     Shikumi.Tool.Web    build-depends:     , aeson            >=2.2      && <2.3-    , baikai           >=0.6      && <0.7+    , baikai           >=0.7.0.0  && <0.8     , base             >=4.20     && <5     , bytestring       >=0.11     && <0.13     , containers       >=0.6      && <0.9@@ -67,12 +71,12 @@     , filepath         >=1.4      && <1.6     , generic-lens     >=2.2      && <2.4     , http-client      >=0.7      && <0.8-    , http-client-tls  >=0.3      && <0.4+    , http-client-tls  >=0.3      && <0.5     , http-types       >=0.12     && <0.13     , lens             ^>=5.3     , process          >=1.6      && <1.7     , regex-tdfa       >=1.3      && <1.4-    , shikumi          ^>=0.3.0.0+    , shikumi          ^>=0.4.0.0     , text             ^>=2.1     , vector           >=0.13     && <0.14 @@ -84,25 +88,32 @@   ghc-options:    -threaded -with-rtsopts=-N   other-modules:     AcceptanceSpec+    AgentHistorySpec     BuiltinAcceptanceSpec     CodeActSpec     CompactionSpec     EnvSpec     Fixtures     FsSpec-    MockLLM     ProgramOfThoughtSpec     ProtocolSpec+    ReActSessionExample     ReActSpec+    ResponsesIntegrationSpec+    ResponsesSchemaSpec     RestrictedSpec+    RLMExample+    RLMSpec     SchemaSpec+    SessionSpec     ShellSpec+    ToolOutputSpec     ToolSpec     WebSpec    build-depends:     , aeson-    , baikai         >=0.6      && <0.7+    , baikai           >=0.7.0.0  && <0.8     , base     , bytestring     , containers@@ -111,8 +122,10 @@     , filepath     , generic-lens     , lens-    , shikumi        ^>=0.3.0.0-    , shikumi-tools  ^>=0.3.0.0+    , shikumi          ^>=0.4.0.0+    , shikumi-cache    ^>=0.2.0.0+    , shikumi-testing  ^>=0.1.0.0+    , shikumi-tools    ^>=0.4.0.0     , tasty     , tasty-hunit     , text
+ src/Shikumi/Agent/History.hs view
@@ -0,0 +1,338 @@+-- | Versioned, completed-exchange checkpoints. Registries and closures stay caller-owned.+module Shikumi.Agent.History+  ( ReActSession,+    HistoryError (..),+    HistoryEntry (..),+    newSession,+    sessionOrigin,+    sessionPrefix,+    bindSessionOrigin,+    protectSession,+    compactionSafe,+    renderSummaryEntry,+    renderSessionSummaryInput,+    restartSessionFromSummary,+    sessionProtocol,+    sessionFingerprint,+    sessionTools,+    sessionTurns,+    sessionIterations,+    sessionFinished,+    sessionCompactedThrough,+    auditHistory,+    promptEntries,+    promptMessages,+    entryMessages,+    appendUser,+    appendExchange,+    validateCalls,+    parsePromptActions,+    validateSession,+    compactSession,+    encodeSession,+    decodeSession,+  )+where++import Baikai qualified as B+import Baikai.Cost qualified as BC+import Control.Lens ((&), (.~), (^.))+import Control.Monad (foldM, unless, when)+import Data.Aeson+import Data.Aeson.Types (Parser, parseEither)+import Data.Bifunctor (first)+import Data.ByteString.Lazy qualified as LBS+import Data.Generics.Labels ()+import Data.Ratio (denominator, numerator, (%))+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.Encoding (decodeUtf8, encodeUtf8)+import Data.Vector qualified as V+import Shikumi.LLM.Continuation (RequestOrigin, contextIdentity, hasOpaqueContinuation)+import Shikumi.Tool.Output++newtype HistoryError = HistoryError Text deriving stock (Eq, Show)++-- | Rejected responses are audit-only; their correction replaces them in requests.+-- Accepted exchanges contain the original assistant payload and ordered results.+data HistoryEntry+  = UserTurn !B.UserPayload+  | Exchange !B.AssistantPayload ![(B.ToolCall, ToolOutput)] !(Maybe Text)+  deriving stock (Eq, Show)++data ReActSession = ReActSession+  { sessionProtocol :: !Text,+    sessionFingerprint :: !Value,+    sessionTools :: !Value,+    sessionTurns :: !Int,+    sessionIterations :: !Int,+    sessionFinished :: !Bool,+    auditHistory :: ![HistoryEntry],+    compactedThrough :: !Int,+    summary :: !(Maybe Text),+    sessionOrigin :: !(Maybe RequestOrigin),+    sessionPrefix :: !(Maybe Value)+  }+  deriving stock (Eq, Show)++newSession :: Text -> Value -> Value -> Text -> Either HistoryError ReActSession+newSession proto fingerprint tools input = do+  let s = ReActSession proto fingerprint tools 0 0 False [userEntry input] 0 Nothing Nothing Nothing+  validateSession s+  pure s++userEntry :: Text -> HistoryEntry+userEntry t = UserTurn (B.UserPayload (V.singleton (B.UserText (B.TextContent t))) Nothing)++sessionCompactedThrough :: ReActSession -> Int+sessionCompactedThrough = compactedThrough++appendUser :: Text -> ReActSession -> Either HistoryError ReActSession+appendUser t s = do+  validateSession s+  pure s {auditHistory = auditHistory s <> [userEntry t], sessionIterations = 0, sessionFinished = False}++callsOf :: B.AssistantPayload -> [B.ToolCall]+callsOf p = [c | B.AssistantToolCall c <- V.toList (p ^. #content)]++-- | Validate the whole native proposal before dispatch, including prior accepted IDs.+validateCalls :: [Text] -> [B.ToolCall] -> Either HistoryError ()+validateCalls previous calls = do+  _ <- foldM check (Set.fromList previous) calls+  pure ()+  where+    check seen c+      | T.null (T.strip (c ^. #id_)) = Left (HistoryError "empty tool call ID")+      | Set.member (c ^. #id_) seen = Left (HistoryError "duplicate tool call ID")+      | B.isCutOffToolCall c = Left (HistoryError "cut-off tool arguments")+      | otherwise = Right (Set.insert (c ^. #id_) seen)++appendExchange :: B.AssistantPayload -> [(B.ToolCall, ToolOutput)] -> Maybe Text -> Bool -> ReActSession -> Either HistoryError ReActSession+appendExchange p outputs correction finished s = do+  let next = s {auditHistory = auditHistory s <> [Exchange p outputs correction], sessionTurns = sessionTurns s + 1, sessionIterations = sessionIterations s + 1, sessionFinished = finished}+  validateSession next+  pure next++validateSession :: ReActSession -> Either HistoryError ()+validateSession s = do+  ensure (sessionProtocol s `elem` ["native", "prompt"]) "unknown protocol"+  ensure (not (null (auditHistory s))) "empty history"+  case auditHistory s of+    UserTurn _ : _ -> pure ()+    _ -> Left (HistoryError "history must start with a user turn")+  ensure (sessionTurns s == length [() | Exchange {} <- auditHistory s]) "turn count mismatch"+  ensure (sessionIterations s >= 0 && sessionIterations s <= sessionTurns s) "invalid iteration count"+  ensure (compactedThrough s >= 0 && compactedThrough s <= length (auditHistory s)) "invalid compaction boundary"+  ensure ((compactedThrough s == 0) == (summary s == Nothing)) "missing compaction summary"+  ensure (sessionIterations s == length (takeWhile isExchange (reverse (auditHistory s)))) "iteration count mismatch"+  when (sessionFinished s) $ case reverse (auditHistory s) of+    Exchange _ [(call, _)] Nothing : _ -> ensure (call ^. #name == "shikumi_submit_final") "finished without final submission"+    _ -> Left (HistoryError "finished without accepted exchange")+  _ <- foldM validateEntry [] (auditHistory s)+  pure ()+  where+    isExchange Exchange {} = True+    isExchange _ = False+    validateEntry ids (UserTurn _) = pure ids+    validateEntry ids (Exchange p outputs correction) = case correction of+      Just reason -> do+        ensure (not (T.null reason) && null outputs) "rejected exchange has results or no correction"+        pure ids+      Nothing -> do+        let calls = map fst outputs+        ensure (not (null calls)) "unresolved exchange"+        validateCalls ids calls+        ensure (not (any ((== "shikumi_submit_final") . (^. #name)) calls) || length calls == 1) "mixed final submission"+        when (sessionProtocol s == "native") $+          ensure (callsOf p == calls) "results do not match assistant calls in order"+        when (sessionProtocol s == "prompt") $ do+          ensure (null (callsOf p)) "prompt exchange contains native calls"+          ensure (all (T.isPrefixOf "prompt-" . (^. #id_)) calls) "invalid synthetic prompt ID"+          let text = T.concat [t | B.AssistantText (B.TextContent t) <- V.toList (p ^. #content)]+          actions <- parsePromptActions text+          ensure (actions == map (\c -> (c ^. #name, c ^. #arguments)) calls) "prompt results do not match actions"+        pure (ids <> map (^. #id_) calls)++-- | Shared proposal/checkpoint parser so acceptance cannot diverge after dispatch.+parsePromptActions :: Text -> Either HistoryError [(Text, Value)]+parsePromptActions text = first (HistoryError . T.pack) $ do+  let trimmed = T.strip text+      stripped = case T.stripPrefix "```" trimmed of+        Nothing -> trimmed+        Just rest -> T.strip (fst (T.breakOn "```" (T.drop 1 (T.dropWhile (/= '\n') rest))))+  value <- eitherDecodeStrict (encodeUtf8 stripped)+  parseEither (withObject "proposal" (\o -> o .: "calls" >>= traverse (withObject "call" (\c -> (,) <$> c .: "tool" <*> c .: "args")))) value++ensure :: Bool -> Text -> Either HistoryError ()+ensure ok message = unless ok (Left (HistoryError message))++entryMessages :: Text -> HistoryEntry -> [B.Message]+entryMessages _ (UserTurn p) = [B.UserMessage p]+entryMessages _ (Exchange _ _ (Just correction)) = [B.user ("Protocol correction: " <> correction)]+entryMessages proto (Exchange p outputs Nothing)+  | proto == "native" = B.AssistantMessage p : map (uncurry toolOutputMessage) outputs+  | otherwise = B.AssistantMessage p : [B.user (T.intercalate "\n" [c ^. #name <> ": " <> renderToolOutput o | (c, o) <- outputs])]++promptEntries :: ReActSession -> [HistoryEntry]+promptEntries s = maybe [] (\t -> [userEntry ("Earlier conversation summary: " <> t)]) (summary s) <> drop (compactedThrough s) (auditHistory s)++promptMessages :: ReActSession -> [B.Message]+promptMessages s = concatMap (entryMessages (sessionProtocol s)) (promptEntries s)++-- | Replace only an old prefix of complete entries; the full audit is unchanged.+compactSession :: Int -> Text -> ReActSession -> Either HistoryError ReActSession+compactSession through text s = do+  ensure (compactionSafe through s) "compaction would change the prefix of opaque reasoning; explicitly restart from a caller-approved summary"+  ensure (through > compactedThrough s) "compaction must advance"+  let next = s {compactedThrough = through, summary = Just text}+  validateSession next+  pure next++-- | Any retained opaque exchange still depends on the original prefix.+compactionSafe :: Int -> ReActSession -> Bool+compactionSafe through s = not (hasOpaqueContinuation (concatMap (entryMessages (sessionProtocol s)) (drop through (auditHistory s))))++bindSessionOrigin :: Maybe RequestOrigin -> ReActSession -> ReActSession+bindSessionOrigin origin s = s {sessionOrigin = origin}++-- | Capture the exact request view after completing the exchange. This is+-- compatibility data, not an authenticity proof for untrusted checkpoints.+protectSession :: B.Context -> ReActSession -> ReActSession+protectSession ctx s = s {sessionPrefix = if hasOpaqueContinuation (promptMessages s) then Just (contextIdentity ctx) else Nothing}++-- | Structural projection: provider-owned reasoning and unknown rich extensions+-- never enter a summarizer request. The full audit retains all original bytes.+renderSummaryEntry :: HistoryEntry -> Text+renderSummaryEntry (UserTurn p) = T.intercalate "\n" [case b of B.UserText (B.TextContent t) -> t; B.UserImage _ -> "[image omitted]" | b <- V.toList (p ^. #content)]+renderSummaryEntry (Exchange p outputs correction) = T.intercalate "\n" (map block (V.toList (p ^. #content)) <> map output outputs <> maybe [] (\t -> ["Correction: " <> t]) correction)+  where+    block (B.AssistantText (B.TextContent t)) = t+    block (B.AssistantThinking t)+      | t ^. #redacted = "[redacted reasoning omitted]"+      | otherwise = t ^. #thinking <> "\n[reasoning continuation omitted]"+    block (B.AssistantToolCall c) = c ^. #name <> " " <> jsonText (c ^. #arguments)+    output (c, o) =+      c ^. #name+        <> ": "+        <> T.intercalate+          "\n"+          ( (if result o ^. #isError then ["Tool error"] else [])+              <> [case b of B.ToolResultText (B.TextContent t) -> t; B.ToolResultImage _ -> "[image omitted]" | b <- V.toList (result o ^. #content)]+              <> maybe [] (\v -> ["Structured JSON: " <> jsonText v]) (structuredContent o)+              <> ["[extension blocks omitted]" | not (null (extensionBlocks o))]+          )+    jsonText = decodeUtf8 . LBS.toStrict . encode++renderSessionSummaryInput :: ReActSession -> Text+renderSessionSummaryInput = T.intercalate "\n" . map renderSummaryEntry . auditHistory++-- | A separate, unbound conversation; no IO and no old native exchanges.+restartSessionFromSummary :: Text -> ReActSession -> Either HistoryError ReActSession+restartSessionFromSummary text s = do+  validateSession s+  ensure (not (T.null (T.strip text))) "restart summary must not be empty"+  newSession (sessionProtocol s) (sessionFingerprint s) (sessionTools s) ("Earlier conversation summary: " <> text)++-- The transfer format is local and explicit, independent of Baikai's Message JSON.+encodeSession :: ReActSession -> Value+encodeSession s =+  object+    [ "version" .= (2 :: Int),+      "origin" .= sessionOrigin s,+      "protectedPrefix" .= sessionPrefix s,+      "protocol" .= sessionProtocol s,+      "fingerprint" .= sessionFingerprint s,+      "tools" .= sessionTools s,+      "turns" .= sessionTurns s,+      "iterations" .= sessionIterations s,+      "finished" .= sessionFinished s,+      "history" .= map encodeEntry (auditHistory s),+      "compactedThrough" .= compactedThrough s,+      "summary" .= summary s+    ]++decodeSession :: Value -> Either HistoryError ReActSession+decodeSession value = do+  s <- first (HistoryError . T.pack) (parseEither parseSession value)+  validateSession s+  pure s++parseSession :: Value -> Parser ReActSession+parseSession = withObject "ReActSession" $ \o -> do+  version <- o .: "version"+  unless (version `elem` [1, 2 :: Int]) (fail "unknown checkpoint version")+  ReActSession+    <$> o .: "protocol"+    <*> o .: "fingerprint"+    <*> o .: "tools"+    <*> o .: "turns"+    <*> o .: "iterations"+    <*> o .: "finished"+    <*> (o .: "history" >>= traverse parseEntry)+    <*> o .: "compactedThrough"+    <*> o .: "summary"+    <*> (if version == 1 then pure Nothing else o .: "origin")+    <*> (if version == 1 then pure Nothing else o .: "protectedPrefix")++encodeEntry :: HistoryEntry -> Value+encodeEntry (UserTurn p) = object ["kind" .= String "user", "content" .= (p ^. #content), "timestamp" .= (p ^. #timestamp)]+encodeEntry (Exchange p outputs correction) =+  object+    [ "kind" .= String "exchange",+      "content" .= (p ^. #content),+      "timestamp" .= (p ^. #timestamp),+      "stop" .= (p ^. #stopReason),+      "error" .= (p ^. #errorMessage),+      "usage" .= encodeUsage (p ^. #usage),+      "results" .= [object ["call" .= c, "blocks" .= (result r ^. #content), "isError" .= (result r ^. #isError), "structured" .= structuredContent r, "extensions" .= extensionBlocks r] | (c, r) <- outputs],+      "correction" .= correction+    ]++parseEntry :: Value -> Parser HistoryEntry+parseEntry = withObject "HistoryEntry" $ \o -> do+  kind <- o .: "kind" :: Parser Text+  case kind of+    "user" -> UserTurn <$> (B.UserPayload <$> o .: "content" <*> o .: "timestamp")+    "exchange" ->+      Exchange+        <$> (B.AssistantPayload <$> o .: "content" <*> (o .: "usage" >>= parseUsage) <*> o .: "stop" <*> o .: "error" <*> o .: "timestamp")+        <*> ( o .: "results"+                >>= traverse+                  ( withObject "result" $ \r ->+                      (,) <$> r .: "call" <*> (ToolOutput <$> (B.ToolResult <$> r .: "blocks" <*> r .: "isError") <*> r .: "structured" <*> r .: "extensions")+                  )+            )+        <*> o .: "correction"+    _ -> fail "unknown history entry"++-- Costs use integer ratios, preserving even non-terminating rational values exactly.+encodeUsage :: B.Usage -> Value+encodeUsage u =+  object+    [ "tokens" .= toJSON u,+      "costRatios"+        .= map+          (\r -> (numerator r, denominator r))+          [c ^. #usd, b ^. #inputUsd, b ^. #outputUsd, b ^. #cachedInputUsd, b ^. #cachedWriteUsd]+    ]+  where+    c = u ^. #cost+    b = c ^. #breakdown++parseUsage :: Value -> Parser B.Usage+parseUsage = withObject "usage" $ \o -> do+  ratios <- o .: "costRatios" :: Parser [(Integer, Integer)]+  unless (all ((> 0) . snd) ratios) (fail "invalid cost denominator")+  basis <- o .: "tokens" >>= withObject "tokens" (\t -> t .: "cost" >>= withObject "cost" (\c -> c .:? "basis" .!= mempty))+  cost <- case map (uncurry (%)) ratios of+    [total, i, out, cached, written] -> pure (BC.Cost total (BC.CostBreakdown i out cached written) basis)+    _ -> fail "invalid cost breakdown"+  u <-+    o .: "tokens"+      >>= withObject+        "tokens"+        ( \t ->+            B.Usage <$> t .: "input_tokens" <*> t .: "output_tokens" <*> t .: "cache_read_tokens" <*> t .: "cache_write_tokens" <*> t .: "reasoning_tokens" <*> t .: "total_tokens" <*> t .:? "availability" <*> pure cost+        )+  pure (u & #cost .~ cost)
src/Shikumi/Agent/ReAct.hs view
@@ -36,6 +36,13 @@     -- * Building agents     react,     reactWithTrajectory,+    SessionResult (..),+    finalToolName,+    startSession,+    startSessionWithModel,+    advanceSession,+    continueSession,+    runSession,      -- * The protocol seam     Proposal (..),@@ -69,8 +76,9 @@     flattenAssistantBlocks,     user,   )+import Baikai qualified as B import Control.Lens ((&), (.~), (^.))-import Data.Aeson (Value (..), eitherDecodeStrict, encode)+import Data.Aeson (Value (..), eitherDecodeStrict, encode, object, (.=)) import Data.Aeson.KeyMap qualified as KM import Data.ByteString.Lazy qualified as LBS import Data.Generics.Labels ()@@ -86,13 +94,17 @@ import Effectful.Error.Static (Error, catchError, throwError) import GHC.Generics (Generic) import Shikumi.Adapter (ModelCapability (..), ToPrompt (toPrompt), attachSchema, capabilityFor)+import Shikumi.Agent.History (ReActSession)+import Shikumi.Agent.History qualified as H import Shikumi.Compaction (CompactionConfig (..), compactTail, defaultCompactionConfig, usageExceedsWindow)-import Shikumi.Error (ShikumiError (..))+import Shikumi.Error (ShikumiError (..), fromBaikaiError) import Shikumi.LLM (LLM, complete)+import Shikumi.LLM.Continuation qualified as C import Shikumi.Program (Program (FMap), embed)-import Shikumi.Schema (FromModel, ToSchema, Validatable, parseOutput, toSchema)+import Shikumi.Schema (FromModel, ToSchema, Validatable, fromModelChecked, parseOutput, toSchema) import Shikumi.Signature (Signature, getInstruction)-import Shikumi.Tool (ToolRegistry, registryBaikai, registryTools, renderToolError, runToolCall, someToolDescription, someToolName, someToolSchema)+import Shikumi.Tool (ToolRegistry, registryBaikai, registryNames, registryTools, renderToolError, runToolCall, runToolCallOutput, someToolDescription, someToolName, someToolSchema)+import Shikumi.Tool.Output (ToolOutput (..), textToolOutput)  -- --------------------------------------------------------------------------- -- The trajectory data model@@ -574,3 +586,189 @@ -- | Compact-encode a JSON value to text. encodeText :: Value -> Text encodeText = decodeUtf8 . LBS.toStrict . encode++-- ---------------------------------------------------------------------------+-- Completed-turn sessions (the legacy loop above retains its extraction call)+-- ---------------------------------------------------------------------------++-- | A pause always contains complete exchanges, including all dispatched results.+data SessionResult o = SessionPaused ReActSession | SessionFinished o ReActSession+  deriving stock (Eq, Show)++finalToolName :: Text+finalToolName = "shikumi_submit_final"++-- | The fingerprint stores exact schema and instruction values (no lossy hash).+sessionIdentity :: forall i o. (ToSchema o) => Signature i o -> Value+sessionIdentity sig =+  object+    [ "instruction" .= getInstruction sig,+      "inputFields" .= show (sig ^. #inputFields),+      "outputSchema" .= toSchema (Proxy @o)+    ]++sessionToolSchemas :: ToolRegistry -> Value+sessionToolSchemas reg =+  object+    [ "tools" .= [object ["name" .= someToolName t, "schema" .= someToolSchema t, "description" .= someToolDescription t] | t <- registryTools reg]+    ]++historyOrThrow :: (Error ShikumiError :> es) => Either H.HistoryError a -> Eff es a+historyOrThrow = either (\(H.HistoryError t) -> throwError (ValidationFailure ("ReAct history: " <> t))) pure++protocolText :: ReActConfig -> Text+protocolText cfg = case resolveProtocolKind (protocol cfg) emptyModel of+  ProtocolNative -> "native"+  _ -> "prompt"++checkSession :: (ToSchema o, Error ShikumiError :> es) => Signature i o -> ToolRegistry -> ReActConfig -> ReActSession -> Eff es ()+checkSession sig reg cfg s = do+  historyOrThrow (H.validateSession s)+  if finalToolName `elem` registryNames reg+    then throwError (ValidationFailure "Registry collides with shikumi_submit_final")+    else pure ()+  if H.sessionFingerprint s == sessionIdentity sig && H.sessionTools s == sessionToolSchemas reg && H.sessionProtocol s == protocolText cfg+    then pure ()+    else throwError (ValidationFailure "Incompatible ReAct checkpoint signature, tool registry, or protocol; explicitly start a new session")++-- | Start without making a model call. Auto resolves as in the legacy API.+startSession :: (ToPrompt i, ToSchema o, Error ShikumiError :> es) => Signature i o -> ToolRegistry -> ReActConfig -> i -> Eff es ReActSession+startSession sig reg cfg input = do+  s <- historyOrThrow (H.newSession (protocolText cfg) (sessionIdentity sig) (sessionToolSchemas reg) (toPrompt input))+  checkSession sig reg cfg s+  pure s++-- | Bind a requested target before the first call. Use a router to supply+-- credentials and model capabilities; checkpoints retain only public identity.+startSessionWithModel :: (ToPrompt i, ToSchema o, Error ShikumiError :> es) => Model -> Signature i o -> ToolRegistry -> ReActConfig -> i -> Eff es ReActSession+startSessionWithModel model sig reg cfg input = do+  s <- startSession sig reg cfg input+  case C.requestOrigin model of+    Nothing -> throwError (ValidationFailure "Explicit session model needs a provider, API, model and credential-free endpoint")+    Just origin -> pure (H.bindSessionOrigin (Just origin) s)++-- | Append a new user turn to a validated checkpoint, resetting its iteration budget.+-- This never dispatches old exchanges. Use 'runSession' or 'advanceSession' next.+continueSession :: (ToPrompt i, ToSchema o, Error ShikumiError :> es) => Signature i o -> ToolRegistry -> ReActConfig -> i -> ReActSession -> Eff es ReActSession+continueSession sig reg cfg input s = do+  checkSession sig reg cfg s+  historyOrThrow (H.appendUser (toPrompt input) s)++-- | Make at most one proposal (plus one bounded context retry and summaries).+-- All calls in an accepted proposal are completed sequentially before returning.+advanceSession :: forall i o es. (ToSchema o, FromModel o, Validatable o, LLM :> es, Error ShikumiError :> es) => Signature i o -> ToolRegistry -> ReActConfig -> ReActSession -> Eff es (SessionResult o)+advanceSession sig reg cfg original = do+  checkSession sig reg cfg original+  if H.sessionFinished original+    then throwError (ValidationFailure "Finished session requires a new user turn before advancing")+    else+      if H.sessionIterations original >= max 0 (maxIters cfg)+        then pure (SessionPaused original)+        else do+          (requestedSession, resp) <- requestRecover original+          let resolved = C.requestOrigin (resp ^. #model)+              s = H.bindSessionOrigin resolved requestedSession+          let payload = resp ^. #message+              reject reason = SessionPaused . seal <$> historyOrThrow (H.appendExchange payload [] (Just reason) False s)+          if payload ^. #stopReason == B.ErrorReason+            then throwError $ case resp ^. #errorInfo of+              Just err -> fromBaikaiError err+              Nothing -> ProviderFailure (maybe "Model response failed" id (payload ^. #errorMessage))+            else pure ()+          -- A conforming response echoes the resolved request identity. Never+          -- bless old unknown opaque history using a newly selected target.+          case H.sessionOrigin requestedSession of+            Just expected | resolved /= Just expected -> throwError (ValidationFailure "ReAct response request identity differs from session target")+            _ -> pure ()+          case resolved of+            Just _ -> either throwError pure (C.validateReplayOrigin (resp ^. #model) [B.AssistantMessage payload])+            Nothing -> pure ()+          case parseCalls s resp of+            Left reason -> reject reason+            Right calls -> case H.validateCalls (priorIds s) calls of+              Left (H.HistoryError reason) -> reject reason+              Right ()+                | null calls -> reject "Submit a tool call or shikumi_submit_final with the answer arguments."+                | any ((== finalToolName) . (^. #name)) calls -> case calls of+                    [call] -> case fromModelChecked (call ^. #arguments) of+                      Left err -> reject ("Invalid final submission: " <> T.pack (show err))+                      Right answer -> do+                        finished <- historyOrThrow (H.appendExchange payload [(call, textToolOutput "Final submission accepted.")] Nothing True s)+                        pure (SessionFinished answer (seal finished))+                    _ -> reject "Final submission cannot be mixed with other calls."+                | otherwise -> do+                    outputs <-+                      traverse+                        ( \call -> do+                            output <- runToolCallOutput reg call+                            pure (call, either (\err -> ToolOutput (B.toolResultErrorText (renderToolError err)) Nothing []) id output)+                        )+                        calls+                    next <- historyOrThrow (H.appendExchange payload outputs Nothing False s)+                    compacted <-+                      if usageExceedsWindow (compaction cfg) (resp ^. #model) (payload ^. #usage)+                        then if H.compactionSafe (boundary next) next then forceCompact (seal next) else pure (seal next)+                        else pure (seal next)+                    pure (SessionPaused compacted)+  where+    priorIds s = [c ^. #id_ | H.Exchange _ results Nothing <- H.auditHistory s, (c, _) <- results]+    parseCalls s resp+      | H.sessionProtocol s == "native" = Right (toolCallsOf resp)+      | not (null (toolCallsOf resp)) = Left "Prompt protocol requires JSON text, not native tool calls."+      | otherwise = do+          actions <- either (\(H.HistoryError t) -> Left t) Right (H.parsePromptActions (responseText resp))+          pure [B.ToolCall ("prompt-" <> T.pack (show (H.sessionTurns s + 1)) <> "-" <> T.pack (show n)) name args | (n, (name, args)) <- zip [1 :: Int ..] actions]+    requestContext s =+      let native = H.sessionProtocol s == "native"+          finalTool = B.emptyTool & #name .~ finalToolName & #description .~ "Submit the final validated answer, alone." & #parameters .~ toSchema (Proxy @o)+          sys =+            getInstruction sig+              <> "\nSubmit your final answer using shikumi_submit_final alone. Its arguments must match: "+              <> encodeText (toSchema (Proxy @o))+              <> if native then "" else "\n" <> toolMenu reg <> "\nReply with JSON {\"calls\":[{\"tool\":\"<name>\",\"args\":{...}}]}. Use the same form for shikumi_submit_final."+          ctx = buildCtx sys (H.promptMessages s) (if native then registryBaikai reg <> V.singleton finalTool else V.empty) Nothing+       in ctx+    seal s = H.protectSession (requestContext s) s+    request s = do+      let ctx = requestContext s+          model = maybe emptyModel C.originModel (H.sessionOrigin s)+          opts = C.stampContinuation (H.sessionOrigin s) (H.sessionPrefix s) (emptyOptions & #toolChoice .~ (if H.sessionProtocol s == "native" then Just ToolChoiceAuto else Nothing))+      -- Unknown opaque checkpoints fail even for custom interpreters. Resolved+      -- target checks also run after routing and at built-in boundaries.+      if C.hasOpaqueContinuation (H.promptMessages s) && H.sessionOrigin s == Nothing+        then throwError (ValidationFailure "ReAct opaque history has unknown origin; explicitly restart from a caller-approved summary")+        else pure ()+      complete model ctx opts+    requestRecover s =+      catchError+        ((s,) <$> request s)+        ( \_cs -> \case+            ContextWindowExceeded {} | enabled (compaction cfg) -> do+              compacted <- forceCompact s+              (compacted,) <$> request compacted+            err -> throwError err+        )+    boundary s = length (H.auditHistory s) - max 0 (keepRecent (compaction cfg))+    forceCompact s = do+      if H.compactionSafe (boundary s) s+        then pure ()+        else historyOrThrow (Left (H.HistoryError "context recovery would change opaque reasoning prefix; explicitly restart from a caller-approved summary"))+      let entries = H.promptEntries s+          keep = max 0 (keepRecent (compaction cfg))+          through = length (H.auditHistory s) - keep+          render = H.renderSummaryEntry+      if length entries <= keep || through <= H.sessionCompactedThrough s+        then pure s+        else do+          texts <- compactTail (compaction cfg) emptyModel id id (map render entries)+          case texts of+            t : _ -> seal <$> historyOrThrow (H.compactSession through t s)+            [] -> pure s++-- | Advance until validated final submission or the per-user-turn iteration limit.+runSession :: (ToSchema o, FromModel o, Validatable o, LLM :> es, Error ShikumiError :> es) => Signature i o -> ToolRegistry -> ReActConfig -> ReActSession -> Eff es (SessionResult o)+runSession sig reg cfg s = do+  result <- advanceSession sig reg cfg s+  case result of+    SessionPaused next | H.sessionIterations next < max 0 (maxIters cfg) -> runSession sig reg cfg next+    _ -> pure result
+ src/Shikumi/CodeExec/RLM.hs view
@@ -0,0 +1,166 @@+{-# 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."+    ]
+ src/Shikumi/CodeExec/Session.hs view
@@ -0,0 +1,263 @@+-- | 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"]
src/Shikumi/Tool.hs view
@@ -33,6 +33,9 @@     someToolSchema,     lowerSomeTool,     runErased,+    mkDynTool,+    runErasedOutput,+    runToolCallOutput,     ToolRegistry,     mkRegistry,     registryLookup,@@ -63,9 +66,10 @@ import Data.Vector qualified as V import Effectful (Eff, (:>)) import Effectful.Error.Static (Error, catchError, throwError)-import Shikumi.Error (ShikumiError (..))+import Shikumi.Error (ShikumiError (..), renderShikumiError) import Shikumi.LLM (LLM) import Shikumi.Schema (FromModel, ToSchema, Validatable, fromModelChecked, toSchema)+import Shikumi.Tool.Output (ToolOutput, renderToolOutput, textToolOutput)  -- --------------------------------------------------------------------------- -- The typed tool@@ -117,22 +121,41 @@     (ToSchema i, FromModel i, Validatable i, ToJSON o) =>     Tool i o ->     SomeTool+  DynTool ::+    Text ->+    Text ->+    Value ->+    (forall es. (LLM :> es, Error ShikumiError :> es) => Value -> Eff es (Either ToolError ToolOutput)) ->+    SomeTool +-- | Register a runtime schema and rich-result body without an input Haskell type.+mkDynTool ::+  Text ->+  Text ->+  Value ->+  (forall es. (LLM :> es, Error ShikumiError :> es) => Value -> Eff es (Either ToolError ToolOutput)) ->+  SomeTool+mkDynTool = DynTool+ -- | The name of an erased tool. someToolName :: SomeTool -> Text someToolName (SomeTool t) = name t+someToolName (DynTool n _ _ _) = n  -- | The description of an erased tool. someToolDescription :: SomeTool -> Text someToolDescription (SomeTool t) = description t+someToolDescription (DynTool _ d _ _) = d  -- | The derived input JSON Schema of an erased tool. someToolSchema :: SomeTool -> Value someToolSchema (SomeTool t) = toolSchemaOf t+someToolSchema (DynTool _ _ s _) = s  -- | Lower an erased tool to baikai's wire tool. lowerSomeTool :: SomeTool -> B.Tool lowerSomeTool (SomeTool t) = lowerTool t+lowerSomeTool (DynTool n d s _) = emptyTool & #name .~ n & #description .~ d & #parameters .~ s  -- | Run an erased tool against a raw JSON arguments object: decode to the hidden -- @i@, run the body, encode the @o@ to text. A decode failure becomes@@ -144,11 +167,20 @@   SomeTool ->   Value ->   Eff es (Either ToolError Text)-runErased (SomeTool t) args =+runErased st args = fmap (fmap renderToolOutput) (runErasedOutput st args)++-- | Rich dispatch; dynamic bodies share typed tools' infrastructure error policy.+runErasedOutput :: (LLM :> es, Error ShikumiError :> es) => SomeTool -> Value -> Eff es (Either ToolError ToolOutput)+runErasedOutput (DynTool n _ _ body) args =+  body args `catchError` \_cs e ->+    if isInfraToolError e+      then throwError e+      else pure (Left (ToolRunFailed n (shikumiErrorText e)))+runErasedOutput (SomeTool t) args =   case fromModelChecked args of     Left err -> pure (Left (ToolArgsInvalid (name t) (shikumiErrorText err)))     Right i ->-      (Right . encodeText <$> run t i)+      (Right . textToolOutput . encodeText <$> run t i)         `catchError` \_cs e ->           if isInfraToolError e             then throwError e@@ -212,6 +244,12 @@     Nothing -> pure (Left (ToolNotFound (tc ^. #name)))     Just st -> runErased st (tc ^. #arguments) +-- | Dispatch without projecting away structured result fields.+runToolCallOutput :: (LLM :> es, Error ShikumiError :> es) => ToolRegistry -> ToolCall -> Eff es (Either ToolError ToolOutput)+runToolCallOutput reg tc = case registryLookup (tc ^. #name) reg of+  Nothing -> pure (Left (ToolNotFound (tc ^. #name)))+  Just st -> runErasedOutput st (tc ^. #arguments)+ -- --------------------------------------------------------------------------- -- Helpers -- ---------------------------------------------------------------------------@@ -220,18 +258,9 @@ encodeText :: (ToJSON a) => a -> Text encodeText = decodeUtf8 . LBS.toStrict . encode --- | The human-readable payload of a 'ShikumiError' (its single 'Text' field).+-- | Shared human-readable error rendering for tool observations. shikumiErrorText :: ShikumiError -> Text-shikumiErrorText = \case-  InvalidJSON t -> t-  MissingField t -> "missing field " <> t-  SchemaMismatch t -> t-  ValidationFailure t -> t-  ProviderFailure t -> t-  ContextWindowExceeded t -> t-  Timeout t -> t-  BudgetExceeded t -> t-  CodeExecFailed t -> t+shikumiErrorText = renderShikumiError  -- | Which 'ShikumiError's must escape the agent loop rather than become -- observations. Budget and context-window exhaustion are infrastructure faults:
+ src/Shikumi/Tool/Output.hs view
@@ -0,0 +1,52 @@+-- | Lossless local tool results and explicit provider/text projections.+module Shikumi.Tool.Output+  ( ToolOutput (..),+    textToolOutput,+    renderToolOutput,+    toolOutputMessage,+  )+where++import Baikai qualified as B+import Control.Lens ((%~), (&), (^.))+import Data.Aeson (Value, encode, toJSON)+import Data.ByteString.Lazy qualified as LBS+import Data.Generics.Labels ()+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.Encoding (decodeUtf8)+import Data.Vector qualified as V++-- | Native content, optional structured data, and uninterpreted extension blocks.+-- Image preservation does not imply that a provider accepts image tool results.+data ToolOutput = ToolOutput+  { result :: !B.ToolResult,+    structuredContent :: !(Maybe Value),+    extensionBlocks :: ![Value]+  }+  deriving stock (Eq, Show)++textToolOutput :: Text -> ToolOutput+textToolOutput t = ToolOutput (B.toolResultText t) Nothing []++jsonText :: Value -> Text+jsonText = decodeUtf8 . LBS.toStrict . encode++extraText :: ToolOutput -> [Text]+extraText o =+  maybe [] (\v -> ["Structured JSON: " <> jsonText v]) (structuredContent o)+    <> map (("Extension JSON: " <>) . jsonText) (extensionBlocks o)++-- | Text projection preserves image bytes as labeled JSON rather than dropping them.+renderToolOutput :: ToolOutput -> Text+renderToolOutput o = (if result o ^. #isError then "Tool error: " else "") <> T.intercalate "\n" (map render (V.toList (result o ^. #content)) <> extraText o)+  where+    render (B.ToolResultText (B.TextContent t)) = t+    render (B.ToolResultImage img) = "Image JSON: " <> jsonText (toJSON img)++-- | Preserve native blocks and append explicitly labeled JSON text blocks.+toolOutputMessage :: B.ToolCall -> ToolOutput -> B.Message+toolOutputMessage call o =+  B.toolResultFromCall+    call+    (result o & #content %~ (<> V.fromList (map (B.ToolResultText . B.TextContent) (extraText o))))
test/AcceptanceSpec.hs view
@@ -24,7 +24,6 @@     weatherSignature,     weatherTool,   )-import MockLLM (runAgent) import Shikumi.Agent.ReAct   ( Action (..),     Step (..),@@ -34,6 +33,7 @@     defaultReActConfig,     reactWithTrajectory,   )+import Shikumi.Testing (runAgent) import Shikumi.Tool (toolSchemaOf) import Test.Tasty (TestTree, testGroup) import Test.Tasty.HUnit (Assertion, assertBool, assertFailure, testCase, (@?=))
+ test/AgentHistorySpec.hs view
@@ -0,0 +1,517 @@+{-# LANGUAGE DataKinds #-}++module AgentHistorySpec (tests) where++import Baikai qualified as B+import Baikai.Cost qualified as BC+import Baikai.Error (contentFiltered)+import Baikai.Usage qualified as BU+import Control.Lens ((&), (.~), (^.))+import Data.Aeson (Value (..), eitherDecode, encode, object, toJSON, (.=))+import Data.Aeson.Key qualified+import Data.Aeson.KeyMap qualified as KM+import Data.Generics.Labels ()+import Data.IORef+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as T+import Data.Vector qualified as V+import Effectful (Eff, IOE, liftIO, runEff)+import Effectful.Dispatch.Dynamic (interpret)+import Effectful.Error.Static (Error, runErrorNoCallStack, throwError)+import Fixtures+import GHC.Generics (Generic)+import ReActSessionExample qualified+import Shikumi.Adapter qualified+import Shikumi.Agent.History+import Shikumi.Agent.ReAct+import Shikumi.Compaction (CompactionConfig (..))+import Shikumi.Error (ShikumiError (..))+import Shikumi.LLM (LLM (..), complete)+import Shikumi.LLM.Continuation qualified as C+import Shikumi.Schema (FromModel, ToSchema, Validatable (..))+import Shikumi.Signature (Signature, mkSignature, setInstruction)+import Shikumi.Testing (mkTextResponse, mkToolCallResponse, mkToolCallsResponse)+import Shikumi.Tool+import Shikumi.Tool.Output+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit++newtype Positive = Positive {value :: Int}+  deriving stock (Eq, Show, Generic)+  deriving anyclass (ToSchema, FromModel)++instance Validatable Positive where+  validate x@(Positive n) = if n > 0 then Right x else Left "must be positive"++cfg :: ReActConfig+cfg = defaultReActConfig {protocol = ProtocolNative, compaction = CompactionConfig 0 4 False}++rich :: ToolOutput+rich = ToolOutput (B.ToolResult (V.singleton (B.ToolResultImage (B.ImageContent "image bytes" "image/png"))) False) (Just (object ["n" .= (42 :: Int)])) [object ["resource" .= String "uri"]]++registry :: ToolRegistry+registry = mkRegistry [dynamic "A", dynamic "B"]+  where+    dynamic name = mkDynTool name "test" (object []) $ \_ -> do+      _ <- complete B.emptyModel (B.emptyContext & #systemPrompt .~ Just ("dispatch:" <> name)) B.emptyOptions+      pure (Right rich)++firstTurn :: B.Response+firstTurn = mkToolCallsResponse [("call-A", "A", object []), ("call-B", "B", object [])]++finalTurn :: B.Response+finalTurn = mkToolCallResponse "final-1" finalToolName (toJSON expectedWeather)++-- The test interpreter observes dispatch via a nested LLM operation, respecting+-- tool bodies' rank-polymorphic effect boundary without unsafe IO in a tool.+recording :: [Either ShikumiError B.Response] -> Eff '[LLM, Error ShikumiError, IOE] a -> IO (Either ShikumiError a, [B.Context], [Text])+recording script action = do+  scriptRef <- newIORef script+  contexts <- newIORef []+  dispatches <- newIORef []+  result <-+    runEff+      . runErrorNoCallStack+      . interpret+        ( \_ -> \case+            Complete model ctx opts -> case ctx ^. #systemPrompt of+              Just sys | "dispatch:" `T.isPrefixOf` sys -> do+                liftIO (modifyIORef' dispatches (<> [T.drop 9 sys]))+                pure (mkTextResponse "done")+              _ -> do+                either throwError pure (C.validateRequestContinuation model ctx opts)+                liftIO (modifyIORef' contexts (<> [ctx]))+                next <-+                  liftIO+                    ( atomicModifyIORef'+                        scriptRef+                        ( \xs -> case xs of+                            [] -> ([], Left (ProviderFailure "unexpected model call"))+                            x : rest -> (rest, x)+                        )+                    )+                either throwError pure next+            Stream {} -> pure []+        )+      $ action+  (result,,) <$> readIORef contexts <*> readIORef dispatches++start :: Eff '[LLM, Error ShikumiError, IOE] ReActSession+start = startSession weatherSignature registry cfg weatherQuestion++paused :: SessionResult o -> Eff '[LLM, Error ShikumiError, IOE] ReActSession+paused (SessionPaused s) = pure s+paused _ = throwError (ValidationFailure "expected paused session")++restored :: ReActSession -> Eff '[LLM, Error ShikumiError, IOE] ReActSession+restored s = either (throwError . ValidationFailure . T.pack . show) pure (decodeSession (encodeSession s))++assertFinished :: Either ShikumiError (SessionResult WeatherResp) -> Assertion+assertFinished (Right (SessionFinished answer _)) = answer @?= expectedWeather+assertFinished other = assertFailure (show other)++mutate :: Text -> Value -> Value -> Value+mutate key v (Object o) = Object (KM.insert (fromStringKey key) v o)+  where+    fromStringKey = Data.Aeson.Key.fromText+mutate _ _ v = v++tests :: TestTree+tests =+  testGroup+    "AgentHistory"+    [ testCase "documented continuation example" $ do+        ReActSessionExample.example >>= (@?= Right ("Paris, France", 2)),+      testCase "native two-tool checkpoint resumes with exact messages and no extraction" $ do+        (answer, requests, dispatched) <- recording (map Right [firstTurn, finalTurn]) $ do+          s <- start >>= advanceSession weatherSignature registry cfg >>= paused+          copy <- restored s+          if copy == s then pure () else throwError (ValidationFailure "round-trip differs")+          resumed <- continueSession weatherSignature registry cfg weatherQuestion copy+          advanceSession weatherSignature registry cfg resumed+        assertFinished answer+        dispatched @?= ["A", "B"]+        length requests @?= 2+        case requests of+          [_, second] ->+            second ^. #messages+              @?= V.fromList+                [ B.user (Shikumi.Adapter.toPrompt weatherQuestion),+                  B.AssistantMessage (firstTurn ^. #message),+                  toolOutputMessage (B.ToolCall "call-A" "A" (object [])) rich,+                  toolOutputMessage (B.ToolCall "call-B" "B" (object [])) rich,+                  B.user (Shikumi.Adapter.toPrompt weatherQuestion)+                ]+          _ -> assertFailure "wrong request count",+      testCase "resumed and uninterrupted next requests are identical" $ do+        let execute restore = recording (map Right [firstTurn, finalTurn]) $ do+              s <- start >>= advanceSession weatherSignature registry cfg >>= paused+              copy <- if restore then restored s else pure s+              next <- continueSession weatherSignature registry cfg weatherQuestion copy+              advanceSession weatherSignature registry cfg next+        (_, uninterrupted, _) <- execute False+        (_, resumed, _) <- execute True+        resumed @?= uninterrupted,+      testCase "message metadata, thinking signature and rich blocks survive JSON bytes" $ do+        let replay = B.ThinkingReplay B.OpenAIResponses "test-model" (V.singleton (object ["opaque" .= ("replay" :: String)]))+            thinking = B.ThinkingContent "opaque" (Just "signature") True (Just replay)+            response = firstTurn & #message . #content .~ V.cons (B.AssistantThinking thinking) (firstTurn ^. #message . #content)+        (result, _, _) <- recording [Right response] (start >>= advanceSession weatherSignature registry cfg >>= paused)+        case result of+          Right s -> (eitherDecode (encode (encodeSession s)) >>= either (Left . show) Right . decodeSession) @?= Right s+          Left e -> assertFailure (show e),+      testCase "separate Claude and Responses continuations survive resume exactly" $ do+        mapM_+          ( \(model, thinking) -> do+              let response = firstTurn & #model .~ model & #message . #content .~ V.cons (B.AssistantThinking thinking) (firstTurn ^. #message . #content)+              (answer, requests, dispatched) <- recording [Right response, Right (finalTurn & #model .~ model)] $ do+                s <- startSessionWithModel model weatherSignature registry cfg weatherQuestion >>= advanceSession weatherSignature registry cfg >>= paused >>= restored+                advanceSession weatherSignature registry cfg s+              assertFinished answer+              dispatched @?= ["A", "B"]+              case requests of+                [_, ctx] -> assertBool "exact assistant" (B.AssistantMessage (response ^. #message) `elem` V.toList (ctx ^. #messages))+                _ -> assertFailure "wrong requests"+          )+          reasoningFixtures,+      testCase "safe summary excludes opaque sentinels while audit preserves them" $ do+        let thinking = B.ThinkingContent "SECRET-REDACTED" (Just "SECRET-SIGNATURE") True (Just (B.ThinkingReplay B.OpenAIResponses "m" (V.singleton (String "SECRET-REPLAY"))))+            payload = firstTurn ^. #message & #content .~ V.cons (B.AssistantThinking thinking) (firstTurn ^. #message . #content)+            built = newSession "native" (object []) (object []) "question" >>= appendExchange payload [(B.ToolCall "call-A" "A" (object []), rich), (B.ToolCall "call-B" "B" (object []), rich)] Nothing False+        case built of+          Left e -> assertFailure (show e)+          Right s -> do+            let rendered = renderSessionSummaryInput s+                audit = show (encodeSession s)+            mapM_+              ( \secret -> do+                  assertBool "absent from summary" (not (secret `T.isInfixOf` rendered))+                  assertBool "present in audit" (secret `T.isInfixOf` T.pack audit)+              )+              ["SECRET-REDACTED", "SECRET-SIGNATURE", "SECRET-REPLAY"]+            assertBool "tool names retained" ("A" `T.isInfixOf` rendered)+            decodeSession (encodeSession s) @?= Right s,+      testCase "opaque compaction defers proactively and overflow makes no summary call" $ do+        let (model, thinking) = claudeFixture+            compactCfg = cfg {compaction = CompactionConfig 0 1 True}+            response = firstTurn & #model .~ (model & #contextWindow .~ 10) & #message . #usage . #inputTokens .~ 10 & #message . #content .~ V.cons (B.AssistantThinking thinking) (firstTurn ^. #message . #content)+        (result, requests, dispatched) <- recording [Right response, Left (ContextWindowExceeded "full")] $ do+          s <- startSessionWithModel model weatherSignature registry compactCfg weatherQuestion >>= advanceSession weatherSignature registry compactCfg >>= paused+          if sessionCompactedThrough s == 0 then pure () else throwError (ValidationFailure "unexpected compaction")+          advanceSession weatherSignature registry compactCfg s+        case result of+          Left (ValidationFailure message) -> assertBool "actionable" ("restart" `T.isInfixOf` message)+          _ -> assertFailure (show result)+        length requests @?= 2+        dispatched @?= ["A", "B"],+      testCase "automatic summary requests contain no discarded opaque payload" $ do+        mapM_+          ( \(model, thinking) -> do+              let compactCfg = cfg {compaction = CompactionConfig 0 1 True}+                  response = firstTurn & #model .~ model & #message . #content .~ V.cons (B.AssistantThinking thinking) (firstTurn ^. #message . #content)+                  later = mkToolCallResponse "call-C" "A" (object []) & #model .~ (model & #contextWindow .~ 10) & #message . #usage . #inputTokens .~ 10+              (result, requests, dispatched) <- recording [Right response, Right later, Right (mkTextResponse "safe summary")] $ do+                s <- startSessionWithModel model weatherSignature registry compactCfg weatherQuestion >>= advanceSession weatherSignature registry compactCfg >>= paused+                advanceSession weatherSignature registry compactCfg s >>= paused+              case result of+                Left e -> assertFailure (show e)+                Right s -> do+                  sessionCompactedThrough s @?= 2+                  length (auditHistory s) @?= 3+              dispatched @?= ["A", "B", "A"]+              case reverse requests of+                summaryRequest : _ -> do+                  let rendered = T.pack (show (toJSON summaryRequest))+                  mapM_ (\secret -> assertBool "opaque absent from actual summary request" (not (secret `T.isInfixOf` rendered))) ["signature-bytes", "encrypted-bytes", "redacted-secret"]+                _ -> assertFailure "no summary request"+              length requests @?= 3+          )+          (reasoningFixtures <> [(B.mkModel B.AnthropicMessages "claude-test" "https://claude.example", B.ThinkingContent "redacted-secret" Nothing True Nothing)]),+      testCase "response request mismatch rejects before tool dispatch" $ do+        let (model, _) = claudeFixture+        (result, requests, dispatched) <- recording [Right (firstTurn & #model .~ (model & #modelId .~ "changed"))] $ do+          s <- startSessionWithModel model weatherSignature registry cfg weatherQuestion+          advanceSession weatherSignature registry cfg s+        assertBool "mismatch rejected" (isLeft result)+        length requests @?= 1+        dispatched @?= [],+      testCase "legacy opaque origin is unknown; explicit restart leaves audit unchanged" $ do+        let (model, thinking) = claudeFixture+            response = firstTurn & #model .~ model & #message . #content .~ V.cons (B.AssistantThinking thinking) (firstTurn ^. #message . #content)+        (built, _, _) <- recording [Right response] (startSessionWithModel model weatherSignature registry cfg weatherQuestion >>= advanceSession weatherSignature registry cfg >>= paused)+        case built of+          Left e -> assertFailure (show e)+          Right old -> do+            let legacy = mutate "version" (Number 1) (encodeSession old)+            case decodeSession legacy of+              Left e -> assertFailure (show e)+              Right unknown -> do+                sessionOrigin unknown @?= Nothing+                (rejected, requests, dispatched) <- recording [] (advanceSession weatherSignature registry cfg unknown)+                assertBool "legacy rejected" (isLeft rejected)+                requests @?= []+                dispatched @?= []+                case restartSessionFromSummary "Caller approved summary" unknown of+                  Left e -> assertFailure (show e)+                  Right fresh -> do+                    sessionOrigin fresh @?= Nothing+                    sessionTurns fresh @?= 0+                    length (auditHistory fresh) @?= 1+                    assertBool "no native old messages" (not (C.hasOpaqueContinuation (promptMessages fresh)))+                    length (auditHistory unknown) @?= 2+                    decodeSession (encodeSession old) @?= Right old+                    (answer, freshRequests, freshDispatch) <- recording [Right finalTurn] (advanceSession weatherSignature registry cfg fresh)+                    assertFinished answer+                    length freshRequests @?= 1+                    freshDispatch @?= [],+      testCase "immutable version-one checkpoint reads and upgrades losslessly" $ do+        let bytes = "{\"version\":1,\"protocol\":\"prompt\",\"fingerprint\":{},\"tools\":{},\"turns\":0,\"iterations\":0,\"finished\":false,\"history\":[{\"kind\":\"user\",\"content\":[{\"type\":\"user_text\",\"data\":{\"text\":\"question\"}}],\"timestamp\":null}],\"compactedThrough\":0,\"summary\":null}"+        (eitherDecode bytes >>= either (Left . show) Right . decodeSession) @?= either (Left . show) Right (newSession "prompt" (object []) (object []) "question"),+      testCase "version-one plain session binds only after a resolved response" $ do+        (result, _, _) <- recording [] start+        case result of+          Left e -> assertFailure (show e)+          Right initial -> case decodeSession (mutate "version" (Number 1) (encodeSession initial)) of+            Left e -> assertFailure (show e)+            Right legacy -> do+              sessionOrigin legacy @?= Nothing+              let (model, _) = claudeFixture+              (answer, _, _) <- recording [Right (firstTurn & #model .~ model)] (advanceSession weatherSignature registry cfg legacy >>= paused)+              case answer of+                Right bound -> sessionOrigin bound @?= C.requestOrigin model+                Left e -> assertFailure (show e),+      testCase "all invalid native proposals are audit-only and dispatch nothing" $ do+        let invalid =+              [ mkToolCallsResponse [("", "A", object []), ("ok", "B", object [])],+                mkToolCallsResponse [("same", "A", object []), ("same", "B", object [])],+                mkToolCallsResponse [("ok", "A", object []), ("cut", "B", String "{")],+                mkToolCallsResponse [("a", "A", object []), ("f", finalToolName, toJSON expectedWeather)],+                mkToolCallResponse "bad-final" finalToolName (object [])+              ]+        mapM_+          ( \response -> do+              (result, requests, dispatched) <- recording [Right response, Right finalTurn] $ do+                s <- start >>= advanceSession weatherSignature registry cfg >>= paused+                copy <- restored s+                advanceSession weatherSignature registry cfg copy+              assertFinished result+              dispatched @?= []+              case requests of+                [_, ctx] -> do+                  assertBool "invalid assistant omitted" (not (B.AssistantMessage (response ^. #message) `elem` V.toList (ctx ^. #messages)))+                  assertBool "no orphan tool results" (null [() | B.ToolResultMessage _ <- V.toList (ctx ^. #messages)])+                _ -> assertFailure "wrong requests"+          )+          invalid,+      testCase "IDs cannot be reused in later accepted exchanges" $ do+        (result, _, dispatched) <- recording (map Right [firstTurn, firstTurn, finalTurn]) $ do+          s <- start >>= advanceSession weatherSignature registry cfg >>= paused+          next <- advanceSession weatherSignature registry cfg s >>= paused+          advanceSession weatherSignature registry cfg next+        assertFinished result+        dispatched @?= ["A", "B"],+      testCase "unknown versions, unresolved exchanges and corrupt JSON are rejected" $ do+        (result, _, _) <- recording [Right firstTurn] (start >>= advanceSession weatherSignature registry cfg >>= paused)+        case result of+          Right s -> do+            assertBool "version" (isLeft (decodeSession (mutate "version" (Number 99) (encodeSession s))))+            let corruptResults (Object o) = case KM.lookup "history" o of+                  Just (Array entries) ->+                    Object+                      ( KM.insert+                          "history"+                          ( Array+                              ( V.map+                                  ( \e -> case e of+                                      Object fields | KM.lookup "kind" fields == Just (String "exchange") -> Object (KM.insert "results" (Array V.empty) fields)+                                      _ -> e+                                  )+                                  entries+                              )+                          )+                          o+                      )+                  _ -> Object o+                corruptResults v = v+            assertBool "unresolved" (isLeft (decodeSession (corruptResults (encodeSession s))))+            assertBool "invalid JSON" (isLeft (eitherDecode "{" :: Either String Value))+          Left e -> assertFailure (show e),+      testCase "signature and registry mismatch fail before any execution" $ do+        (result, requests, dispatched) <- recording [] $ do+          s <- start+          advanceSession (setInstruction "changed" weatherSignature) registry cfg s+        assertBool "signature rejected" (isLeft result)+        requests @?= []+        dispatched @?= []+        (changed, reqs, _) <- recording [] $ do+          s <- start+          advanceSession weatherSignature (mkRegistry []) cfg s+        assertBool "registry rejected" (isLeft changed)+        reqs @?= [],+      testCase "reserved final tool collision is rejected at startup" $ do+        (result, requests, _) <- recording [] (startSession weatherSignature (mkRegistry [mkDynTool finalToolName "" (object []) (\_ -> pure (Right rich))]) cfg weatherQuestion)+        assertBool "collision" (isLeft result)+        requests @?= [],+      testCase "iteration exhaustion returns a checkpoint without another call" $ do+        let limited = cfg {maxIters = 1}+        (result, requests, dispatched) <- recording [Right firstTurn] $ do+          s <- startSession weatherSignature registry limited weatherQuestion+          runSession weatherSignature registry limited s+        case result of+          Right (SessionPaused s) -> sessionTurns s @?= 1+          _ -> assertFailure (show result)+        length requests @?= 1+        dispatched @?= ["A", "B"],+      testCase "prompt fallback preserves continuation and synthetic IDs" $ do+        let promptCfg = cfg {protocol = ProtocolPrompt}+            proposal = mkTextResponse "{\"calls\":[{\"tool\":\"A\",\"args\":{}},{\"tool\":\"B\",\"args\":{}}]}"+            final = mkTextResponse "{\"calls\":[{\"tool\":\"shikumi_submit_final\",\"args\":{\"tempC\":12,\"summary\":\"mild\"}}]}"+        (result, requests, dispatched) <- recording (map Right [proposal, final]) $ do+          s <- startSession weatherSignature registry promptCfg weatherQuestion >>= advanceSession weatherSignature registry promptCfg >>= paused >>= restored+          next <- continueSession weatherSignature registry promptCfg weatherQuestion s+          advanceSession weatherSignature registry promptCfg next+        assertFinished result+        dispatched @?= ["A", "B"]+        length requests @?= 2,+      testCase "context retry compacts whole exchanges without redispatch" $ do+        let compactCfg = cfg {compaction = CompactionConfig 0 1 True}+        (result, requests, dispatched) <- recording [Right firstTurn, Left (ContextWindowExceeded "full"), Right (mkTextResponse "Earlier question"), Right finalTurn] $ do+          s <- startSession weatherSignature registry compactCfg weatherQuestion >>= advanceSession weatherSignature registry compactCfg >>= paused+          advanceSession weatherSignature registry compactCfg s+        assertFinished result+        dispatched @?= ["A", "B"]+        length requests @?= 4+        case result of+          Right (SessionFinished _ s) -> do+            decodeSession (encodeSession s) @?= Right s+            length (auditHistory s) @?= 3+          _ -> pure (),+      testCase "usage compaction retains complete exchanges across restore" $ do+        let compactCfg = cfg {compaction = CompactionConfig 0 1 True}+            large = firstTurn & #model . #contextWindow .~ 10 & #message . #usage . #inputTokens .~ 10+        (result, requests, dispatched) <- recording (map Right [large, mkTextResponse "question summary", finalTurn]) $ do+          s <- startSession weatherSignature registry compactCfg weatherQuestion >>= advanceSession weatherSignature registry compactCfg >>= paused >>= restored+          advanceSession weatherSignature registry compactCfg s+        assertFinished result+        dispatched @?= ["A", "B"]+        length requests @?= 3+        case reverse requests of+          ctx : _ -> do+            length [() | B.AssistantMessage _ <- V.toList (ctx ^. #messages)] @?= 1+            length [() | B.ToolResultMessage _ <- V.toList (ctx ^. #messages)] @?= 2+          _ -> assertFailure "missing request",+      testCase "tool infrastructure failure aborts remaining dispatch without retry" $ do+        let failing = mkRegistry [mkDynTool "A" "" (object []) (\_ -> throwError (BudgetExceeded "tool budget")), mkDynTool "B" "" (object []) (\_ -> pure (Right rich))]+        (result, requests, dispatched) <- recording [Right firstTurn] $ do+          s <- startSession weatherSignature failing cfg weatherQuestion+          advanceSession weatherSignature failing cfg s+        result @?= Left (BudgetExceeded "tool budget")+        length requests @?= 1+        dispatched @?= [],+      testCase "provider error payload is never dispatched" $ do+        let failed = firstTurn & #message . #stopReason .~ B.ErrorReason & #message . #errorMessage .~ Just "transport failed"+        (result, _, dispatched) <- recording [Right failed] (start >>= advanceSession weatherSignature registry cfg)+        result @?= Left (ProviderFailure "transport failed")+        dispatched @?= [],+      testCase "structured refusal preserves error and never dispatches partial calls" $ do+        let err = contentFiltered "refused"+            failed = firstTurn & #message . #stopReason .~ B.ErrorReason & #errorInfo .~ Just err+        (result, requests, dispatched) <- recording [Right failed] (start >>= advanceSession weatherSignature registry cfg)+        result @?= Left (ProviderError err)+        length requests @?= 1+        dispatched @?= [],+      testCase "ordinary tool failures are error-flagged model messages" $ do+        let unknown = mkToolCallResponse "unknown-id" "unknown-tool" (object [])+        (result, requests, _) <- recording (map Right [unknown, finalTurn]) $ do+          s <- start >>= advanceSession weatherSignature registry cfg >>= paused+          advanceSession weatherSignature registry cfg s+        assertFinished result+        case reverse requests of+          ctx : _ -> [p ^. #isError | B.ToolResultMessage p <- V.toList (ctx ^. #messages)] @?= [True]+          _ -> assertFailure "missing request",+      testCase "checkpoint rejects altered result names and duplicate results" $ do+        (result, _, _) <- recording [Right firstTurn] (start >>= advanceSession weatherSignature registry cfg >>= paused)+        case result of+          Right s -> case auditHistory s of+            [_, Exchange payload results Nothing] -> do+              let base = newSession "native" (sessionFingerprint s) (sessionTools s) "question"+                  changed = case results of+                    (call, output) : rest -> (call & #name .~ "different", output) : rest+                    [] -> []+              assertBool "name mismatch" (isLeft (base >>= appendExchange payload changed Nothing False))+              assertBool "duplicate result" (isLeft (base >>= appendExchange payload (results <> results) Nothing False))+            _ -> assertFailure "unexpected audit"+          Left err -> assertFailure (show err),+      testCase "schema changes under an existing tool name require restart" $ do+        let changed = mkRegistry [mkDynTool "A" "test" (object ["type" .= String "string"]) (\_ -> pure (Right rich)), mkDynTool "B" "test" (object []) (\_ -> pure (Right rich))]+        (result, requests, _) <- recording [] $ do+          s <- start+          continueSession weatherSignature changed cfg weatherQuestion s+        assertBool "schema rejected" (isLeft result)+        requests @?= [],+      testCase "fenced prompt uses identical dispatch and persistence parsing" $ do+        let promptCfg = cfg {protocol = ProtocolPrompt}+            proposal = mkTextResponse "```json\n{\"calls\":[{\"tool\":\"A\",\"args\":{}}]}"+        (result, _, dispatched) <- recording [Right proposal] $ do+          s <- startSession weatherSignature registry promptCfg weatherQuestion+          advanceSession weatherSignature registry promptCfg s >>= paused >>= restored+        assertBool "checkpoint returned" (not (isLeft result))+        dispatched @?= ["A"],+      testCase "final submission runs custom semantic validation" $ do+        let sig = mkSignature "Return a positive value" :: Signature AnswerWeatherQuestion Positive+            bad = mkToolCallResponse "bad" finalToolName (object ["value" .= (-1 :: Int)])+            good = mkToolCallResponse "good" finalToolName (object ["value" .= (2 :: Int)])+        (result, requests, dispatched) <- recording (map Right [bad, good]) $ do+          s <- startSession sig registry cfg weatherQuestion+          runSession sig registry cfg s+        case result of+          Right (SessionFinished answer s) -> do+            answer @?= Positive 2+            sessionTurns s @?= 2+          _ -> assertFailure (show result)+        length requests @?= 2+        dispatched @?= [],+      testCase "exact rational costs and timestamps survive checkpoint encoding" $ do+        let cost = BC.Cost (1 / 3) (BC.CostBreakdown (1 / 7) (2 / 9) (1 / 11) (1 / 13)) (BC.CostBasis (Set.singleton BC.StandardTokenRates) (Set.singleton BC.CacheWriteUsageNotReported))+            response =+              firstTurn+                & #message . #usage . #cost .~ cost+                & #message . #usage . #reasoningTokens .~ Just 5+                & #message . #usage . #availability .~ Just (BU.UsageAvailability (Set.singleton BU.CacheWriteUsage) False (Set.singleton (BU.BillingServiceTier "standard")))+                & #message . #timestamp .~ Just (read "2026-09-07 01:00:00 UTC")+                & #message . #errorMessage .~ Just "nonfatal diagnostic"+        (result, _, _) <- recording [Right response] (start >>= advanceSession weatherSignature registry cfg >>= paused)+        case result of+          Right s -> (eitherDecode (encode (encodeSession s)) >>= either (Left . show) Right . decodeSession) @?= Right s+          Left err -> assertFailure (show err),+      testCase "legacy checkpoints without billing metadata still decode" $ do+        let stripBilling (Object fields) = Object (KM.mapWithKey (\_ -> stripBilling) (KM.delete "basis" (KM.delete "availability" fields)))+            stripBilling (Array values) = Array (V.map stripBilling values)+            stripBilling other = other+        (result, _, _) <- recording [Right firstTurn] (start >>= advanceSession weatherSignature registry cfg >>= paused)+        case result of+          Right s -> decodeSession (stripBilling (encodeSession s)) @?= Right s+          Left err -> assertFailure (show err),+      testCase "context retry is bounded" $ do+        let compactCfg = cfg {compaction = CompactionConfig 0 1 True}+        (result, requests, dispatched) <- recording [Right firstTurn, Left (ContextWindowExceeded "full"), Right (mkTextResponse "summary"), Left (ContextWindowExceeded "again")] $ do+          s <- startSession weatherSignature registry compactCfg weatherQuestion >>= advanceSession weatherSignature registry compactCfg >>= paused+          advanceSession weatherSignature registry compactCfg s+        result @?= Left (ContextWindowExceeded "again")+        length requests @?= 4+        dispatched @?= ["A", "B"]+    ]+  where+    isLeft (Left _) = True+    isLeft _ = False++-- Separate API-valid shapes; neither mixes Claude signatures and Responses items.+reasoningFixtures :: [(B.Model, B.ThinkingContent)]+reasoningFixtures =+  [ claudeFixture,+    (B.mkModel B.OpenAIResponses "responses-test" "https://responses.example", B.ThinkingContent "" Nothing False (Just (B.ThinkingReplay B.OpenAIResponses "responses-test" (V.singleton (object ["type" .= String "reasoning", "id" .= String "rs_1", "encrypted_content" .= String "encrypted-bytes", "summary" .= ([] :: [Value])])))))+  ]++claudeFixture :: (B.Model, B.ThinkingContent)+claudeFixture = (B.mkModel B.AnthropicMessages "claude-test" "https://claude.example", B.ThinkingContent "" (Just "signature-bytes") False Nothing)
test/BuiltinAcceptanceSpec.hs view
@@ -14,7 +14,6 @@ import Data.Text.IO qualified as TIO import Data.Vector qualified as V import GHC.Generics (Generic)-import MockLLM (mkTextResponse, mkToolCallResponse, runAgent) import Shikumi.Adapter (ToPrompt) import Shikumi.Agent.ReAct   ( Action (..),@@ -27,6 +26,7 @@   ) import Shikumi.Schema (FromModel, ToSchema, Validatable) import Shikumi.Signature (Signature, mkSignature)+import Shikumi.Testing (mkTextResponse, mkToolCallResponse, runAgent) import Shikumi.Tool.Builtin (builtinRegistry) import Shikumi.Tool.Env (localToolEnv) import Shikumi.Tool.Web (FetchResult (..), SearchResult (..), WebClient (..))
test/CodeActSpec.hs view
@@ -18,7 +18,6 @@ import Effectful (runEff) import Effectful.Error.Static (runErrorNoCallStack) import GHC.Generics (Generic)-import MockLLM (mkTextResponse, mkUsageResponse, runAgent, runMockLLMThrowingOn) import Shikumi.Adapter (ToPrompt) import Shikumi.Agent.ReAct (Action (..), Step (..), Termination (..), Trajectory (..)) import Shikumi.CodeExec.CodeAct (CodeActConfig (..), codeActWithTrajectory, defaultCodeActConfig)@@ -29,6 +28,7 @@ import Shikumi.Program (runProgram) import Shikumi.Schema (FromModel, ToSchema, Validatable) import Shikumi.Signature (Signature, mkSignature)+import Shikumi.Testing (mkTextResponse, mkUsageResponse, runAgent, runScriptLLMThrowingOn) import Shikumi.Tool (SomeTool (..), Tool, ToolRegistry, mkRegistry, mkTool) import Test.Tasty (TestTree, testGroup) import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))@@ -138,7 +138,7 @@         out <-           runEff             . runErrorNoCallStack @ShikumiError-            . runMockLLMThrowingOn [2] (ContextWindowExceeded "context length exceeded") script+            . runScriptLLMThrowingOn [2] (ContextWindowExceeded "context length exceeded") script             $ runProgram prog (Task "compute 42")         case out of           Right (answer, traj) -> do@@ -161,7 +161,7 @@         out <-           runEff             . runErrorNoCallStack @ShikumiError-            . runMockLLMThrowingOn [2] (ContextWindowExceeded "context length exceeded") script+            . runScriptLLMThrowingOn [2] (ContextWindowExceeded "context length exceeded") script             $ runProgram prog (Task "compute 42")         out @?= Left (ContextWindowExceeded "context length exceeded")     ]
test/CompactionSpec.hs view
@@ -1,9 +1,11 @@ module CompactionSpec (tests) where  import Baikai (emptyModel, zeroUsage)-import Control.Lens ((&), (.~))+import Baikai.Speed (Speed (..))+import Control.Lens ((&), (.~), (^.)) import Data.Aeson (Value (..)) import Data.Generics.Labels ()+import Data.IORef (newIORef, readIORef) import Data.Text (Text) import Data.Vector qualified as V import Effectful (runEff)@@ -15,13 +17,6 @@     weatherRegistry,     weatherSignature,   )-import MockLLM-  ( mkTextResponse,-    mkUsageResponse,-    runAgent,-    runEffMock,-    runMockLLMThrowingOn,-  ) import Shikumi.Agent.ReAct   ( Action (..),     ReActConfig (..),@@ -40,7 +35,16 @@     usageExceedsWindow,   ) import Shikumi.Error (ShikumiError (..))+import Shikumi.LLM.Defaults import Shikumi.Program (runProgram)+import Shikumi.Testing+  ( captureLLMRequests,+    mkTextResponse,+    mkUsageResponse,+    runEffScript,+    runScriptLLM,+    runScriptLLMThrowingOn,+  ) import Test.Tasty (TestTree, testGroup) import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=)) @@ -61,13 +65,13 @@       testCase "compactTail folds older items and keeps the recent tail" $ do         let cfg = defaultCompactionConfig {keepRecent = 2}         res <--          runEffMock [mkTextResponse "S"] $+          runEffScript [mkTextResponse "S"] $             compactTail cfg emptyModel id ("summary:" <>) (["e1", "e2", "e3", "e4", "e5", "e6"] :: [Text])         res @?= Right ["summary:S", "e5", "e6"],       testCase "compactTail with enabled=False is the identity and calls no model" $ do         let cfg = defaultCompactionConfig {enabled = False, keepRecent = 0}             items = ["e1", "e2", "e3"] :: [Text]-        res <- runEffMock [] $ compactTail cfg emptyModel id ("summary:" <>) items+        res <- runEffScript [] $ compactTail cfg emptyModel id ("summary:" <>) items         res @?= Right items,       testCase "agent on tiny window compacts and completes" $ do         let cfg =@@ -84,7 +88,17 @@                 mkUsageResponse model 10 finishReply,                 mkTextResponse extractReply               ]-        res <- runAgent script (reactWithTrajectory weatherSignature weatherRegistry cfg) weatherQuestion+        captured <- newIORef []+        res <-+          runEff+            . runErrorNoCallStack @ShikumiError+            . runScriptLLM script+            . captureLLMRequests captured+            . withRequestDefaults (emptyRequestDefaults {defaultSpeed = Just SpeedFast})+            $ runProgram (reactWithTrajectory weatherSignature weatherRegistry cfg) weatherQuestion+        calls <- readIORef captured+        length calls @?= 5+        map (\(_, _, opts) -> opts ^. #speed) calls @?= replicate 5 (Just SpeedFast)         case res of           Right (o :: WeatherResp, traj) -> do             o @?= expectedWeather@@ -119,7 +133,7 @@         res <-           runEff             . runErrorNoCallStack @ShikumiError-            . runMockLLMThrowingOn [2] (ContextWindowExceeded "context length exceeded") script+            . runScriptLLMThrowingOn [2] (ContextWindowExceeded "context length exceeded") script             $ runProgram prog weatherQuestion         case res of           Right (_ :: WeatherResp, traj) ->@@ -142,7 +156,7 @@         res <-           runEff             . runErrorNoCallStack @ShikumiError-            . runMockLLMThrowingOn [2] (ContextWindowExceeded "context length exceeded") script+            . runScriptLLMThrowingOn [2] (ContextWindowExceeded "context length exceeded") script             $ runProgram prog weatherQuestion         res @?= Left (ContextWindowExceeded "context length exceeded"),       testCase "extract overflow is caught, compacted, and retried once" $ do@@ -163,7 +177,7 @@         res <-           runEff             . runErrorNoCallStack @ShikumiError-            . runMockLLMThrowingOn [3] (ContextWindowExceeded "context length exceeded") script+            . runScriptLLMThrowingOn [3] (ContextWindowExceeded "context length exceeded") script             $ runProgram prog weatherQuestion         case res of           Right (_ :: WeatherResp, traj) ->@@ -185,7 +199,7 @@         res <-           runEff             . runErrorNoCallStack @ShikumiError-            . runMockLLMThrowingOn [2, 4] (ContextWindowExceeded "context length exceeded") script+            . runScriptLLMThrowingOn [2, 4] (ContextWindowExceeded "context length exceeded") script             $ runProgram prog weatherQuestion         res @?= Left (ContextWindowExceeded "context length exceeded")     ]
test/EnvSpec.hs view
@@ -7,8 +7,8 @@ import Data.Generics.Labels () import Data.List (find) import Data.Text qualified as T-import MockLLM (runEffMock) import Shikumi.Error (ShikumiError (..))+import Shikumi.Testing (runEffScript) import Shikumi.Tool.Env   ( DirEntry,     ExecRequest (..),@@ -37,7 +37,7 @@         let file = T.pack (root </> "hello.txt")             nested = T.pack (root </> "nested")         result <--          runEffMock [] $ do+          runEffScript [] $ do             envWriteFile localToolEnv file "hello\n"             bytes <- envReadFile localToolEnv file             stat <- envStat localToolEnv file@@ -78,7 +78,7 @@             assertBool "cwd returns an absolute path" ("/" `T.isPrefixOf` cwd),       testCase "a negative exec timeout is clamped, not disabled" $ do         result <--          runEffMock [] $+          runEffScript [] $             envExec               localToolEnv               ExecRequest
test/Fixtures.hs view
@@ -33,10 +33,10 @@ import Data.Aeson (ToJSON, Value, object, (.=)) import Data.Text (Text) import GHC.Generics (Generic)-import MockLLM (mkTextResponse, mkToolCallResponse) import Shikumi.Adapter (ToPrompt) import Shikumi.Schema (FromModel, ToSchema, Validatable) import Shikumi.Signature (Signature, mkSignature)+import Shikumi.Testing (mkTextResponse, mkToolCallResponse) import Shikumi.Tool (SomeTool (..), Tool, ToolRegistry, mkRegistry, mkTool)  -- ---------------------------------------------------------------------------
test/FsSpec.hs view
@@ -9,8 +9,8 @@ import Data.List (sort) import Data.Text (Text) import Data.Text qualified as T-import MockLLM (runEffMock) import Shikumi.Error (ShikumiError (..))+import Shikumi.Testing (runEffScript) import Shikumi.Tool (Tool (..)) import Shikumi.Tool.Builtin.Fs   ( EditReq (..),@@ -53,7 +53,7 @@         withTempDir "roundtrip" $ \root -> do           let file = T.pack (root </> "note.txt")           result <--            runEffMock [] $ do+            runEffScript [] $ do               rgPresent <- envExec localToolEnv (presentReq "rg")               fdPresent <- envExec localToolEnv (presentReq "fd")               written <- run (writeTool localToolEnv) WriteReq {path = file, content = "alpha\nold title\n"}@@ -94,7 +94,7 @@               nodeFile = T.pack (root </> "node_modules" </> "dep.txt")               binaryFile = T.pack (root </> "binary.bin")           result <--            runEffMock [] $ do+            runEffScript [] $ do               envWriteFile localToolEnv visible "SECRET visible\n"               envMkdir localToolEnv (T.pack (root </> ".git"))               envWriteFile localToolEnv gitFile "SECRET git\n"@@ -113,7 +113,7 @@         withTempDir "bad-regex" $ \root -> do           let visible = T.pack (root </> "visible.txt")           result <--            runEffMock [] $ do+            runEffScript [] $ do               envWriteFile localToolEnv visible "hello\n"               run                 (grepTool noFastToolEnv)@@ -127,7 +127,7 @@               nestedDir = T.pack (root </> "sub")               nested = T.pack (root </> "sub" </> "nested.txt")           result <--            runEffMock [] $ do+            runEffScript [] $ do               envWriteFile localToolEnv top "marker top\n"               envMkdir localToolEnv nestedDir               envWriteFile localToolEnv nested "marker nested\n"@@ -155,7 +155,7 @@         withTempDir "read-truncated" $ \root -> do           let file = T.pack (root </> "three.txt")           result <--            runEffMock [] $ do+            runEffScript [] $ do               envWriteFile localToolEnv file "one\ntwo\nthree\n"               offsetToEnd <- run (readTool localToolEnv) ReadReq {path = file, offset = Just 1, limit = Nothing}               firstLine <- run (readTool localToolEnv) ReadReq {path = file, offset = Just 0, limit = Just 1}@@ -170,7 +170,7 @@       testCase "glob at exactly maxResults is not marked truncated" $         withTempDir "glob-cap" $ \root -> do           result <--            runEffMock [] $ do+            runEffScript [] $ do               traverse_                 ( \n ->                     envWriteFile@@ -193,7 +193,7 @@             Left _ -> assertBool "directory symlink creation unsupported; skipping" True             Right () -> do               result <--                runEffMock [] $ do+                runEffScript [] $ do                   envWriteFile localToolEnv file "real\n"                   run (globTool noFastToolEnv) GlobReq {patternText = "*.txt", path = Just (T.pack root)}               case result of
test/Main.hs view
@@ -3,6 +3,7 @@ module Main (main) where  import AcceptanceSpec qualified+import AgentHistorySpec qualified import BuiltinAcceptanceSpec qualified import CodeActSpec qualified import CompactionSpec qualified@@ -10,11 +11,16 @@ import FsSpec qualified import ProgramOfThoughtSpec qualified import ProtocolSpec qualified+import RLMSpec qualified import ReActSpec qualified+import ResponsesIntegrationSpec qualified+import ResponsesSchemaSpec qualified import RestrictedSpec qualified import SchemaSpec qualified+import SessionSpec qualified import ShellSpec qualified import Test.Tasty (defaultMain, testGroup)+import ToolOutputSpec qualified import ToolSpec qualified import WebSpec qualified @@ -23,8 +29,14 @@   defaultMain $     testGroup       "shikumi-tools"-      [ SchemaSpec.tests,+      [ ResponsesSchemaSpec.tests,+        ResponsesIntegrationSpec.tests,+        SessionSpec.tests,+        RLMSpec.tests,+        AgentHistorySpec.tests,+        SchemaSpec.tests,         ToolSpec.tests,+        ToolOutputSpec.tests,         EnvSpec.tests,         WebSpec.tests,         FsSpec.tests,
− test/MockLLM.hs
@@ -1,136 +0,0 @@--- | A deterministic, network-free interpreter of the @LLM@ effect for the--- @shikumi-tools@ specs, plus builders for the two kinds of scripted assistant--- 'Response' a ReAct turn can produce: a plain-text reply (the prompt protocol and--- the extract step) and a native tool-call block (the native protocol).------ Each 'Shikumi.LLM.Complete' pops the next scripted 'Response' in order; an--- exhausted script yields an empty text response (specs always script enough).-module MockLLM-  ( runMockLLM,-    runMockLLMThrowingOnce,-    runMockLLMThrowingOn,-    runEffMock,-    runAgent,-    mkTextResponse,-    mkUsageResponse,-    mkToolCallResponse,-    mkToolCallsResponse,-  )-where--import Baikai-  ( AssistantContent (..),-    Model,-    Response,-    emptyResponse,-    emptyTextContent,-    emptyToolCall,-  )-import Control.Lens ((&), (.~))-import Data.Aeson (Value)-import Data.Generics.Labels ()-import Data.IORef (IORef, atomicModifyIORef', newIORef)-import Data.Text (Text)-import Data.Vector qualified as V-import Effectful (Eff, IOE, liftIO, runEff, type (:>))-import Effectful.Dispatch.Dynamic (interpret)-import Effectful.Error.Static (Error, runErrorNoCallStack, throwError)-import Numeric.Natural (Natural)-import Shikumi.Error (ShikumiError)-import Shikumi.LLM (LLM (..))-import Shikumi.Program (Program, runProgram)---- | Interpret @LLM@ by popping scripted 'Response's in order. The streaming op is--- unused. An exhausted script yields an empty text response.-runMockLLM :: (IOE :> es) => [Response] -> Eff (LLM : es) a -> Eff es a-runMockLLM script act = do-  ref <- liftIO (newIORef script)-  interpret-    ( \_ -> \case-        Complete {} -> liftIO (pop ref)-        Stream {} -> pure []-    )-    act---- | Interpret @LLM@ like 'runMockLLM', but throw once on the first completion.-runMockLLMThrowingOnce ::-  (IOE :> es, Error ShikumiError :> es) =>-  ShikumiError ->-  [Response] ->-  Eff (LLM : es) a ->-  Eff es a-runMockLLMThrowingOnce = runMockLLMThrowingOn [1]---- | Interpret @LLM@ like 'runMockLLM', but throw on selected 1-based completion--- calls. Useful for bounded-retry tests.-runMockLLMThrowingOn ::-  (IOE :> es, Error ShikumiError :> es) =>-  [Int] ->-  ShikumiError ->-  [Response] ->-  Eff (LLM : es) a ->-  Eff es a-runMockLLMThrowingOn throwAt err script act = do-  ref <- liftIO (newIORef script)-  countRef <- liftIO (newIORef (0 :: Int))-  interpret-    ( \_ -> \case-        Complete {} -> do-          n <- liftIO (atomicModifyIORef' countRef (\n0 -> let n1 = n0 + 1 in (n1, n1)))-          if n `elem` throwAt-            then throwError err-            else liftIO (pop ref)-        Stream {} -> pure []-    )-    act---- | Discharge a network-free @LLM@ computation against a scripted mock: handle the--- error channel, then @IO@. The whole stack is exactly the row a ReAct agent runs--- in (@LLM@ + @Error ShikumiError@), plus @IOE@ at the bottom for the mock's @IORef@.-runEffMock ::-  [Response] ->-  Eff '[LLM, Error ShikumiError, IOE] a ->-  IO (Either ShikumiError a)-runEffMock script = runEff . runErrorNoCallStack . runMockLLM script---- | Run a 'Program' (e.g. a @react@ agent) against a scripted mock LM.-runAgent ::-  [Response] ->-  Program i o ->-  i ->-  IO (Either ShikumiError o)-runAgent script prog i = runEffMock script (runProgram prog i)--pop :: IORef [Response] -> IO Response-pop ref = atomicModifyIORef' ref step-  where-    step (x : xs) = (xs, x)-    step [] = ([], mkTextResponse "")---- | An assistant 'Response' carrying @t@ as its single text block.-mkTextResponse :: Text -> Response-mkTextResponse t =-  emptyResponse & #message . #content .~ V.singleton (AssistantText (emptyTextContent & #text .~ t))---- | A text response that also carries a resolved model and input-token usage.-mkUsageResponse :: Model -> Natural -> Text -> Response-mkUsageResponse model inputTokens text =-  mkTextResponse text-    & #model .~ model-    & #message . #usage . #inputTokens .~ inputTokens-    & #message . #usage . #totalTokens .~ inputTokens---- | An assistant 'Response' carrying a single native tool-call block.-mkToolCallResponse :: Text -> Text -> Value -> Response-mkToolCallResponse callId nm args = mkToolCallsResponse [(callId, nm, args)]---- | An assistant 'Response' carrying several native tool-call blocks in order.-mkToolCallsResponse :: [(Text, Text, Value)] -> Response-mkToolCallsResponse calls =-  emptyResponse-    & #message-      . #content-      .~ V.fromList-        [ AssistantToolCall (emptyToolCall & #id_ .~ callId & #name .~ nm & #arguments .~ args)-        | (callId, nm, args) <- calls-        ]
test/ProgramOfThoughtSpec.hs view
@@ -12,7 +12,6 @@ import Data.Text (Text) import Data.Text qualified as T import GHC.Generics (Generic)-import MockLLM (mkTextResponse, runAgent) import Shikumi.Adapter (ToPrompt) import Shikumi.CodeExec.Interpreter (CodeInterpreter (..)) import Shikumi.CodeExec.ProgramOfThought (PoTConfig (..), programOfThought, programOfThoughtWith)@@ -21,6 +20,7 @@ import Shikumi.Program (foldParams) import Shikumi.Schema (FromModel, ToSchema, Validatable) import Shikumi.Signature (Signature, mkSignature)+import Shikumi.Testing (mkTextResponse, runAgent) import Test.Tasty (TestTree, testGroup) import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=)) 
test/ProtocolSpec.hs view
@@ -21,7 +21,6 @@     weatherRegistry,     weatherSignature,   )-import MockLLM (mkTextResponse, mkToolCallsResponse, runAgent) import Shikumi.Agent.ReAct   ( Action (..),     Step (..),@@ -32,6 +31,7 @@     reactWithTrajectory,     resolveProtocolKind,   )+import Shikumi.Testing (mkTextResponse, mkToolCallsResponse, runAgent) import Test.Tasty (TestTree, testGroup) import Test.Tasty.HUnit (assertFailure, testCase, (@?=)) 
+ test/RLMExample.hs view
@@ -0,0 +1,92 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE GADTs #-}+{-# LANGUAGE OverloadedLabels #-}+{-# LANGUAGE OverloadedStrings #-}++module RLMExample (main, example) where++import Baikai qualified as B+import Control.Lens ((&), (.~))+import Data.Generics.Labels ()+import Data.IORef (atomicModifyIORef', newIORef)+import Data.Text (Text)+import Data.Vector qualified as V+import Effectful (liftIO, runEff)+import Effectful.Dispatch.Dynamic (interpret)+import Effectful.Error.Static (runErrorNoCallStack, throwError)+import GHC.Generics (Generic)+import Shikumi.Adapter (ToPrompt)+import Shikumi.CodeExec.RLM+import Shikumi.CodeExec.Session+import Shikumi.Error (ShikumiError (..))+import Shikumi.LLM (LLM (..))+import Shikumi.Program (runProgram)+import Shikumi.Schema (FromModel, ToSchema, Validatable)+import Shikumi.Signature (Signature, mkSignature)++newtype Question = Question {question :: Text}+  deriving stock (Generic)+  deriving anyclass (ToPrompt)++newtype Answer = Answer {answer :: Text}+  deriving stock (Generic, Show, Eq)+  deriving anyclass (ToSchema, FromModel)++instance Validatable Answer++signature :: Signature Question Answer+signature = mkSignature "Inspect the source and answer the question."++example :: IO (Either ShikumiError (Text, Int))+example = do+  replies <-+    newIORef+      [ "{\"op\":\"slice\",\"name\":\"source\",\"start\":0,\"count\":12}",+        "{\"op\":\"query\",\"prompt\":\"What color is named in: Color: amber?\"}",+        "amber",+        "{\"op\":\"submit\",\"value\":{\"answer\":\"amber\"}}"+      ]+  runEff+    . runErrorNoCallStack+    . interpret+      ( \_ -> \case+          Complete {} -> do+            next <-+              liftIO+                ( atomicModifyIORef'+                    replies+                    ( \case+                        [] -> ([], Nothing)+                        x : xs -> (xs, Just x)+                    )+                )+            case next of+              Nothing -> throwError (ProviderFailure "Unexpected extra model call")+              Just text -> pure (B.emptyResponse & #message . #content .~ V.singleton (B.AssistantText (B.emptyTextContent & #text .~ text)))+          Stream {} -> throwError (ProviderFailure "Unexpected streaming call")+      )+    $ do+      -- Only the question is ToPrompt input. The source remains outside it.+      source <-+        either+          (\(SessionError e) -> throwError (ValidationFailure e))+          pure+          (contextStore [("source", "Color: amber")])+      (outcome, report) <-+        runProgram+          (rlmWithReport defaultRLMConfig source signature)+          (Question "Which color is named?")+      case outcome of+        RLMSubmitted (Answer text) -> pure (text, subqueriesAttempted report)+        RLMExhausted limit ->+          throwError+            ( BudgetExceeded+                ( "Session exhausted: " <> case limit of+                    Subqueries -> "subqueries"+                    _ -> "another configured allowance"+                )+            )++main :: IO ()+main = example >>= print
+ test/RLMSpec.hs view
@@ -0,0 +1,233 @@+{-# LANGUAGE GADTs #-}++module RLMSpec (tests) where++import Baikai (Model, emptyModel)+import Baikai.Speed (Speed (..))+import Control.Concurrent (forkFinally, newEmptyMVar, putMVar, takeMVar)+import Control.Lens ((&), (.~), (^.))+import Data.Aeson (Value (..), object, (.=))+import Data.Generics.Labels ()+import Data.IORef (modifyIORef', newIORef, readIORef)+import Data.Text (Text)+import Data.Text qualified as T+import Effectful (liftIO, runEff)+import Effectful.Dispatch.Dynamic (interpret)+import Effectful.Error.Static (runErrorNoCallStack, throwError)+import GHC.Generics (Generic)+import RLMExample qualified+import Shikumi.Adapter (ToPrompt)+import Shikumi.CodeExec.Prompt (encodeText)+import Shikumi.CodeExec.RLM+import Shikumi.CodeExec.Session+import Shikumi.Error (ShikumiError (..))+import Shikumi.LLM (LLM (..))+import Shikumi.LLM.Defaults+import Shikumi.Program (Program, runProgram)+import Shikumi.Schema (FromModel, ToSchema, Validatable (..))+import Shikumi.Signature (Signature, mkSignature)+import Shikumi.Testing (captureLLMRequests, mkTextResponse)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit++newtype Question = Question {question :: Text}+  deriving stock (Generic, Show)+  deriving anyclass (ToPrompt)++newtype Answer = Answer {answer :: Text}+  deriving stock (Generic, Show, Eq)+  deriving anyclass (ToSchema, FromModel)++instance Validatable Answer where+  validate (Answer a) = if T.null a then Left "answer must be nonempty" else Right (Answer a)++sig :: Signature Question Answer+sig = mkSignature "Combine the two facts."++-- Capture exact textual request content and selected models; fail if the loop+-- dispatches even one unscripted call (including hidden extraction or repair).+runCaptured :: [Either ShikumiError Text] -> Program Question a -> IO (Either ShikumiError a, [(Model, Text)])+runCaptured script prog = do+  effective <- newIORef []+  remaining <- newIORef script+  captured <- newIORef []+  out <-+    runEff+      . runErrorNoCallStack @ShikumiError+      . interpret+        ( \_ -> \case+            Complete model ctx _ -> do+              -- Context JSON contains every message and system string, not just a+              -- hand-selected excerpt. Decode-free capture also catches source leaks.+              let rendered = encodeText (object ["system" .= (ctx ^. #systemPrompt), "messages" .= (ctx ^. #messages)])+              liftIO (modifyIORef' captured ((model, rendered) :))+              xs <- liftIO (readIORef remaining)+              case xs of+                [] -> throwError (ProviderFailure "unscripted call")+                x : rest -> do+                  liftIO (modifyIORef' remaining (const rest))+                  either throwError (pure . mkTextResponse) x+            Stream {} -> throwError (ProviderFailure "unexpected stream")+        )+      . captureLLMRequests effective+      . withRequestDefaults (emptyRequestDefaults {defaultSpeed = Just SpeedFast})+      $ runProgram prog (Question "Find both facts")+  requests <- readIORef effective+  map (\(_, _, opts) -> opts ^. #speed) requests @?= replicate (length requests) (Just SpeedFast)+  calls <- reverse <$> readIORef captured+  pure (out, calls)++docs :: [(Text, Text)] -> IO ContextStore+docs xs = either (\e -> assertFailure (show e) >> fail "docs") pure (contextStore xs)++submit :: Text -> Text+submit a = encodeText (object ["op" .= ("submit" :: Text), "value" .= object ["answer" .= a]])++expectLimit :: SessionLimit -> Either ShikumiError (RLMOutcome Answer, RLMReport) -> Assertion+expectLimit limit (Right (out, report)) = do+  out @?= RLMExhausted limit+  termination report @?= Just limit+expectLimit _ x = assertFailure (show x)++tests :: TestTree+tests =+  testGroup+    "experimental RLM"+    [ testCase "documented RLM example" $ RLMExample.example >>= (@?= Right ("amber", 1)),+      testCase "two-megabyte source, bounded requests, persisted facts and concurrent isolation" $ do+        a <- newEmptyMVar+        b <- newEmptyMVar+        _ <- forkFinally (largeCase "amber" "owl") (putMVar a)+        _ <- forkFinally (largeCase "violet" "fox") (putMVar b)+        ra <- takeMVar a+        rb <- takeMVar b+        either (assertFailure . show) (@?= Right (Answer "amber owl")) ra+        either (assertFailure . show) (@?= Right (Answer "violet fox")) rb,+      testCase "three-question batch at two-call limit dispatches zero subqueries" $ do+        d <- docs []+        let c = defaultRLMConfig {session = defaultSessionConfig {maxSubqueries = 2}}+        (r, calls) <- runCaptured [Right "{\"op\":\"query_batch\",\"prompts\":[\"a\",\"b\",\"c\"]}"] (rlmWithReport c d sig)+        expectLimit Subqueries r+        length calls @?= 1+        case r of Right (_, rep) -> subqueriesAttempted rep @?= 0; _ -> pure (),+      testCase "accepted batch executes exactly two ordered calls using selected model" $ do+        d <- docs []+        let model = emptyModel & #modelId .~ "submodel"+            c = defaultRLMConfig {session = defaultSessionConfig {maxSubqueries = 2, subModel = model}}+        (r, calls) <- runCaptured (map Right ["{\"op\":\"query_batch\",\"prompts\":[\"first\",\"second\"]}", "one", "two", submit "one two"]) (rlmWithReport c d sig)+        length calls @?= 4+        map ((^. #modelId) . fst) calls @?= ["", "submodel", "submodel", ""]+        case r of+          Right (RLMSubmitted (Answer "one two"), rep) -> do+            subqueriesAttempted rep @?= 2+            operations rep @?= 2+            [observationValue o | row <- audit rep, Just o <- [auditObservation row]] @?= [String "one", String "two"]+          _ -> assertFailure (show r),+      testCase "failed subquery propagates infrastructure error and stops batch" $ do+        d <- docs []+        (r, calls) <- runCaptured [Right "{\"op\":\"query_batch\",\"prompts\":[\"a\",\"b\"]}", Left (Timeout "cancelled")] (rlmWithReport defaultRLMConfig d sig)+        r @?= Left (Timeout "cancelled")+        length calls @?= 2,+      testCase "malformed action and invalid submission recover without extraction" $ do+        d <- docs [("empty", "")]+        (r, calls) <- runCaptured (map Right ["{", "{\"op\":\"submit\",\"value\":{}}", submit "", submit "fixed"]) (rlmWithReport defaultRLMConfig d sig)+        length calls @?= 4+        case r of+          Right (RLMSubmitted (Answer "fixed"), rep) -> operations rep @?= 4+          _ -> assertFailure (show r),+      testCase "outer, operation and observation exhaustion make no extra call" $ do+        d <- docs []+        let bad = Right "{"+        mapM_+          ( \(c, limit) -> do+              (r, calls) <- runCaptured [bad] (rlmWithReport c d sig)+              expectLimit limit r+              length calls @?= 1+          )+          [ (defaultRLMConfig {maxOuterIterations = 1}, OuterIterations),+            (defaultRLMConfig {session = defaultSessionConfig {maxOperations = 1}}, Operations),+            (defaultRLMConfig {session = defaultSessionConfig {maxObservedChars = 1}}, ObservationCharacters)+          ],+      testCase "request bounds are enforced before outer or sub-model dispatch" $ do+        d <- docs []+        (r, calls) <- runCaptured [] (rlmWithReport (defaultRLMConfig {maxRequestChars = 1}) d sig)+        expectLimit RequestCharacters r+        length calls @?= 0+        let c = defaultRLMConfig {maxRequestChars = 4000, session = defaultSessionConfig {maxSubqueryChars = 10000}}+            query = encodeText (object ["op" .= ("query" :: Text), "prompt" .= T.replicate 5000 "q"])+        (r2, calls2) <- runCaptured [Right query] (rlmWithReport c d sig)+        expectLimit RequestCharacters r2+        length calls2 @?= 1,+      testCase "retained action history cannot exceed request ceiling" $ do+        d <- docs []+        let c = defaultRLMConfig {maxRequestChars = 4000}+            action = encodeText (object ["op" .= ("store" :: Text), "name" .= ("memo" :: Text), "value" .= T.replicate 3000 "a"])+        (r, calls) <- runCaptured [Right action] (rlmWithReport c d sig)+        expectLimit RequestCharacters r+        length calls @?= 1,+      testCase "oversized subquery response is explicitly truncated in report and next request" $ do+        d <- docs []+        let c = defaultRLMConfig {session = defaultSessionConfig {maxObservationChars = 200}}+        (r, calls) <- runCaptured (map Right ["{\"op\":\"query\",\"prompt\":\"a\"}", T.replicate 10000 "😀", submit "ok"]) (rlmWithReport c d sig)+        length calls @?= 3+        case r of+          Right (RLMSubmitted _, rep) -> do+            let obs = [o | row <- audit rep, Just o <- [auditObservation row]]+            assertBool "truncation recorded" (any ((/= Nothing) . truncation) obs)+            assertBool "all observations bounded" (all ((<= 200) . T.length . renderObservation) obs)+          _ -> assertFailure (show r),+      testCase "invalid configs fail before any call; convenience maps exhaustion" $ do+        d <- docs []+        mapM_+          ( \c -> do+              (r, calls) <- runCaptured [] (rlmWithReport c d sig)+              case r of Left (ValidationFailure _) -> pure (); _ -> assertFailure (show r)+              length calls @?= 0+          )+          [defaultRLMConfig {maxOuterIterations = 0}, defaultRLMConfig {maxRequestChars = (-1)}, defaultRLMConfig {session = defaultSessionConfig {maxSubqueries = 0}}]+        (r, _) <- runCaptured [] (rlm (defaultRLMConfig {maxRequestChars = 1}) d sig)+        r @?= Left (BudgetExceeded "RLM session exhausted: RequestCharacters")+    ]++largeCase :: Text -> Text -> IO (Either ShikumiError Answer)+largeCase first second = do+  let prefix = "FACT_A=" <> first+      source = prefix <> T.replicate (1500000 - T.length prefix) "." <> "FACT_B=" <> second <> T.replicate 600000 "."+      c = defaultRLMConfig {maxRequestChars = 16000, session = defaultSessionConfig {maxScanChars = 2000000, maxObservationChars = 1000}}+      action op fields = encodeText (object (("op" .= (op :: Text)) : fields))+      script =+        map+          Right+          [ action "load" ["name" .= ("memo" :: Text)],+            action "find" ["name" .= ("source" :: Text), "needle" .= ("FACT_A=" :: Text), "start" .= (0 :: Int), "limit" .= (1 :: Int)],+            action "slice" ["name" .= ("source" :: Text), "start" .= (0 :: Int), "count" .= T.length prefix],+            action "store" ["name" .= ("memo" :: Text), "value" .= first],+            action "find" ["name" .= ("source" :: Text), "needle" .= ("FACT_B=" :: Text), "start" .= (1000000 :: Int), "limit" .= (1 :: Int)],+            action "slice" ["name" .= ("source" :: Text), "start" .= (1500000 :: Int), "count" .= (7 + T.length second)],+            action "load" ["name" .= ("memo" :: Text)],+            action "query" ["prompt" .= ("Combine FACT_A=" <> first <> " and FACT_B=" <> second)],+            first <> " " <> second,+            submit (first <> " " <> second)+          ]+  d <- docs [("source", source)]+  (r, calls) <- runCaptured script (rlmWithReport c d sig)+  assertBool "fixture is at least two megabytes" (T.length source > 2000000)+  length calls @?= 10+  mapM_+    ( \(_, request) -> do+        assertBool "no source copied" (not (source `T.isInfixOf` request))+        assertBool "even serialized capture stays bounded" (T.length request <= maxRequestChars c)+    )+    calls+  case r of+    Right (RLMSubmitted out, rep) -> do+      operations rep @?= 9+      subqueriesAttempted rep @?= 1+      let obs = [o | row <- audit rep, Just o <- [auditObservation row]]+      assertBool "fresh invocation has no memo" (any ((/= Nothing) . observationError) (take 1 obs))+      assertBool "first fact found at source origin" (any ((== object ["matches" .= ([0] :: [Int]), "scannedEnd" .= (2000000 :: Int), "nextOffset" .= (1 :: Int), "complete" .= False]) . observationValue) obs)+      assertBool "second fact sliced at distant source offset" (any ((== object ["name" .= ("source" :: Text), "start" .= (1500000 :: Int), "end" .= (1500007 + T.length second), "text" .= ("FACT_B=" <> second)]) . observationValue) obs)+      assertBool "stored fact is observed" (any ((== String first) . observationValue) obs)+      pure (Right out)+    Right other -> assertFailure (show other) >> pure (Left (ProviderFailure "exhausted"))+    Left e -> pure (Left e)
+ test/ReActSessionExample.hs view
@@ -0,0 +1,89 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE OverloadedLabels #-}+{-# LANGUAGE OverloadedStrings #-}++-- | Offline example compiled and executed by the shikumi-tools test suite.+module ReActSessionExample (main, example) where++import Baikai qualified as B+import Control.Lens ((&), (.~))+import Data.Aeson (eitherDecode, encode, object, (.=))+import Data.Generics.Labels ()+import Data.IORef (atomicModifyIORef', newIORef)+import Data.Text (Text)+import Data.Text qualified as T+import Data.Vector qualified as V+import Effectful (liftIO, runEff)+import Effectful.Dispatch.Dynamic (interpret)+import Effectful.Error.Static (runErrorNoCallStack, throwError)+import GHC.Generics (Generic)+import Shikumi.Adapter (ToPrompt)+import Shikumi.Agent.History (decodeSession, encodeSession, sessionTurns)+import Shikumi.Agent.ReAct+import Shikumi.Error (ShikumiError (..))+import Shikumi.LLM (LLM (..))+import Shikumi.LLM.Continuation (validateRequestContinuation)+import Shikumi.Schema (FromModel, ToSchema, Validatable)+import Shikumi.Signature (Signature, mkSignature)+import Shikumi.Tool (mkDynTool, mkRegistry)+import Shikumi.Tool.Output (textToolOutput)++newtype Question = Question {question :: Text}+  deriving stock (Generic)+  deriving anyclass (ToPrompt)++newtype Answer = Answer {answer :: Text}+  deriving stock (Generic, Show, Eq)+  deriving anyclass (ToSchema, FromModel)++instance Validatable Answer++signature :: Signature Question Answer+signature = mkSignature "Use the lookup tool and answer the question."++-- | Returns the typed answer and cumulative assistant exchange count.+example :: IO (Either ShikumiError (Text, Int))+example = do+  let registry = mkRegistry [mkDynTool "lookup" "Return the stored city." (object []) (\_ -> pure (Right (textToolOutput "Paris")))]+      cfg = defaultReActConfig {protocol = ProtocolNative}+      model = B.mkModel (B.Custom "session-example") "example" "https://example.invalid"+      response callId name args = B.emptyResponse & #model .~ model & #message . #content .~ V.singleton (B.AssistantToolCall (B.ToolCall callId name args))+      script = [response "call-A" "lookup" (object []), response "final-A" finalToolName (object ["answer" .= ("Paris, France" :: Text)])]+  replies <- newIORef script+  runEff+    . runErrorNoCallStack+    . interpret+      ( \_ -> \case+          Complete m ctx opts -> do+            either throwError pure (validateRequestContinuation m ctx opts)+            next <-+              liftIO+                ( atomicModifyIORef'+                    replies+                    ( \xs -> case xs of+                        [] -> ([], Nothing)+                        x : rest -> (rest, Just x)+                    )+                )+            maybe (throwError (ProviderFailure "Unexpected extra model call")) pure next+          Stream {} -> pure []+      )+    $ do+      initial <- startSessionWithModel model signature registry cfg (Question "Which city is stored?")+      first <- advanceSession signature registry cfg initial+      checkpoint <- case first of+        SessionPaused s -> pure s+        _ -> throwError (ValidationFailure "Expected a checkpoint")+      -- Write these bytes to caller-owned storage if persistence is needed.+      let bytes = encode (encodeSession checkpoint)+          decoded = eitherDecode bytes >>= either (Left . show) Right . decodeSession+      saved <- either (throwError . ValidationFailure . T.pack) pure decoded+      resumed <- continueSession signature registry cfg (Question "Include the country too.") saved+      finished <- runSession signature registry cfg resumed+      case finished of+        SessionFinished (Answer text) s -> pure (text, sessionTurns s)+        SessionPaused _ -> throwError (ValidationFailure "Iteration budget exhausted")++main :: IO ()+main = example >>= print
test/ReActSpec.hs view
@@ -21,7 +21,6 @@     weatherRegistry,     weatherSignature,   )-import MockLLM (mkTextResponse, runAgent) import Shikumi.Agent.ReAct   ( Action (..),     Step (..),@@ -31,6 +30,7 @@     reactWithTrajectory,   ) import Shikumi.Error (ShikumiError (..))+import Shikumi.Testing (mkTextResponse, runAgent) import Shikumi.Tool (SomeTool (..), Tool, ToolRegistry, mkRegistry, mkTool) import Test.Tasty (TestTree, testGroup) import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))
+ test/ResponsesIntegrationSpec.hs view
@@ -0,0 +1,215 @@+module ResponsesIntegrationSpec (tests) where++import Baikai qualified as B+import Baikai.Error qualified as BE+import Control.Concurrent (forkFinally, killThread, threadDelay)+import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)+import Control.Lens ((&), (.~), (^.))+import Data.Aeson+import Data.Aeson.KeyMap qualified as KM+import Data.Generics.Labels ()+import Data.IORef+import Data.Text qualified as T+import Data.Vector qualified as V+import Effectful (Eff, IOE, liftIO, runEff, (:>))+import Effectful.Concurrent (runConcurrent)+import Effectful.Dispatch.Dynamic (interpose)+import Effectful.Error.Static (runErrorNoCallStack, throwError)+import Effectful.Prim (runPrim)+import Shikumi.Agent.History qualified as H+import Shikumi.Agent.ReAct qualified as R+import Shikumi.Cache (cachedLLM)+import Shikumi.Cache.Backend.Memory (newMemoryCache, runCacheMemory)+import Shikumi.Compaction (CompactionConfig (..))+import Shikumi.Effect.Time (runTime)+import Shikumi.Error (ShikumiError (..))+import Shikumi.LLM qualified as L+import Shikumi.LLM.Defaults+import Shikumi.LLM.Observation qualified as O+import Shikumi.Routing (routeLLM, runRouting)+import Shikumi.Testing.Fixtures (Answer (..), Question (..), instructedSig)+import Shikumi.Testing.Responses+import Shikumi.Tool qualified as Tool+import Shikumi.Tool.Output (textToolOutput)+import System.Timeout (timeout)+import Test.Tasty+import Test.Tasty.HUnit++cfg :: R.ReActConfig+cfg = R.defaultReActConfig {R.protocol = R.ProtocolNative, R.maxIters = 3, R.compaction = CompactionConfig 0 4 False}++tools :: Tool.ToolRegistry+tools =+  Tool.mkRegistry+    [ Tool.mkDynTool "lookup" "Return the stored city." (object ["type" .= String "object", "properties" .= object []]) $ \_ -> do+        _ <- L.complete B.emptyModel (B.emptyContext & #systemPrompt .~ Just "local-lookup") B.emptyOptions+        pure (Right (textToolOutput "Paris"))+    ]++-- Count actual body entries via a local effect handler; it never dispatches HTTP.+countLookup :: (L.LLM :> es, IOE :> es) => IORef Int -> Eff es a -> Eff es a+countLookup ref = interpose $ \_ -> \case+  L.Complete _ c _ | c ^. #systemPrompt == Just "local-lookup" -> do+    liftIO (atomicModifyIORef' ref (\n -> (n + 1, ())))+    pure B.emptyResponse+  L.Complete m c o -> L.complete m c o+  L.Stream m c o -> L.stream m c o++firstReply :: Reply+firstReply = sseReply [completed [reasoningItem, functionItem "item-lookup" "call-lookup" "lookup" (object [])]]++lastReply :: Reply+lastReply = sseReply [completed [functionItem "item-final" "call-final" R.finalToolName (object ["answer" .= String "Paris", "confidence" .= (0.9 :: Double)])]]++restore :: H.ReActSession -> Either String H.ReActSession+restore s = eitherDecode (encode (H.encodeSession s)) >>= either (Left . show) Right . H.decodeSession++tests :: TestTree+tests =+  testGroup+    "Responses sessions"+    [ testCase "real reasoning and tool IDs survive checkpoint bytes with one dispatch" $+        withResponsesFixture [firstReply, lastReply] $ \f -> do+          ref <- newIORef 0+          result <- runEff+            . runErrorNoCallStack @ShikumiError+            . runRouting (model f)+            . L.runLLMWith (registry f)+            . withTransportOptions fixtureOptions+            . withRequestDefaults (emptyRequestDefaults {defaultThinking = Just B.ThinkingHigh})+            . routeLLM+            . countLookup ref+            $ do+              initial <- R.startSessionWithModel (model f) instructedSig tools cfg (Question "Look up the city.")+              step <- R.advanceSession instructedSig tools cfg initial+              checkpoint <- case step of R.SessionPaused s -> pure s; _ -> throwError (ValidationFailure "expected checkpoint")+              saved <- either (throwError . ValidationFailure . T.pack) pure (restore checkpoint)+              resumed <- R.continueSession instructedSig tools cfg (Question "Submit the answer.") saved+              finished <- R.runSession instructedSig tools cfg resumed+              pure (checkpoint, saved, finished)+          case result of+            Right (before, saved, R.SessionFinished answer _) -> do+              before @?= saved+              answer @?= Answer "Paris" 0.9+              assertBool "unsafe compaction fails" (either (const True) (const False) (H.compactSession 1 "summary" saved))+            other -> assertFailure (show other)+          readIORef ref >>= (@?= 1)+          bodies <- requests f+          length bodies @?= 2+          case bodies of+            [_, Object second] -> case KM.lookup "input" second of+              Just (Array items) -> do+                assertBool "opaque reasoning item is byte-structurally intact" (reasoningItem `elem` V.toList items)+                let typed kind = [o | Object o <- V.toList items, KM.lookup "type" o == Just (String kind)]+                map (KM.lookup "call_id") (typed "function_call") @?= [Just (String "call-lookup")]+                map (KM.lookup "call_id") (typed "function_call_output") @?= [Just (String "call-lookup")]+                let kinds = [KM.lookup "type" o | Object o <- V.toList items]+                assertBool "call precedes result" (V.findIndex (== Just (String "function_call")) (V.fromList kinds) < V.findIndex (== Just (String "function_call_output")) (V.fromList kinds))+              _ -> assertFailure "missing wire input"+            _ -> assertFailure "wrong request count",+      testCase "wrong routed model and API fail before a second HTTP request" $+        withResponsesFixture [firstReply] $ \f -> do+          ref <- newIORef 0+          initial <- runEff+            . runErrorNoCallStack @ShikumiError+            . runRouting (model f)+            . L.runLLMWith (registry f)+            . withTransportOptions fixtureOptions+            . routeLLM+            . countLookup ref+            $ do+              s <- R.startSessionWithModel (model f) instructedSig tools cfg (Question "lookup")+              R.advanceSession instructedSig tools cfg s+          case initial of+            Right (R.SessionPaused checkpoint) ->+              mapM_+                ( \wrong -> do+                    result <-+                      runEff+                        . runErrorNoCallStack @ShikumiError+                        . runRouting wrong+                        . L.runLLMWith (registry f)+                        . withTransportOptions fixtureOptions+                        . routeLLM+                        $ R.advanceSession instructedSig tools cfg checkpoint+                    assertBool "origin rejected" (case result of Left (ValidationFailure _) -> True; _ -> False)+                )+                [model f & #modelId .~ "wrong-model", model f & #api .~ B.OpenAIChatCompletions]+            other -> assertFailure (show other)+          requests f >>= (\xs -> length xs @?= 1)+          readIORef ref >>= (@?= 1),+      testCase "released refusal error is terminal; transient transport retries are observed" $ do+        withResponsesFixture [sseReply [failureFrame "content_filter"]] $ \f -> do+          (observe, snapshot) <- O.newBillingCollectorWithLimit 4+          let policy = (L.defaultLLMConfig (registry f)) {L.observer = Just observe, L.retryPolicy = L.RetryPolicy 3 0 0}+          result <-+            runEff . runConcurrent . runErrorNoCallStack @ShikumiError . L.runLLMResilient policy $+              L.complete (model f) B.emptyContext fixtureOptions+          case result of+            Left (ProviderError e) -> BE.category e @?= BE.OtherError+            other -> assertFailure (show other)+          requests f >>= (\xs -> length xs @?= 1)+          snapshot >>= (\s -> O.failedAttempts s @?= 1)+        withResponsesFixture [Reply 500 [] 0, sseReply [completed [messageItem "ok"]]] $ \f -> do+          (observe, snapshot) <- O.newBillingCollectorWithLimit 4+          let policy = (L.defaultLLMConfig (registry f)) {L.observer = Just observe, L.retryPolicy = L.RetryPolicy 2 0 0}+          result <-+            runEff . runConcurrent . runErrorNoCallStack @ShikumiError . L.runLLMResilient policy $+              L.complete (model f) B.emptyContext fixtureOptions+          assertBool "retry succeeds" (either (const False) (const True) result)+          summary <- snapshot+          map O.attempt (O.retainedAttempts summary) @?= [1, 2]+          O.completedAttempts summary @?= 1+          O.failedAttempts summary @?= 1,+      testCase "cache hit adds no released transport attempt" $+        withResponsesFixture [sseReply [completed [messageItem "ok"]]] $ \f -> do+          cache <- newMemoryCache+          (observe, snapshot) <- O.newBillingCollectorWithLimit 4+          let policy = (L.defaultLLMConfig (registry f)) {L.observer = Just observe}+          result <- runEff+            . runPrim+            . runTime+            . runConcurrent+            . runErrorNoCallStack @ShikumiError+            . runCacheMemory cache+            . L.runLLMResilient policy+            . cachedLLM+            $ do+              _ <- L.complete (model f) B.emptyContext fixtureOptions+              _ <- L.complete (model f) B.emptyContext fixtureOptions+              pure ()+          result @?= Right ()+          requests f >>= (\xs -> length xs @?= 1)+          snapshot >>= (\s -> O.completedAttempts s @?= 1),+      testCase "timeout and cancellation release loopback workers" $ do+        cleanup <- withResponsesFixture [Reply 200 [completed [messageItem "late"]] 2000000] $ \f -> do+          result <-+            runEff . runErrorNoCallStack @ShikumiError . L.runLLMWith (registry f) $+              L.complete (model f) B.emptyContext (fixtureOptions & #timeoutMs .~ Just 50)+          assertBool "transport timeout fails" (either (const True) (const False) result)+          pure (activeRequests f)+        drained <- timeout 2000000 (waitForZero cleanup)+        drained @?= Just ()+        cleanup2 <- withResponsesFixture [Reply 200 [completed [messageItem "late"]] 2000000] $ \f -> do+          done <- newEmptyMVar+          tid <-+            forkFinally+              ( runEff . runErrorNoCallStack @ShikumiError . L.runLLMWith (registry f) $+                  L.complete (model f) B.emptyContext (fixtureOptions & #timeoutMs .~ Just 5000)+              )+              (putMVar done)+          entered <- timeout 2000000 (waitForActive (activeRequests f))+          killThread tid+          outcome <- takeMVar done+          assertBool "cancellation propagates from the released adapter" (either (const True) (const False) outcome)+          entered @?= Just ()+          pure (activeRequests f)+        drained2 <- timeout 2000000 (waitForZero cleanup2)+        drained2 @?= Just ()+    ]++waitForZero :: IO Int -> IO ()+waitForZero readCount = readCount >>= \n -> if n == 0 then pure () else threadDelay 1000 >> waitForZero readCount++waitForActive :: IO Int -> IO ()+waitForActive readCount = readCount >>= \n -> if n > 0 then pure () else threadDelay 1000 >> waitForActive readCount
+ test/ResponsesSchemaSpec.hs view
@@ -0,0 +1,91 @@+module ResponsesSchemaSpec (tests) where++import Baikai qualified as B+import Control.Lens ((&), (.~), (^.))+import Data.Aeson (Value (..), object, (.=))+import Data.Aeson.Key (Key)+import Data.Aeson.KeyMap qualified as KM+import Data.Generics.Labels ()+import Data.Vector qualified as V+import Effectful (runEff)+import Effectful.Error.Static (runErrorNoCallStack)+import Shikumi.Adapter (attachSchema)+import Shikumi.Error (ShikumiError)+import Shikumi.LLM qualified as L+import Shikumi.LLM.Defaults+import Shikumi.Program (runProgram)+import Shikumi.Routing (routeLLM, runRouting)+import Shikumi.Schema (deriveSchema)+import Shikumi.Testing.Fixtures+import Shikumi.Testing.Responses+import Test.Tasty+import Test.Tasty.HUnit++field :: [Key] -> Value -> Maybe Value+field [] v = Just v+field (k : ks) (Object o) = KM.lookup k o >>= field ks+field _ _ = Nothing++tests :: TestTree+tests =+  testGroup+    "Responses real adapter"+    [ testCase "typed schema, defaults and streamed terminal cross actual HTTP" $+        withResponsesFixture (replicate 2 (sseReply [completed [messageItem "{\"answer\":\"Forty-two\",\"confidence\":0.9}"]])) $ \f -> do+          let defaults = emptyRequestDefaults {defaultThinking = Just B.ThinkingHigh, defaultMaxTokens = Just 128}+              schema = object ["type" .= String "object", "properties" .= object ["answer" .= object ["type" .= String "string"]]]+          result <-+            runEff+              . runErrorNoCallStack @ShikumiError+              . runRouting (model f)+              . L.runLLMWith (registry f)+              . withTransportOptions fixtureOptions+              . withRequestDefaults defaults+              . routeLLM+              $ runProgram instructedProg (Question "What is the answer?")+          result @?= Right (Answer "Forty-two" 0.9)+          streamed <-+            runEff+              . runErrorNoCallStack @ShikumiError+              . runRouting (model f)+              . L.runLLMWith (registry f)+              . withTransportOptions fixtureOptions+              . withRequestDefaults defaults+              . routeLLM+              $ L.stream (model f) B.emptyContext (attachSchema schema B.emptyOptions)+          case streamed of+            Right events -> case [p | B.EventDone tp <- events, B.AssistantMessage p <- [tp ^. #message]] of+              [p] -> do+                p ^. #usage . #inputTokens @?= 20+                p ^. #usage . #outputTokens @?= 5+              _ -> assertFailure "missing terminal payload"+            Left e -> assertFailure (show e)+          bodies <- requests f+          length bodies @?= 2+          map (field ["text", "format", "schema"]) bodies @?= [Just (deriveSchema @Answer), Just schema]+          mapM_+            ( \body -> do+                field ["model"] body @?= Just (String "fixture-reasoner")+                field ["stream"] body @?= Just (Bool True)+                field ["store"] body @?= Just (Bool False)+                field ["max_output_tokens"] body @?= Just (Number 128)+                field ["reasoning", "effort"] body @?= Just (String "high")+                field ["text", "format", "type"] body @?= Just (String "json_schema")+                field ["text", "format", "strict"] body @?= Just (Bool True)+                field ["include"] body @?= Just (Array (V.singleton (String "reasoning.encrypted_content")))+            )+            bodies,+      testCase "unsupported stop and image tool output fail before HTTP" $+        withResponsesFixture [] $ \f -> do+          let badImage = B.ToolResultMessage (B.ToolResultPayload "call" "lookup" (V.singleton (B.ToolResultImage (B.ImageContent "bytes" "image/png"))) False Nothing)+          -- Both failures are exercised through the released mapper.+          a <-+            runEff . runErrorNoCallStack @ShikumiError . L.runLLMWith (registry f) $+              L.complete (model f) B.emptyContext (fixtureOptions & #stopSequences .~ ["stop"])+          b <-+            runEff . runErrorNoCallStack @ShikumiError . L.runLLMWith (registry f) $+              L.complete (model f) (B.emptyContext & #messages .~ V.singleton badImage) fixtureOptions+          assertBool "stop rejected" (either (const True) (const False) a)+          assertBool "image result rejected" (either (const True) (const False) b)+          requests f >>= (@?= [])+    ]
+ test/SessionSpec.hs view
@@ -0,0 +1,113 @@+module SessionSpec (tests) where++import Data.Aeson (Value (..), object, (.=))+import Data.Text qualified as T+import Shikumi.CodeExec.Session+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit++setup :: SessionConfig -> [(T.Text, T.Text)] -> IO SessionState+setup c ds = either (\e -> assertFailure (show e) >> fail "setup") pure (contextStore ds >>= newSession c)++observation :: Either SessionLimit SessionResult -> IO SessionObservation+observation (Right (Observed o)) = pure o+observation x = assertFailure (show x) >> fail "observation"++tests :: TestTree+tests =+  testGroup+    "bounded session"+    [ testCase "variables persist, sources cannot be shadowed, sessions isolate" $ do+        s <- setup defaultSessionConfig [("doc", "hello")]+        let (s1, _) = stepSession s (Right (Store "memo" (String "remember")))+            (s2, rejected) = stepSession s1 (Right (Store "doc" Null))+        o <- observation rejected+        assertBool "shadow rejected" (observationError o /= Nothing)+        snd (stepSession s2 (Right (Load "memo"))) @?= Right (Observed (SessionObservation (String "remember") Nothing Nothing))+        isolated <- observation (snd (stepSession s (Right (Load "memo"))))+        assertBool "private variable" (observationError isolated /= Nothing)+        operationCount s2 @?= 2,+      testCase "Unicode slices use character offsets and empty documents work" $ do+        s <- setup defaultSessionConfig [("doc", "a😀界z"), ("empty", "")]+        o <- observation (snd (stepSession s (Right (Slice "doc" 1 2))))+        observationValue o @?= object ["name" .= ("doc" :: T.Text), "start" .= (1 :: Int), "end" .= (3 :: Int), "text" .= ("😀界" :: T.Text)]+        e <- observation (snd (stepSession s (Right (Slice "empty" 0 0))))+        observationError e @?= Nothing,+      testCase "invalid offsets, unknown names and builtins consume operations" $ do+        s <- setup defaultSessionConfig [("doc", "abc")]+        mapM_+          ( \a -> do+              let (s1, r) = stepSession s (Right a)+              o <- observation r+              assertBool (show a) (observationError o /= Nothing)+              operationCount s1 @?= 1+          )+          [Slice "doc" (-1) 1, Slice "doc" 4 0, Slice "doc" 1 3, Find "doc" "" 0 1, Find "doc" "a" 0 0, Load "absent", Describe "absent", Store "slice" Null],+      testCase "find scans bounded text and supports overlap and continuation" $ do+        s <- setup (defaultSessionConfig {maxScanChars = 4}) [("doc", "aaaaXYZ")]+        o <- observation (snd (stepSession s (Right (Find "doc" "aa" 0 2))))+        observationValue o @?= object ["matches" .= ([0, 1] :: [Int]), "scannedEnd" .= (4 :: Int), "nextOffset" .= (2 :: Int), "complete" .= False],+      testCase "stored character budget includes all values and names; rejection is atomic" $ do+        s <- setup (defaultSessionConfig {maxStoredChars = 10}) []+        let (s1, _) = stepSession s (Right (Store "x" (String "ok")))+            (s2, r) = stepSession s1 (Right (Store "x" (String (T.replicate 20 "x"))))+        o <- observation r+        assertBool "oversized rejected" (observationError o /= Nothing)+        snd (stepSession s2 (Right (Load "x"))) @?= snd (stepSession s1 (Right (Load "x"))),+      testCase "all limits must be positive and oversized contexts are refused" $ do+        let updates = [\c -> c {maxContextChars = 0}, \c -> c {maxStoredChars = (-1)}, \c -> c {maxActionBytes = 0}, \c -> c {maxObservationChars = 0}, \c -> c {maxObservedChars = 0}, \c -> c {maxOperations = 0}, \c -> c {maxScanChars = 0}, \c -> c {maxMatches = 0}, \c -> c {maxSubqueries = 0}, \c -> c {maxSubqueryChars = 0}]+        mapM_ (\f -> assertBool "invalid config" (validateSessionConfig (f defaultSessionConfig) /= Right ())) updates+        case contextStore [("d", "oversized")] >>= newSession (defaultSessionConfig {maxContextChars = 2}) of+          Left _ -> pure ()+          Right _ -> assertFailure "context admitted",+      testCase "UTF8 byte limit and malformed JSON are recoverable" $ do+        let c = defaultSessionConfig {maxActionBytes = 10}+        s <- setup c []+        mapM_+          ( \raw -> do+              let (s1, r) = stepSession s (parseSessionAction c raw)+              o <- observation r+              assertBool "parse error" (observationError o /= Nothing)+              operationCount s1 @?= 1+          )+          ["😀😀😀", "{", "{\"op\":1}"],+      testCase "observation bounds include escaping and truncation metadata" $ do+        s <- setup (defaultSessionConfig {maxObservationChars = 200, maxObservedChars = 250}) []+        let (s1, r) = recordObservation s (SessionObservation (String (T.replicate 1000 "\n")) Nothing Nothing)+        case r of+          Left e -> assertFailure (show e)+          Right o -> do+            assertBool "marked" (truncation o /= Nothing)+            assertBool "serialized bound" (T.length (renderObservation o) <= 200)+            observedChars s1 @?= T.length (renderObservation o)+            snd (recordObservation s1 o) @?= Left ObservationCharacters,+      testCase "truncated Unicode slices retain source offsets and can resume" $ do+        s <- setup (defaultSessionConfig {maxObservationChars = 250}) [("doc", T.replicate 1000 "😀\n")]+        o <- observation (snd (stepSession s (Right (Slice "doc" 0 1000))))+        case snd (recordObservation s o) of+          Right bounded -> case truncation bounded of+            Just tr -> do+              assertBool "prefix is nonempty" (displayedChars tr > 0)+              nextOffset tr @?= Just (displayedChars tr)+              assertBool "serialized slice is bounded" (T.length (renderObservation bounded) <= 250)+              next <- observation (snd (stepSession s (Right (Slice "doc" (displayedChars tr) 2))))+              observationError next @?= Nothing+            Nothing -> assertFailure "missing slice truncation"+          Left e -> assertFailure (show e),+      testCase "subquery slots persist across actions and oversized prompts reserve none" $ do+        s <- setup (defaultSessionConfig {maxSubqueries = 2, maxSubqueryChars = 3}) []+        let (s1, r) = stepSession s (Right (Query "long"))+        o <- observation r+        assertBool "prompt rejected" (observationError o /= Nothing)+        let (s2, r2) = stepSession s1 (Right (Query "a"))+        r2 @?= Right (RunQueries ["a"])+        snd (stepSession s2 (Right (QueryBatch ["b", "c"]))) @?= Left Subqueries,+      testCase "operation exhaustion and whole batch reservation are exact" $ do+        s <- setup (defaultSessionConfig {maxOperations = 1, maxSubqueries = 2}) []+        snd (stepSession s (Right (QueryBatch ["a", "b", "c"]))) @?= Left Subqueries+        let (s1, r) = stepSession s (Right (QueryBatch ["a", "b"]))+        r @?= Right (RunQueries ["a", "b"])+        subqueryAttempts s1 @?= 0+        subqueryAttempts (attemptedSubquery (attemptedSubquery s1)) @?= 2+        snd (stepSession s1 (Right (Submit Null))) @?= Left Operations+    ]
test/ShellSpec.hs view
@@ -4,7 +4,7 @@  import Control.Lens ((^.)) import Data.Text qualified as T-import MockLLM (runEffMock)+import Shikumi.Testing (runEffScript) import Shikumi.Tool (Tool (..)) import Shikumi.Tool.Builtin.Shell (BashReq (..), bashTool) import Shikumi.Tool.Env (localToolEnv)@@ -17,7 +17,7 @@     "Tool.Shell"     [ testCase "bash captures stdout and zero exit" $ do         result <--          runEffMock [] $+          runEffScript [] $             run               (bashTool localToolEnv)               BashReq {command = "echo hi", cwd = Nothing, timeoutMs = Just 5000, stdin = Nothing}@@ -28,7 +28,7 @@             assertBool "stdout contains hi" ("hi" `T.isInfixOf` (resp ^. #stdout)),       testCase "bash returns stderr and non-zero exit as a value" $ do         result <--          runEffMock [] $+          runEffScript [] $             run               (bashTool localToolEnv)               BashReq {command = "echo oops 1>&2; exit 3", cwd = Nothing, timeoutMs = Just 5000, stdin = Nothing}
+ test/ToolOutputSpec.hs view
@@ -0,0 +1,55 @@+module ToolOutputSpec (tests) where++import Baikai qualified as B+import Control.Lens ((&), (.~), (^.))+import Data.Aeson (object, (.=))+import Data.Generics.Labels ()+import Data.Text qualified as T+import Data.Vector qualified as V+import Effectful.Error.Static (throwError)+import Fixtures (weatherArgs, weatherRegistry)+import Shikumi.Error (ShikumiError (..))+import Shikumi.Testing (runEffScript)+import Shikumi.Tool+import Shikumi.Tool.Output+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit++tests :: TestTree+tests =+  testGroup+    "ToolOutput"+    [ testCase "typed text compatibility" $ do+        let call = B.ToolCall "A" "get_weather" weatherArgs+        rich <- runEffScript [] (runToolCallOutput weatherRegistry call)+        plain <- runEffScript [] (runToolCall weatherRegistry call)+        fmap (fmap renderToolOutput) rich @?= plain,+      testCase "structured-only dynamic output is preserved and visible" $ do+        let value = object ["answer" .= (42 :: Int)]+            output = ToolOutput (B.ToolResult V.empty False) (Just value) [value]+            reg = mkRegistry [mkDynTool "dynamic" "runtime" (object []) (\_ -> pure (Right output))]+            call = B.ToolCall "A" "dynamic" (object [])+        actual <- runEffScript [] (runToolCallOutput reg call)+        actual @?= Right (Right output)+        assertBool "JSON labels" ("Structured JSON:" `T.isInfixOf` renderToolOutput output && "Extension JSON:" `T.isInfixOf` renderToolOutput output)+        case toolOutputMessage call output of+          B.ToolResultMessage p -> do+            p ^. #toolCallId @?= "A"+            V.length (p ^. #content) @?= 2+          _ -> assertFailure "expected tool message",+      testCase "mixed native blocks retain images and error flag" $ do+        let img = B.emptyImageContent & #imageData .~ "bytes" & #mimeType .~ "image/png"+            blocks = V.fromList [B.ToolResultText (B.TextContent "caption"), B.ToolResultImage img]+            output = ToolOutput (B.ToolResult blocks True) Nothing []+        case toolOutputMessage (B.ToolCall "A" "tool" (object [])) output of+          B.ToolResultMessage p -> do+            p ^. #content @?= blocks+            p ^. #isError @?= True+          _ -> assertFailure "expected tool message"+        assertBool "error flag visible in prompt/text projection" ("Tool error:" `T.isPrefixOf` renderToolOutput output)+        assertBool "image not dropped in text projection" ("Image JSON:" `T.isInfixOf` renderToolOutput output),+      testCase "dynamic recoverable failure is model-visible" $ do+        let tool = mkDynTool "bad" "" (object []) (\_ -> throwError (ValidationFailure "bad result"))+        result <- runEffScript [] (runErasedOutput tool (object []))+        result @?= Right (Left (ToolRunFailed "bad" "bad result"))+    ]
test/ToolSpec.hs view
@@ -13,8 +13,8 @@ import Data.Text qualified as T import Effectful.Error.Static (throwError) import Fixtures (WeatherReq, WeatherResp, weatherArgs, weatherRegistry)-import MockLLM (runEffMock) import Shikumi.Error (ShikumiError (..))+import Shikumi.Testing (runEffScript) import Shikumi.Tool (SomeTool (..), Tool, ToolError (..), mkRegistry, mkTool, runToolCall) import Test.Tasty (TestTree, testGroup) import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))@@ -37,27 +37,27 @@   testGroup     "Tool"     [ testCase "decodes valid args and runs the body" $ do-        res <- runEffMock [] (runToolCall weatherRegistry (tc "get_weather" weatherArgs))+        res <- runEffScript [] (runToolCall weatherRegistry (tc "get_weather" weatherArgs))         case res of           Right (Right obs) -> assertBool "observation mentions the forecast" ("mild" `T.isInfixOf` obs)           other -> assertFailure ("expected a tool result, got " <> show other),       testCase "returns ToolArgsInvalid for a missing required field" $ do-        res <- runEffMock [] (runToolCall weatherRegistry (tc "get_weather" (object ["city" .= ("Paris" :: Text)])))+        res <- runEffScript [] (runToolCall weatherRegistry (tc "get_weather" (object ["city" .= ("Paris" :: Text)])))         case res of           Right (Left (ToolArgsInvalid nm _)) -> nm @?= "get_weather"           other -> assertFailure ("expected ToolArgsInvalid, got " <> show other),       testCase "returns ToolNotFound for an unknown name" $ do-        res <- runEffMock [] (runToolCall weatherRegistry (tc "nope" (object [])))+        res <- runEffScript [] (runToolCall weatherRegistry (tc "nope" (object [])))         case res of           Right (Left (ToolNotFound nm)) -> nm @?= "nope"           other -> assertFailure ("expected ToolNotFound, got " <> show other),       testCase "a tool body throwing BudgetExceeded escapes as ShikumiError" $ do-        res <- runEffMock [] (runToolCall (mkRegistry [SomeTool budgetTool]) (tc "burn_budget" weatherArgs))+        res <- runEffScript [] (runToolCall (mkRegistry [SomeTool budgetTool]) (tc "burn_budget" weatherArgs))         case res of           Left (BudgetExceeded msg) -> msg @?= "ceiling reached"           other -> assertFailure ("expected escaped BudgetExceeded, got " <> show other),       testCase "a tool body throwing ValidationFailure becomes ToolRunFailed" $ do-        res <- runEffMock [] (runToolCall (mkRegistry [SomeTool flakyTool]) (tc "flaky" weatherArgs))+        res <- runEffScript [] (runToolCall (mkRegistry [SomeTool flakyTool]) (tc "flaky" weatherArgs))         case res of           Right (Left (ToolRunFailed nm msg)) -> do             nm @?= "flaky"
test/WebSpec.hs view
@@ -11,8 +11,8 @@ import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef) import Data.Text (Text) import Data.Text qualified as T-import MockLLM (runEffMock) import Shikumi.Error (ShikumiError (..))+import Shikumi.Testing (runEffScript) import Shikumi.Tool (SomeTool (..), ToolRegistry, mkRegistry, runToolCall) import Shikumi.Tool.Builtin.Web (webFetchTool, webSearchTool) import Shikumi.Tool.Web@@ -39,7 +39,7 @@     "Tool.Web"     [ testCase "web_fetch returns a stubbed 200 observation" $ do         result <--          runEffMock [] $+          runEffScript [] $             runToolCall               stubRegistry               (tc "web_fetch" (object ["url" .= ("https://example.test" :: Text)]))@@ -50,7 +50,7 @@           other -> assertFailure ("expected web_fetch observation, got " <> show other),       testCase "web_fetch surfaces a 404 status as a value" $ do         result <--          runEffMock [] $+          runEffScript [] $             runToolCall               notFoundRegistry               (tc "web_fetch" (object ["url" .= ("https://example.test/missing" :: Text)]))@@ -61,7 +61,7 @@           other -> assertFailure ("expected 404 as a tool value, got " <> show other),       testCase "web_search returns stubbed hits" $ do         result <--          runEffMock [] $+          runEffScript [] $             runToolCall               stubRegistry               (tc "web_search" (object ["query" .= ("shikumi" :: Text)]))@@ -93,7 +93,7 @@       testCase "fetch of a denied URL fails fast with ValidationFailure" $ do         manager <- newTlsManager         result <--          runEffMock [] $+          runEffScript [] $             webFetch               (localWebClient manager Nothing)               "http://169.254.169.254/latest/meta-data/"@@ -109,7 +109,7 @@           Just _ -> do             manager <- newTlsManager             result <--              runEffMock [] $+              runEffScript [] $                 runToolCall                   (mkRegistry [SomeTool (webFetchTool (localWebClient manager Nothing))])                   (tc "web_fetch" (object ["url" .= ("https://example.com" :: Text)]))