Skip to content

feat(api-tox): v2.2.0 Phase 1.1 — auto-rollback on error regression - #34

Merged
dreamrec merged 3 commits into
mainfrom
claude/v2.2.0-feature-1.1-auto-rollback
May 11, 2026
Merged

feat(api-tox): v2.2.0 Phase 1.1 — auto-rollback on error regression#34
dreamrec merged 3 commits into
mainfrom
claude/v2.2.0-feature-1.1-auto-rollback

Conversation

@dreamrec

Copy link
Copy Markdown
Owner

Summary

First feature of the v2.2.0→v3.0 roadmap (see docs/ROADMAP.md — Phase 1 "Reliability foundation"). Wraps each LLM tool batch with a baseline-and-diff against td_get_errors plus a TD ui.undo.startBlock so the whole batch becomes one undo entry. If new critical errors appear (compile-class: Python syntax, expression-parse, GLSL compile, Script DAT load), the batch is rolled back atomically via ui.undo.undo() and a hint is appended to the last tool_result so the LLM sees the regression on its next API call.

This is the trust-foundation feature: changes what kind of work you can hand the agent — without it, every multi-step build needs supervision; with it, the agent can fail safely on its own.

No version bump. Phase 1 cuts as v2.2.0 when the whole phase is in (auto-rollback is 1.1 of ~6 features).

Design highlights

Decision Why
Snapshot = TD ui.undo.startBlock Reuses the battle-tested patch_* infra. Cheaper than .toe snapshots (~500ms-2s avoided per batch). Atomic rollback.
Critical = compile-class only Narrow set of substring matches (SyntaxError, Compile error, Failed to compile, etc.). Runtime errors / warnings / missing-file refs are noisy during a build; false-positive rollbacks would erode trust faster than missing some regressions.
Stand down on td_exec_python Side effects can't be reverted by undo. Half-rolling-back is worse than not rolling back.
Skip pure-read batches Saves two td_get_errors calls per batch when nothing can mutate state.
Hint goes in last tool_result.content Preserves Anthropic's alternating user/assistant constraint. Pairs the regression context with the failing batch's results — the LLM is most likely to attend to it there.
Internal handlers, not LLM-callable auto_rollback_begin / auto_rollback_end live in TOOL_TO_HANDLER but NOT TOOL_SCHEMAS. New INTERNAL_ONLY_TOOL_NAMES set keeps the schema-vs-handler parity pin tests meaningful.

Opt-out

Set TDPILOT_DISABLE_AUTO_ROLLBACK=1 in the TD process environment. The runtime's guard factory returns None, and Agent._loop becomes a literal no-op around the would-be guard.

Files

New (2):

  • td_component/tdpilot_api_rollback.py (~330 lines, +tests): predicate, diff, batch classifier, AutoRollbackGuard context manager, the two cook-thread ui.undo-touching handlers.
  • tests/test_tdpilot_api_rollback.py (~290 lines): 60 tests across the predicate (20+ pattern cases), diff, batch classifier, env-var gate, the guard's state machine (with a recorded mock dispatcher covering clean / regression / baseline-failure / undo-block-failure / exception-mid-batch / exec_python-standdown paths), the hint formatter, and the internal handlers' outside-TD failure mode.

Modified (7):

  • td_component/tdpilot_api_agent.pyrollback_guard_factory ctor kwarg; _loop wraps the per-batch loop with the guard.
  • td_component/tdpilot_api_runtime.py_build_rollback_guard_factory honours TDPILOT_DISABLE_AUTO_ROLLBACK; returns None when disabled.
  • td_component/tdpilot_api_extension.py — adds the rollback module to handler_modules.
  • td_component/tdpilot_api_schema_map.py — registers the two internal handlers + INTERNAL_ONLY_TOOL_NAMES frozenset.
  • td_component/build_tdpilot_api_tox.py — adds tdpilot_api_rollback to _SOURCE_FILES.
  • scripts/check_tox_api_freshness.py — paired-list sync (commented gotcha in the file; future-PR opportunity to consolidate).
  • tests/test_tdpilot_api_batch.py + tests/test_tdpilot_api_tracing.py — parity pins now subtract INTERNAL_ONLY_TOOL_NAMES.

Binary update (2): td_component/tdpilot_API.tox (213,814 → 221,182 bytes — new module bundled) + .tox-api-source-hash.json.

Test plan

  • uv run pytest tests/ --ignore=tests/agent_evals -q1760 passed (1700 prior + 60 new).
  • uv run --extra dev ruff format --check src tests scripts td_component — clean.
  • uv run --extra dev ruff check src tests scripts td_component — clean.
  • uv run python scripts/check_versions.py — in sync at v2.1.5 (correct — no bump on this PR).
  • uv run python scripts/check_tox_freshness.py — MCP-server tox fresh (not touched).
  • uv run python scripts/check_tox_api_freshness.pyAPI tox fresh (rebuilt by maintainer in TD).
  • bash scripts/check_no_personal_paths.sh — clean.
  • CI all-green on push.

Manual verification (post-merge, in TD)

  1. Open tdpilot_API.tox in /project1.
  2. Open the chat and ask the agent to do something compile-breaking on purpose (e.g. "create a glslTOP and set its pixeldat parameter to a DAT that doesn't exist yet").
  3. Expected: agent's tool batch returns with [tdpilot_auto_rollback] This batch introduced 1 new critical error(s)… in the chat bubble, the COMP's td_get_errors recurse=True shows zero new criticals (rollback succeeded), and the agent self-corrects on its next turn.
  4. Disable check: set TDPILOT_DISABLE_AUTO_ROLLBACK=1, reload the COMP, repeat — the network should now stay broken (no rollback).

