Skip to content

Commit

Permalink
agentops_property - internal objects tracking the right way (#506)
Browse files Browse the repository at this point in the history
* AgentProperty descriptor

The function `check_call_stack_for_agent_id` is designed to search
through the call stack to find an instance of a class that contains
specific `AgentProperty` descriptors, particularly looking for an
`agent_ops_agent_id`. Here's a step-by-step explanation:1. **Function
Definition and Docstring**:   - The function is defined to return either
a `UUID` or `None`.   - The docstring explains that it looks through the
call stack for the class that called the LLM (Language Learning Model)
and checks for `AgentProperty` descriptors.2. **Inspecting the Call
Stack**:   - The function uses `inspect.stack()` to get the current call
stack, which is a list of `FrameInfo` objects representing the stack
frames.3. **Iterating Through Stack Frames**:   - It iterates over each
`frame_info` in the call stack.4. **Accessing Local Variables**:   - For
each frame, it accesses the local variables using
`frame_info.frame.f_locals`.5. **Checking Each Local Variable**:   - It
iterates over each local variable (`var_name`, `var`) in the current
frame.6. **Stopping at Main**:   - If the variable name is `"__main__"`,
it returns `None` and stops further processing.7. **Checking for
AgentProperty Descriptors**:   - It tries to check if the variable
(`var`) has `AgentProperty` descriptors.   - It gets the type of the
variable (`var_type`).   - It retrieves all class attributes of
`var_type` into a dictionary `class_attrs`.8. **Looking for Specific
Descriptors**:   - It looks for an attribute named `agent_ops_agent_id`
in `class_attrs`.   - If `agent_ops_agent_id` is an instance of
`AgentProperty`, it retrieves the agent ID using
`agent_id_desc.__get__(var, var_type)`.9. **Returning the Agent ID**:
- If an agent ID is found, it optionally retrieves the agent name using
a similar process and returns the agent ID.   - If no agent ID is found,
it continues to the next variable.10. **Handling Exceptions**:    - If
any exception occurs during the process, it catches the exception and
continues to the next variable.11. **Returning None**:    - If no agent
ID is found in the entire call stack, it returns `None`.This function is
useful for debugging or tracking purposes, where you need to identify
the agent that initiated a particular call in a complex system.

Signed-off-by: Teo <teocns@gmail.com>

* fmt

Signed-off-by: Teo <teocns@gmail.com>

* agentops_property, __set_name__ to automatically handle attribute naming

1. Renamed AgentOpsDescriptor to agentops_property;
2. Thanks to __set_name__, The descriptor will now automatically know
its own name when it's assigned as a class attribute

Signed-off-by: Teo <teocns@gmail.com>

* test: update agent property tests, add more coverage on call stacks

* black

Signed-off-by: Teo <teocns@gmail.com>

* refactor(decorators): using public name accessor

* Big W

* ruff

Signed-off-by: Teo <teocns@gmail.com>

---------

Signed-off-by: Teo <teocns@gmail.com>
  • Loading branch information
teocns authored Nov 18, 2024
1 parent 80d4bf5 commit 6f6b956
Show file tree
Hide file tree
Showing 4 changed files with 457 additions and 45 deletions.
52 changes: 32 additions & 20 deletions agentops/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
from typing import Optional, Union
from uuid import uuid4

from .client import Client
from .descriptor import agentops_property
from .event import ActionEvent, ErrorEvent, ToolEvent
from .helpers import check_call_stack_for_agent_id, get_ISO_time
from .session import Session
from .client import Client
from .log_config import logger
from .session import Session


def record_function(event_name: str):
Expand Down Expand Up @@ -303,45 +304,56 @@ def sync_wrapper(*args, session: Optional[Session] = None, **kwargs):

def track_agent(name: Union[str, None] = None):
def decorator(obj):
if name:
obj.agent_ops_agent_name = name

if inspect.isclass(obj):
# Set up the descriptors on the class
setattr(obj, "agentops_agent_id", agentops_property())
setattr(obj, "agentops_agent_name", agentops_property())

original_init = obj.__init__

def new_init(self, *args, **kwargs):
"""
WIthin the __init__ method, we set agentops_ properties via the private, internal descriptor
"""
try:
kwarg_name = kwargs.get("agentops_name", None)
if kwarg_name is not None:
self.agent_ops_agent_name = kwarg_name
del kwargs["agentops_name"]
# Handle name from kwargs first
name_ = kwargs.pop("agentops_name", None)

# Call original init
original_init(self, *args, **kwargs)

self.agent_ops_agent_id = str(uuid4())
# Set the agent ID
self._agentops_agent_id = str(uuid4())

# Force set the private name directly to bypass potential Pydantic interference
if name_ is not None:
setattr(self, "_agentops_agent_name", name_)
elif name is not None:
setattr(self, "_agentops_agent_name", name)
elif hasattr(self, "role"):
setattr(self, "_agentops_agent_name", self.role)

session = kwargs.get("session", None)
if session is not None:
self.agent_ops_session_id = session.session_id
self._agentops_session_id = session.session_id

Client().create_agent(
name=self.agent_ops_agent_name,
agent_id=self.agent_ops_agent_id,
name=self.agentops_agent_name,
agent_id=self.agentops_agent_id,
session=session,
)
except AttributeError as e:

except AttributeError as ex:
logger.debug(ex)
Client().add_pre_init_warning(f"Failed to track an agent {name} with the @track_agent decorator.")
logger.warning("Failed to track an agent with the @track_agent decorator.")
original_init(self, *args, **kwargs)

obj.__init__ = new_init

elif inspect.isfunction(obj):
obj.agent_ops_agent_id = str(uuid4()) # type: ignore
Client().create_agent(
name=obj.agent_ops_agent_name,
agent_id=obj.agent_ops_agent_id, # type: ignore
)
obj.agentops_agent_id = str(uuid4())
obj.agentops_agent_name = name
Client().create_agent(name=obj.agentops_agent_name, agent_id=obj.agentops_agent_id)

else:
raise Exception("Invalid input, 'obj' must be a class or a function")
Expand Down
187 changes: 187 additions & 0 deletions agentops/descriptor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import inspect
import logging
from typing import Union
from uuid import UUID


class agentops_property:
"""
A descriptor that provides a standardized way to handle agent property access and storage.
Properties are automatically stored with an '_agentops_' prefix to avoid naming conflicts.
The descriptor can be used in two ways:
1. As a class attribute directly
2. Added dynamically through a decorator (like @track_agent)
Attributes:
private_name (str): The internal name used for storing the property value,
prefixed with '_agentops_'. Set either through __init__ or __set_name__.
Example:
```python
# Direct usage in a class
class Agent:
name = agentops_property()
id = agentops_property()
def __init__(self):
self.name = "Agent1" # Stored as '_agentops_name'
self.id = "123" # Stored as '_agentops_id'
# Usage with decorator
@track_agent()
class Agent:
pass
# agentops_agent_id and agentops_agent_name are added automatically
```
Notes:
- Property names with 'agentops_' prefix are automatically stripped when creating
the internal storage name
- Returns None if the property hasn't been set
- The descriptor will attempt to resolve property names even when added dynamically
"""

def __init__(self, name=None):
"""
Initialize the descriptor.
Args:
name (str, optional): The name for the property. Used as fallback when
the descriptor is added dynamically and __set_name__ isn't called.
"""
self.private_name = None
if name:
self.private_name = f"_agentops_{name.replace('agentops_', '')}"

def __set_name__(self, owner, name):
"""
Called by Python when the descriptor is defined directly in a class.
Sets up the private name used for attribute storage.
Args:
owner: The class that owns this descriptor
name: The name given to this descriptor in the class
"""
self.private_name = f"_agentops_{name.replace('agentops_', '')}"

def __get__(self, obj, objtype=None):
"""
Get the property value.
Args:
obj: The instance to get the property from
objtype: The class of the instance
Returns:
The property value, or None if not set
The descriptor itself if accessed on the class rather than an instance
Raises:
AttributeError: If the property name cannot be determined
"""
if obj is None:
return self

# Handle case where private_name wasn't set by __set_name__
if self.private_name is None:
# Try to find the name by looking through the class dict
for name, value in type(obj).__dict__.items():
if value is self:
self.private_name = f"_agentops_{name.replace('agentops_', '')}"
break
if self.private_name is None:
raise AttributeError("Property name could not be determined")

# First try getting from object's __dict__ (for Pydantic)
if hasattr(obj, "__dict__"):
dict_value = obj.__dict__.get(self.private_name[1:])
if dict_value is not None:
return dict_value

# Fall back to our private storage
return getattr(obj, self.private_name, None)

def __set__(self, obj, value):
"""
Set the property value.
Args:
obj: The instance to set the property on
value: The value to set
Raises:
AttributeError: If the property name cannot be determined
"""
if self.private_name is None:
# Same name resolution as in __get__
for name, val in type(obj).__dict__.items():
if val is self:
self.private_name = f"_agentops_{name.replace('agentops_', '')}"
break
if self.private_name is None:
raise AttributeError("Property name could not be determined")

# Set in both object's __dict__ (for Pydantic) and our private storage
if hasattr(obj, "__dict__"):
obj.__dict__[self.private_name[1:]] = value
setattr(obj, self.private_name, value)

def __delete__(self, obj):
"""
Delete the property value.
Args:
obj: The instance to delete the property from
Raises:
AttributeError: If the property name cannot be determined
"""
if self.private_name is None:
raise AttributeError("Property name could not be determined")
try:
delattr(obj, self.private_name)
except AttributeError:
pass

@staticmethod
def stack_lookup() -> Union[UUID, None]:
"""
Look through the call stack to find an agent ID.
This method searches the call stack for objects that have agentops_property
descriptors and returns the agent_id if found.
Returns:
UUID: The agent ID if found in the call stack
None: If no agent ID is found or if "__main__" is encountered
"""
for frame_info in inspect.stack():
local_vars = frame_info.frame.f_locals

for var_name, var in local_vars.items():
# Stop at main
if var == "__main__":
return None

try:
# Check if object has our AgentOpsDescriptor descriptors
var_type = type(var)

# Get all class attributes
class_attrs = {name: getattr(var_type, name, None) for name in dir(var_type)}

agent_id_desc = class_attrs.get("agentops_agent_id")

if isinstance(agent_id_desc, agentops_property):
agent_id = agent_id_desc.__get__(var, var_type)

if agent_id:
agent_name_desc = class_attrs.get("agentops_agent_name")
if isinstance(agent_name_desc, agentops_property):
agent_name = agent_name_desc.__get__(var, var_type)
return agent_id
except Exception:
continue

return None
31 changes: 10 additions & 21 deletions agentops/helpers.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
from pprint import pformat
from functools import wraps
from datetime import datetime, timezone
import inspect
from typing import Union
import requests
import json
from importlib.metadata import version, PackageNotFoundError
from datetime import datetime, timezone
from functools import wraps
from importlib.metadata import PackageNotFoundError, version
from pprint import pformat
from typing import Any, Optional, Union
from uuid import UUID
from .descriptor import agentops_property

import requests

from .log_config import logger
from uuid import UUID


def get_ISO_time():
Expand Down Expand Up @@ -100,20 +102,7 @@ def remove_unwanted_items(value):


def check_call_stack_for_agent_id() -> Union[UUID, None]:
for frame_info in inspect.stack():
# Look through the call stack for the class that called the LLM
local_vars = frame_info.frame.f_locals
for var in local_vars.values():
# We stop looking up the stack at main because after that we see global variables
if var == "__main__":
return None
if hasattr(var, "agent_ops_agent_id") and getattr(var, "agent_ops_agent_id"):
logger.debug(
"LLM call from agent named: %s",
getattr(var, "agent_ops_agent_name"),
)
return getattr(var, "agent_ops_agent_id")
return None
return agentops_property.stack_lookup()


def get_agentops_version():
Expand Down
Loading

0 comments on commit 6f6b956

Please sign in to comment.