commit 3f7bf9c974e83c54cbd7283fea1abe4c55ab4822
Author: Ben Sima <ben@bensima.com>
Date: Thu Jan 1 11:24:16 2026
Add comprehensive task management tools (t-295.1-8)
Added new agent tools for full task database access:
- show_task: View task details including description, comments, dependencies
- update_task_status: Change task status (open, in-progress, review, etc.)
- create_task: Create new tasks with parent, priority, namespace options
- add_task_comment: Add comments/progress notes to tasks
- list_tasks: Query tasks with status/priority/namespace filters
- task_stats: Get counts by status and priority
- task_tree: View subtask hierarchy for epics
All tools registered in Telegram.hs agent tool list.
Task-Id: t-295.1
Task-Id: t-295.2
Task-Id: t-295.3
Task-Id: t-295.4
Task-Id: t-295.5
Task-Id: t-295.7
Task-Id: t-295.8
diff --git a/Omni/Agent/Telegram.hs b/Omni/Agent/Telegram.hs
index 5f9ea196..8767a987 100644
--- a/Omni/Agent/Telegram.hs
+++ b/Omni/Agent/Telegram.hs
@@ -1361,7 +1361,16 @@ processEngagedMessage tgConfig provider engineCfg msg uid userName chatId userMe
Tasks.ttcChatId = chatId,
Tasks.ttcThreadId = Types.tmThreadId msg
}
- in [Tasks.workOnTaskTool taskCtx, Tasks.listReadyTasksTool]
+ in [ Tasks.workOnTaskTool taskCtx,
+ Tasks.listReadyTasksTool,
+ Tasks.showTaskTool,
+ Tasks.updateTaskStatusTool,
+ Tasks.createTaskTool,
+ Tasks.addTaskCommentTool,
+ Tasks.listTasksTool,
+ Tasks.taskStatsTool,
+ Tasks.taskTreeTool
+ ]
else []
tools = memoryTools <> searchTools <> webReaderTools <> pdfTools <> notesTools <> calendarTools <> todoTools <> messageTools <> hledgerTools <> emailTools <> pythonTools <> httpTools <> outreachTools <> feedbackTools <> fileTools <> skillsTools <> subagentToolList <> auditLogTools <> taskTools
diff --git a/Omni/Agent/Tools/Tasks.hs b/Omni/Agent/Tools/Tasks.hs
index ac86cfc9..591167c6 100644
--- a/Omni/Agent/Tools/Tasks.hs
+++ b/Omni/Agent/Tools/Tasks.hs
@@ -2,19 +2,26 @@
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE NoImplicitPrelude #-}
--- | Task tools for the coding orchestrator.
+-- | Task tools for the agent.
--
--- Allows Ava to work on tasks from the task database by spawning
--- the pi-orchestrate coder/reviewer loop with Telegram status updates.
+-- Allows the agent to work on and manage tasks from the task database.
--
-- : out omni-agent-tools-tasks
-- : dep aeson
-- : dep async
-- : dep process
+-- : dep time
module Omni.Agent.Tools.Tasks
- ( -- * Tools
+ ( -- * Task Management Tools
workOnTaskTool,
listReadyTasksTool,
+ showTaskTool,
+ updateTaskStatusTool,
+ createTaskTool,
+ addTaskCommentTool,
+ listTasksTool,
+ taskStatsTool,
+ taskTreeTool,
-- * Context for tools
TaskToolContext (..),
@@ -26,9 +33,11 @@ module Omni.Agent.Tools.Tasks
where
import Alpha
-import Data.Aeson ((.:), (.=))
+import Data.Aeson ((.:), (.:?), (.=))
import qualified Data.Aeson as Aeson
+import qualified Data.Aeson.KeyMap as KeyMap
import qualified Data.Text as Text
+-- import Data.Time (getCurrentTime)
import qualified Omni.Agent.Engine as Engine
import qualified Omni.Agent.Telegram.Orchestrator as Orchestrator
import qualified Omni.Agent.Telegram.Types as TgTypes
@@ -151,18 +160,518 @@ listReadyTasksTool =
executeListReadyTasks :: Aeson.Value -> IO Aeson.Value
executeListReadyTasks _ = do
readyTasks <- Task.getReadyTasks
- let formatted = map formatTask readyTasks
+ let formatted = map formatTaskBrief readyTasks
pure
<| Aeson.object
[ "success" .= True,
"count" .= length readyTasks,
"tasks" .= formatted
]
+
+-- | Tool to show task details (t-295.1)
+showTaskTool :: Engine.Tool
+showTaskTool =
+ Engine.Tool
+ { Engine.toolName = "show_task",
+ Engine.toolDescription =
+ "Get detailed information about a task including description, status, "
+ <> "dependencies, comments, and metadata.",
+ Engine.toolJsonSchema =
+ Aeson.object
+ [ "type" .= ("object" :: Text),
+ "properties"
+ .= Aeson.object
+ [ "task_id"
+ .= Aeson.object
+ [ "type" .= ("string" :: Text),
+ "description" .= ("Task ID, e.g. 't-123' or 't-280.2.1'" :: Text)
+ ]
+ ],
+ "required" .= (["task_id"] :: [Text])
+ ],
+ Engine.toolExecute = executeShowTask
+ }
+
+executeShowTask :: Aeson.Value -> IO Aeson.Value
+executeShowTask 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
+ allTasks <- Task.loadTasks
+ case Task.findTask tid allTasks of
+ Nothing ->
+ pure <| Aeson.object ["error" .= ("Task not found: " <> tid)]
+ Just task ->
+ pure <| formatTaskFull task
+
+-- | Tool to update task status (t-295.2)
+updateTaskStatusTool :: Engine.Tool
+updateTaskStatusTool =
+ Engine.Tool
+ { Engine.toolName = "update_task_status",
+ Engine.toolDescription =
+ "Change the status of a task. Valid statuses: open, in-progress, review, "
+ <> "needs-help, approved, done, draft.",
+ Engine.toolJsonSchema =
+ Aeson.object
+ [ "type" .= ("object" :: Text),
+ "properties"
+ .= Aeson.object
+ [ "task_id"
+ .= Aeson.object
+ [ "type" .= ("string" :: Text),
+ "description" .= ("Task ID to update" :: Text)
+ ],
+ "status"
+ .= Aeson.object
+ [ "type" .= ("string" :: Text),
+ "enum" .= (["open", "in-progress", "review", "needs-help", "approved", "done", "draft"] :: [Text]),
+ "description" .= ("New status for the task" :: Text)
+ ]
+ ],
+ "required" .= (["task_id", "status"] :: [Text])
+ ],
+ Engine.toolExecute = executeUpdateTaskStatus
+ }
+
+executeUpdateTaskStatus :: Aeson.Value -> IO Aeson.Value
+executeUpdateTaskStatus v = do
+ case v of
+ Aeson.Object obj -> do
+ let tidM = case KeyMap.lookup "task_id" obj of
+ Just (Aeson.String t) -> Just t
+ _ -> Nothing
+ statusM = case KeyMap.lookup "status" obj of
+ Just (Aeson.String s) -> Just s
+ _ -> Nothing
+ case (tidM, statusM) of
+ (Just tid, Just statusStr) -> do
+ case parseStatus statusStr of
+ Nothing ->
+ pure <| Aeson.object ["error" .= ("Invalid status: " <> statusStr)]
+ Just status -> do
+ result <- try @SomeException <| Task.updateTaskStatus tid status []
+ case result of
+ Left err ->
+ pure <| Aeson.object ["error" .= tshow err]
+ Right () -> do
+ -- Re-fetch to get the updated task for response
+ allTasks <- Task.loadTasks
+ let mTask = Task.findTask tid allTasks
+ pure
+ <| Aeson.object
+ [ "success" .= True,
+ "task_id" .= tid,
+ "new_status" .= tshow status,
+ "title" .= maybe "" Task.taskTitle mTask
+ ]
+ _ ->
+ pure <| Aeson.object ["error" .= ("Missing task_id or status" :: Text)]
+ _ ->
+ pure <| Aeson.object ["error" .= ("Invalid arguments" :: Text)]
+
+parseStatus :: Text -> Maybe Task.Status
+parseStatus "open" = Just Task.Open
+parseStatus "in-progress" = Just Task.InProgress
+parseStatus "review" = Just Task.Review
+parseStatus "needs-help" = Just Task.NeedsHelp
+parseStatus "approved" = Just Task.Approved
+parseStatus "done" = Just Task.Done
+parseStatus "draft" = Just Task.Draft
+parseStatus _ = Nothing
+
+-- | Tool to create a new task (t-295.3)
+createTaskTool :: Engine.Tool
+createTaskTool =
+ Engine.Tool
+ { Engine.toolName = "create_task",
+ Engine.toolDescription =
+ "Create a new task in the database. Can specify parent for subtasks, "
+ <> "priority, namespace, and description.",
+ Engine.toolJsonSchema =
+ Aeson.object
+ [ "type" .= ("object" :: Text),
+ "properties"
+ .= Aeson.object
+ [ "title"
+ .= Aeson.object
+ [ "type" .= ("string" :: Text),
+ "description" .= ("Task title (required)" :: Text)
+ ],
+ "description"
+ .= Aeson.object
+ [ "type" .= ("string" :: Text),
+ "description" .= ("Detailed description of the task" :: Text)
+ ],
+ "parent_id"
+ .= Aeson.object
+ [ "type" .= ("string" :: Text),
+ "description" .= ("Parent task ID for subtasks, e.g. 't-280'" :: Text)
+ ],
+ "priority"
+ .= Aeson.object
+ [ "type" .= ("string" :: Text),
+ "enum" .= (["P0", "P1", "P2", "P3"] :: [Text]),
+ "description" .= ("Task priority (default: P2)" :: Text)
+ ],
+ "namespace"
+ .= Aeson.object
+ [ "type" .= ("string" :: Text),
+ "description" .= ("Code namespace, e.g. 'Omni/Agent/Tools.hs'" :: Text)
+ ],
+ "discovered_from"
+ .= Aeson.object
+ [ "type" .= ("string" :: Text),
+ "description" .= ("Task ID this was discovered from (creates soft dependency)" :: Text)
+ ]
+ ],
+ "required" .= (["title"] :: [Text])
+ ],
+ Engine.toolExecute = executeCreateTask
+ }
+
+data CreateTaskArgs = CreateTaskArgs
+ { ctaTitle :: Text,
+ ctaDescription :: Maybe Text,
+ ctaParentId :: Maybe Text,
+ ctaPriority :: Maybe Text,
+ ctaNamespace :: Maybe Text,
+ ctaDiscoveredFrom :: Maybe Text
+ }
+ deriving (Show)
+
+instance Aeson.FromJSON CreateTaskArgs where
+ parseJSON =
+ Aeson.withObject "CreateTaskArgs" <| \v ->
+ (CreateTaskArgs </ (v .: "title"))
+ <*> (v .:? "description")
+ <*> (v .:? "parent_id")
+ <*> (v .:? "priority")
+ <*> (v .:? "namespace")
+ <*> (v .:? "discovered_from")
+
+executeCreateTask :: Aeson.Value -> IO Aeson.Value
+executeCreateTask v = do
+ case Aeson.fromJSON v of
+ Aeson.Error e ->
+ pure <| Aeson.object ["error" .= Text.pack e]
+ Aeson.Success (args :: CreateTaskArgs) -> do
+ let priority = case ctaPriority args of
+ Just "P0" -> Task.P0
+ Just "P1" -> Task.P1
+ Just "P3" -> Task.P3
+ _ -> Task.P2
+ description = fromMaybe "" (ctaDescription args)
+ -- Build dependencies list
+ deps = case ctaDiscoveredFrom args of
+ Just dfid -> [Task.Dependency dfid Task.DiscoveredFrom]
+ Nothing -> []
+ result <- try @SomeException <| Task.createTask
+ (ctaTitle args)
+ Task.WorkTask
+ (ctaParentId args)
+ (ctaNamespace args)
+ priority
+ Nothing -- complexity
+ deps
+ description
+ case result of
+ Left err ->
+ pure <| Aeson.object ["error" .= tshow err]
+ Right task ->
+ pure
+ <| Aeson.object
+ [ "success" .= True,
+ "task_id" .= Task.taskId task,
+ "title" .= Task.taskTitle task,
+ "message" .= ("Created task " <> Task.taskId task <> ": " <> Task.taskTitle task)
+ ]
+
+-- | Tool to add a comment to a task (t-295.4)
+addTaskCommentTool :: Engine.Tool
+addTaskCommentTool =
+ Engine.Tool
+ { Engine.toolName = "add_task_comment",
+ Engine.toolDescription =
+ "Add a comment to a task. Comments can include progress updates, "
+ <> "blockers, questions, or any relevant notes.",
+ Engine.toolJsonSchema =
+ Aeson.object
+ [ "type" .= ("object" :: Text),
+ "properties"
+ .= Aeson.object
+ [ "task_id"
+ .= Aeson.object
+ [ "type" .= ("string" :: Text),
+ "description" .= ("Task ID to comment on" :: Text)
+ ],
+ "comment"
+ .= Aeson.object
+ [ "type" .= ("string" :: Text),
+ "description" .= ("Comment text" :: Text)
+ ]
+ ],
+ "required" .= (["task_id", "comment"] :: [Text])
+ ],
+ Engine.toolExecute = executeAddTaskComment
+ }
+
+executeAddTaskComment :: Aeson.Value -> IO Aeson.Value
+executeAddTaskComment v = do
+ case v of
+ Aeson.Object obj -> do
+ let tidM = case KeyMap.lookup "task_id" obj of
+ Just (Aeson.String t) -> Just t
+ _ -> Nothing
+ commentM = case KeyMap.lookup "comment" obj of
+ Just (Aeson.String c) -> Just c
+ _ -> Nothing
+ case (tidM, commentM) of
+ (Just tid, Just comment) -> do
+ let author = Task.Agent Task.Engineer
+ result <- try @SomeException <| Task.addComment tid comment author
+ case result of
+ Left err ->
+ pure <| Aeson.object ["error" .= tshow err]
+ Right _ ->
+ pure
+ <| Aeson.object
+ [ "success" .= True,
+ "task_id" .= tid,
+ "message" .= ("Added comment to " <> tid)
+ ]
+ _ ->
+ pure <| Aeson.object ["error" .= ("Missing task_id or comment" :: Text)]
+ _ ->
+ pure <| Aeson.object ["error" .= ("Invalid arguments" :: Text)]
+
+-- | Tool to list tasks with filters (t-295.5)
+listTasksTool :: Engine.Tool
+listTasksTool =
+ Engine.Tool
+ { Engine.toolName = "list_tasks",
+ Engine.toolDescription =
+ "List tasks with optional filtering by status, priority, or namespace. "
+ <> "Returns up to 50 tasks matching the criteria.",
+ Engine.toolJsonSchema =
+ Aeson.object
+ [ "type" .= ("object" :: Text),
+ "properties"
+ .= Aeson.object
+ [ "status"
+ .= Aeson.object
+ [ "type" .= ("string" :: Text),
+ "enum" .= (["open", "in-progress", "review", "needs-help", "approved", "done", "draft"] :: [Text]),
+ "description" .= ("Filter by status" :: Text)
+ ],
+ "priority"
+ .= Aeson.object
+ [ "type" .= ("string" :: Text),
+ "enum" .= (["P0", "P1", "P2", "P3"] :: [Text]),
+ "description" .= ("Filter by priority" :: Text)
+ ],
+ "namespace"
+ .= Aeson.object
+ [ "type" .= ("string" :: Text),
+ "description" .= ("Filter by namespace prefix" :: Text)
+ ],
+ "limit"
+ .= Aeson.object
+ [ "type" .= ("integer" :: Text),
+ "description" .= ("Max results (default: 20, max: 50)" :: Text)
+ ]
+ ],
+ "required" .= ([] :: [Text])
+ ],
+ Engine.toolExecute = executeListTasks
+ }
+
+data ListTasksArgs = ListTasksArgs
+ { ltaStatus :: Maybe Text,
+ ltaPriority :: Maybe Text,
+ ltaNamespace :: Maybe Text,
+ ltaLimit :: Maybe Int
+ }
+ deriving (Show)
+
+instance Aeson.FromJSON ListTasksArgs where
+ parseJSON =
+ Aeson.withObject "ListTasksArgs" <| \v ->
+ (ListTasksArgs </ (v .:? "status"))
+ <*> (v .:? "priority")
+ <*> (v .:? "namespace")
+ <*> (v .:? "limit")
+
+executeListTasks :: Aeson.Value -> IO Aeson.Value
+executeListTasks v = do
+ case Aeson.fromJSON v of
+ Aeson.Error e ->
+ pure <| Aeson.object ["error" .= Text.pack e]
+ Aeson.Success (args :: ListTasksArgs) -> do
+ allTasks <- Task.loadTasks
+ let limit = min 50 (fromMaybe 20 (ltaLimit args))
+ statusFilter = case ltaStatus args +> parseStatus of
+ Just s -> filter (\t -> Task.taskStatus t == s)
+ Nothing -> identity
+ priorityFilter = case ltaPriority args +> parsePriority of
+ Just p -> filter (\t -> Task.taskPriority t == p)
+ Nothing -> identity
+ namespaceFilter = case ltaNamespace args of
+ Just ns -> filter (\t -> maybe False (Text.isPrefixOf ns) (Task.taskNamespace t))
+ Nothing -> identity
+ filtered = take limit <| namespaceFilter <| priorityFilter <| statusFilter allTasks
+ formatted = map formatTaskBrief filtered
+ pure
+ <| Aeson.object
+ [ "success" .= True,
+ "count" .= length formatted,
+ "tasks" .= formatted
+ ]
+
+parsePriority :: Text -> Maybe Task.Priority
+parsePriority "P0" = Just Task.P0
+parsePriority "P1" = Just Task.P1
+parsePriority "P2" = Just Task.P2
+parsePriority "P3" = Just Task.P3
+parsePriority _ = Nothing
+
+-- | Tool to get task statistics (t-295.8)
+taskStatsTool :: Engine.Tool
+taskStatsTool =
+ Engine.Tool
+ { Engine.toolName = "task_stats",
+ Engine.toolDescription =
+ "Get overall task statistics including counts by status and priority.",
+ Engine.toolJsonSchema =
+ Aeson.object
+ [ "type" .= ("object" :: Text),
+ "properties" .= Aeson.object [],
+ "required" .= ([] :: [Text])
+ ],
+ Engine.toolExecute = executeTaskStats
+ }
+
+executeTaskStats :: Aeson.Value -> IO Aeson.Value
+executeTaskStats _ = do
+ allTasks <- Task.loadTasks
+ let byStatus status = length (filter (\t -> Task.taskStatus t == status) allTasks)
+ byPriority priority = length (filter (\t -> Task.taskPriority t == priority) allTasks)
+ pure
+ <| Aeson.object
+ [ "success" .= True,
+ "total" .= length allTasks,
+ "by_status"
+ .= Aeson.object
+ [ "open" .= byStatus Task.Open,
+ "in_progress" .= byStatus Task.InProgress,
+ "review" .= byStatus Task.Review,
+ "needs_help" .= byStatus Task.NeedsHelp,
+ "approved" .= byStatus Task.Approved,
+ "done" .= byStatus Task.Done,
+ "draft" .= byStatus Task.Draft
+ ],
+ "by_priority"
+ .= Aeson.object
+ [ "P0" .= byPriority Task.P0,
+ "P1" .= byPriority Task.P1,
+ "P2" .= byPriority Task.P2,
+ "P3" .= byPriority Task.P3
+ ]
+ ]
+
+-- | Tool to get task tree/hierarchy (t-295.7)
+taskTreeTool :: Engine.Tool
+taskTreeTool =
+ Engine.Tool
+ { Engine.toolName = "task_tree",
+ Engine.toolDescription =
+ "Get the subtask hierarchy for a task (epic). Shows all children and their status.",
+ Engine.toolJsonSchema =
+ Aeson.object
+ [ "type" .= ("object" :: Text),
+ "properties"
+ .= Aeson.object
+ [ "task_id"
+ .= Aeson.object
+ [ "type" .= ("string" :: Text),
+ "description" .= ("Parent task ID to show tree for" :: Text)
+ ]
+ ],
+ "required" .= (["task_id"] :: [Text])
+ ],
+ Engine.toolExecute = executeTaskTree
+ }
+
+executeTaskTree :: Aeson.Value -> IO Aeson.Value
+executeTaskTree 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
+ allTasks <- Task.loadTasks
+ case Task.findTask tid allTasks of
+ Nothing ->
+ pure <| Aeson.object ["error" .= ("Task not found: " <> tid)]
+ Just parentTask -> do
+ let children = filter (\t -> Task.taskParent t == Just tid) allTasks
+ formatChild t =
+ Aeson.object
+ [ "id" .= Task.taskId t,
+ "title" .= Task.taskTitle t,
+ "status" .= tshow (Task.taskStatus t)
+ ]
+ pure
+ <| Aeson.object
+ [ "success" .= True,
+ "task_id" .= tid,
+ "title" .= Task.taskTitle parentTask,
+ "status" .= tshow (Task.taskStatus parentTask),
+ "children" .= map formatChild children,
+ "child_count" .= length children
+ ]
+
+-- | Format a task for brief listing
+formatTaskBrief :: Task.Task -> Aeson.Value
+formatTaskBrief t =
+ Aeson.object
+ [ "id" .= Task.taskId t,
+ "title" .= Task.taskTitle t,
+ "status" .= tshow (Task.taskStatus t),
+ "priority" .= tshow (Task.taskPriority t),
+ "namespace" .= Task.taskNamespace t
+ ]
+
+-- | Format a task with full details
+formatTaskFull :: Task.Task -> Aeson.Value
+formatTaskFull t =
+ Aeson.object
+ [ "success" .= True,
+ "id" .= Task.taskId t,
+ "title" .= Task.taskTitle t,
+ "description" .= Task.taskDescription t,
+ "status" .= tshow (Task.taskStatus t),
+ "priority" .= tshow (Task.taskPriority t),
+ "namespace" .= Task.taskNamespace t,
+ "parent" .= Task.taskParent t,
+ "dependencies" .= map formatDep (Task.taskDependencies t),
+ "comments" .= map formatComment (Task.taskComments t),
+ "type" .= tshow (Task.taskType t),
+ "complexity" .= Task.taskComplexity t,
+ "created_at" .= Task.taskCreatedAt t,
+ "updated_at" .= Task.taskUpdatedAt t
+ ]
where
- formatTask t =
+ formatDep d =
+ Aeson.object
+ [ "id" .= Task.depId d,
+ "type" .= tshow (Task.depType d)
+ ]
+ formatComment c =
Aeson.object
- [ "id" .= Task.taskId t,
- "title" .= Task.taskTitle t,
- "priority" .= tshow (Task.taskPriority t),
- "namespace" .= Task.taskNamespace t
+ [ "author" .= tshow (Task.commentAuthor c),
+ "time" .= Task.commentCreatedAt c,
+ "text" .= Task.commentText c
]