tinyflow-llm 0.1.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.
Files changed (43) hide show
  1. tinyflow/__init__.py +53 -0
  2. tinyflow/config/helpers.py +62 -0
  3. tinyflow/config/settings.py +80 -0
  4. tinyflow/core/__init__.py +31 -0
  5. tinyflow/core/agent.py +457 -0
  6. tinyflow/core/exceptions.py +63 -0
  7. tinyflow/core/logger.py +13 -0
  8. tinyflow/core/message.py +6 -0
  9. tinyflow/core/protocol.py +81 -0
  10. tinyflow/core/tools.py +200 -0
  11. tinyflow/core/types.py +252 -0
  12. tinyflow/embeddings/__init__.py +4 -0
  13. tinyflow/embeddings/base.py +32 -0
  14. tinyflow/embeddings/factory.py +140 -0
  15. tinyflow/embeddings/local_embedding.py +65 -0
  16. tinyflow/embeddings/openai_embedding.py +70 -0
  17. tinyflow/memory/__init__.py +5 -0
  18. tinyflow/memory/base.py +16 -0
  19. tinyflow/memory/simple.py +34 -0
  20. tinyflow/memory/vector.py +26 -0
  21. tinyflow/providers/anthropic_llm.py +132 -0
  22. tinyflow/providers/base/factory.py +81 -0
  23. tinyflow/providers/base/llm.py +37 -0
  24. tinyflow/providers/gemini_llm.py +130 -0
  25. tinyflow/providers/openai_llm.py +198 -0
  26. tinyflow/tools/builtin/__init__.py +36 -0
  27. tinyflow/tools/builtin/code_execution.py +143 -0
  28. tinyflow/tools/builtin/search.py +145 -0
  29. tinyflow/tools/builtin/web_reader.py +88 -0
  30. tinyflow/vector/__init__.py +4 -0
  31. tinyflow/vector/base.py +65 -0
  32. tinyflow/vector/chroma_db.py +134 -0
  33. tinyflow/vector/factory.py +84 -0
  34. tinyflow/vector/qdrant_db.py +198 -0
  35. tinyflow/workflow/__init__.py +21 -0
  36. tinyflow/workflow/executor.py +272 -0
  37. tinyflow/workflow/hooks.py +191 -0
  38. tinyflow/workflow/state.py +148 -0
  39. tinyflow/workflow/step.py +74 -0
  40. tinyflow_llm-0.1.0.dist-info/METADATA +243 -0
  41. tinyflow_llm-0.1.0.dist-info/RECORD +43 -0
  42. tinyflow_llm-0.1.0.dist-info/WHEEL +4 -0
  43. tinyflow_llm-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,130 @@
