Full review (plans/2026-07-17-codebase-review-and-cleanup.md) covering Go, web SPA, and docs. Applied low-risk doc/tooling fixes; code refactors and dead-code deletions are listed as actionable recommendations pending approval. Doc fixes: - AGENTS.md: remove ghost of retired request_execution (contradicted the retire notice above it); fix knowledge/wiki/ -> archive/knowledge/; replace brittle counts (33 tools, 36 docs, 20 checks) with pointers to source; drop point-in-time dates. - OIKOS.md: fix broken plan link (now in done/); 001-011 -> 001-020; 15 MCP tools -> pointer; replace hardcoded knowledge counts. - README.md: 15 tools -> pointer; fix wails plan link (now in done/); complete internal/ package list (add checkdefaults, observability, safego); add cmd/desktop/ to repo layout. - commands.md, page-templates.md: fix broken links; HERMES.md -> NOMOS.md. Plans housekeeping: - Move 4 done 2026-07-14 plans from plans/ to plans/done/. - Reconcile plans/index.md: add the 2 missing 2026-07-14 entries and the 2 missing 2026-07-15 done entries; add this review. - Fix stale plan path in migrations/020 comment. New docs: - docs/index.md and docs/operations/README.md (folder READMEs per writing-style.md). Tooling: - web/package.json: add check/typecheck/lint scripts + svelte-check devDep. - Makefile: desktop-package version now reads from VERSION file instead of hardcoded 0.1.0. VERSION 0.7.6 -> 0.7.7 (patch: docs + tooling only).
46 KiB
2026-07-14 — Post-fix session audit: empty responses & plan drift remainders
Status: Done — 2026-07-14. All 18 fixes shipped, e2e-validated via the
golden eval harness (4/4 passed), committed (337d577 + 3de359b +
dd3076a), pushed to main, and deployed to oikos-nomos-1 (v0.5.3). The
knowledge loop is structurally closed, the plan-duplication chain is broken,
and the eval harness catches regressions on future changes.
PM addition — OIDC token-refresh fix (lines 9-23 below) also shipped:
committed as 3b98097 ("fix(web): refresh expired OIDC tokens before API
calls"). The root cause of the empty-graph symptom is fixed and deployed.
2026-07-14 (PM) — OIDC token-refresh fix (unplanned, root-cause for the
empty graph symptom): the overview background graph and the Knowledge Base
graph both rendered empty because the SPA's OIDC access token expired
(~5 min TTL) and was never refreshed. fetchWithAuth called getToken()
synchronously (no refresh); ensureToken returned the stale token without
refreshing; storeTokens discarded expires_in; and the resulting 401
made fetchGraph return null → both graphs drew nothing, with no error
surfaced. Fixed structurally in web/src/lib/oidc.ts +
web/src/lib/config.ts + web/src/lib/stores/events.ts: tokens now carry
expiresAt, getToken() returns null within 30s of expiry, fetchWithAuth
awaits ensureToken() (refreshes on demand), sseUrl is async + refreshes
before constructing the EventSource, and a 401 flushes the OIDC session so
the static token fallback takes over. Build passes. Not yet committed or
deployed (pending operator verification). Not part of any numbered phase
above — filed here because it was the highest-impact surface symptom.
Shipped (2026-07-14, v0.5.0–v0.5.3 — commits 337d577 + 3de359b + dd3076a, deployed)
| Fix | File(s) | Validation |
|---|---|---|
A.1 proposePlan sets generation on INSERT |
cmd/nomos/store.go |
eval: plan steps carry generation: 1 |
A.2 proposePlan refuses re-proposal when in flight (drops append-mode) |
cmd/nomos/store.go, cmd/nomos/tasks.go |
eval: propose_plan called exactly once on "proceed" |
A.3 propose_plan tool description restated as a crisp contract |
cmd/nomos/tasks.go |
agent self-described the contract |
| F.3 Approval vocabulary expanded + directive result strings | cmd/nomos/tasks.go |
eval: "proceed" and "go ahead" both recognized as approval |
B.1 chatWith emits done after error on every terminal path |
cmd/nomos/agent.go |
eval: no reconnect/resume entries in nomos logs |
| B.2 Reconnect/resume note carries last user msg + plan-in-flight directive | cmd/nomos/store.go, cmd/nomos/main.go, cmd/nomos/continue.go |
wired into all 4 resume entry points |
B.3 resumeSession escalates the recovery note across 3 attempts |
cmd/nomos/continue.go |
e2e: escalated retry produced a real response |
D.1 complete_task refused when discovery ran without writeback |
cmd/nomos/store.go, cmd/nomos/tasks.go |
e2e: agent REFUSED → wrote back → retried → succeeded |
D.2 propose_plan auto-appends a writeback step if missing |
cmd/nomos/tasks.go |
e2e: appended step 4 when agent omitted writeback |
| F.1 Consolidated SOUL.md's three overlapping task-flow sections to one | nomos/SOUL.md |
eval: agent follows the consolidated flow (4/4 evals pass) |
| F.2 Tightened set_goal/update_plan_step result strings to imperatives | cmd/nomos/tasks.go |
eval: tool results are now directive |
C.1 completeTask rejects re-completion of a terminal session |
cmd/nomos/store.go, cmd/nomos/tasks.go |
eval: complete_task called exactly once |
| C.2 SOUL.md: don't re-execute on UI-clarification complaints | nomos/SOUL.md |
eval: no re-execution on followup |
| B.4 Surface real model error text (finish_reason + refusal) | cmd/nomos/agent.go |
error event now carries finish_reason=length etc. |
| B.5 Back off between resume retries (4s, 8s) | cmd/nomos/continue.go |
exponential backoff between attempts |
| B.6 Don't persist empty placeholder as a visible bubble | cmd/nomos/main.go, cmd/nomos/store.go |
empty rows deleted, not persisted |
| E.1 SOUL.md: prefer knowledge over re-execution for fleet-wide facts | nomos/SOUL.md |
eval: search_knowledge called first, 0 run calls on fleet audit |
E.2 list_lxcs last-audited hint in the result |
internal/mcp/server.go |
last_audited_at column via about edge subquery |
| Bonus Fixed pre-existing tool-call doubling bug in persistence | cmd/nomos/main.go, cmd/nomos/continue.go |
eval: tool-call counts now accurate (was 2× in every session since v0.3.x) |
Tests: TestProposePlan_RefuseInFlight + TestHadDiscoveryAndWriteback in
cmd/nomos/store_test.go. Golden eval harness: cmd/nomos/eval/ with 4
conversations in cmd/nomos/eval/evals/golden.yaml — all 4 pass.
Golden eval results (v0.5.3, 4/4 passed)
| Eval | Tool calls | Key assertions |
|---|---|---|
| trivial_readonly | 2 | no plan, no run, completes |
| plan_advances_on_proceed | 13 | propose_plan ×1, writes back, complete_task ×1 |
| ui_complaint_no_rerun | 12 | propose_plan ×1, writes back |
| knowledge_preferred_over_rerun | 7 | search_knowledge ×1, 0 run calls |
Run: go run ./cmd/nomos/eval -gateway http://localhost:8092 -manifest cmd/nomos/eval/evals/*.yaml (~$0.10/run).
Remaining (not yet shipped)
None. All 18 fixes + the OIDC token-refresh fix (PM addition, 3b98097)
are shipped, committed, and deployed.
Commit-history context (the 20-commit iteration)
Reviewing git log since the agent-task phases landed (be3ce76 → 5caf49b),
the same problems recur because we keep fixing them with SOUL.md prose +
safety-net append logic instead of structural gates:
5384499(Jul 11) — "plan panel showed only the latest step" → fixed by makingproposePlanAPPEND when a step is in flight, so history is preserved even if the model re-proposes per step. This is the source of the duplication the operator saw today. The fix traded "lost progress" for "duplicate progress" — and the duplication is what's visible to the operator now.e30813a/532310b(Jul 11) — "research-first / knowledge-write-back- last explicit steps" → added the FIRST/LAST step language to SOUL.md. Three commits later the warnings are still being ignored in production.5caf49b(Jul 14, today) — "mandatory pre-plan flow" → another SOUL.md section at the top of the file, overlapping the existing "Every chat is a task" / "AFTER EVERY TASK: WRITE BACK" sections. The agent now has three overlapping sections telling it the same thing.60effcb(Jul 14) — Phase 5 of the prior plan added thegenerationcolumn, thereplacedstatus, the frontend grouping, and the writeback warnings. The migration landed; the INSERT inproposePlandid not.
The pattern: every iteration adds another paragraph to SOUL.md and a
safety net in the store layer. The agent still does the wrong thing
because prose instructions are unreliable and the safety nets paper over
the symptom instead of refusing the bad action. This plan pivots to
structural gates — proposePlan and completeTask should refuse the
calls that produce drift, not accommodate them.
Sessions under audit
| Session | Time | Goal | Messages | Outcome | Real tool calls |
|---|---|---|---|---|---|
722d8878 (failure) |
10:45 | Fleet update audit | 3 | failed — empty response during auto-resume | 41 in turn 1 |
d9cdcee1 (success w/ friction) |
11:44 | Same prompt (user retried) | 11 | success | 38 across 5 turns |
Both sessions are the same operator request: "Check all the services on the homelab and give me an overview of what needs updating, categorize by criticality." Cross-referencing them shows where the prior fixes held vs. where they didn't.
What worked (preserve)
upsert_knowledgeaboutarray (5.2 from prior plan) — the agent linked the audit to all affected LXCs in one call:about: ["lxc:nextcloud","lxc:jellyfin","host:hubris", ...].complete_taskwriteback warning (5.5) — fired correctly (the session has noupdate_entity_attributescalls and the warning text appears in the tool result).propose_planwriteback nudge (5.4) — fired (last step title was "Write back: upsert_knowledge if anything changed", which contains neither required tool name).- Seq-order completion enforcement (5.6) — no out-of-order completions observed.
- Replaced-status mechanism (3.3) — pending steps from the prior
generation were correctly marked
replacedon re-propose.
What didn't (the findings below)
Findings
1. Empty response still ends the session — operator had to start over
Where: Session 722d8878 msg 2: [System: auto-resume failed after retrying: Nomos returned an empty or unusable response — please retry. The task is paused — send another message to continue.]
What happened: Turn 1 ran 41 tool calls (set_goal + list_lxcs + get_health_summary + get_state_snapshot + search_knowledge + 4× get_relations
- 4× get_entity + 20×
runforapt-get updateacross the fleet). The model returned that successfully. Auto-continuation then ranresumeSession, which retriedchatWith3 times (continue.go:229) — all three came back empty. The session ended with the system note above. The operator abandoned it and openedd9cdcee1with the same prompt.
Root cause: Three identical retries with the same injected note produce
three identical empty responses (the model isn't randomly failing — it's
responding to the prompt the same way each time). The retry loop never varies
the prompt, never backs off, and never escalates to a more aggressive
recovery (e.g. a fresh continuation prompt that summarizes what just happened
and asks explicitly for the next single step).
Severity: Blocker — a 41-tool-call turn costs real money and time, and the operator gets nothing for it.
2. generation column exists but proposePlan never sets it — frontend grouping is dead code
Where: cmd/nomos/store.go:458-461 (INSERT statement) vs.
migrations/020_session_reliability.up.sql:7 (the column) and
web/src/lib/components/PlanProgress.svelte:17-22 (the grouping logic).
What happened: Migration 020 added generation INTEGER NOT NULL DEFAULT 1
and PlanProgress groups steps by s.generation ?? 1. But the INSERT in
proposePlan is:
INSERT INTO session_plan_steps (session_id, seq, title, detail, target_slug)
VALUES ($1, $2, $3, $4, $5) RETURNING id
No generation column. Every step, in every plan revision, lands with
generation = 1. PlanProgress always sees one group ("Current plan") and
the collapse-old-generations behavior never triggers.
Concrete impact in d9cdcee1:
- Turn 1 (msg 1):
propose_plancreates steps seq 1-5 (all generation 1). - User: "why is the plan not updated accordingly? the steps in the sidebar."
- Turn 3 (msg 5):
update_plan_step seq=1, status=done. Steps 2-5 still pending, all generation 1. - User: "proceed with the rest."
- Turn 4 (msg 7): empty assistant response (text="", no tools).
- Turn 5 (msg 8): Agent calls
propose_planagain with the same 5 steps.proposePlanseesanyStarted=true(seq 1 is done), so it goes into append mode: marks the 4 still-pending steps (2-5) asreplaced, then inserts 5 new steps at seq 6-10. All inserted with generation=1. - The frontend now sees 10 steps, all
generation: 1, grouped together. Four are markedreplaced(visible as "skipped/replaced" — dimmed but still in the list); six are the new active steps. - User: "btw the plan here and the one in the sidebar differ." → Confirmed: the chat text describes a 5-step plan ("Step 1 done, refreshing 2-4"); the sidebar shows 10 steps with a confusing mix of done/replaced/running.
Severity: Blocker — this is the direct, observable cause of the user's
two complaints in d9cdcee1. The prior plan (3.4) shipped the column and
the frontend code but never wired the backend INSERT.
3. Agent re-proposes the plan on "proceed" instead of continuing
Where: d9cdcee1 msg 8 — propose_plan called again after user said
"proceed with the rest".
What happened: The agent had a perfectly good plan in flight (step 1 done,
2-5 pending). On the next operator turn ("proceed"), it should have called
update_plan_step(seq=2, status=running) and run against the targets.
Instead it called propose_plan with the same 5 steps, triggering the
append-mode behavior in #2.
Root cause: SOUL.md doesn't explicitly say "do NOT call propose_plan again once you've already proposed — call update_plan_step + run instead." The agent treated "proceed" as a cue to re-state the plan, not to advance it.
Severity: Friction (compounds #2 into a blocker).
3a. WHY the agent re-proposed instead of advancing — the three-bug chain
Finding #3's surface description ("agent re-proposed on proceed") is real but
doesn't explain the mechanism. Tracing the message timestamps and the
auto: true flag on msg 8 reveals that the re-proposal wasn't the agent's
direct response to "proceed with the rest" at all — it was the agent's
response to a generic system reconnect note, fired by a chain of three
compounding bugs:
The chain (all confirmed from code + session data):
| Step | What happened | Where |
|---|---|---|
| 1. Trigger — model returned empty on the approval | User sent "procceed with the rest." handleChat → a.chat() → chatWith(). The model returned an empty completion 3× (all maxLLMRetries=2 attempts exhausted). SOUL.md's approval vocabulary was "approved/yes/go ahead" — "proceed" wasn't listed, so the model likely wasn't certain it was approved and no-op'd. |
agent.go:362-371 |
| 2. Amplifier — empty response misclassified as network disconnect | On empty response, chatWith emits error and returns without emitting done (agent.go:370-371 — the done event only fires on the success path at line 382). The frontend's onComplete callback sees !receivedDone and treats it as a severed connection, calling handleDisconnect(). A model empty-response gets handled by the network disconnect path. |
agent.go:370-371 (missing done) + chat.ts:349-356 (!receivedDone → handleDisconnect) |
| 3. Divergence — generic reconnect note triggers re-proposal | handleDisconnect waits 1s, then sends an empty message (streamChat('', sessionId, …)). The backend's reconnect path (main.go:177-188) calls resumeSession with: "[System: the operator's connection was re-established. The task may have progressed in the background — report your current state and progress.]". The agent re-read the transcript (plan proposed, step 1 done, user said "proceed"), saw this generic note, and interpreted "report your current state and progress" as "redo the work and report it" → re-proposed + re-executed + complete_task. |
chat.ts:386-419 (reconnect) + main.go:180 (note) + continue.go:189 (resumeSession) |
Timestamps confirm this: msg 7 (empty) at 11:49:01.949, msg 8 (re-propose, auto: true) at 11:49:16.200 — 15 seconds later, matching the 1s reconnect delay + the LLM call latency. The user never sent a second message; the frontend's reconnect logic did.
The user's actual approval ("procceed with the rest") was in the transcript but the agent wasn't responding to it — it was responding to the system reconnect note, which didn't mention approval, the plan, or the user's words. The propose_plan result had said "STOP and wait for approval," and the generic reconnect note didn't say "you're approved" — so the agent re-proposed to get a fresh approval cycle.
Why this matters for the fix: Phase A.2 (refuse re-proposal when in flight) would have prevented the duplication but not fixed the cause. The agent would have hit the refusal and then… what? With the generic reconnect note, it still doesn't know it's approved. The three bugs need three targeted fixes (Phase B below). This is the answer to "why didn't the agent update the original plan": it never received a clear signal to advance, because the approval signal was lost in an empty response that got misclassified as a network drop.
Severity: Blocker — this is the root cause of the plan divergence the operator observed.
4. Operator clarification was interpreted as "redo the whole task"
Where: d9cdcee1 msg 9 → msg 10. User said "btw the plan here and the
one in the sidebar differ." Agent's response (msg 10): re-ran all 6 run
calls (apt-get update + apt list --upgradable on nextcloud, jellyfin,
hubris), re-called upsert_knowledge, and called complete_task a
second time.
What happened: The operator wanted the sidebar aligned with the chat. The agent re-executed the actual audit work and re-completed the task.
Root cause: No prompt-level instruction about how to handle "the UI seems inconsistent" complaints — the agent defaulted to "do the work again, maybe it'll line up this time."
Severity: Friction — wasted 6 run calls and a duplicate knowledge
entry; user gets a noisier transcript.
5. complete_task called twice on the same session
Where: d9cdcee1 msg 8 and msg 10 both call complete_task with
outcome=success.
What happened: After msg 8, agent_sessions.status is done. The user
complained about the plan drift; the agent re-ran the audit and called
complete_task again. There's no guard in completeTask against re-completing
an already-terminal session.
Severity: Cosmetic, but it produces duplicate knowledge entries and erodes audit-log clarity.
6. Turn 1 of 722d8878: 41 tool calls including run against every LXC
Where: Session 722d8878 msg 1.
What happened: Despite a same-day knowledge entry
(investigation:nomos/fleet-wide-apt-update-audit-2026-07-14 — the agent even
called get_knowledge_content for it), the agent ran apt-get update on
every LXC in turn 1 instead of presenting the prior audit and proposing a
small refresh plan. The agent already had the answer in the DB; it re-ran
the fleet audit anyway.
Severity: Friction — wasted ~20 run calls (each is a queued execution).
The successful retry session (d9cdcee1) only re-ran 3 (the critical trio),
which is the right pattern — but it had to learn that from the failure
session's example.
7. Agent ignores its own writeback warnings
Where: d9cdcee1 — propose_plan returned the nudge from tasks.go:213
("⚠️ The final step doesn't mention update_entity_attributes…") and
complete_task returned the warning from tasks.go:280 ("⚠️ No entity
attributes or relationships were updated in this session…"). The agent saw
both, did nothing about either, and ended the task.
What happened: The warnings are surfaced in the tool result text, but
the model treats tool results as ephemeral context — it doesn't act on a
warning that appears after the work it already decided is done. The session
recorded zero update_entity_attributes calls and zero
create_relationship calls.
Severity: Blocker — the knowledge-loop drift problem the prior plan was supposed to fix is still happening. The graph accumulates nothing structured from this session; the next fleet audit will rediscover every fact from scratch.
8. Empty assistant bubble persisted in the transcript
Where: d9cdcee1 msg 7: {"role":"assistant","text":"","tool_calls":[]}.
What happened: On the "proceed with the rest" turn, the model returned an
empty completion. The inner chatWith retry (agent.go:331) eventually
succeeded and produced msg 8 — but the empty msg 7 was already persisted to
the transcript and stays there. The UI shows an empty assistant bubble between
the user's "proceed" and the agent's actual response.
Severity: Cosmetic, but visible to the operator and erodes trust ("is the agent broken?").
Improvement plan
Phase A — Make propose_plan refuse duplication (addresses #2, #3)
The operator's "plan was added twice" complaint is the visible output of
the append-mode safety net added in 5384499. The safety net was the wrong
default: it preserved history but produced a confusing 10-step sidebar. The
right default is to refuse a re-proposal when a plan is already in
flight — the agent must use update_plan_step + run to advance.
A.1 — proposePlan: set generation on insert (still needed for history)
File: cmd/nomos/store.go:415-491
How:
- Resolve the next generation number at the top of
proposePlan, in the same transaction:var nextGen int if !anyStarted { // fresh/revise: reset to 1 (and the DELETE already wiped old rows) nextGen = 1 } else { if err := tx.QueryRow(ctx, ` SELECT COALESCE(MAX(generation), 0) + 1 FROM session_plan_steps WHERE session_id = $1`, sessionID).Scan(&nextGen); err != nil { return nil, err } } - Add
generationto the INSERT:PassINSERT INTO session_plan_steps (session_id, seq, title, detail, target_slug, generation) VALUES ($1, $2, $3, $4, $5, $6) RETURNING idnextGenas$6. - Include
"generation": nextGenin theoutmap so the tool result and theplan.proposedevent carry it (the frontend already reads it viaapi.ts:66). - Backfill is unnecessary — existing rows default to generation 1.
A.2 — proposePlan: refuse re-proposal once any step has started
File: cmd/nomos/store.go:415-491 + cmd/nomos/tasks.go:206-219
How:
- In
proposePlan, whenanyStarted == true, return a sentinel error instead of appending:if anyStarted { return nil, errPlanInFlight } - In
handleTaskTool'spropose_plancase, detect the sentinel and return a directive tool result:Plan already in flight — refusing duplicate proposal. Steps 1..N exist; at least one is running or done. To advance the plan, call update_plan_step(seq=K, status=running) followed by run(...) for step K's target. Do NOT call propose_plan again. Call it again only if the operator explicitly asks you to revise the whole plan, and if so, say that in your reply before calling it. - Drop the append-mode code path (store.go:437-448) — it's the duplication
source. Keep the destructive-replace path (store.go:432-436) for the
!anyStartedcase (genuine pre-execution revision). - The
replacedstatus becomes unreachable through normal flow but stays in the schema for any future "explicit revise" path that uses it.
This is the single highest-impact fix in this plan. It directly removes the "plan added twice" behavior the operator reported, and forces the agent to use the correct advancement tools. Combined with the directive tool result, even a model that ignores SOUL.md will get the right behavior because the bad action is refused.
A.3 — propose_plan tool description: state the contract crisply
File: internal/mcp/server.go (the propose_plan tool schema)
How: Replace the current description with a one-paragraph contract:
Propose the full ordered plan for this task. Call ONCE per task, before
any execution. After this call: STOP and wait for operator approval.
Once a step has started (status=running/done/...), this tool REFUSES
further calls — use update_plan_step + run to advance. The LAST step
MUST be "Write back: update_entity_attributes + create_relationship
+ upsert_knowledge".
This puts the contract where the model reads it (in the tool schema that gets serialized into the system prompt), not just in SOUL.md where it competes with three overlapping sections.
A.4 — PlanProgress: verify grouping renders with the wired-up column
File: web/src/lib/components/PlanProgress.svelte:17-90
Once A.1 lands, the grouping code that already exists should work. Verify:
- Latest generation (
Math.max(...generations)) → expanded, labeled "Current plan". - Older generations → collapsed by default, labeled "Plan v1 (replaced)", with a count badge.
- A future explicit-revise path (not in this plan) would land generation 2 as the new "Current plan" and the old steps collapse.
This is verification, not new code — the structure is there, it just never received varied generation numbers to group on.
Phase B — Close the three-bug chain that caused the divergence (addresses #3a, #1, #8)
Phase A.2 (refuse re-proposal) prevents the symptom (duplicate plan in sidebar). This phase fixes the cause — the three bugs in finding #3a that made the agent re-propose in the first place. Each fix targets one link in the chain.
B.1 — Emit done after error so the frontend doesn't misclassify (fixes bug 2 — the amplifier)
File: cmd/nomos/agent.go:370-371 (+ the other early-return error paths
at lines 348, 356)
What: On empty response, chatWith emits error and returns without
emitting done. The done event only fires on the success path
(agent.go:382). The frontend's onComplete (chat.ts:349-356) sees
!receivedDone and routes into handleDisconnect — treating a model
failure as a network drop, which triggers an unwanted auto-reconnect →
resumeSession → re-proposal.
How:
- After the
erroremit at line 370, also emitdonebefore returning:emit(agentEvent{Type: "error", Data: "Nomos returned an empty or unusable response — please retry.", SessionID: sessionID}) emit(agentEvent{Type: "done", Data: map[string]any{ "session_id": sessionID, "correlation_id": correlationID, "iterations": i + 1, "error": true, }, SessionID: sessionID}) return - Do the same for the other early-return error paths (agent.go:348 stream
error, agent.go:356 no choices) so every terminal path emits
done. - On the frontend,
onComplete(chat.ts:349-356) now seesreceivedDone === trueand setsstreaming.set(false)instead of callinghandleDisconnect. The error is still shown via theerrorevent handler (chat.ts:329-331). - Add
"error": trueto the done payload so the frontend can distinguish "ended cleanly" from "ended with error" (e.g. to show a retry button instead of loading dots).
Impact: This alone prevents the unwanted resumeSession call after a
model empty-response. The error becomes a visible chat error (with the
retry button from prior Phase 1.6), not a silent trigger for re-execution.
This is the single highest-leverage fix in this phase — it breaks the chain
at the amplifier.
B.2 — Reconnect note: reference the user's last message and plan state (fixes bug 3 — the divergence)
File: cmd/nomos/main.go:180 (reconnect note) + the other resume entry
points at main.go:341 (/resume endpoint) and continue.go:83-86
(idle-sweep note)
What: Even with B.1, genuine network disconnects will still happen. When
they do, the reconnect note ("report your current state and progress") is
too generic — it doesn't tell the agent what the operator actually wanted,
so the agent guesses (badly). The note should carry the operator's last
message and whether a plan is in flight.
How:
- Add two helpers to
store.go:func (s *store) lastUserMessage(ctx, sessionID) string // SELECT text FROM messages WHERE session_id=$1 AND role='user' ORDER BY created_at DESC LIMIT 1 func (s *store) hasPlanInFlight(ctx, sessionID) bool // SELECT EXISTS(... WHERE session_id=$1 AND status IN ('pending','running')) - In
handleChat's reconnect path (main.go:177-188), build a specific note:lastUserMsg := st.lastUserMessage(pctx, req.SessionID) planInFlight := st.hasPlanInFlight(pctx, req.SessionID) note := fmt.Sprintf("[System: the operator's connection was re-established. "+ "The operator's last message was: \"%s\". ", lastUserMsg) if planInFlight { note += "A plan is in flight — advance it with update_plan_step + run. Do NOT call propose_plan again." } else { note += "Report your current state and progress." } note += "]" - Apply the same enrichment to the
/resumeendpoint note (main.go:341) and the idle-sweep note (continue.go:83-86) — all three resume entry points should carry the same context.
Impact: Even if B.1 is bypassed (genuine disconnect mid-plan), the agent gets "advance the plan" instead of "report state." No more re-proposal from reconnect.
B.3 — resumeSession: escalate the recovery note across attempts (fixes bug 1 — the trigger)
File: cmd/nomos/continue.go:229-253
What: The current loop retries 3 times with the same note. A transient model issue (or a prompt causing the model to no-op) gets three identical empty responses.
How:
- Build a different
noteper attempt:notes := []string{ note, // attempt 0: the original (now enriched per B.2) note fmt.Sprintf("[System: your previous turn produced no response. %s. "+ "Produce a response now — call the next tool or report progress in one sentence.]", note), fmt.Sprintf("[System: two consecutive empty responses. Stop trying to be clever. "+ "The next action is: pick the lowest-pending plan step, mark it running with "+ "update_plan_step, and call run for its target. Do that now.]"), } - Pass
notes[attempt]tochatWithso each retry gets a progressively more directive prompt. - Keep the 3-attempt cap.
Impact: A model that's transiently flaking or confused gets a real second chance with an increasingly specific directive, instead of three identical prompts.
B.4 — Surface the real model error text (addresses finding #1's observability)
File: cmd/nomos/agent.go:370 + cmd/nomos/continue.go:255-274
What: The operator-facing message is "Nomos returned an empty or unusable response — please retry." The actual error (OpenRouter 503, content filter, token limit) is logged but not shown.
How:
- In
chatWith's error emit (agent.go:370), includeerrText:emit(agentEvent{Type: "error", Data: fmt.Sprintf("Nomos returned an empty or unusable response: %s", errText), SessionID: sessionID}) - In
resumeSession's failure path (continue.go:262):resumeFailedNote := fmt.Sprintf( "[System: auto-resume failed after 3 attempts. Last error: %s. "+ "The task is paused — send another message to continue.]", errText) - The operator can now tell "model overloaded, just retry" from "content filter — I need to rephrase."
B.5 — Back off between resume retries
File: cmd/nomos/continue.go:229
How: Add a small sleep before attempts 1 and 2:
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
select {
case <-cctx.Done(): return
case <-time.After(time.Duration(2<<attempt) * time.Second): // 4s, 8s
}
}
// ... existing body, using notes[attempt] from B.3
}
B.6 — Don't persist the empty placeholder as a visible bubble
File: cmd/nomos/main.go:251-266 (handleChat placeholder) +
cmd/nomos/continue.go:190-218 (resumeSession placeholder)
What: On d9cdcee1 msg 7, the empty assistant bubble persisted in the
transcript because persist() ran with finalText="" after the error
return. The UI shows an empty bubble.
How:
- Mark the placeholder as pending:
{"role":"assistant","text":"","pending":true}instead of just"". - The frontend renders
pending: trueas loading dots (it already does this for empty text during streaming), not an empty bubble. - On success,
persist()overwrites with real content and dropspending. - In
handleChat's final persist call (main.go:282), iffinalText == ""andlen(toolCalls) == 0, delete the placeholder row instead of persisting an empty bubble:if finalText == "" && len(toolCalls) == 0 && msgID != uuid.Nil { st.deleteMessage(pctx, msgID) } else { persist() }
Phase C — Stop the agent re-executing on clarification (addresses #4, #5)
C.1 — completeTask: reject re-completion of a terminal session
File: cmd/nomos/store.go:completeTask
How:
-
Before the UPDATE, fetch the current status. If it's already
done,failed, orpartial, return without re-updating and surface a no-op message:var current string s.pool.QueryRow(ctx, `SELECT status FROM agent_sessions WHERE id=$1`, sessionID).Scan(¤t) if current == "done" || current == "failed" || current == "partial" { return nil // already terminal — silently no-op }Or, stronger, return an error from
completeTaskand have the caller (tasks.go:275) translate it into a tool-result message:"Session is already complete (status=done). If you want to keep working, call update_plan_step + run; do not call complete_task again." -
The error path is preferred — the agent sees it in the tool result and stops trying to re-complete.
C.2 — SOUL.md: handle "the UI is inconsistent" complaints without re-executing
File: nomos/SOUL.md
How: Add a short rule:
If the operator points out that the chat and the sidebar/plan panel disagree,
DO NOT re-run the work. Investigate the discrepancy by reading state:
get_plan_steps / list current step states → reconcile with a single
update_plan_step call. If the panel is correct and the chat is stale,
summarize the panel in your reply. If the chat is correct and the panel
is stale, fix the panel with update_plan_step. Never re-execute tool
work just to fix a display mismatch.
Phase D — Writeback enforcement that actually sticks (addresses #7)
The current warnings are too easy to ignore because they appear after the agent has already moved on mentally. Make them structural.
D.1 — completeTask: refuse to mark success without writeback when state was discovered
File: cmd/nomos/store.go:completeTask + cmd/nomos/tasks.go:254-282
How: Convert the warning into a refusal when the session actually ran discovery tools:
- Extend
hadEntityWriteback(store.go:624) intohadDiscoveryAndWriteback:-- did the session run discovery? SELECT EXISTS(SELECT 1 FROM audit_log WHERE session_id=$1 AND tool_name IN ('run','get_entity','get_relations','list_lxcs','list_entities')) -- AND did it write back? SELECT EXISTS(SELECT 1 FROM audit_log WHERE session_id=$1 AND tool_name IN ('update_entity_attributes','create_relationship')) - In
completeTask, ifdiscovery=true AND writeback=falseANDoutcomeissuccess:- Force-downgrade the outcome to
partial. - Return a hard error (not just a warning) that the agent must act on:
"Refused: this session ran discovery (run/get_entity/...) but did not call update_entity_attributes or create_relationship. Call those now to persist the facts you learned, then call complete_task again. Outcome downgraded to 'partial' until you do."
- Force-downgrade the outcome to
- The agent gets the error in the tool result, sees the directive, and is
forced to call
update_entity_attributesbefore it can complete.
This is the structural version of 5.4/5.5 from the prior plan — warnings didn't work; enforcement will.
D.2 — propose_plan: auto-append a writeback step if missing
File: cmd/nomos/tasks.go:206-219
How: Instead of (or in addition to) the warning string, append a
synthetic writeback step when none of the proposed steps mention
update_entity_attributes:
hasWritebackStep := false
for _, s := range steps {
if strings.Contains(s.Title+s.Detail, "update_entity_attributes") ||
strings.Contains(s.Title+s.Detail, "create_relationship") {
hasWritebackStep = true
break
}
}
if !hasWritebackStep {
steps = append(steps, planStepInput{
Title: "Write back entity attributes and relationships",
Detail: "Call update_entity_attributes for every entity you ran run/get_entity against (versions, states, hosts, IPs), and create_relationship for any edge you discovered. Then upsert_knowledge about the affected entities.",
})
// re-call proposePlan with the extended steps, or append directly to the
// already-persisted plan via a second INSERT.
}
The agent then sees the explicit step in its own plan and the seq-order enforcement (5.6) forces it to complete that step last.
Phase E — Reduce turn-1 fan-out (addresses #6)
The 722d8878 failure session spent 41 tool calls re-discovering what was
already in the DB.
E.1 — SOUL.md: prefer knowledge over re-execution
File: nomos/SOUL.md
How: Add to the discovery section:
BEFORE calling `run` for fleet-wide facts (apt counts, service versions,
host states), call search_knowledge and get_knowledge_content for the
relevant entity or topic. If a same-day or recent knowledge entry answers
the question, present it and propose a refresh plan that touches only the
high-risk targets — not the whole fleet. Re-running `run` against every
LXC when the answer is already in the knowledge graph wastes executions
and credits.
E.2 — list_lxcs: include last-audited hint in the result
File: internal/mcp/server.go:list_lxcs handler
How: When returning LXCs, include for each row the most recent
knowledge_entities.created_at linked via about edges with kind
investigation or document and a tag matching audit/update. The
agent then sees "nextcloud — last audited 2026-07-14 (today)" and can skip
re-running it.
This is a smaller tweak than E.1 (which is the load-bearing fix) — the data hint makes the SOUL.md rule easy to follow.
Phase F — SOUL.md: be crisp, not repetitive (addresses the operator's "more crisp and clear with the agent" feedback)
SOUL.md grew three overlapping sections across the last 20 commits:
| Section | Added by | Says |
|---|---|---|
## ⚠️ MANDATORY TASK FLOW (top) |
5caf49b (Jul 14) |
6-step flow: set_goal → pre-plan → propose → approve → execute → writeback |
## Every chat is a task (mid) |
e30813a (Jul 11) |
Same 6-step flow, longer, plus the trivial-task degenerate case |
### ⚠️ AFTER EVERY TASK: WRITE BACK OR LOSE IT (inside "Every chat") |
60effcb (Jul 14) |
Writeback rule, third time |
The agent has three places telling it the same thing. The MANDATORY TASK FLOW section at the top is the right one to keep — it's the most directive and the closest to the system-prompt boundary. The other two are lower-fold repetition that bloats context and dilutes the directive.
F.1 — Consolidate SOUL.md to one task-flow section
File: nomos/SOUL.md
How:
- Keep the
## ⚠️ MANDATORY TASK FLOWsection at the top verbatim — it's the load-bearing version. - Replace the
## Every chat is a tasksection (lines ~85-165) with a three-line reference: "Every non-trivial chat follows the MANDATORY TASK FLOW at the top of this file. The flow scales down: a trivial read-only question (e.g. 'status of Y?') is a degenerate case — answer directly and callcomplete_taskwith a one-line summary, no propose_plan ceremony." - Remove the
### ⚠️ AFTER EVERY TASK: WRITE BACK OR LOSE ITsubsection entirely — its content is already step 6 of MANDATORY TASK FLOW and step 3 of "Every chat is a task." Three statements of the same rule don't make it more enforced; they make the file longer. - Result: the file is ~80 lines shorter, the agent has one place to read the task contract, and the directive is unmissable because it's no longer competing with two paraphrased copies.
This is reversible prose work, but it directly addresses the operator's feedback that the agent isn't being "crisp and clear" with itself.
F.2 — Make tool-result strings directive, not advisory
Files: cmd/nomos/tasks.go (the result strings for set_goal,
propose_plan, update_plan_step, complete_task)
How: Audit each tool-result string for hedging language and tighten:
| Current | Tightened |
|---|---|
"Goal set: <goal>. Now do a PRE-PLAN: gather information with read-only tools ... Do NOT call run yet." |
"Goal set. NEXT: pre-plan (read-only tools only). Then propose_plan. Do not call run." |
"Plan set: N step(s). Now STOP and present the plan to the operator — do NOT call run yet. Wait for them to approve ..." |
"Plan set (N steps). STOP. Wait for operator approval. Do not call run." |
"Step N → status" |
"Step N → status. (Use update_plan_step to advance; do not re-propose.)" — only on the first call per session, otherwise unchanged. |
"⚠️ 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." |
(Replaced by D.1's refusal when discovery ran.) |
Short, imperative, no hedging. The agent's behavior in d9cdcee1 shows
that long tool-result strings with "consider revising the last step" are
treated as informational; short imperatives ("STOP. Do not call run.")
are followed.
F.3 — State the approval vocabulary in the plan-result string
File: cmd/nomos/tasks.go:206-219 (propose_plan result)
How: Add the approval vocabulary to the propose_plan result so the agent recognizes "proceed", "go", "continue", "yes", "approved", "ok" as approval and does NOT re-propose on those:
Plan set (N steps). STOP. Wait for operator approval.
Approval vocabulary: "approved", "yes", "go", "proceed", "continue", "ok".
On approval, advance with update_plan_step + run. Do NOT call propose_plan again.
This directly addresses finding #3's cause: the agent re-proposed on "proceed with the rest" because SOUL.md only listed "approved / yes / go ahead" as approval vocabulary. Make the list match what operators actually type.
Sequencing & priority
| # | Fix | Effort | Impact | Phase |
|---|---|---|---|---|
| A.2 | proposePlan refuses re-proposal when in flight |
S | Blocker — directly removes the duplication the operator saw | A |
| B.1 | Emit done after error in chatWith |
S | Blocker — breaks the three-bug chain at the amplifier | B |
| B.2 | Reconnect note carries user's last message + plan state | S | Blocker — fixes the divergence cause | B |
| A.1 | Set generation on INSERT |
S | High — needed for any future explicit-revise flow | A |
| A.3 | propose_plan tool description states the contract |
S | High — agent reads tool schema, often ignores SOUL.md | A |
| F.1 | Consolidate SOUL.md to one task-flow section | S | High — addresses "be more crisp" feedback directly | F |
| F.2 | Tighten tool-result strings to imperatives | S | Medium — observable behavior change | F |
| F.3 | Approval vocabulary in propose_plan result | S | High — fixes the "proceed" → empty-response trigger | F |
| B.3 | Escalate recovery note per resume retry | S | High — turns 3 identical empties into a real recovery | B |
| D.1 | Refuse complete_task without writeback |
M | Blocker — fixes the knowledge loop | D |
| D.2 | Auto-append writeback step to plans | M | High — addresses the cause | D |
| B.4 | Surface real model error text | S | Medium — operator can diagnose | B |
| C.1 | Reject re-completion of terminal sessions | S | Medium — stops duplicate complete_task |
C |
| C.2 | SOUL.md: don't re-execute on UI complaints | S | Medium — prevents the 6 wasted run calls |
C |
| B.5 | Back off between resume retries | S | Low-medium | B |
| B.6 | Don't persist empty placeholder as bubble | M | Cosmetic — but visible to operators | B |
| A.4 | Verify PlanProgress grouping renders | S | Depends on A.1 | A |
| E.1 | SOUL.md: prefer knowledge over re-execution | S | Medium — saves credits on fleet audits | E |
| E.2 | list_lxcs last-audited hint |
M | Low — nice-to-have | E |
Suggested order: A.2 + B.1 + B.2 (the three blockers, ship together) → F (crispness, ships alongside) → A.1/A.3/A.4 → D → B.3/B.4/B.5/B.6 → C → E.
The three blockers form a complete fix for the operator's reported bug:
- A.2 stops the duplication from being possible (refuse re-proposal).
- B.1 stops the empty response from triggering a reconnect/resume
(emit
doneaftererror). - B.2 makes any genuine reconnect carry the right context (advance the plan, don't re-report). Together they close the three-bug chain end-to-end. F.3 (approval vocabulary) closes the trigger of the empty response itself.
Verification
After deploying each phase, replay the same operator prompt in a fresh session and check:
- Phase A: Call
propose_plantwice (manually if needed) and confirm the sidebar shows "Current plan" + a collapsed "Plan v1 (replaced)" section, not a flat 10-step list. - Phase B: Force an empty response (e.g. temporarily throttle OpenRouter
to 0 RPM, or use a stub model that returns
""). Confirm: (a) the frontend shows the error inline and does NOT trigger a reconnect/resume (noauto: truemessage appears 15 seconds later); (b) the operator sees the real error text, not "empty or unusable response"; (c) if you then disconnect the network for real, the reconnect note says "advance the plan" (not "report state") and the agent callsupdate_plan_step+run, notpropose_plan. - Phase C: Start a session, let it
complete_task, then send a follow-up complaint. Confirm the agent does NOT callcomplete_taskagain and does NOT re-run the originalruncalls. - Phase D: Run a fleet-audit prompt. Confirm the agent cannot reach
complete_taskwithoutcome=successwithout first callingupdate_entity_attributesfor at least the LXCs it ranrunagainst. - Phase E: Confirm a same-day audit prompt produces a turn-1 with ≤5 tool calls (search_knowledge + get_knowledge_content + small propose_plan), not 41.
- Phase F: Count SOUL.md lines (target: ~80 fewer than current). Replay
the "proceed with the rest" prompt and confirm the agent does NOT call
propose_planagain (it gets a refusal error on the call, then advances viaupdate_plan_step+run).