← Back to task

Commit 046e6d1c

commit 046e6d1ca55651379f938b4481570bcb1b122e1e
Author: Ben Sima <ben@bensima.com>
Date:   Mon Dec 1 10:43:59 2025

    Add actor column to agent_events table
    
    - Add 'actor' column to agent_events table (human/junior/system)
    - Add System to CommentAuthor type (reused for actor) - Add SQL
    FromField/ToField instances for CommentAuthor - Update insertAgentEvent
    to accept actor parameter - Update all SELECT queries to include
    actor column - Update Worker.hs to pass actor for all event types -
    Guardrail events logged with System actor
    
    Migration: ALTER TABLE adds column with default 'junior' for existing
    rows.
    
    Task-Id: t-213.1

diff --git a/Omni/Agent/Worker.hs b/Omni/Agent/Worker.hs
index bbdba9d2..5fa169fe 100644
--- a/Omni/Agent/Worker.hs
+++ b/Omni/Agent/Worker.hs
@@ -267,10 +267,11 @@ runWithEngine worker repo task = do
 
       -- Helper to log events to DB
       -- For text content, store as-is; for structured data, JSON-encode
-      let logEventText = TaskCore.insertAgentEvent tid sessionId
-          logEventJson eventType value = do
+      let logJuniorEvent eventType content = TaskCore.insertAgentEvent tid sessionId eventType content TaskCore.Junior
+          logJuniorJson eventType value = do
             let contentJson = TE.decodeUtf8 (BSL.toStrict (Aeson.encode value))
-            TaskCore.insertAgentEvent tid sessionId eventType contentJson
+            TaskCore.insertAgentEvent tid sessionId eventType contentJson TaskCore.Junior
+          logSystemEvent eventType content = TaskCore.insertAgentEvent tid sessionId eventType content TaskCore.System
 
       -- Build Engine config with callbacks
       totalCostRef <- newIORef (0 :: Double)
@@ -285,29 +286,30 @@ runWithEngine worker repo task = do
                 Engine.engineOnCost = \tokens cost -> do
                   modifyIORef' totalCostRef (+ cost)
                   sayLog <| "Cost: " <> tshow cost <> " cents (" <> tshow tokens <> " tokens)"
-                  logEventJson "Cost" (Aeson.object [("tokens", Aeson.toJSON tokens), ("cents", Aeson.toJSON cost)]),
+                  logJuniorJson "Cost" (Aeson.object [("tokens", Aeson.toJSON tokens), ("cents", Aeson.toJSON cost)]),
                 Engine.engineOnActivity = \activity -> do
                   sayLog <| "[engine] " <> activity,
                 Engine.engineOnToolCall = \toolName args -> do
                   sayLog <| "[tool] " <> toolName
-                  logEventText "ToolCall" (toolName <> ": " <> args),
+                  logJuniorEvent "ToolCall" (toolName <> ": " <> args),
                 Engine.engineOnAssistant = \msg -> do
                   sayLog <| "[assistant] " <> Text.take 200 msg
-                  logEventText "Assistant" msg,
+                  logJuniorEvent "Assistant" msg,
                 Engine.engineOnToolResult = \toolName success output -> do
                   let statusStr = if success then "ok" else "failed"
                   sayLog <| "[result] " <> toolName <> " (" <> statusStr <> "): " <> Text.take 100 output
-                  logEventText "ToolResult" output,
+                  logJuniorEvent "ToolResult" output,
                 Engine.engineOnComplete = do
                   sayLog "[engine] Complete"
-                  logEventText "Complete" "",
+                  logJuniorEvent "Complete" "",
                 Engine.engineOnError = \err -> do
                   sayLog <| "[error] " <> err
-                  logEventText "Error" err,
+                  logJuniorEvent "Error" err,
                 Engine.engineOnGuardrail = \guardrailResult -> do
                   let guardrailMsg = formatGuardrailResult guardrailResult
