Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .release-intents/20260816-agentscope-agentspec-tracing.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"summary": "Fix AgentScope custom-model activation and AgentSpec trace semantics",
"packages": {
"respan-instrumentation-agentscope": "patch",
"respan-instrumentation-agentspec": "patch"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,17 @@ respan = Respan(
respan.flush()
```

For custom model classes defined outside AgentScope's public model modules,
configure all instances on one instrumentor so Respan activates one lifecycle
identity while patching every distinct custom model class:

```python
AgentScopeInstrumentor(models=[planner_model, reviewer_model, fallback_model])
```

Use `model=...` for a single custom model. Do not pass `model` and `models`
together.

## What Is Captured

- Agent `reply()` and `reply_stream()` calls as `agent` spans.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import json
import logging
import time
from collections.abc import AsyncIterator, Mapping
from collections.abc import AsyncIterator, Mapping, Sequence
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
Expand Down Expand Up @@ -1077,12 +1077,20 @@ def __init__(
*,
agent: Any | None = None,
model: Any | None = None,
models: Sequence[Any] | None = None,
toolkit: Any | None = None,
instrument_models: bool = True,
instrument_tools: bool = True,
) -> None:
if model is not None and models is not None:
raise ValueError("Pass either model or models, not both")

self._agent = agent
self._model = model
self._models = (
tuple(models)
if models is not None
else (() if model is None else (model,))
)
self._toolkit = toolkit
self._instrument_models = instrument_models
self._instrument_tools = instrument_tools
Expand Down Expand Up @@ -1205,11 +1213,17 @@ def activate(self) -> None:
)

if self._instrument_models:
if self._model is not None:
patched_any |= self._patch_model_target(
self._model,
is_bound_method=True,
)
if self._models:
seen_model_classes: set[type[Any]] = set()
for model in self._models:
model_class = type(model)
if model_class in seen_model_classes:
continue
seen_model_classes.add(model_class)
patched_any |= self._patch_model_target(
model,
is_bound_method=True,
)
else:
try:
model_module = self._load_model_module()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@ async def __call__(self, messages, tools=None, tool_choice=None, **kwargs):
return FakeChatResponse(content=[FakeToolCallBlock()])


class FakeSecondChatModel(FakeChatModelBase):
async def __call__(self, messages, tools=None, tool_choice=None, **kwargs):
return FakeChatResponse(content=[FakeTextBlock("Second model.")])


class FakeKeyErrorModel(FakeChatModelBase):
async def __call__(self, messages, tools=None, tool_choice=None, **kwargs):
return FakeKeyErrorProxy(
Expand Down Expand Up @@ -278,6 +283,38 @@ def test_activate_specific_instances_does_not_patch_classes(monkeypatch):
assert fake_modules.agent_class.reply is original_agent_reply


def test_activate_patches_multiple_custom_model_classes_once():
first_model = FakeChatModelBase()
duplicate_class_model = FakeChatModelBase()
second_model = FakeSecondChatModel()
original_first_call = FakeChatModelBase.__call__
original_second_call = FakeSecondChatModel.__call__

instrumentor = AgentScopeInstrumentor(
agent=object(),
models=[first_model, duplicate_class_model, second_model],
instrument_tools=False,
)
instrumentor.activate()

assert FakeChatModelBase.__call__ is not original_first_call
assert FakeSecondChatModel.__call__ is not original_second_call
assert len(instrumentor._patches) == 2

instrumentor.deactivate()

assert FakeChatModelBase.__call__ is original_first_call
assert FakeSecondChatModel.__call__ is original_second_call


def test_activate_rejects_model_and_models_together():
with pytest.raises(ValueError, match="either model or models"):
AgentScopeInstrumentor(
model=FakeChatModelBase(),
models=[FakeSecondChatModel()],
)


def test_activate_skips_when_respan_tracing_is_disabled(monkeypatch, caplog):
fake_modules = _install_fake_agentscope_modules(monkeypatch)
RespanTracer(is_enabled=False)
Expand Down
Loading
Loading