Skip to content

feat(widgets): consecutive-patch loop guard in widget operation status#34

Open
nsyring wants to merge 1 commit intoagent0ai:mainfrom
nsyring:fix/widget-consecutive-patch-loop-guard
Open

feat(widgets): consecutive-patch loop guard in widget operation status#34
nsyring wants to merge 1 commit intoagent0ai:mainfrom
nsyring:fix/widget-consecutive-patch-loop-guard

Conversation

@nsyring
Copy link
Copy Markdown

@nsyring nsyring commented Apr 26, 2026

feat(widgets): consecutive-patch loop guard in widget operation status

Summary

Adds a soft-warning fragment to the widget operation status text after the third consecutive patchWidget call on the same widget id without an intervening readWidget, renderWidget, upsertWidget, reloadWidget, or removeWidget on that id. The guard never refuses or throws — the patch still applies — but the agent and a reviewer reading the chat see at the bottom of the status:

Widget "X" patched, rendered ok, loaded to TRANSIENT. Note: 3rd consecutive patch on this widget without a fresh readWidget — verify visually or report current state instead of patching again.

The threshold count, the rules for what resets the streak, and the soft-warning wording are all kept in one small block of pure helpers in app/L0/_all/mod/_core/spaces/store.js. No skill rule changes, no API additions, no caller updates required.

Why

A recurring failure mode I reproduced repeatedly during local widget development: the agent finds something visually off, patches it, the patch reports rendered ok, but the user-visible problem is unchanged (or barely changed). The agent treats technical patch success as confirmation of outcome success, patches again with a slightly different tweak, repeats. After 5–10 patches the renderer has accumulated unrelated drift, the agent has spent a lot of tokens, and the original problem is still there.

Quoting the agent's own self-reflection on the loop:

Sobald sichtbar war: „Graph ist noch kaputt", habe ich das als dauerhaft offene Reparaturpflicht behandelt. […] Die Tools geben Erfolg auf Patch-Ebene, nicht auf Outcome-Ebene. […] Dadurch entsteht leicht ein falsches Gefühl von Fortschritt. […] Was an den Tools besser wäre: ein eingebauter "loop guard with forced mode switch" […] nach z. B. 3 ähnlichen Patches: kein weiteres Patchen ohne neues readWidget oder nur noch "report current state".

The widget skill already says (in space-widgets/SKILL.md and the read-before-mutate rules in system-prompt.md) that the agent should hold a fresh source view before mutating. But that contract is on the prompt-facing side. There was no runtime signal pushing back when the agent ignored the rule. This PR adds that signal, conservatively.

Design choices

Soft warning, not hard veto. The fragment is appended to the status text as Note: …. The patch still applies; the result envelope is unchanged; existing skill rules (fresh read then do it, patch error loop prevention, framework-corrected rewrite continues, etc.) keep their semantics. A hard veto would risk false positives when the user explicitly asks for an iterative tweak loop.

Threshold = 3. Matches the existing convention in PR #6 ("if the same error repeats twice on the same widget, stop and call readWidget"), which already trains the agent to expect 3 as a friction point. Centralized as the CONSECUTIVE_PATCH_WARNING_THRESHOLD constant so a future tuning pass is one line.

What resets the streak:

  • readWidget(id) success on the same id — the agent acknowledged the on-disk state
  • seeWidget(id) success on the same id — the agent performed the visual verification the warning explicitly asks for
  • renderWidget(id), upsertWidget(id), reloadWidget(id), removeWidget(id) — any non-patch operation on that id
  • removeWidgets, removeAllWidgets — clears all affected ids

What does not reset:

  • Patches on a different widget id. The user may genuinely be juggling several widgets in one chat; we want the streak per (spaceId, widgetId), not global.
  • Widget operations on a different space. Same reasoning at the space level.

State scope. The counter map is a module-level Map in spaces/store.js. The state lives for the page lifetime — same scope as the existing transient runtime, the empty-canvas-seen storage, and the prompt-builder caches. A page reload starts fresh, which is the right behavior.

What changed

app/L0/_all/mod/_core/spaces/store.js:

  • New module-level CONSECUTIVE_PATCH_WARNING_THRESHOLD = 3 and consecutivePatchCountByWidgetKey = new Map().
  • buildConsecutivePatchKey(spaceId, widgetId) returns the map key (or empty if either id is missing).
  • recordWidgetOperationForLoopGuard(spaceId, widgetId, operationLabel) increments the counter for patchWidget(...) operations and resets it for any other operation on that id. Returns the new count for the caller.
  • clearWidgetOperationLoopGuard(spaceId, widgetId) clears the counter explicitly, called from readWidget and from the remove-widget paths.
  • formatOrdinalSuffix(value) produces 1st/2nd/3rd/11th/21st/etc. for the warning text.
  • formatConsecutivePatchWarning(consecutivePatchCount) returns the fragment text when count ≥ threshold; otherwise empty.
  • formatWidgetOperationStatusText(...) accepts a new consecutivePatchCount option and appends " Note: <warning>." at the end of the status sentence — after the period that ends the existing sentence — so consumers grepping the existing terminator-style suffix (loaded to TRANSIENT., done.) still match.
  • buildWidgetToolResult(...) calls recordWidgetOperationForLoopGuard(...) before formatting status and forwards the count to the formatter.
  • readWidget(...) calls clearWidgetOperationLoopGuard(...) after a successful storage read.
  • The three removeWidget / removeWidgets / removeAllWidgets paths now also call clearWidgetOperationLoopGuard(...) alongside the existing clearCurrentWidgetTransientSection(...).

