feather-ai-sdk 0.1.0__tar.gz
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.
- feather_ai_sdk-0.1.0/PKG-INFO +25 -0
- feather_ai_sdk-0.1.0/README.md +11 -0
- feather_ai_sdk-0.1.0/pyproject.toml +19 -0
- feather_ai_sdk-0.1.0/setup.cfg +4 -0
- feather_ai_sdk-0.1.0/src/__init__.py +0 -0
- feather_ai_sdk-0.1.0/src/feather_ai/__init__.py +22 -0
- feather_ai_sdk-0.1.0/src/feather_ai/agent.py +115 -0
- feather_ai_sdk-0.1.0/src/feather_ai/document.py +35 -0
- feather_ai_sdk-0.1.0/src/feather_ai/internal_utils/__init__.py +0 -0
- feather_ai_sdk-0.1.0/src/feather_ai/internal_utils/_exceptions.py +29 -0
- feather_ai_sdk-0.1.0/src/feather_ai/internal_utils/_multimodal.py +62 -0
- feather_ai_sdk-0.1.0/src/feather_ai/internal_utils/_provider.py +57 -0
- feather_ai_sdk-0.1.0/src/feather_ai/internal_utils/_response.py +21 -0
- feather_ai_sdk-0.1.0/src/feather_ai/internal_utils/_structured_tool.py +62 -0
- feather_ai_sdk-0.1.0/src/feather_ai/internal_utils/_tools.py +227 -0
- feather_ai_sdk-0.1.0/src/feather_ai/internal_utils/_tracing.py +36 -0
- feather_ai_sdk-0.1.0/src/feather_ai/orchestration/__init__.py +0 -0
- feather_ai_sdk-0.1.0/src/feather_ai/prompt.py +59 -0
- feather_ai_sdk-0.1.0/src/feather_ai/tools/__init__.py +53 -0
- feather_ai_sdk-0.1.0/src/feather_ai/tools/code_execution.py +19 -0
- feather_ai_sdk-0.1.0/src/feather_ai/tools/web.py +174 -0
- feather_ai_sdk-0.1.0/src/feather_ai/utils.py +24 -0
- feather_ai_sdk-0.1.0/src/feather_ai_sdk.egg-info/PKG-INFO +25 -0
- feather_ai_sdk-0.1.0/src/feather_ai_sdk.egg-info/SOURCES.txt +26 -0
- feather_ai_sdk-0.1.0/src/feather_ai_sdk.egg-info/dependency_links.txt +1 -0
- feather_ai_sdk-0.1.0/src/feather_ai_sdk.egg-info/requires.txt +6 -0
- feather_ai_sdk-0.1.0/src/feather_ai_sdk.egg-info/top_level.txt +2 -0
- feather_ai_sdk-0.1.0/tests/test_agent.py +146 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: feather-ai-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The lightest Agentic AI Framework you'll ever see
|
|
5
|
+
Author: Luca Bozzetti
|
|
6
|
+
Requires-Python: <3.14,>=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: langchain-google-genai>=3.1.0
|
|
9
|
+
Requires-Dist: langchain-openai>=1.0.3
|
|
10
|
+
Requires-Dist: langchain-anthropic>=1.1.0
|
|
11
|
+
Requires-Dist: langchain-mistralai>=1.0.1
|
|
12
|
+
Requires-Dist: tavily-python>=00.7.13
|
|
13
|
+
Requires-Dist: langchain-experimental>=0.4.0
|
|
14
|
+
|
|
15
|
+
# Colors:
|
|
16
|
+
|
|
17
|
+
BLUE VARIANTS:
|
|
18
|
+
a) #0357c1
|
|
19
|
+
b) #22c4e0
|
|
20
|
+
|
|
21
|
+
PINK VARIANTS:
|
|
22
|
+
a) #be3389
|
|
23
|
+
|
|
24
|
+
ORANGE/BROWN VARIANTS:
|
|
25
|
+
a) #dfa987
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "feather-ai-sdk"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "The lightest Agentic AI Framework you'll ever see"
|
|
5
|
+
authors = [{ name = "Luca Bozzetti" }]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
requires-python = ">=3.9,<3.14"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"langchain-google-genai>=3.1.0",
|
|
10
|
+
"langchain-openai>=1.0.3",
|
|
11
|
+
"langchain-anthropic>=1.1.0",
|
|
12
|
+
"langchain-mistralai>=1.0.1",
|
|
13
|
+
"tavily-python>=00.7.13",
|
|
14
|
+
"langchain-experimental>=0.4.0"
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["setuptools"]
|
|
19
|
+
build-backend = "setuptools.build_meta"
|
|
File without changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
feather_ai
|
|
3
|
+
==========
|
|
4
|
+
|
|
5
|
+
Public API for the feather_ai package.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .agent import AIAgent
|
|
9
|
+
from .document import Document
|
|
10
|
+
from .prompt import Prompt
|
|
11
|
+
from .utils import load_instruction_from_file
|
|
12
|
+
from src.feather_ai.internal_utils._exceptions import ModelNotSupportedException
|
|
13
|
+
from src.feather_ai.internal_utils._response import AIResponse
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"AIAgent",
|
|
17
|
+
"Document",
|
|
18
|
+
"Prompt",
|
|
19
|
+
"load_instruction_from_file",
|
|
20
|
+
"ModelNotSupportedException",
|
|
21
|
+
"AIResponse",
|
|
22
|
+
]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This file contains the main Agent class provided by feather_ai.
|
|
3
|
+
The AIAgent class can be used to create intelligent agents that can perform various tasks.
|
|
4
|
+
Its main advantage over other agentic AI frameworks is its simplicity and ease of use.
|
|
5
|
+
"""
|
|
6
|
+
from typing import Optional, List, Callable, Any, Dict, Type
|
|
7
|
+
|
|
8
|
+
from langchain_core.language_models import BaseChatModel
|
|
9
|
+
from langchain_core.messages import HumanMessage, SystemMessage, BaseMessage, AIMessage
|
|
10
|
+
from langchain_core.runnables import Runnable
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from src.feather_ai.internal_utils._provider import get_provider
|
|
14
|
+
from src.feather_ai.internal_utils._response import AIResponse
|
|
15
|
+
from .internal_utils._structured_tool import get_respond_tool
|
|
16
|
+
from .prompt import Prompt
|
|
17
|
+
from .internal_utils._tools import make_tool, execute_tool, async_execute_tool, react_agent_with_tooling, \
|
|
18
|
+
async_react_agent_with_tooling
|
|
19
|
+
from .internal_utils._tracing import get_tool_trace_from_langchain
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AIAgent:
|
|
23
|
+
"""
|
|
24
|
+
The AIAgent class represents an intelligent AI agent that can perform tool calling and give structured output.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
model (str): The LLM used by the agent.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self,
|
|
31
|
+
model: str,
|
|
32
|
+
instructions: Optional[str] = None,
|
|
33
|
+
tools: Optional[List[Callable[..., Any]]] = None,
|
|
34
|
+
output_schema: Optional[Type[BaseModel]] = None,
|
|
35
|
+
):
|
|
36
|
+
"""
|
|
37
|
+
Initializes a new Agent instance.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
model (str): The LLM used by the agent.
|
|
41
|
+
"""
|
|
42
|
+
self.model = model
|
|
43
|
+
self.system_instructions = instructions
|
|
44
|
+
self.structured_output = True if output_schema else False
|
|
45
|
+
provider_data = get_provider(model)
|
|
46
|
+
self.llm: BaseChatModel | Runnable = provider_data[0]
|
|
47
|
+
if self.structured_output and not tools:
|
|
48
|
+
self.llm = self.llm.with_structured_output(output_schema)
|
|
49
|
+
self.provider_str: str = provider_data[1]
|
|
50
|
+
# Bind tools to LLM
|
|
51
|
+
if tools:
|
|
52
|
+
self.tools = [make_tool(tool) for tool in tools]
|
|
53
|
+
if self.structured_output:
|
|
54
|
+
self.tools.append(get_respond_tool(output_schema))
|
|
55
|
+
self.llm: BaseChatModel = get_provider(model)[0].bind_tools(self.tools, tool_choice='any') # type: ignore
|
|
56
|
+
else:
|
|
57
|
+
self.llm: BaseChatModel = get_provider(model)[0].bind_tools(self.tools) # type: ignore
|
|
58
|
+
|
|
59
|
+
def run(self, prompt: Prompt | str):
|
|
60
|
+
"""
|
|
61
|
+
Standard run method for the AIAgent class.
|
|
62
|
+
Returns:
|
|
63
|
+
AgentResponse object containing the agent's response.
|
|
64
|
+
"""
|
|
65
|
+
messages: List[BaseMessage] = [
|
|
66
|
+
SystemMessage(content=self.system_instructions if self.system_instructions else ""),
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
## Check if user passed a Prompt Object or a string
|
|
70
|
+
if isinstance(prompt, Prompt):
|
|
71
|
+
messages.append(prompt.get_message(self.provider_str))
|
|
72
|
+
else:
|
|
73
|
+
messages.append(HumanMessage(content=prompt))
|
|
74
|
+
|
|
75
|
+
tool_calls = None
|
|
76
|
+
## Call tools if any
|
|
77
|
+
if hasattr(self, "tools"):
|
|
78
|
+
response, tool_calls = react_agent_with_tooling(self.llm, self.tools, messages, self.structured_output)
|
|
79
|
+
else:
|
|
80
|
+
response = self.llm.invoke(messages)
|
|
81
|
+
|
|
82
|
+
## Check for structured output
|
|
83
|
+
if self.structured_output:
|
|
84
|
+
return AIResponse(response, tool_calls, messages)
|
|
85
|
+
else:
|
|
86
|
+
return AIResponse(response.content, tool_calls, messages)
|
|
87
|
+
|
|
88
|
+
async def arun(self, prompt: Prompt | str):
|
|
89
|
+
"""
|
|
90
|
+
Async run method for the AIAgent class.
|
|
91
|
+
Returns:
|
|
92
|
+
AgentResponse object containing the agent's response.
|
|
93
|
+
"""
|
|
94
|
+
messages: List[BaseMessage] = [
|
|
95
|
+
SystemMessage(content=self.system_instructions if self.system_instructions else ""),
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
## Check if user passed a Prompt Object or a string
|
|
99
|
+
if isinstance(prompt, Prompt):
|
|
100
|
+
messages.append(prompt.get_message(self.provider_str))
|
|
101
|
+
else:
|
|
102
|
+
messages.append(HumanMessage(content=prompt))
|
|
103
|
+
|
|
104
|
+
tool_calls = None
|
|
105
|
+
## Call tools if any
|
|
106
|
+
if hasattr(self, "tools"):
|
|
107
|
+
response, tool_calls = await async_react_agent_with_tooling(self.llm, self.tools, messages, self.structured_output)
|
|
108
|
+
else:
|
|
109
|
+
response = await self.llm.ainvoke(messages)
|
|
110
|
+
|
|
111
|
+
## Check for structured output
|
|
112
|
+
if self.structured_output:
|
|
113
|
+
return AIResponse(response, tool_calls, messages)
|
|
114
|
+
else:
|
|
115
|
+
return AIResponse(response.content, tool_calls, messages)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Provides a standardized Document class for multimodal prompts.
|
|
3
|
+
Can either be called by the user or automatically from the Prompt class.
|
|
4
|
+
"""
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from io import BytesIO
|
|
7
|
+
import mimetypes
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Document:
|
|
13
|
+
"""Represents a document with content and metadata."""
|
|
14
|
+
content: bytes
|
|
15
|
+
mime_type: str
|
|
16
|
+
filename: str = ""
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def from_path(cls, path: str | Path) -> "Document":
|
|
20
|
+
"""Create document from file path."""
|
|
21
|
+
path = Path(path)
|
|
22
|
+
content = path.read_bytes()
|
|
23
|
+
mime_type = mimetypes.guess_type(path)[0] or "application/octet-stream"
|
|
24
|
+
return cls(content=content, mime_type=mime_type, filename=path.name)
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def from_bytes(cls, content: bytes, mime_type: str, filename: str | None = None) -> "Document":
|
|
28
|
+
"""Create document from bytes."""
|
|
29
|
+
return cls(content=content, mime_type=mime_type, filename=filename)
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_bytesio(cls, buffer: BytesIO, mime_type: str, filename: str | None = None) -> "Document":
|
|
33
|
+
"""Create document from BytesIO."""
|
|
34
|
+
content = buffer.getvalue()
|
|
35
|
+
return cls(content=content, mime_type=mime_type, filename=filename)
|
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom exceptions for feather_ai.
|
|
3
|
+
"""
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ModelNotSupportedException(Exception):
|
|
8
|
+
"""
|
|
9
|
+
Raised when a user requests a model that the library does not support.
|
|
10
|
+
|
|
11
|
+
Attributes:
|
|
12
|
+
model_name (str): The name of the unsupported model.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, model_name: str, message: str | None = None):
|
|
16
|
+
if message is None:
|
|
17
|
+
message = f"Model '{model_name}' is not supported by feather_ai."
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.model_name = model_name
|
|
20
|
+
|
|
21
|
+
class ApiKeyMissingException(Exception):
|
|
22
|
+
"""
|
|
23
|
+
Raised when an API key for the requested provider is missing.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, provider: Optional[str] = None, env_key: Optional[str] = None, message: str | None = None):
|
|
27
|
+
if message is None:
|
|
28
|
+
message = f"API key for provider '{provider}' is missing. Please set the environment variable '{env_key}'."
|
|
29
|
+
super().__init__(message)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
All providers handle multimodality differently so this defines all the message types
|
|
3
|
+
"""
|
|
4
|
+
import base64
|
|
5
|
+
from typing import Dict, Any, List
|
|
6
|
+
|
|
7
|
+
from src.feather_ai.document import Document
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_content_claude(documents: List[Document]) -> List[Dict[str, Any]]:
|
|
11
|
+
"""
|
|
12
|
+
helper to build multimodal content for Claude
|
|
13
|
+
"""
|
|
14
|
+
content_claude = []
|
|
15
|
+
for doc in documents:
|
|
16
|
+
if doc.mime_type == "application/pdf":
|
|
17
|
+
# PDFs use the document type
|
|
18
|
+
content_claude.append({
|
|
19
|
+
"type": "document",
|
|
20
|
+
"source": {
|
|
21
|
+
"type": "base64",
|
|
22
|
+
"media_type": "application/pdf",
|
|
23
|
+
"data": base64.b64encode(doc.content).decode("utf-8")
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
elif doc.mime_type.startswith("image/"):
|
|
27
|
+
# Images use the image type
|
|
28
|
+
content_claude.append({
|
|
29
|
+
"type": "image",
|
|
30
|
+
"source": {
|
|
31
|
+
"type": "base64",
|
|
32
|
+
"media_type": doc.mime_type,
|
|
33
|
+
"data": base64.b64encode(doc.content).decode("utf-8")
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
elif doc.mime_type.startswith("text/") or doc.mime_type in ["application/json", "application/xml"]:
|
|
37
|
+
# Text files: decode and include as text
|
|
38
|
+
try:
|
|
39
|
+
text_content_decoded = doc.content.decode("utf-8")
|
|
40
|
+
content_claude.append({
|
|
41
|
+
"type": "text",
|
|
42
|
+
"text": f"\n--- Content of {doc.filename or 'file'} ---\n{text_content_decoded}\n--- End of {doc.filename or 'file'} ---\n"
|
|
43
|
+
})
|
|
44
|
+
except UnicodeDecodeError:
|
|
45
|
+
pass
|
|
46
|
+
else:
|
|
47
|
+
# Unsupported format for Claude
|
|
48
|
+
raise ValueError(f"Unsupported file type for Claude: {doc.mime_type}")
|
|
49
|
+
|
|
50
|
+
return content_claude
|
|
51
|
+
|
|
52
|
+
def get_content_gemini(documents: List[Document]) -> List[Dict[str, Any]]:
|
|
53
|
+
"""
|
|
54
|
+
helper to build multimodal content for Gemini
|
|
55
|
+
"""
|
|
56
|
+
content_gemini = [{
|
|
57
|
+
"type": "media",
|
|
58
|
+
"mime_type": doc.mime_type,
|
|
59
|
+
"data": base64.b64encode(doc.content).decode("utf-8")
|
|
60
|
+
} for doc in documents]
|
|
61
|
+
|
|
62
|
+
return content_gemini
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Tuple
|
|
3
|
+
|
|
4
|
+
from langchain_google_genai import ChatGoogleGenerativeAI
|
|
5
|
+
from langchain_mistralai import ChatMistralAI
|
|
6
|
+
from langchain_openai import ChatOpenAI
|
|
7
|
+
from langchain_anthropic import ChatAnthropic
|
|
8
|
+
from langchain_core.language_models.chat_models import BaseChatModel
|
|
9
|
+
from ._exceptions import ModelNotSupportedException, ApiKeyMissingException
|
|
10
|
+
|
|
11
|
+
provider_mapping = {
|
|
12
|
+
"gemini": ChatGoogleGenerativeAI,
|
|
13
|
+
"claude": ChatAnthropic,
|
|
14
|
+
"openai": ChatOpenAI,
|
|
15
|
+
"mistral": ChatMistralAI
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
model_mapping = {
|
|
19
|
+
"gemini": lambda model: model.startswith("gemini"),
|
|
20
|
+
"claude": lambda model: model.startswith("claude"),
|
|
21
|
+
"openai": lambda model: model.startswith("gpt"),
|
|
22
|
+
"mistral": lambda model: model.startswith("mistral")
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
env_vars = {
|
|
26
|
+
"gemini": "GOOGLE_API_KEY",
|
|
27
|
+
"claude": "ANTHROPIC_API_KEY",
|
|
28
|
+
"openai": "OPENAI_API_KEY",
|
|
29
|
+
"mistral": "MISTRAL_API_KEY"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
def get_provider(model: str) -> Tuple[BaseChatModel, str]:
|
|
33
|
+
"""
|
|
34
|
+
get the specified LLM provider baseclass
|
|
35
|
+
Args:
|
|
36
|
+
model: string containing model name
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
The Chat baseclass from langchain
|
|
40
|
+
"""
|
|
41
|
+
provider_key = None
|
|
42
|
+
# Extract the provider prefix from the model name
|
|
43
|
+
for key in provider_mapping.keys():
|
|
44
|
+
if model_mapping[key](model):
|
|
45
|
+
provider_key = key
|
|
46
|
+
break
|
|
47
|
+
|
|
48
|
+
# Error handling if model does not exist
|
|
49
|
+
if provider_key is None:
|
|
50
|
+
raise ModelNotSupportedException(model)
|
|
51
|
+
|
|
52
|
+
if not os.getenv(env_vars[provider_key]):
|
|
53
|
+
raise ApiKeyMissingException(provider_key, env_vars[provider_key])
|
|
54
|
+
|
|
55
|
+
return provider_mapping[provider_key](
|
|
56
|
+
model=model # type: ignore
|
|
57
|
+
), provider_key
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Contains the Response class that summarizes a response from an AI Agent
|
|
3
|
+
"""
|
|
4
|
+
from typing import Optional, List, Type
|
|
5
|
+
|
|
6
|
+
from langchain_core.messages import BaseMessage
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
from src.feather_ai.internal_utils._tracing import ToolTrace
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AIResponse:
|
|
12
|
+
def __init__(self, content: str | BaseModel, tool_calls: Optional[List[ToolTrace]] = None, input_messages: Optional[List[BaseMessage]] = None):
|
|
13
|
+
self.content = content
|
|
14
|
+
self.tool_calls = tool_calls
|
|
15
|
+
self.input_messages = input_messages
|
|
16
|
+
|
|
17
|
+
def __str__(self):
|
|
18
|
+
if self.tool_calls:
|
|
19
|
+
return f"AIResponse(content={self.content}, tool_calls={[str(tool_call) for tool_call in self.tool_calls]})"
|
|
20
|
+
else:
|
|
21
|
+
return f"AIResponse(content={self.content})"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This is a bit of a hacky way to combine tool calling with structured output.
|
|
3
|
+
Inspired by https://langchain-ai.github.io/langgraph/how-tos/react-agent-structured-output/#define-graph
|
|
4
|
+
We use a respond() tool to return structured output.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Type
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
from langchain_core.tools import StructuredTool
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_respond_tool(schema: Type[BaseModel]) -> StructuredTool:
|
|
13
|
+
"""
|
|
14
|
+
Generates a LangChain tool named 'respond' based on a Pydantic schema.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
# 1. Define the function logic
|
|
18
|
+
# We accept **kwargs so the function can accept any arguments defined in the schema.
|
|
19
|
+
# We simply instantiate the Pydantic model with these arguments.
|
|
20
|
+
def respond_func(**kwargs) -> BaseModel:
|
|
21
|
+
return schema(**kwargs)
|
|
22
|
+
|
|
23
|
+
# 2. Construct the dynamic docstring
|
|
24
|
+
# Start with the fixed header required by the prompt
|
|
25
|
+
docstring_parts = [
|
|
26
|
+
"This is the tool you should use to respond to the user when you do not want to make any other tool calls. "
|
|
27
|
+
"The user requested for structured output which will be provided through this tool."
|
|
28
|
+
"Please always use this tool ALONE and only call it ONCE, without calling any other tools.",
|
|
29
|
+
"",
|
|
30
|
+
"Args:"
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
# Iterate over the Pydantic model fields to generate the Args section
|
|
34
|
+
# Note: Uses Pydantic v2 'model_fields'. For v1, use '__fields__'.
|
|
35
|
+
field_names = []
|
|
36
|
+
for name, field in schema.model_fields.items():
|
|
37
|
+
field_names.append(name)
|
|
38
|
+
description = field.description
|
|
39
|
+
|
|
40
|
+
# If a description exists, append it; otherwise just list the name
|
|
41
|
+
if description:
|
|
42
|
+
docstring_parts.append(f"{name}: {description}")
|
|
43
|
+
else:
|
|
44
|
+
docstring_parts.append(f"{name}:")
|
|
45
|
+
|
|
46
|
+
# Add the Returns section
|
|
47
|
+
docstring_parts.append("")
|
|
48
|
+
docstring_parts.append("Returns:")
|
|
49
|
+
docstring_parts.append(f"A {schema.__name__} object with the fields {', '.join(field_names)}")
|
|
50
|
+
|
|
51
|
+
# Join the parts to create the final docstring
|
|
52
|
+
respond_func.__doc__ = "\n".join(docstring_parts)
|
|
53
|
+
|
|
54
|
+
# 3. Create and return the StructuredTool
|
|
55
|
+
# We pass the original schema as 'args_schema'. LangChain uses this to
|
|
56
|
+
# generate the JSON Schema for the LLM (handling types, defaults, and optionals automatically).
|
|
57
|
+
return StructuredTool.from_function(
|
|
58
|
+
func=respond_func,
|
|
59
|
+
name="respond",
|
|
60
|
+
description=respond_func.__doc__,
|
|
61
|
+
args_schema=schema
|
|
62
|
+
)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Helper functions for tool calling
|
|
3
|
+
"""
|
|
4
|
+
import asyncio
|
|
5
|
+
from typing import Callable, Any, get_type_hints, List, Tuple, Type
|
|
6
|
+
|
|
7
|
+
from langchain_core.language_models import BaseChatModel
|
|
8
|
+
from langchain_core.messages import ToolMessage, BaseMessage, AIMessage
|
|
9
|
+
from langchain_core.tools import StructuredTool, BaseTool
|
|
10
|
+
from pydantic import create_model, BaseModel
|
|
11
|
+
import inspect
|
|
12
|
+
import logging
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
from src.feather_ai.internal_utils._tracing import ToolTrace, get_tool_trace_from_langchain
|
|
16
|
+
|
|
17
|
+
def execute_tool(response, tools):
|
|
18
|
+
"""
|
|
19
|
+
Helper function to execute tool calls in the response from the LLM.
|
|
20
|
+
"""
|
|
21
|
+
messages = []
|
|
22
|
+
# Following code was copied from the tutorial about MCP:
|
|
23
|
+
# Check if the LLM made tool calls
|
|
24
|
+
if hasattr(response, 'tool_calls') and response.tool_calls:
|
|
25
|
+
logger.info(f"Calling the following tools: {response.tool_calls}")
|
|
26
|
+
# Add the assistant message with tool calls to our conversation
|
|
27
|
+
messages.append(response)
|
|
28
|
+
|
|
29
|
+
# Execute each tool call and create proper tool messages
|
|
30
|
+
for tool_call in response.tool_calls:
|
|
31
|
+
tool_name = tool_call['name']
|
|
32
|
+
tool_args = tool_call['args']
|
|
33
|
+
tool_call_id = tool_call['id']
|
|
34
|
+
|
|
35
|
+
# Find and execute the tool
|
|
36
|
+
tool_result = None
|
|
37
|
+
for tool in tools:
|
|
38
|
+
if hasattr(tool, 'coroutine') and tool.coroutine is not None:
|
|
39
|
+
raise ValueError("You cannot use the normal run method with asynchronous tools. Use the arun method instead.")
|
|
40
|
+
if tool.name == tool_name:
|
|
41
|
+
try:
|
|
42
|
+
tool_result = tool.run(tool_args)
|
|
43
|
+
except Exception as tool_error:
|
|
44
|
+
tool_result = f"Error executing tool: {tool_error}"
|
|
45
|
+
break
|
|
46
|
+
|
|
47
|
+
if tool_result is None:
|
|
48
|
+
tool_result = f"Tool {tool_name} not found"
|
|
49
|
+
|
|
50
|
+
# Create a tool message
|
|
51
|
+
tool_message = ToolMessage(
|
|
52
|
+
content=str(tool_result),
|
|
53
|
+
tool_call_id=tool_call_id
|
|
54
|
+
)
|
|
55
|
+
messages.append(tool_message)
|
|
56
|
+
|
|
57
|
+
return messages
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def async_execute_tool(response, tools):
|
|
61
|
+
"""
|
|
62
|
+
Helper function to execute tool calls in the response from the LLM.
|
|
63
|
+
Calls all tools asynchronously in parallel.
|
|
64
|
+
"""
|
|
65
|
+
messages = []
|
|
66
|
+
|
|
67
|
+
# Check if the LLM made tool calls
|
|
68
|
+
if hasattr(response, 'tool_calls') and response.tool_calls:
|
|
69
|
+
logger.info(f"Calling the following tools: {response.tool_calls}")
|
|
70
|
+
# Add the assistant message with tool calls to our conversation
|
|
71
|
+
messages.append(response)
|
|
72
|
+
|
|
73
|
+
# Create async tasks for all tool calls
|
|
74
|
+
async def execute_single_tool(tool_call):
|
|
75
|
+
tool_name = tool_call['name']
|
|
76
|
+
tool_args = tool_call['args']
|
|
77
|
+
tool_call_id = tool_call['id']
|
|
78
|
+
|
|
79
|
+
# Find and execute the tool
|
|
80
|
+
tool_result = None
|
|
81
|
+
for tool in tools:
|
|
82
|
+
if tool.name == tool_name:
|
|
83
|
+
try:
|
|
84
|
+
# Check if tool has a coroutine (async) or func (sync)
|
|
85
|
+
if hasattr(tool, 'coroutine') and tool.coroutine is not None:
|
|
86
|
+
tool_result = await tool.arun(tool_args)
|
|
87
|
+
else:
|
|
88
|
+
logger.warning("Using synchronous tools will lead to sequential tool execution. Consider using asynchronous tools instead.")
|
|
89
|
+
tool_result = tool.run(tool_args)
|
|
90
|
+
except Exception as tool_error:
|
|
91
|
+
tool_result = f"Error executing tool: {tool_error}"
|
|
92
|
+
break
|
|
93
|
+
|
|
94
|
+
if tool_result is None:
|
|
95
|
+
tool_result = f"Tool {tool_name} not found"
|
|
96
|
+
|
|
97
|
+
# Create and return a tool message
|
|
98
|
+
return ToolMessage(
|
|
99
|
+
content=str(tool_result),
|
|
100
|
+
tool_call_id=tool_call_id
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Execute all tools in parallel
|
|
104
|
+
tool_messages = await asyncio.gather(
|
|
105
|
+
*[execute_single_tool(tc) for tc in response.tool_calls]
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
messages.extend(tool_messages)
|
|
109
|
+
|
|
110
|
+
return messages
|
|
111
|
+
|
|
112
|
+
async def async_react_agent_with_tooling(llm: BaseChatModel, tools: List[BaseTool], messages: List[BaseMessage], structured_output: bool = False) -> Tuple[AIMessage | Type[BaseModel], List[ToolTrace]]:
|
|
113
|
+
"""
|
|
114
|
+
Agent that can call tools in multiple rounds.
|
|
115
|
+
Args:
|
|
116
|
+
llm: langchain chat model
|
|
117
|
+
tools: tools to be called by the chat model
|
|
118
|
+
messages: current conversation
|
|
119
|
+
structured_output: optional flag to indicate if the agent should return structured output
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
|
|
123
|
+
"""
|
|
124
|
+
tool_calls = []
|
|
125
|
+
while True:
|
|
126
|
+
response = await llm.ainvoke(messages)
|
|
127
|
+
|
|
128
|
+
# sorry for this hack, would not be necessary if langchain supported tool calls with structured output
|
|
129
|
+
if structured_output and hasattr(response, 'tool_calls') and response.tool_calls:
|
|
130
|
+
for tool_call in response.tool_calls:
|
|
131
|
+
if tool_call['name'] == 'respond':
|
|
132
|
+
tool_args = tool_call['args']
|
|
133
|
+
for tool in tools:
|
|
134
|
+
if tool.name == 'respond':
|
|
135
|
+
return tool.run(tool_args), tool_calls
|
|
136
|
+
|
|
137
|
+
tool_messages = await async_execute_tool(response, tools)
|
|
138
|
+
tool_calls.extend(get_tool_trace_from_langchain(response, tool_messages))
|
|
139
|
+
if not tool_messages:
|
|
140
|
+
if response.content:
|
|
141
|
+
return response, tool_calls
|
|
142
|
+
else:
|
|
143
|
+
response.content = messages[-1].content
|
|
144
|
+
return response, tool_calls
|
|
145
|
+
messages.extend(tool_messages)
|
|
146
|
+
|
|
147
|
+
def react_agent_with_tooling(llm: BaseChatModel, tools: List[BaseTool], messages: List[BaseMessage], structured_output: bool = False) -> Tuple[AIMessage | Type[BaseModel], List[ToolTrace]]:
|
|
148
|
+
"""
|
|
149
|
+
Agent that can call tools in multiple rounds.
|
|
150
|
+
Args:
|
|
151
|
+
llm: langchain chat model
|
|
152
|
+
tools: tools to be called by the chat model
|
|
153
|
+
messages: current conversation
|
|
154
|
+
structured_output: optional flag to indicate if the agent should return structured output
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
|
|
158
|
+
"""
|
|
159
|
+
tool_calls = []
|
|
160
|
+
while True:
|
|
161
|
+
response = llm.invoke(messages)
|
|
162
|
+
|
|
163
|
+
# sorry for this hack, would not be necessary if langchain supported tool calls with structured output
|
|
164
|
+
if structured_output and hasattr(response, 'tool_calls') and response.tool_calls:
|
|
165
|
+
for tool_call in response.tool_calls:
|
|
166
|
+
if tool_call['name'] == 'respond':
|
|
167
|
+
tool_args = tool_call['args']
|
|
168
|
+
for tool in tools:
|
|
169
|
+
if tool.name == 'respond':
|
|
170
|
+
return tool.run(tool_args), tool_calls
|
|
171
|
+
|
|
172
|
+
tool_messages = execute_tool(response, tools)
|
|
173
|
+
tool_calls.extend(get_tool_trace_from_langchain(response, tool_messages))
|
|
174
|
+
if not tool_messages:
|
|
175
|
+
if response.content:
|
|
176
|
+
return response, tool_calls
|
|
177
|
+
else:
|
|
178
|
+
response.content = messages[-1].content
|
|
179
|
+
return response, tool_calls
|
|
180
|
+
messages.extend(tool_messages)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def make_tool(func: Callable) -> StructuredTool:
|
|
184
|
+
"""
|
|
185
|
+
Convert any function into a LangChain StructuredTool.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
func: Any callable function with type hints
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
A LangChain StructuredTool wrapping the function
|
|
192
|
+
"""
|
|
193
|
+
if isinstance(func, BaseTool):
|
|
194
|
+
return func
|
|
195
|
+
# Get function metadata
|
|
196
|
+
tool_name = func.__name__
|
|
197
|
+
tool_description = func.__doc__ or f"Run {func.__name__}"
|
|
198
|
+
|
|
199
|
+
# Get function signature and type hints
|
|
200
|
+
sig = inspect.signature(func)
|
|
201
|
+
type_hints = get_type_hints(func)
|
|
202
|
+
|
|
203
|
+
# Build Pydantic model fields from parameters
|
|
204
|
+
fields = {}
|
|
205
|
+
for param_name, param in sig.parameters.items():
|
|
206
|
+
param_type = type_hints.get(param_name, Any)
|
|
207
|
+
param_default = ... if param.default == inspect.Parameter.empty else param.default
|
|
208
|
+
fields[param_name] = (param_type, param_default)
|
|
209
|
+
|
|
210
|
+
# Create Pydantic model for schema
|
|
211
|
+
InputSchema = create_model(f"{tool_name}Input", **fields)
|
|
212
|
+
|
|
213
|
+
# Use 'coroutine' parameter for async functions, 'func' for sync functions
|
|
214
|
+
if asyncio.iscoroutinefunction(func):
|
|
215
|
+
return StructuredTool(
|
|
216
|
+
name=tool_name,
|
|
217
|
+
description=tool_description,
|
|
218
|
+
args_schema=InputSchema,
|
|
219
|
+
coroutine=func
|
|
220
|
+
)
|
|
221
|
+
else:
|
|
222
|
+
return StructuredTool(
|
|
223
|
+
name=tool_name,
|
|
224
|
+
description=tool_description,
|
|
225
|
+
args_schema=InputSchema,
|
|
226
|
+
func=func
|
|
227
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Everything related to tracing LLM calls
|
|
3
|
+
"""
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from langchain_core.messages import AIMessage, ToolMessage, BaseMessage
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_tool_trace_from_langchain(response: AIMessage, messages: list[BaseMessage]):
|
|
9
|
+
tool_traces = []
|
|
10
|
+
tool_calls = response.tool_calls
|
|
11
|
+
tool_messages = [message for message in messages if isinstance(message, ToolMessage)]
|
|
12
|
+
|
|
13
|
+
# error handling:
|
|
14
|
+
if len(tool_calls) != len(tool_messages):
|
|
15
|
+
raise ValueError("Number of tool calls and tool responses do not match."
|
|
16
|
+
f"len tool calls: {len(tool_calls)}, len responses: {len(tool_messages)}."
|
|
17
|
+
f"response: {response}, tool_messages: {tool_messages}")
|
|
18
|
+
|
|
19
|
+
for idx, tool_call in enumerate(tool_calls):
|
|
20
|
+
tool_name = tool_call['name']
|
|
21
|
+
tool_args = tool_call['args']
|
|
22
|
+
|
|
23
|
+
tool_input = f"{tool_name}({tool_args})"
|
|
24
|
+
output = tool_messages[idx].content
|
|
25
|
+
tool_traces.append(ToolTrace(tool_name, tool_input, output))
|
|
26
|
+
|
|
27
|
+
return tool_traces
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class ToolTrace:
|
|
31
|
+
tool_name: str
|
|
32
|
+
input: str
|
|
33
|
+
output: str
|
|
34
|
+
|
|
35
|
+
def __str__(self) -> str:
|
|
36
|
+
return f"{self.input}: {self.output}"
|
|
File without changes
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Union
|
|
3
|
+
from langchain_core.messages import HumanMessage
|
|
4
|
+
from src.feather_ai.internal_utils._multimodal import get_content_claude, get_content_gemini
|
|
5
|
+
from src.feather_ai.document import Document
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Prompt:
|
|
9
|
+
"""
|
|
10
|
+
The prompt class can be used to create a multimodal prompt with documents, images, etc.
|
|
11
|
+
"""
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
text: str,
|
|
15
|
+
documents: list[Union[str, Path, Document]] = None
|
|
16
|
+
):
|
|
17
|
+
self.text = text
|
|
18
|
+
self.documents: list[Document] = []
|
|
19
|
+
|
|
20
|
+
if documents:
|
|
21
|
+
for doc in documents:
|
|
22
|
+
if isinstance(doc, (str, Path)):
|
|
23
|
+
self.documents.append(Document.from_path(doc))
|
|
24
|
+
elif isinstance(doc, Document):
|
|
25
|
+
self.documents.append(doc)
|
|
26
|
+
else:
|
|
27
|
+
raise TypeError(f"Unsupported document type: {type(doc)}")
|
|
28
|
+
|
|
29
|
+
# create the messages (different for each provider)
|
|
30
|
+
text_content = [
|
|
31
|
+
{"type": "text", "text": text},
|
|
32
|
+
{"type": "text", "text": "The user uploaded the following documents:"},
|
|
33
|
+
{"type": "text", "text": f"[{[doc.filename for doc in self.documents]}]"}
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
self.message_map = {
|
|
37
|
+
"gemini": HumanMessage(
|
|
38
|
+
content=text_content + get_content_gemini(self.documents)
|
|
39
|
+
),
|
|
40
|
+
"claude": HumanMessage(
|
|
41
|
+
content=text_content + get_content_claude(self.documents)
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
else:
|
|
45
|
+
self.message = HumanMessage(content=text)
|
|
46
|
+
|
|
47
|
+
def get_message(self, provider: str) -> HumanMessage:
|
|
48
|
+
if provider in ["mistral", "openai"] and hasattr(self, "message_map"):
|
|
49
|
+
raise ValueError(
|
|
50
|
+
f"Provider {provider} does not support multimodal prompts yet."
|
|
51
|
+
f"Please process your PDF into text first and then give a simple text Prompt."
|
|
52
|
+
)
|
|
53
|
+
if self.documents:
|
|
54
|
+
try:
|
|
55
|
+
return self.message_map[provider]
|
|
56
|
+
except KeyError:
|
|
57
|
+
raise ValueError(f"Provider {provider} is not supported.")
|
|
58
|
+
|
|
59
|
+
return self.message
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""
|
|
2
|
+
feather_ai.tools
|
|
3
|
+
================
|
|
4
|
+
|
|
5
|
+
Public API for the feather_ai tools package.
|
|
6
|
+
Provides ready-to-use tools for AI agents.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .web import (
|
|
10
|
+
google_search,
|
|
11
|
+
google_search_async,
|
|
12
|
+
extract,
|
|
13
|
+
extract_async,
|
|
14
|
+
crawl,
|
|
15
|
+
crawl_async,
|
|
16
|
+
web_tools,
|
|
17
|
+
web_tools_async,
|
|
18
|
+
)
|
|
19
|
+
from .code_execution import code_execution_python
|
|
20
|
+
|
|
21
|
+
# Sync tools list
|
|
22
|
+
all_tools = [
|
|
23
|
+
google_search,
|
|
24
|
+
extract,
|
|
25
|
+
crawl,
|
|
26
|
+
code_execution_python,
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
# Async tools list
|
|
30
|
+
all_tools_async = [
|
|
31
|
+
google_search_async,
|
|
32
|
+
extract_async,
|
|
33
|
+
crawl_async,
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
# Web tools (sync)
|
|
38
|
+
"google_search",
|
|
39
|
+
"extract",
|
|
40
|
+
"crawl",
|
|
41
|
+
# Web tools (async)
|
|
42
|
+
"google_search_async",
|
|
43
|
+
"extract_async",
|
|
44
|
+
"crawl_async",
|
|
45
|
+
# Web tool lists
|
|
46
|
+
"web_tools",
|
|
47
|
+
"web_tools_async",
|
|
48
|
+
# Code execution
|
|
49
|
+
"code_execution_python",
|
|
50
|
+
# All tools lists
|
|
51
|
+
"all_tools",
|
|
52
|
+
"all_tools_async",
|
|
53
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Defines tools for native code execution.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from langchain_experimental.utilities import PythonREPL
|
|
6
|
+
|
|
7
|
+
_python_repl = None
|
|
8
|
+
|
|
9
|
+
def code_execution_python(python_code: str) -> str:
|
|
10
|
+
"""
|
|
11
|
+
A Python shell. Use this to execute python commands. Input should be a valid python command. If you want to see the output of a value, you should print it out with `print(...)`.
|
|
12
|
+
Returns:
|
|
13
|
+
Python output as a string
|
|
14
|
+
"""
|
|
15
|
+
global _python_repl
|
|
16
|
+
if not _python_repl:
|
|
17
|
+
_python_repl = PythonREPL()
|
|
18
|
+
|
|
19
|
+
return _python_repl.run(python_code)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from typing import Optional, List, Dict
|
|
4
|
+
from tavily import TavilyClient, AsyncTavilyClient
|
|
5
|
+
|
|
6
|
+
from src.feather_ai.internal_utils._exceptions import ApiKeyMissingException
|
|
7
|
+
|
|
8
|
+
_client: Optional[TavilyClient] = None
|
|
9
|
+
_async_client: Optional[TavilyClient] = None
|
|
10
|
+
|
|
11
|
+
def google_search(query: str) -> str:
|
|
12
|
+
"""
|
|
13
|
+
Simple google search tool for recent events and facts
|
|
14
|
+
Args:
|
|
15
|
+
query: Your search query
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
A curated list of relevant results
|
|
19
|
+
"""
|
|
20
|
+
global _client
|
|
21
|
+
if not os.getenv("TAVILY_API_KEY"):
|
|
22
|
+
raise ApiKeyMissingException(message="I you want to use the google search tool, please set the environment variable TAVILY_API_KEY."
|
|
23
|
+
"You can get a free API key at https://www.tavily.com/")
|
|
24
|
+
if not _client:
|
|
25
|
+
_client = TavilyClient(os.getenv("TAVILY_API_KEY"))
|
|
26
|
+
|
|
27
|
+
search_response = _client.search(query)
|
|
28
|
+
|
|
29
|
+
# Curate the results into a markdown string
|
|
30
|
+
def curate_results(result: dict):
|
|
31
|
+
"""Turn the search results into a markdown string for better readability"""
|
|
32
|
+
return (f"Title: {result.get("title", "")}\nurl: {result.get("url", "")}\n"
|
|
33
|
+
f"Content Snippets: {result.get("content", "")}")
|
|
34
|
+
|
|
35
|
+
curated_results = [curate_results(result) for result in search_response["results"]]
|
|
36
|
+
return "\n\n".join(curated_results)
|
|
37
|
+
|
|
38
|
+
async def google_search_async(query: str) -> str:
|
|
39
|
+
"""
|
|
40
|
+
Simple google search tool for recent events and facts
|
|
41
|
+
Performs a google search with the given query and returns a list of relevant search results
|
|
42
|
+
Args:
|
|
43
|
+
query: Your search query
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
A curated list of relevant results
|
|
47
|
+
"""
|
|
48
|
+
global _async_client
|
|
49
|
+
if not os.getenv("TAVILY_API_KEY"):
|
|
50
|
+
raise ApiKeyMissingException(
|
|
51
|
+
message="I you want to use the google search tool, please set the environment variable TAVILY_API_KEY."
|
|
52
|
+
"You can get a free API key at https://www.tavily.com/")
|
|
53
|
+
if not _async_client:
|
|
54
|
+
_async_client = AsyncTavilyClient(os.getenv("TAVILY_API_KEY"))
|
|
55
|
+
|
|
56
|
+
search_response = await _async_client.search(query)
|
|
57
|
+
|
|
58
|
+
# Curate the results into a markdown string
|
|
59
|
+
def curate_results(result: Dict[str, str]):
|
|
60
|
+
"""Turn the search results into a markdown string for better readability"""
|
|
61
|
+
return (f"Title: {result.get("title", "")}\nurl: {result.get("url", "")}\n"
|
|
62
|
+
f"Content Snippets: {result.get("content", "")}")
|
|
63
|
+
|
|
64
|
+
curated_results = [curate_results(result) for result in search_response["results"]]
|
|
65
|
+
return "\n\n".join(curated_results)
|
|
66
|
+
|
|
67
|
+
def extract(urls: List[str]) -> List[Dict]:
|
|
68
|
+
"""
|
|
69
|
+
Extracts raw content from the provided urls
|
|
70
|
+
Args:
|
|
71
|
+
urls: list of urls that should be extracted
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Dict with keys url, raw_content and favicon
|
|
75
|
+
"""
|
|
76
|
+
global _client
|
|
77
|
+
if not os.getenv("TAVILY_API_KEY"):
|
|
78
|
+
raise ApiKeyMissingException(
|
|
79
|
+
message="I you want to use web tools like extract, please set the environment variable TAVILY_API_KEY."
|
|
80
|
+
"You can get a free API key at https://www.tavily.com/")
|
|
81
|
+
if not _client:
|
|
82
|
+
_client = TavilyClient(os.getenv("TAVILY_API_KEY"))
|
|
83
|
+
|
|
84
|
+
response = _client.extract(urls)
|
|
85
|
+
|
|
86
|
+
from pprint import pprint
|
|
87
|
+
pprint(response)
|
|
88
|
+
|
|
89
|
+
# filter response to only include relevant keys
|
|
90
|
+
keys = ["title", "url", "raw_content", "favicon"]
|
|
91
|
+
errors = response.get("failed_results", [""])
|
|
92
|
+
filtered_dicts = [{k: result.get(k) for k in keys if k in result} for result in response["results"]]
|
|
93
|
+
|
|
94
|
+
return errors + filtered_dicts
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def extract_async(urls: List[str]) -> List[Dict]:
|
|
98
|
+
"""
|
|
99
|
+
Extracts raw content from the provided urls
|
|
100
|
+
Args:
|
|
101
|
+
urls: list of urls that should be extracted
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Dict with keys url, raw_content and favicon
|
|
105
|
+
"""
|
|
106
|
+
global _async_client
|
|
107
|
+
if not os.getenv("TAVILY_API_KEY"):
|
|
108
|
+
raise ApiKeyMissingException(
|
|
109
|
+
message="I you want to use web tools like extract, please set the environment variable TAVILY_API_KEY."
|
|
110
|
+
"You can get a free API key at https://www.tavily.com/")
|
|
111
|
+
if not _async_client:
|
|
112
|
+
_async_client = AsyncTavilyClient(os.getenv("TAVILY_API_KEY"))
|
|
113
|
+
|
|
114
|
+
response = await _async_client.extract(urls)
|
|
115
|
+
|
|
116
|
+
# filter response to only include relevant keys
|
|
117
|
+
keys = ["title", "url", "raw_content", "favicon"]
|
|
118
|
+
errors = response.get("failed_results", [""])
|
|
119
|
+
filtered_dicts = [{k: result.get(k) for k in keys if k in result} for result in response["results"]]
|
|
120
|
+
|
|
121
|
+
return errors + filtered_dicts
|
|
122
|
+
|
|
123
|
+
def crawl(base_url: str) -> List[str]:
|
|
124
|
+
"""
|
|
125
|
+
Extracts all subpages of the given url
|
|
126
|
+
Args:
|
|
127
|
+
base_url: the base url to start the crawl from
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
a list of suppages that can be extracted with the extract tool
|
|
131
|
+
"""
|
|
132
|
+
global _client
|
|
133
|
+
if not os.getenv("TAVILY_API_KEY"):
|
|
134
|
+
raise ApiKeyMissingException(message="I you want to use the google search tool, please set the environment variable TAVILY_API_KEY."
|
|
135
|
+
"You can get a free API key at https://www.tavily.com/")
|
|
136
|
+
if not _client:
|
|
137
|
+
_client = TavilyClient(os.getenv("TAVILY_API_KEY"))
|
|
138
|
+
|
|
139
|
+
response = _client.map(base_url)
|
|
140
|
+
|
|
141
|
+
return response["results"]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def crawl_async(base_url: str) -> List[str]:
|
|
145
|
+
"""
|
|
146
|
+
Extracts all subpages of the given url
|
|
147
|
+
Args:
|
|
148
|
+
base_url: the base url to start the crawl from
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
a list of suppages that can be extracted with the extract tool
|
|
152
|
+
"""
|
|
153
|
+
global _async_client
|
|
154
|
+
if not os.getenv("TAVILY_API_KEY"):
|
|
155
|
+
raise ApiKeyMissingException(message="I you want to use the google search tool, please set the environment variable TAVILY_API_KEY."
|
|
156
|
+
"You can get a free API key at https://www.tavily.com/")
|
|
157
|
+
if not _async_client:
|
|
158
|
+
_async_client = AsyncTavilyClient(os.getenv("TAVILY_API_KEY"))
|
|
159
|
+
|
|
160
|
+
response = await _async_client.map(base_url)
|
|
161
|
+
|
|
162
|
+
return response["results"]
|
|
163
|
+
|
|
164
|
+
web_tools = [google_search, extract, crawl]
|
|
165
|
+
web_tools_async = [google_search_async, extract_async, crawl_async]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
if __name__ == "__main__":
|
|
170
|
+
from dotenv import load_dotenv
|
|
171
|
+
from pprint import pprint
|
|
172
|
+
load_dotenv()
|
|
173
|
+
result = google_search("Champions League Winner 2025")
|
|
174
|
+
print(result)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This class exposes some utility functions commonly used for AI agents
|
|
3
|
+
"""
|
|
4
|
+
import os
|
|
5
|
+
import logging
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def load_instruction_from_file(
|
|
10
|
+
filename: str, default_instruction: str = "Default instruction."
|
|
11
|
+
) -> str:
|
|
12
|
+
"""Reads instruction text from a single file relative to the caller's working directory."""
|
|
13
|
+
instruction = default_instruction
|
|
14
|
+
try:
|
|
15
|
+
# Use current working directory instead of script location
|
|
16
|
+
filepath = os.path.join(os.getcwd(), filename)
|
|
17
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
18
|
+
instruction = f.read()
|
|
19
|
+
logger.info("Successfully loaded instruction from %s", filename)
|
|
20
|
+
except FileNotFoundError:
|
|
21
|
+
logger.warning("Instruction file not found: %s. Using default.", filepath)
|
|
22
|
+
except Exception as e:
|
|
23
|
+
logger.exception("ERROR loading instruction file %s: %s. Using default.", filepath, e)
|
|
24
|
+
return instruction
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: feather-ai-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The lightest Agentic AI Framework you'll ever see
|
|
5
|
+
Author: Luca Bozzetti
|
|
6
|
+
Requires-Python: <3.14,>=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: langchain-google-genai>=3.1.0
|
|
9
|
+
Requires-Dist: langchain-openai>=1.0.3
|
|
10
|
+
Requires-Dist: langchain-anthropic>=1.1.0
|
|
11
|
+
Requires-Dist: langchain-mistralai>=1.0.1
|
|
12
|
+
Requires-Dist: tavily-python>=00.7.13
|
|
13
|
+
Requires-Dist: langchain-experimental>=0.4.0
|
|
14
|
+
|
|
15
|
+
# Colors:
|
|
16
|
+
|
|
17
|
+
BLUE VARIANTS:
|
|
18
|
+
a) #0357c1
|
|
19
|
+
b) #22c4e0
|
|
20
|
+
|
|
21
|
+
PINK VARIANTS:
|
|
22
|
+
a) #be3389
|
|
23
|
+
|
|
24
|
+
ORANGE/BROWN VARIANTS:
|
|
25
|
+
a) #dfa987
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/__init__.py
|
|
4
|
+
src/feather_ai/__init__.py
|
|
5
|
+
src/feather_ai/agent.py
|
|
6
|
+
src/feather_ai/document.py
|
|
7
|
+
src/feather_ai/prompt.py
|
|
8
|
+
src/feather_ai/utils.py
|
|
9
|
+
src/feather_ai/internal_utils/__init__.py
|
|
10
|
+
src/feather_ai/internal_utils/_exceptions.py
|
|
11
|
+
src/feather_ai/internal_utils/_multimodal.py
|
|
12
|
+
src/feather_ai/internal_utils/_provider.py
|
|
13
|
+
src/feather_ai/internal_utils/_response.py
|
|
14
|
+
src/feather_ai/internal_utils/_structured_tool.py
|
|
15
|
+
src/feather_ai/internal_utils/_tools.py
|
|
16
|
+
src/feather_ai/internal_utils/_tracing.py
|
|
17
|
+
src/feather_ai/orchestration/__init__.py
|
|
18
|
+
src/feather_ai/tools/__init__.py
|
|
19
|
+
src/feather_ai/tools/code_execution.py
|
|
20
|
+
src/feather_ai/tools/web.py
|
|
21
|
+
src/feather_ai_sdk.egg-info/PKG-INFO
|
|
22
|
+
src/feather_ai_sdk.egg-info/SOURCES.txt
|
|
23
|
+
src/feather_ai_sdk.egg-info/dependency_links.txt
|
|
24
|
+
src/feather_ai_sdk.egg-info/requires.txt
|
|
25
|
+
src/feather_ai_sdk.egg-info/top_level.txt
|
|
26
|
+
tests/test_agent.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Testing the AIAgent baseclass
|
|
3
|
+
Models:
|
|
4
|
+
claude-haiku-4-5
|
|
5
|
+
gpt-5-nano
|
|
6
|
+
gemini-2.5-flash-lite
|
|
7
|
+
mistral-small-2506
|
|
8
|
+
"""
|
|
9
|
+
import asyncio
|
|
10
|
+
import os
|
|
11
|
+
from pprint import pprint
|
|
12
|
+
|
|
13
|
+
from dotenv import load_dotenv
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
import logging
|
|
16
|
+
|
|
17
|
+
from src.feather_ai.tools.code_execution import code_execution_python
|
|
18
|
+
from src.feather_ai.tools.web import google_search, web_tools, web_tools_async
|
|
19
|
+
|
|
20
|
+
logging.basicConfig(level=logging.INFO)
|
|
21
|
+
|
|
22
|
+
from src.feather_ai import AIAgent
|
|
23
|
+
from src.feather_ai.prompt import Prompt
|
|
24
|
+
from src.feather_ai.utils import load_instruction_from_file
|
|
25
|
+
|
|
26
|
+
load_dotenv()
|
|
27
|
+
|
|
28
|
+
models = ["claude-haiku-4-5", "gpt-5-nano", "gemini-2.5-flash-lite", "mistral-small-2506"]
|
|
29
|
+
models_small = ["gemini-2.5-flash-lite"]
|
|
30
|
+
files = ["text_file.txt", "ocr_pdf_test.pdf", "ocr_image_test.jpeg"]
|
|
31
|
+
|
|
32
|
+
class Response(BaseModel):
|
|
33
|
+
answer: str = Field(..., description="The answer to the question")
|
|
34
|
+
confidence: float = Field(..., description="How confident from 0-1 you are in your answer")
|
|
35
|
+
def get_weather(location: str):
|
|
36
|
+
return f"The weather in {location} is rainy today."
|
|
37
|
+
|
|
38
|
+
def test_base_agent():
|
|
39
|
+
question = "What is the capital of France?"
|
|
40
|
+
print(question)
|
|
41
|
+
for model in models:
|
|
42
|
+
agent = AIAgent(model, instructions=load_instruction_from_file("test_instructions.txt"))
|
|
43
|
+
resp = agent.run("What is the capital of France?")
|
|
44
|
+
print(f"{model}: {resp.content}")
|
|
45
|
+
|
|
46
|
+
def test_multimodal():
|
|
47
|
+
for model in models:
|
|
48
|
+
print("-"*10 + f"Testing {model}" + "-"*10)
|
|
49
|
+
prompt = Prompt(
|
|
50
|
+
text="Please summarize the following documents each in 1 sentence",
|
|
51
|
+
documents=[os.path.join("test_docs", file) for file in files]
|
|
52
|
+
)
|
|
53
|
+
agent = AIAgent(model)
|
|
54
|
+
try:
|
|
55
|
+
resp = agent.run(prompt)
|
|
56
|
+
except ValueError as e:
|
|
57
|
+
print(f"xxx Error for model {model}: {e}")
|
|
58
|
+
continue
|
|
59
|
+
print(resp.content)
|
|
60
|
+
|
|
61
|
+
def test_tool_calling():
|
|
62
|
+
print("=== Testing Tool calling ===")
|
|
63
|
+
for model in models_small:
|
|
64
|
+
agent = AIAgent(model, tools=[google_search])
|
|
65
|
+
resp = agent.run("What is the weather in Paris today? Use google search to find the answer.")
|
|
66
|
+
print(f"{model}: {resp.content}, Tool calls: {[str(tool_call) for tool_call in resp.tool_calls]}")
|
|
67
|
+
pprint(resp.input_messages)
|
|
68
|
+
|
|
69
|
+
def test_structured_output():
|
|
70
|
+
print("=== Testing Structured Output ===")
|
|
71
|
+
for model in models:
|
|
72
|
+
agent = AIAgent(model, output_schema=Response)
|
|
73
|
+
resp = agent.run("What is the capital of France?")
|
|
74
|
+
pprint(f"{model}: {resp.content}")
|
|
75
|
+
assert isinstance(resp.content, Response)
|
|
76
|
+
|
|
77
|
+
def test_tool_calling_with_structured_output():
|
|
78
|
+
print("=== Testing Tool calling with Structured Output ===")
|
|
79
|
+
for model in models_small:
|
|
80
|
+
agent = AIAgent(model, tools=[get_weather], output_schema=Response)
|
|
81
|
+
resp = agent.run("What is the weather in Paris today?")
|
|
82
|
+
pprint(f"{model}: {resp.content}")
|
|
83
|
+
assert isinstance(resp.content, Response)
|
|
84
|
+
|
|
85
|
+
def test_multiple_tools():
|
|
86
|
+
print("=== Testing Multiple tools ===")
|
|
87
|
+
system_message = ("You are a helpful assistant that can interact with several tools."
|
|
88
|
+
"You will be given a question and you must answer it using the tools you have."
|
|
89
|
+
"First, just call the get_weather tool with the location as an argument."
|
|
90
|
+
"Then once you get an answer, use the get_tips tool to provide some tips based on the weather.")
|
|
91
|
+
def get_tips(weather: str):
|
|
92
|
+
return f"If its {weather}, bring an umbrella!"
|
|
93
|
+
for model in models:
|
|
94
|
+
agent = AIAgent(model, instructions=system_message, tools=[get_weather, get_tips], output_schema=Response)
|
|
95
|
+
resp = agent.run("I am going to Paris today. What should I do?")
|
|
96
|
+
pprint(f"{model}: {resp.content}")
|
|
97
|
+
assert isinstance(resp.content, Response)
|
|
98
|
+
|
|
99
|
+
async def test_complex_async_tooling():
|
|
100
|
+
print("=== Testing Multiple tools ===")
|
|
101
|
+
system_message = ("You are a helpful assistant that can interact with several tools."
|
|
102
|
+
"You will be given a question and you must answer it using the tools you have."
|
|
103
|
+
"First, just call the get_weather tool with the location as an argument."
|
|
104
|
+
"Then once you get an answer, use the get_tips tool to provide some tips based on the weather.")
|
|
105
|
+
|
|
106
|
+
async def get_weather(location: str):
|
|
107
|
+
if location == "Munich":
|
|
108
|
+
return f"The weather in Munich is sunny today."
|
|
109
|
+
return f"The weather in {location} is rainy today."
|
|
110
|
+
async def get_tips(weather: str, location: str):
|
|
111
|
+
if weather == "sunny":
|
|
112
|
+
return f"Go for a walk in the english garden!"
|
|
113
|
+
return f"If its {weather} in {location}, bring an umbrella!"
|
|
114
|
+
|
|
115
|
+
agent = AIAgent("gemini-2.5-flash-lite", instructions=system_message, tools=[get_weather, get_tips], output_schema=Response)
|
|
116
|
+
resp = await agent.arun("Please check what my friends in Paris, Munich and Singapore should do today. Keep your answer in 3 short sentences.")
|
|
117
|
+
print(resp)
|
|
118
|
+
assert isinstance(resp.content, Response)
|
|
119
|
+
|
|
120
|
+
def test_async_run():
|
|
121
|
+
async def test_run():
|
|
122
|
+
agent = AIAgent("claude-haiku-4-5")
|
|
123
|
+
resp = await agent.arun("What is the capital of France?")
|
|
124
|
+
print(resp.content)
|
|
125
|
+
|
|
126
|
+
asyncio.run(test_run())
|
|
127
|
+
|
|
128
|
+
def test_complex_tools():
|
|
129
|
+
agent = AIAgent("gemini-2.5-flash-lite", tools=[*web_tools])
|
|
130
|
+
resp = agent.run("Search for a python code execution tool in langchain. Use google_search and then extract from the provided urls.")
|
|
131
|
+
print(resp)
|
|
132
|
+
print("--------------------------------")
|
|
133
|
+
pprint(resp.input_messages)
|
|
134
|
+
|
|
135
|
+
async def test_async_complex_tools():
|
|
136
|
+
agent = AIAgent("gemini-2.5-flash-lite", tools=[code_execution_python, *web_tools_async])
|
|
137
|
+
resp = await agent.arun(""
|
|
138
|
+
"Please scrape this base url: https://docs.langchain.com/oss/python/langchain"
|
|
139
|
+
"and this: https://google.github.io/adk-docs/ for a code execution tool in both frameworks. Give me an overview which one is easier to use.")
|
|
140
|
+
print(resp)
|
|
141
|
+
print("--------------------------------")
|
|
142
|
+
pprint(resp.input_messages)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
asyncio.run(test_async_complex_tools())
|