packages feed

agentic-0.2.1.0: src/Agentic/Describe.hs

-- | Looking at a flow without running it.
module Agentic.Describe
  ( describe
  , Description (..)
  , StepInfo (..)
  , ToolInfo (..)
  , renderTree
  , mermaid
  , dot
  , flowGraph
  , FlowGraph (..)
  , Item (..)
  , NodeKind (..)
  , Edge (..)
  , EdgeStyle (..)
  , descriptionValue
  ) where

import Agentic.Contract (Outline, outline, outlineLabel)
import Agentic.Core
import Agentic.Questions (QuestionSpec (..), Questions (..))
import Agentic.Value (Value (..), renderJson)
import Data.List (mapAccumL)
import Data.Text (Text)
import qualified Data.Text as T

data Description
  = Leaf StepInfo
  | Sequence [Description]
    -- ^ @a >>> b >>> c@, flattened.
  | Together [Description]
    -- ^ @a &&& b &&& c@, flattened.
  | Halves Description Description
    -- ^ @a *** b@: one flow on each half of a pair.
  | Branch Description Description
  | ForEach Description
  | Repeated Description
    -- ^ @repeatUntil@: run again on its own output until a condition holds.
  | Annotated Note Description

data StepInfo
  = Identity
    -- ^ The input, unchanged ('returnA').
  | Glue
    -- ^ @arr@: a pure function.
  | FirstHalf
    -- ^ 'takeFirst'.
  | SecondHalf
    -- ^ 'takeSecond'.
  | Effect
    -- ^ @act@: plain code with an effect.
  | DraftInfo
      { instruction :: Instruction
      , input :: Outline
      , output :: Outline
      , tools :: [ToolInfo]
      }
  | JudgeInfo
      { input :: Outline
      , questions :: [QuestionSpec]
      }

data ToolInfo = ToolInfo
  { name :: Text
  , description :: Text
  , input :: Outline
  , output :: Outline
  , body :: Description
  }

-- | Describe a flow. This never runs anything.
describe :: Agentic m i o -> Description
describe = \case
  Step s -> Leaf (stepInfo s)
  Seq f g -> Sequence (sequenced (describe f) <> sequenced (describe g))
  -- Only the right is flattened: @(a &&& b) &&& c@ is a different shape of
  -- pair from @a &&& b &&& c@, and the graph routes by that shape.
  Fanout f g -> Together (describe f : together (describe g))
  Split f g -> Halves (describe f) (describe g)
  First f -> Halves (describe f) (Leaf Identity)
  Choose f g -> Branch (describe f) (describe g)
  Each f -> ForEach (describe f)
  Repeat _ f -> Repeated (describe f)
  Noted n f -> Annotated n (describe f)
  where
    sequenced = \case
      Sequence ds -> ds
      d -> [d]
    together = \case
      Together ds -> ds
      d -> [d]

stepInfo :: Step m i o -> StepInfo
stepInfo = \case
  Pass -> Identity
  Wrap _ -> Identity
  Arr _ -> Glue
  TakeFirst -> FirstHalf
  TakeSecond -> SecondHalf
  Act _ -> Effect
  Draft input out instruction tools ->
    DraftInfo instruction (outline input) (outline out) (map toolInfo tools)
  Judge input qs -> JudgeInfo (outline input) qs.specs

toolInfo :: Tool m -> ToolInfo
toolInfo (Tool name description input out body) =
  ToolInfo name description (outline input) (outline out) (describe body)

-- ---------------------------------------------------------------------------
-- The tree view

instance Show Description where
  show = T.unpack . renderTree

data Tree = Node Text [Tree]

-- | The tree view. Each tool's body is expanded the first time the tool
-- appears. Unnamed glue between steps is hidden, but a branch is never hidden:
-- inside @&&&@, @***@ and @|||@ it shows as @arr@ (or @pass@ for 'returnA'), and
-- a pass-through beside a step shows as "keeping its input".
renderTree :: Description -> Text
renderTree = T.intercalate "\n" . concatMap (draw "" "") . snd . trees []

