Files

77 lines
3.1 KiB
Python

"""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.HookContext 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
# Gate on the DIRTY set (any app-building session that wrote a frontend file with a runtime
# attached), not the mode: onboarding builds its dashboard in a plain agent session via CreateApp,
# which must be render-gated too, not just the dedicated view-builder.
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."
),
}