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:
@@ -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 (<kind>:nomos/<title>) as
|
||||
// printed in upsert_knowledge's result text.
|
||||
var knowledgeSlugRe = regexp.MustCompile(`[a-z]+:nomos/[a-z0-9-]+`)
|
||||
|
||||
Reference in New Issue
Block a user