Skip to content

Commit 7d026a4

Browse files
Austin Kidwellclaude
andcommitted
feat(research): smart completion detection via stop button + multi-signal
Replace fixed dom_min_elapsed guards (240s/360s) with stop button cycle detection as primary signal for research/labs modes. Stop button appear→disappear with 3s debounce is the most reliable indicator. Multi-signal confirmation: MutationObserver stability (5s zero mutations) or text stability (8s unchanged) confirms after stop button disappears. Falls back to CSS/text-stability with reduced guards if stop button never appears. Error state detection after stop button disappearance. Vision path deprecated for research/labs completion detection. Expected ~65% reduction in query time (360s→~130s for 2-min queries). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cc1edd6 commit 7d026a4

2 files changed

Lines changed: 254 additions & 11 deletions

File tree

council-automation/council_browser.py

Lines changed: 243 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@
4242
BROWSER_DOM_CONFIRM_WAIT,
4343
BROWSER_TYPE_DELAY,
4444
BROWSER_USER_DATA_DIR,
45+
BROWSER_STOP_BUTTON_POLL_MS,
46+
BROWSER_STOP_BUTTON_DEBOUNCE_MS,
47+
BROWSER_MIN_GENERATION_TIME_MS,
48+
BROWSER_CONFIRMATION_WINDOW_MS,
49+
BROWSER_MUTATION_STABILITY_MS,
4550
MAX_CONCURRENT_SESSIONS,
4651
SELECTORS_PATH,
4752
SEMAPHORE_TTL,
@@ -859,25 +864,257 @@ async def _analyze_screenshot(self, screenshot_bytes: bytes) -> dict:
859864

860865
return json.loads(text)
861866

