packages feed

shikumi-tools 0.1.0.1 → 0.2.0.0

raw patch · 21 files changed

+1839/−36 lines, 21 filesdep +directorydep +filepathdep +http-clientdep ~shikumidep ~shikumi-toolsPVP ok

version bump matches the API change (PVP)

Dependencies added: directory, filepath, http-client, http-client-tls, http-types, process, regex-tdfa

Dependency ranges changed: shikumi, shikumi-tools

API changes (from Hackage documentation)

+ Shikumi.Agent.ReAct: [compaction] :: ReActConfig -> !CompactionConfig
+ Shikumi.Tool.Builtin: builtinFsTools :: ToolEnv -> [SomeTool]
+ Shikumi.Tool.Builtin: builtinRegistry :: ToolEnv -> WebClient -> ToolRegistry
+ Shikumi.Tool.Builtin: builtinTools :: ToolEnv -> WebClient -> [SomeTool]
+ Shikumi.Tool.Builtin: builtinWebTools :: WebClient -> [SomeTool]
+ Shikumi.Tool.Builtin.Fs: EditReq :: !Text -> !Text -> !Text -> !Maybe Bool -> EditReq
+ Shikumi.Tool.Builtin.Fs: EditResp :: !Text -> !Int -> EditResp
+ Shikumi.Tool.Builtin.Fs: GlobReq :: !Text -> !Maybe Text -> GlobReq
+ Shikumi.Tool.Builtin.Fs: GlobResp :: ![Text] -> !Bool -> GlobResp
+ Shikumi.Tool.Builtin.Fs: GrepMatch :: !Text -> !Int -> !Text -> GrepMatch
+ Shikumi.Tool.Builtin.Fs: GrepReq :: !Text -> !Maybe Text -> !Maybe Text -> !Maybe Bool -> GrepReq
+ Shikumi.Tool.Builtin.Fs: GrepResp :: ![GrepMatch] -> !Bool -> GrepResp
+ Shikumi.Tool.Builtin.Fs: ReadReq :: !Text -> !Maybe Int -> !Maybe Int -> ReadReq
+ Shikumi.Tool.Builtin.Fs: ReadResp :: !Text -> !Int -> !Bool -> ReadResp
+ Shikumi.Tool.Builtin.Fs: WriteReq :: !Text -> !Text -> WriteReq
+ Shikumi.Tool.Builtin.Fs: WriteResp :: !Text -> !Int -> WriteResp
+ Shikumi.Tool.Builtin.Fs: [bytesWritten] :: WriteResp -> !Int
+ Shikumi.Tool.Builtin.Fs: [content] :: WriteReq -> !Text
+ Shikumi.Tool.Builtin.Fs: [file] :: GrepMatch -> !Text
+ Shikumi.Tool.Builtin.Fs: [glob] :: GrepReq -> !Maybe Text
+ Shikumi.Tool.Builtin.Fs: [ignoreCase] :: GrepReq -> !Maybe Bool
+ Shikumi.Tool.Builtin.Fs: [limit] :: ReadReq -> !Maybe Int
+ Shikumi.Tool.Builtin.Fs: [lineCount] :: ReadResp -> !Int
+ Shikumi.Tool.Builtin.Fs: [line] :: GrepMatch -> !Int
+ Shikumi.Tool.Builtin.Fs: [matches] :: GrepResp -> ![GrepMatch]
+ Shikumi.Tool.Builtin.Fs: [newString] :: EditReq -> !Text
+ Shikumi.Tool.Builtin.Fs: [offset] :: ReadReq -> !Maybe Int
+ Shikumi.Tool.Builtin.Fs: [oldString] :: EditReq -> !Text
+ Shikumi.Tool.Builtin.Fs: [path] :: GlobReq -> !Maybe Text
+ Shikumi.Tool.Builtin.Fs: [paths] :: GlobResp -> ![Text]
+ Shikumi.Tool.Builtin.Fs: [patternText] :: GlobReq -> !Text
+ Shikumi.Tool.Builtin.Fs: [replaceAll] :: EditReq -> !Maybe Bool
+ Shikumi.Tool.Builtin.Fs: [replacements] :: EditResp -> !Int
+ Shikumi.Tool.Builtin.Fs: [text] :: GrepMatch -> !Text
+ Shikumi.Tool.Builtin.Fs: [truncated] :: GlobResp -> !Bool
+ Shikumi.Tool.Builtin.Fs: data EditReq
+ Shikumi.Tool.Builtin.Fs: data EditResp
+ Shikumi.Tool.Builtin.Fs: data GlobReq
+ Shikumi.Tool.Builtin.Fs: data GlobResp
+ Shikumi.Tool.Builtin.Fs: data GrepMatch
+ Shikumi.Tool.Builtin.Fs: data GrepReq
+ Shikumi.Tool.Builtin.Fs: data GrepResp
+ Shikumi.Tool.Builtin.Fs: data ReadReq
+ Shikumi.Tool.Builtin.Fs: data ReadResp
+ Shikumi.Tool.Builtin.Fs: data WriteReq
+ Shikumi.Tool.Builtin.Fs: data WriteResp
+ Shikumi.Tool.Builtin.Fs: editTool :: ToolEnv -> Tool EditReq EditResp
+ Shikumi.Tool.Builtin.Fs: globTool :: ToolEnv -> Tool GlobReq GlobResp
+ Shikumi.Tool.Builtin.Fs: grepTool :: ToolEnv -> Tool GrepReq GrepResp
+ Shikumi.Tool.Builtin.Fs: instance Data.Aeson.Types.ToJSON.ToJSON Shikumi.Tool.Builtin.Fs.EditResp
+ Shikumi.Tool.Builtin.Fs: instance Data.Aeson.Types.ToJSON.ToJSON Shikumi.Tool.Builtin.Fs.GlobResp
+ Shikumi.Tool.Builtin.Fs: instance Data.Aeson.Types.ToJSON.ToJSON Shikumi.Tool.Builtin.Fs.GrepMatch
+ Shikumi.Tool.Builtin.Fs: instance Data.Aeson.Types.ToJSON.ToJSON Shikumi.Tool.Builtin.Fs.GrepResp
+ Shikumi.Tool.Builtin.Fs: instance Data.Aeson.Types.ToJSON.ToJSON Shikumi.Tool.Builtin.Fs.ReadResp
+ Shikumi.Tool.Builtin.Fs: instance Data.Aeson.Types.ToJSON.ToJSON Shikumi.Tool.Builtin.Fs.WriteResp
+ Shikumi.Tool.Builtin.Fs: instance GHC.Classes.Eq Shikumi.Tool.Builtin.Fs.EditReq
+ Shikumi.Tool.Builtin.Fs: instance GHC.Classes.Eq Shikumi.Tool.Builtin.Fs.EditResp
+ Shikumi.Tool.Builtin.Fs: instance GHC.Classes.Eq Shikumi.Tool.Builtin.Fs.GlobReq
+ Shikumi.Tool.Builtin.Fs: instance GHC.Classes.Eq Shikumi.Tool.Builtin.Fs.GlobResp
+ Shikumi.Tool.Builtin.Fs: instance GHC.Classes.Eq Shikumi.Tool.Builtin.Fs.GrepMatch
+ Shikumi.Tool.Builtin.Fs: instance GHC.Classes.Eq Shikumi.Tool.Builtin.Fs.GrepReq
+ Shikumi.Tool.Builtin.Fs: instance GHC.Classes.Eq Shikumi.Tool.Builtin.Fs.GrepResp
+ Shikumi.Tool.Builtin.Fs: instance GHC.Classes.Eq Shikumi.Tool.Builtin.Fs.ReadReq
+ Shikumi.Tool.Builtin.Fs: instance GHC.Classes.Eq Shikumi.Tool.Builtin.Fs.ReadResp
+ Shikumi.Tool.Builtin.Fs: instance GHC.Classes.Eq Shikumi.Tool.Builtin.Fs.WriteReq
+ Shikumi.Tool.Builtin.Fs: instance GHC.Classes.Eq Shikumi.Tool.Builtin.Fs.WriteResp
+ Shikumi.Tool.Builtin.Fs: instance GHC.Classes.Ord Shikumi.Tool.Builtin.Fs.GrepMatch
+ Shikumi.Tool.Builtin.Fs: instance GHC.Internal.Generics.Generic Shikumi.Tool.Builtin.Fs.EditReq
+ Shikumi.Tool.Builtin.Fs: instance GHC.Internal.Generics.Generic Shikumi.Tool.Builtin.Fs.EditResp
+ Shikumi.Tool.Builtin.Fs: instance GHC.Internal.Generics.Generic Shikumi.Tool.Builtin.Fs.GlobReq
+ Shikumi.Tool.Builtin.Fs: instance GHC.Internal.Generics.Generic Shikumi.Tool.Builtin.Fs.GlobResp
+ Shikumi.Tool.Builtin.Fs: instance GHC.Internal.Generics.Generic Shikumi.Tool.Builtin.Fs.GrepMatch
+ Shikumi.Tool.Builtin.Fs: instance GHC.Internal.Generics.Generic Shikumi.Tool.Builtin.Fs.GrepReq
+ Shikumi.Tool.Builtin.Fs: instance GHC.Internal.Generics.Generic Shikumi.Tool.Builtin.Fs.GrepResp
+ Shikumi.Tool.Builtin.Fs: instance GHC.Internal.Generics.Generic Shikumi.Tool.Builtin.Fs.ReadReq
+ Shikumi.Tool.Builtin.Fs: instance GHC.Internal.Generics.Generic Shikumi.Tool.Builtin.Fs.ReadResp
+ Shikumi.Tool.Builtin.Fs: instance GHC.Internal.Generics.Generic Shikumi.Tool.Builtin.Fs.WriteReq
+ Shikumi.Tool.Builtin.Fs: instance GHC.Internal.Generics.Generic Shikumi.Tool.Builtin.Fs.WriteResp
+ Shikumi.Tool.Builtin.Fs: instance GHC.Internal.Show.Show Shikumi.Tool.Builtin.Fs.EditReq
+ Shikumi.Tool.Builtin.Fs: instance GHC.Internal.Show.Show Shikumi.Tool.Builtin.Fs.EditResp
+ Shikumi.Tool.Builtin.Fs: instance GHC.Internal.Show.Show Shikumi.Tool.Builtin.Fs.GlobReq
+ Shikumi.Tool.Builtin.Fs: instance GHC.Internal.Show.Show Shikumi.Tool.Builtin.Fs.GlobResp
+ Shikumi.Tool.Builtin.Fs: instance GHC.Internal.Show.Show Shikumi.Tool.Builtin.Fs.GrepMatch
+ Shikumi.Tool.Builtin.Fs: instance GHC.Internal.Show.Show Shikumi.Tool.Builtin.Fs.GrepReq
+ Shikumi.Tool.Builtin.Fs: instance GHC.Internal.Show.Show Shikumi.Tool.Builtin.Fs.GrepResp
+ Shikumi.Tool.Builtin.Fs: instance GHC.Internal.Show.Show Shikumi.Tool.Builtin.Fs.ReadReq
+ Shikumi.Tool.Builtin.Fs: instance GHC.Internal.Show.Show Shikumi.Tool.Builtin.Fs.ReadResp
+ Shikumi.Tool.Builtin.Fs: instance GHC.Internal.Show.Show Shikumi.Tool.Builtin.Fs.WriteReq
+ Shikumi.Tool.Builtin.Fs: instance GHC.Internal.Show.Show Shikumi.Tool.Builtin.Fs.WriteResp
+ Shikumi.Tool.Builtin.Fs: instance Shikumi.Adapter.ToPrompt Shikumi.Tool.Builtin.Fs.EditReq
+ Shikumi.Tool.Builtin.Fs: instance Shikumi.Adapter.ToPrompt Shikumi.Tool.Builtin.Fs.GlobReq
+ Shikumi.Tool.Builtin.Fs: instance Shikumi.Adapter.ToPrompt Shikumi.Tool.Builtin.Fs.GrepReq
+ Shikumi.Tool.Builtin.Fs: instance Shikumi.Adapter.ToPrompt Shikumi.Tool.Builtin.Fs.ReadReq
+ Shikumi.Tool.Builtin.Fs: instance Shikumi.Adapter.ToPrompt Shikumi.Tool.Builtin.Fs.WriteReq
+ Shikumi.Tool.Builtin.Fs: instance Shikumi.Schema.FromModel Shikumi.Tool.Builtin.Fs.EditReq
+ Shikumi.Tool.Builtin.Fs: instance Shikumi.Schema.FromModel Shikumi.Tool.Builtin.Fs.GlobReq
+ Shikumi.Tool.Builtin.Fs: instance Shikumi.Schema.FromModel Shikumi.Tool.Builtin.Fs.GrepReq
+ Shikumi.Tool.Builtin.Fs: instance Shikumi.Schema.FromModel Shikumi.Tool.Builtin.Fs.ReadReq
+ Shikumi.Tool.Builtin.Fs: instance Shikumi.Schema.FromModel Shikumi.Tool.Builtin.Fs.WriteReq
+ Shikumi.Tool.Builtin.Fs: instance Shikumi.Schema.ToSchema Shikumi.Tool.Builtin.Fs.EditReq
+ Shikumi.Tool.Builtin.Fs: instance Shikumi.Schema.ToSchema Shikumi.Tool.Builtin.Fs.GlobReq
+ Shikumi.Tool.Builtin.Fs: instance Shikumi.Schema.ToSchema Shikumi.Tool.Builtin.Fs.GrepReq
+ Shikumi.Tool.Builtin.Fs: instance Shikumi.Schema.ToSchema Shikumi.Tool.Builtin.Fs.ReadReq
+ Shikumi.Tool.Builtin.Fs: instance Shikumi.Schema.ToSchema Shikumi.Tool.Builtin.Fs.WriteReq
+ Shikumi.Tool.Builtin.Fs: readTool :: ToolEnv -> Tool ReadReq ReadResp
+ Shikumi.Tool.Builtin.Fs: writeTool :: ToolEnv -> Tool WriteReq WriteResp
+ Shikumi.Tool.Builtin.Shell: BashReq :: !Text -> !Maybe Text -> !Maybe Int -> !Maybe Text -> BashReq
+ Shikumi.Tool.Builtin.Shell: BashResp :: !Int -> !Text -> !Text -> BashResp
+ Shikumi.Tool.Builtin.Shell: [command] :: BashReq -> !Text
+ Shikumi.Tool.Builtin.Shell: [cwd] :: BashReq -> !Maybe Text
+ Shikumi.Tool.Builtin.Shell: [exitCode] :: BashResp -> !Int
+ Shikumi.Tool.Builtin.Shell: [stderr] :: BashResp -> !Text
+ Shikumi.Tool.Builtin.Shell: [stdin] :: BashReq -> !Maybe Text
+ Shikumi.Tool.Builtin.Shell: [stdout] :: BashResp -> !Text
+ Shikumi.Tool.Builtin.Shell: [timeoutMs] :: BashReq -> !Maybe Int
+ Shikumi.Tool.Builtin.Shell: bashTool :: ToolEnv -> Tool BashReq BashResp
+ Shikumi.Tool.Builtin.Shell: data BashReq
+ Shikumi.Tool.Builtin.Shell: data BashResp
+ Shikumi.Tool.Builtin.Shell: instance Data.Aeson.Types.ToJSON.ToJSON Shikumi.Tool.Builtin.Shell.BashResp
+ Shikumi.Tool.Builtin.Shell: instance GHC.Classes.Eq Shikumi.Tool.Builtin.Shell.BashReq
+ Shikumi.Tool.Builtin.Shell: instance GHC.Classes.Eq Shikumi.Tool.Builtin.Shell.BashResp
+ Shikumi.Tool.Builtin.Shell: instance GHC.Internal.Generics.Generic Shikumi.Tool.Builtin.Shell.BashReq
+ Shikumi.Tool.Builtin.Shell: instance GHC.Internal.Generics.Generic Shikumi.Tool.Builtin.Shell.BashResp
+ Shikumi.Tool.Builtin.Shell: instance GHC.Internal.Show.Show Shikumi.Tool.Builtin.Shell.BashReq
+ Shikumi.Tool.Builtin.Shell: instance GHC.Internal.Show.Show Shikumi.Tool.Builtin.Shell.BashResp
+ Shikumi.Tool.Builtin.Shell: instance Shikumi.Adapter.ToPrompt Shikumi.Tool.Builtin.Shell.BashReq
+ Shikumi.Tool.Builtin.Shell: instance Shikumi.Schema.FromModel Shikumi.Tool.Builtin.Shell.BashReq
+ Shikumi.Tool.Builtin.Shell: instance Shikumi.Schema.ToSchema Shikumi.Tool.Builtin.Shell.BashReq
+ Shikumi.Tool.Builtin.Web: FetchReq :: !Text -> !Maybe Int -> FetchReq
+ Shikumi.Tool.Builtin.Web: SearchReq :: !Text -> !Maybe Int -> SearchReq
+ Shikumi.Tool.Builtin.Web: [maxBytes] :: FetchReq -> !Maybe Int
+ Shikumi.Tool.Builtin.Web: [maxResults] :: SearchReq -> !Maybe Int
+ Shikumi.Tool.Builtin.Web: [query] :: SearchReq -> !Text
+ Shikumi.Tool.Builtin.Web: [url] :: FetchReq -> !Text
+ Shikumi.Tool.Builtin.Web: data FetchReq
+ Shikumi.Tool.Builtin.Web: data SearchReq
+ Shikumi.Tool.Builtin.Web: instance GHC.Classes.Eq Shikumi.Tool.Builtin.Web.FetchReq
+ Shikumi.Tool.Builtin.Web: instance GHC.Classes.Eq Shikumi.Tool.Builtin.Web.SearchReq
+ Shikumi.Tool.Builtin.Web: instance GHC.Internal.Generics.Generic Shikumi.Tool.Builtin.Web.FetchReq
+ Shikumi.Tool.Builtin.Web: instance GHC.Internal.Generics.Generic Shikumi.Tool.Builtin.Web.SearchReq
+ Shikumi.Tool.Builtin.Web: instance GHC.Internal.Show.Show Shikumi.Tool.Builtin.Web.FetchReq
+ Shikumi.Tool.Builtin.Web: instance GHC.Internal.Show.Show Shikumi.Tool.Builtin.Web.SearchReq
+ Shikumi.Tool.Builtin.Web: instance Shikumi.Adapter.ToPrompt Shikumi.Tool.Builtin.Web.FetchReq
+ Shikumi.Tool.Builtin.Web: instance Shikumi.Adapter.ToPrompt Shikumi.Tool.Builtin.Web.SearchReq
+ Shikumi.Tool.Builtin.Web: instance Shikumi.Schema.FromModel Shikumi.Tool.Builtin.Web.FetchReq
+ Shikumi.Tool.Builtin.Web: instance Shikumi.Schema.FromModel Shikumi.Tool.Builtin.Web.SearchReq
+ Shikumi.Tool.Builtin.Web: instance Shikumi.Schema.ToSchema Shikumi.Tool.Builtin.Web.FetchReq
+ Shikumi.Tool.Builtin.Web: instance Shikumi.Schema.ToSchema Shikumi.Tool.Builtin.Web.SearchReq
+ Shikumi.Tool.Builtin.Web: webFetchTool :: WebClient -> Tool FetchReq FetchResult
+ Shikumi.Tool.Builtin.Web: webSearchTool :: WebClient -> Tool SearchReq SearchResult
+ Shikumi.Tool.Env: DirEntry :: !Text -> !Bool -> DirEntry
+ Shikumi.Tool.Env: ExecRequest :: !Text -> !Maybe Path -> !Maybe Text -> !Maybe Int -> ExecRequest
+ Shikumi.Tool.Env: ExecResult :: !Int -> !Text -> !Text -> ExecResult
+ Shikumi.Tool.Env: FileStat :: !Bool -> !Bool -> !Integer -> FileStat
+ Shikumi.Tool.Env: ToolEnv :: (forall (es :: [Effect]). EnvRow es => ExecRequest -> Eff es ExecResult) -> (forall (es :: [Effect]). EnvRow es => Path -> Eff es ByteString) -> (forall (es :: [Effect]). EnvRow es => Path -> ByteString -> Eff es ()) -> (forall (es :: [Effect]). EnvRow es => Path -> Eff es (Maybe FileStat)) -> (forall (es :: [Effect]). EnvRow es => Path -> Eff es [DirEntry]) -> (forall (es :: [Effect]). EnvRow es => Path -> Eff es Bool) -> (forall (es :: [Effect]). EnvRow es => Path -> Eff es ()) -> (forall (es :: [Effect]). EnvRow es => Path -> Eff es ()) -> (forall (es :: [Effect]). EnvRow es => Eff es Path) -> ToolEnv
+ Shikumi.Tool.Env: [command] :: ExecRequest -> !Text
+ Shikumi.Tool.Env: [cwd] :: ExecRequest -> !Maybe Path
+ Shikumi.Tool.Env: [envCwd] :: ToolEnv -> forall (es :: [Effect]). EnvRow es => Eff es Path
+ Shikumi.Tool.Env: [envExec] :: ToolEnv -> forall (es :: [Effect]). EnvRow es => ExecRequest -> Eff es ExecResult
+ Shikumi.Tool.Env: [envExists] :: ToolEnv -> forall (es :: [Effect]). EnvRow es => Path -> Eff es Bool
+ Shikumi.Tool.Env: [envMkdir] :: ToolEnv -> forall (es :: [Effect]). EnvRow es => Path -> Eff es ()
+ Shikumi.Tool.Env: [envReadFile] :: ToolEnv -> forall (es :: [Effect]). EnvRow es => Path -> Eff es ByteString
+ Shikumi.Tool.Env: [envReaddir] :: ToolEnv -> forall (es :: [Effect]). EnvRow es => Path -> Eff es [DirEntry]
+ Shikumi.Tool.Env: [envRm] :: ToolEnv -> forall (es :: [Effect]). EnvRow es => Path -> Eff es ()
+ Shikumi.Tool.Env: [envStat] :: ToolEnv -> forall (es :: [Effect]). EnvRow es => Path -> Eff es (Maybe FileStat)
+ Shikumi.Tool.Env: [envWriteFile] :: ToolEnv -> forall (es :: [Effect]). EnvRow es => Path -> ByteString -> Eff es ()
+ Shikumi.Tool.Env: [exitCode] :: ExecResult -> !Int
+ Shikumi.Tool.Env: [isDir] :: DirEntry -> !Bool
+ Shikumi.Tool.Env: [isFile] :: FileStat -> !Bool
+ Shikumi.Tool.Env: [name] :: DirEntry -> !Text
+ Shikumi.Tool.Env: [size] :: FileStat -> !Integer
+ Shikumi.Tool.Env: [stderr] :: ExecResult -> !Text
+ Shikumi.Tool.Env: [stdin] :: ExecRequest -> !Maybe Text
+ Shikumi.Tool.Env: [stdout] :: ExecResult -> !Text
+ Shikumi.Tool.Env: [timeoutMs] :: ExecRequest -> !Maybe Int
+ Shikumi.Tool.Env: data DirEntry
+ Shikumi.Tool.Env: data ExecRequest
+ Shikumi.Tool.Env: data ExecResult
+ Shikumi.Tool.Env: data FileStat
+ Shikumi.Tool.Env: data ToolEnv
+ Shikumi.Tool.Env: instance Data.Aeson.Types.ToJSON.ToJSON Shikumi.Tool.Env.DirEntry
+ Shikumi.Tool.Env: instance Data.Aeson.Types.ToJSON.ToJSON Shikumi.Tool.Env.ExecRequest
+ Shikumi.Tool.Env: instance Data.Aeson.Types.ToJSON.ToJSON Shikumi.Tool.Env.ExecResult
+ Shikumi.Tool.Env: instance Data.Aeson.Types.ToJSON.ToJSON Shikumi.Tool.Env.FileStat
+ Shikumi.Tool.Env: instance GHC.Classes.Eq Shikumi.Tool.Env.DirEntry
+ Shikumi.Tool.Env: instance GHC.Classes.Eq Shikumi.Tool.Env.ExecRequest
+ Shikumi.Tool.Env: instance GHC.Classes.Eq Shikumi.Tool.Env.ExecResult
+ Shikumi.Tool.Env: instance GHC.Classes.Eq Shikumi.Tool.Env.FileStat
+ Shikumi.Tool.Env: instance GHC.Internal.Generics.Generic Shikumi.Tool.Env.DirEntry
+ Shikumi.Tool.Env: instance GHC.Internal.Generics.Generic Shikumi.Tool.Env.ExecRequest
+ Shikumi.Tool.Env: instance GHC.Internal.Generics.Generic Shikumi.Tool.Env.ExecResult
+ Shikumi.Tool.Env: instance GHC.Internal.Generics.Generic Shikumi.Tool.Env.FileStat
+ Shikumi.Tool.Env: instance GHC.Internal.Show.Show Shikumi.Tool.Env.DirEntry
+ Shikumi.Tool.Env: instance GHC.Internal.Show.Show Shikumi.Tool.Env.ExecRequest
+ Shikumi.Tool.Env: instance GHC.Internal.Show.Show Shikumi.Tool.Env.ExecResult
+ Shikumi.Tool.Env: instance GHC.Internal.Show.Show Shikumi.Tool.Env.FileStat
+ Shikumi.Tool.Env: localToolEnv :: ToolEnv
+ Shikumi.Tool.Env: type EnvRow (es :: [Effect]) = (LLM :> es, Error ShikumiError :> es)
+ Shikumi.Tool.Env: type Path = Text
+ Shikumi.Tool.Web: FetchResult :: !Int -> !Text -> !Text -> !Bool -> FetchResult
+ Shikumi.Tool.Web: SearchConfig :: !Text -> !Text -> SearchConfig
+ Shikumi.Tool.Web: SearchHit :: !Text -> !Text -> !Text -> SearchHit
+ Shikumi.Tool.Web: SearchResult :: [SearchHit] -> SearchResult
+ Shikumi.Tool.Web: WebClient :: (forall (es :: [Effect]). EnvRow es => Text -> Maybe Int -> Eff es FetchResult) -> (forall (es :: [Effect]). EnvRow es => Text -> Maybe Int -> Eff es SearchResult) -> WebClient
+ Shikumi.Tool.Web: [apiKey] :: SearchConfig -> !Text
+ Shikumi.Tool.Web: [baseUrl] :: SearchConfig -> !Text
+ Shikumi.Tool.Web: [body] :: FetchResult -> !Text
+ Shikumi.Tool.Web: [contentType] :: FetchResult -> !Text
+ Shikumi.Tool.Web: [hits] :: SearchResult -> [SearchHit]
+ Shikumi.Tool.Web: [snippet] :: SearchHit -> !Text
+ Shikumi.Tool.Web: [status] :: FetchResult -> !Int
+ Shikumi.Tool.Web: [title] :: SearchHit -> !Text
+ Shikumi.Tool.Web: [truncated] :: FetchResult -> !Bool
+ Shikumi.Tool.Web: [url] :: SearchHit -> !Text
+ Shikumi.Tool.Web: [webFetch] :: WebClient -> forall (es :: [Effect]). EnvRow es => Text -> Maybe Int -> Eff es FetchResult
+ Shikumi.Tool.Web: [webSearch] :: WebClient -> forall (es :: [Effect]). EnvRow es => Text -> Maybe Int -> Eff es SearchResult
+ Shikumi.Tool.Web: data FetchResult
+ Shikumi.Tool.Web: data Manager
+ Shikumi.Tool.Web: data SearchConfig
+ Shikumi.Tool.Web: data SearchHit
+ Shikumi.Tool.Web: data WebClient
+ Shikumi.Tool.Web: instance Data.Aeson.Types.FromJSON.FromJSON Shikumi.Tool.Web.FetchResult
+ Shikumi.Tool.Web: instance Data.Aeson.Types.FromJSON.FromJSON Shikumi.Tool.Web.SearchHit
+ Shikumi.Tool.Web: instance Data.Aeson.Types.FromJSON.FromJSON Shikumi.Tool.Web.SearchResult
+ Shikumi.Tool.Web: instance Data.Aeson.Types.ToJSON.ToJSON Shikumi.Tool.Web.FetchResult
+ Shikumi.Tool.Web: instance Data.Aeson.Types.ToJSON.ToJSON Shikumi.Tool.Web.SearchConfig
+ Shikumi.Tool.Web: instance Data.Aeson.Types.ToJSON.ToJSON Shikumi.Tool.Web.SearchHit
+ Shikumi.Tool.Web: instance Data.Aeson.Types.ToJSON.ToJSON Shikumi.Tool.Web.SearchResult
+ Shikumi.Tool.Web: instance GHC.Classes.Eq Shikumi.Tool.Web.FetchResult
+ Shikumi.Tool.Web: instance GHC.Classes.Eq Shikumi.Tool.Web.SearchConfig
+ Shikumi.Tool.Web: instance GHC.Classes.Eq Shikumi.Tool.Web.SearchHit
+ Shikumi.Tool.Web: instance GHC.Classes.Eq Shikumi.Tool.Web.SearchResult
+ Shikumi.Tool.Web: instance GHC.Internal.Generics.Generic Shikumi.Tool.Web.FetchResult
+ Shikumi.Tool.Web: instance GHC.Internal.Generics.Generic Shikumi.Tool.Web.SearchConfig
+ Shikumi.Tool.Web: instance GHC.Internal.Generics.Generic Shikumi.Tool.Web.SearchHit
+ Shikumi.Tool.Web: instance GHC.Internal.Generics.Generic Shikumi.Tool.Web.SearchResult
+ Shikumi.Tool.Web: instance GHC.Internal.Show.Show Shikumi.Tool.Web.FetchResult
+ Shikumi.Tool.Web: instance GHC.Internal.Show.Show Shikumi.Tool.Web.SearchConfig
+ Shikumi.Tool.Web: instance GHC.Internal.Show.Show Shikumi.Tool.Web.SearchHit
+ Shikumi.Tool.Web: instance GHC.Internal.Show.Show Shikumi.Tool.Web.SearchResult
+ Shikumi.Tool.Web: localWebClient :: Manager -> Maybe SearchConfig -> WebClient
+ Shikumi.Tool.Web: newTlsManager :: IO Manager
+ Shikumi.Tool.Web: newtype SearchResult
- Shikumi.Agent.ReAct: ProtocolImpl :: (i -> Trajectory -> (Context, Options)) -> (Response -> Either Text (Text, Action)) -> (i -> Trajectory -> (Context, Options)) -> (Response -> Either ShikumiError o) -> ProtocolImpl i o
+ Shikumi.Agent.ReAct: ProtocolImpl :: !i -> Trajectory -> (Context, Options) -> !Response -> Either Text (Text, Action) -> !i -> Trajectory -> (Context, Options) -> !Response -> Either ShikumiError o -> ProtocolImpl i o
- Shikumi.Agent.ReAct: ReActConfig :: !Int -> !ToolProtocol -> ReActConfig
+ Shikumi.Agent.ReAct: ReActConfig :: !Int -> !ToolProtocol -> !CompactionConfig -> ReActConfig
- Shikumi.Agent.ReAct: [parseExtract] :: ProtocolImpl i o -> Response -> Either ShikumiError o
+ Shikumi.Agent.ReAct: [parseExtract] :: ProtocolImpl i o -> !Response -> Either ShikumiError o
- Shikumi.Agent.ReAct: [parsePropose] :: ProtocolImpl i o -> Response -> Either Text (Text, Action)
+ Shikumi.Agent.ReAct: [parsePropose] :: ProtocolImpl i o -> !Response -> Either Text (Text, Action)
- Shikumi.Agent.ReAct: [renderExtract] :: ProtocolImpl i o -> i -> Trajectory -> (Context, Options)
+ Shikumi.Agent.ReAct: [renderExtract] :: ProtocolImpl i o -> !i -> Trajectory -> (Context, Options)
- Shikumi.Agent.ReAct: [renderPropose] :: ProtocolImpl i o -> i -> Trajectory -> (Context, Options)
+ Shikumi.Agent.ReAct: [renderPropose] :: ProtocolImpl i o -> !i -> Trajectory -> (Context, Options)

