Skip to content

Commit 0dd7c45

Browse files
NikitasT2003claude
andcommitted
fix(agent): cost guardrails — per-role max_tokens + recursion limit
User report: ~20 tool calls in `thesis curate`, $10 bill in 1 minute on Claude Sonnet 4.5. Root causes were defaults, not a bug: 1. `make_model` passed no `max_tokens`. Sonnet would emit up to 8192 tokens per reply. With ~20 agent calls that's 160K output tokens at $15/M = $2.40 just from caps-off output, before you count subagent fan-out and the ballooning conversation history. 2. No `recursion_limit` on the agent invocation. LangGraph's default was letting the orchestrator loop tens of times before giving up. 3. The main system prompt did not discourage speculative tool calls (ls/glob/read) — so the agent would re-read the same file, probe directories, re-plan on every turn. Changes config.make_model(model_id, *, role=None, max_tokens=None) - new `role` parameter drives per-role output caps: drafter 4000 tokens curator 2000 tokens researcher 1000 tokens default 2000 tokens - explicit `max_tokens` wins; role-derived cap is the default - env overrides: THESIS_MAX_TOKENS_DRAFTER / _CURATOR / _RESEARCHER / _DEFAULT - applied to both the Anthropic path (init_chat_model) and the OpenRouter path (ChatOpenAI) agent._recursion_limit() + use in invoke() config - default ceiling of 25 iterations (was effectively uncapped) - overridable via THESIS_RECURSION_LIMIT env var - lower-bounded at 5 so users can't accidentally make the agent unusable subagents.get_subagents() - each role's model is now created with its `role=` tag so caps kick in automatically agent.build_agent() main orchestrator - uses drafter-quality model but curator-cap output (2000) since the orchestrator mostly plans + delegates and rarely needs a long reply agent.MAIN_SYSTEM_PROMPT - new "COST + EFFICIENCY RULES" section: don't re-read AGENTS.md (already in system prompt), don't re-read files within a turn, prefer decisive tool calls, stop when done. cli.chat REPL - passes `recursion_limit` through the config as well, so interactive sessions are also protected. Tests (14 new, 290 total passing) - `_role_max_tokens` defaults + env overrides + fallback logic - Anthropic + OpenRouter paths both receive the cap - Explicit `max_tokens` arg wins - `_recursion_limit` default + override + lower bound + junk-value safety - `invoke()` config integration: `recursion_limit` propagated, env override propagated - Worst-case per-call output cost sanity check — drafter cap currently worst-case $0.06 per reply at Sonnet 4.5 prices. Expected impact: prior $10/min runs should drop to well under $1 for the same workload. Users with large theses can still opt back in to more headroom via env vars. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 266fb1f commit 0dd7c45

5 files changed

Lines changed: 270 additions & 8 deletions

File tree

