pyagentic-core 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyagentic/__init__.py +6 -0
- pyagentic/_base/__init__.py +0 -0
- pyagentic/_base/_agent.py +190 -0
- pyagentic/_base/_context.py +216 -0
- pyagentic/_base/_exceptions.py +39 -0
- pyagentic/_base/_metaclasses.py +153 -0
- pyagentic/_base/_params.py +138 -0
- pyagentic/_base/_resolver.py +51 -0
- pyagentic/_base/_tool.py +154 -0
- pyagentic/logging.py +53 -0
- pyagentic/updates.py +26 -0
- pyagentic_core-1.0.0.dist-info/METADATA +112 -0
- pyagentic_core-1.0.0.dist-info/RECORD +16 -0
- pyagentic_core-1.0.0.dist-info/WHEEL +5 -0
- pyagentic_core-1.0.0.dist-info/licenses/LICENSE +21 -0
- pyagentic_core-1.0.0.dist-info/top_level.txt +1 -0
pyagentic/__init__.py
ADDED
|
@@ -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)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from typing import dataclass_transform, TypeVar
|
|
3
|
+
from typeguard import check_type
|
|
4
|
+
|
|
5
|
+
from pyagentic._base._exceptions import SystemMessageNotDeclared, UnexpectedContextItemType
|
|
6
|
+
from pyagentic._base._context import _AgentContext, ContextItem, computed_context
|
|
7
|
+
from pyagentic._base._tool import _ToolDefinition
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass_transform(field_specifiers=(ContextItem,))
|
|
11
|
+
class AgentMeta(type):
|
|
12
|
+
"""
|
|
13
|
+
Metaclass that applies only to Agent subclasses:
|
|
14
|
+
- Ensures @system_message was declared
|
|
15
|
+
- Collects @tool definitions and ContextItem attributes
|
|
16
|
+
- Initializes class __tool_defs__ and __context_items__
|
|
17
|
+
- Dynamically injects an __init__ signature based on class __annotations__
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@staticmethod
|
|
21
|
+
def _extract_tool_defs(namespace) -> dict[str, _ToolDefinition]:
|
|
22
|
+
"""
|
|
23
|
+
Extracts tool definitions from a given namespace
|
|
24
|
+
|
|
25
|
+
Any method with the `@tool` descriptor will be attached to the `__tool_defs__` class
|
|
26
|
+
attribute
|
|
27
|
+
"""
|
|
28
|
+
tools = {}
|
|
29
|
+
for attr_name, attr_value in namespace.items():
|
|
30
|
+
if hasattr(attr_value, "__tool_def__"):
|
|
31
|
+
tools[attr_name] = attr_value.__tool_def__
|
|
32
|
+
return tools
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def _extract_annotations(bases, namespace) -> dict[str, TypeVar]:
|
|
36
|
+
"""
|
|
37
|
+
Extracts all annotations from current class and all its subclasses. Combines them into
|
|
38
|
+
one dictionary, with class order respected (subclasses overide parent classes.)
|
|
39
|
+
"""
|
|
40
|
+
annotations = {}
|
|
41
|
+
for base in reversed(bases):
|
|
42
|
+
if hasattr(base, "__annotations__"):
|
|
43
|
+
for name, type_ in base.__annotations__.items():
|
|
44
|
+
if not name.startswith("__"):
|
|
45
|
+
annotations[name] = type_
|
|
46
|
+
for name, type_ in namespace.get("__annotations__", {}).items():
|
|
47
|
+
if not name.startswith("__"):
|
|
48
|
+
annotations[name] = type_
|
|
49
|
+
return annotations
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def _extract_context_attrs(annotations, namespace) -> dict[str, tuple[TypeVar, ContextItem]]:
|
|
53
|
+
"""
|
|
54
|
+
Extracts any class field from annotations and namespace where the value is that of
|
|
55
|
+
`ContextItem`, these will later be appeneded to the agents context. This will return
|
|
56
|
+
both the type and the user defined context item.
|
|
57
|
+
"""
|
|
58
|
+
context_attrs = {}
|
|
59
|
+
for attr_name, attr_type in annotations.items():
|
|
60
|
+
default = namespace.get(attr_name, None)
|
|
61
|
+
if isinstance(default, ContextItem):
|
|
62
|
+
context_attrs[attr_name] = (attr_type, default)
|
|
63
|
+
|
|
64
|
+
for name, value in namespace.items():
|
|
65
|
+
if getattr(value, "_is_context", False):
|
|
66
|
+
context_attrs[name] = (computed_context, value)
|
|
67
|
+
return context_attrs
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def _build_init_signature(cls) -> inspect.Signature:
|
|
71
|
+
"""
|
|
72
|
+
Builds the signature for the classes __init__, injecting any context item attributes
|
|
73
|
+
defined by the user properly into the inits signature. This allows IDE's to be able
|
|
74
|
+
to recognize user-defined class attributes when initializing a class.
|
|
75
|
+
"""
|
|
76
|
+
params = [inspect.Parameter("self", inspect.Parameter.POSITIONAL_ONLY)]
|
|
77
|
+
for field_name, field_type in cls.__annotations__.items():
|
|
78
|
+
if field_name in cls.__context_attrs__:
|
|
79
|
+
default_val = cls.__context_attrs__[field_name][1].get_default_value()
|
|
80
|
+
else:
|
|
81
|
+
default_val = getattr(cls, field_name, inspect._empty)
|
|
82
|
+
param = inspect.Parameter(
|
|
83
|
+
field_name,
|
|
84
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
85
|
+
default=default_val,
|
|
86
|
+
annotation=field_type,
|
|
87
|
+
)
|
|
88
|
+
params.append(param)
|
|
89
|
+
return inspect.Signature(params)
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def _build_init(sig):
|
|
93
|
+
"""
|
|
94
|
+
Builds the init function for the class. This init will automatically have user-defined
|
|
95
|
+
context items as arguements, allow for easy initialization of agents for a variety
|
|
96
|
+
of different tasks.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(self, *args, **kwargs): # type: ignore
|
|
100
|
+
ContextClass = _AgentContext.make_ctx_class(
|
|
101
|
+
name=self.__class__.__name__, ctx_map=self.__context_attrs__
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
context_kwargs = {}
|
|
105
|
+
for attr_name, (attr_type, attr_default) in self.__context_attrs__.items():
|
|
106
|
+
if attr_type == computed_context:
|
|
107
|
+
continue
|
|
108
|
+
if attr_name in kwargs:
|
|
109
|
+
val = kwargs[attr_name]
|
|
110
|
+
if (not check_type(val, attr_type)):
|
|
111
|
+
raise UnexpectedContextItemType(
|
|
112
|
+
name=attr_name, expected=attr_type, recieved=type(val)
|
|
113
|
+
)
|
|
114
|
+
context_kwargs[attr_name] = val
|
|
115
|
+
else:
|
|
116
|
+
context_kwargs[attr_name] = attr_default.get_default_value()
|
|
117
|
+
|
|
118
|
+
self.context = ContextClass(
|
|
119
|
+
instructions=self.__system_message__,
|
|
120
|
+
input_template=self.__input_template__,
|
|
121
|
+
**context_kwargs,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
bound = sig.bind(self, *args, **kwargs)
|
|
125
|
+
for name, val in list(bound.arguments.items())[1:]: # skip 'self'
|
|
126
|
+
if name in self.__context_attrs__:
|
|
127
|
+
pass
|
|
128
|
+
setattr(self, name, val)
|
|
129
|
+
|
|
130
|
+
self.__post_init__()
|
|
131
|
+
|
|
132
|
+
__init__.__signature__ = sig # type: ignore
|
|
133
|
+
return __init__
|
|
134
|
+
|
|
135
|
+
def __new__(mcs, name, bases, namespace, **kwargs):
|
|
136
|
+
cls = super().__new__(mcs, name, bases, namespace, **kwargs)
|
|
137
|
+
|
|
138
|
+
if namespace.get("__abstract_base__", False):
|
|
139
|
+
return cls
|
|
140
|
+
|
|
141
|
+
if "__system_message__" not in namespace:
|
|
142
|
+
raise SystemMessageNotDeclared()
|
|
143
|
+
|
|
144
|
+
cls.__tool_defs__ = mcs._extract_tool_defs(namespace)
|
|
145
|
+
|
|
146
|
+
cls.__annotations__ = mcs._extract_annotations(bases, namespace)
|
|
147
|
+
|
|
148
|
+
cls.__context_attrs__ = mcs._extract_context_attrs(cls.__annotations__, namespace)
|
|
149
|
+
|
|
150
|
+
sig = mcs._build_init_signature(cls)
|
|
151
|
+
|
|
152
|
+
cls.__init__ = mcs._build_init(sig)
|
|
153
|
+
return cls
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from collections import defaultdict
|
|
3
|
+
from typing import get_type_hints, Any, List, Dict, Type
|
|
4
|
+
|
|
5
|
+
from pyagentic._base._resolver import ContextualMixin, MaybeContext
|
|
6
|
+
from pyagentic._base._context import _AgentContext
|
|
7
|
+
|
|
8
|
+
# simple mapping from Python types to JSON Schema/OpenAI types
|
|
9
|
+
_TYPE_MAP: Dict[Type[Any], str] = {
|
|
10
|
+
int: "integer",
|
|
11
|
+
float: "number",
|
|
12
|
+
str: "string",
|
|
13
|
+
bool: "boolean",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ParamInfo(ContextualMixin):
|
|
19
|
+
"""
|
|
20
|
+
Declare metadata for parameters in tool declarations and/or Parameter declarations.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
description (str | None): A human-readable description of the parameter.
|
|
24
|
+
required (bool): Whether this parameter must be provided by the user.
|
|
25
|
+
default (Any): The default value to use if none is provided.
|
|
26
|
+
values (list[str]): values to limit the input of this parameter. If used, the
|
|
27
|
+
agent is forced to use on the the values in the list.
|
|
28
|
+
|
|
29
|
+
Context-Ready Attributes:
|
|
30
|
+
These attributes can be given a `ContextRef` to link them to any context items in
|
|
31
|
+
the agent.
|
|
32
|
+
|
|
33
|
+
- description
|
|
34
|
+
- default
|
|
35
|
+
- values
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
description: MaybeContext[str] = None
|
|
39
|
+
required: bool = False
|
|
40
|
+
default: MaybeContext[Any] = None
|
|
41
|
+
values: MaybeContext[list[str]] = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Param:
|
|
45
|
+
"""
|
|
46
|
+
Base class for defining structured parameters that can be converted
|
|
47
|
+
into OpenAI-compatible JSON schema entries.
|
|
48
|
+
|
|
49
|
+
Subclasses should declare class attributes with type annotations,
|
|
50
|
+
optionally assigning a ParamInfo instance or a raw default value.
|
|
51
|
+
|
|
52
|
+
On subclass creation, __attributes__ is populated mapping field names
|
|
53
|
+
to (type, ParamInfo) pairs. Instances perform simple type-checked
|
|
54
|
+
assignment and reject unknown fields.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
__attributes__: dict[str, tuple[type, ParamInfo]] = {}
|
|
58
|
+
|
|
59
|
+
def __init_subclass__(cls, **kwargs):
|
|
60
|
+
"""
|
|
61
|
+
Inspect annotated attributes on the subclass and build a mapping
|
|
62
|
+
of parameter definitions for OpenAI schema generation.
|
|
63
|
+
"""
|
|
64
|
+
super().__init_subclass__(**kwargs)
|
|
65
|
+
cls.__attributes__ = {}
|
|
66
|
+
for name, type_ in get_type_hints(cls).items():
|
|
67
|
+
default = cls.__dict__.get(name, None)
|
|
68
|
+
if isinstance(default, ParamInfo):
|
|
69
|
+
cls.__attributes__[name] = (type_, default)
|
|
70
|
+
elif default is not None:
|
|
71
|
+
cls.__attributes__[name] = (type_, ParamInfo(default=default))
|
|
72
|
+
else:
|
|
73
|
+
cls.__attributes__[name] = (type_, ParamInfo())
|
|
74
|
+
|
|
75
|
+
def __init__(self, **kwargs):
|
|
76
|
+
"""
|
|
77
|
+
Instantiate a Param subclass by validating and assigning each
|
|
78
|
+
annotated field, falling back to class-level defaults if absent.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
TypeError: if a provided value does not match the annotated type,
|
|
82
|
+
or if unexpected fields are passed.
|
|
83
|
+
"""
|
|
84
|
+
cls = type(self)
|
|
85
|
+
hints = get_type_hints(cls)
|
|
86
|
+
|
|
87
|
+
# Assign annotated fields
|
|
88
|
+
for name, typ in hints.items():
|
|
89
|
+
if name in kwargs:
|
|
90
|
+
value = kwargs.pop(name)
|
|
91
|
+
# simple type check
|
|
92
|
+
if not isinstance(value, typ) and value is not None:
|
|
93
|
+
raise TypeError(f"Field '{name}' expected {typ}, got {type(value)}")
|
|
94
|
+
setattr(self, name, value)
|
|
95
|
+
else:
|
|
96
|
+
# use class‐level default if given, else None
|
|
97
|
+
attr = getattr(cls, name, None)
|
|
98
|
+
if isinstance(attr, ParamInfo):
|
|
99
|
+
default = attr.default
|
|
100
|
+
else:
|
|
101
|
+
default = attr
|
|
102
|
+
setattr(self, name, default)
|
|
103
|
+
|
|
104
|
+
if kwargs:
|
|
105
|
+
unexpected = ", ".join(kwargs)
|
|
106
|
+
raise TypeError(f"Unexpected fields for {cls.__name__}: {unexpected}")
|
|
107
|
+
|
|
108
|
+
def __repr__(self):
|
|
109
|
+
vals = ", ".join(f"{k}={v!r}" for k, v in self.dict().items())
|
|
110
|
+
return f"{type(self).__name__}({vals})"
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def to_openai(cls, context: _AgentContext) -> List[Dict[str, Any]]:
|
|
114
|
+
"""
|
|
115
|
+
Generate a JSON-schema-style dictionary suitable for OpenAI function
|
|
116
|
+
parameter definitions.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Dict[str, Any]: A schema object with keys:
|
|
120
|
+
- "type": always "object"
|
|
121
|
+
- "properties": mapping from field names to their OpenAI types
|
|
122
|
+
- "required": list of names marked as required
|
|
123
|
+
"""
|
|
124
|
+
properties: Dict[str, dict] = defaultdict(dict)
|
|
125
|
+
required = []
|
|
126
|
+
|
|
127
|
+
for name, (type_, info) in cls.__attributes__.items():
|
|
128
|
+
resolved_info = info.resolve(context)
|
|
129
|
+
properties[name]["type"] = _TYPE_MAP.get(type_, "string")
|
|
130
|
+
if resolved_info.description:
|
|
131
|
+
properties[name]["description"] = resolved_info.description
|
|
132
|
+
if resolved_info.values:
|
|
133
|
+
properties[name]["enum"] = resolved_info.values
|
|
134
|
+
|
|
135
|
+
if resolved_info.required:
|
|
136
|
+
required.append(name)
|
|
137
|
+
|
|
138
|
+
return {"type": "object", "properties": dict(properties), "required": required}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from dataclasses import dataclass, fields, replace
|
|
2
|
+
from typing import Annotated, TypeVar, get_origin, get_args, Any, Self
|
|
3
|
+
|
|
4
|
+
from typeguard import check_type, TypeCheckError
|
|
5
|
+
|
|
6
|
+
from pyagentic._base._context import _AgentContext, ContextRef
|
|
7
|
+
from pyagentic._base._exceptions import InvalidContextRefMismatchTyping
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _CtxMarker:
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
T = TypeVar("T")
|
|
15
|
+
MaybeContext = Annotated[T, _CtxMarker()]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ContextualMixin:
|
|
20
|
+
"""
|
|
21
|
+
Class to be extended if any of the properties in the class may use a `ContextRef`. Gives the
|
|
22
|
+
subclass access to `resolve`, which allows a context to be passed in to backfill any
|
|
23
|
+
context-ready properties
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def resolve(self, ctx: _AgentContext) -> Self:
|
|
27
|
+
updates: dict[str, Any] = {}
|
|
28
|
+
for f in fields(self):
|
|
29
|
+
tp = f.type
|
|
30
|
+
# only look at Annotated[...] with our marker
|
|
31
|
+
if get_origin(tp) is Annotated and any(
|
|
32
|
+
isinstance(m, _CtxMarker) for m in get_args(tp)[1:]
|
|
33
|
+
):
|
|
34
|
+
|
|
35
|
+
raw = getattr(self, f.name)
|
|
36
|
+
if isinstance(raw, ContextRef):
|
|
37
|
+
value = ctx.get(raw.path)
|
|
38
|
+
# expected type is the first Annotated arg
|
|
39
|
+
expected_type = get_args(tp)[0]
|
|
40
|
+
try:
|
|
41
|
+
check_type(value, expected_type)
|
|
42
|
+
except TypeCheckError:
|
|
43
|
+
raise InvalidContextRefMismatchTyping(
|
|
44
|
+
ref_path=raw.path,
|
|
45
|
+
field_name=f.name,
|
|
46
|
+
recieved_type=type(value),
|
|
47
|
+
expected_type=expected_type,
|
|
48
|
+
)
|
|
49
|
+
updates[f.name] = value
|
|
50
|
+
# return a new instance with those fields replaced
|
|
51
|
+
return replace(self, **updates)
|
pyagentic/_base/_tool.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from typing import Callable, Any, TypeVar, get_type_hints
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
|
|
5
|
+
from pyagentic._base._params import Param, ParamInfo, _TYPE_MAP
|
|
6
|
+
from pyagentic._base._context import _AgentContext
|
|
7
|
+
from pyagentic._base._exceptions import ToolDeclarationFailed
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _ToolDefinition:
|
|
11
|
+
"""
|
|
12
|
+
Private class to handle tool definitions
|
|
13
|
+
|
|
14
|
+
Attributes:
|
|
15
|
+
name(str): Name of the tool, automatically filled out as the function name
|
|
16
|
+
description(str): Description of the tool for LLM to read
|
|
17
|
+
parameters(str): Dictionary containing parameters captured by the tool descriptor
|
|
18
|
+
condition(str): The condition supplied determining when this tool should be included
|
|
19
|
+
in the LLM inference call
|
|
20
|
+
|
|
21
|
+
Methods:
|
|
22
|
+
to_openai()->dict: Converts the definition to an "openai-ready" dictionary
|
|
23
|
+
compile_args()->dict[str, Any]: Converts any raw kwargs, usually from LLM tool call, to
|
|
24
|
+
match that of the tool definition. This process does the following:
|
|
25
|
+
1. Fills in any default values for args not supplied
|
|
26
|
+
2. Casts a raw dictionary to any arg that is a Param class
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
name: str,
|
|
32
|
+
description: str,
|
|
33
|
+
parameters: dict[str, tuple[TypeVar, ParamInfo]],
|
|
34
|
+
condition: Callable[[Any], bool] = None,
|
|
35
|
+
):
|
|
36
|
+
self.name: str = name
|
|
37
|
+
self.description: str = description
|
|
38
|
+
self.parameters: dict[str, tuple[TypeVar, ParamInfo]] = parameters
|
|
39
|
+
self.condition = condition
|
|
40
|
+
|
|
41
|
+
def to_openai(self, context: _AgentContext) -> dict:
|
|
42
|
+
"""
|
|
43
|
+
Converts the definition to an "openai-ready" dictionary
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
dict: A openai dictionary for tool calling
|
|
47
|
+
"""
|
|
48
|
+
params = defaultdict(dict)
|
|
49
|
+
required = []
|
|
50
|
+
|
|
51
|
+
for name, attr in self.parameters.items():
|
|
52
|
+
type_, default = attr
|
|
53
|
+
|
|
54
|
+
if issubclass(type_, Param):
|
|
55
|
+
params[name] = type_.to_openai(context)
|
|
56
|
+
else:
|
|
57
|
+
params[name] = {"type": _TYPE_MAP.get(type_, "string")}
|
|
58
|
+
if isinstance(default, ParamInfo):
|
|
59
|
+
resolved_default = default.resolve(context)
|
|
60
|
+
if resolved_default.description:
|
|
61
|
+
params[name]["description"] = resolved_default.description
|
|
62
|
+
if resolved_default.required:
|
|
63
|
+
required.append(name)
|
|
64
|
+
if resolved_default.values:
|
|
65
|
+
params[name]["enum"] = resolved_default.values
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
"type": "function",
|
|
69
|
+
"name": self.name,
|
|
70
|
+
"description": self.description,
|
|
71
|
+
"parameters": {"type": "object", "properties": dict(params)},
|
|
72
|
+
"required": required,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
def compile_args(self, **kwargs) -> dict[str, Any]:
|
|
76
|
+
"""
|
|
77
|
+
Converts the definition to an "openai-ready" dictionary
|
|
78
|
+
compile_args()->dict[str, Any]: Converts any raw kwargs, usually from LLM tool call, to
|
|
79
|
+
match that of the tool definition. This process does the following:
|
|
80
|
+
1. Fills in any default values for args not supplied
|
|
81
|
+
2. Casts a raw dictionary to any arg that is a Param class
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
**kwargs: Recieves any arguements that will be verified and compiled
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
dict[str, Any]: Dictionary of args that are ready to be run through the tool
|
|
88
|
+
"""
|
|
89
|
+
compiled_args = {}
|
|
90
|
+
|
|
91
|
+
for name, (type_, info) in self.parameters.items():
|
|
92
|
+
if name in kwargs:
|
|
93
|
+
if issubclass(type_, Param):
|
|
94
|
+
param_args = kwargs[name]
|
|
95
|
+
compiled_args[name] = type_(**param_args)
|
|
96
|
+
else:
|
|
97
|
+
compiled_args[name] = kwargs[name]
|
|
98
|
+
else:
|
|
99
|
+
compiled_args[name] = info.default
|
|
100
|
+
|
|
101
|
+
return compiled_args
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def tool(
|
|
105
|
+
description: str,
|
|
106
|
+
condition: Callable[[Any], bool] = None,
|
|
107
|
+
):
|
|
108
|
+
"""
|
|
109
|
+
Decorator to mark a method as a callable tool.
|
|
110
|
+
All methods marked with this descriptor **must** return a string
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
description(str): Description of the tool that will be read by the LLM
|
|
114
|
+
condition(Callable): A callable that returns a boolean, determining when the tool
|
|
115
|
+
will be included in the LLM inference call
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def decorator(fn: Callable):
|
|
119
|
+
# Check return type
|
|
120
|
+
types = get_type_hints(fn)
|
|
121
|
+
return_type = types.pop("return", None)
|
|
122
|
+
if return_type != str:
|
|
123
|
+
raise ToolDeclarationFailed(
|
|
124
|
+
tool_name=fn.__name__, message="Method must have a return type of `str`"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# 2) grab default values
|
|
128
|
+
sig = inspect.signature(fn)
|
|
129
|
+
defaults = {
|
|
130
|
+
param_name: param.default
|
|
131
|
+
for param_name, param in sig.parameters.items()
|
|
132
|
+
if param.default is not inspect._empty
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
params = {}
|
|
136
|
+
|
|
137
|
+
for name, type_ in types.items():
|
|
138
|
+
default = defaults.get(name, None)
|
|
139
|
+
if isinstance(default, ParamInfo):
|
|
140
|
+
params[name] = (type_, default)
|
|
141
|
+
elif default is not None:
|
|
142
|
+
params[name] = (type_, ParamInfo(default=default))
|
|
143
|
+
else:
|
|
144
|
+
params[name] = (type_, ParamInfo())
|
|
145
|
+
|
|
146
|
+
fn.__tool_def__ = _ToolDefinition(
|
|
147
|
+
name=fn.__name__,
|
|
148
|
+
description=description or fn.__doc__ or "",
|
|
149
|
+
parameters=params,
|
|
150
|
+
condition=condition,
|
|
151
|
+
)
|
|
152
|
+
return fn
|
|
153
|
+
|
|
154
|
+
return decorator
|
pyagentic/logging.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from logging.config import dictConfig
|
|
3
|
+
|
|
4
|
+
LOG_LEVEL = "WARNING"
|
|
5
|
+
|
|
6
|
+
LOGGING_CONFIG = {
|
|
7
|
+
"version": 1,
|
|
8
|
+
"disable_existing_loggers": False,
|
|
9
|
+
"formatters": {
|
|
10
|
+
"colored": {
|
|
11
|
+
"()": "colorlog.ColoredFormatter",
|
|
12
|
+
"format": (
|
|
13
|
+
"%(log_color)s[%(asctime)s]%(reset)s "
|
|
14
|
+
"%(log_color)s%(levelname)-8s%(reset)s - "
|
|
15
|
+
"%(name)s - %(message)s"
|
|
16
|
+
),
|
|
17
|
+
"log_colors": {
|
|
18
|
+
"DEBUG": "cyan",
|
|
19
|
+
"INFO": "green",
|
|
20
|
+
"WARNING": "yellow",
|
|
21
|
+
"ERROR": "red",
|
|
22
|
+
"CRITICAL": "bold_red",
|
|
23
|
+
},
|
|
24
|
+
"reset": True,
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
"handlers": {
|
|
28
|
+
"console": {
|
|
29
|
+
"class": "logging.StreamHandler",
|
|
30
|
+
"formatter": "colored",
|
|
31
|
+
"level": LOG_LEVEL,
|
|
32
|
+
"stream": "ext://sys.stdout",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
"root": {
|
|
36
|
+
"handlers": ["console"],
|
|
37
|
+
"level": LOG_LEVEL,
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
_configured = False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def configure_logging() -> None:
|
|
45
|
+
global _configured
|
|
46
|
+
if not _configured:
|
|
47
|
+
dictConfig(LOGGING_CONFIG)
|
|
48
|
+
_configured = True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_logger(name: str) -> logging.Logger:
|
|
52
|
+
configure_logging()
|
|
53
|
+
return logging.getLogger(name)
|
pyagentic/updates.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from typing import Literal
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Status(Enum):
|
|
7
|
+
GENERATING = "generating"
|
|
8
|
+
PROCESSING = "processing"
|
|
9
|
+
SUCCEDED = "succeded"
|
|
10
|
+
ERROR = "error"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EmitUpdate(BaseModel):
|
|
14
|
+
type: Literal["base"] = "base"
|
|
15
|
+
status: Status
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AiUpdate(EmitUpdate):
|
|
19
|
+
type: Literal["ai_response"] = "ai_response"
|
|
20
|
+
message: str = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ToolUpdate(EmitUpdate):
|
|
24
|
+
type: Literal["tool_update"] = "tool_update"
|
|
25
|
+
tool_call: str = None
|
|
26
|
+
tool_args: dict = None
|
|
@@ -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,16 @@
|
|
|
1
|
+
pyagentic/__init__.py,sha256=HK0W-YC66O4elkka_oUpWKRMCi0a5O3B9i4eT-zM534,312
|
|
2
|
+
pyagentic/logging.py,sha256=cyxD_FLZaLRX0tvEkIEhfhLozTxn2TR_JPVTf8UzgcI,1266
|
|
3
|
+
pyagentic/updates.py,sha256=O-3b2SIiWYYD3aAn7mLfxo-lYVNoNxA9LopmYFgwIqQ,530
|
|
4
|
+
pyagentic/_base/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
pyagentic/_base/_agent.py,sha256=IH5OvaWHz_NqkDU9bDP2cXnqVmrdST9X6Vs7RppLw3I,6895
|
|
6
|
+
pyagentic/_base/_context.py,sha256=nAyilVB3RH3bG32GKGwTfABgs7d_9RHQ8pYTkGnOi4g,7491
|
|
7
|
+
pyagentic/_base/_exceptions.py,sha256=okLcEx5ml68IbfC5rbaPTxTmkwa5pDdpIsmC27HqXr0,1321
|
|
8
|
+
pyagentic/_base/_metaclasses.py,sha256=04RQLea6T2RUuVj_ef8tEGDH-TdTVoAKPNERK-YTJo8,6201
|
|
9
|
+
pyagentic/_base/_params.py,sha256=LoMhfzZV5Y6A54zkzAvhVpO5AffRbvr_P6TOzrxjqO4,5096
|
|
10
|
+
pyagentic/_base/_resolver.py,sha256=C25_u6JFO4CXpLMWI9ooyUwE9WrpYvu_eeZMo65IW2U,1831
|
|
11
|
+
pyagentic/_base/_tool.py,sha256=nu5OrzglQLyZgo7lWmxSGJyUyMnRWLTVt0YTm_8fILM,5546
|
|
12
|
+
pyagentic_core-1.0.0.dist-info/licenses/LICENSE,sha256=wcOzTj82hOc96HztJk2VzLB2hFHpdIGpjz8vvbpP1_s,1069
|
|
13
|
+
pyagentic_core-1.0.0.dist-info/METADATA,sha256=MngTyhX15ucwi8_2TS7Xnhu6fzZBYCBLLYX538SDbX4,3742
|
|
14
|
+
pyagentic_core-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
15
|
+
pyagentic_core-1.0.0.dist-info/top_level.txt,sha256=lAWfd-ay434uEpSg2FM0ySN0o4vgNGE6D9dJkIhGyfY,10
|
|
16
|
+
pyagentic_core-1.0.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
pyagentic
|