{escape(str(report_meta.get('title') or 'AI Influence Report'))}
{render_platform_svg(str(report_meta.get('title') or 'AI Influence Report'))}
{escape(markdown)}
+{figures_block}
{source_cards}
"""
diff --git a/harness/lib/ai_influence_youtube_report/runtime.py b/harness/lib/ai_influence_youtube_report/runtime.py
new file mode 100644
index 000000000..4610d1795
--- /dev/null
+++ b/harness/lib/ai_influence_youtube_report/runtime.py
@@ -0,0 +1,400 @@
+"""Production runtime for AI Influence YouTube browser-agent report generation."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+from .archive import archive_writer_commit
+from .browser_agent import BrowserAgentClient, BrowserAgentProvider, ChatGPTReportOperatorProvider
+from .evidence_map import build_evidence_map
+from .figures import build_figure_manifest, build_figure_specs, paint_figure, render_figure_markdown
+from .render import render_report_html
+from .validator import validator_run
+
+
+def _ensure_dir(path: str | Path) -> Path:
+ p = Path(path).expanduser()
+ p.mkdir(parents=True, exist_ok=True)
+ return p
+
+
+def _slug(value: str, fallback: str = "item") -> str:
+ text = "".join(ch if ch.isalnum() or ch in "._-:" else "-" for ch in str(value or "").strip()).strip("-")
+ return text or fallback
+
+
+def _normalize_sources(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ normalized: list[dict[str, Any]] = []
+ for idx, item in enumerate(items, start=1):
+ evidence_ref = str(item.get("evidence_ref") or f"E{idx:03d}").strip() or f"E{idx:03d}"
+ normalized.append(
+ {
+ "evidence_ref": evidence_ref,
+ "channel": str(item.get("channel") or item.get("channel_name") or "N/A"),
+ "title": str(item.get("title") or "N/A"),
+ "published_at": str(item.get("published_at") or ""),
+ "transcript_grade": str(item.get("transcript_grade") or "T2"),
+ "citation_span": str(item.get("citation_span") or item.get("summary") or "")[:400],
+ "group_type": str(item.get("group_type") or "other"),
+ "summary": str(item.get("summary") or ""),
+ "why_it_matters": str(item.get("why_it_matters") or ""),
+ "transcript": str(item.get("transcript") or ""),
+ "url": str(item.get("url") or ""),
+ "category": str(item.get("category") or ""),
+ "signal_type": str(item.get("signal_type") or ""),
+ }
+ )
+ return normalized
+
+
+def _plan_payload(sources: list[dict[str, Any]], *, report_title: str, run_id: str) -> dict[str, Any]:
+ return {
+ "run_id": run_id,
+ "report_title": report_title,
+ "sources": sources,
+ "instructions": {
+ "goal": "基于 transcript 证据先做结构化规划,再拆章写作,最后综合成完整报告。",
+ "output_contract": "phase1 必须给出 trends -> chapters -> subsections -> evidence_refs。",
+ },
+ }
+
+
+def _parse_plan_text(plan_result: dict[str, Any]) -> dict[str, Any]:
+ text = str(plan_result.get("text") or "").strip()
+ if not text:
+ raise RuntimeError("browser_agent_plan_empty")
+ try:
+ data = json.loads(text)
+ except Exception as exc:
+ raise RuntimeError(f"browser_agent_plan_invalid_json:{type(exc).__name__}:{exc}") from exc
+ if not isinstance(data, dict):
+ raise RuntimeError("browser_agent_plan_not_object")
+ trends = data.get("trends")
+ if not isinstance(trends, list) or not trends:
+ raise RuntimeError("browser_agent_plan_missing_trends")
+ return data
+
+
+def _flatten_chapters(plan_json: dict[str, Any]) -> list[dict[str, Any]]:
+ chapters: list[dict[str, Any]] = []
+ for trend_index, trend in enumerate(plan_json.get("trends") or [], start=1):
+ if not isinstance(trend, dict):
+ continue
+ trend_title = str(trend.get("title") or f"Trend {trend_index}")
+ for chapter_index, chapter in enumerate(trend.get("chapters") or [], start=1):
+ if not isinstance(chapter, dict):
+ continue
+ chapter_id = str(chapter.get("chapter_id") or f"chapter-{trend_index}-{chapter_index}")
+ evidence_refs: list[str] = []
+ for subsection in chapter.get("subsections") or []:
+ if isinstance(subsection, dict):
+ for ref in subsection.get("evidence_refs") or []:
+ clean = str(ref or "").strip()
+ if clean and clean not in evidence_refs:
+ evidence_refs.append(clean)
+ chapters.append(
+ {
+ "chapter_id": chapter_id,
+ "title": str(chapter.get("title") or chapter_id),
+ "trend_title": trend_title,
+ "subsections": chapter.get("subsections") or [],
+ "evidence_refs": evidence_refs,
+ }
+ )
+ if not chapters:
+ raise RuntimeError("browser_agent_plan_missing_chapters")
+ return chapters
+
+
+def _chapter_payload(chapter: dict[str, Any], evidence_rows: list[dict[str, Any]], *, report_title: str, run_id: str) -> dict[str, Any]:
+ return {
+ "run_id": run_id,
+ "report_title": report_title,
+ "chapter": chapter,
+ "evidence_rows": evidence_rows,
+ }
+
+
+def _chapter_batch_payload(
+ batch: list[dict[str, Any]],
+ *,
+ report_title: str,
+ run_id: str,
+) -> list[dict[str, Any]]:
+ return [
+ _chapter_payload(item["chapter"], item["evidence_rows"], report_title=report_title, run_id=run_id)
+ for item in batch
+ ]
+
+
+def _chapter_batches(items: list[dict[str, Any]], batch_size: int) -> list[list[dict[str, Any]]]:
+ size = max(int(batch_size or 1), 1)
+ return [items[index:index + size] for index in range(0, len(items), size)]
+
+
+def _parse_chapter_batch_text(batch_result: dict[str, Any], requested_batch: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ text = str(batch_result.get("text") or "").strip()
+ if not text:
+ raise RuntimeError("browser_agent_phase2_batch_empty")
+ try:
+ payload = json.loads(text)
+ except Exception as exc:
+ raise RuntimeError(f"browser_agent_phase2_batch_invalid_json:{type(exc).__name__}:{exc}") from exc
+ if isinstance(payload, dict):
+ entries = payload.get("chapters") or payload.get("items") or payload.get("results") or []
+ elif isinstance(payload, list):
+ entries = payload
+ else:
+ entries = []
+ if not isinstance(entries, list) or not entries:
+ raise RuntimeError("browser_agent_phase2_batch_missing_chapters")
+ by_id = {
+ str(item.get("chapter_id") or "").strip(): item
+ for item in entries
+ if isinstance(item, dict) and str(item.get("chapter_id") or "").strip()
+ }
+ outputs: list[dict[str, Any]] = []
+ missing: list[str] = []
+ for requested in requested_batch:
+ chapter = requested["chapter"]
+ chapter_id = str(chapter.get("chapter_id") or "").strip()
+ payload_item = by_id.get(chapter_id)
+ if not isinstance(payload_item, dict):
+ missing.append(chapter_id)
+ continue
+ chapter_text = str(payload_item.get("text") or payload_item.get("markdown") or "").strip()
+ if not chapter_text:
+ missing.append(chapter_id)
+ continue
+ outputs.append(
+ {
+ "chapter_id": chapter_id,
+ "title": str(payload_item.get("title") or chapter.get("title") or chapter_id),
+ "trend_title": str(chapter.get("trend_title") or ""),
+ "evidence_refs": list(chapter.get("evidence_refs") or []),
+ "text": chapter_text,
+ "chatgpt_url": str(batch_result.get("chatgpt_url") or ""),
+ "browser_session_id": str(batch_result.get("browser_session_id") or ""),
+ }
+ )
+ if missing:
+ raise RuntimeError(f"browser_agent_phase2_batch_missing_outputs:{','.join(missing)}")
+ return outputs
+
+
+def _synthesis_payload(chapter_outputs: list[dict[str, Any]], *, report_title: str, run_id: str) -> dict[str, Any]:
+ return {
+ "run_id": run_id,
+ "report_title": report_title,
+ "chapters": chapter_outputs,
+ }
+
+
+def generate_browser_agent_report_bundle(
+ source_items: list[dict[str, Any]],
+ *,
+ run_dir: str | Path,
+ run_id: str,
+ report_title: str,
+ requested_model: str = "chatgpt-5.5-thinking-high",
+ sprint_id: str = "",
+ provider: BrowserAgentProvider | None = None,
+ provider_options: dict[str, Any] | None = None,
+ figure_operator_runner: Any = None,
+ figure_operator_options: dict[str, Any] | None = None,
+ phase2_batch_size: int = 2,
+) -> dict[str, Any]:
+ runtime_dir = _ensure_dir(Path(run_dir).expanduser() / "browser-agent-report")
+ sources = _normalize_sources(source_items)
+ safe_sources = [item for item in sources if str(item.get("transcript_grade") or "").strip().upper() != "T3"]
+ if not safe_sources:
+ raise RuntimeError("browser_agent_report_requires_non_t3_sources")
+
+ ledger_path = runtime_dir / "model_call_ledger.jsonl"
+ resolved_provider = provider or ChatGPTReportOperatorProvider(
+ request_root=runtime_dir / "requests",
+ **(provider_options or {}),
+ )
+ client = BrowserAgentClient(
+ resolved_provider,
+ ledger_path=ledger_path,
+ sprint_id=str(sprint_id or run_id),
+ )
+
+ plan_result = client.plan(
+ _plan_payload(safe_sources, report_title=report_title, run_id=run_id),
+ requested_model=requested_model,
+ run_id=run_id,
+ )
+ plan_json = _parse_plan_text(plan_result)
+ chapters = _flatten_chapters(plan_json)
+ evidence_by_ref = {str(item["evidence_ref"]): item for item in safe_sources}
+ chapter_jobs: list[dict[str, Any]] = []
+ for chapter in chapters:
+ evidence_rows = [evidence_by_ref[ref] for ref in chapter.get("evidence_refs") or [] if ref in evidence_by_ref]
+ if not evidence_rows:
+ continue
+ chapter_jobs.append({"chapter": chapter, "evidence_rows": evidence_rows})
+ chapter_outputs: list[dict[str, Any]] = []
+ for batch_index, batch in enumerate(_chapter_batches(chapter_jobs, phase2_batch_size), start=1):
+ if len(batch) == 1 and max(int(phase2_batch_size or 1), 1) <= 1:
+ chapter = batch[0]["chapter"]
+ chapter_result = client.write_chapter(
+ _chapter_payload(chapter, batch[0]["evidence_rows"], report_title=report_title, run_id=run_id),
+ requested_model=requested_model,
+ run_id=run_id,
+ chapter_id=str(chapter["chapter_id"]),
+ )
+ chapter_outputs.append(
+ {
+ "chapter_id": str(chapter["chapter_id"]),
+ "title": str(chapter.get("title") or chapter["chapter_id"]),
+ "trend_title": str(chapter.get("trend_title") or ""),
+ "evidence_refs": list(chapter.get("evidence_refs") or []),
+ "text": str(chapter_result.get("text") or "").strip(),
+ "chatgpt_url": str(chapter_result.get("chatgpt_url") or ""),
+ "browser_session_id": str(chapter_result.get("browser_session_id") or ""),
+ }
+ )
+ continue
+ batch_result = client.write_chapter_batch(
+ _chapter_batch_payload(batch, report_title=report_title, run_id=run_id),
+ requested_model=requested_model,
+ run_id=run_id,
+ batch_id=f"batch-{batch_index:02d}",
+ )
+ chapter_outputs.extend(_parse_chapter_batch_text(batch_result, batch))
+ if not chapter_outputs:
+ raise RuntimeError("browser_agent_report_no_chapter_outputs")
+
+ synthesis_result = client.synthesize(
+ chapter_outputs,
+ requested_model=requested_model,
+ run_id=run_id,
+ )
+ synthesis_text = str(synthesis_result.get("text") or "").strip()
+ if not synthesis_text:
+ raise RuntimeError("browser_agent_report_synthesis_empty")
+
+ evidence_map = build_evidence_map(safe_sources)
+ figure_specs = build_figure_specs(plan_json, chapter_outputs, evidence_map, report_title=report_title)
+ figures_dir = _ensure_dir(runtime_dir / "figures")
+ for spec in figure_specs:
+ (figures_dir / f"{spec.figure_id}.spec.json").write_text(
+ json.dumps(spec.to_dict(), ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ figure_results = [
+ paint_figure(
+ spec,
+ run_dir=figures_dir,
+ operator_runner=figure_operator_runner,
+ **(figure_operator_options or {}),
+ )
+ for spec in figure_specs
+ ]
+ for figure in figure_results:
+ (figures_dir / f"{figure.figure_id}.result.json").write_text(
+ json.dumps(figure.to_dict(), ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ figure_manifest = build_figure_manifest(run_id, figure_results).to_dict()
+
+ sections = [synthesis_text]
+ lead_figure_blocks = [render_figure_markdown(item.to_dict()) for item in figure_results if item.placement == "report_lead" and item.status == "painted"]
+ lead_figure_blocks = [item for item in lead_figure_blocks if item.strip()]
+ if lead_figure_blocks:
+ sections.append("## 关键图示\n\n" + "\n\n".join(lead_figure_blocks))
+ for chapter in chapter_outputs:
+ chapter_text = str(chapter.get("text") or "").strip()
+ if chapter_text:
+ chapter_blocks = []
+ chapter_figures = [
+ render_figure_markdown(item.to_dict())
+ for item in figure_results
+ if item.placement == "chapter_inline"
+ and item.status == "painted"
+ and str(chapter.get("chapter_id") or "") in set(item.source_chapter_ids)
+ ]
+ chapter_figures = [item for item in chapter_figures if item.strip()]
+ if chapter_figures:
+ chapter_blocks.append("\n\n".join(chapter_figures))
+ chapter_blocks.append(chapter_text)
+ sections.append(f"## {chapter['title']}\n\n" + "\n\n".join(chapter_blocks))
+ appendix_figure_blocks = [render_figure_markdown(item.to_dict()) for item in figure_results if item.placement == "appendix" and item.status == "painted"]
+ appendix_figure_blocks = [item for item in appendix_figure_blocks if item.strip()]
+ if appendix_figure_blocks:
+ sections.append("## 附图\n\n" + "\n\n".join(appendix_figure_blocks))
+ report_md = "\n\n".join(section for section in sections if section.strip()).strip()
+ report_html = render_report_html(
+ report_md,
+ evidence_map,
+ {
+ "title": report_title,
+ "figures": [item.to_dict() for item in figure_results if item.status == "painted"],
+ },
+ )
+ report_bundle = {
+ "run_id": run_id,
+ "report_md": report_md,
+ "report_html": report_html,
+ "evidence_map": evidence_map,
+ "figure_manifest": figure_manifest,
+ "plan_json": plan_json,
+ "chapter_outputs": chapter_outputs,
+ "plan_result": {
+ "model_call_id": str(plan_result.get("model_call_id") or ""),
+ "chatgpt_url": str(plan_result.get("chatgpt_url") or ""),
+ "browser_session_id": str(plan_result.get("browser_session_id") or ""),
+ "request_dir": str(plan_result.get("request_dir") or ""),
+ },
+ "synthesis_result": {
+ "model_call_id": str(synthesis_result.get("model_call_id") or ""),
+ "chatgpt_url": str(synthesis_result.get("chatgpt_url") or ""),
+ "browser_session_id": str(synthesis_result.get("browser_session_id") or ""),
+ "request_dir": str(synthesis_result.get("request_dir") or ""),
+ },
+ }
+ validator_report = validator_run(report_bundle).to_dict()
+ figure_manifest["validator_overall"] = str(validator_report.get("overall") or "")
+ (figures_dir / "figure-manifest.json").write_text(
+ json.dumps(figure_manifest, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ archive_manifest = archive_writer_commit(
+ {
+ "archive_dir": str(runtime_dir / "archive"),
+ "chatgpt_session_url": str(synthesis_result.get("chatgpt_url") or plan_result.get("chatgpt_url") or ""),
+ },
+ report_bundle,
+ validator_report,
+ )
+ (runtime_dir / "plan.json").write_text(json.dumps(plan_json, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+ (runtime_dir / "evidence_map.json").write_text(json.dumps(evidence_map, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+ (runtime_dir / "report.md").write_text(report_md + "\n", encoding="utf-8")
+ (runtime_dir / "report.html").write_text(report_html, encoding="utf-8")
+ (runtime_dir / "chapter_outputs.json").write_text(json.dumps(chapter_outputs, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+ (runtime_dir / "figure_manifest.json").write_text(json.dumps(figure_manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+ (runtime_dir / "validator_report.json").write_text(json.dumps(validator_report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+ result = {
+ "ok": True,
+ "runtime_dir": str(runtime_dir),
+ "run_id": run_id,
+ "report_title": report_title,
+ "report_md_path": str(runtime_dir / "report.md"),
+ "report_html_path": str(runtime_dir / "report.html"),
+ "plan_json_path": str(runtime_dir / "plan.json"),
+ "evidence_map_path": str(runtime_dir / "evidence_map.json"),
+ "figure_manifest_path": str(runtime_dir / "figure_manifest.json"),
+ "validator_overall": str(validator_report.get("overall") or ""),
+ "archive_dir": str(runtime_dir / "archive"),
+ "archive_manifest": archive_manifest,
+ "chatgpt_session_url": str(synthesis_result.get("chatgpt_url") or plan_result.get("chatgpt_url") or ""),
+ "chapter_count": len(chapter_outputs),
+ "source_count": len(safe_sources),
+ "painted_figure_count": int(figure_manifest.get("painted_count") or 0),
+ }
+ (runtime_dir / "runtime-result.json").write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+ return result
diff --git a/harness/lib/ai_influence_youtube_report/schema.py b/harness/lib/ai_influence_youtube_report/schema.py
index fc5068f07..36d68c275 100644
--- a/harness/lib/ai_influence_youtube_report/schema.py
+++ b/harness/lib/ai_influence_youtube_report/schema.py
@@ -144,6 +144,60 @@ def to_dict(self) -> dict[str, Any]:
return to_json_dict(self)
+@dataclass(frozen=True)
+class FigureSpec:
+ figure_id: str
+ title: str
+ figure_type: str
+ placement: str
+ source_chapter_ids: list[str]
+ evidence_refs: list[str]
+ input_outline: list[str]
+ render_prompt: str
+ caption: str = ""
+ status: str = "queued"
+ schema_version: str = "figure_spec.v1"
+
+ def to_dict(self) -> dict[str, Any]:
+ return to_json_dict(self)
+
+
+@dataclass(frozen=True)
+class FigureResult:
+ figure_id: str
+ title: str
+ figure_type: str
+ placement: str
+ source_chapter_ids: list[str]
+ evidence_refs: list[str]
+ status: str
+ image_path: str = ""
+ request_dir: str = ""
+ chatgpt_url: str = ""
+ browser_session_id: str = ""
+ original_image_ok: bool = False
+ error: str = ""
+ caption: str = ""
+ schema_version: str = "figure_result.v1"
+
+ def to_dict(self) -> dict[str, Any]:
+ return to_json_dict(self)
+
+
+@dataclass(frozen=True)
+class FigureManifest:
+ run_id: str
+ figures: list[dict[str, Any]]
+ painted_count: int
+ skipped_count: int
+ failed_count: int
+ validator_overall: str = ""
+ schema_version: str = "figure_manifest.v1"
+
+ def to_dict(self) -> dict[str, Any]:
+ return to_json_dict(self)
+
+
@dataclass(frozen=True)
class ValidatorReport:
run_id: str
diff --git a/harness/lib/ai_influence_youtube_report/validator.py b/harness/lib/ai_influence_youtube_report/validator.py
index 57c0a70a7..2dee363c2 100644
--- a/harness/lib/ai_influence_youtube_report/validator.py
+++ b/harness/lib/ai_influence_youtube_report/validator.py
@@ -16,6 +16,9 @@ def validator_run(report_bundle: dict[str, Any]) -> ValidatorReport:
report_md = str(report_bundle.get("report_md") or "")
report_html = str(report_bundle.get("report_html") or "")
evidence_map = report_bundle.get("evidence_map") or {}
+ figure_manifest = report_bundle.get("figure_manifest") or {}
+ figures = figure_manifest.get("figures") or []
+ painted_figures = [fig for fig in figures if str(fig.get("status") or "") == "painted"]
checks = [
_check("1", "no_internal_tokens_md", not INTERNAL_TOKEN_RE.search(report_md), ["report_md"]),
_check("2", "no_internal_tokens_html", not INTERNAL_TOKEN_RE.search(report_html), ["report_html"]),
@@ -25,6 +28,13 @@ def validator_run(report_bundle: dict[str, Any]) -> ValidatorReport:
_check("6", "no_t3_core_evidence", not any(e.get("transcript_grade") == "T3" for e in evidence_map.get("entries", [])), ["evidence_map"]),
_check("7", "source_mapping_reader_facing", all({"channel", "title", "published_at"} <= set(e) for e in evidence_map.get("entries", [])), ["evidence_map"]),
_check("8", "hierarchy_or_citations_present", bool(report_bundle.get("plan_json") or report_bundle.get("inline_citations")), ["plan_json"]),
+ _check("9", "figure_manifest_schema", (not figure_manifest) or isinstance(figures, list), ["figure_manifest"]),
+ _check(
+ "10",
+ "painted_figures_grounded",
+ all(bool(fig.get("image_path")) and bool(fig.get("evidence_refs")) for fig in painted_figures),
+ ["figure_manifest"],
+ ),
]
overall = "PASS" if all(check.status == "PASS" for check in checks) else "FAIL"
return ValidatorReport(run_id=str(report_bundle.get("run_id") or "unknown"), overall=overall, checks=checks)
diff --git a/harness/lib/browser/profile_lease.py b/harness/lib/browser/profile_lease.py
index c91685cd3..e7e53f31b 100644
--- a/harness/lib/browser/profile_lease.py
+++ b/harness/lib/browser/profile_lease.py
@@ -243,6 +243,17 @@ def release(self, profile_id: str, task_id: str) -> dict[str, Any]:
fcntl.flock(lock_fh, fcntl.LOCK_UN)
lock_fh.close()
+ def peek(self, profile_id: str) -> dict[str, Any] | None:
+ """Return the current active lease record without mutating ownership."""
+ profile = _normalise_profile_id(profile_id)
+ current = self._read(profile)
+ if current is None:
+ return None
+ if current.is_expired:
+ self.expire(profile)
+ return None
+ return current.to_dict()
+
def expire(self, profile_id: str | None = None) -> int:
"""Expire one lease or all expired leases.
diff --git a/harness/lib/browser/profile_registry.py b/harness/lib/browser/profile_registry.py
index 7102e209c..228d657da 100644
--- a/harness/lib/browser/profile_registry.py
+++ b/harness/lib/browser/profile_registry.py
@@ -95,6 +95,9 @@ def health_path(self, profile_id: str) -> Path:
def cdp_last_path(self, profile_id: str) -> Path:
return self.profile_dir(profile_id) / "cdp.last.json"
+ def active_session_path(self, profile_id: str) -> Path:
+ return self.profile_dir(profile_id) / "active-session.json"
+
def evidence_dir(self, profile_id: str) -> Path:
path = self.profile_dir(profile_id) / "evidence"
path.mkdir(parents=True, exist_ok=True)
@@ -131,6 +134,26 @@ def write_cdp_last(self, profile_id: str, cdp_state: dict[str, Any]) -> dict[str
def read_cdp_last(self, profile_id: str) -> dict[str, Any]:
return _read_json(self.cdp_last_path(profile_id))
+ def write_active_session(self, profile_id: str, session_state: dict[str, Any]) -> dict[str, Any]:
+ payload = dict(session_state or {})
+ payload["profile_id"] = _normalise_profile_id(profile_id)
+ payload["updated_at"] = _now_iso()
+ _write_json_atomic(self.active_session_path(profile_id), payload)
+ return payload
+
+ def read_active_session(self, profile_id: str) -> dict[str, Any]:
+ return _read_json(self.active_session_path(profile_id))
+
+ def clear_active_session(self, profile_id: str) -> bool:
+ path = self.active_session_path(profile_id)
+ if not path.exists():
+ return False
+ try:
+ path.unlink()
+ except FileNotFoundError:
+ return False
+ return True
+
def get_storage_state_ref(self, profile_id: str) -> str | None:
return self.read_meta(profile_id).get("storage_state_ref")
diff --git a/harness/lib/browser/profile_selection.py b/harness/lib/browser/profile_selection.py
new file mode 100644
index 000000000..0b1388511
--- /dev/null
+++ b/harness/lib/browser/profile_selection.py
@@ -0,0 +1,134 @@
+"""Helpers for selecting browser profiles with lease awareness."""
+from __future__ import annotations
+
+import hashlib
+from typing import Any
+
+from .profile_lease import ProfileLease
+from .runtime_control import default_profile_id
+
+
+def _slug(value: str) -> str:
+ clean = "".join(ch if ch.isalnum() or ch in "._-" else "-" for ch in str(value or "").strip().lower())
+ return clean.strip("-._") or "default"
+
+
+def _account_label(account_identifier: str) -> str:
+ return str(account_identifier or "").split("@", 1)[0].strip()
+
+
+def alternate_profile_id(service: str, *, account_identifier: str, profile_directory: str) -> str:
+ account = _slug(_account_label(account_identifier))
+ profile = _slug(profile_directory)
+ return f"{_slug(service)}/{account}-{profile}"
+
+
+def profile_id_for_candidate(
+ service: str,
+ *,
+ account_identifier: str,
+ profile_directory: str,
+ is_primary_profile: bool,
+) -> str:
+ if is_primary_profile:
+ return default_profile_id(
+ service,
+ account_label=_account_label(account_identifier) or None,
+ profile_directory=profile_directory or None,
+ )
+ return alternate_profile_id(
+ service,
+ account_identifier=account_identifier,
+ profile_directory=profile_directory,
+ )
+
+
+def ordered_profiles(purpose: str, profiles: list[str], selection: str) -> list[str]:
+ clean = [str(item).strip() for item in profiles if str(item).strip()]
+ if len(clean) <= 1 or selection == "first":
+ return clean
+ digest = hashlib.sha256(str(purpose or "").encode("utf-8")).hexdigest()
+ start = int(digest[:8], 16) % len(clean)
+ return clean[start:] + clean[:start]
+
+
+def peek_profile_lease(profile_id: str, *, lease_manager: ProfileLease | None = None) -> dict[str, Any] | None:
+ manager = lease_manager or ProfileLease()
+ return manager.peek(profile_id)
+
+
+def pick_available_profile(
+ *,
+ service: str,
+ purpose: str,
+ allowed_profiles: list[str],
+ selection: str,
+ account_identifier: str,
+ explicit_profile: str = "",
+ explicit_profile_id: str = "",
+ lease_manager: ProfileLease | None = None,
+) -> dict[str, Any]:
+ manager = lease_manager or ProfileLease()
+ if explicit_profile:
+ resolved_profile_id = explicit_profile_id or default_profile_id(
+ service,
+ account_label=_account_label(account_identifier) or None,
+ profile_directory=explicit_profile or None,
+ )
+ return {
+ "selected_profile_directory": explicit_profile,
+ "selected_profile_id": resolved_profile_id,
+ "lease_blocked_profiles": [],
+ "lease_probe": [{"profile_directory": explicit_profile, "profile_id": resolved_profile_id, "blocked": False}],
+ "selection_reason": "explicit_profile",
+ }
+
+ ordered = ordered_profiles(purpose, allowed_profiles, selection)
+ if not ordered:
+ return {
+ "selected_profile_directory": "",
+ "selected_profile_id": "",
+ "lease_blocked_profiles": [],
+ "lease_probe": [],
+ "selection_reason": "no_allowed_profiles",
+ }
+
+ primary = ordered[0]
+ probes: list[dict[str, Any]] = []
+ blocked: list[str] = []
+ for profile_directory in ordered:
+ profile_id = profile_id_for_candidate(
+ service,
+ account_identifier=account_identifier,
+ profile_directory=profile_directory,
+ is_primary_profile=(profile_directory == primary),
+ )
+ lease = peek_profile_lease(profile_id, lease_manager=manager)
+ is_blocked = lease is not None
+ probe = {
+ "profile_directory": profile_directory,
+ "profile_id": profile_id,
+ "blocked": is_blocked,
+ }
+ if lease:
+ probe["held_by"] = str(lease.get("task_id") or "")
+ probe["expires_at"] = str(lease.get("expires_at") or "")
+ blocked.append(profile_directory)
+ probes.append(probe)
+ if not is_blocked:
+ return {
+ "selected_profile_directory": profile_directory,
+ "selected_profile_id": profile_id,
+ "lease_blocked_profiles": blocked,
+ "lease_probe": probes,
+ "selection_reason": "lease_available",
+ }
+
+ first_probe = probes[0]
+ return {
+ "selected_profile_directory": str(first_probe["profile_directory"]),
+ "selected_profile_id": str(first_probe["profile_id"]),
+ "lease_blocked_profiles": blocked,
+ "lease_probe": probes,
+ "selection_reason": "all_candidates_leased",
+ }
diff --git a/harness/lib/browser/runtime_control.py b/harness/lib/browser/runtime_control.py
index f8114b06e..fe851ff68 100644
--- a/harness/lib/browser/runtime_control.py
+++ b/harness/lib/browser/runtime_control.py
@@ -1,7 +1,9 @@
"""Unified browser profile/lease/contract control plane helpers."""
from __future__ import annotations
+import datetime
import json
+import os
import re
from pathlib import Path
from typing import Any
@@ -17,11 +19,142 @@ def _write_json(path: Path, payload: dict[str, Any]) -> None:
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+def _read_json_file(path: Path) -> dict[str, Any] | None:
+ if not path.exists():
+ return None
+ try:
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return None
+ return payload if isinstance(payload, dict) else None
+
+
def _slug(value: str) -> str:
text = re.sub(r"[^a-zA-Z0-9._-]+", "-", str(value or "").strip())
return text.strip("-._").lower() or "default"
+def _env_flag(*names: str, default: bool = False) -> bool:
+ for name in names:
+ value = str(os.environ.get(name) or "").strip().lower()
+ if not value:
+ continue
+ return value in {"1", "true", "yes", "on"}
+ return default
+
+
+def _harness_root() -> Path:
+ return Path(os.environ.get("HARNESS_DIR") or (Path.home() / ".solar" / "harness")).expanduser()
+
+
+def _latest_outbox_status(task_id: str) -> str:
+ outbox = _harness_root() / "actors" / "browser_agent_session" / "outbox"
+ if not outbox.exists():
+ return ""
+ latest_payload: dict[str, Any] | None = None
+ latest_mtime = -1.0
+ for path in outbox.glob(f"result-{task_id}-*.json"):
+ try:
+ mtime = path.stat().st_mtime
+ if mtime < latest_mtime:
+ continue
+ payload = _read_json_file(path)
+ if not payload:
+ continue
+ latest_payload = payload
+ latest_mtime = mtime
+ except OSError:
+ continue
+ return str((latest_payload or {}).get("status") or "").strip().lower()
+
+
+def _request_task_id(request_dir: Path) -> str:
+ submitted = _read_json_file(request_dir / "submitted-run.json") or {}
+ task_id = str(submitted.get("task_id") or "").strip()
+ if task_id:
+ return task_id
+ submit_stdout = request_dir / "submit-stdout.txt"
+ if submit_stdout.exists():
+ try:
+ payload = json.loads(submit_stdout.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ payload = {}
+ if isinstance(payload, dict):
+ task_id = str(payload.get("task_id") or "").strip()
+ if task_id:
+ return task_id
+ submitted_state = _read_json_file(request_dir / "submitted-state.json") or {}
+ return str(submitted_state.get("task_id") or "").strip()
+
+
+def _recover_stale_profile_lease(
+ *,
+ lease_manager: ProfileLease,
+ profile_id: str,
+ request_dir: Path,
+ lease_result: dict[str, Any],
+) -> bool:
+ if str(lease_result.get("reason") or "").strip() != "already_acquired":
+ return False
+ held_by = str(lease_result.get("held_by") or "").strip()
+ if not held_by:
+ return False
+ sibling_request_dir = request_dir.parent / held_by
+ submitted = _read_json_file(sibling_request_dir / "submitted-run.json") if sibling_request_dir.exists() else None
+ submitted_status = str((submitted or {}).get("status") or "").strip().lower()
+ held_task_id = _request_task_id(sibling_request_dir) if sibling_request_dir.exists() else ""
+ terminal_statuses = {"completed", "failed"}
+ if submitted_status in terminal_statuses:
+ released = lease_manager.release(profile_id, held_by)
+ return bool(released.get("released"))
+ if held_task_id and _latest_outbox_status(held_task_id) in terminal_statuses:
+ released = lease_manager.release(profile_id, held_by)
+ return bool(released.get("released"))
+ return False
+
+
+def _parse_iso8601(value: str | None) -> datetime.datetime:
+ if not value:
+ return datetime.datetime.fromtimestamp(0, tz=datetime.timezone.utc)
+ safe = str(value).rstrip("Z") + "+00:00"
+ try:
+ return datetime.datetime.fromisoformat(safe)
+ except ValueError:
+ return datetime.datetime.fromtimestamp(0, tz=datetime.timezone.utc)
+
+
+def resolve_session_lineage(metadata: dict[str, Any] | None = None) -> str:
+ meta = dict(metadata or {})
+ candidates = [
+ meta.get("session_lineage"),
+ meta.get("lineage_key"),
+ os.environ.get("BROWSER_AGENT_SESSION_LINEAGE"),
+ os.environ.get("SOLAR_BROWSER_SESSION_LINEAGE"),
+ os.environ.get("dispatch_id"),
+ os.environ.get("DISPATCH_ID"),
+ os.environ.get("SPRINT_ID"),
+ os.environ.get("SOLAR_RUNTIME_SESSION_ID"),
+ os.environ.get("TASK_ID"),
+ meta.get("task_id"),
+ ]
+ for candidate in candidates:
+ value = str(candidate or "").strip()
+ if value:
+ return value
+ return ""
+
+
+def session_reuse_enabled(metadata: dict[str, Any] | None = None, *, default: bool = True) -> bool:
+ meta = dict(metadata or {})
+ if meta.get("session_reuse") is not None:
+ return bool(meta.get("session_reuse"))
+ return _env_flag(
+ "BROWSER_AGENT_SESSION_REUSE",
+ "SOLAR_BROWSER_SESSION_REUSE",
+ default=default,
+ )
+
+
def default_profile_id(service: str, account_label: str | None = None, profile_directory: str | None = None) -> str:
label = account_label or profile_directory or "default"
return f"{_slug(service)}/{_slug(label)}"
@@ -43,6 +176,9 @@ def initialize_runtime_contract(
control_modes: dict[str, bool] | None = None,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
+ metadata = dict(metadata or {})
+ lineage_key = resolve_session_lineage({**metadata, "task_id": task_id})
+ reuse_enabled = session_reuse_enabled(metadata)
request_dir.mkdir(parents=True, exist_ok=True)
profile_id = explicit_profile_id or default_profile_id(
service,
@@ -59,6 +195,22 @@ def initialize_runtime_contract(
mode="exclusive",
allowed_attach=bool((control_modes or {}).get("playwright_cdp_attach")),
)
+ if (
+ not lease_result.get("acquired")
+ and _recover_stale_profile_lease(
+ lease_manager=lease_manager,
+ profile_id=profile_id,
+ request_dir=request_dir,
+ lease_result=lease_result,
+ )
+ ):
+ lease_result = lease_manager.acquire(
+ profile_id=profile_id,
+ task_id=task_ref,
+ runtime=runtime_owner,
+ mode="exclusive",
+ allowed_attach=bool((control_modes or {}).get("playwright_cdp_attach")),
+ )
if not lease_result.get("acquired"):
raise RuntimeError(
"browser_profile_lease_acquire_failed:"
@@ -113,7 +265,9 @@ def initialize_runtime_contract(
"service": service,
"wrapper_kind": wrapper_kind,
"control_modes": dict(control_modes or {}),
- **dict(metadata or {}),
+ "session_lineage": lineage_key,
+ "session_reuse": reuse_enabled,
+ **metadata,
},
)
_write_json(request_dir / "browser-profile-ref.json", profile_ref)
@@ -126,6 +280,8 @@ def initialize_runtime_contract(
"wrapper_kind": wrapper_kind,
"runtime_owner": runtime_owner,
"profile_id": profile_id,
+ "session_lineage": lineage_key,
+ "session_reuse": reuse_enabled,
"lease": lease_result.get("lease"),
},
)
@@ -141,6 +297,8 @@ def initialize_runtime_contract(
"lease_manager": lease_manager,
"lease": lease_result.get("lease") or {},
"task_id": task_ref,
+ "session_lineage": lineage_key,
+ "session_reuse": reuse_enabled,
"allowed_account_identifiers": stored_meta.get("allowed_account_identifiers") or [],
"account_identifier": account_identifier or "",
}
@@ -167,6 +325,69 @@ def update_runtime_endpoint(
context["session_contract"] = session_contract
+def read_active_session(
+ context: dict[str, Any] | None,
+ *,
+ require_lineage_match: bool = True,
+ max_age_seconds: int = 1800,
+) -> dict[str, Any] | None:
+ if not context:
+ return None
+ registry: ProfileRegistry = context["registry"]
+ profile_id = str(context["profile_id"])
+ record = registry.read_active_session(profile_id)
+ if not record:
+ return None
+ updated_at = _parse_iso8601(str(record.get("updated_at") or ""))
+ age = (datetime.datetime.now(datetime.timezone.utc) - updated_at).total_seconds()
+ if age > max(0, int(max_age_seconds)):
+ registry.clear_active_session(profile_id)
+ return None
+ if require_lineage_match:
+ current = str(context.get("session_lineage") or "").strip()
+ existing = str(record.get("session_lineage") or "").strip()
+ if not current or not existing or current != existing:
+ return None
+ return record
+
+
+def activate_reusable_session(
+ context: dict[str, Any] | None,
+ *,
+ cdp_url: str,
+ browser_session_ref: str,
+ headless: bool,
+ attached: bool = False,
+ details: dict[str, Any] | None = None,
+) -> dict[str, Any] | None:
+ if not context:
+ return None
+ registry: ProfileRegistry = context["registry"]
+ profile_id = str(context["profile_id"])
+ payload = {
+ "service": str(context["service"]),
+ "wrapper_kind": str(context["wrapper_kind"]),
+ "runtime_owner": str(context["runtime_owner"]),
+ "task_id": str(context["task_id"]),
+ "session_lineage": str(context.get("session_lineage") or ""),
+ "session_reuse": bool(context.get("session_reuse")),
+ "cdp_url": str(cdp_url or "").strip() or None,
+ "browser_session_ref": str(browser_session_ref or "").strip() or None,
+ "headless": bool(headless),
+ "attached": bool(attached),
+ "details": dict(details or {}),
+ }
+ return registry.write_active_session(profile_id, payload)
+
+
+def clear_active_session(context: dict[str, Any] | None) -> bool:
+ if not context:
+ return False
+ registry: ProfileRegistry = context["registry"]
+ profile_id = str(context["profile_id"])
+ return registry.clear_active_session(profile_id)
+
+
def finalize_runtime_contract(
context: dict[str, Any] | None,
*,
diff --git a/harness/lib/browser_agent_session_pool.py b/harness/lib/browser_agent_session_pool.py
new file mode 100644
index 000000000..a3df238d7
--- /dev/null
+++ b/harness/lib/browser_agent_session_pool.py
@@ -0,0 +1,157 @@
+from __future__ import annotations
+
+import datetime as dt
+import fcntl
+import json
+from pathlib import Path
+from typing import Any
+
+
+def _now_iso() -> str:
+ return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+def _request_affinity_key(request_lineage: str) -> str:
+ text = str(request_lineage or "").strip()
+ if not text:
+ return ""
+ pieces = [part for part in text.split(":") if part]
+ if len(pieces) >= 3 and pieces[0] == "ai-influence-youtube-report":
+ return ":".join((pieces[0], pieces[2]))
+ return text
+
+
+class BrowserAgentSessionPool:
+ def __init__(self, root: Path, *, service: str = "chatgpt", pool_size: int = 2):
+ self.root = Path(root)
+ self.service = str(service or "chatgpt").strip() or "chatgpt"
+ self.pool_size = max(1, int(pool_size))
+ self.pool_dir = self.root / self.service
+ self.pool_dir.mkdir(parents=True, exist_ok=True)
+ self.lock_path = self.pool_dir / ".lock"
+
+ def _slot_path(self, slot_id: str) -> Path:
+ return self.pool_dir / f"{slot_id}.json"
+
+ def _default_slot(self, index: int) -> dict[str, Any]:
+ slot_id = f"slot-{index:02d}"
+ return {
+ "slot_id": slot_id,
+ "service": self.service,
+ "state": "idle",
+ "session_lineage": f"browser-agent-session:{self.service}:{slot_id}",
+ "assigned_task_id": "",
+ "assigned_request_lineage": "",
+ "assigned_request_dir": "",
+ "last_request_lineage": "",
+ "leased_at": "",
+ "last_used_at": "",
+ "warm": False,
+ }
+
+ def ensure_slots(self) -> list[dict[str, Any]]:
+ with open(self.lock_path, "a+", encoding="utf-8") as lock_fh:
+ fcntl.flock(lock_fh, fcntl.LOCK_EX)
+ slots = self._ensure_slots_unlocked()
+ fcntl.flock(lock_fh, fcntl.LOCK_UN)
+ return slots
+
+ def list_slots(self) -> list[dict[str, Any]]:
+ slots = self.ensure_slots()
+ return [self._read_slot(idx) or self._default_slot(idx) for idx in range(1, len(slots) + 1)]
+
+ def acquire_slot(
+ self,
+ *,
+ task_id: str,
+ request_lineage: str = "",
+ request_dir: str = "",
+ ) -> dict[str, Any]:
+ with open(self.lock_path, "a+", encoding="utf-8") as lock_fh:
+ fcntl.flock(lock_fh, fcntl.LOCK_EX)
+ slots = self._ensure_slots_unlocked()
+ idle_slots = [slot for slot in slots if str(slot.get("state") or "idle") == "idle"]
+ affinity_key = _request_affinity_key(request_lineage)
+ if not idle_slots:
+ chosen = min(slots, key=lambda slot: str(slot.get("last_used_at") or ""))
+ else:
+ affinity_idle = []
+ if affinity_key:
+ affinity_idle = [
+ slot
+ for slot in idle_slots
+ if _request_affinity_key(
+ str(slot.get("last_request_lineage") or slot.get("assigned_request_lineage") or "")
+ )
+ == affinity_key
+ ]
+ if affinity_idle:
+ warm_affinity = [slot for slot in affinity_idle if bool(slot.get("warm"))]
+ candidates = warm_affinity or affinity_idle
+ chosen = max(candidates, key=lambda slot: str(slot.get("last_used_at") or ""))
+ else:
+ cold_idle = [slot for slot in idle_slots if not bool(slot.get("warm"))]
+ chosen = cold_idle[0] if cold_idle else min(
+ idle_slots, key=lambda slot: str(slot.get("last_used_at") or "")
+ )
+ chosen["state"] = "running"
+ chosen["assigned_task_id"] = str(task_id or "")
+ chosen["assigned_request_lineage"] = str(request_lineage or "")
+ chosen["assigned_request_dir"] = str(request_dir or "")
+ chosen["last_request_lineage"] = str(request_lineage or chosen.get("last_request_lineage") or "")
+ chosen["leased_at"] = _now_iso()
+ self._write_slot(chosen)
+ fcntl.flock(lock_fh, fcntl.LOCK_UN)
+ return dict(chosen)
+
+ def release_slot(self, slot_id: str, *, keep_warm: bool = True) -> dict[str, Any]:
+ with open(self.lock_path, "a+", encoding="utf-8") as lock_fh:
+ fcntl.flock(lock_fh, fcntl.LOCK_EX)
+ slot = self._read_slot_by_id(slot_id)
+ if slot is None:
+ slot = self._default_slot(self._slot_index(slot_id))
+ slot["last_request_lineage"] = str(slot.get("assigned_request_lineage") or slot.get("last_request_lineage") or "")
+ slot["state"] = "idle"
+ slot["assigned_task_id"] = ""
+ slot["assigned_request_lineage"] = ""
+ slot["assigned_request_dir"] = ""
+ slot["leased_at"] = ""
+ slot["last_used_at"] = _now_iso()
+ slot["warm"] = bool(keep_warm)
+ self._write_slot(slot)
+ fcntl.flock(lock_fh, fcntl.LOCK_UN)
+ return dict(slot)
+
+ def _ensure_slots_unlocked(self) -> list[dict[str, Any]]:
+ slots: list[dict[str, Any]] = []
+ for idx in range(1, self.pool_size + 1):
+ slot = self._read_slot(idx)
+ if slot is None:
+ slot = self._default_slot(idx)
+ self._write_slot(slot)
+ slots.append(slot)
+ return slots
+
+ def _slot_index(self, slot_id: str) -> int:
+ try:
+ return int(str(slot_id).split("-")[-1])
+ except Exception:
+ return 1
+
+ def _read_slot_by_id(self, slot_id: str) -> dict[str, Any] | None:
+ path = self._slot_path(str(slot_id))
+ if not path.exists():
+ return None
+ try:
+ return json.loads(path.read_text(encoding="utf-8"))
+ except Exception:
+ return None
+
+ def _read_slot(self, index: int) -> dict[str, Any] | None:
+ return self._read_slot_by_id(f"slot-{index:02d}")
+
+ def _write_slot(self, slot: dict[str, Any]) -> None:
+ path = self._slot_path(str(slot["slot_id"]))
+ tmp = path.with_suffix(".json.tmp")
+ tmp.write_text(json.dumps(slot, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+ tmp.replace(path)
diff --git a/harness/lib/browser_job_runtime.py b/harness/lib/browser_job_runtime.py
index 272218ab5..fcde3ef14 100644
--- a/harness/lib/browser_job_runtime.py
+++ b/harness/lib/browser_job_runtime.py
@@ -684,6 +684,7 @@ async def main() -> None:
allowed_domains=payload["allowed_domains"] or None,
user_data_dir=payload["user_data_dir"] or None,
profile_directory=payload["profile_directory"],
+ channel="chrome",
)
browser = BrowserSession(browser_profile=profile)
await browser.start()
diff --git a/harness/lib/browser_operator_submit.py b/harness/lib/browser_operator_submit.py
new file mode 100644
index 000000000..73c2f0564
--- /dev/null
+++ b/harness/lib/browser_operator_submit.py
@@ -0,0 +1,363 @@
+from __future__ import annotations
+
+import os
+import re
+import shlex
+import subprocess
+import sys
+import time
+import json
+from pathlib import Path
+from typing import Any, Callable, Mapping
+
+
+ROOT = Path(__file__).resolve().parents[1]
+DEFAULT_CHATGPT_OPERATOR = ROOT / "tools" / "chatgpt_report_operator.py"
+if str(ROOT / "tools") not in sys.path:
+ sys.path.append(str(ROOT / "tools"))
+
+from browser_agent_session_control import poll_request # type: ignore # noqa: E402
+
+
+def strip_browser_agent_noise(text: str) -> str:
+ if not text:
+ return ""
+ lines = str(text).splitlines()
+ cleaned: list[str] = []
+ started = False
+ noise_prefixes = ("INFO [", "WARNING [", "ERROR [", "DEBUG [")
+ for line in lines:
+ if not started and (line.startswith(noise_prefixes) or not line.strip()):
+ continue
+ started = True
+ cleaned.append(line)
+ return "\n".join(cleaned).strip()
+
+
+def env_override_text(*names: str) -> str | None:
+ for name in names:
+ raw = os.environ.get(name)
+ if raw is None:
+ continue
+ value = str(raw).strip()
+ if value:
+ return value
+ return None
+
+
+def env_override_bool(*names: str) -> bool | None:
+ raw = env_override_text(*names)
+ if raw is None:
+ return None
+ lowered = raw.lower()
+ if lowered in {"1", "true", "yes", "on"}:
+ return True
+ if lowered in {"0", "false", "no", "off"}:
+ return False
+ return None
+
+
+def default_slugify(value: str) -> str:
+ return re.sub(r"[^a-z0-9]+", "-", str(value).lower()).strip("-")
+
+
+def derive_chatgpt_session_lineage(
+ purpose: str,
+ *,
+ slugify: Callable[[str], str] = default_slugify,
+) -> str:
+ value = str(purpose or "").strip().lower()
+ if not value:
+ return "browser-agent:default"
+ for prefix, lineage_prefix in (
+ ("ai-influence-video-grouping-", "ai-influence-planning:"),
+ ("ai-influence-report-plan-", "ai-influence-planning:"),
+ ("github-trend-report-", "github-trend-report:"),
+ ("hf-paper-l7-high-reasoning-", "hf-paper-l7-high-reasoning:"),
+ ):
+ if value.startswith(prefix):
+ return f"{lineage_prefix}{value[len(prefix):]}"
+ if value.startswith("hf-paper-report-plan-"):
+ return f"hf-paper-report:{value[len('hf-paper-report-plan-'):]}"
+ if value.startswith("hf-paper-report-section-"):
+ tail = value[len("hf-paper-report-section-"):]
+ date_key = tail.split("-", 3)[0:3]
+ if len(date_key) == 3 and all(part.isdigit() for part in date_key):
+ return f"hf-paper-report:{'-'.join(date_key)}"
+ return f"hf-paper-report:{slugify(tail)[:80]}"
+ if value.startswith("ai-influence-report-chapter-"):
+ tail = value[len("ai-influence-report-chapter-"):]
+ match = re.match(r"(?P\d{4}-\d{2}-\d{2})-(?P.+)-(?P[^-]+)$", tail)
+ if match:
+ return f"ai-influence-report:{match.group('date')}:{slugify(match.group('report'))[:80]}"
+ return f"ai-influence-report:{slugify(tail)[:80]}"
+ return f"browser-agent:{slugify(value)[:96]}"
+
+
+def browser_agent_chatgpt_cmd(config: dict[str, Any]) -> list[str]:
+ flow_cfg = ((config.get("youtube") or {}).get("ai_influence_report_flow") or {})
+ reasoner_cfg = ((config.get("youtube") or {}).get("phase_report_reasoner") or {})
+ cmd = (
+ os.environ.get("TECH_HOTSPOT_BROWSER_CHATGPT_CMD")
+ or os.environ.get("BROWSER_AGENT_CHATGPT_CMD")
+ or str((flow_cfg.get("browser_agent") or {}).get("cmd") or "")
+ or str(reasoner_cfg.get("browser_agent_cmd") or "")
+ ).strip()
+ if cmd:
+ return shlex.split(cmd)
+ if DEFAULT_CHATGPT_OPERATOR.exists():
+ return [sys.executable, str(DEFAULT_CHATGPT_OPERATOR)]
+ return []
+
+
+def build_chatgpt_operator_env(
+ *,
+ model: str,
+ reasoning_effort: str,
+ expected: str,
+ request_dir: str | Path,
+ purpose: str,
+ session_lineage: str,
+ session_reuse: bool,
+ operator_kind: str | None = None,
+ target_url: str | None = None,
+ headless: bool | None = None,
+ profile_directory: str | None = None,
+ target_account_email: str | None = None,
+ scrub_client_state: bool | None = None,
+ open_project_first: bool | None = None,
+ require_project: bool | None = None,
+ force_new_chat: bool | None = None,
+ require_isolated_conversation: bool | None = None,
+ project_name: str | None = None,
+ base_env: Mapping[str, str] | None = None,
+) -> dict[str, str]:
+ env = dict(base_env or os.environ)
+ env.update(
+ {
+ "CHATGPT_MODEL": str(model),
+ "CHATGPT_REASONING_EFFORT": str(reasoning_effort),
+ "BROWSER_AGENT_EXPECTED_OUTPUT": expected,
+ "BROWSER_AGENT_REQUEST_DIR": str(request_dir),
+ "BROWSER_AGENT_PURPOSE": purpose,
+ "BROWSER_AGENT_CHATGPT_MODEL_MODE": "thinking",
+ "BROWSER_AGENT_CHATGPT_REQUIRE_UI_MODE": "true",
+ "BROWSER_AGENT_SESSION_LINEAGE": session_lineage,
+ "SOLAR_BROWSER_SESSION_LINEAGE": session_lineage,
+ "BROWSER_AGENT_SESSION_REUSE": "true" if bool(session_reuse) else "false",
+ "SOLAR_BROWSER_SESSION_REUSE": "true" if bool(session_reuse) else "false",
+ }
+ )
+ if operator_kind:
+ env["CHATGPT_REPORT_OPERATOR_KIND"] = operator_kind
+ if target_url:
+ env["BROWSER_AGENT_CHATGPT_URL"] = str(target_url)
+ if headless is not None:
+ env["BROWSER_AGENT_HEADLESS"] = "true" if bool(headless) else "false"
+ if profile_directory:
+ env["BROWSER_AGENT_PROFILE_DIRECTORY"] = str(profile_directory)
+ if target_account_email:
+ env["BROWSER_AGENT_TARGET_ACCOUNT_EMAIL"] = str(target_account_email)
+ env["BROWSER_AGENT_CHATGPT_ACCOUNT_EMAIL"] = str(target_account_email)
+ if scrub_client_state is not None:
+ env["BROWSER_AGENT_CHATGPT_SCRUB_CLIENT_STATE"] = "true" if bool(scrub_client_state) else "false"
+ if open_project_first is not None:
+ env["BROWSER_AGENT_CHATGPT_OPEN_PROJECT_FIRST"] = "true" if bool(open_project_first) else "false"
+ if require_project is not None:
+ env["BROWSER_AGENT_CHATGPT_REQUIRE_PROJECT"] = "true" if bool(require_project) else "false"
+ if force_new_chat is not None:
+ env["BROWSER_AGENT_CHATGPT_FORCE_NEW_CHAT"] = "true" if bool(force_new_chat) else "false"
+ if require_isolated_conversation is not None:
+ env["BROWSER_AGENT_CHATGPT_REQUIRE_ISOLATED_CONVERSATION"] = (
+ "true" if bool(require_isolated_conversation) else "false"
+ )
+ if project_name:
+ env["BROWSER_AGENT_CHATGPT_PROJECT_NAME"] = str(project_name)
+ return env
+
+
+def submit_chatgpt_operator_request(
+ *,
+ cmd: list[str],
+ prompt: str,
+ timeout: int,
+ env: Mapping[str, str],
+ request_dir: str | Path,
+ expected: str,
+ use_session_control: bool = False,
+ poll_interval_seconds: float = 2.0,
+) -> dict[str, Any]:
+ request_path = Path(request_dir).expanduser()
+ request_path.mkdir(parents=True, exist_ok=True)
+ started = time.time()
+ if not use_session_control:
+ run = subprocess.run(
+ cmd,
+ input=prompt,
+ text=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ timeout=timeout,
+ env=dict(env),
+ )
+ output = strip_browser_agent_noise(run.stdout or "")
+ (request_path / "stdout.txt").write_text(output + ("\n" if output else ""), encoding="utf-8")
+ if run.returncode != 0:
+ raise RuntimeError(f"browser_agent_chatgpt failed rc={run.returncode}: {output[-2000:]}")
+ min_chars = 500 if expected == "json" else 1000
+ if len(output) < min_chars:
+ raise ValueError(f"browser_agent_chatgpt output too short: {len(output)} chars")
+ return {
+ "output": output,
+ "latency_ms": int((time.time() - started) * 1000),
+ }
+
+ submit_env = dict(env)
+ submit_env["CHATGPT_REPORT_ACTION"] = "submit"
+ submit_run = subprocess.run(
+ cmd,
+ input=prompt,
+ text=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ timeout=min(timeout, 120),
+ env=submit_env,
+ )
+ submit_output = strip_browser_agent_noise(submit_run.stdout or "")
+ (request_path / "submit-stdout.txt").write_text(submit_output + ("\n" if submit_output else ""), encoding="utf-8")
+ if submit_run.returncode != 0:
+ raise RuntimeError(f"browser_agent_chatgpt submit failed rc={submit_run.returncode}: {submit_output[-2000:]}")
+
+ submitted_path = request_path / "submitted-run.json"
+ task_id = ""
+ if submitted_path.exists():
+ try:
+ submitted_payload = json.loads(submitted_path.read_text(encoding="utf-8"))
+ if isinstance(submitted_payload, dict):
+ task_id = str(submitted_payload.get("task_id") or "").strip()
+ except Exception:
+ task_id = ""
+ if not task_id:
+ try:
+ parsed_submit = json.loads(submit_output)
+ if isinstance(parsed_submit, dict):
+ task_id = str(parsed_submit.get("task_id") or "").strip()
+ except Exception:
+ task_id = ""
+ if not task_id:
+ raise RuntimeError("browser_agent_chatgpt submit did not provide task_id")
+
+ poll_deadline = time.time() + max(1, timeout)
+ poll_attempts = 0
+ while time.time() <= poll_deadline:
+ status_payload = poll_request(task_id)
+ status = str(status_payload.get("status") or "").strip().lower()
+ (request_path / "poll-status.json").write_text(
+ json.dumps(status_payload, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ if status == "failed":
+ latest = status_payload.get("latest_result") if isinstance(status_payload.get("latest_result"), dict) else {}
+ raise RuntimeError(
+ "browser_agent_chatgpt session task failed: "
+ + str((latest or {}).get("error") or status_payload)
+ )
+ if status == "completed":
+ break
+ poll_attempts += 1
+ multiplier = min(max(poll_attempts, 1), 4)
+ if status == "submitted":
+ sleep_seconds = min(8.0, max(0.2, float(poll_interval_seconds)) * multiplier)
+ else:
+ sleep_seconds = min(12.0, max(0.2, float(poll_interval_seconds)) * max(2, multiplier))
+ time.sleep(sleep_seconds)
+ else:
+ raise TimeoutError(f"browser_agent_chatgpt session task timed out waiting for completion: task_id={task_id}")
+
+ collect_env = dict(env)
+ collect_env["CHATGPT_REPORT_ACTION"] = "collect"
+ collect_run = subprocess.run(
+ cmd,
+ input="",
+ text=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ timeout=min(timeout, 300),
+ env=collect_env,
+ )
+ output = strip_browser_agent_noise(collect_run.stdout or "")
+ (request_path / "stdout.txt").write_text(output + ("\n" if output else ""), encoding="utf-8")
+ if collect_run.returncode != 0:
+ raise RuntimeError(f"browser_agent_chatgpt collect failed rc={collect_run.returncode}: {output[-2000:]}")
+ min_chars = 500 if expected == "json" else 1000
+ if len(output) < min_chars:
+ raise ValueError(f"browser_agent_chatgpt output too short: {len(output)} chars")
+ return {
+ "output": output,
+ "latency_ms": int((time.time() - started) * 1000),
+ "task_id": task_id,
+ }
+
+
+def submit_gemini_operator_request(
+ *,
+ cmd: list[str],
+ prompt: str,
+ timeout: int,
+ env: Mapping[str, str],
+ request_dir: str | Path,
+) -> dict[str, Any]:
+ request_path = Path(request_dir).expanduser()
+ request_path.mkdir(parents=True, exist_ok=True)
+ started = time.time()
+ run = subprocess.run(
+ cmd,
+ input=prompt,
+ text=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ timeout=timeout,
+ env=dict(env),
+ )
+ output = strip_browser_agent_noise(run.stdout or "")
+ (request_path / "stdout.txt").write_text(output + ("\n" if output else ""), encoding="utf-8")
+ if run.returncode != 0:
+ raise RuntimeError(f"browser_agent_gemini failed rc={run.returncode}: {output[-2000:]}")
+ if len(output) < 500:
+ raise ValueError(f"browser_agent_gemini output too short: {len(output)} chars")
+ return {
+ "output": output,
+ "latency_ms": int((time.time() - started) * 1000),
+ }
+
+
+def submit_youtube_operator_request(
+ *,
+ cmd: list[str],
+ youtube_url: str,
+ timeout: int,
+ env: Mapping[str, str],
+ request_dir: str | Path,
+) -> dict[str, Any]:
+ request_path = Path(request_dir).expanduser()
+ request_path.mkdir(parents=True, exist_ok=True)
+ started = time.time()
+ run = subprocess.run(
+ cmd,
+ input=youtube_url,
+ text=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ timeout=timeout,
+ env=dict(env),
+ )
+ output = strip_browser_agent_noise(run.stdout or "")
+ (request_path / "stdout.txt").write_text(output + ("\n" if output else ""), encoding="utf-8")
+ if run.returncode != 0:
+ raise RuntimeError(f"browser_agent_youtube failed rc={run.returncode}: {output[-2000:]}")
+ if len(output) < 2:
+ raise ValueError(f"browser_agent_youtube output too short: {len(output)} chars")
+ return {
+ "output": output,
+ "latency_ms": int((time.time() - started) * 1000),
+ }
diff --git a/harness/lib/intent_consumer.py b/harness/lib/intent_consumer.py
index 3ed03bf95..763f070e0 100755
--- a/harness/lib/intent_consumer.py
+++ b/harness/lib/intent_consumer.py
@@ -143,6 +143,13 @@ def build_consumer_text(raw: dict[str, Any], rewritten: dict[str, Any], ir: dict
acceptance = rewritten.get("acceptance") or ir.get("acceptance") or []
title = str(rewritten.get("title") or ir.get("title") or "RawIntent")
research = extract_research_artifact(raw, ir)
+ source_inputs = ir.get("source_inputs") if isinstance(ir.get("source_inputs"), dict) else {}
+ enhanced_requirement = (
+ source_inputs.get("enhanced_requirement")
+ if isinstance(source_inputs.get("enhanced_requirement"), dict)
+ else None
+ )
+ enhanced_content = str((enhanced_requirement or {}).get("content") or "").strip()
lines = [
f"# RawIntent Consumer Request - {title}",
"",
@@ -183,6 +190,15 @@ def build_consumer_text(raw: dict[str, Any], rewritten: dict[str, Any], ir: dict
"Research artifact must remain a first-class source input for product-brief, PRD, and requirement_ir generation.",
"",
])
+ if enhanced_content:
+ lines.extend([
+ "## Enhanced Requirement Design",
+ "",
+ enhanced_content,
+ "",
+ "Requirement compiler should prefer the enhanced requirement design above as the compile input, while preserving raw user intent as provenance.",
+ "",
+ ])
lines.extend([
"## Raw User Intent",
"",
diff --git a/harness/lib/intent_gateway.py b/harness/lib/intent_gateway.py
index 5aeb909fd..383a3fdfc 100644
--- a/harness/lib/intent_gateway.py
+++ b/harness/lib/intent_gateway.py
@@ -25,6 +25,14 @@
SPRINTS_DIR = Path(os.environ.get("SOLAR_HARNESS_SPRINTS_DIR", Path.home() / ".solar" / "harness" / "sprints"))
INTENTS_DIR = Path(os.environ.get("SOLAR_INTENT_GATEWAY_DIR", Path.home() / ".solar" / "harness" / "intents"))
+DEFAULT_GPT_REQUIREMENT_WRITER_TRIGGER_PHRASES = (
+ "研究实现",
+ "分析论文并实现",
+ "调研并实现",
+ "研究并落地",
+ "先研究再实现",
+)
+
def now_iso() -> str:
return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -42,6 +50,55 @@ def write_json(path: Path, payload: dict[str, Any]) -> None:
os.replace(tmp, path)
+def _load_trigger_phrases_from_file(path_text: str) -> list[str]:
+ path = Path(path_text).expanduser()
+ data = json.loads(path.read_text(encoding="utf-8"))
+ if isinstance(data, dict):
+ phrases = data.get("phrases")
+ if isinstance(phrases, list):
+ return [str(item).strip() for item in phrases if str(item).strip()]
+ if isinstance(data, list):
+ return [str(item).strip() for item in data if str(item).strip()]
+ raise RuntimeError(f"invalid_requirement_writer_trigger_file:{path}")
+
+
+def load_requirement_writer_trigger_phrases() -> list[str]:
+ raw_file = str(os.environ.get("SOLAR_GPT_REQUIREMENT_WRITER_TRIGGER_FILE") or "").strip()
+ if raw_file:
+ return _load_trigger_phrases_from_file(raw_file)
+ raw = str(os.environ.get("SOLAR_GPT_REQUIREMENT_WRITER_TRIGGER_PHRASES") or "").strip()
+ if raw:
+ items = [item.strip() for item in re.split(r"[\n,|]+", raw) if item.strip()]
+ if items:
+ return items
+ return list(DEFAULT_GPT_REQUIREMENT_WRITER_TRIGGER_PHRASES)
+
+
+def parse_markdown_sections(markdown_text: str) -> list[dict[str, Any]]:
+ sections: list[dict[str, Any]] = []
+ current: dict[str, Any] | None = None
+ body_lines: list[str] = []
+ for line in str(markdown_text or "").splitlines():
+ match = re.match(r"^(#{1,6})\s+(.+?)\s*$", line)
+ if match:
+ if current is not None:
+ current["content"] = "\n".join(body_lines).strip()
+ sections.append(current)
+ heading = match.group(2).strip()
+ current = {
+ "level": len(match.group(1)),
+ "heading": heading,
+ "slug": slug(heading, 48).lower(),
+ }
+ body_lines = []
+ continue
+ body_lines.append(line)
+ if current is not None:
+ current["content"] = "\n".join(body_lines).strip()
+ sections.append(current)
+ return [section for section in sections if section.get("heading")]
+
+
def read_text_arg(args: argparse.Namespace) -> str:
parts: list[str] = []
if args.text:
@@ -71,6 +128,108 @@ def extract_research_artifact(args: argparse.Namespace) -> dict[str, Any] | None
}
+def requirement_writer_trigger(raw_text: str) -> dict[str, Any]:
+ clean = str(raw_text or "").strip()
+ phrases = load_requirement_writer_trigger_phrases()
+ for phrase in phrases:
+ if phrase in clean:
+ return {
+ "triggered": True,
+ "mode": "explicit_keyword",
+ "phrase": phrase,
+ "required": True,
+ "reason": f"matched:{phrase}",
+ "configured_phrases": phrases,
+ }
+ if (
+ any(token in clean for token in ("论文", "paper", "research", "调研", "研究"))
+ and any(token in clean for token in ("实现", "落地", "接入", "reproduce", "reproduction"))
+ ):
+ return {
+ "triggered": True,
+ "mode": "heuristic_research_implementation",
+ "phrase": "research+implementation",
+ "required": False,
+ "reason": "mixed_research_and_implementation_markers",
+ "configured_phrases": phrases,
+ }
+ return {
+ "triggered": False,
+ "mode": "off",
+ "phrase": "",
+ "required": False,
+ "reason": "no_trigger",
+ "configured_phrases": phrases,
+ }
+
+
+def _requirement_writer_cmd() -> list[str]:
+ raw = os.environ.get("SOLAR_GPT_REQUIREMENT_WRITER_CMD", "").strip()
+ if raw:
+ return shlex.split(raw)
+ return [sys.executable, str(HARNESS_DIR / "tools" / "chatgpt_requirement_writer_operator.py")]
+
+
+def invoke_requirement_writer(raw_intent: dict[str, Any], base: Path, *, trigger: dict[str, Any]) -> dict[str, Any]:
+ raw_block = raw_intent.get("raw") if isinstance(raw_intent.get("raw"), dict) else {}
+ raw_text = str((raw_block or {}).get("text") or "").strip()
+ if not raw_text:
+ raise RuntimeError("requirement_writer_missing_raw_text")
+ request_dir = base / "gpt_requirement_writer"
+ request_dir.mkdir(parents=True, exist_ok=True)
+ env = dict(os.environ)
+ env["SOLAR_RAW_REQUIREMENT"] = raw_text
+ env["SOLAR_RAW_INTENT_FILE"] = str(base / "raw_intent.json")
+ env["BROWSER_AGENT_RAW_INTENT_FILE"] = str(base / "raw_intent.json")
+ env["BROWSER_AGENT_REQUEST_DIR"] = str(request_dir)
+ env["BROWSER_AGENT_EXPECTED_OUTPUT"] = "markdown"
+ env["BROWSER_AGENT_PURPOSE"] = f"requirement-design:{trigger.get('mode') or 'unknown'}"
+ env["BROWSER_AGENT_SESSION_REUSE"] = env.get("BROWSER_AGENT_SESSION_REUSE") or "true"
+ env["SOLAR_BROWSER_SESSION_REUSE"] = env.get("SOLAR_BROWSER_SESSION_REUSE") or env["BROWSER_AGENT_SESSION_REUSE"]
+ lineage = f"gpt-requirement-writer:{base.name}"
+ env["BROWSER_AGENT_SESSION_LINEAGE"] = env.get("BROWSER_AGENT_SESSION_LINEAGE") or lineage
+ env["SOLAR_BROWSER_SESSION_LINEAGE"] = env.get("SOLAR_BROWSER_SESSION_LINEAGE") or env["BROWSER_AGENT_SESSION_LINEAGE"]
+ env["CHATGPT_REQUIREMENT_WRITER_ACTION"] = "run"
+ cmd = _requirement_writer_cmd()
+ proc = subprocess.run(
+ cmd,
+ input=raw_text,
+ text=True,
+ capture_output=True,
+ timeout=int(os.environ.get("SOLAR_GPT_REQUIREMENT_WRITER_TIMEOUT_SEC", "2400") or "2400"),
+ env=env,
+ )
+ stdout = (proc.stdout or "").strip()
+ stderr = (proc.stderr or "").strip()
+ if proc.returncode != 0 or not stdout:
+ raise RuntimeError(
+ "requirement_writer_failed:"
+ f"exit={proc.returncode}:trigger={trigger.get('mode')}:{stderr or stdout or 'no_output'}"
+ )
+ output_md = base / "gpt_requirement_writer_output.md"
+ output_md.write_text(stdout.rstrip() + "\n", encoding="utf-8")
+ sections = parse_markdown_sections(stdout)
+ report = {
+ "ok": True,
+ "operator": "GPTRequirementWriter",
+ "trigger": trigger,
+ "request_dir": str(request_dir),
+ "output_markdown": str(output_md),
+ "stdout_length": len(stdout),
+ "sections": sections,
+ }
+ write_json(base / "gpt_requirement_writer_output.json", report)
+ return {
+ "ok": True,
+ "trigger": trigger,
+ "markdown": stdout,
+ "output_markdown": str(output_md),
+ "request_dir": str(request_dir),
+ "report_json": str(base / "gpt_requirement_writer_output.json"),
+ "sections": sections,
+ }
+
+
def infer_mode(text: str) -> str:
value = text.lower()
# Engineering intents can contain words like "research" or "Deep Research"
@@ -131,6 +290,69 @@ def deterministic_rewrite(raw_text: str) -> dict[str, Any]:
}
+def _title_from_requirement_writer(markdown_text: str, fallback: str) -> str:
+ for line in str(markdown_text or "").splitlines():
+ cleaned = re.sub(r"^#+\s*", "", line).strip()
+ if cleaned:
+ return re.sub(r"\s+", " ", cleaned)[:90]
+ return fallback
+
+
+def rewrite_from_requirement_writer(
+ raw_text: str,
+ enhanced_markdown: str,
+ *,
+ trigger: dict[str, Any],
+ model_rewrite_meta: dict[str, Any],
+) -> dict[str, Any]:
+ title = _title_from_requirement_writer(
+ enhanced_markdown,
+ re.sub(r"\s+", " ", raw_text.strip())[:90] or "Untitled Intent",
+ )
+ mode = infer_mode(raw_text)
+ constraints: list[str] = [
+ "All execution must enter Solar-Harness through RawIntent and requirement compilation.",
+ "Do not bypass task_graph, operator runtime, quota-aware fallback, or evidence logging.",
+ "Compiled package must preserve the original raw user requirement as provenance.",
+ "Requirement compiler must prioritize GPTRequirementWriter enhanced design when present.",
+ ]
+ if mode == "research":
+ constraints.append("Claims require source/evidence artifacts before final closeout.")
+ acceptance = [
+ "RawIntent, rewritten_intent, requirement_ir, requirement_trace, and GPTRequirementWriter artifacts are persisted.",
+ "Compiled work is routable through PM/Planner/task_graph and multi-task operator runtime.",
+ "Requirement compiler uses chaptered enhanced requirement design as compile input while retaining raw provenance.",
+ ]
+ return {
+ "schema_version": "solar.rewritten_intent.v1",
+ "rewrite_method": "gpt_requirement_writer",
+ "title": title,
+ "problem": raw_text.strip(),
+ "objective": title,
+ "outcome": "A compiled, dispatchable Solar-Harness work item with an enhanced chaptered requirement design.",
+ "constraints": constraints,
+ "non_goals": ["Do not dispatch raw natural language directly to builder panes."],
+ "acceptance": acceptance,
+ "suggested_lane": mode if mode != "delivery" else "strategy",
+ "suggested_logical_operators": [
+ "GPTRequirementWriter",
+ "Planner",
+ "ImplementationWorker",
+ "Verifier",
+ ],
+ "enhanced_requirement_markdown": enhanced_markdown,
+ "requirement_enhancement": {
+ "triggered": True,
+ "trigger_mode": trigger.get("mode") or "unknown",
+ "trigger_phrase": trigger.get("phrase") or "",
+ "required": bool(trigger.get("required")),
+ "operator": "GPTRequirementWriter",
+ "configured_phrases": trigger.get("configured_phrases") or [],
+ },
+ "model_rewrite": model_rewrite_meta,
+ }
+
+
def model_rewrite(raw_intent: dict[str, Any], prompt_path: Path) -> tuple[dict[str, Any] | None, dict[str, Any]]:
cmd = os.environ.get("SOLAR_INTENT_REWRITE_CMD", "").strip()
if not cmd:
@@ -176,7 +398,12 @@ def model_rewrite(raw_intent: dict[str, Any], prompt_path: Path) -> tuple[dict[s
return fallback, meta
-def build_requirement_ir(intent_id: str, raw_intent: dict[str, Any], rewritten: dict[str, Any]) -> dict[str, Any]:
+def build_requirement_ir(
+ intent_id: str,
+ raw_intent: dict[str, Any],
+ rewritten: dict[str, Any],
+ enhancement: dict[str, Any] | None = None,
+) -> dict[str, Any]:
context = raw_intent.get("context", {}) if isinstance(raw_intent.get("context"), dict) else {}
raw_block = raw_intent.get("raw", {}) if isinstance(raw_intent.get("raw"), dict) else {}
research = raw_intent.get("research") if isinstance(raw_intent.get("research"), dict) else None
@@ -191,6 +418,27 @@ def build_requirement_ir(intent_id: str, raw_intent: dict[str, Any], rewritten:
"conversation_id": research.get("conversation_id", ""),
"source_url": research.get("source_url", ""),
}
+ if enhancement and enhancement.get("ok"):
+ enhanced_sections = enhancement.get("sections") if isinstance(enhancement.get("sections"), list) else []
+ source_inputs["enhanced_requirement"] = {
+ "operator": "GPTRequirementWriter",
+ "trigger": enhancement.get("trigger") or {},
+ "markdown_path": enhancement.get("output_markdown") or "",
+ "report_json": enhancement.get("report_json") or "",
+ "request_dir": enhancement.get("request_dir") or "",
+ "content": enhancement.get("markdown") or "",
+ "sections": enhanced_sections,
+ "compile_segments": [
+ {
+ "heading": str(section.get("heading") or ""),
+ "level": int(section.get("level") or 0),
+ "text": (
+ f"{section.get('heading')}\n{section.get('content')}".strip()
+ ),
+ }
+ for section in enhanced_sections
+ ],
+ }
return {
"schema_version": "solar.requirement_ir.v1",
"intent_id": intent_id,
@@ -206,6 +454,10 @@ def build_requirement_ir(intent_id: str, raw_intent: dict[str, Any], rewritten:
"lane": rewritten.get("suggested_lane", "delivery"),
"logical_operators": rewritten.get("suggested_logical_operators", []),
"compiler_next": "pm_planner_task_graph",
+ "requirement_enhancement": rewritten.get("requirement_enhancement") or {
+ "triggered": False,
+ "operator": "",
+ },
}
@@ -250,14 +502,38 @@ def capture(args: argparse.Namespace) -> dict[str, Any]:
"contains_secrets": "unknown",
},
}
+ base = INTENTS_DIR / intent_id
if research:
raw_intent["research"] = research
- base = INTENTS_DIR / intent_id
+ enhancement_trigger = requirement_writer_trigger(raw_text)
+ raw_intent["routing_hints"]["requirement_enhancement"] = enhancement_trigger
+ write_json(base / "raw_intent.json", raw_intent)
+ enhancement: dict[str, Any] | None = None
+ if enhancement_trigger.get("triggered"):
+ try:
+ enhancement = invoke_requirement_writer(raw_intent, base, trigger=enhancement_trigger)
+ except RuntimeError as exc:
+ if enhancement_trigger.get("required"):
+ raise SystemExit(f"intent-gateway requirement enhancement failed: {exc}")
+ enhancement = {
+ "ok": False,
+ "trigger": enhancement_trigger,
+ "error": str(exc),
+ }
+ write_json(base / "gpt_requirement_writer_output.json", enhancement)
model_result, rewrite_meta = model_rewrite(raw_intent, base / "rewrite_prompt.json")
- rewritten = model_result or deterministic_rewrite(raw_text)
+ if enhancement and enhancement.get("ok"):
+ rewritten = rewrite_from_requirement_writer(
+ raw_text,
+ str(enhancement.get("markdown") or ""),
+ trigger=enhancement_trigger,
+ model_rewrite_meta=rewrite_meta,
+ )
+ else:
+ rewritten = model_result or deterministic_rewrite(raw_text)
rewritten["intent_id"] = intent_id
rewritten["model_rewrite"] = rewrite_meta
- requirement_ir = build_requirement_ir(intent_id, raw_intent, rewritten)
+ requirement_ir = build_requirement_ir(intent_id, raw_intent, rewritten, enhancement=enhancement)
trace = {
"schema_version": "solar.requirement_trace.v1",
"intent_id": intent_id,
@@ -269,11 +545,15 @@ def capture(args: argparse.Namespace) -> dict[str, Any]:
},
"stages": [
{"stage": "raw_intent_capture", "status": "ok"},
+ {
+ "stage": "requirement_enhancement",
+ "status": "ok" if enhancement and enhancement.get("ok") else ("skipped" if not enhancement_trigger.get("triggered") else "warn"),
+ "method": (enhancement_trigger.get("mode") if enhancement_trigger.get("triggered") else "not_triggered"),
+ },
{"stage": "intent_rewrite", "status": "ok", "method": rewritten.get("rewrite_method")},
{"stage": "requirement_ir_compile", "status": "ok"},
],
}
- write_json(base / "raw_intent.json", raw_intent)
write_json(base / "rewritten_intent.json", rewritten)
write_json(base / "requirement_ir.json", requirement_ir)
write_json(base / "requirement_trace.json", trace)
@@ -289,6 +569,7 @@ def capture(args: argparse.Namespace) -> dict[str, Any]:
"rewritten_intent": str(base / "rewritten_intent.json"),
"requirement_ir": str(base / "requirement_ir.json"),
"requirement_trace": str(base / "requirement_trace.json"),
+ "requirement_enhancement": enhancement or {"ok": False, "trigger": enhancement_trigger},
}
@@ -307,6 +588,21 @@ def bind_intent_artifacts(intent_id: str, sprint_id: str) -> dict[str, Any]:
if isinstance(payload, dict):
payload["sprint_id"] = sprint_id
write_json(dst, payload)
+ optional_copies = {
+ "gpt_requirement_writer_output.json": SPRINTS_DIR / f"{sprint_id}.gpt_requirement_writer_output.json",
+ "gpt_requirement_writer_output.md": SPRINTS_DIR / f"{sprint_id}.gpt_requirement_writer_output.md",
+ }
+ for name, dst in optional_copies.items():
+ src = base / name
+ if not src.exists():
+ continue
+ if src.suffix == ".json":
+ payload = json.loads(src.read_text(encoding="utf-8"))
+ if isinstance(payload, dict):
+ payload["sprint_id"] = sprint_id
+ write_json(dst, payload)
+ else:
+ dst.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
manifest = {"ok": True, "intent_id": intent_id, "sprint_id": sprint_id, "artifacts": {k: str(v) for k, v in mapping.items()}}
write_json(base / "binding.json", manifest)
return manifest
diff --git a/harness/lib/logical_operator_router.py b/harness/lib/logical_operator_router.py
index d823f79ee..2a8305d37 100644
--- a/harness/lib/logical_operator_router.py
+++ b/harness/lib/logical_operator_router.py
@@ -6,13 +6,15 @@
from __future__ import annotations
import json
+import os
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
HOME = Path.home()
-HARNESS_DIR = Path.home() / ".solar" / "harness"
-LOGICAL_OPS_PATH = HARNESS_DIR / "config" / "logical-operators.json"
-ACTORS_PATH = HARNESS_DIR / "config" / "agent-actors.json"
+
+
+def _default_harness_dir() -> Path:
+ return Path(os.environ.get("HARNESS_DIR", HOME / ".solar" / "harness")).expanduser()
P0_LOGICAL_OPERATORS = frozenset([
"DeepArchitect", "RootCauseDebugger", "ImplementationWorker", "PatchWorker",
@@ -30,8 +32,9 @@ def __init__(
bindings_path: Optional[Path] = None,
actors_path: Optional[Path] = None,
):
- self.bindings_path = bindings_path or LOGICAL_OPS_PATH
- self.actors_path = actors_path or ACTORS_PATH
+ harness_dir = _default_harness_dir()
+ self.bindings_path = bindings_path or (harness_dir / "config" / "logical-operators.json")
+ self.actors_path = actors_path or (harness_dir / "config" / "agent-actors.json")
self._bindings: Dict[str, Dict[str, Any]] = {}
self._actors: Dict[str, Dict[str, Any]] = {}
self._load()
diff --git a/harness/scripts/browser_agent_chatgpt_wrapper.py b/harness/scripts/browser_agent_chatgpt_wrapper.py
index 06ade0e36..8aec2bfa3 100755
--- a/harness/scripts/browser_agent_chatgpt_wrapper.py
+++ b/harness/scripts/browser_agent_chatgpt_wrapper.py
@@ -6,11 +6,16 @@
import json
import logging
import os
+import re
+import signal
import subprocess
import shutil
import sys
import time
+from datetime import datetime, timezone
from pathlib import Path
+from typing import NoReturn
+from urllib.parse import unquote, urlparse
ROOT = Path(__file__).resolve().parents[1]
LIB = ROOT / "lib"
@@ -19,8 +24,10 @@
import browser_job_runtime as bjrt
from browser import runtime_control as brtc
+from browser.profile_registry import ProfileRegistry
from browser_use.browser.profile import BrowserProfile
from browser_use.browser.session import BrowserSession
+from browser_use.browser.watchdogs.local_browser_watchdog import LocalBrowserWatchdog
DEFAULT_URL = "https://chatgpt.com/"
@@ -29,6 +36,8 @@
DEFAULT_BROWSER_CHANNEL = "chrome"
DEFAULT_CHROME_EXECUTABLE = Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome")
DEFAULT_ALLOWED_DOMAINS = ["chatgpt.com", "auth.openai.com", "challenges.cloudflare.com"]
+NOTIFY_SCRIPT = ROOT / "osascript-notify.sh"
+_BROWSER_USE_CDP_PATCHED = False
def _env_flag(*names: str, default: bool = False) -> bool:
@@ -40,6 +49,62 @@ def _env_flag(*names: str, default: bool = False) -> bool:
return default
+def _force_wrapper_exit(code: int) -> NoReturn:
+ try:
+ sys.stdout.flush()
+ sys.stderr.flush()
+ finally:
+ os._exit(code)
+
+
+def _finalize_runtime_success(
+ *,
+ control_ctx: dict,
+ browser,
+ headless: bool,
+ reused_existing_session: bool,
+ runtime_staged_dir,
+ runtime_cleanup_dir,
+ request_dir: Path,
+ action: str,
+ final_page_state: dict | None,
+ keep_session_alive: bool,
+) -> None:
+ current_cdp_port = _remote_debugging_port_from_cdp_url(str(getattr(browser, "cdp_url", "") or ""))
+ _reap_orphan_browser_use_chrome_processes(
+ protected_ports={current_cdp_port} if current_cdp_port else set()
+ )
+ if keep_session_alive:
+ brtc.activate_reusable_session(
+ control_ctx,
+ cdp_url=str(getattr(browser, "cdp_url", "") or ""),
+ browser_session_ref=f"browser-use-session://chatgpt/{control_ctx['profile_id']}",
+ headless=headless,
+ attached=reused_existing_session,
+ details={
+ "request_dir": str(request_dir),
+ "staged_user_data_dir": str(runtime_staged_dir or ""),
+ "cleanup_dir": str(runtime_cleanup_dir or ""),
+ },
+ )
+ else:
+ brtc.clear_active_session(control_ctx)
+ brtc.finalize_runtime_contract(
+ control_ctx,
+ success=True,
+ error_text="",
+ page_state=final_page_state,
+ logged_in_state_verified=True,
+ details={
+ "provider": "browser_agent_chatgpt",
+ "action": action,
+ "request_dir": str(request_dir),
+ "forced_exit": True,
+ },
+ requires_precise_page_control=False,
+ )
+
+
def _headed_run_allowed() -> bool:
return _env_flag(
"BROWSER_AGENT_CHATGPT_ALLOW_HEADED",
@@ -50,12 +115,7 @@ def _headed_run_allowed() -> bool:
def _browser_channel() -> str:
- value = str(
- os.environ.get("BROWSER_AGENT_CHATGPT_BROWSER_CHANNEL")
- or os.environ.get("BROWSER_AGENT_BROWSER_CHANNEL")
- or DEFAULT_BROWSER_CHANNEL
- ).strip().lower()
- return value or DEFAULT_BROWSER_CHANNEL
+ return DEFAULT_BROWSER_CHANNEL
def _system_chrome_version() -> str:
@@ -96,6 +156,100 @@ def _browser_user_agent(*, browser_channel: str) -> str:
return _build_mac_chrome_user_agent("148.0.0.0")
+def _is_cdp_connect_failure(exc: Exception) -> bool:
+ text = f"{type(exc).__name__}: {exc}".lower()
+ markers = (
+ "failed to establish cdp connection",
+ "failed to setup cdp connection",
+ "connect call failed",
+ "root cdp client not initialized",
+ )
+ return any(marker in text for marker in markers)
+
+
+async def _wait_for_cdp_websocket_ready(ws_url: str, *, timeout: float = 5.0) -> None:
+ parsed = urlparse(str(ws_url or "").strip())
+ host = parsed.hostname or "127.0.0.1"
+ port = parsed.port or (443 if parsed.scheme == "wss" else 80)
+ deadline = time.monotonic() + max(0.5, timeout)
+ stable_hits = 0
+ last_error: Exception | None = None
+ while time.monotonic() < deadline:
+ writer = None
+ try:
+ _, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout=0.5)
+ stable_hits += 1
+ if stable_hits >= 2:
+ return
+ await asyncio.sleep(0.1)
+ except Exception as exc:
+ last_error = exc
+ stable_hits = 0
+ await asyncio.sleep(0.1)
+ finally:
+ if writer is not None:
+ writer.close()
+ try:
+ await writer.wait_closed()
+ except Exception:
+ pass
+ detail = f"{type(last_error).__name__}: {last_error}" if last_error else "unknown"
+ raise RuntimeError(f"CDP websocket endpoint not ready at {host}:{port} ({detail})")
+
+
+async def _patched_wait_for_cdp_url(port: int, timeout: float = 30) -> str:
+ import aiohttp
+
+ start_time = time.monotonic()
+ last_error: Exception | None = None
+ last_status: int | None = None
+ while time.monotonic() - start_time < timeout:
+ try:
+ async with aiohttp.ClientSession() as session:
+ async with session.get(f"http://127.0.0.1:{port}/json/version") as resp:
+ last_status = resp.status
+ if resp.status != 200:
+ await asyncio.sleep(0.1)
+ continue
+ payload = await resp.json()
+ ws_url = str(payload.get("webSocketDebuggerUrl") or "").strip()
+ if not ws_url:
+ raise RuntimeError(f"Missing webSocketDebuggerUrl in /json/version payload for port {port}")
+ remaining = max(0.5, timeout - (time.monotonic() - start_time))
+ await _wait_for_cdp_websocket_ready(ws_url, timeout=min(5.0, remaining))
+ return f"http://127.0.0.1:{port}/"
+ except Exception as exc:
+ last_error = exc
+ await asyncio.sleep(0.1)
+ status_text = f" last_status={last_status}" if last_status is not None else ""
+ detail = f"{type(last_error).__name__}: {last_error}" if last_error else "unknown"
+ raise TimeoutError(f"Browser did not expose a stable CDP endpoint within {timeout} seconds ({detail}{status_text})")
+
+
+async def on_BrowserStopEvent(self, event) -> None:
+ browser_profile = getattr(getattr(self, "browser_session", None), "browser_profile", None)
+ keep_alive = bool(getattr(browser_profile, "keep_alive", False))
+ if keep_alive and not bool(getattr(event, "force", False)):
+ return
+ original = getattr(self, "_solar_original_on_BrowserStopEvent", None)
+ if callable(original):
+ await original(event)
+
+
+def _install_browser_use_cdp_patch() -> None:
+ global _BROWSER_USE_CDP_PATCHED
+ if _BROWSER_USE_CDP_PATCHED:
+ return
+ setattr(LocalBrowserWatchdog, "_solar_original_on_BrowserStopEvent", getattr(LocalBrowserWatchdog, "on_BrowserStopEvent", None))
+ LocalBrowserWatchdog._wait_for_cdp_url = staticmethod(_patched_wait_for_cdp_url)
+ LocalBrowserWatchdog.on_BrowserStopEvent = on_BrowserStopEvent
+ setattr(LocalBrowserWatchdog, "_solar_cdp_patch_installed", True)
+ _BROWSER_USE_CDP_PATCHED = True
+
+
+_install_browser_use_cdp_patch()
+
+
def _challenge_grace_seconds() -> float:
raw = str(
os.environ.get("BROWSER_AGENT_CHATGPT_CHALLENGE_GRACE_SECONDS")
@@ -115,6 +269,103 @@ def _challenge_persisted_too_long(challenge_since: float | None, *, now: float |
deadline = challenge_since + (grace_s if grace_s is not None else _challenge_grace_seconds())
return (now if now is not None else time.time()) >= deadline
+
+def _is_generic_chatgpt_root(url: str) -> bool:
+ normalized = str(url or "").strip().rstrip("/")
+ return normalized in {"https://chatgpt.com", "https://chat.openai.com"}
+
+
+def _conversation_target_id(url: str) -> str:
+ match = re.search(r"/c/([^/?#]+)", str(url or ""))
+ return unquote(match.group(1)) if match else ""
+
+
+def _conversation_state_ready(data: dict, *, expected_conversation_id: str = "") -> bool:
+ conversation_id = str((data or {}).get("conversation_id") or "").strip()
+ if expected_conversation_id and conversation_id != expected_conversation_id:
+ return False
+ message_count = int((data or {}).get("message_count") or 0)
+ assistant_count = int((data or {}).get("assistant_count") or 0)
+ latest_assistant_text = str((data or {}).get("latest_assistant_text") or "").strip()
+ if bool((data or {}).get("is_generating")) and (
+ message_count > 0 or assistant_count > 0 or latest_assistant_text
+ ):
+ return True
+ if latest_assistant_text:
+ return True
+ return message_count > 0
+
+
+async def _wait_for_submitted_conversation(page, initial_state: dict, *, timeout_s: int = 15) -> dict:
+ best = dict(initial_state or {})
+ deadline = time.monotonic() + max(1, int(timeout_s))
+ while time.monotonic() < deadline:
+ state = await _capture_state(page, timeout_s=8.0, default=best, label="submitted_conversation")
+ if state:
+ best = state
+ conversation_id = str((state or {}).get("conversation_id") or "").strip()
+ current_url = str((state or {}).get("url") or "").strip()
+ if conversation_id or (current_url and not _is_generic_chatgpt_root(current_url)):
+ return state
+ await asyncio.sleep(1.0)
+ return best
+
+
+async def _wait_for_conversation_ready(page, *, target_url: str, timeout_s: int = 12) -> dict:
+ expected_conversation_id = _conversation_target_id(target_url)
+ last_data = await _capture_state(page, timeout_s=8.0, default={}, label="conversation_ready_initial")
+ if not expected_conversation_id:
+ return last_data
+ deadline = time.time() + max(1, int(timeout_s))
+ while time.time() < deadline:
+ data = await _capture_state(page, timeout_s=8.0, default=last_data, label="conversation_ready")
+ last_data = data
+ if _conversation_state_ready(data, expected_conversation_id=expected_conversation_id):
+ return data
+ await asyncio.sleep(1.0)
+ return last_data
+
+
+async def _find_existing_conversation_page(browser, *, target_url: str):
+ expected_conversation_id = _conversation_target_id(target_url)
+ if not expected_conversation_id:
+ return None
+ try:
+ pages = await asyncio.wait_for(browser.get_pages(), timeout=10)
+ except Exception:
+ return None
+ for page in pages:
+ try:
+ state = await _capture_state(page, timeout_s=5.0, default={}, label="find_existing_conversation_page")
+ except Exception:
+ continue
+ conversation_id = str((state or {}).get("conversation_id") or "").strip()
+ page_url = str((state or {}).get("url") or "").strip()
+ canonical_url = str((state or {}).get("canonical_url") or "").strip()
+ if conversation_id == expected_conversation_id:
+ return page
+ if expected_conversation_id and (
+ expected_conversation_id in page_url
+ or expected_conversation_id in canonical_url
+ ):
+ return page
+ return None
+
+
+async def _capture_state(page, *, timeout_s: float = 8.0, default: dict | None = None, label: str = "capture") -> dict:
+ fallback = dict(default or {})
+ try:
+ raw = await asyncio.wait_for(page.evaluate(CAPTURE_JS), timeout=max(1.0, float(timeout_s)))
+ except asyncio.TimeoutError:
+ fallback["_capture_timeout"] = label
+ return fallback
+ try:
+ data = json.loads(raw)
+ except Exception:
+ fallback["_capture_decode_error"] = label
+ return fallback
+ return data if isinstance(data, dict) else fallback
+
CAPTURE_JS = r"""() => {
const clean = (value) => String(value || "")
.replace(/\u00a0/g, " ")
@@ -286,6 +537,45 @@ def _challenge_persisted_too_long(challenge_since: float | None, *, now: float |
const style = window.getComputedStyle(el);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
};
+ const isSendCandidate = (button) => {
+ if (!button || !visible(button)) return false;
+ const label = String(button.getAttribute("aria-label") || button.textContent || "").trim();
+ if (/语音|voice|stop|停止|cancel|中止/i.test(label)) return false;
+ const disabled = button.disabled || button.getAttribute("aria-disabled") === "true";
+ return !disabled;
+ };
+ const clickButton = (button, selector) => {
+ if (!isSendCandidate(button)) return null;
+ const label = String(button.getAttribute("aria-label") || button.textContent || "").trim();
+ button.click();
+ return JSON.stringify({ ok: true, selector, label });
+ };
+ const composer = document.querySelector("#prompt-textarea, div[contenteditable='true'][role='textbox'], textarea[name='prompt-textarea'], textarea");
+ if (composer) {
+ const form = composer.closest("form");
+ if (form) {
+ const localCandidates = [
+ "button[type='submit']",
+ "button[data-testid='send-button']",
+ "button[data-testid='composer-send-button']",
+ "button[aria-label*='Send']",
+ "button[aria-label*='send']",
+ "button[aria-label*='发送']",
+ "button.composer-submit-button-color[type='button']",
+ "button.composer-submit-button-color",
+ ];
+ for (const selector of localCandidates) {
+ const buttons = Array.from(form.querySelectorAll(selector));
+ for (const button of buttons) {
+ const result = clickButton(button, `form ${selector}`);
+ if (result) return result;
+ }
+ }
+ const localSubmit = form.querySelector("button[type='submit'], input[type='submit']");
+ const localResult = clickButton(localSubmit, "form direct_submit");
+ if (localResult) return localResult;
+ }
+ }
const candidates = [
"form button[type='submit']",
"button[type='submit']",
@@ -300,19 +590,43 @@ def _challenge_persisted_too_long(challenge_since: float | None, *, now: float |
for (const selector of candidates) {
const buttons = Array.from(document.querySelectorAll(selector));
for (const button of buttons) {
- if (!visible(button)) continue;
- const label = String(button.getAttribute("aria-label") || button.textContent || "").trim();
- if (/语音|voice|stop|停止|cancel|中止/i.test(label)) continue;
- const disabled = button.disabled || button.getAttribute("aria-disabled") === "true";
- if (disabled) continue;
- button.click();
- return JSON.stringify({ ok: true, selector, label });
+ const result = clickButton(button, selector);
+ if (result) return result;
}
}
- const composer = document.querySelector("#prompt-textarea, div[contenteditable='true'][role='textbox'], textarea[name='prompt-textarea']");
return JSON.stringify({ ok: false, error: "submit_button_not_found" });
}"""
+SUBMIT_FALLBACK_JS = r"""() => {
+ const composer = document.querySelector("#prompt-textarea, div[contenteditable='true'][role='textbox'], textarea[name='prompt-textarea'], textarea");
+ if (!composer) return JSON.stringify({ ok: false, error: "composer_not_found" });
+ const value = String(composer.value || composer.innerText || composer.textContent || "").trim();
+ if (!value) return JSON.stringify({ ok: false, error: "composer_empty" });
+ composer.focus();
+ composer.dispatchEvent(new Event("input", { bubbles: true }));
+ composer.dispatchEvent(new Event("change", { bubbles: true }));
+ const form = composer.closest("form");
+ if (form && typeof form.requestSubmit === "function") {
+ form.requestSubmit();
+ return JSON.stringify({ ok: true, mode: "form_request_submit" });
+ }
+ if (form) {
+ const event = new Event("submit", { bubbles: true, cancelable: true });
+ form.dispatchEvent(event);
+ return JSON.stringify({ ok: true, mode: "form_submit_event", default_prevented: event.defaultPrevented });
+ }
+ for (const type of ["keydown", "keypress", "keyup"]) {
+ composer.dispatchEvent(new KeyboardEvent(type, {
+ bubbles: true,
+ cancelable: true,
+ key: "Enter",
+ code: "Enter",
+ metaKey: true,
+ }));
+ }
+ return JSON.stringify({ ok: true, mode: "composer_meta_enter_dispatch" });
+}"""
+
HTML_JS = r"""() => document.documentElement.outerHTML"""
TEXT_JS = r"""() => (document.body && (document.body.innerText || document.body.textContent) || "").trim()"""
@@ -387,22 +701,83 @@ def _challenge_persisted_too_long(challenge_since: float | None, *, now: float |
const style = window.getComputedStyle(el);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
};
- const nodes = Array.from(document.querySelectorAll("a,button,[role='button'],div"));
- const project = nodes.find((el) => visible(el) && clean(el.innerText || el.textContent || "") === target);
- if (!project) {
- const expanders = nodes.filter((el) => {
- if (!visible(el)) return false;
- const text = clean(el.innerText || el.textContent || "");
- const aria = clean(el.getAttribute("aria-label") || "");
- return /^(更多|More)$/.test(text) || /(show more|更多|展开|projects|项目)/i.test(aria);
- }).slice(0, 5);
- for (const item of expanders) {
- try { item.click(); } catch (_) {}
+ const textOf = (el) => clean(el.innerText || el.textContent || "");
+ const allNodes = () => Array.from(document.querySelectorAll("a,button,[role='button'],[role='treeitem'],[role='menuitem'],div"));
+ const clickIfFound = (nodes, predicate) => {
+ const item = nodes.find((el) => visible(el) && predicate(el, textOf(el), clean(el.getAttribute("aria-label") || "")));
+ if (!item) return null;
+ item.click();
+ return { text: textOf(item), aria: clean(item.getAttribute("aria-label") || ""), tag: item.tagName };
+ };
+ const sidebarToggle = clickIfFound(allNodes(), (_el, text, aria) => /^(打开边栏|Open sidebar)$/.test(aria) || /^(打开边栏|Open sidebar)$/.test(text));
+ const roots = Array.from(document.querySelectorAll("nav,aside,section,[data-testid*='sidebar'],[aria-label*='sidebar'],[aria-label*='侧边栏']"))
+ .filter((el) => visible(el));
+ const rootCandidates = roots.length ? roots : [document.body];
+ const collectSearchRoots = () => {
+ const out = [];
+ for (const root of rootCandidates) {
+ out.push(root);
+ const sectionHeaders = Array.from(root.querySelectorAll("div,button,a,[role='button'],[role='treeitem'],h2,h3,h4"))
+ .filter((el) => visible(el) && /^(项目|Projects?)$/.test(textOf(el)));
+ for (const header of sectionHeaders) {
+ const container = header.closest("section,nav,aside,div,li") || header.parentElement;
+ if (container) out.push(container);
+ if (header.parentElement) out.push(header.parentElement);
+ if (container && container.nextElementSibling) out.push(container.nextElementSibling);
+ if (header.nextElementSibling) out.push(header.nextElementSibling);
+ }
+ }
+ return Array.from(new Set(out.filter(Boolean)));
+ };
+ const searchRoots = collectSearchRoots();
+ const openProjectGroup = () => {
+ for (const root of searchRoots) {
+ const nodes = Array.from(root.querySelectorAll("button,a,[role='button'],[role='treeitem'],div")).filter((el) => visible(el));
+ const expander = nodes.find((el) => {
+ const text = textOf(el);
+ const aria = clean(el.getAttribute("aria-label") || "");
+ return /^(项目|Projects?)$/.test(text) || /(projects?|项目)/i.test(aria);
+ });
+ if (expander) {
+ try { expander.click(); } catch (_) {}
+ }
+ }
+ };
+ openProjectGroup();
+ const findProject = () => {
+ for (const root of searchRoots) {
+ const nodes = Array.from(root.querySelectorAll("a,button,[role='button'],[role='treeitem'],div")).filter((el) => visible(el));
+ const exact = nodes.find((el) => textOf(el) === target);
+ if (exact) return exact;
}
- return JSON.stringify({ ok: false, step: "open_project", error: "project_not_found", project_name: target });
+ const nodes = allNodes();
+ return nodes.find((el) => visible(el) && textOf(el) === target) || null;
+ };
+ const project = findProject();
+ if (!project) {
+ const candidates = searchRoots
+ .flatMap((root) => Array.from(root.querySelectorAll("a,button,[role='button'],[role='treeitem'],div")))
+ .filter((el) => visible(el))
+ .map((el) => textOf(el))
+ .filter(Boolean)
+ .slice(0, 120);
+ return JSON.stringify({
+ ok: false,
+ step: "open_project",
+ error: "project_not_found",
+ project_name: target,
+ sidebar_toggle_clicked: sidebarToggle,
+ candidates,
+ });
}
project.click();
- return JSON.stringify({ ok: true, step: "open_project", project_name: target });
+ return JSON.stringify({
+ ok: true,
+ step: "open_project",
+ project_name: target,
+ clicked: textOf(project),
+ sidebar_toggle_clicked: sidebarToggle,
+ });
}"""
NEW_CHAT_JS = r"""() => {
@@ -715,6 +1090,138 @@ def _write_json(path: Path, payload: object) -> None:
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+def _read_json(path: Path) -> dict[str, object]:
+ if not path.exists():
+ return {}
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return {}
+ return data if isinstance(data, dict) else {}
+
+
+def _pid_alive(pid: int | None) -> bool:
+ if not pid or int(pid) <= 0:
+ return False
+ try:
+ os.kill(int(pid), 0)
+ except OSError:
+ return False
+ return True
+
+
+def _now_iso() -> str:
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+def _completion_signal_path(request_dir: Path) -> Path:
+ return request_dir / "completion-sentinel.json"
+
+
+def _completion_notify_marker_path(request_dir: Path) -> Path:
+ return request_dir / "completion-notify.json"
+
+
+def _notify_completion_ready(request_dir: Path, *, conversation_id: str, latest_text: str) -> None:
+ if not NOTIFY_SCRIPT.exists():
+ return
+ marker_path = _completion_notify_marker_path(request_dir)
+ marker = _read_json(marker_path)
+ if (
+ str(marker.get("status") or "").strip().lower() == "notified"
+ and str(marker.get("conversation_id") or "").strip()
+ and str(marker.get("conversation_id") or "").strip() == str(conversation_id or "").strip()
+ ):
+ return
+ message = f"{request_dir.name} 可以取结果了"
+ if conversation_id:
+ message = f"{message} ({conversation_id[:12]})"
+ snippet = str(latest_text or "").strip().replace("\n", " ")
+ if snippet:
+ message = f"{message}: {snippet[:72]}"
+ try:
+ subprocess.Popen(
+ ["bash", str(NOTIFY_SCRIPT), "ChatGPT 已完成", message, "Glass"],
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ start_new_session=True,
+ )
+ _write_json(
+ marker_path,
+ {
+ "ok": True,
+ "status": "notified",
+ "conversation_id": str(conversation_id or "").strip(),
+ "request_dir": str(request_dir),
+ "message": message,
+ "notified_at": _now_iso(),
+ },
+ )
+ except Exception:
+ return
+
+
+def _maybe_start_completion_sentinel(
+ *,
+ request_dir: Path,
+ target_url: str,
+ conversation_id: str,
+ model: str,
+ reasoning_effort: str,
+) -> dict[str, object]:
+ if not _env_flag("BROWSER_AGENT_CHATGPT_ENABLE_COMPLETION_SENTINEL", default=True):
+ return {"ok": False, "status": "disabled"}
+ if str(os.environ.get("BROWSER_AGENT_CHATGPT_ACTION") or "").strip().lower() == "watch_complete":
+ return {"ok": False, "status": "child_mode"}
+ state_path = _completion_signal_path(request_dir)
+ existing = _read_json(state_path)
+ existing_status = str(existing.get("status") or "").strip().lower()
+ existing_pid = int(existing.get("watch_pid") or 0) if str(existing.get("watch_pid") or "").isdigit() else 0
+ if existing_status == "completed":
+ return {"ok": True, "status": "completed", "state_path": str(state_path)}
+ if existing_status in {"watching", "launched", "attached"} and _pid_alive(existing_pid):
+ return {"ok": True, "status": existing_status, "watch_pid": existing_pid, "state_path": str(state_path)}
+ env = os.environ.copy()
+ env["BROWSER_AGENT_CHATGPT_ACTION"] = "watch_complete"
+ env["BROWSER_AGENT_CHATGPT_CONVERSATION_URL"] = str(target_url or "").strip()
+ env["CHATGPT_MODEL"] = str(model or "").strip()
+ env["CHATGPT_REASONING_EFFORT"] = str(reasoning_effort or "").strip()
+ env["BROWSER_AGENT_CHATGPT_ENABLE_COMPLETION_SENTINEL"] = "false"
+ try:
+ proc = subprocess.Popen(
+ [sys.executable, str(Path(__file__).resolve())],
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ start_new_session=True,
+ env=env,
+ )
+ except Exception as exc:
+ payload = {
+ "ok": False,
+ "status": "launch_failed",
+ "error": f"{type(exc).__name__}: {exc}",
+ "request_dir": str(request_dir),
+ "target_url": str(target_url or ""),
+ "conversation_id": str(conversation_id or ""),
+ "updated_at": _now_iso(),
+ }
+ _write_json(state_path, payload)
+ return payload
+ payload = {
+ "ok": True,
+ "status": "launched",
+ "watch_pid": int(proc.pid),
+ "request_dir": str(request_dir),
+ "target_url": str(target_url or ""),
+ "conversation_id": str(conversation_id or ""),
+ "updated_at": _now_iso(),
+ }
+ _write_json(state_path, payload)
+ return payload
+
+
def _kill_browser_profile_processes(profile_dir: Path | None) -> None:
if not profile_dir:
return
@@ -733,10 +1240,151 @@ def _kill_browser_profile_processes(profile_dir: Path | None) -> None:
pass
+def _remote_debugging_port_from_cdp_url(cdp_url: str | None) -> str:
+ text = str(cdp_url or "").strip()
+ match = re.search(r":(\d+)/", text)
+ return str(match.group(1) or "").strip() if match else ""
+
+
+def _protected_cdp_ports_from_profile_registry() -> set[str]:
+ root = Path(
+ os.environ.get("BROWSER_PROFILE_REGISTRY_ROOT")
+ or (Path.home() / ".solar" / "browser-profiles")
+ ).expanduser()
+ protected: set[str] = set()
+ if not root.exists():
+ return protected
+ try:
+ for path in root.glob("**/active-session.json"):
+ try:
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ continue
+ if not isinstance(payload, dict):
+ continue
+ port = _remote_debugging_port_from_cdp_url(str(payload.get("cdp_url") or ""))
+ if port:
+ protected.add(port)
+ except Exception:
+ return protected
+ return protected
+
+
+def _kill_browser_processes_by_remote_debugging_port(port: str | None) -> None:
+ value = str(port or "").strip()
+ if not value:
+ return
+ try:
+ result = subprocess.run(
+ ["ps", "-axo", "pid,command"],
+ check=False,
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ except Exception:
+ return
+ marker = f"--remote-debugging-port={value}"
+ pids: list[int] = []
+ for raw_line in str(result.stdout or "").splitlines():
+ line = raw_line.strip()
+ if marker not in line or "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" not in line:
+ continue
+ parts = line.split(None, 1)
+ if not parts:
+ continue
+ try:
+ pids.append(int(parts[0]))
+ except ValueError:
+ continue
+ for sig in (signal.SIGTERM, signal.SIGKILL):
+ for pid in pids:
+ try:
+ os.kill(pid, sig)
+ except ProcessLookupError:
+ continue
+ except Exception:
+ continue
+ time.sleep(0.5)
+
+
+def _browser_processes_exist_for_remote_debugging_port(port: str | None) -> bool:
+ value = str(port or "").strip()
+ if not value:
+ return False
+ try:
+ result = subprocess.run(
+ ["ps", "-axo", "command"],
+ check=False,
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ except Exception:
+ return False
+ marker = f"--remote-debugging-port={value}"
+ for raw_line in str(result.stdout or "").splitlines():
+ line = raw_line.strip()
+ if marker in line and "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" in line:
+ return True
+ return False
+
+
+def _wait_for_browser_processes_gone_by_remote_debugging_port(
+ port: str | None,
+ *,
+ timeout_s: float = 10.0,
+ sleep_s: float = 0.5,
+) -> None:
+ value = str(port or "").strip()
+ if not value:
+ return
+ deadline = time.time() + max(float(timeout_s), 0.0)
+ while time.time() < deadline:
+ if not _browser_processes_exist_for_remote_debugging_port(value):
+ return
+ time.sleep(max(float(sleep_s), 0.1))
+
+
+def _reap_orphan_browser_use_chrome_processes(*, protected_ports: set[str] | None = None) -> None:
+ protected = {str(item).strip() for item in (protected_ports or set()) if str(item).strip()}
+ protected.update(_protected_cdp_ports_from_profile_registry())
+ try:
+ result = subprocess.run(
+ ["ps", "-axo", "pid,ppid,command"],
+ check=False,
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ except Exception:
+ return
+ orphan_ports: set[str] = set()
+ for raw_line in str(result.stdout or "").splitlines():
+ line = raw_line.strip()
+ if "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" not in line:
+ continue
+ if "--headless=new" not in line or "browser-use-user-data-dir-" not in line:
+ continue
+ parts = line.split(None, 2)
+ if len(parts) < 3:
+ continue
+ _, ppid, command = parts
+ if ppid != "1":
+ continue
+ port_match = re.search(r"--remote-debugging-port=(\d+)", command)
+ port = str(port_match.group(1) or "").strip() if port_match else ""
+ if not port or port in protected:
+ continue
+ orphan_ports.add(port)
+ for port in sorted(orphan_ports):
+ _kill_browser_processes_by_remote_debugging_port(port)
+
+
def _prompt_from_stdin() -> str:
prompt = sys.stdin.read()
action = str(os.environ.get("BROWSER_AGENT_CHATGPT_ACTION") or "run").strip().lower()
- if not prompt.strip() and action not in {"poll", "collect"}:
+ if not prompt.strip() and action not in {"poll", "collect", "watch_complete"}:
raise SystemExit("stdin prompt is empty")
return prompt
@@ -748,7 +1396,7 @@ async def _wait_for_ready(page, *, timeout_s: int = 60) -> dict:
challenge_since: float | None = None
challenge_grace_s = _challenge_grace_seconds()
while time.time() < deadline:
- data = json.loads(await page.evaluate(CAPTURE_JS))
+ data = await _capture_state(page, timeout_s=8.0, default=last_data, label="wait_for_ready")
last_data = data
if data.get("login_wall"):
raise RuntimeError("chatgpt_login_wall_detected")
@@ -809,9 +1457,9 @@ async def _ensure_prompt_visible(page, prompt: str) -> dict:
async def _wait_for_prompt_submission(page, baseline_message_count: int, *, timeout_s: float = 12.0) -> dict:
deadline = time.time() + timeout_s
- last_data = json.loads(await page.evaluate(CAPTURE_JS))
+ last_data = await _capture_state(page, timeout_s=8.0, default={}, label="wait_for_prompt_submission_initial")
while time.time() < deadline:
- data = json.loads(await page.evaluate(CAPTURE_JS))
+ data = await _capture_state(page, timeout_s=8.0, default=last_data, label="wait_for_prompt_submission")
last_data = data
if int(data.get("message_count") or 0) > baseline_message_count or data.get("is_generating"):
return data
@@ -820,27 +1468,10 @@ async def _wait_for_prompt_submission(page, baseline_message_count: int, *, time
async def _submit_prompt(page, prompt: str) -> dict:
- baseline = json.loads(await page.evaluate(CAPTURE_JS))
+ baseline = await _capture_state(page, timeout_s=8.0, default={}, label="submit_prompt_baseline")
baseline_message_count = int(baseline.get("message_count") or 0)
if len(prompt) > 1000 or "\n" in prompt:
try:
- keyboard_note = await _keyboard_insert_prompt(page, prompt)
- if keyboard_note.get("ok"):
- await asyncio.sleep(1.0)
- composer_state = json.loads(await page.evaluate(COMPOSER_STATE_JS))
- submit_note = json.loads(await page.evaluate(SUBMIT_JS))
- if not submit_note.get("ok") and int(composer_state.get("text_length") or 0) > 0:
- await page.press("Enter")
- submit_note = {"mode": "enter_key_after_keyboard_insert", "js_error": submit_note.get("error")}
- post_submit = await _wait_for_prompt_submission(page, baseline_message_count)
- post_submit["_submit_note"] = {
- "mode": "keyboard_insert_submit",
- "keyboard": keyboard_note,
- "submit": submit_note,
- }
- post_submit["_composer_state_before_submit"] = composer_state
- if _post_submit_has_current_prompt(post_submit, prompt) and (int(post_submit.get("message_count") or 0) > baseline_message_count or post_submit.get("is_generating")):
- return post_submit
set_note = json.loads(await page.evaluate(SET_PROMPT_JS, prompt))
if not set_note.get("ok"):
raise RuntimeError(f"set_prompt_failed:{set_note}")
@@ -849,29 +1480,57 @@ async def _submit_prompt(page, prompt: str) -> dict:
if int(composer_state.get("text_length") or 0) > 0:
submit_result = json.loads(await page.evaluate(SUBMIT_JS))
if not submit_result.get("ok"):
- await page.press("Meta+Enter")
- submit_note = {"mode": "meta_enter_after_native_setter", "js_error": submit_result.get("error")}
+ submit_fallback = json.loads(await page.evaluate(SUBMIT_FALLBACK_JS))
+ submit_note = {
+ "mode": "dom_fallback_after_native_setter",
+ "js_error": submit_result.get("error"),
+ "fallback": submit_fallback,
+ }
else:
submit_note = {"mode": "js_submit_after_native_setter", **submit_result}
post_submit = await _wait_for_prompt_submission(page, baseline_message_count)
post_submit["_submit_note"] = {
"mode": "native_setter_submit",
- "keyboard_first": keyboard_note,
"set_prompt": set_note,
"submit": submit_note,
}
post_submit["_composer_state_before_submit"] = composer_state
if _post_submit_has_current_prompt(post_submit, prompt) and (int(post_submit.get("message_count") or 0) > baseline_message_count or post_submit.get("is_generating")):
return post_submit
+ keyboard_note = await _keyboard_insert_prompt(page, prompt)
+ if keyboard_note.get("ok"):
+ await asyncio.sleep(1.0)
+ composer_state = json.loads(await page.evaluate(COMPOSER_STATE_JS))
+ submit_note = json.loads(await page.evaluate(SUBMIT_JS))
+ if not submit_note.get("ok") and int(composer_state.get("text_length") or 0) > 0:
+ submit_fallback = json.loads(await page.evaluate(SUBMIT_FALLBACK_JS))
+ submit_note = {
+ "mode": "dom_fallback_after_keyboard_insert",
+ "js_error": submit_note.get("error"),
+ "fallback": submit_fallback,
+ }
+ post_submit = await _wait_for_prompt_submission(page, baseline_message_count)
+ post_submit["_submit_note"] = {
+ "mode": "keyboard_insert_submit",
+ "keyboard": keyboard_note,
+ "submit": submit_note,
+ }
+ post_submit["_composer_state_before_submit"] = composer_state
+ if _post_submit_has_current_prompt(post_submit, prompt) and (int(post_submit.get("message_count") or 0) > baseline_message_count or post_submit.get("is_generating")):
+ return post_submit
clipboard_note = await _clipboard_paste_and_submit(page, prompt)
post_submit = await _wait_for_prompt_submission(page, baseline_message_count)
post_submit["_submit_note"] = {"mode": "clipboard_paste_enter", "clipboard": clipboard_note}
post_submit["_composer_state_before_submit"] = clipboard_note.get("composer_state_after_paste") or {}
if _post_submit_has_current_prompt(post_submit, prompt) and (int(post_submit.get("message_count") or 0) > baseline_message_count or post_submit.get("is_generating")):
return post_submit
- await page.press("Meta+Enter")
+ submit_fallback = json.loads(await page.evaluate(SUBMIT_FALLBACK_JS))
post_submit = await _wait_for_prompt_submission(page, baseline_message_count)
- post_submit["_submit_note"] = {"mode": "clipboard_paste_meta_enter_retry", "clipboard": clipboard_note}
+ post_submit["_submit_note"] = {
+ "mode": "clipboard_dom_submit_retry",
+ "clipboard": clipboard_note,
+ "fallback": submit_fallback,
+ }
post_submit["_composer_state_before_submit"] = clipboard_note.get("composer_state_after_paste") or {}
if _post_submit_has_current_prompt(post_submit, prompt) and (int(post_submit.get("message_count") or 0) > baseline_message_count or post_submit.get("is_generating")):
return post_submit
@@ -1013,11 +1672,14 @@ async def _wait_for_answer(page, baseline_assistant_count: int, *, timeout_s: in
last_text = ""
stable = 0
first_response_seen = False
+ last_data: dict = {}
stable_required = int(os.environ.get("BROWSER_AGENT_STABLE_POLLS") or "8")
challenge_since: float | None = None
challenge_grace_s = _challenge_grace_seconds()
while time.time() < deadline:
- data = json.loads(await page.evaluate(CAPTURE_JS))
+ data = await _capture_state(page, timeout_s=8.0, default=last_data, label="wait_for_answer")
+ if data:
+ last_data = data
if data.get("login_wall"):
raise RuntimeError("chatgpt_login_wall_detected")
if data.get("challenge_wall"):
@@ -1041,10 +1703,141 @@ async def _wait_for_answer(page, baseline_assistant_count: int, *, timeout_s: in
return data
await asyncio.sleep(3)
if first_response_seen:
- return json.loads(await page.evaluate(CAPTURE_JS))
+ return await _capture_state(page, timeout_s=8.0, default=last_data, label="wait_for_answer_final")
raise TimeoutError("chatgpt_response_timeout")
+async def _watch_completion_signal(
+ *,
+ request_dir: Path,
+ target_url: str,
+ timeout_s: int,
+ headless: bool,
+ browser_channel: str,
+ browser_user_agent: str,
+ allowed_domains: list[str],
+ model: str,
+ reasoning_effort: str,
+) -> int:
+ state_path = _completion_signal_path(request_dir)
+ runtime = _read_json(request_dir / "runtime.json")
+ profile_id = str(runtime.get("profile_id") or "").strip()
+ if not profile_id:
+ _write_json(
+ state_path,
+ {
+ "ok": False,
+ "status": "error",
+ "error": "profile_id_missing",
+ "request_dir": str(request_dir),
+ "updated_at": _now_iso(),
+ },
+ )
+ return 1
+ active_session = ProfileRegistry().read_active_session(profile_id)
+ cdp_url = str(active_session.get("cdp_url") or "").strip()
+ if not cdp_url:
+ _write_json(
+ state_path,
+ {
+ "ok": False,
+ "status": "error",
+ "error": "active_session_missing",
+ "request_dir": str(request_dir),
+ "profile_id": profile_id,
+ "updated_at": _now_iso(),
+ },
+ )
+ return 1
+ _write_json(
+ state_path,
+ {
+ "ok": True,
+ "status": "attached",
+ "request_dir": str(request_dir),
+ "profile_id": profile_id,
+ "cdp_url": cdp_url,
+ "target_url": str(target_url or ""),
+ "watch_pid": os.getpid(),
+ "updated_at": _now_iso(),
+ },
+ )
+ browser = BrowserSession(
+ cdp_url=cdp_url,
+ browser_profile=BrowserProfile(
+ headless=headless,
+ keep_alive=True,
+ allowed_domains=allowed_domains,
+ channel=browser_channel,
+ user_agent=browser_user_agent,
+ ),
+ )
+ try:
+ await asyncio.wait_for(browser.start(), timeout=20)
+ page = await _find_existing_conversation_page(browser, target_url=target_url)
+ if page is None:
+ page = await asyncio.wait_for(browser.get_current_page(), timeout=15)
+ if page is None:
+ page = await asyncio.wait_for(browser.new_page(), timeout=15)
+ should_navigate = True
+ collect_target_id = _conversation_target_id(target_url)
+ if collect_target_id:
+ current_state = await _capture_state(page, timeout_s=8.0, default={}, label="watch_complete_current_state")
+ if str((current_state or {}).get("conversation_id") or "").strip() == collect_target_id:
+ should_navigate = False
+ if should_navigate and target_url:
+ try:
+ await asyncio.wait_for(page.goto(target_url), timeout=30)
+ except Exception:
+ await asyncio.wait_for(page.navigate(target_url), timeout=30)
+ await _wait_for_ready(page, timeout_s=90)
+ final_data = await _wait_for_conversation_ready(page, target_url=target_url, timeout_s=20)
+ if final_data.get("is_generating") or not str(final_data.get("latest_assistant_text") or "").strip():
+ final_data = await _wait_for_answer(page, -1, timeout_s=timeout_s)
+ latest = await _write_conversation_artifacts(
+ page,
+ request_dir,
+ final_data,
+ model=model,
+ reasoning_effort=reasoning_effort,
+ prompt=None,
+ )
+ payload = {
+ "ok": True,
+ "status": "completed",
+ "request_dir": str(request_dir),
+ "profile_id": profile_id,
+ "target_url": str(target_url or ""),
+ "conversation_id": str(final_data.get("conversation_id") or ""),
+ "message_count": final_data.get("message_count"),
+ "assistant_count": final_data.get("assistant_count"),
+ "watch_pid": os.getpid(),
+ "completed_at": _now_iso(),
+ }
+ _write_json(state_path, payload)
+ _notify_completion_ready(
+ request_dir,
+ conversation_id=str(final_data.get("conversation_id") or ""),
+ latest_text=latest,
+ )
+ _force_wrapper_exit(0)
+ except Exception as exc:
+ _write_json(
+ state_path,
+ {
+ "ok": False,
+ "status": "error",
+ "request_dir": str(request_dir),
+ "profile_id": profile_id,
+ "target_url": str(target_url or ""),
+ "watch_pid": os.getpid(),
+ "error": f"{type(exc).__name__}: {exc}",
+ "updated_at": _now_iso(),
+ },
+ )
+ _force_wrapper_exit(1)
+
+
async def _write_conversation_artifacts(
page,
request_dir: Path,
@@ -1117,7 +1910,7 @@ async def _move_current_conversation_to_project(page, project_name: str, *, time
if step.get("ok"):
result["steps"].append(step)
await asyncio.sleep(1.5)
- final_state = json.loads(await page.evaluate(CAPTURE_JS))
+ final_state = await _capture_state(page, timeout_s=8.0, default={}, label="move_current_conversation_to_project")
result.update({
"ok": True,
"finished_at": bjrt._now(),
@@ -1156,7 +1949,7 @@ async def _open_project_new_chat(page, project_name: str) -> dict:
await asyncio.sleep(1.5)
step = json.loads(await page.evaluate(NEW_CHAT_JS))
result["steps"].append(step)
- ready = json.loads(await page.evaluate(CAPTURE_JS))
+ ready = await _capture_state(page, timeout_s=8.0, default={}, label="open_project_new_chat")
message_count = int(ready.get("message_count") or 0)
# Some project pages already open a blank composer; failure to find a
# New Chat button is only safe when there are no existing messages.
@@ -1320,7 +2113,7 @@ async def _run(prompt: str) -> int:
target_url = str(os.environ.get("BROWSER_AGENT_CHATGPT_URL") or DEFAULT_URL)
action = str(os.environ.get("BROWSER_AGENT_CHATGPT_ACTION") or "run").strip().lower()
collect_url = str(os.environ.get("BROWSER_AGENT_CHATGPT_CONVERSATION_URL") or "").strip()
- if action in {"poll", "collect"} and collect_url:
+ if action in {"poll", "collect", "watch_complete"} and collect_url:
target_url = collect_url
timeout_s = int(os.environ.get("BROWSER_AGENT_CHATGPT_TIMEOUT") or "1200")
project_name = str(os.environ.get("BROWSER_AGENT_CHATGPT_PROJECT_NAME") or "").strip()
@@ -1338,7 +2131,7 @@ async def _run(prompt: str) -> int:
or os.environ.get("BROWSER_AGENT_TARGET_ACCOUNT_EMAIL")
or ""
).strip()
- headless = _env_flag("BROWSER_AGENT_HEADLESS", default=False)
+ headless = _env_flag("BROWSER_AGENT_HEADLESS", default=True)
headed_allowed = _headed_run_allowed()
profile_strategy = str(
os.environ.get("BROWSER_AGENT_CHATGPT_PROFILE_STRATEGY")
@@ -1398,6 +2191,18 @@ async def _run(prompt: str) -> int:
"started_at": bjrt._now(),
}
_write_json(request_dir / "wrapper-meta.json", meta)
+ if action == "watch_complete":
+ return await _watch_completion_signal(
+ request_dir=request_dir,
+ target_url=target_url,
+ timeout_s=timeout_s,
+ headless=headless,
+ browser_channel=browser_channel,
+ browser_user_agent=browser_user_agent,
+ allowed_domains=allowed_domains,
+ model=model,
+ reasoning_effort=reasoning_effort,
+ )
if not headless and not headed_allowed:
raise RuntimeError("browser_agent_headed_run_requires_explicit_opt_in")
control_ctx = brtc.initialize_runtime_contract(
@@ -1426,34 +2231,124 @@ async def _run(prompt: str) -> int:
final_error_text: str | None = None
final_page_state: dict | None = None
logged_in_verified = False
-
- browser = BrowserSession(
- browser_profile=BrowserProfile(
- headless=headless,
- user_data_dir=staged_dir,
- profile_directory=profile_directory,
- allowed_domains=allowed_domains,
- channel=browser_channel,
- user_agent=browser_user_agent,
- )
+ active_session = brtc.read_active_session(control_ctx, require_lineage_match=False)
+ browser: BrowserSession | None = None
+ reused_existing_session = False
+ keep_session_alive = bool(control_ctx.get("session_reuse")) and bool(control_ctx.get("session_lineage"))
+ runtime_cleanup_dir = cleanup_dir
+ runtime_staged_dir = staged_dir
+ active_session_port = _remote_debugging_port_from_cdp_url(
+ str((active_session or {}).get("cdp_url") or "")
)
+ _reap_orphan_browser_use_chrome_processes(
+ protected_ports={active_session_port} if active_session_port else set()
+ )
+ if active_session and active_session.get("cdp_url"):
+ same_lineage = str(active_session.get("session_lineage") or "").strip() == str(control_ctx.get("session_lineage") or "").strip()
+ stale_cleanup_dir = Path(str((active_session.get("details") or {}).get("cleanup_dir") or "")).expanduser() if str((active_session.get("details") or {}).get("cleanup_dir") or "").strip() else None
+ stale_cdp_port = _remote_debugging_port_from_cdp_url(str(active_session.get("cdp_url") or ""))
+ try:
+ browser = BrowserSession(
+ cdp_url=str(active_session.get("cdp_url") or "").strip(),
+ browser_profile=BrowserProfile(
+ headless=headless,
+ keep_alive=keep_session_alive,
+ allowed_domains=allowed_domains,
+ channel=browser_channel,
+ user_agent=browser_user_agent,
+ ),
+ )
+ await asyncio.wait_for(browser.start(), timeout=20)
+ if same_lineage and keep_session_alive:
+ reused_existing_session = True
+ runtime_cleanup_dir = Path(str((active_session.get("details") or {}).get("cleanup_dir") or "")).expanduser() if str((active_session.get("details") or {}).get("cleanup_dir") or "").strip() else cleanup_dir
+ runtime_staged_dir = str((active_session.get("details") or {}).get("staged_user_data_dir") or "").strip() or staged_dir
+ else:
+ await asyncio.wait_for(browser.kill(), timeout=20)
+ brtc.clear_active_session(control_ctx)
+ if stale_cleanup_dir is not None:
+ shutil.rmtree(stale_cleanup_dir, ignore_errors=True)
+ browser = None
+ except Exception:
+ _kill_browser_processes_by_remote_debugging_port(stale_cdp_port)
+ _wait_for_browser_processes_gone_by_remote_debugging_port(stale_cdp_port, timeout_s=15.0)
+ brtc.clear_active_session(control_ctx)
+ browser = None
+ if browser is None:
+ browser = BrowserSession(
+ browser_profile=BrowserProfile(
+ headless=headless,
+ keep_alive=keep_session_alive,
+ user_data_dir=staged_dir,
+ profile_directory=profile_directory,
+ allowed_domains=allowed_domains,
+ channel=browser_channel,
+ user_agent=browser_user_agent,
+ )
+ )
try:
- await asyncio.wait_for(browser.start(), timeout=40)
+ if not reused_existing_session:
+ try:
+ await asyncio.wait_for(browser.start(), timeout=40)
+ except Exception as exc:
+ if not _is_cdp_connect_failure(exc):
+ raise
+ brtc.clear_active_session(control_ctx)
+ try:
+ await asyncio.wait_for(browser.kill(), timeout=20)
+ except Exception:
+ pass
+ _wait_for_browser_processes_gone_by_remote_debugging_port(
+ _remote_debugging_port_from_cdp_url(str(getattr(browser, "cdp_url", "") or "")),
+ timeout_s=15.0,
+ )
+ await asyncio.sleep(1.0)
+ browser = BrowserSession(
+ browser_profile=BrowserProfile(
+ headless=headless,
+ keep_alive=keep_session_alive,
+ user_data_dir=staged_dir,
+ profile_directory=profile_directory,
+ allowed_domains=allowed_domains,
+ channel=browser_channel,
+ user_agent=browser_user_agent,
+ )
+ )
+ await asyncio.wait_for(browser.start(), timeout=40)
brtc.update_runtime_endpoint(
control_ctx,
cdp_url=str(getattr(browser, "cdp_url", "") or ""),
browser_session_ref=f"browser-use-session://chatgpt/{control_ctx['profile_id']}",
)
+ collect_target_id = _conversation_target_id(target_url)
if action in {"run", "submit"}:
page = await asyncio.wait_for(browser.new_page(), timeout=15)
+ elif action in {"poll", "collect"} and collect_target_id:
+ page = await _find_existing_conversation_page(browser, target_url=target_url)
+ if page is None:
+ page = await asyncio.wait_for(browser.new_page(), timeout=15)
else:
page = await asyncio.wait_for(browser.get_current_page(), timeout=15)
if page is None:
page = await asyncio.wait_for(browser.new_page(), timeout=15)
- try:
- await asyncio.wait_for(page.goto(target_url), timeout=30)
- except Exception:
- await asyncio.wait_for(page.navigate(target_url), timeout=30)
+ should_navigate = True
+ if action in {"poll", "collect"} and collect_target_id:
+ current_state = await _capture_state(page, timeout_s=8.0, default={}, label="collect_current_state")
+ if str((current_state or {}).get("conversation_id") or "").strip() == collect_target_id:
+ should_navigate = False
+ if action in {"poll", "collect"} and _is_generic_chatgpt_root(target_url):
+ current_url = ""
+ try:
+ current_url = str(await page.get_url() or "").strip()
+ except Exception:
+ current_url = ""
+ if current_url:
+ should_navigate = False
+ if should_navigate:
+ try:
+ await asyncio.wait_for(page.goto(target_url), timeout=30)
+ except Exception:
+ await asyncio.wait_for(page.navigate(target_url), timeout=30)
try:
ready = await _wait_for_ready(page, timeout_s=90)
except Exception:
@@ -1491,8 +2386,68 @@ async def _run(prompt: str) -> int:
if int(ready.get("message_count") or 0) > 0:
raise RuntimeError("chatgpt_new_chat_did_not_clear_existing_conversation")
if action in {"poll", "collect"}:
- final_data = json.loads(await page.evaluate(CAPTURE_JS))
+ expected_conversation_id = collect_target_id
+ final_data = await _wait_for_conversation_ready(
+ page,
+ target_url=target_url,
+ timeout_s=int(os.environ.get("BROWSER_AGENT_CHATGPT_COLLECT_READY_TIMEOUT") or "12"),
+ )
+ if expected_conversation_id and not _conversation_state_ready(
+ final_data,
+ expected_conversation_id=expected_conversation_id,
+ ):
+ try:
+ await page.reload()
+ await asyncio.sleep(1.0)
+ ready = await _wait_for_ready(page, timeout_s=45)
+ _write_json(request_dir / f"{action}-reload-ready-state.json", ready)
+ final_data = await _wait_for_conversation_ready(
+ page,
+ target_url=target_url,
+ timeout_s=int(os.environ.get("BROWSER_AGENT_CHATGPT_COLLECT_READY_TIMEOUT") or "12"),
+ )
+ except Exception as exc:
+ _write_json(
+ request_dir / f"{action}-reload-error.json",
+ {"error": f"{type(exc).__name__}: {exc}", "checked_at": bjrt._now()},
+ )
+ latest_ready_text = str(final_data.get("latest_assistant_text") or "").strip()
+ if (
+ action == "collect"
+ and not final_data.get("is_generating")
+ and not latest_ready_text
+ and int(final_data.get("assistant_count") or 0) > 0
+ ):
+ try:
+ final_data = await _wait_for_answer(
+ page,
+ -1,
+ timeout_s=int(os.environ.get("BROWSER_AGENT_CHATGPT_COLLECT_TIMEOUT") or "45"),
+ )
+ except TimeoutError:
+ final_data = await _capture_state(page, timeout_s=8.0, default=final_data, label="collect_after_timeout")
if final_data.get("is_generating") or not str(final_data.get("latest_assistant_text") or "").strip():
+ sentinel_state = _maybe_start_completion_sentinel(
+ request_dir=request_dir,
+ target_url=str(final_data.get("url") or target_url or "").strip(),
+ conversation_id=str(final_data.get("conversation_id") or "").strip(),
+ model=model,
+ reasoning_effort=reasoning_effort,
+ )
+ if expected_conversation_id and int(final_data.get("message_count") or 0) == 0:
+ try:
+ html = await page.evaluate(HTML_JS)
+ page_text = await page.evaluate(TEXT_JS)
+ title = await page.get_title()
+ final_url = await page.get_url()
+ (request_dir / f"{action}-empty-conversation-page.html").write_text(str(html or ""), encoding="utf-8")
+ (request_dir / f"{action}-empty-conversation-page.txt").write_text(str(page_text or "") + "\n", encoding="utf-8")
+ _write_json(
+ request_dir / f"{action}-empty-conversation-page.json",
+ {"title": title, "url": final_url, "state": final_data},
+ )
+ except Exception:
+ pass
_write_json(request_dir / f"{action}-state.json", {
"ok": True,
"status": "running" if final_data.get("is_generating") else "submitted",
@@ -1500,12 +2455,14 @@ async def _run(prompt: str) -> int:
"conversation_id": final_data.get("conversation_id"),
"assistant_count": final_data.get("assistant_count"),
"message_count": final_data.get("message_count"),
+ "completion_sentinel": sentinel_state,
"checked_at": bjrt._now(),
})
print(json.dumps({
"status": "running" if final_data.get("is_generating") else "submitted",
"url": final_data.get("url"),
"conversation_id": final_data.get("conversation_id"),
+ "completion_sentinel_status": sentinel_state.get("status"),
}, ensure_ascii=False))
final_page_state = {
"url": final_data.get("url"),
@@ -1515,7 +2472,20 @@ async def _run(prompt: str) -> int:
"login_wall": final_data.get("login_wall"),
"challenge_wall": final_data.get("challenge_wall"),
}
- return 0
+ logged_in_verified = True
+ _finalize_runtime_success(
+ control_ctx=control_ctx,
+ browser=browser,
+ headless=headless,
+ reused_existing_session=reused_existing_session,
+ runtime_staged_dir=runtime_staged_dir,
+ runtime_cleanup_dir=runtime_cleanup_dir,
+ request_dir=request_dir,
+ action=action,
+ final_page_state=final_page_state,
+ keep_session_alive=keep_session_alive,
+ )
+ _force_wrapper_exit(0)
if action == "collect":
try:
final_data = await _wait_for_answer(
@@ -1524,7 +2494,7 @@ async def _run(prompt: str) -> int:
timeout_s=int(os.environ.get("BROWSER_AGENT_CHATGPT_COLLECT_TIMEOUT") or "45"),
)
except TimeoutError:
- final_data = json.loads(await page.evaluate(CAPTURE_JS))
+ final_data = await _capture_state(page, timeout_s=8.0, default=final_data, label="collect_finalize_after_timeout")
latest = await _write_conversation_artifacts(
page,
request_dir,
@@ -1553,7 +2523,19 @@ async def _run(prompt: str) -> int:
}
logged_in_verified = True
print(latest)
- return 0
+ _finalize_runtime_success(
+ control_ctx=control_ctx,
+ browser=browser,
+ headless=headless,
+ reused_existing_session=reused_existing_session,
+ runtime_staged_dir=runtime_staged_dir,
+ runtime_cleanup_dir=runtime_cleanup_dir,
+ request_dir=request_dir,
+ action=action,
+ final_page_state=final_page_state,
+ keep_session_alive=keep_session_alive,
+ )
+ _force_wrapper_exit(0)
_write_json(request_dir / f"{action}-state.json", {
"ok": True,
"status": "running",
@@ -1591,7 +2573,7 @@ async def _run(prompt: str) -> int:
)
_write_json(request_dir / "chatgpt-ui-configure-result.json", configure_result)
if require_isolated_conversation:
- pre_submit_ready = json.loads(await page.evaluate(CAPTURE_JS))
+ pre_submit_ready = await _capture_state(page, timeout_s=8.0, default={}, label="pre_submit_isolation")
_write_json(request_dir / "pre-submit-isolation-state.json", {
"url": pre_submit_ready.get("url"),
"conversation_id": pre_submit_ready.get("conversation_id"),
@@ -1667,16 +2649,30 @@ async def _run(prompt: str) -> int:
+ json.dumps(post_submit_mode_state, ensure_ascii=False)
)
if action == "submit":
+ submitted_state = await _wait_for_submitted_conversation(
+ page,
+ post_submit,
+ timeout_s=int(os.environ.get("BROWSER_AGENT_CHATGPT_SUBMIT_STABILIZE_SECONDS") or "15"),
+ )
+ _write_json(request_dir / "submitted-state.json", submitted_state)
submitted = {
"ok": True,
- "status": "running" if post_submit.get("is_generating") else "submitted",
- "url": post_submit.get("url"),
- "conversation_id": post_submit.get("conversation_id"),
- "message_count": post_submit.get("message_count"),
- "assistant_count": post_submit.get("assistant_count"),
+ "status": "running" if submitted_state.get("is_generating") else "submitted",
+ "url": submitted_state.get("url"),
+ "conversation_id": submitted_state.get("conversation_id"),
+ "message_count": submitted_state.get("message_count"),
+ "assistant_count": submitted_state.get("assistant_count"),
"submitted_at": bjrt._now(),
}
_write_json(request_dir / "submitted-run.json", submitted)
+ sentinel_state = _maybe_start_completion_sentinel(
+ request_dir=request_dir,
+ target_url=str(submitted.get("url") or target_url or "").strip(),
+ conversation_id=str(submitted.get("conversation_id") or "").strip(),
+ model=model,
+ reasoning_effort=reasoning_effort,
+ )
+ _write_json(request_dir / "completion-sentinel-submit.json", sentinel_state)
final_page_state = {
"url": submitted.get("url"),
"conversation_id": submitted.get("conversation_id"),
@@ -1685,7 +2681,41 @@ async def _run(prompt: str) -> int:
}
logged_in_verified = True
print(json.dumps(submitted, ensure_ascii=False))
- return 0
+ if keep_session_alive and not final_error_text:
+ brtc.activate_reusable_session(
+ control_ctx,
+ cdp_url=str(getattr(browser, "cdp_url", "") or ""),
+ browser_session_ref=f"browser-use-session://chatgpt/{control_ctx['profile_id']}",
+ headless=headless,
+ attached=reused_existing_session,
+ details={
+ "request_dir": str(request_dir),
+ "staged_user_data_dir": str(runtime_staged_dir or ""),
+ "cleanup_dir": str(runtime_cleanup_dir or ""),
+ },
+ )
+ else:
+ brtc.clear_active_session(control_ctx)
+ brtc.finalize_runtime_contract(
+ control_ctx,
+ success=True,
+ error_text="",
+ page_state=final_page_state,
+ logged_in_state_verified=True,
+ details={
+ "provider": "browser_agent_chatgpt",
+ "action": action,
+ "request_dir": str(request_dir),
+ "forced_exit_after_submit": True,
+ },
+ requires_precise_page_control=False,
+ )
+ # Force the dedicated wrapper process to exit after submit so
+ # browser-use background activity cannot block actor handoff.
+ # browser-use can keep the event loop alive after submit artifacts
+ # are already persisted. Exit the dedicated wrapper process here so
+ # actor handoff can advance into collect immediately.
+ _force_wrapper_exit(0)
final_data = await _wait_for_answer(page, baseline_assistant_count, timeout_s=timeout_s)
final_page_state = {
"url": final_data.get("url"),
@@ -1714,17 +2744,52 @@ async def _run(prompt: str) -> int:
print(latest)
logged_in_verified = True
- return 0
+ _finalize_runtime_success(
+ control_ctx=control_ctx,
+ browser=browser,
+ headless=headless,
+ reused_existing_session=reused_existing_session,
+ runtime_staged_dir=runtime_staged_dir,
+ runtime_cleanup_dir=runtime_cleanup_dir,
+ request_dir=request_dir,
+ action=action,
+ final_page_state=final_page_state,
+ keep_session_alive=keep_session_alive,
+ )
+ _force_wrapper_exit(0)
except Exception as exc:
final_error_text = str(exc)
raise
finally:
try:
- await asyncio.wait_for(browser.stop(), timeout=20)
+ if keep_session_alive and logged_in_verified and not final_error_text:
+ brtc.activate_reusable_session(
+ control_ctx,
+ cdp_url=str(getattr(browser, "cdp_url", "") or ""),
+ browser_session_ref=f"browser-use-session://chatgpt/{control_ctx['profile_id']}",
+ headless=headless,
+ attached=reused_existing_session,
+ details={
+ "request_dir": str(request_dir),
+ "staged_user_data_dir": str(runtime_staged_dir or ""),
+ "cleanup_dir": str(runtime_cleanup_dir or ""),
+ },
+ )
+ # Let the wrapper process exit without synchronously stopping
+ # browser-use here. We keep Chrome alive for pooled reuse, and
+ # browser.stop() has been observed to hang after submit/poll
+ # success, which prevents the actor from ever advancing into
+ # collect.
+ else:
+ await asyncio.wait_for(browser.kill(), timeout=20)
+ brtc.clear_active_session(control_ctx)
except Exception:
pass
- _kill_browser_profile_processes(staged_dir)
- if cleanup_dir is not None:
+ if not (keep_session_alive and logged_in_verified and not final_error_text):
+ _kill_browser_profile_processes(Path(str(runtime_staged_dir)).expanduser() if runtime_staged_dir else staged_dir)
+ if runtime_cleanup_dir is not None:
+ shutil.rmtree(runtime_cleanup_dir, ignore_errors=True)
+ elif cleanup_dir is not None and cleanup_dir != runtime_cleanup_dir:
shutil.rmtree(cleanup_dir, ignore_errors=True)
brtc.finalize_runtime_contract(
control_ctx,
@@ -1765,4 +2830,5 @@ def main() -> int:
if __name__ == "__main__":
- raise SystemExit(main())
+ exit_code = main()
+ _force_wrapper_exit(exit_code)
diff --git a/harness/scripts/browser_agent_gemini_deep_research_wrapper.py b/harness/scripts/browser_agent_gemini_deep_research_wrapper.py
index f8f5c6cef..af450d719 100644
--- a/harness/scripts/browser_agent_gemini_deep_research_wrapper.py
+++ b/harness/scripts/browser_agent_gemini_deep_research_wrapper.py
@@ -23,6 +23,7 @@
import time
from urllib.parse import parse_qs, urlparse
from pathlib import Path
+from typing import NoReturn
ROOT = Path(__file__).resolve().parents[1]
LIB = ROOT / "lib"
@@ -252,6 +253,56 @@ def _quiet_browser_logs() -> None:
):
logging.getLogger(name).setLevel(logging.ERROR)
+
+def _force_wrapper_exit(code: int) -> NoReturn:
+ try:
+ sys.stdout.flush()
+ sys.stderr.flush()
+ finally:
+ os._exit(code)
+
+
+def _finalize_runtime_success(
+ *,
+ control_ctx: dict,
+ browser,
+ headless: bool,
+ reused_existing_session: bool,
+ runtime_staged_dir,
+ runtime_cleanup_dir,
+ request_dir: Path,
+ final_page_state: dict | None,
+ keep_session_alive: bool,
+) -> None:
+ if keep_session_alive:
+ brtc.activate_reusable_session(
+ control_ctx,
+ cdp_url=str(getattr(browser, "cdp_url", "") or ""),
+ browser_session_ref=f"browser-use-session://gemini/{control_ctx['profile_id']}",
+ headless=headless,
+ attached=reused_existing_session,
+ details={
+ "request_dir": str(request_dir),
+ "staged_user_data_dir": str(runtime_staged_dir or ""),
+ "cleanup_dir": str(runtime_cleanup_dir or ""),
+ },
+ )
+ else:
+ brtc.clear_active_session(control_ctx)
+ brtc.finalize_runtime_contract(
+ control_ctx,
+ success=True,
+ error_text="",
+ page_state=final_page_state,
+ logged_in_state_verified=True,
+ details={
+ "provider": "browser_agent_gemini_deep_research",
+ "request_dir": str(request_dir),
+ "forced_exit": True,
+ },
+ requires_precise_page_control=True,
+ )
+
def _request_dir() -> Path:
out = Path(os.environ.get("BROWSER_AGENT_REQUEST_DIR") or f"/tmp/gemini-dr-wrapper-{int(time.time())}").expanduser()
out.mkdir(parents=True, exist_ok=True)
@@ -452,6 +503,45 @@ async def _dismiss_overlays(page) -> None:
await page.wait_for_timeout(200)
except Exception:
pass
+ for selector in (
+ "button[aria-label*='关闭']",
+ "button[aria-label*='close']",
+ "button:has-text('知道了')",
+ "button:has-text('我知道了')",
+ "button:has-text('关闭')",
+ "button:has-text('稍后')",
+ "button:has-text('Got it')",
+ "button:has-text('Close')",
+ ):
+ try:
+ btn = page.locator(selector).first
+ if await btn.count() and await btn.is_visible():
+ await btn.click(force=True)
+ await page.wait_for_timeout(250)
+ except Exception:
+ continue
+ try:
+ await page.locator("body").click(position={"x": 8, "y": 8}, force=True)
+ await page.wait_for_timeout(150)
+ except Exception:
+ pass
+
+
+async def _click_mode_selector(page, selector_btn) -> None:
+ await _dismiss_overlays(page)
+ try:
+ await selector_btn.click(force=True)
+ return
+ except Exception:
+ pass
+ try:
+ handle = await selector_btn.element_handle()
+ if handle is not None:
+ await page.evaluate("(el) => el.click()", handle)
+ return
+ except Exception:
+ pass
+ await selector_btn.click()
async def _click_send_button(page) -> None:
@@ -710,7 +800,7 @@ async def _ensure_pro_model_with_extended_thinking(page) -> None:
current_mode = await _read_current_mode_label(page)
print(f"[Gemini Wrapper] Current mode label before selection: {current_mode or 'N/A'}", flush=True)
- await selector_btn.click()
+ await _click_mode_selector(page, selector_btn)
await page.wait_for_timeout(1000)
# 1. Ensure a Pro-grade model is selected instead of Flash-Lite.
@@ -757,7 +847,7 @@ async def _ensure_pro_model_with_extended_thinking(page) -> None:
# Re-open dropdown for thinking level configuration.
try:
- await selector_btn.click()
+ await _click_mode_selector(page, selector_btn)
await page.wait_for_timeout(1000)
except Exception:
pass
@@ -779,7 +869,7 @@ async def _ensure_pro_model_with_extended_thinking(page) -> None:
else:
print("[Gemini Wrapper] '扩展' (Extended) thinking level is already selected.", flush=True)
# Close dropdown by clicking selector button again
- await selector_btn.click()
+ await _click_mode_selector(page, selector_btn)
await page.wait_for_timeout(500)
else:
print("[Gemini Wrapper] Warning: '扩展' thinking level item not found.", flush=True)
@@ -788,7 +878,7 @@ async def _ensure_pro_model_with_extended_thinking(page) -> None:
# 3. Final gate: do not proceed if the top mode is still Flash-Lite.
try:
- await selector_btn.click()
+ await _click_mode_selector(page, selector_btn)
await page.wait_for_timeout(500)
except Exception:
pass
@@ -801,7 +891,7 @@ async def _run(prompt: str) -> int:
user_data_dir = Path(os.environ.get("BROWSER_AGENT_USER_DATA_DIR") or str(DEFAULT_USER_DATA_DIR)).expanduser()
target_url = str(os.environ.get("BROWSER_AGENT_GEMINI_URL") or DEFAULT_URL)
timeout_s = int(os.environ.get("BROWSER_AGENT_GEMINI_TIMEOUT") or "1200")
- headless = str(os.environ.get("BROWSER_AGENT_HEADLESS") or "false").strip().lower() in {"1", "true", "yes", "on"}
+ headless = str(os.environ.get("BROWSER_AGENT_HEADLESS") or "true").strip().lower() in {"1", "true", "yes", "on"}
minimum_mode_evidence = str(os.environ.get("BROWSER_AGENT_GEMINI_MODE_EVIDENCE_MIN") or "strong").strip().lower()
allowed_domains = DEFAULT_ALLOWED_DOMAINS
@@ -832,6 +922,12 @@ async def _run(prompt: str) -> int:
final_error_text: str | None = None
final_page_state: dict | None = None
logged_in_verified = False
+ active_session = brtc.read_active_session(control_ctx, require_lineage_match=False)
+ browser: BrowserSession | None = None
+ reused_existing_session = False
+ keep_session_alive = bool(control_ctx.get("session_reuse")) and bool(control_ctx.get("session_lineage"))
+ runtime_cleanup_dir = cleanup_dir
+ runtime_staged_dir = staged_dir
meta = {
"provider": "browser_agent_gemini_deep_research",
@@ -844,17 +940,46 @@ async def _run(prompt: str) -> int:
}
_write_json(request_dir / "wrapper-meta.json", meta)
- browser = BrowserSession(
- browser_profile=BrowserProfile(
- headless=headless,
- user_data_dir=staged_dir,
- profile_directory=profile_directory,
- allowed_domains=allowed_domains,
- channel="chrome",
+ if active_session and active_session.get("cdp_url"):
+ same_lineage = str(active_session.get("session_lineage") or "").strip() == str(control_ctx.get("session_lineage") or "").strip()
+ stale_cleanup_dir = Path(str((active_session.get("details") or {}).get("cleanup_dir") or "")).expanduser() if str((active_session.get("details") or {}).get("cleanup_dir") or "").strip() else None
+ try:
+ browser = BrowserSession(
+ cdp_url=str(active_session.get("cdp_url") or "").strip(),
+ browser_profile=BrowserProfile(
+ headless=headless,
+ allowed_domains=allowed_domains,
+ channel="chrome",
+ ),
+ )
+ await asyncio.wait_for(browser.start(), timeout=20)
+ if same_lineage and keep_session_alive:
+ reused_existing_session = True
+ runtime_cleanup_dir = Path(str((active_session.get("details") or {}).get("cleanup_dir") or "")).expanduser() if str((active_session.get("details") or {}).get("cleanup_dir") or "").strip() else cleanup_dir
+ runtime_staged_dir = str((active_session.get("details") or {}).get("staged_user_data_dir") or "").strip() or staged_dir
+ else:
+ await asyncio.wait_for(browser.kill(), timeout=20)
+ brtc.clear_active_session(control_ctx)
+ if stale_cleanup_dir is not None:
+ import shutil
+ shutil.rmtree(stale_cleanup_dir, ignore_errors=True)
+ browser = None
+ except Exception:
+ brtc.clear_active_session(control_ctx)
+ browser = None
+ if browser is None:
+ browser = BrowserSession(
+ browser_profile=BrowserProfile(
+ headless=headless,
+ user_data_dir=staged_dir,
+ profile_directory=profile_directory,
+ allowed_domains=allowed_domains,
+ channel="chrome",
+ )
)
- )
try:
- await asyncio.wait_for(browser.start(), timeout=40)
+ if not reused_existing_session:
+ await asyncio.wait_for(browser.start(), timeout=40)
brtc.update_runtime_endpoint(
control_ctx,
cdp_url=str(getattr(browser, "cdp_url", "") or ""),
@@ -1085,16 +1210,47 @@ async def _run(prompt: str) -> int:
_assert_mode_evidence_strength(mode_evidence_strength, minimum=minimum_mode_evidence)
print(latest_txt)
- return 0
+ _finalize_runtime_success(
+ control_ctx=control_ctx,
+ browser=browser,
+ headless=headless,
+ reused_existing_session=reused_existing_session,
+ runtime_staged_dir=runtime_staged_dir,
+ runtime_cleanup_dir=runtime_cleanup_dir,
+ request_dir=request_dir,
+ final_page_state=final_page_state,
+ keep_session_alive=keep_session_alive,
+ )
+ _force_wrapper_exit(0)
except Exception as exc:
final_error_text = str(exc)
raise
finally:
try:
- await asyncio.wait_for(browser.stop(), timeout=20)
+ if keep_session_alive and logged_in_verified and not final_error_text:
+ brtc.activate_reusable_session(
+ control_ctx,
+ cdp_url=str(getattr(browser, "cdp_url", "") or ""),
+ browser_session_ref=f"browser-use-session://gemini/{control_ctx['profile_id']}",
+ headless=headless,
+ attached=reused_existing_session,
+ details={
+ "request_dir": str(request_dir),
+ "staged_user_data_dir": str(runtime_staged_dir or ""),
+ "cleanup_dir": str(runtime_cleanup_dir or ""),
+ },
+ )
+ await asyncio.wait_for(browser.stop(), timeout=20)
+ else:
+ await asyncio.wait_for(browser.kill(), timeout=20)
+ brtc.clear_active_session(control_ctx)
except Exception:
pass
- if cleanup_dir is not None:
+ if not (keep_session_alive and logged_in_verified and not final_error_text):
+ if runtime_cleanup_dir is not None:
+ import shutil
+ shutil.rmtree(runtime_cleanup_dir, ignore_errors=True)
+ elif cleanup_dir is not None and cleanup_dir != runtime_cleanup_dir:
import shutil
shutil.rmtree(cleanup_dir, ignore_errors=True)
brtc.finalize_runtime_contract(
@@ -1115,9 +1271,9 @@ def main() -> int:
prompt = _prompt_from_stdin()
if not prompt:
print("ERROR: Stdin prompt input is empty.", file=sys.stderr)
- return 1
+ _force_wrapper_exit(1)
try:
- return asyncio.run(_run(prompt))
+ rc = asyncio.run(_run(prompt))
except Exception as exc:
request_dir = _request_dir()
_write_json(request_dir / "wrapper-error.json", {
@@ -1126,7 +1282,8 @@ def main() -> int:
"failed_at": bjrt._now(),
})
print(f"browser_agent_gemini_deep_research_wrapper failed: {type(exc).__name__}: {exc}", file=sys.stderr)
- return 1
+ rc = 1
+ _force_wrapper_exit(int(rc))
if __name__ == "__main__":
raise SystemExit(main())
diff --git a/harness/scripts/browser_agent_notebooklm_wrapper.py b/harness/scripts/browser_agent_notebooklm_wrapper.py
index dcf26092c..099a92d9a 100644
--- a/harness/scripts/browser_agent_notebooklm_wrapper.py
+++ b/harness/scripts/browser_agent_notebooklm_wrapper.py
@@ -713,10 +713,11 @@ async def _run(payload: dict) -> int:
browser = BrowserSession(
browser_profile=BrowserProfile(
- headless=str(os.environ.get("BROWSER_AGENT_HEADLESS") or "false").strip().lower() in {"1", "true", "yes", "on"},
+ headless=str(os.environ.get("BROWSER_AGENT_HEADLESS") or "true").strip().lower() in {"1", "true", "yes", "on"},
user_data_dir=staged_dir,
profile_directory=profile_directory,
allowed_domains=DEFAULT_ALLOWED_DOMAINS,
+ channel="chrome",
)
)
try:
@@ -839,7 +840,7 @@ async def _run(payload: dict) -> int:
return 0
finally:
try:
- await asyncio.wait_for(browser.stop(), timeout=20)
+ await asyncio.wait_for(browser.kill(), timeout=20)
except Exception:
pass
if cleanup_dir is not None:
diff --git a/harness/scripts/browser_agent_technology_diagram_painter_wrapper.py b/harness/scripts/browser_agent_technology_diagram_painter_wrapper.py
index a0bb43702..bb39f048a 100755
--- a/harness/scripts/browser_agent_technology_diagram_painter_wrapper.py
+++ b/harness/scripts/browser_agent_technology_diagram_painter_wrapper.py
@@ -4,7 +4,7 @@
Pipeline:
1. Connect via browser-use profile session (CDP).
2. Navigate to https://chatgpt.com/.
-3. Verify logged in as target account (browser-agent@example.com).
+3. Verify logged in as target account.
4. Click "...更多" (More) on the left navigation bar, and select "图片" (Image).
5. Select model "gpt5.5" and "thinking high" from the model selector.
6. Enter text + drawing prompt into the textarea and submit.
@@ -21,6 +21,7 @@
import sys
import time
from pathlib import Path
+from typing import NoReturn
ROOT = Path(__file__).resolve().parents[1]
LIB = ROOT / "lib"
@@ -28,6 +29,7 @@
sys.path.insert(0, str(LIB))
import browser_job_runtime as bjrt
+from browser import runtime_control as brtc
from browser_use.browser.profile import BrowserProfile
from browser_use.browser.session import BrowserSession
from playwright.async_api import async_playwright
@@ -38,7 +40,11 @@
DEFAULT_ALLOWED_DOMAINS = [
"chatgpt.com", "openai.com", "auth0.openai.com", "google.com", "accounts.google.com"
]
-TARGET_ACCOUNT_EMAIL = "browser-agent@example.com"
+TARGET_ACCOUNT_EMAIL = (
+ os.environ.get("BROWSER_AGENT_TARGET_ACCOUNT_EMAIL")
+ or os.environ.get("BROWSER_AGENT_CHATGPT_ACCOUNT_EMAIL")
+ or "haogege1977@gmail.com"
+)
# ---------------------------------------------------------------------------
# Logging helpers
@@ -76,10 +82,270 @@ def _prompt_from_stdin() -> dict:
return {}
+def _force_wrapper_exit(code: int) -> NoReturn:
+ try:
+ sys.stdout.flush()
+ sys.stderr.flush()
+ finally:
+ os._exit(code)
+
+
+def _challenge_grace_seconds() -> float:
+ raw = str(
+ os.environ.get("BROWSER_AGENT_CHATGPT_CHALLENGE_GRACE_SECONDS")
+ or os.environ.get("BROWSER_AGENT_CHALLENGE_GRACE_SECONDS")
+ or "20"
+ ).strip()
+ try:
+ value = float(raw)
+ except ValueError:
+ value = 20.0
+ return max(0.0, value)
+
+
+def _challenge_persisted_too_long(challenge_since: float | None, *, now: float | None = None, grace_s: float | None = None) -> bool:
+ if challenge_since is None:
+ return False
+ deadline = challenge_since + (grace_s if grace_s is not None else _challenge_grace_seconds())
+ return (now if now is not None else time.time()) >= deadline
+
+
# ---------------------------------------------------------------------------
# ChatGPT page interaction helpers
# ---------------------------------------------------------------------------
+CAPTURE_JS = r"""() => {
+ const clean = (value) => String(value || "").replace(/\u00a0/g, " ").replace(/\s+/g, " ").trim();
+ const visible = (el) => {
+ if (!el) return false;
+ const rect = el.getBoundingClientRect();
+ const style = window.getComputedStyle(el);
+ return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
+ };
+ const composerCandidates = Array.from(
+ document.querySelectorAll(
+ "#prompt-textarea, div[contenteditable='true'][role='textbox'], textarea[name='prompt-textarea'], textarea, [data-testid='composer-text-input']"
+ )
+ );
+ const composer = composerCandidates.find(visible) || composerCandidates[0] || null;
+ const bodyText = clean(document.body ? (document.body.innerText || document.body.textContent || "") : "").toLowerCase();
+ const challengeWall =
+ /cloudflare|turnstile|checking your browser|verify you are human|请稍候|正在验证|验证你是真人/i.test(
+ `${document.title || ""}\n${location.href}\n${bodyText}`
+ ) ||
+ Array.from(document.querySelectorAll("iframe")).some((iframe) =>
+ /challenges\.cloudflare\.com|turnstile/i.test(String(iframe.src || ""))
+ );
+ return JSON.stringify({
+ title: document.title || "",
+ url: location.href,
+ composer_ready: !!composer,
+ challenge_wall: challengeWall,
+ });
+}"""
+
+SET_PROMPT_JS = r"""(promptText) => {
+ const visible = (el) => {
+ if (!el) return false;
+ const rect = el.getBoundingClientRect();
+ const style = window.getComputedStyle(el);
+ return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
+ };
+ const candidates = Array.from(
+ document.querySelectorAll(
+ "#prompt-textarea, div[contenteditable='true'][role='textbox'], textarea[name='prompt-textarea'], textarea, [data-testid='composer-text-input']"
+ )
+ );
+ const composer = candidates.find(visible) || candidates[0];
+ if (!composer) {
+ return JSON.stringify({ ok: false, error: "composer_not_found" });
+ }
+ const prompt = String(promptText || "").replace(/\r\n/g, "\n");
+ const lines = prompt.split("\n");
+ composer.focus();
+ if (composer.tagName === "TEXTAREA") {
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set;
+ if (setter) {
+ setter.call(composer, prompt);
+ } else {
+ composer.value = prompt;
+ }
+ composer.dispatchEvent(new InputEvent("beforeinput", { bubbles: true, inputType: "insertText", data: prompt }));
+ composer.dispatchEvent(new Event("input", { bubbles: true }));
+ composer.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: prompt }));
+ composer.dispatchEvent(new Event("change", { bubbles: true }));
+ return JSON.stringify({ ok: true, mode: "textarea" });
+ }
+ try {
+ const selection = window.getSelection();
+ const range = document.createRange();
+ range.selectNodeContents(composer);
+ range.collapse(true);
+ selection.removeAllRanges();
+ selection.addRange(range);
+ if (document.execCommand && document.execCommand("insertText", false, prompt)) {
+ composer.dispatchEvent(new InputEvent("beforeinput", { bubbles: true, inputType: "insertText", data: prompt }));
+ composer.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: prompt }));
+ return JSON.stringify({ ok: true, mode: "contenteditable_execcommand" });
+ }
+ } catch (_) {}
+ composer.innerHTML = "";
+ for (const line of lines) {
+ const p = document.createElement("p");
+ if (line.length) {
+ p.textContent = line;
+ } else {
+ p.appendChild(document.createElement("br"));
+ }
+ composer.appendChild(p);
+ }
+ composer.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: prompt }));
+ return JSON.stringify({ ok: true, mode: "contenteditable" });
+}"""
+
+COMPOSER_STATE_JS = r"""() => {
+ const visible = (el) => {
+ if (!el) return false;
+ const rect = el.getBoundingClientRect();
+ const style = window.getComputedStyle(el);
+ return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
+ };
+ const candidates = Array.from(
+ document.querySelectorAll(
+ "#prompt-textarea, div[contenteditable='true'][role='textbox'], textarea[name='prompt-textarea'], textarea, [data-testid='composer-text-input']"
+ )
+ );
+ const composer = candidates.find(visible) || candidates[0];
+ if (!composer) return JSON.stringify({ ok: false, error: "composer_not_found" });
+ const text = String(composer.value || composer.innerText || composer.textContent || "").trim();
+ return JSON.stringify({ ok: true, text_length: text.length, tag: composer.tagName, id: composer.id || "" });
+}"""
+
+SUBMIT_JS = r"""() => {
+ const visible = (el) => {
+ if (!el) return false;
+ const rect = el.getBoundingClientRect();
+ const style = window.getComputedStyle(el);
+ return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
+ };
+ const selectors = [
+ "form button[type='submit']",
+ "button[type='submit']",
+ "button[data-testid='send-button']",
+ "button[data-testid='composer-send-button']",
+ "button[aria-label*='Send']",
+ "button[aria-label*='send']",
+ "button[aria-label*='发送']",
+ "button.composer-submit-button-color[type='button']",
+ "button.composer-submit-button-color",
+ ];
+ for (const selector of selectors) {
+ const buttons = Array.from(document.querySelectorAll(selector));
+ for (const button of buttons) {
+ if (!visible(button)) continue;
+ const label = String(button.getAttribute("aria-label") || button.textContent || "").trim();
+ if (/语音|voice|stop|停止|cancel|中止/i.test(label)) continue;
+ if (button.disabled || button.getAttribute("aria-disabled") === "true") continue;
+ button.click();
+ return JSON.stringify({ ok: true, selector, label });
+ }
+ }
+ return JSON.stringify({ ok: false, error: "submit_button_not_found" });
+}"""
+
+SUBMIT_FALLBACK_JS = r"""() => {
+ const composer = document.querySelector(
+ "#prompt-textarea, div[contenteditable='true'][role='textbox'], textarea[name='prompt-textarea'], textarea, [data-testid='composer-text-input']"
+ );
+ if (!composer) return JSON.stringify({ ok: false, error: "composer_not_found" });
+ const value = String(composer.value || composer.innerText || composer.textContent || "").trim();
+ if (!value) return JSON.stringify({ ok: false, error: "composer_empty" });
+ composer.focus();
+ composer.dispatchEvent(new Event("input", { bubbles: true }));
+ composer.dispatchEvent(new Event("change", { bubbles: true }));
+ const form = composer.closest("form");
+ if (form && typeof form.requestSubmit === "function") {
+ form.requestSubmit();
+ return JSON.stringify({ ok: true, mode: "form_request_submit" });
+ }
+ if (form) {
+ const event = new Event("submit", { bubbles: true, cancelable: true });
+ form.dispatchEvent(event);
+ return JSON.stringify({ ok: true, mode: "form_submit_event", default_prevented: event.defaultPrevented });
+ }
+ for (const type of ["keydown", "keypress", "keyup"]) {
+ composer.dispatchEvent(new KeyboardEvent(type, {
+ bubbles: true,
+ cancelable: true,
+ key: "Enter",
+ code: "Enter",
+ metaKey: true,
+ }));
+ }
+ return JSON.stringify({ ok: true, mode: "composer_meta_enter_dispatch" });
+}"""
+
+
+async def _capture_state(page, *, timeout_s: float = 8.0, default: dict | None = None, label: str = "capture") -> dict:
+ fallback = dict(default or {})
+ try:
+ raw = await asyncio.wait_for(page.evaluate(CAPTURE_JS), timeout=max(1.0, float(timeout_s)))
+ except asyncio.TimeoutError:
+ fallback["_capture_timeout"] = label
+ return fallback
+ try:
+ data = json.loads(raw)
+ except Exception:
+ fallback["_capture_decode_error"] = label
+ return fallback
+ return data if isinstance(data, dict) else fallback
+
+
+async def _wait_for_chat_ready(page, *, timeout_s: int = 60) -> dict:
+ deadline = time.time() + timeout_s
+ last_state: dict = {}
+ refresh_count = 0
+ challenge_since: float | None = None
+ challenge_grace_s = _challenge_grace_seconds()
+ while time.time() < deadline:
+ state = await _capture_state(page, timeout_s=8.0, default=last_state, label="wait_for_chat_ready")
+ last_state = state
+ if state.get("challenge_wall"):
+ if challenge_since is None:
+ challenge_since = time.time()
+ if _challenge_persisted_too_long(challenge_since, grace_s=challenge_grace_s):
+ raise RuntimeError("chatgpt_cloudflare_challenge_detected")
+ await asyncio.sleep(1.5)
+ continue
+ challenge_since = None
+ if state.get("composer_ready") and not state.get("challenge_wall"):
+ return state
+ remaining = deadline - time.time()
+ if refresh_count == 0 and remaining < max(10, timeout_s - 25):
+ try:
+ await page.goto(DEFAULT_URL)
+ refresh_count += 1
+ except Exception:
+ pass
+ elif refresh_count == 1 and remaining < max(5, timeout_s - 55):
+ try:
+ await page.reload()
+ refresh_count += 1
+ except Exception:
+ pass
+ await asyncio.sleep(1.0)
+ raise TimeoutError(
+ "chatgpt_composer_not_ready: "
+ + json.dumps(
+ {
+ "title": last_state.get("title"),
+ "url": last_state.get("url"),
+ "challenge_wall": last_state.get("challenge_wall"),
+ },
+ ensure_ascii=False,
+ )
+ )
+
async def _verify_account(page) -> bool:
"""Check if logged-in account matches TARGET_ACCOUNT_EMAIL."""
print("[TechDiagram] Verifying ChatGPT account...", flush=True)
@@ -245,15 +511,8 @@ async def _select_model(page) -> bool:
async def _submit_prompt(page, full_prompt: str) -> bool:
print("[TechDiagram] Submitting prompt...", flush=True)
try:
- # Wait for either the current ChatGPT composer or legacy textareas.
- editor = page.locator(
- "#prompt-textarea:visible, "
- "textarea:visible, "
- "div[contenteditable='true']:visible, "
- "[data-testid='composer-text-input']:visible"
- ).first
try:
- await editor.wait_for(state="visible", timeout=15000)
+ ready_state = await _wait_for_chat_ready(page, timeout_s=60)
except Exception:
# Current ChatGPT DOM changes frequently; capture a useful artifact
# instead of failing as a black box.
@@ -264,46 +523,24 @@ async def _submit_prompt(page, full_prompt: str) -> bool:
)
print(f"[TechDiagram] Composer not found. url={page.url} title={await page.title()}", flush=True)
return False
+ if not ready_state.get("composer_ready"):
+ return False
- # Click to focus
- await editor.click()
- await page.wait_for_timeout(500)
-
- # Fill the prompt (using fill for textarea, or pasting text/typing for div)
- # Using keyboard type or filling depends on the element type. For ProseMirror, fill might not trigger events.
- # We can try fill first, if it fails or is a div, we use JS or keyboard.
- tag_name = await editor.evaluate("el => el.tagName.toLowerCase()")
- if tag_name == "textarea":
- await editor.fill(full_prompt)
- else:
- # It's a contenteditable div (ProseMirror)
- # Use JS to set text content or just type
- # Typing can be slow, but it's safest for triggering React events.
- # To speed it up, we can set the text then dispatch an input event, or just paste.
- handle = await editor.element_handle()
- await page.evaluate(f"""
- (el) => {{
- el.innerHTML = '';
- el.innerText = {json.dumps(full_prompt)};
- el.dispatchEvent(new Event('input', {{ bubbles: true }}));
- }}
- """, handle)
-
+ set_result = json.loads(await page.evaluate(SET_PROMPT_JS, full_prompt))
+ if not set_result.get("ok"):
+ print(f"[TechDiagram] Failed to set prompt: {set_result}", flush=True)
+ return False
await page.wait_for_timeout(1000)
-
- # Submit: try clicking the send button first
- send_btn = page.locator(
- 'button[data-testid="send-button"], '
- 'button[aria-label*="Send"], '
- 'button[aria-label*="发送"], '
- 'button[aria-label*="Submit"], '
- '[data-testid="composer-speech-button"]'
- ).first
- if await send_btn.count() and await send_btn.is_enabled():
- await send_btn.click()
- else:
- # Fallback to Enter
- await page.keyboard.press("Enter")
+ composer_state = json.loads(await page.evaluate(COMPOSER_STATE_JS))
+ if int(composer_state.get("text_length") or 0) <= 0:
+ print(f"[TechDiagram] Composer stayed empty after fill: {composer_state}", flush=True)
+ return False
+ submit_result = json.loads(await page.evaluate(SUBMIT_JS))
+ if not submit_result.get("ok"):
+ submit_result = json.loads(await page.evaluate(SUBMIT_FALLBACK_JS))
+ if not submit_result.get("ok"):
+ print(f"[TechDiagram] Submit fallback failed: {submit_result}", flush=True)
+ return False
print("[TechDiagram] Prompt submitted.", flush=True)
return True
@@ -312,7 +549,7 @@ async def _submit_prompt(page, full_prompt: str) -> bool:
return False
-async def _wait_and_download_image(page, request_dir: Path, timeout_s: int = 120) -> dict:
+async def _wait_and_download_image(page, request_dir: Path, timeout_s: int = 120, capture_state: dict | None = None) -> dict:
print(f"[TechDiagram] Waiting for image generation (timeout {timeout_s}s)...", flush=True)
deadline = time.time() + timeout_s
@@ -370,6 +607,15 @@ async def _wait_and_download_image(page, request_dir: Path, timeout_s: int = 120
except Exception as e:
print(f"[TechDiagram] Screenshot fallback failed: {e}", flush=True)
+ promoted = await _maybe_promote_original_capture(
+ page,
+ request_dir,
+ capture_state,
+ is_generating=bool(is_generating),
+ )
+ if promoted:
+ return promoted
+
# Determine if we hit an error (e.g. usage limit)
error_msg = await page.evaluate("""
(() => {
@@ -423,6 +669,7 @@ async def _install_original_image_capture(page, request_dir: Path) -> dict:
"tasks": [],
"candidates": [],
"counter": 0,
+ "last_candidate_at": 0.0,
}
async def capture_response(response) -> None:
@@ -475,6 +722,7 @@ async def capture_response(response) -> None:
"height": height,
"content_type": content_type,
})
+ state["last_candidate_at"] = time.time()
_write_json(request_dir / "network-image-candidates.json", state["candidates"])
print(
f"[TechDiagram] Captured image response candidate: {out_path} "
@@ -523,6 +771,28 @@ async def _best_original_capture(state: dict, request_dir: Path) -> dict | None:
}
+async def _maybe_promote_original_capture(
+ page,
+ request_dir: Path,
+ capture_state: dict | None,
+ *,
+ is_generating: bool,
+ stable_seconds: float = 20.0,
+) -> dict | None:
+ if not capture_state:
+ return None
+ candidates = list(capture_state.get("candidates") or [])
+ if not candidates:
+ return None
+ last_candidate_at = float(capture_state.get("last_candidate_at") or 0.0)
+ if is_generating and (time.time() - last_candidate_at) < max(5.0, stable_seconds):
+ return None
+ promoted = await _best_original_capture(capture_state, request_dir)
+ if promoted:
+ print("[TechDiagram] Promoted captured original image response as final result.", flush=True)
+ return promoted
+
+
async def _extract_dom_original_asset(page, request_dir: Path) -> dict | None:
"""Try to extract large canvas/blob/data images directly from the page."""
assets = await page.evaluate("""
@@ -791,7 +1061,7 @@ async def _run(input_data: dict) -> int:
request_dir = _request_dir()
profile_directory = str(os.environ.get("BROWSER_AGENT_PROFILE_DIRECTORY") or DEFAULT_PROFILE_DIRECTORY)
user_data_dir = Path(os.environ.get("BROWSER_AGENT_USER_DATA_DIR") or str(DEFAULT_USER_DATA_DIR)).expanduser()
- headless = str(os.environ.get("BROWSER_AGENT_HEADLESS") or "false").strip().lower() in {"1", "true", "yes", "on"}
+ headless = str(os.environ.get("BROWSER_AGENT_HEADLESS") or "true").strip().lower() in {"1", "true", "yes", "on"}
timeout_s = int(os.environ.get("BROWSER_AGENT_TIMEOUT") or "600")
staged_dir, cleanup_dir = bjrt._stage_browser_profile(user_data_dir, profile_directory)
@@ -811,17 +1081,74 @@ async def _run(input_data: dict) -> int:
}
_write_json(request_dir / "wrapper-meta.json", meta)
- browser = BrowserSession(
- browser_profile=BrowserProfile(
- headless=headless,
- user_data_dir=staged_dir,
- profile_directory=profile_directory,
- allowed_domains=DEFAULT_ALLOWED_DOMAINS,
- channel="chrome",
- )
+ control_ctx = brtc.initialize_runtime_contract(
+ request_dir=request_dir,
+ service="chatgpt",
+ runtime_owner="browser_use",
+ wrapper_kind="technology_diagram",
+ profile_directory=profile_directory,
+ user_data_dir=str(user_data_dir),
+ staged_user_data_dir=str(staged_dir or ""),
+ account_identifier=TARGET_ACCOUNT_EMAIL or None,
+ task_id=str(os.environ.get("TASK_ID") or request_dir.name),
+ control_modes={
+ "browser_use_session": True,
+ "playwright_cdp_attach": False,
+ "webwright_bridge": False,
+ },
+ metadata={
+ "request_dir": str(request_dir),
+ "target_url": DEFAULT_URL,
+ "session_reuse": True,
+ "session_lineage": str(os.environ.get("BROWSER_AGENT_SESSION_LINEAGE") or "technology-diagram-painter"),
+ "headless": headless,
+ },
)
+ active_session = brtc.read_active_session(control_ctx, require_lineage_match=False)
+ browser: BrowserSession | None = None
+ reused_existing_session = False
+ keep_session_alive = True
+ finalized = False
+ succeeded = False
+ runtime_cleanup_dir = cleanup_dir
+ runtime_staged_dir = staged_dir
+ if active_session and active_session.get("cdp_url"):
+ try:
+ browser = BrowserSession(
+ cdp_url=str(active_session.get("cdp_url") or "").strip(),
+ browser_profile=BrowserProfile(
+ headless=headless,
+ keep_alive=keep_session_alive,
+ allowed_domains=DEFAULT_ALLOWED_DOMAINS,
+ channel="chrome",
+ ),
+ )
+ await asyncio.wait_for(browser.start(), timeout=20)
+ reused_existing_session = True
+ runtime_cleanup_dir = Path(str((active_session.get("details") or {}).get("cleanup_dir") or "")).expanduser() if str((active_session.get("details") or {}).get("cleanup_dir") or "").strip() else cleanup_dir
+ runtime_staged_dir = str((active_session.get("details") or {}).get("staged_user_data_dir") or "").strip() or staged_dir
+ except Exception:
+ brtc.clear_active_session(control_ctx)
+ browser = None
+ if browser is None:
+ browser = BrowserSession(
+ browser_profile=BrowserProfile(
+ headless=headless,
+ keep_alive=keep_session_alive,
+ user_data_dir=staged_dir,
+ profile_directory=profile_directory,
+ allowed_domains=DEFAULT_ALLOWED_DOMAINS,
+ channel="chrome",
+ )
+ )
try:
- await asyncio.wait_for(browser.start(), timeout=40)
+ if not reused_existing_session:
+ await asyncio.wait_for(browser.start(), timeout=40)
+ brtc.update_runtime_endpoint(
+ control_ctx,
+ cdp_url=str(getattr(browser, "cdp_url", "") or ""),
+ browser_session_ref=f"browser-use-session://chatgpt/{control_ctx['profile_id']}",
+ )
async with async_playwright() as pw:
pw_browser = await pw.chromium.connect_over_cdp(browser.cdp_url)
pw_context = pw_browser.contexts[0] if pw_browser.contexts else None
@@ -835,6 +1162,7 @@ async def _run(input_data: dict) -> int:
print(f"[TechDiagram] Navigating to {DEFAULT_URL}", flush=True)
await playwright_page.goto(DEFAULT_URL, wait_until="domcontentloaded")
await playwright_page.wait_for_timeout(3000)
+ await _wait_for_chat_ready(playwright_page, timeout_s=60)
# 2. Verify account
await _verify_account(playwright_page)
@@ -842,6 +1170,7 @@ async def _run(input_data: dict) -> int:
# 3. Navigate UI
await _click_left_nav_more_and_image(playwright_page)
await _select_model(playwright_page)
+ await _wait_for_chat_ready(playwright_page, timeout_s=45)
# 4. Submit
submitted = await _submit_prompt(playwright_page, full_prompt)
@@ -850,7 +1179,12 @@ async def _run(input_data: dict) -> int:
original_capture["active"] = True
# 5. Wait for image
- result = await _wait_and_download_image(playwright_page, request_dir, timeout_s=timeout_s)
+ result = await _wait_and_download_image(
+ playwright_page,
+ request_dir,
+ timeout_s=timeout_s,
+ capture_state=original_capture,
+ )
if result.get("status") == "success" and str(result.get("url") or "").endswith("fallback"):
original = await _best_original_capture(original_capture, request_dir)
if original:
@@ -871,14 +1205,60 @@ async def _run(input_data: dict) -> int:
if result.get("status") != "success":
return 1
- return 0
+ brtc.activate_reusable_session(
+ control_ctx,
+ cdp_url=str(getattr(browser, "cdp_url", "") or ""),
+ browser_session_ref=f"browser-use-session://chatgpt/{control_ctx['profile_id']}",
+ headless=headless,
+ attached=reused_existing_session,
+ details={
+ "request_dir": str(request_dir),
+ "staged_user_data_dir": str(runtime_staged_dir or ""),
+ "cleanup_dir": str(runtime_cleanup_dir or ""),
+ },
+ )
+ brtc.finalize_runtime_contract(
+ control_ctx,
+ success=True,
+ error_text="",
+ page_state={"url": DEFAULT_URL},
+ logged_in_state_verified=True,
+ details={
+ "provider": "browser_agent_technology_diagram",
+ "request_dir": str(request_dir),
+ "reused_existing_session": reused_existing_session,
+ },
+ requires_precise_page_control=False,
+ )
+ finalized = True
+ succeeded = True
+ _force_wrapper_exit(0)
finally:
try:
- await asyncio.wait_for(browser.stop(), timeout=20)
+ if browser is not None and not succeeded:
+ await asyncio.wait_for(browser.kill(), timeout=20)
except Exception:
pass
- if cleanup_dir is not None:
+ if not finalized:
+ try:
+ brtc.clear_active_session(control_ctx)
+ brtc.finalize_runtime_contract(
+ control_ctx,
+ success=False,
+ error_text="technology_diagram_wrapper_failed",
+ page_state={"url": DEFAULT_URL},
+ logged_in_state_verified=False,
+ details={
+ "provider": "browser_agent_technology_diagram",
+ "request_dir": str(request_dir),
+ "reused_existing_session": reused_existing_session,
+ },
+ requires_precise_page_control=False,
+ )
+ except Exception:
+ pass
+ if cleanup_dir is not None and not succeeded:
import shutil
shutil.rmtree(cleanup_dir, ignore_errors=True)
diff --git a/harness/scripts/browser_agent_youtube_transcript_wrapper.py b/harness/scripts/browser_agent_youtube_transcript_wrapper.py
index e8aa8d8fc..a47902f4f 100644
--- a/harness/scripts/browser_agent_youtube_transcript_wrapper.py
+++ b/harness/scripts/browser_agent_youtube_transcript_wrapper.py
@@ -771,7 +771,7 @@ async def _run(youtube_url: str) -> int:
request_dir = _request_dir()
profile_directory = str(os.environ.get("BROWSER_AGENT_PROFILE_DIRECTORY") or DEFAULT_PROFILE_DIRECTORY)
user_data_dir = Path(os.environ.get("BROWSER_AGENT_USER_DATA_DIR") or str(DEFAULT_USER_DATA_DIR)).expanduser()
- headless = str(os.environ.get("BROWSER_AGENT_HEADLESS") or "false").strip().lower() in {"1", "true", "yes", "on"}
+ headless = str(os.environ.get("BROWSER_AGENT_HEADLESS") or "true").strip().lower() in {"1", "true", "yes", "on"}
allowed_domains = DEFAULT_ALLOWED_DOMAINS
timeout_s = int(os.environ.get("BROWSER_AGENT_YT_TIMEOUT") or "300")
@@ -924,7 +924,7 @@ async def _run(youtube_url: str) -> int:
finally:
try:
- await asyncio.wait_for(browser.stop(), timeout=20)
+ await asyncio.wait_for(browser.kill(), timeout=20)
except Exception:
pass
if cleanup_dir is not None:
diff --git a/harness/scripts/run_youtube_daily_previous_day_collect.sh b/harness/scripts/run_youtube_daily_previous_day_collect.sh
index 58ab3603b..c67c98286 100755
--- a/harness/scripts/run_youtube_daily_previous_day_collect.sh
+++ b/harness/scripts/run_youtube_daily_previous_day_collect.sh
@@ -26,8 +26,8 @@ trap 'rm -rf "$LOCK_DIR"' EXIT INT TERM
export PYTHONPATH="$HARNESS_DIR/lib:${PYTHONPATH:-}"
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${PATH:-}"
export PYTHONIOENCODING="utf-8"
-export BROWSER_AGENT_HEADLESS="${BROWSER_AGENT_HEADLESS:-false}"
-export TECH_HOTSPOT_BROWSER_CHATGPT_HEADLESS="${TECH_HOTSPOT_BROWSER_CHATGPT_HEADLESS:-false}"
+export BROWSER_AGENT_HEADLESS="${BROWSER_AGENT_HEADLESS:-true}"
+export TECH_HOTSPOT_BROWSER_CHATGPT_HEADLESS="${TECH_HOTSPOT_BROWSER_CHATGPT_HEADLESS:-true}"
export BROWSER_AGENT_CHATGPT_PROFILE_POLICY_FILE="${BROWSER_AGENT_CHATGPT_PROFILE_POLICY_FILE:-/Users/lisihao/.solar/harness/browser-agent-chatgpt-local.json}"
read -r YESTERDAY_DATE YESTERDAY_WEEK < <("$PYTHON" - <<'PY'
diff --git a/harness/scripts/tech_hotspot_radar.py b/harness/scripts/tech_hotspot_radar.py
index 83fc06fda..beaea0b38 100755
--- a/harness/scripts/tech_hotspot_radar.py
+++ b/harness/scripts/tech_hotspot_radar.py
@@ -53,6 +53,20 @@
from report_ir import compile_report_ir as runtime_compile_report_ir
from report_ir import create_chapter_jobs as runtime_create_chapter_jobs
from report_synthesis import synthesize_report as runtime_synthesize_report
+from browser_operator_submit import browser_agent_chatgpt_cmd as runtime_browser_agent_chatgpt_cmd
+from browser_operator_submit import build_chatgpt_operator_env as runtime_build_chatgpt_operator_env
+from browser_operator_submit import derive_chatgpt_session_lineage as runtime_derive_chatgpt_session_lineage
+from browser_operator_submit import env_override_bool as runtime_env_override_bool
+from browser_operator_submit import env_override_text as runtime_env_override_text
+from browser_operator_submit import strip_browser_agent_noise as runtime_strip_browser_agent_noise
+from browser_operator_submit import submit_chatgpt_operator_request as runtime_submit_chatgpt_operator_request
+from ai_influence_youtube_report.figures import (
+ build_figure_manifest as runtime_build_figure_manifest,
+)
+from ai_influence_youtube_report.figures import (
+ paint_figure as runtime_paint_technology_diagram_figure,
+)
+from ai_influence_youtube_report.schema import FigureSpec as RuntimeFigureSpec
try:
import yaml
@@ -9531,6 +9545,156 @@ def hf_build_section_writer_prompt(
"""
+def hf_grouped_report_section_batch_size(config: dict[str, Any]) -> int:
+ hf_cfg = dict(config.get("hf_paper_insight") or {})
+ reporting_cfg = dict(hf_cfg.get("reporting") or {})
+ raw_value = reporting_cfg.get("grouped_report_section_batch_size")
+ if raw_value is None:
+ raw_value = hf_cfg.get("grouped_report_section_batch_size")
+ try:
+ return max(int(raw_value or 2), 1)
+ except Exception:
+ return 2
+
+
+def hf_build_section_batch_writer_prompt(
+ sections: list[dict[str, Any]],
+ section_records_map: dict[str, list[dict[str, Any]]],
+ *,
+ date_str: str,
+ model_name: str,
+ report_context: dict[str, Any] | None = None,
+) -> str:
+ context = report_context or hf_report_context(date_str, {})
+ sections_payload: list[dict[str, Any]] = []
+ for section in sections:
+ section_id = str(section.get("section_id") or "").strip()
+ section_payload = []
+ for record in section_records_map.get(section_id) or []:
+ section_payload.append({
+ "paper_id": record.get("paper_id"),
+ "packet_id": record.get("packet_id"),
+ "title": record.get("title"),
+ "summary": record.get("summary"),
+ "taxonomy": record.get("taxonomy"),
+ "scores": record.get("scores"),
+ "github": record.get("github"),
+ "assets": record.get("assets"),
+ "judgment": record.get("judgment"),
+ "why_matters": record.get("why_matters"),
+ "recommended_action": record.get("recommended_action"),
+ "reasoning": record.get("reasoning"),
+ })
+ sections_payload.append(
+ {
+ "section": section,
+ "paper_materials": section_payload,
+ }
+ )
+ return f"""你是 AI Influence 的 HF Paper 批量章节主笔。
+
+你这次要一次写多个趋势部分。请基于每个部分分到的论文,分别写出该部分的趋势描述、洞察分析和规划建议。
+
+硬规则:
+- 只能基于输入的论文材料与已有判断,不要引入外部事实。
+- 不是逐篇复述摘要,而是提炼“这一组论文共同说明了什么变化”。
+- 必须给出每个部分内部每篇论文的角色定位。
+- 每个核心判断都要带 evidence_ids。
+- 输出必须是合法 JSON object,不要 Markdown,不要代码块,不要解释系统行为。
+
+输出 JSON schema:
+{{
+ "sections": [
+ {{
+ "section_id": "部分ID",
+ "title": "部分标题",
+ "trend_type": "real_trend|weak_signal|hype|watchlist",
+ "section_summary": "一段100-180字的部分摘要",
+ "trend_description": "该部分趋势描述",
+ "insight_analysis": "该部分洞察分析",
+ "planning_recommendations": ["规划建议1", "规划建议2"],
+ "paper_commentary": [
+ {{
+ "paper_id": "论文ID",
+ "title": "论文标题",
+ "role": "这篇论文在该部分里的角色",
+ "takeaway": "这篇论文最值得看的点",
+ "evidence_ids": ["paper_id", "packet_id"]
+ }}
+ ],
+ "evidence_ids": ["paper_id 或 packet_id"],
+ "evidence_gap": []
+ }}
+ ]
+}}
+
+报告周期:{context.get('window_label') or date_str}
+报告日期:{date_str}
+模型:{model_name}
+
+批量部分规划与论文材料:
+{json.dumps(sections_payload, ensure_ascii=False, indent=2)}
+"""
+
+
+def hf_call_grouped_report_section_batch(
+ sections: list[dict[str, Any]],
+ section_records_map: dict[str, list[dict[str, Any]]],
+ config: dict[str, Any],
+ *,
+ date_str: str,
+ model_name: str,
+ report_context: dict[str, Any] | None = None,
+ batch_index: int = 1,
+) -> list[dict[str, Any]]:
+ payload = hf_call_report_json_with_repair(
+ hf_build_section_batch_writer_prompt(
+ sections,
+ section_records_map,
+ date_str=date_str,
+ model_name=model_name,
+ report_context=report_context,
+ ),
+ config,
+ purpose=f"hf-paper-report-sections-{date_str}-batch-{batch_index:02d}",
+ model_name=model_name,
+ chapter_id=f"hf-report-sections-batch-{batch_index:02d}",
+ required_keys=["sections"],
+ )
+ items = payload.get("sections") or []
+ if not isinstance(items, list) or not items:
+ raise ValueError(f"hf_grouped_report_section_batch_missing_sections:{batch_index}")
+ by_id = {
+ str(item.get("section_id") or "").strip(): item
+ for item in items
+ if isinstance(item, dict) and str(item.get("section_id") or "").strip()
+ }
+ required_keys = [
+ "title",
+ "section_summary",
+ "trend_description",
+ "insight_analysis",
+ "planning_recommendations",
+ "paper_commentary",
+ ]
+ normalized: list[dict[str, Any]] = []
+ missing_sections: list[str] = []
+ for section in sections:
+ section_id = str(section.get("section_id") or "").strip()
+ item = by_id.get(section_id)
+ if not isinstance(item, dict):
+ missing_sections.append(section_id or "unknown")
+ continue
+ missing = [key for key in required_keys if _hf_missing_value(item.get(key))]
+ if missing:
+ raise ValueError(f"hf_grouped_report_section_batch_missing_keys:{section_id}:{missing}")
+ item["paper_ids"] = list(section.get("paper_ids") or [])
+ normalized.append(item)
+ if missing_sections:
+ raise ValueError(f"hf_grouped_report_section_batch_missing_section_ids:{','.join(missing_sections)}")
+ return normalized
+
+
def hf_call_report_json_with_repair(prompt: str, config: dict[str, Any], *, purpose: str, model_name: str, chapter_id: str, required_keys: list[str], max_attempts: int = 2) -> dict[str, Any]:
high_cfg, _mode = hf_paper_high_reasoning_config(config, "browser_agent")
errors: list[str] = []
@@ -9612,20 +9776,52 @@ def hf_call_grouped_report_flow(
plan = hf_normalize_report_plan(raw_plan, public_records, date_str=date_str, report_context=context)
record_map = {str(item.get("paper_id") or "").strip(): item for item in public_records}
sections: list[dict[str, Any]] = []
+ section_jobs: list[dict[str, Any]] = []
for idx, section in enumerate(plan.get("sections") or [], 1):
section_records = [record_map[pid] for pid in section.get("paper_ids") or [] if pid in record_map]
if not section_records:
continue
- section_payload = hf_call_report_json_with_repair(
- hf_build_section_writer_prompt(section, section_records, date_str=date_str, model_name=model_name, report_context=context),
- config,
- purpose=f"hf-paper-report-section-{date_str}-{section.get('section_id') or idx}",
- model_name=model_name,
- chapter_id=str(section.get("section_id") or f"section-{idx}"),
- required_keys=["title", "section_summary", "trend_description", "insight_analysis", "planning_recommendations", "paper_commentary"],
+ section_jobs.append(
+ {
+ "section": section,
+ "records": section_records,
+ "index": idx,
+ }
+ )
+ batch_size = hf_grouped_report_section_batch_size(config)
+ for batch_index, offset in enumerate(range(0, len(section_jobs), batch_size), start=1):
+ batch = section_jobs[offset:offset + batch_size]
+ if batch_size <= 1:
+ for item in batch:
+ section = item["section"]
+ section_payload = hf_call_report_json_with_repair(
+ hf_build_section_writer_prompt(
+ section,
+ item["records"],
+ date_str=date_str,
+ model_name=model_name,
+ report_context=context,
+ ),
+ config,
+ purpose=f"hf-paper-report-section-{date_str}-{section.get('section_id') or item['index']}",
+ model_name=model_name,
+ chapter_id=str(section.get("section_id") or f"section-{item['index']}"),
+ required_keys=["title", "section_summary", "trend_description", "insight_analysis", "planning_recommendations", "paper_commentary"],
+ )
+ section_payload["paper_ids"] = list(section.get("paper_ids") or [])
+ sections.append(section_payload)
+ continue
+ sections.extend(
+ hf_call_grouped_report_section_batch(
+ [item["section"] for item in batch],
+ {str(item["section"].get("section_id") or ""): item["records"] for item in batch},
+ config,
+ date_str=date_str,
+ model_name=model_name,
+ report_context=context,
+ batch_index=batch_index,
+ )
)
- section_payload["paper_ids"] = list(section.get("paper_ids") or [])
- sections.append(section_payload)
if not sections:
raise ValueError("hf_grouped_report_no_sections")
return {
@@ -9636,6 +9832,376 @@ def hf_call_grouped_report_flow(
}
+_HF_FIGURE_ARCHITECTURE_HINTS = (
+ "architecture",
+ "架构",
+ "system",
+ "系统",
+ "platform",
+ "平台",
+ "infra",
+ "component",
+ "模块",
+ "ecosystem",
+ "生态",
+)
+_HF_FIGURE_FLOW_HINTS = (
+ "flow",
+ "流程",
+ "pipeline",
+ "route",
+ "路径",
+ "trend",
+ "趋势",
+ "演进",
+ "转化",
+ "project",
+ "规划",
+)
+_HF_FIGURE_STACK_HINTS = (
+ "stack",
+ "技术栈",
+ "layer",
+ "分层",
+ "toolchain",
+ "模型",
+ "数据",
+ "benchmark",
+ "sdk",
+)
+
+
+def browser_agent_technology_diagram_cmd(config: dict[str, Any]) -> list[str]:
+ figure_cfg = (((config.get("hf_paper_insight") or {}).get("figure_bundle") or {}))
+ cmd = (
+ os.environ.get("TECH_HOTSPOT_BROWSER_TECH_DIAGRAM_CMD")
+ or os.environ.get("BROWSER_AGENT_TECH_DIAGRAM_OPERATOR_CMD")
+ or os.environ.get("BROWSER_AGENT_TECH_DIAGRAM_CMD")
+ or str(figure_cfg.get("cmd") or "")
+ ).strip()
+ if cmd:
+ return shlex.split(cmd)
+ operator = HARNESS_TOOLS_DIR / "technology_diagram_painter_operator.py"
+ browser_use_python = Path.home() / ".claude" / "mcp-servers" / "browser-use" / ".venv" / "bin" / "python"
+ if operator.exists() and browser_use_python.exists():
+ return [str(browser_use_python), str(operator)]
+ if operator.exists():
+ return [sys.executable, str(operator)]
+ return []
+
+
+def hf_figure_bundle_config(config: dict[str, Any]) -> dict[str, Any]:
+ base = ((config.get("hf_paper_insight") or {}).get("figure_bundle") or {})
+ return {
+ "enabled": bool(base.get("enabled", True)),
+ "timeout_seconds": int(base.get("timeout_seconds") or 900),
+ "max_figures": max(0, int(base.get("max_figures") or 3)),
+ "operator_script": str(base.get("operator_script") or "").strip(),
+ "python_executable": str(base.get("python_executable") or "").strip(),
+ }
+
+
+def _hf_grouped_report_signal_text(section: dict[str, Any]) -> str:
+ parts = [
+ str(section.get("title") or ""),
+ str(section.get("section_summary") or ""),
+ str(section.get("trend_description") or ""),
+ str(section.get("insight_analysis") or ""),
+ " ".join(_hf_list(section.get("planning_recommendations"))),
+ ]
+ for item in section.get("paper_commentary") or []:
+ if not isinstance(item, dict):
+ continue
+ parts.extend(
+ [
+ str(item.get("title") or ""),
+ str(item.get("role") or ""),
+ str(item.get("takeaway") or ""),
+ ]
+ )
+ return " ".join(parts).lower()
+
+
+def _hf_pick_figure_type(signal_text: str) -> str:
+ if any(token in signal_text for token in _HF_FIGURE_STACK_HINTS):
+ return "technology_stack"
+ if any(token in signal_text for token in _HF_FIGURE_FLOW_HINTS):
+ return "trend_flow"
+ return "architecture_overview"
+
+
+def _hf_figure_prompt(spec: RuntimeFigureSpec) -> str:
+ outline = "\n".join(f"- {item}" for item in spec.input_outline if str(item).strip())
+ evidence = ", ".join(spec.evidence_refs) if spec.evidence_refs else "N/A"
+ sections = ", ".join(spec.source_chapter_ids) if spec.source_chapter_ids else "N/A"
+ return "\n".join(
+ [
+ f"Figure Type: {spec.figure_type}",
+ f"Figure Title: {spec.title}",
+ f"Placement: {spec.placement}",
+ f"Source Sections: {sections}",
+ f"Evidence Refs: {evidence}",
+ "",
+ "请只基于以下结构化要点绘制技术洞察图,不得引入正文中不存在的模块、流程或层级:",
+ outline or "- N/A",
+ "",
+ "输出一张适合嵌入 Hugging Face paper insight 报告正文的正式 Figure。",
+ ]
+ ).strip()
+
+
+def hf_build_grouped_report_figure_specs(
+ public_records: list[dict[str, Any]],
+ grouped_report: dict[str, Any],
+ *,
+ max_figures: int = 3,
+) -> list[RuntimeFigureSpec]:
+ if max_figures <= 0:
+ return []
+ plan = grouped_report.get("plan") or {}
+ sections = grouped_report.get("sections") or []
+ record_by_id = {
+ str(item.get("paper_id") or "").strip(): item
+ for item in public_records
+ if str(item.get("paper_id") or "").strip()
+ }
+ specs: list[RuntimeFigureSpec] = []
+ lead_refs: list[str] = []
+ lead_outline: list[str] = []
+ for idx, section in enumerate(sections[:3], start=1):
+ title = _hf_text(section.get("title"), default=f"趋势部分 {idx}")
+ summary = hf_clean_public_text(_hf_text(section.get("section_summary"), default=""))
+ lead_outline.append(f"Section: {title}")
+ if summary:
+ lead_outline.append(f"Summary: {summary}")
+ for ref in _hf_list(section.get("evidence_ids")):
+ clean = str(ref or "").strip()
+ if clean and clean not in lead_refs:
+ lead_refs.append(clean)
+ for item in section.get("paper_commentary") or []:
+ if not isinstance(item, dict):
+ continue
+ paper_id = str(item.get("paper_id") or "").strip()
+ if paper_id and paper_id not in lead_refs:
+ lead_refs.append(paper_id)
+ if lead_refs:
+ lead = RuntimeFigureSpec(
+ figure_id="fig_01",
+ title=f"{_hf_text(plan.get('headline'), default='HF Paper Insight')} - Overview",
+ figure_type="architecture_overview",
+ placement="report_lead",
+ source_chapter_ids=[
+ str(section.get("section_id") or f"section-{idx}")
+ for idx, section in enumerate(sections[:3], start=1)
+ ],
+ evidence_refs=lead_refs[:8],
+ input_outline=lead_outline[:8] or ["HF paper insight grouped overview"],
+ render_prompt="",
+ caption="图 1:基于本期 grouped report 章节归纳出的整体结构图。",
+ )
+ specs.append(lead)
+
+ seen_types = {item.figure_type for item in specs}
+ for idx, section in enumerate(sections, start=1):
+ if len(specs) >= max_figures:
+ break
+ refs: list[str] = []
+ for ref in _hf_list(section.get("evidence_ids")):
+ clean = str(ref or "").strip()
+ if clean and clean not in refs:
+ refs.append(clean)
+ paper_ids: list[str] = []
+ for item in section.get("paper_commentary") or []:
+ if not isinstance(item, dict):
+ continue
+ paper_id = str(item.get("paper_id") or "").strip()
+ if paper_id and paper_id not in paper_ids:
+ paper_ids.append(paper_id)
+ for ref in _hf_list(item.get("evidence_ids")):
+ clean = str(ref or "").strip()
+ if clean and clean not in refs:
+ refs.append(clean)
+ if not refs:
+ refs = paper_ids[:]
+ if not refs:
+ continue
+ signal_text = _hf_grouped_report_signal_text(section)
+ figure_type = _hf_pick_figure_type(signal_text)
+ if figure_type in seen_types:
+ continue
+ outline = [
+ f"Section: {_hf_text(section.get('title'), default=f'趋势部分 {idx}')}",
+ f"Trend Type: {hf_public_trend_label(section.get('trend_type'))}",
+ f"Summary: {hf_clean_public_text(_hf_text(section.get('section_summary'), default='待补'))}",
+ ]
+ for rec in _hf_list(section.get("planning_recommendations"))[:3]:
+ outline.append(f"Recommendation: {hf_clean_public_text(rec)}")
+ for paper_id in paper_ids[:3]:
+ record = record_by_id.get(paper_id) or {}
+ title = str(record.get("title") or paper_id)
+ route = str(((record.get("taxonomy") or {}).get("research_route")) or "")
+ stack = str(((record.get("taxonomy") or {}).get("stack_layer")) or "")
+ outline.append(f"Paper: {title} | Route: {route or 'N/A'} | Layer: {stack or 'N/A'}")
+ spec = RuntimeFigureSpec(
+ figure_id=f"fig_{len(specs) + 1:02d}",
+ title=_hf_text(section.get("title"), default=f"趋势部分 {idx}"),
+ figure_type=figure_type,
+ placement="section_inline",
+ source_chapter_ids=[str(section.get("section_id") or f"section-{idx}")],
+ evidence_refs=refs[:8],
+ input_outline=outline[:10],
+ render_prompt="",
+ caption=f"图 {len(specs) + 1}:{_hf_text(section.get('title'), default=f'趋势部分 {idx}')} 的 {figure_type} 图示。",
+ )
+ specs.append(spec)
+ seen_types.add(figure_type)
+
+ rendered: list[RuntimeFigureSpec] = []
+ for spec in specs:
+ rendered.append(
+ RuntimeFigureSpec(
+ figure_id=spec.figure_id,
+ title=spec.title,
+ figure_type=spec.figure_type,
+ placement=spec.placement,
+ source_chapter_ids=list(spec.source_chapter_ids),
+ evidence_refs=list(spec.evidence_refs),
+ input_outline=list(spec.input_outline),
+ render_prompt=_hf_figure_prompt(spec),
+ caption=spec.caption,
+ status=spec.status,
+ )
+ )
+ return rendered
+
+
+def hf_generate_grouped_report_figure_bundle(
+ public_records: list[dict[str, Any]],
+ grouped_report: dict[str, Any],
+ config: dict[str, Any],
+ *,
+ out_dir: str | Path,
+) -> dict[str, Any]:
+ bundle_cfg = hf_figure_bundle_config(config)
+ figures_dir = Path(out_dir).expanduser() / "hf-paper-figures"
+ figures_dir.mkdir(parents=True, exist_ok=True)
+ operator_script = bundle_cfg["operator_script"] or None
+ python_executable = bundle_cfg["python_executable"] or None
+ resolved_cmd = browser_agent_technology_diagram_cmd(config)
+ if (not operator_script or not python_executable) and len(resolved_cmd) >= 2:
+ python_executable = python_executable or resolved_cmd[0]
+ operator_script = operator_script or resolved_cmd[1]
+ if not bundle_cfg["enabled"]:
+ manifest = runtime_build_figure_manifest("hf-paper-figure-bundle", [], validator_overall="SKIPPED").to_dict()
+ (figures_dir / "hf-paper-figure-manifest.json").write_text(
+ json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ return {"enabled": False, "figures": [], "manifest": manifest, "figures_dir": str(figures_dir)}
+
+ specs = hf_build_grouped_report_figure_specs(
+ public_records,
+ grouped_report,
+ max_figures=int(bundle_cfg["max_figures"]),
+ )
+ for spec in specs:
+ (figures_dir / f"{spec.figure_id}.spec.json").write_text(
+ json.dumps(spec.to_dict(), ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ if not specs:
+ manifest = runtime_build_figure_manifest("hf-paper-figure-bundle", [], validator_overall="SKIPPED").to_dict()
+ manifest_path = figures_dir / "hf-paper-figure-manifest.json"
+ manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+ return {
+ "enabled": True,
+ "figures": [],
+ "manifest": manifest,
+ "manifest_path": str(manifest_path),
+ "figures_dir": str(figures_dir),
+ "painted_count": 0,
+ "failed_count": 0,
+ "skipped_count": 0,
+ }
+ figure_results = [
+ runtime_paint_technology_diagram_figure(
+ spec,
+ run_dir=figures_dir,
+ operator_script=operator_script,
+ python_executable=python_executable,
+ timeout_seconds=int(bundle_cfg["timeout_seconds"]),
+ )
+ for spec in specs
+ ]
+ for figure in figure_results:
+ (figures_dir / f"{figure.figure_id}.result.json").write_text(
+ json.dumps(figure.to_dict(), ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ manifest = runtime_build_figure_manifest(
+ "hf-paper-figure-bundle",
+ figure_results,
+ validator_overall="PASS" if all(item.status != "failed" for item in figure_results) else "WARN",
+ ).to_dict()
+ manifest_path = figures_dir / "hf-paper-figure-manifest.json"
+ manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+ return {
+ "enabled": True,
+ "figures": [item.to_dict() for item in figure_results],
+ "manifest": manifest,
+ "manifest_path": str(manifest_path),
+ "figures_dir": str(figures_dir),
+ "painted_count": int(manifest.get("painted_count") or 0),
+ "failed_count": int(manifest.get("failed_count") or 0),
+ "skipped_count": int(manifest.get("skipped_count") or 0),
+ }
+
+
+def _hf_render_grouped_report_figure_markdown(figure: dict[str, Any]) -> str:
+ image_path = str(figure.get("image_path") or "").strip()
+ if not image_path:
+ return ""
+ title = str(figure.get("title") or figure.get("figure_id") or "Figure")
+ caption = str(figure.get("caption") or "").strip()
+ evidence = ", ".join(str(ref) for ref in figure.get("evidence_refs") or [])
+ parts = [f""]
+ if caption:
+ parts.append(caption)
+ if evidence:
+ parts.append(f"证据引用:{evidence}")
+ return "\n\n".join(parts).strip()
+
+
+def _hf_render_grouped_report_figure_html(figure: dict[str, Any]) -> str:
+ image_path = str(figure.get("image_path") or "").strip()
+ if not image_path:
+ return ""
+ title = html.escape(str(figure.get("title") or figure.get("figure_id") or "Figure"))
+ caption = html.escape(str(figure.get("caption") or "").strip())
+ evidence = ", ".join(html.escape(str(ref)) for ref in figure.get("evidence_refs") or [])
+ return (
+ ''
+ f''
+ f"{title}"
+ + (f" {caption}" if caption else "")
+ + (f' 证据引用:{evidence}' if evidence else "")
+ + ""
+ )
+
+
+def _hf_grouped_figures_for_section(figures: list[dict[str, Any]], section_id: str) -> list[dict[str, Any]]:
+ target = str(section_id or "").strip()
+ if not target:
+ return []
+ return [
+ item
+ for item in figures
+ if str(item.get("placement") or "") == "section_inline"
+ and target in {str(ref) for ref in item.get("source_chapter_ids") or []}
+ and str(item.get("status") or "") == "painted"
+ ]
+
+
def _hf_render_grouped_report_markdown(
*,
date_str: str,
@@ -9644,11 +10210,13 @@ def _hf_render_grouped_report_markdown(
fallback_count: int,
public_records: list[dict[str, Any]],
grouped_report: dict[str, Any],
+ figures: list[dict[str, Any]] | None = None,
report_context: dict[str, Any] | None = None,
) -> str:
context = report_context or hf_report_context(date_str, {})
plan = grouped_report.get("plan") or {}
sections = grouped_report.get("sections") or []
+ figure_rows = figures or []
public_variant = hf_public_report_variant_label(report_variant)
lines = [
f"# {hf_public_headline(plan.get('headline'), context, premium=True)}",
@@ -9676,9 +10244,30 @@ def _hf_render_grouped_report_markdown(
f"| 周期 | {context.get('window_label') or date_str} |",
"",
])
+ lead_figures = [
+ _hf_render_grouped_report_figure_markdown(item)
+ for item in figure_rows
+ if str(item.get("placement") or "") == "report_lead" and str(item.get("status") or "") == "painted"
+ ]
+ lead_figures = [item for item in lead_figures if item.strip()]
+ if lead_figures:
+ lines.extend([
+ "## 关键图示",
+ "",
+ *lead_figures,
+ "",
+ ])
for idx, section in enumerate(sections, 1):
recommendations = _hf_list(section.get("planning_recommendations"))
commentary = section.get("paper_commentary") if isinstance(section.get("paper_commentary"), list) else []
+ section_figures = [
+ _hf_render_grouped_report_figure_markdown(item)
+ for item in _hf_grouped_figures_for_section(
+ figure_rows,
+ str(section.get("section_id") or f"section-{idx}"),
+ )
+ ]
+ section_figures = [item for item in section_figures if item.strip()]
lines.extend([
f"## {idx:02d}. {_hf_text(section.get('title'), default=f'趋势部分 {idx}')}",
"",
@@ -9686,6 +10275,11 @@ def _hf_render_grouped_report_markdown(
"",
f"- 趋势判断:`{hf_public_trend_label(section.get('trend_type'))}`",
"",
+ ])
+ if section_figures:
+ lines.extend(section_figures)
+ lines.append("")
+ lines.extend([
"### 趋势描述",
"",
hf_clean_public_text(_hf_text(section.get("trend_description"), default="待补")),
@@ -9732,11 +10326,13 @@ def _hf_render_grouped_report_html(
fallback_count: int,
public_records: list[dict[str, Any]],
grouped_report: dict[str, Any],
+ figures: list[dict[str, Any]] | None = None,
report_context: dict[str, Any] | None = None,
) -> str:
context = report_context or hf_report_context(date_str, {})
plan = grouped_report.get("plan") or {}
sections = grouped_report.get("sections") or []
+ figure_rows = figures or []
public_variant = hf_public_report_variant_label(report_variant)
metric_cards = [
("报告类型", public_variant),
@@ -9748,6 +10344,11 @@ def _hf_render_grouped_report_html(
f'
{html.escape(label)}{html.escape(value)}
'
for label, value in metric_cards
)
+ lead_figures_html = "".join(
+ _hf_render_grouped_report_figure_html(item)
+ for item in figure_rows
+ if str(item.get("placement") or "") == "report_lead" and str(item.get("status") or "") == "painted"
+ )
section_html = []
for idx, section in enumerate(sections, 1):
recommendations = "".join(f"
{html.escape(hf_clean_public_text(item))}
" for item in _hf_list(section.get("planning_recommendations"))) or "