Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 58 additions & 19 deletions council-automation/council_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -645,10 +645,12 @@ async def _verify_labs_activation(self, page) -> bool:
async def _detect_dom_completion(self, page) -> dict:
"""Check Perplexity DOM for completion signals (research/labs modes)."""
return await page.evaluate("""() => {
// Signal 1: No streaming/loading indicators
// Signal 1: No streaming/loading indicators (includes Perplexity-specific selectors)
const streaming = document.querySelectorAll(
'[class*="streaming"], [class*="loading"], [class*="generating"], '
+ '[class*="animate-pulse"], [class*="animate-spin"]'
+ '[class*="animate-pulse"], [class*="animate-spin"], '
+ '[class*="cursor"], [class*="typing"], [class*="progress"], '
+ '.animate-blink, [data-testid*="loading"]'
);

// Signal 2: Sources/citations section visible
Expand All @@ -672,12 +674,19 @@ async def _detect_dom_completion(self, page) -> dict:
'[class*="related"], [data-testid*="related"]'
);

// Signal 6: Stop/Cancel button present = still generating
const stopBtn = document.querySelector(
'button[aria-label*="Stop"], button[aria-label*="Cancel"], '
+ 'button[class*="stop"], [data-testid*="stop"]'
);

return {
isStreaming: streaming.length > 0,
hasSources: !!sources,
hasActionButtons: actions.length >= 2,
hasFollowUp: !!followUp,
hasRelated: !!related,
hasStopButton: !!stopBtn,
};
}""")

Expand All @@ -700,7 +709,13 @@ async def activate_council(self, page) -> bool:
return await self.activate_mode(page)

async def submit_query(self, page, query: str) -> None:
"""Type and submit the query."""
"""Type and submit the query.

Mode activation (/research, /council, /labs) is already completed
before this method is called, so the mode is locked in. Native
setter (fast paste) is safe here — it sets the query text without
affecting the already-activated mode.
"""
textarea = self.selectors.get("textarea", "#ask-input")

# Try native setter first (preserves newlines), fall back to page.fill()
Expand Down Expand Up @@ -992,9 +1007,16 @@ async def _wait_vision_research(self, page, timeout: int, start: float) -> bool:
else:
consecutive_complete += 1
if consecutive_complete >= 2:
_log(f"Vision (research): page complete (confirmed 2x) ({time.time() - start:.1f}s)")
return True
_log(" Vision (research): complete (need 1 more confirmation)")
elapsed = time.time() - start
min_elapsed = BROWSER_DOM_MIN_ELAPSED_LABS / 1000 if self.perplexity_mode == "labs" else BROWSER_DOM_MIN_ELAPSED_RESEARCH / 1000
if elapsed < min_elapsed:
_log(f" Vision: ignoring early complete ({elapsed:.0f}s < {min_elapsed:.0f}s min)")
consecutive_complete = 0
else:
_log(f"Vision (research): page complete (confirmed 2x) ({elapsed:.1f}s)")
return True
else:
_log(" Vision (research): complete (need 1 more confirmation)")
else:
consecutive_complete = 0

Expand Down Expand Up @@ -1093,7 +1115,8 @@ async def _wait_research_fallback(self, page, timeout: int, start: float) -> boo
last_text_len = 0
stable_since = time.time()
_log(f"Research/labs fallback: polling with {stable_threshold}s stability, "
f"{dom_min_elapsed}s DOM guard, {dom_min_text} char minimum...")
f"{dom_min_elapsed}s DOM guard, {dom_confirm_wait}s growth-polling confirm, "
f"{dom_min_text} char minimum...")

