Solving tips
- An agent needs more than one exit: a step cap, a time budget, and a natural finish. Any one of them firing must stop the loop.
- Take the clock as a passed-in callable so the loop is deterministic and testable — never read the real wall clock inside logic you want to grade.
- Return why you stopped, not just the answer; the caller often retries or escalates based on the reason.
Build an agent loop that can stop for more than one reason. A basic loop stops when the model says it is done, but a production agent also has to stop when it has taken too many steps or spent too long. This exercise is the control logic that enforces all three limits and reports which one fired.
How the limits work
You keep a running list of messages and, each turn, call model(messages). The loop must respect three stop conditions and return the reason for the one that triggers first:
- Final answer — the model returns
{"type": "final", "content": "..."}. Stop and return(content, "final"). - Step cap — you have made
max_stepsmodel calls without a final answer. Stop and return(None, "max_steps"). - Time budget — the elapsed time (measured with the injected
clock) has reachedtime_budget. Stop and return(None, "budget").
clock is a zero-argument callable that returns a number that only increases (think seconds). Record the start time once, then compare clock() - start against time_budget before each model call. Because the clock is passed in, the whole loop is deterministic: the same clock stub always produces the same result.
Task
Complete run_with_limits(model, tools, user_msg, clock, max_steps=10, time_budget=None):
- Start
messageswith{"role": "user", "content": user_msg}and recordstart = clock(). - Before each model call, if
time_budget is not Noneandclock() - start >= time_budget, stop with(None, "budget"). - Otherwise call the model. On a final answer return
(content, "final"). On a tool call, append the assistant message, run the named tool, and append{"role": "tool", "name": ..., "content": <output>}. - Allow at most
max_stepsmodel calls. If they are exhausted with no final answer, return(None, "max_steps").
Example
def make_clock(step=1.0, start=0.0):
# returns start, start+step, start+2*step, ... on successive calls
t = {"now": start}
def clock():
v = t["now"]; t["now"] += step; return v
return clock
def never_finishes(messages):
# always asks for a tool, so only a limit can stop the loop
return {"role": "assistant", "type": "tool", "name": "noop", "args": {}}
tools = {"noop": lambda args: "ok"}
# clock ticks 0,1,2,3,...; budget of 3 stops before the 3rd model call
run_with_limits(never_finishes, tools, "go", make_clock(), max_steps=10, time_budget=3)
# -> (None, "budget")
def finishes(messages):
return {"role": "assistant", "type": "final", "content": "done"}
run_with_limits(finishes, tools, "go", make_clock(), max_steps=10, time_budget=3)
# -> ("done", "final")
Constraints
- Check the time budget before spending a model call, so an already-exhausted budget stops immediately.
- Handle an unknown tool name gracefully (append an error result rather than crashing).
- Do not call any real LLM, network, or wall clock —
model, the tools, andclockare all provided.