src/thesis_agent/agent.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,36 @@
2828
writing thesis chapters, researcher for read-only questions.
2929
* Write scopes are enforced. Do not try to write outside them.
3030
* When asked for something you cannot ground in the indexed sources, say so.
31+
32+
COST + EFFICIENCY RULES (non-negotiable):
33+
* Plan briefly, then act. Do NOT enumerate every possible path before
34+
reading a file. One `ls` or one `glob` is usually enough.
35+
* Read each file AT MOST ONCE per task. AGENTS.md is already in your
36+
system prompt — never `read_file("AGENTS.md")`.
37+
* Do not re-read files you already read this turn.
38+
* Prefer one decisive tool call over three speculative ones.
39+
* When delegating to a subagent, give it ALL the context it needs in the
40+
initial prompt — don't fan out into multiple task() calls for one job.
41+
* If a tool returns an error, read the error and adjust; do not blindly
42+
retry the same call.
43+
* Stop as soon as the user's request is satisfied. Do not polish,
44+
re-verify, or summarise unprompted.
3145
"""
3246

47+
# LangGraph safety ceiling — stops runaway loops before they bill a fortune.
48+
# Overridable via THESIS_RECURSION_LIMIT for power users (e.g. large theses
49+
# with many sources where 25 steps per curate pass isn't enough).
50+
import os as _os # noqa: E402
51+
52+
_DEFAULT_RECURSION_LIMIT = 25
53+
54+
55+
def _recursion_limit() -> int:
56+
raw = _os.environ.get("THESIS_RECURSION_LIMIT")
57+
if raw and raw.strip().isdigit():
58+
return max(5, int(raw))
59+
return _DEFAULT_RECURSION_LIMIT
60+
3361

3462
def _find_skills_dir() -> Path:
3563
"""Bundled skills live next to the package; user may override with
@@ -74,7 +102,10 @@ def build_agent(
74102
checkpointer, store = stack.enter_context(memory_context(p))
75103

76104
kwargs: dict[str, Any] = {
77-
"model": make_model(mconf.drafter),
105+
# Main orchestrator runs on the drafter model (most capable)
106+
# but with the curator's tighter output cap — the orchestrator
107+
# mostly plans and delegates; it rarely needs a long reply.
108+
"model": make_model(mconf.drafter, role="curator"),
78109
"tools": [], # sandbox: no shell, no network, no code exec
79110
"system_prompt": system_prompt,
80111
"subagents": get_subagents(mconf),
@@ -103,7 +134,13 @@ def build_agent(
103134
def invoke(prompt: str, *, thread_id: str, p: Paths | None = None) -> str:
104135
"""One-shot agent invocation. Returns the assistant's final message."""
105136
with build_agent(p=p) as agent:
106-
cfg = {"configurable": {"thread_id": thread_id}}
137+
cfg = {
138+
"configurable": {"thread_id": thread_id},
139+
# Hard ceiling on agent loop iterations — stops runaway spend
140+
# before it starts. ~25 steps is enough for curating one source
141+
# or drafting one section. Override with THESIS_RECURSION_LIMIT.
142+
"recursion_limit": _recursion_limit(),
143+
}
107144
result = agent.invoke(
108145
{"messages": [{"role": "user", "content": prompt}]},
109146
config=cfg,

src/thesis_agent/cli.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -958,9 +958,13 @@ def chat(
958958
continue
959959

960960
try:
961+
from thesis_agent.agent import _recursion_limit
961962
result = agent.invoke(
962963
{"messages": [{"role": "user", "content": msg}]},
963-
config={"configurable": {"thread_id": tid}},
964+
config={
965+
"configurable": {"thread_id": tid},
966+
"recursion_limit": _recursion_limit(),
967+
},
964968
)
965969
msgs = result.get("messages", [])
966970
if msgs:

src/thesis_agent/config.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -278,12 +278,44 @@ def models() -> ModelConfig:
278278
)
279279

280280

281-
def make_model(model_id: str, *, temperature: float = 0.0):
281+
def _role_max_tokens(role: str | None) -> int:
282+
"""Per-role output caps. Overridable via env vars.
283+
284+
Rationale: without a cap Sonnet will emit up to 8K tokens per reply
285+
and rack up cost fast on multi-turn agent loops. Caps picked from the
286+
shape of each role's output:
287+
- drafter ~ full thesis section (4K is generous)
288+
- curator ~ one wiki page (2K fits the template + buffer)
289+
- researcher ~ a citation-backed summary (1K)
290+
- default ~ 2K
291+
"""
292+
defaults = {"drafter": 4000, "curator": 2000, "researcher": 1000}
293+
env_key = f"THESIS_MAX_TOKENS_{(role or 'DEFAULT').upper()}"
294+
override = os.environ.get(env_key)
295+
if override and override.strip().isdigit():
296+
return int(override)
297+
return defaults.get(role or "", 2000)
298+
299+
300+
def make_model(
301+
model_id: str,
302+
*,
303+
temperature: float = 0.0,
304+
role: str | None = None,
305+
max_tokens: int | None = None,
306+
):
282307
"""Build a LangChain chat model for the current provider.
283308
284309
- anthropic: uses `init_chat_model("anthropic:<id>")` — deepagents idiom.
285310
- openrouter: returns a `ChatOpenAI` pointed at OpenRouter with headers.
311+
312+
`role` (drafter/curator/researcher) drives the default `max_tokens`
313+
output cap. Explicit `max_tokens` wins over role-derived defaults.
314+
Env overrides: `THESIS_MAX_TOKENS_DRAFTER` / `..._CURATOR` / `..._RESEARCHER`.
286315
"""
316+
if max_tokens is None:
317+
max_tokens = _role_max_tokens(role)
318+
287319
prov = provider()
288320
if prov == "openrouter":
289321
from langchain_openai import ChatOpenAI
@@ -308,14 +340,15 @@ def make_model(model_id: str, *, temperature: float = 0.0):
308340
base_url=base_url,
309341
model=model_id,
310342
temperature=temperature,
343+
max_tokens=max_tokens,
311344
default_headers=headers or None,
312345
extra_body=extra_body or None,
313346
)
314347

315348
# Default: anthropic / init_chat_model string form.
316349
from langchain.chat_models import init_chat_model
317350

318-
return init_chat_model(model_id, temperature=temperature)
351+
return init_chat_model(model_id, temperature=temperature, max_tokens=max_tokens)
319352

320353

321354
def read_thread_id() -> str:

src/thesis_agent/subagents.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ def get_subagents(models: ModelConfig) -> list[dict]:
7474
"raw sources. Use after `thesis ingest` when entries in "
7575
"`research/raw/_index.json` have `status: pending`."
7676
),
77-
"model": make_model(models.curator),
77+
"model": make_model(models.curator, role="curator"),
7878
"system_prompt": WIKI_CURATOR_PROMPT,
7979
},
8080
{
@@ -84,7 +84,7 @@ def get_subagents(models: ModelConfig) -> list[dict]:
8484
"style, grounded in curated wiki pages. Provide the section "
8585
"identifier (from thesis/outline.md) and a short brief."
8686
),
87-
"model": make_model(models.drafter),
87+
"model": make_model(models.drafter, role="drafter"),
8888
"system_prompt": DRAFTER_PROMPT,
8989
},
9090
{
@@ -93,7 +93,7 @@ def get_subagents(models: ModelConfig) -> list[dict]:
9393
"Delegate to the researcher (read-only) to answer questions about "
9494
"what indexed sources say on a topic, with citations."
9595
),
96-
"model": make_model(models.researcher),
96+
"model": make_model(models.researcher, role="researcher"),
9797
"system_prompt": RESEARCHER_PROMPT,
9898
},
9999
]