while (time.time() - start) * 1000 < timeout:
elapsed = time.time() - start
Expand All @@ -1104,17 +1127,29 @@ async def _wait_research_fallback(self, page, timeout: int, start: float) -> boo
current_len_check = await self._get_text_length(page)
if current_len_check >= dom_min_text:
dom = await self._detect_dom_completion(page)
if not dom['isStreaming'] and dom['hasActionButtons'] and (dom['hasSources'] or dom['hasRelated']):
# Confirmation: wait and verify text stopped growing
_log(f"DOM signals detected at {elapsed:.0f}s ({current_len_check} chars), confirming stability for {dom_confirm_wait}s...")
pre_confirm_len = current_len_check
await asyncio.sleep(dom_confirm_wait)
post_confirm_len = await self._get_text_length(page)
if post_confirm_len == pre_confirm_len:
_log(f"Completion confirmed via DOM signals + stability (sources={dom['hasSources']}, actions={dom['hasActionButtons']}, {post_confirm_len} chars)")
if (not dom['isStreaming'] and not dom.get('hasStopButton', False)
and dom['hasActionButtons'] and (dom['hasSources'] or dom['hasRelated'])):
# Growth-polling confirmation: check every 5s during confirm window
_log(f"DOM signals detected at {elapsed:.0f}s ({current_len_check} chars), "
f"verifying with {dom_confirm_wait}s growth check...")
growth_detected = False
check_interval = 5 # seconds
checks = int(dom_confirm_wait / check_interval)
prev_len = current_len_check
for check_i in range(checks):
await asyncio.sleep(check_interval)
new_len = await self._get_text_length(page)
if new_len != prev_len:
growth_detected = True
_log(f" Text grew during confirm check {check_i+1}/{checks}: {prev_len} → {new_len}")
break
prev_len = new_len
if not growth_detected:
_log(f"Completion confirmed via DOM signals + {dom_confirm_wait}s growth polling "
f"(sources={dom['hasSources']}, actions={dom['hasActionButtons']}, {prev_len} chars)")
return True
else:
_log(f"DOM signals were premature — text grew from {pre_confirm_len} to {post_confirm_len} during confirm wait")
_log(f"DOM signals were premature — text still growing, resetting stability timer")
stable_since = time.time() # Reset stability timer
except Exception:
pass
Expand All @@ -1125,9 +1160,13 @@ async def _wait_research_fallback(self, page, timeout: int, start: float) -> boo
last_text_len = current_len
stable_since = time.time() # Reset — content still growing

# Layer 3: Stability timeout (mode-aware, requires substantial text)
if current_len >= dom_min_text and (time.time() - stable_since) >= stable_threshold:
_log(f"Completion via text stability ({stable_threshold}s, {current_len} chars)")
# Layer 3: Stability timeout (mode-aware, requires substantial text + min elapsed)
# Guard: don't trust stability before dom_min_elapsed — Perplexity pauses 60-120s
# between "thinking" phases, so early stability is almost certainly a false positive.
if (elapsed >= dom_min_elapsed
and current_len >= dom_min_text
and (time.time() - stable_since) >= stable_threshold):
_log(f"Completion via text stability ({stable_threshold}s, {current_len} chars, {elapsed:.0f}s elapsed)")
return True

await asyncio.sleep(poll_interval)
Expand Down
16 changes: 9 additions & 7 deletions council-automation/council_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,17 +96,19 @@
BROWSER_STABLE_MS = 8_000 # ms, content unchanged = stable
BROWSER_POLL_INTERVAL = 2_000 # ms, check interval

# Mode-aware stability thresholds (research/labs pause >8s between sections)
BROWSER_STABLE_MS_RESEARCH = 50_000 # ms, research pauses 15-30s+ between sections
BROWSER_STABLE_MS_LABS = 60_000 # ms, labs can pause even longer
# Mode-aware stability thresholds (research/labs pause 60-120s+ between sections)
# These must be LONGER than the longest Perplexity "thinking" pause to avoid
# declaring completion during an inter-section pause.
BROWSER_STABLE_MS_RESEARCH = 240_000 # ms, 4 min — research thinking pauses can be 60-120s (2x safety margin)
BROWSER_STABLE_MS_LABS = 300_000 # ms, 5 min — labs can pause even longer (2x safety margin)
BROWSER_POLL_INTERVAL_RESEARCH = 3_000 # ms, slightly slower polling for long responses

# DOM signal guards — prevent premature completion detection
# Perplexity shows sources/action buttons mid-generation; don't trust DOM signals early
BROWSER_DOM_MIN_ELAPSED_RESEARCH = 45_000 # ms, ignore DOM signals for first 45s (research)
BROWSER_DOM_MIN_ELAPSED_LABS = 90_000 # ms, ignore DOM signals for first 90s (labs)
BROWSER_DOM_MIN_TEXT_LENGTH = 1500 # chars, require substantial text before trusting DOM
BROWSER_DOM_CONFIRM_WAIT = 10_000 # ms, after DOM signals trigger, wait and re-check
BROWSER_DOM_MIN_ELAPSED_RESEARCH = 240_000 # ms, ignore DOM/vision signals for first 4 min (2x safety margin)
BROWSER_DOM_MIN_ELAPSED_LABS = 360_000 # ms, ignore DOM/vision signals for first 6 min (2x safety margin)
BROWSER_DOM_MIN_TEXT_LENGTH = 3000 # chars, research reports are 5000+ when complete
BROWSER_DOM_CONFIRM_WAIT = 30_000 # ms, polling window with 5s growth checks (must exceed longest inter-section pause)
BROWSER_TYPE_DELAY = 30 # ms between keystrokes
BROWSER_USER_DATA_DIR = Path.home() / ".claude" / "config" / "playwright-chrome-profile"
BROWSER_SESSION_PATH = Path.home() / ".claude" / "config" / "playwright-session.json"
Expand Down
Loading