Skip to content

Commit 9e4d1c8

Browse files
authored
fix(agno): split tool-call loops into react steps (#240)
2 parents a92d031 + a89feae commit 9e4d1c8

5 files changed

Lines changed: 862 additions & 33 deletions

File tree

instrumentation-loongsuite/loongsuite-instrumentation-agno/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Changed
1111

12+
- Split Agno 2.x tool-call loops into ReAct step spans with one LLM span per
13+
provider request, and populate streaming agent token usage from child LLM
14+
spans when Agno run events do not include metrics.
1215
- Propagate Agno run user and session identity to model and tool spans while
1316
preferring ENTRY baggage identity over Agno framework-provided values.
1417

instrumentation-loongsuite/loongsuite-instrumentation-agno/src/opentelemetry/instrumentation/agno/__init__.py

Lines changed: 67 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import importlib
16+
import logging
1517
from typing import Any, Collection
1618

1719
from wrapt import wrap_function_wrapper
@@ -30,8 +32,65 @@
3032
_AGENT = "agno.agent"
3133
_MODULE = "agno.models.base"
3234
_TOOLKIT = "agno.tools.function"
35+
_logger = logging.getLogger(__name__)
3336
__all__ = ["AgnoInstrumentor"]
3437

38+
_INNER_MODEL_WRAPPERS = (
39+
("Model._process_model_response", "process_model_response"),
40+
("Model._aprocess_model_response", "aprocess_model_response"),
41+
("Model.process_response_stream", "process_response_stream"),
42+
("Model.aprocess_response_stream", "aprocess_response_stream"),
43+
("Model.run_function_calls", "run_function_calls"),
44+
("Model.arun_function_calls", "arun_function_calls"),
45+
)
46+
47+
_FALLBACK_MODEL_WRAPPERS = (
48+
("Model.response", "response"),
49+
("Model.aresponse", "aresponse"),
50+
("Model.response_stream", "response_stream"),
51+
("Model.aresponse_stream", "aresponse_stream"),
52+
)
53+
54+
55+
def _has_wrap_target(module_name: str, name: str) -> bool:
56+
try:
57+
target = importlib.import_module(module_name)
58+
for part in name.split("."):
59+
target = getattr(target, part)
60+
except Exception:
61+
return False
62+
return True
63+
64+
65+
def _wrap_model_methods(model_wrapper: AgnoModelWrapper) -> None:
66+
if all(
67+
_has_wrap_target(_MODULE, name)
68+
for name, _wrapper_name in _INNER_MODEL_WRAPPERS
69+
):
70+
for name, wrapper_name in _INNER_MODEL_WRAPPERS:
71+
wrap_function_wrapper(
72+
module=_MODULE,
73+
name=name,
74+
wrapper=getattr(model_wrapper, wrapper_name),
75+
)
76+
return
77+
78+
_logger.warning(
79+
"Agno inner model hooks are unavailable; falling back to outer "
80+
"Model.response wrappers."
81+
)
82+
for name, wrapper_name in _FALLBACK_MODEL_WRAPPERS:
83+
wrap_function_wrapper(
84+
module=_MODULE,
85+
name=name,
86+
wrapper=getattr(model_wrapper, wrapper_name),
87+
)
88+
89+
90+
def _unwrap_if_present(target: Any, name: str) -> None:
91+
if hasattr(target, name):
92+
unwrap(target, name)
93+
3594

3695
class AgnoInstrumentor(BaseInstrumentor): # type: ignore
3796
"""
@@ -91,27 +150,9 @@ def _instrument(self, **kwargs: Any) -> None:
91150
wrapper=function_call_wrapper.aexecute,
92151
)
93152

94-
# Wrap the model
95-
wrap_function_wrapper(
96-
module=_MODULE,
97-
name="Model.response",
98-
wrapper=model_wrapper.response,
99-
)
100-
wrap_function_wrapper(
101-
module=_MODULE,
102-
name="Model.aresponse",
103-
wrapper=model_wrapper.aresponse,
104-
)
105-
wrap_function_wrapper(
106-
module=_MODULE,
107-
name="Model.response_stream",
108-
wrapper=model_wrapper.response_stream,
109-
)
110-
wrap_function_wrapper(
111-
module=_MODULE,
112-
name="Model.aresponse_stream",
113-
wrapper=model_wrapper.aresponse_stream,
114-
)
153+
# Wrap the model. Prefer Agno's per-request internals so a tool-call
154+
# loop emits one LLM span per provider call.
155+
_wrap_model_methods(model_wrapper)
115156

116157
def _uninstrument(self, **kwargs: Any) -> None:
117158
# Unwrap the agent call function
@@ -129,8 +170,9 @@ def _uninstrument(self, **kwargs: Any) -> None:
129170
# Unwrap the model
130171
import agno.models.base # noqa: PLC0415
131172

132-
unwrap(agno.models.base.Model, "response")
133-
unwrap(agno.models.base.Model, "aresponse")
134-
unwrap(agno.models.base.Model, "response_stream")
135-
unwrap(agno.models.base.Model, "aresponse_stream")
173+
for name, _wrapper_name in (
174+
*_INNER_MODEL_WRAPPERS,
175+
*_FALLBACK_MODEL_WRAPPERS,
176+
):
177+
_unwrap_if_present(agno.models.base.Model, name.split(".", 1)[1])
136178
self._handler = None

0 commit comments

Comments
 (0)