🤖 Generated with Claude Code

dreamrec and others added 3 commits May 11, 2026 16:49
First feature of the v2.2.0→v3.0 reliability phase (see
docs/ROADMAP.md). Wraps each LLM tool batch with a baseline-and-diff
check against td_get_errors plus a TD ui.undo.startBlock so the
whole batch is one undo entry. If the batch introduces new critical
errors (compile-style: Python syntax, expression-parse, GLSL
compile, Script DAT load), ui.undo.undo() rolls it back atomically
and a hint is appended to the last tool_result so the LLM sees the
regression on its next API call.

Pure-read batches skip the wrap (saves two td_get_errors calls);
batches containing td_exec_python / td_emergency_stabilize /
td_patch_apply stand down because their side effects aren't
undo-reversible (half-rolling-back is worse than not rolling back).

Disable via env var TDPILOT_DISABLE_AUTO_ROLLBACK=1. No version
bump — Phase 1 ships as v2.2.0 when the whole phase is in.

Implementation:

- NEW td_component/tdpilot_api_rollback.py — pure-Python predicate
  (is_critical_error), diff (diff_errors), batch classifier
  (batch_should_be_guarded), and the AutoRollbackGuard context
  manager. Two cook-thread handlers (handle_auto_rollback_begin /
  handle_auto_rollback_end) registered in TOOL_TO_HANDLER but NOT
  in TOOL_SCHEMAS — the LLM never sees them as callable tools.

- tdpilot_api_agent.py — new rollback_guard_factory ctor kwarg;
  _loop wraps the per-batch for-loop with the guard. Hint goes into
  the last tool_result's content (preserves the alternating
  user/assistant constraint) and also surfaces via on_text for the
  chat UI.

- tdpilot_api_runtime.py — _build_rollback_guard_factory reads
  TDPILOT_DISABLE_AUTO_ROLLBACK and returns None when disabled, in
  which case Agent._loop is a literal no-op around the guard.

- tdpilot_api_schema_map.py — INTERNAL_ONLY_TOOL_NAMES frozenset
  registered next to TOOL_TO_HANDLER; the schema-vs-handler parity
  pin tests in test_tdpilot_api_batch.py + test_tdpilot_api_tracing.py
  subtract this set so the parity invariant stays meaningful.

- tdpilot_api_extension.py — registers tdpilot_api_rollback as a
  handler module so the dispatcher finds the two internal handlers.

- build_tdpilot_api_tox.py — adds tdpilot_api_rollback to
  _SOURCE_FILES (auto-rolls into _API_TOX_SOURCE_FILES via the
  derivation in lines 1-50ish).

Tests:

- 60 new tests in tests/test_tdpilot_api_rollback.py covering the
  predicate (20+ pattern cases), diff, batch classifier, env-var
  gate, the guard's state machine (with a recorded mock dispatcher
  covering clean / regression / baseline-failure / undo-block-failure
  / exception-mid-batch / exec_python-standdown paths), the hint
  formatter, and the internal handlers' outside-TD failure mode.

- Two pre-existing parity-pin tests updated to honour the
  INTERNAL_ONLY_TOOL_NAMES exclusion.

Local sweep:
- pytest: 1760 passed (1700 prior + 60 new).
- ruff format / check: clean.
- check_versions: in sync at v2.1.5 (no bump on this PR).
- check_tox_freshness (MCP server tox): fresh — that tox not touched.
- check_tox_api_freshness (chat-pipe tox): EXPECTED FAIL until the
  user rebuilds the .tox inside TouchDesigner. See the rebuild
  recipe in AGENTS.md / feedback_td_tox_rebuild_recipe.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Paired-list maintenance: build_tdpilot_api_tox.py:_API_TOX_SOURCE_FILES
and scripts/check_tox_api_freshness.py:SOURCE_FILES must contain the
same paths — the build script writes the hash, the check script
verifies it. Adding tdpilot_api_rollback.py to one but not the other
produced a stable mismatch (built hash a9a4..., check-computed
hash da67...) even on a freshly-rebuilt .tox.

This is a known footgun (the comment on line 28-32 of the check
script flags it explicitly); future Phase 1+ features adding new
source files will hit the same trap until both lists are
consolidated behind a single source of truth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4e75b0d036

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +363 to +367
self._dispatcher("auto_rollback_end", {"undo": True})
except Exception: # noqa: BLE001
pass
self.rollback_fired = True
self.hint_text = format_hint(diff)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Confirm undo succeeded before claiming rollback

When new critical errors are detected, this branch marks rollback_fired=True and formats a “changes were automatically reverted” hint even if auto_rollback_end fails or returns an error payload. In those failure cases the network can remain broken while the agent and user are told rollback succeeded, which can corrupt follow-up tool decisions and hide a real regression. Please gate rollback_fired/rolled_back=True messaging on a successful auto_rollback_end result (e.g., ok/rolled_back), not just on _undo_block_opened.

Useful? React with 👍 / 👎.

Comment on lines +610 to +611
and getattr(rollback_guard, "rollback_fired", False)
and getattr(rollback_guard, "hint_text", "")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Append rollback hint even when undo block never opened

