perplexed

Long deep-research streams no longer get cut off at the ceiling — the timer now resets every time bytes arrive

The per-chunk idle-timeout discipline the legacy modal flow has used for two iterations now governs the directory-template flow too. A healthy slow stream completes naturally; a silently-stalled one fails in seconds instead of minutes.

The structural fix the morning's market-map run earned

Why care?

If you run any directory template on Perplexity — especially the long deep-research templates like market-map-profile — your timeouts now behave the way you'd intuit they should. A stream that is healthily producing bytes runs as long as it needs to run. A stream that goes silent (Perplexity rate-limit, socket close, upstream stall) surfaces the failure inside seconds, not at the end of a 30-minute ceiling. The change is invisible when everything goes well and only noticeable when something would have gone wrong — which is when you most want it noticeable.

Earlier today we shipped a market-map template, watched it produce a beautiful 7,500-word draft on Humanoid Robots, and then watched the last sentence cut off mid-word because the wall-clock timer fired before the stream finished. We patched the immediate pain by raising the ceiling. This entry is the structural fix underneath it.

What's new?

A single concept: the timer that decides "is this stream still alive?" is now armed per chunk, not once per request.

  • Old behavior. One setTimeout(controller.abort, timeoutMs) at fetch time. If the stream takes longer than timeoutMs, even by one byte, abort. If the stream goes silent for 27 of the 30 allotted minutes, wait the full 30 anyway before noticing.

  • New behavior. Each reader.read() is raced against a fresh timer. As long as bytes keep arriving, the timer is cleared and re-armed. The stream is only killed if it goes quiet for idleMs — by default 270 seconds for deep-research models, 90 seconds for everything else.

Two cft-block keys now govern timeouts (both optional):

CFT
provider: perplexity
model: sonar-deep-research
stream-idle-timeout-ms: 270000   # per-chunk idle (default already 270s for deep-research)
request-timeout-ms: 2400000      # absolute wall-clock ceiling (40 min)
system: |
  ...

stream-idle-timeout-ms: is the new primary safety. request-timeout-ms: is the legacy key, now repurposed as an opt-in absolute ceiling — set to 0 to disable and rely on idle-only.

How it works

Two different streaming primitives have lived in this codebase since the directory-template flow forked from the legacy PerplexityModal flow. The legacy modal moved to per-chunk idle-timeout discipline two iterations ago after the same problem hit users there first. The directory-template flow never received the backport — until today.

The pattern, ported from perplexityService.ts:659-668 into streamPerplexityToFile:

TS
const readWithIdleTimeout = (): Promise<ReadableStreamReadResult<Uint8Array>> => {
    let timer: number | undefined;
    const timeout = new Promise<never>((_, reject) => {
        timer = activeWindow.setTimeout(() => {
            reject(new Error(`stream went idle for ${idleMs / 1000}s (likely API stall, rate limit, or socket close)`));
        }, idleMs);
    });
    return Promise.race([reader.read(), timeout]).finally(() => {
        if (timer !== undefined) activeWindow.clearTimeout(timer);
    });
};

// inside the read loop:
({ value, done } = await readWithIdleTimeout());

Promise.race([reader.read(), timeout]) resolves with whichever wins. If a byte arrives, the .finally() clears the timer before it can fire and the loop continues. If the timer fires first, it rejects, the surrounding catch sets truncated = true, the existing cleanup pipeline flushes whatever already arrived, and the run returns with a partial-but-cited draft on disk and a Notice telling the user to re-run.

The AbortController stays — both as the cancel mechanism for the user-initiated Cancel command, and as the abort target for the optional wall-clock ceiling. The two timers are complementary: idle handles "is this stream alive right now?" and ceiling handles "regardless, do not let this run forever."

The two pathologies the old design handled poorly

