commit fa19a86d0a65cb114d816f0a1d96019750534706
Author: Ben Sima <ben@bensima.com>
Date: Thu Jan 1 00:30:03 2026
Add exception handling to agent tool execution (t-303)
- Add executeToolSafe helper that wraps toolExecute in try/catch
- Returns structured JSON error with tool name and exception flag
- Prevents single tool failure from crashing entire agent
- Applied to all 3 toolExecute call sites in Engine.hs
Also fix Task/Core.hs LambdaCase extension for Role FromJSON instance.
Task-Id: t-303
diff --git a/Omni/Agent/Engine.hs b/Omni/Agent/Engine.hs
index ce0b1d69..90edcac9 100644
--- a/Omni/Agent/Engine.hs
+++ b/Omni/Agent/Engine.hs
@@ -239,6 +239,20 @@ data Tool = Tool
toolExecute :: Aeson.Value -> IO Aeson.Value
}
+-- | Execute a tool safely, catching any exceptions and returning them as JSON errors.
+-- This prevents a single failing tool from crashing the entire agent.
+executeToolSafe :: Tool -> Aeson.Value -> IO Aeson.Value
+executeToolSafe tool args = do
+ result <- try @SomeException (toolExecute tool args)
+ case result of
+ Right val -> pure val
+ Left err ->
+ pure <| Aeson.object
+ [ "error" .= ("Tool execution failed: " <> tshow err),
+ "tool" .= toolName tool,
+ "exception" .= True
+ ]
+
data ToolApi = ToolApi
{ toolApiName :: Text,
toolApiDescription :: Text,
@@ -821,7 +835,7 @@ executeToolCallsWithTracking engineCfg toolMap tcs initialTestFailures initialEd
pure (Message ToolRole errMsg Nothing (Just callId), 0, 0)
Just args -> do
startTime <- Time.getCurrentTime
- resultValue <- toolExecute tool args
+ resultValue <- executeToolSafe tool args
endTime <- Time.getCurrentTime
let durationMs = round (Time.diffUTCTime endTime startTime * 1000)
rawResultText = TE.decodeUtf8 (BL.toStrict (Aeson.encode resultValue))
@@ -1045,7 +1059,7 @@ runAgentWithProvider engineCfg provider agentCfg userPrompt = do
pure (Provider.Message Provider.ToolRole errMsg Nothing (Just callId), 0, 0)
Just args -> do
startTime <- Time.getCurrentTime
- resultValue <- toolExecute tool args
+ resultValue <- executeToolSafe tool args
endTime <- Time.getCurrentTime
let durationMs = round (Time.diffUTCTime endTime startTime * 1000)
resultText = TE.decodeUtf8 (BL.toStrict (Aeson.encode resultValue))
@@ -1250,7 +1264,7 @@ runAgentWithProviderStreaming engineCfg provider agentCfg userPrompt onStreamChu
pure (Provider.Message Provider.ToolRole errMsg Nothing (Just callId), 0, 0)
Just args -> do
startTime <- Time.getCurrentTime
- resultValue <- toolExecute tool args
+ resultValue <- executeToolSafe tool args
endTime <- Time.getCurrentTime
let durationMs = round (Time.diffUTCTime endTime startTime * 1000)
resultText = TE.decodeUtf8 (BL.toStrict (Aeson.encode resultValue))
diff --git a/Omni/Task/Core.hs b/Omni/Task/Core.hs
index f380eecd..433aad06 100644
--- a/Omni/Task/Core.hs
+++ b/Omni/Task/Core.hs
@@ -1,4 +1,5 @@
{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE NoImplicitPrelude #-}
@@ -219,7 +220,7 @@ instance FromJSON Role where
"designer" -> pure Designer
"engineer" -> pure Engineer
"reviewer" -> pure Reviewer
- other -> fail <| "Unknown role: " <> Text.unpack other
+ _ -> empty
-- Custom JSON instances for CommentAuthor to handle nested Agent structure
-- JSON format: "human", "system", or {"agent": "product"}