Skip to content

Commit cc40eaf

Browse files
committed
fix(agent): size the step budget for research turns and fail usefully
A turn died in production with "Recursion limit of 25 reached without hitting a stop condition", killing the SSE stream mid-answer. 25 does not mean 25 tool calls. The limit counts super-steps, and the middleware chain makes one model-call-plus-tool round trip cost four of them (model -> TodoListMiddleware.after_model -> CopilotKitMiddleware.after_model -> tools), plus ~3 fixed for entry and exit. The real budget was about five tool calls per turn. The budget also resets on resume, so this was one turn wanting a sixth call, not a long conversation accumulating. That was the right size when the limit was lowered from 100 to 25 in 4ac1a7a for a triage-only bot. The agent now loads 85 tools and gets asked research questions -- "find how many PRs shipped in CPK related to channels, categorize them, chart it" answered from 212 PRs and must have landed right at the cap; the next comparable question went over. Raise the default to 60 (about fourteen tool calls) and read it from AGENT_RECURSION_LIMIT so it can be tuned without a deploy, rejecting a value too low to finish a single tool call. Raising the ceiling makes a runaway turn more expensive, so also stop losing the failure. GraphRecursionError escaped as an ASGI error and the Channel could only answer with its generic "I hit an error", forty seconds in, with no hint that a narrower question would work. Catch it, end the run the way the protocol expects, say what happened and what to try, and log the tool trail so the next occurrence is diagnosable from the logs instead of from Slack.
1 parent da91c40 commit cc40eaf

6 files changed

Lines changed: 268 additions & 4 deletions

File tree

agent/agent.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""OpenTag's triage-first Deep Agent."""
22

33
import os
4+
from collections.abc import Mapping
45
from pathlib import Path
56

67
from copilotkit import CopilotKitMiddleware
@@ -30,6 +31,19 @@
3031
)
3132
VALID_VERBOSITY_LEVELS = frozenset({"low", "medium", "high"})
3233

