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 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)
@@ -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
+ [![Python Version](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/downloads/)
22
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
23
+ [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
24
+ [![Tests](https://github.com/rmikulec/pyagentic/workflows/Tests/badge.svg?branch=main)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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