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.
Files changed (28) hide show
  1. feather_ai_sdk-0.1.0/PKG-INFO +25 -0
  2. feather_ai_sdk-0.1.0/README.md +11 -0
  3. feather_ai_sdk-0.1.0/pyproject.toml +19 -0
  4. feather_ai_sdk-0.1.0/setup.cfg +4 -0
  5. feather_ai_sdk-0.1.0/src/__init__.py +0 -0
  6. feather_ai_sdk-0.1.0/src/feather_ai/__init__.py +22 -0
  7. feather_ai_sdk-0.1.0/src/feather_ai/agent.py +115 -0
  8. feather_ai_sdk-0.1.0/src/feather_ai/document.py +35 -0
  9. feather_ai_sdk-0.1.0/src/feather_ai/internal_utils/__init__.py +0 -0
  10. feather_ai_sdk-0.1.0/src/feather_ai/internal_utils/_exceptions.py +29 -0
  11. feather_ai_sdk-0.1.0/src/feather_ai/internal_utils/_multimodal.py +62 -0
  12. feather_ai_sdk-0.1.0/src/feather_ai/internal_utils/_provider.py +57 -0
  13. feather_ai_sdk-0.1.0/src/feather_ai/internal_utils/_response.py +21 -0
  14. feather_ai_sdk-0.1.0/src/feather_ai/internal_utils/_structured_tool.py +62 -0
  15. feather_ai_sdk-0.1.0/src/feather_ai/internal_utils/_tools.py +227 -0
  16. feather_ai_sdk-0.1.0/src/feather_ai/internal_utils/_tracing.py +36 -0
  17. feather_ai_sdk-0.1.0/src/feather_ai/orchestration/__init__.py +0 -0
  18. feather_ai_sdk-0.1.0/src/feather_ai/prompt.py +59 -0
  19. feather_ai_sdk-0.1.0/src/feather_ai/tools/__init__.py +53 -0
  20. feather_ai_sdk-0.1.0/src/feather_ai/tools/code_execution.py +19 -0
  21. feather_ai_sdk-0.1.0/src/feather_ai/tools/web.py +174 -0
  22. feather_ai_sdk-0.1.0/src/feather_ai/utils.py +24 -0
  23. feather_ai_sdk-0.1.0/src/feather_ai_sdk.egg-info/PKG-INFO +25 -0
  24. feather_ai_sdk-0.1.0/src/feather_ai_sdk.egg-info/SOURCES.txt +26 -0
  25. feather_ai_sdk-0.1.0/src/feather_ai_sdk.egg-info/dependency_links.txt +1 -0
  26. feather_ai_sdk-0.1.0/src/feather_ai_sdk.egg-info/requires.txt +6 -0
  27. feather_ai_sdk-0.1.0/src/feather_ai_sdk.egg-info/top_level.txt +2 -0
  28. 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,11 @@
1
+ # Colors:
2
+
3
+ BLUE VARIANTS:
4
+ a) #0357c1
5
+ b) #22c4e0
6
+
7
+ PINK VARIANTS:
8
+ a) #be3389
9
+
10
+ ORANGE/BROWN VARIANTS:
11
+ 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"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
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)
@@ -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}"
@@ -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,6 @@
1
+ langchain-google-genai>=3.1.0
2
+ langchain-openai>=1.0.3
3
+ langchain-anthropic>=1.1.0
4
+ langchain-mistralai>=1.0.1
5
+ tavily-python>=00.7.13
6
+ langchain-experimental>=0.4.0
@@ -0,0 +1,2 @@
1
+ __init__
2
+ feather_ai
@@ -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())