34+
# Super-steps the graph may take in one turn. This is NOT a tool-call budget:
35+
# the middleware chain means one model-call-plus-tool round trip costs four
36+
# super-steps (model -> TodoListMiddleware.after_model ->
37+
# CopilotKitMiddleware.after_model -> tools), plus ~3 fixed for entry and exit.
38+
# So the usable budget is roughly (limit - 3) / 4 tool calls -- the old value of
39+
# 25 bought about five, which a GitHub or Linear research question exhausts
40+
# mid-answer. 60 buys about fourteen.
41+
DEFAULT_RECURSION_LIMIT = 60
42+
43+
# Below this the agent cannot complete a single tool call and every research
44+
# turn dies; treat it as a misconfiguration rather than a tuning choice.
45+
MIN_RECURSION_LIMIT = 8
46+
3347
# Deep Agents adds shell execution and a general-purpose delegation tool by
3448
# default. OpenTag has no sandbox for execute, and delegating routine turns to
3549
# another agent adds latency without improving triage.
@@ -56,6 +70,25 @@ def _validated_openai_setting(
5670
return value
5771

5872

73+
def recursion_limit(env: Mapping[str, str] = os.environ) -> int:
74+
"""Read AGENT_RECURSION_LIMIT, or fall back to the tuned default."""
75+
raw = env.get("AGENT_RECURSION_LIMIT")
76+
if raw is None or not raw.strip():
77+
return DEFAULT_RECURSION_LIMIT
78+
try:
79+
value = int(raw)
80+
except ValueError as error:
81+
raise RuntimeError(
82+
f'Invalid AGENT_RECURSION_LIMIT: "{raw}" — must be an integer'
83+
) from error
84+
if value < MIN_RECURSION_LIMIT:
85+
raise RuntimeError(
86+
f"Invalid AGENT_RECURSION_LIMIT: {value} — must be at least "
87+
f"{MIN_RECURSION_LIMIT}, or the agent cannot finish a tool call"
88+
)
89+
return value
90+
91+
5992
def build_agent():
6093
"""Build the OpenTag triage graph."""
6194
api_key = os.environ.get("OPENAI_API_KEY")
@@ -111,4 +144,10 @@ def build_agent():
111144
print(f"[AGENT] internal-source tools: {len(internal_tools)}")
112145
print(f"[AGENT] Main tools: {[t.name for t in main_tools]}")
113146

114-
return agent_graph.with_config({"recursion_limit": 25})
147+
limit = recursion_limit()
148+
print(
149+
f"[AGENT] recursion limit: {limit} "
150+
f"(~{(limit - 3) // 4} tool calls per turn)"
151+
)
152+
153+
return agent_graph.with_config({"recursion_limit": limit})

agent/main.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@
55
import sys
66

77
from ag_ui_langgraph import add_langgraph_fastapi_endpoint
8-
from copilotkit import LangGraphAGUIAgent
98
from fastapi import FastAPI
109
from fastapi.middleware.cors import CORSMiddleware
1110

1211
from agent import build_agent
12+
from step_budget import StepBudgetAwareAgent
1313

1414
app = FastAPI(
1515
title="OpenTag Agent",
@@ -63,7 +63,7 @@ def local_server_port(env: Mapping[str, str] = os.environ) -> int:
6363
agent_graph = build_agent()
6464
add_langgraph_fastapi_endpoint(
6565
app=app,
66-
agent=LangGraphAGUIAgent(
66+
agent=StepBudgetAwareAgent(
6767
name=AGENT_NAME,
6868
description=AGENT_DESCRIPTION,
6969
graph=agent_graph,

agent/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ py-modules = [
2525
"agent",
2626
"internal_sources",
2727
"main",
28+
"step_budget",
2829
"tools",
2930
"write_confirmation",
3031
]

agent/step_budget.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Turn a blown step budget into an answer instead of a broken stream.
2+
3+
`GraphRecursionError` is raised from inside LangGraph's Pregel loop, which by
4+
then is being consumed by the AG-UI SSE response. Uncaught, it escapes as an
5+
ASGI error mid-stream: the client sees a truncated run, and the Channel can
6+
only fall back to its generic "I hit an error" reply -- forty seconds after the
7+
user asked, with no hint that a narrower question would have worked.
8+
9+
This wrapper ends the run the way the protocol expects (a text message, then
10+
RUN_FINISHED) and says what actually happened.
11+
"""
12+
13+
import logging
14+
import uuid
15+
from typing import Any, AsyncGenerator
16+
17+
from ag_ui.core import (
18+
RunFinishedEvent,
19+
TextMessageContentEvent,
20+
TextMessageEndEvent,
21+
TextMessageStartEvent,
22+
)
23+
from copilotkit import LangGraphAGUIAgent
24+
from langgraph.errors import GraphRecursionError
25+
26+
logger = logging.getLogger(__name__)
27+
28+
STEP_BUDGET_MESSAGE = (
29+
"That one ran past my step budget, so I stopped before finishing — "
30+
"nothing was left half-written. Narrowing it usually does the trick: "
31+
"name a single repo, a date range, or ask for the counts first and the "
32+
"breakdown after."
33+
)
34+
35+
36+
class StepBudgetAwareAgent(LangGraphAGUIAgent):
37+
"""A LangGraph AG-UI agent that reports hitting its recursion limit."""
38+
39+
async def run(self, input: Any) -> AsyncGenerator[Any, None]:
40+
try:
41+
async for event in super().run(input):
42+
yield event
43+
except GraphRecursionError:
44+
thread_id = getattr(input, "thread_id", "") or ""
45+
run_id = getattr(input, "run_id", "") or ""
46+
# The trajectory is the part worth having in the logs: without it
47+
# the next occurrence is only diagnosable by reading Slack.
48+
logger.error(
49+
{
50+
"error": "Agent exhausted its step budget",
51+
"context": {
52+
"component": "step_budget",
53+
"thread_id": thread_id,
54+
"run_id": run_id,
55+
"tool_calls": _tool_call_trail(input),
56+
},
57+
}
58+
)
59+
60+
message_id = str(uuid.uuid4())
61+
yield TextMessageStartEvent(message_id=message_id, role="assistant")
62+
yield TextMessageContentEvent(
63+
message_id=message_id, delta=STEP_BUDGET_MESSAGE
64+
)
65+
yield TextMessageEndEvent(message_id=message_id)
66+
yield RunFinishedEvent(thread_id=thread_id, run_id=run_id)
67+
68+
69+
def _tool_call_trail(input: Any) -> list[str]:
70+
"""Names of the tools the run had already called, oldest first."""
71+
names: list[str] = []
72+
for message in getattr(input, "messages", None) or []:
73+
for call in getattr(message, "tool_calls", None) or []:
74+
name = getattr(call, "function", None)
75+
name = getattr(name, "name", None) or getattr(call, "name", None)
76+
if name:
77+
names.append(str(name))
78+
return names

agent/tests/test_agent_configuration.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,49 @@ def test_build_agent_rejects_invalid_openai_tuning(monkeypatch, name, value):
9797

9898

9999
def test_build_agent_bounds_graph_recursion(monkeypatch):
100+
monkeypatch.delenv("AGENT_RECURSION_LIMIT", raising=False)
100101
_, captured = build_with_captured_configuration(monkeypatch)
101102

102-
assert captured["config"] == {"recursion_limit": 25}
103+
assert captured["config"] == {
104+
"recursion_limit": agent_mod.DEFAULT_RECURSION_LIMIT
105+
}
106+
107+
108+
def test_the_default_step_budget_covers_a_multi_step_research_turn():
109+
"""One tool round trip costs four super-steps, plus ~3 fixed per run.
110+
111+
The default has to leave room for a real research question -- the old
112+
value of 25 allowed about five tool calls, which a GitHub search plus a
113+
chart exhausts mid-answer.
114+
"""
115+
tool_calls = (agent_mod.DEFAULT_RECURSION_LIMIT - 3) // 4
116+
117+
assert tool_calls >= 12
118+
119+
120+
def test_recursion_limit_is_configurable(monkeypatch):
121+
monkeypatch.setenv("AGENT_RECURSION_LIMIT", "120")
122+
_, captured = build_with_captured_configuration(monkeypatch)
123+
124+
assert captured["config"] == {"recursion_limit": 120}
125+
126+
127+
def test_a_blank_recursion_limit_falls_back_to_the_default():
128+
assert (
129+
agent_mod.recursion_limit({"AGENT_RECURSION_LIMIT": " "})
130+
== agent_mod.DEFAULT_RECURSION_LIMIT
131+
)
132+
assert agent_mod.recursion_limit({}) == agent_mod.DEFAULT_RECURSION_LIMIT
133+
134+
135+
def test_a_nonsense_recursion_limit_is_rejected():
136+
with pytest.raises(RuntimeError, match="AGENT_RECURSION_LIMIT"):
137+
agent_mod.recursion_limit({"AGENT_RECURSION_LIMIT": "lots"})
138+
139+
140+
def test_a_recursion_limit_too_low_to_finish_a_tool_call_is_rejected():
141+
with pytest.raises(RuntimeError, match="at least"):
142+
agent_mod.recursion_limit({"AGENT_RECURSION_LIMIT": "4"})
103143

104144

105145
def test_build_agent_refreshes_current_date_before_each_model_call(monkeypatch):

agent/tests/test_step_budget.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import asyncio
2+
3+
import pytest
4+
from ag_ui.core import EventType
5+
from langgraph.errors import GraphRecursionError
6+
from step_budget import STEP_BUDGET_MESSAGE, StepBudgetAwareAgent
7+
8+
9+
class FakeInput:
10+
def __init__(self, messages=None):
11+
self.thread_id = "thread-1"
12+
self.run_id = "run-1"
13+
self.messages = messages or []
14+
15+
16+
class FakeCall:
17+
def __init__(self, name):
18+
self.name = name
19+
20+
21+
class FakeMessage:
22+
def __init__(self, names):
23+
self.tool_calls = [FakeCall(n) for n in names]
24+
25+
26+
def drain(agent, run_input):
27+
async def collect():
28+
return [event async for event in agent.run(run_input)]
29+
30+
return asyncio.run(collect())
31+
32+
33+
def run_with(monkeypatch, events, error=None):
34+
"""Drive StepBudgetAwareAgent.run over a stubbed parent generator."""
35+
agent = StepBudgetAwareAgent.__new__(StepBudgetAwareAgent)
36+
37+
async def fake_parent(_self, _input):
38+
for event in events:
39+
yield event
40+
if error is not None:
41+
raise error
42+
43+
monkeypatch.setattr(
44+
"step_budget.LangGraphAGUIAgent.run", fake_parent, raising=True
45+
)
46+
return drain(agent, FakeInput([FakeMessage(["search_pull_requests"])]))
47+
48+
49+
def test_events_pass_through_untouched_when_the_run_succeeds(monkeypatch):
50+
events = run_with(monkeypatch, ["a", "b"])
51+
52+
assert events == ["a", "b"]
53+
54+
55+
def test_a_blown_step_budget_becomes_an_answer_not_a_broken_stream(monkeypatch):
56+
events = run_with(
57+
monkeypatch, ["a"], GraphRecursionError("Recursion limit of 60 reached")
58+
)
59+
60+
# Whatever the run produced before the limit survives, then the run is
61+
# closed the way the protocol expects rather than dying mid-stream.
62+
assert events[0] == "a"
63+
types = [getattr(e, "type", None) for e in events[1:]]
64+
assert types == [
65+
EventType.TEXT_MESSAGE_START,
66+
EventType.TEXT_MESSAGE_CONTENT,
67+
EventType.TEXT_MESSAGE_END,
68+
EventType.RUN_FINISHED,
69+
]
70+
71+
72+
def test_the_step_budget_message_tells_the_user_what_to_do(monkeypatch):
73+
events = run_with(monkeypatch, [], GraphRecursionError("boom"))
74+
content = next(
75+
e for e in events if getattr(e, "type", None) == EventType.TEXT_MESSAGE_CONTENT
76+
)
77+
78+
assert content.delta == STEP_BUDGET_MESSAGE
79+
assert "narrow" in content.delta.lower()
80+
# The user needs to know the write side is clean, not half-applied.
81+
assert "half-written" in content.delta
82+
83+
84+
def test_the_closing_events_carry_the_run_identity(monkeypatch):
85+
events = run_with(monkeypatch, [], GraphRecursionError("boom"))
86+
finished = events[-1]
87+
start = events[0]
88+
end = events[2]
89+
90+
assert finished.thread_id == "thread-1"
91+
assert finished.run_id == "run-1"
92+
# One message, opened and closed under the same id.
93+
assert start.message_id == end.message_id
94+
95+
96+
def test_other_failures_still_propagate(monkeypatch):
97+
with pytest.raises(ValueError):
98+
run_with(monkeypatch, [], ValueError("something else"))
99+
100+
101+
def test_the_tool_trail_is_logged_for_diagnosis(monkeypatch, caplog):
102+
run_with(monkeypatch, [], GraphRecursionError("boom"))
103+
104+
logged = [r for r in caplog.records if "step_budget" in str(r.msg)]
105+
assert logged, "the failure must be diagnosable from the logs alone"
106+
assert "search_pull_requests" in str(logged[0].msg)

0 commit comments

Comments
 (0)