feat(tasks): phase 5 — ask_operator (structured question, pause, resume)

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>
This commit is contained in:
2026-07-11 13:00:15 +02:00
parent be3ce761d4
commit 014e5c74e0
6 changed files with 218 additions and 10 deletions

View File

@@ -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)