From 11516c5c7bcbc5041d2187847836e97e3ff02e70 Mon Sep 17 00:00:00 2001 From: YueJia LI <2403763315@qq.com> Date: Fri, 16 Oct 2026 12:40:58 +0800 Subject: [PATCH 1/2] Charge auto-research LLM calls to the agenda that owns them. Execution workers spent outside any agenda scope, so those tokens never reached the agenda ledger until a whole grant settled. Co-authored-by: Cursor --- agents/agenda_budget.py | 212 +++++++++++++- agents/llm_client.py | 62 ++++- meta_harness/grant_usage.py | 11 + orchestrator/auto_research.py | 31 +++ tests/test_agenda_execution_budget.py | 382 ++++++++++++++++++++++++++ 5 files changed, 694 insertions(+), 4 deletions(-) create mode 100644 tests/test_agenda_execution_budget.py diff --git a/agents/agenda_budget.py b/agents/agenda_budget.py index ac8a5356..20d79fdf 100644 --- a/agents/agenda_budget.py +++ b/agents/agenda_budget.py @@ -1,4 +1,13 @@ -"""Context attribution and hard pre-call reservations for agenda resources.""" +"""Context attribution and hard pre-call reservations for agenda resources. + +`agenda_scope` attributes the work inside it to one agenda. Legacy `call_llm` +checks that agenda before contacting a provider and records the call on +`agenda_token_ledger` plus `token_spent`. Grant-backed role calls keep the +grant reservation as the `token_spent` clock — a parent settlement already +moves actual usage from reserved to spent — and instead append a ledger row +when the sub-reservation settles, so execution spend is visible before the +grant closes. A budget of NULL, 0, or negative stays accounting-only. +""" from __future__ import annotations @@ -88,3 +97,204 @@ def resume( agenda_id, token_budget=token_budget, ) + + +PAUSED_BUDGET = "paused_budget" +ACTIVE = "active" + + +class AgendaBudgetExceededError(RuntimeError): + """Raised before an LLM call when the agenda's token budget is spent.""" + + def __init__(self, agenda_id: int, token_spent: int, token_budget: int): + self.agenda_id = int(agenda_id) + self.token_spent = int(token_spent) + self.token_budget = int(token_budget) + super().__init__( + f"agenda {agenda_id} token budget exhausted " + f"({token_spent}/{token_budget} tokens); raise token_budget " + f"above spent and reserved, then resume the agenda" + ) + + +def budget_cap(token_budget: object) -> int | None: + """Return a positive cap, or None when the budget is accounting-only. + + NULL, 0, and negative budgets record usage and never pause the agenda. + """ + if token_budget is None: + return None + try: + value = int(token_budget) + except (TypeError, ValueError): + return None + if value <= 0: + return None + return value + + +def _live_grant_tokens(agenda_id: int) -> int: + """Settled LLM usage on grants that have not yet folded into token_spent. + + Parent grant settlement adds those tokens to token_spent and leaves the + grant, so counting only still-active grants does not charge them twice. + """ + from db import database as db + + if not db.table_exists("resource_grant_usage_reservations"): + return 0 + if not db.table_exists("resource_grants"): + return 0 + row = db.fetchone( + """ + SELECT COALESCE(SUM(u.tokens_used), 0) AS spent + FROM resource_grant_usage_reservations u + JOIN resource_grants g ON g.id = u.resource_grant_id + WHERE u.agenda_id=? + AND u.status='settled' + AND g.status='active' + AND g.expires_at > CURRENT_TIMESTAMP + """, + (int(agenda_id),), + ) + return int((row or {}).get("spent") or 0) + + +def _agenda_budget_row(agenda_id: int) -> dict | None: + from db import database as db + + if not db.table_exists("research_agendas"): + return None + row = db.fetchone( + """ + SELECT id, token_budget, token_spent, status + FROM research_agendas + WHERE id=? + """, + (int(agenda_id),), + ) + return dict(row) if row else None + + +def charged_tokens(agenda_id: int, token_spent: int) -> int: + """Tokens already spent plus LLM usage still inside an active grant.""" + return int(token_spent or 0) + _live_grant_tokens(agenda_id) + + +def _set_status(agenda_id: int, status: str, *, commit: bool) -> None: + from db import database as db + + db.execute( + """ + UPDATE research_agendas + SET status=?, is_active=?, updated_at=CURRENT_TIMESTAMP + WHERE id=? + """, + (status, 1 if status == ACTIVE else 0, int(agenda_id)), + ) + if commit: + db.commit() + + +def _pause_if_exhausted(agenda_id: int, *, commit: bool) -> None: + row = _agenda_budget_row(agenda_id) + if not row: + return + cap = budget_cap(row.get("token_budget")) + if cap is None: + return + charged = charged_tokens(agenda_id, int(row.get("token_spent") or 0)) + if charged >= cap and str(row.get("status") or "") != PAUSED_BUDGET: + _set_status(agenda_id, PAUSED_BUDGET, commit=commit) + + +def check_budget(agenda_id: int) -> None: + """Raise before a provider request when this agenda may not spend more. + + A NULL, 0, or negative budget disables the cap. A paused agenda whose + budget was raised back above spent plus in-flight grant usage is + reactivated, so increasing the budget is enough to continue. + """ + row = _agenda_budget_row(agenda_id) + if row is None: + return + cap = budget_cap(row.get("token_budget")) + if cap is None: + return + charged = charged_tokens(agenda_id, int(row.get("token_spent") or 0)) + if charged >= cap: + if str(row.get("status") or "") != PAUSED_BUDGET: + _set_status(agenda_id, PAUSED_BUDGET, commit=True) + raise AgendaBudgetExceededError(agenda_id, charged, cap) + if str(row.get("status") or "") == PAUSED_BUDGET: + _set_status(agenda_id, ACTIVE, commit=True) + + +def _insert_ledger_row( + agenda_id: int, + operation: str, + tokens: int, + cost_usd: float | None, +) -> bool: + from db import database as db + + if not db.table_exists("agenda_token_ledger"): + return False + db.execute( + """ + INSERT INTO agenda_token_ledger (agenda_id, operation, tokens, cost_usd) + VALUES (?, ?, ?, ?) + """, + (int(agenda_id), str(operation or "llm_call"), int(tokens or 0), cost_usd), + ) + return True + + +def record_usage( + agenda_id: int, + operation: str, + tokens: int, + cost_usd: float | None = None, +) -> None: + """Record one legacy LLM call and bump token_spent. + + Grant-backed calls must not use this. Their parent reservation already + adds actual tokens to token_spent when the grant settles. + """ + from db import database as db + + tokens = int(tokens or 0) + agenda_id = int(agenda_id) + try: + if not _insert_ledger_row(agenda_id, operation, tokens, cost_usd): + return + if db.table_exists("research_agendas"): + db.execute( + """ + UPDATE research_agendas + SET token_spent = COALESCE(token_spent, 0) + ?, + updated_at=CURRENT_TIMESTAMP + WHERE id=? + """, + (tokens, agenda_id), + ) + _pause_if_exhausted(agenda_id, commit=False) + db.commit() + except Exception: + db.rollback() + raise + + +def attribute_settled_grant_usage( + agenda_id: int, + operation: str, + tokens: int, + cost_usd: float | None = None, +) -> None: + """Append a ledger row for one settled grant sub-reservation. + + Does not touch token_spent. The caller owns the transaction. + """ + if not _insert_ledger_row(agenda_id, operation, int(tokens or 0), cost_usd): + return + _pause_if_exhausted(int(agenda_id), commit=False) diff --git a/agents/llm_client.py b/agents/llm_client.py index 076f152c..ce88894e 100644 --- a/agents/llm_client.py +++ b/agents/llm_client.py @@ -1131,11 +1131,60 @@ def is_llm_transient_provider_error(exc: Exception) -> bool: return any(marker in msg for marker in markers) +def _agenda_budget_scope() -> tuple[int, str, str] | None: + """Active agenda attribution, if this call is running inside one.""" + from agents.agenda_budget import current_scope + + return current_scope() + + +def _enforce_scoped_budget(agenda_id: int | None = None) -> tuple[int, str, str] | None: + """Check the agenda cap before a provider request. + + A scope for a different agenda is ignored so one worker cannot charge + or block another agenda's call. + """ + scope = _agenda_budget_scope() + if scope is None: + return None + if agenda_id is not None and int(scope[0]) != int(agenda_id): + return None + from agents.agenda_budget import check_budget + + check_budget(int(scope[0])) + return scope + + +def _record_scoped_usage( + scope: tuple[int, str, str] | None, + tokens: int, + cost_usd: float | None, +) -> None: + """Record a legacy, non-grant call. Failures keep the paid response.""" + if scope is None or not tokens: + return + try: + from agents.agenda_budget import record_usage + + record_usage(int(scope[0]), str(scope[1]), int(tokens), cost_usd=cost_usd) + except Exception as acct_err: # noqa: BLE001 + print( + f"[LLM] WARNING: agenda token accounting failed " + f"(agenda {scope[0]}): {acct_err}", + flush=True, + ) + + def call_llm(system_prompt: str, user_prompt: str, temperature: float = 0.0, max_tokens: int = None) -> tuple[str, int]: - """Call LLM with automatic provider selection, rate limiting, and failover.""" + """Call LLM with automatic provider selection, rate limiting, and failover. + + Inside ``agenda_scope``, the agenda cap is checked before any provider + request and a successful response is recorded on the agenda ledger. + """ max_tokens = max_tokens or LLM_MAX_OUTPUT_TOKENS _init_providers() + budget_scope = _enforce_scoped_budget() last_error = None tried = set() @@ -1161,7 +1210,7 @@ def call_llm(system_prompt: str, user_prompt: str, temperature: float = 0.0, start = time.time() try: - text, tokens, cached_toks, input_toks, _cost_usd = _call_provider( + text, tokens, cached_toks, input_toks, cost_usd = _call_provider( provider, system_prompt, user_prompt, max_tokens) latency = time.time() - start @@ -1182,6 +1231,7 @@ def call_llm(system_prompt: str, user_prompt: str, temperature: float = 0.0, stats["cached_tokens"] += cached_toks stats["input_tokens"] += input_toks _release_provider(provider["name"]) + _record_scoped_usage(budget_scope, tokens, cost_usd) return text, tokens except Exception as e: @@ -1309,6 +1359,7 @@ def call_llm_with_provider( provider = _providers[0] name = provider["name"] + budget_scope = _enforce_scoped_budget() limiter = _rate_limiters.get(name) if limiter: limiter.wait() @@ -1316,7 +1367,7 @@ def call_llm_with_provider( _provider_stats[name]["in_flight"] = _provider_stats[name].get("in_flight", 0) + 1 start = time.time() try: - text, tokens, cached_toks, input_toks, _cost_usd = _call_provider( + text, tokens, cached_toks, input_toks, cost_usd = _call_provider( provider, system_prompt, user_prompt, max_tokens ) latency = time.time() - start @@ -1329,6 +1380,7 @@ def call_llm_with_provider( stats["input_tokens"] += input_toks if not text or len(text.strip()) < 10: raise RuntimeError(f"{name} returned empty response") + _record_scoped_usage(budget_scope, tokens, cost_usd) return text, tokens, { "name": provider.get("name"), "model": provider.get("model"), @@ -1396,6 +1448,10 @@ def call_llm_for_role( ) if not grant_row: raise PermissionError("active scoped ResourceGrant is required for LLM role") + # Execution workers enter agenda_scope before this call. The grant still + # owns token_spent; this only refuses a provider request once spent plus + # in-flight grant usage has already reached the agenda cap. + _enforce_scoped_budget(int(agenda_id)) # Trim the request to what this grant still has, rather than sending the # caller's own default and letting the sub-reservation refuse it. A small # short grant is the whole point of the bounded pilot path: without this a diff --git a/meta_harness/grant_usage.py b/meta_harness/grant_usage.py index 7e5ab432..997280d2 100644 --- a/meta_harness/grant_usage.py +++ b/meta_harness/grant_usage.py @@ -235,6 +235,17 @@ def settle( """, (tokens_used, cost_usd, reservation_id, self.resource_grant_id), ) + # Execution workers spend through this sub-reservation. The parent + # grant still owns token_spent; the compatibility ledger is the + # per-call record, written in this same transaction. + from agents.agenda_budget import attribute_settled_grant_usage + + attribute_settled_grant_usage( + int(row["agenda_id"]), + str(row.get("operation") or "llm_call"), + int(tokens_used), + cost_usd, + ) db.commit() except Exception: db.rollback() diff --git a/orchestrator/auto_research.py b/orchestrator/auto_research.py index 3b9ea618..4c8caad0 100644 --- a/orchestrator/auto_research.py +++ b/orchestrator/auto_research.py @@ -14,6 +14,7 @@ import os import threading import time +from contextlib import contextmanager from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path @@ -3835,7 +3836,37 @@ def _launch_candidates_to_capacity() -> dict: } +@contextmanager +def _auto_research_budget_scope(insight: dict): + """Attribute this candidate's LLM calls to its agenda. + + Discovery enters an agenda scope before it spends. Execution workers + dispatched after a selection is handed to auto-research used to call the + provider outside any scope, so those tokens never reached the agenda + ledger. The scope is attribution only; it does not reserve budget. + """ + agenda_id = int(insight.get("agenda_id") or 0) + insight_id = int(insight.get("id") or 0) + if agenda_id <= 0 or insight_id <= 0: + yield + return + from agents.agenda_budget import agenda_scope + + with agenda_scope( + agenda_id, + "auto_research_execution", + f"auto-research:{agenda_id}:{insight_id}", + ): + yield + + def _process_candidate(insight: dict) -> None: + """Run one auto-research candidate inside its agenda token scope.""" + with _auto_research_budget_scope(insight): + _process_candidate_in_scope(insight) + + +def _process_candidate_in_scope(insight: dict) -> None: insight_id = insight["id"] tier = insight.get("tier") initial_auto_stage = str(insight.get("auto_stage") or "").strip() diff --git a/tests/test_agenda_execution_budget.py b/tests/test_agenda_execution_budget.py new file mode 100644 index 00000000..32ba7bec --- /dev/null +++ b/tests/test_agenda_execution_budget.py @@ -0,0 +1,382 @@ +"""Execution-path LLM calls are charged to the agenda that owns them. + +Discovery already metered itself. auto_research workers did not enter an +agenda scope, and grant sub-reservations never wrote agenda_token_ledger, so +execution spend stayed invisible until a whole grant settled. +""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from agents import llm_client +from agents.agenda_budget import ( + AgendaBudgetExceededError, + agenda_scope, + charged_tokens, + check_budget, + current_scope, + record_usage, +) +from db import database +from meta_harness.grant_usage import GrantUsageLedger +from orchestrator import auto_research + + +class _TempAgendaDb(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.TemporaryDirectory() + self.db_path = Path(self.tmpdir.name) / "agenda.db" + self.old_db_path = database.DB_PATH + self.old_database_url = database.DATABASE_URL + self._close_connection() + database.DATABASE_URL = "" + database.DB_PATH = self.db_path + database.execute( + """ + CREATE TABLE research_agendas ( + id INTEGER PRIMARY KEY, + token_budget INTEGER, + token_spent INTEGER DEFAULT 0, + token_reserved INTEGER DEFAULT 0, + status TEXT DEFAULT 'active', + is_active INTEGER DEFAULT 1, + updated_at TEXT + ) + """ + ) + database.execute( + """ + CREATE TABLE agenda_token_ledger ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agenda_id INTEGER NOT NULL, + operation TEXT NOT NULL, + tokens INTEGER NOT NULL DEFAULT 0, + cost_usd REAL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + database.execute( + """ + CREATE TABLE resource_grants ( + id INTEGER PRIMARY KEY, + agenda_id INTEGER NOT NULL, + idea_id INTEGER, + status TEXT NOT NULL, + expires_at TEXT NOT NULL + ) + """ + ) + database.execute( + """ + CREATE TABLE resource_grant_usage_reservations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agenda_id INTEGER NOT NULL, + resource_grant_id INTEGER NOT NULL, + operation TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + token_reserved INTEGER NOT NULL, + tokens_used INTEGER, + cost_usd REAL, + status TEXT NOT NULL, + release_reason TEXT, + settled_at TEXT + ) + """ + ) + database.commit() + + def tearDown(self): + self._close_connection() + database.DATABASE_URL = self.old_database_url + database.DB_PATH = self.old_db_path + self.tmpdir.cleanup() + + @staticmethod + def _close_connection(): + for attr in ("pg_conn", "sqlite_conn", "conn"): + conn = getattr(database._local, attr, None) + if conn is None: + continue + try: + conn.close() + except Exception: + pass + setattr(database._local, attr, None) + + def _agenda(self, agenda_id: int, token_budget, token_spent: int = 0) -> None: + database.execute( + """ + INSERT INTO research_agendas + (id, token_budget, token_spent, status, is_active) + VALUES (?, ?, ?, 'active', 1) + """, + (agenda_id, token_budget, token_spent), + ) + database.commit() + + def _active_grant(self, grant_id: int, agenda_id: int, idea_id: int = 7) -> None: + database.execute( + """ + INSERT INTO resource_grants (id, agenda_id, idea_id, status, expires_at) + VALUES (?, ?, ?, 'active', '2099-01-01 00:00:00') + """, + (grant_id, agenda_id, idea_id), + ) + database.commit() + + def _ledger_tokens(self, agenda_id: int) -> int: + row = database.fetchone( + "SELECT COALESCE(SUM(tokens), 0) AS s FROM agenda_token_ledger WHERE agenda_id=?", + (agenda_id,), + ) + return int(row["s"]) + + def _status(self, agenda_id: int) -> tuple[str, int, int]: + row = database.fetchone( + "SELECT status, token_spent, is_active FROM research_agendas WHERE id=?", + (agenda_id,), + ) + return str(row["status"]), int(row["token_spent"]), int(row["is_active"]) + + +class BudgetCapTests(_TempAgendaDb): + def test_null_zero_and_negative_budgets_stay_accounting_only(self): + for agenda_id, budget in ((1, None), (2, 0), (3, -5)): + self._agenda(agenda_id, budget) + record_usage(agenda_id, "auto_research_execution", 40, cost_usd=0.1) + check_budget(agenda_id) + status, spent, active = self._status(agenda_id) + self.assertEqual(status, "active") + self.assertEqual(active, 1) + self.assertEqual(spent, 40) + self.assertEqual(self._ledger_tokens(agenda_id), 40) + + def test_crossing_the_cap_pauses_and_the_next_check_refuses(self): + self._agenda(4, 25) + record_usage(4, "auto_research_execution", 25) + status, spent, active = self._status(4) + self.assertEqual((status, spent, active), ("paused_budget", 25, 0)) + with self.assertRaises(AgendaBudgetExceededError): + check_budget(4) + + def test_raising_the_budget_above_spend_unpauses_on_the_next_check(self): + self._agenda(5, 10) + record_usage(5, "auto_research_execution", 10) + database.execute( + "UPDATE research_agendas SET token_budget=30 WHERE id=5" + ) + database.commit() + check_budget(5) + status, spent, active = self._status(5) + self.assertEqual((status, spent, active), ("active", 10, 1)) + + def test_in_flight_grant_usage_counts_before_token_spent_moves(self): + self._agenda(6, 15, token_spent=0) + self._active_grant(60, 6) + database.execute( + """ + INSERT INTO resource_grant_usage_reservations + (agenda_id, resource_grant_id, operation, idempotency_key, + token_reserved, tokens_used, status) + VALUES (6, 60, 'validation_code_iteration', 'k', 15, 15, 'settled') + """ + ) + database.commit() + self.assertEqual(charged_tokens(6, 0), 15) + with self.assertRaises(AgendaBudgetExceededError) as caught: + check_budget(6) + self.assertEqual(caught.exception.token_spent, 15) + self.assertEqual(self._status(6)[0], "paused_budget") + self.assertEqual(self._status(6)[1], 0) + + +class GrantSettlementLedgerTests(_TempAgendaDb): + def test_settling_execution_usage_writes_the_ledger_without_double_spending(self): + self._agenda(8, 100, token_spent=4) + self._active_grant(80, 8) + database.execute( + """ + INSERT INTO resource_grant_usage_reservations + (agenda_id, resource_grant_id, operation, idempotency_key, + token_reserved, status) + VALUES (8, 80, 'validation_code_iteration', 'once', 40, 'reserved') + """ + ) + database.commit() + reservation_id = int( + database.fetchone( + "SELECT id FROM resource_grant_usage_reservations WHERE idempotency_key='once'" + )["id"] + ) + GrantUsageLedger(80).settle(reservation_id, tokens_used=12, cost_usd=0.2) + GrantUsageLedger(80).settle(reservation_id, tokens_used=12, cost_usd=0.2) + + self.assertEqual(self._ledger_tokens(8), 12) + row = database.fetchone( + "SELECT operation, cost_usd FROM agenda_token_ledger WHERE agenda_id=8" + ) + self.assertEqual(row["operation"], "validation_code_iteration") + self.assertAlmostEqual(float(row["cost_usd"]), 0.2) + status, spent, _active = self._status(8) + self.assertEqual(spent, 4) + self.assertEqual(status, "active") + + def test_settlement_pauses_when_in_flight_usage_reaches_the_cap(self): + self._agenda(9, 10) + self._active_grant(90, 9) + database.execute( + """ + INSERT INTO resource_grant_usage_reservations + (agenda_id, resource_grant_id, operation, idempotency_key, + token_reserved, status) + VALUES (9, 90, 'auto_research_execution', 'cap', 10, 'reserved') + """ + ) + database.commit() + reservation_id = int( + database.fetchone("SELECT id FROM resource_grant_usage_reservations")["id"] + ) + GrantUsageLedger(90).settle(reservation_id, tokens_used=10) + self.assertEqual(self._status(9)[0], "paused_budget") + self.assertEqual(self._status(9)[1], 0) + self.assertEqual(self._ledger_tokens(9), 10) + + +class ExecutionScopeHookTests(_TempAgendaDb): + def setUp(self): + super().setUp() + self.old_providers = list(llm_client._providers) + self.old_stats = dict(llm_client._provider_stats) + self.old_limiters = dict(llm_client._rate_limiters) + self.old_cooldown = dict(llm_client._provider_cooldown) + llm_client._providers = [ + { + "name": "tabcode", + "base_url": "https://example.invalid", + "api_key": "test-key", + "model": "test-model", + "protocol": "chat_completions", + } + ] + llm_client._provider_stats = { + "tabcode": { + "calls": 0, + "tokens": 0, + "errors": 0, + "total_latency": 0, + "in_flight": 0, + "cached_tokens": 0, + "input_tokens": 0, + } + } + llm_client._rate_limiters = {} + llm_client._provider_cooldown = {} + + def tearDown(self): + llm_client._providers = self.old_providers + llm_client._provider_stats = self.old_stats + llm_client._rate_limiters = self.old_limiters + llm_client._provider_cooldown = self.old_cooldown + super().tearDown() + + def test_legacy_call_inside_execution_scope_checks_before_the_provider(self): + self._agenda(11, 100) + with ( + mock.patch.object(llm_client, "_init_providers"), + mock.patch.object( + llm_client, + "_call_provider", + return_value=("valid enough response", 18, 0, 6, 0.4), + ) as provider, + agenda_scope(11, "auto_research_execution", "auto-research:11:1"), + ): + text, tokens = llm_client.call_llm("system", "user prompt here") + + self.assertEqual((text, tokens), ("valid enough response", 18)) + self.assertEqual(provider.call_count, 1) + self.assertEqual(self._ledger_tokens(11), 18) + self.assertEqual(self._status(11)[1], 18) + cost = database.fetchone( + "SELECT cost_usd FROM agenda_token_ledger WHERE agenda_id=11" + ) + self.assertAlmostEqual(float(cost["cost_usd"]), 0.4) + + def test_exhausted_scope_does_not_contact_the_provider(self): + self._agenda(12, 10, token_spent=10) + with ( + mock.patch.object(llm_client, "_init_providers"), + mock.patch.object(llm_client, "_call_provider") as provider, + agenda_scope(12, "auto_research_execution", "auto-research:12:1"), + ): + with self.assertRaises(AgendaBudgetExceededError): + llm_client.call_llm("system", "user prompt here") + self.assertEqual(provider.call_count, 0) + self.assertEqual(self._ledger_tokens(12), 0) + + def test_unscoped_call_is_not_charged_to_an_agenda(self): + self._agenda(13, 100) + with ( + mock.patch.object(llm_client, "_init_providers"), + mock.patch.object( + llm_client, + "_call_provider", + return_value=("valid enough response", 9, 0, 2, None), + ), + ): + llm_client.call_llm("system", "user prompt here") + self.assertEqual(self._ledger_tokens(13), 0) + self.assertEqual(self._status(13)[1], 0) + + def test_role_call_on_the_execution_path_stops_before_the_provider(self): + self._agenda(14, 8, token_spent=8) + self._active_grant(140, 14, idea_id=70) + with ( + mock.patch.object(llm_client, "_call_provider") as provider, + agenda_scope(14, "auto_research_execution", "auto-research:14:70"), + ): + with self.assertRaises(AgendaBudgetExceededError): + llm_client.call_llm_for_role( + "system", + "user prompt here", + agenda_id=14, + idea_id=70, + role="proposer", + stage="pilot", + resource_grant_id=140, + operation="validation_code_iteration", + idempotency_key="validation:t1", + prompt_version="proposer-v1", + ) + self.assertEqual(provider.call_count, 0) + + +class AutoResearchScopeTests(unittest.TestCase): + def test_process_candidate_enters_the_execution_scope(self): + seen = {} + + def _capture(insight): + seen["scope"] = current_scope() + seen["insight"] = insight["id"] + + with mock.patch.object(auto_research, "_process_candidate_in_scope", _capture): + auto_research._process_candidate({"id": 9, "agenda_id": 4}) + + self.assertEqual(seen["insight"], 9) + self.assertEqual(seen["scope"][:2], (4, "auto_research_execution")) + self.assertIsNone(current_scope()) + + def test_unscoped_candidate_does_not_invent_an_agenda(self): + seen = {} + + def _capture(_insight): + seen["scope"] = current_scope() + + with mock.patch.object(auto_research, "_process_candidate_in_scope", _capture): + auto_research._process_candidate({"id": 9}) + + self.assertIsNone(seen["scope"]) From 5b303f53c3a87f49a9bc1c96e15feca37e8e6aff Mon Sep 17 00:00:00 2001 From: YueJia LI <2403763315@qq.com> Date: Fri, 16 Oct 2026 13:44:08 +0800 Subject: [PATCH 2/2] Retrigger the CLA check against the updated signer list. Co-authored-by: Cursor