llm-simple-0.3.0.0: test/LLM/ToolUtilsSpec.hs
module LLM.ToolUtilsSpec (spec) where
import Control.Concurrent (threadDelay)
import Control.Exception (throwIO)
import Data.Aeson (Value (Null), object, (.=))
import Data.Text (Text)
import Data.Text qualified as T
import Heptapod (generate)
import LLM.Agent.Events (emitEvent)
import LLM.Agent.ToolUtils (executeTool)
import LLM.Agent.Types (EventObserver, GenerateEventDetail (..), RuntimeArgs (..), Tool (..), ToolContext (..))
import LLM.Core.Types (ToolCall (..), ToolDef (..), ToolResult (..))
import LLM.Generate.GenerateUtils (llmHooks)
import LLM.Generate.Logger (Hooks (..), LogLevel (..), noHooks, safeHooks)
import System.Timeout (timeout)
import Test.Hspec (Spec, describe, it, shouldBe, shouldContain)
spec :: Spec
spec = describe "ToolUtils" $ do
describe "executeTool" $ do
it "refuses mutating tools when rtReadonly is True" $ do
genId <- generate
let writeTool =
Tool
{ toolDef =
ToolDef
{ toolName = "writefile",
toolDescription = "test",
toolReadonly = False,
toolParameters = Null
},
toolExecute = \_ _ -> pure ("wrote" :: Text)
}
readonlyTool =
Tool
{ toolDef =
ToolDef
{ toolName = "grep",
toolDescription = "test",
toolReadonly = True,
toolParameters = Null
},
toolExecute = \_ _ -> pure ("found" :: Text)
}
rt =
RuntimeArgs
{ rtGenerationId = genId,
rtAbortSignal = Nothing,
rtLLMHooks = llmHooks noHooks,
rtHooks = noHooks,
rtOnEvent = \_ -> pure (),
rtReadonly = True
}
ctx =
ToolContext
{ tcConversation = [],
tcUsage = mempty,
tcWindowOffset = 0,
tcRuntimeArgs = rt
}
writeCall =
ToolCall
{ tcId = "1",
tcName = "writefile",
tcArguments = object ["path" .= ("x" :: Text), "content" .= ("y" :: Text)],
tcProviderMeta = Nothing
}
readCall =
ToolCall
{ tcId = "2",
tcName = "grep",
tcArguments = object ["pattern" .= ("x" :: Text)],
tcProviderMeta = Nothing
}
writeResult <- executeTool noHooks ctx [writeTool] writeCall
readResult <- executeTool noHooks ctx [readonlyTool] readCall
T.unpack writeResult.trContent `shouldContain` "readonly mode"
T.unpack readResult.trContent `shouldContain` "found"
describe "async exceptions" $ do
it "executeTool does not turn a timeout into a tool result" $ do
ctx <- mkContext (\_ -> pure ())
let slow = mkTool "slow" (threadDelay 1_000_000 >> pure "done")
r <- timeout 50_000 (executeTool noHooks ctx [slow] (mkCall "slow"))
fmap (.trContent) r `shouldBe` Nothing
it "executeTool still reports synchronous exceptions as tool errors" $ do
ctx <- mkContext (\_ -> pure ())
let failing = mkTool "boom" (throwIO (userError "kaput"))
r <- executeTool noHooks ctx [failing] (mkCall "boom")
T.unpack r.trContent `shouldContain` "Tool error"
it "emitEvent does not swallow a timeout in the observer" $ do
ctx <- mkContext (\_ -> threadDelay 1_000_000)
r <- timeout 50_000 (emitEvent ctx.tcRuntimeArgs GenerationStarted)
r `shouldBe` Nothing
it "safeHooks does not swallow a timeout in a hook" $ do
let hooks = safeHooks noHooks {onLog = \_ _ -> threadDelay 1_000_000}
r <- timeout 50_000 (hooks.onLog Info "x")
r `shouldBe` Nothing
mkTool :: Text -> IO Text -> Tool Text
mkTool name act =
Tool
{ toolDef =
ToolDef
{ toolName = name,
toolDescription = "test",
toolReadonly = True,
toolParameters = Null
},
toolExecute = \_ _ -> act
}
mkCall :: Text -> ToolCall
mkCall name =
ToolCall
{ tcId = "1",
tcName = name,
tcArguments = object [],
tcProviderMeta = Nothing
}
mkContext :: EventObserver -> IO ToolContext
mkContext onEvent = do
genId <- generate
pure
ToolContext
{ tcConversation = [],
tcUsage = mempty,
tcWindowOffset = 0,
tcRuntimeArgs =
RuntimeArgs
{ rtGenerationId = genId,
rtAbortSignal = Nothing,
rtLLMHooks = llmHooks noHooks,
rtHooks = noHooks,
rtOnEvent = onEvent,
rtReadonly = False
}
}