diff --git a/cookbook/playground/gemini_agents.py b/cookbook/playground/gemini_agents.py new file mode 100644 index 000000000..c8eded2a6 --- /dev/null +++ b/cookbook/playground/gemini_agents.py @@ -0,0 +1,16 @@ +from phi.agent import Agent +from phi.tools.yfinance import YFinanceTools +from phi.playground import Playground, serve_playground_app +from phi.model.google import Gemini + +finance_agent = Agent( + name="Finance Agent", + model=Gemini(id="gemini-2.0-flash-exp"), + tools=[YFinanceTools(stock_price=True)], + debug_mode=True, +) + +app = Playground(agents=[finance_agent]).get_app(use_async=False) + +if __name__ == "__main__": + serve_playground_app("gemini_agents:app", reload=True) diff --git a/cookbook/playground/multimodal_agent.py b/cookbook/playground/multimodal_agent.py index ecff42ec2..412168ec5 100644 --- a/cookbook/playground/multimodal_agent.py +++ b/cookbook/playground/multimodal_agent.py @@ -89,6 +89,7 @@ gif_agent = Agent( name="Gif Generator Agent", + agent_id="gif_agent", model=OpenAIChat(id="gpt-4o"), tools=[GiphyTools()], description="You are an AI agent that can generate gifs using Giphy.", @@ -105,6 +106,7 @@ audio_agent = Agent( name="Audio Generator Agent", + agent_id="audio_agent", model=OpenAIChat(id="gpt-4o"), tools=[ ElevenLabsTools( @@ -116,7 +118,7 @@ "When the user asks you to generate audio, use the `text_to_speech` tool to generate the audio.", "You'll generate the appropriate prompt to send to the tool to generate audio.", "You don't need to find the appropriate voice first, I already specified the voice to user." - "Return the audio file name in your response. Don't convert it to markdown.", + "Don't return file name or file url in your response or markdown just tell the audio was created successfully.", "The audio should be long and detailed.", ], markdown=True, diff --git a/cookbook/providers/google/flash_thinking.py b/cookbook/providers/google/flash_thinking.py new file mode 100644 index 000000000..0e2514f7a --- /dev/null +++ b/cookbook/providers/google/flash_thinking.py @@ -0,0 +1,12 @@ +from phi.agent import Agent +from phi.model.google import Gemini + +task = ( + "Three missionaries and three cannibals need to cross a river. " + "They have a boat that can carry up to two people at a time. " + "If, at any time, the cannibals outnumber the missionaries on either side of the river, the cannibals will eat the missionaries. " + "How can all six people get across the river safely? Provide a step-by-step solution and show the solutions as an ascii diagram" +) + +agent = Agent(model=Gemini(id="gemini-2.0-flash-thinking-exp-1219"), markdown=True) +agent.print_response(task, stream=True) diff --git a/cookbook/providers/ollama/agent_stream.py b/cookbook/providers/ollama/agent_stream.py index 13c8e1060..d39e0b46e 100644 --- a/cookbook/providers/ollama/agent_stream.py +++ b/cookbook/providers/ollama/agent_stream.py @@ -3,6 +3,7 @@ from typing import Iterator # noqa from phi.agent import Agent, RunResponse # noqa from phi.model.ollama import Ollama +from phi.tools.crawl4ai_tools import Crawl4aiTools from phi.tools.yfinance import YFinanceTools agent = Agent( @@ -20,3 +21,10 @@ # Print the response in the terminal agent.print_response("What are analyst recommendations for NVDA and TSLA", stream=True) + + +agent = Agent(model=Ollama(id="llama3.1:8b"), tools=[Crawl4aiTools(max_length=1000)], show_tool_calls=True) +agent.print_response( + "Summarize me the key points in bullet points of this: https://blog.google/products/gemini/google-gemini-deep-research/", + stream=True, +) diff --git a/cookbook/storage/json_storage.py b/cookbook/storage/json_storage.py new file mode 100644 index 000000000..67509dd4e --- /dev/null +++ b/cookbook/storage/json_storage.py @@ -0,0 +1,13 @@ +"""Run `pip install duckduckgo-search openai` to install dependencies.""" + +from phi.agent import Agent +from phi.tools.duckduckgo import DuckDuckGo +from phi.storage.agent.json import JsonFileAgentStorage + +agent = Agent( + storage=JsonFileAgentStorage(dir_path="tmp/agent_sessions_json"), + tools=[DuckDuckGo()], + add_history_to_messages=True, +) +agent.print_response("How many people live in Canada?") +agent.print_response("What is their national anthem called?") diff --git a/cookbook/storage/yaml_storage.py b/cookbook/storage/yaml_storage.py new file mode 100644 index 000000000..70e894680 --- /dev/null +++ b/cookbook/storage/yaml_storage.py @@ -0,0 +1,13 @@ +"""Run `pip install duckduckgo-search openai` to install dependencies.""" + +from phi.agent import Agent +from phi.tools.duckduckgo import DuckDuckGo +from phi.storage.agent.yaml import YamlFileAgentStorage + +agent = Agent( + storage=YamlFileAgentStorage(dir_path="tmp/agent_sessions_yaml"), + tools=[DuckDuckGo()], + add_history_to_messages=True, +) +agent.print_response("How many people live in Canada?") +agent.print_response("What is their national anthem called?") diff --git a/cookbook/tools/confluence_tools.py b/cookbook/tools/confluence_tools.py new file mode 100644 index 000000000..6e989909d --- /dev/null +++ b/cookbook/tools/confluence_tools.py @@ -0,0 +1,22 @@ +from phi.agent import Agent +from phi.tools.confluence import ConfluenceTools + + +agent = Agent( + name="Confluence agent", + tools=[ConfluenceTools()], + show_tool_calls=True, + markdown=True, +) + +## getting space details +agent.print_response("How many spaces are there and what are their names?") + +## getting page_content +agent.print_response("What is the content present in page 'Large language model in LLM space'") + +## getting page details in a particular space +agent.print_response("Can you extract all the page names from 'LLM' space") + +## creating a new page in a space +agent.print_response("Can you create a new page named 'TESTING' in 'LLM' space") diff --git a/cookbook/tools/elevenlabs_tools.py b/cookbook/tools/elevenlabs_tools.py index ffd156233..5be4a708c 100644 --- a/cookbook/tools/elevenlabs_tools.py +++ b/cookbook/tools/elevenlabs_tools.py @@ -1,3 +1,7 @@ +""" +pip install elevenlabs +""" + from phi.agent import Agent from phi.model.openai import OpenAIChat from phi.tools.eleven_labs_tools import ElevenLabsTools @@ -23,3 +27,5 @@ ) audio_agent.print_response("Generate a very long audio of history of french revolution") + +audio_agent.print_response("Generate a kick sound effect") diff --git a/cookbook/workflows/startup_idea_validator.py b/cookbook/workflows/startup_idea_validator.py new file mode 100644 index 000000000..c4070f622 --- /dev/null +++ b/cookbook/workflows/startup_idea_validator.py @@ -0,0 +1,213 @@ +""" +1. Install dependencies using: `pip install openai exa_py sqlalchemy phidata` +2. Run the script using: `python cookbook/workflows/blog_post_generator.py` +""" + +import json +from typing import Optional, Iterator + +from pydantic import BaseModel, Field + +from phi.agent import Agent +from phi.model.openai import OpenAIChat +from phi.tools.googlesearch import GoogleSearch +from phi.workflow import Workflow, RunResponse, RunEvent +from phi.storage.workflow.sqlite import SqlWorkflowStorage +from phi.utils.pprint import pprint_run_response +from phi.utils.log import logger + + +class IdeaClarification(BaseModel): + originality: str = Field(..., description="Originality of the idea.") + mission: str = Field(..., description="Mission of the company.") + objectives: str = Field(..., description="Objectives of the company.") + + +class MarketResearch(BaseModel): + total_addressable_market: str = Field(..., description="Total addressable market (TAM).") + serviceable_available_market: str = Field(..., description="Serviceable available market (SAM).") + serviceable_obtainable_market: str = Field(..., description="Serviceable obtainable market (SOM).") + target_customer_segments: str = Field(..., description="Target customer segments.") + + +class StartupIdeaValidator(Workflow): + idea_clarifier_agent: Agent = Agent( + model=OpenAIChat(id="gpt-4o-mini"), + instructions=[ + "Given a user's startup idea, its your goal to refine that idea. ", + "Evaluates the originality of the idea by comparing it with existing concepts. ", + "Define the mission and objectives of the startup.", + ], + add_history_to_messages=True, + add_datetime_to_instructions=True, + response_model=IdeaClarification, + structured_outputs=True, + debug_mode=False, + ) + + market_research_agent: Agent = Agent( + model=OpenAIChat(id="gpt-4o-mini"), + tools=[GoogleSearch()], + instructions=[ + "You are provided with a startup idea and the company's mission and objectives. ", + "Estimate the total addressable market (TAM), serviceable available market (SAM), and serviceable obtainable market (SOM). ", + "Define target customer segments and their characteristics. ", + "Search the web for resources if you need to.", + ], + add_history_to_messages=True, + add_datetime_to_instructions=True, + response_model=MarketResearch, + structured_outputs=True, + debug_mode=False, + ) + + competitor_analysis_agent: Agent = Agent( + model=OpenAIChat(id="gpt-4o-mini"), + tools=[GoogleSearch()], + instructions=[ + "You are provided with a startup idea and some market research related to the idea. ", + "Identify existing competitors in the market. ", + "Perform Strengths, Weaknesses, Opportunities, and Threats (SWOT) analysis for each competitor. ", + "Assess the startup’s potential positioning relative to competitors.", + ], + add_history_to_messages=True, + add_datetime_to_instructions=True, + markdown=True, + debug_mode=False, + ) + + report_agent: Agent = Agent( + model=OpenAIChat(id="gpt-4o-mini"), + instructions=[ + "You are provided with a startup idea and other data about the idea. ", + "Summarise everything into a single report.", + ], + add_history_to_messages=True, + add_datetime_to_instructions=True, + markdown=True, + debug_mode=False, + ) + + def get_idea_clarification(self, startup_idea: str) -> Optional[IdeaClarification]: + try: + response: RunResponse = self.idea_clarifier_agent.run(startup_idea) + + # Check if we got a valid response + if not response or not response.content: + logger.warning("Empty Idea Clarification response") + # Check if the response is of the expected type + if not isinstance(response.content, IdeaClarification): + logger.warning("Invalid response type") + + return response.content + + except Exception as e: + logger.warning(f"Failed: {str(e)}") + + return None + + def get_market_research(self, startup_idea: str, idea_clarification: IdeaClarification) -> Optional[MarketResearch]: + agent_input = {"startup_idea": startup_idea, **idea_clarification.model_dump()} + + try: + response: RunResponse = self.market_research_agent.run(json.dumps(agent_input, indent=4)) + + # Check if we got a valid response + if not response or not response.content: + logger.warning("Empty Market Research response") + + # Check if the response is of the expected type + if not isinstance(response.content, MarketResearch): + logger.warning("Invalid response type") + + return response.content + + except Exception as e: + logger.warning(f"Failed: {str(e)}") + + return None + + def get_competitor_analysis(self, startup_idea: str, market_research: MarketResearch) -> Optional[str]: + agent_input = {"startup_idea": startup_idea, **market_research.model_dump()} + + try: + response: RunResponse = self.competitor_analysis_agent.run(json.dumps(agent_input, indent=4)) + + # Check if we got a valid response + if not response or not response.content: + logger.warning("Empty Competitor Analysis response") + + return response.content + + except Exception as e: + logger.warning(f"Failed: {str(e)}") + + return None + + def run(self, startup_idea: str) -> Iterator[RunResponse]: + logger.info(f"Generating a startup validation report for: {startup_idea}") + + # Clarify and quantify the idea + idea_clarification: Optional[IdeaClarification] = self.get_idea_clarification(startup_idea) + + if idea_clarification is None: + yield RunResponse( + event=RunEvent.workflow_completed, + content=f"Sorry, could not even clarify the idea: {startup_idea}", + ) + return + + # Do some market research + market_research: Optional[MarketResearch] = self.get_market_research(startup_idea, idea_clarification) + + if market_research is None: + yield RunResponse( + event=RunEvent.workflow_completed, + content="Market research failed", + ) + return + + competitor_analysis: Optional[str] = self.get_competitor_analysis(startup_idea, market_research) + + # Compile the final report + final_response: RunResponse = self.report_agent.run( + json.dumps( + { + "startup_idea": startup_idea, + **idea_clarification.model_dump(), + **market_research.model_dump(), + "competitor_analysis_report": competitor_analysis, + }, + indent=4, + ) + ) + + yield RunResponse(content=final_response.content, event=RunEvent.workflow_completed) + + +# Run the workflow if the script is executed directly +if __name__ == "__main__": + from rich.prompt import Prompt + + # Get idea from user + idea = Prompt.ask( + "[bold]What is your startup idea?[/bold]\n✨", + default="A marketplace for Christmas Ornaments made from leather", + ) + + # Convert the idea to a URL-safe string for use in session_id + url_safe_idea = idea.lower().replace(" ", "-") + + startup_idea_validator = StartupIdeaValidator( + description="Startup Idea Validator", + session_id=f"validate-startup-idea-{url_safe_idea}", + storage=SqlWorkflowStorage( + table_name="validate_startup_ideas_workflow", + db_file="tmp/workflows.db", + ), + debug_mode=True + ) + + final_report: Iterator[RunResponse] = startup_idea_validator.run(startup_idea=idea) + + pprint_run_response(final_report, markdown=True) diff --git a/phi/agent/session.py b/phi/agent/session.py index ee50c6c3e..3d2e8254f 100644 --- a/phi/agent/session.py +++ b/phi/agent/session.py @@ -27,17 +27,25 @@ class AgentSession(BaseModel): model_config = ConfigDict(from_attributes=True) def monitoring_data(self) -> Dict[str, Any]: - monitoring_data = self.model_dump(exclude={"memory"}) # Google Gemini adds a "parts" field to the messages, which is not serializable - # If there are runs in the memory, remove the "parts" from the messages - if self.memory is not None and "runs" in self.memory: - _runs = self.memory["runs"] - if len(_runs) > 0: - for _run in _runs: - if "messages" in _run: - for m in _run["messages"]: - if isinstance(m, dict): - m.pop("parts", None) + # If the provider is Google, remove the "parts" from the messages + if self.agent_data is not None: + if self.agent_data.get("model", {}).get("provider") == "Google" and self.memory is not None: + # Remove parts from runs' response messages + if "runs" in self.memory: + for _run in self.memory["runs"]: + if "response" in _run and "messages" in _run["response"]: + for m in _run["response"]["messages"]: + if isinstance(m, dict): + m.pop("parts", None) + + # Remove parts from top-level memory messages + if "messages" in self.memory: + for m in self.memory["messages"]: + if isinstance(m, dict): + m.pop("parts", None) + + monitoring_data = self.model_dump() return monitoring_data def telemetry_data(self) -> Dict[str, Any]: diff --git a/phi/document/chunking/recursive.py b/phi/document/chunking/recursive.py index 662a9218c..47c552294 100644 --- a/phi/document/chunking/recursive.py +++ b/phi/document/chunking/recursive.py @@ -38,6 +38,7 @@ def chunk(self, document: Document) -> List[Document]: chunk_id = None if document.id: chunk_id = f"{document.id}_{chunk_number}" + chunk_number += 1 meta_data["chunk_size"] = len(chunk) chunks.append(Document(id=chunk_id, name=document.name, meta_data=meta_data, content=chunk)) diff --git a/phi/memory/summarizer.py b/phi/memory/summarizer.py index 2b771374e..6b4799add 100644 --- a/phi/memory/summarizer.py +++ b/phi/memory/summarizer.py @@ -44,13 +44,15 @@ def get_system_message(self, messages_for_summarization: List[Dict[str, str]]) - Conversation: """) - - system_prompt += "\n".join( - [ - f"User: {message_pair['user']}\nAssistant: {message_pair['assistant']}" - for message_pair in messages_for_summarization - ] - ) + conversation = [] + for message_pair in messages_for_summarization: + conversation.append(f"User: {message_pair['user']}") + if "assistant" in message_pair: + conversation.append(f"Assistant: {message_pair['assistant']}") + elif "model" in message_pair: + conversation.append(f"Assistant: {message_pair['model']}") + + system_prompt += "\n".join(conversation) if not self.use_structured_outputs: system_prompt += "\n\nProvide your output as a JSON containing the following fields:" diff --git a/phi/model/content.py b/phi/model/content.py index 228c0dda9..77a1bcdf0 100644 --- a/phi/model/content.py +++ b/phi/model/content.py @@ -24,6 +24,7 @@ class Audio(Media): url: Optional[str] = None # Remote location for file base64_audio: Optional[str] = None # Base64-encoded audio data length: Optional[str] = None + mime_type: Optional[str] = None @model_validator(mode="before") def validate_exclusive_audio(cls, data: Any): diff --git a/phi/model/google/gemini.py b/phi/model/google/gemini.py index b2de87c2e..878fcf80c 100644 --- a/phi/model/google/gemini.py +++ b/phi/model/google/gemini.py @@ -155,7 +155,7 @@ def format_messages(self, messages: List[Message]) -> List[Dict[str, Any]]: content = message.content # Initialize message_parts to be used for Gemini message_parts: List[Any] = [] - if not content or message.role in ["tool", "model"]: + if (not content or message.role in ["tool", "model"]) and hasattr(message, "parts"): message_parts = message.parts # type: ignore else: if isinstance(content, str): diff --git a/phi/model/ollama/chat.py b/phi/model/ollama/chat.py index 4c5c809da..acc744454 100644 --- a/phi/model/ollama/chat.py +++ b/phi/model/ollama/chat.py @@ -134,6 +134,11 @@ def request_kwargs(self) -> Dict[str, Any]: request_params["keep_alive"] = self.keep_alive if self.tools is not None: request_params["tools"] = self.get_tools_for_api() + # Ensure types are valid strings + for tool in request_params["tools"]: + for prop, obj in tool["function"]["parameters"]["properties"].items(): + if isinstance(obj["type"], list): + obj["type"] = obj["type"][0] if self.request_params is not None: request_params.update(self.request_params) return request_params diff --git a/phi/model/openai/chat.py b/phi/model/openai/chat.py index 041ab5fc9..7b6256cb9 100644 --- a/phi/model/openai/chat.py +++ b/phi/model/openai/chat.py @@ -925,7 +925,6 @@ async def aresponse_stream(self, messages: List[Message]) -> Any: # -*- Generate response metrics.response_timer.start() async for response in self.ainvoke_stream(messages=messages): - if response.choices and len(response.choices) > 0: metrics.completion_tokens += 1 if metrics.completion_tokens == 1: diff --git a/phi/playground/router.py b/phi/playground/router.py index a5e1663dd..0cf81bdba 100644 --- a/phi/playground/router.py +++ b/phi/playground/router.py @@ -92,7 +92,7 @@ def chat_response_streamer( run_response = agent.run(message, images=images, stream=True, stream_intermediate_steps=True) for run_response_chunk in run_response: run_response_chunk = cast(RunResponse, run_response_chunk) - yield run_response_chunk.model_dump_json() + yield run_response_chunk.to_json() def process_image(file: UploadFile) -> List[Union[str, Dict]]: content = file.file.read() @@ -399,7 +399,7 @@ async def chat_response_streamer( run_response = await agent.arun(message, images=images, stream=True, stream_intermediate_steps=True) async for run_response_chunk in run_response: run_response_chunk = cast(RunResponse, run_response_chunk) - yield run_response_chunk.model_dump_json() + yield run_response_chunk.to_json() async def process_image(file: UploadFile) -> List[Union[str, Dict]]: content = file.file.read() diff --git a/phi/run/response.py b/phi/run/response.py index 3fc0c21d2..13e0fee5f 100644 --- a/phi/run/response.py +++ b/phi/run/response.py @@ -1,3 +1,4 @@ +import json from time import time from enum import Enum from typing import Optional, Any, Dict, List @@ -58,6 +59,21 @@ class RunResponse(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) + def to_json(self) -> str: + _dict = self.model_dump( + exclude_none=True, + exclude={"messages"}, + ) + if self.messages is not None: + _dict["messages"] = [ + m.model_dump( + exclude_none=True, + exclude={"parts"}, # Exclude what Gemini adds + ) + for m in self.messages + ] + return json.dumps(_dict, indent=2) + def to_dict(self) -> Dict[str, Any]: _dict = self.model_dump( exclude_none=True, diff --git a/phi/storage/agent/json.py b/phi/storage/agent/json.py new file mode 100644 index 000000000..b39a296ef --- /dev/null +++ b/phi/storage/agent/json.py @@ -0,0 +1,89 @@ +import json +import time +from pathlib import Path +from typing import Union, Optional, List + +from phi.storage.agent.base import AgentStorage +from phi.agent import AgentSession +from phi.utils.log import logger + + +class JsonFileAgentStorage(AgentStorage): + def __init__(self, dir_path: Union[str, Path]): + self.dir_path = Path(dir_path) + self.dir_path.mkdir(parents=True, exist_ok=True) + + def serialize(self, data: dict) -> str: + return json.dumps(data, ensure_ascii=False, indent=4) + + def deserialize(self, data: str) -> dict: + return json.loads(data) + + def create(self) -> None: + """Create the storage if it doesn't exist.""" + if not self.dir_path.exists(): + self.dir_path.mkdir(parents=True, exist_ok=True) + + def read(self, session_id: str, user_id: Optional[str] = None) -> Optional[AgentSession]: + """Read an AgentSession from storage.""" + try: + with open(self.dir_path / f"{session_id}.json", "r", encoding="utf-8") as f: + data = self.deserialize(f.read()) + if user_id and data["user_id"] != user_id: + return None + return AgentSession.model_validate(data) + except FileNotFoundError: + return None + + def get_all_session_ids(self, user_id: Optional[str] = None, agent_id: Optional[str] = None) -> List[str]: + """Get all session IDs, optionally filtered by user_id and/or agent_id.""" + session_ids = [] + for file in self.dir_path.glob("*.json"): + with open(file, "r", encoding="utf-8") as f: + data = self.deserialize(f.read()) + if (not user_id or data["user_id"] == user_id) and (not agent_id or data["agent_id"] == agent_id): + session_ids.append(data["session_id"]) + return session_ids + + def get_all_sessions(self, user_id: Optional[str] = None, agent_id: Optional[str] = None) -> List[AgentSession]: + """Get all sessions, optionally filtered by user_id and/or agent_id.""" + sessions = [] + for file in self.dir_path.glob("*.json"): + with open(file, "r", encoding="utf-8") as f: + data = self.deserialize(f.read()) + if (not user_id or data["user_id"] == user_id) and (not agent_id or data["agent_id"] == agent_id): + sessions.append(AgentSession.model_validate(data)) + return sessions + + def upsert(self, session: AgentSession) -> Optional[AgentSession]: + """Insert or update an AgentSession in storage.""" + try: + data = session.model_dump() + data["updated_at"] = int(time.time()) + if "created_at" not in data: + data["created_at"] = data["updated_at"] + + with open(self.dir_path / f"{session.session_id}.json", "w", encoding="utf-8") as f: + f.write(self.serialize(data)) + return session + except Exception as e: + logger.error(f"Error upserting session: {e}") + return None + + def delete_session(self, session_id: Optional[str] = None): + """Delete a session from storage.""" + if session_id is None: + return + try: + (self.dir_path / f"{session_id}.json").unlink(missing_ok=True) + except Exception as e: + logger.error(f"Error deleting session: {e}") + + def drop(self) -> None: + """Drop all sessions from storage.""" + for file in self.dir_path.glob("*.json"): + file.unlink() + + def upgrade_schema(self) -> None: + """Upgrade the schema of the storage.""" + pass diff --git a/phi/storage/agent/yaml.py b/phi/storage/agent/yaml.py new file mode 100644 index 000000000..8c855e311 --- /dev/null +++ b/phi/storage/agent/yaml.py @@ -0,0 +1,89 @@ +import yaml +import time +from pathlib import Path +from typing import Union, Optional, List + +from phi.storage.agent.base import AgentStorage +from phi.agent import AgentSession +from phi.utils.log import logger + + +class YamlFileAgentStorage(AgentStorage): + def __init__(self, dir_path: Union[str, Path]): + self.dir_path = Path(dir_path) + self.dir_path.mkdir(parents=True, exist_ok=True) + + def serialize(self, data: dict) -> str: + return yaml.dump(data, default_flow_style=False) + + def deserialize(self, data: str) -> dict: + return yaml.safe_load(data) + + def create(self) -> None: + """Create the storage if it doesn't exist.""" + if not self.dir_path.exists(): + self.dir_path.mkdir(parents=True, exist_ok=True) + + def read(self, session_id: str, user_id: Optional[str] = None) -> Optional[AgentSession]: + """Read an AgentSession from storage.""" + try: + with open(self.dir_path / f"{session_id}.yaml", "r", encoding="utf-8") as f: + data = self.deserialize(f.read()) + if user_id and data["user_id"] != user_id: + return None + return AgentSession.model_validate(data) + except FileNotFoundError: + return None + + def get_all_session_ids(self, user_id: Optional[str] = None, agent_id: Optional[str] = None) -> List[str]: + """Get all session IDs, optionally filtered by user_id and/or agent_id.""" + session_ids = [] + for file in self.dir_path.glob("*.yaml"): + with open(file, "r", encoding="utf-8") as f: + data = self.deserialize(f.read()) + if (not user_id or data["user_id"] == user_id) and (not agent_id or data["agent_id"] == agent_id): + session_ids.append(data["session_id"]) + return session_ids + + def get_all_sessions(self, user_id: Optional[str] = None, agent_id: Optional[str] = None) -> List[AgentSession]: + """Get all sessions, optionally filtered by user_id and/or agent_id.""" + sessions = [] + for file in self.dir_path.glob("*.yaml"): + with open(file, "r", encoding="utf-8") as f: + data = self.deserialize(f.read()) + if (not user_id or data["user_id"] == user_id) and (not agent_id or data["agent_id"] == agent_id): + sessions.append(AgentSession.model_validate(data)) + return sessions + + def upsert(self, session: AgentSession) -> Optional[AgentSession]: + """Insert or update an AgentSession in storage.""" + try: + data = session.model_dump() + data["updated_at"] = int(time.time()) + if "created_at" not in data: + data["created_at"] = data["updated_at"] + + with open(self.dir_path / f"{session.session_id}.yaml", "w", encoding="utf-8") as f: + f.write(self.serialize(data)) + return session + except Exception as e: + logger.error(f"Error upserting session: {e}") + return None + + def delete_session(self, session_id: Optional[str] = None): + """Delete a session from storage.""" + if session_id is None: + return + try: + (self.dir_path / f"{session_id}.yaml").unlink(missing_ok=True) + except Exception as e: + logger.error(f"Error deleting session: {e}") + + def drop(self) -> None: + """Drop all sessions from storage.""" + for file in self.dir_path.glob("*.yaml"): + file.unlink() + + def upgrade_schema(self) -> None: + """Upgrade the schema of the storage.""" + pass diff --git a/phi/tools/confluence.py b/phi/tools/confluence.py new file mode 100644 index 000000000..e318a34cc --- /dev/null +++ b/phi/tools/confluence.py @@ -0,0 +1,174 @@ +from phi.tools import Toolkit +from phi.utils.log import logger +from typing import Optional +from os import getenv +import json + +try: + from atlassian import Confluence +except (ModuleNotFoundError, ImportError): + raise ImportError("atlassian-python-api not install . Please install using `pip install atlassian-python-api`") + + +class ConfluenceTools(Toolkit): + def __init__( + self, + username: Optional[str] = None, + password: Optional[str] = None, + url: Optional[str] = None, + api_key: Optional[str] = None, + ): + """Initialize Confluence Tools with authentication credentials. + + Args: + username (str, optional): Confluence username. Defaults to None. + password (str, optional): Confluence password. Defaults to None. + url (str, optional): Confluence instance URL. Defaults to None. + api_key (str, optional): Confluence API key. Defaults to None. + + Notes: + Credentials can be provided either through method arguments or environment variables: + - CONFLUENCE_URL + - CONFLUENCE_USERNAME + - CONFLUENCE_API_KEY + """ + + super().__init__(name="confluence_tools") + self.url = url or getenv("CONFLUENCE_URL") + self.username = username or getenv("CONFLUENCE_USERNAME") + self.password = api_key or getenv("CONFLUENCE_API_KEY") or password or getenv("CONFLUENCE_PASSWORD") + + if not self.url: + logger.error( + "Confluence URL not provided. Pass it in the constructor or set CONFLUENCE_URL in environment variable" + ) + + if not self.username: + logger.error( + "Confluence username not provided. Pass it in the constructor or set CONFLUENCE_USERNAME in environment variable" + ) + + if not self.password: + logger.error("Confluence API KEY or password not provided") + + self.confluence = Confluence(url=self.url, username=self.username, password=self.password) + + self.register(self.get_page_content) + self.register(self.get_space_key) + self.register(self.create_page) + self.register(self.update_page) + self.register(self.get_all_space_detail) + self.register(self.get_all_page_from_space) + + def get_page_content(self, space_name: str, page_title: str, expand: Optional[str] = "body.storage"): + """Retrieve the content of a specific page in a Confluence space. + + Args: + space_name (str): Name of the Confluence space. + page_title (str): Title of the page to retrieve. + expand (str, optional): Fields to expand in the page response. Defaults to "body.storage". + + Returns: + str: JSON-encoded page content or error message. + """ + try: + logger.info(f"Retrieving page content from space '{space_name}'") + key = self.get_space_key(space_name=space_name) + page = self.confluence.get_page_by_title(key, page_title, expand=expand) + if page: + logger.info(f"Successfully retrieved page '{page_title}' from space '{space_name}'") + return json.dumps(page) + + logger.warning(f"Page '{page_title}' not found in space '{space_name}'") + return json.dumps({"error": f"Page '{page_title}' not found in space '{space_name}'"}) + + except Exception as e: + logger.error(f"Error retrieving page '{page_title}': {e}") + return json.dumps({"error": str(e)}) + + def get_all_space_detail(self): + """Retrieve details about all Confluence spaces. + + Returns: + str: List of space details as a string. + """ + logger.info("Retrieving details for all Confluence spaces") + results = self.confluence.get_all_spaces()["results"] + return str(results) + + def get_space_key(self, space_name: str): + """Get the space key for a particular Confluence space. + + Args: + space_name (str): Name of the space whose key is required. + + Returns: + str: Space key or "No space found" if space doesn't exist. + """ + result = self.confluence.get_all_spaces() + spaces = result["results"] + + for space in spaces: + if space["name"] == space_name: + logger.info(f"Found space key for '{space_name}'") + return space["key"] + + logger.warning(f"No space named {space_name} found") + return "No space found" + + def get_all_page_from_space(self, space_name: str): + """Retrieve all pages from a specific Confluence space. + + Args: + space_name (str): Name of the Confluence space. + + Returns: + list: Details of pages in the specified space. + """ + logger.info(f"Retrieving all pages from space '{space_name}'") + space_key = self.get_space_key(space_name) + page_details = self.confluence.get_all_pages_from_space( + space_key, status=None, expand=None, content_type="page" + ) + page_details = str([{"id": page["id"], "title": page["title"]} for page in page_details]) + return page_details + + def create_page(self, space_name: str, title: str, body: str, parent_id: Optional[str] = None) -> str: + """Create a new page in Confluence. + + Args: + space_name (str): Name of the Confluence space. + title (str): Title of the new page. + body (str): Content of the new page. + parent_id (str, optional): ID of the parent page if creating a child page. Defaults to None. + + Returns: + str: JSON-encoded page ID and title or error message. + """ + try: + space_key = self.get_space_key(space_name=space_name) + page = self.confluence.create_page(space_key, title, body, parent_id=parent_id) + logger.info(f"Page created: {title} with ID {page['id']}") + return json.dumps({"id": page["id"], "title": title}) + except Exception as e: + logger.error(f"Error creating page '{title}': {e}") + return json.dumps({"error": str(e)}) + + def update_page(self, page_id: str, title: str, body: str) -> str: + """Update an existing Confluence page. + + Args: + page_id (str): ID of the page to update. + title (str): New title for the page. + body (str): Updated content for the page. + + Returns: + str: JSON-encoded status and ID of the updated page or error message. + """ + try: + updated_page = self.confluence.update_page(page_id, title, body) + logger.info(f"Page updated: {title} with ID {updated_page['id']}") + return json.dumps({"status": "success", "id": updated_page["id"]}) + except Exception as e: + logger.error(f"Error updating page '{title}': {e}") + return json.dumps({"error": str(e)}) diff --git a/phi/tools/eleven_labs_tools.py b/phi/tools/eleven_labs_tools.py index 8c4d73320..109b6809f 100644 --- a/phi/tools/eleven_labs_tools.py +++ b/phi/tools/eleven_labs_tools.py @@ -1,6 +1,3 @@ -""" -pip install elevenlabs -""" from base64 import b64encode from io import BytesIO @@ -20,7 +17,7 @@ except ImportError: raise ImportError("`elevenlabs` not installed. Please install using `pip install elevenlabs`") -OutputFormat = Literal[ +ElevenLabsAudioOutputFormat = Literal[ "mp3_22050_32", # mp3 with 22.05kHz sample rate at 32kbps "mp3_44100_32", # mp3 with 44.1kHz sample rate at 32kbps "mp3_44100_64", # mp3 with 44.1kHz sample rate at 64kbps @@ -42,7 +39,7 @@ def __init__( api_key: Optional[str] = None, target_directory: Optional[str] = None, model_id: str = "eleven_multilingual_v2", - output_format: OutputFormat = "mp3_44100_64", + output_format: ElevenLabsAudioOutputFormat = "mp3_44100_64", ): super().__init__(name="elevenlabs_tools") @@ -66,7 +63,11 @@ def __init__( def get_voices(self) -> str: """ +<<<<<<< HEAD Use this function to generate sound effect audio from a text prompt. +======= + Use this function to get all the voices available. +>>>>>>> 48addb496442892c21382ff27d03578b3f9d7ac6 Returns: result (list): A list of voices that have an ID, name and description. @@ -122,22 +123,19 @@ def _process_audio(self, audio_generator: Iterator[bytes]) -> str: return base64_audio - def generate_sound_effect(self, agent: Agent, prompt: str, voice_id: Optional[str] = None) -> str: + def generate_sound_effect(self, agent: Agent, prompt: str, duration_seconds: Optional[float] = None) -> str: """ Use this function to generate sound effect audio from a text prompt. Args: prompt (str): Text to generate audio from. - voice_id (str): The ID of the voice to use for audio generation. + duration_seconds (Optional[float]): Duration in seconds to generate audio from. Returns: str: Return the path to the generated audio file. """ try: audio_generator = self.eleven_labs_client.text_to_sound_effects.convert( - voice_id=voice_id or self.voice_id, - model_id=self.model_id, - text=prompt, - output_format=self.output_format, + text=prompt, duration_seconds=duration_seconds ) base64_audio = self._process_audio(audio_generator) @@ -163,7 +161,11 @@ def text_to_speech(self, agent: Agent, prompt: str, voice_id: Optional[str] = No Args: prompt (str): Text to generate audio from. +<<<<<<< HEAD voice_id (str): The ID of the voice to use for audio generation. +======= + voice_id (Optional[str]): The ID of the voice to use for audio generation. Uses default if none is specified. +>>>>>>> 48addb496442892c21382ff27d03578b3f9d7ac6 Returns: str: Return the path to the generated audio file. """ @@ -182,7 +184,7 @@ def text_to_speech(self, agent: Agent, prompt: str, voice_id: Optional[str] = No Audio( id=str(uuid4()), base64_audio=base64_audio, - mime_type="audio/mpeg", + mime_type="audio/mpeg", ) ) diff --git a/phi/tools/function.py b/phi/tools/function.py index 89520833e..a8174e147 100644 --- a/phi/tools/function.py +++ b/phi/tools/function.py @@ -158,7 +158,6 @@ def process_entrypoint(self, strict: bool = False): # Get JSON schema for parameters only parameters = get_json_schema(type_hints=param_type_hints, strict=strict) - # If strict=True mark all fields as required # See: https://platform.openai.com/docs/guides/structured-outputs/supported-schemas#all-fields-must-be-required if strict: diff --git a/pyproject.toml b/pyproject.toml index e6000c9e8..1ca8e856e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "phidata" -version = "2.7.3" +version = "2.7.4" description = "Build multi-modal Agents with memory, knowledge and tools." requires-python = ">=3.7" readme = "README.md" @@ -98,6 +98,7 @@ module = [ "anthropic.*", "apify_client.*", "arxiv.*", + "atlassian.*", "boto3.*", "botocore.*", "bs4.*",