2
2
3
3
import asyncio
4
4
import json
5
+ import queue
5
6
import threading
6
7
from collections .abc import Generator
8
+ from collections .abc import Iterator
7
9
from dataclasses import dataclass
8
10
from queue import Queue
9
11
from typing import Any
10
12
from typing import cast
11
13
from typing import Dict
12
14
from typing import List
15
+ from typing import Optional
13
16
14
17
import litellm
15
18
from agents import Agent
@@ -69,7 +72,6 @@ def short_tag(link: str, i: int) -> str:
69
72
70
73
71
74
@function_tool
72
- @traced (name = "web_search" )
73
75
def web_search (query : str ) -> str :
74
76
"""Search the web for information. This tool provides urls and short snippets,
75
77
but does not fetch the full content of the urls."""
@@ -93,7 +95,6 @@ def web_search(query: str) -> str:
93
95
94
96
95
97
@function_tool
96
- @traced (name = "web_fetch" )
97
98
def web_fetch (urls : List [str ]) -> str :
98
99
"""Fetch the full contents of a list of URLs."""
99
100
exa_client = ExaClient ()
@@ -114,18 +115,6 @@ def web_fetch(urls: List[str]) -> str:
114
115
return json .dumps ({"results" : out })
115
116
116
117
117
- @function_tool
118
- @traced (name = "reasoning" )
119
- def reasoning () -> str :
120
- """Use this tool for reasoning. Powerful for complex questions and
121
- tasks, or questions that require multiple steps to answer."""
122
- # Note: This is a simplified version. In the full implementation,
123
- # we would need to pass the context through the agent's context system
124
- return (
125
- "Reasoning tool - this would need to be implemented with proper context access"
126
- )
127
-
128
-
129
118
@traced (name = "llm_completion" , type = "llm" )
130
119
def llm_completion (
131
120
model_name : str ,
@@ -143,7 +132,6 @@ def llm_completion(
143
132
144
133
145
134
@function_tool
146
- @traced (name = "internal_search" )
147
135
def internal_search (context_wrapper : RunContextWrapper [MyContext ], query : str ) -> str :
148
136
"""Search internal company vector database for information. Sources
149
137
include:
@@ -283,15 +271,13 @@ class ResearchScratchpad(BaseModel):
283
271
284
272
285
273
@function_tool
286
- @traced (name = "add_note" )
287
274
def add_note (note : str , source_url : str | None = None ):
288
275
"""Store a factual note you want to cite later."""
289
276
scratchpad .notes .append ({"note" : note , "source_url" : source_url })
290
277
return {"ok" : True , "count" : len (scratchpad .notes )}
291
278
292
279
293
280
@function_tool
294
- @traced (name = "finalize_report" )
295
281
def finalize_report ():
296
282
"""Signal you're done researching. Return a structured, citation-rich report."""
297
283
# The model should *compose* the report as the tool *result*, using notes in scratchpad.
@@ -330,7 +316,6 @@ def construct_deep_research_agent(llm: LLM) -> Agent:
330
316
- Minimize redundancy by skimming before fetching.
331
317
- Think out loud in a compact way, but keep reasoning crisp.
332
318
"""
333
-
334
319
return Agent (
335
320
name = "Researcher" ,
336
321
instructions = DR_INSTRUCTIONS ,
@@ -420,7 +405,7 @@ def construct_simple_agent(
420
405
and search internal databases.
421
406
""" ,
422
407
model = litellm_model ,
423
- tools = [web_search , web_fetch , reasoning , internal_search ],
408
+ tools = [web_search , web_fetch , internal_search ],
424
409
model_settings = ModelSettings (
425
410
temperature = llm .config .temperature ,
426
411
include_usage = True , # Track usage metrics
@@ -430,45 +415,114 @@ def construct_simple_agent(
430
415
431
416
def thread_worker_dr_turn (messages , cfg , llm , emitter , search_tool ):
432
417
try :
433
- asyncio . run ( dr_turn (messages , cfg , llm , emitter , search_tool ) )
418
+ dr_turn (messages , cfg , llm , emitter , search_tool )
434
419
except Exception as e :
435
420
logger .error (f"Error in dr_turn: { e } " , exc_info = e , stack_info = True )
436
421
emitter .emit (kind = "done" , data = {"ok" : False })
437
422
438
423
439
- async def dr_turn (
424
+ SENTINEL = object ()
425
+
426
+
427
+ class StreamBridge :
428
+ """
429
+ Spins up an asyncio loop in a background thread, starts Runner.run_streamed there,
430
+ consumes its async event stream, and exposes a blocking .events() iterator.
431
+ """
432
+
433
+ def __init__ (self , agent , messages , ctx , max_turns : int = 100 ):
434
+ self .agent = agent
435
+ self .messages = messages
436
+ self .ctx = ctx
437
+ self .max_turns = max_turns
438
+
439
+ self ._q : "queue.Queue[object]" = queue .Queue ()
440
+ self ._loop : Optional [asyncio .AbstractEventLoop ] = None
441
+ self ._thread : Optional [threading .Thread ] = None
442
+ self ._streamed = None
443
+
444
+ def start (self ):
445
+ def worker ():
446
+ async def run_and_consume ():
447
+ # Create the streamed run *inside* the loop thread
448
+ self ._streamed = Runner .run_streamed (
449
+ self .agent ,
450
+ self .messages ,
451
+ context = self .ctx ,
452
+ max_turns = self .max_turns ,
453
+ )
454
+ try :
455
+ async for ev in self ._streamed .stream_events ():
456
+ self ._q .put (ev )
457
+ finally :
458
+ self ._q .put (SENTINEL )
459
+
460
+ # Each thread needs its own loop
461
+ self ._loop = asyncio .new_event_loop ()
462
+ asyncio .set_event_loop (self ._loop )
463
+ try :
464
+ self ._loop .run_until_complete (run_and_consume ())
465
+ finally :
466
+ self ._loop .close ()
467
+
468
+ self ._thread = threading .Thread (target = worker , daemon = True )
469
+ self ._thread .start ()
470
+ return self
471
+
472
+ def events (self ) -> Iterator [object ]:
473
+ while True :
474
+ ev = self ._q .get ()
475
+ if ev is SENTINEL :
476
+ break
477
+ yield ev
478
+
479
+ def cancel (self ):
480
+ # Post a cancellation to the loop thread safely
481
+ if self ._loop and self ._streamed :
482
+
483
+ def _do_cancel ():
484
+ try :
485
+ self ._streamed .cancel ()
486
+ except Exception :
487
+ pass
488
+
489
+ self ._loop .call_soon_threadsafe (_do_cancel )
490
+
491
+
492
+ def dr_turn (
440
493
messages : List [Dict [str , Any ]],
441
494
cfg : GraphConfig ,
442
495
llm : LLM ,
443
- emitter : Emitter ,
496
+ turn_event_stream_emitter : Emitter , # TurnEventStream is the primary output of the turn
444
497
search_tool : SearchTool | None = None ,
445
498
) -> None :
446
- clarification = get_clarification (messages , cfg , llm , emitter , search_tool )
499
+ clarification = get_clarification (
500
+ messages , cfg , llm , turn_event_stream_emitter , search_tool
501
+ )
447
502
output = json .loads (clarification .choices [0 ].message .content )
448
503
clarification_output = ClarificationOutput (** output )
449
504
if clarification_output .clarification_needed :
450
- emitter .emit (kind = "agent" , data = clarification_output .clarification_question )
451
- emitter .emit (kind = "done" , data = {"ok" : True })
505
+ turn_event_stream_emitter .emit (
506
+ kind = "agent" , data = clarification_output .clarification_question
507
+ )
508
+ turn_event_stream_emitter .emit (kind = "done" , data = {"ok" : True })
452
509
return
453
510
454
511
agent = construct_deep_research_agent (llm )
455
512
ctx = MyContext (
456
513
run_dependencies = RunDependencies (
457
514
search_tool = search_tool ,
458
- emitter = emitter ,
515
+ emitter = turn_event_stream_emitter ,
459
516
)
460
517
)
461
- # 1) start the streamed run (async)
462
- streamed = Runner .run_streamed (agent , messages , context = ctx , max_turns = 100 )
463
-
464
- # 2) forward the agent’s async event stream
465
- async for ev in streamed .stream_events ():
518
+ bridge = StreamBridge (agent , messages , ctx , max_turns = 100 ).start ()
519
+ for ev in bridge .events ():
466
520
if isinstance (ev , RunItemStreamEvent ):
467
521
pass
468
522
elif isinstance (ev , RawResponsesStreamEvent ):
469
- emitter .emit (kind = "agent" , data = ev .data .model_dump ())
523
+ turn_event_stream_emitter .emit (kind = "agent" , data = ev .data .model_dump ())
470
524
471
- emitter .emit (kind = "done" , data = {"ok" : True })
525
+ turn_event_stream_emitter .emit (kind = "done" , data = {"ok" : True })
472
526
473
527
474
528
class ClarificationOutput (BaseModel ):
0 commit comments