← Back to task

Commit fa19a86d

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"}