Skip to content

Commit 9622b06

Browse files
fenilfalduDwij1704
andauthored
Tool decorator enhance (#1008)
* Added the cost attribute in tool decorator along with the tests * ruff check and tests added * code cleanup * minor changes * updated the testcase * ruff checks --------- Co-authored-by: Dwij <96073160+Dwij1704@users.noreply.github.com>
1 parent fca6472 commit 9622b06

7 files changed

Lines changed: 208 additions & 13 deletions

File tree

agentops/sdk/decorators/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@
1414
operation = create_entity_decorator(SpanKind.OPERATION)
1515
workflow = create_entity_decorator(SpanKind.WORKFLOW)
1616
session = create_entity_decorator(SpanKind.SESSION)
17+
tool = create_entity_decorator(SpanKind.TOOL)
1718
operation = task
1819

19-
__all__ = ["agent", "task", "workflow", "session", "operation"]
20+
__all__ = ["agent", "task", "workflow", "session", "operation", "tool"]
2021

2122
# Create decorators task, workflow, session, agent

agentops/sdk/decorators/factory.py

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
import functools
33
import asyncio
44

5+
56
import wrapt # type: ignore
67

78
from agentops.logging import logger
89
from agentops.sdk.core import TracingCore
10+
from agentops.semconv.span_attributes import SpanAttributes
911

1012
from .utility import (
1113
_create_as_current_span,
@@ -28,17 +30,18 @@ def create_entity_decorator(entity_kind: str):
2830
A decorator with optional arguments for name and version
2931
"""
3032

31-
def decorator(wrapped=None, *, name=None, version=None):
33+
def decorator(wrapped=None, *, name=None, version=None, cost=None):
3234
# Handle case where decorator is called with parameters
3335
if wrapped is None:
34-
return functools.partial(decorator, name=name, version=version)
36+
return functools.partial(decorator, name=name, version=version, cost=cost)
3537

3638
# Handle class decoration
3739
if inspect.isclass(wrapped):
3840
# Create a proxy class that wraps the original class
3941
class WrappedClass(wrapped):
4042
def __init__(self, *args, **kwargs):
4143
operation_name = name or wrapped.__name__
44+
4245
self._agentops_span_context_manager = _create_as_current_span(operation_name, entity_kind, version)
4346
self._agentops_active_span = self._agentops_span_context_manager.__enter__()
4447

@@ -51,23 +54,18 @@ def __init__(self, *args, **kwargs):
5154
super().__init__(*args, **kwargs)
5255

5356
async def __aenter__(self):
54-
# Added for async context manager support
55-
# This allows using the class with 'async with' statement
56-
5757
# If span is already created in __init__, just return self
5858
if hasattr(self, "_agentops_active_span") and self._agentops_active_span is not None:
5959
return self
6060

6161
# Otherwise create span (for backward compatibility)
6262
operation_name = name or wrapped.__name__
63+
6364
self._agentops_span_context_manager = _create_as_current_span(operation_name, entity_kind, version)
6465
self._agentops_active_span = self._agentops_span_context_manager.__enter__()
6566
return self
6667

6768
async def __aexit__(self, exc_type, exc_val, exc_tb):
68-
# Added for proper async cleanup
69-
# This ensures spans are properly closed when using 'async with'
70-
7169
if hasattr(self, "_agentops_active_span") and hasattr(self, "_agentops_span_context_manager"):
7270
try:
7371
_record_entity_output(self._agentops_active_span, self)
@@ -104,10 +102,12 @@ def wrapper(wrapped, instance, args, kwargs):
104102

105103
# Handle generator functions
106104
if is_generator:
107-
# Use the old approach for generators
108105
span, ctx, token = _make_span(operation_name, entity_kind, version)
109106
try:
110107
_record_entity_input(span, args, kwargs)
108+
# Set cost attribute if tool
109+
if entity_kind == "tool" and cost is not None:
110+
span.set_attribute(SpanAttributes.LLM_USAGE_TOOL_COST, cost)
111111
except Exception as e:
112112
logger.warning(f"Failed to record entity input: {e}")
113113

@@ -116,10 +116,12 @@ def wrapper(wrapped, instance, args, kwargs):
116116

117117
# Handle async generator functions
118118
elif is_async_generator:
119-
# Use the old approach for async generators
120119
span, ctx, token = _make_span(operation_name, entity_kind, version)
121120
try:
122121
_record_entity_input(span, args, kwargs)
122+
# Set cost attribute if tool
123+
if entity_kind == "tool" and cost is not None:
124+
span.set_attribute(SpanAttributes.LLM_USAGE_TOOL_COST, cost)
123125
except Exception as e:
124126
logger.warning(f"Failed to record entity input: {e}")
125127

@@ -133,6 +135,9 @@ async def _wrapped_async():
133135
with _create_as_current_span(operation_name, entity_kind, version) as span:
134136
try:
135137
_record_entity_input(span, args, kwargs)
138+
# Set cost attribute if tool
139+
if entity_kind == "tool" and cost is not None:
140+
span.set_attribute(SpanAttributes.LLM_USAGE_TOOL_COST, cost)
136141
except Exception as e:
137142
logger.warning(f"Failed to record entity input: {e}")
138143

@@ -144,6 +149,7 @@ async def _wrapped_async():
144149
logger.warning(f"Failed to record entity output: {e}")
145150
return result
146151
except Exception as e:
152+
logger.error(f"Error in async function execution: {e}")
147153
span.record_exception(e)
148154
raise
149155

@@ -154,7 +160,9 @@ async def _wrapped_async():
154160
with _create_as_current_span(operation_name, entity_kind, version) as span:
155161
try:
156162
_record_entity_input(span, args, kwargs)
157-
163+
# Set cost attribute if tool
164+
if entity_kind == "tool" and cost is not None:
165+
span.set_attribute(SpanAttributes.LLM_USAGE_TOOL_COST, cost)
158166
except Exception as e:
159167
logger.warning(f"Failed to record entity input: {e}")
160168

@@ -167,6 +175,7 @@ async def _wrapped_async():
167175
logger.warning(f"Failed to record entity output: {e}")
168176
return result
169177
except Exception as e:
178+
logger.error(f"Error in sync function execution: {e}")
170179
span.record_exception(e)
171180
raise
172181

agentops/semconv/span_attributes.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ class SpanAttributes:
6565
LLM_USAGE_CACHE_READ_INPUT_TOKENS = "gen_ai.usage.cache_read_input_tokens"
6666
LLM_USAGE_REASONING_TOKENS = "gen_ai.usage.reasoning_tokens"
6767
LLM_USAGE_STREAMING_TOKENS = "gen_ai.usage.streaming_tokens"
68+
LLM_USAGE_TOOL_COST = "gen_ai.usage.total_cost"
6869

6970
# Message attributes
7071
# see ./message.py for message-related attributes

docs/v1/concepts/sessions.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Optionally, sessions may include:
2828
- **Host Environment**: Automatically gathers basic information about the system on which the session ran.
2929
- **Video**: If applicable, an optional video recording of the session.
3030

31+
3132
### Methods
3233
#### `end_session`
3334
**Params**

docs/v2/concepts/decorators.mdx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ AgentOps provides the following decorators:
1313
| `@operation` | Track discrete operations performed by agents | OPERATION span |
1414
| `@workflow` | Track a sequence of operations | WORKFLOW span |
1515
| `@task` | Track smaller units of work (similar to operations) | TASK span |
16+
| `@tool` | Track tool usage and cost in agent operations | TOOL span |
1617

1718
## Decorator Hierarchy
1819

@@ -190,6 +191,50 @@ class DataProcessor:
190191

191192
The `@task` and `@operation` decorators function identically (they are aliases in the codebase), and you can choose the one that best fits your semantic needs.
192193

194+
### @tool
195+
196+
The `@tool` decorator tracks tool usage within agent operations and supports cost tracking. It works with all function types: synchronous, asynchronous, generator, and async generator.
197+
198+
```python
199+
from agentops.sdk.decorators import agent, tool
200+
import asyncio
201+
202+
@agent
203+
class ProcessingAgent:
204+
def __init__(self):
205+
pass
206+
207+
@tool(cost=0.01)
208+
def sync_tool(self, item):
209+
"""Synchronous tool with cost tracking."""
210+
return f"Processed {item}"
211+
212+
@tool(cost=0.02)
213+
async def async_tool(self, item):
214+
"""Asynchronous tool with cost tracking."""
215+
await asyncio.sleep(0.1)
216+
return f"Async processed {item}"
217+
218+
@tool(cost=0.03)
219+
def generator_tool(self, items):
220+
"""Generator tool with cost tracking."""
221+
for item in items:
222+
yield self.sync_tool(item)
223+
224+
@tool(cost=0.04)
225+
async def async_generator_tool(self, items):
226+
"""Async generator tool with cost tracking."""
227+
for item in items:
228+
await asyncio.sleep(0.1)
229+
yield await self.async_tool(item)
230+
```
231+
232+
The tool decorator provides:
233+
- Cost tracking for each tool call
234+
- Proper span creation and nesting
235+
- Support for all function types (sync, async, generator, async generator)
236+
- Cost accumulation in generator and async generator operations
237+
193238
## Decorator Attributes
194239

195240
You can pass additional attributes to decorators:

docs/v2/usage/sdk-reference.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ my_workflow()
145145
- `@agent`: Creates an agent span for tracking agent operations
146146
- `@operation` / `@task`: Creates operation/task spans for tracking specific operations (these are aliases)
147147
- `@workflow`: Creates workflow spans for organizing related operations
148+
- `@tool`: Creates tool spans for tracking tool usage and cost in agent operations. Supports cost parameter for tracking tool usage costs.
148149

149150
See [Decorators](/v2/concepts/decorators) for more detailed documentation on using these decorators.
150151

tests/unit/sdk/test_decorators.py

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import asyncio
33
import pytest
44

5-
from agentops.sdk.decorators import agent, operation, session, workflow, task
5+
from agentops.sdk.decorators import agent, operation, session, workflow, task, tool
66
from agentops.semconv import SpanKind
77
from agentops.semconv.span_attributes import SpanAttributes
88
from tests.unit.sdk.instrumentation_tester import InstrumentationTester
@@ -624,3 +624,140 @@ def __init__(self):
624624
with pytest.raises(ValueError):
625625
async with TestClass() as instance:
626626
raise ValueError("Trigger exception for __aexit__ coverage")
627+
628+
629+
class TestToolDecorator:
630+
"""Tests for the tool decorator functionality."""
631+
632+
@pytest.fixture
633+
def agent_class(self):
634+
@agent
635+
class TestAgent:
636+
@tool(cost=0.01)
637+
def process_item(self, item):
638+
return f"Processed {item}"
639+
640+
@tool(cost=0.02)
641+
async def async_process_item(self, item):
642+
await asyncio.sleep(0.1)
643+
return f"Async processed {item}"
644+
645+
@tool(cost=0.03)
646+
def generator_process_items(self, items):
647+
for item in items:
648+
yield self.process_item(item)
649+
650+
@tool(cost=0.04)
651+
async def async_generator_process_items(self, items):
652+
for item in items:
653+
await asyncio.sleep(0.1)
654+
yield await self.async_process_item(item)
655+
656+
return TestAgent()
657+
658+
def test_sync_tool_cost(self, agent_class, instrumentation: InstrumentationTester):
659+
"""Test synchronous tool with cost attribute."""
660+
result = agent_class.process_item("test")
661+
662+
assert result == "Processed test"
663+
664+
spans = instrumentation.get_finished_spans()
665+
tool_span = next(
666+
span for span in spans if span.attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.TOOL
667+
)
668+
assert tool_span.attributes.get(SpanAttributes.LLM_USAGE_TOOL_COST) == 0.01
669+
670+
@pytest.mark.asyncio
671+
async def test_async_tool_cost(self, agent_class, instrumentation: InstrumentationTester):
672+
"""Test asynchronous tool with cost attribute."""
673+
result = await agent_class.async_process_item("test")
674+
675+
assert result == "Async processed test"
676+
677+
spans = instrumentation.get_finished_spans()
678+
tool_span = next(
679+
span for span in spans if span.attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.TOOL
680+
)
681+
assert tool_span.attributes.get(SpanAttributes.LLM_USAGE_TOOL_COST) == 0.02
682+
683+
def test_generator_tool_cost(self, agent_class, instrumentation: InstrumentationTester):
684+
"""Test generator tool with cost attribute."""
685+
items = ["item1", "item2", "item3"]
686+
results = list(agent_class.generator_process_items(items))
687+
688+
assert len(results) == 3
689+
assert results[0] == "Processed item1"
690+
assert results[1] == "Processed item2"
691+
assert results[2] == "Processed item3"
692+
693+
spans = instrumentation.get_finished_spans()
694+
tool_spans = [span for span in spans if span.attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.TOOL]
695+
assert len(tool_spans) == 4 # Only one span for the generator
696+
assert tool_spans[0].attributes.get(SpanAttributes.LLM_USAGE_TOOL_COST) == 0.01
697+
assert tool_spans[3].attributes.get(SpanAttributes.LLM_USAGE_TOOL_COST) == 0.03
698+
699+
@pytest.mark.asyncio
700+
async def test_async_generator_tool_cost(self, agent_class, instrumentation: InstrumentationTester):
701+
"""Test async generator tool with cost attribute."""
702+
items = ["item1", "item2", "item3"]
703+
results = [result async for result in agent_class.async_generator_process_items(items)]
704+
705+
assert len(results) == 3
706+
assert results[0] == "Async processed item1"
707+
assert results[1] == "Async processed item2"
708+
assert results[2] == "Async processed item3"
709+
710+
spans = instrumentation.get_finished_spans()
711+
tool_span = [span for span in spans if span.attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.TOOL]
712+
assert len(tool_span) == 4 # Only one span for the generator
713+
assert tool_span[0].attributes.get(SpanAttributes.LLM_USAGE_TOOL_COST) == 0.02
714+
assert tool_span[3].attributes.get(SpanAttributes.LLM_USAGE_TOOL_COST) == 0.04
715+
716+
def test_multiple_tool_calls(self, agent_class, instrumentation: InstrumentationTester):
717+
"""Test multiple calls to the same tool."""
718+
for i in range(3):
719+
result = agent_class.process_item(f"item{i}")
720+
assert result == f"Processed item{i}"
721+
722+
spans = instrumentation.get_finished_spans()
723+
tool_spans = [span for span in spans if span.attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.TOOL]
724+
assert len(tool_spans) == 3
725+
for span in tool_spans:
726+
assert span.attributes.get(SpanAttributes.LLM_USAGE_TOOL_COST) == 0.01
727+
728+
@pytest.mark.asyncio
729+
async def test_parallel_tool_calls(self, agent_class, instrumentation: InstrumentationTester):
730+
"""Test parallel execution of async tools."""
731+
results = await asyncio.gather(
732+
agent_class.async_process_item("item1"),
733+
agent_class.async_process_item("item2"),
734+
agent_class.async_process_item("item3"),
735+
)
736+
737+
assert len(results) == 3
738+
assert results[0] == "Async processed item1"
739+
assert results[1] == "Async processed item2"
740+
assert results[2] == "Async processed item3"
741+
742+
spans = instrumentation.get_finished_spans()
743+
tool_spans = [span for span in spans if span.attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.TOOL]
744+
assert len(tool_spans) == 3
745+
for span in tool_spans:
746+
assert span.attributes.get(SpanAttributes.LLM_USAGE_TOOL_COST) == 0.02
747+
748+
def test_tool_without_cost(self, agent_class, instrumentation: InstrumentationTester):
749+
"""Test tool without cost parameter."""
750+
751+
@tool
752+
def no_cost_tool(self):
753+
return "No cost tool result"
754+
755+
result = no_cost_tool(agent_class)
756+
757+
assert result == "No cost tool result"
758+
759+
spans = instrumentation.get_finished_spans()
760+
tool_span = next(
761+
span for span in spans if span.attributes.get(SpanAttributes.AGENTOPS_SPAN_KIND) == SpanKind.TOOL
762+
)
763+
assert SpanAttributes.LLM_USAGE_TOOL_COST not in tool_span.attributes

0 commit comments

Comments
 (0)