diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index 14ab253d..12419b27 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -941,6 +941,7 @@ async def run_browser_agent( done_called = False done_message = "" done_success = True + done_keep_open = False # Completion detection: once an irreversible SEND has confirmed, the goal is # met. The model otherwise stalls re-verifying what the confirm already proved # (measured: send done at turn ~11, then ~12 wasted perception turns). We drive @@ -1427,6 +1428,7 @@ async def run_browser_agent( done_called = True done_message = (tu.input.get("message") or "").strip() done_success = tu.input.get("success", True) is not False + done_keep_open = tu.input.get("keep_open", False) is True tool_results.append({ "type": "tool_result", "tool_use_id": tu.id, "content": [{"type": "text", "text": "ok"}], @@ -2122,6 +2124,28 @@ async def run_browser_agent( }) except Exception as e: logger.debug(f"[browser-playbook] distill skipped: {e}") + # The model asked to leave the browser open because the deliverable lives + # on the page (a video playing, a page to read). Pin the card so the + # auto-close on parent finish skips it. Only on honest success: never pin + # a broken or ghost run open. The keep broadcast lands before the parent + # reaches terminal state (it awaits this run), so the frontend has the + # flag set before any close path runs. + if honest and done_keep_open and dashboard_id: + try: + from backend.apps.dashboards.dashboards import _load, _save + dashboard = _load(dashboard_id) + card = dashboard.layout.browser_cards.get(browser_id) + if card is not None: + card.keep_open = True + dashboard.updated_at = datetime.now() + _save(dashboard) + await ws_manager.broadcast_global("dashboard:browser_card_keep", { + "dashboard_id": dashboard_id, + "browser_id": browser_id, + }) + except Exception as e: + logger.warning(f"[browser-agent {session_id}] keep_open persist failed: {e}") + agent_manager._sync_session_close(session) await ws_manager.send_to_session(session_id, "agent:status", { "session_id": session_id, diff --git a/backend/apps/agents/browser/browser_schema.py b/backend/apps/agents/browser/browser_schema.py index afc14fac..b4e59f2d 100644 --- a/backend/apps/agents/browser/browser_schema.py +++ b/backend/apps/agents/browser/browser_schema.py @@ -117,6 +117,17 @@ BROWSER_TOOLS_SCHEMA = [ "(login wall, missing info, something blocked you). Default true." ), }, + "keep_open": { + "type": "boolean", + "description": ( + "Set true ONLY when the result IS the open page and the user will keep " + "using it right now: a video or audio playing, a page you opened for them " + "to read or watch, a download you started, or a place left ready for them " + "to take over. The browser then stays put instead of closing. Leave false " + "(default) for info tasks where you just look something up and report the " + "answer back, since there's nothing left to keep on screen." + ), + }, }, "required": ["message"], }, @@ -885,9 +896,11 @@ SYSTEM_PROMPT = ( "tool, never by typing a sentence. Put your reply to the user in Done's `message`, " "written like a normal chat reply: what got done plus the human proof (the name, the " "time, what's now on screen), in one or two plain sentences with zero interface words. " - "Set `success` false if you couldn't finish. For irreversible actions, only report " - "success with real proof you actually observed (the name and where/when you saw it), " - "just phrased for a person, not for a machine." + "Set `success` false if you couldn't finish. Set `keep_open` true when the result is the " + "open page itself and the user keeps using it now (a video playing, a page opened to " + "read, a download started), so the browser stays instead of closing. For irreversible " + "actions, only report success with real proof you actually observed (the name and " + "where/when you saw it), just phrased for a person, not for a machine." ) MAX_TURNS = 40 diff --git a/backend/apps/dashboards/models.py b/backend/apps/dashboards/models.py index 52be0543..717fcf19 100644 --- a/backend/apps/dashboards/models.py +++ b/backend/apps/dashboards/models.py @@ -40,6 +40,10 @@ class BrowserCardPosition(BaseModel): # Used by the frontend to auto-remove the browser when its owner agent # reaches a terminal completed/error state. spawned_by: Optional[str] = None + # When the agent leaves the deliverable on the page (a video playing, a page + # to read), it sets this so the frontend's auto-close on parent finish skips + # the card and the browser stays put. + keep_open: bool = False class NotePosition(BaseModel): diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index f3534ebc..3e36cd1b 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -59,6 +59,7 @@ export interface BrowserCardPosition { zOrder: number; /** Agent session that spawned this browser; auto-removed when its owner reaches terminal state. */ spawned_by?: string | null; + keep_open?: boolean; /** Dashboard this card belongs to; cards render and persist only on their owning dashboard. */ dashboard_id?: string; } @@ -663,6 +664,14 @@ const dashboardLayoutSlice = createSlice({ delete state.endingBrowserCards[action.payload]; }, + keepBrowserCardOpen(state, action: PayloadAction) { + const card = state.browserCards[action.payload]; + if (!card) return; + card.keep_open = true; + // Undo any in-flight ending mark in case a close path raced ahead. + delete state.endingBrowserCards[action.payload]; + }, + suspendBrowserCard(state, action: PayloadAction<{ browserId: string; dataUrl: string }>) { if (!state.browserCards[action.payload.browserId]) return; state.suspendedBrowserCards[action.payload.browserId] = { @@ -1098,6 +1107,7 @@ export const { resumeBrowserCard, markBrowserCardEnding, cancelBrowserCardEnding, + keepBrowserCardOpen, pasteBrowserCard, updateBrowserCardUrl, addBrowserTab, diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index 092c4a6b..258f62e4 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -23,7 +23,7 @@ import { clearTurnLabel, } from '../state/agentsSlice'; import { streamStart, streamDelta, streamEnd, clearStreamingForSession } from '../state/streamingSlice'; -import { addBrowserCardFromBackend, markBrowserCardEnding, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP } from '../state/dashboardLayoutSlice'; +import { addBrowserCardFromBackend, markBrowserCardEnding, keepBrowserCardOpen, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP } from '../state/dashboardLayoutSlice'; import { upsertOutput } from '../state/outputsSlice'; import { displaySessionName } from '../state/sessionDisplay'; import { getAuthToken } from '../config'; @@ -510,7 +510,7 @@ class WebSocketManager { ) { const browserCards = store.getState().dashboardLayout.browserCards; for (const card of Object.values(browserCards)) { - if (card.spawned_by === session_id) { + if (card.spawned_by === session_id && !card.keep_open) { store.dispatch(markBrowserCardEnding({ browserId: card.browser_id, status: data.status, })); @@ -733,7 +733,7 @@ class WebSocketManager { if (closedStatus === 'completed' || closedStatus === 'error') { const browserCards = store.getState().dashboardLayout.browserCards; for (const card of Object.values(browserCards)) { - if (card.spawned_by === session_id) { + if (card.spawned_by === session_id && !card.keep_open) { store.dispatch(markBrowserCardEnding({ browserId: card.browser_id, status: closedStatus, })); @@ -743,6 +743,12 @@ class WebSocketManager { } break; + case 'dashboard:browser_card_keep': + if (data.browser_id) { + store.dispatch(keepBrowserCardOpen(data.browser_id)); + } + break; + case 'dashboard:browser_card_added': if (data.browser_card) { // Tag with origin dashboard so the card renders only on the dashboard