shikumi-0.5.1.0: src/Shikumi/Stream.hs
{-# LANGUAGE GADTs #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
-- | Program-level streaming (EP-25). A new, additive entry point 'streamProgram'
-- runs a t'Program' and delivers a sequence of 'StreamEvent's to a caller-supplied
-- callback as execution proceeds, while still returning the fully-decoded typed
-- output — identical to what 'Shikumi.Program.runProgram' would return for the same
-- input. 'runProgram'\'s blocking contract (MasterPlan integration point #4) is
-- untouched; this module adds surface, it changes nothing.
--
-- Two kinds of event reach the callback:
--
-- * a __field chunk__ — a piece of an output field's text as the model writes it.
-- Honestly scoped to a single 'Shikumi.Program.Predict' node (and chains of
-- them) on the prompt-fallback / raw-text path — the path exercised in the
-- hermetic stubs, since 'Shikumi.Program.runProgram'\'s placeholder model maps to
-- the fallback adapter. Native whole-JSON field chunking is /not/ promised (a
-- JSON blob is only parseable once whole).
-- * a __status message__ — a human-readable signal that a phase started or ended
-- ("LM call started\/finished", "tool started\/finished", "node started\/finished").
-- Status brackets every node, including composites; aggregating combinators
-- ('Shikumi.Program.Map', 'Shikumi.Program.Parallel',
-- 'Shikumi.Program.MajorityVote', 'Shikumi.Program.Ensemble') and opaque
-- 'Shikumi.Program.Embed' bodies stream status only, not field chunks.
--
-- The chunk text is the raw provider delta (marker boilerplate included on the
-- fallback path); trimming markers out of the stream is left to a future refinement.
module Shikumi.Stream
( -- * Event types
FieldChunk (..),
StatusPhase (..),
Status (..),
StreamEvent (..),
-- * Entry points
streamComplete,
streamProgram,
responseFromEvents,
atomicEventsFromResponse,
)
where
import Baikai
( AssistantContent (..),
AssistantMessageEvent (..),
BlockEndPayload (..),
Context,
DataEndPayload (..),
DeltaPayload (..),
IndexPayload (..),
Message (..),
Model,
Options,
Response,
StartPayload (..),
StopReason (ErrorReason),
TerminalPayload (..),
doneTerminal,
emptyModel,
emptyResponse,
emptyTextContent,
responseError,
)
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 (ToPrompt, evidenceFields)
import Shikumi.Error (ShikumiError (..))
import Shikumi.LLM (LLM, stream, streamTerminalError)
import Shikumi.Program
( Params (..),
Program (Compose, Embed, FMap, MajorityVote, Map, Parallel, Predict, PredictCaptured, Retry, RetryWhen, Validate),
acceptOrReject,
parseResponseWithSettings,
preparePredict,
retryWith,
runProgram,
)
import Shikumi.Schema (FromModel, ToSchema, Validatable)
import Shikumi.Schema.Types qualified as ST
import Shikumi.Signature (Signature, outputFields)
-- ---------------------------------------------------------------------------
-- Event types
-- ---------------------------------------------------------------------------
-- | A piece of one output field's text, as the model writes it. Mirrors DSPy's
-- @StreamResponse { predict_name, signature_field_name, chunk, is_last_chunk }@.
data FieldChunk = FieldChunk
{ -- | which output field this chunk belongs to (@""@ if not attributed)
fieldName :: !Text,
-- | the text delta
chunk :: !Text,
-- | 'True' on the final chunk of this field
isLast :: !Bool
}
deriving stock (Eq, Show, Generic)
-- | The phase a status message marks. Borrowed in spirit from the trace SpanKind.
data StatusPhase
= -- | an LM call is about to begin
LmStart
| -- | an LM call finished
LmEnd
| -- | a tool call is about to begin
ToolStart
| -- | a tool call finished
ToolEnd
| -- | a (sub-)program node started
NodeStart
| -- | a (sub-)program node finished
NodeEnd
deriving stock (Eq, Show, Generic)
-- | A human-readable status signal. Mirrors DSPy's @StatusMessage@, plus the phase.
data Status = Status
{ phase :: !StatusPhase,
message :: !Text
}
deriving stock (Eq, Show, Generic)
-- | One streaming event handed to the caller's callback, in order.
data StreamEvent
= StreamFieldChunk !FieldChunk
| StreamStatus !Status
deriving stock (Eq, Show, Generic)
-- ---------------------------------------------------------------------------
-- M1: driving one streaming LM call
-- ---------------------------------------------------------------------------
-- | 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 decode it into a typed
-- value exactly as the blocking path would.
--
-- Each @TextDelta@ becomes a 'StreamFieldChunk' attributed to @fieldNm@; the final
-- delta carries @isLast = True@. If the stream carried no deltas (e.g. a cached
-- whole response arrived as a single @TextEnd@), one synthetic chunk carrying the
-- @TextEnd@ content is emitted so the caller still sees the field.
streamComplete ::
(LLM :> es) =>
-- | the output field name to attribute chunks to
Text ->
Model ->
Context ->
Options ->
-- | per-event callback, runs in the caller's @Eff@
(StreamEvent -> Eff es ()) ->
Eff es Response
streamComplete fieldNm model ctx opts cb = do
evs <- stream model ctx opts
emitChunks fieldNm [d | TextDelta (DeltaPayload _ d) <- evs] (firstTextEnd evs) cb
pure (reassemble evs)
-- | Emit the field chunks for one block: every delta in order, the last marked
-- 'isLast'. With no deltas, synthesize a single final chunk from the @TextEnd@
-- content (if any), so the field is still observed.
emitChunks ::
Text ->
[Text] ->
Maybe Text ->
(StreamEvent -> Eff es ()) ->
Eff es ()
emitChunks fieldNm deltas mEnd cb = case deltas of
[] -> maybe (pure ()) (\c -> cb (mkChunk c True)) mEnd
_ ->
let n = length deltas
in mapM_ (\(k, d) -> cb (mkChunk d (k == n))) (zip [1 :: Int ..] deltas)
where
mkChunk t l = StreamFieldChunk (FieldChunk fieldNm t l)
-- | The content of the first @TextEnd@ block, if any.
firstTextEnd :: [AssistantMessageEvent] -> Maybe Text
firstTextEnd evs = listToMaybe [c | TextEnd (BlockEndPayload _ c) <- evs]
-- | Reassemble a 'Response' from the stream's terminal event (its payload's
-- fully-assembled assistant message). Falls back to a response synthesized from
-- the @TextEnd@ content if no terminal assistant message is present.
--
-- The 'EventError' branch is retained for robustness against third-party @LLM@
-- interpreters, but is /unreachable through shikumi's own interpreters/: they now
-- convert a terminal 'EventError' to an out-of-band t'ShikumiError' before the event
-- list ever reaches here (see "Shikumi.LLM" — @raiseStreamError@), so a streamed
-- program fails with the real transport error, not a decode of a partial body.
reassemble :: [AssistantMessageEvent] -> Response
reassemble evs = case terminalPayloads of
(p : _) -> emptyResponse & #message .~ p
[] -> synthResponse (fromMaybe "" (firstTextEnd evs))
where
terminalPayloads =
[p | EventDone TerminalPayload {message = AssistantMessage p} <- evs]
++ [p | EventError TerminalPayload {message = AssistantMessage p} <- evs]
-- | A response carrying @t@ as its single assistant text block.
synthResponse :: Text -> Response
synthResponse t =
emptyResponse & #message . #content .~ V.singleton (AssistantText (emptyTextContent & #text .~ t))
-- ---------------------------------------------------------------------------
-- M2/M3: streaming a whole program
-- ---------------------------------------------------------------------------
-- | Run a program, delivering 'StreamEvent's to the callback as it executes, and
-- return the typed output equal to 'Shikumi.Program.runProgram'\'s for the same
-- input. The effect row is exactly 'runProgram'\'s plus the callback, so a streamed
-- program runs under the same interpreters as a blocking one.
--
-- A leaf 'Predict' streams its first output field's chunks bracketed by
-- @LmStart@\/@LmEnd@. Composites are bracketed by @NodeStart@\/@NodeEnd@:
-- 'Compose'/'FMap' and the single-result control nodes
-- ('Retry'\/'RetryWhen'\/'Validate') recurse so their leaf predicts still stream;
-- the aggregating combinators ('Map'\/'Parallel'\/'MajorityVote'\/'Shikumi.Program.Ensemble') and the
-- opaque 'Embed' body run blocking and emit node-boundary status only (field chunks
-- come from leaf LM calls on the single-result path).
streamProgram ::
forall i o es.
(LLM :> es, Error ShikumiError :> es) =>
Program i o ->
i ->
(StreamEvent -> Eff es ()) ->
Eff es o
streamProgram prog i cb = case prog of
PredictCaptured _ sig ps -> streamPredict sig ps i cb
Predict sig ps -> streamPredict sig ps i cb
Compose f g -> bracketNode cb $ do
b <- streamProgram f i cb
streamProgram g b cb
FMap k p -> bracketNode cb (k <$> streamProgram p i cb)
Retry n p -> bracketNode cb (retryWith (\pp ii -> streamProgram pp ii cb) (const True) n p i)
RetryWhen ok n p -> bracketNode cb (retryWith (\pp ii -> streamProgram pp ii cb) ok n p i)
Validate v p -> bracketNode cb (streamProgram p i cb >>= acceptOrReject v)
-- Aggregating / opaque nodes: bracket with node status, run blocking (no field
-- chunks where per-field streaming is ambiguous or the body is opaque).
Map _ _ -> blockingNode prog i cb
Parallel _ _ -> blockingNode prog i cb
MajorityVote _ _ _ _ -> blockingNode prog i cb
Embed _ -> blockingNode prog i cb
_ -> blockingNode prog i cb
-- | 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) =>
(LLM :> es, Error ShikumiError :> es) =>
Signature i o ->
Params ->
i ->
(StreamEvent -> Eff es ()) ->
Eff es o
streamPredict sig ps i cb = do
(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"))
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
bracketNode cb act = do
cb (StreamStatus (Status NodeStart "node started"))
r <- act
cb (StreamStatus (Status NodeEnd "node finished"))
pure r
-- | Run a node blocking (no field chunks) bracketed by node-boundary status.
blockingNode ::
(LLM :> es, Error ShikumiError :> es) =>
Program i o ->
i ->
(StreamEvent -> Eff es ()) ->
Eff es o
blockingNode prog i cb = bracketNode cb (runProgram prog i)