Shape 1 — slow but healthy stream. Deep-research generations on long templates sustain a slow trickle of tokens for tens of minutes. The old wall-clock cap killed them at the ceiling regardless of whether they were still producing. The idle timer lets them complete as long as bytes keep arriving inside the idle window. (The ceiling is still there if you want a hard cap — it's just not what's making the moment-to-moment safety decision anymore.)

Shape 2 — silently stalled stream. Conversely, a stream may go quiet at minute 3 (Perplexity rate-limit, socket close, upstream stall) and the old wall-clock cap wouldn't notice until minute 30. The user stared at an empty file for 27 unnecessary minutes. The idle timer surfaces the failure within idleMs seconds — fast feedback when something is genuinely wrong.

Both shapes are real. The wall-clock pattern punished healthy-but-slow while tolerating stalled-but-silent. The idle-timeout pattern inverts both — slow-but-healthy completes; stalled-but-silent fails fast.

Migration notes

Nothing breaks for existing templates. The request-timeout-ms: key is still honored where declared; it just means "absolute wall-clock ceiling" now instead of "the only timer at all." Templates that didn't declare it still inherit settings.requestTimeoutMs as their ceiling. market-map-profile.md keeps its 2400000 (40 min) value — under the new semantics it's the ceiling on top of the 270s idle timer, which is exactly what an analyst-grade deep-research budget should look like.

To get truly unbounded healthy streams (idle-only safety, no ceiling), set request-timeout-ms: 0 in the cft block, or set the plugin-level Request timeout (ms) setting to 0. The idle timer will catch silent stalls fast either way.

To override the idle timer itself, declare stream-idle-timeout-ms: <number> in the cft block. The defaults (270s deep, 90s normal) match the legacy modal flow and have been load-bearing there for two iterations, so most templates won't need to touch this.

What's deferred

The open issue listed four follow-ups. This entry closes items 1 and 2 (the port itself, and the dual-key naming decision). Two remain explicitly deferred:

  • Cross-service audit. The same wall-clock pathology may live in the Gemini service, the LM Studio service, and the Claude streaming flows. The idle-timeout discipline should be the house style across all of them; the audit is its own change.

  • Settings-pane exposure of idle defaults. Today the 270s/90s values are hardcoded in streamPerplexityToFile (matching perplexityService.ts). Exposing them as plugin settings is a follow-up if we ever need to tune them without a code change.

The deep-research detection is by model-name regex (/deep-research/i) — the same shape perplexityService.ts uses. Robust enough for today's Perplexity model lineup; revisit if Perplexity ever ships a long-running model under a different naming convention.

Why this matters for the multi-stage exploration

The Multi-Stage Cooperative Claude + Perplexity with RAG exploration explicitly listed this fix as upstream of the multi-stage spec. Reason: the eventual Claude editorial pass on a 7-8K-word draft is itself a long generation that would hit the same wall-clock cliff. With per-chunk idle-timeout discipline now load-bearing in the directory-template flow, the editorial pass can reuse the same readWithIdleTimeout primitive when it lands. The multi-stage spec is now unblocked on this dimension.

Files touched

  • src/services/directoryTemplateService.tsstreamPerplexityToFile signature changed from timeoutMs: number to timeouts: { idleMs: number; ceilingMs: number }. readWithIdleTimeout() helper added. Callsite in applyTemplate resolves both keys from cft config with sensible fallbacks (idle defaults from deep-research detection, ceiling defaults from settings.requestTimeoutMs, explicit 0 disables ceiling).

  • docs/directory-templates.mdPer-template timeout override section rewritten as Per-template timeout overrides (plural), now documents both keys, the two pathologies the idle timer fixes, and the migration semantics.

  • src/docs/templates/README.md — cft-key list updated to mention both keys.

  • src/docs/templates/market-map-profile.md — inline comment on the request-timeout-ms declaration rewritten to explain it as the absolute ceiling on top of the 270s idle timer.

  • context-v/issues/Wall-Clock-Timeout-Cuts-Off-Long-Deep-Research-Streams.md — referenced as the canonical issue this closes.