tencentcloud-agentobs-sdk-agentscope
Traces AgentScope agents into Tencent Cloud CLS, following OpenTelemetry GenAI semantic conventions.
Both AgentScope majors are supported. The right mechanism is selected from the installed version — v1 patches concrete model/agent classes and registers ReAct hooks, v2 joins the agent's middleware chain — and both produce an identical span hierarchy, so a saved CLS query keeps working across an upgrade.
Install
pip install tencentcloud-agentobs-sdk-agentscope[cls]
Omit [cls] to print spans to stdout instead of shipping them.
Use
Point it at a CLS topic, then call init() once at the top of your program:
export CLS_ENDPOINT=ap-guangzhou.cls.tencentcs.com # region endpoint, no scheme needed
export CLS_TOPIC_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
export CLS_SECRET_ID=AKID...
export CLS_SECRET_KEY=...
from tencentcloud_agentobs_sdk_agentscope import init
init() # reads the CLS_* variables above
Or pass them directly — equivalent:
init(
endpoint="ap-guangzhou.cls.tencentcs.com",
topic_id="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
secret_id="AKID...",
secret_key="...",
service_name="my-agent-app",
)
It also runs with nothing configured — spans go to stdout, so you can check the instrumentation first and add credentials later. See Pointing it at CLS for every option.
Everything after that is automatic — no changes to agent code.
A complete example
A runnable minimal application. Everything past the first two lines is ordinary AgentScope code — which is the point: nothing has to be sprinkled through your own logic.
import asyncio
import os
# ① Call before creating any agent. It patches AgentScope's classes, so agents
# constructed afterwards are the ones that get instrumented.
from tencentcloud_agentobs_sdk_agentscope import init
init(
endpoint=os.environ["CLS_ENDPOINT"], # e.g. ap-guangzhou.cls.tencentcs.com
topic_id=os.environ["CLS_TOPIC_ID"],
secret_id=os.environ["CLS_SECRET_ID"],
secret_key=os.environ["CLS_SECRET_KEY"],
service_name="my-agent-app",
)
# With those variables exported, the above is just init(service_name="my-agent-app")
from agentscope.agent import ReActAgent
from agentscope.formatter import DashScopeChatFormatter
from agentscope.memory import InMemoryMemory
from agentscope.message import Msg, TextBlock
from agentscope.model import DashScopeChatModel
from agentscope.tool import Toolkit, ToolResponse
def get_weather(city: str) -> ToolResponse:
"""Look up the weather for a city."""
return ToolResponse(
content=[TextBlock(type="text", text=f"{city}: sunny, 26C")]
)
async def main():
toolkit = Toolkit()
toolkit.register_tool_function(get_weather)
# ② No observability code from here down
agent = ReActAgent(
name="assistant",
sys_prompt="You are an assistant. Use tools when needed.",
model=DashScopeChatModel(
model_name="qwen-max",
api_key=os.environ["DASHSCOPE_API_KEY"],
stream=False,
),
formatter=DashScopeChatFormatter(),
memory=InMemoryMemory(),
toolkit=toolkit,
)
reply = await agent(Msg("user", "What's the weather in Beijing?", "user"))
print(reply.content)
if __name__ == "__main__":
asyncio.run(main())
A runnable version of this is examples/readme_demo.py (the model is scripted, so
it needs no API key). It produces this span tree:
invoke_agent assistant [agent] one agent execution
react round_1 [step] the model decides to call a tool
chat qwen-max [chat] token usage, finish_reasons
execute_tool get_weather [tool] arguments and result
react round_2 [step] the model answers from the result
chat qwen-max [chat]
And each span reaches CLS looking like this (key fields only):
{
"spanKind": "chat",
"name": "chat qwen-max",
"sessionID": "sess_37beab19b04d",
"stepID": "sess_37beab19b04d:t1:s1",
"statusCode": "OK",
"durationMs": "812",
"attribute": {
"gen_ai.operation.name": "chat",
"gen_ai.request.model": "qwen-max",
"gen_ai.provider.name": "dashscope",
"gen_ai.usage.total_tokens": 17,
"gen_ai.response.finish_reasons": ["tool_calls"]
}
}
sessionID / turnID / stepID nest as text, so one prefix query selects a
whole session, one interaction or one round — see Identifiers.
The above uses the v1 API. v2 changed a fair amount (Agent replaces
ReActAgent, credentials are separate objects, add_tool is async) but the
integration is identical — still one init() up front:
import os
from tencentcloud_agentobs_sdk_agentscope import init
init(
endpoint=os.environ["CLS_ENDPOINT"],
topic_id=os.environ["CLS_TOPIC_ID"],
secret_id=os.environ["CLS_SECRET_ID"],
secret_key=os.environ["CLS_SECRET_KEY"],
service_name="my-agent-app",
)
from agentscope.agent import Agent
from agentscope.credential import DashScopeCredential
from agentscope.message import UserMsg
from agentscope.model import DashScopeChatModel
from agentscope.tool import FunctionTool, Toolkit
async def main():
toolkit = Toolkit()
await toolkit.add_tool(FunctionTool(get_weather)) # async in v2
agent = Agent(
name="assistant",
system_prompt="You are an assistant. Use tools when needed.",
model=DashScopeChatModel(
credential=DashScopeCredential(api_key=os.environ["DASHSCOPE_API_KEY"]),
model="qwen-max",
),
toolkit=toolkit,
)
reply = await agent.reply(UserMsg(name="user", content="Weather in Beijing?"))
On v2 you also get several things v1 cannot report — permission decisions,
context compression, system prompt changes, waits for human approval — with no
extra configuration. See examples/v2_full_demo.py for a runnable comparison.
Try it locally before wiring up CLS
It runs with no credentials at all — spans go to stdout, so you can confirm the instrumentation before involving an account:
python my_app.py # no credentials, prints to stdout
AGENTOBS_CONSOLE=1 python my_app.py # credentials set, but stay local
Registering the middleware yourself (v2)
init() patches Agent.__init__ so every agent is instrumented without being
touched. If you would rather see where instrumentation comes from — or you only
want it on some agents — register it explicitly instead, the same way AgentScope's
own TracingMiddleware is used:
import os
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from tencentcloud_agentobs_sdk_agentscope import AgentObsMiddleware
from tencentcloud_agentobs_sdk_agentscope.cls_cloud_exporter import CLSCloudExporter
# Registering by hand means installing an exporter too — init() would have done it
provider = TracerProvider()
provider.add_span_processor(BatchSpanProcessor(CLSCloudExporter(
endpoint=os.environ["CLS_ENDPOINT"],
topic_id=os.environ["CLS_TOPIC_ID"],
)))
tracer = provider.get_tracer("my-app")
# Only the agents you pass it to are traced; the rest produce no spans
agent = Agent(..., middlewares=[AgentObsMiddleware(tracer)])
This produces the identical span hierarchy.
The trade-off is the usual one: patching cannot be forgotten, but explicit
registration is visible. Miss a middlewares=[...] on one agent and that agent
simply has no trace, with nothing reporting an error.
Pointing it at CLS
Three ways, in precedence order: init() arguments > environment variables >
.env file.
1. Arguments — most explicit, and what to use when credentials come from your own configuration service:
from tencentcloud_agentobs_sdk_agentscope import init
init(
endpoint="ap-guangzhou.cls.tencentcs.com", # no scheme needed, https:// is added
topic_id="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
secret_id="AKID...",
secret_key="...",
service_name="my-agent-app",
)
2. Environment variables — the usual choice in production, leaving init()
bare:
export CLS_ENDPOINT=ap-guangzhou.cls.tencentcs.com
export CLS_TOPIC_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
export CLS_SECRET_ID=AKID...
export CLS_SECRET_KEY=...
3. A .env file — for local work, keeping secrets out of shell history.
Either .env in the working directory or ~/.agentobs-agentscope/config.env:
CLS_ENDPOINT=ap-guangzhou.cls.tencentcs.com
CLS_TOPIC_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
CLS_SECRET_ID=AKID...
CLS_SECRET_KEY=...
A built-in command writes that file for you, prompting without echo for the secrets and creating it mode 0600. A second command reports what is in effect:
python -m tencentcloud_agentobs_sdk_agentscope.cls_config init
python -m tencentcloud_agentobs_sdk_agentscope.cls_config show
python -m tencentcloud_agentobs_sdk_agentscope.cls_config verify
show prints only the length of a secret (<set, 40 chars>) — enough to tell
whether it is set and whether a paste was truncated, useless to anyone reading
over your shoulder or through a log. init skips any variable the environment
already provides rather than prompting for it. verify sends a single test span
to CLS, checking configuration → SDK → network → upload step by step, and
prints a diagnostic at the first failure.
The .env file only fills variables that are not already set; it never
overrides a real environment variable. That is deliberate — otherwise a stale
local file could quietly redirect production telemetry.
Only two values decide whether anything is uploaded
has_credentials = bool(endpoint and topic_id)
- Both present → spans are batched and uploaded to CLS
- Either missing → a warning is logged and spans go to stdout instead. It does not fail, so local development needs no account
AGENTOBS_CONSOLE=1→ forces stdout even with credentials present- Credentials present but the exporter cannot start (installed without
[cls], say) → also degrades to stdout with a warning. Observability should not take an application down. ConstructingCLSCloudExporteryourself still raises, since that call is an explicit request to ship
secret_id / secret_key play no part in that decision, so if credentials reach
the CLS SDK some other way inside Tencent Cloud, endpoint and topic id are enough.
The rest of init()
| Argument | Default | Purpose |
|---|---|---|
service_name |
agentscope-app |
Service name, recorded on the span resource |
console |
follows AGENTOBS_CONSOLE |
Force printing to stdout |
tracer_provider |
automatic | Supply one; otherwise an installed provider is reused or a new one created |
suppress_native_tracing |
False |
Turn AgentScope's own tracing off — see below |
suppress_native_tracing defaults to False because enabling it deletes whatever
the user already routes through agentscope.init(tracing_url=...). Set it only
when CLS is meant to be the sole destination.
Environment variables
| Variable | Meaning |
|---|---|
CLS_ENDPOINT |
CLS region endpoint |
CLS_TOPIC_ID |
Target log topic |
CLS_SECRET_ID |
Tencent Cloud secret id |
CLS_SECRET_KEY |
Tencent Cloud secret key |
CLS_SERVICE_NAME |
Service name on spans (default agentscope-app) |
CLS_SOURCE |
Source identifier (default: local IP, auto-detected) |
CLS_BATCH_SIZE |
Spans per upload batch (default 32) |
CLS_DEBUG |
Set to 1 for exporter debug logging |
AGENTOBS_CONSOLE |
Set to 1 to print spans instead of uploading |
AGENTOBS_DISABLED |
Set to 1 to disable instrumentation entirely |
AGENTOBS_DISABLED exists because a library reached through a dependency tree
cannot be un-called: an operator who must not report from one deployment would
otherwise have to edit code they may not own. It short-circuits both entry points
— init() and the opentelemetry-instrument entry point, which never calls
init() — and init() still returns a usable provider so a caller holding the
result does not break.
Coexisting with telemetry you already have
The SDK is additive. It does not take anything over:
- An installed tracer provider is reused, not replaced. If your application already exports to Langfuse, Arize-Phoenix, ARMS or your own collector, the CLS exporter is added alongside it and both destinations receive every span.
- AgentScope's own tracing is left enabled. On v1 that is the documented route
to those platforms (
agentscope.init(tracing_url=...)), so switching it off would delete a pipeline you may already have alerts on. shutdown()only flushes a provider it did not create, since your application may still be using it.
So this keeps working, and lands in both places:
import os, base64, agentscope
from tencentcloud_agentobs_sdk_agentscope import init
auth = base64.b64encode(f"{pub}:{secret}".encode()).decode()
os.environ["OTEL_EXPORTER_OTLP_HEADERS"] = f"Authorization=Basic {auth}"
agentscope.init(tracing_url="https://cloud.langfuse.com/api/public/otel/v1/traces")
init() # CLS as well
If CLS is meant to be the only destination, opt out explicitly:
init(suppress_native_tracing=True) # v1 only; v2 has no built-in tracing
Note that AgentScope v2 removed its tracing layer altogether — there is no
agentscope.init and no tracing_url. On v2 this SDK is the only source of
spans, and it covers more than v1's native tracing did: all seven middleware
hooks, including permission gating, context compression and time-to-first-token.
Span hierarchy
agent one agent execution (= one turn)
└── step one ReAct round (reason → act)
├── chat LLM call
├── tool tool call
│ └── agent sub-agent, nested under the tool that dispatched it
└── embedding
Rounds are delimited by AgentScope's own reasoning/acting boundaries, so a round that issues several LLM calls (a format retry, for example) is still one step.
Identifiers
Follows the CLS per-turn trace model: one user interaction is one trace, and turns of the same session are correlated by attribute rather than a shared trace.
| Attribute | Format | Meaning |
|---|---|---|
gen_ai.session.id |
sess_{hex} |
Process-wide unless supplied via baggage |
gen_ai.turn.id |
{sessionId}:t{N} |
One per outermost agent invocation |
gen_ai.step.id |
{turnId}:s{N} |
One per ReAct round |
The three nest as text, so a single prefix query selects a whole session, one
turn, or one round. A sub-agent stays inside its parent's turn — it is part of
the same interaction — and its steps carry a .{depth} suffix so that restarting
round numbering cannot collide with the parent's.
Baggage always wins: when the calling service already published a session or turn id, that value is adopted so agent traces correlate with the request that triggered them.
Behaviour under failure
Instrumentation never breaks the application it observes:
- A framework internal that moved costs one span kind, not the trace, and never the request — each patch is applied independently and failures downgrade to a warning.
- Exceptions propagate unchanged; spans are closed and marked
ERRORon the way out. - Streaming responses keep their span open until the stream drains, including
when a consumer abandons it part-way. Their span also carries
gen_ai.response.time_to_first_token_ms, measured on the first chunk holding real content — providers often open a stream with empty or role-only deltas, and timing those would report a responsiveness the user never experienced. - AgentScope's built-in tracing is left alone, so telemetry you already export keeps flowing — see Coexisting with telemetry you already have.
Permission gating (v2)
v2 gates every tool behind a permission check, and a denied or deferred call makes the agent answer "waiting for approval" and stop. That outcome would otherwise be invisible — a round with no tool span and no reason — so the decision is recorded on the step:
| Attribute | Meaning |
|---|---|
gen_ai.tool.permission.behavior |
allow / deny / ask / passthrough |
gen_ai.tool.permission.denied |
true when the call was blocked |
gen_ai.tool.permission.reason |
Which rule or mode decided |
gen_ai.tool.permission.message |
Text shown to the user |
A blocked call also emits a gen_ai.tool.permission.blocked event carrying the
spec's permission_denied error type, so it is queryable the same way a timeout
is. The decision itself is passed through untouched.
Context compression (v2)
Compression replaces older messages with a generated summary, so the conversation the model sees stops matching what the trace recorded earlier. It explains a sudden jump in token usage, and the same prompt behaving differently between rounds:
| Attribute | Meaning |
|---|---|
gen_ai.context.compressed |
Present only when compression ran |
gen_ai.context.messages_before / _after |
Context size around it |
gen_ai.context.messages_removed |
How much history was dropped |
gen_ai.context.summary_created |
Whether a summary replaced the history |
gen_ai.context.compression.duration_ms |
How long it took |
The hook fires every round but usually does nothing, since the token count is
below the trigger threshold. Nothing is written in that case — a
compressed=false field on every agent span would carry no information. Message
counts are reported rather than tokens because counting tokens means calling
model.count_tokens again, which for some providers is a network request;
instrumentation must not add one.
System prompt (v2)
v2 assembles the system prompt per round from the base prompt plus skill and workspace instructions, and the activated skill groups change between rounds — so two runs of the "same" agent can be given different instructions.
| Attribute | Meaning |
|---|---|
gen_ai.system_prompt.hash |
Identifies the prompt version |
gen_ai.system_prompt.length |
Shows growth |
gen_ai.system_instructions |
The text, stored once per turn |
gen_ai.system_prompt.changed |
Set when it changes mid-turn |
Only the hash and length are recorded per round: the text is long and mostly unchanging, so copying it onto every round would dominate the trace's size while adding nothing after the first copy.
Note that on_system_prompt is a transformer hook — whatever it returns becomes
the agent's prompt. The input is therefore returned unchanged on every path,
including when recording fails, so an instrumentation bug can never alter the
agent's instructions.
Human-in-the-loop and external execution (v2)
A reply that pauses for approval emits no ReplyEndEvent at all — its spans and
statuses look exactly like a completed one. So "the agent answered" and "the agent
is blocked on a human" would be indistinguishable:
| Attribute | Meaning |
|---|---|
agentscope.reply.id |
Survives the pause; links this trace to the resumed one |
agentscope.reply.finished_reason |
awaiting_user_confirm, awaiting_external_execution, or the reason reported by the framework |
agentscope.hitl.pending_tools |
Which tool calls are waiting for approval |
agentscope.external_execution.pending_tools |
Which are waiting on the caller |
Spans cannot survive a pause, so the two halves of an interrupted interaction are
joined by reply.id rather than by trace.
on_acting only wraps tools the agent runs itself, so a tool executed by the
caller and returned through ExternalExecutionResultEvent would leave a gap in
the trace exactly where the work happened. A span is reconstructed from the
result and marked agentscope.is_external_execution=true — the duration is not
recoverable, but the call, its id and its output are.
Development
python -m venv .venv
.venv/bin/pip install -e ".[dev]"
.venv/bin/python tests/test_hierarchy.py # span hierarchy, v1 and v2
.venv/bin/python tests/test_dispatch.py # version dispatch
.venv/bin/python tests/test_edge_cases.py # concurrency, streaming, errors, nesting
Those run against stand-in AgentScope classes, so they need neither an AgentScope install nor network access.
Stand-ins cannot catch a mismatch with the real library, though — which is what
most bugs in this project turned out to be. tests/test_real_agentscope.py
drives a genuine agent (with a scripted model, so still no network) and is worth
running against both majors:
python -m venv .venv-as1 && .venv-as1/bin/pip install "agentscope==1.0.21" "mcp==1.13.1" tqdm opentelemetry-sdk opentelemetry-instrumentation wrapt
python -m venv .venv-as2 && .venv-as2/bin/pip install "agentscope==2.0.7" "mcp>=2.1" opentelemetry-sdk opentelemetry-instrumentation wrapt
.venv-as1/bin/python tests/test_real_agentscope.py
.venv-as2/bin/python tests/test_real_agentscope.py
# Asserts the CLS spec's required fields, id formats, naming and OTLP kinds
.venv-as1/bin/python tests/test_cls_spec_conformance.py
.venv-as2/bin/python tests/test_cls_spec_conformance.py
# Vendor mapping, and (v2 only) permission gating visibility
.venv-as1/bin/python tests/test_provider_name.py
.venv-as2/bin/python tests/test_permission.py
.venv/bin/python tests/test_ttft.py
.venv-as2/bin/python tests/test_context_compression.py
.venv-as2/bin/python tests/test_system_prompt.py
The pinned mcp versions are AgentScope's own requirement, not this SDK's: 1.x
and 2.x need incompatible releases of it.
Examples
examples/readme_demo.py — the runnable form of the example above, printing the
span tree:
.venv-as1/bin/python examples/readme_demo.py
examples/quickstart.py — the minimal loop under either major, printing full CLS
span JSON:
.venv-as1/bin/python examples/quickstart.py
examples/v2_full_demo.py — v2 only. Runs the same agent twice, once with the
tool allowed and once with it gated on approval, and prints the attributes each
run actually produced so the difference is visible:
.venv-as2/bin/python examples/v2_full_demo.py
Both use a scripted model, so neither needs an API key or network access.
License
Apache-2.0
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file tencentcloud_agentobs_sdk_agentscope-0.1.2.tar.gz.
File metadata
- Download URL: tencentcloud_agentobs_sdk_agentscope-0.1.2.tar.gz
- Upload date:
- Size: 112.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.11.9
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
454272960700cedcafa6692358c0527d3f362c55af318d788778aa144447280c
|
|
| MD5 |
e649eef380bf5bfc0593e0d1fd910d06
|
|
| BLAKE2b-256 |
0486b4c9d37e98292203468e925219830f725de30537d635b3b84eccd54e6c17
|
File details
Details for the file tencentcloud_agentobs_sdk_agentscope-0.1.2-py3-none-any.whl.
File metadata
- Download URL: tencentcloud_agentobs_sdk_agentscope-0.1.2-py3-none-any.whl
- Upload date:
- Size: 79.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.11.9
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c91f95128401a2a66011934c4f1e24109c801f80eb32a8547719677dc6537ae3
|
|
| MD5 |
675f3e2858be09680aba76534f74b324
|
|
| BLAKE2b-256 |
04a8b25662fccd3ae374eeac35318a560379dec537e9f3bf19fe3b3a039b2fd8
|