Skip to content

Commit 52f6cb1

Browse files
CopilotnikhilNava
andauthored
Add bounded size limit to _pending_tool_calls dictionary (#125)
* Initial plan * Implement bounded size for _pending_tool_calls to prevent memory growth Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com> * Fix trailing empty line in test file Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com>
1 parent 0bf19e6 commit 52f6cb1

3 files changed

Lines changed: 157 additions & 4 deletions

File tree

libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/trace_processor.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676

7777
class OpenAIAgentsTraceProcessor(TracingProcessor):
7878
_MAX_HANDOFFS_IN_FLIGHT = 1000
79+
_MAX_PENDING_TOOL_CALLS = 1000
7980

8081
def __init__(self, tracer: Tracer) -> None:
8182
self._tracer = tracer
@@ -95,7 +96,9 @@ def __init__(self, tracer: Tracer) -> None:
9596
# Track parent-child relationships: child_span_id -> parent_span_id
9697
self._span_parents: dict[str, str] = {}
9798
# Track tool_call_ids from GenerationSpan: (function_name, trace_id) -> call_id
98-
self._pending_tool_calls: dict[str, str] = {}
99+
# Use an OrderedDict and _MAX_PENDING_TOOL_CALLS to cap the size of the dict
100+
# in case tool calls are captured but never consumed
101+
self._pending_tool_calls: OrderedDict[str, str] = OrderedDict()
99102

100103
# helper
101104
def _stamp_custom_parent(self, otel_span: OtelSpan, trace_id: str) -> None:
@@ -202,7 +205,9 @@ def on_span_end(self, span: Span[Any]) -> None:
202205
capture_output_message(agent_span_id, data.output, self._agent_outputs)
203206
# Capture tool_call_ids for later use by FunctionSpan
204207
if data.output:
205-
capture_tool_call_ids(data.output, self._pending_tool_calls)
208+
capture_tool_call_ids(
209+
data.output, self._pending_tool_calls, self._MAX_PENDING_TOOL_CALLS
210+
)
206211
otel_span.update_name(
207212
f"{otel_span.attributes[GEN_AI_OPERATION_NAME_KEY]} {otel_span.attributes[GEN_AI_REQUEST_MODEL_KEY]}"
208213
)

libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/utils.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -537,8 +537,16 @@ def get_span_status(obj: Span[Any]) -> Status:
537537
return Status(StatusCode.OK)
538538

539539

540-
def capture_tool_call_ids(output_list: Any, pending_tool_calls: dict[str, str]) -> None:
541-
"""Extract and store tool_call_ids from generation output for later use by FunctionSpan."""
540+
def capture_tool_call_ids(
541+
output_list: Any, pending_tool_calls: dict[str, str], max_size: int = 1000
542+
) -> None:
543+
"""Extract and store tool_call_ids from generation output for later use by FunctionSpan.
544+
545+
Args:
546+
output_list: The generation output containing tool calls
547+
pending_tool_calls: OrderedDict to store pending tool calls
548+
max_size: Maximum number of pending tool calls to keep in memory
549+
"""
542550
if not output_list:
543551
return
544552
try:
@@ -556,6 +564,9 @@ def capture_tool_call_ids(output_list: Any, pending_tool_calls: dict[str, str])
556564
# Key by (function_name, arguments) to uniquely identify each call
557565
key = f"{func_name}:{func_args}"
558566
pending_tool_calls[key] = call_id
567+
# Cap the size of the dict to prevent unbounded growth
568+
while len(pending_tool_calls) > max_size:
569+
pending_tool_calls.popitem(last=False)
559570
except Exception:
560571
pass
561572

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""
5+
Standalone test for bounded tool calls functionality.
6+
This test can be run without installing the full package.
7+
"""
8+
9+
from collections import OrderedDict
10+
11+
12+
def capture_tool_call_ids_test(
13+
output_list, pending_tool_calls, max_size=1000
14+
):
15+
"""Test version of capture_tool_call_ids function."""
16+
if not output_list:
17+
return
18+
try:
19+
for msg in output_list:
20+
if isinstance(msg, dict) and msg.get("role") == "assistant":
21+
tool_calls = msg.get("tool_calls")
22+
if tool_calls:
23+
for tc in tool_calls:
24+
if isinstance(tc, dict):
25+
call_id = tc.get("id")
26+
func = tc.get("function", {})
27+
func_name = func.get("name") if isinstance(func, dict) else None
28+
func_args = func.get("arguments", "") if isinstance(func, dict) else ""
29+
if call_id and func_name:
30+
key = f"{func_name}:{func_args}"
31+
pending_tool_calls[key] = call_id
32+
# Cap the size of the dict to prevent unbounded growth
33+
while len(pending_tool_calls) > max_size:
34+
pending_tool_calls.popitem(last=False)
35+
except Exception:
36+
pass
37+
38+
39+
def test_bounded_size():
40+
"""Test that the bounded size logic works correctly."""
41+
pending_tool_calls = OrderedDict()
42+
max_size = 10
43+
44+
print("Testing bounded size functionality...")
45+
print(f"Max size: {max_size}")
46+
47+
# Create tool calls that exceed max_size
48+
for i in range(15):
49+
output_list = [
50+
{
51+
"role": "assistant",
52+
"tool_calls": [
53+
{
54+
"id": f"call_{i}",
55+
"function": {
56+
"name": f"function_{i}",
57+
"arguments": f'{{"arg": {i}}}',
58+
},
59+
}
60+
],
61+
}
62+
]
63+
capture_tool_call_ids_test(output_list, pending_tool_calls, max_size)
64+
print(f"After adding call {i}: size = {len(pending_tool_calls)}")
65+
66+
# Verify the size does not exceed max_size
67+
assert len(pending_tool_calls) <= max_size, f"Size {len(pending_tool_calls)} exceeds max {max_size}"
68+
assert len(pending_tool_calls) == max_size, f"Size {len(pending_tool_calls)} should equal max {max_size}"
69+
70+
print(f"\n✅ Final size: {len(pending_tool_calls)} (max: {max_size})")
71+
72+
# Verify that the oldest entries were removed (FIFO behavior)
73+
print("\nVerifying FIFO behavior...")
74+
for i in range(5):
75+
key = f'function_{i}:{{"arg": {i}}}'
76+
assert key not in pending_tool_calls, f"Old entry {key} should have been removed"
77+
print(f"✅ Entry {i} was correctly removed (oldest)")
78+
79+
# The last 10 entries should still be present
80+
for i in range(5, 15):
81+
key = f'function_{i}:{{"arg": {i}}}'
82+
assert key in pending_tool_calls, f"Recent entry {key} should be present"
83+
assert pending_tool_calls[key] == f"call_{i}", f"Call ID mismatch for {key}"
84+
print(f"✅ Entry {i} is present with correct call_id")
85+
86+
print("\n✅ All tests passed! Bounded size logic works correctly.")
87+
return True
88+
89+
90+
def test_single_tool_call():
91+
"""Test storing a single tool call."""
92+
pending_tool_calls = OrderedDict()
93+
max_size = 10
94+
95+
print("\nTesting single tool call...")
96+
output_list = [
97+
{
98+
"role": "assistant",
99+
"tool_calls": [
100+
{
101+
"id": "call_123",
102+
"function": {
103+
"name": "add_numbers",
104+
"arguments": '{"a": 5, "b": 10}',
105+
},
106+
}
107+
],
108+
}
109+
]
110+
capture_tool_call_ids_test(output_list, pending_tool_calls, max_size)
111+
112+
assert len(pending_tool_calls) == 1
113+
key = 'add_numbers:{"a": 5, "b": 10}'
114+
assert key in pending_tool_calls
115+
assert pending_tool_calls[key] == "call_123"
116+
117+
print("✅ Single tool call test passed!")
118+
return True
119+
120+
121+
if __name__ == "__main__":
122+
print("=" * 70)
123+
print("Running Bounded Tool Calls Tests")
124+
print("=" * 70)
125+
126+
try:
127+
test_bounded_size()
128+
test_single_tool_call()
129+
print("\n" + "=" * 70)
130+
print("🎉 All tests passed successfully!")
131+
print("=" * 70)
132+
except AssertionError as e:
133+
print(f"\n❌ Test failed: {e}")
134+
exit(1)
135+
except Exception as e:
136+
print(f"\n❌ Unexpected error: {e}")
137+
exit(1)

0 commit comments

Comments
 (0)