← Back to task

Commit fdb93c1a

commit fdb93c1a4eadede06f8c1900bef4fdb5d3b9153f
Author: Ben Sima <ben@bensima.com>
Date:   Tue Dec 30 20:31:14 2025

    Omni/Agent/Tools/Tasks: Add work_on_task and list_ready_tasks tools
    
    Allows Ava to work on coding tasks via normal LLM tool calling.
    - work_on_task: Spawns pi-orchestrate.sh for a task
    - list_ready_tasks: Lists tasks ready to work on
    
    Replaces the hardcoded pattern matching approach.
    
    Task-Id: t-280

diff --git a/Omni/Agent/Telegram.hs b/Omni/Agent/Telegram.hs
index 3a805f36..25f07911 100644
--- a/Omni/Agent/Telegram.hs
+++ b/Omni/Agent/Telegram.hs
@@ -97,7 +97,6 @@ import qualified Omni.Agent.Telegram.Actions as Actions
 import qualified Omni.Agent.Telegram.IncomingQueue as IncomingQueue
 import qualified Omni.Agent.Telegram.Media as Media
 import qualified Omni.Agent.Telegram.Messages as Messages
-import qualified Omni.Agent.Telegram.Orchestrator as Orchestrator
 import qualified Omni.Agent.Telegram.Reminders as Reminders
 import qualified Omni.Agent.Telegram.Types as Types
 import qualified Omni.Agent.Tools as Tools
@@ -111,6 +110,7 @@ import qualified Omni.Agent.Tools.Notes as Notes
 import qualified Omni.Agent.Tools.Outreach as Outreach
 import qualified Omni.Agent.Tools.Pdf as Pdf
 import qualified Omni.Agent.Tools.Python as Python
+import qualified Omni.Agent.Tools.Tasks as Tasks
 import qualified Omni.Agent.Tools.Todos as Todos
 import qualified Omni.Agent.Tools.WebReader as WebReader
 import qualified Omni.Agent.Tools.WebSearch as WebSearch
@@ -776,9 +776,8 @@ handleAuthorizedMessage tgConfig provider engineCfg msg uid userName chatId = do
 
   -- Check for special commands before LLM processing
   outreachHandled <- handleOutreachCommand tgConfig chatId threadId msgText
-  workOnHandled <- if outreachHandled then pure True else handleWorkOnCommand chatId threadId userName msgText
 
-  unless workOnHandled <| handleAuthorizedMessageContinued tgConfig provider engineCfg msg uid userName chatId
+  unless outreachHandled <| handleAuthorizedMessageContinued tgConfig provider engineCfg msg uid userName chatId
 
 handleAuthorizedMessageContinued ::
   Types.TelegramConfig ->
@@ -962,9 +961,8 @@ handleAuthorizedMessageBatch tgConfig provider engineCfg msg uid userName chatId
 
   -- Check for special commands before LLM processing
   outreachHandled <- handleOutreachCommand tgConfig chatId threadId batchedText
-  workOnHandled <- if outreachHandled then pure True else handleWorkOnCommand chatId threadId userName batchedText
 
-  unless workOnHandled <| handleAuthorizedMessageBatchContinued tgConfig provider engineCfg msg uid userName chatId batchedText
+  unless outreachHandled <| handleAuthorizedMessageBatchContinued tgConfig provider engineCfg msg uid userName chatId batchedText
 
 handleAuthorizedMessageBatchContinued ::
   Types.TelegramConfig ->
@@ -1319,7 +1317,11 @@ processEngagedMessage tgConfig provider engineCfg msg uid userName chatId userMe
       auditLogTools =
         [AvaLogs.readAvaLogsTool | isBenAuthorized userName]
           <> [AvaLogs.searchChatHistoryTool]
-      tools = memoryTools <> searchTools <> webReaderTools <> pdfTools <> notesTools <> calendarTools <> todoTools <> messageTools <> hledgerTools <> emailTools <> pythonTools <> httpTools <> outreachTools <> feedbackTools <> fileTools <> skillsTools <> subagentToolList <> auditLogTools
+      taskTools =
+        if isBenAuthorized userName
+          then [Tasks.workOnTaskTool, Tasks.listReadyTasksTool]
+          else []
+      tools = memoryTools <> searchTools <> webReaderTools <> pdfTools <> notesTools <> calendarTools <> todoTools <> messageTools <> hledgerTools <> emailTools <> pythonTools <> httpTools <> outreachTools <> feedbackTools <> fileTools <> skillsTools <> subagentToolList <> auditLogTools <> taskTools
 
   let agentCfg =
         Engine.defaultAgentConfig