+                      contentJson = TE.decodeUtf8 (BSL.toStrict (Aeson.encode guardrailResult))
                   sayLog <| "[guardrail] " <> guardrailMsg
-                  logEventJson "Guardrail" (Aeson.toJSON guardrailResult)
+                  logSystemEvent "Guardrail" contentJson
               }
 
       -- Build Agent config with guardrails
diff --git a/Omni/Jr/Web.hs b/Omni/Jr/Web.hs
index 7555e22b..f7a22193 100644
--- a/Omni/Jr/Web.hs
+++ b/Omni/Jr/Web.hs
@@ -1632,9 +1632,11 @@ instance Lucid.ToHtml TaskDetailPage where
           authorClass = case TaskCore.commentAuthor c of
             TaskCore.Human -> "author-human"
             TaskCore.Junior -> "author-junior"
+            TaskCore.System -> "author-system"
           authorLabel author = case author of
             TaskCore.Human -> "Human" :: Text
             TaskCore.Junior -> "Junior" :: Text
+            TaskCore.System -> "System" :: Text
 
       commentForm :: (Monad m) => Text -> Lucid.HtmlT m ()
       commentForm tid =
diff --git a/Omni/Task/Core.hs b/Omni/Task/Core.hs
index 1212a569..41fcae4a 100644
--- a/Omni/Task/Core.hs
+++ b/Omni/Task/Core.hs
@@ -140,8 +140,8 @@ data Fact = Fact
   }
   deriving (Show, Eq, Generic)
 
--- Comment author
-data CommentAuthor = Human | Junior
+-- Comment/event author (also used as Actor for timeline events)
+data CommentAuthor = Human | Junior | System
   deriving (Show, Eq, Read, Generic)
 
 -- Comment for task notes/context
@@ -266,16 +266,6 @@ instance SQL.FromField ActivityStage where
 instance SQL.ToField ActivityStage where
   toField x = SQL.toField (show x :: String)
 
-instance SQL.FromField CommentAuthor where
-  fromField f = do
-    t <- SQL.fromField f :: SQLOk.Ok String
-    case readMaybe t of
-      Just x -> pure x
-      Nothing -> SQL.returnError SQL.ConversionFailed f "Invalid CommentAuthor"
-
-instance SQL.ToField CommentAuthor where
-  toField x = SQL.toField (show x :: String)
-
 -- Store dependencies as JSON text
 instance SQL.FromField [Dependency] where
   fromField f = do
@@ -540,10 +530,16 @@ createAgentEventsTable conn = do
     \ session_id TEXT NOT NULL, \
     \ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, \
     \ event_type TEXT NOT NULL, \
-    \ content TEXT NOT NULL \
+    \ content TEXT NOT NULL, \
+    \ actor TEXT NOT NULL DEFAULT 'junior' \
     \)"
   SQL.execute_ conn "CREATE INDEX IF NOT EXISTS idx_agent_events_task ON agent_events(task_id)"
   SQL.execute_ conn "CREATE INDEX IF NOT EXISTS idx_agent_events_session ON agent_events(session_id)"
+  -- Add actor column to existing tables (migration)
+  SQL.execute_ conn "ALTER TABLE agent_events ADD COLUMN actor TEXT NOT NULL DEFAULT 'junior'" `catch` ignoreAlterError
+  where
+    ignoreAlterError :: SQL.SQLError -> IO ()
+    ignoreAlterError _ = pure () -- Column already exists
 
 -- | Expected columns for task_activity table (name, type, nullable)
 taskActivityColumns :: [(Text, Text)]
@@ -1609,6 +1605,20 @@ deleteFact fid =
 -- Agent Events (for observability)
 -- ============================================================================
 
