[eric] agents: lift Stop hook (App Builder render gate) into stop_hook module

This commit is contained in:
ciregenz
2026-06-23 02:15:58 -07:00
parent 678472023d
commit bbf76a0b9f
3 changed files with 127 additions and 52 deletions
+2 -52
View File
@@ -60,9 +60,9 @@ from backend.apps.agents.manager.streaming.state import ThinkingState, TurnState
from backend.apps.agents.manager.streaming.hook_context import HookContext
from backend.apps.agents.manager.streaming import thinking as thinking_mod
from backend.apps.agents.manager.streaming import tool_result_hook
from backend.apps.agents.manager.streaming import stop_hook as stop_hook_mod
from backend.apps.agents.manager.permissions import gate_hooks
from backend.apps.agents.manager.view_builder_state import (
VIEW_BUILDER_RENDER_MAX_RETRIES,
view_builder_render_retry_counts,
view_builder_dirty_sessions,
)
@@ -888,57 +888,7 @@ class AgentManager:
del _stderr_buffer[:250]
async def stop_hook(input_data, tool_use_id, context):
"""End-of-turn render gate for App Builder sessions. Reads the
browser-reported render-state of the preview; if the app fails
to render, blocks with the error so the agent fixes it, up to
MAX_RETRIES then lets the stop through."""
if session.mode != "view-builder":
return {}
if session.id not in view_builder_dirty_sessions:
return {}
from backend.apps.outputs.runtime import (
manager as outputs_runtime_manager,
)
if outputs_runtime_manager.get(session.id) is None:
return {}
state, error_text = outputs_runtime_manager.get_render_state_for_workspace(session.id)
waited = 0.0
while state is None and waited < 5.0:
await asyncio.sleep(0.25)
waited += 0.25
state, error_text = outputs_runtime_manager.get_render_state_for_workspace(session.id)
if state != "error":
view_builder_render_retry_counts.pop(session.id, None)
view_builder_dirty_sessions.discard(session.id)
return {}
attempts = view_builder_render_retry_counts.get(session.id, 0)
if attempts >= VIEW_BUILDER_RENDER_MAX_RETRIES:
logger.warning(
"view-builder preview still failing after %s attempts for session %s; allowing stop",
attempts, session.id,
)
view_builder_render_retry_counts.pop(session.id, None)
view_builder_dirty_sessions.discard(session.id)
return {}
view_builder_render_retry_counts[session.id] = attempts + 1
logger.info(
"view-builder render block (attempt %s/%s) for session %s",
attempts + 1, VIEW_BUILDER_RENDER_MAX_RETRIES, session.id,
)
trimmed = error_text[-3000:] if len(error_text) > 3000 else error_text
return {
"decision": "block",
"reason": (
f"The preview failed to render (attempt {attempts + 1}/"
f"{VIEW_BUILDER_RENDER_MAX_RETRIES}):\n\n"
f"{trimmed}\n\n"
"Fix this so the app renders before finishing; the user "
"currently sees an error instead of the app."
),
}
return await stop_hook_mod.stop_hook(hook_ctx, input_data, tool_use_id, context)
options_kwargs = {
"model": resolved_model,
@@ -0,0 +1,75 @@
"""The SDK Stop hook: an end-of-turn render gate for App Builder (view-builder) sessions.
If the live preview failed to render, it blocks the stop with the error so the agent fixes it,
up to a retry cap, then lets the turn end. Operates on the HookContext; the dict returns are
the claude_agent_sdk Stop hook protocol, not internal state."""
import asyncio
import logging
from typing import Dict
from typeguard import typechecked
from backend.apps.agents.manager.streaming.hook_context import HookContext
from backend.apps.agents.manager.view_builder_state import (
VIEW_BUILDER_RENDER_MAX_RETRIES,
view_builder_render_retry_counts,
view_builder_dirty_sessions,
)
logger = logging.getLogger(__name__)
@typechecked
async def stop_hook(ctx: HookContext, input_data: dict, tool_use_id, context) -> Dict[str, object]:
"""End-of-turn render gate for App Builder sessions. Reads the
browser-reported render-state of the preview; if the app fails
to render, blocks with the error so the agent fixes it, up to
MAX_RETRIES then lets the stop through."""
session = ctx.session
if session.mode != "view-builder":
return {}
if session.id not in view_builder_dirty_sessions:
return {}
from backend.apps.outputs.runtime import (
manager as outputs_runtime_manager,
)
if outputs_runtime_manager.get(session.id) is None:
return {}
state, error_text = outputs_runtime_manager.get_render_state_for_workspace(session.id)
waited = 0.0
while state is None and waited < 5.0:
await asyncio.sleep(0.25)
waited += 0.25
state, error_text = outputs_runtime_manager.get_render_state_for_workspace(session.id)
if state != "error":
view_builder_render_retry_counts.pop(session.id, None)
view_builder_dirty_sessions.discard(session.id)
return {}
attempts = view_builder_render_retry_counts.get(session.id, 0)
if attempts >= VIEW_BUILDER_RENDER_MAX_RETRIES:
logger.warning(
"view-builder preview still failing after %s attempts for session %s; allowing stop",
attempts, session.id,
)
view_builder_render_retry_counts.pop(session.id, None)
view_builder_dirty_sessions.discard(session.id)
return {}
view_builder_render_retry_counts[session.id] = attempts + 1
logger.info(
"view-builder render block (attempt %s/%s) for session %s",
attempts + 1, VIEW_BUILDER_RENDER_MAX_RETRIES, session.id,
)
trimmed = error_text[-3000:] if len(error_text) > 3000 else error_text
return {
"decision": "block",
"reason": (
f"The preview failed to render (attempt {attempts + 1}/"
f"{VIEW_BUILDER_RENDER_MAX_RETRIES}):\n\n"
f"{trimmed}\n\n"
"Fix this so the app renders before finishing; the user "
"currently sees an error instead of the app."
),
}
+50
View File
@@ -0,0 +1,50 @@
"""Unit coverage for the extracted Stop hook (the App Builder render gate). Not exercised by
the streaming harness (Stop fires from SDK internals), so pin it directly: the gate is inert
off view-builder mode / when not dirty, and blocks the stop when the preview errors under cap."""
import pytest
from unittest.mock import patch, MagicMock
from backend.apps.agents.core.models import AgentSession
from backend.apps.agents.manager.streaming.hook_context import HookContext
from backend.apps.agents.manager.streaming import stop_hook as stop_hook_mod
from backend.apps.agents.manager import view_builder_state
def _ctx(mode: str) -> HookContext:
session = AgentSession(name="t", model="sonnet", dashboard_id="d", mode=mode)
return HookContext(
session=session, session_id=session.id, prompt="hi",
builtin_perms={}, policy_defaults={}, sessions={},
)
@pytest.mark.asyncio
async def test_stop_hook_inert_when_not_view_builder():
ctx = _ctx("agent")
assert await stop_hook_mod.stop_hook(ctx, {}, None, None) == {}
@pytest.mark.asyncio
async def test_stop_hook_inert_when_not_dirty():
ctx = _ctx("view-builder") # dirty set is empty -> nothing to gate
assert await stop_hook_mod.stop_hook(ctx, {}, None, None) == {}
@pytest.mark.asyncio
async def test_stop_hook_blocks_on_render_error_under_cap():
ctx = _ctx("view-builder")
sid = ctx.session.id
view_builder_state.view_builder_dirty_sessions.add(sid)
fake_runtime = MagicMock()
fake_runtime.get.return_value = object() # workspace exists
fake_runtime.get_render_state_for_workspace.return_value = ("error", "boom traceback")
try:
with patch("backend.apps.outputs.runtime.manager", fake_runtime):
out = await stop_hook_mod.stop_hook(ctx, {}, None, None)
assert out["decision"] == "block"
assert "failed to render" in out["reason"]
assert "boom traceback" in out["reason"]
finally:
view_builder_state.view_builder_dirty_sessions.discard(sid)
view_builder_state.view_builder_render_retry_counts.pop(sid, None)