867+
# --- Smart Completion Detection methods (Phase 1-2, 5) ---
868+
869+
async def _wait_for_stop_button_cycle(self, page, timeout: int, start: float) -> bool:
870+
"""Wait for stop button to appear then disappear (with debounce).
871+
872+
Returns True if the stop button completed a full cycle (appeared → disappeared).
873+
Returns False if the stop button never appeared within 30s.
874+
"""
875+
stop_selectors = (
876+
'button[aria-label*="Stop"], button[aria-label*="Cancel"], '
877+
'[data-testid*="stop"], button:has(svg circle[stroke-dasharray]), '
878+
'button[class*="stop"]'
879+
)
880+
poll_s = BROWSER_STOP_BUTTON_POLL_MS / 1000
881+
debounce_s = BROWSER_STOP_BUTTON_DEBOUNCE_MS / 1000
882+
883+
# Phase 1: Wait for stop button to appear (confirms generation started)
884+
_log("Smart: waiting for stop button to appear...")
885+
appear_deadline = start + 30 # 30s to detect stop button
886+
appeared = False
887+
while time.time() < appear_deadline and (time.time() - start) * 1000 < timeout:
888+
try:
889+
has_stop = await page.evaluate(f"""() => {{
890+
return !!document.querySelector('{stop_selectors}');
891+
}}""")
892+
if has_stop:
893+
appeared = True
894+
_log(f"Smart: stop button appeared ({time.time() - start:.1f}s)")
895+
break
896+
except Exception:
897+
pass
898+
await asyncio.sleep(poll_s)
899+
900+
if not appeared:
901+
_log("Smart: stop button never appeared (30s), falling back")
902+
return False
903+
904+
# Phase 2: Wait for stop button to disappear
905+
_log("Smart: waiting for stop button to disappear...")
906+
while (time.time() - start) * 1000 < timeout:
907+
try:
908+
has_stop = await page.evaluate(f"""() => {{
909+
return !!document.querySelector('{stop_selectors}');
910+
}}""")
911+
if not has_stop:
912+
_log(f"Smart: stop button disappeared ({time.time() - start:.1f}s), debouncing {debounce_s}s...")
913+
# Debounce: re-check after delay to handle inter-section flickers
914+
await asyncio.sleep(debounce_s)
915+
try:
916+
reappeared = await page.evaluate(f"""() => {{
917+
return !!document.querySelector('{stop_selectors}');
918+
}}""")
919+
except Exception:
920+
reappeared = False
921+
if reappeared:
922+
_log("Smart: stop button reappeared during debounce, re-entering wait loop")
923+
continue
924+
_log(f"Smart: stop button confirmed gone ({time.time() - start:.1f}s)")
925+
return True
926+
except Exception:
927+
pass
928+
await asyncio.sleep(poll_s)
929+
930+
_log(f"Smart: timed out waiting for stop button to disappear ({time.time() - start:.1f}s)")
931+
return False
932+
933+
async def _inject_mutation_observer(self, page) -> None:
934+
"""Inject a MutationObserver on the .prose content area.
935+
936+
Tracks window.__mutationState = { lastMutationTime, isStable, stableForMs }.
937+
Stability = BROWSER_MUTATION_STABILITY_MS of zero mutations.
938+
"""
939+
stability_ms = BROWSER_MUTATION_STABILITY_MS
940+
await page.evaluate(f"""() => {{
941+
window.__mutationState = {{
942+
lastMutationTime: Date.now(),
943+
isStable: false,
944+
stableForMs: 0,
945+
}};
946+
const target = document.querySelector('.prose') ||
947+
document.querySelector('div.prose.max-w-none') ||
948+
document.body;
949+
const observer = new MutationObserver((mutations) => {{
950+
if (mutations.length > 0) {{
951+
window.__mutationState.lastMutationTime = Date.now();
952+
window.__mutationState.isStable = false;
953+
window.__mutationState.stableForMs = 0;
954+
}}
955+
}});
956+
observer.observe(target, {{
957+
childList: true,
958+
characterData: true,
959+
subtree: true,
960+
}});
961+
// Periodic stability check
962+
setInterval(() => {{
963+
const elapsed = Date.now() - window.__mutationState.lastMutationTime;
964+
window.__mutationState.stableForMs = elapsed;
965+
window.__mutationState.isStable = elapsed >= {stability_ms};
966+
}}, 500);
967+
}}""")
968+
_log("Smart: MutationObserver injected on .prose content area")
969+
970+
async def _check_mutation_stability(self, page) -> bool:
971+
"""Check if the MutationObserver reports stable (no mutations for threshold)."""
972+
try:
973+
state = await page.evaluate("() => window.__mutationState || {}")
974+
return bool(state.get("isStable", False))
975+
except Exception:
976+
return False
977+
978+
async def _check_for_error_state(self, page) -> bool:
979+
"""Check for error indicators after stop button disappears.
980+
981+
Returns True if an error was detected.
982+
"""
983+
try:
984+
error = await page.evaluate("""() => {
985+
// Check for error text
986+
const body = document.body.innerText || '';
987+
const errorPatterns = [
988+
'Something went wrong',
989+
'Rate limit',
990+
'Error generating',
991+
'An error occurred',
992+
'Please try again',
993+
];
994+
for (const pattern of errorPatterns) {
995+
if (body.includes(pattern)) return pattern;
996+
}
997+
// Check for error-styled elements
998+
const errorEl = document.querySelector('[class*="error"]');
999+
if (errorEl && errorEl.textContent.trim().length > 5) {
1000+
return errorEl.textContent.trim().substring(0, 100);
1001+
}
1002+
return null;
1003+
}""")
1004+
if error:
1005+
_log(f"Smart: error state detected: {error}")
1006+
return True
1007+
except Exception:
1008+
pass
1009+
return False
1010+
1011+
async def _wait_research_smart(self, page, timeout: int, start: float) -> bool:
1012+
"""Smart completion detection for research/labs modes.
1013+
1014+
Signal hierarchy:
1015+
1. Primary: stop button cycle (appeared → disappeared with debounce)
1016+
2. Confirming: MutationObserver stability OR text stability (10s window)
1017+
3. Fallback: existing _wait_research_fallback() with reduced guards
1018+
1019+
If the stop button disappears suspiciously fast (<30s), waits for
1020+
a confirming signal before accepting. If stop button never appears,
1021+
falls through to the CSS/text-stability fallback.
1022+
"""
1023+
min_gen_s = BROWSER_MIN_GENERATION_TIME_MS / 1000
1024+
confirm_s = BROWSER_CONFIRMATION_WINDOW_MS / 1000
1025+
text_stable_s = 8 # seconds of unchanged text for confirmation
1026+
1027+
# Inject MutationObserver early
1028+
await self._inject_mutation_observer(page)
1029+
1030+
# Primary signal: stop button cycle
1031+
stop_cycle = await self._wait_for_stop_button_cycle(page, timeout, start)
1032+
1033+
if not stop_cycle:
1034+
# Stop button never appeared — fall through to existing fallback
1035+
_log("Smart: no stop button detected, using fallback completion detection")
1036+
return await self._wait_research_fallback(page, timeout, start)
1037+
1038+
elapsed = time.time() - start
1039+
1040+
# Check for error state after stop button disappears
1041+
if await self._check_for_error_state(page):
1042+
_log("Smart: error detected after stop button disappeared")
1043+
return False
1044+
1045+
# Suspiciously fast? Wait for confirming signal
1046+
if elapsed < min_gen_s:
1047+
_log(f"Smart: stop button gone at {elapsed:.1f}s (< {min_gen_s}s), waiting for confirmation...")
1048+
confirm_start = time.time()
1049+
text_snapshot = await self._get_text_length(page)
1050+
text_stable_since = time.time()
1051+
1052+
while (time.time() - confirm_start) < confirm_s:
1053+
# Check mutation stability
1054+
if await self._check_mutation_stability(page):
1055+
_log(f"Smart: confirmed via MutationObserver stability ({time.time() - start:.1f}s)")
1056+
return True
1057+
# Check text stability
1058+
current_len = await self._get_text_length(page)
1059+
if current_len != text_snapshot:
1060+
text_snapshot = current_len
1061+
text_stable_since = time.time()
1062+
elif (time.time() - text_stable_since) >= text_stable_s:
1063+
_log(f"Smart: confirmed via text stability ({text_stable_s}s, {time.time() - start:.1f}s)")
1064+
return True
1065+
await asyncio.sleep(1)
1066+
1067+
_log(f"Smart: no confirming signal in {confirm_s}s, falling back to CSS detection")
1068+
return await self._wait_research_fallback(page, timeout, start)
1069+
1070+
# Normal timing — brief confirmation phase (10s max)
1071+
_log(f"Smart: stop button gone at {elapsed:.1f}s, running brief confirmation...")
1072+
confirm_start = time.time()
1073+
text_snapshot = await self._get_text_length(page)
1074+
text_stable_since = time.time()
1075+
1076+
while (time.time() - confirm_start) < confirm_s:
1077+
# Check mutation stability
1078+
if await self._check_mutation_stability(page):
1079+
_log(f"Smart: confirmed via MutationObserver stability ({time.time() - start:.1f}s)")
1080+
return True
1081+
# Check text stability
1082+
current_len = await self._get_text_length(page)
1083+
if current_len != text_snapshot:
1084+
text_snapshot = current_len
1085+
text_stable_since = time.time()
1086+
elif (time.time() - text_stable_since) >= text_stable_s:
1087+
_log(f"Smart: confirmed via text stability ({text_stable_s}s, {time.time() - start:.1f}s)")
1088+
return True
1089+
await asyncio.sleep(1)
1090+
1091+
# Confirmation window expired but stop button is still gone — trust it
1092+
_log(f"Smart: confirmation window expired, trusting stop button signal ({time.time() - start:.1f}s)")
1093+
return True
1094+
8621095
async def wait_for_completion(self, page, timeout: int | None = None) -> bool:
8631096
"""Wait for all model responses and synthesis to complete.
8641097
865-
Primary: Vision-based detection via Haiku screenshot analysis.
1098+
Research/labs: Smart detection (stop button + multi-signal confirmation).
1099+
Council: Vision-based detection via Haiku screenshot analysis.
8661100
Fallback: CSS selector + stability polling (when ANTHROPIC_API_KEY not set).
8671101
"""
8681102
timeout = timeout or self.timeout
8691103
start = time.time()
8701104