Single-file change. Net +86 / −5.

Behavior matrix

Sequence Patch count for streak Status fragment after final op
Fresh: patch 1 (no fragment)
patch, patch 2 (no fragment)
patch, patch, patch 3 Note: 3rd consecutive patch on this widget without a fresh readWidget — verify visually or report current state instead of patching again.
patch, patch, patch, patch 4 Note: 4th consecutive patch on this widget …
patch, patch, read, patch 1 (read reset) (no fragment)
patch, patch, render, patch 1 (render reset) (no fragment)
patch X, patch X, patch Y, patch X X: 3, Y: 1 X patch shows Note: 3rd…; Y patch shows nothing
patch X (different space) per-space counter independent

Test plan

  • node --check passes
  • Pure-helper sanity table verified for: counter increments, reset on non-patch, reset on different-id, ordinal suffix correctness for 1, 2, 3, 4, 5, 11, 12, 13, 21, 22, 23, 101, 103, 111
  • Manual verification across two providers in npm run desktop:pack builds:
    • gpt-5.4 via OpenAI Codex provider — when the agent runs into an iterative-tweak loop on the same widget without verifying visually, the 3rd patch surfaces the warning fragment in the chat transcript; the agent then typically calls readWidget and the streak resets
    • Local qwen3-coder model — same behavior; the smaller model also reads the warning and switches mode

Out of scope (possible follow-ups)

  • A skill-rule wired into the system-prompt that explicitly tells the agent how to react to the warning ("if you see Note: Nth consecutive patch …, the next move must be readWidget(id) or a textual state report"). Adding such a rule needs a separate prompt-engineering pass and a careful read of the existing turn-rule list to avoid contradictions.
  • Tunable threshold via a runtime configuration option. The current PR pins it to 3 as a constant; one future ergonomics pass could expose space.spaces.consecutivePatchWarningThreshold if a deployment needs different sensitivity.
  • A structured consecutivePatchCount field on the result envelope (alongside the status fragment). Skill rules that want to react programmatically would benefit, but the current status-fragment surface is sufficient for the pathology this PR addresses.
  • Counting patches on a per-symbol or per-region level (i.e. flag "third patch touching the same renderer line"). More precise but materially harder to implement; the per-widget counter is a useful first signal.

🤖 Generated with Claude Code

Surfaces a soft warning in the patchWidget status text after the third
consecutive patch on the same widget id without an intervening
readWidget, renderWidget, upsertWidget, reloadWidget, or removeWidget
on that id. The patch still applies; the result envelope is unchanged;
no skill rules or APIs are added. The warning is appended after the
existing terminator (loaded to TRANSIENT. / done.) so consumers
grepping the existing status text still match.

Why: a recurring pathology in agent traces is the iterative-tweak loop
where the agent treats technical patch success as outcome success and
keeps patching after each "rendered ok" without verifying visually.
After 5-10 patches the renderer accumulates unrelated drift, the agent
spends tokens, and the original problem persists. The widget skill
already requires a fresh source view before mutating, but until now
there was no runtime push-back when the agent ignored that rule.

Design choices:

- Soft warning, not hard veto. A veto would risk false positives when
  the user explicitly asks for an iterative tweak loop; a note in the
  status is enough to nudge mode-switch behavior in the agent.
- Threshold = 3, matching the existing widget skill convention from
  PR agent0ai#6 ("if the same error repeats twice, stop and call readWidget").
  Centralized as CONSECUTIVE_PATCH_WARNING_THRESHOLD for a one-line
  future tuning pass.
- Per (spaceId, widgetId), not global. Patches on a different widget
  do not reset another widget's streak so multi-widget chats keep
  their own counters.
- Page-lifetime state (a module-level Map). A page reload starts fresh,
  matching the existing transient runtime and prompt-builder caches.

Single-file change in spaces/store.js. New helpers
recordWidgetOperationForLoopGuard, clearWidgetOperationLoopGuard,
formatOrdinalSuffix, formatConsecutivePatchWarning. The format helper
emits "1st"/"2nd"/"3rd"/"11th"/"21st" correctly. buildWidgetToolResult
records the operation and forwards the count to
formatWidgetOperationStatusText; readWidget and the three remove-paths
clear the counter alongside the existing transient-section clear.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant