# Bounding a model request on pi

> A reasoning model that streams for an hour isn't an error, so nothing in pi stops it. How loop-pi bounds each request by inactivity and total time, and what happens once one fails.

- Canonical page: https://rob-knight.com/articles/bounding-a-model-request-on-pi/
- Kind: Article
- Published: 2026-10-09
- Updated: 2026-10-19
- Topics: agents

Every model request in a loop-pi root and its lanes has two limits: 15 minutes without model progress, and 75 minutes in total. A request that keeps reasoning can run for an hour and still answer. One that goes quiet, or never finishes, is aborted, and an armed root picks itself back up after a backoff without me typing continue. Both limits live in `request-ceiling`, a pi extension in [loop-pi](https://github.com/rknightion/loop-pi), and came in with [ac2a09e](https://github.com/rknightion/loop-pi/commit/ac2a09e2e8a35fe4f7bd82eed88a1dde1cb1c8d3).

<figure class="diagram figure-breakout">
  <img class="on-light" src="/diagrams/52-tldr-bounding-a-model-request-on-pi-v2-light.svg" alt="At a glance: a model request on loop-pi has two limits, 15 minutes without model progress and 75 minutes absolute, and an armed root recovers by itself after a failure. A minutes axis with ticks at 0, 15, 60 and 75 carries three bars. Reasoning, events keep arriving: the bar runs past 60 minutes and ends in an OK mark, answer. Open but silent, keepalives only: a hollow bar of keepalive dots stops at 15 minutes, the inactivity limit, 15 min without model progress, because keepalives don't count. Never finishes: the bar stops at 75 minutes, the absolute limit, 75 min absolute. Below, what follows a failed request: retries share one 20-minute retry episode, then the root is resumed after a backoff of 1 to 15 minutes, as a new turn, with no attempt limit. A side branch: authentication, quota, policy and context-length errors stop with an incident instead." width="1440" height="1164" loading="lazy" decoding="async">
  <img class="on-dark" src="/diagrams/52-tldr-bounding-a-model-request-on-pi-v2-dark.svg" alt="" aria-hidden="true" width="1440" height="1164" loading="lazy" decoding="async">
  <figcaption>Inactivity counts from the latest model event, or from the start when none has arrived, which is why the silent request is cut at the first 15-minute tick.</figcaption>
</figure>

## Why pi needs a bound

pi 1.3.0, the version loop-pi pins, puts no limit on the total time of a streaming request. Its HTTP dispatcher [sets a 300-second idle timeout](https://github.com/earendil-works/pi/blob/71023aec1546d6d677a65669c5665c0927ce0dd0/packages/coding-agent/src/core/http-dispatcher.ts#L81-L96) on headers and body, every streamed event resets the body timer, and the [provider timeout is cleared once headers arrive](https://github.com/rknightion/loop-pi/blob/02e63b3399be9778af9b5f4923d85aee7eca3312/extensions/SEAMS.md?plain=1#L280-L287). A model that keeps streaming reasoning never trips any of them.

The case for a bound is 3 October. Between 14:33 and 18:15 UTC several of my roots on `gpt-6.1-sol` at medium effort got a fast tool result back and then reasoned for 20 to 72 minutes before answering. Three calls ran 64, 70 and 72 minutes and ended in a 128,000-token length stop with no output at all. Two more ran 32 and 36 minutes and were aborted by a 502. Nothing was configured wrong: the proxy's request log shows every call over 15 minutes asked for `reasoning_effort: medium`, which is what the home sends. Since 2 October Sol at medium on the standard tier had served 17,609 requests, 28 of them over 15 minutes, with a maximum of 128,000 reasoning tokens. Sol at high, on both tiers, had served about 3,200 with none over 15 minutes and a maximum of 9,268. I think the runaway is the provider's behaviour at medium effort, though I can't see inside it to say why.

But long isn't always broken. I've had valid answers after up to an hour of reasoning. A fixed wall-clock limit throws those away with the runaways, so the bound has to tell a model that's still working from one that isn't.

It matters more in a loop than in an interactive session. A root waiting on its own model call holds everything, and lane results sit unread until it answers.

## Two limits

`request-ceiling` starts both clocks when a model request starts and clears them when the assistant message ends. One agent-loop turn is one request, and a retry is a new turn with new clocks. The [defaults](https://github.com/rknightion/loop-pi/blob/02e63b3399be9778af9b5f4923d85aee7eca3312/extensions/request-ceiling/core.ts#L10-L16) are:

- **Inactivity, 15 minutes**, measured from the last model event, or from the start of the request if none has arrived. A model event is the start or end of a reasoning, text or tool-call block, or a nonempty delta of one, and encrypted reasoning counts. Transport keepalives, response headers and empty deltas don't, so a connection that's open but silent still expires.
- **Absolute, 75 minutes**, fixed when the request starts and never moved. It's the backstop for a model that keeps producing events and never finishes.

<figure class="diagram figure-breakout">
  <img class="on-light" src="/diagrams/35-request-ceiling-timeline-v2-light.svg" alt="One streaming request on an axis from 0 to 75 minutes, with four lanes. Provider timeout: ends when response headers arrive, seconds in. Idle timeout 300 s: reset by every streamed event, so it never fires. Inactivity 15 min: ticks mark model events up to 28 minutes; keepalive dots carry on but don't reset inactivity, and 15 minutes after the last model event the request is aborted at 43 minutes: abort, incident, follow-up turn. Absolute 75 min: a deadline fixed when the request starts and never moved, the backstop for a request whose model events never stop. A legend names the model event tick and the keepalive dot." width="1440" height="736" loading="lazy" decoding="async">
  <img class="on-dark" src="/diagrams/35-request-ceiling-timeline-v2-dark.svg" alt="" aria-hidden="true" width="1440" height="736" loading="lazy" decoding="async">
  <figcaption>This request is cut at 43 minutes, 15 after its last model event and well before the 75-minute deadline it was given at the start.</figcaption>
</figure>

Both are settings (`loopPi.requestCeiling.inactivityMs` and `absoluteMs`), and a value that isn't a positive number in range falls back to its default rather than turning the limit off. The [tests](https://github.com/rknightion/loop-pi/blob/02e63b3399be9778af9b5f4923d85aee7eca3312/extensions/request-ceiling/activity-stream.test.ts#L55-L85) stream a request on a controlled clock through an hour of encrypted reasoning to a useful answer, a heartbeat-only stream that expires for inactivity and an endless one that hits the absolute limit.

Two more bounds sit beside them. An assistant response that stops on its length limit with no text and no tool call is [rewritten as a non-retryable error](https://github.com/rknightion/loop-pi/blob/02e63b3399be9778af9b5f4923d85aee7eca3312/extensions/SEAMS.md?plain=1#L308-L311). pi would otherwise retry the identical request, or treat it as a context overflow and compact, and an identical request is likely to run away the same way. The home also caps Sol's output at 80,000 tokens, so a runaway like 3 October's hits its length stop well short of 128,000. For that failure the cap does more than the absolute limit would, since those calls ended by themselves within 72 minutes.

A ceiling abort or an empty length stop writes an incident file, which for an abort names the limit that fired, and sends the root or lane one message saying what happened. That message starts a new turn. A session gets at most two of those in a row, and any normal response resets the count. Past that, the session stops.

The stall watchdog reads the [request checkpoint](https://github.com/rknightion/loop-pi/blob/02e63b3399be9778af9b5f4923d85aee7eca3312/extensions/SEAMS.md?plain=1#L185-L240) loop-pi writes while a call is in flight, and holds its stall alert while the request is live. Model events extend that allowance and keepalives don't, the same rule as the inactivity limit, so a call that's still reasoning isn't reported as a stalled root.

## Retries

Bounding one request doesn't bound a request that fails and is retried. pi's own retry waits a base delay that doubles each attempt up to a cap, with no jitter and no split between a rate limit and a server error, so lanes that hit the same 5xx retry together. The extension can't replace that wait, so it [adds to it](https://github.com/rknightion/loop-pi/blob/02e63b3399be9778af9b5f4923d85aee7eca3312/extensions/request-ceiling/backoff.ts#L1-L15). A 429 waits at least 60 seconds plus up to half again at random. A 5xx, or anything else pi retries, gets pi's delay plus up to half of it again, so lanes that failed together drift apart.

For the provider the home configures, retries also share one [20-minute episode](https://github.com/rknightion/loop-pi/blob/02e63b3399be9778af9b5f4923d85aee7eca3312/extensions/request-ceiling/recovery.ts#L11-L19). It starts at the first safely recoverable failure, not at the start of the request, so a healthy long first request carries no recovery deadline. It counts every wait and request after that. A successful response resets it, and so does input I type while the session is idle; reasoning, queued input, compaction and the extension's own follow-ups can't extend it. The episode allows at most five attempts, and pi's default of three retries holds that to four. When it runs out, the active work is aborted and nothing synthetic may restart that request.

## Recovering the root

The episode only ends a burst of retries. When a provider error, a stalled request or a ceiling abort leaves an armed root stopped, `loop-continuation` [resumes it](https://github.com/rknightion/loop-pi/blob/02e63b3399be9778af9b5f4923d85aee7eca3312/extensions/loop-continuation/root-recovery.ts#L20-L21) after a backoff that starts at 1 minute and doubles to a 15-minute cap, with no attempt limit. The resume is a new turn, not a replay, and tells the root that tool calls from the failed request never ran, that completed tool results did, and to reconcile from its state files before carrying on. Each resume resets the retry episode and is logged in the run's state log.

Some failures need me, and [those stop instead](https://github.com/rknightion/loop-pi/blob/02e63b3399be9778af9b5f4923d85aee7eca3312/extensions/loop-continuation/root-recovery.ts#L94-L130). Authentication, quota, policy and context-length errors end the run with an incident, and an abort I made myself is never resumed. Lanes aren't resumed either, and their root sees the failure. The [pi harness page](/reference/loop-harness-pi/#runtime-and-launch) is the version a root reads.

<figure class="diagram figure-breakout">
  <img class="on-light" src="/diagrams/59-root-recovery-ladder-light.svg" alt="Left to right. Inside one 20-minute episode: request fails, then pi retry with jitter, where a 429 waits at least 60 s and a 5xx gets added jitter. When the episode is exhausted the root stops. An armed root is then resumed on a ladder of backoff waits drawn to scale, 1, 2, 4, 8, 15 and 15 minutes, each ending in a resume as a new turn, and 15 minutes each time after that with no attempt limit. A branch down from root stops: authentication, quota, policy and context-length errors stop with an incident instead. Notes: lanes are not resumed automatically; an owner abort is never resumed." width="1440" height="713" loading="lazy" decoding="async">
  <img class="on-dark" src="/diagrams/59-root-recovery-ladder-dark.svg" alt="" aria-hidden="true" width="1440" height="713" loading="lazy" decoding="async">
  <figcaption>The waits are drawn to scale: the first four resumes together wait 15 minutes, as long as any single wait once the cap is reached.</figcaption>
</figure>

## When a retry is safe

The harder part of retrying is knowing whether it's safe. A request that streamed half an answer and then dropped might have started a tool, and replaying it could run that tool twice. So a retry is [allowed only when the request is portable](https://github.com/rknightion/loop-pi/blob/02e63b3399be9778af9b5f4923d85aee7eca3312/extensions/SEAMS.md?plain=1#L386-L391): nothing local has started, no tool calls or results are unresolved or duplicated, and the request carries no hosted items, bound files or anchors. A timeout after partial output can still be retried when the failed response provably ran none of its local tools. Unknown structured errors, authentication errors and explicit refusals stop rather than retry, and the stop message names the reason.

None of this is an exactly-once guarantee. It's transient recovery for requests whose effects the extension can see, and work done by a hosted tool or an external system whose execution can't be established is out of reach.

## Limits

Neither limit makes the provider stop reasoning; each stops my side of the wait and throws the work away. I set the absolute limit at 75 minutes to leave room for an hour of reasoning that ends in an answer, and neither number is an optimum measured from traffic.

The inactivity limit is only as good as the stream. Reasoning the provider doesn't surface as events looks the same as silence and is cut at 15 minutes.

I haven't yet watched the two limits in natural runs, so I can't say how often either fires or whether the hour-long answers now get through.

The 3 October numbers come from one afternoon, and the high-effort comparison is a different mix of work, so it's evidence that medium is where the runaways happened, not proof that high effort never runs away. The [machinery article](/articles/the-machinery-around-the-loop/) covers how `request-ceiling` sits beside the other loop-pi extensions.
