@@ -157,12 +157,37 @@ type Pipeline struct {
157157 // for a table of contents. Default: 20.
158158 TOCCheckPages int
159159
160+ // LLMCallTimeout bounds each INDIVIDUAL LLM call the pipeline issues
161+ // (one section's summary, one leaf's HyDE questions, one TOC
162+ // detect/extract/verify turn). It is the safety valve against a
163+ // provider call that hangs with neither a response nor an error:
164+ // without it, that call's bounded-concurrency errgroup blocks on
165+ // Wait() forever and the document never leaves `summarizing` (observed
166+ // stuck for 13+ hours).
167+ //
168+ // A call that exceeds this deadline is handled exactly like any other
169+ // per-section failure: the surrounding stage logs it and skips the
170+ // section, leaving its existing/empty summary. One hung call can no
171+ // longer freeze the whole document.
172+ //
173+ // Zero (the Go zero value, used by Pipeline literals in tests) means
174+ // "no per-call timeout" so existing test paths that don't set it keep
175+ // their unbounded behaviour. NewPipeline defaults it to 90s.
176+ LLMCallTimeout time.Duration
177+
160178 // globalLLMSem is the lazily-initialized shared semaphore enforcing
161179 // GlobalLLMConcurrency. nil means "no global cap" — callers fall back
162180 // to per-stage limits only.
163181 globalLLMSem chan struct {}
164182}
165183
184+ // defaultLLMCallTimeout is the per-call deadline NewPipeline applies when
185+ // LLMCallTimeout is left unset. 90s is generous for a single summarize /
186+ // HyDE / TOC turn even on a slow reasoning model, while still being short
187+ // enough that a hung call is reaped in seconds-to-low-minutes rather than
188+ // blocking the document forever.
189+ const defaultLLMCallTimeout = 90 * time .Second
190+
166191// NewPipeline returns a Pipeline with sensible defaults filled in.
167192func NewPipeline (p Pipeline ) * Pipeline {
168193 if p .SummaryMaxChars == 0 {
@@ -210,12 +235,45 @@ func NewPipeline(p Pipeline) *Pipeline {
210235 if p .GlobalLLMConcurrency > 0 {
211236 p .globalLLMSem = make (chan struct {}, p .GlobalLLMConcurrency )
212237 }
238+ // A per-call timeout is the difference between "one bad section" and
239+ // "the whole document wedged for hours", so NewPipeline always fills
240+ // one in. Pipeline literals (test paths) that leave it zero keep the
241+ // historical unbounded behaviour.
242+ if p .LLMCallTimeout <= 0 {
243+ p .LLMCallTimeout = defaultLLMCallTimeout
244+ }
213245 if p .Logger == nil {
214246 p .Logger = slog .Default ()
215247 }
216248 return & p
217249}
218250
251+ // completeWithTimeout issues a single LLM call bounded by timeout. A
252+ // non-positive timeout disables the bound (calls ctx directly), preserving
253+ // the legacy behaviour for Pipeline literals that never set one.
254+ //
255+ // The returned error on deadline expiry is context.DeadlineExceeded
256+ // wrapped by the client — callers in the ingest stages already treat any
257+ // Complete error as a non-fatal per-section skip, so a timeout slots into
258+ // the existing degrade-and-continue path with no special-casing.
259+ func completeWithTimeout (ctx context.Context , client llmgate.Client , req llmgate.Request , timeout time.Duration ) (* llmgate.Response , error ) {
260+ if timeout <= 0 {
261+ return client .Complete (ctx , req )
262+ }
263+ callCtx , cancel := context .WithTimeout (ctx , timeout )
264+ defer cancel ()
265+ return client .Complete (callCtx , req )
266+ }
267+
268+ // isTimeout reports whether err is a context deadline/cancellation — the
269+ // signature of a per-call LLM timeout. The ingest retry loops use it to
270+ // stop retrying immediately on a timeout: re-issuing a call that just hung
271+ // would only multiply the wall-time cost (N retries × the timeout) without
272+ // changing the outcome, so a timeout is terminal, not retryable.
273+ func isTimeout (err error ) bool {
274+ return errors .Is (err , context .DeadlineExceeded ) || errors .Is (err , context .Canceled )
275+ }
276+
219277// acquireGlobalLLM blocks until a global-LLM-concurrency slot is free,
220278// or returns false if ctx is canceled first. Returns a release func the
221279// caller must invoke (typically deferred). Safe to call when the global
@@ -331,10 +389,11 @@ func (p *Pipeline) runTOCBuilder(ctx context.Context, docID tree.DocumentID, par
331389 model = p .SummaryModel
332390 }
333391 builder := & TOCBuilder {
334- LLM : p .LLM ,
335- Model : model ,
336- Concurrency : p .TOCConcurrency ,
337- TOCCheckPages : p .TOCCheckPages ,
392+ LLM : p .LLM ,
393+ Model : model ,
394+ Concurrency : p .TOCConcurrency ,
395+ TOCCheckPages : p .TOCCheckPages ,
396+ LLMCallTimeout : p .LLMCallTimeout ,
338397 }
339398 nodes , usage , err := builder .Build (ctx , pages )
340399 if err != nil {
@@ -717,7 +776,7 @@ func (p *Pipeline) summaryFor(ctx context.Context, s db.Section, childLines []st
717776// request. Kept for the SummaryAxesEnabled=false opt-out branch and
718777// for unit tests that build a Pipeline literal without the new flag.
719778func (p * Pipeline ) legacyOneLineSummary (ctx context.Context , s db.Section , body , profile string ) (string , error ) {
720- resp , err := p .LLM . Complete ( ctx , llmgate.Request {
779+ resp , err := completeWithTimeout ( ctx , p .LLM , llmgate.Request {
721780 Model : p .SummaryModel ,
722781 Temperature : 0.0 ,
723782 MaxTokens : 260 ,
@@ -727,7 +786,7 @@ func (p *Pipeline) legacyOneLineSummary(ctx context.Context, s db.Section, body,
727786 "Section titled %q.\n \n %s\n \n Return a single sentence (≤ 60 words) that names this section's concrete topics, entities, identifiers, and key items so a retrieval engine can match it to user questions." ,
728787 cleanForLLM (s .Title ), body )},
729788 },
730- })
789+ }, p . LLMCallTimeout )
731790 if err != nil {
732791 // Stub LLMs return ErrNotImplemented. Degrade gracefully: use a
733792 // truncated excerpt as the "summary" so downstream retrieval
@@ -770,7 +829,7 @@ func (p *Pipeline) structuredSummaryFor(ctx context.Context, s db.Section, body,
770829 JSONSchema : []byte (summaryAxesJSONSchema ),
771830 }
772831
773- axes , rawText , err := runSummaryAxesWithRetry (ctx , p .LLM , req , defaultSummaryAxesRetries )
832+ axes , rawText , err := runSummaryAxesWithRetry (ctx , p .LLM , req , defaultSummaryAxesRetries , p . LLMCallTimeout )
774833 if err != nil {
775834 // Transport / ErrNotImplemented / unrecoverable: fall back to a
776835 // text excerpt as OneLine with empty axes. Never fail ingest.
0 commit comments