pyagentic-core 1.2.1__tar.gz → 1.3.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 (27) hide show
  1. {pyagentic_core-1.2.1 → pyagentic_core-1.3.0}/PKG-INFO +2 -1
  2. pyagentic_core-1.3.0/pyagentic/__init__.py +15 -0
  3. {pyagentic_core-1.2.1 → pyagentic_core-1.3.0}/pyagentic/_base/_agent.py +82 -13
  4. pyagentic_core-1.3.0/pyagentic/_base/_metaclasses.py +378 -0
  5. {pyagentic_core-1.2.1 → pyagentic_core-1.3.0}/pyagentic/_base/_params.py +1 -1
  6. {pyagentic_core-1.2.1 → pyagentic_core-1.3.0}/pyagentic/_base/_tool.py +1 -1
  7. {pyagentic_core-1.2.1 → pyagentic_core-1.3.0}/pyagentic/_base/_validation.py +1 -4
  8. pyagentic_core-1.3.0/pyagentic/_utils/_typing.py +97 -0
  9. {pyagentic_core-1.2.1 → pyagentic_core-1.3.0}/pyagentic/models/response.py +10 -12
  10. {pyagentic_core-1.2.1 → pyagentic_core-1.3.0}/pyagentic_core.egg-info/PKG-INFO +2 -1
  11. {pyagentic_core-1.2.1 → pyagentic_core-1.3.0}/pyagentic_core.egg-info/requires.txt +1 -0
  12. {pyagentic_core-1.2.1 → pyagentic_core-1.3.0}/pyproject.toml +3 -1
  13. pyagentic_core-1.2.1/pyagentic/__init__.py +0 -6
  14. pyagentic_core-1.2.1/pyagentic/_base/_metaclasses.py +0 -179
  15. pyagentic_core-1.2.1/pyagentic/_utils/_typing.py +0 -73
  16. {pyagentic_core-1.2.1 → pyagentic_core-1.3.0}/LICENSE +0 -0
  17. {pyagentic_core-1.2.1 → pyagentic_core-1.3.0}/README.md +0 -0
  18. {pyagentic_core-1.2.1 → pyagentic_core-1.3.0}/pyagentic/_base/__init__.py +0 -0
  19. {pyagentic_core-1.2.1 → pyagentic_core-1.3.0}/pyagentic/_base/_context.py +0 -0
  20. {pyagentic_core-1.2.1 → pyagentic_core-1.3.0}/pyagentic/_base/_exceptions.py +0 -0
  21. {pyagentic_core-1.2.1 → pyagentic_core-1.3.0}/pyagentic/_base/_resolver.py +0 -0
  22. {pyagentic_core-1.2.1 → pyagentic_core-1.3.0}/pyagentic/logging.py +0 -0
  23. {pyagentic_core-1.2.1 → pyagentic_core-1.3.0}/pyagentic/updates.py +0 -0
  24. {pyagentic_core-1.2.1 → pyagentic_core-1.3.0}/pyagentic_core.egg-info/SOURCES.txt +0 -0
  25. {pyagentic_core-1.2.1 → pyagentic_core-1.3.0}/pyagentic_core.egg-info/dependency_links.txt +0 -0
  26. {pyagentic_core-1.2.1 → pyagentic_core-1.3.0}/pyagentic_core.egg-info/top_level.txt +0 -0
  27. {pyagentic_core-1.2.1 → pyagentic_core-1.3.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyagentic-core
3
- Version: 1.2.1
3
+ Version: 1.3.0
4
4
  Summary: Build LLM Agents in a Pythonic way
5
5
  Author-email: Ryan Mikulec <rmikulec.dev@gmail.com>
6
6
  License: MIT
@@ -17,6 +17,7 @@ Requires-Dist: taskipy>=1.14.1
17
17
  Requires-Dist: openai>=1.99.3
18
18
  Requires-Dist: pydantic>=2.11.7
19
19
  Requires-Dist: typeguard>=4.4.4
20
+ Requires-Dist: c3linearize>=0.1.0
20
21
  Dynamic: license-file
21
22
 
22
23
  # PyAgentic
@@ -0,0 +1,15 @@
1
+ from pyagentic._base._agent import Agent, AgentExtension
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__ = [
7
+ "Agent",
8
+ "AgentExtension",
9
+ "Param",
10
+ "ParamInfo",
11
+ "tool",
12
+ "computed_context",
13
+ "ContextRef",
14
+ "ContextItem",
15
+ ]
@@ -1,10 +1,12 @@
1
1
  import inspect
2
2
  import json
3
3
  import openai
4
- from typing import Callable, Any, TypeVar, ClassVar, Type
4
+ from functools import wraps
5
+ from typing import Callable, Any, TypeVar, ClassVar, Type, Self, dataclass_transform
5
6
 
6
7
  from pyagentic.logging import get_logger
7
- from pyagentic._base._tool import _ToolDefinition
8
+ from pyagentic._base._params import ParamInfo
9
+ from pyagentic._base._tool import _ToolDefinition, tool
8
10
  from pyagentic._base._context import ContextItem
9
11
  from pyagentic._base._metaclasses import AgentMeta
10
12
 
@@ -25,6 +27,29 @@ async def _safe_run(fn, *args, **kwargs):
25
27
  return result
26
28
 
27
29
 
30
+ @dataclass_transform(field_specifiers=(ContextItem,))
31
+ class AgentExtension:
32
+ """Inherit this in any mixin that contributes fields to the Agent __init__."""
33
+
34
+ __annotations__: dict[str, Any] = {}
35
+
36
+ def __init_subclass__(cls, **kwargs):
37
+ super().__init_subclass__(**kwargs)
38
+
39
+ # Merge annotations from all AgentExtension bases (oldest first),
40
+ # then let the subclass' own annotations win on key conflicts.
41
+ merged: dict[str, Any] = {}
42
+ for base in reversed(cls.__mro__[1:]): # skip cls, walk up towards object
43
+ if issubclass(base, AgentExtension):
44
+ ann = getattr(base, "__annotations__", None)
45
+ if ann:
46
+ merged.update(ann)
47
+
48
+ merged.update(getattr(cls, "__annotations__", {}))
49
+ # Assign a fresh dict so we don't mutate a base class' annotations
50
+ cls.__annotations__ = dict(merged)
51
+
52
+
28
53
  class Agent(metaclass=AgentMeta):
29
54
  __abstract_base__ = ClassVar[True]
30
55
  """
@@ -48,9 +73,12 @@ class Agent(metaclass=AgentMeta):
48
73
  __tool_defs__: ClassVar[dict[str, _ToolDefinition]]
49
74
  __context_attrs__: ClassVar[dict[str, tuple[TypeVar, ContextItem]]]
50
75
  __system_message__: ClassVar[str]
76
+ __description__: ClassVar[str]
51
77
  __input_template__: ClassVar[str] = None
52
78
  __response_model__: ClassVar[Type[AgentResponse]] = None
53
79
  __tool_response_models__: ClassVar[dict[str, Type[ToolResponse]]]
80
+ __linked_agents__: ClassVar[dict[str, Type[Self]]]
81
+ __call_params__: ClassVar[dict[str, tuple[TypeVar, ParamInfo]]]
54
82
 
55
83
  # Base Attributes
56
84
  model: str
@@ -60,9 +88,22 @@ class Agent(metaclass=AgentMeta):
60
88
  def __post_init__(self):
61
89
  self.client: openai.AsyncOpenAI = openai.AsyncOpenAI(api_key=self.api_key)
62
90
 
91
+ async def _process_agent_call(self, tool_call) -> AgentResponse:
92
+ logger.info(f"Calling {tool_call.name} with kwargs: {tool_call.arguments}")
93
+ self.context._messages.append(tool_call)
94
+ try:
95
+ agent = getattr(self, tool_call.name)
96
+ kwargs = json.loads(tool_call.arguments)
97
+ response = await agent(**kwargs)
98
+ result = f"Agent {tool_call.name}: {response.final_output}"
99
+ except Exception as e:
100
+ result = f"Agent `{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
101
+ self.context._messages.append(
102
+ {"type": "function_call_output", "call_id": tool_call.call_id, "output": result}
103
+ )
104
+ return response
105
+
63
106
  async def _process_tool_call(self, tool_call) -> ToolResponse:
64
- if tool_call.type != "function_call":
65
- return False
66
107
  self.context._messages.append(tool_call)
67
108
  logger.info(f"Calling {tool_call.name} with kwargs: {tool_call.arguments}")
68
109
  # Lookup the bound method
@@ -98,8 +139,8 @@ class Agent(metaclass=AgentMeta):
98
139
  self.context._messages.append(
99
140
  {"type": "function_call_output", "call_id": tool_call.call_id, "output": result}
100
141
  )
101
- ToolCalledModel = self.__tool_response_models__[tool_call.name]
102
- return ToolCalledModel(
142
+ ToolResponseModel = self.__tool_response_models__[tool_call.name]
143
+ return ToolResponseModel(
103
144
  raw_kwargs=tool_call.arguments, call_depth=0, output=result, **compiled_args
104
145
  )
105
146
 
@@ -110,6 +151,9 @@ class Agent(metaclass=AgentMeta):
110
151
  # Check if any of the tool params use a ContextRef
111
152
  # convert to openai schema
112
153
  tool_defs.append(tool_def.to_openai(self.context))
154
+ for name, agent in self.__linked_agents__.items():
155
+ tool_def = agent.get_tool_definition(name)
156
+ tool_defs.append(tool_def.to_openai(self.context))
113
157
  return tool_defs
114
158
 
115
159
  async def run(self, input_: str) -> str:
@@ -168,9 +212,16 @@ class Agent(metaclass=AgentMeta):
168
212
 
169
213
  # Dispatch any tool calls
170
214
  tool_responses = []
215
+ agent_responses = []
171
216
  for tool_call in tool_calls:
172
- tool_response = await self._process_tool_call(tool_call)
173
- tool_responses.append(tool_response)
217
+ if tool_call.type != "function_call":
218
+ continue
219
+ elif tool_call.name in self.__tool_defs__:
220
+ tool_response = await self._process_tool_call(tool_call)
221
+ tool_responses.append(tool_response)
222
+ elif tool_call.name in self.__linked_agents__:
223
+ agent_response = await self._process_agent_call(tool_call)
224
+ agent_responses.append(agent_response)
174
225
 
175
226
  # If tools ran, re-invoke LLM for natural reply
176
227
  if tool_responses:
@@ -198,8 +249,26 @@ class Agent(metaclass=AgentMeta):
198
249
  if self.emitter:
199
250
  await _safe_run(self.emitter, AiUpdate(status=Status.SUCCEDED, message=ai_message))
200
251
 
201
- return self.__response_model__(
202
- response=response,
203
- final_output=ai_message,
204
- tool_responses=tool_responses,
205
- )
252
+ response_fields = {"final_output": ai_message}
253
+ if self.__tool_defs__:
254
+ response_fields["tool_responses"] = tool_responses
255
+ if self.__linked_agents__:
256
+ response_fields["agent_responses"] = agent_responses
257
+
258
+ return self.__response_model__(**response_fields)
259
+
260
+ async def __call__(self, user_input: str):
261
+ return await self.run(input_=user_input)
262
+
263
+ @classmethod
264
+ def get_tool_definition(cls, name: str) -> _ToolDefinition:
265
+ desc = getattr(cls, "__description__", "") or ""
266
+
267
+ # fresh async wrapper so each class gets its own function object
268
+ @wraps(cls.__call__)
269
+ async def _invoke(self, *args, **kwargs):
270
+ return await cls.__call__(self, *args, **kwargs)
271
+
272
+ td = tool(desc)(_invoke).__tool_def__ # decorator attaches metadata to the wrapper
273
+ td.name = name
274
+ return td
@@ -0,0 +1,378 @@
1
+ import inspect
2
+ import threading
3
+ import warnings
4
+ from typing import dataclass_transform, TypeVar, Mapping
5
+ from types import MappingProxyType
6
+ from collections import ChainMap
7
+ from c3linearize import linearize
8
+ from typeguard import check_type, TypeCheckError
9
+
10
+ from pyagentic._base._validation import _AgentConstructionValidator
11
+ from pyagentic._base._exceptions import SystemMessageNotDeclared, UnexpectedContextItemType
12
+ from pyagentic._base._context import _AgentContext, ContextItem, computed_context
13
+ from pyagentic._base._tool import _ToolDefinition
14
+
15
+ from pyagentic.models.response import AgentResponse, ToolResponse
16
+
17
+ from pyagentic._utils._typing import analyze_type
18
+
19
+
20
+ # Placeholder class for Agent type annotation
21
+ # Can't import actual agent as it would cause a circular import error
22
+ class Agent:
23
+ pass
24
+
25
+
26
+ @dataclass_transform(field_specifiers=(ContextItem,))
27
+ class AgentMeta(type):
28
+ """
29
+ Metaclass that applies only to Agent subclasses:
30
+ - Ensures @system_message was declared
31
+ - Collects @tool definitions and ContextItem attributes
32
+ - Initializes class __tool_defs__ and __context_items__
33
+ - Dynamically injects an __init__ signature based on class __annotations__
34
+ """
35
+
36
+ __BaseAgent__ = None
37
+ _lock = threading.RLock()
38
+
39
+ @staticmethod
40
+ def _inherited_namespace_from_bases(bases: tuple[type, ...]) -> dict[str, object]:
41
+ """
42
+ Build the inherited (raw) namespace you'd see via MRO lookup for a class with `bases`.
43
+ Returns a dict where earlier bases in the MRO win.
44
+ """
45
+ # Build a graph: any hashable node -> list of parents.
46
+ # We'll use a sentinel NEW for the (not-yet-created) class.
47
+ NEW = object()
48
+ graph = {NEW: list(bases)}
49
+
50
+ # Add all reachable base classes and their parents.
51
+ stack = list(bases)
52
+ seen = set()
53
+ while stack:
54
+ cls = stack.pop()
55
+ if cls in seen or cls is object:
56
+ continue
57
+ seen.add(cls)
58
+ parents = [b for b in cls.__bases__ if b is not object]
59
+ graph[cls] = parents
60
+ stack.extend(parents)
61
+
62
+ # C3 linearize starting from NEW
63
+ order = linearize(graph)[NEW]
64
+ mro_bases = [c for c in order if isinstance(c, type) and c is not object]
65
+
66
+ # Chain the raw class dicts in MRO precedence (leftmost wins)
67
+ return dict(ChainMap(*(vars(c) for c in mro_bases)))
68
+
69
+ @staticmethod
70
+ def _extract_tool_defs(namespace) -> Mapping[str, _ToolDefinition]:
71
+ """
72
+ Extracts tool definitions from a given namespace
73
+
74
+ Any method with the `@tool` descriptor will be attached to the `__tool_defs__` class
75
+ attribute
76
+ """
77
+ tools: dict[str, _ToolDefinition] = {}
78
+ for attr_name, attr_value in namespace.items():
79
+ if hasattr(attr_value, "__tool_def__"):
80
+ tools[attr_name] = attr_value.__tool_def__
81
+ return MappingProxyType(tools)
82
+
83
+ @staticmethod
84
+ def _extract_annotations(namespace, bases) -> dict[str, TypeVar]:
85
+ """
86
+ Extracts all annotations from current class and all its subclasses. Combines them into
87
+ one dictionary, with class order respected (subclasses overide parent classes.)
88
+ """
89
+ annotations = {}
90
+ for base in reversed(bases):
91
+ if hasattr(base, "__annotations__"):
92
+ for name, type_ in base.__annotations__.items():
93
+ if not name.startswith("__"):
94
+ annotations[name] = type_
95
+ for name, type_ in namespace.get("__annotations__", {}).items():
96
+ if not name.startswith("__"):
97
+ annotations[name] = type_
98
+ return annotations
99
+
100
+ @staticmethod
101
+ def _extract_context_attrs(
102
+ annotations, namespace
103
+ ) -> Mapping[str, tuple[TypeVar, ContextItem]]:
104
+ """
105
+ Extracts any class field from annotations and namespace where the value is that of
106
+ `ContextItem`, these will later be appeneded to the agents context. This will return
107
+ both the type and the user defined context item.
108
+ """
109
+ context_attrs: dict[str, tuple[TypeVar, ContextItem]] = {}
110
+ for attr_name, attr_type in annotations.items():
111
+ default = namespace.get(attr_name, None)
112
+ if isinstance(default, ContextItem):
113
+ context_attrs[attr_name] = (attr_type, default)
114
+
115
+ for name, value in namespace.items():
116
+ if getattr(value, "_is_context", False):
117
+ context_attrs[name] = (computed_context, value)
118
+ return MappingProxyType(context_attrs)
119
+
120
+ @staticmethod
121
+ def _extract_linked_agents(annotations, Agent) -> Mapping[str, "Agent"]:
122
+ """
123
+ Extracts any class field from annotations and namespace where the value is that of
124
+ `ContextItem`, these will later be appeneded to the agents context. This will return
125
+ both the type and the user defined context item.
126
+ """
127
+ linked_agents: dict[str, "Agent"] = {}
128
+ for attr_name, attr_type in annotations.items():
129
+ type_info = analyze_type(attr_type, Agent)
130
+ if type_info.has_forward_ref:
131
+ msg = (
132
+ f"Forward reference for agents are unsupported: '{attr_name}': {attr_type!r}. "
133
+ "Make sure the forward ref was not used for an agent, or a TypeError may occur"
134
+ )
135
+ warnings.warn(msg, RuntimeWarning, stacklevel=2)
136
+ elif type_info.is_subclass:
137
+ linked_agents[attr_name] = attr_type
138
+
139
+ return MappingProxyType(linked_agents)
140
+
141
+ @staticmethod
142
+ def _build_init_signature(cls) -> inspect.Signature:
143
+ """
144
+ Build __init__ signature with all non-default (required) params
145
+ before any defaulted (optional) params.
146
+ """
147
+ self_param = inspect.Parameter("self", inspect.Parameter.POSITIONAL_ONLY)
148
+
149
+ required: list[inspect.Parameter] = []
150
+ optional: list[inspect.Parameter] = []
151
+ agents: list[inspect.Parameter] = [] # Agents go last in signature for better order
152
+
153
+ for field_name, field_type in cls.__annotations__.items():
154
+ if field_name in cls.__context_attrs__:
155
+ param = inspect.Parameter(
156
+ field_name,
157
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
158
+ default=cls.__context_attrs__[field_name][1].get_default_value(),
159
+ annotation=field_type,
160
+ )
161
+ optional.append(param)
162
+ elif field_name in cls.__linked_agents__:
163
+ # Treat linked agents as optional by default
164
+ param = inspect.Parameter(
165
+ field_name,
166
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
167
+ default=None,
168
+ annotation=field_type,
169
+ )
170
+ agents.append(param)
171
+ else:
172
+ default = getattr(cls, field_name, inspect._empty)
173
+ param = inspect.Parameter(
174
+ field_name,
175
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
176
+ default=default,
177
+ annotation=field_type,
178
+ )
179
+
180
+ if default is inspect._empty:
181
+ required.append(param)
182
+ else:
183
+ optional.append(param)
184
+
185
+ return inspect.Signature([self_param, *required, *optional, *agents])
186
+
187
+ @staticmethod
188
+ def _build_init(sig):
189
+ """
190
+ Builds the init function for the class. This init will automatically have user-defined
191
+ context items as arguements, allow for easy initialization of agents for a variety
192
+ of different tasks.
193
+ """
194
+
195
+ def __init__(self, *args, **kwargs):
196
+
197
+ # -------- ContextClass Construction --------------------
198
+ ContextClass = _AgentContext.make_ctx_class(
199
+ name=self.__class__.__name__, ctx_map=self.__context_attrs__
200
+ )
201
+
202
+ compiled = {}
203
+ for attr_name, (attr_type, attr_default) in self.__context_attrs__.items():
204
+ # Skip compted contexts, this validaiton will happen with the validator
205
+ # using a dry run with supplied default values
206
+ if attr_type == computed_context:
207
+ continue
208
+ # Add all ContextItems to the kwargs, checking type as it goes
209
+ if attr_name in kwargs:
210
+ val = kwargs[attr_name]
211
+ try:
212
+ check_type(val, attr_type)
213
+ except TypeCheckError:
214
+ raise UnexpectedContextItemType(
215
+ name=attr_name, expected=attr_type, recieved=type(val)
216
+ )
217
+ compiled[attr_name] = val
218
+ else:
219
+ compiled[attr_name] = attr_default.get_default_value()
220
+
221
+ self.context = ContextClass(
222
+ instructions=self.__system_message__,
223
+ input_template=self.__input_template__,
224
+ **compiled,
225
+ )
226
+ # ------------- Retrieve Linked Agents -------------------
227
+ for agent_name in self.__linked_agents__.keys():
228
+ agent_instance = kwargs.get(agent_name, None)
229
+ compiled[agent_name] = agent_instance
230
+
231
+ bound = sig.bind(self, *args, **(kwargs | compiled))
232
+ # Add all other arguements to instance
233
+ for name, val in list(bound.arguments.items())[1:]: # skip 'self'
234
+ if name in self.__context_attrs__:
235
+ continue
236
+ setattr(self, name, val)
237
+
238
+ self.__post_init__()
239
+
240
+ __init__.__signature__ = sig
241
+ __init__.__annotations__ = {
242
+ p.name: p.annotation for p in sig.parameters.values() if p.name != "self"
243
+ }
244
+ return __init__
245
+
246
+ def __new__(mcs, name, bases, namespace, **kwargs):
247
+ """
248
+ This metaclass is attached to the Agent base class, so that when a new subclass of Agent
249
+ is created, then this class will automatically set up class variables that define
250
+ the functionality of the agent.
251
+
252
+ - __tool_defs__: dictionary holding all tool defintions registered by @tool
253
+ - __context_attrs__: dictionary holding tuple of type and item for all attributes
254
+ that are either have a default of ContextItem or use @computed_context
255
+ - __tool_response_models__: dictionary holding pydantic response models for each tool
256
+ - __response_model__: The response model of the current agent that is being built
257
+
258
+ Inhertance is repected in MRO order. Tools, context attributes, computed contexts and
259
+ linked agents can all be inherited, from other agents or mixins.
260
+ __system_message__ and __input_template__ are *not* inherited
261
+ """
262
+
263
+ """
264
+ Create an inherited namespace by combining all bases in MRO order.
265
+ This uses c3linearize to determine the order, allowing uses to extend other Agents
266
+ and / or any mixins.
267
+ Mixins are classes that do not extend Agent, but can offer Agent attributes, like
268
+ tools, context items, and/or linked agents
269
+ """
270
+ inherited_namespace = mcs._inherited_namespace_from_bases(bases)
271
+
272
+ """
273
+ Declare the new Agent subclass.
274
+ If this is the base agent being declared (usually on import), then the initializtion of
275
+ tools, context items, etc.. will be skipped, and this class will be stored in the meta
276
+ for future use.
277
+ All other Agent subclasses will have __abstract_base__ marked as False, so that future
278
+ implementions "know" it is not the base.
279
+ Since system message is not inherited, an exception is raised if the user does not
280
+ supply one
281
+ """
282
+ with mcs._lock:
283
+ cls = super().__new__(mcs, name, bases, namespace, **kwargs)
284
+ # If it is a base Agent, then return
285
+ if namespace.get("__abstract_base__", False):
286
+ mcs.__BaseAgent__ = cls
287
+ return cls
288
+ cls.__abstract_base__ = False
289
+ # Verify system message is set
290
+ if "__system_message__" not in namespace:
291
+ raise SystemMessageNotDeclared()
292
+
293
+ """
294
+ Extract and attach Agent attributes
295
+
296
+ __tool_defs__: Tool definitions (from any method marked with an @tool decorator) are
297
+ extracted from both the current namespace and the MRO ordered inherited namespace
298
+
299
+ __annotations__: Python annotations are extracted using the namespace and the inherited
300
+ namespace
301
+
302
+ __context_attrs__: Context attributes (from any class attribute with a ContextItem in
303
+ the namespace, or any method marked with a @computed_context decorator) are extracted
304
+ from both the current namespace and the inherited namespace. This also needs the
305
+ classes annotations, in order to attach the annotation to the context attribute for
306
+ later validation
307
+
308
+ __linked_agents__: Linked agents work a bit differently, since they cannot have default
309
+ values, the namespaces are not used. Instead, it relies on the MRO ordered annotations
310
+ to build a dict of any agents are are linked.
311
+ """
312
+ tool_defs = mcs._extract_tool_defs(inherited_namespace | namespace)
313
+ annotations = mcs._extract_annotations(inherited_namespace | namespace, bases)
314
+ context_attrs = mcs._extract_context_attrs(annotations, inherited_namespace | namespace)
315
+ linked_agents = mcs._extract_linked_agents(annotations, mcs.__BaseAgent__)
316
+ with mcs._lock:
317
+ cls.__tool_defs__ = tool_defs
318
+ cls.__annotations__ = annotations
319
+ cls.__context_attrs__ = context_attrs
320
+ cls.__linked_agents__ = linked_agents
321
+
322
+ """
323
+ Create response models. Response models are created on class declaration to give the agent
324
+ a predetermined output. This allows developers to know exactly what the output of the
325
+ agent will be, before even creating an instance of the agent.
326
+
327
+ __tool_response_models__: All tools have their own pydantic response model, these need
328
+ to be build using their Tool Definition. This needs to be stored on the agent, so that
329
+ it can create instances of the tool response after calling the tool.
330
+
331
+ __response_model__: The final pydantic response model of the agent. This is constructed
332
+ using the tool definition models, and any response model of linked agents.
333
+ """
334
+ tool_response_models = {
335
+ tool_name: ToolResponse.from_tool_def(tool_def)
336
+ for tool_name, tool_def in cls.__tool_defs__.items()
337
+ }
338
+ tool_response_model_list = list(tool_response_models.values())
339
+ linked_agent_response_model_list = [
340
+ agent.__response_model__ for agent in cls.__linked_agents__.values()
341
+ ]
342
+ ResponseModel = AgentResponse.from_tool_defs(
343
+ agent_name=cls.__name__,
344
+ tool_response_models=tool_response_model_list,
345
+ linked_agents_response_models=linked_agent_response_model_list,
346
+ )
347
+ with mcs._lock:
348
+ cls.__tool_response_models__ = MappingProxyType(tool_response_models)
349
+ cls.__response_model__ = ResponseModel
350
+
351
+ """
352
+ Build the new init
353
+
354
+ The base init just accepts *args and **kwargs, this is changed by building a new init
355
+ signature. The new signature combines any context attributes, agents, and any other
356
+ dataclass field in the following order:
357
+ 1. required: any dataclass field with no value in the namespace
358
+ 2. optional: mostly context attributes, can include dataclass fields with values in
359
+ the namespace
360
+ 3. linked agents: These come last in order to keep a clear order in the init. They
361
+ all default to None. So if the user does not supply a linked agent, then the
362
+ parent agent will ignore it.
363
+
364
+ The new init function then creates a new AgentContext class, using the context attributes
365
+ as its attributes. It loads in all the context items in it using the specified defaults
366
+ and attaches it to the agent.
367
+ After that it attaches any linked agents to the parent agent.
368
+ """
369
+ sig = mcs._build_init_signature(cls)
370
+ __init__ = mcs._build_init(sig)
371
+ with mcs._lock:
372
+ cls.__init__ = __init__
373
+
374
+ """
375
+ Validate and return
376
+ """
377
+ _AgentConstructionValidator(cls).validate()
378
+ return cls
@@ -103,7 +103,7 @@ class Param:
103
103
  case TypeCategory.LIST_PRIMITIVE:
104
104
  setattr(self, field_name, value)
105
105
  case TypeCategory.SUBCLASS:
106
- value = field_type(**value) if type(value) == dict else value
106
+ value = field_type(**value) if type(value) is dict else value
107
107
  setattr(self, field_name, value)
108
108
  case TypeCategory.LIST_SUBCLASS:
109
109
  listed_value = (
@@ -142,7 +142,7 @@ def tool(
142
142
  # Check return type
143
143
  types = get_type_hints(fn)
144
144
  return_type = types.pop("return", None)
145
- if return_type != str:
145
+ if return_type != str and fn.__name__ != "__call__":
146
146
  raise ToolDeclarationFailed(
147
147
  tool_name=fn.__name__, message="Method must have a return type of `str`"
148
148
  )
@@ -27,10 +27,7 @@ class _AgentConstructionValidator:
27
27
  def __init__(self, AgentClass: Type["Agent"]):
28
28
  self.problems = []
29
29
  self.AgentClass = AgentClass
30
- self.sample_agent = self.AgentClass(
31
- model="testing",
32
- api_key="validation",
33
- )
30
+ self.sample_agent = self.AgentClass(model="validation", api_key="validation")
34
31
 
35
32
  def validate(self):
36
33
  """
@@ -0,0 +1,97 @@
1
+ from typing import get_origin, get_args, Any, Optional, ForwardRef
2
+ from dataclasses import dataclass
3
+ from enum import Enum
4
+
5
+
6
+ PRIMITIVES = (bool, str, int, float, type(None))
7
+
8
+
9
+ def is_primitive(type_: Any) -> bool:
10
+ """
11
+ Helper function to check if a type is a python primitive
12
+ """
13
+ return type_ in PRIMITIVES
14
+
15
+
16
+ class TypeCategory(Enum):
17
+ PRIMITIVE = "primitive"
18
+ LIST_PRIMITIVE = "list_primitive"
19
+ SUBCLASS = "subclass"
20
+ LIST_SUBCLASS = "list_subclass"
21
+ UNSUPPORTED = "unsupported"
22
+
23
+
24
+ @dataclass
25
+ class TypeInfo:
26
+ """Normalized information about a type"""
27
+
28
+ category: TypeCategory
29
+ base_type: type
30
+ inner_type: Optional[type] = None # For list types
31
+
32
+ @property
33
+ def is_list(self) -> bool:
34
+ return self.category in [TypeCategory.LIST_PRIMITIVE, TypeCategory.LIST_SUBCLASS]
35
+
36
+ @property
37
+ def is_subclass(self) -> bool:
38
+ return self.category in [TypeCategory.SUBCLASS, TypeCategory.LIST_SUBCLASS]
39
+
40
+ @property
41
+ def effective_type(self) -> type:
42
+ """Returns the type to work with (inner type for lists, base type otherwise)"""
43
+ return self.inner_type if self.is_list else self.base_type
44
+
45
+ @property
46
+ def has_forward_ref(self) -> bool:
47
+ """
48
+ True if either base_type or inner_type is a forward reference
49
+ (e.g., a string annotation or typing.ForwardRef). Safe across Python versions.
50
+ """
51
+
52
+ def _is_forward_ref(t) -> bool:
53
+ if t is None:
54
+ return False
55
+ # Strings from deferred annotations / forward refs
56
+ if isinstance(t, str):
57
+ return True
58
+ # typing.ForwardRef in 3.8+ (internal shape has varied, so duck-type too)
59
+ if isinstance(t, ForwardRef):
60
+ return True
61
+ # Fallback: anything that looks like a ForwardRef (duck-typing)
62
+ return hasattr(t, "__forward_arg__") # covers older/private ForwardRef variants
63
+
64
+ return _is_forward_ref(self.base_type) or _is_forward_ref(self.inner_type)
65
+
66
+
67
+ def analyze_type(type_: type, base_class: type) -> TypeInfo:
68
+ """
69
+ Analyze a type and return normalized information about it.
70
+
71
+ Args:
72
+ type_: The type to analyze
73
+ is_primitive_func: Function to check if a type is primitive
74
+ param_base_class: Base class for Param types (e.g., Param)
75
+ """
76
+ origin = get_origin(type_)
77
+
78
+ try:
79
+ if origin == list:
80
+ inner_type = get_args(type_)[0]
81
+ if is_primitive(inner_type):
82
+ return TypeInfo(TypeCategory.LIST_PRIMITIVE, type_, inner_type)
83
+ elif issubclass(inner_type, base_class):
84
+ return TypeInfo(TypeCategory.LIST_SUBCLASS, type_, inner_type)
85
+ else:
86
+ return TypeInfo(TypeCategory.UNSUPPORTED, type_, inner_type)
87
+
88
+ elif is_primitive(type_):
89
+ return TypeInfo(TypeCategory.PRIMITIVE, type_)
90
+
91
+ elif issubclass(type_, base_class):
92
+ return TypeInfo(TypeCategory.SUBCLASS, type_)
93
+
94
+ else:
95
+ return TypeInfo(TypeCategory.UNSUPPORTED, type_)
96
+ except TypeError:
97
+ return TypeInfo(TypeCategory.UNSUPPORTED, type_)
@@ -1,8 +1,6 @@
1
1
  from pydantic import BaseModel, Field, create_model
2
2
  from typing import Type, Self, Union
3
3
 
4
- from openai.types.responses import Response
5
-
6
4
  from pyagentic._base._tool import _ToolDefinition
7
5
  from pyagentic._base._params import Param
8
6
 
@@ -114,24 +112,24 @@ class AgentResponse(BaseModel):
114
112
  a fastapi app. This is done by calling `from_tool_defs`.
115
113
  """
116
114
 
117
- response: Response
118
115
  final_output: str
119
116
 
120
117
  @classmethod
121
118
  def from_tool_defs(
122
- cls, agent_name: str, tool_response_models: list[Type[ToolResponse]]
119
+ cls,
120
+ agent_name: str,
121
+ tool_response_models: list[Type[ToolResponse]],
122
+ linked_agents_response_models: list[Type[Self]],
123
123
  ) -> Type[Self]:
124
124
  """
125
125
  Creates a subclass of `AgentResponse`, using Tool Definitions to create a predetermined
126
126
  schema of what the response will look like.
127
127
  """
128
+ fields = {}
128
129
  if tool_response_models:
129
130
  ToolResult = Union[tuple(tool_response_models)]
130
- return create_model(
131
- f"{agent_name}Response", __base__=cls, tool_responses=(list[ToolResult], ...)
132
- )
133
- else:
134
- return create_model(
135
- f"{agent_name}Response",
136
- __base__=cls,
137
- )
131
+ fields["tool_responses"] = (list[ToolResult], ...)
132
+ if linked_agents_response_models:
133
+ AgentResult = Union[tuple(linked_agents_response_models)]
134
+ fields["agent_responses"] = (list[AgentResult], ...)
135
+ return create_model(f"{agent_name}Response", __base__=cls, **fields)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyagentic-core
3
- Version: 1.2.1
3
+ Version: 1.3.0
4
4
  Summary: Build LLM Agents in a Pythonic way
5
5
  Author-email: Ryan Mikulec <rmikulec.dev@gmail.com>
6
6
  License: MIT
@@ -17,6 +17,7 @@ Requires-Dist: taskipy>=1.14.1
17
17
  Requires-Dist: openai>=1.99.3
18
18
  Requires-Dist: pydantic>=2.11.7
19
19
  Requires-Dist: typeguard>=4.4.4
20
+ Requires-Dist: c3linearize>=0.1.0
20
21
  Dynamic: license-file
21
22
 
22
23
  # PyAgentic
@@ -5,3 +5,4 @@ taskipy>=1.14.1
5
5
  openai>=1.99.3
6
6
  pydantic>=2.11.7
7
7
  typeguard>=4.4.4
8
+ c3linearize>=0.1.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyagentic-core"
3
- version = "1.2.1"
3
+ version = "1.3.0"
4
4
  description = "Build LLM Agents in a Pythonic way"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -20,6 +20,7 @@ dependencies = [
20
20
  "openai>=1.99.3",
21
21
  "pydantic>=2.11.7",
22
22
  "typeguard>=4.4.4",
23
+ "c3linearize>=0.1.0",
23
24
  ]
24
25
 
25
26
  [dependency-groups]
@@ -28,6 +29,7 @@ dev = [
28
29
  "coverage>=7.10.2",
29
30
  "deepdiff>=8.5.0",
30
31
  "flake8>=7.3.0",
32
+ "pytest-asyncio>=1.1.0",
31
33
  "pytest>=8.4.1",
32
34
  "python-semantic-release>=10.3.1",
33
35
  ]
@@ -1,6 +0,0 @@
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"]
@@ -1,179 +0,0 @@
1
- import inspect
2
- from typing import dataclass_transform, TypeVar
3
- from typeguard import check_type, TypeCheckError
4
-
5
- from pyagentic._base._validation import _AgentConstructionValidator
6
- from pyagentic._base._exceptions import SystemMessageNotDeclared, UnexpectedContextItemType
7
- from pyagentic._base._context import _AgentContext, ContextItem, computed_context
8
- from pyagentic._base._tool import _ToolDefinition
9
-
10
- from pyagentic.models.response import AgentResponse, ToolResponse
11
-
12
-
13
- @dataclass_transform(field_specifiers=(ContextItem,))
14
- class AgentMeta(type):
15
- """
16
- Metaclass that applies only to Agent subclasses:
17
- - Ensures @system_message was declared
18
- - Collects @tool definitions and ContextItem attributes
19
- - Initializes class __tool_defs__ and __context_items__
20
- - Dynamically injects an __init__ signature based on class __annotations__
21
- """
22
-
23
- @staticmethod
24
- def _extract_tool_defs(namespace) -> dict[str, _ToolDefinition]:
25
- """
26
- Extracts tool definitions from a given namespace
27
-
28
- Any method with the `@tool` descriptor will be attached to the `__tool_defs__` class
29
- attribute
30
- """
31
- tools = {}
32
- for attr_name, attr_value in namespace.items():
33
- if hasattr(attr_value, "__tool_def__"):
34
- tools[attr_name] = attr_value.__tool_def__
35
- return tools
36
-
37
- @staticmethod
38
- def _extract_annotations(bases, namespace) -> dict[str, TypeVar]:
39
- """
40
- Extracts all annotations from current class and all its subclasses. Combines them into
41
- one dictionary, with class order respected (subclasses overide parent classes.)
42
- """
43
- annotations = {}
44
- for base in reversed(bases):
45
- if hasattr(base, "__annotations__"):
46
- for name, type_ in base.__annotations__.items():
47
- if not name.startswith("__"):
48
- annotations[name] = type_
49
- for name, type_ in namespace.get("__annotations__", {}).items():
50
- if not name.startswith("__"):
51
- annotations[name] = type_
52
- return annotations
53
-
54
- @staticmethod
55
- def _extract_context_attrs(annotations, namespace) -> dict[str, tuple[TypeVar, ContextItem]]:
56
- """
57
- Extracts any class field from annotations and namespace where the value is that of
58
- `ContextItem`, these will later be appeneded to the agents context. This will return
59
- both the type and the user defined context item.
60
- """
61
- context_attrs = {}
62
- for attr_name, attr_type in annotations.items():
63
- default = namespace.get(attr_name, None)
64
- if isinstance(default, ContextItem):
65
- context_attrs[attr_name] = (attr_type, default)
66
-
67
- for name, value in namespace.items():
68
- if getattr(value, "_is_context", False):
69
- context_attrs[name] = (computed_context, value)
70
- return context_attrs
71
-
72
- @staticmethod
73
- def _build_init_signature(cls) -> inspect.Signature:
74
- """
75
- Builds the signature for the classes __init__, injecting any context item attributes
76
- defined by the user properly into the inits signature. This allows IDE's to be able
77
- to recognize user-defined class attributes when initializing a class.
78
- """
79
- params = [inspect.Parameter("self", inspect.Parameter.POSITIONAL_ONLY)]
80
- for field_name, field_type in cls.__annotations__.items():
81
- if field_name in cls.__context_attrs__:
82
- default_val = cls.__context_attrs__[field_name][1].get_default_value()
83
- else:
84
- default_val = getattr(cls, field_name, inspect._empty)
85
- param = inspect.Parameter(
86
- field_name,
87
- inspect.Parameter.POSITIONAL_OR_KEYWORD,
88
- default=default_val,
89
- annotation=field_type,
90
- )
91
- params.append(param)
92
- return inspect.Signature(params)
93
-
94
- @staticmethod
95
- def _build_init(sig):
96
- """
97
- Builds the init function for the class. This init will automatically have user-defined
98
- context items as arguements, allow for easy initialization of agents for a variety
99
- of different tasks.
100
- """
101
-
102
- def __init__(self, *args, **kwargs):
103
-
104
- # -------- ContextClass Construction --------------------
105
- ContextClass = _AgentContext.make_ctx_class(
106
- name=self.__class__.__name__, ctx_map=self.__context_attrs__
107
- )
108
-
109
- context_kwargs = {}
110
- for attr_name, (attr_type, attr_default) in self.__context_attrs__.items():
111
- # Skip compted contexts, this validaiton will happen with the validator
112
- # using a dry run with supplied default values
113
- if attr_type == computed_context:
114
- continue
115
- # Add all ContextItems to the kwargs, checking type as it goes
116
- if attr_name in kwargs:
117
- val = kwargs[attr_name]
118
- try:
119
- check_type(val, attr_type)
120
- except TypeCheckError:
121
- raise UnexpectedContextItemType(
122
- name=attr_name, expected=attr_type, recieved=type(val)
123
- )
124
- context_kwargs[attr_name] = val
125
- else:
126
- context_kwargs[attr_name] = attr_default.get_default_value()
127
-
128
- self.context = ContextClass(
129
- instructions=self.__system_message__,
130
- input_template=self.__input_template__,
131
- **context_kwargs,
132
- )
133
-
134
- bound = sig.bind(self, *args, **kwargs)
135
-
136
- # Add all other arguements to instance
137
- for name, val in list(bound.arguments.items())[1:]: # skip 'self'
138
- if name in self.__context_attrs__:
139
- pass
140
- setattr(self, name, val)
141
-
142
- self.__post_init__()
143
-
144
- __init__.__signature__ = sig # type: ignore
145
- return __init__
146
-
147
- def __new__(mcs, name, bases, namespace, **kwargs):
148
- # Create a new Agent class
149
- cls = super().__new__(mcs, name, bases, namespace, **kwargs)
150
- # If it is a base Agent, then return
151
- if namespace.get("__abstract_base__", False):
152
- return cls
153
- # Verify system message is set
154
- if "__system_message__" not in namespace:
155
- raise SystemMessageNotDeclared()
156
- # Attach tool definitions
157
- cls.__tool_defs__ = mcs._extract_tool_defs(namespace)
158
- # Attach new annotations
159
- cls.__annotations__ = mcs._extract_annotations(bases, namespace)
160
- # Attach context attributes (ContextItems and computed_context)
161
- cls.__context_attrs__ = mcs._extract_context_attrs(cls.__annotations__, namespace)
162
- # Create tool response models
163
- cls.__tool_response_models__ = {
164
- tool_name: ToolResponse.from_tool_def(tool_def)
165
- for tool_name, tool_def in cls.__tool_defs__.items()
166
- }
167
- # Create final Agent response model, using the tool response models
168
- cls.__response_model__ = AgentResponse.from_tool_defs(
169
- cls.__name__, list(cls.__tool_response_models__.values())
170
- )
171
-
172
- # Build the new init
173
- sig = mcs._build_init_signature(cls)
174
- cls.__init__ = mcs._build_init(sig)
175
-
176
- # Validate agent
177
- _AgentConstructionValidator(cls).validate()
178
-
179
- return cls
@@ -1,73 +0,0 @@
1
- from typing import get_origin, get_args, Any, Optional
2
- from dataclasses import dataclass
3
- from enum import Enum
4
-
5
-
6
- PRIMITIVES = (bool, str, int, float, type(None))
7
-
8
-
9
- def is_primitive(type_: Any) -> bool:
10
- """
11
- Helper function to check if a type is a python primitive
12
- """
13
- return type_ in PRIMITIVES
14
-
15
-
16
- class TypeCategory(Enum):
17
- PRIMITIVE = "primitive"
18
- LIST_PRIMITIVE = "list_primitive"
19
- SUBCLASS = "subclass"
20
- LIST_SUBCLASS = "list_subclass"
21
- UNSUPPORTED = "unsupported"
22
-
23
-
24
- @dataclass
25
- class TypeInfo:
26
- """Normalized information about a type"""
27
-
28
- category: TypeCategory
29
- base_type: type
30
- inner_type: Optional[type] = None # For list types
31
-
32
- @property
33
- def is_list(self) -> bool:
34
- return self.category in [TypeCategory.LIST_PRIMITIVE, TypeCategory.LIST_SUBCLASS]
35
-
36
- @property
37
- def is_subclass(self) -> bool:
38
- return self.category in [TypeCategory.SUBCLASS, TypeCategory.LIST_SUBCLASS]
39
-
40
- @property
41
- def effective_type(self) -> type:
42
- """Returns the type to work with (inner type for lists, base type otherwise)"""
43
- return self.inner_type if self.is_list else self.base_type
44
-
45
-
46
- def analyze_type(type_: type, base_class: type) -> TypeInfo:
47
- """
48
- Analyze a type and return normalized information about it.
49
-
50
- Args:
51
- type_: The type to analyze
52
- is_primitive_func: Function to check if a type is primitive
53
- param_base_class: Base class for Param types (e.g., Param)
54
- """
55
- origin = get_origin(type_)
56
-
57
- if origin == list:
58
- inner_type = get_args(type_)[0]
59
- if is_primitive(inner_type):
60
- return TypeInfo(TypeCategory.LIST_PRIMITIVE, type_, inner_type)
61
- elif issubclass(inner_type, base_class):
62
- return TypeInfo(TypeCategory.LIST_SUBCLASS, type_, inner_type)
63
- else:
64
- return TypeInfo(TypeCategory.UNSUPPORTED, type_, inner_type)
65
-
66
- elif is_primitive(type_):
67
- return TypeInfo(TypeCategory.PRIMITIVE, type_)
68
-
69
- elif issubclass(type_, base_class):
70
- return TypeInfo(TypeCategory.SUBCLASS, type_)
71
-
72
- else:
73
- return TypeInfo(TypeCategory.UNSUPPORTED, type_)
File without changes
File without changes
File without changes