DEV Community

Mukunda Rao Katta
Mukunda Rao Katta

Posted on

Your Agent Loop Has No Clock: agent-deadline

The Slack bot ran fine for weeks.

Then a user asked it to summarize a legal document, cross-reference it against a database, and draft a reply in their company's tone. The agent started working. It called tools to fetch the document. Called more tools to query the database. Called more tools to pull tone samples. Each call kicked off another. Forty-seven tool calls total.

Slack gave up after 30 seconds. It marked the request as a timeout and showed the user an error. The user saw nothing. The agent kept running in the background. Eight minutes of LLM calls, all billed, all wasted. The user filed a bug report saying the bot was broken.

The bot was not broken. It just had no idea what the clock said.

The Shape of the Fix

agent-deadline is a small Python library. You create a Deadline with a duration. Inside your agent loop you call deadline.check_or_raise() at each iteration. When time runs out it raises DeadlineExceeded.

Install:

pip install agent-deadline
Enter fullscreen mode Exit fullscreen mode

Basic usage:

from agent_deadline import Deadline, DeadlineExceeded

deadline = Deadline(seconds=25)  # tighter than Slack's 30s timeout

try:
    while True:
        deadline.check_or_raise()

        response = call_llm(messages)
        tool_calls = response.tool_calls

        if not tool_calls:
            break

        for tc in tool_calls:
            result = run_tool(tc)
            messages.append(result)

except DeadlineExceeded:
    return "Ran out of time. Try a simpler query."
Enter fullscreen mode Exit fullscreen mode

That is the whole pattern. The check_or_raise() call at the top of the loop ensures you never start a new turn after the deadline has passed.

You can also feed remaining time to the model as context. That lets the model know it is running short and should wrap up:

while True:
    deadline.check_or_raise()

    remaining = deadline.remaining_seconds()
    system_prompt = f"You have {remaining:.0f} seconds left. Finish cleanly."

    response = call_llm(messages, system=system_prompt)
    ...
Enter fullscreen mode Exit fullscreen mode

For nested tasks with tighter inner budgets, Deadline.intersect returns the tighter of two deadlines:

outer = Deadline(seconds=60)

for subtask in subtasks:
    inner = Deadline(seconds=10)
    task_deadline = Deadline.intersect(outer, inner)

    process(subtask, deadline=task_deadline)
Enter fullscreen mode Exit fullscreen mode

If the outer deadline has 8 seconds left and the inner is 10 seconds, intersect returns the 8-second one. Neither deadline is modified.

What It Does NOT Do

A few things this library deliberately leaves out:

  • It does not interrupt an in-flight LLM call. If you are 25 seconds into a 60-second LLM request when the deadline fires, that request still completes. check_or_raise() only runs between turns.
  • It does not manage async cancellation. Cancelling an in-flight HTTP request is a different problem. Use asyncio.wait_for or your HTTP client's cancel API for that.
  • It does not retry or recover after DeadlineExceeded. That is for agent-resume or llm-fallback-chain.
  • It does not track cost or tokens. Time is its only concern. Combine with token-budget-py if you want both.

Inside the Lib: Cooperative vs. Preemptive

This library is cooperative, not preemptive. That word choice matters.

Preemptive deadline enforcement would interrupt the agent mid-turn. Thread cancellation, process signals, async task cancellation. These are all real tools. They are also complex. They can leave resources in a bad state, half-written files, uncommitted database rows, open network connections.

Cooperative enforcement is simpler. The agent checks the clock at a known safe point. The check point is check_or_raise(). You decide where those points are. Usually the top of the loop, before committing to another LLM call.

The tradeoff is obvious: a single very long LLM call can still exceed the deadline. If your model takes 35 seconds on one response and your deadline is 30, check_or_raise() will not fire until after that call returns. The next iteration will raise immediately.

This is intentional.

The goal is not microsecond precision. The goal is to stop scheduling new work after the time budget is gone. For most agent loops, cooperative enforcement is precise enough. You are not building a hard real-time system. You are building a loop that should stop before the user's Slack bot times out.

The library uses time.monotonic() internally. Not wall time. Monotonic time does not jump backward when the system clock is adjusted. If your server's NTP sync fires mid-run, your deadline stays stable.

When This Is Useful

Any agent that interfaces with a system that has its own timeout. Slack, webhooks, HTTP APIs that expect a response within N seconds. You need to know your deadline before you start and enforce it from inside the loop.

Nested agent architectures. A parent agent calls a subagent with a time budget. The subagent uses Deadline.intersect to respect both the parent budget and its own internal limit.

Long-running jobs where you want partial results instead of a timeout error. Catch DeadlineExceeded, return what you have so far.

Any situation where runaway agent loops have been a recurring problem. Once you burn money on a forgotten loop, you add deadlines everywhere.

When NOT to Use This

If your loop runs a fixed number of turns, a for i in range(10) is all you need. No deadline required.

If you need hard preemptive cancellation, this library will not give it to you. You need async task cancellation or thread interrupts. This library is the check at the top of the loop, not the kill signal.

If your agent is synchronous and single-threaded and you need to cancel a blocking HTTP call that is already in flight, there is nothing cooperative enforcement can do. That is an HTTP client problem.

Install

pip install agent-deadline
Enter fullscreen mode Exit fullscreen mode

Zero dependencies. Python 3.9+.

Source: MukundaKatta/agent-deadline

27 tests covering check_or_raise, remaining_seconds, intersect, expired deadline behavior, and edge cases around zero-second deadlines.

Siblings

These libraries cover adjacent boundaries in the same agent control plane:

Lib Boundary Repo
agent-resume Checkpoint and restart if the job exceeds its deadline MukundaKatta/agent-resume
llm-stop-conditions MaxSeconds is one stop condition; deadline is a lower-level primitive MukundaKatta/llm-stop-conditions
tool-call-budgets Cap by number of tool calls, not by time MukundaKatta/tool-call-budgets
token-budget-py Cap by token or USD cost, not by time MukundaKatta/token-budget-py

One note on the llm-stop-conditions relationship. That library has a MaxSeconds condition that you pass into an Evaluator. It stops the loop when elapsed time exceeds the threshold. agent-deadline solves the same problem from a different angle. You create the deadline once and carry it into nested calls. The deadline knows about itself. MaxSeconds knows only what you pass in each turn. For flat loops, either works. For nested or delegated tasks where the deadline needs to travel, agent-deadline is cleaner.

What Is Next

A few things worth adding:

Async support. The current API is synchronous. An async check_or_raise() that is awaitable would fit naturally into asyncio agent loops without blocking.

Context manager form. async with Deadline(seconds=30) as d: that automatically calls check_or_raise() at each async for step in a task stream.

JSONL tracing. Log each check_or_raise() call with a timestamp and remaining seconds. Useful for debugging why an agent stopped when you thought it had more time.

The Slack bot story is not unusual. External services set their own timeouts and do not care how long your agent thinks it needs. The fix is to enforce your own deadline first, tighter than theirs, and return something useful when it fires. This library makes that a three-line addition to any agent loop.


Part of the @mukundakatta agent tooling stack, built for the Hermes Agent Challenge.

Top comments (0)