1105+
# Research/labs: always use smart detection (stop button + multi-signal)
1106+
# regardless of vision availability. Vision is deprecated for research/labs.
1107+
if self.perplexity_mode in ("research", "labs"):
1108+
return await self._wait_research_smart(page, timeout, start)
1109+
1110+
# Council mode: vision-based or CSS fallback
8711111
api_key = os.environ.get("ANTHROPIC_API_KEY")
8721112
use_vision = bool(api_key) and VISION_ENABLED
8731113

8741114
if use_vision:
8751115
return await self._wait_vision(page, timeout, start)
8761116
else:
8771117
_log("Vision monitoring unavailable (no ANTHROPIC_API_KEY), using CSS fallback")
878-
# Route research/labs to dedicated fallback (mode-aware stability)
879-
if self.perplexity_mode in ("research", "labs"):
880-
return await self._wait_research_fallback(page, timeout, start)
8811118
return await self._wait_css_fallback(page, timeout, start)
8821119

8831120
async def _wait_vision(self, page, timeout: int, start: float) -> bool:
@@ -887,9 +1124,8 @@ async def _wait_vision(self, page, timeout: int, start: float) -> bool:
8871124
Requires seeing 'synthesizing' before trusting 'complete', and
8881125
requires 2 consecutive 'complete' polls for confidence.
8891126
"""
890-
# Route research/labs to dedicated vision method (different prompt)
891-
if self.perplexity_mode in ("research", "labs"):
892-
return await self._wait_vision_research(page, timeout, start)
1127+
# Note: research/labs now use _wait_research_smart() (routed in wait_for_completion)
1128+
# This method is only called for council mode.
8931129

8941130
poll_interval = VISION_POLL_INTERVAL_MODELS
8951131
all_models_done = False

council-automation/council_config.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,17 +99,24 @@
9999
# Mode-aware stability thresholds (research/labs pause 60-120s+ between sections)
100100
# These must be LONGER than the longest Perplexity "thinking" pause to avoid
101101
# declaring completion during an inter-section pause.
102-
BROWSER_STABLE_MS_RESEARCH = 240_000 # ms, 4 min — research thinking pauses can be 60-120s (2x safety margin)
103-
BROWSER_STABLE_MS_LABS = 300_000 # ms, 5 min — labs can pause even longer (2x safety margin)
102+
BROWSER_STABLE_MS_RESEARCH = 120_000 # ms, 2 min — fallback only (smart detection is primary)
103+
BROWSER_STABLE_MS_LABS = 150_000 # ms, 2.5 min — fallback only (smart detection is primary)
104104
BROWSER_POLL_INTERVAL_RESEARCH = 3_000 # ms, slightly slower polling for long responses
105105

106106
# DOM signal guards — prevent premature completion detection
107107
# Perplexity shows sources/action buttons mid-generation; don't trust DOM signals early
108-
BROWSER_DOM_MIN_ELAPSED_RESEARCH = 240_000 # ms, ignore DOM/vision signals for first 4 min (2x safety margin)
109-
BROWSER_DOM_MIN_ELAPSED_LABS = 360_000 # ms, ignore DOM/vision signals for first 6 min (2x safety margin)
108+
BROWSER_DOM_MIN_ELAPSED_RESEARCH = 120_000 # ms, fallback only (smart detection is primary)
109+
BROWSER_DOM_MIN_ELAPSED_LABS = 180_000 # ms, fallback only (smart detection is primary)
110110
BROWSER_DOM_MIN_TEXT_LENGTH = 3000 # chars, research reports are 5000+ when complete
111111
BROWSER_DOM_CONFIRM_WAIT = 30_000 # ms, polling window with 5s growth checks (must exceed longest inter-section pause)
112112
BROWSER_TYPE_DELAY = 30 # ms between keystrokes
113+
114+
# --- Smart completion detection (stop button + multi-signal) ---
115+
BROWSER_STOP_BUTTON_POLL_MS = 1_000 # ms, polling interval for stop button check
116+
BROWSER_STOP_BUTTON_DEBOUNCE_MS = 3_000 # ms, re-check after disappearance
117+
BROWSER_MIN_GENERATION_TIME_MS = 30_000 # ms, minimum before accepting stop button signal
118+
BROWSER_CONFIRMATION_WINDOW_MS = 10_000 # ms, wait for confirming signal after primary
119+
BROWSER_MUTATION_STABILITY_MS = 5_000 # ms, zero mutations = stable
113120
BROWSER_USER_DATA_DIR = Path.home() / ".claude" / "config" / "playwright-chrome-profile"
114121
BROWSER_SESSION_PATH = Path.home() / ".claude" / "config" / "playwright-session.json"
115122
SELECTORS_PATH = Path.home() / ".claude" / "perplexity-selectors.json"

0 commit comments

Comments
 (0)