1
+ import os
2
+ from typing import AsyncGenerator, List, Optional
3
+
4
+ from google import genai
5
+ from google.genai import types
6
+ from tenacity import retry, stop_after_attempt, wait_exponential
7
+
8
+ from tinyflow.core.tools import Tool
9
+ from tinyflow.core.types import (
10
+ LLMResponse,
11
+ Message,
12
+ StreamChunk,
13
+ StreamPart,
14
+ TextStreamDelta,
15
+ )
16
+ from tinyflow.providers.base.llm import BaseLLM
17
+
18
+
19
+ class GeminiProvider(BaseLLM):
20
+ def __init__(
21
+ self,
22
+ api_key: Optional[str] = None,
23
+ model: Optional[str] = None,
24
+ base_url: Optional[str] = None,
25
+ ):
26
+ # Configuration precedence: Manual > Environment Variable > Error
27
+ self.api_key = api_key or os.getenv("GEMINI_API_KEY") or os.getenv("LLM_API_KEY")
28
+ self.model = (
29
+ model or os.getenv("GEMINI_MODEL") or os.getenv("LLM_MODEL") or "gemini-1.5-flash"
30
+ )
31
+ self.base_url = base_url # Gemini supports custom endpoints via client options
32
+
33
+ if not self.api_key:
34
+ raise ValueError(
35
+ "Gemini API key required. Set GEMINI_API_KEY env var or pass api_key."
36
+ )
37
+
38
+ self.client = genai.Client(
39
+ api_key=self.api_key,
40
+ )
41
+
42
+ def _convert_messages(self, messages: List[Message]) -> List[types.Content]:
43
+ """
44
+ Gemini requires converting OpenAI-style messages to Google Content type
45
+ Note: This is a simplified conversion; complex Tool Results require more refined handling
46
+ """
47
+ gemini_contents = []
48
+ for m in messages:
49
+ role = "user" if m.role in ["user", "tool"] else "model"
50
+ if m.role == "system":
51
+ # Gemini V1 SDK suggests passing system prompt via config's system_instruction
52
+ # Temporarily doing simple merging here, or extracting separately during generation
53
+ continue
54
+
55
+ parts = [types.Part(text=m.content)] if m.content else []
56
+
57
+ # Simple conversion logic; production needs to handle function_call conversion
58
+ gemini_contents.append(types.Content(role=role, parts=parts))
59
+
60
+ return gemini_contents
61
+
62
+ def _get_system_prompt(self, messages: List[Message]) -> Optional[str]:
63
+ for m in messages:
64
+ if m.role == "system":
65
+ return m.content
66
+ return None
67
+
68
+ @retry(
69
+ wait=wait_exponential(multiplier=1, min=2, max=10),
70
+ stop=stop_after_attempt(3),
71
+ reraise=True,
72
+ )
73
+ async def generate(
74
+ self, messages: List[Message], tools: Optional[List[Tool]] = None
75
+ ) -> LLMResponse:
76
+ contents = self._convert_messages(messages)
77
+ system_instr = self._get_system_prompt(messages)
78
+
79
+ # Gemini supports passing functions directly, SDK automatically parses Schema
80
+ # But we already have Tool; for simplicity, momentarily disabling Tools for Gemini
81
+ if tools:
82
+ pass
83
+
84
+ response = await self.client.aio.models.generate_content(
85
+ model=self.model,
86
+ contents=contents,
87
+ config=types.GenerateContentConfig(system_instruction=system_instr),
88
+ )
89
+
90
+ return LLMResponse(content=response.text, raw_response=response)
91
+
92
+ def stream(
93
+ self, messages: List[Message], tools: Optional[List[Tool]] = None
94
+ ) -> AsyncGenerator[StreamChunk, None]:
95
+ contents = self._convert_messages(messages)
96
+ system_instr = self._get_system_prompt(messages)
97
+
98
+ async def _stream():
99
+ # Call streaming interface
100
+ response_stream = await self.client.aio.models.generate_content_stream(
101
+ model=self.model,
102
+ contents=contents,
103
+ config=types.GenerateContentConfig(system_instruction=system_instr),
104
+ )
105
+
106
+ async for chunk in response_stream:
107
+ if chunk.text:
108
+ yield StreamChunk(content=chunk.text)
109
+
110
+ return _stream()
111
+
112
+ def stream_text(
113
+ self, messages: List[Message], tools: Optional[List[Tool]] = None
114
+ ) -> AsyncGenerator[StreamPart, None]:
115
+ """Stream response with rich part types."""
116
+ contents = self._convert_messages(messages)
117
+ system_instr = self._get_system_prompt(messages)
118
+
119
+ async def _stream_text():
120
+ response_stream = await self.client.aio.models.generate_content_stream(
121
+ model=self.model,
122
+ contents=contents,
123
+ config=types.GenerateContentConfig(system_instruction=system_instr),
124
+ )
125
+
126
+ async for chunk in response_stream:
127
+ if chunk.text:
128
+ yield TextStreamDelta(type="text", text=chunk.text)
129
+
130
+ return _stream_text()
@@ -0,0 +1,198 @@
1
+ import logging
2
+ import os
3
+ from typing import AsyncGenerator, List, Optional
4
+
5
+ from openai import AsyncOpenAI
6
+ from tenacity import retry, stop_after_attempt, wait_exponential
7
+
8
+ from tinyflow.core.tools import Tool
9
+ from tinyflow.core.types import (
10
+ LLMResponse,
11
+ Message,
12
+ ReasoningStreamDelta,
13
+ StreamChunk,
14
+ StreamPart,
15
+ TextStreamDelta,
16
+ ToolCallComplete,
17
+ ToolCallStreamDelta,
18
+ ToolCallStreamStart,
19
+ )
20
+ from tinyflow.providers.base.llm import BaseLLM
21
+
22
+ logger = logging.getLogger("tinyflow.providers.openai")
23
+
24
+
25
+ class OpenAIProvider(BaseLLM):
26
+ def __init__(
27
+ self,
28
+ api_key: Optional[str] = None,
29
+ model: Optional[str] = None,
30
+ base_url: Optional[str] = None,
31
+ timeout: float = 60.0,
32
+ ):
33
+ # Configuration precedence: Manual > Environment Variable > Error
34
+ self.api_key = api_key or os.getenv("OPENAI_API_KEY") or os.getenv("LLM_API_KEY")
35
+ self.model = model or os.getenv("OPENAI_MODEL") or os.getenv("LLM_MODEL") or "gpt-4o"
36
+ self.base_url = base_url or os.getenv("OPENAI_BASE_URL") or os.getenv("LLM_BASE_URL")
37
+ self.timeout = timeout
38
+
39
+ logging.info(
40
+ f"OpenAIProvider Initialized: model='{self.model}', base_url='{self.base_url}'"
41
+ )
42
+
43
+ if not self.api_key:
44
+ raise ValueError(
45
+ "OpenAI API key required. Set OPENAI_API_KEY env var or pass api_key."
46
+ )
47
+
48
+ self.client = AsyncOpenAI(
49
+ api_key=self.api_key,
50
+ base_url=self.base_url,
51
+ timeout=self.timeout,
52
+ )
53
+
54
+ def _convert_messages(self, messages: List[Message]):
55
+ openai_msgs = []
56
+ for m in messages:
57
+ msg = {"role": m.role, "content": m.content}
58
+ if m.tool_calls:
59
+ msg["tool_calls"] = m.tool_calls
60
+ if m.tool_call_id:
61
+ msg["tool_call_id"] = m.tool_call_id
62
+ if m.name:
63
+ msg["name"] = m.name
64
+
65
+ if m.reasoning_content:
66
+ msg["reasoning_content"] = m.reasoning_content
67
+
68
+ openai_msgs.append(msg)
69
+ return openai_msgs
70
+
71
+ @retry(
72
+ wait=wait_exponential(multiplier=1, min=2, max=10),
73
+ stop=stop_after_attempt(3),
74
+ reraise=True,
75
+ )
76
+ async def generate(
77
+ self, messages: List[Message], tools: Optional[List[Tool]] = None
78
+ ) -> LLMResponse:
79
+ logger.debug(f"OpenAI Generate call with {len(messages)} messages")
80
+ openai_messages = self._convert_messages(messages)
81
+ openai_tools = [t.to_openai_schema(strict=True) for t in tools] if tools else None
82
+
83
+ response = await self.client.chat.completions.create(
84
+ model=self.model,
85
+ messages=openai_messages,
86
+ tools=openai_tools, # pyright: ignore[reportArgumentType]
87
+ )
88
+ msg = response.choices[0].message
89
+ logger.debug(
90
+ f"OpenAI Generate response: {msg.content[:50] if msg.content else 'None'}..."
91
+ )
92
+
93
+ # Process tool_calls format
94
+ tool_calls_dict = None
95
+ if msg.tool_calls:
96
+ tool_calls_dict = [
97
+ {
98
+ "id": tc.id,
99
+ "function": {
100
+ "name": tc.function.name,
101
+ "arguments": tc.function.arguments,
102
+ },
103
+ "type": "function",
104
+ }
105
+ for tc in msg.tool_calls
106
+ ]
107
+
108
+ return LLMResponse(content=msg.content, tool_calls=tool_calls_dict, raw_response=response)
109
+
110
+ def stream(
111
+ self, messages: List[Message], tools: Optional[List[Tool]] = None
112
+ ) -> AsyncGenerator[StreamChunk, None]:
113
+ openai_messages = self._convert_messages(messages)
114
+
115
+ async def _stream():
116
+ stream = await self.client.chat.completions.create(
117
+ model=self.model, messages=openai_messages, stream=True
118
+ )
119
+
120
+ async for chunk in stream:
121
+ if not chunk.choices:
122
+ continue
123
+ delta = chunk.choices[0].delta
124
+ if delta.content:
125
+ yield StreamChunk(content=delta.content)
126
+
127
+ return _stream()
128
+
129
+ def stream_text(
130
+ self, messages: List[Message], tools: Optional[List[Tool]] = None
131
+ ) -> AsyncGenerator[StreamPart, None]:
132
+ openai_messages = self._convert_messages(messages)
133
+ openai_tools = [t.to_openai_schema(strict=True) for t in tools] if tools else None
134
+
135
+ async def _stream_text():
136
+ stream = await self.client.chat.completions.create(
137
+ model=self.model,
138
+ messages=openai_messages,
139
+ tools=openai_tools, # type: ignore[reportCallIssue]
140
+ stream=True,
141
+ )
142
+
143
+ current_tool_call: Optional[dict] = None
144
+
145
+ async for chunk in stream:
146
+ if not chunk.choices:
147
+ continue
148
+
149
+ delta = chunk.choices[0].delta
150
+
151
+ if delta.content:
152
+ yield TextStreamDelta(type="text", text=delta.content)
153
+
154
+ # Support DeepSeek/o1 "reasoning_content"
155
+ if hasattr(delta, "reasoning_content") and delta.reasoning_content:
156
+ yield ReasoningStreamDelta(type="reasoning", text=delta.reasoning_content)
157
+
158
+ if delta.tool_calls:
159
+ for tc_delta in delta.tool_calls:
160
+ if tc_delta.id:
161
+ current_tool_call = {
162
+ "id": tc_delta.id,
163
+ "name": tc_delta.function.name,
164
+ "args": tc_delta.function.arguments or "",
165
+ }
166
+ yield ToolCallStreamStart(
167
+ type="tool-call-streaming-start",
168
+ tool_call_id=current_tool_call["id"],
169
+ tool_name=current_tool_call["name"],
170
+ )
171
+ else:
172
+ if current_tool_call:
173
+ current_tool_call["args"] += tc_delta.function.arguments or ""
174
+ yield ToolCallStreamDelta(
175
+ type="tool-call-delta",
176
+ tool_call_id=current_tool_call["id"],
177
+ tool_name=current_tool_call["name"],
178
+ args_text_delta=tc_delta.function.arguments or "",
179
+ )
180
+
181
+ if chunk.choices[0].finish_reason:
182
+ if current_tool_call:
183
+ import json
184
+
185
+ try:
186
+ args = json.loads(current_tool_call["args"])
187
+ except json.JSONDecodeError:
188
+ args = current_tool_call["args"]
189
+
190
+ yield ToolCallComplete(
191
+ type="tool-call",
192
+ tool_call_id=current_tool_call["id"],
193
+ tool_name=current_tool_call["name"],
194
+ input=args,
195
+ )
196
+ current_tool_call = None
197
+
198
+ return _stream_text()
@@ -0,0 +1,36 @@
1
+ """Built-in tools for TinyFlow Agent framework.
2
+
3
+ This package provides ready-to-use tools for common Agent tasks:
4
+ - Search: Tavily AI and DuckDuckGo
5
+ - Web Reading: Jina AI Reader
6
+ - Code Execution: Python Interpreter (restricted)
7
+ """
8
+
9
+ from tinyflow.tools.builtin.code_execution import (
10
+ create_python_interpreter_tool,
11
+ python_interpreter_tool,
12
+ )
13
+ from tinyflow.tools.builtin.search import (
14
+ create_duckduckgo_search_tool,
15
+ create_tavily_search_tool,
16
+ duckduckgo_search_tool,
17
+ tavily_search_tool,
18
+ )
19
+ from tinyflow.tools.builtin.web_reader import (
20
+ create_jina_reader_tool,
21
+ jina_reader_tool,
22
+ )
23
+
24
+ __all__ = [
25
+ # Search tools
26
+ "create_tavily_search_tool",
27
+ "create_duckduckgo_search_tool",
28
+ "tavily_search_tool",
29
+ "duckduckgo_search_tool",
30
+ # Web reader tools
31
+ "create_jina_reader_tool",
32
+ "jina_reader_tool",
33
+ # Code execution
34
+ "create_python_interpreter_tool",
35
+ "python_interpreter_tool",
36
+ ]
@@ -0,0 +1,143 @@
1
+ """Python interpreter tool for code execution.
2
+
3
+ ⚠️ SECURITY WARNING: This tool executes arbitrary Python code.
4
+ Use only in sandboxed environments. Never expose to untrusted users.
5
+ """
6
+
7
+ import io
8
+ import sys
9
+ import traceback
10
+ from typing import Any, Dict, Optional
11
+
12
+ from pydantic import BaseModel, Field
13
+
14
+ from tinyflow.core.tools import Tool
15
+
16
+
17
+ class PythonInterpreterInput(BaseModel):
18
+ """Input parameters for Python interpreter."""
19
+
20
+ code: str = Field(
21
+ description="The Python code to execute. "
22
+ "Use print() to output results. "
23
+ "Available libraries: os, sys, math, json, re, datetime, collections, itertools"
24
+ )
25
+ timeout: int = Field(default=30, description="Maximum execution time in seconds (1-60)")
26
+
27
+
28
+ def create_python_interpreter_tool(
29
+ allowed_modules: Optional[list] = None, allow_file_access: bool = False
30
+ ) -> Tool:
31
+ """Create a Python interpreter tool instance.
32
+
33
+ ⚠️ SECURITY WARNING:
34
+ This tool executes arbitrary Python code. By default, it runs in a
35
+ restricted environment with limited builtins. However, it should only
36
+ be used in sandboxed environments.
37
+
38
+ Args:
39
+ allowed_modules: List of module names to allow importing.
40
+ Defaults to safe standard library modules.
41
+ allow_file_access: Whether to allow file system access. Default False.
42
+
43
+ Returns:
44
+ Configured Tool instance for Python code execution.
45
+ """
46
+ # Default safe modules
47
+ if allowed_modules is None:
48
+ allowed_modules = [
49
+ "math",
50
+ "json",
51
+ "re",
52
+ "datetime",
53
+ "collections",
54
+ "itertools",
55
+ "random",
56
+ "statistics",
57
+ "typing",
58
+ ]
59
+
60
+ def execute_python_code(args: PythonInterpreterInput) -> str:
61
+ """Execute Python code in a restricted environment."""
62
+ max(1, min(args.timeout, 60))
63
+
64
+ # Create restricted globals
65
+ restricted_globals: Dict[str, Any] = {
66
+ "__builtins__": {
67
+ "print": print,
68
+ "len": len,
69
+ "range": range,
70
+ "enumerate": enumerate,
71
+ "zip": zip,
72
+ "map": map,
73
+ "filter": filter,
74
+ "sum": sum,
75
+ "min": min,
76
+ "max": max,
77
+ "abs": abs,
78
+ "round": round,
79
+ "pow": pow,
80
+ "divmod": divmod,
81
+ "bool": bool,
82
+ "int": int,
83
+ "float": float,
84
+ "str": str,
85
+ "list": list,
86
+ "dict": dict,
87
+ "tuple": tuple,
88
+ "set": set,
89
+ "frozenset": frozenset,
90
+ "type": type,
91
+ "isinstance": isinstance,
92
+ "hasattr": hasattr,
93
+ "getattr": getattr,
94
+ "Exception": Exception,
95
+ }
96
+ }
97
+
98
+ # Import allowed modules
99
+ for module_name in allowed_modules:
100
+ try:
101
+ module = __import__(module_name)
102
+ restricted_globals[module_name] = module
103
+ except ImportError:
104
+ pass
105
+
106
+ # Capture stdout
107
+ old_stdout = sys.stdout
108
+ sys.stdout = io.StringIO()
109
+
110
+ try:
111
+ # Execute code with timeout-like protection (via limited operations)
112
+ # Note: For true sandboxing, consider using subprocess or RestrictedPython
113
+ exec(args.code, restricted_globals)
114
+
115
+ # Get output
116
+ output = sys.stdout.getvalue()
117
+
118
+ if not output.strip():
119
+ return "Code executed successfully (no output)."
120
+
121
+ return f"Output:\n{output}"
122
+
123
+ except Exception as e:
124
+ error_msg = f"Error executing code: {type(e).__name__}: {str(e)}"
125
+ # Include traceback for debugging
126
+ tb = traceback.format_exc()
127
+ return f"{error_msg}\n\nTraceback:\n{tb}"
128
+ finally:
129
+ sys.stdout = old_stdout
130
+
131
+ return Tool(
132
+ name="python_interpreter",
133
+ description="Execute Python code in a restricted environment. "
134
+ "Available: math, json, re, datetime, collections, itertools. "
135
+ "Use print() to see output. "
136
+ "⚠️ Security: Code runs in restricted mode but use with caution.",
137
+ parameters=PythonInterpreterInput,
138
+ execute=execute_python_code,
139
+ )
140
+
141
+
142
+ # Convenience instance with default restrictions
143
+ python_interpreter_tool = create_python_interpreter_tool()
@@ -0,0 +1,145 @@
1
+ """Built-in search tools using mainstream APIs.
2
+
3
+ This module provides search capabilities through:
4
+ - Tavily AI (high-quality AI search, requires API key)
5
+ - DuckDuckGo (free, no API key required)
6
+ """
7
+
8
+ import os
9
+ from typing import Optional
10
+
11
+ from pydantic import BaseModel, Field
12
+
13
+ from tinyflow.core.tools import Tool
14
+
15
+
16
+ class TavilySearchInput(BaseModel):
17
+ """Input parameters for Tavily search."""
18
+
19
+ query: str = Field(description="The search query to execute")
20
+ max_results: int = Field(default=5, description="Maximum number of results to return (1-10)")
21
+ include_answer: bool = Field(
22
+ default=True, description="Include an AI-generated answer summary"
23
+ )
24
+
25
+
26
+ class DuckDuckGoSearchInput(BaseModel):
27
+ """Input parameters for DuckDuckGo search."""
28
+
29
+ query: str = Field(description="The search query to execute")
30
+ max_results: int = Field(default=5, description="Maximum number of results to return")
31
+
32
+
33
+ def _get_tavily_client(api_key: Optional[str] = None):
34
+ """Get or create Tavily client with the provided or environment API key."""
35
+ try:
36
+ from tavily import TavilyClient
37
+ except ImportError:
38
+ raise ImportError(
39
+ "tavily-python is required for TavilySearchTool. "
40
+ "Install with: pip install tavily-python"
41
+ )
42
+
43
+ # Configuration precedence: Manual > Environment Variable > Error
44
+ key = api_key or os.getenv("TAVILY_API_KEY")
45
+ if not key:
46
+ raise ValueError(
47
+ "Tavily API key required. Set TAVILY_API_KEY environment variable "
48
+ "or pass api_key parameter to the tool."
49
+ )
50
+
51
+ return TavilyClient(api_key=key)
52
+
53
+
54
+ def create_tavily_search_tool(api_key: Optional[str] = None) -> Tool:
55
+ """Create a Tavily search tool instance.
56
+
57
+ Args:
58
+ api_key: Tavily API key. If not provided, uses TAVILY_API_KEY env var.
59
+
60
+ Returns:
61
+ Configured Tool instance for Tavily search.
62
+ """
63
+
64
+ def execute_tavily_search(args: TavilySearchInput) -> str:
65
+ """Execute Tavily search and format results."""
66
+ client = _get_tavily_client(api_key)
67
+
68
+ response = client.search(
69
+ query=args.query,
70
+ max_results=args.max_results,
71
+ include_answer=args.include_answer,
72
+ )
73
+
74
+ # Format results
75
+ results_text = f"Search results for: {args.query}\n\n"
76
+
77
+ if args.include_answer and "answer" in response:
78
+ results_text += f"AI Summary: {response['answer']}\n\n"
79
+
80
+ results = response.get("results", [])
81
+ for i, result in enumerate(results, 1):
82
+ title = result.get("title", "No title")
83
+ content = result.get("content", "No content")
84
+ url = result.get("url", "")
85
+ results_text += f"{i}. {title}\n{content}\nURL: {url}\n\n"
86
+
87
+ return results_text.strip()
88
+
89
+ return Tool(
90
+ name="tavily_search",
91
+ description="Search the web using Tavily AI for high-quality, relevant results. "
92
+ "Best for research, fact-checking, and finding current information.",
93
+ parameters=TavilySearchInput,
94
+ execute=execute_tavily_search,
95
+ )
96
+
97
+
98
+ def create_duckduckgo_search_tool() -> Tool:
99
+ """Create a DuckDuckGo search tool instance.
100
+
101
+ Note: DuckDuckGo search does not require an API key.
102
+
103
+ Returns:
104
+ Configured Tool instance for DuckDuckGo search.
105
+ """
106
+
107
+ def execute_duckduckgo_search(args: DuckDuckGoSearchInput) -> str:
108
+ """Execute DuckDuckGo search and format results."""
109
+ try:
110
+ from duckduckgo_search import DDGS
111
+ except ImportError:
112
+ raise ImportError(
113
+ "duckduckgo-search is required for DuckDuckGoSearchTool. "
114
+ "Install with: pip install duckduckgo-search"
115
+ )
116
+
117
+ with DDGS() as ddgs:
118
+ results = list(ddgs.text(args.query, max_results=args.max_results))
119
+
120
+ # Format results
121
+ results_text = f"Search results for: {args.query}\n\n"
122
+
123
+ if not results:
124
+ return f"No results found for: {args.query}"
125
+
126
+ for i, result in enumerate(results, 1):
127
+ title = result.get("title", "No title")
128
+ body = result.get("body", "No description")
129
+ href = result.get("href", "")
130
+ results_text += f"{i}. {title}\n{body}\nURL: {href}\n\n"
131
+
132
+ return results_text.strip()
133
+
134
+ return Tool(
135
+ name="duckduckgo_search",
136
+ description="Search the web using DuckDuckGo. Free, no API key required. "
137
+ "Good for general web searches when Tavily is not available.",
138
+ parameters=DuckDuckGoSearchInput,
139
+ execute=execute_duckduckgo_search,
140
+ )
141
+
142
+
143
+ # Convenience instances (will use env vars for API keys)
144
+ tavily_search_tool = create_tavily_search_tool()
145
+ duckduckgo_search_tool = create_duckduckgo_search_tool()