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).
890 lines
46 KiB
Markdown
890 lines
46 KiB
Markdown
# 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
|
||
making `proposePlan` APPEND 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 the `generation`
|
||
column, the `replaced` status, the frontend grouping, and the writeback
|
||
warnings. The migration landed; the INSERT in `proposePlan` did 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_knowledge` `about` array** (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_task` writeback warning** (5.5) — fired correctly (the session
|
||
has no `update_entity_attributes` calls and the warning text appears in the
|
||
tool result).
|
||
- **`propose_plan` writeback 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 `replaced` on 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× `run` for `apt-get update` across the fleet). The model
|
||
returned that successfully. Auto-continuation then ran `resumeSession`, which
|
||
retried `chatWith` **3 times** (continue.go:229) — all three came back empty.
|
||
The session ended with the system note above. The operator abandoned it and
|
||
opened `d9cdcee1` with 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:
|
||
|
||
```sql
|
||
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_plan` creates 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_plan` **again** with the same 5 steps.
|
||
`proposePlan` sees `anyStarted=true` (seq 1 is done), so it goes into
|
||
append mode: marks the 4 still-pending steps (2-5) as `replaced`, 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 marked `replaced` (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 `return`s **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:**
|
||
1. Resolve the next generation number at the top of `proposePlan`, in the
|
||
same transaction:
|
||
```go
|
||
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
|
||
}
|
||
}
|
||
```
|
||
2. Add `generation` to the INSERT:
|
||
```sql
|
||
INSERT INTO session_plan_steps (session_id, seq, title, detail, target_slug, generation)
|
||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id
|
||
```
|
||
Pass `nextGen` as `$6`.
|
||
3. Include `"generation": nextGen` in the `out` map so the tool result and
|
||
the `plan.proposed` event carry it (the frontend already reads it via
|
||
`api.ts:66`).
|
||
4. 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:**
|
||
1. In `proposePlan`, when `anyStarted == true`, return a sentinel error
|
||
instead of appending:
|
||
```go
|
||
if anyStarted {
|
||
return nil, errPlanInFlight
|
||
}
|
||
```
|
||
2. In `handleTaskTool`'s `propose_plan` case, 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.
|
||
```
|
||
3. 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
|
||
`!anyStarted` case (genuine pre-execution revision).
|
||
4. The `replaced` status 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:**
|
||
1. After the `error` emit at line 370, also emit `done` before returning:
|
||
```go
|
||
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
|
||
```
|
||
2. 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`.
|
||
3. On the frontend, `onComplete` (chat.ts:349-356) now sees
|
||
`receivedDone === true` and sets `streaming.set(false)` instead of
|
||
calling `handleDisconnect`. The error is still shown via the `error`
|
||
event handler (chat.ts:329-331).
|
||
4. Add `"error": true` to 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:**
|
||
1. Add two helpers to `store.go`:
|
||
```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'))
|
||
```
|
||
2. In `handleChat`'s reconnect path (main.go:177-188), build a specific note:
|
||
```go
|
||
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 += "]"
|
||
```
|
||
3. Apply the same enrichment to the `/resume` endpoint 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:**
|
||
1. Build a different `note` per attempt:
|
||
```go
|
||
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.]"),
|
||
}
|
||
```
|
||
2. Pass `notes[attempt]` to `chatWith` so each retry gets a progressively
|
||
more directive prompt.
|
||
3. 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:**
|
||
1. In `chatWith`'s error emit (agent.go:370), include `errText`:
|
||
```go
|
||
emit(agentEvent{Type: "error", Data: fmt.Sprintf("Nomos returned an empty or unusable response: %s", errText), SessionID: sessionID})
|
||
```
|
||
2. In `resumeSession`'s failure path (continue.go:262):
|
||
```go
|
||
resumeFailedNote := fmt.Sprintf(
|
||
"[System: auto-resume failed after 3 attempts. Last error: %s. "+
|
||
"The task is paused — send another message to continue.]", errText)
|
||
```
|
||
3. 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:
|
||
```go
|
||
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:**
|
||
1. Mark the placeholder as pending:
|
||
`{"role":"assistant","text":"","pending":true}` instead of just `""`.
|
||
2. The frontend renders `pending: true` as loading dots (it already does
|
||
this for empty text during streaming), not an empty bubble.
|
||
3. On success, `persist()` overwrites with real content and drops `pending`.
|
||
4. In `handleChat`'s final persist call (main.go:282), if `finalText == ""`
|
||
and `len(toolCalls) == 0`, delete the placeholder row instead of
|
||
persisting an empty bubble:
|
||
```go
|
||
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:**
|
||
1. Before the UPDATE, fetch the current status. If it's already `done`,
|
||
`failed`, or `partial`, return without re-updating and surface a no-op
|
||
message:
|
||
```go
|
||
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 `completeTask` and 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."`
|
||
|
||
2. 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:
|
||
1. Extend `hadEntityWriteback` (store.go:624) into `hadDiscoveryAndWriteback`:
|
||
```sql
|
||
-- 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'))
|
||
```
|
||
2. In `completeTask`, if `discovery=true AND writeback=false` AND `outcome`
|
||
is `success`:
|
||
- **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."`
|
||
3. The agent gets the error in the tool result, sees the directive, and is
|
||
forced to call `update_entity_attributes` before 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`:
|
||
```go
|
||
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:**
|
||
1. Keep the `## ⚠️ MANDATORY TASK FLOW` section at the top verbatim — it's
|
||
the load-bearing version.
|
||
2. Replace the `## Every chat is a task` section (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 call `complete_task` with a one-line summary, no
|
||
propose_plan ceremony."
|
||
3. Remove the `### ⚠️ AFTER EVERY TASK: WRITE BACK OR LOSE IT` subsection
|
||
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.
|
||
4. 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 `done` after `error`).
|
||
- **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_plan` twice (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
|
||
(no `auto: true` message 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 calls `update_plan_step` + `run`,
|
||
not `propose_plan`.
|
||
- **Phase C:** Start a session, let it `complete_task`, then send a follow-up
|
||
complaint. Confirm the agent does NOT call `complete_task` again and does
|
||
NOT re-run the original `run` calls.
|
||
- **Phase D:** Run a fleet-audit prompt. Confirm the agent cannot reach
|
||
`complete_task` with `outcome=success` without first calling
|
||
`update_entity_attributes` for at least the LXCs it ran `run` against.
|
||
- **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_plan` again (it gets a refusal error on the call, then advances
|
||
via `update_plan_step` + `run`).
|