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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions automated-loop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion automated-loop/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down
1 change: 1 addition & 0 deletions automated-loop/loop_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
126 changes: 123 additions & 3 deletions automated-loop/research_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -234,14 +237,15 @@ 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,
project_path: str | Path,
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,
Expand All @@ -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()
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -714,14 +827,20 @@ 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"],
help="Query mode (playwright only)",
)
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"],
Expand All @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion automated-loop/tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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

Expand Down
40 changes: 40 additions & 0 deletions automated-loop/tests/test_research_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down