[aidan] fix/browser-early-close: let agent keep browser open when result lives on the page (#88)

This commit is contained in:
Aidan
2026-06-15 19:00:40 -07:00
committed by GitHub
parent 3bf1b0da79
commit de2e70ca8f
5 changed files with 63 additions and 6 deletions
@@ -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,
+16 -3
View File
@@ -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
+4
View File
@@ -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):
@@ -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<string>) {
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,
+9 -3
View File
@@ -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