diff --git a/automated-loop/README.md b/automated-loop/README.md index 3c7517a..81951d8 100644 --- a/automated-loop/README.md +++ b/automated-loop/README.md @@ -187,6 +187,7 @@ The loop uses sensible defaults and works out of the box. For customization, cre "perplexity": { "research_timeout_seconds": 600, "headful": true, + "provider": "perplexity", "perplexity_mode": "research" }, "claude": { @@ -203,6 +204,15 @@ The loop uses sensible defaults and works out of the box. For customization, cre } ``` +To use You.com instead of the default Playwright-based Perplexity flow, set: + +```bash +export AUTOMATED_LOOP_RESEARCH_PROVIDER=youcom +export YDC_API_KEY=your-key-here +``` + +The integration stays optional. If the provider is not set, existing Perplexity behavior remains unchanged. + All fields are optional — unspecified values use defaults. The `"model": "sonnet"` default is recommended for most workloads. ## Exit Codes diff --git a/automated-loop/config.py b/automated-loop/config.py index c253eb2..e15033d 100644 --- a/automated-loop/config.py +++ b/automated-loop/config.py @@ -72,10 +72,11 @@ class LimitsConfig(BaseModel): class PerplexityConfig(BaseModel): - """Perplexity research settings (Playwright browser automation).""" + """Research provider settings.""" research_timeout_seconds: int = Field(default=600, ge=60) headful: bool = Field(default=True) + provider: str = Field(default="perplexity") perplexity_mode: str = Field(default="research") diff --git a/automated-loop/loop_driver.py b/automated-loop/loop_driver.py index 80d5998..1f43fa1 100644 --- a/automated-loop/loop_driver.py +++ b/automated-loop/loop_driver.py @@ -95,6 +95,7 @@ def __init__( retry_config=config.retry, research_timeout=config.perplexity.research_timeout_seconds, headful=config.perplexity.headful, + provider=config.perplexity.provider, perplexity_mode=config.perplexity.perplexity_mode, exploration_config=config.exploration, verification_config=config.verification, diff --git a/automated-loop/research_bridge.py b/automated-loop/research_bridge.py index 84e8534..bf22aa5 100644 --- a/automated-loop/research_bridge.py +++ b/automated-loop/research_bridge.py @@ -10,10 +10,13 @@ import argparse import json import logging +import os import random import subprocess import sys import time +import urllib.error +import urllib.request from datetime import datetime, timezone from pathlib import Path from typing import Optional @@ -234,7 +237,7 @@ def _get_git_log(self) -> Optional[str]: class ResearchBridge: - """Queries Perplexity via Playwright browser automation with project context.""" + """Queries a research provider with project context.""" def __init__( self, @@ -242,6 +245,7 @@ def __init__( retry_config: Optional[RetryConfig] = None, research_timeout: int = 600, headful: bool = True, + provider: str = "perplexity", perplexity_mode: str = "research", exploration_config: Optional[ExplorationConfig] = None, verification_config: Optional[VerificationConfig] = None, @@ -251,6 +255,7 @@ def __init__( self.retry_config = retry_config or RetryConfig() self.research_timeout = research_timeout self.headful = headful + self.provider = provider self.perplexity_mode = perplexity_mode self.exploration_config = exploration_config or ExplorationConfig() self.verification_config = verification_config or VerificationConfig() @@ -443,11 +448,14 @@ def _single_query( codebase_context: Optional[dict[str, str]] = None, focus_area: Optional[str] = None, ) -> Result[ResearchResult]: - """Execute a single research query via Playwright browser automation (no retry).""" + """Execute a single research query (no retry).""" query_text = self.build_query( extra_context, codebase_context=codebase_context, focus_area=focus_area, ) + if self.provider == "youcom": + return self._single_youcom_query(query_text) + try: cmd = [ sys.executable, str(COUNCIL_BROWSER_SCRIPT), @@ -504,6 +512,111 @@ def _single_query( except Exception as e: return Result.fail(f"Research query failed: {e}", "QUERY_ERROR") + def _single_youcom_query(self, query_text: str) -> Result[ResearchResult]: + """Execute a single research query via You.com Research API.""" + api_key = os.environ.get("YDC_API_KEY") + if not api_key: + return Result.fail( + "YDC_API_KEY is required when AUTOMATED_LOOP_RESEARCH_PROVIDER=youcom", + "MISSING_API_KEY", + ) + + payload = json.dumps({ + "input": query_text, + "research_effort": "standard", + }).encode("utf-8") + request = urllib.request.Request( + "https://api.you.com/v1/research", + data=payload, + headers={ + "Content-Type": "application/json", + "X-API-Key": api_key, + }, + method="POST", + ) + + try: + with urllib.request.urlopen(request, timeout=self.research_timeout) as response: + data = json.loads(response.read().decode("utf-8")) + + output = data.get("output", {}) + content = output.get("content", "") + if not content: + return Result.fail("Empty response from You.com research", "PARSE_ERROR") + + sources = output.get("sources") or data.get("sources") or [] + response = self._format_youcom_response(content, sources) + + research_result = ResearchResult( + query=query_text[:500], + response=response, + model="youcom-research-standard", + ) + self._save_result(research_result) + return Result.ok(research_result) + except urllib.error.HTTPError as e: + details = e.read().decode("utf-8", errors="replace") + return Result.fail( + f"You.com API request failed ({e.code}): {details}", + "API_ERROR", + ) + except urllib.error.URLError as e: + return Result.fail(f"You.com API request failed: {e}", "QUERY_ERROR") + except json.JSONDecodeError as e: + return Result.fail(f"Invalid JSON from You.com API: {e}", "PARSE_ERROR") + except TimeoutError: + return Result.fail( + f"You.com research timed out ({self.research_timeout}s)", "TIMEOUT" + ) + except Exception as e: + return Result.fail(f"You.com research query failed: {e}", "QUERY_ERROR") + + def _format_youcom_response(self, content: str, sources: object) -> str: + """Append a readable source list to You.com research output. + + You.com returns inline citation markers in the synthesized answer, plus + a structured `sources` array. Keeping both gives readers the answer and + the provenance without forcing them to infer what the markers mean. + """ + normalized_sources = self._normalize_youcom_sources(sources) + if not normalized_sources: + return content + + lines = [content.rstrip(), "", "## Sources"] + for index, source in enumerate(normalized_sources, start=1): + title = source.get("title") or source.get("url") or f"Source {index}" + url = source.get("url") + if url and title != url: + lines.append(f"{index}. {title} - {url}") + elif url: + lines.append(f"{index}. {url}") + else: + lines.append(f"{index}. {title}") + + return "\n".join(lines).rstrip() + + def _normalize_youcom_sources(self, sources: object) -> list[dict[str, str]]: + """Normalize the You.com `sources` payload into simple display rows.""" + if not isinstance(sources, list): + return [] + + normalized: list[dict[str, str]] = [] + for item in sources: + if not isinstance(item, dict): + continue + + row: dict[str, str] = {} + title = item.get("title") + url = item.get("url") + if isinstance(title, str) and title.strip(): + row["title"] = title.strip() + if isinstance(url, str) and url.strip(): + row["url"] = url.strip() + if row: + normalized.append(row) + + return normalized + def verify_plan( self, plan_text: str, @@ -714,7 +827,7 @@ def _save_result(self, result: ResearchResult) -> None: def main() -> None: """CLI entry point for standalone research bridge usage.""" - parser = argparse.ArgumentParser(description="Query Perplexity for project next steps") + parser = argparse.ArgumentParser(description="Query a research provider for project next steps") parser.add_argument("--project", default=".", help="Project directory path") parser.add_argument( "--mode", default="playwright", choices=["playwright"], @@ -722,6 +835,12 @@ def main() -> None: ) parser.add_argument("--context", default=None, help="Extra context to include") parser.add_argument("--headful", action="store_true", help="Run browser in visible mode") + parser.add_argument( + "--provider", + default=os.environ.get("AUTOMATED_LOOP_RESEARCH_PROVIDER", "perplexity"), + choices=["perplexity", "youcom"], + help="Research provider to use", + ) parser.add_argument( "--perplexity-mode", default="research", choices=["research", "council", "labs"], @@ -741,6 +860,7 @@ def main() -> None: bridge = ResearchBridge( args.project, headful=args.headful, + provider=args.provider, perplexity_mode=args.perplexity_mode, ) result = bridge.query(extra_context=args.context) diff --git a/automated-loop/tests/test_config.py b/automated-loop/tests/test_config.py index e29dd76..4198455 100644 --- a/automated-loop/tests/test_config.py +++ b/automated-loop/tests/test_config.py @@ -19,6 +19,7 @@ def test_defaults(self) -> None: assert config.limits.max_iterations == 50 assert config.limits.timeout_seconds == 300 assert config.perplexity.research_timeout_seconds == 600 + assert config.perplexity.provider == "perplexity" assert config.perplexity.headful is True assert config.perplexity.perplexity_mode == "research" assert config.claude.model == "sonnet" @@ -95,12 +96,13 @@ def test_load_empty_object_uses_defaults(self, config_dir: Path) -> None: def test_load_partial_config_fills_defaults(self, config_dir: Path) -> None: config_file = config_dir / "config.json" config_file.write_text( - json.dumps({"perplexity": {"perplexity_mode": "labs"}}), + json.dumps({"perplexity": {"provider": "youcom", "perplexity_mode": "labs"}}), encoding="utf-8", ) result = load_config(config_file) assert result.success + assert result.data.perplexity.provider == "youcom" assert result.data.perplexity.perplexity_mode == "labs" assert result.data.limits.max_iterations == 50 # default diff --git a/automated-loop/tests/test_research_bridge.py b/automated-loop/tests/test_research_bridge.py index d0b062a..a7b11aa 100644 --- a/automated-loop/tests/test_research_bridge.py +++ b/automated-loop/tests/test_research_bridge.py @@ -202,6 +202,46 @@ def test_empty_synthesis_response(self, mock_run: MagicMock, research_project_di assert not result.success assert result.error_code == "PARSE_ERROR" + @patch.dict("os.environ", {}, clear=True) + def test_youcom_requires_api_key(self, research_project_dir: Path) -> None: + bridge = ResearchBridge(research_project_dir, provider="youcom") + + result = bridge.query() + + assert not result.success + assert result.error_code == "MISSING_API_KEY" + + @patch.dict("os.environ", {"YDC_API_KEY": "test-key"}, clear=True) + @patch("research_bridge.urllib.request.urlopen") + def test_successful_youcom_query( + self, mock_urlopen: MagicMock, research_project_dir: Path, + ) -> None: + response = MagicMock() + response.read.return_value = json.dumps({ + "output": { + "content": "Next steps: use You.com research [[1]]", + "content_type": "text", + "sources": [ + { + "title": "You.com Research API docs", + "url": "https://docs.you.com/", + } + ], + } + }).encode("utf-8") + mock_urlopen.return_value.__enter__.return_value = response + + bridge = ResearchBridge(research_project_dir, provider="youcom") + result = bridge.query() + + assert result.success + assert result.data is not None + assert "You.com research" in result.data.response + assert "## Sources" in result.data.response + assert "You.com Research API docs" in result.data.response + assert "https://docs.you.com/" in result.data.response + assert result.data.model == "youcom-research-standard" + class TestRetryAndCircuitBreaker: """Tests for retry, backoff, and circuit breaker logic."""