packages feed

agentic 0.2.0.4 → 0.2.0.5

raw patch · 8 files changed

+184/−25 lines, 8 filesPVP: minor bump suggested

API additions: PVP suggests at least a minor version bump

API changes (from Hackage documentation)

+ Agentic.Core: [TakeFirst] :: forall (m :: Type -> Type) o b. Step m (o, b) o
+ Agentic.Core: [TakeSecond] :: forall (m :: Type -> Type) a o. Step m (a, o) o
+ Agentic.Core: infixr 6 :/\
+ Agentic.Core: pattern (:/\) :: a -> b -> (a, b)
+ Agentic.Core: takeFirst :: forall (m :: Type -> Type) a b. Agentic m (a, b) a
+ Agentic.Core: takeSecond :: forall (m :: Type -> Type) a b. Agentic m (a, b) b
+ Agentic.Core: type a :/\ b = (a, b)
+ Agentic.Describe: FirstHalf :: StepInfo
+ Agentic.Describe: SecondHalf :: StepInfo

Files

CHANGELOG.md view
@@ -1,5 +1,22 @@ # Changelog for agentic +## 0.2.0.5 - 2026-10-06++* `takeFirst` and `takeSecond` do what `arr fst` and `arr snd` do, but a+  diagram can follow them. `StepInfo` has `FirstHalf` and `SecondHalf` for+  them.+* `:/\` is a pair, as a type and a pattern, so the nested pairs `&&&` builds+  read flat: `\(creature :/\ picture :/\ card) -> ...`.+* `mermaid` and `dot` follow each half of a pair from `&&&` or `***`, so a+  later `first`, `second` or `***` is wired only to the half it gets.+  Previously every step before the pair was wired into both halves.+* A step reached by the same node along several routes, such as both sides of+  a `|||`, gets one edge instead of one per route.+* A `repeatUntil`'s input is drawn going straight out as well as through the+  body, since the condition is checked before the first run.+* `(a &&& b) &&& c` is described as a pair inside a pair, not flattened like+  `a &&& b &&& c`.+ ## 0.2.0.4 - 2026-10-06  * Record fields are no longer functions: the packages are written with
README.md view
@@ -260,7 +260,7 @@   draft @[Creature] "Name 10 prehistoric creatures a grade 5 class might have heard of. Include a mix of kinds, not only dinosaurs."     >>> each classify     >>> arr (partition (clearly Dinosaur 0.8)) `named` "split off the clear dinosaurs (≥ 0.8)"-    >>> (each (arr fst >>> exhibit) *** arr (map notADinosaur) `named` "note what the others were")+    >>> (each (takeFirst >>> exhibit) *** arr (map notADinosaur) `named` "note what the others were")     >>> arr (uncurry Exhibit)     >>> draft @Poster "Create a poster of these dinosaurs for a grade 5 class. Add a corner about the creatures that weren't dinosaurs, and what they were." @@ -273,9 +273,12 @@   (returnA &&& draft @DinoPic "Draw an ascii picture of this dinosaur, 10 lines high"            &&& draft @TrumpCard "Make a trump card for this dinosaur")     `named` "exhibit"-    >>> arr (\(c, (p, t)) -> Entry c p t)+    >>> arr (\(dinosaur :/\ picture :/\ card) -> Entry dinosaur picture card) ``` +`a &&& b &&& c` builds the nested pair `(a, (b, c))`, and `:/\` matches it+without the brackets: `dinosaur :/\ picture :/\ card`. It works as a type too.+ The trump card's stats use a `Stat` contract that checks 1 to 10, so every card uses the same scale. And the poster is drafted from a named `Exhibit` record rather than a tuple, so Claude sees `dinosaurs` and `notDinosaurs` instead of@@ -352,7 +355,7 @@   n3 -->|first| n6   n3 -->|first| n7   n3 -->|second| n8-  n3 -->|first| n9+  n3 --> n9   n6 --> n9   n7 --> n9   n8 --> n9@@ -362,6 +365,11 @@ `describe` returns a plain `Description` you can walk yourself, and `descriptionValue` turns it into JSON for UIs and other agents. The tree hides unnamed glue between steps, but never a branch.++The diagram follows each half of a pair from `&&&` or `***` to wherever it+goes next, but it can't see inside an `arr`, so `arr fst` loses track of which+step the half came from. `takeFirst` and `takeSecond` do the same job and keep+it.  ### Naming things 
agentic.cabal view
@@ -1,6 +1,6 @@ cabal-version:      3.0 name:               agentic-version:            0.2.0.4+version:            0.2.0.5 synopsis:           Composable, inspectable agentic workflows mixing LLMs and Jev description:   Typed agentic workflows built from Arrow combinators. A flow is a description: you can describe it as a tree, Mermaid or Graphviz before running anything, then interpret it against System One (Jev) and System Two (an LLM) providers. This is the core: flows, contracts, questions, the runtime and the interpreter. It depends only on base and text.
src/Agentic/Core.hs view
@@ -1,4 +1,5 @@ {-# LANGUAGE AllowAmbiguousTypes #-}+{-# LANGUAGE PatternSynonyms #-}  -- | Flows: typed, inspectable descriptions of agentic work. module Agentic.Core@@ -22,6 +23,11 @@   , repeatUntil   , note   , named+    -- * Plumbing+  , takeFirst+  , takeSecond+  , (:/\)+  , pattern (:/\)     -- * Judgement helpers   , keep   , gate@@ -63,6 +69,10 @@     -- ^ The input re-wrapped without changing it ('Left', 'Right'), so that     -- 'Agentic.Describe.describe' can show it as a pass-through.   Arr :: (i -> o) -> Step m i o+  TakeFirst :: Step m (a, b) a+    -- ^ 'takeFirst': unlike @arr fst@, 'Agentic.Describe.describe' can see+    -- which half it keeps.+  TakeSecond :: Step m (a, b) b   Act :: (i -> m o) -> Step m i o   Draft :: Codec i -> Codec o -> Instruction -> [Tool m] -> Step m i o   Judge :: Codec i -> Questions o -> Step m i o@@ -110,6 +120,29 @@   right f = Choose (Step (Wrap Left)) (f >>> Step (Wrap Right))   f +++ g = Choose (f >>> Step (Wrap Left)) (g >>> Step (Wrap Right))   f ||| g = Choose f g++-- | The first half of a pair. It does what @arr fst@ does, but a diagram can+-- follow it: after @&&&@ or @***@, it knows which step the half came from.+takeFirst :: Agentic m (a, b) a+takeFirst = Step TakeFirst++-- | The second half of a pair, like 'takeFirst'.+takeSecond :: Agentic m (a, b) b+takeSecond = Step TakeSecond++infixr 6 :/\++-- | A pair, written so that the nested pairs @&&&@ and @***@ build read flat:+-- @a &&& b &&& c@ gives an @A :\/\\ B :\/\\ C@, which is @(A, (B, C))@.+type a :/\ b = (a, b)++-- | Build or match a pair the same way:+--+-- > arr (\(creature :/\ picture :/\ card) -> Entry creature picture card)+pattern (:/\) :: a -> b -> (a, b)+pattern a :/\ b = (a, b)++{-# COMPLETE (:/\) #-}  -- | An LLM writes an @o@ from the step's input. --
src/Agentic/Describe.hs view
@@ -44,6 +44,10 @@     -- ^ The input, unchanged ('returnA').   | Glue     -- ^ @arr@: a pure function.+  | FirstHalf+    -- ^ 'takeFirst'.+  | SecondHalf+    -- ^ 'takeSecond'.   | Effect     -- ^ @act@: plain code with an effect.   | DraftInfo@@ -70,7 +74,9 @@ describe = \case   Step s -> Leaf (stepInfo s)   Seq f g -> Sequence (sequenced (describe f) <> sequenced (describe g))-  Fanout f g -> Together (together (describe f) <> together (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)@@ -90,6 +96,8 @@   Pass -> Identity   Wrap _ -> Identity   Arr _ -> Glue+  TakeFirst -> FirstHalf+  TakeSecond -> SecondHalf   Act _ -> Effect   Draft input out instruction tools ->     DraftInfo instruction input.schema out.schema (map toolInfo tools)@@ -119,6 +127,8 @@ 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 ->@@ -159,7 +169,7 @@       r -> r     -- A branch always shows, even when it's only glue.     branch s d = case trees s d of-      (s', []) -> (s', [Node (if passes d then "pass" else "arr") []])+      (s', []) -> (s', [Node (plumbing d) []])       r -> r     toolTree s t       | t.name `elem` s = (s, Node ("tool " <> t.name <> "  (see above)") [])@@ -171,6 +181,15 @@       [] -> 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@@ -235,7 +254,7 @@   where     flow = do       item (ItemNode "input" Terminal ["input"])-      exits <- build InSequence [("input", Nothing)] d+      exits <- build InSequence (Sources [("input", Nothing)]) d       item (ItemNode "output" Terminal ["output"])       connect exits "output" @@ -243,9 +262,31 @@ -- branch that's only glue is still a branch. data Context = InSequence | InBranch --- | Nodes the next step connects from, each with an optional edge label.-type From = [(Text, Maybe Text)]+-- | 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] @@ -284,14 +325,14 @@     nubOrdered = foldr (\x acc -> x : filter (/= x) acc) []  connect :: From -> Text -> Build ()-connect from to = mapM_ (\(f, l) -> edge (Edge f to l Flow)) from+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 [(n, Nothing)]+  pure (Sources [(n, Nothing)])  box :: [Text] -> Build a -> Build (Text, a) box label inside = do@@ -306,28 +347,51 @@ 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-    InSequence -> pure from+    -- 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 -> concat <$> mapM (build InBranch from) ds-  Halves l r -> (<>) <$> build InBranch (labelled "first") l <*> build InBranch (labelled "second") r-  Branch l r -> (<>) <$> build InBranch (labelled "left") l <*> build InBranch (labelled "right") r-  ForEach f -> snd <$> box ["each"] (build InSequence from f)+  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 from)+    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) exits-    pure exits+    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 = [(f, Just l) | (f, _) <- from]+    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)@@ -344,7 +408,7 @@             ( \t -> do                 n <- fresh                 item (ItemNode n ToolNode ["tool " <> t.name])-                mapM_ (\(e, _) -> edge (Edge e n Nothing Uses)) exits+                mapM_ (\(e, _) -> edge (Edge e n Nothing Uses)) (sources exits)             )             tools         _ -> pure ()@@ -358,6 +422,8 @@     (kind, details) = case info of       Identity -> ("pass", [])       Glue -> ("arr", [])+      FirstHalf -> ("takeFirst", [])+      SecondHalf -> ("takeSecond", [])       Effect -> ("act", [])       DraftInfo instruction _ out _ -> ("draft @" <> typeLabel out, [quoted instruction.text])       JudgeInfo _ [q] -> ("judge", [questionText q])@@ -469,6 +535,8 @@     leaf = \case       Identity -> node "pass" []       Glue -> node "arr" []+      FirstHalf -> node "takeFirst" []+      SecondHalf -> node "takeSecond" []       Effect -> node "act" []       DraftInfo instruction input out tools ->         node
src/Agentic/Interpret.hs view
@@ -43,6 +43,8 @@     step path s x = case s of       Pass -> pure x       Wrap f -> pure (f x)+      TakeFirst -> pure (fst x)+      TakeSecond -> pure (snd x)       Arr f -> pure (f x)       Act f -> emit path Acted >> f x       Judge input qs
test/Portable.hs view
@@ -19,15 +19,15 @@ -- --------------------------------------------------------------------------- -- Explicit codecs: a record, and a sum with payloads -data Joke = Joke Text Text+data Joke = Joke {setup :: Text, punchline :: Text}   deriving (Show, Eq)  instance Contract Joke where   contract =     record "A joke" $       Joke-        <$> required "setup" "The setup line" (\(Joke s _) -> s)-        <*> required "punchline" "The line that lands it" (\(Joke _ p) -> p)+        <$> required "setup" "The setup line" (.setup)+        <*> required "punchline" "The line that lands it" (.punchline)  data Figure = Circle Double | Rect Double Double   deriving (Show, Eq)@@ -157,6 +157,13 @@   let tree = renderTree (describe jokeAndFigure)   check "describe shows the drafts and both tools" (all (`T.isInfixOf` tree) ["draft @Joke", "tool count_letters  act", "tool write_joke  draft @Joke", "draft @Figure"]) -  -- 7. All of the above ran in Pure, not IO.+  -- 7. Plumbing a diagram can follow runs like arr fst and arr snd.+  let halves = (,) <$> interpret handlers (arr (\x -> (x, x + 1)) >>> takeFirst) (1 :: Int) <*> interpret handlers (arr (\x -> (x, x + 1)) >>> takeSecond) (1 :: Int)+  check "takeFirst and takeSecond keep their halves" (fst (halves.runPure world0) == (1, 2))+  let flat :: Int :/\ Text :/\ Bool+      flat = 1 :/\ "two" :/\ True+  check "a :/\\ pair builds and matches nested pairs" (flat == (1, ("two", True)) && (case flat of _ :/\ t :/\ _ -> t == "two"))++  -- 8. All of the above ran in Pure, not IO.   n <- readIORef failures   if n == 0 then putStrLn "all checks passed" else putStrLn (show n <> " checks failed") >> exitFailure
test/Spec.hs view
@@ -229,6 +229,30 @@           edges = filter (T.isInfixOf "-->") (T.lines (mermaid (Agentic.describe flow)))       edges `shouldBe` ["  input --> n0", "  input --> output", "  n0 --> output"] +    it "wires second to the second half of a known pair" $ do+      let flow :: Agentic IO Joke (Joke, Rating)+          flow = (draft @Joke "retell it" &&& draft @Rating "rate it") >>> second (draft @Rating "rate it again")+          edges = filter (T.isInfixOf "-->") (T.lines (mermaid (Agentic.describe flow)))+      edges `shouldBe` ["  input --> n0", "  input --> n1", "  n1 --> n2", "  n0 --> output", "  n2 --> output"]++    it "joins a pass-through from both sides of a choice with one edge" $ do+      let flow :: Agentic IO (Either Joke Joke) (Joke, Rating)+          flow = (returnA &&& draft @Rating "rate it") ||| (returnA &&& draft @Rating "rate it harshly")+          edges = filter (T.isInfixOf "-->") (T.lines (mermaid (Agentic.describe flow)))+      edges `shouldBe` ["  input -->|left| n0", "  input -->|right| n1", "  input --> output", "  n0 --> output", "  n1 --> output"]++    it "follows takeSecond to the half it keeps" $ do+      let flow :: Agentic IO Joke Rating+          flow = (draft @Joke "retell it" &&& draft @Rating "rate it") >>> takeSecond >>> draft @Rating "rate it again"+          edges = filter (T.isInfixOf "-->") (T.lines (mermaid (Agentic.describe flow)))+      edges `shouldBe` ["  input --> n0", "  input --> n1", "  n1 --> n2", "  n2 --> output"]++    it "lets a loop's input leave without going through the body" $ do+      let flow :: Agentic IO Joke Joke+          flow = repeatUntil ((== "kids") . (.genre)) (draft @Joke "make it kid-friendly") >>> act pure `named` "show it"+          edges = filter (T.isInfixOf "-->") (T.lines (mermaid (Agentic.describe flow)))+      edges `shouldBe` ["  input --> n1", "  input --> n2", "  n1 --> n2", "  n2 --> output"]+     it "draws the same graph as DOT, with boxes as clusters" $ do       let flow :: Agentic IO [Joke] [Joke]           flow = each (draft @Joke "polish it") `named` "polish"