+instance SQL.FromField CommentAuthor where
+  fromField f = do
+    t <- SQL.fromField f :: SQLOk.Ok String
+    case t of
+      "human" -> pure Human
+      "junior" -> pure Junior
+      "system" -> pure System
+      _ -> SQL.returnError SQL.ConversionFailed f "Invalid CommentAuthor"
+
+instance SQL.ToField CommentAuthor where
+  toField Human = SQL.toField ("human" :: String)
+  toField Junior = SQL.toField ("junior" :: String)
+  toField System = SQL.toField ("system" :: String)
+
 -- | Stored agent event record
 data StoredEvent = StoredEvent
   { storedEventId :: Int,
@@ -1616,7 +1626,8 @@ data StoredEvent = StoredEvent
     storedEventSessionId :: Text,
     storedEventTimestamp :: UTCTime,
     storedEventType :: Text,
-    storedEventContent :: Text
+    storedEventContent :: Text,
+    storedEventActor :: CommentAuthor
   }
   deriving (Show, Eq, Generic)
 
@@ -1633,6 +1644,7 @@ instance SQL.FromRow StoredEvent where
       <*> SQL.field
       <*> SQL.field
       <*> SQL.field
+      <*> SQL.field
 
 -- | Generate a new session ID (timestamp-based for simplicity)
 generateSessionId :: IO Text
@@ -1640,14 +1652,14 @@ generateSessionId = do
   now <- getCurrentTime
   pure <| "s-" <> T.pack (show now)
 
--- | Insert an agent event
-insertAgentEvent :: Text -> Text -> Text -> Text -> IO ()
-insertAgentEvent taskId sessionId eventType content =
+-- | Insert an agent event with actor
+insertAgentEvent :: Text -> Text -> Text -> Text -> CommentAuthor -> IO ()
+insertAgentEvent taskId sessionId eventType content actor =
   withDb <| \conn ->
     SQL.execute
       conn
-      "INSERT INTO agent_events (task_id, session_id, event_type, content) VALUES (?, ?, ?, ?)"
-      (taskId, sessionId, eventType, content)
+      "INSERT INTO agent_events (task_id, session_id, event_type, content, actor) VALUES (?, ?, ?, ?, ?)"
+      (taskId, sessionId, eventType, content, actor)
 
 -- | Get all events for a task (most recent session)
 getEventsForTask :: Text -> IO [StoredEvent]
@@ -1663,7 +1675,7 @@ getEventsForSession sessionId =
   withDb <| \conn ->
     SQL.query
       conn
-      "SELECT id, task_id, session_id, timestamp, event_type, content \
+      "SELECT id, task_id, session_id, timestamp, event_type, content, actor \
       \FROM agent_events WHERE session_id = ? ORDER BY id ASC"
       (SQL.Only sessionId)
 
@@ -1699,14 +1711,14 @@ getEventsSince sessionId lastId =
   withDb <| \conn ->
     SQL.query
       conn
-      "SELECT id, task_id, session_id, timestamp, event_type, content \
+      "SELECT id, task_id, session_id, timestamp, event_type, content, actor \
       \FROM agent_events WHERE session_id = ? AND id > ? ORDER BY id ASC"
       (sessionId, lastId)
 
 -- | Insert a checkpoint event (for progress tracking)
 insertCheckpoint :: Text -> Text -> Text -> IO ()
-insertCheckpoint taskId sessionId =
-  insertAgentEvent taskId sessionId "Checkpoint"
+insertCheckpoint taskId sessionId content =
+  insertAgentEvent taskId sessionId "Checkpoint" content Junior
 
 -- | Get all checkpoints for a task (across all sessions)
 getCheckpointsForTask :: Text -> IO [StoredEvent]
@@ -1714,7 +1726,7 @@ getCheckpointsForTask taskId =
   withDb <| \conn ->
     SQL.query
       conn
-      "SELECT id, task_id, session_id, timestamp, event_type, content \
+      "SELECT id, task_id, session_id, timestamp, event_type, content, actor \
       \FROM agent_events WHERE task_id = ? AND event_type = 'Checkpoint' ORDER BY id ASC"
       (SQL.Only taskId)