Repository: supercog-ai/agentic

Issue:
Pull run logging outside of next_turn
We should pull run logging outside of the `next_turn` function so that agents that implement their own `next_turn` still have their runs logged correctly. This likely means adding a `_next_turn` function that just calls `self.next_turn` and also logs the event to the run_logs. We'll have to reorg how `handle_event` is declared / inited but this should allow all agents that override `next_turn` to still have their runs logged.

Hints from issue comments:
@scottpersinger: Sounds good. I have been meaning to rename "next_turn" to "run" (per our prior naming discussion). And yeah we can wrap the function however we want.

Solution patch:
diff --git a/MANIFEST.in b/MANIFEST.in
index 806807e..4e15356 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -8,3 +8,8 @@ include src/agentic/dashboard/types.d.ts
 include src/agentic/dashboard/README.md
 include src/agentic/dashboard/components.json
 include src/agentic/dashboard/package.json
+
+recursive-include examples *.py *.md *.yaml *.db
+recursive-include src/agentic/tools *.py
+
+recursive-exclude * __pycache__
diff --git a/examples/deep_research/oss_deep_research.py b/examples/deep_research/oss_deep_research.py
index 91b2dff..0f382e5 100644
--- a/examples/deep_research/oss_deep_research.py
+++ b/examples/deep_research/oss_deep_research.py
@@ -38,11 +38,12 @@ class DeepResearchAgent(Agent):
     sections: Sections|None = None
     topic: str = ""
 
-    def __init__(self, name: str="OSS Deep Research", model: str=GPT_4O_MINI, playwright_fallback: bool = False, verbose: bool = False):
+    def __init__(self, name: str="OSS Deep Research", model: str=GPT_4O_MINI, playwright_fallback: bool = False, verbose: bool = False, **kwargs):
         super().__init__(
             name, 
             welcome="I am the OSS Deep Research agent. Please provide me with a topic to research.",
             model=model,
+            **kwargs
         )
         self.tavily_tool = TavilySearchTool(api_key=agentic_secrets.get_required_secret("TAVILY_API_KEY"))
 
