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>
191 lines
6.7 KiB
Go
191 lines
6.7 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// Task tools are nomos-LOCAL, not MCP tools. They are session-scoped, and the
|
|
// shared MCP server (api:8090/mcp) has no session id — so these are handled
|
|
// in-process by nomos, which knows the session/task and holds the store.
|
|
// buildTools appends these to the model's tool list; the agent loop routes a
|
|
// call whose name isTaskTool to handleTaskTool instead of the MCP client.
|
|
//
|
|
// Phase 3 ships complete_task; set_goal / propose_plan / update_plan_step /
|
|
// ask_operator land in later phases through the same mechanism.
|
|
|
|
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 " +
|
|
"verified done — or when you've genuinely failed or only partially " +
|
|
"succeeded. Sets the task's outcome and a one-line summary shown on the " +
|
|
"task board. Record what you learned with upsert_knowledge BEFORE " +
|
|
"completing, so future tasks on the same entities benefit.",
|
|
InputSchema: map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"outcome": map[string]any{
|
|
"type": "string",
|
|
"enum": []string{"success", "failure", "partial"},
|
|
"description": "Did the task achieve its goal?",
|
|
},
|
|
"summary": map[string]any{
|
|
"type": "string",
|
|
"description": "One line describing the result (shown on the task card).",
|
|
},
|
|
},
|
|
"required": []string{"outcome", "summary"},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func isTaskTool(name string) bool {
|
|
switch name {
|
|
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)
|
|
if outcome == "" {
|
|
outcome = "success"
|
|
}
|
|
if err := a.store.completeTask(ctx, sessionID, outcome, summary); err != nil {
|
|
return fmt.Sprintf("error completing task: %v", err), true
|
|
}
|
|
return fmt.Sprintf("Task marked %s: %s", outcome, summary), true
|
|
default:
|
|
return nil, false
|
|
}
|
|
}
|