The guard explicitly builds a non-rollback hint (rolled_back=False) when regressions are detected but the undo block could not be opened, yet this condition only appends hints when rollback_fired is true. That drops the only regression signal in exactly the path where rollback failed, so the model gets no feedback and may continue from a broken graph state. The append condition should key on hint_text (or new_critical_count) rather than requiring rollback_fired.

Useful? React with 👍 / 👎.

@dreamrec
dreamrec merged commit cfad3a4 into main May 11, 2026
6 checks passed
@dreamrec
dreamrec deleted the claude/v2.2.0-feature-1.1-auto-rollback branch May 11, 2026 13:58
dreamrec added a commit that referenced this pull request May 11, 2026
PR #34 (v2.2.0 Phase 1.1) exposed the paired-list drift footgun: adding
tdpilot_api_rollback.py to build_tdpilot_api_tox.py::_API_TOX_SOURCE_FILES
without updating check_tox_api_freshness.py::SOURCE_FILES broke CI for two
commits — the build wrote a hash over one set of files while the freshness
gate recomputed over a different set. The same shape existed for the dpsk4
.tox pair.

Establishes a single source of truth: each check script now imports the
canonical tuple from the corresponding build script, dropping its parallel
SOURCE_FILES definition. Drift is now structurally impossible.

Required changes per file:

  * td_component/build_export_mcp_tox.py
  * td_component/build_tdpilot_api_tox.py
    Add `if __name__ != "<module_name>":` guard around the trailing
    build_and_export() call so the build scripts become safely importable
    from CI. Negative-form guard (not `__name__ == "__main__"`) because TD's
    startup hook in tdpilot_dpsk4_startup.py exec's these files with the
    startup module's globals, so __name__ is something like
    "tdpilot_dpsk4_startup" — the conventional Python entry-point form
    would have suppressed the auto-rebuild that TD users depend on. The
    _TOX_SOURCE_FILES and _API_TOX_SOURCE_FILES tuples themselves are
    byte-identical (verified via git diff and the .tox-source-hash.json
    `source_files` arrays — only `tox_source_hash` + `built_at` changed).

  * scripts/check_tox_freshness.py
  * scripts/check_tox_api_freshness.py
    Drop the parallel SOURCE_FILES tuple. Import the canonical list from
    the build script (sys.path insert for td_component/ so the import
    resolves under `uv run python scripts/...`).

  * tests/test_build_script_panel_fixes.py
    test_state_cache_listed_in_freshness_gate previously asserted that
    the literal string "td_component/state_cache.py" appeared in
    check_tox_freshness.py. With the parallel list gone, the literal is
    only in the build script. The test now guards the new invariant
    instead: the gate must import _TOX_SOURCE_FILES from the build
    script (a regression test against reintroducing the drift footgun)
    AND state_cache.py must be in the imported tuple.

  * td_component/.tox-source-hash.json
  * td_component/.tox-api-source-hash.json
    The build scripts are themselves in their own hash lists (PR #19's
    info-textDAT class of bug), so the guard edits bump the source hash
    even though the .tox content is unchanged. Refreshed via the build
    scripts' own _write_tox_source_hash / _write_api_tox_source_hash
    helpers (now safely importable thanks to the guard) — `source_files`
    arrays are bit-for-bit identical to the pre-edit state, confirming
    the tuples themselves didn't drift. The .tox binaries are untouched.

Verification:
  - uv run python scripts/check_tox_freshness.py     → exit 0
  - uv run python scripts/check_tox_api_freshness.py → exit 0
  - uv run pytest tests/                             → 1760 passed
  - uvx ruff check on edited files                   → clean

