DEV Community

Manuel Bruña
Manuel Bruña

Posted on

Why My Agent Must Not Treat a Timeout as a Failed Action

A tool call can stop answering while the work it started keeps running. That is the distinction I want APX to preserve when something takes too long. If the runtime tells an agent “failed” when it only knows “I stopped waiting,” the next reasonable-looking step can be a duplicate action.

I am building APX as the local runtime around APC's portable project context. This build note is about a small part of that runtime: the watchdog around a tool call. My thesis is simple: a timeout should report the limit of our knowledge, not invent an outcome for the work.

The problem inside a normal await

The watchdog's source records the earlier shape of the agent loop: await the handler and continue when it answers. That is easy to read, but it assumes every handler eventually settles.

A handler may own a child process, a socket, or a nested agent turn. If its promise never settles, the turn can remain stuck even though the daemon is healthy. A responsive server is not evidence that this particular operation is progressing.

There was another consequence: checking the abort signal only between loop iterations left a gap while a tool was running. The person could ask to stop while the loop was still waiting for the handler.

I do not want correctness here to depend on every present and future tool getting every failure path right. Individual handlers still need fixes, but the surrounding loop also needs a way to stop waiting.

Slow, timed out, and aborted mean different things

The current watchdog races the handler against a slow notification, a deadline, and an abort signal. Those events do different jobs.

The default slow mark is 60 seconds. It reports that the call is taking time, then keeps waiting. It does not turn a slow operation into a failed one. The warning only arms when it falls before the deadline, and a reporting failure does not become a tool failure.

The default deadline is 15 minutes. A handler can declare its own deadline, including a function of its arguments. That matters because the outer waiting budget should account for the tool's own execution budget.

An abort takes a different path: the watchdog throws an AbortError. The agent loop rethrows it instead of converting it into an ordinary tool error and continuing with the remaining calls. That preserves the person's instruction to stop the turn.

None of this makes an arbitrary external operation reversible. Ending the wait and stopping the underlying work require different capabilities. The handler owns the process or connection; the generic watchdog does not magically acquire control over it.

The wording is part of the control flow

When the deadline wins, the watchdog returns a structured result with timed_out: true, an elapsed duration, and a message explaining that the operation was not cancelled. It tells the agent to check whether the work happened before trying again and to report the unknown state to the owner.

I treat that message as operational behavior. The model reads it before choosing another action. If the message collapses uncertainty into failure, it nudges the model toward recovery that may be wrong.

Consider a hypothetical publishing tool. It submits an article, but its response never reaches the caller before the deadline. These are three different observations:

  • The service explicitly rejected the request.
  • The service confirmed the article exists.
  • The caller stopped receiving an answer.

Only the third observation is available after that ambiguous timeout. Publishing again could create two articles. My next step would be to inspect the destination for the expected article, then decide whether a retry is justified. This example is a recovery principle, not a claim that APX automatically reconciles every publishing service.

For a command, the useful evidence might instead be a process status or an output artifact. The right check depends on what the tool was supposed to do. There is no universal “try again” that can replace that knowledge.

A timeout leaves cleanup work behind

Racing promises does not stop the losing promise. That makes the late outcome important even after the caller has moved on.

The watchdog attaches both success and rejection handling to the work. A rejection arriving after the deadline is therefore handled rather than becoming an unhandled rejection in the daemon. It also clears its timers and removes its abort listener once an outcome is selected.

Elapsed duration uses performance.now(). The source explains why: a wall clock can shift while a long-running daemon is alive. I want the reported wait to describe elapsed time, because someone may use that number to decide what to investigate next.

These are small implementation details, but they support the same promise: describe what this layer actually observed.

What I want the tests to protect

The existing watchdog tests cover more than a timer firing. They check that a successful handler's result passes through, that a hanging handler yields a timeout without claiming cancellation, and that a late rejection is handled. They also check that the slow warning reports during the call without ending it.

That is the behavior I want to preserve during refactoring. A faster error message would not be an improvement if it encouraged the agent to repeat work that already happened.

APC can carry the project's durable instructions. APX has to manage the uncertainty of an operation happening now. In that part of the system, “I stopped waiting; the outcome is unknown” is a useful result. It gives the next turn an honest starting point.

Implementation: tool watchdog, agent loop, and watchdog tests.

Top comments (0)