agent/pkg/agentic/task.go
Virgil 75fc9d4bf4 fix(agentic): add namespaced MCP aliases for core tools
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 07:11:13 +00:00

339 lines
12 KiB
Go

// SPDX-License-Identifier: EUPL-1.2
package agentic
import (
"context"
"time"
core "dappco.re/go/core"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// input := agentic.TaskUpdateInput{PlanSlug: "my-plan-abc123", PhaseOrder: 1, TaskIdentifier: "1", File: "pkg/agentic/task.go", Line: 128}
type TaskUpdateInput struct {
PlanSlug string `json:"plan_slug"`
PhaseOrder int `json:"phase_order"`
TaskIdentifier any `json:"task_identifier"`
Status string `json:"status,omitempty"`
Notes string `json:"notes,omitempty"`
Priority string `json:"priority,omitempty"`
Category string `json:"category,omitempty"`
File string `json:"file,omitempty"`
Line int `json:"line,omitempty"`
FileRef string `json:"file_ref,omitempty"`
LineRef int `json:"line_ref,omitempty"`
}
// input := agentic.TaskToggleInput{PlanSlug: "my-plan-abc123", PhaseOrder: 1, TaskIdentifier: 1}
type TaskToggleInput struct {
PlanSlug string `json:"plan_slug"`
PhaseOrder int `json:"phase_order"`
TaskIdentifier any `json:"task_identifier"`
}
// input := agentic.TaskCreateInput{PlanSlug: "my-plan-abc123", PhaseOrder: 1, Title: "Review imports", File: "pkg/agentic/task.go", Line: 153}
type TaskCreateInput struct {
PlanSlug string `json:"plan_slug"`
PhaseOrder int `json:"phase_order"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Status string `json:"status,omitempty"`
Notes string `json:"notes,omitempty"`
Priority string `json:"priority,omitempty"`
Category string `json:"category,omitempty"`
File string `json:"file,omitempty"`
Line int `json:"line,omitempty"`
FileRef string `json:"file_ref,omitempty"`
LineRef int `json:"line_ref,omitempty"`
}
// out := agentic.TaskOutput{Success: true, Task: agentic.PlanTask{ID: "1", Title: "Review imports", File: "pkg/agentic/task.go", Line: 128}}
type TaskOutput struct {
Success bool `json:"success"`
Task PlanTask `json:"task"`
}
// out := agentic.TaskCreateOutput{Success: true, Task: agentic.PlanTask{ID: "3", Title: "Review imports", File: "pkg/agentic/task.go", Line: 153}}
type TaskCreateOutput struct {
Success bool `json:"success"`
Task PlanTask `json:"task"`
}
// result := c.Action("task.create").Run(ctx, core.NewOptions(
//
// core.Option{Key: "plan_slug", Value: "my-plan-abc123"},
// core.Option{Key: "phase_order", Value: 1},
// core.Option{Key: "title", Value: "Review imports"},
//
// ))
func (s *PrepSubsystem) handleTaskCreate(ctx context.Context, options core.Options) core.Result {
_, output, err := s.taskCreate(ctx, nil, TaskCreateInput{
PlanSlug: optionStringValue(options, "plan_slug", "plan", "slug"),
PhaseOrder: optionIntValue(options, "phase_order", "phase"),
Title: optionStringValue(options, "title", "task", "_arg"),
Description: optionStringValue(options, "description"),
Status: optionStringValue(options, "status"),
Notes: optionStringValue(options, "notes"),
Priority: optionStringValue(options, "priority"),
Category: optionStringValue(options, "category"),
File: optionStringValue(options, "file"),
Line: optionIntValue(options, "line"),
FileRef: optionStringValue(options, "file_ref", "file-ref"),
LineRef: optionIntValue(options, "line_ref", "line-ref"),
})
if err != nil {
return core.Result{Value: err, OK: false}
}
return core.Result{Value: output, OK: true}
}
// result := c.Action("task.update").Run(ctx, core.NewOptions(core.Option{Key: "plan_slug", Value: "my-plan-abc123"}))
func (s *PrepSubsystem) handleTaskUpdate(ctx context.Context, options core.Options) core.Result {
_, output, err := s.taskUpdate(ctx, nil, TaskUpdateInput{
PlanSlug: optionStringValue(options, "plan_slug", "plan", "slug"),
PhaseOrder: optionIntValue(options, "phase_order", "phase"),
TaskIdentifier: optionAnyValue(options, "task_identifier", "task"),
Status: optionStringValue(options, "status"),
Notes: optionStringValue(options, "notes"),
Priority: optionStringValue(options, "priority"),
Category: optionStringValue(options, "category"),
File: optionStringValue(options, "file"),
Line: optionIntValue(options, "line"),
FileRef: optionStringValue(options, "file_ref", "file-ref"),
LineRef: optionIntValue(options, "line_ref", "line-ref"),
})
if err != nil {
return core.Result{Value: err, OK: false}
}
return core.Result{Value: output, OK: true}
}
// result := c.Action("task.toggle").Run(ctx, core.NewOptions(core.Option{Key: "plan_slug", Value: "my-plan-abc123"}))
func (s *PrepSubsystem) handleTaskToggle(ctx context.Context, options core.Options) core.Result {
_, output, err := s.taskToggle(ctx, nil, TaskToggleInput{
PlanSlug: optionStringValue(options, "plan_slug", "plan", "slug"),
PhaseOrder: optionIntValue(options, "phase_order", "phase"),
TaskIdentifier: optionAnyValue(options, "task_identifier", "task"),
})
if err != nil {
return core.Result{Value: err, OK: false}
}
return core.Result{Value: output, OK: true}
}
func (s *PrepSubsystem) registerTaskTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{
Name: "task_create",
Description: "Create a plan task by plan slug and phase order.",
}, s.taskCreate)
mcp.AddTool(server, &mcp.Tool{
Name: "agentic_task_create",
Description: "Create a plan task by plan slug and phase order.",
}, s.taskCreate)
mcp.AddTool(server, &mcp.Tool{
Name: "task_update",
Description: "Update a plan task status or notes by plan slug, phase order, and task identifier.",
}, s.taskUpdate)
mcp.AddTool(server, &mcp.Tool{
Name: "agentic_task_update",
Description: "Update a plan task status or notes by plan slug, phase order, and task identifier.",
}, s.taskUpdate)
mcp.AddTool(server, &mcp.Tool{
Name: "task_toggle",
Description: "Toggle a plan task between pending and completed.",
}, s.taskToggle)
mcp.AddTool(server, &mcp.Tool{
Name: "agentic_task_toggle",
Description: "Toggle a plan task between pending and completed.",
}, s.taskToggle)
}
func (s *PrepSubsystem) taskUpdate(_ context.Context, _ *mcp.CallToolRequest, input TaskUpdateInput) (*mcp.CallToolResult, TaskOutput, error) {
if input.Status != "" && !validTaskStatus(input.Status) {
return nil, TaskOutput{}, core.E("taskUpdate", core.Concat("invalid status: ", input.Status), nil)
}
if taskIdentifierValue(input.TaskIdentifier) == "" {
return nil, TaskOutput{}, core.E("taskUpdate", "task_identifier is required", nil)
}
plan, phaseIndex, taskIndex, err := planTaskByIdentifier(PlansRoot(), input.PlanSlug, input.PhaseOrder, input.TaskIdentifier)
if err != nil {
return nil, TaskOutput{}, err
}
if input.Status != "" {
plan.Phases[phaseIndex].Tasks[taskIndex].Status = input.Status
}
if notes := core.Trim(input.Notes); notes != "" {
plan.Phases[phaseIndex].Tasks[taskIndex].Notes = notes
}
if priority := core.Trim(input.Priority); priority != "" {
plan.Phases[phaseIndex].Tasks[taskIndex].Priority = priority
}
if category := core.Trim(input.Category); category != "" {
plan.Phases[phaseIndex].Tasks[taskIndex].Category = category
}
if file := core.Trim(input.File); file != "" {
plan.Phases[phaseIndex].Tasks[taskIndex].File = file
plan.Phases[phaseIndex].Tasks[taskIndex].FileRef = file
}
if fileRef := core.Trim(input.FileRef); fileRef != "" {
plan.Phases[phaseIndex].Tasks[taskIndex].FileRef = fileRef
plan.Phases[phaseIndex].Tasks[taskIndex].File = fileRef
}
if input.Line > 0 {
plan.Phases[phaseIndex].Tasks[taskIndex].Line = input.Line
plan.Phases[phaseIndex].Tasks[taskIndex].LineRef = input.Line
}
if input.LineRef > 0 {
plan.Phases[phaseIndex].Tasks[taskIndex].LineRef = input.LineRef
plan.Phases[phaseIndex].Tasks[taskIndex].Line = input.LineRef
}
plan.UpdatedAt = time.Now()
if result := writePlanResult(PlansRoot(), plan); !result.OK {
err, _ := result.Value.(error)
if err == nil {
err = core.E("taskUpdate", "failed to write plan", nil)
}
return nil, TaskOutput{}, err
}
return nil, TaskOutput{
Success: true,
Task: plan.Phases[phaseIndex].Tasks[taskIndex],
}, nil
}
func (s *PrepSubsystem) taskCreate(_ context.Context, _ *mcp.CallToolRequest, input TaskCreateInput) (*mcp.CallToolResult, TaskCreateOutput, error) {
if core.Trim(input.Title) == "" {
return nil, TaskCreateOutput{}, core.E("taskCreate", "title is required", nil)
}
if input.Status != "" && !validTaskStatus(input.Status) {
return nil, TaskCreateOutput{}, core.E("taskCreate", core.Concat("invalid status: ", input.Status), nil)
}
plan, phaseIndex, err := planPhaseByOrder(PlansRoot(), input.PlanSlug, input.PhaseOrder)
if err != nil {
return nil, TaskCreateOutput{}, err
}
tasks := phaseTaskList(plan.Phases[phaseIndex])
plan.Phases[phaseIndex].Tasks = tasks
nextIndex := len(tasks) + 1
newTask := PlanTask{
ID: core.Sprint(nextIndex),
Title: core.Trim(input.Title),
Description: core.Trim(input.Description),
Priority: core.Trim(input.Priority),
Category: core.Trim(input.Category),
Status: input.Status,
Notes: core.Trim(input.Notes),
File: core.Trim(input.File),
Line: input.Line,
FileRef: core.Trim(input.FileRef),
LineRef: input.LineRef,
}
newTask = normalisePlanTask(newTask, nextIndex)
plan.Phases[phaseIndex].Tasks = append(plan.Phases[phaseIndex].Tasks, newTask)
plan.UpdatedAt = time.Now()
if result := writePlanResult(PlansRoot(), plan); !result.OK {
err, _ := result.Value.(error)
if err == nil {
err = core.E("taskCreate", "failed to write plan", nil)
}
return nil, TaskCreateOutput{}, err
}
return nil, TaskCreateOutput{
Success: true,
Task: newTask,
}, nil
}
func (s *PrepSubsystem) taskToggle(_ context.Context, _ *mcp.CallToolRequest, input TaskToggleInput) (*mcp.CallToolResult, TaskOutput, error) {
if taskIdentifierValue(input.TaskIdentifier) == "" {
return nil, TaskOutput{}, core.E("taskToggle", "task_identifier is required", nil)
}
plan, phaseIndex, taskIndex, err := planTaskByIdentifier(PlansRoot(), input.PlanSlug, input.PhaseOrder, input.TaskIdentifier)
if err != nil {
return nil, TaskOutput{}, err
}
status := plan.Phases[phaseIndex].Tasks[taskIndex].Status
if status == "completed" {
plan.Phases[phaseIndex].Tasks[taskIndex].Status = "pending"
} else {
plan.Phases[phaseIndex].Tasks[taskIndex].Status = "completed"
}
plan.UpdatedAt = time.Now()
if result := writePlanResult(PlansRoot(), plan); !result.OK {
err, _ := result.Value.(error)
if err == nil {
err = core.E("taskToggle", "failed to write plan", nil)
}
return nil, TaskOutput{}, err
}
return nil, TaskOutput{
Success: true,
Task: plan.Phases[phaseIndex].Tasks[taskIndex],
}, nil
}
func planTaskByIdentifier(dir, planSlug string, phaseOrder int, taskIdentifier any) (*Plan, int, int, error) {
plan, phaseIndex, err := planPhaseByOrder(dir, planSlug, phaseOrder)
if err != nil {
return nil, 0, 0, err
}
tasks := phaseTaskList(plan.Phases[phaseIndex])
if len(tasks) == 0 {
return nil, 0, 0, core.E("planTaskByIdentifier", "phase has no tasks", nil)
}
plan.Phases[phaseIndex].Tasks = tasks
identifier := taskIdentifierValue(taskIdentifier)
if identifier == "" {
return nil, 0, 0, core.E("planTaskByIdentifier", "task_identifier is required", nil)
}
for index := range plan.Phases[phaseIndex].Tasks {
task := plan.Phases[phaseIndex].Tasks[index]
if task.ID == identifier || task.Title == identifier || core.Sprint(index+1) == identifier {
return plan, phaseIndex, index, nil
}
}
return nil, 0, 0, core.E("planTaskByIdentifier", core.Concat("task not found: ", identifier), nil)
}
func taskIdentifierValue(value any) string {
switch typed := value.(type) {
case string:
return core.Trim(typed)
case int:
return core.Sprint(typed)
case int64:
return core.Sprint(typed)
case float64:
return core.Sprint(int(typed))
}
return ""
}
func validTaskStatus(status string) bool {
switch status {
case "pending", "completed":
return true
}
return false
}