No version bump — this is a pure refactor; the 7-manifest lockstep doesn't
apply.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dreamrec added a commit that referenced this pull request May 11, 2026
* fix(api-tox): Codex P1+P2 followups on auto-rollback (PR #34)

Two real bugs Codex caught on the v2.2.0 Phase 1.1 ship.

P1 — `rollback_fired = True` claimed even when undo failed
=========================================================

In `AutoRollbackGuard.__exit__`, pre-fix code set `rollback_fired =
True` whenever `auto_rollback_end` was attempted, regardless of the
handler's actual return value. Failure modes:

  * dispatcher raises (caught by `except Exception: pass`)
  * handler returns `{"error": "ui.undo.endBlock failed: ..."}`
  * handler returns `{"ok": False, "endblock_ok": True,
    "undo_error": "..."}` (the dpsk4-baked `handle_auto_rollback_end`
    explicitly returns this when `ui.undo.undo()` raises)
  * handler returns `{"ok": True}` (partial payload — defensive)

…all of these reported "reverted" to both the user and the LLM
while the network stayed broken. Silent-correctness bug, not a
crash. Worst case: agent's follow-up decisions cascade on a
corrupted graph while the chat UI shows a confident "reverted"
message.

Fix: capture the dispatcher's return value, only set rollback_fired
= True when it's `{"ok": True, "rolled_back": True}`. Every other
shape (raise, ok=False, partial) routes to the degraded hint with a
short `(undo raised: ...)` / `(endBlock raised: ...)` detail clause
appended so the LLM can decide whether to retry or escalate.

P2 — hint dropped when undo block never opened
==============================================

In `Agent._loop`, pre-fix the hint-append condition was:

    if (rollback_guard is not None
        and getattr(rollback_guard, "rollback_fired", False)
        and getattr(rollback_guard, "hint_text", "")
        and ...): ...

The middle clause keyed on `rollback_fired`. But in the degraded
path (regression detected but undo block didn't open), the guard
sets `rollback_fired = False` while still populating `hint_text`
specifically to flag the failure. The condition dropped that hint
entirely — leaving the LLM continuing from a broken graph with zero
feedback in exactly the failure mode where it most needed it.

Fix: extracted the inline block into `Agent._apply_rollback_hint`
(testable as a method), condition keys on `hint_text` presence
instead of `rollback_fired`. Both success and degraded paths now
surface the hint to LLM + chat UI uniformly.

Tests
=====

12 new regression tests in `tests/test_tdpilot_api_rollback.py`:

  TestCodexP1RollbackFiredAccuracy (5 tests)
    - end-handler returns `{"ok": False, "undo_error": ...}`
      → rollback_fired stays False, hint contains "(undo raised: ...)"
    - end-handler returns `{"error": "endBlock failed: ..."}`
      → rollback_fired stays False, hint contains "(endBlock raised: ...)"
    - dispatcher raises during auto_rollback_end
      → rollback_fired stays False, degraded hint
    - end-handler returns `{"ok": True, "rolled_back": True}` (happy path)
      → rollback_fired = True, "reverted" hint (no regression on happy path)
    - end-handler returns `{"ok": True}` (partial)
      → rollback_fired stays False (defensive)

  TestCodexP2HintAppendInDegradedPath (7 tests)
    - hint appends with rollback_fired=False (the actual P2 fix-trigger)
    - hint appends with rollback_fired=True (happy path)
    - empty hint_text is a no-op
    - None guard is a no-op
    - empty results_block is a no-op
    - list-shaped tool_result.content gets a text-block appended
    - on_text raising doesn't break the LLM-side append

Refactor
========

Extracted `Agent._loop`'s hint-append block into `Agent._apply_rollback_hint`
so the P2 path is directly testable without urlopen mocking. Also
clean reuse point for future Phase 1 features that need the same
results_block-augmentation pattern (e.g. Phase 1.3 mid-turn integrity
check might want to fold its findings into the same callsite).

Local sweep: 1772 pytest passed (1760 prior + 12 new). Ruff
format/check clean. Versions in sync. dpsk4 .tox freshness clean
(not touched). API .tox freshness FAIL until rebuilt — that's the
final pre-push step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: rebuild tdpilot_API.tox for Codex P1+P2 followups

Bakes the rollback-success-gating fix (P1) and the extracted
Agent._apply_rollback_hint method (P2) into the .tox so users
get the corrected auto-rollback behaviour at COMP load time.

213,814 → 222,406 bytes (~+8.5KB delta from PR #34's tox —
mostly the new private method body + the broadened format_hint).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dreamrec added a commit that referenced this pull request May 11, 2026
* feat(api-tox): v2.2.0 Phase 1.2 — cycle detection in tool chains

Second feature of the v2.2.0 reliability phase (Phase 1.1 was
auto-rollback; see PR #34 + Codex P1/P2 follow-up in PR #36). Pairs
with 1.1 to cover the two high-impact reliability gaps:

  - 1.1 catches "agent broke things" → undo the batch
  - 1.2 catches "agent is stuck looping" → break the turn

How it works
============

A ``CycleLedger`` is constructed once per turn in ``Agent._loop``
and tracks ``(tool_name, args_hash) -> count``. Each call to
``ledger.record(name, args)`` increments and returns the new count.
The agent loop checks against the threshold (default 3) BEFORE
dispatching:

  - count == 1: first time, dispatch.
  - count == 2: second time, dispatch.
  - count >= 3: ``CycleDetected`` raised; ``run_turn``'s catch fires
    ``on_error`` → ``EV_ERROR`` + ``EV_STATE: idle`` in the event
    stream → red banner in the chat UI with tool name + args preview.

The threshold of 3 is conservative on purpose — 2 dispatches give
the agent a chance to recover from a one-off failure before declaring
the turn stuck.

Args identity uses sorted-keys JSON so ``{"a":1,"b":2}`` and
``{"b":2,"a":1}`` collapse to the same key; nested dicts hash
recursively; lists are order-sensitive (connect source/dest
ordering matters). Disable via env var
``TDPILOT_DISABLE_CYCLE_DETECTION=1``.

Implementation
==============

- NEW td_component/tdpilot_api_cycle_detector.py — pure-Python:
  args_hash, CycleLedger, CycleDetected, env-var gate, factory
  builder. No TouchDesigner imports.

- tdpilot_api_agent.py:
  - New cycle_ledger_factory ctor kwarg.
  - _loop builds one ledger per turn after model resolution.
  - Inner per-batch for-loop checks ledger.record() BEFORE
    on_tool_call/dispatch; raises CycleDetected at the threshold.
  - CycleDetected is **late-imported** from cycle_detector inside
    _loop to avoid a circular import (cycle_detector imports
    AgentError from agent at top-level). Cost: one dict lookup per
    turn after the import is cached.

- tdpilot_api_runtime.py:
  - New _build_cycle_ledger_factory method honours
    TDPILOT_DISABLE_CYCLE_DETECTION=1.
  - AgentRuntime._build_agent passes the factory to Agent().

- build_tdpilot_api_tox.py:
  - Adds tdpilot_api_cycle_detector to _SOURCE_FILES so it's baked
    into the API .tox. (Post-#35 the freshness gate auto-imports
    this same list, so no parallel-list maintenance.)

Tests (49 new)
==============

- TestArgsHash (8) — order independence, normalization, nested
  ordering, list-order sensitivity, defensive paths.
- TestCycleLedger (10) — threshold validation (>=2), increment,
  per-key isolation, peek/reset, len semantics.
- TestCycleDetectedException (3) — metadata, message formatting,
  AgentError membership.
- TestEnvVarGate (3) — truthy/falsy classification.
- TestBuildCycleLedgerFactory (3) — env-disable, fresh instances,
  custom threshold.
- TestUsagePattern (4) — the canonical check-then-dispatch flow.
- TestFormatArgsSummary (3) — render rules.
- TestAgentLoopCycleIntegration (3) — end-to-end with mocked
  urlopen, proving the late-import + raise + on_error path
  actually works in run_turn. Covers: enabled+stuck (raises on
  3rd, dispatches 2), disabled-factory (no check, hits
  turn_budget), distinct-args (never triggers, completes cleanly).

Local sweep
===========

- pytest: 1821 passed (1772 prior + 49 new).
- ruff format + check: clean.
- check_versions: in sync at v2.1.5 (no bump on this PR).
- check_tox_freshness (dpsk4): fresh — that .tox not touched.
- check_tox_api_freshness (chat-pipe): EXPECTED FAIL until the
  user rebuilds the .tox via the canonical Textport recipe.
- check_no_personal_paths: clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: rebuild tdpilot_API.tox for Phase 1.2 cycle detection

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dreamrec added a commit that referenced this pull request May 11, 2026
First milestone of the v2.2.0→v3.0 roadmap (see docs/ROADMAP.md).
Phase 1 ships in full; the chat-pipe agent is now safe to leave
unsupervised on complex builds, two failure modes have automatic
recovery, and drag-and-go finally works for new users out of the box.

Bundled in this release
=======================

  - 1.1 Auto-rollback on error regression (PR #34 + #36)
  - 1.2 Cycle detection in tool chains (PR #37)
  - 1.2.1 Drag-and-go UX polish — Authmode COMP param,
    auto-save+auto-reload on Apikey change, 401 reconnect
    banner (PR #38)
  - 1.2.2 Build scripts auto-mirror .tox into main repo on rebuild,
    eliminating stale-symlink footgun (PR #39)
  - PR #35 paired source-file-list refactor (build-script + freshness-
    check now share a single source of truth)

136 new tests since v2.1.5. Total suite at release: 1848 passing.

Headline behaviour change for end users
=======================================

Default chat-pipe webserver auth posture changes from token-required
to origin-allowlist-only (Authmode=open default). The origin
allowlist still rejects cross-origin browser CSRF, so this is safe
on TouchDesigner's typical single-user dev/perform usage profile.
Users sharing .toe files across machines should set Authmode=token
on the COMP for that deployment.

The MCP-server tdpilot-dpsk4.tox (port 9985) auth model is UNCHANGED
— TD_MCP_SHARED_SECRET still required.

Bumps + rebuilt artifacts
=========================

All 10 versioned files (7 manifests + 3 doc titles) bumped from
2.1.5 to 2.2.0 (scripts/check_versions.py enforces sync). API_VERSION
in td_component/callbacks/_header.py also bumped; this invalidated
both .tox source-hashes, both rebuilt inside TouchDesigner via the
canonical recipe. Auto-mirror (Phase 1.2.2, just landed) propagated
the rebuilt .tox files to the main repo's td_component/ automatically.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dreamrec added a commit that referenced this pull request May 19, 2026
)

* feat(api-tox): v2.2.0 Phase 1.1 — auto-rollback on error regression

First feature of the v2.2.0→v3.0 reliability phase (see
docs/ROADMAP.md). Wraps each LLM tool batch with a baseline-and-diff
check against td_get_errors plus a TD ui.undo.startBlock so the
whole batch is one undo entry. If the batch introduces new critical
errors (compile-style: Python syntax, expression-parse, GLSL
compile, Script DAT load), ui.undo.undo() rolls it back atomically
and a hint is appended to the last tool_result so the LLM sees the
regression on its next API call.

Pure-read batches skip the wrap (saves two td_get_errors calls);
batches containing td_exec_python / td_emergency_stabilize /
td_patch_apply stand down because their side effects aren't
undo-reversible (half-rolling-back is worse than not rolling back).

Disable via env var TDPILOT_DISABLE_AUTO_ROLLBACK=1. No version
bump — Phase 1 ships as v2.2.0 when the whole phase is in.

Implementation:

- NEW td_component/tdpilot_api_rollback.py — pure-Python predicate
  (is_critical_error), diff (diff_errors), batch classifier
  (batch_should_be_guarded), and the AutoRollbackGuard context
  manager. Two cook-thread handlers (handle_auto_rollback_begin /
  handle_auto_rollback_end) registered in TOOL_TO_HANDLER but NOT
  in TOOL_SCHEMAS — the LLM never sees them as callable tools.

- tdpilot_api_agent.py — new rollback_guard_factory ctor kwarg;
  _loop wraps the per-batch for-loop with the guard. Hint goes into
  the last tool_result's content (preserves the alternating
  user/assistant constraint) and also surfaces via on_text for the
  chat UI.

- tdpilot_api_runtime.py — _build_rollback_guard_factory reads
  TDPILOT_DISABLE_AUTO_ROLLBACK and returns None when disabled, in
  which case Agent._loop is a literal no-op around the guard.

- tdpilot_api_schema_map.py — INTERNAL_ONLY_TOOL_NAMES frozenset
  registered next to TOOL_TO_HANDLER; the schema-vs-handler parity
  pin tests in test_tdpilot_api_batch.py + test_tdpilot_api_tracing.py
  subtract this set so the parity invariant stays meaningful.

- tdpilot_api_extension.py — registers tdpilot_api_rollback as a
  handler module so the dispatcher finds the two internal handlers.

- build_tdpilot_api_tox.py — adds tdpilot_api_rollback to
  _SOURCE_FILES (auto-rolls into _API_TOX_SOURCE_FILES via the
  derivation in lines 1-50ish).

Tests:

- 60 new tests in tests/test_tdpilot_api_rollback.py covering the
  predicate (20+ pattern cases), diff, batch classifier, env-var
  gate, the guard's state machine (with a recorded mock dispatcher
  covering clean / regression / baseline-failure / undo-block-failure
  / exception-mid-batch / exec_python-standdown paths), the hint
  formatter, and the internal handlers' outside-TD failure mode.

- Two pre-existing parity-pin tests updated to honour the
  INTERNAL_ONLY_TOOL_NAMES exclusion.

Local sweep:
- pytest: 1760 passed (1700 prior + 60 new).
- ruff format / check: clean.
- check_versions: in sync at v2.1.5 (no bump on this PR).
- check_tox_freshness (MCP server tox): fresh — that tox not touched.
- check_tox_api_freshness (chat-pipe tox): EXPECTED FAIL until the
  user rebuilds the .tox inside TouchDesigner. See the rebuild
  recipe in AGENTS.md / feedback_td_tox_rebuild_recipe.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: rebuild tdpilot_API.tox for v2.2.0 Phase 1.1 (auto-rollback)

* ci(api-tox): sync SOURCE_FILES list to include tdpilot_api_rollback.py

Paired-list maintenance: build_tdpilot_api_tox.py:_API_TOX_SOURCE_FILES
and scripts/check_tox_api_freshness.py:SOURCE_FILES must contain the
same paths — the build script writes the hash, the check script
verifies it. Adding tdpilot_api_rollback.py to one but not the other
produced a stable mismatch (built hash a9a4..., check-computed
hash da67...) even on a freshly-rebuilt .tox.

This is a known footgun (the comment on line 28-32 of the check
script flags it explicitly); future Phase 1+ features adding new
source files will hit the same trap until both lists are
consolidated behind a single source of truth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dreamrec added a commit that referenced this pull request May 19, 2026
PR #34 (v2.2.0 Phase 1.1) exposed the paired-list drift footgun: adding
tdpilot_api_rollback.py to build_tdpilot_api_tox.py::_API_TOX_SOURCE_FILES
without updating check_tox_api_freshness.py::SOURCE_FILES broke CI for two
commits — the build wrote a hash over one set of files while the freshness
gate recomputed over a different set. The same shape existed for the dpsk4
.tox pair.

Establishes a single source of truth: each check script now imports the
canonical tuple from the corresponding build script, dropping its parallel
SOURCE_FILES definition. Drift is now structurally impossible.

Required changes per file:

  * td_component/build_export_mcp_tox.py
  * td_component/build_tdpilot_api_tox.py
    Add `if __name__ != "<module_name>":` guard around the trailing
    build_and_export() call so the build scripts become safely importable
    from CI. Negative-form guard (not `__name__ == "__main__"`) because TD's
    startup hook in tdpilot_dpsk4_startup.py exec's these files with the
    startup module's globals, so __name__ is something like
    "tdpilot_dpsk4_startup" — the conventional Python entry-point form
    would have suppressed the auto-rebuild that TD users depend on. The
    _TOX_SOURCE_FILES and _API_TOX_SOURCE_FILES tuples themselves are
    byte-identical (verified via git diff and the .tox-source-hash.json
    `source_files` arrays — only `tox_source_hash` + `built_at` changed).

  * scripts/check_tox_freshness.py
  * scripts/check_tox_api_freshness.py
    Drop the parallel SOURCE_FILES tuple. Import the canonical list from
    the build script (sys.path insert for td_component/ so the import
    resolves under `uv run python scripts/...`).

  * tests/test_build_script_panel_fixes.py
    test_state_cache_listed_in_freshness_gate previously asserted that
    the literal string "td_component/state_cache.py" appeared in
    check_tox_freshness.py. With the parallel list gone, the literal is
    only in the build script. The test now guards the new invariant
    instead: the gate must import _TOX_SOURCE_FILES from the build
    script (a regression test against reintroducing the drift footgun)
    AND state_cache.py must be in the imported tuple.

  * td_component/.tox-source-hash.json
  * td_component/.tox-api-source-hash.json
    The build scripts are themselves in their own hash lists (PR #19's
    info-textDAT class of bug), so the guard edits bump the source hash
    even though the .tox content is unchanged. Refreshed via the build
    scripts' own _write_tox_source_hash / _write_api_tox_source_hash
    helpers (now safely importable thanks to the guard) — `source_files`
    arrays are bit-for-bit identical to the pre-edit state, confirming
    the tuples themselves didn't drift. The .tox binaries are untouched.

Verification:
  - uv run python scripts/check_tox_freshness.py     → exit 0
  - uv run python scripts/check_tox_api_freshness.py → exit 0
  - uv run pytest tests/                             → 1760 passed
  - uvx ruff check on edited files                   → clean

No version bump — this is a pure refactor; the 7-manifest lockstep doesn't
apply.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dreamrec added a commit that referenced this pull request May 19, 2026
* fix(api-tox): Codex P1+P2 followups on auto-rollback (PR #34)

Two real bugs Codex caught on the v2.2.0 Phase 1.1 ship.

P1 — `rollback_fired = True` claimed even when undo failed
=========================================================

In `AutoRollbackGuard.__exit__`, pre-fix code set `rollback_fired =
True` whenever `auto_rollback_end` was attempted, regardless of the
handler's actual return value. Failure modes:

  * dispatcher raises (caught by `except Exception: pass`)
  * handler returns `{"error": "ui.undo.endBlock failed: ..."}`
  * handler returns `{"ok": False, "endblock_ok": True,
    "undo_error": "..."}` (the dpsk4-baked `handle_auto_rollback_end`
    explicitly returns this when `ui.undo.undo()` raises)
  * handler returns `{"ok": True}` (partial payload — defensive)

…all of these reported "reverted" to both the user and the LLM
while the network stayed broken. Silent-correctness bug, not a
crash. Worst case: agent's follow-up decisions cascade on a
corrupted graph while the chat UI shows a confident "reverted"
message.

Fix: capture the dispatcher's return value, only set rollback_fired
= True when it's `{"ok": True, "rolled_back": True}`. Every other
shape (raise, ok=False, partial) routes to the degraded hint with a
short `(undo raised: ...)` / `(endBlock raised: ...)` detail clause
appended so the LLM can decide whether to retry or escalate.

P2 — hint dropped when undo block never opened
==============================================

In `Agent._loop`, pre-fix the hint-append condition was:

    if (rollback_guard is not None
        and getattr(rollback_guard, "rollback_fired", False)
        and getattr(rollback_guard, "hint_text", "")
        and ...): ...

The middle clause keyed on `rollback_fired`. But in the degraded
path (regression detected but undo block didn't open), the guard
sets `rollback_fired = False` while still populating `hint_text`
specifically to flag the failure. The condition dropped that hint
entirely — leaving the LLM continuing from a broken graph with zero
feedback in exactly the failure mode where it most needed it.

Fix: extracted the inline block into `Agent._apply_rollback_hint`
(testable as a method), condition keys on `hint_text` presence
instead of `rollback_fired`. Both success and degraded paths now
surface the hint to LLM + chat UI uniformly.

Tests
=====

12 new regression tests in `tests/test_tdpilot_api_rollback.py`:

  TestCodexP1RollbackFiredAccuracy (5 tests)
    - end-handler returns `{"ok": False, "undo_error": ...}`
      → rollback_fired stays False, hint contains "(undo raised: ...)"
    - end-handler returns `{"error": "endBlock failed: ..."}`
      → rollback_fired stays False, hint contains "(endBlock raised: ...)"
    - dispatcher raises during auto_rollback_end
      → rollback_fired stays False, degraded hint
    - end-handler returns `{"ok": True, "rolled_back": True}` (happy path)
      → rollback_fired = True, "reverted" hint (no regression on happy path)
    - end-handler returns `{"ok": True}` (partial)
      → rollback_fired stays False (defensive)

  TestCodexP2HintAppendInDegradedPath (7 tests)
    - hint appends with rollback_fired=False (the actual P2 fix-trigger)
    - hint appends with rollback_fired=True (happy path)
    - empty hint_text is a no-op
    - None guard is a no-op
    - empty results_block is a no-op
    - list-shaped tool_result.content gets a text-block appended
    - on_text raising doesn't break the LLM-side append

Refactor
========

Extracted `Agent._loop`'s hint-append block into `Agent._apply_rollback_hint`
so the P2 path is directly testable without urlopen mocking. Also
clean reuse point for future Phase 1 features that need the same
results_block-augmentation pattern (e.g. Phase 1.3 mid-turn integrity
check might want to fold its findings into the same callsite).

Local sweep: 1772 pytest passed (1760 prior + 12 new). Ruff
format/check clean. Versions in sync. dpsk4 .tox freshness clean
(not touched). API .tox freshness FAIL until rebuilt — that's the
final pre-push step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: rebuild tdpilot_API.tox for Codex P1+P2 followups

Bakes the rollback-success-gating fix (P1) and the extracted
Agent._apply_rollback_hint method (P2) into the .tox so users
get the corrected auto-rollback behaviour at COMP load time.

213,814 → 222,406 bytes (~+8.5KB delta from PR #34's tox —
mostly the new private method body + the broadened format_hint).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dreamrec added a commit that referenced this pull request May 19, 2026
* feat(api-tox): v2.2.0 Phase 1.2 — cycle detection in tool chains

Second feature of the v2.2.0 reliability phase (Phase 1.1 was
auto-rollback; see PR #34 + Codex P1/P2 follow-up in PR #36). Pairs
with 1.1 to cover the two high-impact reliability gaps:

  - 1.1 catches "agent broke things" → undo the batch
  - 1.2 catches "agent is stuck looping" → break the turn

How it works
============

A ``CycleLedger`` is constructed once per turn in ``Agent._loop``
and tracks ``(tool_name, args_hash) -> count``. Each call to
``ledger.record(name, args)`` increments and returns the new count.
The agent loop checks against the threshold (default 3) BEFORE
dispatching:

  - count == 1: first time, dispatch.
  - count == 2: second time, dispatch.
  - count >= 3: ``CycleDetected`` raised; ``run_turn``'s catch fires
    ``on_error`` → ``EV_ERROR`` + ``EV_STATE: idle`` in the event
    stream → red banner in the chat UI with tool name + args preview.

The threshold of 3 is conservative on purpose — 2 dispatches give
the agent a chance to recover from a one-off failure before declaring
the turn stuck.

Args identity uses sorted-keys JSON so ``{"a":1,"b":2}`` and
``{"b":2,"a":1}`` collapse to the same key; nested dicts hash
recursively; lists are order-sensitive (connect source/dest
ordering matters). Disable via env var
``TDPILOT_DISABLE_CYCLE_DETECTION=1``.

Implementation
==============

- NEW td_component/tdpilot_api_cycle_detector.py — pure-Python:
  args_hash, CycleLedger, CycleDetected, env-var gate, factory
  builder. No TouchDesigner imports.

- tdpilot_api_agent.py:
  - New cycle_ledger_factory ctor kwarg.
  - _loop builds one ledger per turn after model resolution.
  - Inner per-batch for-loop checks ledger.record() BEFORE
    on_tool_call/dispatch; raises CycleDetected at the threshold.
  - CycleDetected is **late-imported** from cycle_detector inside
    _loop to avoid a circular import (cycle_detector imports
    AgentError from agent at top-level). Cost: one dict lookup per
    turn after the import is cached.

- tdpilot_api_runtime.py:
  - New _build_cycle_ledger_factory method honours
    TDPILOT_DISABLE_CYCLE_DETECTION=1.
  - AgentRuntime._build_agent passes the factory to Agent().

- build_tdpilot_api_tox.py:
  - Adds tdpilot_api_cycle_detector to _SOURCE_FILES so it's baked
    into the API .tox. (Post-#35 the freshness gate auto-imports
    this same list, so no parallel-list maintenance.)

Tests (49 new)
==============

- TestArgsHash (8) — order independence, normalization, nested
  ordering, list-order sensitivity, defensive paths.
- TestCycleLedger (10) — threshold validation (>=2), increment,
  per-key isolation, peek/reset, len semantics.
- TestCycleDetectedException (3) — metadata, message formatting,
  AgentError membership.
- TestEnvVarGate (3) — truthy/falsy classification.
- TestBuildCycleLedgerFactory (3) — env-disable, fresh instances,
  custom threshold.
- TestUsagePattern (4) — the canonical check-then-dispatch flow.
- TestFormatArgsSummary (3) — render rules.
- TestAgentLoopCycleIntegration (3) — end-to-end with mocked
  urlopen, proving the late-import + raise + on_error path
  actually works in run_turn. Covers: enabled+stuck (raises on
  3rd, dispatches 2), disabled-factory (no check, hits
  turn_budget), distinct-args (never triggers, completes cleanly).

Local sweep
===========

- pytest: 1821 passed (1772 prior + 49 new).
- ruff format + check: clean.
- check_versions: in sync at v2.1.5 (no bump on this PR).
- check_tox_freshness (dpsk4): fresh — that .tox not touched.
- check_tox_api_freshness (chat-pipe): EXPECTED FAIL until the
  user rebuilds the .tox via the canonical Textport recipe.
- check_no_personal_paths: clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: rebuild tdpilot_API.tox for Phase 1.2 cycle detection

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dreamrec added a commit that referenced this pull request May 19, 2026
First milestone of the v2.2.0→v3.0 roadmap (see docs/ROADMAP.md).
Phase 1 ships in full; the chat-pipe agent is now safe to leave
unsupervised on complex builds, two failure modes have automatic
recovery, and drag-and-go finally works for new users out of the box.

Bundled in this release
=======================

  - 1.1 Auto-rollback on error regression (PR #34 + #36)
  - 1.2 Cycle detection in tool chains (PR #37)
  - 1.2.1 Drag-and-go UX polish — Authmode COMP param,
    auto-save+auto-reload on Apikey change, 401 reconnect
    banner (PR #38)
  - 1.2.2 Build scripts auto-mirror .tox into main repo on rebuild,
    eliminating stale-symlink footgun (PR #39)
  - PR #35 paired source-file-list refactor (build-script + freshness-
    check now share a single source of truth)

136 new tests since v2.1.5. Total suite at release: 1848 passing.

Headline behaviour change for end users
=======================================

Default chat-pipe webserver auth posture changes from token-required
to origin-allowlist-only (Authmode=open default). The origin
allowlist still rejects cross-origin browser CSRF, so this is safe
on TouchDesigner's typical single-user dev/perform usage profile.
Users sharing .toe files across machines should set Authmode=token
on the COMP for that deployment.

The MCP-server tdpilot-dpsk4.tox (port 9985) auth model is UNCHANGED
— TD_MCP_SHARED_SECRET still required.

Bumps + rebuilt artifacts
=========================

All 10 versioned files (7 manifests + 3 doc titles) bumped from
2.1.5 to 2.2.0 (scripts/check_versions.py enforces sync). API_VERSION
in td_component/callbacks/_header.py also bumped; this invalidated
both .tox source-hashes, both rebuilt inside TouchDesigner via the
canonical recipe. Auto-mirror (Phase 1.2.2, just landed) propagated
the rebuilt .tox files to the main repo's td_component/ automatically.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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