@@ -1693,50 +1695,4 @@ formatDraftForReview draft =
       "reply `/approve " <> Outreach.draftId draft <> "` or `/reject " <> Outreach.draftId draft <> " [reason]`"
     ]
 
--- | Handle "work on t-XXX" commands to run the orchestrator
--- Only available to authorized users (Ben)
-handleWorkOnCommand :: Int -> Maybe Int -> Text -> Text -> IO Bool
-handleWorkOnCommand chatId mThreadId userName msgText =
-  case Orchestrator.parseWorkOnCommand msgText of
-    Nothing -> pure False
-    Just taskId -> do
-      if not (isBenAuthorized userName)
-        then do
-          _ <- Messages.enqueueImmediate Nothing chatId mThreadId "sorry, only ben can run the orchestrator" (Just "system") Nothing
-          pure True
-        else do
-          putText <| "Starting orchestrator for task: " <> taskId
-          -- Run orchestrator in background so we don't block the message loop
-          _ <- forkIO <| runOrchestratorForChat chatId mThreadId taskId
-          pure True
 
--- | Run the orchestrator and report results to chat
-runOrchestratorForChat :: Int -> Maybe Int -> Text -> IO ()
-runOrchestratorForChat chatId mThreadId taskId = do
-  let scriptPath = "/home/ava/omni/Omni/Ide/pi-orchestrate.sh"
-      onProgress msg = do
-        putText <| "Orchestrator progress: " <> msg
-        void <| Messages.enqueueImmediate Nothing chatId mThreadId msg (Just "orchestrator") Nothing
-
-      cfg =
-        Orchestrator.OrchestratorConfig
-          { Orchestrator.orchTaskId = taskId,
-            Orchestrator.orchMaxIterations = 3,
-            Orchestrator.orchScriptPath = scriptPath,
-            Orchestrator.orchOnProgress = onProgress
-          }
-
-  result <- Orchestrator.runOrchestrator cfg
-
-  let finalMsg = case result of
-        Orchestrator.OrchSuccess commit ->
-          "✅ done! " <> taskId <> " completed.\ncommit: " <> commit
-        Orchestrator.OrchNeedsHelp reason ->
-          "❌ " <> taskId <> " needs help: " <> reason
-        Orchestrator.OrchFailed err ->
-          "❌ orchestrator failed: " <> err
-        Orchestrator.OrchInvalidTask err ->
-          "⚠️ " <> err
-
-  _ <- Messages.enqueueImmediate Nothing chatId mThreadId finalMsg (Just "orchestrator") Nothing
-  pure ()
diff --git a/Omni/Agent/Tools/Tasks.hs b/Omni/Agent/Tools/Tasks.hs
new file mode 100644
index 00000000..fa21af87
--- /dev/null
+++ b/Omni/Agent/Tools/Tasks.hs
@@ -0,0 +1,150 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+-- | Task tools for the coding orchestrator.
+--
+-- Allows Ava to work on tasks from the task database by spawning
+-- the pi-orchestrate coder/reviewer loop.
+--
+-- : out omni-agent-tools-tasks
+-- : dep aeson
+-- : dep process
+module Omni.Agent.Tools.Tasks
+  ( -- * Tools
+    workOnTaskTool,
+    listReadyTasksTool,
+
+    -- * Testing
+    main,
+    test,
+  )
+where
+
+import Alpha
+import Data.Aeson ((.=), (.:))
+import qualified Data.Aeson as Aeson
+import qualified Data.Text as Text
+
+import qualified Omni.Agent.Engine as Engine
+import qualified Omni.Task.Core as Task
+import qualified Omni.Test as Test
+import qualified System.Process as Process
+
+-- | Arguments for work_on_task tool
+newtype WorkOnTaskArgs = WorkOnTaskArgs
+  { taskId :: Text
+  }
+  deriving (Show)
+
+instance Aeson.FromJSON WorkOnTaskArgs where
+  parseJSON = Aeson.withObject "WorkOnTaskArgs" <| \v ->
+    WorkOnTaskArgs <$> v .: "task_id"
+
+main :: IO ()
+main = Test.run test
+
+test :: Test.Tree
+test =
+  Test.group
+    "Omni.Agent.Tools.Tasks"
+    [ Test.unit "placeholder" <| pure ()
+    ]
+
+-- | Tool to start working on a task
+workOnTaskTool :: Engine.Tool
+workOnTaskTool =
+  Engine.Tool
+    { Engine.toolName = "work_on_task",
+      Engine.toolDescription =
+        "Start working on a coding task. This spawns the coder/reviewer loop "
+          <> "which will make changes, review them, and commit if approved. "
+          <> "Use this when the user asks you to work on, implement, or fix a task. "
+          <> "The task ID should be in format 't-123' or 't-280.2.1'.",
+      Engine.toolJsonSchema =
+        Aeson.object
+          [ "type" .= ("object" :: Text),
+            "properties"
+              .= Aeson.object
+                [ "task_id"
+                    .= Aeson.object
+                      [ "type" .= ("string" :: Text),
+                        "description" .= ("Task ID to work on, e.g. 't-291' or 't-280.2.1'" :: Text)
+                      ]
+                ],
+            "required" .= (["task_id"] :: [Text])
+          ],
+      Engine.toolExecute = executeWorkOnTask
+    }
+
+executeWorkOnTask :: Aeson.Value -> IO Aeson.Value
+executeWorkOnTask v = do
+  case Aeson.fromJSON v of
+    Aeson.Error e ->
+      pure <| Aeson.object ["error" .= Text.pack e]
+    Aeson.Success (args :: WorkOnTaskArgs) -> do
+      let tid = taskId args
+      -- Validate task ID format
+      if not ("t-" `Text.isPrefixOf` tid)
+        then pure <| Aeson.object ["error" .= ("Invalid task ID format: " <> tid <> ". Expected format: t-123")]
+        else do
+          -- Check task exists using Omni.Task.Core
+          allTasks <- Task.loadTasks
+          case Task.findTask tid allTasks of
+            Nothing ->
+              pure <| Aeson.object ["error" .= ("Task not found: " <> tid)]
+            Just task -> do
+              let status = Task.taskStatus task
+              -- Check task is in workable state
+              if status `notElem` [Task.Open, Task.InProgress, Task.Draft]
+                then pure <| Aeson.object ["error" .= ("Task " <> tid <> " is not in a workable state (status: " <> tshow status <> ")")]
+                else do
+                  -- Spawn orchestrator in background
+                  _ <-
+                    Process.spawnProcess
+                      "/home/ava/omni/Omni/Ide/pi-orchestrate.sh"
+                      [Text.unpack tid]
+                  pure <|
+                    Aeson.object
+                      [ "success" .= True,
+                        "task_id" .= tid,
+                        "title" .= Task.taskTitle task,
+                        "message" .= ("Started coder/reviewer loop for " <> tid <> ": " <> Task.taskTitle task)
+                      ]
+
+-- | Tool to list tasks ready for work
+listReadyTasksTool :: Engine.Tool
+listReadyTasksTool =
+  Engine.Tool
+    { Engine.toolName = "list_ready_tasks",
+      Engine.toolDescription =
+        "List coding tasks that are ready to be worked on. "
+          <> "Shows tasks from the task database that have no blocking dependencies.",
+      Engine.toolJsonSchema =
+        Aeson.object
+          [ "type" .= ("object" :: Text),
+            "properties" .= Aeson.object [],
+            "required" .= ([] :: [Text])
+          ],
+      Engine.toolExecute = executeListReadyTasks
+    }
+
+executeListReadyTasks :: Aeson.Value -> IO Aeson.Value
+executeListReadyTasks _ = do
+  readyTasks <- Task.getReadyTasks
+  let formatted = map formatTask readyTasks
+  pure <|
+    Aeson.object
+      [ "success" .= True,
+        "count" .= length readyTasks,
+        "tasks" .= formatted
+      ]
+  where
+    formatTask t =
+      Aeson.object
+        [ "id" .= Task.taskId t,
+          "title" .= Task.taskTitle t,
+          "priority" .= tshow (Task.taskPriority t),
+          "namespace" .= Task.taskNamespace t
+        ]