Skip to content
Merged
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
5 changes: 3 additions & 2 deletions .github/workflows/cloud-agent.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
16 changes: 7 additions & 9 deletions prompts/daily-update.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion scripts/agent_radar.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
INIT_PROTECTED_DIRS = ("prompts", "automation", "docs")


__version__ = "0.19.5"
__version__ = "0.20.0"

CORE_FILES = [
"README.md",
Expand Down
152 changes: 128 additions & 24 deletions scripts/cloud_agent_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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). "
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore old sweep content when skipping duplicates

When the model ignores the new instruction and writes real #### 7. Radar Sweep lines, this duplicate filter searches the entire block, including the very section that is about to be replaced. Any generated line whose URL already appears only in the old sweep is filtered out and then the old sweep is deleted, silently dropping that candidate from the final daily note; strip the existing sweep section before testing whether a URL was covered elsewhere.

AGENTS.md reference: AGENTS.md:L10-L14

Useful? React with 👍 / 👎.

]
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 —
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Inject the deterministic sweep before recall gates

Because this call happens only after compute_synthesis_recall_details and the weighted/mainstream recall checks above, candidates that the runner is about to cover in the auto-generated Radar Sweep still count as missed. With the default 20 prompt candidates plus up to 100 sweep candidates, a broad daily screening pass can be rejected for low weighted recall even though the injected section would satisfy that breadth, so move injection before computing recall or include SHARED_SWEEP_LINES in the recall haystack.

AGENTS.md reference: AGENTS.md:L10-L14

Useful? React with 👍 / 👎.

validate_daily_direction_quota(result)
validate_must_cover_mainstream(result, screen_text, root=root, day=day)
validate_daily_section_structure(result)
Expand Down Expand Up @@ -5492,21 +5573,40 @@ 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:
RUN_AUDIT["fallbacks"].append(f"{model}->{candidate_model}")
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"),
Expand All @@ -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
Expand Down
73 changes: 70 additions & 3 deletions tests/test_cloud_agent_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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.
Expand Down
Loading