Classifier now unwraps pct exec / qm guest exec / bash -c / sh -c / sudo
and env-var assignments before classification, so read-only inspection
wrapped in pct exec no longer escalates to config_mutation. curl GET
(default method, no -d/-F/-T/-o/>) is read-only. Eliminates the three
duplicate rclone sessions (a51e2086, 8acea2e3, cb8c8a4a) that bounced
off the classifier for the same goal.
New classify_command MCP tool: command-scoped preflight that returns the
exact risk class run would assign. Documented in SOUL.md with guidance
to pre-classify before run when the verdict is uncertain.
set_goal surfaces prior partial/failed sessions from the last 24h so the
agent picks up the thread instead of rediscovering it.
completeTask auto-closes in-flight plan steps (pending/running -> done
on success, skipped on partial/failure), so one-step plans no longer
need the per-step running->done dance right before completion.
Migration 021 adds blocker + closed_at to agent_sessions. completeTask
sets closed_at once and derives a structured blocker reason
(approval_timeout, user_abandoned, classifier_overreach, model_refusal,
tool_error, ...) from the last assistant message.
/sessions list now carries message_count, tool_call_count,
duration_seconds (server-side aggregates — no more N+1 transcript
fetches to audit a fleet). GET /sessions/{id} returns both metadata
and messages. New query params filter + paginate: outcome, status,
entity_id, blocker, since (RFC3339 or Go duration), cursor, limit.
Titles now prefer the goal when set; sessions without a goal fall back
to the first assistant text.
New GET /sessions/{id}/tool_calls flat view for audit scripts.
Plan: plans/2026-07-20-session-review-ten-sessions.md. VERSION 0.7.12 -> 0.7.13.
449 lines
21 KiB
Go
449 lines
21 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// 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: "Propose the full ordered plan for this task. Call ONCE, before any " +
|
||
"execution, with EVERY step end-to-end (not one step at a time). FIRST step: " +
|
||
"research (prior knowledge, relations, blast radius). If your plan runs `run` " +
|
||
"against any target, include a LAST step: write back " +
|
||
"(update_entity_attributes + create_relationship + upsert_knowledge) — if you " +
|
||
"omit it, one is auto-appended. After this call: STOP and wait for operator " +
|
||
"approval (approval vocabulary: approved, yes, go, proceed, continue, ok, " +
|
||
"go ahead). Once a step has started (running/done/...), this tool REFUSES " +
|
||
"further calls — advance with update_plan_step + run instead. Re-propose only " +
|
||
"if the operator explicitly asks you to revise the whole plan. complete_task " +
|
||
"with outcome=success is REFUSED if you ran `run` but didn't call " +
|
||
"update_entity_attributes/create_relationship — write back before completing.",
|
||
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"},
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
// P1: the plan window is NOT opened here. Opening it on set_goal
|
||
// meant any config_mutation `run` auto-executed with zero operator
|
||
// approval, before a plan was even proposed (let alone approved) —
|
||
// a safety regression confirmed live in session d0d562e0. The
|
||
// window is now opened only when the operator approves a plan
|
||
// (chat-assent grant or explicit approval in agent.go), which is
|
||
// what the SOUL.md "approve the plan, not each step" model actually
|
||
// describes. set_goal records the goal + flips status to executing
|
||
// and nothing more.
|
||
response := "Goal set: " + goal + ". NEXT: pre-plan with read-only tools (search_knowledge, get_entity, list_lxcs, get_relations), then propose_plan (mandatory — even read-only tasks need a one-step plan; the run handler refuses without one). After propose_plan: if all steps are read-only, execute immediately (no approval needed). If any step is config_mutation/destructive, stop and wait for operator approval."
|
||
// P1.3 (2026-07-20): surface prior partial/failed sessions for the
|
||
// same problem so the agent can pick up the thread instead of
|
||
// rediscovering it. Three rclone sessions (a51e2086, 8acea2e3,
|
||
// cb8c8a4a) all bounced off the classifier because each new session
|
||
// started from scratch. The agent gets a hint with the prior
|
||
// goal + summary; if it looks related, search_knowledge or open
|
||
// the prior session's transcript (GET /sessions/{id}) before
|
||
// re-planning. See plans/2026-07-20-session-review-ten-sessions.md.
|
||
prior, _ := a.store.recentPartialSessions(ctx, sessionID, 24*time.Hour)
|
||
if len(prior) > 0 {
|
||
var b strings.Builder
|
||
b.WriteString("\n\nNOTE — recent unfinished sessions (last 24h, outcome=partial/failed):")
|
||
for i, p := range prior {
|
||
if i >= 5 {
|
||
b.WriteString(fmt.Sprintf("\n ...and %d more", len(prior)-5))
|
||
break
|
||
}
|
||
sum := p.Summary
|
||
if sum == "" {
|
||
sum = "(no summary)"
|
||
}
|
||
if len(sum) > 200 {
|
||
sum = sum[:200] + "..."
|
||
}
|
||
b.WriteString(fmt.Sprintf("\n - %s (sid %s, outcome=%s): %s",
|
||
p.Goal, p.ID[:8], p.Outcome, sum))
|
||
}
|
||
b.WriteString("\nIf any of these looks like the same problem, search_knowledge for the prior investigation or read it via GET /sessions/{id} before re-planning — don't rediscover what was already learned.")
|
||
response += b.String()
|
||
}
|
||
return response, 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
|
||
}
|
||
// D.2: auto-append a writeback step if the agent didn't include one.
|
||
// The agent consistently writes vague last steps ("record findings")
|
||
// and then skips update_entity_attributes entirely (the #1 cause of
|
||
// knowledge-graph drift). Appending an explicit writeback step makes
|
||
// the seq-order enforcement (5.6) require it to be completed last,
|
||
// and D.1's complete_task gate enforces the actual calls. Together
|
||
// they close the loop structurally — neither relies on the agent
|
||
// reading SOUL.md.
|
||
hasWritebackStep := false
|
||
for _, st := range steps {
|
||
if strings.Contains(st.Title, "update_entity_attributes") ||
|
||
strings.Contains(st.Title, "create_relationship") ||
|
||
strings.Contains(st.Detail, "update_entity_attributes") ||
|
||
strings.Contains(st.Detail, "create_relationship") {
|
||
hasWritebackStep = true
|
||
break
|
||
}
|
||
}
|
||
appendedNote := ""
|
||
if !hasWritebackStep {
|
||
steps = append(steps, planStepInput{
|
||
Title: "Write back: update_entity_attributes + create_relationship + upsert_knowledge",
|
||
Detail: "Call update_entity_attributes for every entity you ran against (versions, states, counts, timestamps). Call create_relationship for any edge you discovered. Then upsert_knowledge about the affected entities (pass `about` as an array).",
|
||
})
|
||
appendedNote = fmt.Sprintf(" (appended a writeback step — your plan didn't include one; step %d)", len(steps))
|
||
}
|
||
persisted, err := a.store.proposePlan(ctx, sessionID, steps)
|
||
if err != nil {
|
||
if errors.Is(err, errPlanInFlight) {
|
||
// The plan is already in flight — refuse the re-proposal.
|
||
// The agent must advance the existing plan with
|
||
// update_plan_step + run. This is the structural fix for
|
||
// the "plan added twice" sidebar drift the operator
|
||
// reported: instead of appending (which duplicated) or
|
||
// wiping (which lost progress), we refuse and direct.
|
||
return "Plan already in flight — refusing duplicate proposal. Steps exist and at least one has started (running/done/...). To advance: call update_plan_step(seq=K, status=\"running\") then run(...) for step K's target, then update_plan_step(seq=K, status=\"done\"). Do not call propose_plan again. Re-propose only if the operator explicitly asks you to revise the whole plan (the session is reopened on a follow-up — prior steps are marked `replaced` and a fresh generation is started), and say so in your reply before calling it.", true
|
||
}
|
||
return fmt.Sprintf("error proposing plan: %v", err), true
|
||
}
|
||
// The writeback step is now always present (D.2 auto-appends it if
|
||
// the agent forgot), so the old advisory nudge is replaced by the
|
||
// structural gate: D.1 refuses complete_task without the actual
|
||
// update_entity_attributes/create_relationship calls.
|
||
result := fmt.Sprintf("Plan set (%d steps)%s. If all steps are read-only, execute now — call update_plan_step(running) + run for each step, no approval needed. If any step is config_mutation/destructive, STOP and wait for operator approval (\"approved\", \"yes\", \"go\", \"proceed\", \"continue\", \"ok\", \"go ahead\"). Do not call propose_plan again.", len(persisted), appendedNote)
|
||
return result, 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. (Advance with update_plan_step + run; do not re-propose.)", 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)
|
||
switch outcome {
|
||
case "":
|
||
outcome = "success" // no outcome given at all — assume success, the common case
|
||
case "success", "failure", "partial":
|
||
// valid, use as-is
|
||
default:
|
||
// The tool schema declares an enum, but a weaker model (or a
|
||
// typo) can still send anything — an unrecognized value used to
|
||
// persist as-is, silently, with only "failure" special-cased
|
||
// (store.completeTask derives status='failed' from it; anything
|
||
// else became status='done' regardless of what the value
|
||
// actually said). Default to "partial" rather than silently
|
||
// treating an unrecognized value as "success" — safer to
|
||
// under-claim than over-claim a task's outcome.
|
||
slog.Warn("nomos: complete_task got an unrecognized outcome, defaulting to partial",
|
||
"session", sessionID, "outcome", outcome)
|
||
outcome = "partial"
|
||
}
|
||
// D.1: refuse success when discovery ran but no writeback followed.
|
||
// The prior advisory warning (below) was ignorable — the agent
|
||
// saw it and ended the task anyway. This gate fires BEFORE
|
||
// completeTask runs, so the session stays in 'executing' state
|
||
// and the agent must call update_entity_attributes/create_relationship
|
||
// then retry complete_task. Only blocks `success`; an explicit
|
||
// `failure` or `partial` is allowed through (the agent is
|
||
// acknowledging it didn't finish — no reason to force writeback).
|
||
if outcome == "success" && a.store.hadDiscovery(ctx, sessionID) && !a.store.hadEntityWriteback(ctx, sessionID) {
|
||
return "Refused: this session ran `run` against live targets (discovery) but did not call update_entity_attributes or create_relationship to persist what you learned. The knowledge graph will drift if you complete without writeback. Call update_entity_attributes for each entity you ran against (versions, states, counts, timestamps), and create_relationship for any edge you discovered, then call complete_task again. Outcome is held at 'executing' until you do.", true
|
||
}
|
||
if err := a.store.completeTask(ctx, sessionID, outcome, summary); err != nil {
|
||
if errors.Is(err, errTaskAlreadyComplete) {
|
||
return "Task is already complete. Do not call complete_task again. If the operator pointed out a UI/sidebar inconsistency, fix it with update_plan_step (reconcile step states) or summarize the panel in your reply — do not re-execute the work.", true
|
||
}
|
||
return fmt.Sprintf("error completing task: %v", err), true
|
||
}
|
||
result := fmt.Sprintf("Task marked %s: %s", outcome, summary)
|
||
if !a.store.hadEntityWriteback(ctx, sessionID) {
|
||
result += "\n\n⚠️ No entity attributes or relationships were updated in this session. Call update_entity_attributes and create_relationship to persist what you learned about entities before the next session starts from scratch."
|
||
}
|
||
return result, true
|
||
default:
|
||
return nil, false
|
||
}
|
||
}
|
||
|
||
// autoCompleteTrivialTask is the case-1 fix from
|
||
// plans/2026-07-11-task-completion-safety-net.md: a session that never
|
||
// called set_goal never framed itself as a structured task, so a turn that
|
||
// ends with a plain-text answer and no further tool calls IS the task
|
||
// ending — but the model consistently skips complete_task for exactly this
|
||
// case (confirmed live: 43/50 production sessions were a single trivial
|
||
// Q&A exchange, none of which ever reached a terminal status). Rather than
|
||
// leave agent_sessions.status stuck at its creation-time default forever,
|
||
// close it out mechanically here: no judgment call needed, since SOUL.md
|
||
// already treats a one-shot answered question as done by definition.
|
||
func (a *agent) autoCompleteTrivialTask(ctx context.Context, sessionID, responseText string) {
|
||
summary := strings.TrimSpace(responseText)
|
||
summary = strings.SplitN(summary, "\n", 2)[0] // first line only — the board shows one line
|
||
const maxLen = 120
|
||
if len(summary) > maxLen {
|
||
summary = summary[:maxLen] + "…"
|
||
}
|
||
if summary == "" {
|
||
summary = "Answered without further action needed."
|
||
}
|
||
if err := a.store.completeTask(ctx, sessionID, "success", summary); err != nil {
|
||
slog.Error("nomos: auto-complete trivial task failed", "session", sessionID, "error", err)
|
||
}
|
||
}
|
||
|
||
// autoCompleteIfPlanDone is the structural safety net for "the agent did the
|
||
// work but forgot to call complete_task" — the #1 remaining model reliability
|
||
// gap after D.1's writeback gate. After a turn ends, if the session has a goal,
|
||
// the agent never called complete_task this turn, and either (a) all plan
|
||
// steps are terminal OR (b) the agent did discovery (ran `run`), auto-complete.
|
||
// Path (b) catches the common case where the agent skips update_plan_step
|
||
// bookkeeping but still does the actual work — the D.1 gate already enforces
|
||
// writeback before `complete_task`, so if the agent forgot to complete at all,
|
||
// we close it out mechanically. If writeback happened → success; if not →
|
||
// partial (honest: work was done but knowledge graph wasn't updated).
|
||
func (a *agent) autoCompleteIfPlanDone(ctx context.Context, sessionID, responseText string) {
|
||
if a.store == nil || sessionID == "" || sessionID == "ephemeral" {
|
||
return
|
||
}
|
||
sess, err := a.store.getSession(ctx, sessionID)
|
||
if err != nil || sess.Status != "executing" {
|
||
return
|
||
}
|
||
// Don't auto-complete if there are pending approvals — the agent is
|
||
// blocked waiting for the operator, not done. Auto-completing here
|
||
// would close the session and the operator's approval would land on a
|
||
// dead task. Confirmed in eval: agent hits P5 approval gate, turn
|
||
// ends, auto-complete fires incorrectly because the approval-queue
|
||
// `run` responses were logged as success=true in agent_activity.
|
||
if a.store.hasPendingApprovals(ctx, sessionID) {
|
||
return
|
||
}
|
||
discovery := a.store.hadDiscovery(ctx, sessionID)
|
||
writeback := a.store.hadEntityWriteback(ctx, sessionID)
|
||
// (a) all plan steps terminal, OR (b) agent did discovery (ran `run`).
|
||
shouldComplete := a.store.allPlanStepsTerminal(ctx, sessionID)
|
||
if !shouldComplete && discovery {
|
||
shouldComplete = true
|
||
}
|
||
if !shouldComplete {
|
||
return
|
||
}
|
||
outcome := "success"
|
||
if discovery && !writeback {
|
||
outcome = "partial" // honest: work done, knowledge graph not updated
|
||
}
|
||
summary := strings.TrimSpace(responseText)
|
||
summary = strings.SplitN(summary, "\n", 2)[0]
|
||
const maxLen = 120
|
||
if len(summary) > maxLen {
|
||
summary = summary[:maxLen] + "…"
|
||
}
|
||
if summary == "" {
|
||
summary = "All plan steps completed."
|
||
}
|
||
if err := a.store.completeTask(ctx, sessionID, outcome, summary); err != nil {
|
||
slog.Error("nomos: auto-complete plan-done task failed", "session", sessionID, "error", err)
|
||
} else {
|
||
slog.Info("nomos: auto-completed task — agent didn't call complete_task", "session", sessionID, "outcome", outcome)
|
||
}
|
||
}
|