agno 2.0.0rc2__py3-none-any.whl → 2.3.0__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.
- agno/agent/agent.py +6009 -2874
- agno/api/api.py +2 -0
- agno/api/os.py +1 -1
- agno/culture/__init__.py +3 -0
- agno/culture/manager.py +956 -0
- agno/db/async_postgres/__init__.py +3 -0
- agno/db/base.py +385 -6
- agno/db/dynamo/dynamo.py +388 -81
- agno/db/dynamo/schemas.py +47 -10
- agno/db/dynamo/utils.py +63 -4
- agno/db/firestore/firestore.py +435 -64
- agno/db/firestore/schemas.py +11 -0
- agno/db/firestore/utils.py +102 -4
- agno/db/gcs_json/gcs_json_db.py +384 -42
- agno/db/gcs_json/utils.py +60 -26
- agno/db/in_memory/in_memory_db.py +351 -66
- agno/db/in_memory/utils.py +60 -2
- agno/db/json/json_db.py +339 -48
- agno/db/json/utils.py +60 -26
- agno/db/migrations/manager.py +199 -0
- agno/db/migrations/v1_to_v2.py +510 -37
- agno/db/migrations/versions/__init__.py +0 -0
- agno/db/migrations/versions/v2_3_0.py +938 -0
- agno/db/mongo/__init__.py +15 -1
- agno/db/mongo/async_mongo.py +2036 -0
- agno/db/mongo/mongo.py +653 -76
- agno/db/mongo/schemas.py +13 -0
- agno/db/mongo/utils.py +80 -8
- agno/db/mysql/mysql.py +687 -25
- agno/db/mysql/schemas.py +61 -37
- agno/db/mysql/utils.py +60 -2
- agno/db/postgres/__init__.py +2 -1
- agno/db/postgres/async_postgres.py +2001 -0
- agno/db/postgres/postgres.py +676 -57
- agno/db/postgres/schemas.py +43 -18
- agno/db/postgres/utils.py +164 -2
- agno/db/redis/redis.py +344 -38
- agno/db/redis/schemas.py +18 -0
- agno/db/redis/utils.py +60 -2
- agno/db/schemas/__init__.py +2 -1
- agno/db/schemas/culture.py +120 -0
- agno/db/schemas/memory.py +13 -0
- agno/db/singlestore/schemas.py +26 -1
- agno/db/singlestore/singlestore.py +687 -53
- agno/db/singlestore/utils.py +60 -2
- agno/db/sqlite/__init__.py +2 -1
- agno/db/sqlite/async_sqlite.py +2371 -0
- agno/db/sqlite/schemas.py +24 -0
- agno/db/sqlite/sqlite.py +774 -85
- agno/db/sqlite/utils.py +168 -5
- agno/db/surrealdb/__init__.py +3 -0
- agno/db/surrealdb/metrics.py +292 -0
- agno/db/surrealdb/models.py +309 -0
- agno/db/surrealdb/queries.py +71 -0
- agno/db/surrealdb/surrealdb.py +1361 -0
- agno/db/surrealdb/utils.py +147 -0
- agno/db/utils.py +50 -22
- agno/eval/accuracy.py +50 -43
- agno/eval/performance.py +6 -3
- agno/eval/reliability.py +6 -3
- agno/eval/utils.py +33 -16
- agno/exceptions.py +68 -1
- agno/filters.py +354 -0
- agno/guardrails/__init__.py +6 -0
- agno/guardrails/base.py +19 -0
- agno/guardrails/openai.py +144 -0
- agno/guardrails/pii.py +94 -0
- agno/guardrails/prompt_injection.py +52 -0
- agno/integrations/discord/client.py +1 -0
- agno/knowledge/chunking/agentic.py +13 -10
- agno/knowledge/chunking/fixed.py +1 -1
- agno/knowledge/chunking/semantic.py +40 -8
- agno/knowledge/chunking/strategy.py +59 -15
- agno/knowledge/embedder/aws_bedrock.py +9 -4
- agno/knowledge/embedder/azure_openai.py +54 -0
- agno/knowledge/embedder/base.py +2 -0
- agno/knowledge/embedder/cohere.py +184 -5
- agno/knowledge/embedder/fastembed.py +1 -1
- agno/knowledge/embedder/google.py +79 -1
- agno/knowledge/embedder/huggingface.py +9 -4
- agno/knowledge/embedder/jina.py +63 -0
- agno/knowledge/embedder/mistral.py +78 -11
- agno/knowledge/embedder/nebius.py +1 -1
- agno/knowledge/embedder/ollama.py +13 -0
- agno/knowledge/embedder/openai.py +37 -65
- agno/knowledge/embedder/sentence_transformer.py +8 -4
- agno/knowledge/embedder/vllm.py +262 -0
- agno/knowledge/embedder/voyageai.py +69 -16
- agno/knowledge/knowledge.py +595 -187
- agno/knowledge/reader/base.py +9 -2
- agno/knowledge/reader/csv_reader.py +8 -10
- agno/knowledge/reader/docx_reader.py +5 -6
- agno/knowledge/reader/field_labeled_csv_reader.py +290 -0
- agno/knowledge/reader/json_reader.py +6 -5
- agno/knowledge/reader/markdown_reader.py +13 -13
- agno/knowledge/reader/pdf_reader.py +43 -68
- agno/knowledge/reader/pptx_reader.py +101 -0
- agno/knowledge/reader/reader_factory.py +51 -6
- agno/knowledge/reader/s3_reader.py +3 -15
- agno/knowledge/reader/tavily_reader.py +194 -0
- agno/knowledge/reader/text_reader.py +13 -13
- agno/knowledge/reader/web_search_reader.py +2 -43
- agno/knowledge/reader/website_reader.py +43 -25
- agno/knowledge/reranker/__init__.py +3 -0
- agno/knowledge/types.py +9 -0
- agno/knowledge/utils.py +20 -0
- agno/media.py +339 -266
- agno/memory/manager.py +336 -82
- agno/models/aimlapi/aimlapi.py +2 -2
- agno/models/anthropic/claude.py +183 -37
- agno/models/aws/bedrock.py +52 -112
- agno/models/aws/claude.py +33 -1
- agno/models/azure/ai_foundry.py +33 -15
- agno/models/azure/openai_chat.py +25 -8
- agno/models/base.py +1011 -566
- agno/models/cerebras/cerebras.py +19 -13
- agno/models/cerebras/cerebras_openai.py +8 -5
- agno/models/cohere/chat.py +27 -1
- agno/models/cometapi/__init__.py +5 -0
- agno/models/cometapi/cometapi.py +57 -0
- agno/models/dashscope/dashscope.py +1 -0
- agno/models/deepinfra/deepinfra.py +2 -2
- agno/models/deepseek/deepseek.py +2 -2
- agno/models/fireworks/fireworks.py +2 -2
- agno/models/google/gemini.py +110 -37
- agno/models/groq/groq.py +28 -11
- agno/models/huggingface/huggingface.py +2 -1
- agno/models/internlm/internlm.py +2 -2
- agno/models/langdb/langdb.py +4 -4
- agno/models/litellm/chat.py +18 -1
- agno/models/litellm/litellm_openai.py +2 -2
- agno/models/llama_cpp/__init__.py +5 -0
- agno/models/llama_cpp/llama_cpp.py +22 -0
- agno/models/message.py +143 -4
- agno/models/meta/llama.py +27 -10
- agno/models/meta/llama_openai.py +5 -17
- agno/models/nebius/nebius.py +6 -6
- agno/models/nexus/__init__.py +3 -0
- agno/models/nexus/nexus.py +22 -0
- agno/models/nvidia/nvidia.py +2 -2
- agno/models/ollama/chat.py +60 -6
- agno/models/openai/chat.py +102 -43
- agno/models/openai/responses.py +103 -106
- agno/models/openrouter/openrouter.py +41 -3
- agno/models/perplexity/perplexity.py +4 -5
- agno/models/portkey/portkey.py +3 -3
- agno/models/requesty/__init__.py +5 -0
- agno/models/requesty/requesty.py +52 -0
- agno/models/response.py +81 -5
- agno/models/sambanova/sambanova.py +2 -2
- agno/models/siliconflow/__init__.py +5 -0
- agno/models/siliconflow/siliconflow.py +25 -0
- agno/models/together/together.py +2 -2
- agno/models/utils.py +254 -8
- agno/models/vercel/v0.py +2 -2
- agno/models/vertexai/__init__.py +0 -0
- agno/models/vertexai/claude.py +96 -0
- agno/models/vllm/vllm.py +1 -0
- agno/models/xai/xai.py +3 -2
- agno/os/app.py +543 -175
- agno/os/auth.py +24 -14
- agno/os/config.py +1 -0
- agno/os/interfaces/__init__.py +1 -0
- agno/os/interfaces/a2a/__init__.py +3 -0
- agno/os/interfaces/a2a/a2a.py +42 -0
- agno/os/interfaces/a2a/router.py +250 -0
- agno/os/interfaces/a2a/utils.py +924 -0
- agno/os/interfaces/agui/agui.py +23 -7
- agno/os/interfaces/agui/router.py +27 -3
- agno/os/interfaces/agui/utils.py +242 -142
- agno/os/interfaces/base.py +6 -2
- agno/os/interfaces/slack/router.py +81 -23
- agno/os/interfaces/slack/slack.py +29 -14
- agno/os/interfaces/whatsapp/router.py +11 -4
- agno/os/interfaces/whatsapp/whatsapp.py +14 -7
- agno/os/mcp.py +111 -54
- agno/os/middleware/__init__.py +7 -0
- agno/os/middleware/jwt.py +233 -0
- agno/os/router.py +556 -139
- agno/os/routers/evals/evals.py +71 -34
- agno/os/routers/evals/schemas.py +31 -31
- agno/os/routers/evals/utils.py +6 -5
- agno/os/routers/health.py +31 -0
- agno/os/routers/home.py +52 -0
- agno/os/routers/knowledge/knowledge.py +185 -38
- agno/os/routers/knowledge/schemas.py +82 -22
- agno/os/routers/memory/memory.py +158 -53
- agno/os/routers/memory/schemas.py +20 -16
- agno/os/routers/metrics/metrics.py +20 -8
- agno/os/routers/metrics/schemas.py +16 -16
- agno/os/routers/session/session.py +499 -38
- agno/os/schema.py +308 -198
- agno/os/utils.py +401 -41
- agno/reasoning/anthropic.py +80 -0
- agno/reasoning/azure_ai_foundry.py +2 -2
- agno/reasoning/deepseek.py +2 -2
- agno/reasoning/default.py +3 -1
- agno/reasoning/gemini.py +73 -0
- agno/reasoning/groq.py +2 -2
- agno/reasoning/ollama.py +2 -2
- agno/reasoning/openai.py +7 -2
- agno/reasoning/vertexai.py +76 -0
- agno/run/__init__.py +6 -0
- agno/run/agent.py +266 -112
- agno/run/base.py +53 -24
- agno/run/team.py +252 -111
- agno/run/workflow.py +156 -45
- agno/session/agent.py +105 -89
- agno/session/summary.py +65 -25
- agno/session/team.py +176 -96
- agno/session/workflow.py +406 -40
- agno/team/team.py +3854 -1692
- agno/tools/brightdata.py +3 -3
- agno/tools/cartesia.py +3 -5
- agno/tools/dalle.py +9 -8
- agno/tools/decorator.py +4 -2
- agno/tools/desi_vocal.py +2 -2
- agno/tools/duckduckgo.py +15 -11
- agno/tools/e2b.py +20 -13
- agno/tools/eleven_labs.py +26 -28
- agno/tools/exa.py +21 -16
- agno/tools/fal.py +4 -4
- agno/tools/file.py +153 -23
- agno/tools/file_generation.py +350 -0
- agno/tools/firecrawl.py +4 -4
- agno/tools/function.py +257 -37
- agno/tools/giphy.py +2 -2
- agno/tools/gmail.py +238 -14
- agno/tools/google_drive.py +270 -0
- agno/tools/googlecalendar.py +36 -8
- agno/tools/googlesheets.py +20 -5
- agno/tools/jira.py +20 -0
- agno/tools/knowledge.py +3 -3
- agno/tools/lumalab.py +3 -3
- agno/tools/mcp/__init__.py +10 -0
- agno/tools/mcp/mcp.py +331 -0
- agno/tools/mcp/multi_mcp.py +347 -0
- agno/tools/mcp/params.py +24 -0
- agno/tools/mcp_toolbox.py +284 -0
- agno/tools/mem0.py +11 -17
- agno/tools/memori.py +1 -53
- agno/tools/memory.py +419 -0
- agno/tools/models/azure_openai.py +2 -2
- agno/tools/models/gemini.py +3 -3
- agno/tools/models/groq.py +3 -5
- agno/tools/models/nebius.py +7 -7
- agno/tools/models_labs.py +25 -15
- agno/tools/notion.py +204 -0
- agno/tools/openai.py +4 -9
- agno/tools/opencv.py +3 -3
- agno/tools/parallel.py +314 -0
- agno/tools/replicate.py +7 -7
- agno/tools/scrapegraph.py +58 -31
- agno/tools/searxng.py +2 -2
- agno/tools/serper.py +2 -2
- agno/tools/slack.py +18 -3
- agno/tools/spider.py +2 -2
- agno/tools/tavily.py +146 -0
- agno/tools/whatsapp.py +1 -1
- agno/tools/workflow.py +278 -0
- agno/tools/yfinance.py +12 -11
- agno/utils/agent.py +820 -0
- agno/utils/audio.py +27 -0
- agno/utils/common.py +90 -1
- agno/utils/events.py +222 -7
- agno/utils/gemini.py +181 -23
- agno/utils/hooks.py +57 -0
- agno/utils/http.py +111 -0
- agno/utils/knowledge.py +12 -5
- agno/utils/log.py +1 -0
- agno/utils/mcp.py +95 -5
- agno/utils/media.py +188 -10
- agno/utils/merge_dict.py +22 -1
- agno/utils/message.py +60 -0
- agno/utils/models/claude.py +40 -11
- agno/utils/models/cohere.py +1 -1
- agno/utils/models/watsonx.py +1 -1
- agno/utils/openai.py +1 -1
- agno/utils/print_response/agent.py +105 -21
- agno/utils/print_response/team.py +103 -38
- agno/utils/print_response/workflow.py +251 -34
- agno/utils/reasoning.py +22 -1
- agno/utils/serialize.py +32 -0
- agno/utils/streamlit.py +16 -10
- agno/utils/string.py +41 -0
- agno/utils/team.py +98 -9
- agno/utils/tools.py +1 -1
- agno/vectordb/base.py +23 -4
- agno/vectordb/cassandra/cassandra.py +65 -9
- agno/vectordb/chroma/chromadb.py +182 -38
- agno/vectordb/clickhouse/clickhousedb.py +64 -11
- agno/vectordb/couchbase/couchbase.py +105 -10
- agno/vectordb/lancedb/lance_db.py +183 -135
- agno/vectordb/langchaindb/langchaindb.py +25 -7
- agno/vectordb/lightrag/lightrag.py +17 -3
- agno/vectordb/llamaindex/__init__.py +3 -0
- agno/vectordb/llamaindex/llamaindexdb.py +46 -7
- agno/vectordb/milvus/milvus.py +126 -9
- agno/vectordb/mongodb/__init__.py +7 -1
- agno/vectordb/mongodb/mongodb.py +112 -7
- agno/vectordb/pgvector/pgvector.py +142 -21
- agno/vectordb/pineconedb/pineconedb.py +80 -8
- agno/vectordb/qdrant/qdrant.py +125 -39
- agno/vectordb/redis/__init__.py +9 -0
- agno/vectordb/redis/redisdb.py +694 -0
- agno/vectordb/singlestore/singlestore.py +111 -25
- agno/vectordb/surrealdb/surrealdb.py +31 -5
- agno/vectordb/upstashdb/upstashdb.py +76 -8
- agno/vectordb/weaviate/weaviate.py +86 -15
- agno/workflow/__init__.py +2 -0
- agno/workflow/agent.py +299 -0
- agno/workflow/condition.py +112 -18
- agno/workflow/loop.py +69 -10
- agno/workflow/parallel.py +266 -118
- agno/workflow/router.py +110 -17
- agno/workflow/step.py +645 -136
- agno/workflow/steps.py +65 -6
- agno/workflow/types.py +71 -33
- agno/workflow/workflow.py +2113 -300
- agno-2.3.0.dist-info/METADATA +618 -0
- agno-2.3.0.dist-info/RECORD +577 -0
- agno-2.3.0.dist-info/licenses/LICENSE +201 -0
- agno/knowledge/reader/url_reader.py +0 -128
- agno/tools/googlesearch.py +0 -98
- agno/tools/mcp.py +0 -610
- agno/utils/models/aws_claude.py +0 -170
- agno-2.0.0rc2.dist-info/METADATA +0 -355
- agno-2.0.0rc2.dist-info/RECORD +0 -515
- agno-2.0.0rc2.dist-info/licenses/LICENSE +0 -375
- {agno-2.0.0rc2.dist-info → agno-2.3.0.dist-info}/WHEEL +0 -0
- {agno-2.0.0rc2.dist-info → agno-2.3.0.dist-info}/top_level.txt +0 -0
agno/utils/mcp.py
CHANGED
|
@@ -12,7 +12,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
12
12
|
raise ImportError("`mcp` not installed. Please install using `pip install mcp`")
|
|
13
13
|
|
|
14
14
|
|
|
15
|
-
from agno.media import
|
|
15
|
+
from agno.media import Image
|
|
16
16
|
from agno.tools.function import ToolResult
|
|
17
17
|
|
|
18
18
|
|
|
@@ -27,9 +27,13 @@ def get_entrypoint_for_tool(tool: MCPTool, session: ClientSession):
|
|
|
27
27
|
Returns:
|
|
28
28
|
Callable: The entrypoint function for the tool
|
|
29
29
|
"""
|
|
30
|
-
from agno.agent import Agent
|
|
31
30
|
|
|
32
|
-
async def call_tool(
|
|
31
|
+
async def call_tool(tool_name: str, **kwargs) -> ToolResult:
|
|
32
|
+
try:
|
|
33
|
+
await session.send_ping()
|
|
34
|
+
except Exception as e:
|
|
35
|
+
print(e)
|
|
36
|
+
|
|
33
37
|
try:
|
|
34
38
|
log_debug(f"Calling MCP Tool '{tool_name}' with args: {kwargs}")
|
|
35
39
|
result: CallToolResult = await session.call_tool(tool_name, kwargs) # type: ignore
|
|
@@ -70,7 +74,7 @@ def get_entrypoint_for_tool(tool: MCPTool, session: ClientSession):
|
|
|
70
74
|
image_bytes = None
|
|
71
75
|
|
|
72
76
|
if image_bytes:
|
|
73
|
-
img_artifact =
|
|
77
|
+
img_artifact = Image(
|
|
74
78
|
id=str(uuid4()),
|
|
75
79
|
url=None,
|
|
76
80
|
content=image_bytes,
|
|
@@ -98,7 +102,7 @@ def get_entrypoint_for_tool(tool: MCPTool, session: ClientSession):
|
|
|
98
102
|
log_debug(f"Failed to decode base64 image data: {e}")
|
|
99
103
|
image_data = None
|
|
100
104
|
|
|
101
|
-
img_artifact =
|
|
105
|
+
img_artifact = Image(
|
|
102
106
|
id=str(uuid4()),
|
|
103
107
|
url=getattr(content_item, "url", None),
|
|
104
108
|
content=image_data,
|
|
@@ -122,3 +126,89 @@ def get_entrypoint_for_tool(tool: MCPTool, session: ClientSession):
|
|
|
122
126
|
return ToolResult(content=f"Error: {e}")
|
|
123
127
|
|
|
124
128
|
return partial(call_tool, tool_name=tool.name)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def prepare_command(command: str) -> list[str]:
|
|
132
|
+
"""Sanitize a command and split it into parts before using it to run a MCP server."""
|
|
133
|
+
import os
|
|
134
|
+
import shutil
|
|
135
|
+
from shlex import split
|
|
136
|
+
|
|
137
|
+
# Block dangerous characters
|
|
138
|
+
if any(char in command for char in ["&", "|", ";", "`", "$", "(", ")"]):
|
|
139
|
+
raise ValueError("MCP command can't contain shell metacharacters")
|
|
140
|
+
|
|
141
|
+
parts = split(command)
|
|
142
|
+
if not parts:
|
|
143
|
+
raise ValueError("MCP command can't be empty")
|
|
144
|
+
|
|
145
|
+
# Only allow specific executables
|
|
146
|
+
ALLOWED_COMMANDS = {
|
|
147
|
+
# Python
|
|
148
|
+
"python",
|
|
149
|
+
"python3",
|
|
150
|
+
"uv",
|
|
151
|
+
"uvx",
|
|
152
|
+
"pipx",
|
|
153
|
+
# Node
|
|
154
|
+
"node",
|
|
155
|
+
"npm",
|
|
156
|
+
"npx",
|
|
157
|
+
"yarn",
|
|
158
|
+
"pnpm",
|
|
159
|
+
"bun",
|
|
160
|
+
# Other runtimes
|
|
161
|
+
"deno",
|
|
162
|
+
"java",
|
|
163
|
+
"ruby",
|
|
164
|
+
"docker",
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
executable = parts[0].split("/")[-1]
|
|
168
|
+
|
|
169
|
+
# Check if it's a relative path starting with ./ or ../
|
|
170
|
+
if executable.startswith("./") or executable.startswith("../"):
|
|
171
|
+
# Allow relative paths to binaries
|
|
172
|
+
return parts
|
|
173
|
+
|
|
174
|
+
# Check if it's an absolute path to a binary
|
|
175
|
+
if executable.startswith("/") and os.path.isfile(executable):
|
|
176
|
+
# Allow absolute paths to existing files
|
|
177
|
+
return parts
|
|
178
|
+
|
|
179
|
+
# Check if it's a binary in current directory without ./
|
|
180
|
+
if "/" not in executable and os.path.isfile(executable):
|
|
181
|
+
# Allow binaries in current directory
|
|
182
|
+
return parts
|
|
183
|
+
|
|
184
|
+
# Check if it's a binary in PATH
|
|
185
|
+
if shutil.which(executable):
|
|
186
|
+
return parts
|
|
187
|
+
|
|
188
|
+
if executable not in ALLOWED_COMMANDS:
|
|
189
|
+
raise ValueError(f"MCP command needs to use one of the following executables: {ALLOWED_COMMANDS}")
|
|
190
|
+
|
|
191
|
+
first_part = parts[0]
|
|
192
|
+
executable = first_part.split("/")[-1]
|
|
193
|
+
|
|
194
|
+
# Allow known commands
|
|
195
|
+
if executable in ALLOWED_COMMANDS:
|
|
196
|
+
return parts
|
|
197
|
+
|
|
198
|
+
# Allow relative paths to custom binaries
|
|
199
|
+
if first_part.startswith(("./", "../")):
|
|
200
|
+
return parts
|
|
201
|
+
|
|
202
|
+
# Allow absolute paths to existing files
|
|
203
|
+
if first_part.startswith("/") and os.path.isfile(first_part):
|
|
204
|
+
return parts
|
|
205
|
+
|
|
206
|
+
# Allow binaries in current directory without ./
|
|
207
|
+
if "/" not in first_part and os.path.isfile(first_part):
|
|
208
|
+
return parts
|
|
209
|
+
|
|
210
|
+
# Allow binaries in PATH
|
|
211
|
+
if shutil.which(first_part):
|
|
212
|
+
return parts
|
|
213
|
+
|
|
214
|
+
raise ValueError(f"MCP command needs to use one of the following executables: {ALLOWED_COMMANDS}")
|
agno/utils/media.py
CHANGED
|
@@ -2,10 +2,13 @@ import base64
|
|
|
2
2
|
import time
|
|
3
3
|
from enum import Enum
|
|
4
4
|
from pathlib import Path
|
|
5
|
-
from typing import List
|
|
5
|
+
from typing import List, Optional
|
|
6
6
|
|
|
7
7
|
import httpx
|
|
8
8
|
|
|
9
|
+
from agno.media import Audio, File, Image, Video
|
|
10
|
+
from agno.utils.log import log_info, log_warning
|
|
11
|
+
|
|
9
12
|
|
|
10
13
|
class SampleDataFileExtension(str, Enum):
|
|
11
14
|
DOCX = "docx"
|
|
@@ -30,7 +33,7 @@ def download_image(url: str, output_path: str) -> bool:
|
|
|
30
33
|
# Check if the response contains image content
|
|
31
34
|
content_type = response.headers.get("Content-Type")
|
|
32
35
|
if not content_type or not content_type.startswith("image"):
|
|
33
|
-
|
|
36
|
+
log_warning(f"URL does not point to an image. Content-Type: {content_type}")
|
|
34
37
|
return False
|
|
35
38
|
|
|
36
39
|
path = Path(output_path)
|
|
@@ -42,17 +45,28 @@ def download_image(url: str, output_path: str) -> bool:
|
|
|
42
45
|
if chunk:
|
|
43
46
|
file.write(chunk)
|
|
44
47
|
|
|
45
|
-
|
|
48
|
+
log_info(f"Image successfully downloaded and saved to '{output_path}'.")
|
|
46
49
|
return True
|
|
47
50
|
|
|
48
51
|
except httpx.HTTPError as e:
|
|
49
|
-
|
|
52
|
+
log_warning(f"Error downloading the image: {e}")
|
|
50
53
|
return False
|
|
51
54
|
except IOError as e:
|
|
52
|
-
|
|
55
|
+
log_warning(f"Error saving the image to '{output_path}': {e}")
|
|
53
56
|
return False
|
|
54
57
|
|
|
55
58
|
|
|
59
|
+
def download_audio(url: str, output_path: str) -> str:
|
|
60
|
+
"""Download audio from URL"""
|
|
61
|
+
response = httpx.get(url)
|
|
62
|
+
response.raise_for_status()
|
|
63
|
+
|
|
64
|
+
with open(output_path, "wb") as f:
|
|
65
|
+
for chunk in response.iter_bytes(chunk_size=8192):
|
|
66
|
+
f.write(chunk)
|
|
67
|
+
return output_path
|
|
68
|
+
|
|
69
|
+
|
|
56
70
|
def download_video(url: str, output_path: str) -> str:
|
|
57
71
|
"""Download video from URL"""
|
|
58
72
|
response = httpx.get(url)
|
|
@@ -109,7 +123,7 @@ def save_base64_data(base64_data: str, output_path: str) -> bool:
|
|
|
109
123
|
with open(path, "wb") as file:
|
|
110
124
|
file.write(decoded_data)
|
|
111
125
|
|
|
112
|
-
|
|
126
|
+
log_info(f"Data successfully saved to '{path}'.")
|
|
113
127
|
return True
|
|
114
128
|
except Exception as e:
|
|
115
129
|
raise Exception(f"An unexpected error occurred while saving data to '{output_path}': {e}")
|
|
@@ -131,25 +145,25 @@ def wait_for_media_ready(url: str, timeout: int = 120, interval: int = 5, verbos
|
|
|
131
145
|
max_attempts = timeout // interval
|
|
132
146
|
|
|
133
147
|
if verbose:
|
|
134
|
-
|
|
148
|
+
log_info("Media generated! Waiting for upload to complete...")
|
|
135
149
|
|
|
136
150
|
for attempt in range(max_attempts):
|
|
137
151
|
try:
|
|
138
152
|
response = httpx.head(url, timeout=10)
|
|
139
153
|
response.raise_for_status()
|
|
140
154
|
if verbose:
|
|
141
|
-
|
|
155
|
+
log_info(f"Media ready: {url}")
|
|
142
156
|
return True
|
|
143
157
|
except httpx.HTTPError:
|
|
144
158
|
pass
|
|
145
159
|
|
|
146
160
|
if verbose and (attempt + 1) % 3 == 0:
|
|
147
|
-
|
|
161
|
+
log_info(f"Still processing... ({(attempt + 1) * interval}s elapsed)")
|
|
148
162
|
|
|
149
163
|
time.sleep(interval)
|
|
150
164
|
|
|
151
165
|
if verbose:
|
|
152
|
-
|
|
166
|
+
log_warning(f"Timeout waiting for media. Try this URL later: {url}")
|
|
153
167
|
return False
|
|
154
168
|
|
|
155
169
|
|
|
@@ -183,3 +197,167 @@ def download_knowledge_filters_sample_data(
|
|
|
183
197
|
)
|
|
184
198
|
file_paths.append(str(download_path))
|
|
185
199
|
return file_paths
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def reconstruct_image_from_dict(img_data):
|
|
203
|
+
"""
|
|
204
|
+
Reconstruct an Image object from dictionary data.
|
|
205
|
+
|
|
206
|
+
Handles both base64-encoded content (from database) and regular image data (url/filepath).
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
if isinstance(img_data, dict):
|
|
210
|
+
# If content is base64 string, decode it back to bytes
|
|
211
|
+
if "content" in img_data and isinstance(img_data["content"], str):
|
|
212
|
+
return Image.from_base64(
|
|
213
|
+
img_data["content"],
|
|
214
|
+
id=img_data.get("id"),
|
|
215
|
+
mime_type=img_data.get("mime_type"),
|
|
216
|
+
format=img_data.get("format"),
|
|
217
|
+
detail=img_data.get("detail"),
|
|
218
|
+
original_prompt=img_data.get("original_prompt"),
|
|
219
|
+
revised_prompt=img_data.get("revised_prompt"),
|
|
220
|
+
alt_text=img_data.get("alt_text"),
|
|
221
|
+
)
|
|
222
|
+
else:
|
|
223
|
+
# Regular image (filepath/url)
|
|
224
|
+
return Image(**img_data)
|
|
225
|
+
return img_data
|
|
226
|
+
except Exception as e:
|
|
227
|
+
log_warning(f"Failed to reconstruct image from dict: {e}")
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def reconstruct_video_from_dict(vid_data):
|
|
232
|
+
"""
|
|
233
|
+
Reconstruct a Video object from dictionary data.
|
|
234
|
+
|
|
235
|
+
Handles both base64-encoded content (from database) and regular video data (url/filepath).
|
|
236
|
+
"""
|
|
237
|
+
try:
|
|
238
|
+
if isinstance(vid_data, dict):
|
|
239
|
+
# If content is base64 string, decode it back to bytes
|
|
240
|
+
if "content" in vid_data and isinstance(vid_data["content"], str):
|
|
241
|
+
return Video.from_base64(
|
|
242
|
+
vid_data["content"],
|
|
243
|
+
id=vid_data.get("id"),
|
|
244
|
+
mime_type=vid_data.get("mime_type"),
|
|
245
|
+
format=vid_data.get("format"),
|
|
246
|
+
)
|
|
247
|
+
else:
|
|
248
|
+
# Regular video (filepath/url)
|
|
249
|
+
return Video(**vid_data)
|
|
250
|
+
return vid_data
|
|
251
|
+
except Exception as e:
|
|
252
|
+
log_warning(f"Failed to reconstruct video from dict: {e}")
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def reconstruct_audio_from_dict(aud_data):
|
|
257
|
+
"""
|
|
258
|
+
Reconstruct an Audio object from dictionary data.
|
|
259
|
+
|
|
260
|
+
Handles both base64-encoded content (from database) and regular audio data (url/filepath).
|
|
261
|
+
"""
|
|
262
|
+
try:
|
|
263
|
+
if isinstance(aud_data, dict):
|
|
264
|
+
# If content is base64 string, decode it back to bytes
|
|
265
|
+
if "content" in aud_data and isinstance(aud_data["content"], str):
|
|
266
|
+
return Audio.from_base64(
|
|
267
|
+
aud_data["content"],
|
|
268
|
+
id=aud_data.get("id"),
|
|
269
|
+
mime_type=aud_data.get("mime_type"),
|
|
270
|
+
transcript=aud_data.get("transcript"),
|
|
271
|
+
expires_at=aud_data.get("expires_at"),
|
|
272
|
+
sample_rate=aud_data.get("sample_rate", 24000),
|
|
273
|
+
channels=aud_data.get("channels", 1),
|
|
274
|
+
)
|
|
275
|
+
else:
|
|
276
|
+
# Regular audio (filepath/url)
|
|
277
|
+
return Audio(**aud_data)
|
|
278
|
+
return aud_data
|
|
279
|
+
except Exception as e:
|
|
280
|
+
log_warning(f"Failed to reconstruct audio from dict: {e}")
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def reconstruct_file_from_dict(file_data):
|
|
285
|
+
"""
|
|
286
|
+
Reconstruct a File object from dictionary data.
|
|
287
|
+
|
|
288
|
+
Handles both base64-encoded content (from database) and regular file data (url/filepath).
|
|
289
|
+
"""
|
|
290
|
+
try:
|
|
291
|
+
if isinstance(file_data, dict):
|
|
292
|
+
# If content is base64 string, decode it back to bytes
|
|
293
|
+
if "content" in file_data and isinstance(file_data["content"], str):
|
|
294
|
+
return File.from_base64(
|
|
295
|
+
file_data["content"],
|
|
296
|
+
id=file_data.get("id"),
|
|
297
|
+
mime_type=file_data.get("mime_type"),
|
|
298
|
+
filename=file_data.get("filename"),
|
|
299
|
+
name=file_data.get("name"),
|
|
300
|
+
format=file_data.get("format"),
|
|
301
|
+
)
|
|
302
|
+
else:
|
|
303
|
+
# Regular file (filepath/url)
|
|
304
|
+
return File(**file_data)
|
|
305
|
+
return file_data
|
|
306
|
+
except Exception as e:
|
|
307
|
+
log_warning(f"Failed to reconstruct file from dict: {e}")
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def reconstruct_images(images: Optional[List[dict]]) -> Optional[List[Image]]:
|
|
312
|
+
"""Reconstruct a list of Image objects from list of dictionaries.
|
|
313
|
+
|
|
314
|
+
Failed reconstructions are skipped with a warning logged.
|
|
315
|
+
"""
|
|
316
|
+
if not images:
|
|
317
|
+
return None
|
|
318
|
+
reconstructed = [reconstruct_image_from_dict(img_data) for img_data in images]
|
|
319
|
+
valid_images = [img for img in reconstructed if img is not None]
|
|
320
|
+
return valid_images if valid_images else None
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def reconstruct_videos(videos: Optional[List[dict]]) -> Optional[List[Video]]:
|
|
324
|
+
"""Reconstruct a list of Video objects from list of dictionaries.
|
|
325
|
+
|
|
326
|
+
Failed reconstructions are skipped with a warning logged.
|
|
327
|
+
"""
|
|
328
|
+
if not videos:
|
|
329
|
+
return None
|
|
330
|
+
reconstructed = [reconstruct_video_from_dict(vid_data) for vid_data in videos]
|
|
331
|
+
valid_videos = [vid for vid in reconstructed if vid is not None]
|
|
332
|
+
return valid_videos if valid_videos else None
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def reconstruct_audio_list(audio: Optional[List[dict]]) -> Optional[List[Audio]]:
|
|
336
|
+
"""Reconstruct a list of Audio objects from list of dictionaries.
|
|
337
|
+
|
|
338
|
+
Failed reconstructions are skipped with a warning logged.
|
|
339
|
+
"""
|
|
340
|
+
if not audio:
|
|
341
|
+
return None
|
|
342
|
+
reconstructed = [reconstruct_audio_from_dict(aud_data) for aud_data in audio]
|
|
343
|
+
valid_audio = [aud for aud in reconstructed if aud is not None]
|
|
344
|
+
return valid_audio if valid_audio else None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def reconstruct_files(files: Optional[List[dict]]) -> Optional[List[File]]:
|
|
348
|
+
"""Reconstruct a list of File objects from list of dictionaries.
|
|
349
|
+
|
|
350
|
+
Failed reconstructions are skipped with a warning logged.
|
|
351
|
+
"""
|
|
352
|
+
if not files:
|
|
353
|
+
return None
|
|
354
|
+
reconstructed = [reconstruct_file_from_dict(file_data) for file_data in files]
|
|
355
|
+
valid_files = [f for f in reconstructed if f is not None]
|
|
356
|
+
return valid_files if valid_files else None
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def reconstruct_response_audio(audio: Optional[dict]) -> Optional[Audio]:
|
|
360
|
+
"""Reconstruct a single Audio object for response audio."""
|
|
361
|
+
if not audio:
|
|
362
|
+
return None
|
|
363
|
+
return reconstruct_audio_from_dict(audio)
|
agno/utils/merge_dict.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import Any, Dict
|
|
1
|
+
from typing import Any, Dict, List
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
def merge_dictionaries(a: Dict[str, Any], b: Dict[str, Any]) -> None:
|
|
@@ -18,3 +18,24 @@ def merge_dictionaries(a: Dict[str, Any], b: Dict[str, Any]) -> None:
|
|
|
18
18
|
merge_dictionaries(a[key], b[key])
|
|
19
19
|
else:
|
|
20
20
|
a[key] = b[key]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def merge_parallel_session_states(original_state: Dict[str, Any], modified_states: List[Dict[str, Any]]) -> None:
|
|
24
|
+
"""
|
|
25
|
+
Smart merge for parallel session states that only applies actual changes.
|
|
26
|
+
This prevents parallel steps from overwriting each other's changes.
|
|
27
|
+
"""
|
|
28
|
+
if not original_state or not modified_states:
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
# Collect all actual changes (keys where value differs from original)
|
|
32
|
+
all_changes = {}
|
|
33
|
+
for modified_state in modified_states:
|
|
34
|
+
if modified_state:
|
|
35
|
+
for key, value in modified_state.items():
|
|
36
|
+
if key not in original_state or original_state[key] != value:
|
|
37
|
+
all_changes[key] = value
|
|
38
|
+
|
|
39
|
+
# Apply all collected changes to the original state
|
|
40
|
+
for key, value in all_changes.items():
|
|
41
|
+
original_state[key] = value
|
agno/utils/message.py
CHANGED
|
@@ -1,8 +1,68 @@
|
|
|
1
|
+
from copy import deepcopy
|
|
1
2
|
from typing import Dict, List, Union
|
|
2
3
|
|
|
3
4
|
from pydantic import BaseModel
|
|
4
5
|
|
|
5
6
|
from agno.models.message import Message
|
|
7
|
+
from agno.utils.log import log_debug
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def filter_tool_calls(messages: List[Message], max_tool_calls: int) -> None:
|
|
11
|
+
"""
|
|
12
|
+
Filter messages (in-place) to keep only the most recent N tool calls.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
messages: List of messages to filter (modified in-place)
|
|
16
|
+
max_tool_calls: Number of recent tool calls to keep
|
|
17
|
+
"""
|
|
18
|
+
# Count total tool calls
|
|
19
|
+
tool_call_count = sum(1 for m in messages if m.role == "tool")
|
|
20
|
+
|
|
21
|
+
# No filtering needed
|
|
22
|
+
if tool_call_count <= max_tool_calls:
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
# Collect tool_call_ids to keep (most recent N)
|
|
26
|
+
tool_call_ids_list: List[str] = []
|
|
27
|
+
for msg in reversed(messages):
|
|
28
|
+
if msg.role == "tool" and len(tool_call_ids_list) < max_tool_calls:
|
|
29
|
+
if msg.tool_call_id:
|
|
30
|
+
tool_call_ids_list.append(msg.tool_call_id)
|
|
31
|
+
|
|
32
|
+
tool_call_ids_to_keep: set[str] = set(tool_call_ids_list)
|
|
33
|
+
|
|
34
|
+
# Filter messages in-place
|
|
35
|
+
filtered_messages = []
|
|
36
|
+
for msg in messages:
|
|
37
|
+
if msg.role == "tool":
|
|
38
|
+
# Keep only tool results in our window
|
|
39
|
+
if msg.tool_call_id in tool_call_ids_to_keep:
|
|
40
|
+
filtered_messages.append(msg)
|
|
41
|
+
elif msg.role == "assistant" and msg.tool_calls:
|
|
42
|
+
# Filter tool_calls within the assistant message
|
|
43
|
+
# Use deepcopy to ensure complete isolation of the filtered message
|
|
44
|
+
filtered_msg = deepcopy(msg)
|
|
45
|
+
# Filter tool_calls
|
|
46
|
+
if filtered_msg.tool_calls is not None:
|
|
47
|
+
filtered_msg.tool_calls = [
|
|
48
|
+
tc for tc in filtered_msg.tool_calls if tc.get("id") in tool_call_ids_to_keep
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
if filtered_msg.tool_calls:
|
|
52
|
+
# Has tool_calls remaining, keep it
|
|
53
|
+
filtered_messages.append(filtered_msg)
|
|
54
|
+
# skip empty messages
|
|
55
|
+
elif filtered_msg.content:
|
|
56
|
+
filtered_msg.tool_calls = None
|
|
57
|
+
filtered_messages.append(filtered_msg)
|
|
58
|
+
else:
|
|
59
|
+
filtered_messages.append(msg)
|
|
60
|
+
|
|
61
|
+
messages[:] = filtered_messages
|
|
62
|
+
|
|
63
|
+
# Log filtering information
|
|
64
|
+
num_filtered = tool_call_count - len(tool_call_ids_to_keep)
|
|
65
|
+
log_debug(f"Filtered {num_filtered} tool calls, kept {len(tool_call_ids_to_keep)}")
|
|
6
66
|
|
|
7
67
|
|
|
8
68
|
def get_text_from_message(message: Union[List, Dict, str, Message, BaseModel]) -> str:
|
agno/utils/models/claude.py
CHANGED
|
@@ -32,6 +32,7 @@ class MCPServerConfiguration:
|
|
|
32
32
|
|
|
33
33
|
ROLE_MAP = {
|
|
34
34
|
"system": "system",
|
|
35
|
+
"developer": "system",
|
|
35
36
|
"user": "user",
|
|
36
37
|
"assistant": "assistant",
|
|
37
38
|
"tool": "user",
|
|
@@ -67,13 +68,25 @@ def _format_image_for_message(image: Image) -> Optional[Dict[str, Any]]:
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
try:
|
|
71
|
+
img_type = None
|
|
72
|
+
|
|
70
73
|
# Case 0: Image is an Anthropic uploaded file
|
|
71
74
|
if image.content is not None and hasattr(image.content, "id"):
|
|
72
|
-
|
|
75
|
+
content_bytes = image.content
|
|
73
76
|
|
|
74
77
|
# Case 1: Image is a URL
|
|
75
78
|
if image.url is not None:
|
|
76
|
-
|
|
79
|
+
content_bytes = image.get_content_bytes() # type: ignore
|
|
80
|
+
|
|
81
|
+
# If image URL has a suffix, use it as the type (without dot)
|
|
82
|
+
import os
|
|
83
|
+
from urllib.parse import urlparse
|
|
84
|
+
|
|
85
|
+
if image.url:
|
|
86
|
+
parsed_url = urlparse(image.url)
|
|
87
|
+
_, ext = os.path.splitext(parsed_url.path)
|
|
88
|
+
if ext:
|
|
89
|
+
img_type = ext.lstrip(".").lower()
|
|
77
90
|
|
|
78
91
|
# Case 2: Image is a local file path
|
|
79
92
|
elif image.filepath is not None:
|
|
@@ -83,6 +96,11 @@ def _format_image_for_message(image: Image) -> Optional[Dict[str, Any]]:
|
|
|
83
96
|
if path.exists() and path.is_file():
|
|
84
97
|
with open(image.filepath, "rb") as f:
|
|
85
98
|
content_bytes = f.read()
|
|
99
|
+
|
|
100
|
+
# If image file path has a suffix, use it as the type (without dot)
|
|
101
|
+
path_ext = path.suffix.lstrip(".")
|
|
102
|
+
if path_ext:
|
|
103
|
+
img_type = path_ext.lower()
|
|
86
104
|
else:
|
|
87
105
|
log_error(f"Image file not found: {image}")
|
|
88
106
|
return None
|
|
@@ -95,15 +113,16 @@ def _format_image_for_message(image: Image) -> Optional[Dict[str, Any]]:
|
|
|
95
113
|
log_error(f"Unsupported image type: {type(image)}")
|
|
96
114
|
return None
|
|
97
115
|
|
|
98
|
-
if
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
116
|
+
if not img_type:
|
|
117
|
+
if using_filetype:
|
|
118
|
+
kind = filetype.guess(content_bytes)
|
|
119
|
+
if not kind:
|
|
120
|
+
log_error("Unable to determine image type")
|
|
121
|
+
return None
|
|
103
122
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
123
|
+
img_type = kind.extension
|
|
124
|
+
else:
|
|
125
|
+
img_type = imghdr.what(None, h=content_bytes) # type: ignore
|
|
107
126
|
|
|
108
127
|
if not img_type:
|
|
109
128
|
log_error("Unable to determine image type")
|
|
@@ -217,7 +236,8 @@ def format_messages(messages: List[Message]) -> Tuple[List[Dict[str, str]], str]
|
|
|
217
236
|
|
|
218
237
|
for message in messages:
|
|
219
238
|
content = message.content or ""
|
|
220
|
-
|
|
239
|
+
# Both "system" and "developer" roles should be extracted as system messages
|
|
240
|
+
if message.role in ("system", "developer"):
|
|
221
241
|
if content is not None:
|
|
222
242
|
system_messages.append(content) # type: ignore
|
|
223
243
|
continue
|
|
@@ -279,6 +299,15 @@ def format_messages(messages: List[Message]) -> Tuple[List[Dict[str, str]], str]
|
|
|
279
299
|
type="tool_use",
|
|
280
300
|
)
|
|
281
301
|
)
|
|
302
|
+
elif message.role == "tool":
|
|
303
|
+
content = []
|
|
304
|
+
content.append(
|
|
305
|
+
{
|
|
306
|
+
"type": "tool_result",
|
|
307
|
+
"tool_use_id": message.tool_call_id,
|
|
308
|
+
"content": str(message.content),
|
|
309
|
+
}
|
|
310
|
+
)
|
|
282
311
|
|
|
283
312
|
# Skip empty assistant responses
|
|
284
313
|
if message.role == "assistant" and not content:
|
agno/utils/models/cohere.py
CHANGED
|
@@ -20,7 +20,7 @@ def _format_images_for_message(message: Message, images: Sequence[Image]) -> Lis
|
|
|
20
20
|
if image.content is not None:
|
|
21
21
|
image_content = image.content
|
|
22
22
|
elif image.url is not None:
|
|
23
|
-
image_content = image.
|
|
23
|
+
image_content = image.get_content_bytes() # type: ignore
|
|
24
24
|
elif image.filepath is not None:
|
|
25
25
|
if isinstance(image.filepath, Path):
|
|
26
26
|
image_content = image.filepath.read_bytes()
|
agno/utils/models/watsonx.py
CHANGED
|
@@ -19,7 +19,7 @@ def format_images_for_message(message: Message, images: Sequence[Image]) -> Mess
|
|
|
19
19
|
if image.content is not None:
|
|
20
20
|
image_content = image.content
|
|
21
21
|
elif image.url is not None:
|
|
22
|
-
image_content = image.
|
|
22
|
+
image_content = image.get_content_bytes() # type: ignore
|
|
23
23
|
else:
|
|
24
24
|
log_warning(f"Unsupported image format: {image}")
|
|
25
25
|
continue
|
agno/utils/openai.py
CHANGED
|
@@ -39,7 +39,7 @@ def audio_to_message(audio: Sequence[Audio]) -> List[Dict[str, Any]]:
|
|
|
39
39
|
|
|
40
40
|
# The audio is a URL
|
|
41
41
|
elif audio_snippet.url:
|
|
42
|
-
audio_bytes = audio_snippet.
|
|
42
|
+
audio_bytes = audio_snippet.get_content_bytes()
|
|
43
43
|
if audio_bytes is not None:
|
|
44
44
|
encoded_string = base64.b64encode(audio_bytes).decode("utf-8")
|
|
45
45
|
if not audio_format:
|