feat(api-tox): v2.2.0 Phase 1.1 — auto-rollback on error regression - #34
Conversation
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>
There was a problem hiding this comment.
💡 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".
| self._dispatcher("auto_rollback_end", {"undo": True}) | ||
| except Exception: # noqa: BLE001 | ||
| pass | ||
| self.rollback_fired = True | ||
| self.hint_text = format_hint(diff) |
There was a problem hiding this comment.
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 👍 / 👎.
| and getattr(rollback_guard, "rollback_fired", False) | ||
| and getattr(rollback_guard, "hint_text", "") |
There was a problem hiding this comment.
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 👍 / 👎.
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>
* 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>
* 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>
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>
) * 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>
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>
* 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>
* 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>
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>
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 againsttd_get_errorsplus a TDui.undo.startBlockso 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 viaui.undo.undo()and a hint is appended to the lasttool_resultso 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
ui.undo.startBlockpatch_*infra. Cheaper than.toesnapshots (~500ms-2s avoided per batch). Atomic rollback.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.td_exec_pythontd_get_errorscalls per batch when nothing can mutate state.tool_result.contentauto_rollback_begin/auto_rollback_endlive inTOOL_TO_HANDLERbut NOTTOOL_SCHEMAS. NewINTERNAL_ONLY_TOOL_NAMESset keeps the schema-vs-handler parity pin tests meaningful.Opt-out
Set
TDPILOT_DISABLE_AUTO_ROLLBACK=1in the TD process environment. The runtime's guard factory returnsNone, andAgent._loopbecomes 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,AutoRollbackGuardcontext manager, the two cook-threadui.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.py—rollback_guard_factoryctor kwarg;_loopwraps the per-batch loop with the guard.td_component/tdpilot_api_runtime.py—_build_rollback_guard_factoryhonoursTDPILOT_DISABLE_AUTO_ROLLBACK; returnsNonewhen disabled.td_component/tdpilot_api_extension.py— adds the rollback module tohandler_modules.td_component/tdpilot_api_schema_map.py— registers the two internal handlers +INTERNAL_ONLY_TOOL_NAMESfrozenset.td_component/build_tdpilot_api_tox.py— addstdpilot_api_rollbackto_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 subtractINTERNAL_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 -q— 1760 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.py— API tox fresh (rebuilt by maintainer in TD).bash scripts/check_no_personal_paths.sh— clean.Manual verification (post-merge, in TD)
tdpilot_API.toxin/project1.pixeldatparameter to a DAT that doesn't exist yet").[tdpilot_auto_rollback] This batch introduced 1 new critical error(s)…in the chat bubble, the COMP'std_get_errorsrecurse=True shows zero new criticals (rollback succeeded), and the agent self-corrects on its next turn.TDPILOT_DISABLE_AUTO_ROLLBACK=1, reload the COMP, repeat — the network should now stay broken (no rollback).🤖 Generated with Claude Code