Files

CHANGELOG.md view
@@ -2,6 +2,22 @@  ## Unreleased +## 0.2.0.0 - 2026-06-28++### Added++- Built-in filesystem, shell, and web tool modules, plus helpers for assembling+  built-in tool registries.+- `Shikumi.Tool.Env`, an execution-environment record for filesystem and process+  backed tools.+- `Shikumi.Tool.Web`, a swappable HTTP/search client layer for web tools.++### Changed++- `ReActConfig` now includes a `compaction` field, and ReAct agents compact older+  trajectory steps when usage approaches the model context window.+- Refreshed the internal `shikumi` bound for the `0.2` series.+ ## 0.1.0.1 - 2026-06-21  ### Changed
shikumi-tools.cabal view
@@ -1,6 +1,6 @@ cabal-version:   3.4 name:            shikumi-tools-version:         0.1.0.1+version:         0.2.0.0 synopsis:   Typed tools and ReAct agents for shikumi LM programs (EP-11) @@ -49,18 +49,31 @@     Shikumi.CodeExec.ProgramOfThought     Shikumi.CodeExec.Prompt     Shikumi.Tool+    Shikumi.Tool.Builtin+    Shikumi.Tool.Builtin.Fs+    Shikumi.Tool.Builtin.Shell+    Shikumi.Tool.Builtin.Web+    Shikumi.Tool.Env+    Shikumi.Tool.Web    build-depends:     , aeson-    , baikai        >=0.2      && <0.3-    , base          >=4.20     && <5+    , baikai           >=0.2      && <0.3+    , base             >=4.20     && <5     , bytestring     , containers+    , directory     , effectful+    , filepath     , generic-lens-    , lens          ^>=5.3-    , shikumi       ^>=0.1.0.1-    , text          ^>=2.1+    , http-client+    , http-client-tls+    , http-types+    , lens             ^>=5.3+    , process+    , regex-tdfa+    , shikumi          ^>=0.2.0.0+    , text             ^>=2.1     , vector  test-suite shikumi-tools-test@@ -71,26 +84,35 @@   ghc-options:    -threaded -with-rtsopts=-N   other-modules:     AcceptanceSpec+    BuiltinAcceptanceSpec     CodeActSpec+    CompactionSpec+    EnvSpec     Fixtures+    FsSpec     MockLLM     ProgramOfThoughtSpec     ProtocolSpec     ReActSpec     RestrictedSpec     SchemaSpec+    ShellSpec     ToolSpec+    WebSpec    build-depends:     , aeson     , baikai         >=0.2      && <0.3     , base+    , bytestring     , containers+    , directory     , effectful+    , filepath     , generic-lens     , lens-    , shikumi        ^>=0.1.0.1-    , shikumi-tools  ^>=0.1.0.1+    , shikumi        ^>=0.2.0.0+    , shikumi-tools  ^>=0.2.0.0     , tasty     , tasty-hunit     , text
src/Shikumi/Agent/ReAct.hs view
@@ -58,6 +58,7 @@     Tool,     ToolCall,     ToolChoice (..),+    Usage,     flattenAssistantBlocks,     user,     _Context,@@ -77,9 +78,10 @@ import Data.Vector (Vector) import Data.Vector qualified as V import Effectful (Eff, (:>))-import Effectful.Error.Static (Error, throwError)+import Effectful.Error.Static (Error, catchError, throwError) import GHC.Generics (Generic) import Shikumi.Adapter (ModelCapability (..), ToPrompt (toPrompt), attachSchema, capabilityFor)+import Shikumi.Compaction (CompactionConfig, compactTail, defaultCompactionConfig, usageExceedsWindow) import Shikumi.Error (ShikumiError (..)) import Shikumi.LLM (LLM, complete) import Shikumi.Program (Program (FMap), embed)@@ -139,13 +141,19 @@ -- | How an agent runs: the hard iteration cap and the protocol selector. data ReActConfig = ReActConfig   { maxIters :: !Int,-    protocol :: !ToolProtocol+    protocol :: !ToolProtocol,+    compaction :: !CompactionConfig   }   deriving stock (Show, Eq, Generic)  -- | Six iterations, protocol auto-selected. defaultReActConfig :: ReActConfig-defaultReActConfig = ReActConfig {maxIters = 6, protocol = ProtocolAuto}+defaultReActConfig =+  ReActConfig+    { maxIters = 6,+      protocol = ProtocolAuto,+      compaction = defaultCompactionConfig+    }  -- --------------------------------------------------------------------------- -- Building agents@@ -184,8 +192,8 @@   i ->   Eff es (o, Trajectory) reactLoop sig reg cfg i = do-  traj <- loop 0 []-  o <- extract traj+  traj0 <- loop 0 []+  (o, traj) <- extract traj0   pure (o, traj)   where     impl :: ProtocolImpl i o@@ -197,25 +205,63 @@       | iter >= maxIters cfg =           pure (Trajectory (V.fromList (reverse acc)) (TerminatedMaxIters iter))       | otherwise = do-          let (ctx, opts) = renderPropose impl i (soFar acc)-          resp <- complete _Model ctx opts+          (accForPrompt, resp) <- completeProposeRecover acc           case parsePropose impl resp of-            Left perr -> loop (iter + 1) (correctiveStep perr : acc)+            Left perr -> loop (iter + 1) (correctiveStep perr : accForPrompt)             Right (th, Finish) ->-              pure (Trajectory (V.fromList (reverse (Step th Finish Nothing : acc))) TerminatedFinish)+              pure (Trajectory (V.fromList (reverse (Step th Finish Nothing : accForPrompt))) TerminatedFinish)             Right (th, CallTool nm args) -> do               res <- runToolCall reg (mkToolCall nm args)               let obs = either renderToolError id res-              loop (iter + 1) (Step th (CallTool nm args) (Just obs) : acc)+                  acc' = Step th (CallTool nm args) (Just obs) : accForPrompt+              acc'' <- compactAcc (resp ^. #model) (resp ^. #message . #usage) acc'+              loop (iter + 1) acc''      -- The final extract call: render, issue, decode into @o@ (a decode failure     -- here is the agent's final-answer failure, surfaced as a 'ShikumiError').-    extract :: Trajectory -> Eff es o+    extract :: Trajectory -> Eff es (o, Trajectory)     extract traj = do       let (ctx, opts) = renderExtract impl i traj-      resp <- complete _Model ctx opts-      either throwError pure (parseExtract impl resp)+      (trajForExtract, resp) <-+        catchError+          ((traj,) <$> complete _Model ctx opts)+          ( \_cs -> \case+              ContextWindowExceeded {} -> do+                compacted <- forceCompactTrajectory traj+                let (ctx', opts') = renderExtract impl i compacted+                (compacted,) <$> complete _Model ctx' opts'+              e -> throwError e+          )+      o <- either throwError pure (parseExtract impl resp)+      pure (o, trajForExtract) +    completeProposeRecover :: [Step] -> Eff es ([Step], Response)+    completeProposeRecover acc = do+      let (ctx, opts) = renderPropose impl i (soFar acc)+      catchError+        ((acc,) <$> complete _Model ctx opts)+        ( \_cs -> \case+            ContextWindowExceeded {} -> do+              compacted <- forceCompactAcc acc+              let (ctx', opts') = renderPropose impl i (soFar compacted)+              (compacted,) <$> complete _Model ctx' opts'+            e -> throwError e+        )++    compactAcc :: Model -> Usage -> [Step] -> Eff es [Step]+    compactAcc model usage acc+      | usageExceedsWindow (compaction cfg) model usage = forceCompactAcc acc+      | otherwise = pure acc++    forceCompactAcc :: [Step] -> Eff es [Step]+    forceCompactAcc acc =+      reverse <$> compactTail (compaction cfg) _Model renderStepLine summaryStep (reverse acc)++    forceCompactTrajectory :: Trajectory -> Eff es Trajectory+    forceCompactTrajectory traj = do+      compacted <- compactTail (compaction cfg) _Model renderStepLine summaryStep (V.toList (steps traj))+      pure (traj {steps = V.fromList compacted})+     -- A trajectory view of the steps gathered so far (termination is irrelevant     -- for the propose render).     soFar :: [Step] -> Trajectory@@ -233,6 +279,17 @@         Just ("Your previous reply was not a valid action JSON object: " <> perr <> ". Reply with exactly one action object.")     } +summaryStep :: Text -> Step+summaryStep summaryText =+  Step+    { thought = "(compacted summary of earlier steps)",+      action = CallTool "" Null,+      observation = Just summaryText+    }++renderStepLine :: Step -> Text+renderStepLine s = renderTrajectory (Trajectory (V.singleton s) TerminatedFinish)+ -- | A baikai 'ToolCall' built from a name and a raw arguments object (the call id -- is irrelevant on the prompt path and synthesized on the native path). mkToolCall :: Text -> Value -> ToolCall@@ -246,10 +303,10 @@ -- and the prompt implementations satisfy this interface, so the loop body is -- identical under either seam. data ProtocolImpl i o = ProtocolImpl-  { renderPropose :: i -> Trajectory -> (Context, Options),-    parsePropose :: Response -> Either Text (Text, Action),-    renderExtract :: i -> Trajectory -> (Context, Options),-    parseExtract :: Response -> Either ShikumiError o+  { renderPropose :: !(i -> Trajectory -> (Context, Options)),+    parsePropose :: !(Response -> Either Text (Text, Action)),+    renderExtract :: !(i -> Trajectory -> (Context, Options)),+    parseExtract :: !(Response -> Either ShikumiError o)   }  -- | Resolve a (possibly 'ProtocolAuto') selection to a concrete kind for a model.
src/Shikumi/Tool.hs view
@@ -223,5 +223,6 @@   SchemaMismatch t -> t   ValidationFailure t -> t   ProviderFailure t -> t+  ContextWindowExceeded t -> t   Timeout t -> t   BudgetExceeded t -> t
+ src/Shikumi/Tool/Builtin.hs view
@@ -0,0 +1,40 @@+-- | Built-in work-tool catalog.+module Shikumi.Tool.Builtin+  ( builtinFsTools,+    builtinWebTools,+    builtinTools,+    builtinRegistry,+    module Shikumi.Tool.Builtin.Fs,+    module Shikumi.Tool.Builtin.Shell,+    module Shikumi.Tool.Builtin.Web,+  )+where++import Shikumi.Tool (SomeTool (..), ToolRegistry, mkRegistry)+import Shikumi.Tool.Builtin.Fs+import Shikumi.Tool.Builtin.Shell+import Shikumi.Tool.Builtin.Web+import Shikumi.Tool.Env (ToolEnv)+import Shikumi.Tool.Web (WebClient)++builtinFsTools :: ToolEnv -> [SomeTool]+builtinFsTools env =+  [ SomeTool (readTool env),+    SomeTool (writeTool env),+    SomeTool (editTool env),+    SomeTool (grepTool env),+    SomeTool (bashTool env),+    SomeTool (globTool env)+  ]++builtinWebTools :: WebClient -> [SomeTool]+builtinWebTools client =+  [ SomeTool (webFetchTool client),+    SomeTool (webSearchTool client)+  ]++builtinTools :: ToolEnv -> WebClient -> [SomeTool]+builtinTools env client = builtinFsTools env <> builtinWebTools client++builtinRegistry :: ToolEnv -> WebClient -> ToolRegistry+builtinRegistry env client = mkRegistry (builtinTools env client)
+ src/Shikumi/Tool/Builtin/Fs.hs view
@@ -0,0 +1,474 @@+{-# LANGUAGE RankNTypes #-}++-- | Built-in filesystem tools over 'ToolEnv'.+--+-- The search tools prefer host-provided @rg@/@fd@ through 'ToolEnv.exec' when+-- available, and fall back to a bounded in-process traversal through the same+-- environment record. The fallback skips noisy directories, binary files, and+-- large files, and caps traversal depth and result count.+module Shikumi.Tool.Builtin.Fs+  ( ReadReq (..),+    ReadResp (..),+    WriteReq (..),+    WriteResp (..),+    EditReq (..),+    EditResp (..),+    GrepReq (..),+    GrepMatch (..),+    GrepResp (..),+    GlobReq (..),+    GlobResp (..),+    readTool,+    writeTool,+    editTool,+    grepTool,+    globTool,+  )+where++import Control.Lens ((^.))+import Data.Aeson (ToJSON, Value (..), eitherDecodeStrict, object, (.=))+import Data.Aeson.Key qualified as Key+import Data.Aeson.KeyMap qualified as KM+import Data.Aeson.Types (parseJSON, parseMaybe)+import Data.ByteString qualified as BS+import Data.Char (isAlphaNum)+import Data.Generics.Labels ()+import Data.List (sort)+import Data.Maybe (mapMaybe)+import Data.Proxy (Proxy (..))+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.Encoding qualified as TE+import Data.Text.Encoding.Error (lenientDecode)+import Effectful (Eff)+import Effectful.Error.Static (throwError)+import GHC.Generics (Generic)+import Shikumi.Adapter (ToPrompt)+import Shikumi.Error (ShikumiError (..))+import Shikumi.Schema (FromModel (..), ToSchema (..), fromModel)+import Shikumi.Tool (Tool, mkTool)+import Shikumi.Tool.Env+  ( EnvRow,+    ExecRequest (..),+    ToolEnv (..),+  )+import Text.Regex.TDFA+  ( CompOption (..),+    Regex,+    defaultCompOpt,+    defaultExecOpt,+    makeRegexOptsM,+    matchTest,+    (=~),+  )++data ReadReq = ReadReq+  { path :: !Text,+    offset :: !(Maybe Int),+    limit :: !(Maybe Int)+  }+  deriving stock (Generic, Show, Eq)+  deriving anyclass (ToSchema, FromModel, ToPrompt)++data ReadResp = ReadResp+  { content :: !Text,+    lineCount :: !Int,+    truncated :: !Bool+  }+  deriving stock (Generic, Show, Eq)+  deriving anyclass (ToJSON)++data WriteReq = WriteReq+  { path :: !Text,+    content :: !Text+  }+  deriving stock (Generic, Show, Eq)+  deriving anyclass (ToSchema, FromModel, ToPrompt)++data WriteResp = WriteResp+  { path :: !Text,+    bytesWritten :: !Int+  }+  deriving stock (Generic, Show, Eq)+  deriving anyclass (ToJSON)++data EditReq = EditReq+  { path :: !Text,+    oldString :: !Text,+    newString :: !Text,+    replaceAll :: !(Maybe Bool)+  }+  deriving stock (Generic, Show, Eq)+  deriving anyclass (ToSchema, FromModel, ToPrompt)++data EditResp = EditResp+  { path :: !Text,+    replacements :: !Int+  }+  deriving stock (Generic, Show, Eq)+  deriving anyclass (ToJSON)++data GrepReq = GrepReq+  { patternText :: !Text,+    path :: !(Maybe Text),+    glob :: !(Maybe Text),+    ignoreCase :: !(Maybe Bool)+  }+  deriving stock (Generic, Show, Eq)+  deriving anyclass (ToPrompt)++instance ToSchema GrepReq where+  toSchema _ =+    toolInputSchema+      [ ("pattern", toSchema (Proxy :: Proxy Text)),+        ("path", toSchema (Proxy :: Proxy (Maybe Text))),+        ("glob", toSchema (Proxy :: Proxy (Maybe Text))),+        ("ignoreCase", toSchema (Proxy :: Proxy (Maybe Bool)))+      ]++instance FromModel GrepReq where+  fromModelP _ = \case+    Object obj ->+      GrepReq+        <$> requiredField "pattern" obj+        <*> optionalField "path" obj+        <*> optionalField "glob" obj+        <*> optionalField "ignoreCase" obj+    v -> Left (SchemaMismatch ("grep request must be an object, got " <> T.pack (show v)))++data GrepMatch = GrepMatch+  { file :: !Text,+    line :: !Int,+    text :: !Text+  }+  deriving stock (Generic, Show, Eq, Ord)+  deriving anyclass (ToJSON)++data GrepResp = GrepResp+  { matches :: ![GrepMatch],+    truncated :: !Bool+  }+  deriving stock (Generic, Show, Eq)+  deriving anyclass (ToJSON)++data GlobReq = GlobReq+  { patternText :: !Text,+    path :: !(Maybe Text)+  }+  deriving stock (Generic, Show, Eq)+  deriving anyclass (ToPrompt)++instance ToSchema GlobReq where+  toSchema _ =+    toolInputSchema+      [ ("pattern", toSchema (Proxy :: Proxy Text)),+        ("path", toSchema (Proxy :: Proxy (Maybe Text)))+      ]++instance FromModel GlobReq where+  fromModelP _ = \case+    Object obj ->+      GlobReq+        <$> requiredField "pattern" obj+        <*> optionalField "path" obj+    v -> Left (SchemaMismatch ("glob request must be an object, got " <> T.pack (show v)))++data GlobResp = GlobResp+  { paths :: ![Text],+    truncated :: !Bool+  }+  deriving stock (Generic, Show, Eq)+  deriving anyclass (ToJSON)++readTool :: ToolEnv -> Tool ReadReq ReadResp+readTool env =+  mkTool "read" "Read a file's contents." $ \req -> do+    bs <- envReadFile env (req ^. #path)+    let allLines = T.lines (decodeUtf8 bs)+        start = max 0 (maybe 0 id (req ^. #offset))+        afterOffset = drop start allLines+        selected = maybe afterOffset (`take` afterOffset) (req ^. #limit)+        wasTruncated = start > 0 || length selected < length afterOffset+    pure ReadResp {content = T.unlines selected, lineCount = length selected, truncated = wasTruncated}++writeTool :: ToolEnv -> Tool WriteReq WriteResp+writeTool env =+  mkTool "write" "Write UTF-8 text to a file." $ \req -> do+    let bytes = TE.encodeUtf8 (req ^. #content)+    envWriteFile env (req ^. #path) bytes+    pure WriteResp {path = req ^. #path, bytesWritten = BS.length bytes}++editTool :: ToolEnv -> Tool EditReq EditResp+editTool env =+  mkTool "edit" "Replace text in a UTF-8 file." $ \req -> do+    let old = req ^. #oldString+    if T.null old+      then throwValidation "edit: oldString must not be empty"+      else do+        original <- decodeUtf8 <$> envReadFile env (req ^. #path)+        (edited, count) <-+          if maybe False id (req ^. #replaceAll)+            then pure (replaceAllCount old (req ^. #newString) original)+            else pure (replaceFirstCount old (req ^. #newString) original)+        if count == 0+          then throwValidation "edit: oldString not found"+          else do+            envWriteFile env (req ^. #path) (TE.encodeUtf8 edited)+            pure EditResp {path = req ^. #path, replacements = count}++grepTool :: ToolEnv -> Tool GrepReq GrepResp+grepTool env =+  mkTool "grep" "Search files with a regex and return structured matches." $ \req -> do+    root <- maybe (envCwd env) pure (req ^. #path)+    fast <- commandPresent env "rg"+    if fast+      then do+        resp <- grepFast env root req+        case resp of+          Just value -> pure value+          Nothing -> grepFallback env root req+      else grepFallback env root req++globTool :: ToolEnv -> Tool GlobReq GlobResp+globTool env =+  mkTool "glob" "Find files matching a glob pattern and return structured paths." $ \req -> do+    root <- maybe (envCwd env) pure (req ^. #path)+    fast <- commandPresent env "fd"+    let pat = req ^. #patternText+    if fast+      then do+        resp <- globFast env root pat+        case resp of+          Just value -> pure value+          Nothing -> globFallback env root pat+      else globFallback env root pat++grepFast :: (EnvRow es) => ToolEnv -> Text -> GrepReq -> Eff es (Maybe GrepResp)+grepFast env root req = do+  result <-+    envExec+      env+      ExecRequest+        { command =+            T.unwords+              [ "rg --json --color never --max-filesize 5M",+                if maybe False id (req ^. #ignoreCase) then "-i" else "",+                maybe "" (\g -> "-g " <> shellQuote g) (req ^. #glob),+                "--",+                shellQuote (req ^. #patternText),+                shellQuote root+              ],+          cwd = Nothing,+          stdin = Nothing,+          timeoutMs = Just 30000+        }+  if result ^. #exitCode == 0 || result ^. #exitCode == 1+    then pure (Just (parseRgJson (result ^. #stdout)))+    else pure Nothing++globFast :: (EnvRow es) => ToolEnv -> Text -> Text -> Eff es (Maybe GlobResp)+globFast env root pat = do+  result <-+    envExec+      env+      ExecRequest+        { command =+            T.unwords+              [ "fd --glob --color never --exclude .git --exclude node_modules --exclude dist-newstyle --exclude .stack-work",+                "--",+                shellQuote pat,+                shellQuote root+              ],+          cwd = Nothing,+          stdin = Nothing,+          timeoutMs = Just 30000+        }+  if result ^. #exitCode == 0+    then do+      let found = take maxResults (filter (not . T.null) (T.lines (result ^. #stdout)))+      pure (Just GlobResp {paths = sort found, truncated = length found >= maxResults})+    else pure Nothing++grepFallback :: (EnvRow es) => ToolEnv -> Text -> GrepReq -> Eff es GrepResp+grepFallback env root req = do+  regex <- compileRegex (req ^. #patternText) (maybe False id (req ^. #ignoreCase))+  paths <- walkFiles env root+  let globOk p = maybe True (`globMatches` p) (req ^. #glob)+  matches <-+    fmap concat $+      traverse+        ( \filePath -> do+            if globOk filePath+              then grepFile env regex filePath+              else pure []+        )+        paths+  let limited = take maxResults matches+  pure GrepResp {matches = limited, truncated = length matches > maxResults}++globFallback :: (EnvRow es) => ToolEnv -> Text -> Text -> Eff es GlobResp+globFallback env root pat = do+  paths <- walkFiles env root+  let found = sort (take maxResults (filter (globMatches pat) paths))+  pure GlobResp {paths = found, truncated = length found >= maxResults}++grepFile :: (EnvRow es) => ToolEnv -> Regex -> Text -> Eff es [GrepMatch]+grepFile env regex filePath = do+  stat <- envStat env filePath+  case stat of+    Just s | s ^. #isFile && s ^. #size <= maxFileSize -> do+      bs <- envReadFile env filePath+      if isBinary bs+        then pure []+        else do+          let lineMatches = regexMatches regex+          pure+            [ GrepMatch {file = filePath, line = n, text = lineText}+            | (n, lineText) <- zip [1 ..] (T.lines (decodeUtf8 bs)),+              lineMatches lineText+            ]+    _ -> pure []++walkFiles :: (EnvRow es) => ToolEnv -> Text -> Eff es [Text]+walkFiles env root = go 0 root+  where+    go depth dir+      | depth > maxDepth = pure []+      | otherwise = do+          entries <- envReaddir env dir+          fmap concat $+            traverse+              ( \entry -> do+                  let child = joinPathText dir (entry ^. #name)+                  if entry ^. #isDir+                    then+                      if skipDirName (entry ^. #name)+                        then pure []+                        else go (depth + 1) child+                    else pure [child]+              )+              entries++parseRgJson :: Text -> GrepResp+parseRgJson output =+  let matches = take maxResults (mapMaybe parseLine (T.lines output))+   in GrepResp {matches, truncated = length matches >= maxResults}+  where+    parseLine line = do+      Object obj <- either (const Nothing) Just (eitherDecodeStrict (TE.encodeUtf8 line))+      String "match" <- KM.lookup "type" obj+      Object dat <- KM.lookup "data" obj+      Object pathObj <- KM.lookup "path" dat+      String file <- KM.lookup "text" pathObj+      Object linesObj <- KM.lookup "lines" dat+      String text <- KM.lookup "text" linesObj+      Number lineNo <- KM.lookup "line_number" dat+      n <- parseMaybe parseJSON (Number lineNo)+      pure GrepMatch {file, line = n, text = T.dropWhileEnd (== '\n') text}++compileRegex :: (EnvRow es) => Text -> Bool -> Eff es Regex+compileRegex pat ignoreCase =+  case makeRegexOptsM defaultCompOpt {caseSensitive = not ignoreCase} defaultExecOpt (T.unpack pat) of+    Just regex -> pure regex+    Nothing -> throwValidation ("grep: invalid regex pattern " <> pat)++regexMatches :: Regex -> Text -> Bool+regexMatches regex haystack = matchTest regex (T.unpack haystack)++globMatches :: Text -> Text -> Bool+globMatches pat candidate =+  T.unpack candidate =~ ("^" <> T.unpack (globToRegex pat) <> "$" :: String)++globToRegex :: Text -> Text+globToRegex = go . T.unpack+  where+    go [] = ""+    go ('*' : '*' : rest) = ".*" <> go rest+    go ('*' : rest) = "[^/]*" <> go rest+    go ('?' : rest) = "[^/]" <> go rest+    go (c : rest) = T.pack (escapeRegex c) <> go rest++replaceFirstCount :: Text -> Text -> Text -> (Text, Int)+replaceFirstCount old new src =+  let (before, after) = T.breakOn old src+   in if T.null after+        then (src, 0)+        else (before <> new <> T.drop (T.length old) after, 1)++replaceAllCount :: Text -> Text -> Text -> (Text, Int)+replaceAllCount old new src =+  let pieces = T.splitOn old src+      count = length pieces - 1+   in (T.intercalate new pieces, count)++commandPresent :: (EnvRow es) => ToolEnv -> Text -> Eff es Bool+commandPresent env cmd = do+  result <-+    envExec+      env+      ExecRequest+        { command = "command -v " <> cmd,+          cwd = Nothing,+          stdin = Nothing,+          timeoutMs = Just 5000+        }+  pure (result ^. #exitCode == 0)++decodeUtf8 :: BS.ByteString -> Text+decodeUtf8 = TE.decodeUtf8With lenientDecode++isBinary :: BS.ByteString -> Bool+isBinary = BS.elem 0 . BS.take 8192++skipDirName :: Text -> Bool+skipDirName name =+  name `elem` [".git", ".hg", ".svn", "node_modules", "dist-newstyle", ".stack-work", ".direnv"]++joinPathText :: Text -> Text -> Text+joinPathText base name+  | T.null base = name+  | "/" `T.isSuffixOf` base = base <> name+  | otherwise = base <> "/" <> name++shellQuote :: Text -> Text+shellQuote t = "'" <> T.replace "'" "'\\''" t <> "'"++escapeRegex :: Char -> String+escapeRegex c+  | isAlphaNum c || c == '/' || c == '_' || c == '-' = [c]+  | otherwise = ['\\', c]++throwValidation :: (EnvRow es) => Text -> Eff es a+throwValidation = throwError . ValidationFailure++toolInputSchema :: [(Text, Value)] -> Value+toolInputSchema fields =+  object+    [ "type" .= ("object" :: Text),+      "properties" .= object (map (\(key, value) -> Key.fromText key .= value) fields),+      "required" .= (["pattern"] :: [Text]),+      "additionalProperties" .= False+    ]++requiredField :: (FromModel a) => Text -> KM.KeyMap Value -> Either ShikumiError a+requiredField key obj =+  case KM.lookup (Key.fromText key) obj of+    Nothing -> Left (MissingField key)+    Just value -> fromModel value++optionalField :: (FromModel a) => Text -> KM.KeyMap Value -> Either ShikumiError (Maybe a)+optionalField key obj =+  case KM.lookup (Key.fromText key) obj of+    Nothing -> Right Nothing+    Just Null -> Right Nothing+    Just value -> Just <$> fromModel value++maxResults :: Int+maxResults = 1000++maxFileSize :: Integer+maxFileSize = 5 * 1024 * 1024++maxDepth :: Int+maxDepth = 25
+ src/Shikumi/Tool/Builtin/Shell.hs view
@@ -0,0 +1,53 @@+-- | Built-in shell tool over 'ToolEnv.exec'.+module Shikumi.Tool.Builtin.Shell+  ( BashReq (..),+    BashResp (..),+    bashTool,+  )+where++import Control.Lens ((^.))+import Data.Aeson (ToJSON)+import Data.Generics.Labels ()+import Data.Text (Text)+import GHC.Generics (Generic)+import Shikumi.Adapter (ToPrompt)+import Shikumi.Schema (FromModel, ToSchema)+import Shikumi.Tool (Tool, mkTool)+import Shikumi.Tool.Env (ExecRequest (..), ToolEnv (..))++data BashReq = BashReq+  { command :: !Text,+    cwd :: !(Maybe Text),+    timeoutMs :: !(Maybe Int),+    stdin :: !(Maybe Text)+  }+  deriving stock (Generic, Show, Eq)+  deriving anyclass (ToSchema, FromModel, ToPrompt)++data BashResp = BashResp+  { exitCode :: !Int,+    stdout :: !Text,+    stderr :: !Text+  }+  deriving stock (Generic, Show, Eq)+  deriving anyclass (ToJSON)++bashTool :: ToolEnv -> Tool BashReq BashResp+bashTool env =+  mkTool "bash" "Run a shell command and capture stdout, stderr, and the exit code." $ \req -> do+    result <-+      envExec+        env+        ExecRequest+          { command = req ^. #command,+            cwd = req ^. #cwd,+            timeoutMs = req ^. #timeoutMs,+            stdin = req ^. #stdin+          }+    pure+      BashResp+        { exitCode = result ^. #exitCode,+          stdout = result ^. #stdout,+          stderr = result ^. #stderr+        }
+ src/Shikumi/Tool/Builtin/Web.hs view
@@ -0,0 +1,41 @@+-- | Built-in web tools.+module Shikumi.Tool.Builtin.Web+  ( FetchReq (..),+    SearchReq (..),+    webFetchTool,+    webSearchTool,+  )+where++import Control.Lens ((^.))+import Data.Generics.Labels ()+import Data.Text (Text)+import GHC.Generics (Generic)+import Shikumi.Adapter (ToPrompt)+import Shikumi.Schema (FromModel, ToSchema)+import Shikumi.Tool (Tool, mkTool)+import Shikumi.Tool.Web (FetchResult, SearchResult, WebClient (..))++data FetchReq = FetchReq+  { url :: !Text,+    maxBytes :: !(Maybe Int)+  }+  deriving stock (Generic, Show, Eq)+  deriving anyclass (ToSchema, FromModel, ToPrompt)++data SearchReq = SearchReq+  { query :: !Text,+    maxResults :: !(Maybe Int)+  }+  deriving stock (Generic, Show, Eq)+  deriving anyclass (ToSchema, FromModel, ToPrompt)++webFetchTool :: WebClient -> Tool FetchReq FetchResult+webFetchTool client =+  mkTool "web_fetch" "Fetch the contents of a URL over HTTP." $ \req ->+    webFetch client (req ^. #url) (req ^. #maxBytes)++webSearchTool :: WebClient -> Tool SearchReq SearchResult+webSearchTool client =+  mkTool "web_search" "Search the web with the configured search provider." $ \req ->+    webSearch client (req ^. #query) (req ^. #maxResults)
+ src/Shikumi/Tool/Env.hs view
@@ -0,0 +1,178 @@+{-# LANGUAGE ConstraintKinds #-}+{-# LANGUAGE RankNTypes #-}++-- | Execution-environment operations for built-in work tools.+--+-- Filesystem and process-backed tools should depend on this record instead of+-- calling the host operating system directly. A future sandbox can provide a+-- different 'ToolEnv' value while the tool definitions stay unchanged.+module Shikumi.Tool.Env+  ( Path,+    EnvRow,+    ExecRequest (..),+    ExecResult (..),+    FileStat (..),+    DirEntry (..),+    ToolEnv (..),+    localToolEnv,+  )+where++import Control.Exception (IOException, try)+import Control.Lens ((^.))+import Data.Aeson (ToJSON)+import Data.ByteString (ByteString)+import Data.ByteString qualified as BS+import Data.Generics.Labels ()+import Data.Text (Text)+import Data.Text qualified as T+import Effectful (Eff, (:>))+import Effectful.Dispatch.Static (unsafeEff_)+import Effectful.Error.Static (Error, throwError)+import GHC.Generics (Generic)+import Shikumi.Error (ShikumiError (..))+import Shikumi.LLM (LLM)+import System.Directory qualified as Dir+import System.Exit (ExitCode (..))+import System.Process qualified as Proc+import System.Timeout qualified as Timeout++type Path = Text++type EnvRow es = (LLM :> es, Error ShikumiError :> es)++data ExecRequest = ExecRequest+  { command :: !Text,+    cwd :: !(Maybe Path),+    stdin :: !(Maybe Text),+    timeoutMs :: !(Maybe Int)+  }+  deriving stock (Generic, Show, Eq)+  deriving anyclass (ToJSON)++data ExecResult = ExecResult+  { exitCode :: !Int,+    stdout :: !Text,+    stderr :: !Text+  }+  deriving stock (Generic, Show, Eq)+  deriving anyclass (ToJSON)++data FileStat = FileStat+  { isFile :: !Bool,+    isDir :: !Bool,+    size :: !Integer+  }+  deriving stock (Generic, Show, Eq)+  deriving anyclass (ToJSON)++data DirEntry = DirEntry+  { name :: !Text,+    isDir :: !Bool+  }+  deriving stock (Generic, Show, Eq)+  deriving anyclass (ToJSON)++data ToolEnv = ToolEnv+  { envExec :: forall es. (EnvRow es) => ExecRequest -> Eff es ExecResult,+    envReadFile :: forall es. (EnvRow es) => Path -> Eff es ByteString,+    envWriteFile :: forall es. (EnvRow es) => Path -> ByteString -> Eff es (),+    envStat :: forall es. (EnvRow es) => Path -> Eff es (Maybe FileStat),+    envReaddir :: forall es. (EnvRow es) => Path -> Eff es [DirEntry],+    envExists :: forall es. (EnvRow es) => Path -> Eff es Bool,+    envMkdir :: forall es. (EnvRow es) => Path -> Eff es (),+    envRm :: forall es. (EnvRow es) => Path -> Eff es (),+    envCwd :: forall es. (EnvRow es) => Eff es Path+  }++localToolEnv :: ToolEnv+localToolEnv =+  ToolEnv+    { envExec = localExec,+      envReadFile = \path -> toolIO "readFile" (BS.readFile (T.unpack path)),+      envWriteFile = \path bytes -> toolIO "writeFile" (BS.writeFile (T.unpack path) bytes),+      envStat = localStat,+      envReaddir = localReaddir,+      envExists = \path -> toolIO "exists" (Dir.doesPathExist (T.unpack path)),+      envMkdir = \path -> toolIO "mkdir" (Dir.createDirectoryIfMissing True (T.unpack path)),+      envRm = localRm,+      envCwd = T.pack <$> toolIO "cwd" Dir.getCurrentDirectory+    }++localExec :: (EnvRow es) => ExecRequest -> Eff es ExecResult+localExec req = do+  let effectiveTimeoutMs = timeoutMsOrDefault (req ^. #timeoutMs)+  result <-+    toolIO "exec" $+      Timeout.timeout (effectiveTimeoutMs * 1000) $+        Proc.readCreateProcessWithExitCode process stdinText+  case result of+    Nothing ->+      throwError $+        Timeout+          ("tool env: exec timed out after " <> T.pack (show effectiveTimeoutMs) <> "ms")+    Just (exit, out, err) ->+      pure+        ExecResult+          { exitCode = exitCodeInt exit,+            stdout = T.pack out,+            stderr = T.pack err+          }+  where+    process =+      (Proc.proc "bash" ["-c", T.unpack (req ^. #command)])+        { Proc.cwd = T.unpack <$> (req ^. #cwd)+        }+    stdinText = maybe "" T.unpack (req ^. #stdin)++localStat :: (EnvRow es) => Path -> Eff es (Maybe FileStat)+localStat path = toolIO "stat" $ do+  let fp = T.unpack path+  file <- Dir.doesFileExist fp+  dir <- Dir.doesDirectoryExist fp+  if file+    then do+      size <- Dir.getFileSize fp+      pure (Just FileStat {isFile = True, isDir = False, size})+    else+      if dir+        then pure (Just FileStat {isFile = False, isDir = True, size = 0})+        else pure Nothing++localReaddir :: (EnvRow es) => Path -> Eff es [DirEntry]+localReaddir path = toolIO "readdir" $ do+  let dir = T.unpack path+  names <- Dir.listDirectory dir+  traverse+    ( \entry -> do+        isDir <- Dir.doesDirectoryExist (dir <> "/" <> entry)+        pure DirEntry {name = T.pack entry, isDir}+    )+    names++localRm :: (EnvRow es) => Path -> Eff es ()+localRm path = toolIO "rm" $ do+  let fp = T.unpack path+  file <- Dir.doesFileExist fp+  dir <- Dir.doesDirectoryExist fp+  if file+    then Dir.removeFile fp+    else+      if dir+        then Dir.removeDirectory fp+        else pure ()++toolIO :: (EnvRow es) => Text -> IO a -> Eff es a+toolIO label action = do+  result <- unsafeEff_ (try action)+  case result of+    Right value -> pure value+    Left (err :: IOException) ->+      throwError (ProviderFailure ("tool env: " <> label <> ": " <> T.pack (show err)))++timeoutMsOrDefault :: Maybe Int -> Int+timeoutMsOrDefault = maybe 60000 id++exitCodeInt :: ExitCode -> Int+exitCodeInt ExitSuccess = 0+exitCodeInt (ExitFailure n) = n
+ src/Shikumi/Tool/Web.hs view
@@ -0,0 +1,139 @@+{-# LANGUAGE ConstraintKinds #-}+{-# LANGUAGE RankNTypes #-}++-- | Swappable HTTP operations for the built-in web tools.+--+-- 'web_fetch' is functional with only a TLS 'Manager'. 'web_search' requires a+-- configured provider because search APIs need provider-specific credentials.+module Shikumi.Tool.Web+  ( FetchResult (..),+    SearchHit (..),+    SearchResult (..),+    SearchConfig (..),+    WebClient (..),+    localWebClient,+    newTlsManager,+    Manager,+  )+where++import Control.Exception (try)+import Control.Lens ((^.))+import Data.Aeson (FromJSON, ToJSON, eitherDecode)+import Data.ByteString.Char8 qualified as B8+import Data.ByteString.Lazy qualified as LBS+import Data.Generics.Labels ()+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.Encoding qualified as TE+import Data.Text.Encoding.Error (lenientDecode)+import Effectful (Eff)+import Effectful.Dispatch.Static (unsafeEff_)+import Effectful.Error.Static (throwError)+import GHC.Generics (Generic)+import Network.HTTP.Client+  ( HttpException,+    Manager,+    httpLbs,+    parseRequest,+    responseBody,+    responseHeaders,+    responseStatus,+    setQueryString,+  )+import Network.HTTP.Client.TLS qualified as TLS+import Network.HTTP.Types.Header (ResponseHeaders)+import Network.HTTP.Types.Status (statusCode)+import Shikumi.Error (ShikumiError (..))+import Shikumi.Tool.Env (EnvRow)++data FetchResult = FetchResult+  { status :: !Int,+    contentType :: !Text,+    body :: !Text,+    truncated :: !Bool+  }+  deriving stock (Generic, Show, Eq)+  deriving anyclass (FromJSON, ToJSON)++data SearchHit = SearchHit+  { title :: !Text,+    url :: !Text,+    snippet :: !Text+  }+  deriving stock (Generic, Show, Eq)+  deriving anyclass (FromJSON, ToJSON)++newtype SearchResult = SearchResult {hits :: [SearchHit]}+  deriving stock (Generic, Show, Eq)+  deriving anyclass (FromJSON, ToJSON)++data SearchConfig = SearchConfig+  { baseUrl :: !Text,+    apiKey :: !Text+  }+  deriving stock (Generic, Show, Eq)+  deriving anyclass (ToJSON)++data WebClient = WebClient+  { webFetch :: forall es. (EnvRow es) => Text -> Maybe Int -> Eff es FetchResult,+    webSearch :: forall es. (EnvRow es) => Text -> Maybe Int -> Eff es SearchResult+  }++localWebClient :: Manager -> Maybe SearchConfig -> WebClient+localWebClient manager searchConfig =+  WebClient+    { webFetch = \url maxBytes -> localFetch manager url maxBytes,+      webSearch = \query maxResults -> localSearch manager searchConfig query maxResults+    }++newTlsManager :: IO Manager+newTlsManager = TLS.newTlsManager++localFetch :: (EnvRow es) => Manager -> Text -> Maybe Int -> Eff es FetchResult+localFetch manager url maxBytes = do+  response <- httpIO "web_fetch" $ do+    request <- parseRequest (T.unpack url)+    httpLbs request manager+  let maxBodyBytes = max 0 (maybe 100000 id maxBytes)+      rawBody = responseBody response+      limitedBody = LBS.take (fromIntegral maxBodyBytes) rawBody+      truncated = LBS.length rawBody > fromIntegral maxBodyBytes+      decodedBody = TE.decodeUtf8With lenientDecode (LBS.toStrict limitedBody)+  pure+    FetchResult+      { status = statusCode (responseStatus response),+        contentType = contentTypeOf (responseHeaders response),+        body = decodedBody,+        truncated+      }++localSearch :: (EnvRow es) => Manager -> Maybe SearchConfig -> Text -> Maybe Int -> Eff es SearchResult+localSearch _ Nothing _ _ =+  throwError (ProviderFailure "web_search: no search provider configured")+localSearch manager (Just config) query maxResults = do+  response <- httpIO "web_search" $ do+    request0 <- parseRequest (T.unpack (config ^. #baseUrl))+    let request =+          setQueryString+            [ ("q", Just (TE.encodeUtf8 query)),+              ("key", Just (TE.encodeUtf8 (config ^. #apiKey))),+              ("limit", Just (B8.pack (show (maybe 10 id maxResults))))+            ]+            request0+    httpLbs request manager+  case eitherDecode (responseBody response) of+    Right result -> pure result+    Left err -> throwError (ProviderFailure ("web_search: could not decode search response: " <> T.pack err))++httpIO :: (EnvRow es) => Text -> IO a -> Eff es a+httpIO label action = do+  result <- unsafeEff_ (try action)+  case result of+    Right value -> pure value+    Left (err :: HttpException) ->+      throwError (ProviderFailure (label <> ": " <> T.pack (show err)))++contentTypeOf :: ResponseHeaders -> Text+contentTypeOf headers =+  maybe "" (TE.decodeUtf8With lenientDecode) (lookup "Content-Type" headers)
test/AcceptanceSpec.hs view
@@ -7,6 +7,8 @@ module AcceptanceSpec (tests) where  import Baikai (Response)+import Control.Lens ((&), (.~))+import Data.Generics.Labels () import Data.Maybe (fromMaybe) import Data.Text qualified as T import Data.Vector qualified as V@@ -25,7 +27,6 @@ import MockLLM (runAgent) import Shikumi.Agent.ReAct   ( Action (..),-    ReActConfig (..),     Step (..),     Termination (..),     ToolProtocol (..),@@ -51,7 +52,7 @@         res <-           runAgent             badArgsPromptScript-            (reactWithTrajectory weatherSignature weatherRegistry defaultReActConfig {protocol = ProtocolPrompt})+            (reactWithTrajectory weatherSignature weatherRegistry (defaultReActConfig & #protocol .~ ProtocolPrompt))             weatherQuestion         case res of           Right (o :: WeatherResp, traj) -> do@@ -68,7 +69,7 @@   res <-     runAgent       script-      (reactWithTrajectory weatherSignature weatherRegistry defaultReActConfig {protocol = proto})+      (reactWithTrajectory weatherSignature weatherRegistry (defaultReActConfig & #protocol .~ proto))       weatherQuestion   case res of     Right (o :: WeatherResp, traj) -> do
+ test/BuiltinAcceptanceSpec.hs view
@@ -0,0 +1,140 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE OverloadedStrings #-}++module BuiltinAcceptanceSpec (tests) where++import Baikai (Response)+import Control.Exception (bracket)+import Control.Lens ((&), (.~))+import Data.Aeson (object, (.=))+import Data.Generics.Labels ()+import Data.Maybe (fromMaybe)+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.IO qualified as TIO+import Data.Vector qualified as V+import GHC.Generics (Generic)+import MockLLM (mkTextResponse, mkToolCallResponse, runAgent)+import Shikumi.Adapter (ToPrompt)+import Shikumi.Agent.ReAct+  ( Action (..),+    Step (..),+    Termination (..),+    ToolProtocol (..),+    Trajectory (..),+    defaultReActConfig,+    reactWithTrajectory,+  )+import Shikumi.Schema (FromModel, ToSchema)+import Shikumi.Signature (Signature, mkSignature)+import Shikumi.Tool.Builtin (builtinRegistry)+import Shikumi.Tool.Env (localToolEnv)+import Shikumi.Tool.Web (FetchResult (..), SearchResult (..), WebClient (..))+import System.Directory qualified as Dir+import System.FilePath ((</>))+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))++data WorkTask = WorkTask {task :: !Text}+  deriving stock (Generic, Show, Eq)+  deriving anyclass (ToPrompt)++data WorkAnswer = WorkAnswer {done :: !Bool}+  deriving stock (Generic, Show, Eq)+  deriving anyclass (ToSchema, FromModel, ToPrompt)++tests :: TestTree+tests =+  testGroup+    "BuiltinAcceptance"+    [ testCase "builtinRegistry drives a scripted work cycle" $+        withTempDir "builtin" $ \root -> do+          let file = T.pack (root </> "README.md")+          TIO.writeFile (T.unpack file) "# Old Title\n\nbody\n"+          res <-+            runAgent+              (script (T.pack root) file)+              (reactWithTrajectory workSignature (builtinRegistry localToolEnv stubWebClient) (defaultReActConfig & #protocol .~ ProtocolNative))+              WorkTask {task = "Update the title and verify it."}+          finalContent <- TIO.readFile (T.unpack file)+          case res of+            Left err -> assertFailure ("builtin agent failed: " <> show err)+            Right (answer, traj) -> do+              answer @?= WorkAnswer {done = True}+              termination traj @?= TerminatedFinish+              finalContent @?= "# New Title\n\nbody\n"+              let stepList = V.toList (steps traj)+              V.length (steps traj) @?= 6+              toolNames stepList @?= ["read", "edit", "bash", "grep", "web_fetch"]+              case stepList of+                [readStep, editStep, bashStep, grepStep, fetchStep, finishStep] -> do+                  assertObservation "read observes original content" "Old Title" readStep+                  assertObservation "edit reports replacement" "replacements" editStep+                  assertObservation "bash sees new title" "New Title" bashStep+                  assertObservation "grep sees new title" "New Title" grepStep+                  assertObservation "web_fetch sees stub body" "stub body" fetchStep+                  action finishStep @?= Finish+                _ -> assertFailure "expected five tool steps and one finish step"+    ]++workSignature :: Signature WorkTask WorkAnswer+workSignature = mkSignature "Use the builtin tools to complete the requested repository work."++script :: Text -> Text -> [Response]+script root file =+  [ mkToolCallResponse "call_read" "read" (object ["path" .= file]),+    mkToolCallResponse+      "call_edit"+      "edit"+      ( object+          [ "path" .= file,+            "oldString" .= ("# Old Title" :: Text),+            "newString" .= ("# New Title" :: Text)+          ]+      ),+    mkToolCallResponse+      "call_bash"+      "bash"+      (object ["command" .= ("cat " <> shellQuote file), "timeoutMs" .= (5000 :: Int)]),+    mkToolCallResponse+      "call_grep"+      "grep"+      ( object+          [ "pattern" .= ("New Title" :: Text),+            "path" .= root,+            "glob" .= ("**/*.md" :: Text)+          ]+      ),+    mkToolCallResponse "call_fetch" "web_fetch" (object ["url" .= ("https://example.test/docs" :: Text)]),+    mkTextResponse "The requested work is complete.",+    mkTextResponse "{\"done\": true}"+  ]++stubWebClient :: WebClient+stubWebClient =+  WebClient+    { webFetch = \_ _ ->+        pure FetchResult {status = 200, contentType = "text/plain", body = "stub body", truncated = False},+      webSearch = \_ _ -> pure SearchResult {hits = []}+    }++toolNames :: [Step] -> [Text]+toolNames = foldMap $ \step -> case action step of+  CallTool name _ -> [name]+  Finish -> []++assertObservation :: String -> Text -> Step -> IO ()+assertObservation label needle step =+  assertBool label (needle `T.isInfixOf` fromMaybe "" (observation step))++withTempDir :: FilePath -> (FilePath -> IO a) -> IO a+withTempDir suffix action = do+  tmp <- Dir.getTemporaryDirectory+  let root = tmp </> ("shikumi-tools-" <> suffix)+  bracket+    (Dir.removePathForcibly root >> Dir.createDirectoryIfMissing True root >> pure root)+    Dir.removePathForcibly+    action++shellQuote :: Text -> Text+shellQuote t = "'" <> T.replace "'" "'\\''" t <> "'"
+ test/CompactionSpec.hs view
@@ -0,0 +1,179 @@+module CompactionSpec (tests) where++import Baikai (_Model, _Usage)+import Control.Lens ((&), (.~))+import Data.Generics.Labels ()+import Data.Text (Text)+import Data.Vector qualified as V+import Effectful (runEff)+import Effectful.Error.Static (runErrorNoCallStack)+import Fixtures+  ( WeatherResp,+    expectedWeather,+    weatherQuestion,+    weatherRegistry,+    weatherSignature,+  )+import MockLLM+  ( mkTextResponse,+    mkUsageResponse,+    runAgent,+    runEffMock,+    runMockLLMThrowingOn,+  )+import Shikumi.Agent.ReAct+  ( Action (..),+    ReActConfig (..),+    Step (..),+    Termination (..),+    ToolProtocol (..),+    Trajectory (..),+    defaultReActConfig,+    reactWithTrajectory,+  )+import Shikumi.Compaction+  ( CompactionConfig (..),+    compactTail,+    defaultCompactionConfig,+    overflowThreshold,+    usageExceedsWindow,+  )+import Shikumi.Error (ShikumiError (..))+import Shikumi.Program (runProgram)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))++tests :: TestTree+tests =+  testGroup+    "Compaction"+    [ testCase "usageExceedsWindow flips at the boundary" $ do+        let cfg = defaultCompactionConfig {reserveTokens = 100}+            model = _Model & #contextWindow .~ 1000+            usage n = _Usage & #inputTokens .~ n+        overflowThreshold cfg model @?= 900+        usageExceedsWindow cfg model (usage 899) @?= False+        usageExceedsWindow cfg model (usage 900) @?= True+        usageExceedsWindow (cfg {enabled = False}) model (usage 900) @?= False+        usageExceedsWindow cfg _Model (usage 0) @?= False+        overflowThreshold cfg (_Model & #contextWindow .~ 50) @?= 0,+      testCase "compactTail folds older items and keeps the recent tail" $ do+        let cfg = defaultCompactionConfig {keepRecent = 2}+        res <-+          runEffMock [mkTextResponse "S"] $+            compactTail cfg _Model id ("summary:" <>) (["e1", "e2", "e3", "e4", "e5", "e6"] :: [Text])+        res @?= Right ["summary:S", "e5", "e6"],+      testCase "agent on tiny window compacts and completes" $ do+        let cfg =+              defaultReActConfig+                { maxIters = 5,+                  protocol = ProtocolPrompt,+                  compaction = defaultCompactionConfig {reserveTokens = 10, keepRecent = 1}+                }+            model = _Model & #contextWindow .~ 100+            script =+              [ mkUsageResponse model 10 (callReply "first"),+                mkUsageResponse model 90 (callReply "second"),+                mkTextResponse "summary of the first step",+                mkUsageResponse model 10 finishReply,+                mkTextResponse extractReply+              ]+        res <- runAgent script (reactWithTrajectory weatherSignature weatherRegistry cfg) weatherQuestion+        case res of+          Right (o :: WeatherResp, traj) -> do+            o @?= expectedWeather+            termination traj @?= TerminatedFinish+            let ss = V.toList (steps traj)+            assertBool "trajectory contains a summary step" (any isSummary ss)+            case ss of+              [summary, recent, finish] -> do+                assertBool "first step is summary" (isSummary summary)+                case action recent of+                  CallTool "get_weather" _ -> assertBool "recent step records an observation" (observation recent /= Nothing)+                  other -> assertFailure ("recent step should be get_weather, got " <> show other)+                action finish @?= Finish+              other -> assertFailure ("expected summary, recent tool step, finish; got " <> show other)+          Left e -> assertFailure ("agent failed: " <> show e),+      testCase "overflow error is caught, compacted, and retried once" $ do+        let cfg =+              defaultReActConfig+                { maxIters = 5,+                  protocol = ProtocolPrompt,+                  compaction = defaultCompactionConfig {reserveTokens = 10, keepRecent = 0}+                }+            model = _Model & #contextWindow .~ 100+            script =+              [ mkUsageResponse model 10 (callReply "first"),+                mkTextResponse "reactive summary",+                mkTextResponse finishReply,+                mkTextResponse extractReply+              ]+            prog = reactWithTrajectory weatherSignature weatherRegistry cfg+        res <-+          runEff+            . runErrorNoCallStack @ShikumiError+            . runMockLLMThrowingOn [2] (ContextWindowExceeded "context length exceeded") script+            $ runProgram prog weatherQuestion+        case res of+          Right (_ :: WeatherResp, traj) ->+            assertBool "trajectory contains a summary step after recovery" (any isSummary (V.toList (steps traj)))+          Left e -> assertFailure ("agent failed: " <> show e),+      testCase "extract overflow is caught, compacted, and retried once" $ do+        let cfg =+              defaultReActConfig+                { maxIters = 5,+                  protocol = ProtocolPrompt,+                  compaction = defaultCompactionConfig {reserveTokens = 10, keepRecent = 1}+                }+            model = _Model & #contextWindow .~ 100+            script =+              [ mkUsageResponse model 10 (callReply "first"),+                mkTextResponse finishReply,+                mkTextResponse "extract recovery summary",+                mkTextResponse extractReply+              ]+            prog = reactWithTrajectory weatherSignature weatherRegistry cfg+        res <-+          runEff+            . runErrorNoCallStack @ShikumiError+            . runMockLLMThrowingOn [3] (ContextWindowExceeded "context length exceeded") script+            $ runProgram prog weatherQuestion+        case res of+          Right (_ :: WeatherResp, traj) ->+            assertBool "returned trajectory contains extract recovery summary" (any isSummary (V.toList (steps traj)))+          Left e -> assertFailure ("agent failed: " <> show e),+      testCase "overflow retry is bounded to one retry" $ do+        let cfg =+              defaultReActConfig+                { maxIters = 5,+                  protocol = ProtocolPrompt,+                  compaction = defaultCompactionConfig {reserveTokens = 10, keepRecent = 0}+                }+            model = _Model & #contextWindow .~ 100+            script =+              [ mkUsageResponse model 10 (callReply "first"),+                mkTextResponse "reactive summary"+              ]+            prog = reactWithTrajectory weatherSignature weatherRegistry cfg+        res <-+          runEff+            . runErrorNoCallStack @ShikumiError+            . runMockLLMThrowingOn [2, 4] (ContextWindowExceeded "context length exceeded") script+            $ runProgram prog weatherQuestion+        res @?= Left (ContextWindowExceeded "context length exceeded")+    ]++callReply :: Text -> Text+callReply th =+  "{\"thought\": \""+    <> th+    <> "\", \"action\": {\"tool\": \"get_weather\", \"args\": {\"city\": \"Paris\", \"units\": \"c\"}}}"++finishReply :: Text+finishReply = "{\"thought\": \"I have the forecast.\", \"action\": {\"finish\": true}}"++extractReply :: Text+extractReply = "{\"tempC\": 12.0, \"summary\": \"mild\"}"++isSummary :: Step -> Bool+isSummary s = thought s == "(compacted summary of earlier steps)"
+ test/EnvSpec.hs view
@@ -0,0 +1,89 @@+{-# LANGUAGE OverloadedStrings #-}++module EnvSpec (tests) where++import Control.Lens ((^.))+import Data.ByteString qualified as BS+import Data.Generics.Labels ()+import Data.List (find)+import Data.Text qualified as T+import MockLLM (runEffMock)+import Shikumi.Tool.Env+  ( DirEntry,+    ExecRequest (..),+    envCwd,+    envExec,+    envExists,+    envMkdir,+    envReadFile,+    envReaddir,+    envRm,+    envStat,+    envWriteFile,+    localToolEnv,+  )+import System.Directory qualified as Dir+import System.FilePath ((</>))+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))++tests :: TestTree+tests =+  testGroup+    "Tool.Env"+    [ testCase "localToolEnv can perform filesystem operations and exec" $ do+        root <- freshTempDir+        let file = T.pack (root </> "hello.txt")+            nested = T.pack (root </> "nested")+        result <-+          runEffMock [] $ do+            envWriteFile localToolEnv file "hello\n"+            bytes <- envReadFile localToolEnv file+            stat <- envStat localToolEnv file+            entries <- envReaddir localToolEnv (T.pack root)+            exists <- envExists localToolEnv file+            envMkdir localToolEnv nested+            nestedExists <- envExists localToolEnv nested+            envRm localToolEnv nested+            nestedGone <- not <$> envExists localToolEnv nested+            execResult <-+              envExec+                localToolEnv+                ExecRequest+                  { command = "echo hello",+                    cwd = Just (T.pack root),+                    stdin = Nothing,+                    timeoutMs = Just 5000+                  }+            cwd <- envCwd localToolEnv+            pure (bytes, stat, entries, exists, nestedExists, nestedGone, execResult, cwd)+        Dir.removePathForcibly root+        case result of+          Left err -> assertFailure ("localToolEnv failed: " <> show err)+          Right (bytes, stat, entries, exists, nestedExists, nestedGone, execResult, cwd) -> do+            bytes @?= "hello\n"+            case stat of+              Just fileStat -> do+                fileStat ^. #isFile @?= True+                fileStat ^. #isDir @?= False+                fileStat ^. #size @?= fromIntegral (BS.length bytes)+              Nothing -> assertFailure "expected stat for written file"+            assertBool "readdir includes written file" (hasEntry "hello.txt" entries)+            exists @?= True+            nestedExists @?= True+            nestedGone @?= True+            execResult ^. #exitCode @?= 0+            assertBool "exec stdout includes hello" ("hello" `T.isInfixOf` (execResult ^. #stdout))+            assertBool "cwd returns an absolute path" ("/" `T.isPrefixOf` cwd)+    ]++freshTempDir :: IO FilePath+freshTempDir = do+  tmp <- Dir.getTemporaryDirectory+  let root = tmp </> "shikumi-tools-envspec"+  Dir.removePathForcibly root+  Dir.createDirectoryIfMissing True root+  pure root++hasEntry :: T.Text -> [DirEntry] -> Bool+hasEntry wanted = maybe False (const True) . find (\entry -> entry ^. #name == wanted)
+ test/FsSpec.hs view
@@ -0,0 +1,161 @@+{-# LANGUAGE OverloadedStrings #-}++module FsSpec (tests) where++import Control.Exception (bracket)+import Control.Lens ((^.))+import Data.ByteString qualified as BS+import Data.List (sort)+import Data.Text (Text)+import Data.Text qualified as T+import MockLLM (runEffMock)+import Shikumi.Error (ShikumiError (..))+import Shikumi.Tool (Tool (..))+import Shikumi.Tool.Builtin.Fs+  ( EditReq (..),+    GlobReq (..),+    GrepMatch (..),+    GrepReq (..),+    ReadReq (..),+    WriteReq (..),+    editTool,+    globTool,+    grepTool,+    readTool,+    writeTool,+  )+import Shikumi.Tool.Env+  ( ExecRequest (..),+    ExecResult (..),+    ToolEnv (..),+    envCwd,+    envExec,+    envExists,+    envMkdir,+    envReadFile,+    envReaddir,+    envRm,+    envStat,+    envWriteFile,+    localToolEnv,+  )+import System.Directory qualified as Dir+import System.FilePath ((</>))+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))++tests :: TestTree+tests =+  testGroup+    "Tool.Fs"+    [ testCase "read/write/edit/grep/glob work through fast and fallback paths" $+        withTempDir "roundtrip" $ \root -> do+          let file = T.pack (root </> "note.txt")+          result <-+            runEffMock [] $ do+              rgPresent <- envExec localToolEnv (presentReq "rg")+              fdPresent <- envExec localToolEnv (presentReq "fd")+              written <- run (writeTool localToolEnv) WriteReq {path = file, content = "alpha\nold title\n"}+              before <- run (readTool localToolEnv) ReadReq {path = file, offset = Nothing, limit = Nothing}+              edited <-+                run+                  (editTool localToolEnv)+                  EditReq {path = file, oldString = "old title", newString = "new title", replaceAll = Nothing}+              after <- run (readTool localToolEnv) ReadReq {path = file, offset = Nothing, limit = Nothing}+              fastGrep <-+                run+                  (grepTool localToolEnv)+                  GrepReq {patternText = "new title", path = Just (T.pack root), glob = Just "**/*.txt", ignoreCase = Nothing}+              fallbackGrep <-+                run+                  (grepTool noFastToolEnv)+                  GrepReq {patternText = "new title", path = Just (T.pack root), glob = Just "**/*.txt", ignoreCase = Nothing}+              fastGlob <- run (globTool localToolEnv) GlobReq {patternText = "**/*.txt", path = Just (T.pack root)}+              fallbackGlob <- run (globTool noFastToolEnv) GlobReq {patternText = "**/*.txt", path = Just (T.pack root)}+              pure (rgPresent, fdPresent, written, before, edited, after, fastGrep, fallbackGrep, fastGlob, fallbackGlob)+          case result of+            Left err -> assertFailure ("fs tools failed: " <> show err)+            Right (rgPresent, fdPresent, written, before, edited, after, fastGrep, fallbackGrep, fastGlob, fallbackGlob) -> do+              rgPresent ^. #exitCode @?= 0+              fdPresent ^. #exitCode @?= 0+              written ^. #bytesWritten @?= BS.length "alpha\nold title\n"+              before ^. #content @?= "alpha\nold title\n"+              edited ^. #replacements @?= 1+              after ^. #content @?= "alpha\nnew title\n"+              normalizeMatches (fastGrep ^. #matches) @?= normalizeMatches (fallbackGrep ^. #matches)+              assertBool "grep finds edited text" (any (\m -> m ^. #text == "new title") (fastGrep ^. #matches))+              assertBool "fast glob finds file" (file `elem` (fastGlob ^. #paths))+              assertBool "fallback glob finds file" (file `elem` (fallbackGlob ^. #paths)),+      testCase "fallback grep skips noisy directories and binary files" $+        withTempDir "hardening" $ \root -> do+          let visible = T.pack (root </> "visible.txt")+              gitFile = T.pack (root </> ".git" </> "packed.txt")+              nodeFile = T.pack (root </> "node_modules" </> "dep.txt")+              binaryFile = T.pack (root </> "binary.bin")+          result <-+            runEffMock [] $ do+              envWriteFile localToolEnv visible "SECRET visible\n"+              envMkdir localToolEnv (T.pack (root </> ".git"))+              envWriteFile localToolEnv gitFile "SECRET git\n"+              envMkdir localToolEnv (T.pack (root </> "node_modules"))+              envWriteFile localToolEnv nodeFile "SECRET node\n"+              envWriteFile localToolEnv binaryFile (BS.pack [0, 83, 69, 67, 82, 69, 84])+              run+                (grepTool noFastToolEnv)+                GrepReq {patternText = "SECRET", path = Just (T.pack root), glob = Nothing, ignoreCase = Nothing}+          case result of+            Left err -> assertFailure ("fallback grep failed: " <> show err)+            Right resp -> do+              normalizeMatches (resp ^. #matches) @?= [GrepMatch {file = visible, line = 1, text = "SECRET visible"}]+              resp ^. #truncated @?= False,+      testCase "fallback grep reports malformed regex as ValidationFailure" $+        withTempDir "bad-regex" $ \root -> do+          let visible = T.pack (root </> "visible.txt")+          result <-+            runEffMock [] $ do+              envWriteFile localToolEnv visible "hello\n"+              run+                (grepTool noFastToolEnv)+                GrepReq {patternText = "[", path = Just (T.pack root), glob = Nothing, ignoreCase = Nothing}+          case result of+            Left (ValidationFailure msg) -> assertBool "mentions invalid regex" ("invalid regex" `T.isInfixOf` msg)+            other -> assertFailure ("expected ValidationFailure, got " <> show other)+    ]++withTempDir :: FilePath -> (FilePath -> IO a) -> IO a+withTempDir suffix action = do+  tmp <- Dir.getTemporaryDirectory+  let root = tmp </> ("shikumi-tools-fsspec-" <> suffix)+  bracket+    (Dir.removePathForcibly root >> Dir.createDirectoryIfMissing True root >> pure root)+    Dir.removePathForcibly+    action++presentReq :: Text -> ExecRequest+presentReq command =+  ExecRequest+    { command = "command -v " <> command,+      cwd = Nothing,+      stdin = Nothing,+      timeoutMs = Just 5000+    }++noFastToolEnv :: ToolEnv+noFastToolEnv =+  ToolEnv+    { envExec = \req ->+        if "command -v rg" `T.isPrefixOf` (req ^. #command) || "command -v fd" `T.isPrefixOf` (req ^. #command)+          then pure ExecResult {exitCode = 1, stdout = "", stderr = ""}+          else envExec localToolEnv req,+      envReadFile = envReadFile localToolEnv,+      envWriteFile = envWriteFile localToolEnv,+      envStat = envStat localToolEnv,+      envReaddir = envReaddir localToolEnv,+      envExists = envExists localToolEnv,+      envMkdir = envMkdir localToolEnv,+      envRm = envRm localToolEnv,+      envCwd = envCwd localToolEnv+    }++normalizeMatches :: [GrepMatch] -> [GrepMatch]+normalizeMatches = sort
test/Main.hs view
@@ -3,14 +3,20 @@ module Main (main) where  import AcceptanceSpec qualified+import BuiltinAcceptanceSpec qualified import CodeActSpec qualified+import CompactionSpec qualified+import EnvSpec qualified+import FsSpec qualified import ProgramOfThoughtSpec qualified import ProtocolSpec qualified import ReActSpec qualified import RestrictedSpec qualified import SchemaSpec qualified+import ShellSpec qualified import Test.Tasty (defaultMain, testGroup) import ToolSpec qualified+import WebSpec qualified  main :: IO () main =@@ -19,10 +25,16 @@       "shikumi-tools"       [ SchemaSpec.tests,         ToolSpec.tests,+        EnvSpec.tests,+        WebSpec.tests,+        FsSpec.tests,+        ShellSpec.tests,         ReActSpec.tests,         ProtocolSpec.tests,         AcceptanceSpec.tests,+        BuiltinAcceptanceSpec.tests,         RestrictedSpec.tests,         ProgramOfThoughtSpec.tests,-        CodeActSpec.tests+        CodeActSpec.tests,+        CompactionSpec.tests       ]
test/MockLLM.hs view
@@ -7,15 +7,19 @@ -- exhausted script yields an empty text response (specs always script enough). module MockLLM   ( runMockLLM,+    runMockLLMThrowingOnce,+    runMockLLMThrowingOn,     runEffMock,     runAgent,     mkTextResponse,+    mkUsageResponse,     mkToolCallResponse,   ) where  import Baikai   ( AssistantContent (..),+    Model,     Response,     _Response,     _TextContent,@@ -29,7 +33,8 @@ import Data.Vector qualified as V import Effectful (Eff, IOE, liftIO, runEff, type (:>)) import Effectful.Dispatch.Dynamic (interpret)-import Effectful.Error.Static (Error, runErrorNoCallStack)+import Effectful.Error.Static (Error, runErrorNoCallStack, throwError)+import Numeric.Natural (Natural) import Shikumi.Error (ShikumiError) import Shikumi.LLM (LLM (..)) import Shikumi.Program (Program, runProgram)@@ -46,6 +51,38 @@     )     act +-- | Interpret @LLM@ like 'runMockLLM', but throw once on the first completion.+runMockLLMThrowingOnce ::+  (IOE :> es, Error ShikumiError :> es) =>+  ShikumiError ->+  [Response] ->+  Eff (LLM : es) a ->+  Eff es a+runMockLLMThrowingOnce = runMockLLMThrowingOn [1]++-- | Interpret @LLM@ like 'runMockLLM', but throw on selected 1-based completion+-- calls. Useful for bounded-retry tests.+runMockLLMThrowingOn ::+  (IOE :> es, Error ShikumiError :> es) =>+  [Int] ->+  ShikumiError ->+  [Response] ->+  Eff (LLM : es) a ->+  Eff es a+runMockLLMThrowingOn throwAt err script act = do+  ref <- liftIO (newIORef script)+  countRef <- liftIO (newIORef (0 :: Int))+  interpret+    ( \_ -> \case+        Complete {} -> do+          n <- liftIO (atomicModifyIORef' countRef (\n0 -> let n1 = n0 + 1 in (n1, n1)))+          if n `elem` throwAt+            then throwError err+            else liftIO (pop ref)+        Stream {} -> pure []+    )+    act+ -- | Discharge a network-free @LLM@ computation against a scripted mock: handle the -- error channel, then @IO@. The whole stack is exactly the row a ReAct agent runs -- in (@LLM@ + @Error ShikumiError@), plus @IOE@ at the bottom for the mock's @IORef@.@@ -73,6 +110,14 @@ mkTextResponse :: Text -> Response mkTextResponse t =   _Response & #message . #content .~ V.singleton (AssistantText (_TextContent & #text .~ t))++-- | A text response that also carries a resolved model and input-token usage.+mkUsageResponse :: Model -> Natural -> Text -> Response+mkUsageResponse model inputTokens text =+  mkTextResponse text+    & #model .~ model+    & #message . #usage . #inputTokens .~ inputTokens+    & #message . #usage . #totalTokens .~ inputTokens  -- | An assistant 'Response' carrying a single native tool-call block. mkToolCallResponse :: Text -> Text -> Value -> Response
test/ProtocolSpec.hs view
@@ -21,8 +21,7 @@   ) import MockLLM (runAgent) import Shikumi.Agent.ReAct-  ( ReActConfig (..),-    Termination (..),+  ( Termination (..),     ToolProtocol (..),     Trajectory (..),     defaultReActConfig,@@ -40,12 +39,12 @@         nat <-           runAgent             nativeScript-            (reactWithTrajectory weatherSignature weatherRegistry defaultReActConfig {protocol = ProtocolNative})+            (reactWithTrajectory weatherSignature weatherRegistry (defaultReActConfig & #protocol .~ ProtocolNative))             weatherQuestion         pro <-           runAgent             promptScript-            (reactWithTrajectory weatherSignature weatherRegistry defaultReActConfig {protocol = ProtocolPrompt})+            (reactWithTrajectory weatherSignature weatherRegistry (defaultReActConfig & #protocol .~ ProtocolPrompt))             weatherQuestion         case (nat, pro) of           (Right (oN :: WeatherResp, tN), Right (oP, tP)) -> do
test/ReActSpec.hs view
@@ -3,6 +3,8 @@ -- never finishes stops at @maxIters@ and still extracts a best-effort answer. module ReActSpec (tests) where +import Control.Lens ((&), (.~))+import Data.Generics.Labels () import Data.Vector qualified as V import Fixtures   ( WeatherResp,@@ -16,7 +18,6 @@ import MockLLM (runAgent) import Shikumi.Agent.ReAct   ( Action (..),-    ReActConfig (..),     Step (..),     Termination (..),     Trajectory (..),@@ -51,7 +52,7 @@               _ -> assertFailure "expected exactly two steps"           Left e -> assertFailure ("agent failed: " <> show e),       testCase "stops at maxIters with TerminatedMaxIters" $ do-        let cfg = defaultReActConfig {maxIters = 1}+        let cfg = defaultReActConfig & #maxIters .~ 1         res <-           runAgent             maxItersScript
+ test/ShellSpec.hs view
@@ -0,0 +1,40 @@+{-# LANGUAGE OverloadedStrings #-}++module ShellSpec (tests) where++import Control.Lens ((^.))+import Data.Text qualified as T+import MockLLM (runEffMock)+import Shikumi.Tool (Tool (..))+import Shikumi.Tool.Builtin.Shell (BashReq (..), bashTool)+import Shikumi.Tool.Env (localToolEnv)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))++tests :: TestTree+tests =+  testGroup+    "Tool.Shell"+    [ testCase "bash captures stdout and zero exit" $ do+        result <-+          runEffMock [] $+            run+              (bashTool localToolEnv)+              BashReq {command = "echo hi", cwd = Nothing, timeoutMs = Just 5000, stdin = Nothing}+        case result of+          Left err -> assertFailure ("bashTool failed: " <> show err)+          Right resp -> do+            resp ^. #exitCode @?= 0+            assertBool "stdout contains hi" ("hi" `T.isInfixOf` (resp ^. #stdout)),+      testCase "bash returns stderr and non-zero exit as a value" $ do+        result <-+          runEffMock [] $+            run+              (bashTool localToolEnv)+              BashReq {command = "echo oops 1>&2; exit 3", cwd = Nothing, timeoutMs = Just 5000, stdin = Nothing}+        case result of+          Left err -> assertFailure ("bashTool failed: " <> show err)+          Right resp -> do+            resp ^. #exitCode @?= 3+            assertBool "stderr contains oops" ("oops" `T.isInfixOf` (resp ^. #stderr))+    ]
+ test/WebSpec.hs view
@@ -0,0 +1,115 @@+{-# LANGUAGE OverloadedStrings #-}++module WebSpec (tests) where++import Baikai (ToolCall, _ToolCall)+import Control.Lens ((&), (.~))+import Data.Aeson (Value, object, (.=))+import Data.Generics.Labels ()+import Data.Text (Text)+import Data.Text qualified as T+import MockLLM (runEffMock)+import Shikumi.Tool (SomeTool (..), ToolRegistry, mkRegistry, runToolCall)+import Shikumi.Tool.Builtin.Web (webFetchTool, webSearchTool)+import Shikumi.Tool.Web+  ( FetchResult (..),+    SearchHit (..),+    SearchResult (..),+    WebClient (..),+    localWebClient,+    newTlsManager,+  )+import System.Environment (lookupEnv)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertFailure, testCase)++tc :: Text -> Value -> ToolCall+tc nm args = _ToolCall & #name .~ nm & #arguments .~ args++tests :: TestTree+tests =+  testGroup+    "Tool.Web"+    [ testCase "web_fetch returns a stubbed 200 observation" $ do+        result <-+          runEffMock [] $+            runToolCall+              stubRegistry+              (tc "web_fetch" (object ["url" .= ("https://example.test" :: Text)]))+        case result of+          Right (Right obs) -> do+            assertBool "observation includes status" ("200" `T.isInfixOf` obs)+            assertBool "observation includes body" ("stub body" `T.isInfixOf` obs)+          other -> assertFailure ("expected web_fetch observation, got " <> show other),+      testCase "web_fetch surfaces a 404 status as a value" $ do+        result <-+          runEffMock [] $+            runToolCall+              notFoundRegistry+              (tc "web_fetch" (object ["url" .= ("https://example.test/missing" :: Text)]))+        case result of+          Right (Right obs) -> do+            assertBool "observation includes 404" ("404" `T.isInfixOf` obs)+            assertBool "observation includes missing body" ("missing" `T.isInfixOf` obs)+          other -> assertFailure ("expected 404 as a tool value, got " <> show other),+      testCase "web_search returns stubbed hits" $ do+        result <-+          runEffMock [] $+            runToolCall+              stubRegistry+              (tc "web_search" (object ["query" .= ("shikumi" :: Text)]))+        case result of+          Right (Right obs) -> do+            assertBool "observation includes title" ("Stub Result" `T.isInfixOf` obs)+            assertBool "observation includes url" ("https://example.test/result" `T.isInfixOf` obs)+          other -> assertFailure ("expected web_search observation, got " <> show other),+      testCase "live web_fetch is gated by SHIKUMI_NET_TESTS" $ do+        enabled <- lookupEnv "SHIKUMI_NET_TESTS"+        case enabled of+          Nothing -> pure ()+          Just _ -> do+            manager <- newTlsManager+            result <-+              runEffMock [] $+                runToolCall+                  (mkRegistry [SomeTool (webFetchTool (localWebClient manager Nothing))])+                  (tc "web_fetch" (object ["url" .= ("https://example.com" :: Text)]))+            case result of+              Right (Right obs) -> do+                assertBool "live observation includes 200" ("200" `T.isInfixOf` obs)+                assertBool "live observation has a body" ("Example Domain" `T.isInfixOf` obs)+              Right (Left err) -> assertFailure ("live web_fetch returned tool error: " <> show err)+              Left err -> assertFailure ("live web_fetch failed: " <> show err)+    ]++stubRegistry :: ToolRegistry+stubRegistry = mkRegistry [SomeTool (webFetchTool stubWebClient), SomeTool (webSearchTool stubWebClient)]++notFoundRegistry :: ToolRegistry+notFoundRegistry = mkRegistry [SomeTool (webFetchTool notFoundWebClient)]++stubWebClient :: WebClient+stubWebClient =+  WebClient+    { webFetch = \_ _ ->+        pure FetchResult {status = 200, contentType = "text/plain", body = "stub body", truncated = False},+      webSearch = \_ _ ->+        pure+          SearchResult+            { hits =+                [ SearchHit+                    { title = "Stub Result",+                      url = "https://example.test/result",+                      snippet = "A stubbed search hit."+                    }+                ]+            }+    }++notFoundWebClient :: WebClient+notFoundWebClient =+  WebClient+    { webFetch = \_ _ ->+        pure FetchResult {status = 404, contentType = "text/plain", body = "missing", truncated = False},+      webSearch = \_ _ -> pure SearchResult {hits = []}+    }