diff --git a/examples/march_madness.py b/examples/march_madness.py
index 9f9d8fe..7f31810 100644
--- a/examples/march_madness.py
+++ b/examples/march_madness.py
@@ -215,11 +215,12 @@ TEAMS_LIST = TeamList(teams=[
 class MarchMadnessAgent(Agent):
     tournament: Tournament | None = None
     
-    def __init__(self, name: str="March Madness Predictor", model: str=ANALYZER_MODEL, verbose: bool = False):
+    def __init__(self, name: str="March Madness Predictor", model: str=ANALYZER_MODEL, verbose: bool = False, **kwargs):
         super().__init__(
             name, 
             welcome="I am the March Madness Prediction agent. I'll analyze matchups and build a complete tournament bracket prediction.",
             model=model,
+            **kwargs
         )
         self.tavily_tool = TavilySearchTool(api_key=agentic_secrets.get_required_secret("TAVILY_API_KEY"))
         self.verbose = verbose
diff --git a/examples/podcast_long.py b/examples/podcast_long.py
new file mode 100644
index 0000000..1cfe058
--- /dev/null
+++ b/examples/podcast_long.py
@@ -0,0 +1,108 @@
+
+from agentic.common import Agent, AgentRunner
+from agentic.tools import AuthorizedRestApiTool, PodcastTool, TextToSpeechTool
+
+from agentic.models import GPT_4O
+from agentic.common import Agent, AgentRunner
+
+from datetime import datetime
+from pydub import AudioSegment
+import json
+import traceback
+import os
+
+
+def create_combined_podcast():
+    """
+    Generates a combined podcast audio file by scraping articles from the given list of news segment URLs,
+    summarizing their content, converting them to audio, and concatenating them into one MP3 file.
+    """
+    segment_sites=["https://www.nbcnews.com/news/us-news", "https://www.nbcnews.com/news/world", "https://www.nbcnews.com/tech"]
+    base_url='https://e4ed-73-15-6-205.ngrok-free.app'
+    
+    ast = PodcastTool()
+
+    audio_dir = os.path.join(os.path.dirname(__file__), "audios")
+    os.makedirs(audio_dir, exist_ok=True)
+
+    file_path = os.path.dirname(os.getcwd()) + "/article_scraper_transcript.txt"
+    audio_files = []
+    try:
+        for site in segment_sites:
+            ast.news_scrape_and_download(news_site=site, save_file=file_path, num_articles=4, is_summarize=True, sum_wc=500)
+            audio_file = ast.create_podcast_from_text(file_path)
+            audio_files.append(audio_file)
+        combined = AudioSegment.empty()
+        for f in audio_files:
+            file_info = json.loads(f)
+            audio_url = file_info["audio_url"]
+            audio_url = audio_url.split("file:///")[-1]
+            combined += AudioSegment.from_mp3(audio_url)
+        
+        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+        filename = f"news_podcast_{timestamp}.mp3"
+
+        output_path = os.path.join(audio_dir, filename)
+        combined.export(output_path, format="mp3")
+
+        #devtunnel
+        public_audio_url = f"{base_url}/{filename}"
+
+        return json.dumps(
+            {"content_type": "audio/mpeg", "audio_url": public_audio_url}
+        )
+
+    except Exception as e:
+        traceback.print_exc()
+        error_message = f"Error generating combined podcast: {str(e)}"
+        return json.dumps({"error": error_message})
+
+
+#transistor fm agent
+tfm_inst = """
+  You are a helpful assistant. You can call the Transistor API which is used for managing a podcast.
+  Here are the API endpoints:
+
+  The top level object is a "Show", and each show contains Episodes.
+
+  GET https://api.transistor.fm/v1 - Return the current authorized user
+  GET /v1/shows - Gets the list of Shows
+  GET /v1/episodes - Gets a list episodes for a show. Takes show_id parameter, and optional "status" filter parameter (one of draft, scheduled, published)
+
+  POST /v1/episodes - To create a new episode. Takes these parameters:
+    episode[show_id] 
+    episode[audio_url]
+    episode[title]
+
+  PATCH /v1/episodes/{id}/publish - To publish an episode. Takes these parameters:
+    id - episode ID
+    episode[status] - one of draft, published, scheduled
+"""
+#transistor fm agent
+upload_agent = Agent(
+    name="TransistorFM",
+    welcome="I can work with podcast episodes via the Transistor.fm API.",
+    instructions=tfm_inst,
+    tools=[AuthorizedRestApiTool("header", "TRANSISTOR_API_KEY", "x-api-key")],
+    memories=["Default show ID is 60214"],
+    model=GPT_4O,
+    )
+
+
+inst_comb = """
+When started, immedietly execute the following:
+1. Call create_combined_podcast to create a combined audio and get the public audio link
+2. Call the upload agent, ask it to create and then publish a DRAFT episode using the public audio link on Transistor FM and naming the episode "News Recap from <date> and add the date.
+"""
+
+agent = Agent(
+    name="Podcast Agent",
+    instructions=inst_comb,
+    model=GPT_4O,
+    tools=[PodcastTool(), TextToSpeechTool(), upload_agent, create_combined_podcast]
+)
+
+
+if __name__ == "__main__":
+    AgentRunner(agent).repl_loop()
+        
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 823145e..b1fad88 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -102,6 +102,11 @@ playwright = [
     "playwright>=1.51.0",
 ]
 
+podcast = [
+    "openai>=1.75.0",
+    "beautifulsoup4>=4.13.4",
+]
+
 text-to-speech = [
     "pydub>=0.25.1",
 ]
@@ -140,7 +145,6 @@ packages = [
     "agentic", 
     "agentic.custom_models",
     "agentic.tools",
-    "agentic.tools.utils",
     "agentic.swarm",
     "agentic.streamlit",
     "agentic.dashboard",
diff --git a/src/agentic/actor_agents.py b/src/agentic/actor_agents.py
index cedd53a..bb7827a 100644
--- a/src/agentic/actor_agents.py
+++ b/src/agentic/actor_agents.py
@@ -390,17 +390,11 @@ class ActorBaseAgent:
 
         return partial_response, events
 
-    def handlePromptOrResume(self, actor_message: Prompt | ResumeWithInput):
+    def handle_prompt_or_resume(self, actor_message: Prompt | ResumeWithInput):
         request_id = getattr(actor_message, 'request_id', None)
         if not request_id:
             raise ValueError("Request ID is required")
-                    
-        for event in self._handlePromptOrResume(actor_message, request_id):
-            if self._callbacks.get('handle_event'):
-                self._callbacks['handle_event'](event, self.run_context)
-            yield event
-
-    def _handlePromptOrResume(self, actor_message: Prompt | ResumeWithInput, request_id: str):
+        
         if isinstance(actor_message, Prompt):
             self.run_context = (
                 RunContext(
@@ -601,8 +595,8 @@ class ActorBaseAgent:
         message,
     ):
         depth = self.depth if handoff else self.depth + 1
-        if hasattr(child_ref.handlePromptOrResume, 'remote'):
-            remote_gen = child_ref.handlePromptOrResume.remote(
+        if hasattr(child_ref.handle_prompt_or_resume, 'remote'):
+            remote_gen = child_ref.handle_prompt_or_resume.remote(
                 Prompt(
                     self.name,
                     message,
@@ -611,12 +605,14 @@ class ActorBaseAgent:
                 )
             )
         else:
-            remote_gen = child_ref.handlePromptOrResume(
+            remote_gen = child_ref.handle_prompt_or_resume(
                 Prompt(
                     self.name,
                     message,
                     depth=depth,
                     debug=self.debug,
+                    request_context=self.run_context.context,
+                    request_id=str(uuid.uuid4())
                 )
             )
         
@@ -1200,7 +1196,7 @@ class BaseAgentProxy:
 
         def producer(queue, request_obj, continue_result):
             depthLocal.depth = request_obj.depth
-            for event in self.next_turn(request_obj, request_context=request_context, continue_result=continue_result, request_id=request_id):
+            for event in self._next_turn(request_obj, request_context=request_context, continue_result=continue_result, request_id=request_id):
                 queue.put(event)
             queue.put(self.queue_done_sentinel)
             # Cleanup the agent instance when done
@@ -1224,64 +1220,101 @@ class BaseAgentProxy:
             time.sleep(0.01)
         depthLocal.depth -= 1
 
-    def next_turn(self, request: str|Prompt, request_context: dict = {},
-                 request_id: str = None, continue_result: dict = {},
-                 debug: DebugLevel = DebugLevel(DebugLevel.OFF)) -> Generator[Event, Any, Any]:
-        """This is the key agent loop generator."""
-        self.cancelled = False
-        self.debug.raise_level(debug)
-        
-        # Get or create request ID
-        if not request_id:
-            request_id = continue_result.get("request_id") or str(uuid.uuid4())
-            if isinstance(request, Prompt):
-                request.request_id = request_id
-
-        # Handle mock settings - subclasses should implement this
-        self._handle_mock_settings(self.mock_settings)
-
-        # Get the agent instance for this request
+    def next_turn(self, request: str | Prompt, request_context: dict = {},
+              request_id: str = None, continue_result: dict = {},
+              debug: DebugLevel = DebugLevel(DebugLevel.OFF)) -> Generator[Event, Any, Any]:
+        """
+        Default agent orchestration logic. Subclasses may override this.
+        If not overridden, this handles prompt/resume and returns generator from agent.
+        """
+        # Get agent instance
         agent_instance = self._get_agent_for_request(request_id)
-        if not self.run_id and self.db_path:
-            self.init_run_tracking(agent_instance)
 
         # Prepare the prompt or resume input
         if not continue_result:
             prompt = (
-                request if isinstance(request, Prompt) 
+                request if isinstance(request, Prompt)
                 else Prompt(
-                    self.name, 
-                    request, 
-                    debug=self.debug, 
+                    self.name,
+                    request,
+                    debug=debug,
                     request_context=request_context,
-                    request_id=request_id
+                    request_id=request_id,
                 )
             )
+
+
             # Transmit depth through the Prompt
             if hasattr(depthLocal, 'depth') and depthLocal.depth > prompt.depth:
                 prompt.depth = depthLocal.depth
-                
-            # Get generator from agent
-            agent_gen = self._get_prompt_generator(agent_instance, prompt)
+
+            return self._get_prompt_generator(agent_instance, prompt)
+
         else:
-            # Handle resuming with input
             resume_input = ResumeWithInput(
-                self.name, 
-                continue_result, 
+                self.name,
+                continue_result,
                 request_id=request_id
             )
-            agent_gen = self._get_resume_generator(agent_instance, resume_input)
-            
-        # Process events from generator
-        for event in self._process_generator(agent_gen):
+            return self._get_resume_generator(agent_instance, resume_input)
+
+
+    def _next_turn(self, request: str | Prompt, request_context: dict = {},
+               request_id: str = None, continue_result: dict = {},
+               debug: DebugLevel = DebugLevel(DebugLevel.OFF)) -> Generator[Event, Any, Any]:
+        """
+        Wraps `next_turn` to add run tracking and handle_event logging.
+        Always used internally by the proxy to ensure consistent behavior.
+        """
+        self.cancelled = False
+        self.debug.raise_level(debug)
+
+        if not request_id:
+            request_id = continue_result.get("request_id") or str(uuid.uuid4())
+            if isinstance(request, Prompt):
+                request.request_id = request_id
+
+        self._handle_mock_settings(self.mock_settings)
+
+        # Get agent instance for the run
+        agent_instance = self._get_agent_for_request(request_id)
+
+        # Initialize run tracking if needed
+        if (not self.run_id) and self.db_path:
+            self.init_run_tracking(agent_instance, run_id)
+
+        # Add run_id into context explicitly so child agents inherit it
+        request_context = {**request_context, "run_id": self.run_id}
+
+        # Call the user’s or default next_turn
+        event_gen = self.next_turn(
+            request=request,
+            request_context=request_context,
+            request_id=request_id,
+            continue_result=continue_result,
+            debug=debug
+        )
+
+        # Central logging of all events
+        for event in self._process_generator(event_gen):
             if self.cancelled:
                 raise TurnCancelledError()
-                
-            # Process results if needed
+
+            # Handle TurnEnd result validation
             if isinstance(event, TurnEnd):
                 event = self._process_turn_end(event)
+
             yield event
-            
+
+            if hasattr(event, "agent") and event.agent != self.name:
+                continue    
+
+            # Only now: do logging after yielding
+            callback = self._agent.get_callback("handle_event") if hasattr(self, "_agent") else None
+            if callback:
+                context = RunContext(agent=self._agent, agent_name=self.name, run_id=self.run_id)
+                callback(event, context)
+        
     def _get_prompt_generator(self, agent_instance, prompt):
         """Get generator for a new prompt - to be implemented by subclasses"""
         pass
@@ -1463,11 +1496,11 @@ class RayAgentProxy(BaseAgentProxy):
         
     def _get_prompt_generator(self, agent, prompt):
         """Get generator for a new prompt from a Ray agent"""
-        return agent.handlePromptOrResume.remote(prompt)
+        return agent.handle_prompt_or_resume.remote(prompt)
 
     def _get_resume_generator(self, agent, resume_input):
         """Get generator for resuming with input from a Ray agent"""
-        return agent.handlePromptOrResume.remote(resume_input)
+        return agent.handle_prompt_or_resume.remote(resume_input)
         
     def _process_generator(self, generator):
         """Process generator events - Ray implementation"""
@@ -1578,11 +1611,11 @@ class LocalAgentProxy(BaseAgentProxy):
 
     def _get_prompt_generator(self, agent, prompt):
         """Get generator for a new prompt - Local implementation"""
-        return agent.handlePromptOrResume(prompt)
+        return agent.handle_prompt_or_resume(prompt)
         
     def _get_resume_generator(self, agent, resume_input):
         """Get generator for resuming with input - Local implementation"""
-        return agent.handlePromptOrResume(resume_input)
+        return agent.handle_prompt_or_resume(resume_input)
         
     def _process_generator(self, generator):
         """Process generator events - Local implementation"""
diff --git a/src/agentic/api.py b/src/agentic/api.py
index ef44153..55499b7 100644
--- a/src/agentic/api.py
+++ b/src/agentic/api.py
@@ -200,7 +200,6 @@ class AgentAPIServer:
         ):
             """Process a new request"""
             ctx = {"user": user} if user else {}
-            print("In process request, context is: ", ctx)
             req_event = agent.start_request(
                 request=request.prompt,
                 request_context=ctx,
@@ -208,7 +207,8 @@ class AgentAPIServer:
                 debug=DebugLevel(request.debug) if request.debug else self.debug
             )
             # abusing the "registry" to track the agent per request
-            self.agent_registry[req_event.request_id] = agent
+            # Commented out 4/21/25 because it breaks resume with input
+            # self.agent_registry[req_event.request_id] = agent
             return req_event
                 
         # Resume endpoint
@@ -240,12 +240,14 @@ class AgentAPIServer:
             request_id: str, 
             agent_name: str,
             stream: bool = False, 
+            agent = Depends(get_agent)
         ):
             """Get events for a request"""
-            if request_id in self.agent_registry:
-                agent = self.agent_registry[request_id]
-            else:
-                agent: Agent = get_agent(agent_name)
+            # Commented out 4/21/25 because it breaks resume with input
+            # if request_id in self.agent_registry:
+            #     agent = self.agent_registry[request_id]
+            # else:
+            #     agent: Agent = get_agent(agent_name)
 
             if not stream:
                 # Non-streaming response
diff --git a/src/agentic/dashboard/src/app/hooks/useChat.tsx b/src/agentic/dashboard/src/app/hooks/useChat.tsx
index 7b3e7e0..24f71ac 100644
--- a/src/agentic/dashboard/src/app/hooks/useChat.tsx
+++ b/src/agentic/dashboard/src/app/hooks/useChat.tsx
@@ -20,8 +20,6 @@ export function useChat(agentPath: string, agentName: string, currentRunId: stri
   const prevRunIdRef = useRef<string | undefined | null>(currentRunId);
   
   // Fetch run logs when runId changes
-  // If running Deep Researcher or other custom next_turn agents uncomment this line and comment out the next one
-  // const { data: runLogs, isLoading: isLoadingRunLogs } = useRunLogs(agentPath, null);
   const { data: runLogs, isLoading: isLoadingRunLogs } = useRunLogs(agentPath, currentRunId ?? null);
   
   // Reset events when currentRunId changes to undefined/null
diff --git a/src/agentic/run_manager.py b/src/agentic/run_manager.py
index 9f7ec7d..ddf984b 100644
--- a/src/agentic/run_manager.py
+++ b/src/agentic/run_manager.py
@@ -26,6 +26,7 @@ class RunManager:
         self.user_id = user_id
         self.initial_run_id: Optional[str] = initial_run_id
         self.current_run_id: Optional[str] = current_run_id
+        # Should this not be propagated from the next_turn?
         self.usage_data: Dict = {}
         self.db_path = get_runtime_filepath(db_path)
     
@@ -35,15 +36,23 @@ class RunManager:
         # Initialize a new run when we see a Prompt event
 
         if isinstance(event, PromptStarted) and not self.current_run_id:
+            if type(event.payload)==dict:
+                prompt = event.payload['content']
+            else:
+                prompt = str(event.payload)
             run = db_manager.create_run(
                 run_id=self.initial_run_id,
                 agent_id=run_context.agent_name,
                 user_id=self.user_id,
-                initial_prompt=event.payload,
+                initial_prompt=prompt
             )
             self.current_run_id = run.id
             run_context.run_id = run.id 
             
+        self.current_run_id = run_context.run_id
+        if not self.current_run_id:
+            run_context.run_id = self.initial_run_id
+            self.current_run_id = self.initial_run_id
         # Skip if we haven't initialized a run yet
         if not self.current_run_id:
             return
diff --git a/src/agentic/tools/__init__.py b/src/agentic/tools/__init__.py
index ff4eda7..b55548b 100644
--- a/src/agentic/tools/__init__.py
+++ b/src/agentic/tools/__init__.py
@@ -22,6 +22,7 @@ _TOOL_MAPPING = {
     "MeetingBaasTool": "meeting_tool",
     "OAuthTool": "oauth_tool",
     "PlaywrightTool": "playwright",
+    "PodcastTool": "podcast_tool",
     "RAGTool": "rag_tool",
     "RestApiTool": "rest_api_tool",
     "ScaleSerpBrowserTool": "scaleserp_browser",
@@ -51,6 +52,7 @@ __all__ = [
     "MeetingBaasTool",
     "OAuthTool",
     "PlaywrightTool",
+    "PodcastTool",
     "RAGTool",
     "RestApiTool",
     "ScaleSerpBrowserTool",
diff --git a/src/agentic/tools/__init__.pyi b/src/agentic/tools/__init__.pyi
new file mode 100644
index 0000000..cb49be3
--- /dev/null
+++ b/src/agentic/tools/__init__.pyi
@@ -0,0 +1,57 @@
+from .airbnb_calendar_tool import AirbnbCalendarTool
+from .auth_rest_api_tool import AuthorizedRestApiTool
+from .automatic_tools import AutomaticTools
+from .base import BaseAgenticTool
+from .browser_use import BrowserUseTool
+from .database_tool import DatabaseTool
+from .duckduckgo import DuckDuckGoTool
+from .example_tool import ExampleTool
+from .file_download import FileDownloadTool
+from .github_tool import GithubTool
+from .google_news import GoogleNewsTool
+from .human_interrupt import HumanInterruptTool
+from .image_generator import ImageGeneratorTool
+from .imap_tool import IMAPTool
+from .linkedin_tool import LinkedinDataTool
+from .mcp_tool import MCPTool
+from .meeting_tool import MeetingBaasTool
+from .oauth_tool import OAuthTool
+from .playwright import PlaywrightTool
+from .podcast_tool import PodcastTool
+from .rag_tool import RAGTool
+from .rest_api_tool import RestApiTool
+from .scaleserp_browser import ScaleSerpBrowserTool
+from .tavily_search_tool import TavilySearchTool
+from .text_to_speech_tool import TextToSpeechTool
+from .unit_test_tool import UnitTestingTool
+from .weather_tool import WeatherTool
+
+__all__ = [
+    "AirbnbCalendarTool",
+    "AuthorizedRestApiTool",
+    "AutomaticTools",
+    "BaseAgenticTool",
+    "BrowserUseTool",
+    "DatabaseTool",
+    "DuckDuckGoTool",
+    "ExampleTool",
+    "FileDownloadTool",
+    "GithubTool",
+    "GoogleNewsTool",
+    "HumanInterruptTool",
+    "ImageGeneratorTool",
+    "IMAPTool",
+    "LinkedinDataTool",
+    "MCPTool",
+    "MeetingBaasTool",
+    "OAuthTool",
+    "PlaywrightTool",
+    "PodcastTool",
+    "RAGTool",
+    "RestApiTool",
+    "ScaleSerpBrowserTool",
+    "TavilySearchTool",
+    "TextToSpeechTool",
+    "UnitTestingTool",
+    "WeatherTool",
+]
diff --git a/src/agentic/tools/duckduckgo.py b/src/agentic/tools/duckduckgo.py
index 3c996b7..6843008 100644
--- a/src/agentic/tools/duckduckgo.py
+++ b/src/agentic/tools/duckduckgo.py
@@ -31,33 +31,27 @@ class DuckDuckGoTool(BaseAgenticTool):
 
     Free and does not require any setup.
     """
-
-    region: Optional[str] = "wt-wt"
-    """
-    See https://pypi.org/project/duckduckgo-search/#regions
-    """
-    safesearch: str = "moderate"
-    """
-    Options: strict, moderate, off
-    """
-    time: Optional[str] = "y"
-    """
-    Options: d, w, m, y
-    """
-    max_results: int = 5
-    backend: str = "auto"
-    """
-    Options: auto, html, lite
-    """
-    source: str = "text"
-    """
-    Options: text, news, images
-    """
-
     model_config = ConfigDict(
         extra="forbid",
     )
 
+    def __init__(
+        self,
+        region: Optional[str] = "wt-wt", # Options: see https://pypi.org/project/duckduckgo-search/#regions
+        safesearch: str = "moderate", # Options: strict, moderate, off
+        time: Optional[str] = "y", # Options: d, w, m, y
+        max_results: int = 5,
+        backend: str = "auto", # Options: auto, html, lite
+        source: str = "text", # Options: text, news, images
+    ):
+        self.region = region
+        self.safesearch = safesearch
+        self.time = time
+        self.max_results = max_results
+        self.backend = backend
+        self.source = source
+        super().__init__()
+
     def get_tools(self):
         return [self.web_search_with_duckduckgo]
     
diff --git a/src/agentic/tools/example_tool.py b/src/agentic/tools/example_tool.py
index 07b913b..bcdd777 100644
--- a/src/agentic/tools/example_tool.py
+++ b/src/agentic/tools/example_tool.py
@@ -18,6 +18,7 @@ from agentic.common import RunContext, PauseForInputResult
         ),
     ],
 )
+
 class ExampleTool(BaseAgenticTool):
     """
     A tool that demonstrates the standard patterns for implementing agentic tools.
diff --git a/src/agentic/tools/podcast_tool.py b/src/agentic/tools/podcast_tool.py
new file mode 100644
index 0000000..959d1ef
--- /dev/null
+++ b/src/agentic/tools/podcast_tool.py
@@ -0,0 +1,132 @@
+from typing import Callable
+import requests
+from bs4 import BeautifulSoup
+import openai
+
+from agentic.tools.text_to_speech_tool import TextToSpeechTool
+from agentic.tools.base import BaseAgenticTool
+from agentic.tools.utils.registry import tool_registry, ConfigRequirement, Dependency
+
+@tool_registry.register(
+    name="PodcastTool",
+    description="Tool for downloading and summarizing articles and converting them to podcasts",
+    dependencies=[
+        Dependency(
+            name="beautifulsoup4",
+            version="4.13.4",
+            type="pip",
+        ),
+        Dependency(
+            name="openai",
+            version="1.75.0",
+            type="pip",
+        ),
+    ],
+    config_requirements=[
+        ConfigRequirement(
+            key="OPENAI_API_KEY",
+            description="OpenAI API key",
+            required=True,
+        )
+    ],
+)
+
+class PodcastTool(BaseAgenticTool):
+    def __init__(self):
+        super().__init__()
+
+    def get_tools(self) -> list[Callable]:
+        return [
+            self.download_and_write_article,
+            self.gather_urls,
+            self.summarize,
+            self.news_scrape_and_download,
+            self.create_podcast_from_text
+        ]
+    
+
+    def download_and_write_article(
+        self,
+        download_url:str,
+        save_file: str,
+        is_summarize: bool = True,
+        sum_wc: int = 500
+    ):
+        r = requests.get(download_url)
+        soup = BeautifulSoup(r.text, 'html.parser')
+        content = ""
+        success = False
+        with open(save_file, "a") as f:
+            for title in soup.find_all('title'):
+                if len(title.get_text().split()) > 3:
+                    ttext = title.get_text()
+            for data in soup.find_all("p"):
+                content += data.get_text() + '\n'
+            if len(content) > 3000:
+                if is_summarize:
+                    f.write(self.summarize(article=content, wordcount=sum_wc, title=ttext))   
+                else:
+                    f.write("\n\n"+ ttext + "\n")
+                    f.write(content) 
+                success = True
+        return success
+    
+
+    def summarize(
+        self,
+        article: str,
+        wordcount: int,
+        title: str, 
+        model="gpt-4o"
+    ):
+        client = openai.OpenAI()
+        prompt = "Please summarize the following article titled " + title+ " in detail in " + str(wordcount) + " words in the style of a on-air news reporter. Include a introduction/transition sentence in the beginning: \n"
+        prompt += article
+        messages = [{"role": "user", "content": prompt}]
+        response = client.chat.completions.create(
+            model=model,
+            messages=messages,
+            temperature=0,
+        )
+        res = response.choices[0].message.content
+        return res
+
+## PODCAST SPECIFIC FUNCTIONS
+    def gather_urls(
+        self,
+        site_url: str
+    ):
+        r = requests.get(site_url)
+        urls = set()
+        soup = BeautifulSoup(r.text, 'html.parser')
+        for link in soup.find_all("a"):
+            href = link.get("href")
+            if site_url in href: #validates news link
+                urls.add(href)
+        return urls    
+    
+    def news_scrape_and_download(
+        self,
+        news_site: str,
+        save_file: str,
+        num_articles: int = 5,
+        is_summarize: bool = True,
+        sum_wc: int = 500
+    ):
+        with open(save_file, "w") as f:
+            f.write("")
+        urls = list(self.gather_urls(news_site))
+        count = 0
+        while count < num_articles and len(urls) > 0:
+            curl = urls.pop(0)
+            success = self.download_and_write_article(curl, save_file, True)
+            if success:
+                count += 1
+    
+    def create_podcast_from_text(
+        self,
+        save_file: str
+    ):
+        tts = TextToSpeechTool()
+        res = tts.generate_speech_file_from_text(voice="nova", input_file_name=save_file)
+        return(res)
diff --git a/src/agentic/tools/rest_api_tool.py b/src/agentic/tools/rest_api_tool.py
index ed48649..b188c5a 100644
--- a/src/agentic/tools/rest_api_tool.py
+++ b/src/agentic/tools/rest_api_tool.py
@@ -12,7 +12,7 @@ import httpx
 from urllib.parse import urlparse, parse_qsl, urlencode
 
 from agentic.tools.base import BaseAgenticTool
-from agentic.tools.utils.registry import tool_registry, Dependency
+from agentic.tools.utils.registry import tool_registry
 from agentic.common import RunContext
 
 class AsyncRequestBuilder:
@@ -114,9 +114,16 @@ class AsyncRequestBuilder:
     config_requirements=[],
 )
 class RestApiTool(BaseAgenticTool):
-    request_map: dict[str, AsyncRequestBuilder] = {}
-    return_dataframe: bool = False
-    run_context: RunContext | None = None
+    def __init__(
+        self,
+        request_map:  dict[str, AsyncRequestBuilder] = {},
+        return_dataframe: bool = False,
+        run_context: Optional[RunContext] = None
+    ):
+        super().__init__()
+        self.request_map = request_map
+        self.return_dataframe = return_dataframe
+        self.run_context = run_context
 
     def get_tools(self) -> list[Callable]:
         return [


Test patch:
diff --git a/tests/test_tools_init.py b/tests/test_tools_init.py
index ee972d5..6a252d0 100644
--- a/tests/test_tools_init.py
+++ b/tests/test_tools_init.py
@@ -2,7 +2,13 @@ import os
 import importlib
 import sys
 import pytest
-from .utils.tools_utils import find_tools_directory, get_tool_classes_from_module
+import inspect
+from .utils.tools_utils import (
+    find_tools_directory,
+    get_base_tool_class,
+    get_tool_classes_from_module,
+    get_tool_files_in_directory
+)
 
 def test_all_tools_are_imported_and_listed():
     """
@@ -17,20 +23,12 @@ def test_all_tools_are_imported_and_listed():
         tools_dir = find_tools_directory()
     except FileNotFoundError as e:
         pytest.skip(f"Skipping test: {str(e)}")
-    
-    # Import the tools module to inspect its __all__ variable
-    if str(tools_dir.parent) not in sys.path:
-        sys.path.insert(0, str(tools_dir.parent))
-    
+
     tools_module = importlib.import_module('agentic.tools')
     all_list = getattr(tools_module, '__all__', [])
     
-    # Get the tool mapping dictionary
-    tool_mapping = getattr(tools_module, '_TOOL_MAPPING', {})
-    
-    # Find all Python files in the tools directory (excluding __init__.py)
-    tool_files = [f for f in os.listdir(tools_dir) 
-                 if f.endswith('.py') and f != '__init__.py']
+    tool_mapping = getattr(tools_module, '_TOOL_MAPPING', {})    
+    tool_files = get_tool_files_in_directory(tools_dir)
     
     # Check each tool file
     missing_in_mapping = []
@@ -43,14 +41,14 @@ def test_all_tools_are_imported_and_listed():
             tool_classes = get_tool_classes_from_module(module_name)
             
             # Check if all tool classes are in the mapping and __all__
-            for cls in tool_classes:
+            for cls_name in tool_classes:
                 # Check if in mapping
-                if cls not in tool_mapping or tool_mapping[cls] != module_name:
-                    missing_in_mapping.append(f"{cls} -> {module_name}")
+                if cls_name not in tool_mapping or tool_mapping[cls_name] != module_name:
+                    missing_in_mapping.append(f"{cls_name} -> {module_name}")
                 
                 # Check if in __all__
-                if cls not in all_list:
-                    missing_in_all.append(cls)
+                if cls_name not in all_list:
+                    missing_in_all.append(cls_name)
 
         except (ImportError, AttributeError) as e:
             print(f"Warning: Could not inspect module {module_name}: {e}")
@@ -67,11 +65,10 @@ def test_all_tools_are_imported_and_listed():
     # Fail the test if any issues found
     assert not failure_msgs, '\n'.join(failure_msgs)
 
-
-def test_tools_inherit_from_base_class():
+def test_type_stub_matches_init():
     """
-    Test that ensures all tool classes inherit from BaseAgenticTool.
-    Compatible with lazy-loading implementation.
+    Test that the __init__.pyi type stub file correctly includes all the tool classes
+    that are in __all__ and _TOOL_MAPPING.
     """
     # Get the tools directory path
     try:
@@ -79,20 +76,73 @@ def test_tools_inherit_from_base_class():
     except FileNotFoundError as e:
         pytest.skip(f"Skipping test: {str(e)}")
     
-    # Import the tools module
+    # Path to the type stub file
+    pyi_path = tools_dir / "__init__.pyi"
+    
+    # Check if the type stub file exists
+    if not pyi_path.exists():
+        pytest.skip("__init__.pyi does not exist")
+    
+    # Import the tools module to get __all__ and _TOOL_MAPPING
     if str(tools_dir.parent) not in sys.path:
         sys.path.insert(0, str(tools_dir.parent))
     
-    # Import the base tool class directly from the module to avoid lazy loading issues
-    try:
-        base_module = importlib.import_module('agentic.tools.base')
-        BaseAgenticTool = getattr(base_module, 'BaseAgenticTool')
-    except (ImportError, AttributeError) as e:
-        pytest.fail(f"Could not import BaseAgenticTool: {e}")
+    tools_module = importlib.import_module('agentic.tools')
+    all_list = getattr(tools_module, '__all__', [])
+    tool_mapping = getattr(tools_module, '_TOOL_MAPPING', {})
+    
+    # Read the content of the type stub file
+    with open(pyi_path, 'r') as f:
+        pyi_content = f.read()
+    
+    # Check if all tool classes from __all__ are imported in the type stub
+    missing_imports = []
+    for tool_name in all_list:
+        import_pattern = f"from .{tool_mapping.get(tool_name, '')} import {tool_name}"
+        if import_pattern not in pyi_content:
+            missing_imports.append(f"{tool_name} from {tool_mapping.get(tool_name, '')}")
+    
+    # Check if all tool classes are included in the __all__ list in the type stub
+    missing_in_all = []
+    
+    # This regex pattern matches the __all__ list in the type stub file
+    import re
+    all_pattern = r"__all__\s*=\s*\[(.*?)\]"
+    all_match = re.search(all_pattern, pyi_content, re.DOTALL)
+    
+    if all_match:
+        pyi_all_content = all_match.group(1)
+        for tool_name in all_list:
+            if f'"{tool_name}"' not in pyi_all_content:
+                missing_in_all.append(tool_name)
+    else:
+        missing_in_all = all_list  # All are missing if the __all__ list isn't found
+    
+    # Generate failure messages
+    failure_msgs = []
     
-    # Find all Python files in the tools directory (excluding __init__.py)
-    tool_files = [f for f in os.listdir(tools_dir) 
-                 if f.endswith('.py') and f != '__init__.py' and f != 'base.py']
+    if missing_imports:
+        failure_msgs.append(f"The following tool classes are missing from imports in __init__.pyi: {', '.join(missing_imports)}")
+    
+    if missing_in_all:
+        failure_msgs.append(f"The following tool classes are missing from __all__ in __init__.pyi: {', '.join(missing_in_all)}")
+    
+    # Fail the test if any issues found
+    assert not failure_msgs, '\n'.join(failure_msgs)
+
+def test_tools_inherit_from_base_class():
+    """
+    Test that ensures all tool classes inherit from BaseAgenticTool.
+    Compatible with lazy-loading implementation.
+    """
+    # Get the tools directory path
+    try:
+        tools_dir = find_tools_directory()
+    except FileNotFoundError as e:
+        pytest.skip(f"Skipping test: {str(e)}")
+
+    BaseAgenticTool = get_base_tool_class()
+    tool_files = get_tool_files_in_directory(tools_dir, exclude_files=['__init__.py', 'base.py'])
     
     # Tool classes that don't inherit from BaseAgenticTool
     non_compliant_tools = []
@@ -104,9 +154,9 @@ def test_tools_inherit_from_base_class():
             tool_classes = get_tool_classes_from_module(module_name)
             
             # Check inheritance
-            for cls in tool_classes:
-                if not issubclass(tool_classes[cls], BaseAgenticTool):
-                    non_compliant_tools.append(f"{module_name}.{cls}")
+            for cls_name, cls in tool_classes.items():
+                if not issubclass(cls, BaseAgenticTool):
+                    non_compliant_tools.append(f"{module_name}.{cls_name}")
                     
         except (ImportError, AttributeError) as e:
             print(f"Warning: Could not inspect module {module_name}: {e}")
@@ -114,6 +164,70 @@ def test_tools_inherit_from_base_class():
     # Fail the test if any tools don't inherit from BaseAgenticTool
     assert not non_compliant_tools, f"The following tool classes don't inherit from BaseAgenticTool: {', '.join(non_compliant_tools)}"
 
+def test_all_tools_have_init_function():
+    """
+    Test that ensures all tool classes have a __init__ method.
+    """
+    # Get the tools directory path
+    try:
+        tools_dir = find_tools_directory()
+    except FileNotFoundError as e:
+        pytest.skip(f"Skipping test: {str(e)}")
+
+    BaseAgenticTool = get_base_tool_class()
+    base_init_function = BaseAgenticTool.__init__
+    tool_files = get_tool_files_in_directory(tools_dir, exclude_files=['__init__.py', 'base.py'])
+    
+    # Tool classes that don't have a __init__ method
+    missing_init = []
+    non_callable_init = []
+    non_overridden_init = []
+    
+    for tool_file in tool_files:            
+        # Try to import the module to get its classes
+        try:
+            module_name = tool_file.removesuffix(".py")
+            tool_classes = get_tool_classes_from_module(module_name)
+            
+            # Check for __init__ method
+            for cls_name, cls in tool_classes.items():
+                # Check if __init__ exists
+                if not hasattr(cls, '__init__'):
+                    missing_init.append(f"{module_name}.{cls_name}")
+                    continue                
+                
+                # Check if __init__ is callable
+                init_attr = getattr(cls, '__init__')
+                if not callable(init_attr):
+                    non_callable_init.append(f"{module_name}.{cls_name}")
+                    continue
+
+                # Check if get_tools has different signature than BaseAgenticTool
+                init_function = cls.__dict__.get('__init__')
+                print(f"get_tools_function: {init_function}\nBaseAgenticTool.__init__: {base_init_function}")
+                
+                if init_function is None or init_function is base_init_function:
+                    non_overridden_init.append(f"{module_name}.{cls_name}")
+                    continue
+                    
+        except (ImportError, AttributeError) as e:
+            print(f"Warning: Could not inspect module {module_name}: {e}")
+    
+    # Build error messages
+    errors = []
+    
+    if missing_init:
+        errors.append(f"The following tool classes are missing the __init__ method: {', '.join(missing_init)}")
+    
+    if non_callable_init:
+        errors.append(f"The following tool classes have a __init__ attribute that is not callable: {', '.join(non_callable_init)}")
+
+    if non_overridden_init:
+        errors.append(f"The following tool classes have a __init__ method that doesn't override the BaseAgenticTool's __init__ method: {', '.join(non_overridden_init)}")
+    
+    # Fail the test if any errors
+    assert not errors, '\n'.join(errors)
+
 def test_all_tools_have_get_tools_function():
     """
     Test that ensures all tool classes have a get_tools method.
@@ -124,18 +238,14 @@ def test_all_tools_have_get_tools_function():
     except FileNotFoundError as e:
         pytest.skip(f"Skipping test: {str(e)}")
     
-    # Import the tools module
-    if str(tools_dir.parent) not in sys.path:
-        sys.path.insert(0, str(tools_dir.parent))
-    
-    # Find all Python files in the tools directory (excluding __init__.py)
-    tool_files = [f for f in os.listdir(tools_dir) 
-                 if f.endswith('.py') and f != '__init__.py']
+    BaseAgenticTool = get_base_tool_class()
+    base_get_tools = BaseAgenticTool.get_tools
+    tool_files = get_tool_files_in_directory(tools_dir, exclude_files=['__init__.py', 'base.py'])
     
     # Tool classes that don't have a get_tools method
     missing_get_tools = []
     non_callable_get_tools = []
-    empty_get_tools = []
+    non_overridden_get_tools = []
     
     for tool_file in tool_files:            
         # Try to import the module to get its classes
@@ -144,17 +254,22 @@ def test_all_tools_have_get_tools_function():
             tool_classes = get_tool_classes_from_module(module_name)
             
             # Check for get_tools method
-            for cls in tool_classes:
+            for cls_name, cls in tool_classes.items():
                 # Check if get_tools exists
-                if not hasattr(tool_classes[cls], 'get_tools'):
-                    missing_get_tools.append(f"{module_name}.{cls}")
+                if not hasattr(cls, 'get_tools'):
+                    missing_get_tools.append(f"{module_name}.{cls_name}")
                     continue
-                
+
                 # Check if get_tools is callable
-                get_tools_attr = getattr(tool_classes[cls], 'get_tools')
+                get_tools_attr = getattr(cls, 'get_tools')
                 if not callable(get_tools_attr):
-                    non_callable_get_tools.append(f"{module_name}.{cls}")
+                    non_callable_get_tools.append(f"{module_name}.{cls_name}")
                     continue
+
+                # Check if get_tools has different signature than BaseAgenticTool
+                get_tools_function = cls.__dict__.get('get_tools')
+                if get_tools_function is None or get_tools_function is base_get_tools:
+                    non_overridden_get_tools.append(f"{module_name}.{cls_name}")
                     
         except (ImportError, AttributeError) as e:
             print(f"Warning: Could not inspect module {module_name}: {e}")
@@ -168,10 +283,9 @@ def test_all_tools_have_get_tools_function():
     if non_callable_get_tools:
         errors.append(f"The following tool classes have a get_tools attribute that is not callable: {', '.join(non_callable_get_tools)}")
     
-    if empty_get_tools:
-        # This is a warning, not an error
-        print(f"Warning: The following tool classes have get_tools methods that return empty lists: {', '.join(empty_get_tools)}")
-    
+    if non_overridden_get_tools:
+            errors.append(f"The following tool classes have a get_tools method that doesn't override the BaseAgenticTool's get_tools method: {', '.join(non_overridden_get_tools)}")
+
     # Fail the test if any errors
     assert not errors, '\n'.join(errors)
 
diff --git a/tests/utils/tools_utils.py b/tests/utils/tools_utils.py
index 467878b..97e57cd 100644
--- a/tests/utils/tools_utils.py
+++ b/tests/utils/tools_utils.py
@@ -1,5 +1,6 @@
 import inspect
 import importlib
+import os
 from pathlib import Path
 from typing import Any
 
@@ -44,4 +45,23 @@ def get_tool_classes_from_module(module_name: str) -> dict[str, type[Any]]:
         if inspect.isclass(obj) and name.__contains__('Tool') and obj.__module__ == f'agentic.tools.{module_name}':
             tool_classes[name] = obj
 
-    return tool_classes
\ No newline at end of file
+    return tool_classes
+
+def get_base_tool_class():
+    """
+    Get the BaseAgenticTool class from the base module.
+    """
+    try:
+        base_module = importlib.import_module('agentic.tools.base')
+        return getattr(base_module, 'BaseAgenticTool')
+    except (ImportError, AttributeError) as e:
+        raise ImportError("Could not import BaseAgenticTool: " + str(e))
+
+def get_tool_files_in_directory(directory: Path | str, exclude_files: list[Path | str] = ['__init__.py']) -> list[Path]:
+    """
+    Get a list of all Python files in the given directory excluding the specified files.
+    """
+    return [
+        f for f in os.listdir(directory) 
+        if f.endswith('.py') and f not in exclude_files
+    ]