diff --git a/.github/workflows/cloud-agent.yml b/.github/workflows/cloud-agent.yml index 25cc48c..b34a94e 100644 --- a/.github/workflows/cloud-agent.yml +++ b/.github/workflows/cloud-agent.yml @@ -61,8 +61,9 @@ jobs: INCLUDE_MAINTENANCE_CONTEXT: ${{ vars.INCLUDE_MAINTENANCE_CONTEXT || 'false' }} INCLUDE_RUNBOOK_CONTEXT: ${{ vars.INCLUDE_RUNBOOK_CONTEXT || 'false' }} DRY_RUN_ON_BUDGET_EXCEEDED: ${{ vars.DRY_RUN_ON_BUDGET_EXCEEDED || 'true' }} - AI_GATEWAY_FALLBACK_MODELS: ${{ vars.AI_GATEWAY_FALLBACK_MODELS || 'openai/gpt-5-nano' }} - AI_GATEWAY_SCREEN_FALLBACK_MODELS: ${{ vars.AI_GATEWAY_SCREEN_FALLBACK_MODELS || 'google/gemini-2.5-flash-lite' }} + # Cross-vendor chain: one exhausted free pool must not kill primary AND fallback. + AI_GATEWAY_FALLBACK_MODELS: ${{ vars.AI_GATEWAY_FALLBACK_MODELS || 'google/gemini-2.5-flash-lite,openai/gpt-5-nano' }} + AI_GATEWAY_SCREEN_FALLBACK_MODELS: ${{ vars.AI_GATEWAY_SCREEN_FALLBACK_MODELS || 'google/gemini-2.5-flash-lite,openai/gpt-oss-120b' }} AI_GATEWAY_MAX_OUTPUT_TOKENS: ${{ vars.AI_GATEWAY_MAX_OUTPUT_TOKENS || '32768' }} # Tuned in-repo (repo Actions variables held stale lower values and # silently clamped collection breadth; see docs/release-v0.19.0.md). diff --git a/prompts/daily-update.md b/prompts/daily-update.md index 1b9624b..3395dcd 100644 --- a/prompts/daily-update.md +++ b/prompts/daily-update.md @@ -67,15 +67,13 @@ Depth spec (the runner audits these and records warnings): a wave of operator experience or a heated HN/Reddit thread IS a signal, not garnish. Vendor changelogs tell you what shipped; discussion tells you what it is actually like — the radar needs both in every block. -- **Radar Sweep: one line per remaining fresh candidate** from the injected - "Radar Sweep pool" list — format `- [class] title — one-line why | URL`. - This section is the breadth surface: everything screening surfaced that did - not earn a full bullet still gets its one-liner here (target 8+ lines when - the pool has them; English-only is acceptable, the 中文 block may keep this - section as a short pointer). Do not silently drop pool items. -- Use the full injected screening list (up to 20 top candidates plus the Radar - Sweep pool); each uncovered fresh candidate needs a Radar Sweep line, a Gaps - mention, or a research-log entry. +- **Radar Sweep is auto-generated by the runner** from the full screening pool + after your response — do NOT write section 7 content yourself (leave it empty + or a one-line placeholder). Spend your output budget on sections 1-6 and 8; + the 中文 block keeps section 7 as a short pointer. +- Use the full injected screening list (up to 20 top candidates); uncovered + fresh candidates are captured automatically by the generated Radar Sweep, so + focus your Gaps/research-log notes on what screening missed entirely. Daily direction quota (required): - At least **1 mainstream_product** signal from a real product delta (changelog/blog/release), OR an explicit Gaps bullet naming which vendors were checked and missing. GitHub star counts alone do not count. diff --git a/scripts/agent_radar.py b/scripts/agent_radar.py index 8448a2a..ecab778 100644 --- a/scripts/agent_radar.py +++ b/scripts/agent_radar.py @@ -58,7 +58,7 @@ INIT_PROTECTED_DIRS = ("prompts", "automation", "docs") -__version__ = "0.19.5" +__version__ = "0.20.0" CORE_FILES = [ "README.md", diff --git a/scripts/cloud_agent_runner.py b/scripts/cloud_agent_runner.py index a64d668..fc51aa6 100644 --- a/scripts/cloud_agent_runner.py +++ b/scripts/cloud_agent_runner.py @@ -46,6 +46,8 @@ def _load_local_module(module_name: str): OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses" GITHUB_MODELS_URL = "https://models.github.ai/inference/chat/completions" AI_GATEWAY_URL = "https://ai-gateway.vercel.sh/v1/chat/completions" +# Free-tier pools rate-limit per minute; pace calls instead of bursting. +_AI_GATEWAY_LAST_CALL = 0.0 DEFAULT_OPENAI_MODEL = "gpt-5.5" DEFAULT_GITHUB_MODEL = "openai/gpt-4o" DEFAULT_CHEAP_SCREEN_MODEL = "openai/gpt-5-nano" @@ -702,6 +704,9 @@ def _load_local_module(module_name: str): SHARED_SOURCE_STATUS: list[dict[str, str]] = [] SHARED_SOURCE_LANES: dict[str, dict[str, Any]] = {} SHARED_VENDOR_GAPS: list[str] = [] +# One-liner strings for `#### 7. Radar Sweep`, built deterministically from the +# screening pool so the synthesis model does not have to echo ~70 lines. +SHARED_SWEEP_LINES: list[str] = [] TASK_CONFIG = { @@ -1944,31 +1949,32 @@ def compact_screening_for_prompt(screen_text: str, root: Path | None = None, day for cand in candidates if isinstance(cand, dict) and candidate_dedupe_key(cand) not in shown_keys ][:sweep_limit] + SHARED_SWEEP_LINES.clear() + for cand in remaining: + title = " ".join(str(cand.get("title", "?")).split()) + signal_class = cand.get("signal_class", infer_signal_class(cand)) + evidence = cand.get("evidence", []) + url = str(evidence[0]) if isinstance(evidence, list) and evidence else "" + # Keep sweep entries strictly one line: collapse whitespace and + # hard-slice instead of truncate_text (its marker spans lines). + why = " ".join(str(cand.get("why_it_matters", "")).split())[:100].strip() + sweep_line = f"- [{signal_class}] {title}" + if why: + sweep_line += f" — {why}" + if url: + sweep_line += f" | {url}" + SHARED_SWEEP_LINES.append(sweep_line) if remaining: lines.append( - f"Radar Sweep pool ({len(remaining)} additional fresh candidates beyond the top list). " - "Cover EACH with a one-line entry in `#### 7. Radar Sweep` " - "(format: `- [class] title — one-line why | URL`; English-only is fine there):" + f"Radar Sweep: the runner auto-generates `#### 7. Radar Sweep` from the " + f"remaining {len(remaining)} screening candidates AFTER your response — " + "do NOT write section 7 content yourself (an empty section or a one-line " + "placeholder is fine); spend your output budget on sections 1-6 and 8." ) - for cand in remaining: - title = " ".join(str(cand.get("title", "?")).split()) - signal_class = cand.get("signal_class", infer_signal_class(cand)) - evidence = cand.get("evidence", []) - url = str(evidence[0]) if isinstance(evidence, list) and evidence else "" - # Keep sweep entries strictly one line: collapse whitespace and - # hard-slice instead of truncate_text (its marker spans lines). - why = " ".join(str(cand.get("why_it_matters", "")).split())[:100].strip() - sweep_line = f"- [{signal_class}] {title}" - if why: - sweep_line += f" — {why}" - if url: - sweep_line += f" | {url}" - lines.append(sweep_line) lines.append( - "Synthesis priority: cover MUST mainstream first, then actionable user_workflow " - "(concrete operator detail), then at most 2 infra_primitive emerging bullets. " - "Every Radar Sweep pool item above gets its one-liner in `#### 7. Radar Sweep` — " - "that section is the breadth surface; do not silently drop pool items." + "Synthesis priority: cover EVERY MUST mainstream candidate first — the update " + "is rejected when fewer than half are covered — then actionable user_workflow " + "(concrete operator detail), then at most 2 infra_primitive emerging bullets." ) lines.append( "Freshness/quality: prefer 24-48h product deltas (changelog/blog/release). " @@ -3428,6 +3434,80 @@ def validate_daily_section_structure(result: dict[str, Any]) -> None: RUN_AUDIT["daily_sections_canonical"] = True +_SWEEP_SECTION_RE = re.compile(r"(?ms)^#### 7\. Radar Sweep\s*\n.*?(?=^#### 8\.)") + + +def _sweep_replace_english(block: str) -> str: + lines = [ + line + for line in SHARED_SWEEP_LINES + if not (" | " in line and line.rsplit(" | ", 1)[1].strip() in block) + ] + section = "#### 7. Radar Sweep\n\n" + "\n".join(lines) + "\n\n" + if _SWEEP_SECTION_RE.search(block): + return _SWEEP_SECTION_RE.sub(lambda _m: section, block, count=1) + return re.sub(r"(?m)^(#### 8\.)", lambda m: section + m.group(1), block, count=1) + + +def _sweep_replace_chinese(block: str) -> str: + section = "#### 7. Radar Sweep\n\n(见英文部分;由采集器自动生成。)\n\n" + if _SWEEP_SECTION_RE.search(block): + return _SWEEP_SECTION_RE.sub(lambda _m: section, block, count=1) + if re.search(r"(?m)^#### 8\.", block): + return re.sub(r"(?m)^(#### 8\.)", lambda m: section + m.group(1), block, count=1) + return block + + +def inject_deterministic_radar_sweep(result: dict[str, Any]) -> int: + """Build `#### 7. Radar Sweep` in daily day blocks from the screening pool. + + Weak/free-tier synthesis models spent most of their output budget echoing + ~70 sweep one-liners (and often truncated or dropped them). The runner now + owns section 7: it replaces whatever the model wrote there (or inserts the + section before `#### 8.`) with the deterministic pool lines, skipping + candidates whose URL the model already covered in its own sections.""" + if not SHARED_SWEEP_LINES: + return 0 + injected = 0 + raw_updates = result.get("updates") + if not isinstance(raw_updates, list): + return 0 + for update in raw_updates: + if not isinstance(update, dict): + continue + rel_path = str(update.get("path", "")).replace("\\", "/") + if not is_daily_month_path(rel_path): + continue + # english_block/chinese_block payloads: inject into each part directly. + if isinstance(update.get("english_block"), str): + block = str(update["english_block"]) + if "#### 8." not in block: + continue + update["english_block"] = _sweep_replace_english(block) + zh = update.get("chinese_block") + if isinstance(zh, str): + update["chinese_block"] = _sweep_replace_chinese(zh) + injected += 1 + continue + content = str(update.get("content", "")) + if "### English" not in content or "#### 8." not in content: + continue + english_end = content.find("### 中文") + english = content[:english_end] if english_end != -1 else content + if english_end != -1: + update["content"] = _sweep_replace_english(english) + _sweep_replace_chinese( + content[english_end:] + ) + else: + update["content"] = _sweep_replace_english(content) + injected += 1 + if injected: + RUN_AUDIT["apply_warnings"].append( + f"Radar Sweep auto-generated from screening pool ({len(SHARED_SWEEP_LINES)} line(s) available)" + ) + return injected + + def audit_daily_depth(result: dict[str, Any]) -> None: """Soft depth/coverage audit: count signals, flag shallow bullets, check the Storage/Infra Angle carries watch triggers. Warnings + telemetry only — @@ -3909,6 +3989,7 @@ def validate_synthesis_result( f"MIN_MAINSTREAM_RECALL ({min_mainstream}). " "Cover high-confidence mainstream candidates before emerging repos." ) + inject_deterministic_radar_sweep(result) validate_daily_direction_quota(result) validate_must_cover_mainstream(result, screen_text, root=root, day=day) validate_daily_section_structure(result) @@ -5492,12 +5573,21 @@ def call_ai_gateway_model(prompt: str, model: str) -> dict[str, Any]: "AI_GATEWAY_MAX_OUTPUT_TOKENS", DEFAULT_AI_GATEWAY_MAX_OUTPUT_TOKENS ), } + global _AI_GATEWAY_LAST_CALL last_error = "" # 400/404/409 are client errors: replaying the same payload against a # fallback model will not help, so only retry genuinely transient statuses. retryable_status = {408, 429, 500, 502, 503, 504} models = ai_gateway_fallback_models(model) - for attempt, candidate_model in enumerate(models): + # Free-tier 429s are per-minute quotas that refill: walk the (cross-pool) + # chain several rounds with real backoff instead of giving up after one + # pass (Issue #76: primary and fallback shared one exhausted free pool). + rounds = max(1, env_int("AI_GATEWAY_429_ROUNDS", 3)) + base_sleep = max(1, env_int("AI_GATEWAY_429_BASE_SLEEP", 30)) + call_interval = max(0, env_int("AI_GATEWAY_CALL_INTERVAL", 10)) + retry_after_hint = 0 + attempts = [(r, m) for r in range(rounds) for m in models] + for attempt, (round_index, candidate_model) in enumerate(attempts): payload["model"] = candidate_model audit_model(candidate_model) if candidate_model != model: @@ -5505,8 +5595,18 @@ def call_ai_gateway_model(prompt: str, model: str) -> dict[str, Any]: reason = last_error.split(":", 1)[0] if last_error else "transient failure" print(f"AI Gateway fallback {model}->{candidate_model}: {reason}") if attempt > 0: - # Brief backoff before retrying a transient failure. - time.sleep(min(8, 2 ** attempt)) + if "429" in last_error: + wait = max(retry_after_hint, base_sleep * (round_index + 1)) + time.sleep(min(180, wait)) + else: + # Brief backoff before retrying a transient failure. + time.sleep(min(8, 2 ** min(attempt, 3))) + # Global pacing: keep a minimum gap between gateway calls so bursts + # (4 screening shards + synthesis + audits) do not self-inflict 429s. + since_last = time.monotonic() - _AI_GATEWAY_LAST_CALL + if call_interval and 0 <= since_last < call_interval: + time.sleep(call_interval - since_last) + _AI_GATEWAY_LAST_CALL = time.monotonic() request = urllib.request.Request( AI_GATEWAY_URL, data=json.dumps(payload).encode("utf-8"), @@ -5519,6 +5619,10 @@ def call_ai_gateway_model(prompt: str, model: str) -> dict[str, Any]: except urllib.error.HTTPError as exc: body = redact_http_error_body(exc.read().decode("utf-8", errors="replace")) last_error = f"Vercel AI Gateway API error for {candidate_model} ({exc.code}): {body}" + try: + retry_after_hint = int(str(exc.headers.get("Retry-After", "0")).strip() or 0) + except (TypeError, ValueError): + retry_after_hint = 0 if exc.code not in retryable_status: break continue diff --git a/tests/test_cloud_agent_runner.py b/tests/test_cloud_agent_runner.py index 7c2b4b1..250cb0e 100644 --- a/tests/test_cloud_agent_runner.py +++ b/tests/test_cloud_agent_runner.py @@ -821,12 +821,14 @@ def test_compact_screening_for_prompt_is_smaller_than_raw_json(self) -> None: raw, day=cloud_agent_runner.parse_date("2026-07-02"), ) - # The compact form now carries a Radar Sweep pool for breadth, so it is - # only required to be smaller than the raw JSON, not half its size. + # v0.20: the sweep pool is stashed for deterministic generation instead + # of being echoed through the prompt, so compact shrinks again. self.assertLess(len(compact), len(raw)) self.assertIn("direction-diversified", compact) self.assertIn("Signal-class coverage", compact) - self.assertIn("Radar Sweep pool (17", compact) + self.assertIn("auto-generates `#### 7. Radar Sweep`", compact) + self.assertNotIn("- [infra_primitive] Candidate 4", compact) + self.assertEqual(len(cloud_agent_runner.SHARED_SWEEP_LINES), 17) self.assertIn("no mainstream_product candidates", compact) self.assertIn("automation/screening/2026-07-02.json", compact) @@ -2502,6 +2504,71 @@ def test_ai_gateway_accepts_fenced_json_without_fallback(self) -> None: self.assertEqual(json.loads(cloud_agent_runner.response_output_text(parsed))["summary"], "ok") urlopen_mock.assert_called_once() + def test_ai_gateway_429_walks_rounds_with_backoff(self) -> None: + # Issue #76: one pass over a same-pool chain died on free-tier 429s. + # The loop must walk the chain for several rounds with real backoff. + import io + sleeps: list[float] = [] + good = mock.MagicMock() + good.__enter__.return_value.read.return_value = json.dumps( + {"choices": [{"message": {"content": "{\"summary\": \"ok\"}"}}]} + ).encode("utf-8") + err = urllib.error.HTTPError( + "https://ai-gateway.vercel.sh", 429, "rate limited", + {"Retry-After": "45"}, io.BytesIO(b"rate limited"), + ) + with mock.patch.dict( + os.environ, + { + "AI_GATEWAY_API_KEY": "x", + "AI_GATEWAY_FALLBACK_MODELS": "model-b", + "AI_GATEWAY_429_ROUNDS": "2", + "AI_GATEWAY_CALL_INTERVAL": "0", + }, + clear=False, + ): + with mock.patch.object( + urllib.request, "urlopen", + side_effect=[err, urllib.error.HTTPError( + "u", 429, "rl", {"Retry-After": "45"}, io.BytesIO(b"x")), good], + ): + with mock.patch.object(cloud_agent_runner.time, "sleep", side_effect=sleeps.append): + parsed = cloud_agent_runner.call_ai_gateway_model("prompt", "model-a") + self.assertIn("choices", parsed) + # Third attempt = round 2 of the chain; 429 backoff honors Retry-After (45s). + self.assertTrue(any(t >= 45 for t in sleeps), sleeps) + + def test_inject_deterministic_radar_sweep(self) -> None: + cloud_agent_runner.RUN_AUDIT["apply_warnings"] = [] + cloud_agent_runner.SHARED_SWEEP_LINES.clear() + cloud_agent_runner.SHARED_SWEEP_LINES.extend([ + "- [infra_primitive] tool-a — why a | https://github.com/x/a", + "- [mainstream_product] tool-b — why b | https://vendor.example/b", + ]) + content = ( + "## 2026-07-22\n\n### English\n\n" + "#### 1. Lead Analysis\n\nNarrative.\n\n" + "#### 2. New Signals\n\n" + "- Signal: covered tool-b delta.\n" + " - Source: https://vendor.example/b\n\n" + "#### 7. Radar Sweep\n\n(placeholder)\n\n" + "#### 8. Assessment & Gaps\n\n- Coverage ledger: checked=x; missed=none\n\n" + "### 中文\n\n#### 1. Lead Analysis\n\n主线。\n\n" + "#### 8. Assessment & Gaps\n\n- 台账。\n" + ) + result = {"updates": [{"path": "daily/2026-07.md", "mode": "append", "content": content}]} + injected = cloud_agent_runner.inject_deterministic_radar_sweep(result) + self.assertEqual(injected, 1) + merged = result["updates"][0]["content"] + self.assertIn("- [infra_primitive] tool-a — why a | https://github.com/x/a", merged) + # URL already covered by the model's own signal must not repeat in sweep. + english = merged.split("### 中文")[0] + sweep = english.split("#### 7. Radar Sweep", 1)[1] + self.assertNotIn("tool-b", sweep) + self.assertNotIn("(placeholder)", english) + self.assertIn("(见英文部分;由采集器自动生成。)", merged) + cloud_agent_runner.SHARED_SWEEP_LINES.clear() + def test_max_response_chars_generous_for_all_tasks(self) -> None: # Issue #59 round 3: the daily legitimately produced 75.3k chars under # the v0.19 funnel; every task now gets the 96k cap by default.