mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-09 19:27:45 +02:00
[eric] agents: lift Stop hook (App Builder render gate) into stop_hook module
This commit is contained in:
@@ -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."
|
||||
),
|
||||
}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user