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:
@@ -419,6 +419,22 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
|||||||
})
|
})
|
||||||
messages = append(messages, openai.ToolMessage(string(resultJSON), tc.ID))
|
messages = append(messages, openai.ToolMessage(string(resultJSON), tc.ID))
|
||||||
slog.Info("nomos: tool success", "tool", tc.Function.Name, "ms", elapsed)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,17 +82,24 @@ func (a *agent) processContinuations(ctx context.Context) {
|
|||||||
// complaint this exists to fix — polling alone only helps if there's
|
// complaint this exists to fix — polling alone only helps if there's
|
||||||
// something new to poll for.
|
// something new to poll for.
|
||||||
func (a *agent) continueSession(ctx context.Context, p pendingContinuation) {
|
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)
|
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{
|
placeholder, _ := json.Marshal(map[string]any{
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"text": "",
|
"text": "",
|
||||||
"auto": true,
|
"auto": true,
|
||||||
})
|
})
|
||||||
msgID, err := a.store.insertMessageReturningID(ctx, p.SessionID, "assistant", placeholder)
|
msgID, err := a.store.insertMessageReturningID(ctx, sessionID, "assistant", placeholder)
|
||||||
if err != nil {
|
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
|
var toolCalls []map[string]any
|
||||||
@@ -141,17 +148,17 @@ func (a *agent) continueSession(ctx context.Context, p pendingContinuation) {
|
|||||||
errText, _ = ev.Data.(string)
|
errText, _ = ev.Data.(string)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
a.chatWith(cctx, p.SessionID, "", note, emit)
|
a.chatWith(cctx, sessionID, "", note, emit)
|
||||||
if finalText != "" || len(toolCalls) > 0 {
|
if finalText != "" || len(toolCalls) > 0 {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if attempt == 0 {
|
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 == "" {
|
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
|
persist() // final state — same row, updated one last time with the concluding text
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ func main() {
|
|||||||
handleSessionsList(w, r, st)
|
handleSessionsList(w, r, st)
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/sessions/", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/sessions/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleSessionDetail(w, r, st)
|
handleSessionDetail(w, r, st, nAgent)
|
||||||
})
|
})
|
||||||
|
|
||||||
addr := os.Getenv("NOMOS_LISTEN")
|
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})
|
userMsg, _ := json.Marshal(map[string]any{"role": "user", "text": req.Message})
|
||||||
st.saveMessage(ctx, sessionID, "user", userMsg)
|
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})
|
sseEvent(w, flusher, agentEvent{Type: "session", Data: sessionID, SessionID: sessionID})
|
||||||
|
|
||||||
toolCalls := []map[string]any{}
|
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})
|
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 {
|
if st == nil {
|
||||||
http.Error(w, "not found", 404)
|
http.Error(w, "not found", 404)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
id := strings.TrimPrefix(r.URL.Path, "/sessions/")
|
rest := strings.TrimPrefix(r.URL.Path, "/sessions/")
|
||||||
|
parts := strings.Split(rest, "/")
|
||||||
|
id := parts[0]
|
||||||
if id == "" {
|
if id == "" {
|
||||||
http.Error(w, "session id required", 400)
|
http.Error(w, "session id required", 400)
|
||||||
return
|
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 {
|
switch r.Method {
|
||||||
case http.MethodDelete:
|
case http.MethodDelete:
|
||||||
if err := st.deleteSession(r.Context(), id); err != nil {
|
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) {
|
func handleQuery(w http.ResponseWriter, r *http.Request, client *mcpClient, agentSlug, mcpURL string) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "method not allowed", 405)
|
http.Error(w, "method not allowed", 405)
|
||||||
|
|||||||
@@ -432,6 +432,80 @@ func (s *store) completeTask(ctx context.Context, sessionID, outcome, summary st
|
|||||||
return nil
|
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
|
// knowledgeSlugRe matches a nomos knowledge doc slug (<kind>:nomos/<title>) as
|
||||||
// printed in upsert_knowledge's result text.
|
// printed in upsert_knowledge's result text.
|
||||||
var knowledgeSlugRe = regexp.MustCompile(`[a-z]+:nomos/[a-z0-9-]+`)
|
var knowledgeSlugRe = regexp.MustCompile(`[a-z]+:nomos/[a-z0-9-]+`)
|
||||||
|
|||||||
@@ -75,6 +75,32 @@ func taskToolDefs() []toolDef {
|
|||||||
"required": []string{"seq", "status"},
|
"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",
|
Name: "complete_task",
|
||||||
Description: "Mark the current task finished. Call this once the goal is " +
|
Description: "Mark the current task finished. Call this once the goal is " +
|
||||||
@@ -103,7 +129,7 @@ func taskToolDefs() []toolDef {
|
|||||||
|
|
||||||
func isTaskTool(name string) bool {
|
func isTaskTool(name string) bool {
|
||||||
switch name {
|
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
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
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
|
// 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
|
// handled the call, or (nil, false) if name is not a local task tool (so the
|
||||||
// caller forwards it to the MCP client).
|
// 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
|
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":
|
case "complete_task":
|
||||||
outcome, _ := args["outcome"].(string)
|
outcome, _ := args["outcome"].(string)
|
||||||
summary, _ := args["summary"].(string)
|
summary, _ := args["summary"].(string)
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ Each conversation is a **task**: a goal the operator wants achieved, from
|
|||||||
known problem.
|
known problem.
|
||||||
2. **Plan, then execute.** Gather what you need, propose a plan, get the single
|
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).
|
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 —
|
3. **Finish explicitly with `complete_task`.** When the goal is verified done —
|
||||||
or you've genuinely failed or only partially succeeded — call `complete_task`
|
or you've genuinely failed or only partially succeeded — call `complete_task`
|
||||||
with the `outcome` (success/failure/partial) and a one-line `summary`. This
|
with the `outcome` (success/failure/partial) and a one-line `summary`. This
|
||||||
|
|||||||
Reference in New Issue
Block a user