diff --git a/harness/.gitignore b/harness/.gitignore index 19c250c47..cd1efd021 100644 --- a/harness/.gitignore +++ b/harness/.gitignore @@ -17,6 +17,7 @@ usage/ monitor-drafts/ _raw_mirror/ _state_mirror/ +actors/ scripts/__pycache__/ google-cse-token.json google-cse-client_secret.json diff --git a/harness/config/agent-actors.json b/harness/config/agent-actors.json index 630d9b979..65d063a3d 100644 --- a/harness/config/agent-actors.json +++ b/harness/config/agent-actors.json @@ -2460,6 +2460,123 @@ } ] }, + "mini-chatgpt-requirement-writer": { + "actor_id": "mini-chatgpt-requirement-writer", + "host_id": "mini", + "operator_alias": "mini-chatgpt-requirement-writer", + "aliases": [ + "mini-chatgpt-requirement-writer" + ], + "role": "planner", + "display_meta": { + "display_name": "GPT Requirement Writer", + "tmux_session": "solar-harness-multi-task", + "tmux_window": null, + "tmux_pane_index": null + }, + "lease": { + "acquired_at": null, + "expires_at": null, + "renewable": true, + "preemptible": true, + "heartbeat_timeout_sec": 120, + "lease_id": null, + "holder_sprint": null + }, + "mailbox": { + "inbox": "actors/mini-chatgpt-requirement-writer/inbox", + "outbox": "actors/mini-chatgpt-requirement-writer/outbox", + "logs": "actors/mini-chatgpt-requirement-writer/logs", + "state_json": "actors/mini-chatgpt-requirement-writer/state.json", + "heartbeat_json": "actors/mini-chatgpt-requirement-writer/heartbeat.json" + }, + "context_packet_ref": { + "path": null, + "packet_id": null + }, + "evidence_ledger_ref": { + "path": "actors/mini-chatgpt-requirement-writer/evidence" + }, + "capability": { + "research": 5, + "browser": 2, + "multimodal": 2, + "reliability": 4 + }, + "capability_profile": { + "architecture_reasoning": 4, + "code_impl": 1, + "root_cause_debug": 2, + "test_generation": 1, + "test_execution": 1, + "research_synthesis": 4, + "academic_critique": 3, + "browser_use": 2, + "gui_use": 1, + "long_context": 5, + "multi_agent_coordination": 2, + "speed": 2 + }, + "risk_profile": { + "allowed_write_scope": "project", + "allowed_shell_scope": "denied", + "allowed_network": "allowed", + "allowed_secrets": "secret_ref_only", + "destructive_actions": "denied", + "git_commit": "denied", + "git_push": "denied", + "payment_or_external_action": "denied", + "requires_human_for": [ + "payment_or_external_action" + ] + }, + "cost_profile": { + "cost_tier": "high", + "token_budget_class": "large", + "quota_period": "monthly", + "reserve_ratio": 0.2, + "effort": "heavy", + "prefer_for": [ + "requirement-design", + "spec-authoring", + "chaptered-prd" + ], + "avoid_for": [ + "code-implementation" + ] + }, + "quota": { + "quota_type": "webapp-account", + "period": "unknown", + "on_exhausted": "disable_and_fallback" + }, + "policy": { + "write_files": "artifact_dir_only", + "run_shell": "denied", + "network": "allowed", + "secrets_access": "secret_ref_only", + "git_commit": "denied" + }, + "evidence": { + "last_smoke_at": null, + "last_smoke_result": null, + "last_task_at": null, + "last_task_id": null, + "last_task_result": null, + "provenance": "generated from physical-operators.json:mini-chatgpt-requirement-writer at 2026-06-03T19:55:00Z for raw-requirement requirement writer routing" + }, + "persona_binding": { + "persona_id": "builder", + "persona_file": "personas/builder.md", + "knobs_override": {} + }, + "fallback_ladder": [ + { + "actor_id": "mini-claude-opus-planner", + "condition": "quota_exhausted" + } + ] + }, "mini-youtube-transcript-extractor": { "actor_id": "mini-youtube-transcript-extractor", "host_id": "mini", diff --git a/harness/config/logical-operators.json b/harness/config/logical-operators.json index 60764c8b8..b37760e1d 100644 --- a/harness/config/logical-operators.json +++ b/harness/config/logical-operators.json @@ -302,6 +302,21 @@ "singleton": false } }, + "GPTRequirementWriter": { + "operator_type": "GPTRequirementWriter", + "description": "Routes raw user requirements to a ChatGPT Thinking High browser agent that expands them into chaptered requirement designs for downstream requirement compilation.", + "primary_role": "planner", + "required_capabilities": { + "research_synthesis": 4, + "long_context": 5, + "browser_use": 2 + }, + "cost_hint": "high", + "concurrency": { + "max_parallel": 2, + "singleton": false + } + }, "WebwrightPlaywright": { "operator_type": "WebwrightPlaywright", "description": "Runs deterministic Playwright/Webwright browser automation for browser research, UI inspection, and web artifact collection.", @@ -1084,12 +1099,12 @@ "operator_type": "DeepResearchGemini", "candidates": [ { - "actor_id": "mini-gemini-deep-research", + "actor_id": "browser_agent_session", "priority": 1, "condition": "always" }, { - "actor_id": "browser_agent_session", + "actor_id": "mini-gemini-deep-research", "priority": 2, "condition": "quota_ok" }, @@ -1105,8 +1120,30 @@ "DeepResearchChatGPT": { "operator_type": "DeepResearchChatGPT", "candidates": [ + { + "actor_id": "browser_agent_session", + "priority": 1, + "condition": "always" + }, { "actor_id": "mini-chatgpt-deep-research", + "priority": 2, + "condition": "quota_ok" + }, + { + "actor_id": "mini-claude-opus-planner", + "priority": 3, + "condition": "always" + } + ], + "selection_policy": "priority_first", + "fallback_policy": "queue" + }, + "GPTRequirementWriter": { + "operator_type": "GPTRequirementWriter", + "candidates": [ + { + "actor_id": "mini-chatgpt-requirement-writer", "priority": 1, "condition": "always" }, diff --git a/harness/config/logical-operators.schema.json b/harness/config/logical-operators.schema.json index d8476bf73..e6e13a05e 100644 --- a/harness/config/logical-operators.schema.json +++ b/harness/config/logical-operators.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://solar.local/schemas/logical-operators.schema.json", "title": "Solar Logical Operator Registry", - "description": "Routing abstraction layer between DAG nodes and physical actor assignments. Defines the 16 P0 logical operator types, their bindings to candidate actor_ids, and the DAG node constraint that prevents direct physical id use.", + "description": "Routing abstraction layer between DAG nodes and physical actor assignments. Defines the P0 logical operator types, their bindings to candidate actor_ids, and the DAG node constraint that prevents direct physical id use.", "type": "object", "required": ["version", "logical_operators", "bindings"], "additionalProperties": false, @@ -53,6 +53,7 @@ "DeepResearchBrowser", "DeepResearchGemini", "DeepResearchChatGPT", + "GPTRequirementWriter", "WebwrightPlaywright", "BrowserUseMcp", "YoutubeTranscriptExtractor", diff --git a/harness/config/operator_registry.json b/harness/config/operator_registry.json index 6ca465ef7..99431029e 100644 --- a/harness/config/operator_registry.json +++ b/harness/config/operator_registry.json @@ -62,6 +62,15 @@ "schedule": "on_demand", "output_dir": "reports/chatgpt-report-chapters/" }, + "chatgpt_requirement_writer": { + "primary": "tools/chatgpt_requirement_writer_operator.py", + "executors": [ + "scripts/browser_agent_chatgpt_wrapper.py" + ], + "fallback": [], + "schedule": "on_demand", + "output_dir": "reports/chatgpt-requirement-writer/" + }, "technology_diagram": { "primary": "tools/technology_diagram_painter_operator.py", "executors": [ diff --git a/harness/config/operator_schedules.json b/harness/config/operator_schedules.json index d5904cf12..d76f4443d 100644 --- a/harness/config/operator_schedules.json +++ b/harness/config/operator_schedules.json @@ -44,6 +44,13 @@ "type": "manual", "cron": null }, + "chatgpt_requirement_writer": { + "line": "chatgpt_requirement_writer", + "primary": "tools/chatgpt_requirement_writer_operator.py", + "source_schedule": "on_demand", + "type": "manual", + "cron": null + }, "technology_diagram": { "line": "technology_diagram", "primary": "tools/technology_diagram_painter_operator.py", diff --git a/harness/config/physical-operators.json b/harness/config/physical-operators.json index c04a1c323..68685ee29 100644 --- a/harness/config/physical-operators.json +++ b/harness/config/physical-operators.json @@ -1837,6 +1837,84 @@ }, "deprecated": true }, + "mini-chatgpt-requirement-writer": { + "display_name": "GPT Requirement Writer Browser Agent", + "plane": "interactive_browser", + "owner_host": "lisihao@100.122.223.55", + "pane": "solar-harness-multi-task:*", + "profile": "browser-agent", + "role": "planner", + "persona": "builder", + "provider": "openai", + "vendor": "OpenAI", + "backend": "command", + "model": "chatgpt-5.5", + "model_config": "ChatGPT Web UI Thinking high requirement writer; raw requirement first; project=杂项", + "base_url": "https://chatgpt.com", + "auth_mode": "oauth", + "key_ref": "chatgpt_browser_profile", + "quota_cycle": "webapp-account", + "quota_refresh_at": "unknown", + "quota_guard_state": "ok", + "enabled": true, + "disabled_reason": "", + "enabled_at": "2026-06-03T00:00:00Z", + "available": true, + "health_status": "ok", + "roles": [ + "planner", + "knowledge-extractor" + ], + "task_classes": [ + "requirement-design", + "prd-planning", + "spec-authoring", + "browser-research" + ], + "strengths": [ + "chatgpt-thinking-high", + "chaptered-requirements", + "raw-requirement-grounding", + "browser-agent" + ], + "preferred_for": [ + "raw-requirement-expansion", + "requirement-design", + "chaptered-prd" + ], + "avoid_for": [ + "direct-code-edit", + "cheap-background" + ], + "cost_tier": "high", + "latency_tier": "high", + "context_tier": "high", + "max_concurrency": 1, + "fallback_profile": "builder", + "command": "PATH=\"/opt/homebrew/bin:/usr/local/bin:$PATH\" CHATGPT_MODEL=\"${CHATGPT_MODEL:-chatgpt-5.5}\" CHATGPT_REQUIREMENT_WRITER_MODEL=\"${CHATGPT_REQUIREMENT_WRITER_MODEL:-${CHATGPT_MODEL:-chatgpt-5.5}}\" CHATGPT_REASONING_EFFORT=\"${CHATGPT_REASONING_EFFORT:-high}\" BROWSER_AGENT_CHATGPT_MODEL_MODE=\"${BROWSER_AGENT_CHATGPT_MODEL_MODE:-thinking}\" BROWSER_AGENT_CHATGPT_REQUIRE_UI_MODE=\"${BROWSER_AGENT_CHATGPT_REQUIRE_UI_MODE:-true}\" BROWSER_AGENT_CHATGPT_PROJECT_NAME=\"${BROWSER_AGENT_CHATGPT_PROJECT_NAME:-杂项}\" BROWSER_AGENT_PROFILE_DIRECTORY=\"${BROWSER_AGENT_PROFILE_DIRECTORY:-Profile 1}\" BROWSER_AGENT_PROFILE_STRATEGY=\"${BROWSER_AGENT_PROFILE_STRATEGY:-persistent}\" BROWSER_AGENT_CHATGPT_PROFILE_STRATEGY=\"${BROWSER_AGENT_CHATGPT_PROFILE_STRATEGY:-persistent}\" BROWSER_AGENT_TARGET_ACCOUNT_EMAIL=\"${BROWSER_AGENT_TARGET_ACCOUNT_EMAIL:-browser-agent@example.com}\" BROWSER_AGENT_CHATGPT_ACCOUNT_EMAIL=\"${BROWSER_AGENT_CHATGPT_ACCOUNT_EMAIL:-browser-agent@example.com}\" BROWSER_AGENT_USER_DATA_DIR=\"${BROWSER_AGENT_USER_DATA_DIR:-/Users/lisihao/Library/Application Support/Google/Chrome}\" BROWSER_AGENT_CHATGPT_PROFILE_POLICY_FILE=\"${BROWSER_AGENT_CHATGPT_PROFILE_POLICY_FILE:-/Users/lisihao/.solar/harness/browser-agent-chatgpt-local.json}\" python3 \"$HARNESS_DIR/tools/chatgpt_requirement_writer_operator.py\"", + "policy": { + "write_files": "artifact_dir_only", + "run_shell": "restricted_operator_command", + "network": "allowed", + "secrets_access": "secret_ref_only", + "git_commit": "denied" + }, + "flow_control": { + "success_cooldown_seconds": 180, + "rate_limit_cooldown_seconds": 3600, + "auth_cooldown_seconds": 21600, + "defer_on_cooldown": true, + "defer_on_auth": true + }, + "state": { + "availability": "enabled", + "runtime_state": "idle", + "cooldown_until": null, + "last_heartbeat_at": null, + "last_success_at": null, + "last_error": null + } + }, "mini-youtube-transcript-extractor": { "display_name": "YouTube Transcript Extractor Browser Agent", "plane": "interactive_browser", diff --git a/harness/config/tech-hotspot-radar.yaml b/harness/config/tech-hotspot-radar.yaml index c62fcd4c2..d2e409ba4 100644 --- a/harness/config/tech-hotspot-radar.yaml +++ b/harness/config/tech-hotspot-radar.yaml @@ -58,7 +58,7 @@ youtube: operator: browser_agent chatgpt_project: 杂项 profile_directory: Profile 1 - target_account_email: browser-agent@example.com + target_account_email: haogege1977@gmail.com headless: false scrub_client_state: true open_project_first: true @@ -1226,13 +1226,18 @@ hf_paper_insight: merge_daily_hotspots: true core_paper_limit: 12 supporting_paper_limit: 24 + grouped_report_section_batch_size: 2 + figure_bundle: + enabled: true + max_figures: 3 + timeout_seconds: 900 high_reasoning: mode: browser_agent model: chatgpt-5.5 reasoning_effort: high operator_kind: chapter_writer profile_directory: Profile 1 - target_account_email: browser-agent@example.com + target_account_email: haogege1977@gmail.com headless: true scrub_client_state: false open_project_first: false diff --git a/harness/config/youtube-influence-digest.yaml b/harness/config/youtube-influence-digest.yaml index 36af83916..c2162e730 100644 --- a/harness/config/youtube-influence-digest.yaml +++ b/harness/config/youtube-influence-digest.yaml @@ -21,6 +21,16 @@ output: lookback_hours: 72 keep_seen_days: 45 transcript_max_chars: 60000 + browser_agent_report: + enabled: false + requested_model: chatgpt-5.5-thinking-high + phase2_batch_size: 2 + title_prefix: AI Influence YouTube Report + lineage_prefix: ai-influence-youtube-report + project_name: 杂项 + headless: true + session_reuse: true + timeout_seconds: 1800 fetch: timeout_seconds: 15 sleep_between_channels_seconds: 3.0 diff --git a/harness/lib/actor_runtime.py b/harness/lib/actor_runtime.py index 7ef85803c..ba3ae5065 100644 --- a/harness/lib/actor_runtime.py +++ b/harness/lib/actor_runtime.py @@ -8,6 +8,8 @@ import json import os +import subprocess +import sys import uuid from pathlib import Path from typing import Any, Dict, List, Optional, Tuple @@ -25,6 +27,8 @@ HOME = Path.home() HARNESS_DIR = Path(os.environ.get("HARNESS_DIR", HOME / ".solar" / "harness")) +BROWSER_AGENT_SESSION_ACTOR_ID = "browser_agent_session" +BROWSER_AGENT_SESSION_WORKER = Path(__file__).resolve().parents[1] / "tools" / "browser_agent_session_actor.py" class SubmitResult: @@ -81,6 +85,29 @@ def __init__( self.profiles = load_profiles(profiles_path) self.router = LogicalOperatorRouter(bindings_path) + def _ensure_browser_agent_session_supervisor(self) -> int: + if str(BROWSER_AGENT_SESSION_WORKER.parent) not in sys.path: + sys.path.insert(0, str(BROWSER_AGENT_SESSION_WORKER.parent)) + from browser_agent_session_actor import ensure_supervisor_running # type: ignore + + result = ensure_supervisor_running( + actor_id=BROWSER_AGENT_SESSION_ACTOR_ID, + mailbox_base=self.mailbox_base, + lease_dir=self.harness_dir / "run" / "actor-leases", + ) + return int(result.get("pid") or 0) + + def _recover_browser_agent_session_stale_runtime(self) -> dict[str, Any]: + if str(BROWSER_AGENT_SESSION_WORKER.parent) not in sys.path: + sys.path.insert(0, str(BROWSER_AGENT_SESSION_WORKER.parent)) + from browser_agent_session_actor import recover_stale_supervisor_runtime # type: ignore + + return recover_stale_supervisor_runtime( + actor_id=BROWSER_AGENT_SESSION_ACTOR_ID, + mailbox_base=self.mailbox_base, + lease_dir=self.harness_dir / "run" / "actor-leases", + ) + def _ensure_execution_plan_metadata( self, task_envelope: Dict[str, Any], @@ -170,23 +197,17 @@ def submit( # Resolve actor if not actor_id and logical_operator: - is_browser_op = ( - logical_operator in ("DeepResearchBrowser", "WebwrightPlaywright", "BrowserUseMcp") or + browser_override_actor_id: str | None = None + if ( task_envelope.get("requires_replayable_evidence") or - task_envelope.get("is_long_horizon_web_task") or - task_envelope.get("is_localhost_smoke_or_quick_extract") - ) - if is_browser_op: - if ( - task_envelope.get("requires_replayable_evidence") or - task_envelope.get("is_long_horizon_web_task") - ): - actor_id = "op.browser.webwright.playwright.01" - elif task_envelope.get("is_localhost_smoke_or_quick_extract"): - actor_id = "op.browser.browser_use_mcp.quick.01" - else: - # Default Fallback: Webwright - actor_id = "op.browser.webwright.playwright.01" + task_envelope.get("is_long_horizon_web_task") + ): + browser_override_actor_id = "op.browser.webwright.playwright.01" + elif task_envelope.get("is_localhost_smoke_or_quick_extract"): + browser_override_actor_id = "op.browser.browser_use_mcp.quick.01" + + if browser_override_actor_id: + actor_id = browser_override_actor_id else: selected, rejected = self.router.select_actor(logical_operator) if not selected: @@ -206,6 +227,11 @@ def submit( # Check profile risk denial profile = self.profiles.get(actor_id) evidence_path = f"actors/{actor_id}/evidence/{task_id}" + if actor_id == BROWSER_AGENT_SESSION_ACTOR_ID: + try: + self._recover_browser_agent_session_stale_runtime() + except Exception: + pass # Acquire lease lease = self.broker.acquire( @@ -230,6 +256,16 @@ def submit( inbox_path = mailbox.submit_task(task_envelope) outbox_dir = str(mailbox.outbox) + if actor_id == BROWSER_AGENT_SESSION_ACTOR_ID: + try: + self._ensure_browser_agent_session_supervisor() + except Exception as exc: + try: + Path(inbox_path).unlink(missing_ok=True) + except Exception: + pass + self.broker.transition(actor_id, READY) + return SubmitResult(success=False, error=f"browser_agent_session_supervisor_failed:{type(exc).__name__}:{exc}") # Build scheduler decision sched_decision = build_scheduler_decision( diff --git a/harness/lib/ai_influence_youtube_report/__init__.py b/harness/lib/ai_influence_youtube_report/__init__.py index 4ac4a227c..58db55140 100644 --- a/harness/lib/ai_influence_youtube_report/__init__.py +++ b/harness/lib/ai_influence_youtube_report/__init__.py @@ -1,10 +1,15 @@ """Core runtime contracts for the AI Influence YouTube report flow.""" from .compat import TranscriptStatusDriftError, compat_adapter_v1 +from .figures import build_figure_specs, build_figure_manifest, paint_figure from .gate import transcript_gate from .classifier import group_classifier +from .runtime import generate_browser_agent_report_bundle from .hierarchy import build_hierarchy from .schema import ( + FigureManifest, + FigureResult, + FigureSpec, GateDecision, ModelCallLedgerRow, RunRecord, @@ -16,6 +21,9 @@ from .state_machine import RunState, transition_run __all__ = [ + "FigureManifest", + "FigureResult", + "FigureSpec", "GateDecision", "ModelCallLedgerRow", "RunRecord", @@ -26,8 +34,12 @@ "TranscriptStatusDriftError", "ValidatorReport", "compat_adapter_v1", + "build_figure_manifest", + "build_figure_specs", "build_hierarchy", "group_classifier", + "generate_browser_agent_report_bundle", + "paint_figure", "transition_run", "transcript_gate", ] diff --git a/harness/lib/ai_influence_youtube_report/archive.py b/harness/lib/ai_influence_youtube_report/archive.py index b5cd587fa..8fd32f31c 100644 --- a/harness/lib/ai_influence_youtube_report/archive.py +++ b/harness/lib/ai_influence_youtube_report/archive.py @@ -35,6 +35,25 @@ def archive_writer_commit(run_record: dict[str, Any], report_bundle: dict[str, A else: path.write_text(str(value), encoding="utf-8") artifacts.append({"type": filename.rsplit(".", 1)[-1], "path": str(archive_dir / filename)}) + figure_manifest = report_bundle.get("figure_manifest") + if isinstance(figure_manifest, dict) and figure_manifest: + figures_dir = tmp_dir / "figures" + figures_dir.mkdir(parents=True, exist_ok=True) + (figures_dir / "figure-manifest.json").write_text( + json.dumps(figure_manifest, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + artifacts.append({"type": "json", "path": str(archive_dir / "figures" / "figure-manifest.json")}) + for figure in figure_manifest.get("figures") or []: + image_path = str(figure.get("image_path") or "").strip() + if not image_path: + continue + src = Path(image_path).expanduser() + if not src.exists() or not src.is_file(): + continue + dst = figures_dir / src.name + shutil.copy2(src, dst) + artifacts.append({"type": src.suffix.lstrip(".") or "bin", "path": str(archive_dir / "figures" / src.name)}) manifest = { "schema_version": "archive_manifest.v1", "archive_dir": str(archive_dir), diff --git a/harness/lib/ai_influence_youtube_report/browser_agent.py b/harness/lib/ai_influence_youtube_report/browser_agent.py index 5aacfa6a5..a21b92b5b 100644 --- a/harness/lib/ai_influence_youtube_report/browser_agent.py +++ b/harness/lib/ai_influence_youtube_report/browser_agent.py @@ -1,7 +1,8 @@ """Browser Agent wrapper contract for ChatGPT 5.5 Thinking high. -This module defines the seam and fake-provider behavior for tests. It does not -call ChatGPT directly; production integration is injected through provider. +This module defines the seam and fake-provider behavior for tests. Production +integration is injected through provider and can reuse the existing +``chatgpt_report_operator`` + browser session control chain. """ from __future__ import annotations @@ -9,6 +10,8 @@ import time import json import os +import re +import hashlib import subprocess import sys from pathlib import Path @@ -16,16 +19,227 @@ from uuid import uuid4 from .ledger import append_model_call_ledger +from .prompts import PHASE1_PLAN_PROMPT, PHASE2_BATCH_PROMPT, PHASE2_CHAPTER_PROMPT, PHASE3_SYNTHESIS_PROMPT class BrowserAgentProvider(Protocol): - def call(self, stage: str, payload: dict[str, Any], *, requested_model: str) -> dict[str, Any]: ... + def call( + self, + stage: str, + payload: dict[str, Any], + *, + requested_model: str, + run_id: str = "", + chapter_id: str = "", + sprint_id: str = "", + ) -> dict[str, Any]: ... class LocalModelSubstitutionError(RuntimeError): pass +ROOT = Path(__file__).resolve().parents[2] +DEFAULT_CHATGPT_REPORT_OPERATOR = ROOT / "tools" / "chatgpt_report_operator.py" + + +def _slug(value: str, *, fallback: str = "default", limit: int = 96) -> str: + text = re.sub(r"[^A-Za-z0-9_.:-]+", "-", str(value or "").strip()).strip("-") + return (text or fallback)[:limit] + + +def _load_json(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + return data if isinstance(data, dict) else {} + + +def _phase_instruction(stage: str) -> str: + if stage == "phase1": + return PHASE1_PLAN_PROMPT + if stage == "phase2": + return PHASE2_CHAPTER_PROMPT + if stage == "phase2_batch": + return PHASE2_BATCH_PROMPT + if stage == "phase3": + return PHASE3_SYNTHESIS_PROMPT + raise RuntimeError(f"unsupported_browser_agent_stage:{stage}") + + +def _stage_kind(stage: str) -> str: + if stage == "phase1": + return "planner" + return "chapter_writer" + + +def _stage_expected_output(stage: str) -> str: + if stage in {"phase1", "phase2_batch"}: + return "json" + return "markdown" + + +def _stage_purpose(stage: str) -> str: + mapping = { + "phase1": "ai-influence-youtube-report:phase1-plan", + "phase2": "ai-influence-youtube-report:phase2-chapter", + "phase2_batch": "ai-influence-youtube-report:phase2-batch", + "phase3": "ai-influence-youtube-report:phase3-synthesis", + } + return mapping.get(stage, f"ai-influence-youtube-report:{stage}") + + +def _render_stage_prompt(stage: str, payload: dict[str, Any]) -> str: + return "\n".join( + [ + f"# AI Influence YouTube {stage}", + "", + "## 执行要求", + _phase_instruction(stage), + "", + "## 输入载荷(JSON)", + json.dumps(payload, ensure_ascii=False, indent=2), + ] + ).strip() + + +class ChatGPTReportOperatorProvider: + """Production provider backed by chatgpt_report_operator. + + This keeps AI Influence YouTube browser-agent calls on the same session + control path already used by the main ChatGPT report operators. + """ + + def __init__( + self, + *, + operator_script: str | Path | None = None, + python_executable: str | Path | None = None, + request_root: str | Path | None = None, + project_name: str = "杂项", + target_account_email: str | None = None, + profile_directory: str | None = None, + timeout_seconds: int = 1800, + headless: bool = True, + session_reuse: bool = True, + lineage_prefix: str = "ai-influence-youtube-report", + ) -> None: + self.operator_script = Path(operator_script or DEFAULT_CHATGPT_REPORT_OPERATOR) + self.python_executable = str(python_executable or sys.executable) + self.request_root = Path( + request_root or (Path.home() / ".solar" / "harness" / "run" / "ai-influence-youtube-report-browser-agent") + ).expanduser() + self.project_name = str(project_name or "杂项") + self.target_account_email = str(target_account_email or "").strip() or None + self.profile_directory = str(profile_directory or "").strip() or None + self.timeout_seconds = max(int(timeout_seconds), 60) + self.headless = bool(headless) + self.session_reuse = bool(session_reuse) + self.lineage_prefix = _slug(str(lineage_prefix or "ai-influence-youtube-report"), fallback="ai-influence-youtube-report") + + def _request_dir(self, *, stage: str, run_id: str, chapter_id: str, sprint_id: str, payload: dict[str, Any]) -> Path: + scope = _slug(sprint_id or run_id or "adhoc", fallback="adhoc") + chapter_scope = _slug(chapter_id, fallback="stage") if chapter_id else "stage" + payload_digest = uuid4().hex[:8] + if payload: + payload_digest = hashlib.sha256( + json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8") + ).hexdigest()[:8] + return self.request_root / scope / f"{stage}-{chapter_scope}-{payload_digest}" + + def _session_lineage(self, *, stage: str, run_id: str, chapter_id: str) -> str: + pieces = [self.lineage_prefix, stage] + if run_id: + pieces.append(_slug(run_id, fallback="run")) + if chapter_id: + pieces.append(_slug(chapter_id, fallback="chapter")) + return ":".join(pieces) + + def call( + self, + stage: str, + payload: dict[str, Any], + *, + requested_model: str, + run_id: str = "", + chapter_id: str = "", + sprint_id: str = "", + ) -> dict[str, Any]: + if not self.operator_script.exists(): + raise RuntimeError(f"chatgpt_report_operator not found: {self.operator_script}") + request_dir = self._request_dir(stage=stage, run_id=run_id, chapter_id=chapter_id, sprint_id=sprint_id, payload=payload) + request_dir.mkdir(parents=True, exist_ok=True) + env = os.environ.copy() + env["BROWSER_AGENT_REQUEST_DIR"] = str(request_dir) + env["BROWSER_AGENT_PURPOSE"] = _stage_purpose(stage) + env["CHATGPT_REPORT_ACTION"] = "run" + env["CHATGPT_REPORT_OPERATOR_KIND"] = _stage_kind(stage) + env["BROWSER_AGENT_EXPECTED_OUTPUT"] = _stage_expected_output(stage) + env["CHATGPT_MODEL"] = str(requested_model) + env["BROWSER_AGENT_HEADLESS"] = "true" if self.headless else "false" + env["BROWSER_AGENT_SESSION_REUSE"] = "true" if self.session_reuse else "false" + env["SOLAR_BROWSER_SESSION_REUSE"] = env["BROWSER_AGENT_SESSION_REUSE"] + env["BROWSER_AGENT_SESSION_LINEAGE"] = self._session_lineage(stage=stage, run_id=run_id, chapter_id=chapter_id) + env["SOLAR_BROWSER_SESSION_LINEAGE"] = env["BROWSER_AGENT_SESSION_LINEAGE"] + env["BROWSER_AGENT_CHATGPT_PROJECT_NAME"] = self.project_name + env["HARNESS_DIR"] = str(ROOT) + if self.target_account_email: + env["BROWSER_AGENT_CHATGPT_ACCOUNT_EMAIL"] = self.target_account_email + env["BROWSER_AGENT_TARGET_ACCOUNT_EMAIL"] = self.target_account_email + if self.profile_directory: + env["BROWSER_AGENT_PROFILE_DIRECTORY"] = self.profile_directory + + prompt = _render_stage_prompt(stage, payload) + proc = subprocess.run( + [self.python_executable, str(self.operator_script)], + input=prompt, + text=True, + capture_output=True, + timeout=self.timeout_seconds, + env=env, + ) + (request_dir / "provider.stdout.txt").write_text(proc.stdout or "", encoding="utf-8") + (request_dir / "provider.stderr.txt").write_text(proc.stderr or "", encoding="utf-8") + if proc.returncode != 0: + combined = ((proc.stdout or "") + "\n" + (proc.stderr or "")).strip() + raise RuntimeError(f"chatgpt_report_operator_failed rc={proc.returncode}: {combined[-1600:]}") + + text = str(proc.stdout or "").strip() + if not text: + text = str((request_dir / "assistant-response.txt").read_text(encoding="utf-8")).strip() if (request_dir / "assistant-response.txt").exists() else "" + submitted = _load_json(request_dir / "submitted-run.json") + page = _load_json(request_dir / "page.json") + conversation = _load_json(request_dir / "conversation.json") + browser_session_id = ( + str(submitted.get("conversation_id") or "").strip() + or str(page.get("conversation_id") or "").strip() + or str(conversation.get("conversation_id") or "").strip() + or str(submitted.get("task_id") or "").strip() + or f"{stage}-{uuid4().hex[:12]}" + ) + chatgpt_url = ( + str(submitted.get("url") or "").strip() + or str(page.get("url") or "").strip() + or str(conversation.get("url") or "").strip() + or "about:blank" + ) + model_call_id = str(submitted.get("task_id") or "").strip() or f"{stage}-{uuid4().hex}" + return { + "schema_version": "chatgpt_report_operator_provider.v1", + "model_call_id": model_call_id, + "browser_session_id": browser_session_id, + "chatgpt_url": chatgpt_url, + "resolved_model": str(requested_model), + "status": "succeeded", + "text": text, + "request_dir": str(request_dir), + "task_id": str(submitted.get("task_id") or ""), + } + + class YoutubeTranscriptExtractor: """Production adapter for the YouTube transcript browser-agent operator. @@ -119,7 +333,14 @@ def _call(self, stage: str, payload: dict[str, Any], *, requested_model: str, ru if requested_model.lower() in {"qwen", "qwen3.6", "thunderomlx", "local"}: raise LocalModelSubstitutionError("local model substitution is forbidden for judgment-bearing phases") started = time.time() - result = self.provider.call(stage, payload, requested_model=requested_model) + result = self.provider.call( + stage, + payload, + requested_model=requested_model, + run_id=run_id, + chapter_id=chapter_id, + sprint_id=self.sprint_id, + ) latency_ms = int((time.time() - started) * 1000) call_id = str(result.get("model_call_id") or f"call_{uuid4().hex}") append_model_call_ledger(self.ledger_path, { @@ -149,5 +370,31 @@ def write_chapter(self, chapter_spec: dict[str, Any], *, requested_model: str, r self._chapter_calls.add(chapter_id) return self._call("phase2", chapter_spec, requested_model=requested_model, run_id=run_id, chapter_id=chapter_id) + def write_chapter_batch( + self, + chapter_specs: list[dict[str, Any]], + *, + requested_model: str, + run_id: str, + batch_id: str, + ) -> dict[str, Any]: + chapter_ids: list[str] = [] + for item in chapter_specs: + chapter = item.get("chapter") if isinstance(item, dict) else {} + chapter_id = str((chapter or {}).get("chapter_id") or "").strip() + if not chapter_id: + raise ValueError("phase2_batch_missing_chapter_id") + if chapter_id in self._chapter_calls: + raise ValueError(f"duplicate phase2 chapter call: {chapter_id}") + chapter_ids.append(chapter_id) + self._chapter_calls.update(chapter_ids) + return self._call( + "phase2_batch", + {"chapters": chapter_specs}, + requested_model=requested_model, + run_id=run_id, + chapter_id=batch_id, + ) + def synthesize(self, chapter_outputs: list[dict[str, Any]], *, requested_model: str, run_id: str) -> dict[str, Any]: return self._call("phase3", {"chapters": chapter_outputs}, requested_model=requested_model, run_id=run_id) diff --git a/harness/lib/ai_influence_youtube_report/figures.py b/harness/lib/ai_influence_youtube_report/figures.py new file mode 100644 index 000000000..6e9c11635 --- /dev/null +++ b/harness/lib/ai_influence_youtube_report/figures.py @@ -0,0 +1,376 @@ +"""Figure spec compilation and TechnologyDiagramPainter integration.""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import time +import datetime as dt +from pathlib import Path +from typing import Any, Callable + +from .schema import FigureManifest, FigureResult, FigureSpec + + +ROOT = Path(__file__).resolve().parents[2] +DEFAULT_OPERATOR_SCRIPT = ROOT / "tools" / "technology_diagram_painter_operator.py" + +_ARCHITECTURE_HINTS = ( + "architecture", + "架构", + "system", + "平台", + "infra", + "infrastructure", + "生态", + "component", + "模块", +) +_FLOW_HINTS = ( + "flow", + "流程", + "pipeline", + "route", + "路径", + "loop", + "趋势", + "演进", + "project implications", +) +_STACK_HINTS = ( + "stack", + "技术栈", + "layer", + "分层", + "toolchain", + "模型", + "数据", + "tool", +) + + +def _slug(value: str, fallback: str = "item") -> str: + text = re.sub(r"[^A-Za-z0-9_.:-]+", "-", str(value or "").strip()).strip("-") + return text or fallback + + +def _chapter_signal_text(chapter: dict[str, Any], evidence_entries: dict[str, dict[str, Any]]) -> str: + parts = [ + str(chapter.get("title") or ""), + str(chapter.get("trend_title") or ""), + str(chapter.get("text") or ""), + ] + for ref in chapter.get("evidence_refs") or []: + row = evidence_entries.get(str(ref)) + if not row: + continue + parts.extend( + [ + str(row.get("title") or ""), + str(row.get("citation_span") or ""), + str(row.get("group_type") or ""), + ] + ) + return " ".join(parts).lower() + + +def _pick_figure_type(signal_text: str) -> str | None: + if any(token in signal_text for token in _STACK_HINTS): + return "technology_stack" + if any(token in signal_text for token in _FLOW_HINTS): + return "trend_flow" + if any(token in signal_text for token in _ARCHITECTURE_HINTS): + return "architecture_overview" + return None + + +def _render_prompt(spec: FigureSpec) -> 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" + chapters = ", ".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 Chapters: {chapters}", + f"Evidence Refs: {evidence}", + "", + "必须基于以下结构化要点生成图,不得臆造正文中不存在的模块、流程或技术栈:", + outline or "- N/A", + "", + "请输出一张适合技术洞察报告正文内嵌的正式 Figure。", + ] + ).strip() + + +def build_figure_specs( + plan_json: dict[str, Any], + chapter_outputs: list[dict[str, Any]], + evidence_map: dict[str, Any], + *, + report_title: str, +) -> list[FigureSpec]: + evidence_entries = { + str(entry.get("evidence_ref") or "").strip(): entry + for entry in evidence_map.get("entries") or [] + if str(entry.get("evidence_ref") or "").strip() + } + specs: list[FigureSpec] = [] + seen_types: set[str] = set() + + lead_refs: list[str] = [] + lead_outline: list[str] = [] + for chapter in chapter_outputs[:3]: + for ref in chapter.get("evidence_refs") or []: + clean = str(ref or "").strip() + if clean and clean not in lead_refs: + lead_refs.append(clean) + title = str(chapter.get("title") or "").strip() + trend = str(chapter.get("trend_title") or "").strip() + if title: + lead_outline.append(f"Chapter: {title}") + if trend: + lead_outline.append(f"Trend: {trend}") + if lead_refs: + lead_spec = FigureSpec( + figure_id="fig_01", + title=f"{report_title} - Architecture Overview", + figure_type="architecture_overview", + placement="report_lead", + source_chapter_ids=[str(ch.get("chapter_id") or "") for ch in chapter_outputs[:3] if str(ch.get("chapter_id") or "").strip()], + evidence_refs=lead_refs[:6], + input_outline=lead_outline[:8] or ["Report lead architecture overview"], + render_prompt="", + caption="图 1:基于已通过验证章节总结的整体结构图。", + ) + specs.append(lead_spec) + seen_types.add(lead_spec.figure_type) + + for chapter in chapter_outputs: + if len(specs) >= 3: + break + signal_text = _chapter_signal_text(chapter, evidence_entries) + figure_type = _pick_figure_type(signal_text) + if not figure_type or figure_type in seen_types: + continue + refs = [str(ref or "").strip() for ref in chapter.get("evidence_refs") or [] if str(ref or "").strip()] + if not refs: + continue + subsections = [] + for trend in plan_json.get("trends") or []: + for plan_chapter in trend.get("chapters") or []: + if str(plan_chapter.get("chapter_id") or "") == str(chapter.get("chapter_id") or ""): + for subsection in plan_chapter.get("subsections") or []: + title = str(subsection.get("title") or "").strip() + if title: + subsections.append(title) + spec = FigureSpec( + figure_id=f"fig_{len(specs) + 1:02d}", + title=str(chapter.get("title") or chapter.get("chapter_id") or f"Figure {len(specs) + 1}"), + figure_type=figure_type, + placement="chapter_inline", + source_chapter_ids=[str(chapter.get("chapter_id") or "")], + evidence_refs=refs[:6], + input_outline=( + [f"Trend: {chapter.get('trend_title') or 'N/A'}", f"Chapter: {chapter.get('title') or 'N/A'}"] + + [f"Subsection: {item}" for item in subsections[:4]] + ), + render_prompt="", + caption=f"图 {len(specs) + 1}:{chapter.get('title') or '章节'} 对应的 {figure_type}。", + ) + specs.append(spec) + seen_types.add(figure_type) + + rendered_specs: list[FigureSpec] = [] + for spec in specs: + rendered_specs.append( + FigureSpec( + 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=_render_prompt(spec), + caption=spec.caption, + status=spec.status, + ) + ) + return rendered_specs + + +def _coerce_figure_result(spec: FigureSpec, payload: dict[str, Any]) -> FigureResult: + raw_status = str(payload.get("status") or "").strip().lower() + status = "painted" if raw_status in {"success", "painted"} else (raw_status or "failed") + return FigureResult( + 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), + status=status, + image_path=str(payload.get("image_path") or "").strip(), + request_dir=str(payload.get("request_dir") or "").strip(), + chatgpt_url=str(payload.get("url") or payload.get("chatgpt_url") or "").strip(), + browser_session_id=str(payload.get("browser_session_id") or "").strip(), + original_image_ok=bool(payload.get("original_image_ok")), + error=str(payload.get("error") or "").strip(), + caption=spec.caption, + ) + + +def _flow_control_retry_delay(stderr_text: str, *, cap_seconds: float = 90.0) -> float: + text = str(stderr_text or "") + match = re.search(r"cooldown until (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)", text) + if not match: + return 0.0 + try: + target = dt.datetime.strptime(match.group(1), "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=dt.timezone.utc) + except ValueError: + return 0.0 + delay = (target - dt.datetime.now(dt.timezone.utc)).total_seconds() + 1.0 + return max(0.0, min(float(cap_seconds), delay)) + + +def paint_figure( + spec: FigureSpec, + *, + run_dir: str | Path, + operator_runner: Callable[[dict[str, Any], Path], dict[str, Any]] | None = None, + operator_script: str | Path | None = None, + python_executable: str | Path | None = None, + timeout_seconds: int = 900, +) -> FigureResult: + task_dir = Path(run_dir).expanduser() / spec.figure_id + task_dir.mkdir(parents=True, exist_ok=True) + request_payload = { + "input_text": "\n".join(spec.input_outline).strip(), + "prompt": spec.render_prompt, + "timeout_seconds": max(int(timeout_seconds), 60), + "max_retries": 1, + "request_dir": str((task_dir / "tech-diagram-request").resolve()), + } + if operator_runner is not None: + return _coerce_figure_result(spec, operator_runner(request_payload, task_dir)) + + envelope = { + "operator_id": "technology-diagram-painter", + "technology_diagram_request": request_payload, + } + envelope_path = task_dir / "technology-diagram-envelope.json" + envelope_path.write_text(json.dumps(envelope, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + script = Path(operator_script or DEFAULT_OPERATOR_SCRIPT).expanduser() + if not script.exists(): + return FigureResult( + 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), + status="failed", + error=f"operator_not_found:{script}", + caption=spec.caption, + ) + env = os.environ.copy() + env["SOLAR_OPERATOR_ENVELOPE_JSON"] = str(envelope_path) + env["TASK_DIR"] = str(task_dir) + env.setdefault("BROWSER_AGENT_HEADLESS", "true") + cmd = [str(python_executable or sys.executable), str(script)] + try: + attempts = 2 + for attempt in range(1, attempts + 1): + proc = subprocess.run( + cmd, + text=True, + capture_output=True, + timeout=max(int(timeout_seconds), 60) + 120, + env=env, + ) + stdout_path = task_dir / "operator.stdout.txt" + stderr_path = task_dir / "operator.stderr.txt" + stdout_path.write_text(proc.stdout or "", encoding="utf-8") + stderr_path.write_text(proc.stderr or "", encoding="utf-8") + result_path = task_dir / "tech-diagram-result.json" + if proc.returncode == 0: + if not result_path.exists(): + return FigureResult( + 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), + status="failed", + error="missing_tech_diagram_result_json", + caption=spec.caption, + ) + payload = json.loads(result_path.read_text(encoding="utf-8")) + return _coerce_figure_result(spec, payload if isinstance(payload, dict) else {}) + delay = _flow_control_retry_delay(proc.stderr or "") + if attempt < attempts and delay > 0: + time.sleep(delay) + continue + return FigureResult( + 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), + status="failed", + error=f"operator_rc_{proc.returncode}", + caption=spec.caption, + ) + except Exception as exc: + return FigureResult( + 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), + status="failed", + error=f"{type(exc).__name__}:{exc}", + caption=spec.caption, + ) + + +def build_figure_manifest( + run_id: str, + figure_results: list[FigureResult], + *, + validator_overall: str = "", +) -> FigureManifest: + painted_count = sum(1 for item in figure_results if item.status == "painted") + skipped_count = sum(1 for item in figure_results if item.status == "skipped") + failed_count = sum(1 for item in figure_results if item.status == "failed") + return FigureManifest( + run_id=run_id, + figures=[item.to_dict() for item in figure_results], + painted_count=painted_count, + skipped_count=skipped_count, + failed_count=failed_count, + validator_overall=validator_overall, + ) + + +def render_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"![{title}]({image_path})"] + if caption: + parts.append(caption) + if evidence: + parts.append(f"Evidence: {evidence}") + return "\n\n".join(parts).strip() diff --git a/harness/lib/ai_influence_youtube_report/prompts.py b/harness/lib/ai_influence_youtube_report/prompts.py index 04a5a461b..f31034f80 100644 --- a/harness/lib/ai_influence_youtube_report/prompts.py +++ b/harness/lib/ai_influence_youtube_report/prompts.py @@ -2,4 +2,5 @@ PHASE1_PLAN_PROMPT = "Return JSON only: trends -> chapters -> subsections -> evidence_refs." PHASE2_CHAPTER_PROMPT = "Write exactly one chapter from the provided chapter spec and evidence refs." +PHASE2_BATCH_PROMPT = "Return JSON only: chapters -> [{chapter_id, title, text, evidence_refs}] for all provided chapter specs." PHASE3_SYNTHESIS_PROMPT = "Synthesize chapter outputs into final executive summary and cross-chapter links." diff --git a/harness/lib/ai_influence_youtube_report/render.py b/harness/lib/ai_influence_youtube_report/render.py index 9cfdc8da9..84907ad51 100644 --- a/harness/lib/ai_influence_youtube_report/render.py +++ b/harness/lib/ai_influence_youtube_report/render.py @@ -3,6 +3,7 @@ from __future__ import annotations from html import escape +from pathlib import Path from typing import Any from .source_mapping import render_source_mapping_html @@ -32,12 +33,35 @@ def render_platform_svg(title: str = "AI Influence YouTube Report Flow") -> str: def render_report_html(markdown: str, evidence_pack: dict[str, Any], report_meta: dict[str, Any]) -> str: source_cards = "\n".join(render_source_mapping_html(entry) for entry in evidence_pack.get("entries", [])) + figures_html = [] + for figure in report_meta.get("figures") or []: + image_path = str(figure.get("image_path") or "").strip() + if not image_path: + continue + try: + image_uri = Path(image_path).expanduser().resolve().as_uri() + except Exception: + image_uri = escape(image_path) + title = escape(str(figure.get("title") or figure.get("figure_id") or "Figure")) + caption = escape(str(figure.get("caption") or "").strip()) + figures_html.append( + "\n".join( + [ + '
', + f'{title}', + f"
{caption or title}
", + "
", + ] + ) + ) + figures_block = f"
{''.join(figures_html)}
" if figures_html else "" return f""" {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"![{title}]({image_path})"] + 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'{title}' + 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 "
  • 待补
  • " @@ -9763,6 +10364,13 @@ def _hf_render_grouped_report_html( "" ) commentary_html = "".join(commentary_items) or "
  • 待补
  • " + section_figures_html = "".join( + _hf_render_grouped_report_figure_html(item) + for item in _hf_grouped_figures_for_section( + figure_rows, + str(section.get("section_id") or f"section-{idx}"), + ) + ) section_html.append( f"""
    @@ -9777,6 +10385,7 @@ def _hf_render_grouped_report_html(
    趋势判断{html.escape(hf_public_trend_label(section.get('trend_type')))}
    章节定位{html.escape(_hf_text(section.get('title'), default=f'趋势部分 {idx}'))}
    + {section_figures_html}

    趋势描述

    {html.escape(hf_clean_public_text(_hf_text(section.get('trend_description'), default='待补')))}

    洞察分析

    {html.escape(hf_clean_public_text(_hf_text(section.get('insight_analysis'), default='待补')))}

    规划建议

      {recommendations}
    @@ -9819,6 +10428,10 @@ def _hf_render_grouped_report_html( .hf-rank {{ width: 64px; height: 64px; display: grid; place-items: center; border-radius: 18px; background: linear-gradient(135deg, #8a4b22, #c98950); color: white; font: 700 22px/1 "Avenir Next", sans-serif; }} .hf-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(220px,1fr)); gap: 14px; margin: 18px 0; padding: 16px; background: #fbf6ef; border-radius: 18px; border: 1px solid var(--line); }} .hf-panel {{ padding: 22px; margin-top: 24px; }} + .hf-figure-card {{ margin: 18px 0; padding: 16px; border: 1px solid var(--line); border-radius: 18px; background: #fcf8f1; }} + .hf-figure-card img {{ display: block; width: 100%; border-radius: 14px; border: 1px solid var(--line); background: white; }} + .hf-figure-card figcaption {{ margin-top: 12px; color: var(--muted); }} + .hf-figure-evidence {{ font-family: "Avenir Next", sans-serif; font-size: 12px; }} h1, h2, h3 {{ margin: 0 0 12px; line-height: 1.15; }} h1 {{ font-size: clamp(32px, 4vw, 52px); max-width: 16ch; margin-top: 18px; }} ul {{ padding-left: 20px; }} @@ -9833,6 +10446,7 @@ def _hf_render_grouped_report_html(

    {html.escape('报告周期:' + str(context.get('window_label') or date_str))}

    {metric_html}
    + {f'

    关键图示

    {lead_figures_html}
    ' if lead_figures_html else ''} {''.join(section_html)}

    后续观察点

    @@ -10246,6 +10860,7 @@ def hf_write_public_report( base_report_variant = "premium_insight_report" if public_records and premium_count == len(public_records) else "fallback_report" grouped_report: dict[str, Any] | None = None grouped_report_error_kind = "" + figure_bundle: dict[str, Any] | None = None if public_records: try: grouped_report = hf_call_grouped_report_flow(public_records, config, date_str=date_str, report_context=report_context) @@ -10286,7 +10901,14 @@ def hf_write_public_report( report_context=report_context, ) if grouped_report: + figure_bundle = hf_generate_grouped_report_figure_bundle( + public_records, + grouped_report, + config, + out_dir=out_dir, + ) render_kwargs["grouped_report"] = grouped_report + render_kwargs["figures"] = list((figure_bundle or {}).get("figures") or []) lines.append( render_markdown( **render_kwargs, @@ -10303,6 +10925,7 @@ def hf_write_public_report( pack_path = out_dir / "hf-paper-insight-pack.json" plan_path = out_dir / "hf-paper-report-plan.json" sections_path = out_dir / "hf-paper-report-sections.json" + figure_manifest_path = out_dir / "hf-paper-figures" / "hf-paper-figure-manifest.json" report_path.write_text(report_md, encoding="utf-8") report_html_path.write_text(report_html, encoding="utf-8") if grouped_report: @@ -10323,6 +10946,12 @@ def hf_write_public_report( "grouped_report_ok": bool(grouped_report), "grouped_report_model": (grouped_report or {}).get("model") or "", "grouped_report_error": grouped_report_error_kind, + "figure_bundle_ok": bool((figure_bundle or {}).get("enabled")), + "figure_bundle_manifest": str((figure_bundle or {}).get("manifest_path") or ""), + "figure_bundle_painted_count": int((figure_bundle or {}).get("painted_count") or 0), + "figure_bundle_failed_count": int((figure_bundle or {}).get("failed_count") or 0), + "figure_bundle_skipped_count": int((figure_bundle or {}).get("skipped_count") or 0), + "figures": list((figure_bundle or {}).get("figures") or []), "report_context": report_context, "grouped_report_plan": (grouped_report or {}).get("plan") or {}, "grouped_report_sections": (grouped_report or {}).get("sections") or [], @@ -10355,6 +10984,8 @@ def hf_write_public_report( "grouped_report_ok": bool(grouped_report), "plan_json": str(plan_path) if grouped_report else "", "sections_json": str(sections_path) if grouped_report else "", + "figure_manifest_json": str(figure_manifest_path) if figure_manifest_path.exists() else "", + "painted_figure_count": int((figure_bundle or {}).get("painted_count") or 0), } @@ -13863,30 +14494,7 @@ def _browser_agent_request_dir(config: dict[str, Any], purpose: str) -> Path: def browser_agent_chatgpt_cmd(config: dict[str, Any]) -> list[str]: - """Resolve the browser-agent ChatGPT executor command. - - This is intentionally explicit. If the global Browser Agent operator is not - wired yet, we write a request artifact and fail closed instead of silently - falling back to Codex or local Qwen. - """ - 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) - operator = Path(__file__).resolve().parents[1] / "tools" / "chatgpt_report_operator.py" - if operator.exists(): - return [sys.executable, str(operator)] - wrapper = Path(__file__).resolve().with_name("browser_agent_chatgpt_wrapper.py") - browser_use_python = Path.home() / ".claude" / "mcp-servers" / "browser-use" / ".venv" / "bin" / "python" - if wrapper.exists() and browser_use_python.exists(): - return [str(browser_use_python), str(wrapper)] - return [] + return runtime_browser_agent_chatgpt_cmd(config) def browser_agent_notebooklm_cmd(config: dict[str, Any]) -> list[str]: @@ -13907,41 +14515,19 @@ def browser_agent_notebooklm_cmd(config: dict[str, Any]) -> list[str]: 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() + return runtime_strip_browser_agent_noise(text) 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 + return runtime_env_override_text(*names) 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 + return runtime_env_override_bool(*names) + + +def derive_browser_agent_session_lineage(purpose: str) -> str: + return runtime_derive_chatgpt_session_lineage(purpose, slugify=slugify) def call_browser_agent_chatgpt_text(prompt: str, config: dict[str, Any], *, @@ -13969,6 +14555,13 @@ def call_browser_agent_chatgpt_text(prompt: str, config: dict[str, Any], *, reasoning_effort = str(requested_reasoning_effort or writer_cfg.get("reasoning_effort") or reasoner_cfg.get("reasoning_effort") or "high") timeout = int(requested_timeout_seconds or writer_cfg.get("timeout_seconds") or reasoner_cfg.get("timeout_seconds") or 1800) max_chars = int(requested_max_prompt_chars or writer_cfg.get("max_prompt_chars") or reasoner_cfg.get("max_prompt_chars") or 180000) + session_lineage = ( + _env_override_text("SOLAR_BROWSER_SESSION_LINEAGE", "BROWSER_AGENT_SESSION_LINEAGE") + or derive_browser_agent_session_lineage(purpose) + ) + session_reuse = _env_override_bool("SOLAR_BROWSER_SESSION_REUSE", "BROWSER_AGENT_SESSION_REUSE") + if session_reuse is None: + session_reuse = True if len(prompt) > max_chars: prompt = prompt[:max_chars] + "\n\n[TRUNCATED: prompt exceeded configured max_prompt_chars]\n" req_dir = _browser_agent_request_dir(config, purpose) @@ -13982,6 +14575,8 @@ def call_browser_agent_chatgpt_text(prompt: str, config: dict[str, Any], *, "operator_kind": operator_kind or "auto", "model": model, "reasoning_effort": reasoning_effort, + "session_lineage": session_lineage, + "session_reuse": bool(session_reuse), "created_at": iso_z(), "status": "pending_executor", "note": "AI Influence high-judgment stages must use DeepResearchChatGPT/chatgpt_thinking_high via Browser Agent + ChatGPT 5.5 Thinking high. No Codex/local fallback is allowed.", @@ -13995,19 +14590,6 @@ def call_browser_agent_chatgpt_text(prompt: str, config: dict[str, Any], *, "to a Browser Agent operator wrapper that reads prompt from stdin and writes final output to stdout." ) env = os.environ.copy() - env.update({ - "CHATGPT_MODEL": model, - "CHATGPT_REASONING_EFFORT": reasoning_effort, - "BROWSER_AGENT_EXPECTED_OUTPUT": expected, - "BROWSER_AGENT_REQUEST_DIR": str(req_dir), - "BROWSER_AGENT_PURPOSE": purpose, - "BROWSER_AGENT_CHATGPT_MODEL_MODE": "thinking", - "BROWSER_AGENT_CHATGPT_REQUIRE_UI_MODE": "true", - }) - if operator_kind: - env["CHATGPT_REPORT_OPERATOR_KIND"] = operator_kind - if target_url: - env["BROWSER_AGENT_CHATGPT_URL"] = str(target_url) resolved_headless = headless if resolved_headless is None: resolved_headless = _env_override_bool("BROWSER_AGENT_HEADLESS", "TECH_HOTSPOT_BROWSER_CHATGPT_HEADLESS") @@ -14086,51 +14668,53 @@ def call_browser_agent_chatgpt_text(prompt: str, config: dict[str, Any], *, "require_isolated_conversation", writer_cfg.get("require_isolated_conversation", browser_agent_cfg.get("require_isolated_conversation")), ) - if resolved_headless is not None: - env["BROWSER_AGENT_HEADLESS"] = "true" if bool(resolved_headless) else "false" - if resolved_profile_directory: - env["BROWSER_AGENT_PROFILE_DIRECTORY"] = str(resolved_profile_directory) - if resolved_target_account_email: - env["BROWSER_AGENT_TARGET_ACCOUNT_EMAIL"] = str(resolved_target_account_email) - env["BROWSER_AGENT_CHATGPT_ACCOUNT_EMAIL"] = str(resolved_target_account_email) - if resolved_scrub_client_state is not None: - env["BROWSER_AGENT_CHATGPT_SCRUB_CLIENT_STATE"] = "true" if bool(resolved_scrub_client_state) else "false" - if resolved_open_project_first is not None: - env["BROWSER_AGENT_CHATGPT_OPEN_PROJECT_FIRST"] = "true" if bool(resolved_open_project_first) else "false" - if resolved_require_project is not None: - env["BROWSER_AGENT_CHATGPT_REQUIRE_PROJECT"] = "true" if bool(resolved_require_project) else "false" - if resolved_force_new_chat is not None: - env["BROWSER_AGENT_CHATGPT_FORCE_NEW_CHAT"] = "true" if bool(resolved_force_new_chat) else "false" - if resolved_require_isolated_conversation is not None: - env["BROWSER_AGENT_CHATGPT_REQUIRE_ISOLATED_CONVERSATION"] = "true" if bool(resolved_require_isolated_conversation) else "false" project_name = str( writer_cfg.get("chatgpt_project") or reasoner_cfg.get("chatgpt_project") or (flow_cfg.get("browser_agent") or {}).get("chatgpt_project") or "杂项" ).strip() - if project_name: - env["BROWSER_AGENT_CHATGPT_PROJECT_NAME"] = project_name - started = time.time() - run = subprocess.run( - cmd, - input=prompt, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, + env = runtime_build_chatgpt_operator_env( + model=model, + reasoning_effort=reasoning_effort, + expected=expected, + request_dir=req_dir, + purpose=purpose, + session_lineage=session_lineage, + session_reuse=bool(session_reuse), + operator_kind=operator_kind, + target_url=target_url, + headless=resolved_headless, + profile_directory=resolved_profile_directory, + target_account_email=resolved_target_account_email, + scrub_client_state=resolved_scrub_client_state, + open_project_first=resolved_open_project_first, + require_project=resolved_require_project, + force_new_chat=resolved_force_new_chat, + require_isolated_conversation=resolved_require_isolated_conversation, + project_name=project_name or None, + base_env=env, + ) + submitted = runtime_submit_chatgpt_operator_request( + cmd=cmd, + prompt=prompt, timeout=timeout, env=env, + request_dir=req_dir, + expected=expected, + use_session_control=not bool( + _env_override_bool( + "BROWSER_AGENT_SESSION_CONTROL_DISABLED", + "TECH_HOTSPOT_BROWSER_SESSION_CONTROL_DISABLED", + ) + ), ) - output = _strip_browser_agent_noise(run.stdout or "") - (req_dir / "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:]}") - if len(output) < (500 if expected == "json" else 1000): - raise ValueError(f"browser_agent_chatgpt output too short: {len(output)} chars") + output = str(submitted["output"] or "") meta.update({ "status": "completed", - "latency_ms": int((time.time() - started) * 1000), + "latency_ms": int(submitted["latency_ms"]), "output_chars": len(output), + "session_task_id": str(submitted.get("task_id") or ""), }) (req_dir / "request.json").write_text(json.dumps(meta, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") return { @@ -14199,12 +14783,89 @@ def extract_json_payload_lenient(text: str) -> dict[str, Any]: match = re.search(r"\{.*\}", text or "", flags=re.S) if not match: raise ValueError("no JSON object found in browser agent output") - payload = json.loads(match.group(0)) + raw = match.group(0) + try: + payload = json.loads(raw) + except json.JSONDecodeError: + repaired = _repair_json_string_field_lines(raw) + try: + payload = json.loads(repaired, strict=False) + except json.JSONDecodeError: + payload = json.loads(_repair_json_inner_quotes(repaired), strict=False) if not isinstance(payload, dict): raise ValueError("browser agent JSON output must be object") return payload +def _repair_json_inner_quotes(text: str) -> str: + repaired: list[str] = [] + in_string = False + escaped = False + length = len(text) + idx = 0 + while idx < length: + ch = text[idx] + if not in_string: + repaired.append(ch) + if ch == '"': + in_string = True + idx += 1 + continue + if escaped: + repaired.append(ch) + escaped = False + idx += 1 + continue + if ch == "\\": + repaired.append(ch) + escaped = True + idx += 1 + continue + if ch == '"': + look = idx + 1 + while look < length and text[look].isspace(): + look += 1 + next_char = text[look] if look < length else "" + if next_char in {",", "}", "]", ":"} or not next_char: + repaired.append(ch) + in_string = False + else: + repaired.append('\\"') + idx += 1 + continue + repaired.append(ch) + idx += 1 + return "".join(repaired) + + +def _repair_json_string_field_lines(text: str) -> str: + repaired_lines: list[str] = [] + pattern = re.compile(r'^(\s*"[^"]+"\s*:\s*)"(.+)"(\s*,?\s*)$') + for line in text.splitlines(): + match = pattern.match(line) + if not match: + repaired_lines.append(line) + continue + prefix, body, suffix = match.groups() + escaped_body: list[str] = [] + escaped = False + for ch in body: + if escaped: + escaped_body.append(ch) + escaped = False + continue + if ch == "\\": + escaped_body.append(ch) + escaped = True + continue + if ch == '"': + escaped_body.append('\\"') + continue + escaped_body.append(ch) + repaired_lines.append(f'{prefix}"{"".join(escaped_body)}"{suffix}') + return "\n".join(repaired_lines) + + def call_browser_agent_chatgpt_markdown(prompt: str, config: dict[str, Any], *, purpose: str, requested_model: str | None = None, diff --git a/harness/scripts/youtube_influence_digest.py b/harness/scripts/youtube_influence_digest.py index 8be3d1a8a..55dcaf67c 100755 --- a/harness/scripts/youtube_influence_digest.py +++ b/harness/scripts/youtube_influence_digest.py @@ -482,7 +482,7 @@ def fetch_transcript_via_browser_operator(video_id: str, timeout_seconds: int = env["SOLAR_OPERATOR_ENVELOPE_JSON"] = str(envelope_path) env["TASK_DIR"] = str(td_path) if "BROWSER_AGENT_HEADLESS" not in env: - env["BROWSER_AGENT_HEADLESS"] = "false" + env["BROWSER_AGENT_HEADLESS"] = "true" env.setdefault("BROWSER_AGENT_PROFILE_DIRECTORY", "Default") env.setdefault("BROWSER_AGENT_TARGET_ACCOUNT_EMAIL", "browser-agent@example.com") @@ -563,6 +563,113 @@ def rank(track: dict[str, Any]) -> tuple[int, int]: CURRENT_CONFIG: dict[str, Any] = {} +def browser_agent_report_config(config: dict[str, Any]) -> dict[str, Any]: + out_cfg = dict(config.get("output") or {}) + report_cfg = dict(out_cfg.get("browser_agent_report") or {}) + report_cfg.setdefault("enabled", False) + report_cfg.setdefault("requested_model", "chatgpt-5.5-thinking-high") + report_cfg.setdefault("title_prefix", "AI Influence YouTube Report") + report_cfg.setdefault("lineage_prefix", "ai-influence-youtube-report") + report_cfg.setdefault("project_name", "杂项") + report_cfg.setdefault("phase2_batch_size", 2) + return report_cfg + + +def build_browser_agent_report_sources(videos: list[Video], config: dict[str, Any]) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for index, video in enumerate(videos, start=1): + quality = assess_transcript_quality( + meta={ + "video_id": video.video_id, + "title": video.title, + "channel_name": video.channel_name, + }, + transcript=video.transcript, + status=video.transcript_status, + source=video.transcript_source, + config=config, + ) + rows.append( + { + "evidence_ref": f"E{index:03d}", + "channel": video.channel_name, + "title": video.title, + "published_at": video.published_at, + "transcript_grade": str(quality.get("tier") or "T3"), + "citation_span": (video.summary or video.why_it_matters or video.transcript[:220]).strip(), + "group_type": "other", + "summary": video.summary, + "why_it_matters": video.why_it_matters, + "transcript": video.transcript, + "url": video.url, + "category": video.category, + "signal_type": video.signal_type, + } + ) + return rows + + +def maybe_write_browser_agent_report( + videos: list[Video], + *, + config: dict[str, Any], + run_dir: Path, + run_id: str, + dry_run: bool = False, +) -> dict[str, Any]: + report_cfg = browser_agent_report_config(config) + if dry_run: + return {"enabled": bool(report_cfg.get("enabled")), "ok": False, "status": "dry_run_skipped"} + if not bool(report_cfg.get("enabled")): + return {"enabled": False, "ok": False, "status": "disabled"} + + lib_root = Path(__file__).resolve().parents[1] / "lib" + if str(lib_root) not in sys.path: + sys.path.insert(0, str(lib_root)) + from ai_influence_youtube_report.runtime import generate_browser_agent_report_bundle # noqa: WPS433 + + report_sources = build_browser_agent_report_sources(videos, config) + report_title = f"{report_cfg.get('title_prefix') or 'AI Influence YouTube Report'} — {run_id}" + provider_options = { + "project_name": str(report_cfg.get("project_name") or "杂项"), + "lineage_prefix": str(report_cfg.get("lineage_prefix") or "ai-influence-youtube-report"), + } + figure_operator_options: dict[str, Any] = {} + for key in ( + "target_account_email", + "profile_directory", + "timeout_seconds", + "headless", + "session_reuse", + "operator_script", + "python_executable", + ): + if report_cfg.get(key) is not None: + provider_options[key] = report_cfg.get(key) + for key, target_key in ( + ("diagram_operator_script", "operator_script"), + ("diagram_python_executable", "python_executable"), + ("diagram_timeout_seconds", "timeout_seconds"), + ): + if report_cfg.get(key) is not None: + figure_operator_options[target_key] = report_cfg.get(key) + + result = generate_browser_agent_report_bundle( + report_sources, + run_dir=run_dir, + run_id=run_id, + report_title=report_title, + requested_model=str(report_cfg.get("requested_model") or "chatgpt-5.5-thinking-high"), + sprint_id=f"youtube-influence-{run_id}", + provider_options=provider_options, + figure_operator_options=figure_operator_options, + phase2_batch_size=int(report_cfg.get("phase2_batch_size") or 2), + ) + status_path = run_dir / "browser-agent-report-status.json" + status_path.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return result + + def score_signal(text: str, config: dict[str, Any], priority: str) -> tuple[str, str, int]: lower = text.lower() keywords = config.get("analysis_keywords") or {} @@ -1383,7 +1490,23 @@ def write_markdown(videos: list[Video], channels: list[Channel], config: dict[st if not dry_run: (items_dir / item_name).write_text("\n".join(item_lines), encoding="utf-8") - return {"run_dir": str(run_dir), "digest_path": str(digest_path), "videos": len(videos), "transcripts_ok": transcript_ok, "asr_queued": asr_queued, "wiki_dispatch": dispatch} + browser_agent_report = maybe_write_browser_agent_report( + videos, + config=config, + run_dir=run_dir, + run_id=run_id, + dry_run=dry_run, + ) + + return { + "run_dir": str(run_dir), + "digest_path": str(digest_path), + "videos": len(videos), + "transcripts_ok": transcript_ok, + "asr_queued": asr_queued, + "wiki_dispatch": dispatch, + "browser_agent_report": browser_agent_report, + } def build_arg_parser() -> argparse.ArgumentParser: diff --git a/harness/solar-harness.sh b/harness/solar-harness.sh index fed3bd5a5..7a1cf8b30 100755 --- a/harness/solar-harness.sh +++ b/harness/solar-harness.sh @@ -2921,6 +2921,9 @@ print(json.dumps({ exit 1 ;; esac + elif [[ "$_browser_scope" == "session" ]]; then + shift || true + python3 "$HARNESS_DIR/tools/browser_agent_session_control.py" "$@" else err "unknown browser command: ${_browser_scope:-N/A}" exit 1 diff --git "a/harness/sprints/sprint-20260527-p0-ai-influence-hf-paper-insight-flow-paper-to-project-\347\240\224\347\251\266-s02-architecture/sprint-20260527-p0-ai-influence-hf-paper-insight-flow-paper-to-project-\347\240\224\347\251\266-s02-architecture.design.md" "b/harness/sprints/sprint-20260527-p0-ai-influence-hf-paper-insight-flow-paper-to-project-\347\240\224\347\251\266-s02-architecture/sprint-20260527-p0-ai-influence-hf-paper-insight-flow-paper-to-project-\347\240\224\347\251\266-s02-architecture.design.md" index edc9034fa..be8c8300e 100644 --- "a/harness/sprints/sprint-20260527-p0-ai-influence-hf-paper-insight-flow-paper-to-project-\347\240\224\347\251\266-s02-architecture/sprint-20260527-p0-ai-influence-hf-paper-insight-flow-paper-to-project-\347\240\224\347\251\266-s02-architecture.design.md" +++ "b/harness/sprints/sprint-20260527-p0-ai-influence-hf-paper-insight-flow-paper-to-project-\347\240\224\347\251\266-s02-architecture/sprint-20260527-p0-ai-influence-hf-paper-insight-flow-paper-to-project-\347\240\224\347\251\266-s02-architecture.design.md" @@ -45,7 +45,7 @@ downstream: S03 core-runtime · S04 orchestration-ui |--------|------|---------|----------| | D1 | PaperSnapshot/PaperCanonical/Enrichment/Taxonomy 持久化引擎 (SQLite vs PG vs MongoDB) + schema 物理实现 | OQ-01 | A2 | | D2 | 5 enrichment provider rate limit + backoff + retry 策略 (HF/arXiv/HF assets/Semantic Scholar/GitHub) | OQ-02 | A1/A4 | -| D3 | Browser Agent ChatGPT 5.5 Thinking high 接入实现 (复用现有 vs 新建) | OQ-03 | A1/A3/A4 | +| D3 | Browser Agent ChatGPT 5.5 Thinking high 与 `TechnologyDiagramPainter` 共用接入实现 (复用现有 vs 新建) | OQ-03 | A1/A3/A4 | | D4 | 36 评分权重存储方式 (硬编码 vs YAML config vs DB) + profile 差异化 | OQ-04 | A2/A4 | | D5 | Knowledge ingest 4 通道写入顺序与 fallback (raw→extracted→QMD→graph 串行/并行) | OQ-05 | A1/A4 | @@ -64,18 +64,19 @@ A4_open_questions_resolutions ┘ (与 A1 并行) ## 4. 节点产出结构 -### A1 `architecture.md` 必须 10 节 +### A1 `architecture.md` 必须 11 节 -1. **系统全景图** + 6 大组件 (Collector / Canonicalizer / Enricher / Classifier / Scoring+Packet / Reasoning+Resonance / Compiler+Store / Watch / CLI / Config) +1. **系统全景图** + 7 大组件 (Collector / Canonicalizer / Enricher / Classifier / Scoring+Packet / Reasoning+Resonance / Compiler+FigureBundle / Store / Watch / CLI / Config) 2. **模块划分** — 与 S01 O1-O7 outcome 对齐 + 10 层 L0-L10 拓扑 3. **control plane** (CLI + Config + Watch trigger) **vs data plane** (raw → canonical → enriched → scored → packet → resonance → compiled → store) 4. **L0-L10 pipeline 时序图** (含 5 评分 + 4 信号 + R0-R5 + High Model 路由 + 3 质量门) 5. **持久化引擎决议** (per D1+OQ-01): 选定方案 + schema 物理实现 6. **5 Provider 限流 + retry** (per D2+OQ-02): per-provider 速率 + 失败 backoff + circuit breaker -7. **Browser Agent 接入** (per D3+OQ-03): 复用 / 新建 + 调用路径 + 失败回退 +7. **Browser Agent 接入** (per D3+OQ-03): 复用 / 新建 + ChatGPT reasoning 与 `TechnologyDiagramPainter` 的共享调用路径 + 失败回退 8. **Knowledge ingest 4 通道** (per D5+OQ-05): 写入顺序 (raw 先, extracted/QMD/graph 后) + 失败 fallback + 部分写入处理 -9. **失败恢复 / 观测**: 4 信号 retry / High Model quota / Packet Gate fail / 全 pipeline dashboard -10. **冲突 / 依赖 / 降级 / 非目标 / S03+S04 接力** +9. **Figure Bundle 架构**: `figure-spec` 编译、`TechnologyDiagramPainter` 调度、`figure-manifest`、图文渲染位置与 evidence-grounding gate +10. **失败恢复 / 观测**: 4 信号 retry / High Model quota / Packet Gate fail / figure paint fail / 全 pipeline dashboard +11. **冲突 / 依赖 / 降级 / 非目标 / S03+S04 接力** ### A2 `data_models.md` 必须 6 节 @@ -94,7 +95,8 @@ A4_open_questions_resolutions ┘ (与 A1 并行) 4. **Classifier API** (L3): classify_paper 签名 + taxonomy 输出 5. **Scoring + Packet API** (L4+L5): compute_scores (5 公式) / build_packet_v2 / packet_gate_check 签名 6. **Reasoning + Resonance API** (L6+L7): match_resonance / call_high_model (Browser Agent) / insight_gate_check / resonance_gate_check 签名 -7. **Compiler + Store + Watch API** (L8+L9+L10): compile_outputs (7 资产) / store_to_knowledge (4 通道) / trigger_watch 签名 + CLI 入口签名 + Config schema +7. **Figure API**: `build_figure_specs(report_plan, sections, packets) -> list[FigureSpec]` / `paint_figure(spec) -> FigureResult` / `validate_figure_bundle(bundle) -> FigureValidation` +8. **Compiler + Store + Watch API** (L8+L9+L10): compile_outputs (7 资产 + figure bundle) / store_to_knowledge (4 通道) / trigger_watch 签名 + CLI 入口签名 + Config schema ### A4 `open_questions_resolutions.md` 每 OQ 6 字段 @@ -109,7 +111,7 @@ A4_open_questions_resolutions ┘ (与 A1 并行) 5 OQ 推荐方向 (planner 视角): - **OQ-01** (持久化引擎): SQLite (WAL 模式) + JSON 字段; 数据量 ≤100k papers 可控; 后期 PG 迁移 in scope - **OQ-02** (provider 限流): HF 5/s + arXiv 3/s + HF assets 5/s + Semantic Scholar 100/5min (API key) + GitHub 5000/h (token); per-provider circuit breaker + exponential backoff 3 retry -- **OQ-03** (Browser Agent): 复用现有 Browser Agent skill (gstack browser.browse); ChatGPT 5.5 Thinking high 通过 web 调用而非 API +- **OQ-03** (Browser Agent): 复用现有 Browser Agent skill;ChatGPT 5.5 Thinking high 与 `TechnologyDiagramPainter` 共用同一浏览器控制面与 profile/lease/session 体系 - **OQ-04** (权重存储): YAML config (`~/.solar/config/hf_paper_insight_weights.yaml`) per profile (ai-influence / research-radar / experiment-only); hot-reload 支持 - **OQ-05** (Knowledge ingest): raw 先同步写盘 → 异步触发 extracted/QMD/graph (并行) → 失败 fallback file 缓冲 + ATLAS structured repair diff --git "a/harness/sprints/sprint-20260527-p0-ai-influence-hf-paper-insight-flow-paper-to-project-\347\240\224\347\251\266-s02-architecture/sprint-20260527-p0-ai-influence-hf-paper-insight-flow-paper-to-project-\347\240\224\347\251\266-s02-architecture.prd.md" "b/harness/sprints/sprint-20260527-p0-ai-influence-hf-paper-insight-flow-paper-to-project-\347\240\224\347\251\266-s02-architecture/sprint-20260527-p0-ai-influence-hf-paper-insight-flow-paper-to-project-\347\240\224\347\251\266-s02-architecture.prd.md" index b7a80d4ab..031dc6c63 100644 --- "a/harness/sprints/sprint-20260527-p0-ai-influence-hf-paper-insight-flow-paper-to-project-\347\240\224\347\251\266-s02-architecture/sprint-20260527-p0-ai-influence-hf-paper-insight-flow-paper-to-project-\347\240\224\347\251\266-s02-architecture.prd.md" +++ "b/harness/sprints/sprint-20260527-p0-ai-influence-hf-paper-insight-flow-paper-to-project-\347\240\224\347\251\266-s02-architecture/sprint-20260527-p0-ai-influence-hf-paper-insight-flow-paper-to-project-\347\240\224\347\251\266-s02-architecture.prd.md" @@ -29,7 +29,7 @@ L4 Signal Scoring:输出 research_signal_score、insight_report_score、experi L5 Evidence Packet:生成 PaperEvidencePacket v2,给高级模型使用,不把 raw list 直接喂给模型。 L6 Resonance Matcher:匹配 Paper ↔ Code ↔ Influence,输出 R0-R5 共振等级。 L7 High Reasoning:通过 Browser Agent 调用 ChatGPT 5.5 Thinking high,做技术路线、趋势、噪声、选题、项目建议。 -L8 Compiler:生成 HF insight report、Paper Insight Cards、Three-source Resonance Seeds、AI Influence Topic Pool、Experiment Tasks、Open-source Project Briefs、Deep Research Seed Packs。 +L8 Compiler:生成 HF insight report、Paper Insight Cards、Three-source Resonance Seeds、AI Influence Topic Pool、Experiment Tasks、Open-source Project Briefs、Deep Research Seed Packs,以及由 `TechnologyDiagramPainter` 驱动的 Figure Bundle(架构图 / 流程图 / 技术栈图)。 L9 Knowledge Store:写 raw/extracted/QMD/graph/claim。 L10 Watch Trigger:把高价值论文/路线送入持续追踪、三源共振、Deep Research 或开源孵化队列。 @@ -123,6 +123,7 @@ High Model 输出必须包括: 5. Experiment / Reproduction Tasks 6. Open-source Project Briefs 7. Deep Research Seed Packs +8. Figure Bundle(至少覆盖 `research_route_map`、`paper_to_project_flow`、`technology_stack_map` 中的充分证据图) 报告模板: # AI Influence HF Paper Research Signal Report @@ -138,6 +139,7 @@ High Model 输出必须包括: 9. Open-source Project Briefs 10. Deep Research Seed Packs 11. Watchlist +12. Research Route / Flow / Stack Figures CLI: solar radar hf-papers run \ @@ -226,9 +228,10 @@ Resonance Gate 必须检查 paper source >=1;code/influence 可选但要可回 6. 高模型输出不允许只复述摘要,必须有技术路线、为什么重要、风险、推荐动作。 7. 输出 R0-R5 resonance_level。 8. 至少生成 report/cards/seeds/topics/experiments/projects/deep-research 七类资产。 -9. 产物写入 Knowledge raw/extracted,QMD 可搜索。 -10. 用 2026-05-27 的 HF daily/weekly/monthly 数据跑通第一条完整闭环。 -11. py_compile 和相关最小回归通过。 +9. 在 evidence 充分时,额外生成 `figure-spec`、`figure-manifest` 与至少 1 张 `TechnologyDiagramPainter` 图;证据不足时明确 `skipped/warn`。 +10. 产物写入 Knowledge raw/extracted,QMD 可搜索。 +11. 用 2026-05-27 的 HF daily/weekly/monthly 数据跑通第一条完整闭环。 +12. py_compile 和相关最小回归通过。 边界: - 不把 YouTube 低质量 transcript 作为强证据;YouTube match 默认为 gated。 diff --git "a/harness/sprints/sprint-20260531-p0-ai-influence-youtube-\346\212\245\345\221\212\346\265\201\350\264\250\351\207\217\345\242\236\345\274\272-report-ir-\351\200\220\347\253\240\345\206\231\344\275\234-deep-w-s01-requirements/sprint-20260531-p0-ai-influence-youtube-\346\212\245\345\221\212\346\265\201\350\264\250\351\207\217\345\242\236\345\274\272-report-ir-\351\200\220\347\253\240\345\206\231\344\275\234-deep-w-s01-requirements.design.md" "b/harness/sprints/sprint-20260531-p0-ai-influence-youtube-\346\212\245\345\221\212\346\265\201\350\264\250\351\207\217\345\242\236\345\274\272-report-ir-\351\200\220\347\253\240\345\206\231\344\275\234-deep-w-s01-requirements/sprint-20260531-p0-ai-influence-youtube-\346\212\245\345\221\212\346\265\201\350\264\250\351\207\217\345\242\236\345\274\272-report-ir-\351\200\220\347\253\240\345\206\231\344\275\234-deep-w-s01-requirements.design.md" index f977aef0f..1df1bf1d7 100644 --- "a/harness/sprints/sprint-20260531-p0-ai-influence-youtube-\346\212\245\345\221\212\346\265\201\350\264\250\351\207\217\345\242\236\345\274\272-report-ir-\351\200\220\347\253\240\345\206\231\344\275\234-deep-w-s01-requirements/sprint-20260531-p0-ai-influence-youtube-\346\212\245\345\221\212\346\265\201\350\264\250\351\207\217\345\242\236\345\274\272-report-ir-\351\200\220\347\253\240\345\206\231\344\275\234-deep-w-s01-requirements.design.md" +++ "b/harness/sprints/sprint-20260531-p0-ai-influence-youtube-\346\212\245\345\221\212\346\265\201\350\264\250\351\207\217\345\242\236\345\274\272-report-ir-\351\200\220\347\253\240\345\206\231\344\275\234-deep-w-s01-requirements/sprint-20260531-p0-ai-influence-youtube-\346\212\245\345\221\212\346\265\201\350\264\250\351\207\217\345\242\236\345\274\272-report-ir-\351\200\220\347\253\240\345\206\231\344\275\234-deep-w-s01-requirements.design.md" @@ -33,6 +33,7 @@ Knowledge Context: solar-harness context inject used | **RG6** | **Synthesizer 与 Copy Editor 策略** | 负责开头核心判断、章节过渡去重、语言风格统一及敏感/调度字段净化,严禁 Synthesizer 和 Copy Editor 新增或拔高任何事实。 | S02 / S03 | 避免 Synthesizer 在合成时出现二次幻想或信息泄露。 | | **RG7** | **Quality Score 量化评分体系** | 基于 9 项加权评分公式落盘 `quality-score.json`,并划分 A/B/C/D 等级指导发布决策。 | S02 / S03 / S04 | 评分公式的各项权值之和必须严格为 1.0。 | | **RG8** | **Browser Agent Operator 规范与 Proof 验证** | 明确定义 6 类 Operator 的 Thinking 级别与 UI/Deep Research proof 强校验规则,缺 proof 视为任务失败。 | S02 / S03 / S05 | 严禁使用普通 direct GPT 输出冒充 Deep Research proof。 | +| **RG9** | **TechnologyDiagramPainter 图文报告契约** | 以结构化 `figure-spec` 驱动 `TechnologyDiagramPainter` 生成架构图 / 流程图 / 技术堆栈图,最终报告必须产 `figure-manifest.json` 并在 Markdown/HTML 中嵌入图和 caption。 | S02 / S03 / S04 / S05 | 图必须 evidence-grounded;证据不足时允许 skip/warn,严禁生成装饰性假图。 | ## 4. Data Specification Contracts @@ -95,6 +96,32 @@ $$QualityScore = 0.20 \times G + 0.15 \times C + 0.15 \times D + 0.15 \times X + - $60 \le Score < 75$: **C级** (内部审阅参考,严禁对外发布,`internal_only`) - $Score < 60$: **D级** (进入 repair 流程,修补失败则直接 blocked,`blocked`) +### 4.4 Figure Bundle Contract (`figures/*.json`, `figure-manifest.json`) +S02 必须定义图生成与嵌入契约,供 YouTube 报告 runtime 调用 `TechnologyDiagramPainter`: +- `figure-spec.json`: + - `figure_id`: string (格式:`fig_XX`) + - `figure_type`: string (`architecture_overview` | `trend_flow` | `technology_stack`) + - `placement`: string (`report_lead` | `chapter_inline` | `appendix`) + - `title`: string + - `caption`: string + - `source_chapter_ids`: list[string] + - `evidence_refs`: list[string] + - `input_outline`: list[string] + - `render_prompt`: string + - `status`: string (`queued` | `painted` | `skipped` | `failed`) +- `figure-result.json`: + - `figure_id`, `status`, `image_path`, `request_dir`, `chatgpt_url`, `browser_session_id`, `original_image_ok`, `error` +- `figure-manifest.json`: + - `report_id`, `figures`, `painted_count`, `skipped_count`, `failed_count`, `validator_overall` + +图生成规则: +- 每份报告默认目标 1-3 张图,不允许无限制生图。 +- `architecture_overview` 用于总结核心技术路线或系统结构。 +- `trend_flow` 用于表达趋势演进、因果链或执行流程。 +- `technology_stack` 用于表达生态分层、模型/数据/工具栈关系。 +- 每张图必须绑定 `source_chapter_ids + evidence_refs`,不得脱离正文和证据独立臆造。 +- 若 `evidence_refs` 不足、章节未通过 verifier、或 Deep Writer proof 缺失,对应图必须 `skipped/warn`,不得继续生成。 + ## 5. Verifier & Repair Loop Flow ### 5.1 Chapter Verifier 检查项 @@ -130,6 +157,22 @@ graph TD K -- No --> L[标记为 failed / 降级为 internal_only] ``` +### 5.3 Figure Grounding Gate +`TechnologyDiagramPainter` 不是装饰性后处理,而是 verifier 之后的受控图生成阶段: +1. `Synthesizer` 产出结构化章节结果后,`Figure Spec Builder` 从 `report-ir + chapter_outputs + evidence_map` 编译候选图。 +2. 仅 `passed` 章节可进入 `figure-spec` 编译。 +3. `TechnologyDiagramPainter` 只吃 `figure-spec`,不直接吃整份自由文本报告。 +4. `Figure Validator` 必须检查: + - `has_evidence_refs` + - `source_chapters_passed` + - `image_exists` + - `caption_not_empty` + - `no_internal_field_leak` +5. `Figure Validator` 失败时: + - 可单图降级为 `skipped` + - 不应伪装为“已有图” + - 不得把无图报告渲染成“图文并茂已完成” + ## 6. Traceability Matrix | 父级 Epic 缺口描述 | S01 Requirements 定义 | 下游承接切片与验证目标 | @@ -140,6 +183,7 @@ graph TD | 缺章或验证失败时整份报告直接 blocked,无法容错和自动修补 | **RG5**: 制定 Repair Loop 流程,限制最多 3 轮的 5 大场景定向修补。 | **S03 Core Runtime**: 实现修补状态流转与重试调度。
    **S05 Verification**: 验证缺章能够自动触发重写并最终通过。 | | 最终输出泄露 video_id、json key 等内部敏感信息 | **RG6**: 定义 Synthesizer 和 Copy Editor 的事实净化约束和信息密度合并约束。 | **S03 Core Runtime**: 编写 Copy Editor 净化正则与模型 prompt 拦截规则。
    **S05 Verification**: 验证生成产物中没有 harness 及 raw video 敏感元数据。 | | 缺乏可对报告产物客观衡量的多维度质量评分指标 | **RG7**: 定义包含 9 项加权评分公式的 `report_quality_score` 决策层。 | **S04 Orchestration/UI**: UI 面集成 quality-score.json 状态。
    **S05 Verification**: 生成最终 A/B/C/D 分数报告。 | +| YouTube 洞察报告缺少结构图、流程图、技术栈图,图和文脱节 | **RG9**: 定义 `figure-spec -> TechnologyDiagramPainter -> figure-manifest` 契约,并要求最终 `report.md/report.html` 嵌入图、caption 和 evidence 绑定。 | **S03 Core Runtime**: 增加 Figure Spec Builder、Painter dispatch、Figure Validator、render/embed。
    **S05 Verification**: 验证至少 1 张 evidence-grounded 图能落盘,缺证据时显式 skip/warn。 | ## 7. 下游边界 @@ -149,9 +193,11 @@ graph TD - **S03 Core Runtime**: - **必须消费**:S02 设计的架构、接口与 schema 规范。 - **严禁**:保留旧版“整篇报告一次性生成”作为默认执行方式。 + - **必须新增**:`figure-spec` 编译、`TechnologyDiagramPainter` 调度、`figure-manifest.json` 与最终 markdown/html 内嵌图能力。 - **S04 Orchestration / UI**: - **必须消费**:S01 和 S02 输出的质量评分、校验文件及任务运行状态数据。 - **严禁**:只渲染文本总结,忽视对 quality-score.json 细项和 chapter validation 状态的可视化。 + - **必须新增**:figure pane/status,显示 `painted/skipped/failed`、image path、caption、source chapters。 - **S05 Verification / Release**: - **必须消费**:S01 定义的 Traceability Map、验收指标和负控测试点。 - **严禁**:在没有 TDD 回归测试套件和 activation-proof 的情况下标记 Epic 完成。 @@ -164,7 +210,9 @@ graph TD 4. P0/P1 章节没有提取到合规的 Deep Research proof 凭证,必须将其标记为 failed 阻断发布。 5. 最终生成的报告 markdown 文本中,若包含任何 video_id、json key 等 harness 敏感词汇,必须被 Copy Editor 拦截。 6. 不得使用 T3/failed 的 transcript 段落支持任何 P0/P1/P2 章节的核心趋势判断。 +7. `TechnologyDiagramPainter` 生成的图若没有 `evidence_refs` 或 `figure-manifest.json` 缺字段,不得嵌入最终报告。 +8. 缺证据或 validator 未过时,必须 `skipped/warn`,不得用装饰图补位。 ## 9. Handoff -子 S01 需求拆解与追踪矩阵已在 requirements 层全面锁定。所有 8 个核心需求组(RG1-RG8)已完全映射到 S02-S05 下游阶段。后续 S02 架构阶段必须在 design.md 中严格遵照本规约进行数据模型与状态机接口的设计。 +子 S01 需求拆解与追踪矩阵已在 requirements 层全面锁定。所有 9 个核心需求组(RG1-RG9)已完全映射到 S02-S05 下游阶段。后续 S02 架构阶段必须在 design.md 中严格遵照本规约进行数据模型、图生成契约与状态机接口的设计。 diff --git "a/harness/sprints/sprint-20260531-p0-ai-influence-youtube-\346\212\245\345\221\212\346\265\201\350\264\250\351\207\217\345\242\236\345\274\272-report-ir-\351\200\220\347\253\240\345\206\231\344\275\234-deep-w-s01-requirements/sprint-20260531-p0-ai-influence-youtube-\346\212\245\345\221\212\346\265\201\350\264\250\351\207\217\345\242\236\345\274\272-report-ir-\351\200\220\347\253\240\345\206\231\344\275\234-deep-w-s01-requirements.prd.md" "b/harness/sprints/sprint-20260531-p0-ai-influence-youtube-\346\212\245\345\221\212\346\265\201\350\264\250\351\207\217\345\242\236\345\274\272-report-ir-\351\200\220\347\253\240\345\206\231\344\275\234-deep-w-s01-requirements/sprint-20260531-p0-ai-influence-youtube-\346\212\245\345\221\212\346\265\201\350\264\250\351\207\217\345\242\236\345\274\272-report-ir-\351\200\220\347\253\240\345\206\231\344\275\234-deep-w-s01-requirements.prd.md" index 0f828788d..269b99e72 100644 --- "a/harness/sprints/sprint-20260531-p0-ai-influence-youtube-\346\212\245\345\221\212\346\265\201\350\264\250\351\207\217\345\242\236\345\274\272-report-ir-\351\200\220\347\253\240\345\206\231\344\275\234-deep-w-s01-requirements/sprint-20260531-p0-ai-influence-youtube-\346\212\245\345\221\212\346\265\201\350\264\250\351\207\217\345\242\236\345\274\272-report-ir-\351\200\220\347\253\240\345\206\231\344\275\234-deep-w-s01-requirements.prd.md" +++ "b/harness/sprints/sprint-20260531-p0-ai-influence-youtube-\346\212\245\345\221\212\346\265\201\350\264\250\351\207\217\345\242\236\345\274\272-report-ir-\351\200\220\347\253\240\345\206\231\344\275\234-deep-w-s01-requirements/sprint-20260531-p0-ai-influence-youtube-\346\212\245\345\221\212\346\265\201\350\264\250\351\207\217\345\242\236\345\274\272-report-ir-\351\200\220\347\253\240\345\206\231\344\275\234-deep-w-s01-requirements.prd.md" @@ -28,6 +28,9 @@ Video Pool -> Chapter Verifier -> Repair Loop -> Report Synthesizer + -> Figure Spec Builder + -> TechnologyDiagramPainter + -> Figure Grounding Gate -> Global Verifier -> Copy Editor -> Markdown / HTML / Knowledge Raw @@ -40,7 +43,8 @@ P0 实现目标: 5. 每章必须经过 Chapter Verifier,缺章、弱证据、unsupported claim、内部字段泄露必须触发 repair,而不是整份报告直接 blocked。 6. 所有章节通过后由 Synthesizer 合成整篇报告,再由 Global Verifier + Copy Editor 生成 final/report.md 和 final/report.html。 7. 质量结果必须落盘:quality-score.json、claim-verification.json、chapter-validation-summary.json、report-validation-result.json。 -8. 保留 Browser Agent / ChatGPT 项目“杂项”归档要求,账号、profile、secret 必须走环境变量,不得写入仓库。 +8. 在证据充分时,调用 `TechnologyDiagramPainter` 产出 1-3 张架构图 / 流程图 / 技术堆栈图,并把图片、caption、evidence 绑定嵌入 final report。 +9. 保留 Browser Agent / ChatGPT 项目“杂项”归档要求,账号、profile、secret 必须走环境变量,不得写入仓库。 Report IR 要求: - 新增 compile_report_ir(report_plan, catalog, video_groups, config) -> dict。 @@ -69,6 +73,7 @@ Browser Agent Operator 使用规则: - VerifierOperator:检查证据、结构、内部字段、缺章、unsupported claim;可先用 deterministic verifier + 可选 high model verifier。 - SynthesizerOperator:合并章节,统一叙事,不新增事实。 - CopyEditorOperator:只清理语言、重复、内部字段、标题密度,不新增事实。 +- TechnologyDiagramPainter:只根据结构化 `figure-spec` 生成正式 Figure 风格图;必须保留 image/result/proof;证据不足时必须 `skipped/warn`,不得硬画。 Chapter Writer 验收: - 每章必须有一句话判断。 @@ -139,6 +144,9 @@ reports// validation/report-validation-result.json validation/claim-verification.json validation/quality-score.json + figures/fig_01.spec.json + figures/fig_01.result.json + figures/figure-manifest.json final/report.md final/report.html final/transcripts.txt @@ -155,9 +163,14 @@ reports// run_chapter_verifier run_chapter_repair_loop synthesize_report + compile_report_figure_specs + run_technology_diagram_painter + validate_report_figures + render_report_with_figures run_global_report_verifier run_copy_editor - tools/chatgpt_report_operator.py 已有 planner/chapter_writer/deep_writer 基础能力,本单重点是把它们接入 YouTube 报告主链路,并补 verifier/synthesizer/copy editor operator policy。 + `TechnologyDiagramPainter` 则作为 figure-stage browser operator 接到最终渲染链路。 硬性禁止: - 禁止用 Codex/direct GPT/local Qwen 替代最终 Planner/Chapter/Deep Writer。 @@ -182,9 +195,10 @@ S06 Verification:用 W21/W22 新 transcript fixture 跑端到端,不使用 l 4. P0/P1 章节有 Deep Writer proof;没有 proof 不能通过。 5. 缺章、弱章、内部字段泄露、unsupported claim 会进入 repair loop。 6. 最终 report.md/report.html 来自 Synthesizer + Copy Editor,不是单次 writer 直出。 -7. validation/quality-score.json 存在,并给出 A/B/C/D 决策。 -8. W21/W22 fixture 端到端通过:只使用 T0/T1/T2 新 transcript;T3/legacy ASR 不进入核心证据。 -9. pytest/py_compile 通过,新增测试覆盖 Report IR、chapter evidence selection、deep proof enforcement、repair loop、quality scoring、final validation。 +7. `figures/figure-manifest.json` 存在,并记录 `painted/skipped/failed` 与 evidence 绑定。 +8. 最终 report.md/report.html 在证据充分时包含至少 1 张 `TechnologyDiagramPainter` 生成图及 caption。 +9. W21/W22 fixture 端到端通过:只使用 T0/T1/T2 新 transcript;T3/legacy ASR 不进入核心证据。 +10. pytest/py_compile 通过,新增测试覆盖 Report IR、chapter evidence selection、deep proof enforcement、repair loop、quality scoring、figure grounding、final validation。 ## 本切片目标 diff --git a/harness/tests/browser/test_browser_operator_submit.py b/harness/tests/browser/test_browser_operator_submit.py new file mode 100644 index 000000000..4f99dc2a7 --- /dev/null +++ b/harness/tests/browser/test_browser_operator_submit.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(ROOT / "harness" / "lib")) + +from browser_operator_submit import build_chatgpt_operator_env +from browser_operator_submit import browser_agent_chatgpt_cmd +from browser_operator_submit import derive_chatgpt_session_lineage +from browser_operator_submit import submit_chatgpt_operator_request +from browser_operator_submit import submit_gemini_operator_request +from browser_operator_submit import submit_youtube_operator_request + + +def test_browser_agent_chatgpt_cmd_uses_reasoner_browser_agent_cmd(monkeypatch): + monkeypatch.delenv("TECH_HOTSPOT_BROWSER_CHATGPT_CMD", raising=False) + monkeypatch.delenv("BROWSER_AGENT_CHATGPT_CMD", raising=False) + cmd = browser_agent_chatgpt_cmd( + { + "youtube": { + "phase_report_reasoner": { + "browser_agent_cmd": "python3 /tmp/reasoner-wrapper.py", + } + } + } + ) + assert cmd == ["python3", "/tmp/reasoner-wrapper.py"] + + +def test_derive_chatgpt_session_lineage_groups_report_chapters(): + lineage = derive_chatgpt_session_lineage( + "ai-influence-report-chapter-2026-06-03-agent-memory-landscape-intro" + ) + assert lineage == "ai-influence-report:2026-06-03:agent-memory-landscape" + + +def test_build_chatgpt_operator_env_sets_broker_fields(): + env = build_chatgpt_operator_env( + model="chatgpt-5.5", + reasoning_effort="high", + expected="markdown", + request_dir="/tmp/request-dir", + purpose="hf-paper-report-plan-2026-06-03", + session_lineage="hf-paper-report:2026-06-03", + session_reuse=True, + operator_kind="planner", + target_url="https://chatgpt.com", + headless=True, + profile_directory="Default", + target_account_email="browser-agent@example.com", + scrub_client_state=True, + open_project_first=True, + require_project=True, + force_new_chat=True, + require_isolated_conversation=True, + project_name="杂项", + base_env={"EXISTING": "1"}, + ) + assert env["EXISTING"] == "1" + assert env["BROWSER_AGENT_SESSION_LINEAGE"] == "hf-paper-report:2026-06-03" + assert env["SOLAR_BROWSER_SESSION_REUSE"] == "true" + assert env["CHATGPT_REPORT_OPERATOR_KIND"] == "planner" + assert env["BROWSER_AGENT_CHATGPT_PROJECT_NAME"] == "杂项" + assert env["BROWSER_AGENT_TARGET_ACCOUNT_EMAIL"] == "browser-agent@example.com" + + +def test_submit_chatgpt_operator_request_writes_stdout(tmp_path): + wrapper = tmp_path / "fake_wrapper.py" + wrapper.write_text( + "import json\n" + "print(json.dumps({'body': 'x' * 1200}, ensure_ascii=False))\n", + encoding="utf-8", + ) + result = submit_chatgpt_operator_request( + cmd=[sys.executable, str(wrapper)], + prompt="demo prompt", + timeout=30, + env={}, + request_dir=tmp_path / "request", + expected="markdown", + ) + assert result["latency_ms"] >= 0 + payload = json.loads(result["output"]) + assert len(payload["body"]) == 1200 + assert (tmp_path / "request" / "stdout.txt").exists() + + +def test_submit_chatgpt_operator_request_uses_explicit_submit_poll_collect(monkeypatch, tmp_path): + operator = tmp_path / "fake_operator.py" + operator.write_text( + "import json, os\n" + "from pathlib import Path\n" + "request_dir = Path(os.environ['BROWSER_AGENT_REQUEST_DIR'])\n" + "request_dir.mkdir(parents=True, exist_ok=True)\n" + "action = os.environ.get('CHATGPT_REPORT_ACTION', 'run')\n" + "if action == 'submit':\n" + " (request_dir / 'submitted-run.json').write_text(json.dumps({'task_id': 'task-123'}, ensure_ascii=False), encoding='utf-8')\n" + " print(json.dumps({'status': 'submitted', 'task_id': 'task-123'}, ensure_ascii=False))\n" + "elif action == 'collect':\n" + " print(json.dumps({'body': 'y' * 1200}, ensure_ascii=False))\n" + "else:\n" + " print(json.dumps({'status': action}, ensure_ascii=False))\n", + encoding="utf-8", + ) + statuses = iter( + [ + {"status": "running", "latest_result": {}}, + {"status": "completed", "latest_result": {}}, + ] + ) + sleep_calls: list[float] = [] + monkeypatch.setattr("browser_operator_submit.poll_request", lambda task_id: next(statuses)) + monkeypatch.setattr("browser_operator_submit.time.sleep", lambda seconds: sleep_calls.append(seconds)) + result = submit_chatgpt_operator_request( + cmd=[sys.executable, str(operator)], + prompt="demo prompt", + timeout=30, + env={"BROWSER_AGENT_REQUEST_DIR": str(tmp_path / "request")}, + request_dir=tmp_path / "request", + expected="markdown", + use_session_control=True, + poll_interval_seconds=0.01, + ) + assert result["task_id"] == "task-123" + payload = json.loads(result["output"]) + assert len(payload["body"]) == 1200 + assert (tmp_path / "request" / "submit-stdout.txt").exists() + assert (tmp_path / "request" / "poll-status.json").exists() + assert sleep_calls == [0.4] + + +def test_submit_gemini_operator_request_writes_stdout(tmp_path): + wrapper = tmp_path / "fake_gemini_wrapper.py" + wrapper.write_text( + "print('g' * 600)\n", + encoding="utf-8", + ) + result = submit_gemini_operator_request( + cmd=[sys.executable, str(wrapper)], + prompt="deep research prompt", + timeout=30, + env={}, + request_dir=tmp_path / "gemini-request", + ) + assert result["latency_ms"] >= 0 + assert len(result["output"]) == 600 + assert (tmp_path / "gemini-request" / "stdout.txt").exists() + + +def test_submit_youtube_operator_request_writes_stdout(tmp_path): + wrapper = tmp_path / "fake_youtube_wrapper.py" + wrapper.write_text( + "print('ok')\n", + encoding="utf-8", + ) + result = submit_youtube_operator_request( + cmd=[sys.executable, str(wrapper)], + youtube_url="https://www.youtube.com/watch?v=wQE2ItbsnVo", + timeout=30, + env={}, + request_dir=tmp_path / "youtube-request", + ) + assert result["latency_ms"] >= 0 + assert result["output"] == "ok" + assert (tmp_path / "youtube-request" / "stdout.txt").exists() diff --git a/harness/tests/browser/test_login_recovery.py b/harness/tests/browser/test_login_recovery.py index 9fd18d840..39acd0483 100644 --- a/harness/tests/browser/test_login_recovery.py +++ b/harness/tests/browser/test_login_recovery.py @@ -1,6 +1,7 @@ """Tests for browser login recovery and runtime control helpers.""" from __future__ import annotations +import json import sys from pathlib import Path @@ -33,6 +34,7 @@ def test_login_recovery_selects_playwright_for_precise_control() -> None: def test_runtime_control_initializes_and_finalizes_contract(tmp_path: Path, monkeypatch) -> None: monkeypatch.setenv("BROWSER_PROFILE_REGISTRY_ROOT", str(tmp_path / "profiles")) monkeypatch.setenv("BROWSER_PROFILE_LEASE_DIR", str(tmp_path / "leases")) + monkeypatch.setenv("SPRINT_ID", "sprint-browser-reuse") request_dir = tmp_path / "request" ctx = runtime_control.initialize_runtime_contract( request_dir=request_dir, @@ -50,6 +52,8 @@ def test_runtime_control_initializes_and_finalizes_contract(tmp_path: Path, monk }, task_id="task-123", ) + assert ctx["session_lineage"] == "sprint-browser-reuse" + assert ctx["session_reuse"] is True assert (request_dir / "browser-profile-ref.json").exists() assert (request_dir / "browser-session-contract.json").exists() runtime_control.update_runtime_endpoint( @@ -69,6 +73,137 @@ def test_runtime_control_initializes_and_finalizes_contract(tmp_path: Path, monk assert (request_dir / "login-recovery-report.json").exists() +def test_runtime_control_active_session_roundtrip(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("BROWSER_PROFILE_REGISTRY_ROOT", str(tmp_path / "profiles")) + monkeypatch.setenv("BROWSER_PROFILE_LEASE_DIR", str(tmp_path / "leases")) + monkeypatch.setenv("SPRINT_ID", "sprint-browser-reuse") + request_dir = tmp_path / "request" + ctx = runtime_control.initialize_runtime_contract( + request_dir=request_dir, + service="chatgpt", + runtime_owner="browser_use", + wrapper_kind="chatgpt", + profile_directory="Profile 7", + user_data_dir=str(tmp_path / "chrome"), + staged_user_data_dir=str(tmp_path / "staged"), + account_identifier="alice@example.com", + control_modes={ + "browser_use_session": True, + "playwright_cdp_attach": False, + "webwright_bridge": False, + }, + task_id="task-123", + ) + runtime_control.activate_reusable_session( + ctx, + cdp_url="http://127.0.0.1:9222", + browser_session_ref="browser-use-session://chatgpt/chatgpt/alice", + headless=True, + attached=False, + details={"cleanup_dir": "/tmp/browser-cleanup"}, + ) + active = runtime_control.read_active_session(ctx) + assert active is not None + assert active["cdp_url"] == "http://127.0.0.1:9222" + assert active["session_lineage"] == "sprint-browser-reuse" + assert runtime_control.clear_active_session(ctx) is True + assert runtime_control.read_active_session(ctx) is None + + +def test_runtime_control_recovers_stale_profile_lease_from_terminal_outbox(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("BROWSER_PROFILE_REGISTRY_ROOT", str(tmp_path / "profiles")) + monkeypatch.setenv("BROWSER_PROFILE_LEASE_DIR", str(tmp_path / "leases")) + monkeypatch.setenv("HARNESS_DIR", str(tmp_path / "harness")) + request_root = tmp_path / "requests" + stale_request = request_root / "task-stale" + stale_request.mkdir(parents=True, exist_ok=True) + (stale_request / "submitted-run.json").write_text( + json.dumps({"task_id": "chatgpt-report-stale", "status": "running"}), + encoding="utf-8", + ) + outbox = tmp_path / "harness" / "actors" / "browser_agent_session" / "outbox" + outbox.mkdir(parents=True, exist_ok=True) + (outbox / "result-chatgpt-report-stale-1.json").write_text( + json.dumps({"task_id": "chatgpt-report-stale", "status": "failed"}), + encoding="utf-8", + ) + request_dir = request_root / "task-new" + ctx1 = runtime_control.initialize_runtime_contract( + request_dir=stale_request, + service="chatgpt", + runtime_owner="browser_use", + wrapper_kind="chatgpt", + profile_directory="Profile 7", + user_data_dir=str(tmp_path / "chrome"), + staged_user_data_dir=str(tmp_path / "staged"), + account_identifier="alice@example.com", + control_modes={"browser_use_session": True}, + task_id="task-stale", + ) + assert ctx1["profile_id"] == "chatgpt/alice" + ctx2 = runtime_control.initialize_runtime_contract( + request_dir=request_dir, + service="chatgpt", + runtime_owner="browser_use", + wrapper_kind="chatgpt", + profile_directory="Profile 7", + user_data_dir=str(tmp_path / "chrome"), + staged_user_data_dir=str(tmp_path / "staged"), + account_identifier="alice@example.com", + control_modes={"browser_use_session": True}, + task_id="task-new", + ) + assert ctx2["task_id"] == "task-new" + + +def test_runtime_control_recovers_stale_profile_lease_when_task_id_only_exists_in_submit_stdout(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("BROWSER_PROFILE_REGISTRY_ROOT", str(tmp_path / "profiles")) + monkeypatch.setenv("BROWSER_PROFILE_LEASE_DIR", str(tmp_path / "leases")) + monkeypatch.setenv("HARNESS_DIR", str(tmp_path / "harness")) + request_root = tmp_path / "requests" + stale_request = request_root / "task-stale" + stale_request.mkdir(parents=True, exist_ok=True) + (stale_request / "submitted-run.json").write_text( + json.dumps({"status": "running"}), + encoding="utf-8", + ) + (stale_request / "submit-stdout.txt").write_text( + json.dumps({"status": "queued", "task_id": "chatgpt-report-stale"}), + encoding="utf-8", + ) + outbox = tmp_path / "harness" / "actors" / "browser_agent_session" / "outbox" + outbox.mkdir(parents=True, exist_ok=True) + (outbox / "result-chatgpt-report-stale-1.json").write_text( + json.dumps({"task_id": "chatgpt-report-stale", "status": "failed"}), + encoding="utf-8", + ) + runtime_control.initialize_runtime_contract( + request_dir=stale_request, + service="chatgpt", + runtime_owner="browser_use", + wrapper_kind="chatgpt", + profile_directory="Profile 7", + user_data_dir=str(tmp_path / "chrome"), + staged_user_data_dir=str(tmp_path / "staged"), + account_identifier="alice@example.com", + control_modes={"browser_use_session": True}, + task_id="task-stale", + ) + ctx2 = runtime_control.initialize_runtime_contract( + request_dir=request_root / "task-new", + service="chatgpt", + runtime_owner="browser_use", + wrapper_kind="chatgpt", + profile_directory="Profile 7", + user_data_dir=str(tmp_path / "chrome"), + staged_user_data_dir=str(tmp_path / "staged"), + account_identifier="alice@example.com", + control_modes={"browser_use_session": True}, + task_id="task-new", + ) + assert ctx2["task_id"] == "task-new" + + def test_login_recovery_report_contains_executor_and_policy() -> None: report = build_login_recovery_report( service="gemini", diff --git a/harness/tests/browser/test_profile_registry.py b/harness/tests/browser/test_profile_registry.py index 532e557c5..72f20046a 100644 --- a/harness/tests/browser/test_profile_registry.py +++ b/harness/tests/browser/test_profile_registry.py @@ -30,6 +30,13 @@ def test_profile_registry_meta_and_health_and_cdp(tmp_path: Path) -> None: assert read_cdp["profile_id"] == profile_id assert registry.cdp_last_path(profile_id).exists() + active = registry.write_active_session(profile_id, {"cdp_url": "http://127.0.0.1:9222", "session_lineage": "sprint-1"}) + assert active["profile_id"] == profile_id + assert registry.read_active_session(profile_id)["cdp_url"] == "http://127.0.0.1:9222" + assert registry.active_session_path(profile_id).exists() + assert registry.clear_active_session(profile_id) is True + assert registry.read_active_session(profile_id) == {} + def test_profile_registry_evidence_and_state_refs(tmp_path: Path) -> None: registry = ProfileRegistry(root=tmp_path) diff --git a/harness/tests/runtime/test_browser_agent_session_actor.py b/harness/tests/runtime/test_browser_agent_session_actor.py new file mode 100644 index 000000000..51bf203c0 --- /dev/null +++ b/harness/tests/runtime/test_browser_agent_session_actor.py @@ -0,0 +1,577 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "lib")) +sys.path.insert(0, str(ROOT / "tools")) + +from actor_mailbox import ActorMailbox +from actor_lease import LeaseBroker +from browser.profile_lease import ProfileLease +from browser_agent_session_actor import ( + _active_run_path, + drain_once, + ensure_supervisor_running, + recover_stale_supervisor_runtime, + supervise_loop, +) + + +def test_browser_agent_session_actor_processes_deepresearch_task(monkeypatch): + with tempfile.TemporaryDirectory() as td: + base = Path(td) / "actors" + lease_dir = Path(td) / "run" / "actor-leases" + mailbox = ActorMailbox("browser_agent_session", base) + envelope = { + "task_id": "task-1", + "logical_operator": "DeepResearchBrowser", + "chatgpt_browser_agent_request": { + "prompt": "写一个测试摘要", + "expected_output": "markdown", + "model": "chatgpt-5.5", + "reasoning_effort": "high", + "project_name": "杂项", + }, + } + mailbox.submit_task(envelope) + wrapper = Path(td) / "fake_wrapper.py" + wrapper.write_text("print('browser actor ok')\n", encoding="utf-8") + monkeypatch.setenv("TECH_HOTSPOT_BROWSER_CHATGPT_CMD", f"{sys.executable} {wrapper}") + monkeypatch.setenv("BROWSER_AGENT_CHATGPT_PROFILE_POLICY_DISABLED", "true") + monkeypatch.setenv("HARNESS_DIR", str(Path(td))) + + rc = drain_once( + actor_id="browser_agent_session", + mailbox_base=base, + lease_dir=lease_dir, + ) + assert rc == 0 + results = mailbox.read_results("task-1") + assert len(results) == 1 + assert results[0]["status"] == "completed" + assert results[0]["pool_slot_id"].startswith("slot-") + assert results[0]["pool_session_lineage"].startswith("browser-agent-session:chatgpt:slot-") + assert Path(results[0]["task_dir"]).exists() + slot_file = Path(results[0]["task_dir"]) / "browser-agent-session-slot.json" + assert slot_file.exists() + assert mailbox.read_inbox() == [] + lease = LeaseBroker(lease_dir).get("browser_agent_session") + assert lease is None or lease.state == "READY" + + +def test_browser_agent_session_actor_reports_unsupported_operator(): + with tempfile.TemporaryDirectory() as td: + base = Path(td) / "actors" + lease_dir = Path(td) / "run" / "actor-leases" + mailbox = ActorMailbox("browser_agent_session", base) + envelope = { + "task_id": "task-unsupported", + "logical_operator": "SomeOtherBrowserThing", + } + mailbox.submit_task(envelope) + rc = drain_once( + actor_id="browser_agent_session", + mailbox_base=base, + lease_dir=lease_dir, + ) + assert rc == 0 + results = mailbox.read_results("task-unsupported") + assert len(results) == 1 + assert results[0]["status"] == "failed" + assert "unsupported_browser_agent_session_logical_operator" in results[0]["error"] + + +def test_browser_agent_session_actor_submit_then_collect(monkeypatch): + with tempfile.TemporaryDirectory() as td: + base = Path(td) / "actors" + lease_dir = Path(td) / "run" / "actor-leases" + mailbox = ActorMailbox("browser_agent_session", base) + envelope = { + "task_id": "task-async", + "logical_operator": "DeepResearchBrowser", + "chatgpt_browser_agent_request": { + "prompt": "异步研究任务", + "expected_output": "markdown", + "model": "chatgpt-5.5", + "reasoning_effort": "high", + "project_name": "杂项", + "action": "submit", + }, + } + mailbox.submit_task(envelope) + wrapper = Path(td) / "fake_wrapper_async.py" + wrapper.write_text( + "import json, os, pathlib\n" + "request_dir = pathlib.Path(os.environ['BROWSER_AGENT_REQUEST_DIR'])\n" + "request_dir.mkdir(parents=True, exist_ok=True)\n" + "counter = request_dir / 'collect-count.txt'\n" + "action = os.environ.get('BROWSER_AGENT_CHATGPT_ACTION', 'run')\n" + "if action == 'submit':\n" + " print(json.dumps({'status': 'submitted', 'url': 'https://chatgpt.com/c/async-demo', 'conversation_id': 'async-demo'}, ensure_ascii=False))\n" + "elif action == 'collect':\n" + " value = int(counter.read_text() or '0') if counter.exists() else 0\n" + " counter.write_text(str(value + 1))\n" + " if value == 0:\n" + " print(json.dumps({'status': 'running', 'url': 'https://chatgpt.com/c/async-demo', 'conversation_id': 'async-demo'}, ensure_ascii=False))\n" + " else:\n" + " print('final async answer')\n" + "else:\n" + " print('unexpected action')\n", + encoding="utf-8", + ) + monkeypatch.setenv("TECH_HOTSPOT_BROWSER_CHATGPT_CMD", f"{sys.executable} {wrapper}") + monkeypatch.setenv("BROWSER_AGENT_CHATGPT_PROFILE_POLICY_DISABLED", "true") + monkeypatch.setenv("HARNESS_DIR", str(Path(td))) + + rc1 = drain_once( + actor_id="browser_agent_session", + mailbox_base=base, + lease_dir=lease_dir, + ) + assert rc1 == 0 + active_dir = Path(td) / "run" / "browser-agent-session-active" / "chatgpt" + manifests = sorted(active_dir.glob("*.json")) + assert len(manifests) == 1 + first_results = mailbox.read_results("task-async") + assert any(item["status"] == "submitted" for item in first_results) + + rc2 = drain_once( + actor_id="browser_agent_session", + mailbox_base=base, + lease_dir=lease_dir, + ) + assert rc2 == 0 + manifests = sorted(active_dir.glob("*.json")) + assert len(manifests) == 1 + second_results = mailbox.read_results("task-async") + assert not any(item["status"] == "completed" for item in second_results) + manifest = json.loads(manifests[0].read_text(encoding="utf-8")) + manifest["next_collect_after_ts"] = time.time() - 1 + manifests[0].write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + rc3 = drain_once( + actor_id="browser_agent_session", + mailbox_base=base, + lease_dir=lease_dir, + ) + assert rc3 == 0 + manifests = sorted(active_dir.glob("*.json")) + assert len(manifests) == 1 + manifest = json.loads(manifests[0].read_text(encoding="utf-8")) + manifest["next_collect_after_ts"] = time.time() - 1 + manifests[0].write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + rc4 = drain_once( + actor_id="browser_agent_session", + mailbox_base=base, + lease_dir=lease_dir, + ) + assert rc4 == 0 + manifests = sorted(active_dir.glob("*.json")) + assert manifests == [] + final_results = mailbox.read_results("task-async") + assert any(item["status"] == "completed" for item in final_results) + + +def test_browser_agent_session_actor_respects_next_collect_backoff(monkeypatch): + with tempfile.TemporaryDirectory() as td: + td_path = Path(td) + base = td_path / "actors" + lease_dir = td_path / "run" / "actor-leases" + mailbox = ActorMailbox("browser_agent_session", base) + task_dir = mailbox.logs / "task-backoff" + task_dir.mkdir(parents=True, exist_ok=True) + envelope = { + "task_id": "task-backoff", + "logical_operator": "DeepResearchBrowser", + "chatgpt_browser_agent_request": { + "prompt": "collect later", + "expected_output": "markdown", + "action": "submit", + "request_dir": str(td_path / "request"), + }, + } + (task_dir / "envelope.json").write_text(json.dumps(envelope, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + monkeypatch.setenv("HARNESS_DIR", str(td_path)) + _active_run_path("task-backoff").parent.mkdir(parents=True, exist_ok=True) + _active_run_path("task-backoff").write_text( + json.dumps( + { + "task_id": "task-backoff", + "logical_operator": "DeepResearchBrowser", + "task_dir": str(task_dir), + "request": envelope["chatgpt_browser_agent_request"], + "slot": {"slot_id": "slot-01", "session_lineage": "browser-agent-session:chatgpt:slot-01"}, + "conversation_url": "https://chatgpt.com/c/test", + "conversation_id": "test", + "status": "running", + "submitted_at": "2026-06-05T00:00:00Z", + "updated_at": "2026-06-05T00:00:00Z", + "collect_attempts": 2, + "next_collect_after_ts": time.time() + 600, + }, + ensure_ascii=False, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + rc = drain_once( + actor_id="browser_agent_session", + mailbox_base=base, + lease_dir=lease_dir, + ) + assert rc == 0 + assert mailbox.read_results("task-backoff") == [] + manifest = json.loads(_active_run_path("task-backoff").read_text(encoding="utf-8")) + assert manifest["collect_attempts"] == 2 + + +def test_browser_agent_session_actor_restores_missing_collect_backoff(monkeypatch): + with tempfile.TemporaryDirectory() as td: + td_path = Path(td) + base = td_path / "actors" + lease_dir = td_path / "run" / "actor-leases" + mailbox = ActorMailbox("browser_agent_session", base) + task_dir = mailbox.logs / "task-backoff-missing" + task_dir.mkdir(parents=True, exist_ok=True) + envelope = { + "task_id": "task-backoff-missing", + "logical_operator": "DeepResearchBrowser", + "chatgpt_browser_agent_request": { + "prompt": "collect later", + "expected_output": "markdown", + "action": "submit", + "request_dir": str(td_path / "request"), + }, + } + (task_dir / "envelope.json").write_text(json.dumps(envelope, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + monkeypatch.setenv("HARNESS_DIR", str(td_path)) + _active_run_path("task-backoff-missing").parent.mkdir(parents=True, exist_ok=True) + _active_run_path("task-backoff-missing").write_text( + json.dumps( + { + "task_id": "task-backoff-missing", + "logical_operator": "DeepResearchBrowser", + "task_dir": str(task_dir), + "request": envelope["chatgpt_browser_agent_request"], + "slot": {"slot_id": "slot-01", "session_lineage": "browser-agent-session:chatgpt:slot-01"}, + "conversation_url": "https://chatgpt.com/c/test", + "conversation_id": "test", + "status": "submitted", + "submitted_at": "2026-06-05T00:00:00Z", + "updated_at": "2026-06-05T00:00:00Z", + "collect_attempts": 3, + }, + ensure_ascii=False, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + rc = drain_once( + actor_id="browser_agent_session", + mailbox_base=base, + lease_dir=lease_dir, + ) + assert rc == 0 + assert mailbox.read_results("task-backoff-missing") == [] + manifest = json.loads(_active_run_path("task-backoff-missing").read_text(encoding="utf-8")) + assert manifest["collect_attempts"] == 3 + assert float(manifest["next_collect_after_ts"]) > time.time() + + +def test_browser_agent_session_actor_processes_gemini_task(monkeypatch): + with tempfile.TemporaryDirectory() as td: + base = Path(td) / "actors" + lease_dir = Path(td) / "run" / "actor-leases" + mailbox = ActorMailbox("browser_agent_session", base) + envelope = { + "task_id": "task-gemini", + "logical_operator": "DeepResearchGemini", + "gemini_deep_research_request": { + "prompt": "研究 gemini task", + "expected_output": "markdown", + "project_name": "杂项", + }, + } + mailbox.submit_task(envelope) + operator = Path(td) / "fake_gemini_operator.py" + operator.write_text( + "import json, os\n" + "from pathlib import Path\n" + "task_dir = Path(os.environ['TASK_DIR'])\n" + "request_dir = task_dir / 'gemini-deep-research-request'\n" + "request_dir.mkdir(parents=True, exist_ok=True)\n" + "(request_dir / 'assistant-response.txt').write_text('gemini actor ok', encoding='utf-8')\n" + "(request_dir / 'page.json').write_text(json.dumps({'title': 'Gemini', 'url': 'https://gemini.google.com/app/1', 'conversation_id': '1', 'citations': []}), encoding='utf-8')\n" + "(task_dir / 'gemini-deep-research-result.json').write_text(json.dumps({'ok': True, 'text': 'gemini actor ok', 'request_dir': str(request_dir), 'project_name': '杂项', 'expected_output': 'markdown'}, ensure_ascii=False), encoding='utf-8')\n" + "print('gemini actor ok')\n", + encoding="utf-8", + ) + monkeypatch.setenv("HARNESS_DIR", str(Path(td))) + monkeypatch.setattr("browser_agent_session_actor.resolve_command", lambda envelope: [sys.executable, str(operator)]) + + rc = drain_once( + actor_id="browser_agent_session", + mailbox_base=base, + lease_dir=lease_dir, + ) + assert rc == 0 + results = mailbox.read_results("task-gemini") + assert len(results) == 1 + assert results[0]["status"] == "completed" + assert results[0]["result_file"].endswith("gemini-deep-research-result.json") + assert Path(results[0]["result_file"]).exists() + + +def test_supervise_loop_writes_state_and_prewarms_slots(monkeypatch): + with tempfile.TemporaryDirectory() as td: + base = Path(td) / "actors" + lease_dir = Path(td) / "run" / "actor-leases" + monkeypatch.setenv("HARNESS_DIR", str(Path(td))) + rc = supervise_loop( + actor_id="browser_agent_session", + mailbox_base=base, + lease_dir=lease_dir, + poll_interval_seconds=0.01, + max_loops=1, + ) + assert rc == 0 + state_path = Path(td) / "run" / "browser-agent-session-supervisor" / "browser_agent_session.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + assert state["status"] == "stopped" + assert state["loop_count"] == 1 + pool_dir = Path(td) / "run" / "browser-agent-session-pool" / "chatgpt" + assert (pool_dir / "slot-01.json").exists() + assert (pool_dir / "slot-02.json").exists() + + +def test_ensure_supervisor_running_reuses_alive_pid(monkeypatch): + with tempfile.TemporaryDirectory() as td: + base = Path(td) / "actors" + lease_dir = Path(td) / "run" / "actor-leases" + pid_path = Path(td) / "run" / "browser-agent-session-supervisor" / "browser_agent_session.pid" + pid_path.parent.mkdir(parents=True, exist_ok=True) + pid_path.write_text("43210", encoding="utf-8") + monkeypatch.setenv("HARNESS_DIR", str(Path(td))) + monkeypatch.setattr("browser_agent_session_actor._pid_alive", lambda pid: pid == 43210) + result = ensure_supervisor_running( + actor_id="browser_agent_session", + mailbox_base=base, + lease_dir=lease_dir, + ) + assert result["ok"] is True + assert result["reused"] is True + assert result["pid"] == 43210 + + +def test_ensure_supervisor_running_replaces_stale_pid(monkeypatch): + with tempfile.TemporaryDirectory() as td: + base = Path(td) / "actors" + lease_dir = Path(td) / "run" / "actor-leases" + pid_path = Path(td) / "run" / "browser-agent-session-supervisor" / "browser_agent_session.pid" + pid_path.parent.mkdir(parents=True, exist_ok=True) + pid_path.write_text("43210", encoding="utf-8") + monkeypatch.setenv("HARNESS_DIR", str(Path(td))) + monkeypatch.setattr("browser_agent_session_actor._pid_alive", lambda pid: False) + + class FakeProc: + pid = 54321 + + seen: dict[str, object] = {} + + def _fake_popen(*args, **kwargs): + seen.update(kwargs) + return FakeProc() + + monkeypatch.setattr("browser_agent_session_actor.subprocess.Popen", _fake_popen) + result = ensure_supervisor_running( + actor_id="browser_agent_session", + mailbox_base=base, + lease_dir=lease_dir, + ) + assert result["ok"] is True + assert result["reused"] is False + assert result["pid"] == 54321 + assert pid_path.read_text(encoding="utf-8").strip() == "54321" + assert seen["stdin"] == subprocess.DEVNULL + assert seen["stdout"] == subprocess.DEVNULL + assert seen["stderr"] == subprocess.DEVNULL + + +def test_browser_agent_session_actor_uses_devnull_stdin_for_operator(monkeypatch): + with tempfile.TemporaryDirectory() as td: + base = Path(td) / "actors" + lease_dir = Path(td) / "run" / "actor-leases" + mailbox = ActorMailbox("browser_agent_session", base) + envelope = { + "task_id": "task-devnull-stdin", + "logical_operator": "DeepResearchBrowser", + "chatgpt_browser_agent_request": { + "prompt": "检查 stdin 配置", + "expected_output": "markdown", + "project_name": "杂项", + }, + } + mailbox.submit_task(envelope) + monkeypatch.setenv("HARNESS_DIR", str(Path(td))) + + seen: dict[str, object] = {} + + class FakeCompleted: + returncode = 0 + stdout = "browser actor ok\n" + + def _fake_run(cmd, **kwargs): + seen.update(kwargs) + task_dir = Path(kwargs["env"]["TASK_DIR"]) + request_dir = task_dir / "chatgpt-browser-agent-request" + request_dir.mkdir(parents=True, exist_ok=True) + (task_dir / "chatgpt-browser-agent-result.json").write_text( + json.dumps( + { + "ok": True, + "text": "browser actor ok", + "request_dir": str(request_dir), + "project_name": "杂项", + "expected_output": "markdown", + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + return FakeCompleted() + + monkeypatch.setattr("browser_agent_session_actor.resolve_command", lambda envelope: [sys.executable, "-V"]) + monkeypatch.setattr("browser_agent_session_actor.subprocess.run", _fake_run) + + rc = drain_once( + actor_id="browser_agent_session", + mailbox_base=base, + lease_dir=lease_dir, + ) + assert rc == 0 + assert seen["stdin"] == subprocess.DEVNULL + assert seen["stdout"] == subprocess.PIPE + assert seen["stderr"] == subprocess.STDOUT + + +def test_recover_stale_supervisor_runtime_resets_lease_slot_and_inbox(monkeypatch): + with tempfile.TemporaryDirectory() as td: + base = Path(td) / "actors" + lease_dir = Path(td) / "run" / "actor-leases" + mailbox = ActorMailbox("browser_agent_session", base) + mailbox.ensure_dirs() + monkeypatch.setenv("HARNESS_DIR", str(Path(td))) + + pid_dir = Path(td) / "run" / "browser-agent-session-supervisor" + pid_dir.mkdir(parents=True, exist_ok=True) + (pid_dir / "browser_agent_session.pid").write_text("43210", encoding="utf-8") + (pid_dir / "browser_agent_session.stop").write_text("1\n", encoding="utf-8") + + broker = LeaseBroker(lease_dir) + lease = broker.acquire( + actor_id="browser_agent_session", + task_id="task-stale", + sprint_id="s1", + node_id="n1", + ) + assert lease is not None + broker.transition("browser_agent_session", "RUNNING") + + mailbox.submit_task({"task_id": "task-stale", "logical_operator": "DeepResearchBrowser"}) + + request_dir = Path(td) / "request-dir" + request_dir.mkdir(parents=True, exist_ok=True) + (request_dir / "runtime.json").write_text( + json.dumps( + { + "profile_id": "chatgpt/test-profile", + "lease": { + "profile_id": "chatgpt/test-profile", + "task_id": "phase1-stage-stale", + }, + }, + ensure_ascii=False, + indent=2, + ) + "\n", + encoding="utf-8", + ) + profile_lease = ProfileLease(root=Path(td) / "profile-leases") + acquired = profile_lease.acquire( + "chatgpt/test-profile", + task_id="phase1-stage-stale", + runtime="browser_use", + mode="exclusive", + ) + assert acquired["acquired"] is True + task_log_dir = base / "browser_agent_session" / "logs" / "task-stale" + task_log_dir.mkdir(parents=True, exist_ok=True) + (task_log_dir / "chatgpt-browser-agent-request.json").write_text( + json.dumps({"request_dir": str(request_dir)}, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + pool_dir = Path(td) / "run" / "browser-agent-session-pool" / "chatgpt" + pool_dir.mkdir(parents=True, exist_ok=True) + (pool_dir / "slot-01.json").write_text( + json.dumps( + { + "slot_id": "slot-01", + "service": "chatgpt", + "state": "running", + "session_lineage": "browser-agent-session:chatgpt:slot-01", + "assigned_task_id": "task-stale", + "assigned_request_lineage": "lineage", + "assigned_request_dir": str(task_log_dir), + "leased_at": "2026-06-04T18:02:17Z", + "last_used_at": "", + "warm": True, + }, + ensure_ascii=False, + indent=2, + ) + "\n", + encoding="utf-8", + ) + + active_dir = Path(td) / "run" / "browser-agent-session-active" / "chatgpt" + active_dir.mkdir(parents=True, exist_ok=True) + (active_dir / "task-stale.json").write_text( + json.dumps( + { + "task_id": "task-stale", + "request": {"request_dir": str(request_dir)}, + }, + ensure_ascii=False, + indent=2, + ) + "\n", + encoding="utf-8", + ) + + monkeypatch.setattr("browser_agent_session_actor._pid_alive", lambda pid: False) + monkeypatch.setenv("BROWSER_PROFILE_LEASE_DIR", str(Path(td) / "profile-leases")) + result = recover_stale_supervisor_runtime( + actor_id="browser_agent_session", + mailbox_base=base, + lease_dir=lease_dir, + ) + assert result["ok"] is True + assert result["recovered"] is True + assert "task-stale" in result["recovered_task_ids"] + recovered_lease = broker.get("browser_agent_session") + assert recovered_lease is None or recovered_lease.state == "READY" + slot = json.loads((pool_dir / "slot-01.json").read_text(encoding="utf-8")) + assert slot["state"] == "idle" + assert slot["assigned_task_id"] == "" + assert mailbox.read_inbox() == [] + results = mailbox.read_results("task-stale") + assert any(item["error"] == "stale_supervisor_runtime_recovered" for item in results) + assert profile_lease.peek("chatgpt/test-profile") is None diff --git a/harness/tests/runtime/test_browser_agent_session_control.py b/harness/tests/runtime/test_browser_agent_session_control.py new file mode 100644 index 000000000..1d55e2dcc --- /dev/null +++ b/harness/tests/runtime/test_browser_agent_session_control.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import io +import json +import sys +import tempfile +from contextlib import redirect_stdout +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "lib")) +sys.path.append(str(ROOT / "tools")) + +from actor_mailbox import ActorMailbox +from browser_agent_session_control import _collect_poll_sleep_seconds, main as control_main, submit_request + + +def _run_cli(args: list[str]) -> tuple[int, dict]: + buf = io.StringIO() + with redirect_stdout(buf): + rc = control_main(args) + text = buf.getvalue().strip() + return rc, json.loads(text) if text else {} + + +def test_supervisor_status_drain_stop(monkeypatch): + with tempfile.TemporaryDirectory() as td: + td_path = Path(td) + monkeypatch.setenv("HARNESS_DIR", str(td_path)) + state_dir = td_path / "run" / "browser-agent-session-supervisor" + state_dir.mkdir(parents=True, exist_ok=True) + (state_dir / "browser_agent_session.pid").write_text("43210", encoding="utf-8") + (state_dir / "browser_agent_session.json").write_text( + json.dumps({"status": "running", "loop_count": 3}), + encoding="utf-8", + ) + monkeypatch.setattr("browser_agent_session_actor._pid_alive", lambda pid: pid == 43210) + + rc, status = _run_cli(["supervisor", "status"]) + assert rc == 0 + assert status["pid"] == 43210 + assert status["pid_alive"] is True + + rc, drain = _run_cli(["supervisor", "drain"]) + assert rc == 0 + assert Path(drain["drain_flag"]).exists() + + killpg_calls: list[tuple[int, int]] = [] + kill_calls: list[tuple[int, int]] = [] + monkeypatch.setattr("os.killpg", lambda pid, sig: killpg_calls.append((pid, sig))) + monkeypatch.setattr("os.kill", lambda pid, sig: kill_calls.append((pid, sig))) + rc, stop = _run_cli(["supervisor", "stop"]) + assert rc == 0 + assert Path(stop["stop_flag"]).exists() + assert killpg_calls == [(43210, 15)] + assert kill_calls == [] + + +def test_submit_poll_collect_cli(monkeypatch): + with tempfile.TemporaryDirectory() as td: + td_path = Path(td) + monkeypatch.setenv("HARNESS_DIR", str(td_path)) + mailbox = ActorMailbox("browser_agent_session", td_path / "actors") + + class FakeSubmitResult: + success = True + error = None + lease = None + inbox_path = str(td_path / "actors" / "browser_agent_session" / "inbox" / "task-demo.json") + outbox_path = str(td_path / "actors" / "browser_agent_session" / "outbox") + evidence_ledger_path = "" + scheduler_decision = {} + + def to_dict(self): + return { + "success": True, + "lease": None, + "inbox_path": self.inbox_path, + "outbox_path": self.outbox_path, + "evidence_ledger_path": "", + "scheduler_decision": {}, + "error": None, + } + + class FakeRuntime: + def submit(self, task_envelope, logical_operator=None): + mailbox.submit_task(task_envelope) + return FakeSubmitResult() + + monkeypatch.setattr("browser_agent_session_control._runtime", lambda: FakeRuntime()) + rc, submit = _run_cli(["submit", "--task-id", "task-demo", "--prompt", "研究实现 一个测试"]) + assert rc == 0 + assert submit["task_id"] == "task-demo" + assert submit["actor_id"] == "browser_agent_session" + assert submit["inbox_task_file"] + + rc, poll_queued = _run_cli(["poll", "--task-id", "task-demo"]) + assert rc == 0 + assert poll_queued["status"] == "queued" + + request_dir = td_path / "req" + request_dir.mkdir(parents=True, exist_ok=True) + mailbox.write_result( + "task-demo", + { + "task_id": "task-demo", + "status": "completed", + "request_dir": str(request_dir), + "text": "final answer", + }, + ) + rc, poll_done = _run_cli(["poll", "--task-id", "task-demo"]) + assert rc == 0 + assert poll_done["status"] == "completed" + + rc, collected = _run_cli(["collect", "--task-id", "task-demo", "--timeout-seconds", "1", "--poll-interval-seconds", "0.2"]) + assert rc == 0 + assert collected["status"] == "completed" + + +def test_submit_request_retries_when_browser_agent_session_lease_is_busy(monkeypatch): + with tempfile.TemporaryDirectory() as td: + td_path = Path(td) + monkeypatch.setenv("HARNESS_DIR", str(td_path)) + mailbox = ActorMailbox("browser_agent_session", td_path / "actors") + sleep_calls: list[float] = [] + + class FakeSubmitResult: + def __init__(self, success: bool, error: str | None = None): + self.success = success + self.error = error + self.lease = None + self.inbox_path = str(td_path / "actors" / "browser_agent_session" / "inbox" / "task-demo.json") + self.outbox_path = str(td_path / "actors" / "browser_agent_session" / "outbox") + self.evidence_ledger_path = "" + self.scheduler_decision = {} + + def to_dict(self): + return { + "success": self.success, + "lease": None, + "inbox_path": self.inbox_path, + "outbox_path": self.outbox_path, + "evidence_ledger_path": "", + "scheduler_decision": {}, + "error": self.error, + } + + class FakeRuntime: + def __init__(self): + self.calls = 0 + + def submit(self, task_envelope, logical_operator=None): + self.calls += 1 + if self.calls == 1: + return FakeSubmitResult(False, "lease_acquisition_failed_for_browser_agent_session") + mailbox.submit_task(task_envelope) + return FakeSubmitResult(True) + + runtime = FakeRuntime() + monkeypatch.setattr("browser_agent_session_control._runtime", lambda: runtime) + monkeypatch.setattr("browser_agent_session_control.time.sleep", lambda seconds: sleep_calls.append(seconds)) + + payload = submit_request( + {"prompt": "研究实现 一个测试"}, + logical_operator="DeepResearchChatGPT", + task_id="task-demo", + retry_attempts=2, + retry_wait_seconds=0.5, + ) + + assert payload["success"] is True + assert payload["task_id"] == "task-demo" + assert runtime.calls == 2 + assert sleep_calls == [0.5] + assert payload["inbox_task_file"] + + +def test_collect_poll_sleep_seconds_backs_off_for_running_states(): + assert _collect_poll_sleep_seconds(2.0, 1, "submitted") == 2.0 + assert _collect_poll_sleep_seconds(2.0, 3, "submitted") == 6.0 + assert _collect_poll_sleep_seconds(2.0, 1, "running") == 4.0 + assert _collect_poll_sleep_seconds(2.0, 4, "running") == 8.0 diff --git a/harness/tests/runtime/test_browser_agent_session_pool.py b/harness/tests/runtime/test_browser_agent_session_pool.py new file mode 100644 index 000000000..95aacabaf --- /dev/null +++ b/harness/tests/runtime/test_browser_agent_session_pool.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import tempfile +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "lib")) + +from browser_agent_session_pool import BrowserAgentSessionPool + + +def test_pool_ensure_and_acquire_release(): + with tempfile.TemporaryDirectory() as td: + pool = BrowserAgentSessionPool(Path(td), service="chatgpt", pool_size=2) + slots = pool.ensure_slots() + assert len(slots) == 2 + assert slots[0]["slot_id"] == "slot-01" + assert slots[1]["slot_id"] == "slot-02" + + first = pool.acquire_slot(task_id="task-1", request_lineage="lineage-a", request_dir="/tmp/task-1") + second = pool.acquire_slot(task_id="task-2", request_lineage="lineage-b", request_dir="/tmp/task-2") + assert first["slot_id"] != second["slot_id"] + assert first["state"] == "running" + assert second["state"] == "running" + + released = pool.release_slot(first["slot_id"], keep_warm=True) + assert released["state"] == "idle" + assert released["warm"] is True + assert released["assigned_task_id"] == "" + + +def test_pool_prefers_youtube_run_affinity_over_cold_idle_slot(): + with tempfile.TemporaryDirectory() as td: + pool = BrowserAgentSessionPool(Path(td), service="chatgpt", pool_size=2) + first = pool.acquire_slot( + task_id="task-1", + request_lineage="ai-influence-youtube-report:phase1:20260605T200355Z", + request_dir="/tmp/task-1", + ) + pool.release_slot(first["slot_id"], keep_warm=True) + + second = pool.acquire_slot( + task_id="task-2", + request_lineage="ai-influence-youtube-report:phase2_batch:20260605T200355Z:batch-01", + request_dir="/tmp/task-2", + ) + + assert second["slot_id"] == first["slot_id"] + assert second["last_request_lineage"] == "ai-influence-youtube-report:phase2_batch:20260605T200355Z:batch-01" + + +def test_pool_falls_back_to_cold_idle_without_affinity_match(): + with tempfile.TemporaryDirectory() as td: + pool = BrowserAgentSessionPool(Path(td), service="chatgpt", pool_size=2) + first = pool.acquire_slot( + task_id="task-1", + request_lineage="ai-influence-youtube-report:phase1:run-a", + request_dir="/tmp/task-1", + ) + pool.release_slot(first["slot_id"], keep_warm=True) + + second = pool.acquire_slot( + task_id="task-2", + request_lineage="ai-influence-youtube-report:phase1:run-b", + request_dir="/tmp/task-2", + ) + + assert second["slot_id"] != first["slot_id"] diff --git a/harness/tests/runtime/test_browser_operator_dispatch.py b/harness/tests/runtime/test_browser_operator_dispatch.py index b5d9e501b..caddc4f0a 100644 --- a/harness/tests/runtime/test_browser_operator_dispatch.py +++ b/harness/tests/runtime/test_browser_operator_dispatch.py @@ -1,18 +1,31 @@ +import importlib.util import json +import sys import tempfile -import pytest from pathlib import Path -import sys -# Insert lib path so we can import actor_runtime +import pytest + ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT / "lib")) +LIB = ROOT / "lib" +sys.path.insert(0, str(LIB)) + + +def _load_local_module(name: str): + module_path = LIB / f"{name}.py" + spec = importlib.util.spec_from_file_location(f"test_runtime_{name}", module_path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module -from actor_runtime import ActorRuntime, SubmitResult -from verification_gate import VerificationGate -from actor_lease import LeaseBroker -from evidence_ledger import EvidenceLedger -from context_store import ContextStore + +actor_runtime = _load_local_module("actor_runtime") +verification_gate = _load_local_module("verification_gate") + +ActorRuntime = actor_runtime.ActorRuntime +SubmitResult = actor_runtime.SubmitResult +VerificationGate = verification_gate.VerificationGate def _make_mock_configs(tmpdir): # Minimal logical operators JSON @@ -21,8 +34,22 @@ def _make_mock_configs(tmpdir): "DeepResearchBrowser": { "operator_type": "DeepResearchBrowser", "candidates": [ - {"actor_id": "op.browser.webwright.playwright.01", "priority": 1, "condition": "always"}, - {"actor_id": "browser_agent_session", "priority": 2, "condition": "always"} + {"actor_id": "browser_agent_session", "priority": 1, "condition": "always"}, + {"actor_id": "op.browser.webwright.playwright.01", "priority": 2, "condition": "always"} + ] + }, + "DeepResearchChatGPT": { + "operator_type": "DeepResearchChatGPT", + "candidates": [ + {"actor_id": "browser_agent_session", "priority": 1, "condition": "always"}, + {"actor_id": "mini-chatgpt-deep-research", "priority": 2, "condition": "always"} + ] + }, + "DeepResearchGemini": { + "operator_type": "DeepResearchGemini", + "candidates": [ + {"actor_id": "browser_agent_session", "priority": 1, "condition": "always"}, + {"actor_id": "mini-gemini-deep-research", "priority": 2, "condition": "always"} ] }, "WebwrightPlaywright": { @@ -71,6 +98,24 @@ def _make_mock_configs(tmpdir): "role": "knowledge-extractor", "capability_profile": {"browser_use": 5}, "policy": {} + }, + "mini-chatgpt-deep-research": { + "actor_id": "mini-chatgpt-deep-research", + "host_id": "mini", + "operator_alias": "mini-chatgpt-deep-research", + "aliases": ["mini-chatgpt-deep-research"], + "role": "knowledge-extractor", + "capability_profile": {"browser_use": 4}, + "policy": {} + }, + "mini-gemini-deep-research": { + "actor_id": "mini-gemini-deep-research", + "host_id": "mini", + "operator_alias": "mini-gemini-deep-research", + "aliases": ["mini-gemini-deep-research"], + "role": "knowledge-extractor", + "capability_profile": {"browser_use": 4}, + "policy": {} } } } @@ -133,11 +178,81 @@ def test_custom_routing(): assert res3.success is True assert res3.lease.actor_id == "op.browser.browser_use_mcp.quick.01" - # 4. Fallback Default to Webwright for browser ops if no specific flag is set + # 4. Default DeepResearchBrowser should respect logical binding first env4 = {"objective": "Read news article"} res4 = runtime.submit(env4, logical_operator="DeepResearchBrowser") assert res4.success is True - assert res4.lease.actor_id == "op.browser.webwright.playwright.01" + assert res4.lease.actor_id == "browser_agent_session" + + # 5. Explicit browser logical operators still resolve through bindings + env5 = {"objective": "Long-form replayable browser task"} + res5 = runtime.submit(env5, logical_operator="WebwrightPlaywright") + assert res5.success is True + assert res5.lease.actor_id == "op.browser.webwright.playwright.01" + + env6 = {"objective": "Quick localhost extract"} + res6 = runtime.submit(env6, logical_operator="BrowserUseMcp") + assert res6.success is True + assert res6.lease.actor_id == "op.browser.browser_use_mcp.quick.01" + + env7 = {"objective": "ChatGPT browser report"} + res7 = runtime.submit(env7, logical_operator="DeepResearchChatGPT") + assert res7.success is True + assert res7.lease.actor_id == "browser_agent_session" + + env8 = {"objective": "Gemini browser report"} + res8 = runtime.submit(env8, logical_operator="DeepResearchGemini") + assert res8.success is True + assert res8.lease.actor_id == "browser_agent_session" + + +def test_browser_agent_session_submit_ensures_supervisor(monkeypatch): + with tempfile.TemporaryDirectory() as td: + bp, ap = _make_mock_configs(td) + runtime = ActorRuntime( + harness_dir=Path(td), + lease_broker=DummyLeaseBroker(), + mailbox_base=Path(td), + evidence_ledger=DummyEvidenceLedger(), + context_store=DummyContextStore(), + profiles_path=ap, + bindings_path=bp, + ) + kicked: list[int] = [] + + def _fake_kick(): + kicked.append(1) + return 12345 + + monkeypatch.setattr(runtime, "_ensure_browser_agent_session_supervisor", _fake_kick) + res = runtime.submit({"objective": "Read browser page"}, logical_operator="DeepResearchBrowser") + assert res.success is True + assert res.lease.actor_id == "browser_agent_session" + assert kicked == [1] + + +def test_browser_agent_session_submit_recovers_stale_runtime_before_lease(monkeypatch): + with tempfile.TemporaryDirectory() as td: + bp, ap = _make_mock_configs(td) + runtime = ActorRuntime( + harness_dir=Path(td), + lease_broker=DummyLeaseBroker(), + mailbox_base=Path(td), + evidence_ledger=DummyEvidenceLedger(), + context_store=DummyContextStore(), + profiles_path=ap, + bindings_path=bp, + ) + recovered: list[int] = [] + kicked: list[int] = [] + + monkeypatch.setattr(runtime, "_recover_browser_agent_session_stale_runtime", lambda: recovered.append(1) or {"ok": True}) + monkeypatch.setattr(runtime, "_ensure_browser_agent_session_supervisor", lambda: kicked.append(1) or 12345) + res = runtime.submit({"objective": "Read browser page"}, logical_operator="DeepResearchBrowser") + assert res.success is True + assert res.lease.actor_id == "browser_agent_session" + assert recovered == [1] + assert kicked == [1] def test_security_gates(): diff --git a/harness/tests/runtime/test_gemini_deep_research_operator.py b/harness/tests/runtime/test_gemini_deep_research_operator.py index 5d4d92d03..04b71e927 100644 --- a/harness/tests/runtime/test_gemini_deep_research_operator.py +++ b/harness/tests/runtime/test_gemini_deep_research_operator.py @@ -22,11 +22,6 @@ def test_build_request_reads_prompt_file(tmp_path): def test_run_request_writes_result(monkeypatch, tmp_path, capsys): - class Result: - returncode = 0 - stdout = "final research report" - stderr = "" - request_dir = tmp_path / "gemini-deep-research-request" request_dir.mkdir(parents=True, exist_ok=True) @@ -39,8 +34,16 @@ class Result: "citations": [{"title": "Paper 1", "url": "https://example.com"}] }), encoding="utf-8") + helper_calls = [] + + def _fake_submit(**kwargs): + helper_calls.append(kwargs) + (request_dir / "stdout.txt").write_text("final research report\n", encoding="utf-8") + return {"output": "final research report", "latency_ms": 1} + + monkeypatch.setenv("BROWSER_AGENT_SESSION_CONTROL_DISABLED", "1") monkeypatch.setattr(gdro, "_wrapper_cmd", lambda: ["fake-wrapper"]) - monkeypatch.setattr(gdro.subprocess, "run", lambda *args, **kwargs: Result()) + monkeypatch.setattr(gdro, "submit_gemini_operator_request", _fake_submit) result = gdro.run_request({ "prompt": "hello", @@ -51,6 +54,7 @@ class Result: assert result["ok"] is True assert result["text"] == "deep research report text" assert len(result["citations"]) == 1 + assert len(helper_calls) == 1 assert (tmp_path / "gemini-deep-research-request.json").exists() assert (tmp_path / "gemini-deep-research-result.json").exists() assert (tmp_path / "report.md").exists() @@ -61,31 +65,23 @@ class Result: def test_run_request_retries_on_failure(monkeypatch, tmp_path): - class FailResult: - returncode = 1 - stdout = "" - stderr = "Network Timeout Error" - - class SuccessResult: - returncode = 0 - stdout = "report" - stderr = "" - request_dir = tmp_path / "gemini-deep-research-request" request_dir.mkdir(parents=True, exist_ok=True) calls = [] - def mock_run(*args, **kwargs): + def _fake_submit(**kwargs): calls.append(len(calls) + 1) if len(calls) == 1: - return FailResult() + raise RuntimeError("Wrapper exited with code 1. Log snippet:\nNetwork Timeout Error") # On second attempt, write files and succeed (request_dir / "assistant-response.txt").write_text("recovered text", encoding="utf-8") - return SuccessResult() + (request_dir / "stdout.txt").write_text("report\n", encoding="utf-8") + return {"output": "report", "latency_ms": 1} + monkeypatch.setenv("BROWSER_AGENT_SESSION_CONTROL_DISABLED", "1") monkeypatch.setattr(gdro, "_wrapper_cmd", lambda: ["fake-wrapper"]) - monkeypatch.setattr(gdro.subprocess, "run", mock_run) + monkeypatch.setattr(gdro, "submit_gemini_operator_request", _fake_submit) monkeypatch.setattr(gdro.time, "sleep", lambda sec: None) result = gdro.run_request({ @@ -158,3 +154,92 @@ def _boom(request, task_dir): assert len(failure_calls) == 1 assert failure_calls[0][0] == "mini-gemini-deep-research" assert "Something went wrong" in failure_calls[0][1] + + +def test_run_request_via_session_control(monkeypatch, tmp_path, capsys): + request_dir = tmp_path / "gemini-deep-research-request" + request_dir.mkdir(parents=True, exist_ok=True) + result_file = tmp_path / "gemini-deep-research-result.json" + result_file.write_text( + json.dumps( + { + "ok": True, + "project_name": "杂项", + "expected_output": "markdown", + "request_dir": str(request_dir), + "text": "session control research text", + "title": "Gemini Deep Research", + "url": "https://gemini.google.com/app/123", + "conversation_id": "123", + "citations": [{"title": "Paper 1", "url": "https://example.com"}], + } + ), + encoding="utf-8", + ) + (request_dir / "assistant-response.txt").write_text("session control research text", encoding="utf-8") + (request_dir / "page.json").write_text( + json.dumps( + { + "title": "Gemini Deep Research", + "url": "https://gemini.google.com/app/123", + "conversation_id": "123", + "citations": [{"title": "Paper 1", "url": "https://example.com"}], + } + ), + encoding="utf-8", + ) + + submit_calls = [] + collect_calls = [] + + monkeypatch.delenv("BROWSER_AGENT_SESSION_CONTROL_DISABLED", raising=False) + monkeypatch.setattr(gdro, "_wrapper_cmd", lambda: ["fake-wrapper"]) + + def _fake_submit(request, *, logical_operator, objective, task_id, request_field): + submit_calls.append( + { + "request": request, + "logical_operator": logical_operator, + "objective": objective, + "task_id": task_id, + "request_field": request_field, + } + ) + return {"success": True} + + def _fake_collect(task_id, *, timeout_seconds, poll_interval_seconds, terminal_statuses): + collect_calls.append( + { + "task_id": task_id, + "timeout_seconds": timeout_seconds, + "terminal_statuses": terminal_statuses, + } + ) + return 0, { + "status": "completed", + "latest_result": { + "result_file": str(result_file), + }, + } + + monkeypatch.setattr(gdro, "submit_request", _fake_submit) + monkeypatch.setattr(gdro, "collect_request", _fake_collect) + + result = gdro.run_request( + { + "prompt": "hello", + "project_name": "杂项", + "request_dir": str(request_dir), + }, + task_dir=tmp_path, + ) + + assert result["ok"] is True + assert result["text"] == "session control research text" + assert len(submit_calls) == 1 + assert submit_calls[0]["logical_operator"] == "DeepResearchGemini" + assert submit_calls[0]["request_field"] == "gemini_deep_research_request" + assert len(collect_calls) == 1 + + out = capsys.readouterr().out + assert "Gemini Deep Research Result" in out diff --git a/harness/tests/runtime/test_logical_operator_router.py b/harness/tests/runtime/test_logical_operator_router.py index 2d9b7c61f..3f64bf8ff 100644 --- a/harness/tests/runtime/test_logical_operator_router.py +++ b/harness/tests/runtime/test_logical_operator_router.py @@ -3,6 +3,7 @@ import tempfile from pathlib import Path import sys +import importlib.util sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "lib")) from logical_operator_router import LogicalOperatorRouter, P0_LOGICAL_OPERATORS @@ -117,6 +118,24 @@ def test_all_operators_bound(): assert unbound == [], f"unbound: {unbound}" print("PASS: all_operators_bound") + +def test_default_paths_respect_harness_dir_env(monkeypatch): + with tempfile.TemporaryDirectory() as td: + config_dir = Path(td) / "config" + config_dir.mkdir(parents=True, exist_ok=True) + bp, ap = _make_bindings(config_dir) + monkeypatch.setenv("HARNESS_DIR", td) + module_path = Path(__file__).resolve().parent.parent.parent / "lib" / "logical_operator_router.py" + spec = importlib.util.spec_from_file_location("logical_operator_router_env_test", module_path) + assert spec and spec.loader + lor = importlib.util.module_from_spec(spec) + spec.loader.exec_module(lor) + router = lor.LogicalOperatorRouter() + assert router.bindings_path == Path(td) / "config" / "logical-operators.json" + assert router.actors_path == Path(td) / "config" / "agent-actors.json" + assert router.get_candidates("DeepArchitect") + assert bp.exists() and ap.exists() + if __name__ == "__main__": test_all_17_operators() test_binding_changes_actor() diff --git a/harness/tests/runtime/test_youtube_transcript_operator.py b/harness/tests/runtime/test_youtube_transcript_operator.py new file mode 100644 index 000000000..b4f2e0db8 --- /dev/null +++ b/harness/tests/runtime/test_youtube_transcript_operator.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "tools")) + +import youtube_transcript_operator as yto # noqa: E402 + + +def test_run_request_uses_submit_helper_and_preserves_duration(monkeypatch, tmp_path, capsys): + request_dir = tmp_path / "youtube-transcript-request" + request_dir.mkdir(parents=True, exist_ok=True) + (request_dir / "assistant-response.txt").write_text("[00:00] hello world\n", encoding="utf-8") + (request_dir / "page.json").write_text( + json.dumps( + { + "video_id": "wQE2ItbsnVo", + "title": "TAP - UII", + "channel": "Open Compute Project", + "duration_seconds": 725, + "duration_iso": "PT12M5S", + "segment_count": 1, + } + ), + encoding="utf-8", + ) + (request_dir / "transcript.json").write_text( + json.dumps( + { + "duration_seconds": 725, + "duration_iso": "PT12M5S", + } + ), + encoding="utf-8", + ) + + helper_calls = [] + + def _fake_submit(**kwargs): + helper_calls.append(kwargs) + assert kwargs["env"]["BROWSER_AGENT_HEADLESS"] == "true" + (request_dir / "stdout.txt").write_text("ok\n", encoding="utf-8") + return {"output": "ok", "latency_ms": 1} + + monkeypatch.setattr(yto, "_wrapper_cmd", lambda: ["fake-wrapper"]) + monkeypatch.delenv("BROWSER_AGENT_HEADLESS", raising=False) + monkeypatch.setattr(yto, "submit_youtube_operator_request", _fake_submit) + + result = yto.run_request( + {"youtube_url": "https://www.youtube.com/watch?v=wQE2ItbsnVo", "timeout_seconds": 30, "max_retries": 1}, + task_dir=tmp_path, + ) + + assert len(helper_calls) == 1 + transcript_detail = json.loads((request_dir / "transcript.json").read_text(encoding="utf-8")) + assert transcript_detail["duration_seconds"] == 725 + assert transcript_detail["duration_iso"] == "PT12M5S" + assert result["text"] == "[00:00] hello world" + assert (tmp_path / "youtube-transcript-result.json").exists() + assert (tmp_path / "transcript.txt").exists() + + out = capsys.readouterr().out + assert "YouTube Transcript Extraction Result" in out diff --git a/harness/tests/test-youtube-influence-digest.sh b/harness/tests/test-youtube-influence-digest.sh index 4576b7244..3148c342d 100755 --- a/harness/tests/test-youtube-influence-digest.sh +++ b/harness/tests/test-youtube-influence-digest.sh @@ -22,6 +22,14 @@ output: lookback_hours: 168 keep_seen_days: 7 transcript_max_chars: 20000 + browser_agent_report: + enabled: true + requested_model: chatgpt-5.5-thinking-high + project_name: 杂项 + operator_script: "$TMPDIR/fake_chatgpt_report_operator.py" + python_executable: "$(command -v python3)" + diagram_operator_script: "$TMPDIR/fake_technology_diagram_painter_operator.py" + diagram_python_executable: "$(command -v python3)" fetch: timeout_seconds: 1 sleep_between_channels_seconds: 0 @@ -57,6 +65,99 @@ cat > "$TMPDIR/transcript.xml" < XML +cat > "$TMPDIR/fake_chatgpt_report_operator.py" <<'PY' +import json +import os +import sys +from pathlib import Path + +request_dir = Path(os.environ["BROWSER_AGENT_REQUEST_DIR"]) +request_dir.mkdir(parents=True, exist_ok=True) +kind = os.environ.get("CHATGPT_REPORT_OPERATOR_KIND", "") +purpose = os.environ.get("BROWSER_AGENT_PURPOSE", "") +prompt = sys.stdin.read() +(request_dir / "captured-prompt.txt").write_text(prompt, encoding="utf-8") +(request_dir / "submitted-run.json").write_text( + json.dumps( + { + "task_id": f"task-{kind}", + "conversation_id": f"conv-{kind}", + "url": f"https://chatgpt.com/c/{kind}", + }, + ensure_ascii=False, + indent=2, + ) + "\n", + encoding="utf-8", +) +(request_dir / "page.json").write_text( + json.dumps( + { + "conversation_id": f"conv-{kind}", + "url": f"https://chatgpt.com/c/{kind}", + }, + ensure_ascii=False, + indent=2, + ) + "\n", + encoding="utf-8", +) +if kind == "planner": + payload = { + "trends": [ + { + "title": "Agent Workflow Signals", + "chapters": [ + { + "chapter_id": "chapter-1", + "title": "Agent Workflow Overview", + "subsections": [ + { + "subsection_id": "sub-1", + "title": "Core Evidence", + "evidence_refs": ["E001"], + } + ], + } + ], + } + ] + } + text = json.dumps(payload, ensure_ascii=False) +else: + text = ( + "中心判断:Agent workflow 正在从 demo 走向可复用基础设施。" + if "phase3" in purpose + else "Agent Workflow Overview 正文章节。" + ) +(request_dir / "assistant-response.txt").write_text(text + "\n", encoding="utf-8") +print(text) +PY + +cat > "$TMPDIR/fake_technology_diagram_painter_operator.py" <<'PY' +import json +import os +from pathlib import Path + +envelope_path = Path(os.environ["SOLAR_OPERATOR_ENVELOPE_JSON"]) +task_dir = Path(os.environ["TASK_DIR"]) +task_dir.mkdir(parents=True, exist_ok=True) +envelope = json.loads(envelope_path.read_text(encoding="utf-8")) +request = envelope.get("technology_diagram_request") or {} +request_dir = Path(request.get("request_dir") or task_dir / "tech-diagram-request") +request_dir.mkdir(parents=True, exist_ok=True) +image_path = request_dir / "generated_diagram.png" +image_path.write_bytes(b"fake-png") +result = { + "status": "success", + "image_path": str(image_path), + "request_dir": str(request_dir), + "url": "https://chatgpt.com/c/fake-diagram", + "browser_session_id": "fake-diagram-session", + "original_image_ok": True, +} +(task_dir / "tech-diagram-result.json").write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") +print(json.dumps(result, ensure_ascii=False)) +PY + python3 "$ROOT/scripts/youtube_influence_digest.py" \ --config "$TMPDIR/config.yaml" \ --fixture-feed "$TMPDIR/feed.xml" \ @@ -69,5 +170,11 @@ grep -q "Build an AI agent demo with tools" "$TMPDIR/raw/latest.md" grep -q "transcripts_ok: 1" "$TMPDIR/raw/latest.md" find "$TMPDIR/raw" -path '*/videos/*.md' -type f | grep -q . grep -R "Today we build an AI agent demo" "$TMPDIR/raw" >/dev/null +find "$TMPDIR/raw" -path '*/browser-agent-report/report.md' -type f | grep -q . +find "$TMPDIR/raw" -path '*/browser-agent-report/archive/archive_manifest.json' -type f | grep -q . +find "$TMPDIR/raw" -path '*/browser-agent-report/figure_manifest.json' -type f | grep -q . +find "$TMPDIR/raw" -path '*/browser-agent-report/archive/figures/figure-manifest.json' -type f | grep -q . +grep -R "Agent workflow 正在从 demo 走向可复用基础设施" "$TMPDIR/raw" >/dev/null +grep -R "关键图示" "$TMPDIR/raw" >/dev/null echo "ok: youtube influence digest fixture test passed" diff --git a/harness/tests/test_ai_influence_youtube_report_browser_agent.py b/harness/tests/test_ai_influence_youtube_report_browser_agent.py index 0e87adb62..65ae517b0 100644 --- a/harness/tests/test_ai_influence_youtube_report_browser_agent.py +++ b/harness/tests/test_ai_influence_youtube_report_browser_agent.py @@ -6,12 +6,17 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "lib")) +HARNESS_ROOT = Path(__file__).resolve().parents[1] -from ai_influence_youtube_report.browser_agent import BrowserAgentClient, LocalModelSubstitutionError # noqa: E402 +from ai_influence_youtube_report.browser_agent import ( # noqa: E402 + BrowserAgentClient, + ChatGPTReportOperatorProvider, + LocalModelSubstitutionError, +) class FakeProvider: - def call(self, stage, payload, *, requested_model): + def call(self, stage, payload, *, requested_model, run_id="", chapter_id="", sprint_id=""): return { "schema_version": f"{stage}.fake", "model_call_id": f"{stage}-1", @@ -33,6 +38,24 @@ def test_browser_agent_client_writes_ledger_for_each_phase(tmp_path: Path) -> No assert [row["stage"] for row in rows] == ["phase1", "phase2", "phase3"] +def test_browser_agent_client_writes_ledger_for_batch_phase(tmp_path: Path) -> None: + ledger = tmp_path / "model_call_ledger.jsonl" + client = BrowserAgentClient(FakeProvider(), ledger_path=ledger, sprint_id="sprint-1") + + client.write_chapter_batch( + [ + {"chapter": {"chapter_id": "c1", "title": "第一章"}, "evidence_rows": []}, + {"chapter": {"chapter_id": "c2", "title": "第二章"}, "evidence_rows": []}, + ], + requested_model="chatgpt-5.5-thinking-high", + run_id="run-1", + batch_id="batch-01", + ) + + rows = [json.loads(line) for line in ledger.read_text().splitlines()] + assert [row["stage"] for row in rows] == ["phase2_batch"] + + def test_browser_agent_client_rejects_local_model_substitution(tmp_path: Path) -> None: client = BrowserAgentClient(FakeProvider(), ledger_path=tmp_path / "ledger.jsonl", sprint_id="sprint-1") @@ -46,3 +69,94 @@ def test_phase2_duplicate_chapter_call_is_rejected(tmp_path: Path) -> None: with pytest.raises(ValueError, match="duplicate"): client.write_chapter({}, requested_model="chatgpt-5.5-thinking-high", run_id="run-1", chapter_id="c1") + + +def test_phase2_batch_duplicate_chapter_call_is_rejected(tmp_path: Path) -> None: + client = BrowserAgentClient(FakeProvider(), ledger_path=tmp_path / "ledger.jsonl", sprint_id="sprint-1") + client.write_chapter({}, requested_model="chatgpt-5.5-thinking-high", run_id="run-1", chapter_id="c1") + + with pytest.raises(ValueError, match="duplicate"): + client.write_chapter_batch( + [{"chapter": {"chapter_id": "c1", "title": "重复章"}, "evidence_rows": []}], + requested_model="chatgpt-5.5-thinking-high", + run_id="run-1", + batch_id="batch-01", + ) + + +def test_chatgpt_report_operator_provider_invokes_production_seam(tmp_path: Path) -> None: + operator_script = tmp_path / "fake_chatgpt_report_operator.py" + operator_script.write_text( + """ +import json +import os +import sys +from pathlib import Path + +request_dir = Path(os.environ["BROWSER_AGENT_REQUEST_DIR"]) +request_dir.mkdir(parents=True, exist_ok=True) +prompt = sys.stdin.read() +(request_dir / "captured-prompt.txt").write_text(prompt, encoding="utf-8") +(request_dir / "captured-env.json").write_text(json.dumps({ + "purpose": os.environ.get("BROWSER_AGENT_PURPOSE"), + "kind": os.environ.get("CHATGPT_REPORT_OPERATOR_KIND"), + "expected_output": os.environ.get("BROWSER_AGENT_EXPECTED_OUTPUT"), + "project_name": os.environ.get("BROWSER_AGENT_CHATGPT_PROJECT_NAME"), + "harness_dir": os.environ.get("HARNESS_DIR"), + "model": os.environ.get("CHATGPT_MODEL"), + "lineage": os.environ.get("BROWSER_AGENT_SESSION_LINEAGE"), + "account_email": os.environ.get("BROWSER_AGENT_CHATGPT_ACCOUNT_EMAIL"), + "profile_directory": os.environ.get("BROWSER_AGENT_PROFILE_DIRECTORY"), +}, ensure_ascii=False, indent=2), encoding="utf-8") +(request_dir / "submitted-run.json").write_text(json.dumps({ + "task_id": "task-123", + "conversation_id": "conv-456", + "url": "https://chatgpt.com/c/conv-456", +}, ensure_ascii=False, indent=2), encoding="utf-8") +(request_dir / "page.json").write_text(json.dumps({ + "conversation_id": "conv-456", + "url": "https://chatgpt.com/c/conv-456", +}, ensure_ascii=False, indent=2), encoding="utf-8") +(request_dir / "assistant-response.txt").write_text("phase output\\n", encoding="utf-8") +print("phase output") +""".strip() + + "\n", + encoding="utf-8", + ) + + provider = ChatGPTReportOperatorProvider( + operator_script=operator_script, + python_executable=sys.executable, + request_root=tmp_path / "provider-runs", + target_account_email="browser-agent@example.com", + profile_directory="Profile 7", + ) + + result = provider.call( + "phase1", + {"transcripts": [{"video_id": "abc123"}]}, + requested_model="chatgpt-5.5-thinking-high", + run_id="run-77", + sprint_id="sprint-demo", + ) + + assert result["model_call_id"] == "task-123" + assert result["browser_session_id"] == "conv-456" + assert result["chatgpt_url"] == "https://chatgpt.com/c/conv-456" + assert result["resolved_model"] == "chatgpt-5.5-thinking-high" + assert result["text"] == "phase output" + + request_dir = Path(result["request_dir"]) + env_payload = json.loads((request_dir / "captured-env.json").read_text(encoding="utf-8")) + prompt_text = (request_dir / "captured-prompt.txt").read_text(encoding="utf-8") + assert env_payload["kind"] == "planner" + assert env_payload["expected_output"] == "json" + assert env_payload["purpose"] == "ai-influence-youtube-report:phase1-plan" + assert env_payload["project_name"] == "杂项" + assert env_payload["harness_dir"] == str(HARNESS_ROOT) + assert env_payload["model"] == "chatgpt-5.5-thinking-high" + assert env_payload["lineage"].startswith("ai-influence-youtube-report:phase1:run-77") + assert env_payload["account_email"] == "browser-agent@example.com" + assert env_payload["profile_directory"] == "Profile 7" + assert "Return JSON only: trends -> chapters -> subsections -> evidence_refs." in prompt_text + assert '"video_id": "abc123"' in prompt_text diff --git a/harness/tests/test_ai_influence_youtube_report_figures.py b/harness/tests/test_ai_influence_youtube_report_figures.py new file mode 100644 index 000000000..01bd5bdd9 --- /dev/null +++ b/harness/tests/test_ai_influence_youtube_report_figures.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "lib")) + +from ai_influence_youtube_report.figures import ( + FigureSpec, + _flow_control_retry_delay, + paint_figure, +) + + +def test_flow_control_retry_delay_parses_cooldown_timestamp() -> None: + delay = _flow_control_retry_delay( + "FlowControlBlocked: operator technology-diagram-painter blocked by flow control: state=cooldown until 2099-01-01T00:00:30Z" + ) + assert delay > 0 + + +def test_paint_figure_retries_once_after_flow_control_cooldown(monkeypatch, tmp_path: Path) -> None: + spec = FigureSpec( + figure_id="fig_01", + title="Figure", + figure_type="architecture_overview", + placement="report_lead", + source_chapter_ids=["ch_01"], + evidence_refs=["E1"], + input_outline=["Section: Demo"], + render_prompt="Prompt", + caption="Caption", + ) + script = tmp_path / "fake_operator.py" + script.write_text("# fake\n", encoding="utf-8") + + calls = {"count": 0} + + def fake_run(cmd, text, capture_output, timeout, env): # type: ignore[no-untyped-def] + calls["count"] += 1 + if calls["count"] == 1: + return subprocess.CompletedProcess( + cmd, + 1, + stdout="", + stderr="FlowControlBlocked: operator technology-diagram-painter blocked by flow control: state=cooldown until 2099-01-01T00:00:30Z", + ) + task_dir = Path(env["TASK_DIR"]) + (task_dir / "tech-diagram-result.json").write_text( + json.dumps( + { + "status": "success", + "image_path": str(task_dir / "generated.png"), + "url": "https://chatgpt.com/backend-api/estuary/content?id=file_demo", + "source": "network-image-response", + "original_image_ok": True, + }, + ensure_ascii=False, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + return subprocess.CompletedProcess(cmd, 0, stdout="ok", stderr="") + + monkeypatch.setattr("ai_influence_youtube_report.figures.subprocess.run", fake_run) + monkeypatch.setattr("ai_influence_youtube_report.figures.time.sleep", lambda _: None) + monkeypatch.setattr( + "ai_influence_youtube_report.figures._flow_control_retry_delay", + lambda _stderr, cap_seconds=90.0: 0.0 if calls["count"] > 1 else 0.01, + ) + + result = paint_figure( + spec, + run_dir=tmp_path, + operator_script=script, + python_executable="python3", + timeout_seconds=60, + ) + + assert calls["count"] == 2 + assert result.status == "painted" + assert "generated.png" in result.image_path diff --git a/harness/tests/test_ai_influence_youtube_report_runtime.py b/harness/tests/test_ai_influence_youtube_report_runtime.py index 069142681..811366a47 100644 --- a/harness/tests/test_ai_influence_youtube_report_runtime.py +++ b/harness/tests/test_ai_influence_youtube_report_runtime.py @@ -1,12 +1,16 @@ import sys from pathlib import Path +import json sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "lib")) from ai_influence_youtube_report.archive import archive_writer_commit # noqa: E402 +from ai_influence_youtube_report.browser_agent import BrowserAgentProvider # noqa: E402 from ai_influence_youtube_report.evidence_map import build_evidence_map # noqa: E402 +from ai_influence_youtube_report.figures import build_figure_specs # noqa: E402 from ai_influence_youtube_report.render import render_report_html # noqa: E402 +from ai_influence_youtube_report.runtime import generate_browser_agent_report_bundle # noqa: E402 from ai_influence_youtube_report.validator import validator_run # noqa: E402 @@ -35,3 +39,245 @@ def test_runtime_minimal_report_bundle_passes_and_archives(tmp_path: Path) -> No assert verdict["overall"] == "PASS" assert manifest["schema_version"] == "archive_manifest.v1" + + +def test_build_figure_specs_selects_grounded_report_and_chapter_figures() -> None: + evidence = build_evidence_map([ + { + "evidence_ref": "E001", + "channel": "AI Engineer", + "title": "Agent Infra Stack", + "published_at": "2026-05-25T00:00:00Z", + "transcript_grade": "T1", + "citation_span": "agent stack and workflow pipeline", + "group_type": "conference", + } + ]) + specs = build_figure_specs( + { + "trends": [ + { + "title": "Agent Platforms", + "chapters": [ + { + "chapter_id": "chapter-1", + "title": "平台化趋势", + "subsections": [{"title": "技术栈分层"}], + } + ], + } + ] + }, + [ + { + "chapter_id": "chapter-1", + "title": "平台化趋势", + "trend_title": "Agent Platforms", + "evidence_refs": ["E001"], + "text": "讨论 agent stack, workflow pipeline 与 infra layer。", + } + ], + evidence, + report_title="AI Influence 平台化报告", + ) + + assert specs + assert specs[0].figure_type == "architecture_overview" + assert all(spec.evidence_refs for spec in specs) + + +class _FakeBrowserProvider(BrowserAgentProvider): + def call(self, stage, payload, *, requested_model, run_id="", chapter_id="", sprint_id=""): + if stage == "phase1": + return { + "model_call_id": "plan-1", + "browser_session_id": "session-plan", + "chatgpt_url": "https://chatgpt.com/c/plan-1", + "resolved_model": requested_model, + "text": json.dumps( + { + "trends": [ + { + "title": "Agent Platforms", + "chapters": [ + { + "chapter_id": "chapter-1", + "title": "平台化趋势", + "subsections": [ + { + "subsection_id": "sub-1", + "title": "核心证据", + "evidence_refs": ["E001"], + } + ], + } + ], + } + ] + }, + ensure_ascii=False, + ), + } + if stage == "phase2_batch": + chapters = [] + for item in payload.get("chapters") or []: + chapter = item.get("chapter") or {} + chapter_id_value = str(chapter.get("chapter_id") or "") + chapters.append( + { + "chapter_id": chapter_id_value, + "title": str(chapter.get("title") or chapter_id_value), + "text": f"{chapter.get('title') or chapter_id_value} 正文章节。", + "evidence_refs": list(chapter.get("evidence_refs") or []), + } + ) + return { + "model_call_id": f"batch-{chapter_id}", + "browser_session_id": f"session-{chapter_id}", + "chatgpt_url": f"https://chatgpt.com/c/{chapter_id}", + "resolved_model": requested_model, + "text": json.dumps({"chapters": chapters}, ensure_ascii=False), + } + if stage == "phase2": + return { + "model_call_id": f"chapter-{chapter_id}", + "browser_session_id": f"session-{chapter_id}", + "chatgpt_url": f"https://chatgpt.com/c/{chapter_id}", + "resolved_model": requested_model, + "text": f"{payload['chapter']['title']} 正文章节。", + } + return { + "model_call_id": "synth-1", + "browser_session_id": "session-synth", + "chatgpt_url": "https://chatgpt.com/c/synth-1", + "resolved_model": requested_model, + "text": "中心判断:Agent 平台化正在从工具走向工作流基础设施。", + } + + +def _fake_figure_runner(request: dict, task_dir: Path) -> dict: + image_path = task_dir / "generated_diagram.png" + image_path.write_bytes(b"fake-png") + return { + "status": "success", + "image_path": str(image_path), + "request_dir": str(task_dir / "tech-diagram-request"), + "url": "https://chatgpt.com/c/figure-1", + "browser_session_id": "figure-session-1", + "original_image_ok": True, + } + + +def test_generate_browser_agent_report_bundle_emits_runtime_artifacts(tmp_path: Path) -> None: + result = generate_browser_agent_report_bundle( + [ + { + "evidence_ref": "E001", + "channel": "AI Engineer", + "title": "Agent Platforms", + "published_at": "2026-05-25T00:00:00Z", + "transcript_grade": "T1", + "citation_span": "agent runtime", + "group_type": "conference", + "summary": "平台趋势总结", + "transcript": "agent runtime and workflow infrastructure", + } + ], + run_dir=tmp_path / "run", + run_id="run-1", + report_title="AI Influence 平台化报告", + provider=_FakeBrowserProvider(), + requested_model="chatgpt-5.5-thinking-high", + figure_operator_runner=_fake_figure_runner, + ) + + assert result["ok"] is True + assert result["validator_overall"] == "PASS" + runtime_dir = Path(result["runtime_dir"]) + assert (runtime_dir / "report.md").exists() + assert (runtime_dir / "report.html").exists() + assert (runtime_dir / "plan.json").exists() + assert (runtime_dir / "figure_manifest.json").exists() + assert (runtime_dir / "archive" / "archive_manifest.json").exists() + assert (runtime_dir / "archive" / "figures" / "figure-manifest.json").exists() + report_md = (runtime_dir / "report.md").read_text(encoding="utf-8") + assert "中心判断:Agent 平台化" in report_md + assert "## 平台化趋势" in report_md + assert "关键图示" in report_md + figure_manifest = json.loads((runtime_dir / "figure_manifest.json").read_text(encoding="utf-8")) + assert figure_manifest["painted_count"] >= 1 + assert result["painted_figure_count"] >= 1 + report_html = (runtime_dir / "report.html").read_text(encoding="utf-8") + assert " None: report = validator_run(_bundle()) assert report.overall == "PASS" - assert len(report.checks) == 8 + assert len(report.checks) == 10 def test_validator_fails_any_internal_token() -> None: @@ -42,3 +42,22 @@ def test_validator_fails_any_internal_token() -> None: assert report.overall == "FAIL" assert any(check.status == "FAIL" for check in report.checks) + + +def test_validator_fails_painted_figure_without_grounding() -> None: + bundle = _bundle() + bundle["figure_manifest"] = { + "figures": [ + { + "figure_id": "fig_01", + "status": "painted", + "image_path": "/tmp/figure.png", + "evidence_refs": [], + } + ] + } + + report = validator_run(bundle) + + assert report.overall == "FAIL" + assert any(check.name == "painted_figures_grounded" and check.status == "FAIL" for check in report.checks) diff --git a/harness/tests/test_browser_agent_chatgpt_wrapper.py b/harness/tests/test_browser_agent_chatgpt_wrapper.py index 7cfad5023..113f0449d 100644 --- a/harness/tests/test_browser_agent_chatgpt_wrapper.py +++ b/harness/tests/test_browser_agent_chatgpt_wrapper.py @@ -1,5 +1,7 @@ from __future__ import annotations +import asyncio +import io import os import sys import types @@ -15,6 +17,8 @@ def _load_namespace() -> dict: browser_use_browser = types.ModuleType("browser_use.browser") browser_use_browser_profile = types.ModuleType("browser_use.browser.profile") browser_use_browser_session = types.ModuleType("browser_use.browser.session") + browser_use_browser_watchdogs = types.ModuleType("browser_use.browser.watchdogs") + browser_use_browser_watchdogs_local = types.ModuleType("browser_use.browser.watchdogs.local_browser_watchdog") class _DummyProfile: pass @@ -22,8 +26,15 @@ class _DummyProfile: class _DummySession: pass + class _DummyLocalBrowserWatchdog: + _wait_for_cdp_url = None + + async def on_BrowserStopEvent(self, event): # pragma: no cover - stub only + return None + browser_use_browser_profile.BrowserProfile = _DummyProfile browser_use_browser_session.BrowserSession = _DummySession + browser_use_browser_watchdogs_local.LocalBrowserWatchdog = _DummyLocalBrowserWatchdog prev_modules = { name: sys.modules.get(name) @@ -32,12 +43,16 @@ class _DummySession: "browser_use.browser", "browser_use.browser.profile", "browser_use.browser.session", + "browser_use.browser.watchdogs", + "browser_use.browser.watchdogs.local_browser_watchdog", ) } sys.modules["browser_use"] = browser_use sys.modules["browser_use.browser"] = browser_use_browser sys.modules["browser_use.browser.profile"] = browser_use_browser_profile sys.modules["browser_use.browser.session"] = browser_use_browser_session + sys.modules["browser_use.browser.watchdogs"] = browser_use_browser_watchdogs + sys.modules["browser_use.browser.watchdogs.local_browser_watchdog"] = browser_use_browser_watchdogs_local try: ns: dict = {"__file__": str(SCRIPT), "__name__": "browser_agent_chatgpt_wrapper_test"} code = compile(SCRIPT.read_text(encoding="utf-8"), str(SCRIPT), "exec") @@ -160,6 +175,13 @@ def test_chatgpt_wrapper_defaults_to_chrome_channel(monkeypatch): assert ns["_browser_channel"]() == "chrome" +def test_chatgpt_wrapper_ignores_non_chrome_channel_override(monkeypatch): + ns = _load_namespace() + monkeypatch.setenv("BROWSER_AGENT_CHATGPT_BROWSER_CHANNEL", "firefox") + monkeypatch.setenv("BROWSER_AGENT_BROWSER_CHANNEL", "webkit") + assert ns["_browser_channel"]() == "chrome" + + def test_cloudflare_challenge_grace_defaults_and_expires(monkeypatch): ns = _load_namespace() monkeypatch.delenv("BROWSER_AGENT_CHATGPT_CHALLENGE_GRACE_SECONDS", raising=False) @@ -176,3 +198,253 @@ def test_browser_user_agent_defaults_to_non_headless_chrome(monkeypatch): ua = ns["_browser_user_agent"](browser_channel="chrome") assert "Chrome/" in ua assert "HeadlessChrome/" not in ua + + +def test_is_cdp_connect_failure_matches_known_browser_use_errors(): + ns = _load_namespace() + assert ns["_is_cdp_connect_failure"](RuntimeError("Failed to establish CDP connection to browser: [Errno 61] Connect call failed")) is True + assert ns["_is_cdp_connect_failure"](RuntimeError("Root CDP client not initialized")) is True + assert ns["_is_cdp_connect_failure"](RuntimeError("some unrelated failure")) is False + + +def test_conversation_target_id_extracts_chatgpt_conversation(): + ns = _load_namespace() + assert ns["_conversation_target_id"]("https://chatgpt.com/c/abc-123") == "abc-123" + assert ns["_conversation_target_id"]("https://chatgpt.com/") == "" + + +def test_conversation_state_ready_requires_matching_conversation_and_messages(): + ns = _load_namespace() + assert ns["_conversation_state_ready"]( + {"conversation_id": "abc", "message_count": 1}, + expected_conversation_id="abc", + ) is True + assert ns["_conversation_state_ready"]( + {"conversation_id": "abc", "message_count": 0, "latest_assistant_text": "", "is_generating": False}, + expected_conversation_id="abc", + ) is False + assert ns["_conversation_state_ready"]( + {"conversation_id": "other", "message_count": 3}, + expected_conversation_id="abc", + ) is False + assert ns["_conversation_state_ready"]( + { + "conversation_id": "abc", + "message_count": 0, + "assistant_count": 0, + "latest_assistant_text": "", + "is_generating": True, + }, + expected_conversation_id="abc", + ) is False + assert ns["_conversation_state_ready"]( + { + "conversation_id": "abc", + "message_count": 1, + "assistant_count": 0, + "latest_assistant_text": "", + "is_generating": True, + }, + expected_conversation_id="abc", + ) is True + + +def test_chatgpt_wrapper_reuses_existing_conversation_page_for_collect(): + source = SCRIPT.read_text(encoding="utf-8") + assert "async def _find_existing_conversation_page(browser, *, target_url: str):" in source + assert "page = await _find_existing_conversation_page(browser, target_url=target_url)" in source + assert 'state = await _capture_state(page, timeout_s=5.0, default={}, label="find_existing_conversation_page")' in source + assert "if str((current_state or {}).get(\"conversation_id\") or \"\").strip() == collect_target_id:" in source + assert 'current_state = await _capture_state(page, timeout_s=8.0, default={}, label="collect_current_state")' in source + assert "should_navigate = False" in source + assert "action == \"collect\"" in source + assert "and not final_data.get(\"is_generating\")" in source + assert "and int(final_data.get(\"assistant_count\") or 0) > 0" in source + assert "final_data = await _wait_for_answer(" in source + assert 'baseline = await _capture_state(page, timeout_s=8.0, default={}, label="submit_prompt_baseline")' in source + assert 'pre_submit_ready = await _capture_state(page, timeout_s=8.0, default={}, label="pre_submit_isolation")' in source + + +def test_capture_state_times_out_to_default(): + ns = _load_namespace() + + class _NeverReturnsPage: + async def evaluate(self, script): # pragma: no cover - async stub + await asyncio.sleep(2.0) + return "{}" + + result = asyncio.run( + ns["_capture_state"]( + _NeverReturnsPage(), + timeout_s=0.01, + default={"message_count": 2}, + label="wait_for_answer", + ) + ) + assert result["message_count"] == 2 + assert result["_capture_timeout"] == "wait_for_answer" + + +def test_chatgpt_wrapper_installs_browser_use_cdp_patch(): + ns = _load_namespace() + assert ns["_BROWSER_USE_CDP_PATCHED"] is True + assert ns["LocalBrowserWatchdog"]._wait_for_cdp_url is ns["_patched_wait_for_cdp_url"] + assert ns["LocalBrowserWatchdog"].on_BrowserStopEvent is ns["on_BrowserStopEvent"] + + +def test_open_project_js_targets_sidebar_project_group(): + ns = _load_namespace() + script = ns["OPEN_PROJECT_JS"] + assert "nav,aside,section" in script + assert "项目" in script + assert "Open sidebar" in script + assert "role='treeitem'" in script + + +def test_chatgpt_wrapper_defaults_to_headless_true(): + source = SCRIPT.read_text(encoding="utf-8") + assert '_env_flag("BROWSER_AGENT_HEADLESS", default=True)' in source + assert "await asyncio.wait_for(browser.kill(), timeout=20)" in source + assert "def _force_wrapper_exit(code: int) -> NoReturn:" in source + assert "_force_wrapper_exit(exit_code)" in source + + +def test_chatgpt_wrapper_reads_and_writes_active_session_broker(): + source = SCRIPT.read_text(encoding="utf-8") + assert "brtc.read_active_session(control_ctx, require_lineage_match=False)" in source + assert "brtc.activate_reusable_session(" in source + assert "if not _is_cdp_connect_failure(exc):" in source + assert "_kill_browser_processes_by_remote_debugging_port(stale_cdp_port)" in source + assert "_wait_for_browser_processes_gone_by_remote_debugging_port(stale_cdp_port, timeout_s=15.0)" in source + assert "_reap_orphan_browser_use_chrome_processes(" in source + assert "_protected_cdp_ports_from_profile_registry()" in source + assert "LocalBrowserWatchdog._wait_for_cdp_url = staticmethod(_patched_wait_for_cdp_url)" in source + assert "LocalBrowserWatchdog.on_BrowserStopEvent = on_BrowserStopEvent" in source + assert "await _wait_for_cdp_websocket_ready(ws_url" in source + assert "browser.stop() has been observed to hang after submit/poll" in source + assert "await asyncio.wait_for(browser.stop(), timeout=20)" not in source + assert "Force the dedicated wrapper process to exit" in source + assert "\"forced_exit_after_submit\": True" in source + assert "_force_wrapper_exit(0)" in source + assert "keep_alive=keep_session_alive" in source + assert "if action in {\"poll\", \"collect\"} and _is_generic_chatgpt_root(target_url):" in source + assert "should_navigate = False" in source + assert "submitted_state = await _wait_for_submitted_conversation(" in source + assert "\"BROWSER_AGENT_CHATGPT_SUBMIT_STABILIZE_SECONDS\"" in source + assert "final_data = await _wait_for_conversation_ready(" in source + assert "await page.reload()" in source + assert "collect-empty-conversation-page.json" not in source + assert "f\"{action}-empty-conversation-page.json\"" in source + assert "logged_in_verified = True" in source + assert "if final_data.get(\"is_generating\") or not str(final_data.get(\"latest_assistant_text\") or \"\").strip():" in source + assert "\"forced_exit\": True" in source + assert "elif action in {\"poll\", \"collect\"} and collect_target_id:" in source + assert "active_session_port = _remote_debugging_port_from_cdp_url(" in source + assert "_browser_processes_exist_for_remote_debugging_port" in source + assert "SUBMIT_FALLBACK_JS" in source + assert "\"mode\": \"dom_fallback_after_native_setter\"" in source + assert "\"mode\": \"clipboard_dom_submit_retry\"" in source + assert "const form = composer.closest(\"form\")" in source + assert "form direct_submit" in source + assert "form.querySelectorAll(selector)" in source + + +def test_remote_debugging_port_extracts_from_cdp_url(): + ns = _load_namespace() + assert ns["_remote_debugging_port_from_cdp_url"]("ws://127.0.0.1:55942/devtools/browser/abc") == "55942" + assert ns["_remote_debugging_port_from_cdp_url"]("https://example.com/") == "" + + +def test_protected_cdp_ports_from_profile_registry_reads_active_sessions(tmp_path, monkeypatch): + ns = _load_namespace() + registry_root = tmp_path / "browser-profiles" + active_a = registry_root / "chatgpt" / "acct-a" / "active-session.json" + active_a.parent.mkdir(parents=True, exist_ok=True) + active_a.write_text( + '{"cdp_url":"ws://127.0.0.1:62001/devtools/browser/aaa"}\n', + encoding="utf-8", + ) + active_b = registry_root / "chatgpt" / "acct-b" / "active-session.json" + active_b.parent.mkdir(parents=True, exist_ok=True) + active_b.write_text( + '{"cdp_url":"ws://127.0.0.1:62002/devtools/browser/bbb"}\n', + encoding="utf-8", + ) + monkeypatch.setenv("BROWSER_PROFILE_REGISTRY_ROOT", str(registry_root)) + assert ns["_protected_cdp_ports_from_profile_registry"]() == {"62001", "62002"} + + +def test_browser_processes_exist_for_remote_debugging_port_matches_root_command(monkeypatch): + ns = _load_namespace() + + class _Result: + stdout = ( + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome " + "--remote-debugging-port=62099 --user-data-dir=/tmp/browser-use-user-data-dir-abc\n" + ) + + monkeypatch.setattr(ns["subprocess"], "run", lambda *args, **kwargs: _Result()) + assert ns["_browser_processes_exist_for_remote_debugging_port"]("62099") is True + assert ns["_browser_processes_exist_for_remote_debugging_port"]("62098") is False + + +def test_prompt_from_stdin_allows_watch_complete_without_prompt(monkeypatch): + ns = _load_namespace() + monkeypatch.setenv("BROWSER_AGENT_CHATGPT_ACTION", "watch_complete") + monkeypatch.setattr(sys, "stdin", io.StringIO("")) + assert ns["_prompt_from_stdin"]() == "" + + +def test_notify_completion_ready_dedupes_same_conversation(tmp_path, monkeypatch): + ns = _load_namespace() + notify_script = tmp_path / "notify.sh" + notify_script.write_text("#!/bin/bash\nexit 0\n", encoding="utf-8") + calls: list[list[str]] = [] + + class _DummyProc: + pid = 12345 + + monkeypatch.setitem(ns, "NOTIFY_SCRIPT", notify_script) + monkeypatch.setattr( + ns["subprocess"], + "Popen", + lambda args, **kwargs: calls.append(list(args)) or _DummyProc(), + ) + request_dir = tmp_path / "request" + request_dir.mkdir(parents=True, exist_ok=True) + + ns["_notify_completion_ready"]( + request_dir, + conversation_id="conv-123", + latest_text="first message", + ) + ns["_notify_completion_ready"]( + request_dir, + conversation_id="conv-123", + latest_text="first message", + ) + + marker = ns["_read_json"](request_dir / "completion-notify.json") + assert len(calls) == 1 + assert marker["status"] == "notified" + assert marker["conversation_id"] == "conv-123" + + +def test_chatgpt_wrapper_completion_sentinel_hooks_present(): + source = SCRIPT.read_text(encoding="utf-8") + assert 'NOTIFY_SCRIPT = ROOT / "osascript-notify.sh"' in source + assert 'return request_dir / "completion-sentinel.json"' in source + assert 'return request_dir / "completion-notify.json"' in source + assert "def _maybe_start_completion_sentinel(" in source + assert "async def _watch_completion_signal(" in source + assert 'if action == "watch_complete":' in source + assert 'if action in {"poll", "collect", "watch_complete"} and collect_url:' in source + assert 'env["BROWSER_AGENT_CHATGPT_ACTION"] = "watch_complete"' in source + assert 'env["BROWSER_AGENT_CHATGPT_ENABLE_COMPLETION_SENTINEL"] = "false"' in source + assert "_force_wrapper_exit(0)" in source + assert "_force_wrapper_exit(1)" in source + assert 'marker_path = _completion_notify_marker_path(request_dir)' in source + assert '(request_dir / "completion-notify.json")' not in source + assert 'sentinel_state = _maybe_start_completion_sentinel(' in source + assert '_write_json(request_dir / "completion-sentinel-submit.json", sentinel_state)' in source + assert '"completion_sentinel_status": sentinel_state.get("status")' in source diff --git a/harness/tests/test_browser_agent_gemini_deep_research_wrapper.py b/harness/tests/test_browser_agent_gemini_deep_research_wrapper.py index 5c202bdb3..8c086fdd9 100644 --- a/harness/tests/test_browser_agent_gemini_deep_research_wrapper.py +++ b/harness/tests/test_browser_agent_gemini_deep_research_wrapper.py @@ -141,3 +141,40 @@ def test_build_deep_search_fallback_prompt_is_source_first(): assert "source-first research engine" in prompt assert "categorized literature and link registry" in prompt.lower() assert "working URL" in prompt + + +def test_wrapper_defaults_to_headless_true_and_kills_browser(): + source = MODULE_PATH.read_text(encoding="utf-8") + assert 'os.environ.get("BROWSER_AGENT_HEADLESS") or "true"' in source + assert "await asyncio.wait_for(browser.kill(), timeout=20)" in source + + +def test_wrapper_reads_and_writes_active_session_broker(): + source = MODULE_PATH.read_text(encoding="utf-8") + assert "brtc.read_active_session(control_ctx, require_lineage_match=False)" in source + assert "brtc.activate_reusable_session(" in source + assert "await asyncio.wait_for(browser.stop(), timeout=20)" in source + + +def test_wrapper_dismisses_beta_overlay_before_mode_selection(): + source = MODULE_PATH.read_text(encoding="utf-8") + assert "async def _click_mode_selector(" in source + assert "await _dismiss_overlays(page)" in source + assert "button:has-text('知道了')" in source + assert "await selector_btn.click(force=True)" in source + + +def test_wrapper_force_exits_after_main(): + source = MODULE_PATH.read_text(encoding="utf-8") + assert "def _force_wrapper_exit(code: int) -> NoReturn" in source + assert "_force_wrapper_exit(int(rc))" in source + + +def test_wrapper_force_exits_after_successful_collection(): + source = MODULE_PATH.read_text(encoding="utf-8") + assert "def _finalize_runtime_success(" in source + assert "browser-use-session://gemini/" in source + assert "_finalize_runtime_success(" in source + assert "forced_exit" in source + assert "print(latest_txt)" in source + assert "_force_wrapper_exit(0)" in source diff --git a/harness/tests/test_browser_agent_technology_diagram_painter_wrapper.py b/harness/tests/test_browser_agent_technology_diagram_painter_wrapper.py new file mode 100644 index 000000000..1e28c5d1f --- /dev/null +++ b/harness/tests/test_browser_agent_technology_diagram_painter_wrapper.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import asyncio +import sys +import types +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "browser_agent_technology_diagram_painter_wrapper.py" +SOURCE = SCRIPT.read_text(encoding="utf-8") + + +def _load_namespace(): + browser_use = types.ModuleType("browser_use") + browser_use_browser = types.ModuleType("browser_use.browser") + browser_use_browser_profile = types.ModuleType("browser_use.browser.profile") + browser_use_browser_session = types.ModuleType("browser_use.browser.session") + playwright_async_api = types.ModuleType("playwright.async_api") + + class _DummyProfile: + pass + + class _DummySession: + pass + + async def _dummy_async_playwright(): # pragma: no cover - stub only + return None + + browser_use_browser_profile.BrowserProfile = _DummyProfile + browser_use_browser_session.BrowserSession = _DummySession + playwright_async_api.async_playwright = _dummy_async_playwright + + prev_modules = { + name: sys.modules.get(name) + for name in ( + "browser_use", + "browser_use.browser", + "browser_use.browser.profile", + "browser_use.browser.session", + "playwright.async_api", + ) + } + sys.modules["browser_use"] = browser_use + sys.modules["browser_use.browser"] = browser_use_browser + sys.modules["browser_use.browser.profile"] = browser_use_browser_profile + sys.modules["browser_use.browser.session"] = browser_use_browser_session + sys.modules["playwright.async_api"] = playwright_async_api + try: + ns = {"__file__": str(SCRIPT), "__name__": "browser_agent_technology_diagram_painter_wrapper_test"} + code = compile(SCRIPT.read_text(encoding="utf-8"), str(SCRIPT), "exec") + exec(code, ns) + return types.SimpleNamespace(**ns) + finally: + for name, module in prev_modules.items(): + if module is None: + sys.modules.pop(name, None) + else: + sys.modules[name] = module + + +def test_painter_wrapper_waits_for_chat_ready_before_submit() -> None: + assert "async def _wait_for_chat_ready" in SOURCE + assert "async def _capture_state(" in SOURCE + assert "await _wait_for_chat_ready(playwright_page, timeout_s=60)" in SOURCE + assert "await _wait_for_chat_ready(playwright_page, timeout_s=45)" in SOURCE + assert 'state = await _capture_state(page, timeout_s=8.0, default=last_state, label="wait_for_chat_ready")' in SOURCE + assert 'raise RuntimeError("chatgpt_cloudflare_challenge_detected")' in SOURCE + assert "await page.reload()" in SOURCE + + +def test_painter_wrapper_uses_dom_fill_and_submit_fallbacks() -> None: + assert "SET_PROMPT_JS" in SOURCE + assert "COMPOSER_STATE_JS" in SOURCE + assert "SUBMIT_JS" in SOURCE + assert "SUBMIT_FALLBACK_JS" in SOURCE + assert "form.requestSubmit()" in SOURCE + assert "submit_result = json.loads(await page.evaluate(SUBMIT_JS))" in SOURCE + assert "submit_result = json.loads(await page.evaluate(SUBMIT_FALLBACK_JS))" in SOURCE + + +def test_painter_wrapper_reuses_chatgpt_runtime_session_when_available() -> None: + assert "brtc.initialize_runtime_contract(" in SOURCE + assert "active_session = brtc.read_active_session(control_ctx, require_lineage_match=False)" in SOURCE + assert 'cdp_url=str(active_session.get("cdp_url") or "").strip()' in SOURCE + assert "brtc.activate_reusable_session(" in SOURCE + + +def test_painter_wrapper_promotes_original_capture_before_ui_timeout() -> None: + assert "async def _maybe_promote_original_capture" in SOURCE + assert "Promoted captured original image response as final result." in SOURCE + assert "capture_state=original_capture" in SOURCE + + +def test_painter_wrapper_forces_exit_after_success() -> None: + assert "def _force_wrapper_exit" in SOURCE + assert "_force_wrapper_exit(0)" in SOURCE + + +def test_painter_capture_state_times_out_to_default() -> None: + ns = _load_namespace() + + class _NeverReturnsPage: + async def evaluate(self, script): # pragma: no cover - async stub + await asyncio.sleep(2.0) + return "{}" + + result = asyncio.run( + ns._capture_state( + _NeverReturnsPage(), + timeout_s=0.01, + default={"composer_ready": True}, + label="wait_for_chat_ready", + ) + ) + assert result["composer_ready"] is True + assert result["_capture_timeout"] == "wait_for_chat_ready" diff --git a/harness/tests/test_browser_agent_wrapper_lifecycle_defaults.py b/harness/tests/test_browser_agent_wrapper_lifecycle_defaults.py new file mode 100644 index 000000000..c7b9b2be4 --- /dev/null +++ b/harness/tests/test_browser_agent_wrapper_lifecycle_defaults.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +def test_browser_agent_wrappers_default_to_headless_true_and_kill_browser(): + expectations = { + "scripts/browser_agent_chatgpt_wrapper.py": [ + '_env_flag("BROWSER_AGENT_HEADLESS", default=True)', + "return DEFAULT_BROWSER_CHANNEL", + "await asyncio.wait_for(browser.kill(), timeout=20)", + ], + "scripts/browser_agent_gemini_deep_research_wrapper.py": [ + 'os.environ.get("BROWSER_AGENT_HEADLESS") or "true"', + 'channel="chrome"', + "await asyncio.wait_for(browser.kill(), timeout=20)", + ], + "scripts/browser_agent_youtube_transcript_wrapper.py": [ + 'os.environ.get("BROWSER_AGENT_HEADLESS") or "true"', + 'channel="chrome"', + "await asyncio.wait_for(browser.kill(), timeout=20)", + ], + "scripts/browser_agent_technology_diagram_painter_wrapper.py": [ + 'os.environ.get("BROWSER_AGENT_HEADLESS") or "true"', + 'channel="chrome"', + "await asyncio.wait_for(browser.kill(), timeout=20)", + ], + "scripts/browser_agent_notebooklm_wrapper.py": [ + 'os.environ.get("BROWSER_AGENT_HEADLESS") or "true"', + 'channel="chrome"', + "await asyncio.wait_for(browser.kill(), timeout=20)", + ], + } + for rel, patterns in expectations.items(): + source = _read(rel) + for pattern in patterns: + assert pattern in source, f"{rel} missing pattern: {pattern}" + + +def test_browser_agent_callers_default_to_headless_true(): + expectations = { + "scripts/youtube_influence_digest.py": 'env["BROWSER_AGENT_HEADLESS"] = "true"', + "tools/youtube_transcript_operator.py": 'env["BROWSER_AGENT_HEADLESS"] = "true"', + "tools/technology_diagram_painter_operator.py": 'env["BROWSER_AGENT_HEADLESS"] = "true"', + "scripts/run_youtube_daily_previous_day_collect.sh": 'BROWSER_AGENT_HEADLESS="${BROWSER_AGENT_HEADLESS:-true}"', + } + for rel, pattern in expectations.items(): + source = _read(rel) + assert pattern in source, f"{rel} missing pattern: {pattern}" + + +def test_browser_job_runtime_forces_chrome_channel(): + expectations = { + "lib/browser_job_runtime.py": 'channel="chrome"', + "tools/browser_job_runtime.py": 'channel="chrome"', + } + for rel, pattern in expectations.items(): + source = _read(rel) + assert pattern in source, f"{rel} missing pattern: {pattern}" diff --git a/harness/tests/test_chatgpt_browser_agent_task_operator.py b/harness/tests/test_chatgpt_browser_agent_task_operator.py index f992a92ee..87d1b5231 100644 --- a/harness/tests/test_chatgpt_browser_agent_task_operator.py +++ b/harness/tests/test_chatgpt_browser_agent_task_operator.py @@ -8,8 +8,10 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "tools")) +sys.path.insert(0, str(ROOT / "lib")) import chatgpt_browser_agent_task_operator as cto # noqa: E402 +from browser.profile_lease import ProfileLease # noqa: E402 def test_build_request_reads_prompt_file(tmp_path): @@ -29,14 +31,13 @@ def test_build_request_reads_prompt_file(tmp_path): def test_run_request_writes_result(monkeypatch, tmp_path, capsys): class Result: returncode = 0 - stdout = "final answer" - stderr = "" monkeypatch.setattr(cto, "_wrapper_cmd", lambda: ["fake-wrapper"]) seen_env = {} def _fake_run(*args, **kwargs): seen_env.update(kwargs.get("env") or {}) + kwargs["stdout"].write("final answer") return Result() monkeypatch.setattr(cto.subprocess, "run", _fake_run) @@ -48,9 +49,37 @@ def _fake_run(*args, **kwargs): assert seen_env["BROWSER_AGENT_CHATGPT_REQUIRE_UI_MODE"] == "true" assert (tmp_path / "chatgpt-browser-agent-request.json").exists() assert (tmp_path / "chatgpt-browser-agent-result.json").exists() + assert (tmp_path / "chatgpt-browser-agent-stdout.txt").read_text(encoding="utf-8") == "final answer" assert "ChatGPT Browser Agent Result" in capsys.readouterr().out +def test_run_request_allows_collect_without_prompt_and_sets_conversation_url(monkeypatch, tmp_path): + class Result: + returncode = 0 + + monkeypatch.setattr(cto, "_wrapper_cmd", lambda: ["fake-wrapper"]) + seen_env = {} + + def _fake_run(*args, **kwargs): + seen_env.update(kwargs.get("env") or {}) + kwargs["stdout"].write("final answer") + return Result() + + monkeypatch.setattr(cto.subprocess, "run", _fake_run) + result = cto.run_request( + { + "action": "collect", + "conversation_url": "https://chatgpt.com/c/demo", + "request_dir": str(tmp_path / "request"), + "project_name": "杂项", + }, + task_dir=tmp_path, + ) + assert result["ok"] is True + assert seen_env["BROWSER_AGENT_CHATGPT_ACTION"] == "collect" + assert seen_env["BROWSER_AGENT_CHATGPT_CONVERSATION_URL"] == "https://chatgpt.com/c/demo" + + def test_main_applies_success_cooldown(monkeypatch, tmp_path): envelope = {"task_id": "T1", "operator_id": "mini-browser-chatgpt", "prompt": "hello"} envelope_path = tmp_path / "envelope.json" @@ -92,3 +121,112 @@ def _boom(request, task_dir): ) assert cto.main() == 1 assert calls == [("mini-browser-chatgpt", 3600, 21600, True, True)] + + +def test_main_collect_bypasses_flow_control(monkeypatch, tmp_path): + envelope = { + "task_id": "T3", + "operator_id": "mini-browser-chatgpt", + "chatgpt_browser_agent_request": { + "action": "collect", + "conversation_url": "https://chatgpt.com/c/demo", + "request_dir": str(tmp_path / "request"), + }, + } + envelope_path = tmp_path / "envelope.json" + envelope_path.write_text(json.dumps(envelope), encoding="utf-8") + monkeypatch.setenv("SOLAR_OPERATOR_ENVELOPE_JSON", str(envelope_path)) + monkeypatch.setenv("TASK_DIR", str(tmp_path / "task")) + ensure_calls: list[str] = [] + failure_calls: list[str] = [] + success_calls: list[str] = [] + monkeypatch.setattr( + cto.ofc, + "ensure_operator_available", + lambda operator_id: ensure_calls.append(operator_id), + ) + monkeypatch.setattr( + cto.ofc, + "apply_failure_flow_control", + lambda *args, **kwargs: failure_calls.append("called"), + ) + monkeypatch.setattr( + cto.ofc, + "apply_success_cooldown", + lambda *args, **kwargs: success_calls.append("called"), + ) + monkeypatch.setattr(cto, "run_request", lambda request, task_dir: {"ok": True}) + assert cto.main() == 0 + assert ensure_calls == [] + assert failure_calls == [] + assert success_calls == [] + + +def test_main_browser_agent_session_bypasses_flow_control(monkeypatch, tmp_path): + envelope = { + "task_id": "T4", + "logical_operator": "DeepResearchChatGPT", + "chatgpt_browser_agent_request": { + "action": "submit", + "prompt": "hello", + }, + } + envelope_path = tmp_path / "envelope.json" + envelope_path.write_text(json.dumps(envelope), encoding="utf-8") + monkeypatch.setenv("SOLAR_OPERATOR_ENVELOPE_JSON", str(envelope_path)) + monkeypatch.setenv("TASK_DIR", str(tmp_path / "task")) + ensure_calls: list[str] = [] + failure_calls: list[str] = [] + success_calls: list[str] = [] + monkeypatch.setattr(cto.ofc, "ensure_operator_available", lambda operator_id: ensure_calls.append(operator_id)) + monkeypatch.setattr(cto.ofc, "apply_failure_flow_control", lambda *args, **kwargs: failure_calls.append("called")) + monkeypatch.setattr(cto.ofc, "apply_success_cooldown", lambda *args, **kwargs: success_calls.append("called")) + monkeypatch.setattr(cto, "run_request", lambda request, task_dir: {"ok": True}) + assert cto.main() == 0 + assert ensure_calls == [] + assert failure_calls == [] + assert success_calls == [] + + +def test_operator_id_defaults_to_browser_agent_session_for_session_control_chatgpt(): + assert cto._operator_id({"logical_operator": "DeepResearchChatGPT"}) == "browser_agent_session" + assert cto._operator_id({"logical_operator": "GPTRequirementWriter"}) == "browser_agent_session" + assert cto._operator_id({}) == "mini-chatgpt-deep-research" + + +def test_apply_profile_policy_skips_leased_primary_profile(monkeypatch, tmp_path): + lease_root = tmp_path / "leases" + lease = ProfileLease(root=lease_root) + acquired = lease.acquire( + "chatgpt/browser-agent", + task_id="occupied-task", + runtime="browser_use", + mode="exclusive", + ) + assert acquired["acquired"] is True + policy = tmp_path / "browser-agent-chatgpt-local.json" + policy.write_text( + json.dumps( + { + "version": 1, + "policies": { + "default": { + "expected_account_email": "browser-agent@example.com", + "allowed_profiles": ["Profile 1", "Profile 2"], + "selection": "first", + } + }, + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + monkeypatch.setenv("BROWSER_AGENT_CHATGPT_PROFILE_POLICY_DISABLED", "0") + monkeypatch.setenv("BROWSER_AGENT_CHATGPT_PROFILE_POLICY_FILE", str(policy)) + monkeypatch.setenv("BROWSER_PROFILE_LEASE_DIR", str(lease_root)) + env: dict[str, str] = {} + meta = cto.apply_profile_policy(env, {"purpose": "github-trend-report-demo"}) + assert env["BROWSER_AGENT_PROFILE_DIRECTORY"] == "Profile 2" + assert env["BROWSER_AGENT_PROFILE_ID"] == "chatgpt/browser-agent-profile-2" + assert meta["lease_blocked_profiles"] == ["Profile 1"] + assert meta["selected_profile_directory"] == "Profile 2" diff --git a/harness/tests/test_chatgpt_report_operator.py b/harness/tests/test_chatgpt_report_operator.py index 4e972fc33..0e4cfa696 100644 --- a/harness/tests/test_chatgpt_report_operator.py +++ b/harness/tests/test_chatgpt_report_operator.py @@ -1,3 +1,4 @@ +import io import json import os import subprocess @@ -7,6 +8,9 @@ ROOT = Path(__file__).resolve().parents[1] SCRIPT = ROOT / "tools" / "chatgpt_report_operator.py" +sys.path.append(str(ROOT / "tools")) +import chatgpt_report_operator as cro # noqa: E402 +from browser.profile_lease import ProfileLease # noqa: E402 def run_operator( @@ -49,6 +53,10 @@ def run_operator( "'require_ui_mode':os.environ.get('BROWSER_AGENT_CHATGPT_REQUIRE_UI_MODE')," "'action':os.environ.get('BROWSER_AGENT_CHATGPT_ACTION')," "'project':os.environ.get('BROWSER_AGENT_CHATGPT_PROJECT_NAME')," + "'headless':os.environ.get('BROWSER_AGENT_HEADLESS')," + "'session_reuse':os.environ.get('BROWSER_AGENT_SESSION_REUSE')," + "'session_lineage':os.environ.get('BROWSER_AGENT_SESSION_LINEAGE')," + "'allow_headed':os.environ.get('BROWSER_AGENT_CHATGPT_ALLOW_HEADED')," "'profile_directory':os.environ.get('BROWSER_AGENT_PROFILE_DIRECTORY')," "'target_account_email':os.environ.get('BROWSER_AGENT_TARGET_ACCOUNT_EMAIL')," "'chatgpt_account_email':os.environ.get('BROWSER_AGENT_CHATGPT_ACCOUNT_EMAIL')," @@ -64,6 +72,7 @@ def run_operator( "BROWSER_AGENT_PURPOSE": purpose, "BROWSER_AGENT_EXPECTED_OUTPUT": expected, "BROWSER_AGENT_CHATGPT_PROFILE_POLICY_DISABLED": "1", + "BROWSER_AGENT_SESSION_CONTROL_DISABLED": "1", } ) if env_extra: @@ -92,6 +101,9 @@ def test_planner_sets_thinking_high_and_project(tmp_path): assert payload["tool_mode"] == "none" assert payload["require_ui_mode"] == "true" assert payload["project"] == "杂项" + assert payload["headless"] == "true" + assert payload["session_reuse"] == "true" + assert payload["session_lineage"] == "chatgpt-report:ai-influence-report-plan-2026-05-31" assert "ChatGPT Report Planner" in payload["prompt"] meta = json.loads((tmp_path / "request" / "report-operator-request.json").read_text()) assert meta["operator_kind"] == "planner" @@ -157,6 +169,86 @@ def test_local_profile_policy_can_fill_account_and_choose_from_pool(tmp_path): assert meta["profile_policy"]["selected_account_email"] == "browser-agent@example.com" +def test_local_profile_policy_skips_leased_profile_and_uses_backup(tmp_path): + lease_root = tmp_path / "leases" + lease = ProfileLease(root=lease_root) + acquired = lease.acquire( + "chatgpt/browser-agent", + task_id="held-by-other-task", + runtime="browser_use", + mode="exclusive", + ) + assert acquired["acquired"] is True + policy = tmp_path / "browser-agent-chatgpt-local.json" + policy.write_text( + json.dumps( + { + "version": 1, + "policies": { + "default": { + "expected_account_email": "browser-agent@example.com", + "allowed_profiles": ["Profile 1", "Profile 2"], + "selection": "first", + } + }, + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + proc = run_operator( + tmp_path, + purpose="github-trend-report-demo", + env_extra={ + "BROWSER_AGENT_CHATGPT_PROFILE_POLICY_DISABLED": "0", + "BROWSER_AGENT_CHATGPT_PROFILE_POLICY_FILE": str(policy), + "BROWSER_PROFILE_LEASE_DIR": str(lease_root), + }, + ) + payload = json.loads(proc.stdout) + assert payload["profile_directory"] == "Profile 2" + meta = json.loads((tmp_path / "request" / "report-operator-request.json").read_text()) + assert meta["profile_policy"]["selected_profile_directory"] == "Profile 2" + assert meta["profile_policy"]["selected_profile_id"] == "chatgpt/browser-agent-profile-2" + assert meta["profile_policy"]["lease_blocked_profiles"] == ["Profile 1"] + assert meta["profile_policy"]["selection_reason"] == "lease_available" + + +def test_protected_profile_policy_keeps_headless_default(tmp_path): + policy = tmp_path / "browser-agent-chatgpt-local.json" + policy.write_text( + json.dumps( + { + "version": 1, + "policies": { + "hf_paper_insight": { + "expected_account_email": "browser-agent@example.com", + "allowed_profiles": ["Profile 1"], + "allow_headless": False, + } + }, + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + proc = run_operator( + tmp_path, + purpose="hf-paper-report-plan-2026-06-01", + expected="json", + env_extra={ + "BROWSER_AGENT_CHATGPT_PROFILE_POLICY_DISABLED": "0", + "BROWSER_AGENT_CHATGPT_PROFILE_POLICY_FILE": str(policy), + }, + ) + payload = json.loads(proc.stdout) + meta = json.loads((tmp_path / "request" / "report-operator-request.json").read_text()) + assert payload["headless"] == "true" + assert payload["allow_headed"] == "true" + assert meta["profile_policy"]["headless_forced"] is False + assert meta["profile_policy"]["headed_recovery_allowed"] is True + + def test_hf_report_planner_uses_hf_profile_policy_key(tmp_path): policy = tmp_path / "browser-agent-chatgpt-local.json" policy.write_text( @@ -362,3 +454,126 @@ def test_wrapper_timeout_returns_controlled_error(tmp_path): proc = run_operator(tmp_path, kind="deep_writer", write_deep_proof=True, sleep_seconds=5, check=False) assert proc.returncode == 124 assert "wrapper timed out after 1s" in proc.stderr + + +def test_report_operator_defaults_to_session_control(monkeypatch, tmp_path, capsys): + request_dir = tmp_path / "request" + request_dir.mkdir(parents=True, exist_ok=True) + (request_dir / "chatgpt-mode-state.json").write_text(json.dumps({"ok": True}), encoding="utf-8") + result_file = tmp_path / "result.json" + result_file.write_text(json.dumps({"text": "session control output"}, ensure_ascii=False), encoding="utf-8") + submit_calls = [] + + monkeypatch.delenv("BROWSER_AGENT_SESSION_CONTROL_DISABLED", raising=False) + monkeypatch.setenv("BROWSER_AGENT_REQUEST_DIR", str(request_dir)) + monkeypatch.setenv("BROWSER_AGENT_PURPOSE", "ai-influence-report-plan-2026-06-04") + monkeypatch.setenv("BROWSER_AGENT_CHATGPT_PROFILE_POLICY_DISABLED", "1") + monkeypatch.setattr(sys, "stdin", io.StringIO("write from session control")) + monkeypatch.setattr(cro, "submit_request", lambda request, logical_operator, objective, task_id: submit_calls.append((request, logical_operator)) or {"success": True, "task_id": "task-report"}) + monkeypatch.setattr(cro, "collect_request", lambda *args, **kwargs: (0, {"status": "completed", "latest_result": {"result_file": str(result_file)}})) + + assert cro.main() == 0 + assert submit_calls + assert submit_calls[0][1] == "DeepResearchChatGPT" + assert submit_calls[0][0]["action"] == "submit" + assert capsys.readouterr().out.strip() == "session control output" + + +def test_report_operator_submit_accepts_running_session_status(monkeypatch, tmp_path, capsys): + request_dir = tmp_path / "request" + request_dir.mkdir(parents=True, exist_ok=True) + (request_dir / "chatgpt-mode-state.json").write_text(json.dumps({"ok": True}), encoding="utf-8") + result_file = tmp_path / "result.json" + result_file.write_text( + json.dumps( + { + "text": json.dumps( + { + "status": "running", + "url": "https://chatgpt.com/c/demo", + "conversation_id": "demo", + }, + ensure_ascii=False, + ) + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + + monkeypatch.delenv("BROWSER_AGENT_SESSION_CONTROL_DISABLED", raising=False) + monkeypatch.setenv("BROWSER_AGENT_REQUEST_DIR", str(request_dir)) + monkeypatch.setenv("BROWSER_AGENT_PURPOSE", "hf-paper-report-plan-2026-06-05") + monkeypatch.setenv("BROWSER_AGENT_CHATGPT_PROFILE_POLICY_DISABLED", "1") + monkeypatch.setenv("CHATGPT_REPORT_ACTION", "submit") + monkeypatch.setattr(sys, "stdin", io.StringIO("submit planner")) + monkeypatch.setattr(cro, "submit_request", lambda request, logical_operator, objective, task_id: {"success": True, "task_id": "task-running"}) + monkeypatch.setattr( + cro, + "collect_request", + lambda *args, **kwargs: ( + 0, + { + "status": "running", + "latest_result": {"result_file": str(result_file)}, + }, + ), + ) + + assert cro.main() == 0 + submitted = json.loads((request_dir / "submitted-run.json").read_text(encoding="utf-8")) + assert submitted["task_id"].startswith("chatgpt-report-") + assert submitted["url"] == "https://chatgpt.com/c/demo" + assert submitted["conversation_id"] == "demo" + assert json.loads(capsys.readouterr().out)["status"] == "running" + + +def test_report_operator_collect_backfills_request_dir_from_completed_result(monkeypatch, tmp_path, capsys): + request_dir = tmp_path / "request" + request_dir.mkdir(parents=True, exist_ok=True) + (request_dir / "chatgpt-mode-state.json").write_text(json.dumps({"ok": True}), encoding="utf-8") + (request_dir / "submitted-run.json").write_text( + json.dumps({"task_id": "task-completed"}, ensure_ascii=False), + encoding="utf-8", + ) + result_file = tmp_path / "result.json" + result_file.write_text( + json.dumps( + { + "text": json.dumps( + {"chapters": [{"chapter_id": "ch_01", "text": "body"}]}, + ensure_ascii=False, + ) + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + + monkeypatch.delenv("BROWSER_AGENT_SESSION_CONTROL_DISABLED", raising=False) + monkeypatch.setenv("BROWSER_AGENT_REQUEST_DIR", str(request_dir)) + monkeypatch.setenv("BROWSER_AGENT_PURPOSE", "ai-influence-report-phase2-batch") + monkeypatch.setenv("BROWSER_AGENT_CHATGPT_PROFILE_POLICY_DISABLED", "1") + monkeypatch.setenv("CHATGPT_REPORT_ACTION", "collect") + monkeypatch.setattr(sys, "stdin", io.StringIO("")) + monkeypatch.setattr( + cro, + "collect_request", + lambda *args, **kwargs: ( + 0, + { + "status": "completed", + "active_manifest": { + "conversation_url": "https://chatgpt.com/c/completed", + "conversation_id": "completed", + }, + "latest_result": {"result_file": str(result_file)}, + }, + ), + ) + + assert cro.main() == 0 + assert json.loads(capsys.readouterr().out)["chapters"][0]["chapter_id"] == "ch_01" + assert json.loads((request_dir / "collect-state.json").read_text(encoding="utf-8"))["status"] == "completed" + assert (request_dir / "assistant-response.txt").read_text(encoding="utf-8").strip().startswith("{\"chapters\"") + assert json.loads((request_dir / "page.json").read_text(encoding="utf-8"))["conversation_id"] == "completed" diff --git a/harness/tests/test_chatgpt_requirement_writer_operator.py b/harness/tests/test_chatgpt_requirement_writer_operator.py new file mode 100644 index 000000000..0cab2f700 --- /dev/null +++ b/harness/tests/test_chatgpt_requirement_writer_operator.py @@ -0,0 +1,198 @@ +import io +import json +import os +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "tools" / "chatgpt_requirement_writer_operator.py" +CONFIG_DIR = ROOT / "config" +sys.path.append(str(ROOT / "tools")) +import chatgpt_requirement_writer_operator as crw # noqa: E402 + + +def run_operator( + tmp_path, + *, + stdin_text="rewrite from upstream", + env_extra=None, + write_mode_proof=True, + check=True, +): + wrapper = tmp_path / "fake_wrapper.py" + wrapper.write_text( + "import json, os, sys\n" + "from pathlib import Path\n" + "prompt=sys.stdin.read()\n" + f"write_mode_proof={str(write_mode_proof)!r} == 'True'\n" + "request_dir=os.environ.get('BROWSER_AGENT_REQUEST_DIR')\n" + "if write_mode_proof and request_dir:\n" + " Path(request_dir).mkdir(parents=True, exist_ok=True)\n" + " (Path(request_dir)/'chatgpt-mode-state.json').write_text(json.dumps({'ok': True, 'test': True}), encoding='utf-8')\n" + "out={'model':os.environ.get('CHATGPT_MODEL')," + "'effort':os.environ.get('CHATGPT_REASONING_EFFORT')," + "'model_mode':os.environ.get('BROWSER_AGENT_CHATGPT_MODEL_MODE')," + "'tool_mode':os.environ.get('BROWSER_AGENT_CHATGPT_TOOL_MODE')," + "'require_ui_mode':os.environ.get('BROWSER_AGENT_CHATGPT_REQUIRE_UI_MODE')," + "'headless':os.environ.get('BROWSER_AGENT_HEADLESS')," + "'session_reuse':os.environ.get('BROWSER_AGENT_SESSION_REUSE')," + "'session_lineage':os.environ.get('BROWSER_AGENT_SESSION_LINEAGE')," + "'prompt':prompt[:4000]}\n" + "print(json.dumps(out, ensure_ascii=False))\n", + encoding="utf-8", + ) + env = os.environ.copy() + env.update( + { + "BROWSER_AGENT_CHATGPT_WRAPPER_CMD": f"{sys.executable} {wrapper}", + "BROWSER_AGENT_REQUEST_DIR": str(tmp_path / "request"), + "BROWSER_AGENT_CHATGPT_PROFILE_POLICY_DISABLED": "1", + "BROWSER_AGENT_SESSION_CONTROL_DISABLED": "1", + } + ) + if env_extra: + env.update(env_extra) + return subprocess.run( + [sys.executable, str(SCRIPT)], + input=stdin_text, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + check=check, + ) + + +def test_requirement_writer_prefers_raw_file_over_stdin(tmp_path): + raw_file = tmp_path / "raw.txt" + raw_file.write_text("用户原始需求:请按章节输出系统化需求设计", encoding="utf-8") + proc = run_operator( + tmp_path, + stdin_text="上游改写稿:错误信息,仅用于回退", + env_extra={"SOLAR_RAW_REQUIREMENT_FILE": str(raw_file)}, + ) + payload = json.loads(proc.stdout) + assert "用户原始需求:请按章节输出系统化需求设计" in payload["prompt"] + assert "上游改写稿" not in payload["prompt"] + + +def test_requirement_writer_config_has_logical_and_physical_and_actor_links(): + logical = json.loads((CONFIG_DIR / "logical-operators.json").read_text(encoding="utf-8")) + physical = json.loads((CONFIG_DIR / "physical-operators.json").read_text(encoding="utf-8")) + actors = json.loads((CONFIG_DIR / "agent-actors.json").read_text(encoding="utf-8")) + registry = json.loads((CONFIG_DIR / "operator_registry.json").read_text(encoding="utf-8")) + schedules = json.loads((CONFIG_DIR / "operator_schedules.json").read_text(encoding="utf-8")) + + logical_entry = logical["logical_operators"]["GPTRequirementWriter"] + assert logical_entry["operator_type"] == "GPTRequirementWriter" + binding = logical["bindings"]["GPTRequirementWriter"] + assert any( + c.get("actor_id") == "mini-chatgpt-requirement-writer" + for c in binding.get("candidates", []) + ) + + assert "mini-chatgpt-requirement-writer" in physical["operators"] + actor_cfg = actors["actors"]["mini-chatgpt-requirement-writer"] + assert actor_cfg["operator_alias"] == "mini-chatgpt-requirement-writer" + assert actor_cfg["role"] == "planner" + + line_cfg = registry["lines"]["chatgpt_requirement_writer"] + assert line_cfg["primary"] == "tools/chatgpt_requirement_writer_operator.py" + assert schedules["bindings"]["chatgpt_requirement_writer"]["type"] == "manual" + assert schedules["bindings"]["chatgpt_requirement_writer"]["source_schedule"] == "on_demand" + + +def test_requirement_writer_uses_thinking_high(tmp_path): + proc = run_operator(tmp_path, stdin_text="为 DeepDive 设计一个需求编译器") + payload = json.loads(proc.stdout) + assert payload["model_mode"] == "thinking" + assert payload["effort"] == "high" + assert payload["tool_mode"] == "none" + assert payload["require_ui_mode"] == "true" + assert payload["headless"] == "true" + assert payload["session_reuse"] == "true" + assert payload["session_lineage"].startswith("gpt-requirement-writer:") + assert "GPTRequirementWriter 固化执行协议" in payload["prompt"] + assert "功能需求清单" in payload["prompt"] + meta = json.loads((tmp_path / "request" / "requirement-writer-request.json").read_text()) + assert meta["operator_kind"] == "GPTRequirementWriter" + assert meta["raw_requirement_source"] == "stdin" + assert meta["upstream_input_ignored"] is False + + +def test_requirement_writer_prefers_raw_requirement_env(tmp_path): + proc = run_operator( + tmp_path, + stdin_text="上游改写稿:请写一个简单摘要", + env_extra={"SOLAR_RAW_REQUIREMENT": "用户原始需求:开发一个完整的章节化 PRD 编译前设计算子"}, + ) + payload = json.loads(proc.stdout) + assert "用户原始需求:开发一个完整的章节化 PRD 编译前设计算子" in payload["prompt"] + assert "上游改写稿" not in payload["prompt"] + meta = json.loads((tmp_path / "request" / "requirement-writer-request.json").read_text()) + assert meta["raw_requirement_source"] == "env:SOLAR_RAW_REQUIREMENT" + assert meta["upstream_input_present"] is True + assert meta["upstream_input_ignored"] is True + + +def test_requirement_writer_can_read_raw_intent_file(tmp_path): + raw_intent = tmp_path / "raw_intent.json" + raw_intent.write_text( + json.dumps( + { + "schema_version": "solar.raw_intent.v1", + "raw": {"text": "用户原始需求:把需求写作算子做成章节化、系统化输出"}, + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + proc = run_operator( + tmp_path, + stdin_text="rewrite from upstream", + env_extra={"SOLAR_RAW_INTENT_FILE": str(raw_intent)}, + ) + payload = json.loads(proc.stdout) + assert "章节化、系统化输出" in payload["prompt"] + meta = json.loads((tmp_path / "request" / "requirement-writer-request.json").read_text()) + assert meta["raw_requirement_source"] == "raw_intent:SOLAR_RAW_INTENT_FILE" + assert meta["raw_requirement_source_path"] == str(raw_intent) + + +def test_requirement_writer_requires_mode_proof(tmp_path): + proc = run_operator( + tmp_path, + stdin_text="为浏览器任务设计规格", + write_mode_proof=False, + check=False, + ) + assert proc.returncode == 1 + assert "chatgpt-mode-state.json" in proc.stderr + + +def test_requirement_writer_defaults_to_session_control(monkeypatch, tmp_path, capsys): + request_dir = tmp_path / "request" + request_dir.mkdir(parents=True, exist_ok=True) + (request_dir / "chatgpt-mode-state.json").write_text(json.dumps({"ok": True}), encoding="utf-8") + result_file = tmp_path / "result.json" + result_file.write_text(json.dumps({"text": "requirement session output"}, ensure_ascii=False), encoding="utf-8") + transport_calls = [] + + monkeypatch.delenv("BROWSER_AGENT_SESSION_CONTROL_DISABLED", raising=False) + monkeypatch.setenv("BROWSER_AGENT_REQUEST_DIR", str(request_dir)) + monkeypatch.setenv("BROWSER_AGENT_CHATGPT_PROFILE_POLICY_DISABLED", "1") + monkeypatch.setenv("SOLAR_RAW_REQUIREMENT", "用户原始需求:把需求写成章节化设计") + monkeypatch.setattr(sys, "stdin", io.StringIO("上游改写稿")) + monkeypatch.setattr( + crw, + "_run_via_session_control", + lambda *, prompt, env, timeout, action: transport_calls.append((env.get("BROWSER_AGENT_SESSION_LINEAGE"), action, prompt)) or (0, "requirement session output"), + ) + + assert crw.main() == 0 + assert transport_calls + assert transport_calls[0][1] == "run" + assert "GPTRequirementWriter 固化执行协议" in transport_calls[0][2] + assert capsys.readouterr().out.strip() == "requirement session output" diff --git a/harness/tests/test_codex_pm_router.py b/harness/tests/test_codex_pm_router.py index abd7f7a7f..69b2af8ce 100644 --- a/harness/tests/test_codex_pm_router.py +++ b/harness/tests/test_codex_pm_router.py @@ -239,6 +239,266 @@ def test_build_pm_intake_sanitizes_rawintent_consumer_payload(): assert "RawIntent Consumer Request" not in prd +def test_build_pm_intake_prefers_enhanced_requirement_design_section(): + router = _load_router() + consumer_text = """# RawIntent Consumer Request - research implementation + +## Rewritten Objective + +把研究类需求编译成 sprint package。 + +## Problem + +用户想做研究实现链路,但原始文本很短。 + +## Enhanced Requirement Design + +# 需求概述 + +需要把研究实现类需求先走章节化增强,再进入 requirement compiler,输出更完整的 IR、PRD、contract 和 task_graph。 + +## 功能需求 + +- 必须支持显式启动词 `研究实现` +- 必须保留 raw user intent provenance + +## Raw User Intent + +研究实现 一个需求编译链路。 +""" + payload = router.build_pm_intake(consumer_text, sprint_id="sprint-test", target_system="solar-harness") + requirement_ir = payload["requirement_ir"] + assert "章节化增强" in requirement_ir["normalized_goal"] + assert requirement_ir["user_intent"] == "研究实现 一个需求编译链路。" + assert requirement_ir["source_inputs"]["enhanced_requirement_sections"][0]["heading"] == "需求概述" + assert requirement_ir["source_inputs"]["compile_segments"][0]["kind"] == "enhanced_requirement_section" + assert requirement_ir["source_inputs"]["compile_segments"][0]["semantic_label"] == "architecture_and_scope" + + +def test_build_pm_intake_maps_enhanced_requirement_sections_to_dag_semantics(): + router = _load_router() + consumer_text = """# RawIntent Consumer Request - section semantic mapping + +## Rewritten Objective + +把研究实现需求编译成更细粒度的 DAG 提示。 + +## Problem + +用户需要按章节语义把需求拆进不同 node family。 + +## Enhanced Requirement Design + +# 需求概述 + +需要把增强需求的章节语义映射到 requirement compiler DAG。 + +## 功能需求 + +- 生成 GPTRequirementWriter 增强需求 +- 把章节喂给 requirement compiler + +## 非功能需求 + +- 保持 provenance +- 保持可验证 + +## 风险与约束 + +- 不能破坏现有 DAG 主骨架 + +## 验收标准 + +- ImplementationWorker / Verifier 能看到章节语义提示 + +## Raw User Intent + +研究实现 一个章节语义到 DAG 模板的链路。 +""" + payload = router.build_pm_intake(consumer_text, sprint_id="sprint-test", target_system="solar-harness") + requirement_ir = payload["requirement_ir"] + semantic_hints = requirement_ir["source_inputs"]["enhanced_requirement_semantic_hints"] + assert [item["node_family"] for item in semantic_hints[:4]] == [ + "design", + "implementation", + "quality", + "risk_review", + ] + compile_segments = requirement_ir["source_inputs"]["compile_segments"] + assert compile_segments[1]["semantic_label"] == "functional_requirements" + assert compile_segments[2]["node_family"] == "quality" + assert compile_segments[3]["node_family"] == "risk_review" + by_id = {node["id"]: node for node in payload["compiled_artifacts"]["task_dag"]["nodes"]} + assert "implementation" in by_id["S2"]["semantic_focus"] + assert any(item["heading"] == "功能需求" for item in by_id["S2"]["section_semantic_hints"]) + assert "implementation-plan.md" in by_id["S2"]["outputs"] + assert "section-functional-requirements" in by_id["S2"]["signals"] + assert any(item["target"] == "implementation-plan.md" for item in by_id["S2"]["validation"]) + assert "quality" in by_id["S3"]["semantic_focus"] + assert "quality-checklist.md" in by_id["S3"]["outputs"] + assert any(item["target"] == "quality-checklist.md" for item in by_id["S3"]["validation"]) + assert "verification" in by_id["S4"]["semantic_focus"] + assert any(item["heading"] == "验收标准" for item in by_id["S4"]["section_semantic_hints"]) + assert "acceptance-matrix.json" in by_id["S4"]["outputs"] + assert "acceptance-traceability" in by_id["S4"]["signals"] + assert any(item["target"] == "acceptance-matrix.json" for item in by_id["S4"]["validation"]) + assert "risk_review" in by_id["S1"]["semantic_focus"] + assert "risk-register.md" in by_id["S1"]["outputs"] + assert any(item["target"] == "risk-register.md" for item in by_id["S1"]["validation"]) + assert "verification" in by_id["S4"]["semantic_template_overrides"]["applied"] + assert requirement_ir["section_semantic_plan"]["section_count"] == 5 + + +def test_build_pm_intake_upgrades_standard_dag_when_semantic_families_are_heavy(): + router = _load_router() + consumer_text = """# RawIntent Consumer Request - semantic dag upgrade + +## Rewritten Objective + +把研究实现需求编译成更宽的并行交付 DAG。 + +## Problem + +需求同时包含接口、风险、非功能和验收章节,标准串行 DAG 太窄。 + +## Enhanced Requirement Design + +# 需求概述 + +这是一条研究实现链路,需要在进入实现前把接口、风险和验证分支准备好。 + +## 接口与数据契约 + +- 定义 IR 输入输出边界 +- 声明 contract 和 schema 兼容要求 + +## 风险与约束 + +- 不能破坏现有 requirement compiler 主链 +- 不能丢失 raw provenance + +## 非功能需求 + +- 需要可验证 +- 需要回归证据 + +## 验收标准 + +- 必须有 acceptance matrix +- 必须有 closeout decision + +## 功能需求 + +- 最终把增强需求编译进 DAG 和 PRD + +## Raw User Intent + +研究实现 一个语义驱动 DAG 升级链路。 +""" + payload = router.build_pm_intake(consumer_text, sprint_id="sprint-test", target_system="solar-harness") + requirement_ir = payload["requirement_ir"] + dag = payload["compiled_artifacts"]["task_dag"] + by_id = {node["id"]: node for node in dag["nodes"]} + assert payload["classification"] == router.FULL_SPEC + assert payload["dag_variant"] == "parallel_delivery" + assert dag["semantic_upgrade"]["enabled"] is True + assert dag["semantic_upgrade"]["mode"] == "section_family_parallel_delivery" + assert dag["quality_gates"]["parallelism"]["min_ready_width"] == 3 + source_nodes = [node["id"] for node in dag["nodes"] if not node["depends_on"]] + assert source_nodes == ["S1", "S2", "S3"] + assert by_id["S4"]["depends_on"] == ["S1", "S2", "S3"] + assert "interface_contract" in by_id["S1"]["semantic_focus"] + assert "risk_review" in by_id["S2"]["semantic_focus"] + assert "verification" in by_id["S3"]["semantic_focus"] + assert "quality" in by_id["S3"]["semantic_focus"] + assert "implementation" in by_id["S4"]["semantic_focus"] + assert "acceptance-matrix.json" in by_id["S5"]["outputs"] + assert requirement_ir["dag_view"]["semantic_upgrade"]["trigger_families"] == [ + "interface_contract", + "quality", + "risk_review", + "verification", + ] + + +def test_build_pm_intake_upgrades_research_dag_when_semantic_families_are_heavy(): + router = _load_router() + consumer_text = """# RawIntent Consumer Request - research semantic dag upgrade + +## Rewritten Objective + +把研究实现需求编译成更细粒度的 research DAG。 + +## Problem + +当前 research DAG 对接口、验证和实现含义的拆分不够细。 + +## Enhanced Requirement Design + +# 需求概述 + +需要基于论文研究,形成可落地的实现建议与验证闭环。 + +## 接口与数据契约 + +- 输出 IR、contract、task_graph 的边界 +- 明确接口兼容和数据约束 + +## 风险与约束 + +- 研究结论不能越过证据边界 +- 落地前必须显式暴露风险 + +## 验收标准 + +- 要有 adoption decision +- 要有 verifier closeout + +## 非功能需求 + +- 结果必须可验证 +- 结果必须留有证据链 + +## 功能需求 + +- 输出实现建议、PRD 含义和 DAG 含义 + +## Raw User Intent + +研究实现 某篇论文的系统设计并落地。 +""" + payload = router.build_pm_intake( + consumer_text, + papers=["paper-a"], + sprint_id="sprint-test", + target_system="solar-harness", + ) + dag = payload["compiled_artifacts"]["task_dag"] + by_id = {node["id"]: node for node in dag["nodes"]} + assert payload["classification"] == router.RESEARCH + assert payload["dag_variant"] == "research_parallel_implications" + assert dag["research_mode"] is True + assert dag["semantic_upgrade"]["enabled"] is True + assert dag["semantic_upgrade"]["mode"] == "section_family_research_parallel" + assert dag["quality_gates"]["parallelism"]["min_ready_width"] == 1 + assert by_id["R4"]["depends_on"] == ["R2", "R3"] + assert by_id["R5"]["depends_on"] == ["R2", "R3"] + assert by_id["R6"]["depends_on"] == ["R3", "R4", "R5"] + assert by_id["R7"]["depends_on"] == ["R4", "R5", "R6"] + assert by_id["R8"]["depends_on"] == ["R7"] + assert by_id["R5"]["logical_operator"] == "ResearchSynthesizer" + assert by_id["R6"]["logical_operator"] == "Critic" + assert by_id["R7"]["logical_operator"] == "Verifier" + assert by_id["R8"]["logical_operator"] == "ArtifactCurator" + assert "interface_contract" in by_id["R5"]["semantic_focus"] + assert "risk_review" in by_id["R6"]["semantic_focus"] + assert "verification" in by_id["R7"]["semantic_focus"] + assert "interface_implications.md" in by_id["R5"]["outputs"] + assert "research_risk_review.md" in by_id["R6"]["outputs"] + assert "research_verifier_decision.yaml" in by_id["R7"]["outputs"] + assert "final_prd_implications.md" in by_id["R8"]["outputs"] + + def test_validate_compiled_package_rejects_raw_metadata_pollution(): router = _load_router() payload = router.build_pm_intake("正常需求:补齐 requirement compiler 的 closeout gate。", sprint_id="sprint-test") diff --git a/harness/tests/test_intent_consumer.py b/harness/tests/test_intent_consumer.py index 038d888a9..49084efa8 100644 --- a/harness/tests/test_intent_consumer.py +++ b/harness/tests/test_intent_consumer.py @@ -7,6 +7,8 @@ ROOT = Path(__file__).resolve().parents[1] GATEWAY = ROOT / "lib" / "intent_gateway.py" CONSUMER = ROOT / "lib" / "intent_consumer.py" +sys.path.insert(0, str(ROOT / "lib")) +import intent_consumer # noqa: E402 def _env(tmp_path): @@ -164,7 +166,7 @@ def test_consumer_injects_research_artifact_refs_into_compiled_package(tmp_path) str(GATEWAY), "capture", "--text", - "通过 Browser Agent 前门研究后再编译 requirement package。", + "通过 Browser Agent 前门研究后,收口成统一 convergence package,并继续编译 requirement package、蓝图、追踪矩阵和 rollout。", "--source-channel", "pm_dispatch", "--source-trust", @@ -185,19 +187,50 @@ def test_consumer_injects_research_artifact_refs_into_compiled_package(tmp_path) check=True, ) intent_id = json.loads(cap.stdout)["intent_id"] - proc = subprocess.run( - [sys.executable, str(CONSUMER), "consume", "--intent-id", intent_id, "--json"], - text=True, - capture_output=True, - env=env, - check=True, + sprint_id = "sprint-test-research-artifact" + sprints = tmp_path / "sprints" + sprints.mkdir(parents=True, exist_ok=True) + intent_consumer.SPRINTS_DIR = sprints + (sprints / f"{sprint_id}.requirement_ir.json").write_text( + json.dumps({"schema_version": "solar.requirement_ir.v1", "source_inputs": {}}, ensure_ascii=False), + encoding="utf-8", ) - result = json.loads(proc.stdout)["results"][0] - sprint_id = result["sprint_id"] - ir = json.loads((tmp_path / "sprints" / f"{sprint_id}.requirement_ir.json").read_text()) - product_brief = (tmp_path / "sprints" / f"{sprint_id}.product-brief.md").read_text() - prd = (tmp_path / "sprints" / f"{sprint_id}.prd.md").read_text() + (sprints / f"{sprint_id}.product-brief.md").write_text("# Product Brief\n", encoding="utf-8") + (sprints / f"{sprint_id}.prd.md").write_text("# PRD\n", encoding="utf-8") + raw = json.loads((tmp_path / "intents" / intent_id / "raw_intent.json").read_text()) + ir = json.loads((tmp_path / "intents" / intent_id / "requirement_ir.json").read_text()) + research = intent_consumer.extract_research_artifact(raw, ir) + assert research is not None + intent_consumer.annotate_compiled_package_with_research_artifact(sprint_id, research) + + ir = json.loads((sprints / f"{sprint_id}.requirement_ir.json").read_text()) + product_brief = (sprints / f"{sprint_id}.product-brief.md").read_text() + prd = (sprints / f"{sprint_id}.prd.md").read_text() assert ir["source_inputs"]["research_artifact"]["path"] == "/tmp/frontdoor-research.json" assert "## Research Artifact Inputs" in product_brief assert "conv-frontdoor-002" in product_brief assert "## Research Artifact Inputs" in prd + + +def test_consumer_includes_enhanced_requirement_design_when_present(tmp_path): + env = _env(tmp_path) + fake = tmp_path / "fake_requirement_writer.py" + fake.write_text( + "import json, os\n" + "from pathlib import Path\n" + "request_dir=Path(os.environ['BROWSER_AGENT_REQUEST_DIR'])\n" + "request_dir.mkdir(parents=True, exist_ok=True)\n" + "(request_dir/'chatgpt-mode-state.json').write_text(json.dumps({'ok': True}), encoding='utf-8')\n" + "print('# 需求概述\\n\\n这是增强版章节化需求设计。')\n", + encoding="utf-8", + ) + env["SOLAR_GPT_REQUIREMENT_WRITER_CMD"] = f"{sys.executable} {fake}" + intent_id = _capture(env, text="研究实现 一个自动需求编译链路。", channel="pm_dispatch") + base = tmp_path / "intents" / intent_id + raw = json.loads((base / "raw_intent.json").read_text()) + rewritten = json.loads((base / "rewritten_intent.json").read_text()) + ir = json.loads((base / "requirement_ir.json").read_text()) + rendered = intent_consumer.build_consumer_text(raw, rewritten, ir) + assert "## Enhanced Requirement Design" in rendered + assert "这是增强版章节化需求设计" in rendered + assert "Requirement compiler should prefer the enhanced requirement design above as the compile input" in rendered diff --git a/harness/tests/test_intent_gateway.py b/harness/tests/test_intent_gateway.py index 7d101f89b..b20e7de5c 100644 --- a/harness/tests/test_intent_gateway.py +++ b/harness/tests/test_intent_gateway.py @@ -134,3 +134,115 @@ def test_capture_embeds_research_artifact_into_requirement_ir(tmp_path): assert raw["routing_hints"]["require_research_artifact"] is True assert raw["research"]["path"] == "/tmp/frontdoor-research.json" assert ir["source_inputs"]["research_artifact"]["conversation_id"] == "conv-frontdoor-001" + + +def test_capture_research_implementation_trigger_invokes_requirement_writer(tmp_path): + env = dict(os.environ) + env["SOLAR_INTENT_GATEWAY_DIR"] = str(tmp_path / "intents") + env["SOLAR_HARNESS_SPRINTS_DIR"] = str(tmp_path / "sprints") + fake = tmp_path / "fake_requirement_writer.py" + fake.write_text( + "import json, os, sys\n" + "from pathlib import Path\n" + "raw=os.environ.get('SOLAR_RAW_REQUIREMENT','').strip()\n" + "request_dir=Path(os.environ['BROWSER_AGENT_REQUEST_DIR'])\n" + "request_dir.mkdir(parents=True, exist_ok=True)\n" + "(request_dir/'chatgpt-mode-state.json').write_text(json.dumps({'ok': True}), encoding='utf-8')\n" + "print('# 需求概述\\n\\n基于原始需求扩写:' + raw)\n", + encoding="utf-8", + ) + env["SOLAR_GPT_REQUIREMENT_WRITER_CMD"] = f"{sys.executable} {fake}" + proc = subprocess.run( + [ + sys.executable, + str(SCRIPT), + "capture", + "--text", + "研究实现 一个基于论文分析并自动落地的需求编译链路。", + "--json", + ], + text=True, + capture_output=True, + env=env, + check=True, + ) + payload = json.loads(proc.stdout) + base = tmp_path / "intents" / payload["intent_id"] + rewritten = json.loads((base / "rewritten_intent.json").read_text()) + ir = json.loads((base / "requirement_ir.json").read_text()) + trace = json.loads((base / "requirement_trace.json").read_text()) + assert payload["requirement_enhancement"]["ok"] is True + assert rewritten["rewrite_method"] == "gpt_requirement_writer" + assert rewritten["requirement_enhancement"]["trigger_phrase"] == "研究实现" + assert ir["source_inputs"]["enhanced_requirement"]["operator"] == "GPTRequirementWriter" + assert "基于原始需求扩写" in ir["source_inputs"]["enhanced_requirement"]["content"] + assert ir["source_inputs"]["enhanced_requirement"]["sections"][0]["heading"] == "需求概述" + assert ir["source_inputs"]["enhanced_requirement"]["compile_segments"][0]["heading"] == "需求概述" + assert trace["stages"][1]["stage"] == "requirement_enhancement" + assert (base / "gpt_requirement_writer_output.md").exists() + + +def test_requirement_writer_trigger_phrases_are_configurable(tmp_path): + env = dict(os.environ) + env["SOLAR_INTENT_GATEWAY_DIR"] = str(tmp_path / "intents") + env["SOLAR_HARNESS_SPRINTS_DIR"] = str(tmp_path / "sprints") + env["SOLAR_GPT_REQUIREMENT_WRITER_TRIGGER_PHRASES"] = "实验实现,研究落地" + fake = tmp_path / "fake_requirement_writer.py" + fake.write_text( + "import json, os\n" + "from pathlib import Path\n" + "request_dir=Path(os.environ['BROWSER_AGENT_REQUEST_DIR'])\n" + "request_dir.mkdir(parents=True, exist_ok=True)\n" + "(request_dir/'chatgpt-mode-state.json').write_text(json.dumps({'ok': True}), encoding='utf-8')\n" + "print('# 需求概述\\n\\n配置化触发词命中。')\n", + encoding="utf-8", + ) + env["SOLAR_GPT_REQUIREMENT_WRITER_CMD"] = f"{sys.executable} {fake}" + proc = subprocess.run( + [sys.executable, str(SCRIPT), "capture", "--text", "实验实现 一个新链路。", "--json"], + text=True, + capture_output=True, + env=env, + check=True, + ) + payload = json.loads(proc.stdout) + base = tmp_path / "intents" / payload["intent_id"] + rewritten = json.loads((base / "rewritten_intent.json").read_text()) + assert rewritten["requirement_enhancement"]["trigger_phrase"] == "实验实现" + assert "实验实现" in rewritten["requirement_enhancement"]["configured_phrases"] + + +def test_bind_copies_requirement_writer_artifacts_when_present(tmp_path): + env = dict(os.environ) + env["SOLAR_INTENT_GATEWAY_DIR"] = str(tmp_path / "intents") + env["SOLAR_HARNESS_SPRINTS_DIR"] = str(tmp_path / "sprints") + fake = tmp_path / "fake_requirement_writer.py" + fake.write_text( + "import json, os\n" + "from pathlib import Path\n" + "request_dir=Path(os.environ['BROWSER_AGENT_REQUEST_DIR'])\n" + "request_dir.mkdir(parents=True, exist_ok=True)\n" + "(request_dir/'chatgpt-mode-state.json').write_text(json.dumps({'ok': True}), encoding='utf-8')\n" + "print('# 标题\\n\\n章节化需求设计')\n", + encoding="utf-8", + ) + env["SOLAR_GPT_REQUIREMENT_WRITER_CMD"] = f"{sys.executable} {fake}" + capture = subprocess.run( + [sys.executable, str(SCRIPT), "capture", "--text", "研究实现 一个新编译器。", "--json"], + text=True, + capture_output=True, + env=env, + check=True, + ) + intent_id = json.loads(capture.stdout)["intent_id"] + sprint_id = "sprint-20990101-000001" + subprocess.run( + [sys.executable, str(SCRIPT), "bind", "--intent-id", intent_id, "--sprint-id", sprint_id, "--json"], + text=True, + capture_output=True, + env=env, + check=True, + ) + sprints = tmp_path / "sprints" + assert (sprints / f"{sprint_id}.gpt_requirement_writer_output.json").exists() + assert (sprints / f"{sprint_id}.gpt_requirement_writer_output.md").exists() diff --git a/harness/tests/test_logical_operator_schema.py b/harness/tests/test_logical_operator_schema.py index 5e6c7b240..0c6089f0b 100644 --- a/harness/tests/test_logical_operator_schema.py +++ b/harness/tests/test_logical_operator_schema.py @@ -4,8 +4,8 @@ Sprint: sprint-20260523-lease-based-model-fleet-runtime / N2 Validates: - - Schema defines all 16 P0 logical operator types in the enum - - Fixture contains entries for all 16 types + - Schema defines all logical operator types in the enum + - Fixture contains entries for all logical types - Binding table maps every logical operator type to at least one candidate actor_id - All candidate actor_ids in bindings exist in agent-actors.json - DAG node schema accepts logical_operator @@ -47,6 +47,7 @@ "DeepResearchBrowser", "DeepResearchGemini", "DeepResearchChatGPT", + "GPTRequirementWriter", "WebwrightPlaywright", "BrowserUseMcp", "YoutubeTranscriptExtractor", @@ -76,17 +77,17 @@ def test_schema_defines_logical_operator_type_enum(self): "logical-operators schema missing $defs/logical_operator_type" ) - def test_enum_contains_all_16_types(self): + def test_enum_contains_all_declared_types(self): schema = _load_lo_schema() enum_vals = schema["$defs"]["logical_operator_type"].get("enum", []) missing = set(OPERATOR_TYPES) - set(enum_vals) assert not missing, f"logical_operator_type enum missing: {missing}" - def test_enum_has_exactly_24_entries(self): + def test_enum_has_expected_entries(self): schema = _load_lo_schema() enum_vals = schema["$defs"]["logical_operator_type"].get("enum", []) - assert len(enum_vals) == 24, ( - f"Expected 24 logical operator types, got {len(enum_vals)}: {enum_vals}" + assert len(enum_vals) == len(OPERATOR_TYPES), ( + f"Expected {len(OPERATOR_TYPES)} logical operator types, got {len(enum_vals)}: {enum_vals}" ) @pytest.mark.parametrize("op_type", OPERATOR_TYPES) diff --git a/harness/tests/test_tech_hotspot_browser_agent_cmd.py b/harness/tests/test_tech_hotspot_browser_agent_cmd.py index b0bb28cea..d0952778b 100644 --- a/harness/tests/test_tech_hotspot_browser_agent_cmd.py +++ b/harness/tests/test_tech_hotspot_browser_agent_cmd.py @@ -26,6 +26,34 @@ def test_browser_agent_chatgpt_cmd_falls_back_to_bundled_wrapper(monkeypatch): assert cmd[-1].endswith("chatgpt_report_operator.py") +def test_extract_json_payload_lenient_repairs_inner_quotes(): + ns = _load_namespace() + text = """{ +"section_id": "online-audio-interaction-models", +"title": "音频模型进入 always-on 在线交互范式", +"trend_description": "这说明音频模型可能从"语音入口"升级为"环境交互入口"。", +"evidence_ids": ["2606.05121", "pkt-effc138a62394585"] +}""" + payload = ns["extract_json_payload_lenient"](text) + assert payload["section_id"] == "online-audio-interaction-models" + assert payload["trend_description"] == '这说明音频模型可能从"语音入口"升级为"环境交互入口"。' + + +def test_extract_json_payload_lenient_repairs_embedded_array_quotes_in_string_fields(): + ns = _load_namespace() + text = """{ +"section_id": "person-grounded-agent-skills", +"title": "智能体技能从提示词包装走向经验蒸馏", +"trend_description": "核心判断一(evidence_ids: ["2605.31264", "pkt-859b5fc0299f42fb"]):COLLEAGUE.SKILL 指向的变化,是把智能体技能从“写一段提示词让模型完成任务”,推进到“从人的异构行为痕迹中蒸馏可行动知识”。", +"insight_analysis": "这条路线目前只能定为 watchlist。(evidence_ids: ["2605.31264", "pkt-859b5fc0299f42fb"])", +"evidence_ids": ["2605.31264", "pkt-859b5fc0299f42fb"] +}""" + payload = ns["extract_json_payload_lenient"](text) + assert payload["section_id"] == "person-grounded-agent-skills" + assert 'evidence_ids: ["2605.31264", "pkt-859b5fc0299f42fb"]' in payload["trend_description"] + assert payload["evidence_ids"] == ["2605.31264", "pkt-859b5fc0299f42fb"] + + def test_browser_agent_chatgpt_cmd_prefers_explicit_env(monkeypatch): monkeypatch.setenv("TECH_HOTSPOT_BROWSER_CHATGPT_CMD", "python3 /tmp/custom-wrapper.py") ns = _load_namespace() @@ -43,6 +71,16 @@ def test_browser_agent_notebooklm_cmd_falls_back_to_bundled_wrapper(monkeypatch) assert "browser-use/.venv/bin/python" in cmd[0] +def test_browser_agent_technology_diagram_cmd_falls_back_to_operator(monkeypatch): + monkeypatch.delenv("TECH_HOTSPOT_BROWSER_TECH_DIAGRAM_CMD", raising=False) + monkeypatch.delenv("BROWSER_AGENT_TECH_DIAGRAM_OPERATOR_CMD", raising=False) + monkeypatch.delenv("BROWSER_AGENT_TECH_DIAGRAM_CMD", raising=False) + ns = _load_namespace() + cmd = ns["browser_agent_technology_diagram_cmd"]({}) + assert cmd, "expected bundled technology diagram operator command" + assert cmd[-1].endswith("technology_diagram_painter_operator.py") + + def test_call_browser_agent_chatgpt_text_prefers_process_env_over_config(monkeypatch, tmp_path): wrapper = tmp_path / "fake_wrapper.py" wrapper.write_text( @@ -51,6 +89,8 @@ def test_call_browser_agent_chatgpt_text_prefers_process_env_over_config(monkeyp " 'profile_directory': os.environ.get('BROWSER_AGENT_PROFILE_DIRECTORY'),\n" " 'headless': os.environ.get('BROWSER_AGENT_HEADLESS'),\n" " 'account_email': os.environ.get('BROWSER_AGENT_TARGET_ACCOUNT_EMAIL'),\n" + " 'session_reuse': os.environ.get('BROWSER_AGENT_SESSION_REUSE'),\n" + " 'session_lineage': os.environ.get('BROWSER_AGENT_SESSION_LINEAGE'),\n" " 'pad': 'x' * 700\n" "}, ensure_ascii=False))\n", encoding="utf-8", @@ -59,6 +99,7 @@ def test_call_browser_agent_chatgpt_text_prefers_process_env_over_config(monkeyp monkeypatch.setenv("BROWSER_AGENT_PROFILE_DIRECTORY", "Default") monkeypatch.setenv("BROWSER_AGENT_HEADLESS", "true") monkeypatch.setenv("BROWSER_AGENT_TARGET_ACCOUNT_EMAIL", "browser-agent@example.com") + monkeypatch.setenv("BROWSER_AGENT_SESSION_CONTROL_DISABLED", "1") ns = _load_namespace() result = ns["call_browser_agent_chatgpt_text"]( "验证 env override", @@ -79,6 +120,33 @@ def test_call_browser_agent_chatgpt_text_prefers_process_env_over_config(monkeyp assert payload["profile_directory"] == "Default" assert payload["headless"] == "true" assert payload["account_email"] == "browser-agent@example.com" + assert payload["session_reuse"] == "true" + assert payload["session_lineage"] == "browser-agent:hf-headless-env-override" + + +def test_call_browser_agent_chatgpt_text_derives_report_level_session_lineage(monkeypatch, tmp_path): + wrapper = tmp_path / "fake_wrapper.py" + wrapper.write_text( + "import json, os\n" + "print(json.dumps({\n" + " 'session_reuse': os.environ.get('BROWSER_AGENT_SESSION_REUSE'),\n" + " 'session_lineage': os.environ.get('BROWSER_AGENT_SESSION_LINEAGE'),\n" + " 'pad': 'x' * 700\n" + "}, ensure_ascii=False))\n", + encoding="utf-8", + ) + monkeypatch.setenv("TECH_HOTSPOT_BROWSER_CHATGPT_CMD", f"{sys.executable} {wrapper}") + monkeypatch.setenv("BROWSER_AGENT_SESSION_CONTROL_DISABLED", "1") + ns = _load_namespace() + result = ns["call_browser_agent_chatgpt_text"]( + "验证章节级 lineage 归并到 report 级", + {"output": {"raw_dir": str(tmp_path)}, "youtube": {"phase_report_reasoner": {}}}, + purpose="ai-influence-report-chapter-2026-06-03-agent-memory-landscape-intro", + expected="json", + ) + payload = json.loads(result["text"]) + assert payload["session_reuse"] == "true" + assert payload["session_lineage"] == "ai-influence-report:2026-06-03:agent-memory-landscape" def test_hf_public_report_render_outputs_reader_facing_md_and_html(): @@ -272,6 +340,41 @@ def test_hf_write_public_report_prefers_grouped_flow_outputs(tmp_path): }, ], } + def _fake_figure_bundle(public_records, grouped_report, config, *, out_dir): + manifest_path = Path(out_dir) / "hf-paper-figures" / "hf-paper-figure-manifest.json" + manifest_path.parent.mkdir(parents=True, exist_ok=True) + manifest = { + "run_id": "hf-paper-figure-bundle", + "painted_count": 1, + "failed_count": 0, + "skipped_count": 0, + "validator_overall": "PASS", + "figures": [ + { + "figure_id": "fig_01", + "title": "本期 HF 结构总览", + "figure_type": "architecture_overview", + "placement": "report_lead", + "source_chapter_ids": ["doc-intel"], + "evidence_refs": ["p1", "pkt-1"], + "status": "painted", + "image_path": "/tmp/hf-figure-01.png", + "caption": "图 1:HF grouped report 结构总览。", + } + ], + } + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return { + "enabled": True, + "figures": manifest["figures"], + "manifest": manifest, + "manifest_path": str(manifest_path), + "figures_dir": str(manifest_path.parent), + "painted_count": 1, + "failed_count": 0, + "skipped_count": 0, + } + ns["hf_generate_grouped_report_figure_bundle"] = _fake_figure_bundle result = ns["hf_write_public_report"]( {"output": {"raw_dir": str(tmp_path)}}, date_str="2026-06-01", @@ -288,16 +391,166 @@ def test_hf_write_public_report_prefers_grouped_flow_outputs(tmp_path): pack = json.loads(Path(result["pack_json"]).read_text(encoding="utf-8")) assert "## 01. 文档智能自动化" in markdown assert "## 02. 基础模型接口化" in markdown + assert "## 关键图示" in markdown + assert "![本期 HF 结构总览](/tmp/hf-figure-01.png)" in markdown assert "### 该部分论文分工" in markdown assert "后续观察点" in markdown assert "" in html + assert "hf-figure-card" in html assert "文档智能自动化" in html assert "该部分论文分工" in html assert pack["grouped_report_ok"] is True + assert pack["figure_bundle_ok"] is True + assert pack["figure_bundle_painted_count"] == 1 assert pack["report_variant"] == "premium_insight_report" assert pack["grouped_report_plan"]["headline"] == "AI Influence HF Paper 高级洞察周报 — 2026-05-26 ~ 2026-06-01" assert pack["report_context"]["cadence"] == "weekly" assert len(pack["grouped_report_sections"]) == 2 + assert Path(result["figure_manifest_json"]).exists() + assert result["painted_figure_count"] == 1 + + +def test_hf_build_grouped_report_figure_specs_generates_grounded_specs(): + ns = _load_namespace() + public_records = [ + { + "paper_id": "p1", + "title": "MinerU2.5", + "taxonomy": {"research_route": "applied_research", "stack_layer": "inference"}, + }, + { + "paper_id": "p2", + "title": "Kronos", + "taxonomy": {"research_route": "model_system", "stack_layer": "foundation_model"}, + }, + ] + grouped_report = { + "plan": {"headline": "HF 周报标题"}, + "sections": [ + { + "section_id": "doc-intel", + "title": "文档智能自动化", + "trend_type": "real_trend", + "section_summary": "文档理解开始从 OCR 升级为工作流入口。", + "trend_description": "流程正在从解析走向编排。", + "insight_analysis": "关键是前置层和下游 agent 的连接方式。", + "planning_recommendations": ["做最小 PDF 解析基准"], + "paper_commentary": [{"paper_id": "p1", "title": "MinerU2.5"}], + "evidence_ids": ["p1", "pkt-1"], + }, + { + "section_id": "foundation-interfaces", + "title": "基础模型接口化", + "trend_type": "real_trend", + "section_summary": "时间序列基础模型开始争夺统一生态接口。", + "trend_description": "接口竞争会影响整个技术栈分层。", + "insight_analysis": "谁先形成 SDK 与 benchmark,谁更容易占位。", + "planning_recommendations": ["观察 benchmark 与 SDK 节奏"], + "paper_commentary": [{"paper_id": "p2", "title": "Kronos"}], + "evidence_ids": ["p2", "pkt-2"], + }, + ], + } + specs = ns["hf_build_grouped_report_figure_specs"](public_records, grouped_report, max_figures=3) + assert len(specs) >= 2 + assert specs[0].placement == "report_lead" + assert specs[0].evidence_refs + assert "Figure Type:" in specs[0].render_prompt + assert any(spec.placement == "section_inline" for spec in specs) + + +def test_hf_grouped_report_flow_batches_section_writer_calls(): + ns = _load_namespace() + public_records = [ + {"paper_id": "p1", "title": "Paper 1"}, + {"paper_id": "p2", "title": "Paper 2"}, + {"paper_id": "p3", "title": "Paper 3"}, + ] + plan = { + "headline": "HF Weekly", + "executive_summary": "summary", + "sections": [ + {"section_id": "s1", "title": "Section 1", "paper_ids": ["p1"]}, + {"section_id": "s2", "title": "Section 2", "paper_ids": ["p2"]}, + {"section_id": "s3", "title": "Section 3", "paper_ids": ["p3"]}, + ], + } + calls: list[str] = [] + + ns["hf_paper_high_reasoning_config"] = lambda config, requested_mode: ({"model": "chatgpt-5.5"}, "browser_agent") + ns["hf_build_report_planner_prompt"] = lambda *args, **kwargs: "planner" + ns["hf_normalize_report_plan"] = lambda raw_plan, public_records, date_str, report_context=None: plan + + def _fake_call(prompt, config, *, purpose, model_name, chapter_id, required_keys, max_attempts=2): + calls.append(purpose) + if purpose.startswith("hf-paper-report-plan-"): + return { + "headline": "HF Weekly", + "executive_summary": "summary", + "sections": plan["sections"], + } + if purpose.endswith("batch-01"): + return { + "sections": [ + { + "section_id": "s1", + "title": "Section 1", + "trend_type": "real_trend", + "section_summary": "summary 1", + "trend_description": "desc 1", + "insight_analysis": "analysis 1", + "planning_recommendations": ["next 1"], + "paper_commentary": [{"paper_id": "p1", "title": "Paper 1", "role": "role", "takeaway": "take", "evidence_ids": ["p1"]}], + "evidence_ids": ["p1"], + "evidence_gap": [], + }, + { + "section_id": "s2", + "title": "Section 2", + "trend_type": "real_trend", + "section_summary": "summary 2", + "trend_description": "desc 2", + "insight_analysis": "analysis 2", + "planning_recommendations": ["next 2"], + "paper_commentary": [{"paper_id": "p2", "title": "Paper 2", "role": "role", "takeaway": "take", "evidence_ids": ["p2"]}], + "evidence_ids": ["p2"], + "evidence_gap": [], + }, + ] + } + return { + "sections": [ + { + "section_id": "s3", + "title": "Section 3", + "trend_type": "watchlist", + "section_summary": "summary 3", + "trend_description": "desc 3", + "insight_analysis": "analysis 3", + "planning_recommendations": ["next 3"], + "paper_commentary": [{"paper_id": "p3", "title": "Paper 3", "role": "role", "takeaway": "take", "evidence_ids": ["p3"]}], + "evidence_ids": ["p3"], + "evidence_gap": [], + } + ] + } + + ns["hf_call_report_json_with_repair"] = _fake_call + + result = ns["hf_call_grouped_report_flow"]( + public_records, + {"hf_paper_insight": {"reporting": {"grouped_report_section_batch_size": 2}}}, + date_str="2026-06-05", + report_context={"window_label": "2026-05-29 ~ 2026-06-05"}, + ) + + assert result["ok"] is True + assert [item["section_id"] for item in result["sections"]] == ["s1", "s2", "s3"] + assert calls == [ + "hf-paper-report-plan-2026-06-05", + "hf-paper-report-sections-2026-06-05-batch-01", + "hf-paper-report-sections-2026-06-05-batch-02", + ] def test_hf_weekly_priority_score_prefers_persistent_high_rank_signals(): diff --git a/harness/tests/test_youtube_influence_digest_quality.py b/harness/tests/test_youtube_influence_digest_quality.py index af62ef55e..01a1d0eba 100644 --- a/harness/tests/test_youtube_influence_digest_quality.py +++ b/harness/tests/test_youtube_influence_digest_quality.py @@ -2,6 +2,7 @@ import sys from pathlib import Path +from unittest import mock ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "scripts")) @@ -46,3 +47,48 @@ def test_render_transcript_for_report_hides_t3_body(): ) rendered = yid.render_transcript_for_report(video) assert "质量门禁判定为 `T3`" in rendered + + +def test_maybe_write_browser_agent_report_respects_enabled_flag(tmp_path: Path): + video = yid.build_video( + _base_meta(), + "Today we build an AI agent demo with tools. The workflow shows automation and evaluation.", + "ok", + "browser_agent_operator:abc123xyz00", + {"analysis_keywords": {"agent": ["agent", "tools"]}}, + ) + disabled = yid.maybe_write_browser_agent_report( + [video], + config={"output": {"browser_agent_report": {"enabled": False}}}, + run_dir=tmp_path, + run_id="run-1", + dry_run=False, + ) + assert disabled["enabled"] is False + assert disabled["status"] == "disabled" + + +def test_write_markdown_attaches_browser_agent_report_when_enabled(tmp_path: Path): + video = yid.build_video( + _base_meta(), + "Today we build an AI agent demo with tools. The workflow shows automation and evaluation.", + "ok", + "browser_agent_operator:abc123xyz00", + {"analysis_keywords": {"agent": ["agent", "tools"]}}, + ) + config = { + "output": { + "raw_dir": str(tmp_path / "raw"), + "state_dir": str(tmp_path / "state"), + "browser_agent_report": {"enabled": True}, + } + } + with mock.patch.object( + yid, + "maybe_write_browser_agent_report", + return_value={"enabled": True, "ok": True, "status": "generated", "runtime_dir": str(tmp_path / "report")}, + ) as patched: + result = yid.write_markdown([video], [], config, dry_run=False) + assert patched.called + assert result["browser_agent_report"]["ok"] is True + assert Path(result["digest_path"]).exists() diff --git a/harness/tools/browser_agent_session_actor.py b/harness/tools/browser_agent_session_actor.py new file mode 100644 index 000000000..8b63ad2da --- /dev/null +++ b/harness/tools/browser_agent_session_actor.py @@ -0,0 +1,908 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import time +import subprocess +import sys +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT / "lib") not in sys.path: + sys.path.insert(0, str(ROOT / "lib")) + +from actor_lease import FINALIZING, LEASED, READY, RUNNING, LeaseBroker +from actor_mailbox import ActorMailbox +from browser_agent_session_pool import BrowserAgentSessionPool +from browser.profile_lease import ProfileLease + + +DEFAULT_ACTOR_ID = "browser_agent_session" +CHATGPT_TASK_OPERATOR = ROOT / "tools" / "chatgpt_browser_agent_task_operator.py" +CHATGPT_REQUIREMENT_WRITER = ROOT / "tools" / "chatgpt_requirement_writer_operator.py" +GEMINI_DEEP_RESEARCH_OPERATOR = ROOT / "tools" / "gemini_deep_research_operator.py" + + +def _now_iso() -> str: + import datetime as dt + + return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _current_harness_dir() -> Path: + return Path(os.environ.get("HARNESS_DIR") or (Path.home() / ".solar" / "harness")).expanduser() + + +def _task_dir(mailbox: ActorMailbox, envelope: dict[str, Any]) -> Path: + task_id = str(envelope.get("task_id") or "unknown") + task_dir = mailbox.logs / task_id + task_dir.mkdir(parents=True, exist_ok=True) + return task_dir + + +def _pool_size() -> int: + raw = str(os.environ.get("BROWSER_AGENT_SESSION_POOL_SIZE") or "2").strip() + try: + return max(1, int(raw)) + except Exception: + return 2 + + +def _supervisor_dir() -> Path: + path = _current_harness_dir() / "run" / "browser-agent-session-supervisor" + path.mkdir(parents=True, exist_ok=True) + return path + + +def _supervisor_pid_path(actor_id: str) -> Path: + return _supervisor_dir() / f"{actor_id}.pid" + + +def _supervisor_state_path(actor_id: str) -> Path: + return _supervisor_dir() / f"{actor_id}.json" + + +def _supervisor_stop_flag(actor_id: str) -> Path: + return _supervisor_dir() / f"{actor_id}.stop" + + +def _supervisor_drain_flag(actor_id: str) -> Path: + return _supervisor_dir() / f"{actor_id}.drain" + + +def _pid_alive(pid: int) -> bool: + if pid <= 0: + return False + try: + os.kill(pid, 0) + except OSError: + return False + try: + proc = subprocess.run( + ["ps", "-p", str(pid), "-o", "stat=", "-o", "command="], + text=True, + capture_output=True, + timeout=2, + ) + line = str(proc.stdout or "").strip() + if not line: + return False + parts = line.split(None, 1) + stat = parts[0] if parts else "" + command = parts[1] if len(parts) > 1 else "" + if "Z" in stat or "" in command: + return False + except Exception: + pass + return True + + +def _read_supervisor_pid(actor_id: str) -> int | None: + path = _supervisor_pid_path(actor_id) + if not path.exists(): + return None + try: + return int(path.read_text(encoding="utf-8").strip()) + except Exception: + return None + + +def _write_supervisor_state(actor_id: str, payload: dict[str, Any]) -> None: + path = _supervisor_state_path(actor_id) + tmp = path.with_suffix(".json.tmp") + tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + tmp.replace(path) + + +def ensure_supervisor_running( + *, + actor_id: str, + mailbox_base: Path, + lease_dir: Path, + poll_interval_seconds: float = 2.0, +) -> dict[str, Any]: + existing_pid = _read_supervisor_pid(actor_id) + if existing_pid and _pid_alive(existing_pid): + return {"ok": True, "pid": existing_pid, "reused": True} + if existing_pid: + _supervisor_pid_path(actor_id).unlink(missing_ok=True) + cmd = [ + sys.executable, + str(Path(__file__).resolve()), + "--actor-id", + str(actor_id), + "--mailbox-base", + str(mailbox_base), + "--lease-dir", + str(lease_dir), + "--supervise", + "--poll-interval-seconds", + str(poll_interval_seconds), + ] + proc = subprocess.Popen( + cmd, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + env={**os.environ, "HARNESS_DIR": str(_current_harness_dir())}, + ) + _supervisor_pid_path(actor_id).write_text(str(proc.pid), encoding="utf-8") + _write_supervisor_state( + actor_id, + { + "actor_id": actor_id, + "pid": int(proc.pid), + "status": "starting", + "started_at": _now_iso(), + "poll_interval_seconds": poll_interval_seconds, + }, + ) + return {"ok": True, "pid": int(proc.pid), "reused": False} + + +def request_supervisor_drain(actor_id: str) -> Path: + path = _supervisor_drain_flag(actor_id) + path.write_text(_now_iso() + "\n", encoding="utf-8") + return path + + +def request_supervisor_stop(actor_id: str) -> Path: + path = _supervisor_stop_flag(actor_id) + path.write_text(_now_iso() + "\n", encoding="utf-8") + return path + + +def supervisor_status(actor_id: str, *, mailbox_base: Path) -> dict[str, Any]: + mailbox = ActorMailbox(actor_id, mailbox_base) + slots = BrowserAgentSessionPool( + _current_harness_dir() / "run" / "browser-agent-session-pool", + pool_size=_pool_size(), + ).list_slots() + state_path = _supervisor_state_path(actor_id) + state = {} + if state_path.exists(): + try: + data = json.loads(state_path.read_text(encoding="utf-8")) + if isinstance(data, dict): + state = data + except Exception: + state = {} + pid = _read_supervisor_pid(actor_id) + return { + "ok": True, + "actor_id": actor_id, + "pid": pid, + "pid_alive": _pid_alive(pid or 0), + "state_file": str(state_path), + "state": state, + "heartbeat": mailbox.read_heartbeat() or {}, + "active_runs": _iter_active_runs(), + "slot_count": len(slots), + "slots": slots, + "drain_requested": _supervisor_drain_flag(actor_id).exists(), + "stop_requested": _supervisor_stop_flag(actor_id).exists(), + } + + +def _active_runs_dir() -> Path: + path = _current_harness_dir() / "run" / "browser-agent-session-active" / "chatgpt" + path.mkdir(parents=True, exist_ok=True) + return path + + +def _active_run_path(task_id: str) -> Path: + return _active_runs_dir() / f"{task_id}.json" + + +def _collect_backoff_seconds(attempts: int, status: str) -> float: + clean_status = str(status or "").strip().lower() + count = max(int(attempts or 0), 0) + if clean_status == "submitted": + return min(18.0, 4.0 * max(1, min(count + 1, 4))) + return min(24.0, 6.0 * max(1, min(count + 1, 4))) + + +def _schedule_next_collect(manifest: dict[str, Any], *, status: str) -> None: + attempts = int(manifest.get("collect_attempts") or 0) + manifest["next_collect_after_ts"] = time.time() + _collect_backoff_seconds(attempts, status) + + +def _collect_due(manifest: dict[str, Any]) -> bool: + try: + due_at = float(manifest.get("next_collect_after_ts") or 0.0) + except Exception: + due_at = 0.0 + return due_at <= 0.0 or time.time() >= due_at + + +def _ensure_collect_backoff_window(manifest: dict[str, Any]) -> bool: + try: + due_at = float(manifest.get("next_collect_after_ts") or 0.0) + except Exception: + due_at = 0.0 + if due_at > 0.0: + return False + _schedule_next_collect(manifest, status=str(manifest.get("status") or "running")) + manifest["updated_at"] = _now_iso() + return True + + +def _write_active_run(manifest: dict[str, Any]) -> Path: + path = _active_run_path(str(manifest["task_id"])) + tmp = path.with_suffix(".json.tmp") + tmp.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + tmp.replace(path) + return path + + +def _delete_active_run(task_id: str) -> None: + _active_run_path(task_id).unlink(missing_ok=True) + + +def _iter_active_runs() -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + for path in sorted(_active_runs_dir().glob("*.json")): + try: + data = json.loads(path.read_text(encoding="utf-8")) + if isinstance(data, dict): + items.append(data) + except Exception: + continue + return items + + +def _force_lease_ready(broker: LeaseBroker, actor_id: str) -> str: + lease = broker.get(actor_id) + if not lease: + return "" + task_id = str(lease.task_id or "") + state = str(lease.state or "") + if state == READY: + return task_id + if state == LEASED: + broker.transition(actor_id, READY) + return task_id + if state == RUNNING: + broker.transition(actor_id, FINALIZING) + broker.transition(actor_id, READY) + return task_id + if state == FINALIZING: + broker.transition(actor_id, READY) + return task_id + if state in {"STALE", "CRASHED", "DRAINING", "QUOTA_BLOCKED", "AUTH_BLOCKED", "POLICY_BLOCKED", "HUMAN_REQUIRED", "DISABLED"}: + broker.transition(actor_id, READY) + return task_id + return task_id + + +def _release_profile_lease_from_request_dir(request_dir: str | Path | None) -> dict[str, Any] | None: + if not request_dir: + return None + runtime_path = Path(request_dir).expanduser() / "runtime.json" + if not runtime_path.exists(): + return None + try: + runtime = json.loads(runtime_path.read_text(encoding="utf-8")) + except Exception: + return None + if not isinstance(runtime, dict): + return None + lease = runtime.get("lease") if isinstance(runtime.get("lease"), dict) else {} + profile_id = str(runtime.get("profile_id") or lease.get("profile_id") or "").strip() + task_id = str(lease.get("task_id") or "").strip() + if not profile_id or not task_id: + return None + return ProfileLease().release(profile_id, task_id) + + +def _release_profile_lease_from_task_dir(task_dir: str | Path | None) -> dict[str, Any] | None: + if not task_dir: + return None + base = Path(task_dir).expanduser() + request_path = base / "chatgpt-browser-agent-request.json" + if not request_path.exists(): + return None + try: + request = json.loads(request_path.read_text(encoding="utf-8")) + except Exception: + return None + if not isinstance(request, dict): + return None + return _release_profile_lease_from_request_dir(request.get("request_dir")) + + +def recover_stale_supervisor_runtime( + *, + actor_id: str, + mailbox_base: Path, + lease_dir: Path, +) -> dict[str, Any]: + pid = _read_supervisor_pid(actor_id) + if pid and _pid_alive(pid): + return {"ok": True, "recovered": False, "reason": "supervisor_alive", "pid": pid} + + mailbox = ActorMailbox(actor_id, mailbox_base) + mailbox.ensure_dirs() + broker = LeaseBroker(lease_dir) + pool = BrowserAgentSessionPool(_current_harness_dir() / "run" / "browser-agent-session-pool", pool_size=_pool_size()) + recovered_task_ids: set[str] = set() + + _supervisor_pid_path(actor_id).unlink(missing_ok=True) + _supervisor_stop_flag(actor_id).unlink(missing_ok=True) + _supervisor_drain_flag(actor_id).unlink(missing_ok=True) + + lease_task_id = _force_lease_ready(broker, actor_id) + if lease_task_id: + recovered_task_ids.add(lease_task_id) + + for manifest in _iter_active_runs(): + task_id = str(manifest.get("task_id") or "").strip() + request = manifest.get("request") if isinstance(manifest.get("request"), dict) else {} + _release_profile_lease_from_request_dir(request.get("request_dir")) + if task_id: + recovered_task_ids.add(task_id) + _delete_active_run(task_id) + + for slot in pool.list_slots(): + task_id = str(slot.get("assigned_task_id") or "").strip() + _release_profile_lease_from_task_dir(slot.get("assigned_request_dir")) + if task_id: + recovered_task_ids.add(task_id) + if str(slot.get("state") or "") != "idle" or task_id: + pool.release_slot(str(slot.get("slot_id") or ""), keep_warm=False) + + for task_id in sorted(recovered_task_ids): + for task_file in mailbox.inbox.glob(f"task-{task_id}-*.json"): + task_file.unlink(missing_ok=True) + mailbox.write_result( + task_id, + { + "task_id": task_id, + "actor_id": actor_id, + "status": "failed", + "returncode": 1, + "error": "stale_supervisor_runtime_recovered", + "completed_at": _now_iso(), + }, + ) + + mailbox.write_heartbeat( + "idle", + { + "reason": "stale_supervisor_runtime_recovered", + "recovered_task_count": len(recovered_task_ids), + }, + ) + _write_supervisor_state( + actor_id, + { + "actor_id": actor_id, + "pid": None, + "status": "recovered_stale_runtime", + "recovered_at": _now_iso(), + "recovered_task_count": len(recovered_task_ids), + }, + ) + return { + "ok": True, + "recovered": True, + "pid": pid, + "recovered_task_ids": sorted(recovered_task_ids), + } + + +def _request_field(logical_operator: str) -> str: + if logical_operator == "DeepResearchGemini": + return "gemini_deep_research_request" + return "chatgpt_browser_agent_request" + + +def _result_file_name(logical_operator: str) -> str: + if logical_operator == "DeepResearchGemini": + return "gemini-deep-research-result.json" + return "chatgpt-browser-agent-result.json" + + +def _load_operator_result(task_dir: Path, logical_operator: str) -> dict[str, Any]: + path = task_dir / _result_file_name(logical_operator) + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def _parse_json_text(text: str) -> dict[str, Any] | None: + try: + data = json.loads(str(text or "").strip()) + except Exception: + return None + return data if isinstance(data, dict) else None + + +def resolve_command(envelope: dict[str, Any]) -> list[str]: + override = envelope.get("command") + if override: + if isinstance(override, list): + return [str(item) for item in override] + return ["bash", "-lc", str(override)] + + logical_operator = str(envelope.get("logical_operator") or "").strip() + if logical_operator in {"DeepResearchBrowser", "DeepResearchChatGPT"}: + return [sys.executable, str(CHATGPT_TASK_OPERATOR)] + if logical_operator == "GPTRequirementWriter": + return [sys.executable, str(CHATGPT_REQUIREMENT_WRITER)] + if logical_operator == "DeepResearchGemini": + return [sys.executable, str(GEMINI_DEEP_RESEARCH_OPERATOR)] + raise RuntimeError(f"unsupported_browser_agent_session_logical_operator:{logical_operator or 'N/A'}") + + +def _result_payload( + *, + actor_id: str, + envelope: dict[str, Any], + task_dir: Path, + status: str, + returncode: int, + error: str = "", + slot: dict[str, Any] | None = None, +) -> dict[str, Any]: + task_id = str(envelope.get("task_id") or "") + payload = { + "task_id": task_id, + "actor_id": actor_id, + "logical_operator": str(envelope.get("logical_operator") or ""), + "status": status, + "returncode": returncode, + "task_dir": str(task_dir), + "completed_at": _now_iso(), + } + if error: + payload["error"] = error + if slot: + payload["pool_slot_id"] = str(slot.get("slot_id") or "") + payload["pool_session_lineage"] = str(slot.get("session_lineage") or "") + logical_operator = str(envelope.get("logical_operator") or "") + result_file = task_dir / _result_file_name(logical_operator) + if result_file.exists(): + payload["result_file"] = str(result_file) + try: + result_json = json.loads(result_file.read_text(encoding="utf-8")) + payload["request_dir"] = str(result_json.get("request_dir") or "") + payload["expected_output"] = str(result_json.get("expected_output") or "") + payload["project_name"] = str(result_json.get("project_name") or "") + except Exception: + pass + req_writer_file = task_dir / "gpt-requirement-writer-output.md" + if req_writer_file.exists(): + payload["artifact_file"] = str(req_writer_file) + return payload + + +def _active_run_manifest( + *, + envelope: dict[str, Any], + slot: dict[str, Any], + task_dir: Path, + request_dir: str, + submit_status: dict[str, Any], +) -> dict[str, Any]: + request = dict(envelope.get(_request_field(str(envelope.get("logical_operator") or ""))) or {}) + request["request_dir"] = request_dir + request["action"] = "submit" + manifest = { + "task_id": str(envelope.get("task_id") or ""), + "logical_operator": str(envelope.get("logical_operator") or ""), + "task_dir": str(task_dir), + "request": request, + "slot": { + "slot_id": str(slot.get("slot_id") or ""), + "session_lineage": str(slot.get("session_lineage") or ""), + }, + "conversation_url": str(submit_status.get("url") or ""), + "conversation_id": str(submit_status.get("conversation_id") or ""), + "status": str(submit_status.get("status") or "submitted"), + "submitted_at": str(submit_status.get("submitted_at") or _now_iso()), + "updated_at": _now_iso(), + "collect_attempts": 0, + } + _schedule_next_collect(manifest, status=str(manifest.get("status") or "submitted")) + return manifest + + +def process_task_file( + *, + actor_id: str, + mailbox: ActorMailbox, + broker: LeaseBroker, + task_file: Path, +) -> dict[str, Any]: + envelope = json.loads(task_file.read_text(encoding="utf-8")) + task_dir = _task_dir(mailbox, envelope) + (task_dir / "envelope.json").write_text(json.dumps(envelope, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + broker.transition(actor_id, RUNNING) + mailbox.write_heartbeat("running", {"task_id": envelope.get("task_id"), "logical_operator": envelope.get("logical_operator")}) + pool = BrowserAgentSessionPool(_current_harness_dir() / "run" / "browser-agent-session-pool", pool_size=_pool_size()) + logical_operator = str(envelope.get("logical_operator") or "") + request_field = _request_field(logical_operator) + request_lineage = str( + envelope.get("session_lineage") + or envelope.get(request_field, {}).get("session_lineage") + or envelope.get("purpose") + or envelope.get("objective") + or "" + ).strip() + slot = pool.acquire_slot( + task_id=str(envelope.get("task_id") or ""), + request_lineage=request_lineage, + request_dir=str(task_dir), + ) + (task_dir / "browser-agent-session-slot.json").write_text( + json.dumps(slot, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + request = dict(envelope.get(request_field) or {}) + action = str(request.get("action") or "run").strip().lower() + try: + command = resolve_command(envelope) + env = os.environ.copy() + env["HARNESS_DIR"] = str(_current_harness_dir()) + env["TASK_DIR"] = str(task_dir) + env["SOLAR_OPERATOR_ENVELOPE_JSON"] = str(task_dir / "envelope.json") + env["BROWSER_AGENT_SESSION_REUSE"] = "true" + env["SOLAR_BROWSER_SESSION_REUSE"] = "true" + env["BROWSER_AGENT_SESSION_LINEAGE"] = str(slot.get("session_lineage") or "") + env["SOLAR_BROWSER_SESSION_LINEAGE"] = str(slot.get("session_lineage") or "") + env["BROWSER_AGENT_POOL_SLOT_ID"] = str(slot.get("slot_id") or "") + env["BROWSER_AGENT_SESSION_CONTROL_DISABLED"] = "1" + proc = subprocess.run( + command, + text=True, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=env, + ) + combined = str(proc.stdout or "") + (task_dir / "output.log").write_text(combined, encoding="utf-8") + broker.transition(actor_id, FINALIZING) + + operator_result = _load_operator_result(task_dir, logical_operator) + operator_text = str(operator_result.get("text") or "").strip() + + if proc.returncode == 0: + if action == "submit": + submit_status = _parse_json_text(operator_text) + if not submit_status: + raise RuntimeError("browser_agent_session_submit_missing_json_status") + manifest = _active_run_manifest( + envelope=envelope, + slot=slot, + task_dir=task_dir, + request_dir=str(operator_result.get("request_dir") or ""), + submit_status=submit_status, + ) + _write_active_run(manifest) + payload = _result_payload( + actor_id=actor_id, + envelope=envelope, + task_dir=task_dir, + status=str(submit_status.get("status") or "submitted"), + returncode=0, + slot=slot, + ) + payload["active_run_manifest"] = str(_active_run_path(str(envelope.get("task_id") or ""))) + payload["active_run_created"] = True + mailbox.write_result(str(envelope.get("task_id") or ""), payload) + broker.transition(actor_id, READY) + mailbox.write_heartbeat("idle", {"task_id": envelope.get("task_id"), "status": "submitted"}) + task_file.unlink(missing_ok=True) + return payload + payload = _result_payload( + actor_id=actor_id, + envelope=envelope, + task_dir=task_dir, + status="completed", + returncode=0, + slot=slot, + ) + mailbox.write_result(str(envelope.get("task_id") or ""), payload) + broker.transition(actor_id, READY) + mailbox.write_heartbeat("idle", {"task_id": envelope.get("task_id"), "status": "completed"}) + pool.release_slot(str(slot.get("slot_id") or ""), keep_warm=True) + task_file.unlink(missing_ok=True) + return payload + + payload = _result_payload( + actor_id=actor_id, + envelope=envelope, + task_dir=task_dir, + status="failed", + returncode=proc.returncode, + error=combined[-2000:], + slot=slot, + ) + mailbox.write_result(str(envelope.get("task_id") or ""), payload) + broker.transition(actor_id, READY) + mailbox.write_heartbeat("idle", {"task_id": envelope.get("task_id"), "status": "failed"}) + pool.release_slot(str(slot.get("slot_id") or ""), keep_warm=False) + task_file.unlink(missing_ok=True) + return payload + except Exception as exc: + broker.transition(actor_id, FINALIZING) + payload = _result_payload( + actor_id=actor_id, + envelope=envelope, + task_dir=task_dir, + status="failed", + returncode=1, + error=f"{type(exc).__name__}:{exc}", + slot=slot, + ) + mailbox.write_result(str(envelope.get("task_id") or ""), payload) + broker.transition(actor_id, READY) + mailbox.write_heartbeat("idle", {"task_id": envelope.get("task_id"), "status": "failed"}) + pool.release_slot(str(slot.get("slot_id") or ""), keep_warm=False) + task_file.unlink(missing_ok=True) + return payload + + +def process_active_runs_once( + *, + actor_id: str, + mailbox: ActorMailbox, + broker: LeaseBroker, + skip_task_ids: set[str] | None = None, +) -> int: + processed = 0 + skip_task_ids = skip_task_ids or set() + for manifest in _iter_active_runs(): + task_id = str(manifest.get("task_id") or "").strip() + if not task_id: + continue + if task_id in skip_task_ids: + continue + if _ensure_collect_backoff_window(manifest): + _write_active_run(manifest) + continue + if not _collect_due(manifest): + continue + task_dir = Path(str(manifest.get("task_dir") or "")).expanduser() + envelope_path = task_dir / "envelope.json" + if not envelope_path.exists(): + continue + envelope = json.loads(envelope_path.read_text(encoding="utf-8")) + logical_operator = str(envelope.get("logical_operator") or "") + request_field = _request_field(logical_operator) + request = dict(manifest.get("request") or {}) + request["action"] = "collect" + request["conversation_url"] = str(manifest.get("conversation_url") or "") + request["request_dir"] = str(manifest.get("request_dir") or request.get("request_dir") or "") + request.setdefault("prompt", "") + envelope[request_field] = request + envelope_path.write_text(json.dumps(envelope, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + broker.transition(actor_id, RUNNING) + mailbox.write_heartbeat("running", {"task_id": task_id, "mode": "collect"}) + command = resolve_command(envelope) + slot = dict(manifest.get("slot") or {}) + env = os.environ.copy() + env["HARNESS_DIR"] = str(_current_harness_dir()) + env["TASK_DIR"] = str(task_dir) + env["SOLAR_OPERATOR_ENVELOPE_JSON"] = str(envelope_path) + env["BROWSER_AGENT_SESSION_REUSE"] = "true" + env["SOLAR_BROWSER_SESSION_REUSE"] = "true" + env["BROWSER_AGENT_SESSION_LINEAGE"] = str(slot.get("session_lineage") or "") + env["SOLAR_BROWSER_SESSION_LINEAGE"] = str(slot.get("session_lineage") or "") + env["BROWSER_AGENT_POOL_SLOT_ID"] = str(slot.get("slot_id") or "") + env["BROWSER_AGENT_SESSION_CONTROL_DISABLED"] = "1" + proc = subprocess.run( + command, + text=True, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=env, + ) + combined = str(proc.stdout or "") + (task_dir / "collect-output.log").write_text(combined, encoding="utf-8") + broker.transition(actor_id, FINALIZING) + operator_result = _load_operator_result(task_dir, logical_operator) + operator_text = str(operator_result.get("text") or "").strip() + status_json = _parse_json_text(operator_text) + if proc.returncode == 0 and status_json and str(status_json.get("status") or "").strip().lower() in {"running", "submitted"}: + manifest["status"] = str(status_json.get("status") or manifest.get("status") or "running") + manifest["updated_at"] = _now_iso() + manifest["collect_attempts"] = int(manifest.get("collect_attempts") or 0) + 1 + _schedule_next_collect(manifest, status=str(manifest.get("status") or "running")) + _write_active_run(manifest) + broker.transition(actor_id, READY) + mailbox.write_heartbeat("idle", {"task_id": task_id, "status": manifest["status"]}) + processed += 1 + continue + if proc.returncode == 0: + payload = _result_payload( + actor_id=actor_id, + envelope=envelope, + task_dir=task_dir, + status="completed", + returncode=0, + slot=slot, + ) + mailbox.write_result(task_id, payload) + BrowserAgentSessionPool(_current_harness_dir() / "run" / "browser-agent-session-pool", pool_size=_pool_size()).release_slot( + str(slot.get("slot_id") or ""), + keep_warm=True, + ) + _delete_active_run(task_id) + broker.transition(actor_id, READY) + mailbox.write_heartbeat("idle", {"task_id": task_id, "status": "completed"}) + processed += 1 + continue + payload = _result_payload( + actor_id=actor_id, + envelope=envelope, + task_dir=task_dir, + status="failed", + returncode=proc.returncode, + error=combined[-2000:], + slot=slot, + ) + mailbox.write_result(task_id, payload) + BrowserAgentSessionPool(_current_harness_dir() / "run" / "browser-agent-session-pool", pool_size=_pool_size()).release_slot( + str(slot.get("slot_id") or ""), + keep_warm=False, + ) + _delete_active_run(task_id) + broker.transition(actor_id, READY) + mailbox.write_heartbeat("idle", {"task_id": task_id, "status": "failed"}) + processed += 1 + return processed + + +def drain_once(*, actor_id: str, mailbox_base: Path, lease_dir: Path) -> int: + mailbox = ActorMailbox(actor_id, mailbox_base) + broker = LeaseBroker(lease_dir) + mailbox.ensure_dirs() + task_files = sorted(mailbox.inbox.glob("task-*.json")) + processed = 0 + submitted_task_ids: set[str] = set() + for task_file in task_files: + payload = process_task_file(actor_id=actor_id, mailbox=mailbox, broker=broker, task_file=task_file) + if payload.get("active_run_created"): + submitted_task_ids.add(str(payload.get("task_id") or "")) + processed += 1 + processed += process_active_runs_once( + actor_id=actor_id, + mailbox=mailbox, + broker=broker, + skip_task_ids=submitted_task_ids, + ) + mailbox.write_heartbeat("idle", {"processed": processed}) + return 0 + + +def supervise_loop( + *, + actor_id: str, + mailbox_base: Path, + lease_dir: Path, + poll_interval_seconds: float = 2.0, + max_loops: int = 0, +) -> int: + mailbox = ActorMailbox(actor_id, mailbox_base) + mailbox.ensure_dirs() + BrowserAgentSessionPool(_current_harness_dir() / "run" / "browser-agent-session-pool", pool_size=_pool_size()).ensure_slots() + _supervisor_pid_path(actor_id).write_text(str(os.getpid()), encoding="utf-8") + _supervisor_stop_flag(actor_id).unlink(missing_ok=True) + _supervisor_drain_flag(actor_id).unlink(missing_ok=True) + loops = 0 + while True: + if _supervisor_stop_flag(actor_id).exists(): + break + processed = drain_once(actor_id=actor_id, mailbox_base=mailbox_base, lease_dir=lease_dir) + slots = BrowserAgentSessionPool( + _current_harness_dir() / "run" / "browser-agent-session-pool", + pool_size=_pool_size(), + ).list_slots() + active_runs = len(_iter_active_runs()) + state = { + "actor_id": actor_id, + "pid": int(os.getpid()), + "status": "running", + "heartbeat_at": _now_iso(), + "poll_interval_seconds": poll_interval_seconds, + "active_run_count": active_runs, + "slot_count": len(slots), + "warm_slot_count": sum(1 for slot in slots if bool(slot.get("warm"))), + "running_slot_count": sum(1 for slot in slots if str(slot.get("state") or "") == "running"), + "loop_count": loops + 1, + "last_processed": processed, + } + _write_supervisor_state(actor_id, state) + mailbox.write_heartbeat( + "supervising", + { + "active_run_count": active_runs, + "slot_count": len(slots), + "warm_slot_count": state["warm_slot_count"], + "running_slot_count": state["running_slot_count"], + "loop_count": state["loop_count"], + }, + ) + loops += 1 + inbox_count = len(list(mailbox.inbox.glob("task-*.json"))) + if _supervisor_drain_flag(actor_id).exists() and inbox_count == 0 and active_runs == 0: + break + if max_loops > 0 and loops >= max_loops: + break + time.sleep(max(0.2, float(poll_interval_seconds))) + _supervisor_pid_path(actor_id).unlink(missing_ok=True) + _supervisor_stop_flag(actor_id).unlink(missing_ok=True) + _supervisor_drain_flag(actor_id).unlink(missing_ok=True) + _write_supervisor_state( + actor_id, + { + "actor_id": actor_id, + "pid": int(os.getpid()), + "status": "stopped", + "heartbeat_at": _now_iso(), + "loop_count": loops, + }, + ) + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Process browser_agent_session actor mailbox tasks") + parser.add_argument("--actor-id", default=DEFAULT_ACTOR_ID) + default_harness_dir = _current_harness_dir() + parser.add_argument("--mailbox-base", default=str(default_harness_dir / "actors")) + parser.add_argument("--lease-dir", default=str(default_harness_dir / "run" / "actor-leases")) + parser.add_argument("--once", action="store_true") + parser.add_argument("--supervise", action="store_true") + parser.add_argument("--poll-interval-seconds", type=float, default=2.0) + parser.add_argument("--max-loops", type=int, default=0) + args = parser.parse_args(argv) + + mailbox_base = Path(str(args.mailbox_base)).expanduser() + lease_dir = Path(str(args.lease_dir)).expanduser() + if args.supervise: + return supervise_loop( + actor_id=str(args.actor_id), + mailbox_base=mailbox_base, + lease_dir=lease_dir, + poll_interval_seconds=float(args.poll_interval_seconds), + max_loops=int(args.max_loops), + ) + return drain_once(actor_id=str(args.actor_id), mailbox_base=mailbox_base, lease_dir=lease_dir) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/tools/browser_agent_session_control.py b/harness/tools/browser_agent_session_control.py new file mode 100644 index 000000000..c7bf2764b --- /dev/null +++ b/harness/tools/browser_agent_session_control.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import signal +import sys +import time +import uuid +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT / "lib") not in sys.path: + sys.path.insert(0, str(ROOT / "lib")) +if str(ROOT / "tools") not in sys.path: + sys.path.insert(0, str(ROOT / "tools")) + +from actor_mailbox import ActorMailbox # noqa: E402 +from actor_runtime import ActorRuntime # noqa: E402 +from browser_agent_session_actor import ( # noqa: E402 + DEFAULT_ACTOR_ID, + ensure_supervisor_running, + request_supervisor_drain, + request_supervisor_stop, + supervisor_status, +) + + +def _json_dump(payload: object) -> None: + print(json.dumps(payload, ensure_ascii=False, indent=2)) + + +def _harness_dir() -> Path: + return Path(os.environ.get("HARNESS_DIR") or (Path.home() / ".solar" / "harness")).expanduser() + + +def _runtime() -> ActorRuntime: + return ActorRuntime(harness_dir=_harness_dir()) + + +def _mailbox(actor_id: str) -> ActorMailbox: + return ActorMailbox(actor_id, _harness_dir() / "actors") + + +def _task_id(prefix: str = "browser-agent-session") -> str: + return f"{prefix}-{uuid.uuid4().hex[:12]}" + + +def _collect_poll_sleep_seconds(base_interval: float, attempt: int, status: str) -> float: + clean_status = str(status or "").strip().lower() + base = max(0.2, float(base_interval or 0.2)) + if clean_status in {"completed", "failed"}: + return base + multiplier = min(max(int(attempt or 1), 1), 4) + if clean_status == "submitted": + return min(8.0, base * multiplier) + return min(12.0, base * max(2, multiplier)) + + +def _find_inbox_task(mailbox: ActorMailbox, task_id: str) -> str: + for path in sorted(mailbox.inbox.glob(f"task-{task_id}-*.json")): + return str(path) + return "" + + +def _load_prompt(args: argparse.Namespace) -> str: + text = str(args.prompt or "").strip() + if text: + return text + prompt_file = str(args.prompt_file or "").strip() + if prompt_file: + return Path(prompt_file).expanduser().read_text(encoding="utf-8") + return sys.stdin.read().strip() + + +def submit_request( + request: dict[str, Any], + *, + logical_operator: str = "DeepResearchBrowser", + objective: str = "", + task_id: str = "", + request_field: str = "chatgpt_browser_agent_request", + retry_attempts: int = 12, + retry_wait_seconds: float = 5.0, +) -> dict[str, Any]: + runtime = _runtime() + resolved_task_id = str(task_id or _task_id()) + prompt = str(request.get("prompt") or "").strip() + envelope = { + "task_id": resolved_task_id, + "objective": str(objective or prompt[:120] or logical_operator), + "logical_operator": str(logical_operator or "DeepResearchBrowser"), + str(request_field or "chatgpt_browser_agent_request"): dict(request or {}), + } + result = None + max_attempts = max(1, int(retry_attempts or 1)) + for attempt in range(1, max_attempts + 1): + result = runtime.submit(envelope, logical_operator=str(logical_operator or "DeepResearchBrowser")) + error = str(getattr(result, "error", "") or "").strip() + if getattr(result, "success", False): + break + if not error.startswith(f"lease_acquisition_failed_for_{DEFAULT_ACTOR_ID}"): + break + if attempt >= max_attempts: + break + time.sleep(max(0.2, float(retry_wait_seconds or 0.2))) + assert result is not None + payload = result.to_dict() + payload["task_id"] = resolved_task_id + payload["actor_id"] = DEFAULT_ACTOR_ID + payload["inbox_task_file"] = _find_inbox_task(_mailbox(DEFAULT_ACTOR_ID), resolved_task_id) + return payload + + +def poll_request(task_id: str, *, actor_id: str = DEFAULT_ACTOR_ID) -> dict[str, Any]: + mailbox = _mailbox(actor_id) + clean_task_id = str(task_id or "").strip() + if not clean_task_id: + raise RuntimeError("task_id is required") + results = mailbox.read_results(clean_task_id) + latest = results[-1] if results else {} + status = str(latest.get("status") or "").strip().lower() + inbox_task_file = _find_inbox_task(mailbox, clean_task_id) + active_manifest = _harness_dir() / "run" / "browser-agent-session-active" / "chatgpt" / f"{clean_task_id}.json" + manifest = {} + if active_manifest.exists(): + try: + data = json.loads(active_manifest.read_text(encoding="utf-8")) + if isinstance(data, dict): + manifest = data + except Exception: + manifest = {} + if not status: + if manifest: + status = str(manifest.get("status") or "running") + elif inbox_task_file: + status = "queued" + else: + status = "unknown" + return { + "ok": True, + "task_id": clean_task_id, + "actor_id": actor_id, + "status": status, + "queued": bool(inbox_task_file), + "inbox_task_file": inbox_task_file, + "active_manifest_file": str(active_manifest) if active_manifest.exists() else "", + "active_manifest": manifest, + "latest_result": latest, + "result_count": len(results), + } + + +def collect_request( + task_id: str, + *, + actor_id: str = DEFAULT_ACTOR_ID, + timeout_seconds: float = 60.0, + poll_interval_seconds: float = 2.0, + terminal_statuses: set[str] | None = None, +) -> tuple[int, dict[str, Any]]: + terminal_statuses = {str(item).lower() for item in (terminal_statuses or {"completed", "failed"})} + deadline = time.time() + max(1.0, float(timeout_seconds)) + last_payload: dict[str, Any] = {} + attempts = 0 + while time.time() <= deadline: + payload = poll_request(task_id, actor_id=actor_id) + status = str(payload.get("status") or "").strip().lower() + last_payload = payload + if status in terminal_statuses: + return (1 if status == "failed" else 0), payload + attempts += 1 + time.sleep(_collect_poll_sleep_seconds(float(poll_interval_seconds), attempts, status)) + last_payload["timeout"] = True + return 2, last_payload + + +def _submit(args: argparse.Namespace) -> int: + prompt = _load_prompt(args) + if not prompt: + raise RuntimeError("prompt is required") + request = { + "prompt": prompt, + "expected_output": str(args.expected_output or "markdown"), + "model": str(args.model or "chatgpt-5.5"), + "reasoning_effort": str(args.reasoning_effort or "high"), + "project_name": str(args.project_name or "杂项"), + "action": "submit" if args.async_mode else "run", + "headless": not bool(args.headed), + "session_reuse": True, + "session_lineage": str(args.session_lineage or "").strip(), + } + payload = submit_request( + request, + logical_operator=str(args.logical_operator or "DeepResearchBrowser"), + objective=str(args.objective or prompt[:120]), + task_id=str(args.task_id or ""), + ) + _json_dump(payload) + return 0 if payload.get("success") else 1 + + +def _poll(args: argparse.Namespace) -> int: + payload = poll_request(str(args.task_id or ""), actor_id=str(args.actor_id or DEFAULT_ACTOR_ID)) + _json_dump(payload) + return 0 + + +def _collect(args: argparse.Namespace) -> int: + rc, payload = collect_request( + str(args.task_id or ""), + actor_id=str(args.actor_id or DEFAULT_ACTOR_ID), + timeout_seconds=float(args.timeout_seconds), + poll_interval_seconds=float(args.poll_interval_seconds), + ) + _json_dump(payload) + return rc + + +def _supervisor_status_cmd(args: argparse.Namespace) -> int: + payload = supervisor_status(str(args.actor_id or DEFAULT_ACTOR_ID), mailbox_base=_harness_dir() / "actors") + _json_dump(payload) + return 0 + + +def _supervisor_drain_cmd(args: argparse.Namespace) -> int: + path = request_supervisor_drain(str(args.actor_id or DEFAULT_ACTOR_ID)) + _json_dump({"ok": True, "actor_id": str(args.actor_id or DEFAULT_ACTOR_ID), "drain_flag": str(path)}) + return 0 + + +def _supervisor_stop_cmd(args: argparse.Namespace) -> int: + actor_id = str(args.actor_id or DEFAULT_ACTOR_ID) + path = request_supervisor_stop(actor_id) + payload = supervisor_status(actor_id, mailbox_base=_harness_dir() / "actors") + pid = int(payload.get("pid") or 0) + if pid > 0: + try: + os.killpg(pid, signal.SIGTERM) + except OSError: + try: + os.kill(pid, signal.SIGTERM) + except OSError: + pass + _json_dump({"ok": True, "actor_id": actor_id, "stop_flag": str(path), "pid": pid}) + return 0 + + +def _supervisor_ensure_cmd(args: argparse.Namespace) -> int: + result = ensure_supervisor_running( + actor_id=str(args.actor_id or DEFAULT_ACTOR_ID), + mailbox_base=_harness_dir() / "actors", + lease_dir=_harness_dir() / "run" / "actor-leases", + poll_interval_seconds=float(args.poll_interval_seconds), + ) + _json_dump(result) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="solar-harness browser session", description="Browser agent session control plane") + sub = parser.add_subparsers(dest="command", required=True) + + submit = sub.add_parser("submit") + submit.add_argument("--task-id", default="") + submit.add_argument("--logical-operator", default="DeepResearchBrowser") + submit.add_argument("--objective", default="") + submit.add_argument("--prompt", default="") + submit.add_argument("--prompt-file", default="") + submit.add_argument("--model", default="chatgpt-5.5") + submit.add_argument("--reasoning-effort", default="high") + submit.add_argument("--expected-output", default="markdown") + submit.add_argument("--project-name", default="杂项") + submit.add_argument("--session-lineage", default="") + submit.add_argument("--headed", action="store_true") + submit.add_argument("--async", dest="async_mode", action="store_true") + submit.set_defaults(func=_submit) + + poll = sub.add_parser("poll") + poll.add_argument("--actor-id", default=DEFAULT_ACTOR_ID) + poll.add_argument("--task-id", required=True) + poll.set_defaults(func=_poll) + + collect = sub.add_parser("collect") + collect.add_argument("--actor-id", default=DEFAULT_ACTOR_ID) + collect.add_argument("--task-id", required=True) + collect.add_argument("--timeout-seconds", type=float, default=60.0) + collect.add_argument("--poll-interval-seconds", type=float, default=2.0) + collect.set_defaults(func=_collect) + + supervisor = sub.add_parser("supervisor") + supervisor_sub = supervisor.add_subparsers(dest="supervisor_command", required=True) + + ensure = supervisor_sub.add_parser("ensure") + ensure.add_argument("--actor-id", default=DEFAULT_ACTOR_ID) + ensure.add_argument("--poll-interval-seconds", type=float, default=2.0) + ensure.set_defaults(func=_supervisor_ensure_cmd) + + status = supervisor_sub.add_parser("status") + status.add_argument("--actor-id", default=DEFAULT_ACTOR_ID) + status.set_defaults(func=_supervisor_status_cmd) + + drain = supervisor_sub.add_parser("drain") + drain.add_argument("--actor-id", default=DEFAULT_ACTOR_ID) + drain.set_defaults(func=_supervisor_drain_cmd) + + stop = supervisor_sub.add_parser("stop") + stop.add_argument("--actor-id", default=DEFAULT_ACTOR_ID) + stop.set_defaults(func=_supervisor_stop_cmd) + + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + return int(args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/tools/browser_job_runtime.py b/harness/tools/browser_job_runtime.py index 560c68327..4ab708f11 100755 --- a/harness/tools/browser_job_runtime.py +++ b/harness/tools/browser_job_runtime.py @@ -438,6 +438,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/tools/chatgpt_browser_agent_task_operator.py b/harness/tools/chatgpt_browser_agent_task_operator.py index 0d14458fc..c0a19b057 100755 --- a/harness/tools/chatgpt_browser_agent_task_operator.py +++ b/harness/tools/chatgpt_browser_agent_task_operator.py @@ -17,6 +17,7 @@ sys.path.insert(0, str(ROOT / "lib")) import operator_flow_control as ofc # noqa: E402 +from browser.profile_selection import pick_available_profile # noqa: E402 DEFAULT_OPERATOR_ID = "mini-chatgpt-deep-research" @@ -98,18 +99,6 @@ def _pick_policy_key(request: dict[str, Any]) -> str: return "default" -def _pick_profile(purpose: str, profiles: list[str], selection: str) -> str: - clean = [item for item in profiles if str(item).strip()] - if not clean: - return "" - if len(clean) == 1 or selection == "first": - return clean[0] - import hashlib - - digest = hashlib.sha256(str(purpose or "").encode("utf-8")).hexdigest() - return clean[int(digest[:8], 16) % len(clean)] - - def _enforce_no_default_profile_for_scoped_chatgpt(policy_key: str, policy: dict[str, Any], resolved_profile: str, purpose: str) -> None: protected_keys = {"hf_paper_insight", "github_trend_report", "ai_influence_report"} allow_default = bool(policy.get("allow_default_profile") or policy.get("allow_default_chatgpt_profile")) @@ -141,6 +130,7 @@ def apply_profile_policy(env: dict[str, str], request: dict[str, Any]) -> dict[s user_data_dir = str(policy.get("user_data_dir") or "").strip() explicit_profile = str(env.get("BROWSER_AGENT_PROFILE_DIRECTORY") or "").strip() + explicit_profile_id = str(env.get("BROWSER_AGENT_PROFILE_ID") or "").strip() explicit_account = str( env.get("BROWSER_AGENT_CHATGPT_ACCOUNT_EMAIL") or env.get("BROWSER_AGENT_TARGET_ACCOUNT_EMAIL") @@ -157,11 +147,23 @@ def apply_profile_policy(env: dict[str, str], request: dict[str, Any]) -> dict[s f"purpose={purpose or 'N/A'}:allowed={','.join(allowed_profiles)}:actual={explicit_profile}" ) - resolved_profile = explicit_profile or _pick_profile(purpose, allowed_profiles, selection) + profile_pick = pick_available_profile( + service="chatgpt", + purpose=purpose, + allowed_profiles=allowed_profiles, + selection=selection, + account_identifier=expected_account or explicit_account, + explicit_profile=explicit_profile, + explicit_profile_id=explicit_profile_id, + ) + resolved_profile = str(profile_pick.get("selected_profile_directory") or "") + resolved_profile_id = str(profile_pick.get("selected_profile_id") or "") resolved_account = explicit_account or expected_account _enforce_no_default_profile_for_scoped_chatgpt(key, policy, resolved_profile, purpose) if resolved_profile: env["BROWSER_AGENT_PROFILE_DIRECTORY"] = resolved_profile + if resolved_profile_id: + env["BROWSER_AGENT_PROFILE_ID"] = resolved_profile_id if resolved_account: env["BROWSER_AGENT_TARGET_ACCOUNT_EMAIL"] = resolved_account env["BROWSER_AGENT_CHATGPT_ACCOUNT_EMAIL"] = resolved_account @@ -170,27 +172,39 @@ def apply_profile_policy(env: dict[str, str], request: dict[str, Any]) -> dict[s env["BROWSER_AGENT_CHATGPT_PROFILE_STRATEGY"] = profile_strategy if user_data_dir and not env.get("BROWSER_AGENT_USER_DATA_DIR"): env["BROWSER_AGENT_USER_DATA_DIR"] = user_data_dir - if _is_protected_scoped_chatgpt(key) and not bool(policy.get("allow_headless")): - env["BROWSER_AGENT_HEADLESS"] = "false" - env["TECH_HOTSPOT_BROWSER_CHATGPT_HEADLESS"] = "false" - env["BROWSER_AGENT_CHATGPT_ALLOW_HEADED"] = "true" - env["TECH_HOTSPOT_BROWSER_CHATGPT_ALLOW_HEADED"] = "true" - env["BROWSER_AGENT_ALLOW_HEADED"] = "true" + headed_recovery_allowed = _is_protected_scoped_chatgpt(key) and not bool(policy.get("allow_headless")) + if headed_recovery_allowed: + # Preserve the global headless default. Only explicit login-recovery flows + # may opt into headed mode later. + env.setdefault("BROWSER_AGENT_CHATGPT_ALLOW_HEADED", "true") + env.setdefault("TECH_HOTSPOT_BROWSER_CHATGPT_ALLOW_HEADED", "true") + env.setdefault("BROWSER_AGENT_ALLOW_HEADED", "true") env["BROWSER_AGENT_CHATGPT_PROFILE_POLICY_KEY"] = key return { "enabled": True, "policy_key": key, "policy_path": loaded.get("path") or "", "selected_profile_directory": resolved_profile, + "selected_profile_id": resolved_profile_id, "selected_account_email": resolved_account, "profile_strategy": profile_strategy, + "lease_blocked_profiles": list(profile_pick.get("lease_blocked_profiles") or []), + "lease_probe": list(profile_pick.get("lease_probe") or []), + "selection_reason": str(profile_pick.get("selection_reason") or ""), "user_data_dir_set": bool(env.get("BROWSER_AGENT_USER_DATA_DIR")), - "headless_forced": _is_protected_scoped_chatgpt(key) and not bool(policy.get("allow_headless")), + "headless_forced": False, + "headed_recovery_allowed": headed_recovery_allowed, } def _operator_id(envelope: dict[str, Any]) -> str: - return str(envelope.get("operator_id") or "").strip() or DEFAULT_OPERATOR_ID + explicit = str(envelope.get("operator_id") or "").strip() + if explicit: + return explicit + logical_operator = str(envelope.get("logical_operator") or "").strip() + if logical_operator in {"DeepResearchChatGPT", "GPTRequirementWriter"}: + return "browser_agent_session" + return DEFAULT_OPERATOR_ID def _read_request_file(path_value: str) -> dict[str, Any]: @@ -223,6 +237,13 @@ def build_request(envelope: dict[str, Any], *, task_dir: Path | None = None) -> "require_deep_research", "account_email", "action", + "conversation_url", + "headless", + "open_project_first", + "require_project", + "force_new_chat", + "require_isolated_conversation", + "profile_directory", "timeout_seconds", ): if key in envelope: @@ -305,8 +326,9 @@ def _summary_markdown(response: dict[str, Any]) -> str: def run_request(request: dict[str, Any], *, task_dir: Path) -> dict[str, Any]: + action = str(request.get("action") or "run") prompt = str(request.get("prompt") or "").strip() - if not prompt: + if action not in {"poll", "collect"} and not prompt: raise RuntimeError("ChatGPT browser-agent operator requires prompt") cmd = _wrapper_cmd() if not cmd: @@ -319,6 +341,7 @@ def run_request(request: dict[str, Any], *, task_dir: Path) -> dict[str, Any]: encoding="utf-8", ) env = os.environ.copy() + env.setdefault("BROWSER_AGENT_HEADLESS", "true") env.update( { "BROWSER_AGENT_REQUEST_DIR": str(request_dir), @@ -333,11 +356,27 @@ def run_request(request: dict[str, Any], *, task_dir: Path) -> dict[str, Any]: "BROWSER_AGENT_CHATGPT_REQUIRE_DEEP_RESEARCH": "true" if bool(request.get("require_deep_research", False)) else "false", - "BROWSER_AGENT_CHATGPT_ACTION": str(request.get("action") or "run"), + "BROWSER_AGENT_CHATGPT_ACTION": action, "BROWSER_AGENT_CHATGPT_PROJECT_NAME": str(request.get("project_name") or DEFAULT_PROJECT_NAME), "BROWSER_AGENT_CHATGPT_REQUIRE_PROJECT": "true", } ) + if "conversation_url" in request and str(request.get("conversation_url") or "").strip(): + env["BROWSER_AGENT_CHATGPT_CONVERSATION_URL"] = str(request.get("conversation_url") or "").strip() + if "headless" in request and request.get("headless") is not None: + env["BROWSER_AGENT_HEADLESS"] = "true" if bool(request.get("headless")) else "false" + if "open_project_first" in request and request.get("open_project_first") is not None: + env["BROWSER_AGENT_CHATGPT_OPEN_PROJECT_FIRST"] = "true" if bool(request.get("open_project_first")) else "false" + if "require_project" in request and request.get("require_project") is not None: + env["BROWSER_AGENT_CHATGPT_REQUIRE_PROJECT"] = "true" if bool(request.get("require_project")) else "false" + if "force_new_chat" in request and request.get("force_new_chat") is not None: + env["BROWSER_AGENT_CHATGPT_FORCE_NEW_CHAT"] = "true" if bool(request.get("force_new_chat")) else "false" + if "require_isolated_conversation" in request and request.get("require_isolated_conversation") is not None: + env["BROWSER_AGENT_CHATGPT_REQUIRE_ISOLATED_CONVERSATION"] = ( + "true" if bool(request.get("require_isolated_conversation")) else "false" + ) + if "profile_directory" in request and str(request.get("profile_directory") or "").strip(): + env["BROWSER_AGENT_PROFILE_DIRECTORY"] = str(request.get("profile_directory") or "").strip() account_email = str(request.get("account_email") or "").strip() if account_email: env["BROWSER_AGENT_CHATGPT_ACCOUNT_EMAIL"] = account_email @@ -348,22 +387,28 @@ def run_request(request: dict[str, Any], *, task_dir: Path) -> dict[str, Any]: encoding="utf-8", ) timeout = ofc.int_value(request.get("timeout_seconds") or os.environ.get("BROWSER_AGENT_CHATGPT_TIMEOUT"), 1800) - proc = subprocess.run( - cmd, - input=prompt, - text=True, - capture_output=True, - env=env, - timeout=timeout, - ) - combined = ((proc.stdout or "") + "\n" + (proc.stderr or "")).strip() + stdout_path = task_dir / "chatgpt-browser-agent-stdout.txt" + stderr_path = task_dir / "chatgpt-browser-agent-stderr.txt" + with stdout_path.open("w", encoding="utf-8") as stdout_handle, stderr_path.open("w", encoding="utf-8") as stderr_handle: + proc = subprocess.run( + cmd, + input=prompt, + text=True, + stdout=stdout_handle, + stderr=stderr_handle, + env=env, + timeout=timeout, + ) + stdout_text = stdout_path.read_text(encoding="utf-8") + stderr_text = stderr_path.read_text(encoding="utf-8") + combined = (stdout_text + "\n" + stderr_text).strip() (task_dir / "chatgpt-browser-agent-output.txt").write_text( combined + ("\n" if combined else ""), encoding="utf-8", ) if proc.returncode != 0: raise RuntimeError(f"ChatGPT browser-agent failed rc={proc.returncode}: {combined[-1000:]}") - text = str(proc.stdout or "").strip() + text = str(stdout_text or "").strip() if not text: raise RuntimeError("ChatGPT browser-agent returned empty output") result = { @@ -387,26 +432,32 @@ def main() -> int: task_dir = _task_dir() ofc.clear_task_control(task_dir) request = build_request(envelope, task_dir=task_dir) + action = str(request.get("action") or "run").strip().lower() + is_followup_action = action in {"poll", "collect"} rate_control = _rate_control_settings(envelope) operator_id = str(rate_control["operator_id"]) + skip_flow_control = is_followup_action or operator_id == "browser_agent_session" try: - ofc.ensure_operator_available(operator_id) + if not skip_flow_control: + ofc.ensure_operator_available(operator_id) run_request(request, task_dir=task_dir) - ofc.apply_success_cooldown( - operator_id, - success_cooldown_seconds=int(rate_control.get("success_cooldown_seconds") or 0), - ) + if not skip_flow_control: + ofc.apply_success_cooldown( + operator_id, + success_cooldown_seconds=int(rate_control.get("success_cooldown_seconds") or 0), + ) return 0 except Exception as exc: - ofc.apply_failure_flow_control( - task_dir, - operator_id=operator_id, - failure_text=str(exc), - rate_limit_cooldown_seconds=int(rate_control.get("rate_limit_cooldown_seconds") or 0), - auth_cooldown_seconds=int(rate_control.get("auth_cooldown_seconds") or 0), - defer_on_cooldown=bool(rate_control.get("defer_on_cooldown")), - defer_on_auth=bool(rate_control.get("defer_on_auth")), - ) + if not skip_flow_control: + ofc.apply_failure_flow_control( + task_dir, + operator_id=operator_id, + failure_text=str(exc), + rate_limit_cooldown_seconds=int(rate_control.get("rate_limit_cooldown_seconds") or 0), + auth_cooldown_seconds=int(rate_control.get("auth_cooldown_seconds") or 0), + defer_on_cooldown=bool(rate_control.get("defer_on_cooldown")), + defer_on_auth=bool(rate_control.get("defer_on_auth")), + ) print(f"chatgpt_browser_agent_task_operator failed: {type(exc).__name__}: {exc}", file=sys.stderr) return 1 diff --git a/harness/tools/chatgpt_report_operator.py b/harness/tools/chatgpt_report_operator.py index 3ffb38272..f199328d2 100755 --- a/harness/tools/chatgpt_report_operator.py +++ b/harness/tools/chatgpt_report_operator.py @@ -13,13 +13,14 @@ """ from __future__ import annotations -import hashlib import json import os import signal +import re import shlex import subprocess import sys +import uuid from pathlib import Path from typing import Any @@ -30,6 +31,14 @@ DEFAULT_BROWSER_USE_PYTHON = Path.home() / ".claude" / "mcp-servers" / "browser-use" / ".venv" / "bin" / "python" DEFAULT_LOCAL_PROFILE_POLICY = Path.home() / ".solar" / "harness" / "browser-agent-chatgpt-local.json" +if str(ROOT / "tools") not in sys.path: + sys.path.insert(0, str(ROOT / "tools")) +if str(ROOT / "lib") not in sys.path: + sys.path.insert(0, str(ROOT / "lib")) + +from browser_agent_session_control import collect_request, submit_request # type: ignore # noqa: E402 +from browser.profile_selection import pick_available_profile # type: ignore # noqa: E402 + def infer_kind(purpose: str, explicit: str = "") -> str: value = explicit.strip().lower().replace("-", "_") @@ -52,6 +61,18 @@ def wrapper_cmd() -> list[str]: return [] +def _slug(value: str, limit: int = 96) -> str: + text = re.sub(r"[^A-Za-z0-9_.:-]+", "-", str(value or "").strip()).strip("-") + return (text or "default")[:limit] + + +def _default_session_lineage(*, purpose: str, kind: str) -> str: + clean_purpose = str(purpose or "").strip() + if not clean_purpose: + return f"chatgpt-report:{kind or 'auto'}" + return f"chatgpt-report:{_slug(clean_purpose)}" + + def _profile_policy_path() -> Path | None: disabled = ( os.environ.get("BROWSER_AGENT_CHATGPT_PROFILE_POLICY_DISABLED") @@ -112,19 +133,6 @@ def _merge_policy(base: dict[str, Any], override: dict[str, Any]) -> dict[str, A return merged -def _pick_profile_from_pool(purpose: str, allowed_profiles: list[str], selection: str) -> str: - clean = [item for item in allowed_profiles if str(item).strip()] - if not clean: - return "" - if len(clean) == 1: - return clean[0] - if selection == "first": - return clean[0] - digest = hashlib.sha256(str(purpose or "").encode("utf-8")).hexdigest() - index = int(digest[:8], 16) % len(clean) - return clean[index] - - def _enforce_no_default_profile_for_scoped_chatgpt(policy_key: str, policy: dict[str, Any], resolved_profile: str, purpose: str) -> None: protected_keys = {"hf_paper_insight", "github_trend_report", "ai_influence_report"} allow_default = bool(policy.get("allow_default_profile") or policy.get("allow_default_chatgpt_profile")) @@ -164,6 +172,7 @@ def apply_profile_policy(env: dict[str, str], *, purpose: str) -> dict[str, Any] profile_strategy = str(policy.get("profile_strategy") or "persistent").strip().lower() user_data_dir = str(policy.get("user_data_dir") or "").strip() explicit_profile = str(env.get("BROWSER_AGENT_PROFILE_DIRECTORY") or "").strip() + explicit_profile_id = str(env.get("BROWSER_AGENT_PROFILE_ID") or "").strip() explicit_account = str( env.get("BROWSER_AGENT_CHATGPT_ACCOUNT_EMAIL") or env.get("BROWSER_AGENT_TARGET_ACCOUNT_EMAIL") @@ -181,7 +190,17 @@ def apply_profile_policy(env: dict[str, str], *, purpose: str) -> dict[str, Any] f"purpose={purpose or 'N/A'}:allowed={','.join(allowed_profiles)}:actual={explicit_profile}" ) - resolved_profile = explicit_profile or _pick_profile_from_pool(purpose, allowed_profiles, selection) + profile_pick = pick_available_profile( + service="chatgpt", + purpose=purpose, + allowed_profiles=allowed_profiles, + selection=selection, + account_identifier=expected_account_email or explicit_account, + explicit_profile=explicit_profile, + explicit_profile_id=explicit_profile_id, + ) + resolved_profile = str(profile_pick.get("selected_profile_directory") or "") + resolved_profile_id = str(profile_pick.get("selected_profile_id") or "") resolved_account = explicit_account or expected_account_email if allowed_profiles and not resolved_profile: raise RuntimeError( @@ -197,6 +216,8 @@ def apply_profile_policy(env: dict[str, str], *, purpose: str) -> dict[str, Any] if resolved_profile: env["BROWSER_AGENT_PROFILE_DIRECTORY"] = resolved_profile + if resolved_profile_id: + env["BROWSER_AGENT_PROFILE_ID"] = resolved_profile_id if resolved_account: env["BROWSER_AGENT_TARGET_ACCOUNT_EMAIL"] = resolved_account env["BROWSER_AGENT_CHATGPT_ACCOUNT_EMAIL"] = resolved_account @@ -205,12 +226,11 @@ def apply_profile_policy(env: dict[str, str], *, purpose: str) -> dict[str, Any] env["BROWSER_AGENT_CHATGPT_PROFILE_STRATEGY"] = profile_strategy if user_data_dir and not env.get("BROWSER_AGENT_USER_DATA_DIR"): env["BROWSER_AGENT_USER_DATA_DIR"] = user_data_dir - if _is_protected_scoped_chatgpt(key) and not bool(policy.get("allow_headless")): - env["BROWSER_AGENT_HEADLESS"] = "false" - env["TECH_HOTSPOT_BROWSER_CHATGPT_HEADLESS"] = "false" - env["BROWSER_AGENT_CHATGPT_ALLOW_HEADED"] = "true" - env["TECH_HOTSPOT_BROWSER_CHATGPT_ALLOW_HEADED"] = "true" - env["BROWSER_AGENT_ALLOW_HEADED"] = "true" + headed_recovery_allowed = _is_protected_scoped_chatgpt(key) and not bool(policy.get("allow_headless")) + if headed_recovery_allowed: + env.setdefault("BROWSER_AGENT_CHATGPT_ALLOW_HEADED", "true") + env.setdefault("TECH_HOTSPOT_BROWSER_CHATGPT_ALLOW_HEADED", "true") + env.setdefault("BROWSER_AGENT_ALLOW_HEADED", "true") env["BROWSER_AGENT_CHATGPT_PROFILE_POLICY_KEY"] = key return { @@ -218,12 +238,17 @@ def apply_profile_policy(env: dict[str, str], *, purpose: str) -> dict[str, Any] "policy_key": key, "policy_path": str(loaded.get("path") or ""), "selected_profile_directory": resolved_profile, + "selected_profile_id": resolved_profile_id, "selected_account_email": resolved_account, "allowed_profiles": allowed_profiles, + "lease_blocked_profiles": list(profile_pick.get("lease_blocked_profiles") or []), + "lease_probe": list(profile_pick.get("lease_probe") or []), + "selection_reason": str(profile_pick.get("selection_reason") or ""), "selection": selection, "profile_strategy": profile_strategy, "user_data_dir_set": bool(env.get("BROWSER_AGENT_USER_DATA_DIR")), - "headless_forced": _is_protected_scoped_chatgpt(key) and not bool(policy.get("allow_headless")), + "headless_forced": False, + "headed_recovery_allowed": headed_recovery_allowed, } @@ -328,6 +353,242 @@ def run_wrapper_process(cmd: list[str], *, prompt: str, env: dict[str, str], tim return 124, (stdout or "") + f"\nchatgpt_report_operator: wrapper timed out after {timeout}s" +def _session_control_enabled() -> bool: + disabled = str(os.environ.get("BROWSER_AGENT_SESSION_CONTROL_DISABLED") or "").strip().lower() + return disabled not in {"1", "true", "yes", "on"} + + +def _submitted_run_path(request_dir: str) -> Path: + return Path(request_dir).expanduser() / "submitted-run.json" + + +def _parse_json_object(text: str) -> dict[str, Any]: + try: + data = json.loads(str(text or "").strip()) + except Exception: + return {} + return data if isinstance(data, dict) else {} + + +def _write_submitted_run( + request_dir: str, + *, + task_id: str, + status_payload: dict[str, Any], +) -> None: + path = _submitted_run_path(request_dir) + payload = { + "task_id": task_id, + "status": str(status_payload.get("status") or ""), + "url": str((status_payload.get("latest_result") or {}).get("conversation_url") or ""), + "conversation_id": str((status_payload.get("latest_result") or {}).get("conversation_id") or ""), + } + latest_result = status_payload.get("latest_result") if isinstance(status_payload.get("latest_result"), dict) else {} + result_file = Path(str(latest_result.get("result_file") or "")).expanduser() + if result_file.exists(): + try: + result_json = json.loads(result_file.read_text(encoding="utf-8")) + nested = _parse_json_object(str(result_json.get("text") or "")) + payload["url"] = str(result_json.get("url") or nested.get("url") or payload.get("url") or "") + payload["conversation_id"] = str( + result_json.get("conversation_id") or nested.get("conversation_id") or payload.get("conversation_id") or "" + ) + except Exception: + pass + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def _load_submitted_task_id(request_dir: str) -> str: + path = _submitted_run_path(request_dir) + if not path.exists(): + return "" + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return "" + if isinstance(data, dict): + return str(data.get("task_id") or "").strip() + return "" + + +def _extract_text_from_status_payload(status_payload: dict[str, Any]) -> str: + latest_result = status_payload.get("latest_result") if isinstance(status_payload.get("latest_result"), dict) else {} + result_file = Path(str(latest_result.get("result_file") or "")).expanduser() + if result_file.exists(): + try: + result_json = json.loads(result_file.read_text(encoding="utf-8")) + return str(result_json.get("text") or "").strip() + except Exception: + return "" + return "" + + +def _conversation_fields_from_status_payload(status_payload: dict[str, Any]) -> tuple[str, str]: + latest_result = status_payload.get("latest_result") if isinstance(status_payload.get("latest_result"), dict) else {} + active_manifest = status_payload.get("active_manifest") if isinstance(status_payload.get("active_manifest"), dict) else {} + result_file = Path(str(latest_result.get("result_file") or "")).expanduser() + result_json: dict[str, Any] = {} + nested: dict[str, Any] = {} + if result_file.exists(): + try: + result_json = json.loads(result_file.read_text(encoding="utf-8")) + nested = _parse_json_object(str(result_json.get("text") or "")) + except Exception: + result_json = {} + nested = {} + conversation_url = ( + str(result_json.get("url") or "").strip() + or str(nested.get("url") or "").strip() + or str(latest_result.get("conversation_url") or "").strip() + or str(active_manifest.get("conversation_url") or "").strip() + ) + conversation_id = ( + str(result_json.get("conversation_id") or "").strip() + or str(nested.get("conversation_id") or "").strip() + or str(latest_result.get("conversation_id") or "").strip() + or str(active_manifest.get("conversation_id") or "").strip() + ) + return conversation_url, conversation_id + + +def _write_request_dir_artifacts(request_dir: str, *, status_payload: dict[str, Any]) -> None: + request_path = Path(request_dir).expanduser() + request_path.mkdir(parents=True, exist_ok=True) + text = _extract_text_from_status_payload(status_payload) + status = str(status_payload.get("status") or "").strip().lower() + conversation_url, conversation_id = _conversation_fields_from_status_payload(status_payload) + if text: + (request_path / "assistant-response.txt").write_text(text + "\n", encoding="utf-8") + collect_payload = { + "ok": status != "failed", + "status": status or "unknown", + "url": conversation_url, + "conversation_id": conversation_id, + } + (request_path / "collect-state.json").write_text( + json.dumps(collect_payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + if conversation_url or conversation_id: + page_payload = { + "url": conversation_url, + "conversation_id": conversation_id, + } + (request_path / "page.json").write_text( + json.dumps(page_payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + (request_path / "conversation.json").write_text( + json.dumps(page_payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + +def _run_via_session_control( + *, + prompt: str, + env: dict[str, str], + timeout: int, + action: str, +) -> tuple[int, str]: + request_dir = str(env.get("BROWSER_AGENT_REQUEST_DIR") or "").strip() + request = { + "prompt": prompt, + "expected_output": str(env.get("BROWSER_AGENT_EXPECTED_OUTPUT") or "markdown"), + "model": str(env.get("CHATGPT_MODEL") or "chatgpt-5.5"), + "reasoning_effort": str(env.get("CHATGPT_REASONING_EFFORT") or "high"), + "project_name": str(env.get("BROWSER_AGENT_CHATGPT_PROJECT_NAME") or DEFAULT_PROJECT_NAME), + "action": action, + "headless": str(env.get("BROWSER_AGENT_HEADLESS") or "true").strip().lower() != "false", + "session_reuse": str(env.get("BROWSER_AGENT_SESSION_REUSE") or "true").strip().lower() != "false", + "session_lineage": str(env.get("BROWSER_AGENT_SESSION_LINEAGE") or "").strip(), + "model_mode": str(env.get("BROWSER_AGENT_CHATGPT_MODEL_MODE") or ""), + "tool_mode": str(env.get("BROWSER_AGENT_CHATGPT_TOOL_MODE") or ""), + "require_ui_mode": str(env.get("BROWSER_AGENT_CHATGPT_REQUIRE_UI_MODE") or "").strip().lower() == "true", + "require_deep_research": str(env.get("BROWSER_AGENT_CHATGPT_REQUIRE_DEEP_RESEARCH") or "").strip().lower() == "true", + "request_dir": request_dir, + } + for src_key, dst_key in ( + ("BROWSER_AGENT_CHATGPT_ACCOUNT_EMAIL", "account_email"), + ("BROWSER_AGENT_CHATGPT_CONVERSATION_URL", "conversation_url"), + ("BROWSER_AGENT_PROFILE_DIRECTORY", "profile_directory"), + ): + value = str(env.get(src_key) or "").strip() + if value: + request[dst_key] = value + task_id = str(env.get("BROWSER_AGENT_SESSION_TASK_ID") or "").strip() + if action in {"poll", "collect"} and not task_id and request_dir: + task_id = _load_submitted_task_id(request_dir) + if not task_id: + task_id = f"chatgpt-report-{uuid.uuid4().hex[:12]}" + if action == "submit": + submit_request_payload = dict(request) + submit_request_payload["action"] = "submit" + submit_payload = submit_request( + submit_request_payload, + logical_operator="DeepResearchChatGPT", + objective=str(env.get("BROWSER_AGENT_PURPOSE") or prompt[:120] or "chatgpt-report"), + task_id=task_id, + ) + if not submit_payload.get("success"): + return 1, str(submit_payload.get("error") or "chatgpt_report_operator: submit failed") + rc, status_payload = collect_request( + task_id, + timeout_seconds=min(timeout, 60), + poll_interval_seconds=2.0, + terminal_statuses={"submitted", "running", "failed"}, + ) + if request_dir: + _write_submitted_run(request_dir, task_id=task_id, status_payload=status_payload) + output = _extract_text_from_status_payload(status_payload) + return rc, output or json.dumps({"status": status_payload.get("status"), "task_id": task_id}, ensure_ascii=False) + if action == "run": + submit_request_payload = dict(request) + submit_request_payload["action"] = "submit" + submit_payload = submit_request( + submit_request_payload, + logical_operator="DeepResearchChatGPT", + objective=str(env.get("BROWSER_AGENT_PURPOSE") or prompt[:120] or "chatgpt-report"), + task_id=task_id, + ) + if not submit_payload.get("success"): + return 1, str(submit_payload.get("error") or "chatgpt_report_operator: submit failed") + rc, status_payload = collect_request( + task_id, + timeout_seconds=timeout, + poll_interval_seconds=2.0, + terminal_statuses={"completed", "failed"}, + ) + if request_dir: + _write_submitted_run(request_dir, task_id=task_id, status_payload=status_payload) + _write_request_dir_artifacts(request_dir, status_payload=status_payload) + output = _extract_text_from_status_payload(status_payload) + return rc, output or str((status_payload.get("latest_result") or {}).get("error") or "") + rc, status_payload = collect_request( + task_id, + timeout_seconds=timeout, + poll_interval_seconds=2.0, + terminal_statuses={"completed", "failed"} if action == "collect" else {"submitted", "running", "completed", "failed"}, + ) + if request_dir: + _write_submitted_run(request_dir, task_id=task_id, status_payload=status_payload) + if action == "collect" or str(status_payload.get("status") or "").strip().lower() == "completed": + _write_request_dir_artifacts(request_dir, status_payload=status_payload) + output = _extract_text_from_status_payload(status_payload) + if action == "poll": + if str(status_payload.get("status") or "").strip().lower() == "failed": + return 1, output or str((status_payload.get("latest_result") or {}).get("error") or "") + output = json.dumps( + { + "status": status_payload.get("status"), + "task_id": task_id, + }, + ensure_ascii=False, + ) + return 0, output + return rc, output or str((status_payload.get("latest_result") or {}).get("error") or "") + + def main() -> int: user_prompt = sys.stdin.read() action = (os.environ.get("CHATGPT_REPORT_ACTION") or "run").strip().lower() @@ -343,11 +604,17 @@ def main() -> int: policy = stage_policy(kind, expected) prompt = build_prompt(user_prompt, kind=kind, expected=expected, purpose=purpose) if user_prompt.strip() else "poll/collect" cmd = wrapper_cmd() - if not cmd: + if not _session_control_enabled() and not cmd: print("chatgpt_report_operator: Browser Agent ChatGPT wrapper not configured", file=sys.stderr) return 2 env = os.environ.copy() + env.setdefault("BROWSER_AGENT_HEADLESS", "true") + env.setdefault("BROWSER_AGENT_SESSION_REUSE", "true") + env.setdefault("SOLAR_BROWSER_SESSION_REUSE", env["BROWSER_AGENT_SESSION_REUSE"]) + default_lineage = _default_session_lineage(purpose=purpose, kind=kind) + env.setdefault("BROWSER_AGENT_SESSION_LINEAGE", default_lineage) + env.setdefault("SOLAR_BROWSER_SESSION_LINEAGE", env["BROWSER_AGENT_SESSION_LINEAGE"]) env.update( { "CHATGPT_MODEL": str(policy["model"]), @@ -416,7 +683,10 @@ def main() -> int: pass timeout = int(env.get("BROWSER_AGENT_CHATGPT_TIMEOUT") or ("7200" if kind == "deep_writer" else "1800")) - returncode, raw_output = run_wrapper_process(cmd, prompt=prompt, env=env, timeout=timeout) + if _session_control_enabled(): + returncode, raw_output = _run_via_session_control(prompt=prompt, env=env, timeout=timeout, action=action) + else: + returncode, raw_output = run_wrapper_process(cmd, prompt=prompt, env=env, timeout=timeout) output = (raw_output or "").strip() if returncode != 0: print(output, file=sys.stderr) diff --git a/harness/tools/chatgpt_requirement_writer_operator.py b/harness/tools/chatgpt_requirement_writer_operator.py new file mode 100644 index 000000000..a1dacc252 --- /dev/null +++ b/harness/tools/chatgpt_requirement_writer_operator.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +"""Browser-agent-backed ChatGPT requirement writer operator. + +This operator specializes in turning the user's original requirement into a +chaptered, compiler-friendly requirement design using ChatGPT Thinking High. +It must prefer raw/original requirement sources over upstream rewritten input. +""" +from __future__ import annotations + +import hashlib +import json +import os +import re +import sys +from pathlib import Path + +from chatgpt_report_operator import ( # type: ignore + DEFAULT_PROJECT_NAME, + _run_via_session_control, + _session_control_enabled, + apply_profile_policy, + run_wrapper_process, + wrapper_cmd, +) + + +RAW_REQUIREMENT_ENV_KEYS = ( + "SOLAR_RAW_REQUIREMENT", + "BROWSER_AGENT_RAW_REQUIREMENT", + "BROWSER_AGENT_USER_RAW_REQUIREMENT", + "RAW_REQUIREMENT", + "RAW_USER_REQUIREMENT", +) +RAW_REQUIREMENT_FILE_ENV_KEYS = ( + "SOLAR_RAW_REQUIREMENT_FILE", + "BROWSER_AGENT_RAW_REQUIREMENT_FILE", + "RAW_REQUIREMENT_FILE", +) +RAW_INTENT_FILE_ENV_KEYS = ( + "SOLAR_RAW_INTENT_FILE", + "BROWSER_AGENT_RAW_INTENT_FILE", + "RAW_INTENT_FILE", +) + + +def _slug(value: str, limit: int = 96) -> str: + text = re.sub(r"[^A-Za-z0-9_.:-]+", "-", str(value or "").strip()).strip("-") + return (text or "default")[:limit] + + +def _default_session_lineage(*, purpose: str, request_dir: str) -> str: + clean_purpose = str(purpose or "").strip() + if clean_purpose: + return f"gpt-requirement-writer:{_slug(clean_purpose)}" + request_name = Path(str(request_dir or "requirement-design")).name + return f"gpt-requirement-writer:{_slug(request_name)}" + + +def _read_file_text(path_text: str, *, source_label: str) -> tuple[str, str]: + path = Path(path_text).expanduser() + if not path.exists(): + raise RuntimeError(f"chatgpt_requirement_writer_operator: missing {source_label}={path}") + return path.read_text(encoding="utf-8").strip(), str(path) + + +def _extract_raw_requirement_from_intent(path_text: str, *, source_label: str) -> tuple[str, str]: + path = Path(path_text).expanduser() + if not path.exists(): + raise RuntimeError(f"chatgpt_requirement_writer_operator: missing {source_label}={path}") + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except Exception as exc: + raise RuntimeError( + f"chatgpt_requirement_writer_operator: invalid {source_label}={path}: {type(exc).__name__}: {exc}" + ) from exc + if isinstance(payload, dict): + raw = payload.get("raw") + if isinstance(raw, dict): + text = str(raw.get("text") or "").strip() + if text: + return text, str(path) + text = str(payload.get("raw_requirement") or "").strip() + if text: + return text, str(path) + raise RuntimeError(f"chatgpt_requirement_writer_operator: no raw requirement found in {source_label}={path}") + + +def resolve_requirement_source(stdin_text: str) -> tuple[str, str, str]: + for key in RAW_REQUIREMENT_ENV_KEYS: + value = str(os.environ.get(key) or "").strip() + if value: + return value, f"env:{key}", "" + for key in RAW_REQUIREMENT_FILE_ENV_KEYS: + value = str(os.environ.get(key) or "").strip() + if value: + text, path = _read_file_text(value, source_label=key) + if text: + return text, f"file:{key}", path + for key in RAW_INTENT_FILE_ENV_KEYS: + value = str(os.environ.get(key) or "").strip() + if value: + text, path = _extract_raw_requirement_from_intent(value, source_label=key) + if text: + return text, f"raw_intent:{key}", path + clean_stdin = stdin_text.strip() + if clean_stdin: + return clean_stdin, "stdin", "" + raise RuntimeError("chatgpt_requirement_writer_operator: no requirement input available") + + +def build_prompt(raw_requirement: str, *, expected: str, purpose: str, source_tag: str) -> str: + return "\n\n".join( + [ + "# GPTRequirementWriter 固化执行协议", + "- operator_kind: requirement_writer", + f"- purpose: {purpose or 'N/A'}", + f"- expected_output: {expected}", + "- model_mode: thinking", + "- reasoning_effort: high", + f"- source_priority: {source_tag}", + "", + "## 角色定义", + "你是 GPTRequirementWriter。你的任务是把用户原始需求展开成一份完整、详细、系统、章节化的需求设计稿,供后续 requirement compiler 按章节拆分编译。", + "", + "## 强约束", + "1. 只能以用户原始需求为中心展开;不要把上游算子的改写稿、摘要稿、路线建议稿当成用户原话。", + "2. 如果原始需求信息不足,必须明确写成“假设 / 待确认 / 风险”,不能擅自补成既定事实。", + "3. 输出必须是章节化 Markdown,并使用稳定标题层级,便于后续拆分。", + "4. 必须同时覆盖:背景与目标、范围边界、角色与场景、功能需求、非功能需求、数据与接口、约束依赖、风险与待确认、验收标准、实施建议。", + "5. 每章先给章节目标,再给细项;功能需求要尽量编号化,便于编译器拆段。", + "6. 不要输出内部流水线字段、operator 元信息、raw id 或调度说明。", + "", + "## 推荐章节骨架", + "1. 需求概述与业务目标", + "2. 问题定义与范围边界", + "3. 干系人与目标用户", + "4. 关键使用场景与用户旅程", + "5. 功能需求清单", + "6. 非功能需求", + "7. 数据模型、接口与外部依赖", + "8. 约束、风险、失败模式与安全边界", + "9. 验收标准与交付定义", + "10. 待确认问题与建议决策", + "", + "## 用户原始需求", + raw_requirement.strip(), + ] + ) + + +def main() -> int: + stdin_text = sys.stdin.read() + action = (os.environ.get("CHATGPT_REQUIREMENT_WRITER_ACTION") or "run").strip().lower() + if action not in {"run", "submit", "poll", "collect"}: + print( + f"chatgpt_requirement_writer_operator: invalid CHATGPT_REQUIREMENT_WRITER_ACTION={action}", + file=sys.stderr, + ) + return 2 + expected = (os.environ.get("BROWSER_AGENT_EXPECTED_OUTPUT") or "markdown").strip().lower() + purpose = (os.environ.get("BROWSER_AGENT_PURPOSE") or "requirement-design").strip() + if action in {"poll", "collect"}: + raw_requirement, source_tag, source_path = "", "", "" + else: + try: + raw_requirement, source_tag, source_path = resolve_requirement_source(stdin_text) + except RuntimeError as exc: + print(str(exc), file=sys.stderr) + return 2 + prompt = build_prompt(raw_requirement, expected=expected, purpose=purpose, source_tag=source_tag) + cmd = wrapper_cmd() + if not _session_control_enabled() and not cmd: + print("chatgpt_requirement_writer_operator: Browser Agent ChatGPT wrapper not configured", file=sys.stderr) + return 2 + + env = os.environ.copy() + env.setdefault("BROWSER_AGENT_HEADLESS", "true") + env.setdefault("BROWSER_AGENT_SESSION_REUSE", "true") + env.setdefault("SOLAR_BROWSER_SESSION_REUSE", env["BROWSER_AGENT_SESSION_REUSE"]) + request_dir = str(env.get("BROWSER_AGENT_REQUEST_DIR") or "").strip() + default_lineage = _default_session_lineage(purpose=purpose, request_dir=request_dir) + env.setdefault("BROWSER_AGENT_SESSION_LINEAGE", default_lineage) + env.setdefault("SOLAR_BROWSER_SESSION_LINEAGE", env["BROWSER_AGENT_SESSION_LINEAGE"]) + env.update( + { + "CHATGPT_MODEL": str( + env.get("CHATGPT_REQUIREMENT_WRITER_MODEL") + or env.get("CHATGPT_MODEL") + or "chatgpt-5.5" + ), + "CHATGPT_REASONING_EFFORT": "high", + "BROWSER_AGENT_EXPECTED_OUTPUT": expected, + "BROWSER_AGENT_CHATGPT_MODEL_MODE": "thinking", + "BROWSER_AGENT_CHATGPT_TOOL_MODE": env.get("BROWSER_AGENT_CHATGPT_TOOL_MODE") or "none", + "BROWSER_AGENT_CHATGPT_REQUIRE_UI_MODE": "true", + "BROWSER_AGENT_CHATGPT_PROJECT_NAME": env.get("BROWSER_AGENT_CHATGPT_PROJECT_NAME") or DEFAULT_PROJECT_NAME, + "BROWSER_AGENT_CHATGPT_OPEN_PROJECT_FIRST": env.get("BROWSER_AGENT_CHATGPT_OPEN_PROJECT_FIRST") or "true", + "BROWSER_AGENT_CHATGPT_REQUIRE_PROJECT": env.get("BROWSER_AGENT_CHATGPT_REQUIRE_PROJECT") or "true", + "BROWSER_AGENT_CHATGPT_ACTION": action, + } + ) + try: + policy_meta = apply_profile_policy(env, purpose=purpose) + except RuntimeError as exc: + print(f"chatgpt_requirement_writer_operator: {exc}", file=sys.stderr) + return 2 + + request_dir = env.get("BROWSER_AGENT_REQUEST_DIR") + if request_dir: + request_path = Path(request_dir).expanduser() + request_path.mkdir(parents=True, exist_ok=True) + upstream_input_ignored = bool(stdin_text.strip()) and source_tag != "stdin" and stdin_text.strip() != raw_requirement.strip() + (request_path / "requirement-writer-request.json").write_text( + json.dumps( + { + "operator_kind": "GPTRequirementWriter", + "display_name": "GPT Requirement Writer", + "expected_output": expected, + "purpose": purpose, + "model": env.get("CHATGPT_MODEL") or "", + "model_mode": env.get("BROWSER_AGENT_CHATGPT_MODEL_MODE") or "thinking", + "reasoning_effort": env.get("CHATGPT_REASONING_EFFORT") or "high", + "tool_mode": env.get("BROWSER_AGENT_CHATGPT_TOOL_MODE") or "none", + "project_name": env.get("BROWSER_AGENT_CHATGPT_PROJECT_NAME") or DEFAULT_PROJECT_NAME, + "raw_requirement_source": source_tag, + "raw_requirement_source_path": source_path, + "raw_requirement_sha256": hashlib.sha256(raw_requirement.encode("utf-8")).hexdigest(), + "raw_requirement_length": len(raw_requirement), + "upstream_input_present": bool(stdin_text.strip()), + "upstream_input_ignored": upstream_input_ignored, + "profile_directory": env.get("BROWSER_AGENT_PROFILE_DIRECTORY") or "", + "target_account_email": env.get("BROWSER_AGENT_CHATGPT_ACCOUNT_EMAIL") + or env.get("BROWSER_AGENT_TARGET_ACCOUNT_EMAIL") + or "", + "profile_policy": policy_meta, + }, + ensure_ascii=False, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + timeout = int(env.get("CHATGPT_REQUIREMENT_WRITER_TIMEOUT") or env.get("BROWSER_AGENT_CHATGPT_TIMEOUT") or "2400") + if _session_control_enabled(): + returncode, raw_output = _run_via_session_control(prompt=prompt, env=env, timeout=timeout, action=action) + else: + returncode, raw_output = run_wrapper_process(cmd, prompt=prompt, env=env, timeout=timeout) + output = (raw_output or "").strip() + if returncode != 0: + print(output, file=sys.stderr) + return returncode + if not request_dir: + print("chatgpt_requirement_writer_operator: missing BROWSER_AGENT_REQUEST_DIR for UI mode proof", file=sys.stderr) + return 1 + proof_path = Path(request_dir).expanduser() / "chatgpt-mode-state.json" + post_submit_proof_path = Path(request_dir).expanduser() / "chatgpt-mode-post-submit-state.json" + if not proof_path.exists(): + print( + "chatgpt_requirement_writer_operator: did not produce chatgpt-mode-state.json; " + "normal ChatGPT output is not accepted as Thinking High", + file=sys.stderr, + ) + return 1 + try: + proof = json.loads(proof_path.read_text(encoding="utf-8")) + except Exception as exc: + print( + f"chatgpt_requirement_writer_operator: invalid chatgpt-mode-state.json: {type(exc).__name__}: {exc}", + file=sys.stderr, + ) + return 1 + if not proof.get("ok"): + if post_submit_proof_path.exists(): + try: + post_submit_proof = json.loads(post_submit_proof_path.read_text(encoding="utf-8")) + except Exception as exc: + print( + "chatgpt_requirement_writer_operator: invalid chatgpt-mode-post-submit-state.json: " + f"{type(exc).__name__}: {exc}", + file=sys.stderr, + ) + return 1 + if not post_submit_proof.get("ok"): + print( + "chatgpt_requirement_writer_operator: required ChatGPT UI mode not confirmed: " + + json.dumps(post_submit_proof, ensure_ascii=False), + file=sys.stderr, + ) + return 1 + else: + print( + "chatgpt_requirement_writer_operator: required ChatGPT UI mode not confirmed: " + + json.dumps(proof, ensure_ascii=False), + file=sys.stderr, + ) + return 1 + print(output) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/harness/tools/codex_pm_router.py b/harness/tools/codex_pm_router.py index c60d05906..e8d60b3e9 100755 --- a/harness/tools/codex_pm_router.py +++ b/harness/tools/codex_pm_router.py @@ -82,6 +82,7 @@ "Problem", "Constraints", "Acceptance", + "Enhanced Requirement Design", "Raw User Intent", ) RAWINTENT_METADATA_PREFIXES = ( @@ -99,6 +100,95 @@ "role:", ) +REQUIREMENT_SECTION_SEMANTIC_RULES = ( + { + "semantic_label": "non_functional_requirements", + "node_family": "quality", + "tokens": ("非功能需求", "non-functional", "non functional", "performance", "latency", "reliability", "security", "observability", "scalability", "可用性", "性能", "稳定性", "安全"), + "preferred_logical_operators": ["TestRunner", "Verifier", "ImplementationWorker"], + }, + { + "semantic_label": "functional_requirements", + "node_family": "implementation", + "tokens": ("功能需求", "functional requirement", "functional requirements", "feature", "features", "use case", "用户场景", "场景"), + "preferred_logical_operators": ["ImplementationWorker", "DeepArchitect"], + }, + { + "semantic_label": "acceptance_and_validation", + "node_family": "verification", + "tokens": ("验收", "acceptance", "validation", "验证", "成功标准", "success criteria", "done definition", "完成定义"), + "preferred_logical_operators": ["TestRunner", "Verifier", "Critic"], + }, + { + "semantic_label": "risk_and_constraints", + "node_family": "risk_review", + "tokens": ("风险", "约束", "限制", "constraint", "constraints", "risk", "risks", "open question", "open questions", "假设", "assumption"), + "preferred_logical_operators": ["DeepArchitect", "Verifier", "Critic"], + }, + { + "semantic_label": "interfaces_and_data", + "node_family": "interface_contract", + "tokens": ("接口", "api", "apis", "integration", "integrations", "schema", "schemas", "data model", "数据模型", "contract", "contracts"), + "preferred_logical_operators": ["DeepArchitect", "ImplementationWorker", "Verifier"], + }, + { + "semantic_label": "architecture_and_scope", + "node_family": "design", + "tokens": ("概述", "scope", "范围", "architecture", "架构", "design", "设计", "目标", "goal", "problem", "问题定义", "研究边界"), + "preferred_logical_operators": ["DeepArchitect", "ArtifactCurator"], + }, +) + +NODE_FAMILY_HINTS = { + "DeepArchitect": {"design", "risk_review", "interface_contract", "implementation"}, + "ImplementationWorker": {"implementation", "interface_contract", "quality"}, + "TestRunner": {"quality", "verification"}, + "Verifier": {"verification", "quality", "risk_review", "interface_contract"}, + "Critic": {"verification", "risk_review", "design"}, + "ResearchScout": {"design", "risk_review"}, + "ResearchSynthesizer": {"design", "implementation", "interface_contract"}, + "ArtifactCurator": {"design", "verification", "risk_review"}, +} + +SEMANTIC_NODE_TEMPLATE_OVERRIDES = { + "implementation": { + "signals": ["section-functional-requirements", "implementation-coverage"], + "outputs": ["implementation-plan.md"], + "validation": [{"kind": "artifact", "target": "implementation-plan.md", "required": True}], + "acceptance": ["Functional requirement coverage is explicit and mapped into execution scope."], + }, + "quality": { + "signals": ["section-non-functional-requirements", "quality-constraints"], + "outputs": ["quality-checklist.md"], + "validation": [{"kind": "artifact", "target": "quality-checklist.md", "required": True}], + "acceptance": ["Non-functional requirements are translated into verifiable quality checks."], + }, + "verification": { + "signals": ["section-acceptance", "acceptance-traceability"], + "outputs": ["acceptance-matrix.json"], + "validation": [{"kind": "artifact", "target": "acceptance-matrix.json", "required": True}], + "acceptance": ["Acceptance criteria are traceable to explicit verification evidence."], + }, + "risk_review": { + "signals": ["section-risk", "constraint-review"], + "outputs": ["risk-register.md"], + "validation": [{"kind": "artifact", "target": "risk-register.md", "required": True}], + "acceptance": ["Risk, constraints, and open questions are explicitly reviewed."], + }, + "interface_contract": { + "signals": ["section-interface-contract", "schema-boundary"], + "outputs": ["interface-contract-notes.md"], + "validation": [{"kind": "artifact", "target": "interface-contract-notes.md", "required": True}], + "acceptance": ["Interface and data-contract implications are explicit."], + }, + "design": { + "signals": ["section-design-context", "scope-structure"], + "outputs": ["design-brief.md"], + "validation": [{"kind": "artifact", "target": "design-brief.md", "required": True}], + "acceptance": ["Sectioned design intent and scope boundaries are explicit."], + }, +} + def _now() -> str: return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") @@ -147,7 +237,8 @@ def _strip_yaml_frontmatter(text: str) -> str: def _extract_markdown_section(text: str, heading: str) -> str: - pattern = rf"(?ims)^\s*##\s+{re.escape(heading)}\s*$\n(.*?)(?=^\s*##\s+|\Z)" + headers_pattern = "|".join(re.escape(item) for item in RAWINTENT_SECTION_HEADERS) + pattern = rf"(?ims)^\s*##\s+{re.escape(heading)}\s*$\n(.*?)(?=^\s*##\s+(?:{headers_pattern})\s*$|\Z)" match = re.search(pattern, text) return match.group(1).strip() if match else "" @@ -183,21 +274,134 @@ def _extract_effective_request_text(text: str) -> dict[str, str]: "goal_text": whole, "problem_text": whole, "raw_user_text": whole, + "enhanced_requirement_text": "", + "enhanced_requirement_sections": [], } objective = _collapse_goal_text(_extract_markdown_section(text, "Rewritten Objective")) problem = _collapse_goal_text(_extract_markdown_section(text, "Problem")) + enhanced_requirement_block = _extract_markdown_section(text, "Enhanced Requirement Design") + enhanced_requirement = _collapse_goal_text(enhanced_requirement_block) raw_user_intent = _collapse_goal_text(_extract_markdown_section(text, "Raw User Intent")) - effective = raw_user_intent or problem or objective or whole - goal = objective or problem or raw_user_intent or effective + effective = enhanced_requirement or raw_user_intent or problem or objective or whole + goal = enhanced_requirement or objective or problem or raw_user_intent or effective + sections: list[dict[str, str]] = [] + current_heading = "" + body_lines: list[str] = [] + for line in enhanced_requirement_block.splitlines(): + match = re.match(r"^(#{1,6})\s+(.+?)\s*$", line) + if match: + if current_heading: + sections.append( + { + "heading": current_heading, + "content": _collapse_goal_text("\n".join(body_lines)), + } + ) + current_heading = match.group(2).strip() + body_lines = [] + continue + body_lines.append(line) + if current_heading: + sections.append( + { + "heading": current_heading, + "content": _collapse_goal_text("\n".join(body_lines)), + } + ) return { "effective_text": effective, "goal_text": goal, "problem_text": problem or effective, "raw_user_text": raw_user_intent or effective, + "enhanced_requirement_text": enhanced_requirement, + "enhanced_requirement_sections": sections, + } + + +def _normalize_semantic_text(value: str) -> str: + return re.sub(r"\s+", " ", str(value or "").strip()).lower() + + +def _infer_requirement_section_semantics(heading: str, content: str = "") -> dict[str, Any]: + heading_text = _normalize_semantic_text(heading) + content_text = _normalize_semantic_text(content) + for rule in REQUIREMENT_SECTION_SEMANTIC_RULES: + if any(token in heading_text for token in rule["tokens"]): + return { + "semantic_label": rule["semantic_label"], + "node_family": rule["node_family"], + "preferred_logical_operators": list(rule["preferred_logical_operators"]), + "matched_tokens": [token for token in rule["tokens"] if token in heading_text], + } + for rule in REQUIREMENT_SECTION_SEMANTIC_RULES: + if any(token in content_text for token in rule["tokens"]): + return { + "semantic_label": rule["semantic_label"], + "node_family": rule["node_family"], + "preferred_logical_operators": list(rule["preferred_logical_operators"]), + "matched_tokens": [token for token in rule["tokens"] if token in content_text], + } + return { + "semantic_label": "general_requirement_context", + "node_family": "design", + "preferred_logical_operators": ["DeepArchitect", "ArtifactCurator"], + "matched_tokens": [], } +def _build_section_semantic_hints(sections: list[dict[str, Any]]) -> list[dict[str, Any]]: + hints: list[dict[str, Any]] = [] + for index, section in enumerate(sections, start=1): + heading = str(section.get("heading") or "").strip() + if not heading: + continue + content = str(section.get("content") or "").strip() + semantic = _infer_requirement_section_semantics(heading, content) + hints.append( + { + "section_id": f"ERS-{index:03d}", + "heading": heading, + "content": content, + **semantic, + } + ) + return hints + + +def _section_family_counts(section_hints: list[dict[str, Any]]) -> dict[str, int]: + counts: dict[str, int] = {} + for hint in section_hints: + family = str(hint.get("node_family") or "").strip() + if not family: + continue + counts[family] = counts.get(family, 0) + 1 + return counts + + +def _append_unique_items(target: list[Any], additions: list[Any]) -> list[Any]: + existing = {json.dumps(item, ensure_ascii=False, sort_keys=True) for item in target} + merged = list(target) + for item in additions: + key = json.dumps(item, ensure_ascii=False, sort_keys=True) + if key in existing: + continue + merged.append(item) + existing.add(key) + return merged + + +def _append_unique_strings(target: list[str], additions: list[str]) -> list[str]: + existing = set(target) + merged = list(target) + for item in additions: + if item in existing: + continue + merged.append(item) + existing.add(item) + return merged + + def _looks_like_raw_metadata_pollution(text: str) -> bool: lowered = text.lower() suspicious_tokens = ( @@ -414,6 +618,8 @@ def _node_enrichment(request_type: str, lane_hint: str, node: dict[str, Any]) -> "R4": "synthesis.md", "R5": "critique.md", "R6": "final_prd_implications.md", + "R7": "research_verifier_decision.yaml", + "R8": "final_prd_implications.md", }.get(node["id"], "artifact.md") enriched = dict(node) enriched.setdefault("type", node_type_map.get(node["logical_operator"], "spec")) @@ -521,6 +727,81 @@ def _apply_requirement_mapping( return mapped +def _annotate_task_graph_with_section_semantics( + graph: dict[str, Any], + section_hints: list[dict[str, Any]], +) -> dict[str, Any]: + if not section_hints: + return graph + annotated = dict(graph) + nodes: list[dict[str, Any]] = [] + for node in annotated.get("nodes") or []: + enriched = dict(node) + operator = str(enriched.get("logical_operator") or "") + supported_families = NODE_FAMILY_HINTS.get(operator, {"design"}) + matches = [ + { + "section_id": hint["section_id"], + "heading": hint["heading"], + "semantic_label": hint["semantic_label"], + "node_family": hint["node_family"], + } + for hint in section_hints + if hint["node_family"] in supported_families + or operator in set(hint.get("preferred_logical_operators") or []) + ] + if matches: + enriched["section_semantic_hints"] = matches + focus_families = sorted({item["node_family"] for item in matches}) + enriched["semantic_focus"] = focus_families + enriched["semantic_template_applied"] = True + template_overrides: dict[str, Any] = { + "families": focus_families, + "applied": [], + } + for family in focus_families: + override = SEMANTIC_NODE_TEMPLATE_OVERRIDES.get(family) + if not override: + continue + template_overrides["applied"].append(family) + enriched["signals"] = _append_unique_strings( + [str(item) for item in (enriched.get("signals") or [])], + list(override.get("signals") or []), + ) + enriched["outputs"] = _append_unique_strings( + [str(item) for item in (enriched.get("outputs") or [])], + list(override.get("outputs") or []), + ) + enriched["validation"] = _append_unique_items( + [item for item in (enriched.get("validation") or []) if isinstance(item, dict)], + [item for item in (override.get("validation") or []) if isinstance(item, dict)], + ) + enriched["acceptance"] = _append_unique_strings( + [str(item) for item in (enriched.get("acceptance") or [])], + list(override.get("acceptance") or []), + ) + if template_overrides["applied"]: + enriched["semantic_template_overrides"] = template_overrides + nodes.append(enriched) + annotated["nodes"] = nodes + annotated["section_semantic_plan"] = { + "enabled": True, + "families": sorted({hint["node_family"] for hint in section_hints}), + "section_count": len(section_hints), + "sections": [ + { + "section_id": hint["section_id"], + "heading": hint["heading"], + "semantic_label": hint["semantic_label"], + "node_family": hint["node_family"], + "preferred_logical_operators": hint["preferred_logical_operators"], + } + for hint in section_hints + ], + } + return annotated + + def _make_prd_view( request_type: str, normalized_goal: str, @@ -1314,6 +1595,190 @@ def build_task_graph_skeleton(request_type: str, lane_hint: str, request_text: s return _apply_default_gate_assignments(_standard_task_graph(strategy_lane=lane_hint == "strategy")) +def _upgrade_standard_graph_for_section_semantics( + graph: dict[str, Any], + section_hints: list[dict[str, Any]], + request_type: str, +) -> dict[str, Any]: + if request_type != FULL_SPEC: + return graph + if str(graph.get("dag_variant") or "").strip().lower() != "standard": + return graph + family_counts = _section_family_counts(section_hints) + if not family_counts.get("verification"): + return graph + if not family_counts.get("interface_contract"): + return graph + branch_families = [family for family in ("risk_review", "interface_contract", "quality") if family_counts.get(family)] + if not (family_counts.get("risk_review") or family_counts.get("quality")): + return graph + + upgraded = _apply_default_gate_assignments(_parallel_delivery_task_graph()) + upgraded["semantic_upgrade"] = { + "enabled": True, + "mode": "section_family_parallel_delivery", + "trigger_families": sorted(branch_families + ["verification"]), + "family_counts": family_counts, + } + upgraded["quality_gates"] = { + "parallelism": { + "min_ready_width": 3, + } + } + + customized_nodes: list[dict[str, Any]] = [] + for node in upgraded.get("nodes") or []: + enriched = dict(node) + node_id = str(enriched.get("id") or "") + if node_id == "S1": + enriched["goal"] = "Lock implementation scope, interface boundaries, architecture constraints, and write-scope policy." + enriched["acceptance"] = ["Scope, interface boundaries, and implementation constraints are explicit."] + elif node_id == "S2": + enriched["goal"] = "Inspect risk, constraints, and failure evidence before implementation begins." + enriched["acceptance"] = ["Risk and constraint review is explicit and grounded in requirement sections."] + elif node_id == "S3": + enriched["goal"] = "Prepare acceptance, non-functional verification plan, and regression probes." + enriched["acceptance"] = ["Acceptance matrix, quality checklist, and regression probes are ready."] + elif node_id == "S4": + enriched["goal"] = "Implement the functional scope using the approved interface, risk, and verification constraints." + enriched["acceptance"] = ["Patch is linked to functional requirements and semantic branch outputs."] + elif node_id == "S5": + enriched["goal"] = "Run release verification, review branch evidence, and record the closeout decision." + enriched["acceptance"] = ["Verifier decision is grounded in implementation, quality, and risk evidence."] + customized_nodes.append(enriched) + upgraded["nodes"] = customized_nodes + return upgraded + + +def _upgrade_research_graph_for_section_semantics( + graph: dict[str, Any], + section_hints: list[dict[str, Any]], + request_type: str, +) -> dict[str, Any]: + if request_type != RESEARCH: + return graph + if str(graph.get("dag_variant") or "").strip().lower() != "research": + return graph + family_counts = _section_family_counts(section_hints) + if not family_counts.get("verification"): + return graph + if not family_counts.get("interface_contract"): + return graph + if not (family_counts.get("risk_review") or family_counts.get("quality")): + return graph + + upgraded = { + "dag_variant": "research_parallel_implications", + "research_mode": True, + "semantic_upgrade": { + "enabled": True, + "mode": "section_family_research_parallel", + "trigger_families": sorted( + [ + family + for family in ("interface_contract", "quality", "risk_review", "verification") + if family_counts.get(family) + ] + ), + "family_counts": family_counts, + }, + "evidence_policy": { + "ledger_required": True, + "unsupported_claim_guard": True, + "citation_required": True, + }, + "required_gates": ["G_SOURCE", "G_EVIDENCE", "G_SYNTHESIS", "G_REVIEW"], + "quality_gates": { + "parallelism": { + "min_ready_width": 1, + } + }, + "nodes": [ + { + "id": "R1", + "goal": "Ingest papers, links, and source metadata into the research run.", + "logical_operator": "ResearchScout", + "depends_on": [], + "gate": "G_SOURCE", + "acceptance": ["Source manifest is recorded."], + "estimated_cost": 1, + }, + { + "id": "R2", + "goal": "Extract claims, findings, and technical levers from the research corpus.", + "logical_operator": "ResearchScout", + "depends_on": ["R1"], + "gate": "G_EVIDENCE", + "acceptance": ["Claims ledger is produced."], + "estimated_cost": 2, + }, + { + "id": "R3", + "goal": "Scan contradictions, risk boundaries, and unsupported assumptions across the corpus.", + "logical_operator": "Critic", + "depends_on": ["R2"], + "gate": "G_EVIDENCE", + "acceptance": ["Contradictions, risk boundaries, and evidence gaps are enumerated."], + "estimated_cost": 2, + }, + { + "id": "R4", + "goal": "Synthesize core research findings into actionable system and implementation insights.", + "logical_operator": "ResearchSynthesizer", + "depends_on": ["R2", "R3"], + "gate": "G_SYNTHESIS", + "acceptance": ["Core synthesis is drafted with evidence-backed implications."], + "estimated_cost": 3, + }, + { + "id": "R5", + "goal": "Derive interface, contract, and implementation-implication slices from the research evidence.", + "logical_operator": "ResearchSynthesizer", + "depends_on": ["R2", "R3"], + "gate": "G_SYNTHESIS", + "outputs": ["interface_implications.md"], + "validation": [{"kind": "artifact", "target": "interface_implications.md", "required": True}], + "acceptance": ["Interface and implementation implication slices are explicit."], + "estimated_cost": 2, + }, + { + "id": "R6", + "goal": "Perform explicit risk, verification, and adoption critique across all synthesis branches.", + "logical_operator": "Critic", + "depends_on": ["R3", "R4", "R5"], + "gate": "G_REVIEW", + "outputs": ["research_risk_review.md"], + "validation": [{"kind": "artifact", "target": "research_risk_review.md", "required": True}], + "acceptance": ["Risk, verification, and adoption critique is explicit and evidence-backed."], + "estimated_cost": 2, + }, + { + "id": "R7", + "goal": "Perform independent verifier review and record the adoption decision.", + "logical_operator": "Verifier", + "depends_on": ["R4", "R5", "R6"], + "gate": "G_REVIEW", + "outputs": ["research_verifier_decision.yaml"], + "validation": [{"kind": "artifact", "target": "research_verifier_decision.yaml", "required": True}], + "acceptance": ["Verifier decision is grounded in synthesis, interface, and critique branches."], + "estimated_cost": 2, + }, + { + "id": "R8", + "goal": "Compile final PRD, DAG, and implementation implications after research closeout.", + "logical_operator": "ArtifactCurator", + "depends_on": ["R7"], + "gate": "G_REVIEW", + "outputs": ["final_prd_implications.md"], + "validation": [{"kind": "artifact", "target": "final_prd_implications.md", "required": True}], + "acceptance": ["Final implementation implications package is produced."], + "estimated_cost": 1, + }, + ], + } + return upgraded + + def build_pm_intake( text: str, *, @@ -1331,12 +1796,17 @@ def build_pm_intake( goal_text = effective_text["goal_text"] or compile_text problem_text = effective_text["problem_text"] or compile_text raw_user_text = effective_text["raw_user_text"] or compile_text + enhanced_requirement_text = effective_text.get("enhanced_requirement_text") or "" + enhanced_requirement_sections = effective_text.get("enhanced_requirement_sections") or [] + section_semantic_hints = _build_section_semantic_hints(enhanced_requirement_sections) request_type = classify_request_type(compile_text, papers) canonical_request_type = CLASS_TO_CANONICAL[request_type] lane_hint = choose_lane_hint(request_type, compile_text) output_mode = choose_output_mode(request_type) priority = choose_priority(compile_text, request_type) task_graph = build_task_graph_skeleton(request_type, lane_hint, compile_text) + task_graph = _upgrade_standard_graph_for_section_semantics(task_graph, section_semantic_hints, request_type) + task_graph = _upgrade_research_graph_for_section_semantics(task_graph, section_semantic_hints, request_type) if _is_code_understanding_request(compile_text, repo_context): task_graph = _adapt_graph_for_code_understanding(task_graph, request_type) task_graph["nodes"] = [_node_enrichment(request_type, lane_hint, node) for node in task_graph["nodes"]] @@ -1357,6 +1827,26 @@ def build_pm_intake( "logs": logs, "repo_context": repo_context, } + if enhanced_requirement_text: + source_inputs["enhanced_requirement_text"] = enhanced_requirement_text + if enhanced_requirement_sections: + source_inputs["enhanced_requirement_sections"] = enhanced_requirement_sections + source_inputs["compile_segments"] = [ + { + "kind": "enhanced_requirement_section", + "section_id": hint.get("section_id"), + "heading": str(section.get("heading") or ""), + "text": ( + f"{section.get('heading')}\n{section.get('content')}".strip() + ), + "semantic_label": hint.get("semantic_label"), + "node_family": hint.get("node_family"), + "preferred_logical_operators": hint.get("preferred_logical_operators") or [], + } + for section, hint in zip(enhanced_requirement_sections, section_semantic_hints) + ] + if section_semantic_hints: + source_inputs["enhanced_requirement_semantic_hints"] = section_semantic_hints prd_view = _make_prd_view( canonical_request_type, normalized_goal, @@ -1416,8 +1906,11 @@ def build_pm_intake( "evidence_policy": task_graph.get("evidence_policy", {}), } task_graph = _apply_requirement_mapping(task_graph, requirements, request_type) + task_graph = _annotate_task_graph_with_section_semantics(task_graph, section_semantic_hints) task_graph = enrich_task_graph_defaults(task_graph, requirement_ir, sprint_id=sprint_id or "N/A") requirement_ir["dag_view"] = task_graph + if section_semantic_hints: + requirement_ir["section_semantic_plan"] = task_graph.get("section_semantic_plan", {}) product_brief = { "title": title, "source": "codex-pm-router", diff --git a/harness/tools/gemini_deep_research_operator.py b/harness/tools/gemini_deep_research_operator.py index b9ad87467..61864f0c9 100755 --- a/harness/tools/gemini_deep_research_operator.py +++ b/harness/tools/gemini_deep_research_operator.py @@ -5,7 +5,6 @@ import json import os import shlex -import subprocess import sys import time from copy import deepcopy @@ -17,6 +16,8 @@ sys.path.insert(0, str(ROOT / "lib")) import operator_flow_control as ofc # noqa: E402 +from browser_operator_submit import submit_gemini_operator_request # noqa: E402 +from browser_agent_session_control import collect_request, submit_request # noqa: E402 DEFAULT_OPERATOR_ID = "mini-gemini-deep-research" DEFAULT_PROJECT_NAME = "杂项" @@ -168,6 +169,95 @@ def _summary_markdown(response: dict[str, Any]) -> str: ) +def _session_control_enabled() -> bool: + disabled = str(os.environ.get("BROWSER_AGENT_SESSION_CONTROL_DISABLED") or "").strip().lower() + return disabled not in {"1", "true", "yes", "on"} + + +def _submitted_run_path(request_dir: str) -> Path: + return Path(request_dir).expanduser() / "submitted-run.json" + + +def _write_submitted_run( + request_dir: str, + *, + task_id: str, + status_payload: dict[str, Any], +) -> None: + latest_result = status_payload.get("latest_result") if isinstance(status_payload.get("latest_result"), dict) else {} + payload = { + "task_id": task_id, + "status": str(status_payload.get("status") or ""), + "result_file": str(latest_result.get("result_file") or ""), + } + _submitted_run_path(request_dir).write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + +def _load_submitted_task_id(request_dir: str) -> str: + path = _submitted_run_path(request_dir) + if not path.exists(): + return "" + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return "" + if isinstance(data, dict): + return str(data.get("task_id") or "").strip() + return "" + + +def _extract_text_from_status_payload(status_payload: dict[str, Any]) -> str: + latest_result = status_payload.get("latest_result") if isinstance(status_payload.get("latest_result"), dict) else {} + result_file = Path(str(latest_result.get("result_file") or "")).expanduser() + if not result_file.exists(): + return "" + try: + result_json = json.loads(result_file.read_text(encoding="utf-8")) + except Exception: + return "" + if isinstance(result_json, dict): + return str(result_json.get("text") or "").strip() + return "" + + +def _run_via_session_control( + *, + prompt: str, + request: dict[str, Any], + timeout: int, +) -> str: + request_dir = str(request.get("request_dir") or "").strip() + task_id = str(os.environ.get("BROWSER_AGENT_SESSION_TASK_ID") or "").strip() or _load_submitted_task_id(request_dir) + if not task_id: + task_id = f"gemini-deep-research-{int(time.time())}" + submit_payload = submit_request( + request, + logical_operator="DeepResearchGemini", + objective=str(prompt[:120] or "gemini-deep-research"), + task_id=task_id, + request_field="gemini_deep_research_request", + ) + if not submit_payload.get("success"): + raise RuntimeError(str(submit_payload.get("error") or "gemini_deep_research_operator: submit failed")) + rc, status_payload = collect_request( + task_id, + timeout_seconds=timeout, + poll_interval_seconds=1.0, + terminal_statuses={"completed", "failed"}, + ) + if request_dir: + _write_submitted_run(request_dir, task_id=task_id, status_payload=status_payload) + output = _extract_text_from_status_payload(status_payload) + if rc != 0: + raise RuntimeError(output or str((status_payload.get("latest_result") or {}).get("error") or "gemini_deep_research_operator: collect failed")) + if not output: + raise RuntimeError("gemini_deep_research_operator: empty session control output") + return output + + def run_request(request: dict[str, Any], *, task_dir: Path) -> dict[str, Any]: prompt = str(request.get("prompt") or "").strip() if not prompt: @@ -201,21 +291,38 @@ def run_request(request: dict[str, Any], *, task_dir: Path) -> dict[str, Any]: for attempt in range(1, max_retries + 1): print(f"[Gemini Deep Research Operator] Starting execution attempt {attempt} of {max_retries}...", flush=True) try: - proc = subprocess.run( - cmd, - input=prompt, - text=True, - capture_output=True, - env=env, - timeout=timeout, - ) - combined = ((proc.stdout or "") + "\n" + (proc.stderr or "")).strip() + if _session_control_enabled(): + combined = _run_via_session_control( + prompt=prompt, + request={ + "prompt": prompt, + "expected_output": str(request.get("expected_output") or "markdown"), + "project_name": str(request.get("project_name") or DEFAULT_PROJECT_NAME), + "request_dir": str(request_dir), + "headless": str(env.get("BROWSER_AGENT_HEADLESS") or "true").strip().lower() != "false", + "session_reuse": str(env.get("BROWSER_AGENT_SESSION_REUSE") or "true").strip().lower() != "false", + "session_lineage": str( + os.environ.get("BROWSER_AGENT_SESSION_LINEAGE") + or os.environ.get("SOLAR_BROWSER_SESSION_LINEAGE") + or f"gemini-deep-research:{request_dir.name}" + ).strip(), + }, + timeout=timeout, + ) + else: + submit_gemini_operator_request( + cmd=cmd, + prompt=prompt, + timeout=timeout, + env=env, + request_dir=request_dir, + ) + stdout_path = request_dir / "stdout.txt" + combined = stdout_path.read_text(encoding="utf-8").strip() if stdout_path.exists() else "" (task_dir / f"gemini-deep-research-output-attempt{attempt}.txt").write_text( combined + ("\n" if combined else ""), encoding="utf-8", ) - if proc.returncode != 0: - raise RuntimeError(f"Wrapper exited with code {proc.returncode}. Log snippet:\n{combined[-1000:]}") # Read output artifacts generated by wrapper page_json_path = request_dir / "page.json" diff --git a/harness/tools/technology_diagram_painter_operator.py b/harness/tools/technology_diagram_painter_operator.py index b02778f8a..ef4cc5b84 100755 --- a/harness/tools/technology_diagram_painter_operator.py +++ b/harness/tools/technology_diagram_painter_operator.py @@ -348,7 +348,7 @@ def run_request(request: dict[str, Any], *, task_dir: Path, operator_id: str = D env = os.environ.copy() if "BROWSER_AGENT_HEADLESS" not in env: - env["BROWSER_AGENT_HEADLESS"] = "false" + env["BROWSER_AGENT_HEADLESS"] = "true" env.update({ "BROWSER_AGENT_REQUEST_DIR": str(request_dir), "BROWSER_AGENT_TIMEOUT": str(request.get("timeout_seconds") or 600), diff --git a/harness/tools/youtube_transcript_operator.py b/harness/tools/youtube_transcript_operator.py index a68a6724a..dbb66fdf1 100644 --- a/harness/tools/youtube_transcript_operator.py +++ b/harness/tools/youtube_transcript_operator.py @@ -10,7 +10,6 @@ import os import re import shlex -import subprocess import sys import time from copy import deepcopy @@ -22,6 +21,7 @@ sys.path.insert(0, str(ROOT / "lib")) import operator_flow_control as ofc # noqa: E402 +from browser_operator_submit import submit_youtube_operator_request # noqa: E402 DEFAULT_OPERATOR_ID = "mini-youtube-transcript-extractor" DEFAULT_WRAPPER = ROOT / "scripts" / "browser_agent_youtube_transcript_wrapper.py" @@ -199,7 +199,7 @@ def run_request(request: dict[str, Any], *, task_dir: Path) -> dict[str, Any]: env = os.environ.copy() if "BROWSER_AGENT_HEADLESS" not in env: - env["BROWSER_AGENT_HEADLESS"] = "false" + env["BROWSER_AGENT_HEADLESS"] = "true" env.setdefault("BROWSER_AGENT_PROFILE_DIRECTORY", "Default") env.setdefault("BROWSER_AGENT_TARGET_ACCOUNT_EMAIL", "browser-agent@example.com") env.update({ @@ -214,21 +214,19 @@ def run_request(request: dict[str, Any], *, task_dir: Path) -> dict[str, Any]: for attempt in range(1, max_retries + 1): print(f"[YT Transcript Operator] Starting execution attempt {attempt} of {max_retries}...", flush=True) try: - proc = subprocess.run( - cmd, - input=youtube_url, - text=True, - capture_output=True, - env=env, + submit_youtube_operator_request( + cmd=cmd, + youtube_url=youtube_url, timeout=timeout, + env=env, + request_dir=request_dir, ) - combined = ((proc.stdout or "") + "\n" + (proc.stderr or "")).strip() + stdout_path = request_dir / "stdout.txt" + combined = stdout_path.read_text(encoding="utf-8").strip() if stdout_path.exists() else "" (task_dir / f"youtube-transcript-output-attempt{attempt}.txt").write_text( combined + ("\n" if combined else ""), encoding="utf-8", ) - if proc.returncode != 0: - raise RuntimeError(f"Wrapper exited with code {proc.returncode}. Log snippet:\n{combined[-1000:]}") # Read output artifacts generated by wrapper assistant_response_path = request_dir / "assistant-response.txt"