diff --git a/cmd/nomos/agent.go b/cmd/nomos/agent.go index f6ade55..886cdf5 100644 --- a/cmd/nomos/agent.go +++ b/cmd/nomos/agent.go @@ -419,6 +419,22 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s }) messages = append(messages, openai.ToolMessage(string(resultJSON), tc.ID)) slog.Info("nomos: tool success", "tool", tc.Function.Name, "ms", elapsed) + + // ask_operator pauses the task: the agent has posed a decision only + // the operator can make. End the turn here so it doesn't barrel past + // its own question — the answer (panel or chat reply) resumes it. + // The prompt becomes the assistant's visible message so the question + // also shows inline in the transcript. + if tc.Function.Name == "ask_operator" { + prompt, _ := args["prompt"].(string) + emit(agentEvent{Type: "text", Data: prompt, SessionID: sessionID}) + emit(agentEvent{Type: "done", Data: map[string]any{ + "session_id": sessionID, + "correlation_id": correlationID, + "iteration": i + 1, + }, SessionID: sessionID}) + return + } } } diff --git a/cmd/nomos/continue.go b/cmd/nomos/continue.go index e501bf3..0176942 100644 --- a/cmd/nomos/continue.go +++ b/cmd/nomos/continue.go @@ -82,17 +82,24 @@ func (a *agent) processContinuations(ctx context.Context) { // complaint this exists to fix — polling alone only helps if there's // something new to poll for. func (a *agent) continueSession(ctx context.Context, p pendingContinuation) { - note := buildContinuationNote(p) slog.Info("nomos: auto-continuing session", "session", p.SessionID, "execution", p.ExecID, "status", p.Status) + a.resumeSession(ctx, p.SessionID, buildContinuationNote(p)) +} +// resumeSession re-invokes the agent for a session with a system-injected note — +// a finished execution (continueSession) or an operator's answer to a question +// (handleAnswerQuestion) — persisting progress LIVE (a placeholder row updated +// in place as each tool call lands) so the frontend poller sees each step, +// instead of total silence until the whole resume concludes. +func (a *agent) resumeSession(ctx context.Context, sessionID, note string) { placeholder, _ := json.Marshal(map[string]any{ "role": "assistant", "text": "", "auto": true, }) - msgID, err := a.store.insertMessageReturningID(ctx, p.SessionID, "assistant", placeholder) + msgID, err := a.store.insertMessageReturningID(ctx, sessionID, "assistant", placeholder) if err != nil { - slog.Error("nomos: continuation placeholder insert failed", "session", p.SessionID, "error", err) + slog.Error("nomos: resume placeholder insert failed", "session", sessionID, "error", err) } var toolCalls []map[string]any @@ -141,17 +148,17 @@ func (a *agent) continueSession(ctx context.Context, p pendingContinuation) { errText, _ = ev.Data.(string) } } - a.chatWith(cctx, p.SessionID, "", note, emit) + a.chatWith(cctx, sessionID, "", note, emit) if finalText != "" || len(toolCalls) > 0 { break } if attempt == 0 { - slog.Warn("nomos: auto-continuation produced nothing, retrying once", "session", p.SessionID, "execution", p.ExecID, "error", errText) + slog.Warn("nomos: resume produced nothing, retrying once", "session", sessionID, "error", errText) } } if errText != "" && finalText == "" { - slog.Error("nomos: auto-continuation produced no response after retry", "session", p.SessionID, "execution", p.ExecID, "error", errText) + slog.Error("nomos: resume produced no response after retry", "session", sessionID, "error", errText) } persist() // final state — same row, updated one last time with the concluding text } diff --git a/cmd/nomos/main.go b/cmd/nomos/main.go index 1f043eb..5b70592 100644 --- a/cmd/nomos/main.go +++ b/cmd/nomos/main.go @@ -83,7 +83,7 @@ func main() { handleSessionsList(w, r, st) }) mux.HandleFunc("/sessions/", func(w http.ResponseWriter, r *http.Request) { - handleSessionDetail(w, r, st) + handleSessionDetail(w, r, st, nAgent) }) addr := os.Getenv("NOMOS_LISTEN") @@ -167,6 +167,14 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) { userMsg, _ := json.Marshal(map[string]any{"role": "user", "text": req.Message}) st.saveMessage(ctx, sessionID, "user", userMsg) + // If this task has a pending operator question, the incoming message IS the + // answer — close it so the panel clears. No separate resume needed: this + // chat turn is the resume, and the agent sees the question + answer in its + // replayed history. + if qid := st.openQuestionID(ctx, sessionID); qid != "" { + st.answerQuestion(ctx, sessionID, qid, req.Message) + } + sseEvent(w, flusher, agentEvent{Type: "session", Data: sessionID, SessionID: sessionID}) toolCalls := []map[string]any{} @@ -222,18 +230,31 @@ func handleSessionsList(w http.ResponseWriter, r *http.Request, st *store) { json.NewEncoder(w).Encode(map[string]any{"sessions": sessions}) } -func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store) { +func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *agent) { if st == nil { http.Error(w, "not found", 404) return } - id := strings.TrimPrefix(r.URL.Path, "/sessions/") + rest := strings.TrimPrefix(r.URL.Path, "/sessions/") + parts := strings.Split(rest, "/") + id := parts[0] if id == "" { http.Error(w, "session id required", 400) return } + // POST /sessions/{id}/questions/{qid}/answer — the operator answers a + // pinned question from the context panel; resume the agent with the answer. + if len(parts) == 4 && parts[1] == "questions" && parts[3] == "answer" { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", 405) + return + } + handleAnswerQuestion(w, r, st, a, id, parts[2]) + return + } + switch r.Method { case http.MethodDelete: if err := st.deleteSession(r.Context(), id); err != nil { @@ -256,6 +277,30 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store) { } } +// handleAnswerQuestion records the operator's answer to a pinned question and +// resumes the agent in the background with that answer injected. Returns 202 — +// the agent's response lands via the normal message-polling path, not this POST. +func handleAnswerQuestion(w http.ResponseWriter, r *http.Request, st *store, a *agent, sessionID, questionID string) { + var req struct { + Answer string `json:"answer"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Answer) == "" { + http.Error(w, "answer is required", 400) + return + } + prompt, _, _ := st.getQuestion(r.Context(), questionID) + if err := st.answerQuestion(r.Context(), sessionID, questionID, req.Answer); err != nil { + http.Error(w, err.Error(), 500) + return + } + if a != nil { + note := fmt.Sprintf("[System: the operator answered your question %q with: %q. "+ + "Continue the task from here — do not re-ask.]", prompt, req.Answer) + go a.resumeSession(context.Background(), sessionID, note) + } + w.WriteHeader(202) +} + func handleQuery(w http.ResponseWriter, r *http.Request, client *mcpClient, agentSlug, mcpURL string) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", 405) diff --git a/cmd/nomos/store.go b/cmd/nomos/store.go index b23612d..686f705 100644 --- a/cmd/nomos/store.go +++ b/cmd/nomos/store.go @@ -432,6 +432,80 @@ func (s *store) completeTask(ctx context.Context, sessionID, outcome, summary st return nil } +// askOperator records a structured decision the agent needs from the operator, +// moves the task to awaiting_input, and emits question.raised so the context +// panel pins it. qctx carries {why, options, entities}. Returns the question id. +func (s *store) askOperator(ctx context.Context, sessionID, prompt string, qctx map[string]any) (string, error) { + if s == nil || sessionID == "" || sessionID == "ephemeral" { + return "", nil + } + ctxJSON, _ := json.Marshal(qctx) + var qid uuid.UUID + if err := s.pool.QueryRow(ctx, ` + INSERT INTO session_questions (session_id, prompt, context) VALUES ($1, $2, $3) RETURNING id`, + sessionID, prompt, string(ctxJSON)).Scan(&qid); err != nil { + return "", err + } + s.pool.Exec(ctx, `UPDATE agent_sessions SET status = 'awaiting_input', last_active_at = now() WHERE id = $1`, sessionID) + data := map[string]any{"question_id": qid.String(), "prompt": prompt} + for k, v := range qctx { + data[k] = v + } + _ = observability.Event(ctx, sqlcgen.New(s.pool), "question.raised", s.taskEntityPtr(ctx, sessionID), + "warning", "nomos", sessionID, data) + return qid.String(), nil +} + +// openQuestionID returns the id of the session's open question, or "". Used to +// auto-close a pending question when the operator answers via a plain chat reply. +func (s *store) openQuestionID(ctx context.Context, sessionID string) string { + if s == nil || sessionID == "" || sessionID == "ephemeral" { + return "" + } + var qid string + s.pool.QueryRow(ctx, `SELECT id::text FROM session_questions + WHERE session_id = $1 AND status = 'open' ORDER BY created_at DESC LIMIT 1`, sessionID).Scan(&qid) + return qid +} + +// getQuestion returns a question's prompt, answer, and session — used to build +// the resume note when the operator answers via the panel. +func (s *store) getQuestion(ctx context.Context, questionID string) (prompt, answer, sessionID string) { + if s == nil || questionID == "" { + return "", "", "" + } + qid, err := uuid.Parse(questionID) + if err != nil { + return "", "", "" + } + s.pool.QueryRow(ctx, `SELECT prompt, COALESCE(answer, ''), session_id::text + FROM session_questions WHERE id = $1`, qid).Scan(&prompt, &answer, &sessionID) + return +} + +// answerQuestion records the operator's answer, returns the task to executing, +// and emits question.answered. It does NOT itself resume the agent — the caller +// decides: a chat reply IS the resuming turn, while a panel answer triggers a +// continuation. +func (s *store) answerQuestion(ctx context.Context, sessionID, questionID, answer string) error { + if s == nil || sessionID == "" || sessionID == "ephemeral" || questionID == "" { + return nil + } + qid, err := uuid.Parse(questionID) + if err != nil { + return err + } + if _, err := s.pool.Exec(ctx, ` + UPDATE session_questions SET status = 'answered', answer = $2, answered_at = now() + WHERE id = $1 AND status = 'open'`, qid, answer); err != nil { + return err + } + s.pool.Exec(ctx, `UPDATE agent_sessions SET status = 'executing', last_active_at = now() WHERE id = $1`, sessionID) + _ = observability.Event(ctx, sqlcgen.New(s.pool), "question.answered", s.taskEntityPtr(ctx, sessionID), + "info", "nomos", sessionID, map[string]any{"question_id": questionID, "answer": answer}) + return nil +} + // knowledgeSlugRe matches a nomos knowledge doc slug (:nomos/) as // printed in upsert_knowledge's result text. var knowledgeSlugRe = regexp.MustCompile(`[a-z]+:nomos/[a-z0-9-]+`) diff --git a/cmd/nomos/tasks.go b/cmd/nomos/tasks.go index 23bec92..926d87e 100644 --- a/cmd/nomos/tasks.go +++ b/cmd/nomos/tasks.go @@ -75,6 +75,32 @@ func taskToolDefs() []toolDef { "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 " + @@ -103,7 +129,7 @@ func taskToolDefs() []toolDef { func isTaskTool(name string) bool { switch name { - case "set_goal", "propose_plan", "update_plan_step", "complete_task": + case "set_goal", "propose_plan", "update_plan_step", "ask_operator", "complete_task": return true default: return false @@ -122,6 +148,21 @@ func toInt(v any) int { } } +// 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). @@ -174,6 +215,27 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args } 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) diff --git a/nomos/SOUL.md b/nomos/SOUL.md index c997f4c..d922f96 100644 --- a/nomos/SOUL.md +++ b/nomos/SOUL.md @@ -59,6 +59,10 @@ Each conversation is a **task**: a goal the operator wants achieved, from known problem. 2. **Plan, then execute.** Gather what you need, propose a plan, get the single approval, and carry it out end-to-end (see the plan/approval sections below). + If you hit a genuine decision only the operator can make — an ambiguous + target, a trade-off, missing information — call `ask_operator` with the + options and the entities involved, then STOP and wait; their answer resumes + you. Don't ask about things you can settle yourself with tools. 3. **Finish explicitly with `complete_task`.** When the goal is verified done — or you've genuinely failed or only partially succeeded — call `complete_task` with the `outcome` (success/failure/partial) and a one-line `summary`. This