feat(tasks): phase 4 — structured plan steps (set_goal/propose_plan/update_plan_step)
Gives a task a legible, live-advancing plan via three more nomos-local tools: - set_goal(goal): records the task goal, status → planning, emits goal.set. - propose_plan(steps[]): persists ordered steps (clean replace for v1 — a revision starts a new list), status → executing, emits plan.proposed with the persisted steps (id+seq) so the panel can address them. - update_plan_step(seq, status, execution_id?): advances a step, stamping started_at/finished_at, emits plan.step.started/finished. Anchors the event to the step's target entity when it has one. Belt-and-suspenders: when an execution linked to a step reaches a terminal state, the api auto-closes the step (closePlanStepForExecution in emitExecutionEvent) and emits plan.step.finished — so the board stays honest even if the agent forgets to close a step it started. Verified end-to-end: a goal-driven task fired goal.set → plan.proposed → 2× step.started/finished → task.status on the SSE stream; both steps persisted done with start/finish timestamps; status progressed planning→executing→done. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Task tools are nomos-LOCAL, not MCP tools. They are session-scoped, and the
|
||||
@@ -16,6 +17,64 @@ import (
|
||||
|
||||
func taskToolDefs() []toolDef {
|
||||
return []toolDef{
|
||||
{
|
||||
Name: "set_goal",
|
||||
Description: "State the goal of this task in one sentence, as early as you " +
|
||||
"can. This is what the task is trying to achieve (e.g. 'Deploy TypeType " +
|
||||
"as an LXC on strong'); it heads the task on the board and the context " +
|
||||
"panel. Call it once you understand what the operator wants.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"goal": map[string]any{"type": "string", "description": "The task's goal, one sentence."},
|
||||
},
|
||||
"required": []string{"goal"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "propose_plan",
|
||||
Description: "Lay out the ordered steps you'll take to reach the goal. The " +
|
||||
"operator sees these in the context panel and watches them progress. " +
|
||||
"Call this before you start executing (after gathering what you need); " +
|
||||
"re-call it to revise the plan. As you work, call update_plan_step to " +
|
||||
"advance each one.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"steps": map[string]any{
|
||||
"type": "array",
|
||||
"description": "Ordered steps, first to last.",
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"title": map[string]any{"type": "string", "description": "Short imperative step title (e.g. 'Create the LXC')."},
|
||||
"detail": map[string]any{"type": "string", "description": "Optional one-line detail."},
|
||||
"target_slug": map[string]any{"type": "string", "description": "Optional entity slug this step acts on (e.g. lxc:typetype)."},
|
||||
},
|
||||
"required": []string{"title"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": []string{"steps"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update_plan_step",
|
||||
Description: "Advance a plan step as you work it. Set status to 'running' when " +
|
||||
"you start it (pass execution_id if the step queued a gated action, so " +
|
||||
"the board can auto-close it when that finishes), then 'done' / 'failed' " +
|
||||
"/ 'skipped' / 'blocked' when it resolves. Keeps the operator's progress " +
|
||||
"view honest.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"seq": map[string]any{"type": "integer", "description": "1-based step number from propose_plan."},
|
||||
"status": map[string]any{"type": "string", "enum": []string{"running", "done", "failed", "skipped", "blocked"}, "description": "New status for the step."},
|
||||
"execution_id": map[string]any{"type": "string", "description": "Optional execution UUID this step is running, so it auto-closes on completion."},
|
||||
},
|
||||
"required": []string{"seq", "status"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "complete_task",
|
||||
Description: "Mark the current task finished. Call this once the goal is " +
|
||||
@@ -44,18 +103,77 @@ func taskToolDefs() []toolDef {
|
||||
|
||||
func isTaskTool(name string) bool {
|
||||
switch name {
|
||||
case "complete_task":
|
||||
case "set_goal", "propose_plan", "update_plan_step", "complete_task":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// toInt coerces a JSON tool-arg number (float64 after unmarshal) to int.
|
||||
func toInt(v any) int {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n)
|
||||
case int:
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// handleTaskTool executes a nomos-local task tool. Returns (result, true) if it
|
||||
// handled the call, or (nil, false) if name is not a local task tool (so the
|
||||
// caller forwards it to the MCP client).
|
||||
func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args map[string]any) (any, bool) {
|
||||
switch name {
|
||||
case "set_goal":
|
||||
goal, _ := args["goal"].(string)
|
||||
if strings.TrimSpace(goal) == "" {
|
||||
return "error: set_goal needs a goal", true
|
||||
}
|
||||
if err := a.store.setGoal(ctx, sessionID, goal); err != nil {
|
||||
return fmt.Sprintf("error setting goal: %v", err), true
|
||||
}
|
||||
return "Goal set: " + goal, true
|
||||
|
||||
case "propose_plan":
|
||||
raw, _ := args["steps"].([]any)
|
||||
var steps []planStepInput
|
||||
for _, r := range raw {
|
||||
m, ok := r.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
title, _ := m["title"].(string)
|
||||
if strings.TrimSpace(title) == "" {
|
||||
continue
|
||||
}
|
||||
detail, _ := m["detail"].(string)
|
||||
target, _ := m["target_slug"].(string)
|
||||
steps = append(steps, planStepInput{Title: title, Detail: detail, TargetSlug: target})
|
||||
}
|
||||
if len(steps) == 0 {
|
||||
return "error: propose_plan needs at least one step with a title", true
|
||||
}
|
||||
persisted, err := a.store.proposePlan(ctx, sessionID, steps)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error proposing plan: %v", err), true
|
||||
}
|
||||
return fmt.Sprintf("Plan set: %d step(s). Execute them now, marking each with update_plan_step as you go.", len(persisted)), true
|
||||
|
||||
case "update_plan_step":
|
||||
seq := toInt(args["seq"])
|
||||
status, _ := args["status"].(string)
|
||||
execID, _ := args["execution_id"].(string)
|
||||
if seq <= 0 || status == "" {
|
||||
return "error: update_plan_step needs seq (>=1) and status", true
|
||||
}
|
||||
if err := a.store.updatePlanStep(ctx, sessionID, seq, status, execID); err != nil {
|
||||
return fmt.Sprintf("error updating step %d: %v", seq, err), true
|
||||
}
|
||||
return fmt.Sprintf("Step %d → %s", seq, status), true
|
||||
|
||||
case "complete_task":
|
||||
outcome, _ := args["outcome"].(string)
|
||||
summary, _ := args["summary"].(string)
|
||||
|
||||
Reference in New Issue
Block a user