-- | Convert to trees, threading the names of tools already expanded.
trees :: [Text] -> Description -> ([Text], [Tree])
trees seen = \case
  Leaf Identity -> (seen, [])
  Leaf Glue -> (seen, [])
  Leaf FirstHalf -> (seen, [])
  Leaf SecondHalf -> (seen, [])
  Leaf info -> leaf Nothing info
  Sequence ds -> onSnd concat (mapAccumL trees seen ds)
  Together ds ->
    let keeping = if any passes ds then "  (keeping its input)" else ""
     in case onSnd concat (mapAccumL parallel seen (filter (not . passes) ds)) of
          (seen', [Node t cs]) -> (seen', [Node (t <> keeping) cs])
          (seen', ts) -> (seen', [Node ("together" <> keeping) ts])
  Halves l r ->
    let (seen1, ls) = branch seen l
        (seen2, rs) = branch seen1 r
     in (seen2, [Node "both halves" [labelled "first" ls, labelled "second" rs]])
  Branch l r ->
    let (seen1, ls) = branch seen l
        (seen2, rs) = branch seen1 r
     in (seen2, [Node "branch" [labelled "left" ls, labelled "right" rs]])
  Repeated d -> case branch seen d of
    (seen', [Node "together" ts]) -> (seen', [Node "repeatUntil" ts])
    (seen', ts) -> (seen', [Node "repeatUntil" ts])
  ForEach d -> case branch seen d of
    (seen', [Node "together" ts]) -> (seen', [Node "each" ts])
    (seen', ts) -> (seen', [Node "each" ts])
  Annotated n (Leaf info) | not (passes (Leaf info)) -> leaf (Just n.name) info
  Annotated n d -> case trees seen d of
    (seen', [Node t cs]) -> (seen', [Node (n.name <> "  " <> t) cs])
    (seen', []) -> (seen', [Node n.name []])
    (seen', ts) -> (seen', [Node n.name ts])
  where
    -- A step: what kind it is, then its name, then the details.
    leaf name info =
      let (seen', toolTrees) = case info of
            DraftInfo _ _ _ tools -> mapAccumL toolTree seen tools
            _ -> (seen, [])
       in (seen', [Node (T.intercalate "  " (stepLines name info)) toolTrees])
    -- A branch of @&&&@ that's several steps in a row is grouped, so its steps
    -- don't read as more parallel branches.
    parallel s d = case branch s d of
      (s', ts@(_ : _ : _)) -> (s', [Node "in order" ts])
      r -> r
    -- A branch always shows, even when it's only glue.
    branch s d = case trees s d of
      (s', []) -> (s', [Node (plumbing d) []])
      r -> r
    toolTree s t
      | t.name `elem` s = (s, Node ("tool " <> t.name <> "  (see above)") [])
      | otherwise = case trees (t.name : s) t.body of
          (s', [Node body cs]) -> (s', Node ("tool " <> t.name <> "  " <> body) cs)
          (s', ts) -> (s', Node ("tool " <> t.name) ts)
    labelled l = \case
      [Node t cs] -> Node (l <> " → " <> t) cs
      [] -> Node (l <> " → pass") []
      ts -> Node l ts

-- | How a branch that's only plumbing shows in the tree view.
plumbing :: Description -> Text
plumbing = \case
  d | passes d -> "pass"
  Leaf FirstHalf -> "takeFirst"
  Leaf SecondHalf -> "takeSecond"
  Annotated _ d -> plumbing d
  _ -> "arr"

-- | Does this part of a flow only pass its input through?
passes :: Description -> Bool
passes = \case
  Leaf Identity -> True
  Sequence ds -> all passes ds
  Annotated _ d -> passes d
  _ -> False

questionText :: QuestionSpec -> Text
questionText = \case
  AskYesNo q -> "yes/no " <> quoted q
  AskChoice q opts -> "choice of " <> T.pack (show (length opts)) <> " " <> quoted q
  AskScore q levels -> "score on " <> T.pack (show (length levels)) <> " levels " <> quoted q

quoted :: Text -> Text
quoted t = "\"" <> t <> "\""

draw :: Text -> Text -> Tree -> [Text]
draw lead childLead (Node t cs) = (lead <> t) : go cs
  where
    go = \case
      [] -> []
      [c] -> draw (childLead <> "└─ ") (childLead <> "   ") c
      c : rest -> draw (childLead <> "├─ ") (childLead <> "│  ") c <> go rest

-- ---------------------------------------------------------------------------
-- Mermaid

-- | How data moves through a flow, as a graph: steps joined in order; @&&&@,
-- @***@ and @|||@ forking into their branches and joining again at the next
-- step, with a pass-through drawn as an edge straight to the join; @each@,
-- @repeatUntil@ and named sub-flows as boxes; tools hanging off their draft.
-- 'mermaid' and 'dot' render it.
data FlowGraph = FlowGraph
  { items :: [Item]
  , edges :: [Edge]
  }

-- | A node, or a box of items.
data Item
  = ItemNode Text NodeKind [Text]
    -- ^ An id, what kind of node it is, and its label's lines.
  | ItemBox Text [Text] [Item]
    -- ^ An id, its label's lines, and what's inside.

data NodeKind = Terminal | StepNode | ToolNode

data Edge = Edge
  { from :: Text
  , to :: Text
    -- ^ A node, or a box's id.
  , label :: Maybe Text
  , style :: EdgeStyle
  }

data EdgeStyle = Flow | Uses | Again

-- | The flow's graph, from @input@ to @output@.
flowGraph :: Description -> FlowGraph
flowGraph d = case flow.runBuild (BuildState 0 [[]] []) of
  (_, BuildState _ open edges) -> FlowGraph (reverse (concat open)) (reverse edges)
  where
    flow = do
      item (ItemNode "input" Terminal ["input"])
      exits <- build InSequence (Sources [("input", Nothing)]) d
      item (ItemNode "output" Terminal ["output"])
      connect exits "output"

-- | Where a description sits: unnamed glue between steps is plumbing, but a
-- branch that's only glue is still a branch.
data Context = InSequence | InBranch

-- | Where the next step's input comes from: nodes, each with an optional edge
-- label, or a pair (after @&&&@ or @***@) whose halves are known separately, so
-- a later @first@, @second@ or @***@ wires each half to its own flow.
data From = Sources [(Text, Maybe Text)] | Pair From From

-- | Every node a 'From' draws on, once each. A node reached by routes with
-- different labels (both sides of a @|||@) keeps one unlabelled edge.
sources :: From -> [(Text, Maybe Text)]
sources = dedupe . go
  where
    go = \case
      Sources s -> s
      Pair a b -> go a <> go b
    dedupe = \case
      [] -> []
      (n, l) : rest ->
        let others = [l' | (n', l') <- rest, n' == n]
         in (n, if all (== l) others then l else Nothing) : dedupe [x | x@(n', _) <- rest, n' /= n]

-- | The two sides of a @|||@ joining again: where both made a pair, so does
-- the join.
merge :: From -> From -> From
merge (Pair a b) (Pair c d) = Pair (merge a c) (merge b d)
merge x y = Sources (sources (Pair x y))

-- | A counter for ids, the items of each open box (innermost first), and edges.
data BuildState = BuildState Int [[Item]] [Edge]

newtype Build a = Build {runBuild :: BuildState -> (a, BuildState)}

instance Functor Build where
  fmap f (Build g) = Build (\s -> let (a, s') = g s in (f a, s'))

instance Applicative Build where
  pure a = Build (\s -> (a, s))
  Build f <*> Build g = Build (\s -> let (h, s1) = f s; (a, s2) = g s1 in (h a, s2))

instance Monad Build where
  Build g >>= k = Build (\s -> let (a, s1) = g s in (k a).runBuild s1)

fresh :: Build Text
fresh = Build (\(BuildState n open es) -> ("n" <> T.pack (show n), BuildState (n + 1) open es))

item :: Item -> Build ()
item i = Build $ \case
  BuildState n (current : outer) es -> ((), BuildState n ((i : current) : outer) es)
  BuildState n [] es -> ((), BuildState n [[i]] es)

edge :: Edge -> Build ()
edge e = Build (\(BuildState n open es) -> ((), BuildState n open (e : es)))

edgeCount :: Build Int
edgeCount = Build (\s@(BuildState _ _ es) -> (length es, s))

-- | The nodes that edges added since @before@ lead into from these sources.
entriesSince :: Int -> [Text] -> Build [Text]
entriesSince before sources = Build $ \s@(BuildState _ _ es) ->
  let new = reverse (take (length es - before) es)
   in (nubOrdered [e.to | e <- new, e.from `elem` sources], s)
  where
    nubOrdered = foldr (\x acc -> x : filter (/= x) acc) []

connect :: From -> Text -> Build ()
connect from to = mapM_ (\(f, l) -> edge (Edge f to l Flow)) (sources from)

node :: From -> [Text] -> Build From
node from label = do
  n <- fresh
  item (ItemNode n StepNode label)
  connect from n
  pure (Sources [(n, Nothing)])

box :: [Text] -> Build a -> Build (Text, a)
box label inside = do
  b <- fresh
  Build (\(BuildState n open es) -> ((), BuildState n ([] : open) es))
  a <- inside
  Build $ \case
    BuildState n (contents : parent : outer) es -> ((), BuildState n ((ItemBox b label (reverse contents) : parent) : outer) es)
    s -> ((), s)
  pure (b, a)

build :: Context -> From -> Description -> Build From
build context from = \case
  Leaf Identity -> pure from
  -- A known pair's half goes on exactly; otherwise it's like any @arr@.
  Leaf FirstHalf | Pair a _ <- from -> pure a
  Leaf SecondHalf | Pair _ b <- from -> pure b
  Leaf FirstHalf -> half "takeFirst"
  Leaf SecondHalf -> half "takeSecond"
  Leaf Glue -> case context of
    -- An @arr@ can take a pair apart any way it likes, so its halves are lost,
    -- and so is what the edge labels inside it said.
    InSequence -> pure $ case from of
      Pair _ _ -> Sources [(n, Nothing) | (n, _) <- sources from]
      Sources _ -> from
    InBranch -> node from ["arr"]
  Leaf info -> step Nothing info
  Sequence ds -> chain from ds
  Together ds -> pairUp <$> mapM (build InBranch from) ds
  -- A pair whose halves are known sends each to its flow. Otherwise every
  -- source might hold either half, so the edges say which half they carry.
  Halves l r -> case from of
    Pair a b -> Pair <$> build InBranch a l <*> build InBranch b r
    _ -> Pair <$> build InBranch (labelled "first") l <*> build InBranch (labelled "second") r
  Branch l r -> merge <$> build InBranch (labelled "left") l <*> build InBranch (labelled "right") r
  ForEach f -> Sources . sources . snd <$> box ["each"] (build InSequence (Sources (sources from)) f)
  -- "Again" goes back to where the body starts: the steps the loop's input
  -- flows into. If the body has none, it goes to the box.
  Repeated f -> do
    before <- edgeCount
    (b, exits) <- box ["repeatUntil"] (build InSequence from f)
    entries <- entriesSince before (map fst (sources from))
    let targets = if null entries then [b] else entries
    mapM_ (\(e, _) -> mapM_ (\t -> edge (Edge e t (Just "again") Again)) targets) (sources exits)
    -- The condition is checked before the first run, so the input can leave
    -- without going through the body.
    pure (merge from exits)
  Annotated n (Leaf info) | not (passes (Leaf info)) -> step (Just n) info
  Annotated n f -> snd <$> box (n.name : maybe [] pure n.description) (build InSequence from f)
  where
    labelled l = Sources [(f, Just l) | (f, _) <- sources from]
    -- A half of a pair the graph can't follow: plumbing, like @arr@.
    half name = case context of
      InSequence -> pure from
      InBranch -> node from [name]
    pairUp = \case
      [] -> from
      [x] -> x
      x : xs -> Pair x (pairUp xs)
    chain acc = \case
      [] -> pure acc
      x : xs -> build InSequence acc x >>= (`chain` xs)
    -- A step's node, labelled by 'stepLines', with any tools hanging off it.
    -- In a diagram, a named step's description goes under its name.
    step note' info = do
      let lines'' = case (stepLines ((.name) <$> note') info, note' >>= (.description)) of
            (kind : name : details, Just description) -> kind : name : description : details
            (ls, _) -> ls
      exits <- node from lines''
      case info of
        DraftInfo _ _ _ tools ->
          mapM_
            ( \t -> do
                n <- fresh
                item (ItemNode n ToolNode ["tool " <> t.name])
                mapM_ (\(e, _) -> edge (Edge e n Nothing Uses)) (sources exits)
            )
            tools
        _ -> pure ()
      pure exits

-- | How a step is labelled, everywhere: what kind of step it is, then its name
-- if it has one, then its details (an instruction, or questions).
stepLines :: Maybe Text -> StepInfo -> [Text]
stepLines name info = kind : maybe [] pure name <> details
  where
    (kind, details) = case info of
      Identity -> ("pass", [])
      Glue -> ("arr", [])
      FirstHalf -> ("takeFirst", [])
      SecondHalf -> ("takeSecond", [])
      Effect -> ("act", [])
      DraftInfo instruction _ out _ -> ("draft @" <> outlineLabel out, [quoted instruction.text])
      JudgeInfo _ [q] -> ("judge", [questionText q])
      JudgeInfo _ qs -> ("judge " <> T.pack (show (length qs)) <> " questions in one request", map questionText qs)

-- | A Mermaid flowchart of the flow's graph.
mermaid :: Description -> Text
mermaid d = T.unlines ("flowchart TD" : concatMap (items' "  ") is <> map edge' es)
  where
    FlowGraph is es = flowGraph d
    items' indent = \case
      ItemNode i kind ls -> [indent <> i <> shape kind (T.intercalate "<br/>" (map escape ls))]
      ItemBox i ls inside -> [indent <> "subgraph " <> i <> "[\"" <> T.intercalate "<br/>" (map escape ls) <> "\"]"] <> concatMap (items' (indent <> "  ")) inside <> [indent <> "end"]
    shape kind l = case kind of
      Terminal -> "([\"" <> l <> "\"])"
      StepNode -> "[\"" <> l <> "\"]"
      ToolNode -> "[/\"" <> l <> "\"/]"
    edge' (Edge f t l style) = "  " <> f <> arrow style <> maybe "" (\x -> "|" <> escape x <> "|") l <> " " <> t
    arrow = \case
      Flow -> " -->"
      Uses -> " -.-"
      Again -> " -.->"
    escape = T.replace "\"" "#quot;"

-- | A Graphviz DOT digraph of the flow's graph. Render it with, for example,
-- @dot -Tsvg@.
dot :: Description -> Text
dot d =
  T.unlines $
    ["digraph flow {", "  compound=true;", "  node [shape=box, style=rounded, fontname=\"Helvetica\"];", "  edge [fontname=\"Helvetica\"];"]
      <> concatMap (items' "  ") is
      <> map edge' es
      <> ["}"]
  where
    FlowGraph is es = flowGraph d
    items' indent = \case
      ItemNode i kind ls -> [indent <> i <> " [label=\"" <> T.intercalate "\\n" (map inner ls) <> "\"" <> shape kind <> "];"]
      ItemBox i ls inside -> [indent <> "subgraph cluster_" <> i <> " {", indent <> "  label=\"" <> T.intercalate "\\n" (map inner ls) <> "\";", indent <> "  style=rounded;"] <> concatMap (items' (indent <> "  ")) inside <> [indent <> "}"]
    shape = \case
      Terminal -> ", shape=oval"
      StepNode -> ""
      ToolNode -> ", shape=parallelogram, style=\"\""
    -- An edge into a box points at the box's first node, clipped to the box.
    edge' (Edge f t l style) =
      let (target, attrs) = case firstNode t is of
            Just n
              | inBox t f is -> (n, [])
              | otherwise -> (n, ["lhead=cluster_" <> t])
            Nothing -> (t, [])
          extra = maybe [] (\x -> ["label=" <> str x]) l <> attrs <> styleOf style
       in "  " <> f <> " -> " <> target <> (if null extra then "" else " [" <> T.intercalate ", " extra <> "]") <> ";"
    styleOf = \case
      Flow -> []
      Uses -> ["style=dotted", "arrowhead=none"]
      Again -> ["style=dashed"]
    -- JSON's string escapes are also DOT's.
    str = renderJson . String
    inner = T.pack . init . drop 1 . T.unpack . str

-- | Is the node with this id inside the box with that id?
inBox :: Text -> Text -> [Item] -> Bool
inBox b n = any within
  where
    within = \case
      ItemBox i _ inside
        | i == b -> any contains inside
        | otherwise -> any within inside
      ItemNode {} -> False
    contains = \case
      ItemNode i _ _ -> i == n
      ItemBox _ _ inside -> any contains inside

-- | The first node inside the box with this id, if the id is a box's. Only an
-- edge into an empty loop needs it.
firstNode :: Text -> [Item] -> Maybe Text
firstNode b = go
  where
    go = \case
      [] -> Nothing
      ItemBox i _ inside : rest
        | i == b -> first inside
        | otherwise -> maybe (go rest) Just (go inside)
      ItemNode {} : rest -> go rest
    first = \case
      ItemNode i _ _ : _ -> Just i
      ItemBox _ _ inside : rest -> maybe (first rest) Just (first inside)
      [] -> Nothing

-- ---------------------------------------------------------------------------
-- JSON

-- | The description as a JSON-shaped value, for UIs and other agents.
descriptionValue :: Description -> Value
descriptionValue = \case
  Leaf info -> leaf info
  Sequence ds -> node "sequence" [("steps", Array (map descriptionValue ds))]
  Together ds -> node "together" [("steps", Array (map descriptionValue ds))]
  Halves l r -> node "halves" [("first", descriptionValue l), ("second", descriptionValue r)]
  Branch l r -> node "branch" [("left", descriptionValue l), ("right", descriptionValue r)]
  ForEach d -> node "each" [("step", descriptionValue d)]
  Repeated d -> node "repeat" [("step", descriptionValue d)]
  Annotated n d ->
    node "note" $
      [("name", String n.name)]
        <> maybe [] (\t -> [("description", String t)]) n.description
        <> [("step", descriptionValue d)]
  where
    node kind fields = Object (("kind", String kind) : fields)
    leaf = \case
      Identity -> node "pass" []
      Glue -> node "arr" []
      FirstHalf -> node "takeFirst" []
      SecondHalf -> node "takeSecond" []
      Effect -> node "act" []
      DraftInfo instruction input out tools ->
        node
          "draft"
          [ ("instruction", String instruction.text)
          , ("input", String (outlineLabel input))
          , ("output", String (outlineLabel out))
          , ("tools", Array (map tool tools))
          ]
      JudgeInfo input qs ->
        node "judge" [("input", String (outlineLabel input)), ("questions", Array (map question qs))]
    tool t =
      Object
        [ ("name", String t.name)
        , ("description", String t.description)
        , ("input", String (outlineLabel t.input))
        , ("output", String (outlineLabel t.output))
        ]
    question = \case
      AskYesNo q -> Object [("type", String "yesNo"), ("question", String q)]
      AskChoice q opts -> Object [("type", String "choice"), ("question", String q), ("options", Array [String l | (l, _) <- opts])]
      AskScore q levels -> Object [("type", String "score"), ("question", String q), ("levels", Array [String l | (l, _) <- levels])]

-- | Apply a function to a pair's second half.
onSnd :: (b -> c) -> (a, b) -> (a, c)
onSnd f (a, b) = (a, f b)