This is a submission for the Hermes Agent Challenge.
My Hermes research agent's stop logic had grown into a 40-line if/elif block. Stop after 20 turns. Stop if cost exceeds $2. Stop if the response contains "FINAL ANSWER". Stop if the last tool called was "write_summary". Each condition was written out longhand, tested independently, and hard to reuse across different agents.
I extracted the pattern into agent-loop-stop
.
from agent_loop_stop import any_of, after_n_turns, cost_exceeds, response_contains
stopper = any_of(
after_n_turns(20),
cost_exceeds(2.00),
response_contains("FINAL ANSWER"),
)
for turn in range(1, 100):
response = call_llm(messages)
state = {"turn": turn, "cost_usd": running_cost, "response": response.text}
if stopper.check(state):
break
That's it. stopper.check(state)
returns True when any condition fires. The state dict can have whatever you want in it — built-in conditions read well-known keys.
after_n_turns(20) # state["turn"] >= 20
cost_exceeds(2.00) # state["cost_usd"] > 2.00
response_contains("FINAL ANSWER") # case-insensitive substring
last_tool_was("write_summary") # state["last_tool"] == name
custom(lambda s: s.get("retries") > 3) # any callable
always() # always True (testing)
never() # always False (placeholder)
c = after_n_turns(20) | cost_exceeds(1.00)
c = after_n_turns(10) & cost_exceeds(0.50)
c = ~response_contains("continue")
Or use the function form:
any_of(after_n_turns(20), cost_exceeds(2.00), response_contains("done"))
all_of(after_n_turns(5), cost_exceeds(0.25))
negate(response_contains("error"))
Both styles work identically. The operator form is more concise; the function form is more explicit about what's happening.
from agent_loop_stop import check_all
result = check_all(
state,
{
"turn_limit": after_n_turns(20),
"cost_limit": cost_exceeds(2.00),
"done_signal": response_contains("FINAL ANSWER"),
},
)
if result.stopped:
log.info(f"Agent stopped. Reason(s): {result.triggered}")
check_all
checks every named condition individually and returns a StopResult
with which ones triggered. This is what I log in my Hermes agent — if I see "turn_limit" fired instead of "done_signal", that means the agent ran out of turns without finishing.
c = custom(lambda s: len(s.get("tool_calls_this_turn", [])) > 5)
Or subclass for reusable predicates:
from agent_loop_stop import StopCondition
class TokenBudgetStop(StopCondition):
def __init__(self, limit: int):
self._limit = limit
def check(self, state):
return state.get("tokens_used", 0) > self._limit
stopper = any_of(after_n_turns(20), TokenBudgetStop(4000))
Different Hermes agents have different stop requirements:
SUPERVISOR_STOP = any_of(
after_n_turns(50),
cost_exceeds(5.00),
response_contains("SYNTHESIS COMPLETE"),
)
WORKER_STOP = any_of(
after_n_turns(15),
cost_exceeds(0.50),
last_tool_was("submit_findings"),
)
Named, reusable, composable. Each agent gets its own stop config that says exactly what it means.
Standard library only: dataclasses
, typing
. No third-party packages.
pip install agent-loop-stop