tests/test_cost_guards.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
"""Cost safety: per-role output caps + recursion limit.
2+
3+
Context: a user burned $10 in a minute running Claude Sonnet 4.5 because
4+
(a) `make_model` didn't set `max_tokens`, so replies went up to 8K, and
5+
(b) the agent had no `recursion_limit`, so it looped tens of times on
6+
one source. These tests lock in the caps that prevent that.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import pytest
12+
13+
from thesis_agent.agent import _DEFAULT_RECURSION_LIMIT, _recursion_limit
14+
from thesis_agent.config import _role_max_tokens, make_model
15+
16+
17+
@pytest.fixture
18+
def clean_env(monkeypatch):
19+
for v in (
20+
"THESIS_MAX_TOKENS_DRAFTER",
21+
"THESIS_MAX_TOKENS_CURATOR",
22+
"THESIS_MAX_TOKENS_RESEARCHER",
23+
"THESIS_MAX_TOKENS_DEFAULT",
24+
"THESIS_RECURSION_LIMIT",
25+
"THESIS_PROVIDER",
26+
"OPENROUTER_API_KEY",
27+
"ANTHROPIC_API_KEY",
28+
):
29+
monkeypatch.delenv(v, raising=False)
30+
31+
32+
# ---------------------------------------------------------------------------
33+
# _role_max_tokens
34+
# ---------------------------------------------------------------------------
35+
36+
class TestRoleMaxTokens:
37+
def test_default_caps_are_conservative(self, clean_env):
38+
assert _role_max_tokens("drafter") == 4000
39+
assert _role_max_tokens("curator") == 2000
40+
assert _role_max_tokens("researcher") == 1000
41+
# Unknown role / default falls back to 2000 (not Sonnet's 8192 ceiling)
42+
assert _role_max_tokens(None) == 2000
43+
assert _role_max_tokens("unknown-role") == 2000
44+
45+
def test_env_override_per_role(self, clean_env, monkeypatch):
46+
monkeypatch.setenv("THESIS_MAX_TOKENS_DRAFTER", "8000")
47+
monkeypatch.setenv("THESIS_MAX_TOKENS_CURATOR", "3000")
48+
assert _role_max_tokens("drafter") == 8000
49+
assert _role_max_tokens("curator") == 3000
50+
# Roles without an override still use defaults
51+
assert _role_max_tokens("researcher") == 1000
52+
53+
def test_non_digit_env_is_ignored(self, clean_env, monkeypatch):
54+
monkeypatch.setenv("THESIS_MAX_TOKENS_DRAFTER", "lots")
55+
assert _role_max_tokens("drafter") == 4000
56+
57+
def test_default_env_override_affects_unknown_role(self, clean_env, monkeypatch):
58+
monkeypatch.setenv("THESIS_MAX_TOKENS_DEFAULT", "512")
59+
# Unknown roles should pick up the DEFAULT override path. Our helper
60+
# falls back to the hardcoded 2000 for unknowns, BUT if the user sets
61+
# THESIS_MAX_TOKENS_DEFAULT we honour it for the `None` role.
62+
assert _role_max_tokens(None) == 512
63+
64+
65+
# ---------------------------------------------------------------------------
66+
# make_model passes max_tokens through
67+
# ---------------------------------------------------------------------------
68+
69+
class TestMakeModelCaps:
70+
def test_anthropic_model_receives_max_tokens(self, clean_env, monkeypatch):
71+
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-dummy")
72+
obj = make_model("anthropic:claude-haiku-4-5-20251001", role="curator")
73+
# langchain-anthropic stores the cap as .max_tokens
74+
assert getattr(obj, "max_tokens", None) == 2000
75+
76+
def test_openrouter_model_receives_max_tokens(self, clean_env, monkeypatch):
77+
monkeypatch.setenv("THESIS_PROVIDER", "openrouter")
78+
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-dummy")
79+
obj = make_model("z-ai/glm-5.1", role="drafter")
80+
cap = getattr(obj, "max_tokens", None) or getattr(obj, "max_completion_tokens", None)
81+
assert cap == 4000
82+
83+
def test_explicit_max_tokens_wins_over_role_default(self, clean_env, monkeypatch):
84+
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-dummy")
85+
obj = make_model(
86+
"anthropic:claude-haiku-4-5-20251001",
87+
role="drafter",
88+
max_tokens=123,
89+
)
90+
assert getattr(obj, "max_tokens", None) == 123
91+
92+
93+
# ---------------------------------------------------------------------------
94+
# _recursion_limit
95+
# ---------------------------------------------------------------------------
96+
97+
class TestRecursionLimit:
98+
def test_default_is_conservative(self, clean_env):
99+
assert _recursion_limit() == _DEFAULT_RECURSION_LIMIT
100+
assert _DEFAULT_RECURSION_LIMIT <= 50 # regression guard — don't raise silently
101+
102+
def test_env_override(self, clean_env, monkeypatch):
103+
monkeypatch.setenv("THESIS_RECURSION_LIMIT", "40")
104+
assert _recursion_limit() == 40
105+
106+
def test_env_override_has_lower_bound(self, clean_env, monkeypatch):
107+
# Very low values would make the agent unusable — clamp to >= 5.
108+
monkeypatch.setenv("THESIS_RECURSION_LIMIT", "1")
109+
assert _recursion_limit() >= 5
110+
111+
def test_non_digit_env_is_ignored(self, clean_env, monkeypatch):
112+
monkeypatch.setenv("THESIS_RECURSION_LIMIT", "unlimited")
113+
assert _recursion_limit() == _DEFAULT_RECURSION_LIMIT
114+
115+
116+
# ---------------------------------------------------------------------------
117+
# invoke() config passes through recursion_limit
118+
# ---------------------------------------------------------------------------
119+
120+
class TestInvokeConfig:
121+
def test_invoke_sets_recursion_limit_in_config(self, clean_env, monkeypatch, tmp_path):
122+
"""Smoke test: our `invoke()` wrapper must attach `recursion_limit`
123+
to the config dict it passes to the underlying agent."""
124+
from contextlib import contextmanager
125+
126+
from thesis_agent import agent as agent_mod
127+
128+
captured: dict = {}
129+
130+
class _FakeAgent:
131+
def invoke(self, payload, *, config=None):
132+
captured.update(config or {})
133+
return {"messages": [{"role": "assistant", "content": "ok"}]}
134+
135+
@contextmanager
136+
def fake_build_agent(**kw):
137+
yield _FakeAgent()
138+
139+
monkeypatch.chdir(tmp_path)
140+
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-dummy")
141+
monkeypatch.setattr(agent_mod, "build_agent", fake_build_agent)
142+
agent_mod.invoke("hi", thread_id="t-test")
143+
144+
assert "recursion_limit" in captured
145+
assert captured["recursion_limit"] == _DEFAULT_RECURSION_LIMIT
146+
assert captured["configurable"]["thread_id"] == "t-test"
147+
148+
def test_env_override_propagates_to_invoke(self, clean_env, monkeypatch, tmp_path):
149+
from contextlib import contextmanager
150+
151+
from thesis_agent import agent as agent_mod
152+
153+
captured: dict = {}
154+
155+
class _FakeAgent:
156+
def invoke(self, payload, *, config=None):
157+
captured.update(config or {})
158+
return {"messages": [{"role": "assistant", "content": "ok"}]}
159+
160+
@contextmanager
161+
def fake_build_agent(**kw):
162+
yield _FakeAgent()
163+
164+
monkeypatch.chdir(tmp_path)
165+
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-dummy")
166+
monkeypatch.setenv("THESIS_RECURSION_LIMIT", "7")
167+
monkeypatch.setattr(agent_mod, "build_agent", fake_build_agent)
168+
agent_mod.invoke("hi", thread_id="t-test")
169+
170+
assert captured["recursion_limit"] == 7
171+
172+
173+
# ---------------------------------------------------------------------------
174+
# Cost sanity check: worst-case per-call output bill
175+
# ---------------------------------------------------------------------------
176+
177+
def test_worst_case_output_spend_per_call_is_bounded(clean_env):
178+
"""At Sonnet 4.5 output prices ($15/M) a single drafter reply at its
179+
cap costs at most: 4000 tokens * $15 / 1_000_000 = $0.06. Sanity check
180+
the cap hasn't drifted into dangerous territory."""
181+
price_per_million_output = 15.0 # Sonnet 4.5 worst case
182+
for role, expected_max in (("drafter", 4000), ("curator", 2000), ("researcher", 1000)):
183+
cap = _role_max_tokens(role)
184+
assert cap == expected_max
185+
max_cost = cap * price_per_million_output / 1_000_000
186+
assert max_cost <= 0.10, (
187+
f"{role} cap {cap} would cost ${max_cost:.3f} per reply — too high"
188+
)

0 commit comments

Comments
 (0)