pyagentic-core 1.0.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.
- pyagentic_core-1.0.0/LICENSE +21 -0
- pyagentic_core-1.0.0/PKG-INFO +112 -0
- pyagentic_core-1.0.0/README.md +94 -0
- pyagentic_core-1.0.0/pyagentic/__init__.py +6 -0
- pyagentic_core-1.0.0/pyagentic/_base/__init__.py +0 -0
- pyagentic_core-1.0.0/pyagentic/_base/_agent.py +190 -0
- pyagentic_core-1.0.0/pyagentic/_base/_context.py +216 -0
- pyagentic_core-1.0.0/pyagentic/_base/_exceptions.py +39 -0
- pyagentic_core-1.0.0/pyagentic/_base/_metaclasses.py +153 -0
- pyagentic_core-1.0.0/pyagentic/_base/_params.py +138 -0
- pyagentic_core-1.0.0/pyagentic/_base/_resolver.py +51 -0
- pyagentic_core-1.0.0/pyagentic/_base/_tool.py +154 -0
- pyagentic_core-1.0.0/pyagentic/logging.py +53 -0
- pyagentic_core-1.0.0/pyagentic/updates.py +26 -0
- pyagentic_core-1.0.0/pyagentic_core.egg-info/PKG-INFO +112 -0
- pyagentic_core-1.0.0/pyagentic_core.egg-info/SOURCES.txt +19 -0
- pyagentic_core-1.0.0/pyagentic_core.egg-info/dependency_links.txt +1 -0
- pyagentic_core-1.0.0/pyagentic_core.egg-info/requires.txt +4 -0
- pyagentic_core-1.0.0/pyagentic_core.egg-info/top_level.txt +1 -0
- pyagentic_core-1.0.0/pyproject.toml +70 -0
- pyagentic_core-1.0.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Ryan Mikulec
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyagentic-core
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Build LLM Agents in a Pythonic way
|
|
5
|
+
Author-email: Ryan Mikulec <rmikulec.dev@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.13
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: colorlog>=6.9.0
|
|
14
|
+
Requires-Dist: ipykernel>=6.29.5
|
|
15
|
+
Requires-Dist: openai>=1.93.2
|
|
16
|
+
Requires-Dist: typeguard>=4.4.4
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# PyAgentic
|
|
20
|
+
|
|
21
|
+
[](https://www.python.org/downloads/)
|
|
22
|
+
[](https://opensource.org/licenses/MIT)
|
|
23
|
+
[](https://github.com/psf/black)
|
|
24
|
+
[](https://github.com/rmikulec/pyAgentic/actions/workflows/testing.yml?query=branch%3Amain)
|
|
25
|
+
|
|
26
|
+
A declarative framework for building AI agents with OpenAI integration. PyAgentic provides a clean, type-safe way to create intelligent agents using Python's metaclass system and modern async patterns.
|
|
27
|
+
|
|
28
|
+
## Features
|
|
29
|
+
|
|
30
|
+
- **Declarative Agent Definition** - Define agents using simple class-based syntax
|
|
31
|
+
- **Type Safety** - Full typing support with Pydantic integration
|
|
32
|
+
- **Tool Integration** - Easy function decoration for agent capabilities
|
|
33
|
+
- **Context Management** - Sophisticated context handling with lifecycle management
|
|
34
|
+
- **OpenAI Integration** - Native support for OpenAI's API with automatic schema generation
|
|
35
|
+
- **Async Support** - Built-in async/await support for scalable applications
|
|
36
|
+
- **Extensible** - Clean architecture for custom tools, context types, and validations
|
|
37
|
+
|
|
38
|
+
## π Quick Start
|
|
39
|
+
|
|
40
|
+
### Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install pyagentic-core
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Basic Example
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from pyagentic import Agent, tool, ContextItem
|
|
50
|
+
from typing import List
|
|
51
|
+
|
|
52
|
+
class WeatherAgent(Agent):
|
|
53
|
+
"""An agent that provides weather information."""
|
|
54
|
+
|
|
55
|
+
location: str = ContextItem(description="Current location")
|
|
56
|
+
|
|
57
|
+
@tool
|
|
58
|
+
def get_weather(self, city: str) -> str:
|
|
59
|
+
"""Get current weather for a city."""
|
|
60
|
+
# Your weather API logic here
|
|
61
|
+
return f"The weather in {city} is sunny and 75Β°F"
|
|
62
|
+
|
|
63
|
+
@tool
|
|
64
|
+
def get_forecast(self, city: str, days: int = 5) -> List[str]:
|
|
65
|
+
"""Get weather forecast for multiple days."""
|
|
66
|
+
return [f"Day {i+1}: Partly cloudy" for i in range(days)]
|
|
67
|
+
|
|
68
|
+
# Create and use the agent
|
|
69
|
+
agent = WeatherAgent(location="San Francisco")
|
|
70
|
+
response = await agent.run("What's the weather like in New York?")
|
|
71
|
+
print(response)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
## Project Structure
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
pyagentic/
|
|
79
|
+
βββ pyagentic/ # Core framework code
|
|
80
|
+
β βββ _base/ # Internal implementation
|
|
81
|
+
β βββ __init__.py # Public API
|
|
82
|
+
βββ tests/ # Test suite
|
|
83
|
+
β βββ _base/ # Core tests
|
|
84
|
+
β βββ integration/ # Integration tests
|
|
85
|
+
β βββ performance/ # Performance tests
|
|
86
|
+
βββ examples/ # Example agents
|
|
87
|
+
βββ templates/ # Agent templates
|
|
88
|
+
βββ docs/ # Documentation
|
|
89
|
+
βββ scripts/ # Utility scripts
|
|
90
|
+
βββ notebooks/ # Jupyter notebooks
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Contributing
|
|
94
|
+
|
|
95
|
+
Contributions are welcome! Details coming soon.
|
|
96
|
+
|
|
97
|
+
### Development Setup
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
# Install dependencies
|
|
101
|
+
uv sync --group dev
|
|
102
|
+
|
|
103
|
+
# Formatting
|
|
104
|
+
uv run black -l99 pyagentic
|
|
105
|
+
|
|
106
|
+
# Linting
|
|
107
|
+
uv run flake8 --max-line-length 99 pyagentic
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## π License
|
|
111
|
+
|
|
112
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# PyAgentic
|
|
2
|
+
|
|
3
|
+
[](https://www.python.org/downloads/)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://github.com/psf/black)
|
|
6
|
+
[](https://github.com/rmikulec/pyAgentic/actions/workflows/testing.yml?query=branch%3Amain)
|
|
7
|
+
|
|
8
|
+
A declarative framework for building AI agents with OpenAI integration. PyAgentic provides a clean, type-safe way to create intelligent agents using Python's metaclass system and modern async patterns.
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
- **Declarative Agent Definition** - Define agents using simple class-based syntax
|
|
13
|
+
- **Type Safety** - Full typing support with Pydantic integration
|
|
14
|
+
- **Tool Integration** - Easy function decoration for agent capabilities
|
|
15
|
+
- **Context Management** - Sophisticated context handling with lifecycle management
|
|
16
|
+
- **OpenAI Integration** - Native support for OpenAI's API with automatic schema generation
|
|
17
|
+
- **Async Support** - Built-in async/await support for scalable applications
|
|
18
|
+
- **Extensible** - Clean architecture for custom tools, context types, and validations
|
|
19
|
+
|
|
20
|
+
## π Quick Start
|
|
21
|
+
|
|
22
|
+
### Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install pyagentic-core
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Basic Example
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from pyagentic import Agent, tool, ContextItem
|
|
32
|
+
from typing import List
|
|
33
|
+
|
|
34
|
+
class WeatherAgent(Agent):
|
|
35
|
+
"""An agent that provides weather information."""
|
|
36
|
+
|
|
37
|
+
location: str = ContextItem(description="Current location")
|
|
38
|
+
|
|
39
|
+
@tool
|
|
40
|
+
def get_weather(self, city: str) -> str:
|
|
41
|
+
"""Get current weather for a city."""
|
|
42
|
+
# Your weather API logic here
|
|
43
|
+
return f"The weather in {city} is sunny and 75Β°F"
|
|
44
|
+
|
|
45
|
+
@tool
|
|
46
|
+
def get_forecast(self, city: str, days: int = 5) -> List[str]:
|
|
47
|
+
"""Get weather forecast for multiple days."""
|
|
48
|
+
return [f"Day {i+1}: Partly cloudy" for i in range(days)]
|
|
49
|
+
|
|
50
|
+
# Create and use the agent
|
|
51
|
+
agent = WeatherAgent(location="San Francisco")
|
|
52
|
+
response = await agent.run("What's the weather like in New York?")
|
|
53
|
+
print(response)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
## Project Structure
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
pyagentic/
|
|
61
|
+
βββ pyagentic/ # Core framework code
|
|
62
|
+
β βββ _base/ # Internal implementation
|
|
63
|
+
β βββ __init__.py # Public API
|
|
64
|
+
βββ tests/ # Test suite
|
|
65
|
+
β βββ _base/ # Core tests
|
|
66
|
+
β βββ integration/ # Integration tests
|
|
67
|
+
β βββ performance/ # Performance tests
|
|
68
|
+
βββ examples/ # Example agents
|
|
69
|
+
βββ templates/ # Agent templates
|
|
70
|
+
βββ docs/ # Documentation
|
|
71
|
+
βββ scripts/ # Utility scripts
|
|
72
|
+
βββ notebooks/ # Jupyter notebooks
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Contributing
|
|
76
|
+
|
|
77
|
+
Contributions are welcome! Details coming soon.
|
|
78
|
+
|
|
79
|
+
### Development Setup
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
# Install dependencies
|
|
83
|
+
uv sync --group dev
|
|
84
|
+
|
|
85
|
+
# Formatting
|
|
86
|
+
uv run black -l99 pyagentic
|
|
87
|
+
|
|
88
|
+
# Linting
|
|
89
|
+
uv run flake8 --max-line-length 99 pyagentic
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## π License
|
|
93
|
+
|
|
94
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
from pyagentic._base._agent import Agent
|
|
2
|
+
from pyagentic._base._params import Param, ParamInfo
|
|
3
|
+
from pyagentic._base._tool import tool
|
|
4
|
+
from pyagentic._base._context import computed_context, ContextRef, ContextItem
|
|
5
|
+
|
|
6
|
+
__all__ = ["Agent", "Param", "ParamInfo", "tool", "computed_context", "ContextRef", "ContextItem"]
|
|
File without changes
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import json
|
|
3
|
+
import openai
|
|
4
|
+
from typing import Callable, Any, TypeVar, ClassVar
|
|
5
|
+
|
|
6
|
+
from pyagentic.logging import get_logger
|
|
7
|
+
from pyagentic._base._tool import _ToolDefinition
|
|
8
|
+
from pyagentic._base._context import ContextItem
|
|
9
|
+
from pyagentic._base._metaclasses import AgentMeta
|
|
10
|
+
from pyagentic.updates import AiUpdate, Status, EmitUpdate, ToolUpdate
|
|
11
|
+
|
|
12
|
+
logger = get_logger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def _safe_run(fn, *args, **kwargs):
|
|
16
|
+
"""
|
|
17
|
+
Helper function to always run a function, async or not
|
|
18
|
+
"""
|
|
19
|
+
if inspect.iscoroutinefunction(fn):
|
|
20
|
+
result = await fn(*args, **kwargs)
|
|
21
|
+
else:
|
|
22
|
+
result = fn(*args, **kwargs)
|
|
23
|
+
return result
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Agent(metaclass=AgentMeta):
|
|
27
|
+
__abstract_base__ = True
|
|
28
|
+
"""
|
|
29
|
+
Base agent class to be extended in order to define a new Agent
|
|
30
|
+
|
|
31
|
+
Agent defintion requires the use of special function decorators in order to define the
|
|
32
|
+
behavior of the agent.
|
|
33
|
+
|
|
34
|
+
- @tool: Declares a method as a tool, allowing the agent to use it
|
|
35
|
+
|
|
36
|
+
Agents also have default arguements that can be declared on initiation
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
- model (str): The OpenAI model that will be used for inference. Defaults to value
|
|
40
|
+
found in `geo_assistant.config`
|
|
41
|
+
- emitter (Callable): A function that will be called to recieve intermittant information
|
|
42
|
+
about the agent's process. A common use case is that of a websocket, to be able
|
|
43
|
+
to recieve information about the process as it is happening
|
|
44
|
+
"""
|
|
45
|
+
# Class Attributes
|
|
46
|
+
__tool_defs__: ClassVar[dict[str, _ToolDefinition]]
|
|
47
|
+
__context_attrs__: ClassVar[dict[str, tuple[TypeVar, ContextItem]]]
|
|
48
|
+
__system_message__: ClassVar[str]
|
|
49
|
+
__input_template__: ClassVar[str] = None
|
|
50
|
+
|
|
51
|
+
# Base Attributes
|
|
52
|
+
model: str
|
|
53
|
+
api_key: str
|
|
54
|
+
emitter: Callable[[Any], str] = None
|
|
55
|
+
|
|
56
|
+
def __post_init__(self):
|
|
57
|
+
self.client: openai.AsyncOpenAI = openai.AsyncOpenAI(api_key=self.api_key)
|
|
58
|
+
|
|
59
|
+
async def _process_tool_call(self, tool_call) -> bool:
|
|
60
|
+
if tool_call.type != "function_call":
|
|
61
|
+
return False
|
|
62
|
+
self.context._messages.append(tool_call)
|
|
63
|
+
logger.info(f"Calling {tool_call.name} with kwargs: {tool_call.arguments}")
|
|
64
|
+
# Lookup the bound method
|
|
65
|
+
try:
|
|
66
|
+
tool_def = self.__tool_defs__[tool_call.name]
|
|
67
|
+
handler = getattr(self, tool_call.name)
|
|
68
|
+
except KeyError:
|
|
69
|
+
return f"Tool {tool_call.name} not found"
|
|
70
|
+
kwargs = json.loads(tool_call.arguments)
|
|
71
|
+
|
|
72
|
+
# Run the tool, emitting updates
|
|
73
|
+
try:
|
|
74
|
+
if self.emitter:
|
|
75
|
+
await _safe_run(
|
|
76
|
+
self.emitter,
|
|
77
|
+
ToolUpdate(
|
|
78
|
+
status=Status.PROCESSING, tool_call=tool_call.name, tool_args=kwargs
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
compiled_args = tool_def.compile_args(**kwargs)
|
|
83
|
+
result = await _safe_run(handler, **compiled_args)
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logger.exception(e)
|
|
86
|
+
result = f"Tool `{tool_call.name}` failed: {e}. Please kindly state to the user that is failed, provide context, and ask if they want to try again." # noqa E501
|
|
87
|
+
if self.emitter:
|
|
88
|
+
await _safe_run(
|
|
89
|
+
self.emitter,
|
|
90
|
+
ToolUpdate(status=Status.ERROR, tool_call=tool_call.name, tool_args=kwargs),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Record output for LLM
|
|
94
|
+
self.context._messages.append(
|
|
95
|
+
{"type": "function_call_output", "call_id": tool_call.call_id, "output": result}
|
|
96
|
+
)
|
|
97
|
+
return True
|
|
98
|
+
|
|
99
|
+
async def _build_tool_defs(self) -> list[dict]:
|
|
100
|
+
tool_defs = []
|
|
101
|
+
# iterate through registered tools
|
|
102
|
+
for tool_def in self.__tool_defs__.values():
|
|
103
|
+
# Check if any of the tool params use a ContextRef
|
|
104
|
+
# convert to openai schema
|
|
105
|
+
tool_defs.append(tool_def.to_openai(self.context))
|
|
106
|
+
return tool_defs
|
|
107
|
+
|
|
108
|
+
async def run(self, input_: str) -> str:
|
|
109
|
+
"""
|
|
110
|
+
Run the agent with any given input
|
|
111
|
+
|
|
112
|
+
Parameters:
|
|
113
|
+
input_(str): The user input for the agent to process
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
str: The output of the agent
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
# Generate and insert the new system message
|
|
120
|
+
self.context.add_user_message(input_)
|
|
121
|
+
|
|
122
|
+
# Create the tool list
|
|
123
|
+
tool_defs = await self._build_tool_defs()
|
|
124
|
+
|
|
125
|
+
# Begin the first pass on generating a response from openai
|
|
126
|
+
if self.emitter:
|
|
127
|
+
await _safe_run(
|
|
128
|
+
self.emitter,
|
|
129
|
+
EmitUpdate(
|
|
130
|
+
status=Status.GENERATING,
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
try:
|
|
134
|
+
response = await self.client.responses.create(
|
|
135
|
+
model=self.model,
|
|
136
|
+
input=self.context.messages,
|
|
137
|
+
tools=tool_defs,
|
|
138
|
+
)
|
|
139
|
+
reasoning = [rx.to_dict() for rx in response.output if rx.type == "reasoning"]
|
|
140
|
+
tool_calls = [rx for rx in response.output if rx.type == "function_call"]
|
|
141
|
+
except Exception as e:
|
|
142
|
+
logger.exception(e)
|
|
143
|
+
# On failure, emit an udpate, update the messages, and return a standard message
|
|
144
|
+
if self.emitter:
|
|
145
|
+
await _safe_run(
|
|
146
|
+
self.emitter,
|
|
147
|
+
EmitUpdate(
|
|
148
|
+
status=Status.ERROR,
|
|
149
|
+
),
|
|
150
|
+
)
|
|
151
|
+
self.context._messages.append(
|
|
152
|
+
{"role": "assistant", "content": "Failed to generate a response"}
|
|
153
|
+
)
|
|
154
|
+
return f"OpenAI failed to generate a response: {e}"
|
|
155
|
+
|
|
156
|
+
if reasoning:
|
|
157
|
+
self.context._messages.extend(reasoning)
|
|
158
|
+
|
|
159
|
+
# Dispatch any tool calls
|
|
160
|
+
made_calls = False
|
|
161
|
+
for tool_call in tool_calls:
|
|
162
|
+
made_calls = made_calls or (await self._process_tool_call(tool_call))
|
|
163
|
+
|
|
164
|
+
# If tools ran, re-invoke LLM for natural reply
|
|
165
|
+
if made_calls:
|
|
166
|
+
try:
|
|
167
|
+
response = await self.client.responses.create(
|
|
168
|
+
model=self.model,
|
|
169
|
+
input=self.context.messages,
|
|
170
|
+
)
|
|
171
|
+
except Exception as e:
|
|
172
|
+
logger.exception(e)
|
|
173
|
+
if self.emitter:
|
|
174
|
+
await _safe_run(
|
|
175
|
+
self.emitter, EmitUpdate(status=Status.ERROR, message="Generation failed")
|
|
176
|
+
)
|
|
177
|
+
self.context._messages.append(
|
|
178
|
+
{"role": "assistant", "content": "Failed to generate a response"}
|
|
179
|
+
)
|
|
180
|
+
return f"OpenAI failed to generate a response: {e}"
|
|
181
|
+
|
|
182
|
+
# Parse and finalize the Ai Response
|
|
183
|
+
ai_message = response.output_text
|
|
184
|
+
|
|
185
|
+
self.context._messages.append({"role": "assistant", "content": ai_message})
|
|
186
|
+
|
|
187
|
+
if self.emitter:
|
|
188
|
+
await _safe_run(self.emitter, AiUpdate(status=Status.SUCCEDED, message=ai_message))
|
|
189
|
+
|
|
190
|
+
return ai_message
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
from typing import Any, Callable, Type, Self
|
|
3
|
+
from dataclasses import dataclass, make_dataclass, field, asdict
|
|
4
|
+
|
|
5
|
+
from pyagentic._base._exceptions import InvalidContextRefNotFoundInContext
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ContextItem:
|
|
10
|
+
"""
|
|
11
|
+
A `ContextItem` is used to signal that a class attribute can be used in the context
|
|
12
|
+
of an agent. Any of these values can be referenced in:
|
|
13
|
+
- the agent's `instructions`
|
|
14
|
+
- the agent's `input_template`
|
|
15
|
+
- any `ContextRef` used in the Agent (e.g., in a `ParamInfo`)
|
|
16
|
+
- the constructor of the Agent itself
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
default (Any, optional):
|
|
20
|
+
The default value for this context item if no explicit value is provided.
|
|
21
|
+
Defaults to `None`.
|
|
22
|
+
default_factory (Callable[[], Any], optional):
|
|
23
|
+
A zero-argument factory function that produces a default value.
|
|
24
|
+
If provided, its return value takes precedence over `default`.
|
|
25
|
+
Defaults to `None`.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
default: Any = None
|
|
29
|
+
default_factory: Callable = None
|
|
30
|
+
|
|
31
|
+
def __post_init__(self):
|
|
32
|
+
if not (self.default or self.default_factory):
|
|
33
|
+
raise AttributeError("default or default_factory must be given")
|
|
34
|
+
|
|
35
|
+
def get_default_value(self):
|
|
36
|
+
if self.default_factory:
|
|
37
|
+
return self.default_factory()
|
|
38
|
+
else:
|
|
39
|
+
return self.default
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class computed_context:
|
|
43
|
+
"""
|
|
44
|
+
Descriptor used to mark a method in an Agent as a computed context.
|
|
45
|
+
|
|
46
|
+
Computed contexts work very similarly to Python's `@property` descriptor: they are
|
|
47
|
+
re-computed each time they're accessed. When a computed context appears in:
|
|
48
|
+
|
|
49
|
+
- the agent's `instructions`, its value will be refreshed on every call to the agent,
|
|
50
|
+
updating the system message with the latest value.
|
|
51
|
+
- the agent's `input_template`, its value will be refreshed each time a new user message is
|
|
52
|
+
added, updating the prompt accordingly.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
func (Callable[[Agent], Any]):
|
|
56
|
+
The method on the Agent class that computes and returns the context value.
|
|
57
|
+
It will be called with the agent instance each time the context is accessed.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, fget):
|
|
61
|
+
functools.update_wrapper(self, fget)
|
|
62
|
+
self.fget = fget
|
|
63
|
+
self._is_context = True
|
|
64
|
+
|
|
65
|
+
def __set_name__(self, owner, name):
|
|
66
|
+
self.name = name
|
|
67
|
+
|
|
68
|
+
def __get__(self, instance, owner=None):
|
|
69
|
+
# when accessed on the class, return the descriptor itself
|
|
70
|
+
if instance is None:
|
|
71
|
+
return self
|
|
72
|
+
# when accessed on the instance, run the function against the *context* object
|
|
73
|
+
# (weβll inject this descriptor onto the Context class)
|
|
74
|
+
return self.fget(instance)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(repr=True)
|
|
78
|
+
class _AgentContext:
|
|
79
|
+
"""
|
|
80
|
+
Base context class for agents; uses dataclass for auto-generated init/signature.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
instructions: str
|
|
84
|
+
input_template: str = None
|
|
85
|
+
_messages: list = field(default_factory=list)
|
|
86
|
+
|
|
87
|
+
def as_dict(self) -> dict:
|
|
88
|
+
"""
|
|
89
|
+
Exports the context as a dictionary. This dictionary is not serialized, so
|
|
90
|
+
any `ContextItem` or `computed_context` remains their original type.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
- dict: A dictionary containing all `ContextItem` and `computed_context`
|
|
94
|
+
for later processing.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
data = asdict(self)
|
|
98
|
+
|
|
99
|
+
# tinject every computed_context value
|
|
100
|
+
for name, attr in type(self).__dict__.items():
|
|
101
|
+
if getattr(attr, "_is_context", False):
|
|
102
|
+
data[name] = getattr(self, name)
|
|
103
|
+
return data
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def system_message(self) -> str:
|
|
107
|
+
"""
|
|
108
|
+
The current formatted system_message
|
|
109
|
+
"""
|
|
110
|
+
# start with all the normal dataclass fields
|
|
111
|
+
|
|
112
|
+
# now format your instruction template
|
|
113
|
+
return self.instructions.format(**self.as_dict())
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def messages(self) -> list[dict[str, str]]:
|
|
117
|
+
"""
|
|
118
|
+
List of openai-ready messages with the most up-to-date system message
|
|
119
|
+
"""
|
|
120
|
+
messages = self._messages.copy()
|
|
121
|
+
messages.insert(0, {"role": "system", "content": self.system_message})
|
|
122
|
+
return messages
|
|
123
|
+
|
|
124
|
+
def add_user_message(self, message: str):
|
|
125
|
+
"""
|
|
126
|
+
Add a user message to the message list. If a `input_template` is given then
|
|
127
|
+
the message will be formatted in it as well as any context used in the template.
|
|
128
|
+
|
|
129
|
+
To use the user message in the template, place the key `user_message`.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
message(str): The user message to be added.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
if self.input_template:
|
|
136
|
+
data = self.as_dict()
|
|
137
|
+
data["user_message"] = message
|
|
138
|
+
content = self.input_template.format(**data)
|
|
139
|
+
else:
|
|
140
|
+
content = message
|
|
141
|
+
self._messages.append({"role": "user", "content": content})
|
|
142
|
+
|
|
143
|
+
def get(self, name: str) -> Any:
|
|
144
|
+
"""
|
|
145
|
+
Retrieves an item from the context.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
name(str): The name of the item
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Any: The item. If it is a computed context item, then it is computed upon retrieval.
|
|
152
|
+
"""
|
|
153
|
+
try:
|
|
154
|
+
return self.as_dict()[name]
|
|
155
|
+
except KeyError:
|
|
156
|
+
raise InvalidContextRefNotFoundInContext(name)
|
|
157
|
+
|
|
158
|
+
@classmethod
|
|
159
|
+
def make_ctx_class(cls, name: str, ctx_map: dict[str, tuple[Type[Any], Any]]) -> Type[Self]:
|
|
160
|
+
"""
|
|
161
|
+
Dynamically create a dataclass subclass with typed context fields.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
name: base name for the new class (e.g. 'MyAgent').
|
|
165
|
+
ctx_map: mapping of field name to (type, ContextItem).
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
A new dataclass type 'NameContext'.
|
|
169
|
+
"""
|
|
170
|
+
dc_fields = [] # for actual dataclass fields (ContextItem)
|
|
171
|
+
namespace: dict[str, Any] = {"__module__": cls.__module__}
|
|
172
|
+
|
|
173
|
+
for field_name, (type_, info) in ctx_map.items():
|
|
174
|
+
if isinstance(info, ContextItem):
|
|
175
|
+
# ---- your existing logic for setting defaults ----
|
|
176
|
+
if info.default_factory is not None:
|
|
177
|
+
dc_def = field(default_factory=info.default_factory)
|
|
178
|
+
else:
|
|
179
|
+
dc_def = field(default=info.default)
|
|
180
|
+
dc_fields.append((field_name, type_, dc_def))
|
|
181
|
+
|
|
182
|
+
elif isinstance(info, computed_context):
|
|
183
|
+
# stick the descriptor straight into the namespace
|
|
184
|
+
namespace[field_name] = info
|
|
185
|
+
# also record its type for annotation
|
|
186
|
+
namespace.setdefault("__annotations__", {})[field_name] = type_
|
|
187
|
+
|
|
188
|
+
else:
|
|
189
|
+
raise RuntimeError(f"Unexpected ctx_map entry for {field_name!r}: {info!r}")
|
|
190
|
+
|
|
191
|
+
# now build the dataclass
|
|
192
|
+
return make_dataclass(
|
|
193
|
+
cls_name=f"{name}Context",
|
|
194
|
+
fields=dc_fields,
|
|
195
|
+
bases=(cls,),
|
|
196
|
+
namespace=namespace,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class ContextRef:
|
|
201
|
+
"""
|
|
202
|
+
A placeholder pointing at some attribute or method
|
|
203
|
+
on the agentβs context, to be resolved at schema-build time.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
def __init__(self, path: str):
|
|
207
|
+
self.path = path # dot-notation into agent.context
|
|
208
|
+
|
|
209
|
+
def resolve(self, context: _AgentContext) -> Any:
|
|
210
|
+
val = context
|
|
211
|
+
for part in self.path.split("."):
|
|
212
|
+
val = getattr(val, part)
|
|
213
|
+
# if itβs wrapped in our Context helper, drill into .value
|
|
214
|
+
if hasattr(val, "value"):
|
|
215
|
+
val = val.value
|
|
216
|
+
return val() if callable(val) else val
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
class ToolDeclarationFailed(Exception):
|
|
2
|
+
|
|
3
|
+
def __init__(self, tool_name, message):
|
|
4
|
+
message = f"Tool declaration failed for {tool_name}" f"{message}"
|
|
5
|
+
super().__init__(message)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SystemMessageNotDeclared(Exception):
|
|
9
|
+
def __init__(self):
|
|
10
|
+
super().__init__(
|
|
11
|
+
"System message not declared on agent. Agent must be declared with `__system_message__`" # noqa E501
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class UnexpectedContextItemType(Exception):
|
|
16
|
+
def __init__(self, name, expected, recieved):
|
|
17
|
+
message = (
|
|
18
|
+
f"Unexpected value provided for `{name}`. "
|
|
19
|
+
f"Expected: {expected} - Recieved: {recieved}"
|
|
20
|
+
)
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InvalidContextRefNotFoundInContext(Exception):
|
|
25
|
+
def __init__(self, name):
|
|
26
|
+
message = (
|
|
27
|
+
f"'{name}' not found in context. "
|
|
28
|
+
"Make sure it is either declared as a `ContextItem` or using `computed_context`"
|
|
29
|
+
)
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class InvalidContextRefMismatchTyping(Exception):
|
|
34
|
+
def __init__(self, ref_path, field_name, recieved_type, expected_type):
|
|
35
|
+
message = (
|
|
36
|
+
f"ContextRef('{ref_path}') for {self.__class__.__name__}.{field_name} "
|
|
37
|
+
f"is of type {recieved_type}, expected {expected_type}"
|
|
38
|
+
)
|
|
39
|
+
super().__init__(message)
|