packages feed

shikumi 0.5.0.0 → 0.5.1.0

raw patch · 12 files changed

+574/−57 lines, 12 filesdep ~shikumidep ~timePVP ok

version bump matches the API change (PVP)

Dependency ranges changed: shikumi, time

API changes (from Hackage documentation)

+ Shikumi.LLM: runLLMResilientWithEvidence :: forall (es :: [Effect]) a. (IOE :> es, Concurrent :> es, Error ShikumiError :> es) => EvidenceObserver es -> LLMConfig -> Eff (LLM ': es) a -> Eff es a
+ Shikumi.LLM: runLLMWithEvidence :: forall (es :: [Effect]) a. (IOE :> es, Error ShikumiError :> es) => ProviderRegistry -> LLMObserver -> EvidenceObserver es -> Eff (LLM ': es) a -> Eff es a
+ Shikumi.LLM: streamTerminalError :: TerminalPayload -> ShikumiError
+ Shikumi.LLM.Defaults: withEvidenceRuns :: forall (es :: [Effect]) a. LLM :> es => EvidenceStrictness -> Eff es Text -> Eff es a -> Eff es a
+ Shikumi.LLM.Evidence: AttemptReceipt :: !Text -> !Int -> !CallKind -> !ModelCallEvidence -> AttemptReceipt
+ Shikumi.LLM.Evidence: EvidenceObserverFailed :: ShikumiError -> EvidenceObserverFailed
+ Shikumi.LLM.Evidence: [attemptOrdinal] :: AttemptReceipt -> !Int
+ Shikumi.LLM.Evidence: [callKind] :: AttemptReceipt -> !CallKind
+ Shikumi.LLM.Evidence: [evidence] :: AttemptReceipt -> !ModelCallEvidence
+ Shikumi.LLM.Evidence: [logicalCallId] :: AttemptReceipt -> !Text
+ Shikumi.LLM.Evidence: data AttemptReceipt
+ Shikumi.LLM.Evidence: instance GHC.Classes.Eq Shikumi.LLM.Evidence.AttemptReceipt
+ Shikumi.LLM.Evidence: instance GHC.Internal.Exception.Type.Exception Shikumi.LLM.Evidence.EvidenceObserverFailed
+ Shikumi.LLM.Evidence: instance GHC.Internal.Generics.Generic Shikumi.LLM.Evidence.AttemptReceipt
+ Shikumi.LLM.Evidence: instance GHC.Internal.Show.Show Shikumi.LLM.Evidence.AttemptReceipt
+ Shikumi.LLM.Evidence: instance GHC.Internal.Show.Show Shikumi.LLM.Evidence.EvidenceObserverFailed
+ Shikumi.LLM.Evidence: newtype EvidenceObserverFailed
+ Shikumi.LLM.Evidence: type EvidenceObserver (es :: [Effect]) = AttemptReceipt -> Eff es ()
+ Shikumi.Program: preparePredict :: forall i o (es :: [Effect]). (FromModel i, FromModel o, ToSchema o, Validatable o, ToPrompt i, ToPrompt o, Error ShikumiError :> es) => Signature i o -> Params -> i -> Eff es (Signature i o, Context, Options)
+ Shikumi.Stream: atomicEventsFromResponse :: Response -> Maybe [AssistantMessageEvent]
+ Shikumi.Stream: responseFromEvents :: Model -> [AssistantMessageEvent] -> Either ShikumiError Response

Files

CHANGELOG.md view
@@ -2,6 +2,32 @@  ## Unreleased +## 0.5.1.0 — 2026-11-03++### Added++- Support typed decision outputs through `streamProgram` and `routeLLM`, with+  shared request preparation, immutable calibration, and checked atomic terminals.+- Add `preparePredict`, `responseFromEvents`, and `atomicEventsFromResponse`.+- Add `Shikumi.LLM.Evidence`, effectful evidence-aware bare and resilient runners,+  and `withEvidenceRuns` for per-operation run IDs. Acknowledgement occurs after+  budget and billing settlement and before retry, failure or successful return.++### Fixed++- Increment Baikai attempt provenance across retries and link superseded records.+- Make typed evidence-observer failures terminal across both retry layers.+- Fail closed when required evidence cannot accompany a thrown transport error.++### Limits++- Decision streams deliver status only; TypeSafe answers atomically. The LLM+  effect collects events before invoking program callbacks.+- Cancelled attempts have no receipt until upstream exposure is released at+  `mori://shinzui/baikai/okf/improvement-requests/concepts/IR-13`.+- Full evidence can contain provider response text; AttemptReceipt deliberately+  has no JSON instances. Existing LLMConfig and runner signatures are unchanged.+ ## 0.5.0.0 — 2026-11-02  ### Added
shikumi.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.4 name: shikumi-version: 0.5.0.0+version: 0.5.1.0 synopsis: Typed, structured, evaluable LM programs over baikai category: AI description:@@ -51,6 +51,7 @@     Shikumi.LLM.Budget     Shikumi.LLM.Continuation     Shikumi.LLM.Defaults+    Shikumi.LLM.Evidence     Shikumi.LLM.Observation     Shikumi.Module     Shikumi.Multimodal@@ -99,6 +100,7 @@    other-modules:     AdapterSpec+    AttemptEvidenceSpec     CliSchemaSpec     CombinatorSpec     ConstraintSpec@@ -148,11 +150,12 @@     effectful,     generic-lens,     lens,-    shikumi ^>=0.5.0.0,+    shikumi ^>=0.5.1.0,     stm,     streamly-core,     tasty,     tasty-hunit,     tasty-quickcheck,     text,+    time,     vector,
src/Shikumi/LLM.hs view
@@ -22,11 +22,13 @@     LLM (..),     complete,     stream,+    streamTerminalError,      -- * Bare interpreters     runLLM,     runLLMWith,     runLLMWithObserver,+    runLLMWithEvidence,      -- * Resilience     RetryPolicy (..),@@ -36,6 +38,7 @@     LLMConfig (..),     defaultLLMConfig,     runLLMResilient,+    runLLMResilientWithEvidence,      -- * Re-exports of the baikai request/response vocabulary used at call sites     Model,@@ -59,8 +62,10 @@ import Baikai.Effectful (Baikai, runBaikai, runBaikaiWith) import Baikai.Effectful qualified as BE import Baikai.Error (BaikaiError)+import Baikai.Evidence qualified as E import Baikai.Provider.Registry (ProviderRegistry) import Baikai.Usage qualified as U+import Control.Applicative ((<|>)) import Control.Concurrent.STM   ( TVar,     modifyTVar',@@ -69,13 +74,15 @@     retry,     writeTVar,   )-import Control.Lens ((^.))+import Control.Exception (throwIO)+import Control.Lens ((&), (.~), (^.)) import Data.Generics.Labels ()+import Data.IORef (IORef, newIORef, readIORef, writeIORef) import Data.Maybe (fromMaybe, mapMaybe) import Data.Text qualified as T import Data.Time.Clock (getCurrentTime) import Data.Unique (hashUnique, newUnique)-import Effectful (Dispatch (Dynamic), DispatchOf, Eff, Effect, IOE, liftIO, (:>))+import Effectful (Dispatch (Dynamic), DispatchOf, Eff, Effect, IOE, liftIO, raise, (:>)) import Effectful.Concurrent (Concurrent, threadDelay) import Effectful.Concurrent.STM (atomically) import Effectful.Dispatch.Dynamic (reinterpret_, send)@@ -84,6 +91,7 @@ import Shikumi.Error (ShikumiError (..), fromBaikaiError, isTransient) import Shikumi.LLM.Budget (Budget, admitCall, recordCost) import Shikumi.LLM.Continuation+import Shikumi.LLM.Evidence import Shikumi.LLM.Observation qualified as O  -- | The provider-neutral LM effect. 'Complete' is a blocking completion;@@ -122,7 +130,7 @@   (IOE :> es, Error ShikumiError :> es) =>   Eff (LLM : es) a ->   Eff es a-runLLM = reinterpret_ runBaikai (bareHandler O.noObservation)+runLLM = reinterpret_ runBaikai (bareHandler O.noObservation (const (pure ())))  -- | Bare interpreter over an explicit registry. runLLMWith ::@@ -134,8 +142,21 @@  -- | Observe bare transport calls. Callback exceptions propagate without retries. runLLMWithObserver :: (IOE :> es, Error ShikumiError :> es) => ProviderRegistry -> O.LLMObserver -> Eff (LLM : es) a -> Eff es a-runLLMWithObserver reg observer = reinterpret_ (runBaikaiWith reg) (bareHandler observer)+runLLMWithObserver reg observer = runLLMWithEvidence reg observer (const (pure ())) +-- | Bare interpreter with an effectful evidence acknowledgement boundary.+-- The observer does not enable evidence; request it in options or defaults.+-- Callbacks run outside the transport exception handler and cannot cause retries.+runLLMWithEvidence ::+  (IOE :> es, Error ShikumiError :> es) =>+  ProviderRegistry -> O.LLMObserver -> EvidenceObserver es -> Eff (LLM : es) a -> Eff es a+runLLMWithEvidence reg observer observe =+  reinterpret_ (runBaikaiWith reg) (bareHandler observer (raise . acknowledge observe))++acknowledge :: (IOE :> es, Error ShikumiError :> es) => EvidenceObserver es -> AttemptReceipt -> Eff es ()+acknowledge observe receipt =+  observe receipt `catchError` \_ e -> liftIO (throwIO (EvidenceObserverFailed e))+ -- | The bare handler, shared by both interpreters. It runs in the handler stack -- (@Baikai : es@), so it can call the @Baikai@ transport effect and throw -- through the @Error ShikumiError@ effect. The blocking path routes baikai's@@ -147,10 +168,10 @@ -- both operations identically. bareHandler ::   (IOE :> es, Baikai :> es, Error ShikumiError :> es) =>-  O.LLMObserver -> LLM (Eff localEs) a -> Eff es a-bareHandler observer op = do+  O.LLMObserver -> EvidenceObserver es -> LLM (Eff localEs) a -> Eff es a+bareHandler observer observe op = do   cid <- liftIO freshCallId-  transportAttempt observer Nothing cid 1 op+  transportAttempt observer observe Nothing Nothing cid 1 op  freshCallId :: IO T.Text freshCallId = (T.pack . ("call-" <>) . show . hashUnique) <$> newUnique@@ -159,16 +180,17 @@ -- become retryable provider errors, even if the callback throws BaikaiError. transportAttempt ::   (IOE :> es, Baikai :> es, Error ShikumiError :> es) =>-  O.LLMObserver -> Maybe Budget -> T.Text -> Int -> LLM (Eff localEs) a -> Eff es a-transportAttempt observer mb cid ordinal op = case op of-  Complete m c o -> do+  O.LLMObserver -> EvidenceObserver es -> Maybe Budget -> Maybe (IORef (Maybe T.Text)) -> T.Text -> Int -> LLM (Eff localEs) a -> Eff es a+transportAttempt observer observe mb previous cid ordinal op = case op of+  Complete m c original -> do+    o <- liftIO (attemptOptions previous ordinal original)     either throwError pure (validateRequestContinuation m c o)     start <- liftIO getCurrentTime     res <- try @BaikaiError (BE.complete m c (stripContinuationMetadata o))     case res of       Left be -> do         emit m O.CompletionCall start (Just (fromBaikaiError be)) Nothing Nothing-        throwError (fromBaikaiError be)+        failThrown o be       Right resp -> do         liftIO (chargeBudget mb resp)         emit@@ -178,15 +200,17 @@           (fromBaikaiError <$> responseError resp)           (availableUsage (resp ^. #message . #usage))           (O.observedModelOf (resp ^. #evidence))+        deliver o O.CompletionCall (resp ^. #evidence)         raiseResponseError resp-  Stream m c o -> do+  Stream m c original -> do+    o <- liftIO (attemptOptions previous ordinal original)     either throwError pure (validateRequestContinuation m c o)     start <- liftIO getCurrentTime     res <- try @BaikaiError (BE.streamCollect m c (stripContinuationMetadata o))     case res of       Left be -> do         emit m O.StreamCall start (Just (fromBaikaiError be)) Nothing Nothing-        throwError (fromBaikaiError be)+        failThrown o be       Right evs -> do         liftIO (chargeBudgetFromEvents mb evs)         let terminals = [tp | ev <- evs, tp <- case ev of EventDone t -> [t]; EventError t -> [t]; _ -> []]@@ -194,8 +218,19 @@             usage = terminal >>= \t -> case t ^. #message of AssistantMessage p -> availableUsage (p ^. #usage); _ -> Nothing             err = case [streamTerminalError t | EventError t <- evs] of e : _ -> Just e; [] -> Nothing         emit m O.StreamCall start err usage (terminal >>= O.observedModelOf . (^. #evidence))+        deliver o O.StreamCall (terminal >>= (^. #evidence))         raiseStreamError evs   where+    failThrown o be = case o ^. #evidence of+      Just req+        | E.EvidenceRequired _ <- req ^. #strictness ->+            throwError (ValidationFailure ("required attempt evidence unavailable: " <> O.errorClass (fromBaikaiError be)))+      _ -> throwError (fromBaikaiError be)+    deliver o kind record = case (o ^. #evidence, record) of+      (Just _, Just ev) -> do+        liftIO (mapM_ (\ref -> writeIORef ref (Just (ev ^. #callId))) previous)+        observe (AttemptReceipt cid ordinal kind ev)+      _ -> pure ()     emit m kind start err usage observed = liftIO $ do       end <- getCurrentTime       observer@@ -280,10 +315,43 @@   LLMConfig ->   Eff (LLM : es) a ->   Eff es a-runLLMResilient cfg = reinterpret_ (runBaikaiWith (registry cfg)) $ \op -> do+runLLMResilient = runLLMResilientWithEvidence (const (pure ()))++-- | Resilient calls with acknowledgement of each provider-built attempt record.+-- Budget charging precedes billing observation, which precedes evidence+-- acknowledgement. A failed acknowledgement never redispatches. Cancelled calls+-- have no receipt on the current Baikai transport path.+runLLMResilientWithEvidence ::+  (IOE :> es, Concurrent :> es, Error ShikumiError :> es) =>+  EvidenceObserver es -> LLMConfig -> Eff (LLM : es) a -> Eff es a+runLLMResilientWithEvidence observe cfg = reinterpret_ (runBaikaiWith (registry cfg)) $ \op -> do   cid <- liftIO freshCallId+  previous <- liftIO $ case operationOptions op ^. #evidence of+    Nothing -> pure Nothing+    Just _ -> Just <$> newIORef Nothing   withBudget (budget cfg) . withRateLimit (rateLimit cfg) $-    retrying (retryPolicy cfg) (\ordinal -> transportAttempt (fromMaybe O.noObservation (observer cfg)) (budget cfg) cid ordinal op)+    retrying (retryPolicy cfg) (\ordinal -> transportAttempt (fromMaybe O.noObservation (observer cfg)) (raise . acknowledge observe) (budget cfg) previous cid ordinal op)++operationOptions :: LLM m a -> Options+operationOptions (Complete _ _ o) = o+operationOptions (Stream _ _ o) = o++-- The first request is unchanged. Later attempts retain the caller's base and+-- link to the last actual record, never to the process-local billing identity.+attemptOptions :: Maybe (IORef (Maybe T.Text)) -> Int -> Options -> IO Options+attemptOptions previous ordinal opts = case (previous, opts ^. #evidence) of+  (Just ref, Just req) | ordinal > 1 -> do+    latest <- readIORef ref+    pure+      ( opts+          & #evidence+            .~ Just+              ( req+                  & #attempt .~ (req ^. #attempt + fromIntegral (ordinal - 1))+                  & #supersedes .~ (latest <|> req ^. #supersedes)+              )+      )+  _ -> pure opts  -- | Optimistic pre-call budget gate (admission, not reservation). Refuses the -- call with 'BudgetExceeded' when the recorded running total has already reached
src/Shikumi/LLM/Defaults.hs view
@@ -6,11 +6,12 @@     emptyRequestDefaults,     applyRequestDefaults,     withRequestDefaults,+    withEvidenceRuns,   ) where  import Baikai (Options)-import Baikai.Evidence (EvidenceRequest)+import Baikai.Evidence (EvidenceRequest, EvidenceStrictness, evidenceRequest) import Baikai.Model (isJudgmentOnly) import Baikai.Speed (Speed) import Baikai.ThinkingLevel (ThinkingLevel)@@ -18,6 +19,7 @@ import Control.Lens ((&), (.~), (^.)) import Control.Monad (when) import Data.Generics.Labels ()+import Data.Text (Text) import Effectful (Eff, (:>)) import Effectful.Dispatch.Dynamic (interpose) import Effectful.Error.Static (Error, throwError)@@ -67,3 +69,17 @@         Stream model ctx opts -> stream model ctx (applyRequestDefaults (forModel model) opts)     )     action++-- | Mint one evidence run ID per logical LLM operation, above transport retries.+-- Explicit per-call requests win. Unlike a fixed defaultEvidence, a multi-Predict+-- program gets distinct run IDs. Compose below routing and above cache/transport.+withEvidenceRuns :: (LLM :> es) => EvidenceStrictness -> Eff es Text -> Eff es a -> Eff es a+withEvidenceRuns strictness mint = interpose $ \_ -> \case+  Complete m c o -> fill o >>= complete m c+  Stream m c o -> fill o >>= stream m c+  where+    fill o = case o ^. #evidence of+      Just _ -> pure o+      Nothing -> do+        rid <- mint+        pure (o & #evidence .~ Just (evidenceRequest rid & #strictness .~ strictness))
+ src/Shikumi/LLM/Evidence.hs view
@@ -0,0 +1,48 @@+-- | Acknowledged, provider-built evidence for actual transport attempts.+module Shikumi.LLM.Evidence+  ( AttemptReceipt (..),+    EvidenceObserver,+    EvidenceObserverFailed (..),+  )+where++import Baikai.Evidence (ModelCallEvidence)+import Control.Exception (Exception)+import Data.Text (Text)+import Effectful (Eff)+import GHC.Generics (Generic)+import Shikumi.Error (ShikumiError)+import Shikumi.LLM.Observation (CallKind)++-- | The original provider record, delivered before retry, error propagation or+-- successful return. No record is fabricated for cancellation. Installing an+-- observer does not request evidence: use request options or scoped defaults.+-- Best-effort requests may produce no record; Baikai enforces required evidence.+--+-- There are deliberately no JSON instances. Provider error messages inside+-- 'ModelCallEvidence' can include provider response text; applications must choose+-- what is appropriate to persist rather than serializing this envelope blindly.+data AttemptReceipt = AttemptReceipt+  { -- | Process-local identity, shared with billing observations across retries.+    -- Persist the enclosed evidence's callId as the durable attempt identity.+    logicalCallId :: !Text,+    -- | One-based attempt ordinal, shared with billing observations.+    attemptOrdinal :: !Int,+    callKind :: !CallKind,+    -- | The exact record built by Baikai, including its own per-attempt callId.+    evidence :: !ModelCallEvidence+  }+  deriving stock (Eq, Show, Generic)++-- | Normal return acknowledges the record. Runs after budget charging and billing+-- observation. Must be safe for concurrent use: concurrent programs invoke it on+-- worker threads, and worker-local State changes do not reach the parent.+-- Install the observer's effects outside the LLM interpreter.+type EvidenceObserver es = AttemptReceipt -> Eff es ()++-- | Typed observer failures become exceptions so transport retries and Program+-- Retry nodes cannot turn a persistence failure into another provider invocation.+-- Other exceptions, including cancellation, propagate unchanged.+newtype EvidenceObserverFailed = EvidenceObserverFailed ShikumiError+  deriving stock (Show)+  deriving anyclass (Exception)
src/Shikumi/Program.hs view
@@ -72,6 +72,7 @@     sampleTemps,     withSampleTemp,     effectiveSignature,+    preparePredict,     parseResponse,      -- * Parameter interface (the optimizer/compiler contract)@@ -89,7 +90,7 @@   ) where -import Baikai (Model, emptyModel)+import Baikai (Context, Model, Options, emptyModel) import Data.Aeson (FromJSON (..), ToJSON (..), Value (..), object, withObject, (.!=), (.:), (.:?), (.=)) import Data.Aeson.Key qualified as Key import Data.Aeson.KeyMap qualified as KM@@ -370,6 +371,17 @@   i ->   Eff es o runPredict sig ps i = do+  (sig', ctx, opts) <- preparePredict sig ps i+  resp <- complete placeholderModel ctx opts+  either throwError pure (parseResponseWithSettings (decisionSettings ps) sig' resp)++-- | Validate local settings and prepare the same request for both execution modes.+-- Calibration values stay local; the returned options carry only rendering metadata.+preparePredict ::+  forall i o es.+  (FromModel i, FromModel o, ToSchema o, Validatable o, ToPrompt i, ToPrompt o, Error ShikumiError :> es) =>+  Signature i o -> Params -> i -> Eff es (Signature i o, Context, Options)+preparePredict sig ps i = do   let fields = fst (judgmentsInSchema (deriveSchema @o))   either throwError pure $     mapM_@@ -398,8 +410,7 @@       opts         | null (fst (judgmentsInSchema schema)) = renderedOpts         | otherwise = attachDecisionState (renderDecisionState sig' i) renderedOpts-  resp <- complete placeholderModel ctx opts-  either throwError pure (parseResponseWithSettings (decisionSettings ps) sig' resp)+  pure (sig', ctx, opts)  -- | Decode a response back into the typed output, tolerant of /either/ wire shape: -- a native model (whose @responseFormat@ the router enforced) replies with a JSON
src/Shikumi/Routing.hs view
@@ -111,7 +111,6 @@     complete m ctx' opts'   Stream _placeholder ctx opts -> do     m <- currentModel-    if capabilityFor m == DecisionOnly || Map.member metaDecisionEvidenceKey (opts ^. #metadata) then throwError (ValidationFailure "decision outputs do not support streaming") else pure ()     (ctx', opts') <- either throwError pure (translateForWire m ctx opts)     either throwError pure (validateRequestContinuation m ctx' opts')     stream m ctx' opts'
src/Shikumi/Stream.hs view
@@ -36,6 +36,8 @@     -- * Entry points     streamComplete,     streamProgram,+    responseFromEvents,+    atomicEventsFromResponse,   ) where @@ -44,38 +46,44 @@     AssistantMessageEvent (..),     BlockEndPayload (..),     Context,+    DataEndPayload (..),     DeltaPayload (..),+    IndexPayload (..),     Message (..),     Model,     Options,     Response,+    StartPayload (..),+    StopReason (ErrorReason),     TerminalPayload (..),+    doneTerminal,     emptyModel,     emptyResponse,     emptyTextContent,+    responseError,   )-import Control.Lens ((&), (.~))-import Control.Monad (when)+import Control.Lens ((&), (.~), (^.)) import Data.Generics.Labels () import Data.Maybe (fromMaybe, listToMaybe) import Data.Text (Text)+import Data.Time.Clock (diffUTCTime) import Data.Vector qualified as V import Effectful (Eff, (:>)) import Effectful.Error.Static (Error, throwError) import GHC.Generics (Generic)-import Shikumi.Adapter (Adapter (..), ToPrompt, adapterFor, attachNativeRender, attachSchema, evidenceFields, nativeRenderPieces)+import Shikumi.Adapter (ToPrompt, evidenceFields) import Shikumi.Error (ShikumiError (..))-import Shikumi.LLM (LLM, stream)+import Shikumi.LLM (LLM, stream, streamTerminalError) import Shikumi.Program   ( Params (..),     Program (Compose, Embed, FMap, MajorityVote, Map, Parallel, Predict, PredictCaptured, Retry, RetryWhen, Validate),     acceptOrReject,-    effectiveSignature,-    parseResponse,+    parseResponseWithSettings,+    preparePredict,     retryWith,     runProgram,   )-import Shikumi.Schema (FromModel, ToSchema, Validatable, deriveSchema)+import Shikumi.Schema (FromModel, ToSchema, Validatable) import Shikumi.Schema.Types qualified as ST import Shikumi.Signature (Signature, outputFields) @@ -130,7 +138,7 @@  -- | Drive one streaming LM call, folding baikai events into 'StreamEvent's -- delivered to the callback in order, and return the fully assembled 'Response'--- (reassembled from the terminal event) so the caller can 'parse' it into a typed+-- (reassembled from the terminal event) so the caller can decode it into a typed -- value exactly as the blocking path would. -- -- Each @TextDelta@ becomes a 'StreamFieldChunk' attributed to @fieldNm@; the final@@ -237,20 +245,12 @@   Embed _ -> blockingNode prog i cb   _ -> blockingNode prog i cb --- | Stream a single 'Predict', mirroring 'Shikumi.Program.runPredict' exactly so--- the streamed and blocking paths share one wire + decode definition. It overlays--- t'Params' via the exported 'effectiveSignature', renders model-agnostically, stamps--- the derived schema and the native render alternative onto the metadata channel--- (so the router can attach a native @responseFormat@ and swap the native prompt for--- native-capable models), streams the first output field's chunks bracketed by--- @LmStart@/@LmEnd@, and decodes the reassembled response through the shared--- 'Shikumi.Program.parseResponse' (dual-format: JSON body → native parser keeping--- its located error, marker body → fallback parser).------ Chunk-attribution caveat (unchanged): field chunks are honest only for the--- marker/raw-text wire shape; a routed native (JSON) stream still delivers chunks of--- the raw JSON text, though the /final typed value/ now decodes correctly via--- 'parseResponse'.+-- | Prepare exactly the same request and apply the same local settings as the+-- blocking runner. Decision outputs validate the complete terminal before decode+-- and emit only LmStart and LmEnd, including mixed generative decision and text outputs.+-- TypeSafe delivers one atomic structured response. The LLM operation collects+-- events before invoking the program callback; it is not a live transport iterator.+-- Ordinary text outputs retain their field chunks and status timing. streamPredict ::   forall i o es.   (FromModel i, FromModel o, ToSchema o, Validatable o, ToPrompt i, ToPrompt o) =>@@ -261,19 +261,78 @@   (StreamEvent -> Eff es ()) ->   Eff es o streamPredict sig ps i cb = do-  when (not (null (evidenceFields @o (decisionSettings ps)))) (throwError (ValidationFailure "decision outputs do not support streaming"))-  sig' <- effectiveSignature sig ps-  let adapter = adapterFor emptyModel-      (ctx, opts0) = render adapter sig' i-      (nativeSys, nativeDemos) = nativeRenderPieces @i @o sig'-      opts = attachNativeRender nativeSys nativeDemos (attachSchema (deriveSchema @o) opts0)-      fieldNm = case map ST.fieldName (outputFields sig') of+  (sig', ctx, opts) <- preparePredict sig ps i+  let fieldNm = case map ST.fieldName (outputFields sig') of         (x : _) -> x         [] -> ""+      checked = not (null (evidenceFields @o (decisionSettings ps)))+      decode = either throwError pure . parseResponseWithSettings (decisionSettings ps) sig'   cb (StreamStatus (Status LmStart "LM call started"))-  resp <- streamComplete fieldNm emptyModel ctx opts cb-  cb (StreamStatus (Status LmEnd "LM call finished"))-  either throwError pure (parseResponse sig' resp)+  if checked+    then do+      evs <- stream emptyModel ctx opts+      resp <- either throwError pure (responseFromEvents emptyModel evs)+      result <- decode resp+      cb (StreamStatus (Status LmEnd "LM call finished"))+      pure result+    else do+      resp <- streamComplete fieldNm emptyModel ctx opts cb+      cb (StreamStatus (Status LmEnd "LM call finished"))+      decode resp++-- | Validate terminal integrity and preserve the complete response envelope.+-- Answer validity is checked separately by the settings-aware program decoder.+responseFromEvents :: Model -> [AssistantMessageEvent] -> Either ShikumiError Response+responseFromEvents model evs = case reverse evs of+  EventDone tp : rest | not (any terminal rest) -> case tp ^. #message of+    AssistantMessage payload+      | tp ^. #errorInfo /= Nothing || payload ^. #stopReason == ErrorReason -> Left (streamTerminalError tp)+      | otherwise -> do+          let expected = [(i, d) | (i, AssistantData d) <- zip [0 ..] (V.toList (payload ^. #content))]+              actual = [(i, d) | DataEnd (DataEndPayload i d) <- evs]+              starts = [t | EventStart (StartPayload (AssistantMessage p) _) <- evs, Just t <- [p ^. #timestamp]]+              latency = case (starts, payload ^. #timestamp) of+                (t : _, Just end) -> max 0 (floor (1000 * diffUTCTime end t))+                _ -> 0+          if actual /= expected+            then malformed "structured blocks disagree with terminal"+            else+              Right+                ( emptyResponse+                    & #model .~ model+                    & #api .~ (model ^. #api)+                    & #provider .~ (model ^. #provider)+                    & #message .~ payload+                    & #responseId .~ (tp ^. #responseId)+                    & #evidence .~ (tp ^. #evidence)+                    & #latencyMs .~ latency+                )+    _ -> malformed "terminal is not an assistant message"+  EventError tp : rest | not (any terminal rest) -> Left (streamTerminalError tp)+  _ -> malformed "expected one final success terminal"+  where+    terminal EventDone {} = True+    terminal EventError {} = True+    terminal _ = False+    malformed = Left . SchemaMismatch++-- | Reconstruct an atomic structured stream, without IO or new provider evidence.+-- Text-only and failed recordings cannot supply structured stream replay.+atomicEventsFromResponse :: Response -> Maybe [AssistantMessageEvent]+atomicEventsFromResponse resp+  | responseError resp /= Nothing || null structured = Nothing+  | otherwise =+      Just $+        [EventStart (StartPayload (AssistantMessage skeleton) (resp ^. #responseId))]+          ++ concat [block i b | (i, b) <- zip [0 ..] (V.toList (payload ^. #content))]+          ++ [EventDone (doneTerminal (resp ^. #evidence) (resp ^. #responseId) (payload ^. #stopReason) (AssistantMessage payload))]+  where+    payload = resp ^. #message+    skeleton = payload & #content .~ V.empty+    structured = [d | AssistantData d <- V.toList (payload ^. #content)]+    block i (AssistantData d) = [DataStart (IndexPayload i), DataEnd (DataEndPayload i d)]+    block i (AssistantText t) = [TextStart (IndexPayload i), TextEnd (BlockEndPayload i (t ^. #text))]+    block _ _ = []  -- | Bracket an action with @NodeStart@/@NodeEnd@ status messages. bracketNode :: (StreamEvent -> Eff es ()) -> Eff es a -> Eff es a
+ test/AttemptEvidenceSpec.hs view
@@ -0,0 +1,234 @@+module AttemptEvidenceSpec (tests) where++import Baikai qualified as B+import Baikai.Error qualified as BE+import Baikai.Evidence qualified as E+import Control.Concurrent.MVar+import Control.Exception (throwIO, try)+import Control.Lens ((&), (.~), (^.))+import Control.Monad (void)+import Data.Generics.Labels ()+import Data.IORef+import Data.Map.Strict qualified as Map+import Effectful (Eff, IOE, liftIO, runEff, (:>))+import Effectful.Concurrent (Concurrent, runConcurrent)+import Effectful.Concurrent.Async (cancel, wait, waitCatch, withAsync)+import Effectful.Error.Static (Error, runErrorNoCallStack, throwError)+import Shikumi.Error+import Shikumi.LLM qualified as L+import Shikumi.LLM.Budget (newBudget)+import Shikumi.LLM.Defaults+import Shikumi.LLM.Evidence+import Shikumi.LLM.Observation qualified as O+import Shikumi.Program (Program (Compose, Embed, Parallel), retryWith, runProgram, runProgramConc)+import StubProvider+import System.Timeout (timeout)+import Test.Tasty+import Test.Tasty.HUnit++transient :: BE.BaikaiError+transient = (BE.providerError "fixture failure") {BE.category = BE.TransientError}++opts :: B.Options+opts = stubOptions & #evidence .~ Just (E.evidenceRequest "run" & #attempt .~ 4 & #supersedes .~ Just "before")++invoke :: Bool -> B.Options -> Eff '[L.LLM, Error ShikumiError, Concurrent, IOE] ()+invoke streaming o = if streaming then void (L.stream stubModel stubContext o) else void (L.complete stubModel stubContext o)++config :: B.ProviderRegistry -> L.LLMConfig+config reg = (L.defaultLLMConfig reg) {L.retryPolicy = L.RetryPolicy 3 0 0}++record :: (IOE :> es) => IORef [AttemptReceipt] -> EvidenceObserver es+record ref receipt = liftIO (atomicModifyIORef' ref (\xs -> (xs ++ [receipt], ())))++runCall :: EvidenceObserver '[Error ShikumiError, Concurrent, IOE] -> L.LLMConfig -> Eff '[L.LLM, Error ShikumiError, Concurrent, IOE] a -> IO (Either ShikumiError a)+runCall observe cfg action = runEff . runConcurrent . runErrorNoCallStack @ShikumiError . L.runLLMResilientWithEvidence observe cfg $ action++bounded :: IO a -> IO a+bounded action = timeout 3000000 action >>= maybe (assertFailure "test timeout") pure++tests :: TestTree+tests =+  testGroup "AttemptEvidence" $+    [ testGroup+        (if streaming then "Stream" else "Complete")+        [ testCase "retry acknowledges exact records and preserves caller provenance" $ do+            calls <- newIORef []+            receipts <- newIORef []+            reg <- evidenceStubRegistry calls 1 transient+            result <- runCall (record receipts) (config reg) (invoke streaming opts)+            result @?= Right ()+            sent <- readIORef calls+            rs <- readIORef receipts+            map attemptOrdinal rs @?= [1, 2]+            case rs of+              [a, b] -> do+                logicalCallId a @?= logicalCallId b+                evidence a ^. #attempt @?= 4+                evidence b ^. #attempt @?= 5+                evidence b ^. #supersedes @?= Just (evidence a ^. #callId)+                map ((^. #runId) . evidence) rs @?= ["run", "run"]+                evidence a ^. #supersedes @?= Just "before"+                (sent !! 1) ^. #evidence @?= Just (E.evidenceRequest "run" & #attempt .~ 5 & #supersedes .~ Just (evidence a ^. #callId))+                map callKind rs @?= replicate 2 (if streaming then O.StreamCall else O.CompletionCall)+              _ -> assertFailure (show rs),+          testCase "terminal failure is acknowledged before it is raised" $ do+            calls <- newIORef []+            receipts <- newIORef []+            let err = BE.invalidRequest "terminal"+            reg <- evidenceStubRegistry calls 10 err+            result <- runCall (record receipts) (config reg) (invoke streaming opts)+            result @?= Left (fromBaikaiError err)+            readIORef receipts >>= \rs -> map ((^. #status) . evidence) rs @?= [E.CallFailed],+          testCase "acknowledgement blocks return and the next dispatch" $ bounded $ do+            calls <- newIORef []+            reg <- evidenceStubRegistry calls 1 transient+            entered <- newEmptyMVar+            release <- newEmptyMVar+            result <- runEff+              . runConcurrent+              . runErrorNoCallStack @ShikumiError+              . L.runLLMResilientWithEvidence (\r -> if attemptOrdinal r == 1 then liftIO (putMVar entered () >> takeMVar release) else pure ()) (config reg)+              $ withAsync (invoke streaming opts)+              $ \worker -> do+                liftIO (takeMVar entered)+                liftIO (readIORef calls >>= \xs -> length xs @?= 1)+                liftIO (putMVar release ())+                wait worker+            result @?= Right (),+          testCase "successful acknowledgement blocks typed return" $ bounded $ do+            calls <- newIORef []+            reg <- evidenceStubRegistry calls 0 transient+            entered <- newEmptyMVar+            release <- newEmptyMVar+            returned <- newIORef False+            result <- runEff+              . runConcurrent+              . runErrorNoCallStack @ShikumiError+              . L.runLLMResilientWithEvidence (\_ -> liftIO (putMVar entered () >> takeMVar release)) (config reg)+              $ withAsync (invoke streaming opts >> liftIO (writeIORef returned True))+              $ \worker -> do+                liftIO (takeMVar entered >> readIORef returned >>= (@?= False))+                liftIO (putMVar release ())+                wait worker+            result @?= Right (),+          testCase "typed observer failure escapes both retry layers" $ do+            calls <- newIORef []+            reg <- evidenceStubRegistry calls 0 transient+            result <-+              try @EvidenceObserverFailed $+                runCall+                  (\_ -> throwError (ProviderFailure "persist failed"))+                  (config reg)+                  (retryWith (\_ () -> invoke streaming opts) (const True) 3 (Embed (\() -> pure ())) ())+            assertBool "observer exception" (case result of Left _ -> True; _ -> False)+            readIORef calls >>= \xs -> length xs @?= 1,+          testCase "observer exception after failed attempt never retries" $ do+            calls <- newIORef []+            reg <- evidenceStubRegistry calls 1 transient+            result <- try @BE.BaikaiError $ runCall (\_ -> liftIO (throwIO transient)) (config reg) (invoke streaming opts)+            result @?= Left transient+            readIORef calls >>= \xs -> length xs @?= 1,+          testCase "no evidence request means identical options and no receipts" $ do+            calls <- newIORef []+            receipts <- newIORef []+            reg <- evidenceStubRegistry calls 1 transient+            runCall (record receipts) (config reg) (invoke streaming stubOptions) >>= (@?= Right ())+            readIORef calls >>= (@?= [stubOptions, stubOptions])+            readIORef receipts >>= (@?= []),+          testCase "budget refusal creates neither dispatch nor receipt" $ do+            calls <- newIORef []+            receipts <- newIORef []+            reg <- evidenceStubRegistry calls 0 transient+            budget <- newBudget (Just 0)+            result <- runCall (record receipts) ((config reg) {L.budget = Just budget}) (invoke streaming opts)+            assertBool "refused" (case result of Left BudgetExceeded {} -> True; _ -> False)+            readIORef calls >>= (@?= [])+            readIORef receipts >>= (@?= []),+          testCase "bare runner acknowledges the same record" $ do+            calls <- newIORef []+            receipts <- newIORef []+            reg <- evidenceStubRegistry calls 0 transient+            result <- runEff . runConcurrent . runErrorNoCallStack @ShikumiError . L.runLLMWithEvidence reg O.noObservation (record receipts) $ invoke streaming opts+            result @?= Right ()+            readIORef receipts >>= \rs -> map attemptOrdinal rs @?= [1],+          testCase "strict refusal delivers a real record with zero provider invocations" $ do+            calls <- newIORef []+            receipts <- newIORef []+            reg <- evidenceStubRegistry calls 0 transient+            let strict = opts & #evidence .~ Just (E.evidenceRequest "strict" & #strictness .~ E.EvidenceRequired E.EvidenceModelObserved)+            result <- runCall (record receipts) (config reg) (invoke streaming strict)+            assertBool "refused" (case result of Left _ -> True; _ -> False)+            readIORef calls >>= (@?= [])+            readIORef receipts >>= \rs -> length rs @?= 1,+          testCase "billing settles before evidence acknowledgement" $ do+            calls <- newIORef []+            reg <- evidenceStubRegistry calls 0 transient+            (billing, snapshot) <- O.newBillingCollector+            result <- runCall (\_ -> liftIO (snapshot >>= \s -> O.completedAttempts s @?= 1)) ((config reg) {L.observer = Just billing}) (invoke streaming opts)+            result @?= Right (),+          testCase "cancelled rate-limit waiter never dispatches or emits a receipt" $ bounded $ do+            calls <- newIORef []+            receipts <- newIORef []+            reg <- evidenceStubRegistry calls 0 transient+            limiter <- L.newRateLimiter 0+            result <- runEff+              . runConcurrent+              . runErrorNoCallStack @ShikumiError+              . L.runLLMResilientWithEvidence (record receipts) ((config reg) {L.rateLimit = Just limiter})+              $ withAsync (invoke streaming opts)+              $ \worker -> cancel worker >> waitCatch worker+            assertBool "cancelled" (case result of Right (Left _) -> True; _ -> False)+            readIORef calls >>= (@?= [])+            readIORef receipts >>= (@?= []),+          testCase "concurrent programs keep retry provenance isolated" $ do+            calls <- newIORef []+            receipts <- newIORef []+            reg <- evidenceStubRegistry calls 1 transient+            let leaf = Embed (\() -> void (L.complete stubModel stubContext opts))+            result <- runCall (record receipts) (config reg) (runProgramConc (Parallel leaf leaf) ())+            result @?= Right ((), ())+            rs <- readIORef receipts+            let groups = foldr (\r -> Map.insertWith (++) (logicalCallId r) [r]) Map.empty rs+            Map.size groups @?= 2+            mapM_+              ( \group -> case group of+                  [a, b] -> evidence b ^. #supersedes @?= Just (evidence a ^. #callId)+                  [_] -> pure ()+                  _ -> assertFailure (show group)+              )+              (Map.elems groups),+          testCase "Compose mints a distinct run for each operation" $ do+            calls <- newIORef []+            reg <- evidenceStubRegistry calls 0 transient+            count <- newIORef (0 :: Int)+            let mint = liftIO (atomicModifyIORef' count (\n -> (n + 1, if n == 0 then "first" else "second")))+                leaf = Embed (\() -> void (L.complete stubModel stubContext stubOptions))+            result <-+              runCall (const (pure ())) (config reg) . withEvidenceRuns E.EvidenceBestEffort mint $+                runProgram (Compose leaf leaf) ()+            result @?= Right ()+            readIORef calls >>= \xs -> map (fmap (^. #runId) . (^. #evidence)) xs @?= [Just "first", Just "second"],+          testCase "per-operation run minting survives retries and honors explicit requests" $ do+            calls <- newIORef []+            reg <- evidenceStubRegistry calls 1 transient+            minted <- newIORef (0 :: Int)+            let mint = liftIO (atomicModifyIORef' minted (\n -> (n + 1, if n == 0 then "first" else "second")))+            result <- runCall (const (pure ())) (config reg) . withEvidenceRuns E.EvidenceBestEffort mint $ do+              invoke streaming stubOptions+              invoke streaming stubOptions+              invoke streaming opts+            result @?= Right ()+            readIORef calls >>= \xs -> map (fmap (^. #runId) . (^. #evidence)) xs @?= map Just ["first", "first", "second", "run"]+            readIORef minted >>= (@?= 2)+        ]+    | streaming <- [False, True]+    ]+      ++ [ testCase "strict thrown transport failure fails closed without retry" $ do+             calls <- newIORef 0+             reg <- failingStubRegistry calls 10 "ok"+             let strict = opts & #evidence .~ Just (E.evidenceRequest "strict" & #strictness .~ E.EvidenceRequired E.EvidenceRequestedOnly)+             result <- runCall (const (pure ())) (config reg) (invoke False strict)+             assertBool "terminal validation failure" (case result of Left ValidationFailure {} -> True; _ -> False)+             readIORef calls >>= (@?= 1)+         ]
test/Main.hs view
@@ -3,6 +3,7 @@ module Main (main) where  import AdapterSpec qualified+import AttemptEvidenceSpec qualified import CliSchemaSpec qualified import CombinatorSpec qualified import ConstraintSpec qualified@@ -38,7 +39,8 @@   defaultMain $     testGroup       "shikumi"-      [ ResponsesSpec.tests,+      [ AttemptEvidenceSpec.tests,+        ResponsesSpec.tests,         DecisionDeriveSpec.tests,         RequestDefaultsSpec.tests,         ContinuationSpec.tests,
test/StreamSpec.hs view
@@ -12,6 +12,8 @@   ( AssistantContent (..),     AssistantMessageEvent (..),     BlockEndPayload (..),+    DataContent (..),+    DataEndPayload (..),     DeltaPayload (..),     IndexPayload (..),     Message (..),@@ -27,6 +29,7 @@     emptyTextContent,   ) import Control.Lens ((&), (.~), (^.))+import Data.Aeson (Value (Null)) import Data.Generics.Labels () import Data.IORef (IORef, atomicModifyIORef', modifyIORef', newIORef, readIORef) import Data.Text (Text)@@ -50,11 +53,13 @@     Status (..),     StatusPhase (..),     StreamEvent (..),+    atomicEventsFromResponse,+    responseFromEvents,     streamComplete,     streamProgram,   ) import Test.Tasty (TestTree, testGroup)-import Test.Tasty.HUnit (testCase, (@?=))+import Test.Tasty.HUnit (assertBool, testCase, (@?=))  -- A single-text-field signature, the honest field-chunk case. newtype Question = Question {question :: Text}@@ -139,7 +144,18 @@ tests =   testGroup     "StreamSpec"-    [ testCase "M1: streamComplete folds deltas into field chunks and assembles the response" $ do+    [ testCase "checked response rejects missing, duplicate, misplaced or inconsistent terminals" $ do+        let d = DataContent Null Nothing Nothing Nothing+            r = emptyResponse & #message . #content .~ V.singleton (AssistantData d)+            terminal = EventDone (doneTerminal Nothing (Just "response-id") Stop (AssistantMessage (r ^. #message)))+            block = DataEnd (DataEndPayload 0 d)+            malformed = [[], [block], [block, terminal, terminal], [terminal, block], [terminal], [DataEnd (DataEndPayload 1 d), terminal]]+        mapM_ (\events -> assertBool "terminal integrity" (case responseFromEvents emptyModel events of Left (SchemaMismatch _) -> True; _ -> False)) malformed+        fmap (\r' -> r' ^. #responseId) (responseFromEvents emptyModel [block, terminal]) @?= Right (Just "response-id")+        case atomicEventsFromResponse r of+          Just events -> fmap (\r' -> r' ^. #message . #content) (responseFromEvents emptyModel events) @?= Right (r ^. #message . #content)+          Nothing -> error "structured replay fixture",+      testCase "M1: streamComplete folds deltas into field chunks and assembles the response" $ do         ref <- newIORef [streamEventsFor ["Hel", "lo"] "Hello"]         rec <- newIORef []         resp <-
test/StubProvider.hs view
@@ -22,10 +22,12 @@     concurrencyStubRegistry,     classifiedStubRegistry,     exceptionStubRegistry,+    evidenceStubRegistry,   ) where  import Baikai hiding (flattenAssistantText)+import Baikai.Evidence qualified as E import Baikai.Prelude import Control.Concurrent (threadDelay) import Control.Concurrent.STM@@ -39,6 +41,7 @@ import Data.Generics.Labels () import Data.IORef (IORef, atomicModifyIORef') import Data.Text qualified as T+import Data.Time.Clock (getCurrentTime) import Data.Vector qualified as V import Streamly.Data.Stream qualified as Stream @@ -302,3 +305,35 @@  exceptionStubRegistry :: IO Response -> IO ProviderRegistry exceptionStubRegistry action = mkRegistry "" (\_ _ _ -> action)++-- | One actual record per scripted attempt, built from the dispatched request.+-- Both operations share the same complete implementation and evidence provenance.+evidenceStubRegistry :: IORef [Options] -> Int -> BaikaiError -> IO ProviderRegistry+evidenceStubRegistry requests failures err = do+  let completeFn m _ o = do+        n <- atomicModifyIORef' requests (\xs -> (xs ++ [o], length xs))+        now <- getCurrentTime+        cid <- E.newCallId+        let failed = n < failures+            receipt req =+              E.baseEvidence+                req+                cid+                (E.EndpointIdentity "stub" "custom" E.TransportHttpApi Nothing "fixture" Nothing)+                (m ^. #modelId)+                E.noThinkingRequested+                now+                now+                (if failed then E.CallFailed else E.CallSucceeded)+                "digest"+                "configuration"+            response = if failed then errorResponse m now 0 err else stubResponse "ok"+        pure (response & #evidence .~ (receipt <$> o ^. #evidence))+  reg <- newProviderRegistry+  registerApiProviderWith+    reg+    ( (apiProviderWith stubApi (liftCompleteToStream completeFn) completeFn)+        { describeThinking = stubDescribeThinking+        }+    )+  pure reg