Coverage for src/local_deep_research/llm/cancellation.py: 69%

68 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1"""Cooperative cancellation for in-flight LLM calls. 

2 

3Research, chat and other long-running operations run on worker threads that 

4block inside a synchronous LangChain ``llm.invoke()``. Setting the 

5termination flag (``web.research_state.set_termination_flag``) only takes 

6effect at the *next* ``check_termination`` checkpoint — i.e. after the 

7current LLM HTTP request finishes, which can be minutes on a slow local 

8model. The worker therefore keeps burning GPU/API budget after the user 

9clicks Stop. 

10 

11This module is the single place that maps a ``research_id`` to "should this 

12LLM call abort now?". Wrappers (``config.llm_config.ProcessingLLMWrapper`` 

13and the rate-limiting wrapper) resolve the effective research id from: 

14 

151. an explicitly bound id (``bind_llm_to_research`` / ``research_id`` ctor 

16 arg — survives thread-pool hops where the ContextVar is not copied), or 

172. the current thread's search context (``utilities.thread_context``), so 

18 LLMs created without an explicit id inside a research worker are still 

19 covered. 

20 

21When an id is known, wrappers prefer a streaming implementation of 

22``invoke``: they consume ``base_llm.stream()`` chunk by chunk, check the 

23flag between chunks, and close the generator on abort. Cancellation 

24latency drops from "full response time" to "time to next token". Closing 

25the generator releases the underlying HTTP response promptly instead of 

26leaving it to GC. 

27""" 

28 

29from typing import Any, Optional 

30 

31from loguru import logger 

32 

33 

34def get_current_research_id() -> Optional[str]: 

35 """Return the research id from the current thread's search context. 

36 

37 Returns ``None`` outside a research worker (request threads, CLI, 

38 benchmarks without a context). Never raises — a missing/broken 

39 context means "no cancellation scope", not an error. 

40 """ 

41 try: 

42 from ..utilities.thread_context import get_search_context 

43 

44 ctx = get_search_context() 

45 if isinstance(ctx, dict): 

46 rid = ctx.get("research_id") 

47 if rid: 47 ↛ 54line 47 didn't jump to line 54 because the condition on line 47 was always true

48 return str(rid) 

49 except Exception: 

50 logger.debug( 

51 "get_current_research_id: failed to read search context", 

52 exc_info=True, 

53 ) 

54 return None 

55 

56 

57def resolve_research_id(explicit: Optional[str] = None) -> Optional[str]: 

58 """Return the explicit id, falling back to the thread's search context.""" 

59 if explicit: 

60 return str(explicit) 

61 return get_current_research_id() 

62 

63 

64def is_termination_requested(research_id: Optional[str]) -> bool: 

65 """Return True iff Stop was requested for *research_id*. 

66 

67 ``None`` (no cancellation scope) is never considered terminated. 

68 Import is function-local so this module never creates an import cycle 

69 with ``web.research_state``. 

70 """ 

71 if not research_id: 

72 return False 

73 try: 

74 from ..web.research_state import is_termination_requested as _check 

75 

76 return bool(_check(research_id)) 

77 except Exception: 

78 logger.debug( 

79 "is_termination_requested: flag lookup failed", 

80 exc_info=True, 

81 ) 

82 return False 

83 

84 

85def raise_if_terminated(research_id: Optional[str]) -> None: 

86 """Raise ``ResearchTerminatedException`` if Stop was requested. 

87 

88 No-op when *research_id* is falsy or no flag is set. The exception 

89 inherits from ``BaseException`` so ``except Exception`` blocks in 

90 strategy code let it propagate (same pattern as CancelledError). 

91 """ 

92 if is_termination_requested(research_id): 

93 from ..exceptions import ResearchTerminatedException 

94 

95 raise ResearchTerminatedException( 

96 f"Research {research_id} was terminated by user during LLM call" 

97 ) 

98 

99 

100def _try_bind(target: Any, rid: str) -> None: 

101 """Best-effort ``_ldr_research_id`` setattr on one wrapper level. 

102 

103 Plain objects always accept it; exotic proxies or frozen test 

104 doubles may refuse — that only loses the fast-path scope for that 

105 level (the thread-context fallback still applies), so debug-log 

106 and move on. 

107 """ 

