Skip to content

Commit cfad3a4

Browse files
dreamrecclaude
andauthored
feat(api-tox): v2.2.0 Phase 1.1 — auto-rollback on error regression (#34)
* 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>
1 parent 89124d6 commit cfad3a4

13 files changed

Lines changed: 1101 additions & 33 deletions

CHANGELOG.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,72 @@
11
# Changelog
22

3+
## Unreleased (v2.2.0 — Phase 1 in progress)
4+
5+
**v2.2.0 will be the first release of the v2.2.0→v3.0 roadmap (see
6+
`docs/ROADMAP.md`).** Phase 1 ("Reliability foundation") ships across
7+
multiple PRs; v2.2.0 cuts when the whole phase is in. Until then,
8+
"Unreleased" tracks the in-progress work.
9+
10+
### Added — Feature 1.1: Auto-rollback on error regression (chat-pipe / `tdpilot_API.tox`)
11+
12+
Each LLM tool batch is now wrapped with a baseline-and-diff check
13+
against `td_get_errors`, plus a TD `ui.undo.startBlock` so the whole
14+
batch becomes one undo entry. If new *critical* errors appear after
15+
the batch (compile-style only — Python syntax, expression-parse,
16+
GLSL compile, Script DAT load), the batch is rolled back atomically
17+
via `ui.undo.undo()` and a hint is appended to the last
18+
`tool_result` so the LLM sees the regression on its next API call.
19+
The same hint surfaces to the chat UI via `on_text`.
20+
21+
**Implementation:**
22+
23+
- New `td_component/tdpilot_api_rollback.py`: pure-Python predicate
24+
(`is_critical_error`), diff (`diff_errors`), batch classifier
25+
(`batch_should_be_guarded`), and the `AutoRollbackGuard` context
26+
manager. Two cook-thread handlers (`handle_auto_rollback_begin` /
27+
`handle_auto_rollback_end`) registered in `TOOL_TO_HANDLER` but
28+
NOT in `TOOL_SCHEMAS` — the LLM never sees them as callable
29+
tools; only the guard invokes them internally.
30+
- `td_component/tdpilot_api_agent.py`: new `rollback_guard_factory`
31+
constructor kwarg; `_loop` wraps the per-batch `for tu in
32+
tool_uses` block with the guard via a context-manager protocol.
33+
- `td_component/tdpilot_api_runtime.py`: `_build_rollback_guard_factory`
34+
honours the `TDPILOT_DISABLE_AUTO_ROLLBACK=1` env var; returns
35+
`None` (no-op) when disabled.
36+
- Coverage in `tests/test_tdpilot_api_rollback.py` — 60 tests across
37+
the predicate, diff, batch classifier, env-var gate, the guard's
38+
state machine (with a recorded mock dispatcher), the hint
39+
formatter, and the internal handlers' outside-TD failure mode.
40+
41+
**Standdowns (auto-rollback skips the wrap):**
42+
43+
- Pure-read batches (nothing in `MUTATION_TOOL_NAMES`) — saves two
44+
`td_get_errors` calls per batch.
45+
- Batches containing any tool whose side effects `ui.undo` can't
46+
revert (`td_exec_python`, `td_emergency_stabilize`, `td_patch_apply`).
47+
Half-rolling-back is worse than not rolling back.
48+
- Baseline-capture failure (dispatcher raised) — degrades to no-guard
49+
silently rather than breaking the user's turn.
50+
51+
**Opt-out:** set `TDPILOT_DISABLE_AUTO_ROLLBACK=1` in the TD process
52+
environment.
53+
54+
**Files baked into the API .tox** (rebuild required after pulling
55+
this change):
56+
57+
- `td_component/tdpilot_api_rollback.py` (new)
58+
- `td_component/tdpilot_api_agent.py` (modified)
59+
- `td_component/tdpilot_api_runtime.py` (modified)
60+
- `td_component/tdpilot_api_extension.py` (modified — adds the
61+
rollback module to the dispatcher's handler-module list)
62+
- `td_component/tdpilot_api_schema_map.py` (modified — registers
63+
the two internal handlers in `TOOL_TO_HANDLER`)
64+
- `td_component/build_tdpilot_api_tox.py` (modified — adds
65+
`tdpilot_api_rollback` to `_SOURCE_FILES`)
66+
67+
Phase 1.2 (cycle detection) and the rest of Phase 1 follow in
68+
subsequent PRs.
69+
370
## 2.1.5 - 2026-05-10
471

572
**Patch: Codex P2 follow-up on v2.1.4 (PR #29).** A cosmetic-but-real

scripts/check_tox_api_freshness.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@
5656
"td_component/tdpilot_api_introspect.py",
5757
"td_component/tdpilot_api_batch.py",
5858
"td_component/tdpilot_api_recovery.py",
59+
# v2.2.0 Phase 1.1 — auto-rollback on error regression. Pure module
60+
# exporting AutoRollbackGuard + two ui.undo-touching internal
61+
# handlers; baked into the API .tox.
62+
"td_component/tdpilot_api_rollback.py",
5963
"td_component/tdpilot_api_tracing.py",
6064
"td_component/tdpilot_api_compaction.py",
6165
"td_component/tdpilot_api_chat.html",

td_component/.tox-api-source-hash.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
2-
"tox_source_hash": "6ab876c51d853a1dd4ed40d2c2bb2d09dfee8ae6d260eeffb5a832db3239bea7",
3-
"built_at": "2026-05-10T09:45:13.132660+00:00",
2+
"tox_source_hash": "a9a4765ad22188ae30d92d8998c86b9516fce727ab0d3ac711be792a4c791424",
3+
"built_at": "2026-05-11T13:50:16.731643+00:00",
44
"source_files": [
55
"td_component/tdpilot_api_agent.py",
66
"td_component/tdpilot_api_dispatcher.py",
@@ -25,6 +25,7 @@
2525
"td_component/tdpilot_api_introspect.py",
2626
"td_component/tdpilot_api_batch.py",
2727
"td_component/tdpilot_api_recovery.py",
28+
"td_component/tdpilot_api_rollback.py",
2829
"td_component/tdpilot_api_tracing.py",
2930
"td_component/tdpilot_api_compaction.py",
3031
"td_component/tdpilot_api_chat.html",

td_component/build_tdpilot_api_tox.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,12 @@ def _load_legacy_module():
207207
("tdpilot_api_introspect", "textDAT", "td_component/tdpilot_api_introspect.py"),
208208
("tdpilot_api_batch", "textDAT", "td_component/tdpilot_api_batch.py"),
209209
("tdpilot_api_recovery", "textDAT", "td_component/tdpilot_api_recovery.py"),
210+
# Phase 1.1 (v2.2.0) — auto-rollback on error regression. Pure module
211+
# except for two ``ui.undo``-touching handlers exposed only via
212+
# TOOL_TO_HANDLER (not TOOL_SCHEMAS) so the LLM never calls them
213+
# directly; the AutoRollbackGuard invokes them internally around
214+
# each tool-batch in tdpilot_api_agent._loop.
215+
("tdpilot_api_rollback", "textDAT", "td_component/tdpilot_api_rollback.py"),
210216
("tdpilot_api_tracing", "textDAT", "td_component/tdpilot_api_tracing.py"),
211217
("tdpilot_api_compaction", "textDAT", "td_component/tdpilot_api_compaction.py"),
212218
("tdpilot_api_chat_html", "textDAT", "td_component/tdpilot_api_chat.html"),

td_component/tdpilot_API.tox

7.2 KB
Binary file not shown.

td_component/tdpilot_api_agent.py

Lines changed: 95 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,13 @@ def __init__(
264264
# Treat all keys as optional — DeepSeek's compat layer may omit
265265
# some fields depending on the model version.
266266
on_usage: Callable[[dict], None] = _noop,
267+
# Phase 1.1 (v2.2.0) — auto-rollback on error regression.
268+
# Factory takes ``(dispatcher, tool_names)`` and returns a
269+
# context manager (e.g. ``AutoRollbackGuard``) used to wrap each
270+
# tool batch in ``_loop``. ``None`` disables the feature entirely
271+
# — the loop runs unwrapped, identical to pre-v2.2.0 behaviour.
272+
# Wired by AgentRuntime so it can honour ``TDPILOT_DISABLE_AUTO_ROLLBACK``.
273+
rollback_guard_factory: Callable[..., Any] | None = None,
267274
) -> None:
268275
if not api_key:
269276
raise AgentError("api_key is required")
@@ -310,6 +317,7 @@ def __init__(
310317
self.on_turn_done = on_turn_done
311318
self.on_error = on_error
312319
self.on_usage = on_usage
320+
self.rollback_guard_factory = rollback_guard_factory
313321

314322
self.messages: list[dict] = []
315323
self._stop_flag = threading.Event()
@@ -532,32 +540,94 @@ def _loop(self) -> str | None:
532540
return text_blob
533541

534542
# Execute tools, collect results, send back as a user turn.
535-
results_block = []
536-
for tu in tool_uses:
537-
tool_id = tu.get("id", "")
538-
tool_name = tu.get("name", "")
539-
tool_args = tu.get("input", {}) or {}
540-
self.on_tool_call(tool_name, tool_args)
543+
# Phase 1.1 (v2.2.0) — auto-rollback wrap: capture baseline
544+
# errors + open a TD undo block before the batch; after the
545+
# batch, recheck errors and either close the block (clean
546+
# path) or roll it back (regression path). The factory may
547+
# return None or a no-op guard if disabled via env var; the
548+
# ``with`` block is always safe to enter.
549+
tool_names_in_batch = [tu.get("name", "") for tu in tool_uses]
550+
rollback_guard = None
551+
if self.rollback_guard_factory is not None:
541552
try:
542-
result = self.dispatcher(tool_name, tool_args)
543-
# F-12: the explicit `_tool_error` sentinel is the
544-
# only failure signal post-v2.0. Internal handlers
545-
# that emit `{"error": "..."}` get auto-stamped
546-
# with `_tool_error: True` by `recovery.attach_hint()`
547-
# inside the dispatcher pipeline.
548-
is_error = is_tool_error_result(result)
549-
except Exception as exc: # noqa: BLE001
550-
result = {"_tool_error": True, "error": f"{type(exc).__name__}: {exc}"}
551-
is_error = True
552-
self.on_tool_result(tool_name, result, is_error)
553-
results_block.append(
554-
{
555-
"type": "tool_result",
556-
"tool_use_id": tool_id,
557-
"content": _stringify(result),
558-
"is_error": is_error,
559-
}
560-
)
553+
rollback_guard = self.rollback_guard_factory(
554+
self.dispatcher,
555+
tool_names_in_batch,
556+
)
557+
except Exception as exc: # noqa: BLE001 — factory must never break a turn
558+
print(f"[tdpilot_API/agent] rollback_guard_factory raised: {exc}")
559+
rollback_guard = None
560+
561+
results_block: list[dict] = []
562+
try:
563+
if rollback_guard is not None:
564+
rollback_guard.__enter__()
565+
for tu in tool_uses:
566+
tool_id = tu.get("id", "")
567+
tool_name = tu.get("name", "")
568+
tool_args = tu.get("input", {}) or {}
569+
self.on_tool_call(tool_name, tool_args)
570+
try:
571+
result = self.dispatcher(tool_name, tool_args)
572+
# F-12: the explicit `_tool_error` sentinel is the
573+
# only failure signal post-v2.0. Internal handlers
574+
# that emit `{"error": "..."}` get auto-stamped
575+
# with `_tool_error: True` by `recovery.attach_hint()`
576+
# inside the dispatcher pipeline.
577+
is_error = is_tool_error_result(result)
578+
except Exception as exc: # noqa: BLE001
579+
result = {
580+
"_tool_error": True,
581+
"error": f"{type(exc).__name__}: {exc}",
582+
}
583+
is_error = True
584+
self.on_tool_result(tool_name, result, is_error)
585+
results_block.append(
586+
{
587+
"type": "tool_result",
588+
"tool_use_id": tool_id,
589+
"content": _stringify(result),
590+
"is_error": is_error,
591+
}
592+
)
593+
finally:
594+
if rollback_guard is not None:
595+
# __exit__ runs the post-batch check + decides rollback;
596+
# we always want this to fire even if the batch raised.
597+
try:
598+
rollback_guard.__exit__(None, None, None)
599+
except Exception as exc: # noqa: BLE001
600+
print(f"[tdpilot_API/agent] rollback_guard.__exit__ raised: {exc}")
601+
602+
# Phase 1.1 — if the guard fired a rollback, append a hint to
603+
# the LAST tool_result so the LLM sees the regression context
604+
# on its next API call. The hint goes in the tool_result's
605+
# content (which can be a list of blocks) — keeps the
606+
# alternating user/assistant constraint intact and pairs the
607+
# hint with the failing batch's results.
608+
if (
609+
rollback_guard is not None
610+
and getattr(rollback_guard, "rollback_fired", False)
611+
and getattr(rollback_guard, "hint_text", "")
612+
and results_block
613+
):
614+
hint = rollback_guard.hint_text
615+
last = results_block[-1]
616+
existing = last.get("content")
617+
if isinstance(existing, str):
618+
last["content"] = existing + "\n\n" + hint
619+
elif isinstance(existing, list):
620+
last["content"] = list(existing) + [{"type": "text", "text": hint}]
621+
else:
622+
last["content"] = hint
623+
# Surface to the chat UI too — same callback the agent's
624+
# natural-language text uses, so the user sees a yellow
625+
# inline notice in the assistant bubble.
626+
try:
627+
self.on_text(hint)
628+
except Exception: # noqa: BLE001
629+
pass
630+
561631
self.messages.append({"role": "user", "content": results_block})
562632

563633
if stop_reason == "end_turn":

td_component/tdpilot_api_extension.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,11 @@ def _build_runtime(self) -> None:
271271
("tdpilot_api_introspect", "introspect"),
272272
("tdpilot_api_batch", "tool_batch"),
273273
("tdpilot_api_tracing", "tracing"),
274+
# Phase 1.1 — auto_rollback_begin / auto_rollback_end live
275+
# here. Registered in TOOL_TO_HANDLER but NOT in
276+
# TOOL_SCHEMAS, so the LLM can't call them — only the
277+
# AutoRollbackGuard invokes them around each tool batch.
278+
("tdpilot_api_rollback", "rollback"),
274279
):
275280
dat = self.owner.op(mod_name)
276281
if dat is not None:

0 commit comments

Comments
 (0)