packages feed

shikumi-tools-0.3.0.0: test/ReActSpec.hs

{-# LANGUAGE OverloadedStrings #-}

-- | M2: the ReAct loop as a 'Program', driven by a mock LM. A scripted tool-call
-- then finish yields the typed answer plus a two-step trajectory; a script that
-- never finishes stops at @maxIters@ and still extracts a best-effort answer.
module ReActSpec (tests) where

import Baikai (Response)
import Control.Lens ((&), (.~))
import Data.Generics.Labels ()
import Data.Text qualified as T
import Data.Vector qualified as V
import Effectful.Error.Static (throwError)
import Fixtures
  ( WeatherReq,
    WeatherResp,
    expectedWeather,
    maxItersScript,
    promptScript,
    weatherQuestion,
    weatherRegistry,
    weatherSignature,
  )
import MockLLM (mkTextResponse, runAgent)
import Shikumi.Agent.ReAct
  ( Action (..),
    Step (..),
    Termination (..),
    Trajectory (..),
    defaultReActConfig,
    reactWithTrajectory,
  )
import Shikumi.Error (ShikumiError (..))
import Shikumi.Tool (SomeTool (..), Tool, ToolRegistry, mkRegistry, mkTool)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))

budgetTool :: Tool WeatherReq WeatherResp
budgetTool =
  mkTool "burn_budget" "Always exceeds the budget." $ \_req ->
    throwError (BudgetExceeded "ceiling reached")

flakyTool :: Tool WeatherReq WeatherResp
flakyTool =
  mkTool "flaky" "Always fails validation." $ \_req ->
    throwError (ValidationFailure "nothing to see")

budgetRegistry :: ToolRegistry
budgetRegistry = mkRegistry [SomeTool budgetTool]

flakyRegistry :: ToolRegistry
flakyRegistry = mkRegistry [SomeTool flakyTool]

extractReply :: T.Text
extractReply = "{\"tempC\": 12.0, \"summary\": \"mild\"}"

finishReply :: T.Text
finishReply = "{\"thought\": \"I have the forecast.\", \"action\": {\"finish\": true}}"

proposeBudgetReply :: T.Text
proposeBudgetReply =
  "{\"thought\": \"I should spend.\", \"action\": {\"tool\": \"burn_budget\", \"args\": {\"city\": \"Paris\", \"units\": \"c\"}}}"

proposeFlakyReply :: T.Text
proposeFlakyReply =
  "{\"thought\": \"I should try a flaky lookup.\", \"action\": {\"tool\": \"flaky\", \"args\": {\"city\": \"Paris\", \"units\": \"c\"}}}"

budgetScript :: [Response]
budgetScript =
  [ mkTextResponse proposeBudgetReply,
    mkTextResponse finishReply,
    mkTextResponse extractReply
  ]

flakyScript :: [Response]
flakyScript =
  [ mkTextResponse proposeFlakyReply,
    mkTextResponse finishReply,
    mkTextResponse extractReply
  ]

tests :: TestTree
tests =
  testGroup
    "ReAct"
    [ testCase "runs tool then finish; returns typed answer + trajectory" $ do
        res <-
          runAgent
            promptScript
            (reactWithTrajectory weatherSignature weatherRegistry defaultReActConfig)
            weatherQuestion
        case res of
          Right (o :: WeatherResp, traj) -> do
            o @?= expectedWeather
            termination traj @?= TerminatedFinish
            V.length (steps traj) @?= 2
            case V.toList (steps traj) of
              [s1, s2] -> do
                case action s1 of
                  CallTool nm _ -> nm @?= "get_weather"
                  other -> assertFailure ("step 1 should be a tool call, got " <> show other)
                assertBool "tool step records an observation" (observation s1 /= Nothing)
                action s2 @?= Finish
              _ -> assertFailure "expected exactly two steps"
          Left e -> assertFailure ("agent failed: " <> show e),
      testCase "stops at maxIters with TerminatedMaxIters" $ do
        let cfg = defaultReActConfig & #maxIters .~ 1
        res <-
          runAgent
            maxItersScript
            (reactWithTrajectory weatherSignature weatherRegistry cfg)
            weatherQuestion
        case res of
          Right (o :: WeatherResp, traj) -> do
            termination traj @?= TerminatedMaxIters 1
            o @?= expectedWeather
          Left e -> assertFailure ("agent failed: " <> show e),
      testCase "a tool throwing BudgetExceeded aborts the loop" $ do
        res <-
          runAgent
            budgetScript
            (reactWithTrajectory weatherSignature budgetRegistry defaultReActConfig)
            weatherQuestion
        case res of
          Left (BudgetExceeded msg) -> msg @?= "ceiling reached"
          other -> assertFailure ("expected escaped BudgetExceeded, got " <> show other),
      testCase "a tool throwing a recoverable error yields an observation and the loop continues" $ do
        res <-
          runAgent
            flakyScript
            (reactWithTrajectory weatherSignature flakyRegistry defaultReActConfig)
            weatherQuestion
        case res of
          Right (o :: WeatherResp, traj) -> do
            o @?= expectedWeather
            termination traj @?= TerminatedFinish
            case V.toList (steps traj) of
              (Step {action = CallTool nm _, observation = Just obs} : _) -> do
                nm @?= "flaky"
                assertBool "observation is a rendered tool failure" ("failed" `T.isInfixOf` obs)
                assertBool "observation includes validation reason" ("nothing to see" `T.isInfixOf` obs)
              other -> assertFailure ("expected first step to be flaky tool call with observation, got " <> show other)
          Left e -> assertFailure ("agent failed: " <> show e),
      testCase "an unparseable reply produces a corrective step and the loop recovers" $ do
        res <-
          runAgent
            (mkTextResponse "this is not json" : promptScript)
            (reactWithTrajectory weatherSignature weatherRegistry defaultReActConfig)
            weatherQuestion
        case res of
          Right (o :: WeatherResp, traj) -> do
            o @?= expectedWeather
            assertBool
              "trajectory contains corrective parse feedback"
              (any hasCorrectiveObservation (V.toList (steps traj)))
          Left e -> assertFailure ("agent failed: " <> show e)
    ]

hasCorrectiveObservation :: Step -> Bool
hasCorrectiveObservation Step {observation = Just obs} =
  "not a valid action JSON object" `T.isInfixOf` obs
hasCorrectiveObservation _ = False