diff --git a/docs/configuration.md b/docs/configuration.md index ea8af924..26ef1c57 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -182,6 +182,9 @@ dashboard_output = ".agentpack/learning-dashboard.html" team_lessons_output = ".agentpack/team-lessons.md" provider_command = "" provider_timeout_seconds = 60 +concept_provider_command = "" +concept_provider_timeout_seconds = 30 +concept_provider_required = false inject_agent_lessons = true max_changed_files = 20 max_diff_chars_per_file = 1200 @@ -196,8 +199,15 @@ claim-level citation coverage: generated summaries, decisions, risks, tests, lessons, cards, topics, and skill evidence should cite source files with line anchors where available. Learning artifacts are local by default: no hosted service is called, diffs are bounded, and secret redaction runs before diff text -is used. `provider_command` is opt-in and runs a local JSON-in/JSON-out command with the bounded report payload on -stdin. +is used. `provider_command` and `concept_provider_command` are opt-in local +JSON-in/JSON-out commands that receive the bounded report payload on stdin. + +A practical You.com setup is to point `provider_command` at a small script such +as `python scripts/youcom_research_provider.py`. That script can read the +current learning report, call the You.com Research API with `YDC_API_KEY`, and +return extra `summary`, `learning_topics`, `concepts`, or `next_practice` fields +without changing the default offline flow. + Feedback-aware skill memory and practice drills are stored locally in `skill_map_output` and `feedback_output`; shared team learning should export only selected lessons or taxonomy files, not personal skill history. Use diff --git a/scripts/youcom_research_provider.py b/scripts/youcom_research_provider.py new file mode 100644 index 00000000..f17681b8 --- /dev/null +++ b/scripts/youcom_research_provider.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import json +import os +import sys +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + + +API_URL = "https://api.you.com/v1/research" +DEFAULT_EFFORT = "standard" + + +def build_prompt(payload: dict[str, Any]) -> str: + task = str(payload.get("task", "")).strip() + current = payload.get("current_report") or {} + source_files = current.get("source_files") or [] + + parts: list[str] = [] + if task: + parts.append(f"Task: {task}") + if source_files: + files = ", ".join( + f'{item.get("path", "").strip()} ({", ".join(item.get("concepts", [])[:3])})'.strip() + for item in source_files + if isinstance(item, dict) and item.get("path") + ) + if files: + parts.append(f"Changed files: {files}") + parts.append( + "Use live web sources to surface any relevant docs, maintainer guidance, or implementation details " + "that would help a developer work safely on this task. Keep the answer concise and practical." + ) + return "\n\n".join(parts) + + +def enrich_with_research(payload: dict[str, Any]) -> dict[str, Any]: + api_key = os.environ.get("YDC_API_KEY", "").strip() + if not api_key: + raise RuntimeError("YDC_API_KEY environment variable is required") + + research_body = json.dumps({"input": build_prompt(payload), "research_effort": DEFAULT_EFFORT}).encode("utf-8") + request = Request( + API_URL, + data=research_body, + headers={"X-API-Key": api_key, "Content-Type": "application/json"}, + method="POST", + ) + try: + with urlopen(request, timeout=60) as response: + raw = response.read().decode("utf-8") + except HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") if exc.fp else str(exc) + raise RuntimeError(f"You.com Research API error {exc.code}: {detail}") from exc + except URLError as exc: + raise RuntimeError(f"You.com Research API error: {exc.reason}") from exc + + data = json.loads(raw) + content = ((data.get("output") or {}).get("content") or "").strip() + sources = (data.get("output") or {}).get("sources") or [] + if not content: + raise RuntimeError("You.com Research API returned no content") + + summary = [content.splitlines()[0].lstrip("# ").strip() or "Live research notes"] + if sources: + summary.append(f"Live sources reviewed: {len(sources)}") + + topics: list[dict[str, Any]] = [] + if sources: + topics.append( + { + "title": "Live research follow-up", + "why": "Use the cited sources to confirm external implementation guidance before changing the code.", + "prompt": content[:1200], + "files": [item.get("url", "") for item in sources if isinstance(item, dict) and item.get("url")], + "concepts": ["live web research", "cited sources"], + } + ) + + return { + "summary": summary, + "learning_topics": topics, + "concepts": ["live web research", "cited sources"], + "next_practice": "Use the cited sources as a quick sanity check before implementing any externally documented behavior.", + } + + +def main() -> int: + payload = json.load(sys.stdin) + override = enrich_with_research(payload) + json.dump(override, sys.stdout, indent=2, sort_keys=True) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_youcom_research_provider.py b/tests/test_youcom_research_provider.py new file mode 100644 index 00000000..5a29dd0b --- /dev/null +++ b/tests/test_youcom_research_provider.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from scripts.youcom_research_provider import build_prompt + + +def test_build_prompt_includes_task_and_files() -> None: + prompt = build_prompt( + { + "task": "add web-grounded learning notes", + "current_report": { + "source_files": [ + {"path": "src/agentpack/commands/learn.py", "concepts": ["provider command"]}, + {"path": "src/agentpack/learning/provider.py", "concepts": ["JSON command"]}, + ] + }, + } + ) + + assert "Task: add web-grounded learning notes" in prompt + assert "src/agentpack/commands/learn.py (provider command)" in prompt + assert "src/agentpack/learning/provider.py (JSON command)" in prompt + assert "live web sources" in prompt