The last backend piece: when the agent hits a decision only the operator can
make, it surfaces a structured question instead of guessing or stalling.
- ask_operator(prompt, why?, options?, context_entities?): nomos-local tool
that records a session_questions row, moves the task to awaiting_input, emits
question.raised, and ENDS the turn (the agent loop returns after it, so the
agent can't barrel past its own question). The prompt becomes the assistant's
visible message so the question also shows inline in the transcript.
- Two resume paths, both close the question + emit question.answered + return
the task to executing:
- Panel: POST /sessions/{id}/questions/{qid}/answer → resumes the agent in the
background with the answer injected (reusing the continuation machinery,
refactored continueSession → resumeSession). Returns 202; the reply lands via
message polling.
- Chat reply: the next chat message on a task with an open question IS the
answer — auto-closed in handleChat; the turn itself is the resume.
Verified end-to-end: forcing a decision paused the task at awaiting_input with
the structured question (prompt/why/options/entities); a panel answer resumed
the agent (it acknowledged host:strong and continued); a plain chat reply
auto-closed a second question. Cleanup + tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
253 lines
9.1 KiB
Go
253 lines
9.1 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: "ask_operator",
|
|
Description: "Ask the operator a question when you hit a real decision only " +
|
|
"they can make — an ambiguous target, a trade-off, missing information, " +
|
|
"or a destructive choice not already approved. This pins a structured " +
|
|
"question card in the context panel (with your options and the entities " +
|
|
"involved) and PAUSES the task until they answer; their answer resumes " +
|
|
"you automatically. Do NOT use it for things you can determine yourself " +
|
|
"with tools — only for genuine decisions.",
|
|
InputSchema: map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"prompt": map[string]any{"type": "string", "description": "The question, stated plainly."},
|
|
"why": map[string]any{"type": "string", "description": "Why you're asking / what's at stake."},
|
|
"options": map[string]any{
|
|
"type": "array", "items": map[string]any{"type": "string"},
|
|
"description": "The choices, if it's a pick-one decision.",
|
|
},
|
|
"context_entities": map[string]any{
|
|
"type": "array", "items": map[string]any{"type": "string"},
|
|
"description": "Entity slugs relevant to the decision (shown as chips).",
|
|
},
|
|
},
|
|
"required": []string{"prompt"},
|
|
},
|
|
},
|
|
{
|
|
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", "ask_operator", "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
|
|
}
|
|
}
|
|
|
|
// toStringSlice coerces a JSON tool-arg array to a non-empty []string.
|
|
func toStringSlice(v any) []string {
|
|
arr, ok := v.([]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(arr))
|
|
for _, e := range arr {
|
|
if s, ok := e.(string); ok && strings.TrimSpace(s) != "" {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 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 "ask_operator":
|
|
prompt, _ := args["prompt"].(string)
|
|
if strings.TrimSpace(prompt) == "" {
|
|
return "error: ask_operator needs a prompt", true
|
|
}
|
|
qctx := map[string]any{}
|
|
if why, _ := args["why"].(string); strings.TrimSpace(why) != "" {
|
|
qctx["why"] = why
|
|
}
|
|
if opts := toStringSlice(args["options"]); len(opts) > 0 {
|
|
qctx["options"] = opts
|
|
}
|
|
if ents := toStringSlice(args["context_entities"]); len(ents) > 0 {
|
|
qctx["entities"] = ents
|
|
}
|
|
if _, err := a.store.askOperator(ctx, sessionID, prompt, qctx); err != nil {
|
|
return fmt.Sprintf("error posting question: %v", err), true
|
|
}
|
|
return "Question posted to the operator; the task is paused until they answer. " +
|
|
"Do not continue or call more tools — end your turn now and wait for their answer.", 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
|
|
}
|
|
}
|