chuk-tool-processor 0.1.6__py3-none-any.whl → 0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of chuk-tool-processor might be problematic. Click here for more details.

Files changed (46) hide show
  1. chuk_tool_processor/core/processor.py +345 -132
  2. chuk_tool_processor/execution/strategies/inprocess_strategy.py +522 -71
  3. chuk_tool_processor/execution/strategies/subprocess_strategy.py +559 -64
  4. chuk_tool_processor/execution/tool_executor.py +282 -24
  5. chuk_tool_processor/execution/wrappers/caching.py +465 -123
  6. chuk_tool_processor/execution/wrappers/rate_limiting.py +199 -86
  7. chuk_tool_processor/execution/wrappers/retry.py +133 -23
  8. chuk_tool_processor/logging/__init__.py +83 -10
  9. chuk_tool_processor/logging/context.py +218 -22
  10. chuk_tool_processor/logging/formatter.py +56 -13
  11. chuk_tool_processor/logging/helpers.py +91 -16
  12. chuk_tool_processor/logging/metrics.py +75 -6
  13. chuk_tool_processor/mcp/mcp_tool.py +80 -35
  14. chuk_tool_processor/mcp/register_mcp_tools.py +74 -56
  15. chuk_tool_processor/mcp/setup_mcp_sse.py +41 -36
  16. chuk_tool_processor/mcp/setup_mcp_stdio.py +39 -37
  17. chuk_tool_processor/mcp/transport/sse_transport.py +351 -105
  18. chuk_tool_processor/models/execution_strategy.py +52 -3
  19. chuk_tool_processor/models/streaming_tool.py +110 -0
  20. chuk_tool_processor/models/tool_call.py +56 -4
  21. chuk_tool_processor/models/tool_result.py +115 -9
  22. chuk_tool_processor/models/validated_tool.py +15 -13
  23. chuk_tool_processor/plugins/discovery.py +115 -70
  24. chuk_tool_processor/plugins/parsers/base.py +13 -5
  25. chuk_tool_processor/plugins/parsers/{function_call_tool_plugin.py → function_call_tool.py} +39 -20
  26. chuk_tool_processor/plugins/parsers/json_tool.py +50 -0
  27. chuk_tool_processor/plugins/parsers/openai_tool.py +88 -0
  28. chuk_tool_processor/plugins/parsers/xml_tool.py +74 -20
  29. chuk_tool_processor/registry/__init__.py +46 -7
  30. chuk_tool_processor/registry/auto_register.py +92 -28
  31. chuk_tool_processor/registry/decorators.py +134 -11
  32. chuk_tool_processor/registry/interface.py +48 -14
  33. chuk_tool_processor/registry/metadata.py +52 -6
  34. chuk_tool_processor/registry/provider.py +75 -36
  35. chuk_tool_processor/registry/providers/__init__.py +49 -10
  36. chuk_tool_processor/registry/providers/memory.py +59 -48
  37. chuk_tool_processor/registry/tool_export.py +208 -39
  38. chuk_tool_processor/utils/validation.py +18 -13
  39. chuk_tool_processor-0.2.dist-info/METADATA +401 -0
  40. chuk_tool_processor-0.2.dist-info/RECORD +58 -0
  41. {chuk_tool_processor-0.1.6.dist-info → chuk_tool_processor-0.2.dist-info}/WHEEL +1 -1
  42. chuk_tool_processor/plugins/parsers/json_tool_plugin.py +0 -38
  43. chuk_tool_processor/plugins/parsers/openai_tool_plugin.py +0 -76
  44. chuk_tool_processor-0.1.6.dist-info/METADATA +0 -462
  45. chuk_tool_processor-0.1.6.dist-info/RECORD +0 -57
  46. {chuk_tool_processor-0.1.6.dist-info → chuk_tool_processor-0.2.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,13 @@
1
1
  # chuk_tool_processor/mcp/transport/sse_transport.py
2
2
  """
3
- Server-Sent Events (SSE) transport for MCP communication implemented with **httpx**.
3
+ Proper MCP SSE transport that follows the standard MCP SSE protocol.
4
+
5
+ This transport:
6
+ 1. Connects to /sse for SSE stream
7
+ 2. Listens for 'endpoint' event to get message URL
8
+ 3. Sends MCP initialize handshake FIRST
9
+ 4. Only then proceeds with tools/list and tool calls
10
+ 5. Handles async responses via SSE message events
4
11
  """
5
12
  from __future__ import annotations
6
13
 
@@ -16,7 +23,7 @@ from .base_transport import MCPBaseTransport
16
23
  # --------------------------------------------------------------------------- #
17
24
  # Helpers #
18
25
  # --------------------------------------------------------------------------- #
19
- DEFAULT_TIMEOUT = 5.0 # seconds
26
+ DEFAULT_TIMEOUT = 30.0 # Longer timeout for real servers
20
27
  HEADERS_JSON: Dict[str, str] = {"accept": "application/json"}
21
28
 
22
29
 
@@ -30,160 +37,399 @@ def _url(base: str, path: str) -> str:
30
37
  # --------------------------------------------------------------------------- #
31
38
  class SSETransport(MCPBaseTransport):
32
39
  """
33
- Minimal SSE/REST transport. It speaks a simple REST dialect:
34
-
35
- GET /ping 200 OK
36
- GET /tools/list {"tools": [...]}
37
- POST /tools/call → {"name": ..., "result": ...}
38
- GET /resources/list {"resources": [...]}
39
- GET /prompts/list → {"prompts": [...]}
40
- GET /events → <text/event-stream>
40
+ Proper MCP SSE transport that follows the standard protocol:
41
+
42
+ 1. GET /sse Establishes SSE connection
43
+ 2. Waits for 'endpoint' event Gets message URL
44
+ 3. Sends MCP initialize handshake → Establishes session
45
+ 4. POST to message URL Sends tool calls
46
+ 5. Waits for async responses via SSE message events
41
47
  """
42
48
 
43
- EVENTS_PATH = "/events"
44
-
45
- # ------------------------------------------------------------------ #
46
- # Construction #
47
- # ------------------------------------------------------------------ #
48
49
  def __init__(self, url: str, api_key: Optional[str] = None) -> None:
49
50
  self.base_url = url.rstrip("/")
50
51
  self.api_key = api_key
51
52
 
52
53
  # httpx client (None until initialise)
53
54
  self._client: httpx.AsyncClient | None = None
54
- self.session: httpx.AsyncClient | None = None # ← kept for legacy tests
55
+ self.session: httpx.AsyncClient | None = None
55
56
 
56
- # background reader
57
- self._reader_task: asyncio.Task | None = None
58
- self._incoming_queue: "asyncio.Queue[dict[str, Any]]" = asyncio.Queue()
57
+ # MCP SSE state
58
+ self._message_url: Optional[str] = None
59
+ self._session_id: Optional[str] = None
60
+ self._sse_task: Optional[asyncio.Task] = None
61
+ self._connected = asyncio.Event()
62
+ self._initialized = asyncio.Event() # NEW: Track MCP initialization
63
+
64
+ # Async message handling
65
+ self._pending_requests: Dict[str, asyncio.Future] = {}
66
+ self._message_lock = asyncio.Lock()
59
67
 
60
68
  # ------------------------------------------------------------------ #
61
69
  # Life-cycle #
62
70
  # ------------------------------------------------------------------ #
63
71
  async def initialize(self) -> bool:
64
- """Open the httpx client and start the /events consumer."""
65
- if self._client: # already initialised
72
+ """Initialize the MCP SSE transport."""
73
+ if self._client:
66
74
  return True
67
75
 
76
+ headers = {}
77
+ if self.api_key:
78
+ headers["authorization"] = self.api_key
79
+
68
80
  self._client = httpx.AsyncClient(
69
- headers={"authorization": self.api_key} if self.api_key else None,
81
+ headers=headers,
70
82
  timeout=DEFAULT_TIMEOUT,
71
83
  )
72
- self.session = self._client # legacy attribute for tests
84
+ self.session = self._client
73
85
 
74
- # spawn reader (best-effort reconnect)
75
- self._reader_task = asyncio.create_task(self._consume_events(), name="sse-reader")
86
+ # Start SSE connection and wait for endpoint
87
+ self._sse_task = asyncio.create_task(self._handle_sse_connection())
88
+
89
+ try:
90
+ # Wait for endpoint event (up to 10 seconds)
91
+ await asyncio.wait_for(self._connected.wait(), timeout=10.0)
92
+
93
+ # NEW: Send MCP initialize handshake
94
+ if await self._initialize_mcp_session():
95
+ return True
96
+ else:
97
+ print("❌ MCP initialization failed")
98
+ return False
99
+
100
+ except asyncio.TimeoutError:
101
+ print("❌ Timeout waiting for SSE endpoint event")
102
+ return False
103
+ except Exception as e:
104
+ print(f"❌ SSE initialization failed: {e}")
105
+ return False
106
+
107
+ async def _initialize_mcp_session(self) -> bool:
108
+ """Send the required MCP initialize handshake."""
109
+ if not self._message_url:
110
+ print("❌ No message URL available for initialization")
111
+ return False
112
+
113
+ try:
114
+ print("🔄 Sending MCP initialize handshake...")
115
+
116
+ # Required MCP initialize message
117
+ init_message = {
118
+ "jsonrpc": "2.0",
119
+ "id": "initialize",
120
+ "method": "initialize",
121
+ "params": {
122
+ "protocolVersion": "2024-11-05",
123
+ "capabilities": {
124
+ "tools": {},
125
+ "resources": {},
126
+ "prompts": {},
127
+ "sampling": {}
128
+ },
129
+ "clientInfo": {
130
+ "name": "chuk-tool-processor",
131
+ "version": "1.0.0"
132
+ }
133
+ }
134
+ }
135
+
136
+ response = await self._send_message(init_message)
137
+
138
+ if "result" in response:
139
+ server_info = response["result"]
140
+ print(f"✅ MCP initialized: {server_info.get('serverInfo', {}).get('name', 'Unknown Server')}")
141
+
142
+ # Send initialized notification (required by MCP spec)
143
+ notification = {
144
+ "jsonrpc": "2.0",
145
+ "method": "notifications/initialized"
146
+ }
147
+
148
+ # Send notification (don't wait for response)
149
+ await self._send_notification(notification)
150
+ self._initialized.set()
151
+ return True
152
+ else:
153
+ print(f"❌ MCP initialization failed: {response}")
154
+ return False
155
+
156
+ except Exception as e:
157
+ print(f"❌ MCP initialization error: {e}")
158
+ return False
76
159
 
77
- # verify connection
78
- return await self.send_ping()
160
+ async def _send_notification(self, notification: Dict[str, Any]) -> None:
161
+ """Send a JSON-RPC notification (no response expected)."""
162
+ if not self._client or not self._message_url:
163
+ return
164
+
165
+ try:
166
+ headers = {"Content-Type": "application/json"}
167
+ await self._client.post(
168
+ self._message_url,
169
+ json=notification,
170
+ headers=headers
171
+ )
172
+ except Exception as e:
173
+ print(f"⚠️ Failed to send notification: {e}")
79
174
 
80
175
  async def close(self) -> None:
81
- """Stop background reader and close the httpx client."""
82
- if self._reader_task:
83
- self._reader_task.cancel()
176
+ """Close the transport."""
177
+ # Cancel any pending requests
178
+ for future in self._pending_requests.values():
179
+ if not future.done():
180
+ future.cancel()
181
+ self._pending_requests.clear()
182
+
183
+ if self._sse_task:
184
+ self._sse_task.cancel()
84
185
  with contextlib.suppress(asyncio.CancelledError):
85
- await self._reader_task
86
- self._reader_task = None
186
+ await self._sse_task
187
+ self._sse_task = None
87
188
 
88
189
  if self._client:
89
190
  await self._client.aclose()
90
191
  self._client = None
91
- self.session = None # keep tests happy
192
+ self.session = None
92
193
 
93
194
  # ------------------------------------------------------------------ #
94
- # Internal helpers #
195
+ # SSE Connection Handler #
95
196
  # ------------------------------------------------------------------ #
96
- async def _get_json(self, path: str) -> Any:
197
+ async def _handle_sse_connection(self) -> None:
198
+ """Handle the SSE connection and extract the endpoint URL."""
97
199
  if not self._client:
98
- raise RuntimeError("Transport not initialised")
99
-
100
- resp = await self._client.get(_url(self.base_url, path), headers=HEADERS_JSON)
101
- resp.raise_for_status()
102
- return resp.json()
200
+ return
103
201
 
104
- async def _post_json(self, path: str, payload: Dict[str, Any]) -> Any:
105
- if not self._client:
106
- raise RuntimeError("Transport not initialised")
202
+ try:
203
+ headers = {
204
+ "Accept": "text/event-stream",
205
+ "Cache-Control": "no-cache"
206
+ }
207
+
208
+ async with self._client.stream(
209
+ "GET", f"{self.base_url}/sse", headers=headers
210
+ ) as response:
211
+ response.raise_for_status()
212
+
213
+ async for line in response.aiter_lines():
214
+ if not line:
215
+ continue
216
+
217
+ # Parse SSE events
218
+ if line.startswith("event: "):
219
+ event_type = line[7:].strip()
220
+
221
+ elif line.startswith("data: ") and 'event_type' in locals():
222
+ data = line[6:].strip()
223
+
224
+ if event_type == "endpoint":
225
+ # Got the endpoint URL for messages - construct full URL
226
+ self._message_url = f"{self.base_url}{data}"
227
+
228
+ # Extract session_id if present
229
+ if "session_id=" in data:
230
+ self._session_id = data.split("session_id=")[1].split("&")[0]
231
+
232
+ print(f"✅ Got message endpoint: {self._message_url}")
233
+ self._connected.set()
234
+
235
+ elif event_type == "message":
236
+ # Handle incoming JSON-RPC responses
237
+ try:
238
+ message = json.loads(data)
239
+ await self._handle_incoming_message(message)
240
+ except json.JSONDecodeError:
241
+ print(f"❌ Failed to parse message: {data}")
242
+
243
+ except asyncio.CancelledError:
244
+ pass
245
+ except Exception as e:
246
+ print(f"❌ SSE connection failed: {e}")
107
247
 
108
- resp = await self._client.post(
109
- _url(self.base_url, path), json=payload, headers=HEADERS_JSON
110
- )
111
- resp.raise_for_status()
112
- return resp.json()
248
+ async def _handle_incoming_message(self, message: Dict[str, Any]) -> None:
249
+ """Handle incoming JSON-RPC response messages."""
250
+ message_id = message.get("id")
251
+ if message_id and message_id in self._pending_requests:
252
+ # Complete the pending request
253
+ future = self._pending_requests.pop(message_id)
254
+ if not future.done():
255
+ future.set_result(message)
113
256
 
114
257
  # ------------------------------------------------------------------ #
115
- # Public API (implements MCPBaseTransport) #
258
+ # MCP Protocol Methods #
116
259
  # ------------------------------------------------------------------ #
117
260
  async def send_ping(self) -> bool:
118
- if not self._client:
119
- return False
120
- try:
121
- await self._get_json("/ping")
122
- return True
123
- except Exception: # pragma: no cover
124
- return False
261
+ """Test if we have a working and initialized connection."""
262
+ return self._message_url is not None and self._initialized.is_set()
125
263
 
126
264
  async def get_tools(self) -> List[Dict[str, Any]]:
127
- if not self._client:
265
+ """Get available tools using tools/list."""
266
+ # NEW: Wait for initialization before proceeding
267
+ if not self._initialized.is_set():
268
+ print("⏳ Waiting for MCP initialization...")
269
+ try:
270
+ await asyncio.wait_for(self._initialized.wait(), timeout=10.0)
271
+ except asyncio.TimeoutError:
272
+ print("❌ Timeout waiting for MCP initialization")
273
+ return []
274
+
275
+ if not self._message_url:
128
276
  return []
277
+
129
278
  try:
130
- data = await self._get_json("/tools/list")
131
- return data.get("tools", []) if isinstance(data, dict) else []
132
- except Exception: # pragma: no cover
133
- return []
279
+ message = {
280
+ "jsonrpc": "2.0",
281
+ "id": "tools_list",
282
+ "method": "tools/list",
283
+ "params": {}
284
+ }
285
+
286
+ response = await self._send_message(message)
287
+
288
+ if "result" in response and "tools" in response["result"]:
289
+ return response["result"]["tools"]
290
+
291
+ except Exception as e:
292
+ print(f"❌ Failed to get tools: {e}")
293
+
294
+ return []
134
295
 
135
296
  async def call_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
136
- # ─── tests expect this specific message if *not* initialised ───
137
- if not self._client:
138
- return {"isError": True, "error": "SSE transport not implemented"}
297
+ """Execute a tool call using the MCP protocol."""
298
+ # NEW: Ensure initialization before tool calls
299
+ if not self._initialized.is_set():
300
+ return {"isError": True, "error": "MCP session not initialized"}
301
+
302
+ if not self._message_url:
303
+ return {"isError": True, "error": "No message endpoint available"}
304
+
305
+ try:
306
+ message = {
307
+ "jsonrpc": "2.0",
308
+ "id": f"call_{tool_name}",
309
+ "method": "tools/call",
310
+ "params": {
311
+ "name": tool_name,
312
+ "arguments": arguments
313
+ }
314
+ }
315
+
316
+ response = await self._send_message(message)
317
+
318
+ # Process MCP response
319
+ if "error" in response:
320
+ return {
321
+ "isError": True,
322
+ "error": response["error"].get("message", "Unknown error")
323
+ }
324
+
325
+ if "result" in response:
326
+ result = response["result"]
327
+
328
+ # Handle MCP tool response format
329
+ if "content" in result:
330
+ # Extract content from MCP format
331
+ content = result["content"]
332
+ if isinstance(content, list) and content:
333
+ # Take first content item
334
+ first_content = content[0]
335
+ if isinstance(first_content, dict) and "text" in first_content:
336
+ return {"isError": False, "content": first_content["text"]}
337
+
338
+ return {"isError": False, "content": content}
339
+
340
+ # Direct result
341
+ return {"isError": False, "content": result}
342
+
343
+ return {"isError": True, "error": "No result in response"}
344
+
345
+ except Exception as e:
346
+ return {"isError": True, "error": str(e)}
347
+
348
+ async def _send_message(self, message: Dict[str, Any]) -> Dict[str, Any]:
349
+ """Send a JSON-RPC message to the server and wait for async response."""
350
+ if not self._client or not self._message_url:
351
+ raise RuntimeError("Transport not properly initialized")
352
+
353
+ message_id = message.get("id")
354
+ if not message_id:
355
+ raise ValueError("Message must have an ID")
356
+
357
+ # Create a future for this request
358
+ future = asyncio.Future()
359
+ async with self._message_lock:
360
+ self._pending_requests[message_id] = future
139
361
 
140
362
  try:
141
- payload = {"name": tool_name, "arguments": arguments}
142
- return await self._post_json("/tools/call", payload)
143
- except Exception as exc: # pragma: no cover
144
- return {"isError": True, "error": str(exc)}
363
+ headers = {"Content-Type": "application/json"}
364
+
365
+ # Send the request
366
+ response = await self._client.post(
367
+ self._message_url,
368
+ json=message,
369
+ headers=headers
370
+ )
371
+
372
+ # Check if server accepted the request
373
+ if response.status_code == 202:
374
+ # Server accepted - wait for async response via SSE
375
+ try:
376
+ response_message = await asyncio.wait_for(future, timeout=30.0)
377
+ return response_message
378
+ except asyncio.TimeoutError:
379
+ raise RuntimeError(f"Timeout waiting for response to message {message_id}")
380
+ else:
381
+ # Immediate response - parse and return
382
+ response.raise_for_status()
383
+ return response.json()
384
+
385
+ finally:
386
+ # Clean up pending request
387
+ async with self._message_lock:
388
+ self._pending_requests.pop(message_id, None)
145
389
 
146
- # ----------------------- extras used by StreamManager ------------- #
390
+ # ------------------------------------------------------------------ #
391
+ # Additional MCP methods #
392
+ # ------------------------------------------------------------------ #
147
393
  async def list_resources(self) -> List[Dict[str, Any]]:
148
- if not self._client:
394
+ """List available resources."""
395
+ if not self._initialized.is_set() or not self._message_url:
149
396
  return []
397
+
150
398
  try:
151
- data = await self._get_json("/resources/list")
152
- return data.get("resources", []) if isinstance(data, dict) else []
153
- except Exception: # pragma: no cover
154
- return []
399
+ message = {
400
+ "jsonrpc": "2.0",
401
+ "id": "resources_list",
402
+ "method": "resources/list",
403
+ "params": {}
404
+ }
405
+
406
+ response = await self._send_message(message)
407
+ if "result" in response and "resources" in response["result"]:
408
+ return response["result"]["resources"]
409
+
410
+ except Exception:
411
+ pass
412
+
413
+ return []
155
414
 
156
415
  async def list_prompts(self) -> List[Dict[str, Any]]:
157
- if not self._client:
416
+ """List available prompts."""
417
+ if not self._initialized.is_set() or not self._message_url:
158
418
  return []
419
+
159
420
  try:
160
- data = await self._get_json("/prompts/list")
161
- return data.get("prompts", []) if isinstance(data, dict) else []
162
- except Exception: # pragma: no cover
163
- return []
164
-
165
- # ------------------------------------------------------------------ #
166
- # Background event-stream reader #
167
- # ------------------------------------------------------------------ #
168
- async def _consume_events(self) -> None: # pragma: no cover
169
- """Continuously read `/events` and push JSON objects onto a queue."""
170
- if not self._client:
171
- return
172
-
173
- while True:
174
- try:
175
- async with self._client.stream(
176
- "GET", _url(self.base_url, self.EVENTS_PATH), headers=HEADERS_JSON
177
- ) as resp:
178
- resp.raise_for_status()
179
- async for line in resp.aiter_lines():
180
- if not line:
181
- continue
182
- try:
183
- await self._incoming_queue.put(json.loads(line))
184
- except json.JSONDecodeError:
185
- continue
186
- except asyncio.CancelledError:
187
- break
188
- except Exception:
189
- await asyncio.sleep(1.0) # back-off and retry
421
+ message = {
422
+ "jsonrpc": "2.0",
423
+ "id": "prompts_list",
424
+ "method": "prompts/list",
425
+ "params": {}
426
+ }
427
+
428
+ response = await self._send_message(message)
429
+ if "result" in response and "prompts" in response["result"]:
430
+ return response["result"]["prompts"]
431
+
432
+ except Exception:
433
+ pass
434
+
435
+ return []
@@ -1,14 +1,21 @@
1
1
  # chuk_tool_processor/models/execution_strategy.py
2
+ """
3
+ Abstract base class for tool execution strategies.
4
+ """
5
+ from __future__ import annotations
6
+
2
7
  from abc import ABC, abstractmethod
3
- from typing import List, Optional
8
+ from typing import List, Optional, Dict, Any, AsyncIterator
4
9
 
5
10
  from chuk_tool_processor.models.tool_call import ToolCall
6
11
  from chuk_tool_processor.models.tool_result import ToolResult
7
12
 
8
-
9
13
  class ExecutionStrategy(ABC):
10
14
  """
11
15
  Strategy interface for executing ToolCall objects.
16
+
17
+ All execution strategies must implement at least the run method,
18
+ and optionally stream_run for streaming support.
12
19
  """
13
20
  @abstractmethod
14
21
  async def run(
@@ -16,4 +23,46 @@ class ExecutionStrategy(ABC):
16
23
  calls: List[ToolCall],
17
24
  timeout: Optional[float] = None
18
25
  ) -> List[ToolResult]:
19
- pass
26
+ """
27
+ Execute a list of tool calls and return their results.
28
+
29
+ Args:
30
+ calls: List of ToolCall objects to execute
31
+ timeout: Optional timeout in seconds for each call
32
+
33
+ Returns:
34
+ List of ToolResult objects in the same order as the calls
35
+ """
36
+ pass
37
+
38
+ async def stream_run(
39
+ self,
40
+ calls: List[ToolCall],
41
+ timeout: Optional[float] = None
42
+ ) -> AsyncIterator[ToolResult]:
43
+ """
44
+ Execute tool calls and yield results as they become available.
45
+
46
+ Default implementation executes all calls with run() and yields the results.
47
+ Subclasses can override for true streaming behavior.
48
+
49
+ Args:
50
+ calls: List of ToolCall objects to execute
51
+ timeout: Optional timeout in seconds for each call
52
+
53
+ Yields:
54
+ ToolResult objects as they become available
55
+ """
56
+ results = await self.run(calls, timeout=timeout)
57
+ for result in results:
58
+ yield result
59
+
60
+ @property
61
+ def supports_streaming(self) -> bool:
62
+ """
63
+ Check if this strategy supports true streaming.
64
+
65
+ Default implementation returns False. Streaming-capable strategies
66
+ should override this to return True.
67
+ """
68
+ return False