108 try: 

109 setattr(target, "_ldr_research_id", rid) 

110 except Exception: 

111 logger.debug("bind_llm_to_research: setattr refused", exc_info=True) 

112 

113 

114def bind_llm_to_research( 

115 llm: Any, research_id: Optional[str], *, stop_at: Any = None 

116) -> Any: 

117 """Attach *research_id* to an LLM wrapper chain (in place, chain-safe). 

118 

119 Sets ``_ldr_research_id`` on *llm* and, when present, on its 

120 ``base_llm`` (covers ``ProcessingLLMWrapper -> RateLimitedWrapper -> 

121 raw ChatModel`` without unwrapping). Returns *llm* for chaining. 

122 ``None`` clears nothing — a falsy id leaves existing bindings intact 

123 so rebinding with an empty value cannot silently drop cancellation. 

124 

125 *stop_at* names an object the binding must not reach. The walk stops 

126 there, so neither that object nor anything below it is written. Pass 

127 the model under LDR's wrappers: other researches can share it (a 

128 registered instance, or a model a registered factory returns more than 

129 once), or it can hold shared objects of its own. 

130 

131 Invariant: a bound wrapper must not outlive its run. The binding is 

132 resolved per call (explicit id > binding > thread context), so 

133 re-binding for the next run takes effect without reconstructing the 

134 LLM — but a wrapper bound to run A and reused for run B without 

135 re-binding would fail-fast/post-check against A's flag. Callers must 

136 re-bind (or construct with the new id) when crossing runs. 

137 """ 

138 if not research_id: 138 ↛ 139line 138 didn't jump to line 139 because the condition on line 138 was never true

139 return llm 

140 if stop_at is not None and llm is stop_at: 140 ↛ 141line 140 didn't jump to line 141 because the condition on line 140 was never true

141 return llm 

142 rid = str(research_id) 

143 _try_bind(llm, rid) 

144 # Propagate one level down so inner wrappers that are invoked 

145 # directly (e.g. via __getattr__ passthrough) see the same scope, 

146 # down to *stop_at*. Depth is bounded: each level sets a plain 

147 # attribute, never recursing. 

148 try: 

149 inner = getattr(llm, "base_llm", None) 

150 except Exception: 

151 logger.debug( 

152 "bind_llm_to_research: base_llm lookup failed", exc_info=True 

153 ) 

154 return llm 

155 if ( 

156 inner is not None 

157 and inner is not stop_at 

158 and getattr(inner, "_ldr_research_id", None) != rid 

159 ): 

160 _try_bind(inner, rid) 

161 # One more level for the double-wrapped shape 

162 # (Processing -> RateLimited -> ChatModel). 

163 try: 

164 inner_inner = getattr(inner, "base_llm", None) 

165 except Exception: 

166 logger.debug( 

167 "bind_llm_to_research: nested base_llm lookup failed", 

168 exc_info=True, 

169 ) 

170 return llm 

171 if ( 171 ↛ 176line 171 didn't jump to line 176 because the condition on line 171 was never true

172 inner_inner is not None 

173 and inner_inner is not stop_at 

174 and getattr(inner_inner, "_ldr_research_id", None) != rid 

175 ): 

176 _try_bind(inner_inner, rid) 

177 return llm 

178 

179 

180def effective_research_id( 

181 llm: Any, explicit: Optional[str] = None 

182) -> Optional[str]: 

183 """Resolve the cancellation scope for an LLM call. 

184 

185 Precedence: explicit ctor arg > ``_ldr_research_id`` binding > 

186 current thread's search context. Used by wrappers on every call so 

187 rebinding (or context propagation into pool threads) takes effect 

188 without reconstructing the LLM. 

189 """ 

190 if explicit: 

191 return str(explicit) 

192 try: 

193 bound = getattr(llm, "_ldr_research_id", None) 

194 except Exception: 

195 logger.debug( 

196 "effective_research_id: binding lookup failed", exc_info=True 

197 ) 

198 return get_current_research_id() 

199 if bound: 

200 return str(bound) 

201 return get_current_research_id()