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
+ ]